diff --git a/workspaces/boost/.changeset/ai-catalog-frontend-polish.md b/workspaces/boost/.changeset/ai-catalog-frontend-polish.md new file mode 100644 index 00000000000..674877146ea --- /dev/null +++ b/workspaces/boost/.changeset/ai-catalog-frontend-polish.md @@ -0,0 +1,13 @@ +--- +'@red-hat-developer-hub/backstage-plugin-boost': minor +--- + +Polish responsive AI Catalog browsing and AI asset entity details with dynamic +filters, accessible view switching, usage guidance, and type-aware metadata. + +Entity-page extensions are consolidated into AI asset details, agent +instructions, and Usage cards. The separate Usage tab is not part of this +preview; standard TechDocs remain provided by the host application, and usage +content remains hidden when the usage permission is denied. Git sources that +point to subpaths now open their source location instead of guessing an +archive download. diff --git a/workspaces/boost/.changeset/ogx-asset-contract.md b/workspaces/boost/.changeset/ogx-asset-contract.md new file mode 100644 index 00000000000..c0534789ddb --- /dev/null +++ b/workspaces/boost/.changeset/ogx-asset-contract.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-ogx-entity-provider': minor +'@red-hat-developer-hub/backstage-plugin-boost-common': patch +--- + +Align OGX AI asset entities with the canonical provider metadata contract and +harden shared AI asset type validation. diff --git a/workspaces/boost/app-config.yaml b/workspaces/boost/app-config.yaml index 9a2e29808e3..c18cdbd14a9 100644 --- a/workspaces/boost/app-config.yaml +++ b/workspaces/boost/app-config.yaml @@ -51,6 +51,7 @@ catalog: - Component - System - Group + - User - Resource - Location - Template @@ -61,7 +62,16 @@ catalog: - type: file target: ../../fixtures/ai-catalog-fixtures.yaml rules: - - allow: [AiResource, AiModelServerAPI, API, Component, Resource, Group] + - allow: + [ + AiResource, + AiModelServerAPI, + API, + Component, + Resource, + Group, + User, + ] boost: security: @@ -111,6 +121,7 @@ boost: - procurement model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: legal name: Legal description: Handles questions about software licenses, embargoes, privacy/PII, contracts, policies, procedures, or compliance @@ -123,6 +134,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: support name: Software Support description: Handles technical support questions about OpenShift/Kubernetes, deployment, permissions, performance, and FantaCo products @@ -135,6 +147,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: hr name: Human Resources description: Handles questions about employee benefits, health care, vacation/PTO, retirement, workspaces, and compensation @@ -157,6 +170,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: sales name: Sales description: Handles questions about sales territories, leads, discounting, quotas, CRM, and performance metrics @@ -183,6 +197,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin - id: procurement name: Procurement description: Handles questions about competitive bidding, vendor evaluation, procurement ethics, and approval processes @@ -207,6 +222,7 @@ boost: enableRAG: true model: ${BOOST_MODEL:-} lifecycleStage: published + createdBy: user:default/admin providers: ogx: diff --git a/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts b/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts index e0599e7a91e..ce64db12b9d 100644 --- a/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts +++ b/workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts @@ -171,10 +171,8 @@ test.describe('Boost AI Catalog', () => { }) => { await loadTwoAssetCatalog(page); - const filters = page.getByRole('navigation', { name: 'AI Catalog' }); - await filters - .getByRole('button', { name: 'Select an option Type' }) - .click(); + const filters = page.getByRole('navigation', { name: 'Filters' }); + await filters.getByRole('button', { name: 'All Type' }).click(); const typeListbox = page.getByRole('listbox', { name: 'Type' }); await expect(typeListbox).toBeVisible(); @@ -189,6 +187,15 @@ test.describe('Boost AI Catalog', () => { await expect( filters.getByRole('button', { name: 'Skills Type' }), ).toBeVisible(); + + await page.keyboard.press('Escape'); + await filters.getByRole('button', { name: 'Skills Type' }).click(); + await page + .getByRole('listbox', { name: 'Type' }) + .getByRole('option', { name: 'All', exact: true }) + .click(); + await expect.poll(() => queryParam(page, 'type')).toBeNull(); + await expect(catalogCount(page, 2)).toBeVisible(); }); test('search keeps only matching cards and sets q in the URL', async ({ @@ -202,6 +209,47 @@ test.describe('Boost AI Catalog', () => { await expect(catalogCount(page, 1)).toBeVisible(); await expect(skillDetailsLink(page)).toBeVisible(); await expect(agentDetailsLink(page)).toHaveCount(0); + + await page.getByRole('button', { name: 'Clear all' }).click(); + await expect.poll(() => queryParam(page, 'q')).toBeNull(); + await expect(catalogCount(page, 2)).toBeVisible(); + }); + + test('uses a mobile filter drawer on smaller screens', async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 768, height: 900 }); + await loadTwoAssetCatalog(page); + + const filterButton = page.getByRole('button', { name: 'Filters' }); + await expect(filterButton).toBeVisible(); + await filterButton.click(); + + const dialog = page.getByRole('dialog', { name: 'Filters' }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('navigation', { name: 'Filters' }), + ).toBeVisible(); + await runAccessibilityTests( + page, + testInfo, + 'mobile-filter-drawer-accessibility.json', + { disableRules: ['nested-interactive', 'color-contrast'] }, + ); + + await dialog.getByRole('button', { name: 'All Type' }).click(); + await page + .getByRole('listbox', { name: 'Type' }) + .getByRole('option', { name: 'Skills', exact: true }) + .click(); + await page.keyboard.press('Escape'); + + await expect.poll(() => queryParam(page, 'type')).toBe('skill'); + await expect(dialog).toBeVisible(); + + await dialog.getByRole('button', { name: 'Close' }).click(); + await expect(dialog).not.toBeVisible(); + await expect(filterButton).toBeFocused(); }); test('table view lists both assets in the data table and sets view=table', async ({ diff --git a/workspaces/boost/fixtures/ai-catalog-fixtures.yaml b/workspaces/boost/fixtures/ai-catalog-fixtures.yaml index 12d104e3daf..c6f0c2addf8 100644 --- a/workspaces/boost/fixtures/ai-catalog-fixtures.yaml +++ b/workspaces/boost/fixtures/ai-catalog-fixtures.yaml @@ -1,7 +1,22 @@ --- -# AI Catalog sample fixtures — one entity per AI asset category. -# Used by the dev app for local development and testing. +# AI Catalog sample fixtures — representative entities for local development. +# These examples focus on the entity types currently exercised by the dev app. +# --------------------------------------------------------------------------- +# User — referenced by the local OGX agent provider configuration +# --------------------------------------------------------------------------- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: admin + title: Development Admin +spec: + profile: + displayName: Development Admin + email: admin@example.com + memberOf: [] + +--- # --------------------------------------------------------------------------- # Owner groups — referenced by AI asset entities below # --------------------------------------------------------------------------- @@ -169,7 +184,10 @@ spec: type: agent lifecycle: production owner: team-ai-platform + model: openai/vllm-inference/gpt-4.1 instructions: An AI agent that helps developers with code generation, debugging, architecture decisions, and documentation. + handoffDescription: Routes specialized questions to the appropriate team. + enableRAG: true location: type: git target: https://github.com/example/developer-assistant-agent @@ -192,14 +210,64 @@ metadata: rhdh.io/ai-asset-category: model-server rhdh.io/ai-asset-version: '3.0.0' rhdh.io/ai-asset-source: model-registry + links: + - title: API + url: https://granite.example.com/v1 spec: type: ai-model-server lifecycle: production owner: team-ml-ops serverType: openai-v1 serverUrl: https://granite.example.com/v1 + requiresApiKey: true + models: + discoverable: true + available: + - granite-3-code + - granite-3-instruct + default: granite-3-instruct + +--- +# --------------------------------------------------------------------------- +# AiModelServerAPI / ai-model-server with a larger model catalog +# --------------------------------------------------------------------------- +apiVersion: backstage.io/v1alpha1 +kind: AiModelServerAPI +metadata: + name: multi-model-server + title: Multi-Model Server + description: Model server exposing a larger catalog of available models. + tags: + - model-server + - inference + - multi-model + annotations: + rhdh.io/ai-asset-category: model-server + rhdh.io/ai-asset-version: '3.0.0' + rhdh.io/ai-asset-source: model-registry + links: + - title: API + url: https://models.example.com/v1 +spec: + type: ai-model-server + lifecycle: production + owner: team-ml-ops + serverType: openai-v1 + serverUrl: https://models.example.com/v1 + requiresApiKey: true models: discoverable: true available: - granite-3-code - granite-3-instruct + - llama-3.1-8b-instruct + - llama-3.1-70b-instruct + - mistral-7b-instruct + - mixtral-8x7b-instruct + - qwen2.5-7b-instruct + - qwen2.5-coder-7b + - gemma-3-12b-it + - phi-4 + - deepseek-r1-distill-qwen-32b + - command-r-plus + default: llama-3.1-8b-instruct diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/proposal.md b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/proposal.md index 7c6751b0ad4..6d6cf579bc0 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/proposal.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/proposal.md @@ -14,7 +14,7 @@ de/es/fr/it/ja locale files. This work was split out of `ai-catalog-frontend`. - Add locale files `de.ts`, `es.ts`, `fr.ts`, `it.ts`, `ja.ts` - Register lazy imports in `createTranslationResource` -The translation module entry (`./boost-translations-module`) already exists. +The translation module entry (`./translations`) already exists. ## Impact diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md index ca5d913373f..f7e28db52e9 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/specs/ai-catalog-translations/spec.md @@ -30,7 +30,7 @@ Each supported language MUST have a complete translation file following the stan - **GIVEN** the plugin is deployed as a dynamic plugin in RHDH - **WHEN** the translation module needs to be auto-discovered -- **THEN** a separate entry point re-exports `boostTranslationsModule` as default (e.g., `./boost-translations-module` in `package.json` exports) +- **THEN** the `./translations` package export exposes `boostTranslationsModule` as the default export - **AND** RHDH auto-discovers the module without explicit `features` array registration ### Requirement: Complete String Coverage @@ -53,7 +53,7 @@ Every user-facing string in the plugin MUST be translated. - **GIVEN** a user views an AI asset entity page in French - **WHEN** entity cards from the boost plugin render -- **THEN** Summary, Adoption, Version, and Usage tab titles and actions are all in French +- **THEN** AI asset details, agent instructions, and Usage card titles and actions are all in French #### Scenario: Error and empty state strings translated diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/tasks.md b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/tasks.md index 6174a2767ad..4d8a8333eb0 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/tasks.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend-translations/tasks.md @@ -1,6 +1,6 @@ # Tasks: AI Catalog frontend translations (RHIDP-15479) -- [x] 1. Translation module auto-discovery entry (`./boost-translations-module`) already exists +- [x] 1. Translation module auto-discovery entry (`./translations`) already exists - [ ] 2. Create `src/translations/de.ts` - [ ] 3. Create `src/translations/es.ts` - [ ] 4. Create `src/translations/fr.ts` diff --git a/workspaces/boost/openspec/specs/ai-catalog-dynamic-plugin/spec.md b/workspaces/boost/openspec/specs/ai-catalog-dynamic-plugin/spec.md index 84c59820bbc..545b4f98606 100644 --- a/workspaces/boost/openspec/specs/ai-catalog-dynamic-plugin/spec.md +++ b/workspaces/boost/openspec/specs/ai-catalog-dynamic-plugin/spec.md @@ -45,8 +45,10 @@ The OCI-packaged plugin MUST load correctly in an RHDH deployment. - **GIVEN** the boost frontend dynamic plugin is installed - **WHEN** a user navigates to a catalog entity page for an AI asset -- **THEN** the Summary, Adoption, and Version cards render when their required entity data exists -- **AND** the Usage tab (`entity-content:boost/usage`) is present on AI assets +- **THEN** the AI asset details, agent instructions, and Usage cards render when their required entity data exists +- **AND** they use the `entity-card:boost/ai-asset-details`, + `entity-card:boost/agent-instructions`, and `entity-card:boost/usage` + extension IDs #### Scenario: Extensions absent on non-AI entities @@ -60,16 +62,16 @@ Deployers MUST be able to customize the plugin via `app.extensions` in `app-conf #### Scenario: Disable an entity card -- **GIVEN** the deployer sets `entity-card:boost/adoption: false` in `app.extensions` +- **GIVEN** the deployer sets `entity-card:boost/ai-asset-details: false` in `app.extensions` - **WHEN** a user views an AI asset entity page -- **THEN** the Download/Adopt Card is not rendered +- **THEN** the AI asset details card is not rendered - **AND** other boost cards still render #### Scenario: Change entity filter on a card -- **GIVEN** the deployer sets `entity-card:boost/summary` with a `config.filter` that excludes AI asset kinds +- **GIVEN** the deployer sets `entity-card:boost/ai-asset-details` with a `config.filter` that excludes AI asset kinds - **WHEN** a user views an AiResource entity page -- **THEN** the summary card is not rendered (filter excludes AiResource) +- **THEN** the AI asset details card is not rendered (filter excludes AiResource) #### Scenario: Disable the page diff --git a/workspaces/boost/openspec/specs/ai-catalog-entity-extensions/spec.md b/workspaces/boost/openspec/specs/ai-catalog-entity-extensions/spec.md index 85c49098bf7..76c4569c513 100644 --- a/workspaces/boost/openspec/specs/ai-catalog-entity-extensions/spec.md +++ b/workspaces/boost/openspec/specs/ai-catalog-entity-extensions/spec.md @@ -2,102 +2,137 @@ > **Status: Implemented** — Current RHDH 2.1 release source of truth. > -> **Scope:** `plugins/boost` entity-page cards and Usage tab. Catalog entity -> visibility remains a Catalog/RHDH concern; this spec covers frontend +> **Scope:** `plugins/boost` entity-page cards for AI catalog assets. Catalog +> entity visibility remains a Catalog/RHDH concern; this spec covers frontend > presentation only. ## Purpose -This specification describes the frontend entity-page extensions delivered by -the AI Catalog plugin. Catalog entity discovery is governed by Backstage's -built-in `catalog.entity.read` permission; the Usage tab behavior below is -field-level presentation and must not be treated as a second entity-visibility -permission. +This specification describes the entity-page extensions delivered by the AI +Catalog plugin. AI assets use a small set of composable cards rather than a +separate card for every individual field. Type-specific fields are grouped in +the AI asset details card, while agent instructions and usage actions have +their own cards when applicable. -The current frontend may still use the existing -`ai-catalog.asset.access.usage-docs` check as a presentation gate for the -Usage tab. That check is transitional UI behavior: it is not the Catalog -entity-visibility control and it is not a backend security boundary. If a -future API returns protected fields, the API must enforce authorization at its -response boundary. +Catalog entity discovery is governed by Backstage's built-in +`catalog.entity.read` permission. The transitional +`ai-catalog.asset.access.usage-docs` permission may gate the Usage card, but it +is not an entity-visibility control or a backend security boundary. ## Requirements -### Requirement: Summary Card +### Requirement: AI asset details card -The Summary card MUST render on AI asset entity pages when the entity contains -at least one supported summary field. +The AI asset details card MUST render on AI asset entity pages when the entity +contains a description, rationale, version, or supported type-specific field. +It MUST omit itself when there is no supported content. -#### Scenario: Summary card renders on AI entity +#### Scenario: Details card renders for supported content -- **WHEN** an AI asset has a description, rationale, available model, agent - instruction, handoff description, or RAG setting -- **THEN** `entity-card:boost/summary` renders the available summary fields +- **WHEN** an AI asset has a description, rationale, version, or supported + type-specific metadata +- **THEN** `entity-card:boost/ai-asset-details` renders the available fields -#### Scenario: Summary card has no supported data +#### Scenario: Description-only asset -- **WHEN** an AI asset has none of the supported summary fields -- **THEN** the Summary card is not rendered +- **WHEN** an AI asset has only `metadata.description` +- **THEN** the details card renders the description -#### Scenario: Summary card absent on non-AI entity +#### Scenario: Details card has no supported data -- **WHEN** a developer views a catalog entity page for a non-AI entity -- **THEN** the Summary card is not rendered +- **WHEN** an AI asset has none of the supported fields +- **THEN** the details card is not rendered -### Requirement: Adoption Card +#### Scenario: Details card absent on non-AI entity -The Adoption card MUST copy or open a URL in the browser. It MUST NOT call a -Boost backend. +- **WHEN** a user views a catalog entity page for a non-AI entity +- **THEN** the details card is not rendered -#### Scenario: Copy or open action exists +### Requirement: Type-specific asset details -- **WHEN** the entity has a skill command, OCI pull, git archive link, or MCP remote -- **THEN** the card exposes that action in the browser +The details card MUST present fields according to the AI asset type without +requiring separate cards for each field. -#### Scenario: No action +#### Scenario: Agent details -- **WHEN** there is no adoption action -- **THEN** the Adoption card is not rendered +- **WHEN** an agent has model, tools, RAG, handoff description, handoff + targets, or related model data +- **THEN** the card renders the available values +- **AND** handoff targets link to resolvable catalog entities -### Requirement: Version Card +#### Scenario: Model-server details -The Version card MUST show only the current annotated version. +- **WHEN** a model server has server type, API-key requirement, default model, + or available models +- **THEN** the card renders the available values +- **AND** a large model inventory is available through a focused models view -#### Scenario: Current version annotation exists +#### Scenario: Other AI asset details -- **WHEN** `rhdh.io/ai-asset-version` is present -- **THEN** `entity-card:boost/version-list` shows that version -- **AND** it does not list a version history +- **WHEN** a skill, rule, or MCP server has supported type-specific metadata +- **THEN** the card renders the metadata using the appropriate field + presentation -### Requirement: Usage Tab +### Requirement: Agent instructions card -The Usage tab MUST be a Boost `EntityContentBlueprint` on AI assets. It MUST NOT -replace the Catalog TechDocs tab. +The agent instructions card MUST render only for agents with instructions and +MUST render the instruction content as Markdown. -Usage authorization is an API/data-boundary concern. If a future backend API -returns protected usage data, that API MUST enforce field-level authorization; -the frontend MUST NOT rely on hiding a tab as the security boundary. +#### Scenario: Agent instructions exist -#### Scenario: Current usage permission denies access +- **WHEN** an agent has non-empty instructions +- **THEN** `entity-card:boost/agent-instructions` renders the instructions + using Markdown content -- **WHEN** `ai-catalog.asset.access.usage-docs` denies access to the entity -- **THEN** the Usage tab shows a permission-denied message -- **AND** it links to the owner when the entity has a valid owner reference +#### Scenario: Agent instructions are absent + +- **WHEN** an agent has no instructions +- **THEN** the instructions card is not rendered + +### Requirement: Usage card + +The Usage card MUST expose a safe, type-specific usage action when one can be +derived from the entity. It MUST NOT call a Boost backend. + +#### Scenario: Usage action exists + +- **WHEN** the entity has a supported skill command, OCI pull command, model + server endpoint, MCP endpoint, Git archive, or Git source location +- **THEN** `entity-card:boost/usage` exposes the corresponding copy or link + action + +#### Scenario: Git subpath source -#### Scenario: Entity visibility is independent from usage presentation +- **WHEN** a Git source points to a repository subpath rather than a + repository root +- **THEN** the Usage card opens the source location instead of guessing an + archive download -- **WHEN** a user is authorized to read an entity through `catalog.entity.read` -- **AND** `ai-catalog.asset.access.usage-docs` denies access to the entity -- **THEN** the entity remains discoverable -- **AND** the Usage tab shows its permission-denied state +#### Scenario: No usage action -#### Scenario: Current usage permission allows access +- **WHEN** there is no safe supported usage action +- **THEN** the Usage card is not rendered + +#### Scenario: Usage permission denies access + +- **WHEN** `ai-catalog.asset.access.usage-docs` denies access to the entity +- **THEN** the Usage card is not rendered +- **AND** the entity remains discoverable through the Catalog permission model + +#### Scenario: Usage permission allows access - **WHEN** `ai-catalog.asset.access.usage-docs` allows access to the entity -- **THEN** the Usage tab links to TechDocs and `metadata.links` when present -- **AND** the Catalog TechDocs tab is unchanged +- **THEN** the Usage card may render its supported usage action + +### Requirement: Standard TechDocs behavior + +The AI Catalog plugin MUST NOT replace or duplicate the standard Backstage +TechDocs experience with a dedicated Usage tab. TechDocs remain available +through the host application's normal entity-page extensions. -#### Scenario: Tab visibility +#### Scenario: Standard TechDocs remain available -- **WHEN** a developer views a non-AI entity page -- **THEN** the Usage tab is not present +- **WHEN** a user views an AI asset with TechDocs configured +- **THEN** the host application's standard TechDocs experience remains + available +- **AND** the Boost plugin does not add a separate Usage tab for it diff --git a/workspaces/boost/openspec/specs/ai-catalog-filter-customization/spec.md b/workspaces/boost/openspec/specs/ai-catalog-filter-customization/spec.md index aa13182dbca..cecefebcd9d 100644 --- a/workspaces/boost/openspec/specs/ai-catalog-filter-customization/spec.md +++ b/workspaces/boost/openspec/specs/ai-catalog-filter-customization/spec.md @@ -35,7 +35,7 @@ A `FilterDefinition` interface MUST define the contract, and a Blueprint MUST wr - **AND** the extension attaches to `page:boost/ai-catalog` input `filters` - **AND** the Blueprint has no `config` schema (no deployer YAML config per filter) -#### Scenario: FilterSidebar renders generic Select for each filter +#### Scenario: CatalogFilters renders generic Select for each filter - **WHEN** the filter sidebar renders - **THEN** it maps over resolved `FilterDefinition[]` and renders a `` rendering in `FilterSidebar` +- **AND** all share the same generic ` { + const next = keys as string[]; + const allSelected = next.includes(allFilterValue); + const wasAllSelected = selectedWithAll.includes(allFilterValue); + onChange( + allSelected && !wasAllSelected + ? [] + : next.filter(value => value !== allFilterValue), + ); + }} + /> + ); +} + +export const CatalogFilters = ({ + filters, + entities, + values, + onFilterChange, +}: CatalogFiltersProps) => { + const { t } = useTranslation(); + + if (filters.length === 0) return null; + + return ( + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.module.css b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.module.css new file mode 100644 index 00000000000..75d5eace63d --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.module.css @@ -0,0 +1,39 @@ +/* + * 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. + */ + +@layer components { + .drawer { + align-self: stretch; + width: min(260px, calc(100vw - var(--bui-space-6))); + max-width: calc(100vw - var(--bui-space-6)); + height: 100%; + max-height: none; + margin-inline-end: auto; + border-top: 0; + border-bottom: 0; + border-left: 0; + border-radius: 0; + } + + .drawer :global(.bui-DialogHeader), + .drawer :global(.bui-DialogBody) { + padding-inline: var(--bui-space-4); + } + + .drawer :global(.bui-DialogHeader) { + padding-block: var(--bui-space-4); + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.test.tsx new file mode 100644 index 00000000000..3d741ab53d1 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 { fireEvent, screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; + +import type { FilterDefinition } from '../../../blueprints/AiCatalogFilterBlueprint'; +import { FilterDrawer } from './FilterDrawer'; + +const filter: FilterDefinition = { + urlParam: 'type', + label: 'Type', + getOptions: () => [{ id: 'skill', label: 'Skills' }], + matchEntity: () => true, + priority: 100, +}; + +describe('FilterDrawer', () => { + it('opens a drawer with the registered filters', async () => { + await renderInTestApp( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Filters' })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.tsx new file mode 100644 index 00000000000..c58dd5ed6b3 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogFilters/FilterDrawer.tsx @@ -0,0 +1,71 @@ +/* + * 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 { + Button, + Dialog, + DialogBody, + DialogHeader, + DialogTrigger, +} from '@backstage/ui'; +import { RiFilter3Line } from '@remixicon/react'; + +import type { FilterDefinition } from '../../../blueprints/AiCatalogFilterBlueprint'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { CatalogFilters } from './CatalogFilters'; +import styles from './FilterDrawer.module.css'; + +interface FilterDrawerProps { + readonly filters: FilterDefinition[]; + readonly entities: Entity[]; + readonly values: Map; + readonly onFilterChange: (urlParam: string, values: string[]) => void; +} + +export const FilterDrawer = ({ + filters, + entities, + values, + onFilterChange, +}: FilterDrawerProps) => { + const { t } = useTranslation(); + + if (filters.length === 0) return null; + + return ( + + + + {t('catalog.filter.title')} + + + + + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogEmptyState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogEmptyState.tsx new file mode 100644 index 00000000000..8b3d48503e2 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogEmptyState.tsx @@ -0,0 +1,81 @@ +/* + * 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 { Button, ButtonLink, Flex, Text } from '@backstage/ui'; +import { RiExternalLinkLine, RiRefreshLine } from '@remixicon/react'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import emptyStateIllustration from '../../../images/empty-state-illustration.png'; +import styles from './CatalogResults.module.css'; + +interface CatalogEmptyStateProps { + readonly onRefresh: () => void; +} + +export const CatalogEmptyState = ({ onRefresh }: CatalogEmptyStateProps) => { + const { t } = useTranslation(); + return ( + + + {t('catalog.empty.title')} + + {t('catalog.empty.description')} + + + } + > + {t('catalog.empty.learnMore')} + + + + + + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/ErrorState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogErrorState.tsx similarity index 79% rename from workspaces/boost/plugins/boost/src/components/catalog/ErrorState.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogErrorState.tsx index 1a3cba3b086..19e43a25471 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/ErrorState.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogErrorState.tsx @@ -16,13 +16,17 @@ import { Alert, Button, Flex } from '@backstage/ui'; -import { useTranslation } from '../../hooks/useTranslation'; -import styles from './AiCatalogPage.module.css'; +import { useTranslation } from '../../../hooks/useTranslation'; +import styles from './CatalogResults.module.css'; -export const ErrorState = ({ onRetry }: { onRetry: () => void }) => { +export const CatalogErrorState = ({ + onRetry, +}: { + readonly onRetry: () => void; +}) => { const { t } = useTranslation(); return ( - + (
@@ -38,23 +38,38 @@ const SkeletonCard = () => (
); -export const LoadingState = () => ( - +interface CatalogLoadingStateProps { + readonly filterCount: number; + readonly cardCount: number; +} + +export const CatalogLoadingState = ({ + filterCount, + cardCount, +}: CatalogLoadingStateProps) => ( + <>
- {Array.from({ length: 4 }, (_, i) => ( + {Array.from({ length: filterCount }, (_, i) => (
- +
))}
- - -
- {Array.from({ length: 6 }, (_, i) => ( +
+
+ + +
+
+ {Array.from({ length: cardCount }, (_, i) => ( ))}
- - +
+ ); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css new file mode 100644 index 00000000000..827ba141702 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.module.css @@ -0,0 +1,142 @@ +/* + * 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. + */ + +@layer components { + .resultsSurface { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + width: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid var(--bui-border-1); + border-radius: var(--bui-radius-4); + } + + .resultsBody { + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + padding: var(--bui-space-4); + } + + .pagination { + flex-shrink: 0; + padding: var(--bui-space-3) var(--bui-space-4); + border-top: 1px solid var(--bui-border-1); + } + + .emptyState { + flex: 1; + min-width: 0; + min-height: 0; + } + + .emptyStateContent { + width: 100%; + max-width: 520px; + min-width: 0; + } + + .emptyStateDescription { + max-width: 52ch; + } + + .emptyStateActions { + flex-wrap: wrap; + } + + .refreshButton { + border: 1px solid var(--bui-border-1); + } + + .emptyStateIllustration { + display: block; + width: min(100%, 420px); + height: auto; + } + + .errorState { + min-height: 240px; + } + + .loadingSidebar { + display: flex; + flex-direction: column; + gap: var(--bui-space-1); + width: 100%; + flex-shrink: 0; + } + + .loadingToolbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 72px; + padding: var(--bui-space-4); + border-bottom: 1px solid var(--bui-border-1); + } + + .loadingGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--bui-space-4); + flex: 1; + min-height: 0; + overflow: auto; + } + + .skeletonCard { + display: flex; + flex-direction: column; + gap: var(--bui-space-2); + padding: var(--bui-space-4); + border: 1px solid var(--bui-border-1); + border-radius: var(--bui-radius-2); + } + + .skeletonFilterInput { + margin-top: var(--bui-space-1); + } + + .skeletonGrid { + padding: var(--bui-space-4); + } + + @media (max-width: 1023px) { + .loadingSidebar { + display: none; + } + + .loadingGrid { + grid-template-columns: 1fr; + } + } + + @media (min-width: 640px) and (max-width: 1023px) { + .loadingGrid { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (min-width: 1024px) { + .loadingSidebar { + width: 240px; + } + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx new file mode 100644 index 00000000000..dea94456908 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/CatalogResults.tsx @@ -0,0 +1,116 @@ +/* + * 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 { Grid, Flex, TablePagination, type SortState } from '@backstage/ui'; + +import { AiAssetCard } from '../AiAssetCard'; +import { AiCatalogTable } from '../AiCatalogTable'; +import { CatalogToolbar } from '../CatalogToolbar'; +import { EmptyFilteredState } from './EmptyFilteredState'; +import type { CatalogViewMode } from '../../../hooks/useUrlFilters'; +import styles from './CatalogResults.module.css'; + +interface CatalogResultsProps { + readonly hasActiveFilters: boolean; + readonly entities: Entity[]; + readonly pageEntities: Entity[]; + readonly viewMode: CatalogViewMode; + readonly sort: SortState; + readonly pageSize: number; + readonly pageStart: number; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly searchInputValue: string; + readonly onSearchChange: (value: string) => void; + readonly onClearFilters: () => void; + readonly onViewModeChange: (value: CatalogViewMode) => void; + readonly onNextPage: () => void; + readonly onPreviousPage: () => void; + readonly onPageSizeChange: (pageSize: number) => void; +} + +export const CatalogResults = ({ + hasActiveFilters, + entities, + pageEntities, + viewMode, + sort, + pageSize, + pageStart, + hasNextPage, + hasPreviousPage, + searchInputValue, + onSearchChange, + onClearFilters, + onViewModeChange, + onNextPage, + onPreviousPage, + onPageSizeChange, +}: CatalogResultsProps) => { + const totalCount = entities.length; + + return ( +
+ +
+ {totalCount === 0 && hasActiveFilters && ( + + )} + + {totalCount > 0 && viewMode === 'grid' && ( + + {pageEntities.map(entity => ( + + + + ))} + + )} + + {totalCount > 0 && viewMode === 'table' && ( + + )} +
+ {totalCount > 0 && ( + + + + )} +
+ ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.test.tsx similarity index 95% rename from workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.test.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.test.tsx index 6df341d70bc..03499d3e809 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.test.tsx @@ -17,7 +17,7 @@ import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen } from '@testing-library/react'; -import { boostMessages } from '../../translations/ref'; +import { boostMessages } from '../../../translations/ref'; import { EmptyFilteredState } from './EmptyFilteredState'; const { catalog: msg } = boostMessages; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx similarity index 91% rename from workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.tsx rename to workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx index ec9b0a29cdc..ee94304244a 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/EmptyFilteredState.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogResults/EmptyFilteredState.tsx @@ -16,12 +16,12 @@ import { Button, Flex, Text } from '@backstage/ui'; -import { useTranslation } from '../../hooks/useTranslation'; +import { useTranslation } from '../../../hooks/useTranslation'; export const EmptyFilteredState = ({ onClearFilters, }: { - onClearFilters: () => void; + readonly onClearFilters: () => void; }) => { const { t } = useTranslation(); return ( diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.module.css b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.module.css new file mode 100644 index 00000000000..333d587fcd4 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.module.css @@ -0,0 +1,81 @@ +/* + * 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. + */ + +@layer components { + .toolbar { + min-height: 72px; + padding: var(--bui-space-4); + border-bottom: 1px solid var(--bui-border-1); + } + + .resultCount { + flex-shrink: 0; + } + + .toolbarActions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .viewToggle { + flex-shrink: 0; + } + + .search { + width: min(280px, 100%); + } + + .search :global(.bui-SearchFieldInputWrapper) { + border: 1px solid var(--bui-border-2); + border-radius: var(--bui-radius-2); + background: var(--bui-bg-neutral-1); + } + + .search :global(.bui-SearchFieldInputWrapper:has([data-focused])) { + border-color: var(--bui-bg-solid); + } + + @media (max-width: 1023px) { + .toolbar { + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + } + + .toolbarActions { + flex-wrap: nowrap; + justify-content: flex-end; + min-width: 0; + } + + .search { + flex: 1 1 auto; + width: min(240px, 100%); + min-width: 0; + } + } + + @media (max-width: 479px) { + .toolbar { + align-items: flex-start; + flex-wrap: wrap; + } + + .toolbarActions { + width: 100%; + } + } +} diff --git a/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx new file mode 100644 index 00000000000..3e04bb1dec5 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/CatalogToolbar.tsx @@ -0,0 +1,103 @@ +/* + * 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 { useMemo } from 'react'; +import { + Button, + Flex, + SearchField, + Text, + ToggleButton, + ToggleButtonGroup, +} from '@backstage/ui'; +import { RiGridLine, RiListUnordered } from '@remixicon/react'; + +import type { CatalogViewMode } from '../../hooks/useUrlFilters'; +import { useTranslation } from '../../hooks/useTranslation'; +import styles from './CatalogToolbar.module.css'; + +interface CatalogToolbarProps { + readonly totalCount: number; + readonly hasActiveFilters: boolean; + readonly searchInputValue: string; + readonly viewMode: CatalogViewMode; + readonly onSearchChange: (value: string) => void; + readonly onClearFilters: () => void; + readonly onViewModeChange: (value: CatalogViewMode) => void; +} + +export const CatalogToolbar = ({ + totalCount, + hasActiveFilters, + searchInputValue, + viewMode, + onSearchChange, + onClearFilters, + onViewModeChange, +}: CatalogToolbarProps) => { + const { t } = useTranslation(); + const viewModeKeys = useMemo(() => new Set([viewMode]), [viewMode]); + + return ( + + + {`${t('catalog.toolbar.allPrefix')} (${totalCount})`} + + + {hasActiveFilters && ( + + )} + + { + const selected = [...keys][0]; + if (selected === 'grid' || selected === 'table') { + onViewModeChange(selected); + } + }} + disallowEmptySelection + > + } + /> + } + /> + + + + ); +}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/EmptyState.tsx b/workspaces/boost/plugins/boost/src/components/catalog/EmptyState.tsx deleted file mode 100644 index 218ffd9c566..00000000000 --- a/workspaces/boost/plugins/boost/src/components/catalog/EmptyState.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 { ButtonLink, Flex, Text } from '@backstage/ui'; - -import { useTranslation } from '../../hooks/useTranslation'; -import styles from './AiCatalogPage.module.css'; - -export const EmptyState = () => { - const { t } = useTranslation(); - return ( - - {t('catalog.empty.title')} - - {t('catalog.empty.description')} - - - {t('catalog.empty.learnMore')} - - - ); -}; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx deleted file mode 100644 index 6af89d758db..00000000000 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * 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 { useMemo } from 'react'; -import type { Entity } from '@backstage/catalog-model'; -import { Select } from '@backstage/ui'; - -import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; -import { useTranslation } from '../../hooks/useTranslation'; -import styles from './FilterSidebar.module.css'; - -type TranslationKey = Parameters['t']>[0]; - -interface FilterSidebarProps { - filters: FilterDefinition[]; - entities: Entity[]; - values: Map; - onFilterChange: (urlParam: string, values: string[]) => void; -} - -function FilterSelect({ - filter, - entities, - selected, - onChange, -}: { - filter: FilterDefinition; - entities: Entity[]; - selected: string[]; - onChange: (values: string[]) => void; -}) { - const { t } = useTranslation(); - const label = filter.labelKey - ? t(filter.labelKey as TranslationKey) - : filter.label; - const options = useMemo( - () => filter.getOptions(entities), - [filter, entities], - ); - - return ( -