diff --git a/workspaces/boost/.changeset/extensible-browse-filters.md b/workspaces/boost/.changeset/extensible-browse-filters.md new file mode 100644 index 00000000000..b5563c20759 --- /dev/null +++ b/workspaces/boost/.changeset/extensible-browse-filters.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-boost': minor +--- + +Add extensible browse filters via NFS AiCatalogFilterBlueprint. Deployers can disable built-in filters and third-party plugins can add new filters to the AI Catalog sidebar via app-config. + +**Breaking:** The page extension ID changed from `page:boost` to `page:boost/ai-catalog`. Update any `app.extensions` references in `app-config.yaml` accordingly. diff --git a/workspaces/boost/app-config.yaml b/workspaces/boost/app-config.yaml index 8f165fed985..9b4dd48ffe4 100644 --- a/workspaces/boost/app-config.yaml +++ b/workspaces/boost/app-config.yaml @@ -8,6 +8,8 @@ app: redirects: - from: / to: /ai-catalog + # Example: disable a built-in filter + # - ai-catalog-filter:boost/owner: false organization: name: Red Hat diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend/design.md b/workspaces/boost/openspec/changes/ai-catalog-frontend/design.md index eddcb6c0625..f9e211c5d84 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend/design.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend/design.md @@ -16,7 +16,6 @@ Boost has a backend with 30+ API routes and 9 plugin packages but no frontend. T - Chat UI, admin panels, or agent gallery (future domains) - Custom entity detail pages (use existing catalog pages) -- RHDH dynamic plugin packaging (Scalprum/export-dynamic deferred) - Custom search collator for global search (rely on default catalog indexing) ## Decisions @@ -52,6 +51,55 @@ BUI (`@backstage/ui`) is the component library for all new UI. MUI v5 as fallbac Permission checks for `ai-catalog.asset.read.usage-docs` default to allow when the permission is not yet registered (RHDHPLAN-1508 not built). Content is shown, and enforcement activates automatically when RBAC lands. +### Decision 8: Extensible browse filters via data-driven FilterDefinition + +The browse page filter sidebar becomes NFS-extensible using a **data-driven** approach. Filters are plain objects (`FilterDefinition`), not per-filter React components. The `FilterSidebar` renders a generic `` for each — no per-filter component files +- `useUrlFilters` reads/writes URL params dynamically from the definition array +- `applyEntityFilters` loops over active definitions calling `matchEntity` in AND logic + +**Deployer customization (app-config.yaml):** + +```yaml +app: + extensions: + # Disable a built-in filter + - ai-catalog-filter:boost/owner: false + # Custom filter from a third-party module (just enable it) + - ai-catalog-filter:my-plugin/team-filter: {} +``` + +**Third-party filter contribution:** + +```typescript +createFrontendModule({ + pluginId: 'boost', + extensions: [ + AiCatalogFilterBlueprint.make({ + name: 'team-filter', + params: { + urlParam: 'team', + label: 'Team', + getOptions: entities => + [...new Set(entities.map(e => e.spec?.team).filter(Boolean))] + .sort() + .map(t => ({ id: t, label: t })), + matchEntity: (entity, values) => + values.some(v => v === entity.spec?.team), + priority: 200, + }, + }), + ], +}); +``` + ## Entity Model Backstage v1.51.0 introduced `AiResource` kind and `API` with `spec.type: mcp-server` via `@backstage/plugin-catalog-backend-module-ai-model`. Boost uses upstream kinds where available: diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/dynamic-plugin-export/spec.md b/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/dynamic-plugin-export/spec.md new file mode 100644 index 00000000000..a1a756bd7dc --- /dev/null +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/dynamic-plugin-export/spec.md @@ -0,0 +1,119 @@ +# Dynamic Plugin Export + +> **Status: Draft** — Pre-implementation specification. Subject to change during implementation. + +Package the AI Catalog frontend plugin for RHDH dynamic plugin deployment and register it in the overlays repo for OCI image builds. Boost is NFS-only (`createFrontendPlugin` as default export) — no Scalprum, no `./alpha`, no legacy entry point. + +## Requirements + +### Requirement: Plugin Export Configuration + +The plugin is configured for `rhdh-cli plugin export`. + +#### Scenario: export-dynamic script exists + +- **GIVEN** the `plugins/boost/package.json` +- **WHEN** a developer runs `yarn export-dynamic` +- **THEN** `rhdh-cli plugin export` runs and produces output in `dist-dynamic/` + +#### Scenario: dist-dynamic included in package files + +- **GIVEN** the plugin's `package.json` `files` array +- **THEN** it includes `dist-dynamic/*.*` and `dist-dynamic/dist/**` +- **AND** the published package contains the dynamic plugin bundle + +### Requirement: Default Extension Configuration + +A default `app-config.dynamic.yaml` provides sensible extension defaults for deployers. + +#### Scenario: Page route configured + +- **GIVEN** the `app-config.dynamic.yaml` in `plugins/boost/` +- **THEN** it configures `page:boost/ai-catalog` with the `/ai-catalog` path + +#### Scenario: Entity cards configured + +- **GIVEN** the `app-config.dynamic.yaml` +- **THEN** it lists `entity-card:boost/summary`, `entity-card:boost/adoption`, and `entity-card:boost/version-list` under `app.extensions` +- **AND** each card has a default entity filter matching AI asset kinds + +#### Scenario: Entity tab configured + +- **GIVEN** the `app-config.dynamic.yaml` +- **THEN** it lists `entity-content:boost/usage` under `app.extensions` +- **AND** the tab has a default title and group assignment + +### Requirement: Overlay Registration + +The plugin is registered in the overlays repo for automated OCI image builds. + +#### Scenario: Plugin added to rhdh-plugin-export-overlays + +- **GIVEN** the `redhat-developer/rhdh-plugin-export-overlays` repository +- **WHEN** a PR adds the boost frontend plugin entry +- **THEN** the CI pipeline builds an OCI image for the plugin +- **AND** the image is published to the configured registry + +#### Scenario: Image reference updated in workspace + +- **GIVEN** the OCI image is published +- **WHEN** the workspace `dynamic-plugins-image-reference.yaml` is updated +- **THEN** it contains the OCI image reference for the boost frontend plugin +- **AND** the reference follows the same format as existing backend plugin entries + +### Requirement: Plugin Loads in RHDH + +The OCI-packaged plugin loads correctly in an RHDH deployment. + +#### Scenario: Plugin loads with Module Federation + +- **GIVEN** an RHDH deployment with `ENABLE_STANDARD_MODULE_FEDERATION=true` +- **AND** the boost frontend dynamic plugin is installed via `dynamic-plugins.yaml` with `enabled: true` +- **WHEN** a user navigates to the RHDH instance +- **THEN** the "AI Catalog" nav item appears in the sidebar +- **AND** navigating to `/ai-catalog` renders the browse page + +#### Scenario: Entity page extensions mount + +- **GIVEN** the boost frontend dynamic plugin is installed +- **WHEN** a user navigates to a catalog entity page for an AI asset +- **THEN** the AI Asset Summary Card, Download/Adopt Card, and Version List Card render on the overview +- **AND** the Usage tab appears in the entity page tabs + +#### Scenario: Extensions absent on non-AI entities + +- **GIVEN** the boost frontend dynamic plugin is installed +- **WHEN** a user navigates to a catalog entity page for a non-AI entity (e.g., a regular Component) +- **THEN** no boost entity cards or tabs are rendered + +### Requirement: Adopter Overrides + +Deployers can customize the plugin via `app.extensions` in `app-config.yaml`. + +#### Scenario: Disable an entity card + +- **GIVEN** the deployer sets `entity-card:boost/adoption: false` in `app.extensions` +- **WHEN** a user views an AI asset entity page +- **THEN** the Download/Adopt Card is not rendered +- **AND** other boost cards still render + +#### Scenario: Rename a tab + +- **GIVEN** the deployer sets `entity-content:boost/usage` with `config.title: "How to Use"` +- **WHEN** a user views an AI asset entity page +- **THEN** the tab label reads "How to Use" instead of the default + +#### Scenario: Change entity filter on a card + +- **GIVEN** the deployer sets `entity-card:boost/summary` with `config.filter` restricting to `kind: component` +- **WHEN** a user views an AiResource entity page +- **THEN** the summary card is not rendered (filter excludes AiResource) +- **WHEN** a user views a Component/ai-agent entity page +- **THEN** the summary card renders + +#### Scenario: Disable the page + +- **GIVEN** the deployer sets `page:boost/ai-catalog: false` in `app.extensions` +- **WHEN** a user views the RHDH sidebar +- **THEN** the "AI Catalog" nav item is not present +- **AND** navigating to `/ai-catalog` shows a 404 or redirects diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/e2e-tests/spec.md b/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/e2e-tests/spec.md new file mode 100644 index 00000000000..e353f759f26 --- /dev/null +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/e2e-tests/spec.md @@ -0,0 +1,131 @@ +# E2E Tests + +> **Status: Draft** — Pre-implementation specification. Subject to change during implementation. + +Playwright end-to-end tests for the AI Catalog frontend plugin. Boost is NFS-only (no legacy app), so no `APP_MODE` dual testing. Tests use translation keys instead of hardcoded strings and include axe-core accessibility audits. + +## Requirements + +### Requirement: Playwright Infrastructure + +The workspace has a working Playwright setup following rhdh-plugins conventions. + +#### Scenario: Playwright config exists + +- **GIVEN** the boost workspace +- **WHEN** a developer runs `yarn test:e2e` +- **THEN** Playwright starts the dev app via `yarn start`, waits for readiness, and runs tests from `e2e-tests/` + +#### Scenario: Multi-locale test projects + +- **GIVEN** the Playwright config defines projects for at least `en` and one non-English locale (e.g., `ja`) +- **WHEN** the full e2e suite runs +- **THEN** tests execute against both locales +- **AND** per-locale `app-config-e2e-*.yaml` overrides configure separate ports so projects can run in parallel + +#### Scenario: CI integration + +- **GIVEN** CI runs `yarn playwright test` in the boost workspace +- **WHEN** the test suite completes +- **THEN** test reports are generated in `e2e-test-report/` +- **AND** test artifacts (screenshots, traces on failure) are stored in `e2e-test-results/` + +### Requirement: Browse Page Tests + +The AI Catalog browse page is tested end-to-end. + +#### Scenario: Card grid renders with fixture data + +- **WHEN** the e2e test navigates to `/ai-catalog` +- **THEN** AI asset cards are visible on the page +- **AND** cards display translated text (using translation keys, not hardcoded English) + +#### Scenario: Search filters cards + +- **GIVEN** the browse page has loaded with AI assets +- **WHEN** the test types a keyword in the search bar +- **THEN** the visible cards are filtered to match the keyword +- **AND** the URL updates with `?q=` + +#### Scenario: Sidebar filters narrow results + +- **GIVEN** the browse page has loaded +- **WHEN** the test selects a category filter (e.g., "skill") +- **THEN** only cards matching that category are shown +- **AND** the URL updates with the filter param + +#### Scenario: Multiple filters combine as AND + +- **GIVEN** the browse page has loaded +- **WHEN** the test selects category "skill" AND a specific tag +- **THEN** only cards matching both criteria are shown + +#### Scenario: Clear filters resets the view + +- **GIVEN** filters are active and the card grid is narrowed +- **WHEN** the test clicks the clear-filters action +- **THEN** all filter URL params are removed +- **AND** the full card grid is restored + +#### Scenario: Card click navigates to entity detail + +- **GIVEN** AI asset cards are visible +- **WHEN** the test clicks on a card +- **THEN** the browser navigates to the catalog entity detail page for that asset + +### Requirement: State and Error Handling Tests + +Edge cases are covered by e2e tests. + +#### Scenario: Empty state when no assets match + +- **GIVEN** the browse page has loaded +- **WHEN** the test applies filters that match no assets +- **THEN** the empty state message is displayed (verified via translation key) +- **AND** the clear-filters action is visible + +#### Scenario: Pagination controls work + +- **GIVEN** the catalog has more assets than one page +- **WHEN** the test clicks the next-page control +- **THEN** the card grid updates to show the next page of results +- **AND** the URL updates with the page param + +#### Scenario: Sort control changes order + +- **GIVEN** the browse page has loaded +- **WHEN** the test changes the sort to "last updated" +- **THEN** the card order updates accordingly + +### Requirement: Accessibility Audits + +Every tested page passes automated accessibility checks. + +#### Scenario: Browse page passes axe audit + +- **WHEN** the browse page has loaded +- **THEN** an axe-core scan with WCAG 2.1 AA rules reports zero violations +- **AND** violations (if any) are attached to the test report for debugging + +#### Scenario: Filtered state passes axe audit + +- **GIVEN** filters are active on the browse page +- **WHEN** an axe-core scan runs +- **THEN** zero WCAG 2.1 AA violations are reported (focus management, aria-labels intact) + +### Requirement: Translation Key Usage + +Tests do not hardcode English strings. + +#### Scenario: UI text verified via translation keys + +- **GIVEN** the test needs to verify a UI label +- **WHEN** it locates the element +- **THEN** it uses the translated string from the plugin's translation module for the current locale +- **AND** the same test code works for `en`, `de`, `es`, `fr`, `it`, `ja` without modification + +#### Scenario: Translation helper utility exists + +- **GIVEN** a `getTranslations(locale)` utility exists in `e2e-tests/utils/` +- **WHEN** a test calls it +- **THEN** it returns the message map for that locale loaded from the plugin's translation modules diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/filter-customization/spec.md b/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/filter-customization/spec.md new file mode 100644 index 00000000000..632deb8fd93 --- /dev/null +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/filter-customization/spec.md @@ -0,0 +1,172 @@ +# Filter Customization + +> **Status: Draft** — Pre-implementation specification. Subject to change during implementation. + +The AI Catalog browse page filter sidebar becomes extensible via the Backstage New Frontend System (NFS). Deployers can enable/disable built-in filters and third-party plugins can contribute new filters — all without modifying boost source code. + +## Design Approach + +Filters are **data, not components**. Each filter is a `FilterDefinition` — a plain object with a URL param name, a label, a function to extract options from entities, and a function to match an entity against selected values. The `FilterSidebar` renders a generic `` for each +- **AND** each `` binds to URL state via `urlParam` + +### Requirement: Built-in Filters as Extensions + +Existing hardcoded filters are converted to `FilterDefinition` objects registered as default Blueprint extensions. + +#### Scenario: Default filter set matches current behavior + +- **WHEN** no app-config overrides are present +- **THEN** the browse page renders the same four filters as before: category (type), provider, owner, tags +- **AND** filter behavior (AND logic, URL params, dynamic options) is unchanged + +#### Scenario: Built-in filters are plain objects + +- **GIVEN** the 4 built-in filter definitions (category, provider, owner, tags) +- **THEN** each is a plain object in `src/filters/builtInFilterDefinitions.ts` +- **AND** no filter has its own React component file +- **AND** all share the same generic `` in the sidebar alongside built-in filters +- **AND** selecting a value narrows the card grid using the provided `matchEntity` +- **AND** the `team` URL param persists the selection + +#### Scenario: Custom filter participates in AND logic + +- **GIVEN** a custom filter is active with a selected value +- **AND** the built-in category filter also has a selected value +- **WHEN** the card grid is filtered +- **THEN** only entities matching both the custom filter AND the category filter are shown + +#### Scenario: Custom filter state in URL + +- **GIVEN** a custom filter has a selection +- **WHEN** the URL is copied and opened in a new tab +- **THEN** the custom filter selection is restored from the URL +- **AND** the card grid shows the same filtered results + +#### Scenario: Custom filter disabled by deployer + +- **GIVEN** a third-party plugin registers `ai-catalog-filter:my-plugin/team-filter` +- **AND** the deployer sets `ai-catalog-filter:my-plugin/team-filter: false` in `app.extensions` +- **WHEN** the developer navigates to `/ai-catalog` +- **THEN** the custom team filter is not rendered + +### Requirement: Dynamic Filter Architecture + +The filter pipeline adapts to the registered filter set. + +#### Scenario: useUrlFilters reads registered filters dynamically + +- **GIVEN** the page receives a resolved `FilterDefinition[]` +- **WHEN** the page initializes +- **THEN** `useUrlFilters` reads URL params for each definition's `urlParam` (not a hardcoded list) +- **AND** unrecognized URL params from disabled or removed filters are ignored (not cleared) + +#### Scenario: applyEntityFilters uses registered matchEntity functions + +- **WHEN** the card grid is filtered +- **THEN** `applyEntityFilters` iterates over all active `FilterDefinition` entries +- **AND** for each with selected values, calls `matchEntity(entity, values)` +- **AND** applies all in AND logic +- **AND** does not reference hardcoded filter field names + +#### Scenario: Clear filters resets registered filters and search only + +- **WHEN** the developer clicks "Clear filters" +- **THEN** all registered filter URL params are cleared +- **AND** the search param (`q`) is cleared +- **AND** view mode (`view`) and page size (`pageSize`) are preserved +- **AND** the full unfiltered card grid is restored + +#### Scenario: Active filter detection includes custom filters + +- **WHEN** only a custom filter has a selection (no built-in filters active) +- **THEN** the "has active filters" indicator is shown +- **AND** the empty state shows "Clear filters" when results are empty + +### Requirement: Page Extension Input + +The AI Catalog page declares a filters input for child extensions. + +#### Scenario: Page collects filter extensions + +- **GIVEN** the `aiCatalogPage` extension is defined with `PageBlueprint.makeWithOverrides` and `name: 'ai-catalog'` +- **AND** it declares a `filters` input of kind `ai-catalog-filter` +- **WHEN** the page renders +- **THEN** it receives all enabled `ai-catalog-filter` extensions as resolved `FilterDefinition[]` +- **AND** sorts them by priority +- **AND** passes the sorted array to the filter sidebar and filter pipeline + +#### Scenario: No filters input graceful fallback + +- **WHEN** zero filter extensions are registered (all disabled) +- **THEN** the page renders without a filter sidebar +- **AND** search still works +- **AND** the page does not crash diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/translations/spec.md b/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/translations/spec.md new file mode 100644 index 00000000000..1902826cbc1 --- /dev/null +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend/specs/translations/spec.md @@ -0,0 +1,96 @@ +# Translations + +> **Status: Draft** — Pre-implementation specification. Subject to change during implementation. + +Add non-English translation files for the AI Catalog plugin, covering all 5 languages supported across rhdh-plugins. The i18n scaffold (TranslationBlueprint, `createTranslationRef`, English `ref.ts`) is already in place from Story 1. + +## Requirements + +### Requirement: Translation Files for Supported Languages + +Each supported language has a complete translation file following the standard rhdh-plugins pattern. + +#### Scenario: Translation files exist for all 5 languages + +- **GIVEN** the plugin has English strings in `src/translations/ref.ts` +- **WHEN** the translation story is complete +- **THEN** `src/translations/` contains `de.ts`, `es.ts`, `fr.ts`, `it.ts`, `ja.ts` +- **AND** each file uses `createTranslationMessages` referencing the `boostTranslationRef` + +#### Scenario: Translation resource registers all locales + +- **GIVEN** all 5 locale files exist +- **WHEN** the `createTranslationResource` in `src/translations/index.ts` is configured +- **THEN** it lazy-imports all 5 locales (`de: () => import('./de')`, etc.) +- **AND** each locale is only loaded when the user selects that language + +#### Scenario: Translation module auto-discovery for dynamic plugins + +- **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) +- **AND** RHDH auto-discovers the module without explicit `features` array registration + +### Requirement: Complete String Coverage + +Every user-facing string in the plugin is translated. + +#### Scenario: All browse page strings translated + +- **GIVEN** a user switches their RHDH locale to German +- **WHEN** they navigate to `/ai-catalog` +- **THEN** the page title, search placeholder, filter labels, empty state message, error state message, pagination labels, and sort options are all in German + +#### Scenario: All filter strings translated + +- **GIVEN** a user is viewing the AI Catalog in Japanese +- **WHEN** the filter sidebar is visible +- **THEN** filter section headings (type, provider, owner, tags), clear-filters action text, and "has active filters" indicators are in Japanese + +#### Scenario: All entity extension strings translated + +- **GIVEN** a user views an AI asset entity page in French +- **WHEN** entity cards and tabs from the boost plugin render +- **THEN** card titles (summary, download/adopt, version list), tab labels (usage), download button text, copy button text, docker/podman labels, and "Contact owner" fallback text are all in French + +#### Scenario: Error and empty state strings translated + +- **GIVEN** a user is viewing the AI Catalog in Spanish +- **WHEN** no assets match the current filters +- **THEN** the empty state message and clear-filters button text are in Spanish +- **WHEN** the catalog API is unreachable +- **THEN** the error message and retry button text are in Spanish + +### Requirement: Translation Quality + +Translations are accurate and consistent with RHDH conventions. + +#### Scenario: Translation keys use dot-notation + +- **GIVEN** the English source in `ref.ts` uses nested objects for message keys +- **WHEN** locale files are created +- **THEN** they use flattened dot-notation keys (e.g., `'catalog.filter.type': 'Typ'`) +- **AND** keys match the structure in `ref.ts` exactly + +#### Scenario: Placeholder interpolation preserved + +- **GIVEN** an English string contains interpolation placeholders (e.g., `{{count}} assets`) +- **WHEN** the string is translated +- **THEN** the same placeholders appear in the translated string +- **AND** the interpolation works correctly at runtime + +### Requirement: Verification + +Translations render correctly in the dev app. + +#### Scenario: Locale switching in dev app + +- **GIVEN** the dev app is running +- **WHEN** a developer switches the locale via RHDH Settings +- **THEN** all AI Catalog strings update to the selected language without a page reload + +#### Scenario: Fallback to English for missing keys + +- **GIVEN** a translation file is missing a key that exists in `ref.ts` +- **WHEN** the UI renders that string +- **THEN** the English fallback is shown (not a raw key or empty string) diff --git a/workspaces/boost/openspec/changes/ai-catalog-frontend/tasks.md b/workspaces/boost/openspec/changes/ai-catalog-frontend/tasks.md index da7db7c99cb..e796cba1599 100644 --- a/workspaces/boost/openspec/changes/ai-catalog-frontend/tasks.md +++ b/workspaces/boost/openspec/changes/ai-catalog-frontend/tasks.md @@ -49,3 +49,79 @@ - [ ] 3.8 i18n: all user-facing strings via translation resources - [ ] 3.9 WCAG 2.1 AA for all interactive elements - [ ] 3.10 Unit tests for each card/tab, RBAC-gated rendering, download/copy behavior + +## 4. Extensible Browse Filters via NFS (RHIDP-15449) + +- [ ] 4.1 Define `FilterDefinition` interface in `src/blueprints/AiCatalogFilterBlueprint.ts` — fields: `urlParam` (string), `label` (string), `getOptions(entities) => {id, label}[]`, `matchEntity(entity, values) => boolean`, `priority` (number) +- [ ] 4.2 Create single `filterDefinitionDataRef` via `createExtensionDataRef` in same file +- [ ] 4.3 Create `AiCatalogFilterBlueprint` via `createExtensionBlueprint` — kind `ai-catalog-filter`, attaches to `page:boost/ai-catalog` input `filters`, params are `FilterDefinition` fields, no config schema. Factory outputs the `FilterDefinition` via the single data ref. +- [ ] 4.4 Create `src/filters/builtInFilterDefinitions.ts` with 4 plain `FilterDefinition` objects: + - `categoryFilter` — urlParam `type`, getOptions from `getAllCategories()`, matchEntity checks `spec.type`, priority 100 + - `providerFilter` — urlParam `provider`, getOptions from `rhdh.io/ai-asset-source` annotation, priority 200 + - `ownerFilter` — urlParam `owner`, getOptions from `spec.owner`, priority 300 + - `tagsFilter` — urlParam `tag`, getOptions from `metadata.tags`, priority 400 +- [ ] 4.5 Register 4 built-in filters as `AiCatalogFilterBlueprint.make(...)` extensions in `plugin.tsx`, add to `createFrontendPlugin({ extensions: [...] })` +- [ ] 4.6 Upgrade `aiCatalogPage` to `PageBlueprint.makeWithOverrides` with `name: 'ai-catalog'` — declare `filters` input via `createExtensionInput` accepting `ai-catalog-filter` extensions. Factory resolves `FilterDefinition[]`, sorts by priority, passes to page component as prop. +- [ ] 4.7 Refactor `FilterSidebar` — receive `FilterDefinition[]` + URL values. Map over definitions, render `` +- [ ] 4.16 Unit tests: `builtinFilters` (getOptions returns correct options, matchEntity matches correctly), `FilterSidebar` (renders N selects from definitions, returns null when empty), `useUrlFilters` (dynamic param read/write, setFilter, clearFilters preserves view/pageSize), `applyEntityFilters` (AND loop with matchEntity, search + filters combined), priority ordering + +## 5. Add Translations for Supported Languages (RHIDP-15479) + +- [ ] 5.1 Create `src/translations/de.ts` — German translations using `createTranslationMessages` referencing `boostTranslationRef`, flattened dot-notation keys +- [ ] 5.2 Create `src/translations/es.ts` — Spanish translations +- [ ] 5.3 Create `src/translations/fr.ts` — French translations +- [ ] 5.4 Create `src/translations/it.ts` — Italian translations +- [ ] 5.5 Create `src/translations/ja.ts` — Japanese translations +- [ ] 5.6 Update `src/translations/index.ts` — register all 5 locales in `createTranslationResource` with lazy imports (`de: () => import('./de')`, etc.) +- [ ] 5.7 Audit all user-facing strings in browse page, filter sidebar, entity cards, entity tabs, empty/error/loading states — ensure every string uses `useTranslationRef` with a key in `ref.ts` +- [ ] 5.8 Add missing keys to `ref.ts` if any strings are found not externalized +- [ ] 5.9 Ensure interpolation placeholders (e.g., `{{count}}`) are preserved in all locale files +- [ ] 5.10 Add separate entry point for translation module auto-discovery — re-export `boostTranslationsModule` as default from a dedicated file, add entry to `package.json` `exports` +- [ ] 5.11 Verify locale switching in dev app — switch locale via Settings, confirm all AI Catalog strings update without page reload +- [ ] 5.12 Verify English fallback — if a key is missing from a locale file, English is shown (not a raw key or empty string) + +## 6. E2E Tests with Playwright (RHIDP-15480) + +- [ ] 6.1 Install `@playwright/test` and `@backstage/e2e-test-utils` as devDependencies +- [ ] 6.2 Create `playwright.config.ts` at workspace root — `webServer` starts `yarn start`, `testDir: 'e2e-tests'`, NFS-only (no `APP_MODE`) +- [ ] 6.3 Add Playwright projects for at least `en` and one non-English locale (e.g., `ja`) with separate ports +- [ ] 6.4 Create `e2e-tests/test_yamls/` with per-locale `app-config-e2e-*.yaml` overrides (baseUrl, backend port, CORS) +- [ ] 6.5 Add `package.json` scripts: `test:e2e` → `playwright test`, `playwright` → forwarding script +- [ ] 6.6 Create `e2e-tests/utils/translations.ts` — `getTranslations(locale)` helper loading messages from plugin translation modules +- [ ] 6.7 Create `e2e-tests/utils/accessibility.ts` — axe-core audit helper with WCAG 2.1 AA tags, attaches results to `TestInfo` +- [ ] 6.8 Test: browse page renders card grid with fixture data — verify cards visible using translation keys +- [ ] 6.9 Test: search filters cards by keyword — type in search, verify URL updates, cards filtered +- [ ] 6.10 Test: sidebar filter narrows results — select category, verify only matching cards shown +- [ ] 6.11 Test: multiple filters combine as AND — select category + tag, verify intersection +- [ ] 6.12 Test: clear filters resets view — click clear, verify URL params removed, full grid restored +- [ ] 6.13 Test: card click navigates to entity detail — click card, verify URL changes to catalog entity page +- [ ] 6.14 Test: empty state when no matches — apply impossible filter combination, verify empty state message +- [ ] 6.15 Test: pagination controls — navigate pages, verify card grid updates +- [ ] 6.16 Test: sort control — change sort order, verify card reordering +- [ ] 6.17 Accessibility: axe-core audit on browse page (unfiltered) +- [ ] 6.18 Accessibility: axe-core audit on browse page (with active filters) +- [ ] 6.19 Verify same tests pass for non-English locale project + +## 7. Dynamic Plugin Export and Overlay Registration (RHIDP-15481) + +- [ ] 7.1 Add `"export-dynamic": "rhdh-cli plugin export"` script to `plugins/boost/package.json` +- [ ] 7.2 Add `"dist-dynamic/*.*"` and `"dist-dynamic/dist/**"` to `files` array in `plugins/boost/package.json` +- [ ] 7.3 Run `yarn export-dynamic` and verify `dist-dynamic/` is produced without errors +- [ ] 7.4 Create `plugins/boost/app-config.dynamic.yaml` with default `app.extensions` config: + - `page:boost/ai-catalog` at `/ai-catalog` + - `entity-card:boost/summary`, `entity-card:boost/adoption`, `entity-card:boost/version-list` with AI asset filter + - `entity-content:boost/usage` tab with title and group +- [ ] 7.5 Add boost frontend plugin entry to `redhat-developer/rhdh-plugin-export-overlays` — PR with overlay config for OCI image build +- [ ] 7.6 Update workspace `dynamic-plugins-image-reference.yaml` with the published OCI image ref for the frontend plugin +- [ ] 7.7 Verify plugin loads in RHDH with `ENABLE_STANDARD_MODULE_FEDERATION=true` — AI Catalog nav item appears, browse page renders +- [ ] 7.8 Verify entity page extensions mount on AI asset entities and are absent on non-AI entities +- [ ] 7.9 Verify adopter overrides via `app.extensions` — disable a card, rename a tab, change entity filter +- [ ] 7.10 Verify `page:boost/ai-catalog: false` removes the nav item and page diff --git a/workspaces/boost/package.json b/workspaces/boost/package.json index d4d65c3835f..9be57ec87bc 100644 --- a/workspaces/boost/package.json +++ b/workspaces/boost/package.json @@ -45,6 +45,10 @@ "@backstage/repo-tools": "^0.17.3", "@changesets/cli": "^2.27.1", "@fission-ai/openspec": "^1.4.1", + "@jest/environment-jsdom-abstract": "^30.3.0", + "@types/jest": "^30.0.0", + "jest": "^30.3.0", + "jsdom": "^27.1.0", "knip": "^5.27.4", "node-gyp": "^9.0.0", "prettier": "^3.7.4", diff --git a/workspaces/boost/packages/app/src/App.tsx b/workspaces/boost/packages/app/src/App.tsx index 2ee821b4263..2cac93ae048 100644 --- a/workspaces/boost/packages/app/src/App.tsx +++ b/workspaces/boost/packages/app/src/App.tsx @@ -18,8 +18,9 @@ import { createApp } from '@backstage/frontend-defaults'; import { rhdhThemeModule } from '@red-hat-developer-hub/backstage-plugin-theme/alpha'; import { navModule } from './modules/nav'; +import { sampleFilterModule } from './modules/sampleFilter'; import { signInModule } from './modules/signIn'; export default createApp({ - features: [rhdhThemeModule, signInModule, navModule], + features: [rhdhThemeModule, signInModule, navModule, sampleFilterModule], }); diff --git a/workspaces/boost/packages/app/src/modules/sampleFilter/index.ts b/workspaces/boost/packages/app/src/modules/sampleFilter/index.ts new file mode 100644 index 00000000000..84c8d4df7bf --- /dev/null +++ b/workspaces/boost/packages/app/src/modules/sampleFilter/index.ts @@ -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 { createFrontendModule } from '@backstage/frontend-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; + +import { AiCatalogFilterBlueprint } from '@red-hat-developer-hub/backstage-plugin-boost'; + +function uniqueSorted( + items: (string | undefined)[], +): { id: string; label: string }[] { + const set = new Set(); + for (const item of items) { + if (item) set.add(item); + } + return Array.from(set) + .sort((a, b) => a.localeCompare(b)) + .map(v => ({ id: v, label: v })); +} + +/** + * Example module demonstrating how a third-party plugin contributes + * a custom filter to the AI Catalog browse page. + * + * Lifecycle is not a built-in filter — it's added here via the + * extension system to show how deployers and third-party plugins + * can extend the filter sidebar. + */ +const lifecycleFilterExt = AiCatalogFilterBlueprint.make({ + name: 'lifecycle', + params: { + urlParam: 'lifecycle', + label: 'Lifecycle', + getOptions: (entities: Entity[]) => + uniqueSorted( + entities.map( + e => + (e.spec as Record | undefined)?.lifecycle as + | string + | undefined, + ), + ), + matchEntity: (entity: Entity, values: string[]) => { + const lifecycle = (entity.spec as Record | undefined) + ?.lifecycle as string | undefined; + return ( + lifecycle !== undefined && + values.some(v => v.toLowerCase() === lifecycle.toLowerCase()) + ); + }, + priority: 500, + }, +}); + +export const sampleFilterModule = createFrontendModule({ + pluginId: 'boost', + extensions: [lifecycleFilterExt], +}); diff --git a/workspaces/boost/plugins/boost/package.json b/workspaces/boost/plugins/boost/package.json index 1c119bd3729..b621201b331 100644 --- a/workspaces/boost/plugins/boost/package.json +++ b/workspaces/boost/plugins/boost/package.json @@ -86,6 +86,7 @@ "devDependencies": { "@backstage/cli": "^0.36.3", "@backstage/frontend-defaults": "^0.5.3", + "@backstage/frontend-test-utils": "^0.6.2", "@backstage/test-utils": "^1.7.19", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.0.0", diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index b4a06707c15..bf15a623012 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -7,6 +7,7 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; +import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { FilterPredicate } from '@backstage/filter-predicates'; @@ -21,6 +22,37 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; +// @public +export const AiCatalogFilterBlueprint: ExtensionBlueprint<{ + kind: 'ai-catalog-filter'; + params: { + urlParam: string; + label: string; + labelKey?: string; + getOptions: (entities: Entity[]) => { + id: string; + label: string; + }[]; + matchEntity: (entity: Entity, values: string[]) => boolean; + priority?: number; + }; + output: ExtensionDataRef< + FilterDefinition, + 'ai-catalog-filter.definition', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + filterDefinition: ConfigurableExtensionDataRef< + FilterDefinition, + 'ai-catalog-filter.definition', + {} + >; + }; +}>; + // @public const boostPlugin: OverridableFrontendPlugin< { @@ -28,6 +60,98 @@ const boostPlugin: OverridableFrontendPlugin< }, {}, { + 'ai-catalog-filter:boost/category': OverridableExtensionDefinition<{ + kind: 'ai-catalog-filter'; + name: 'category'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + FilterDefinition, + 'ai-catalog-filter.definition', + {} + >; + inputs: {}; + params: { + urlParam: string; + label: string; + labelKey?: string; + getOptions: (entities: Entity[]) => { + id: string; + label: string; + }[]; + matchEntity: (entity: Entity, values: string[]) => boolean; + priority?: number; + }; + }>; + 'ai-catalog-filter:boost/owner': OverridableExtensionDefinition<{ + kind: 'ai-catalog-filter'; + name: 'owner'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + FilterDefinition, + 'ai-catalog-filter.definition', + {} + >; + inputs: {}; + params: { + urlParam: string; + label: string; + labelKey?: string; + getOptions: (entities: Entity[]) => { + id: string; + label: string; + }[]; + matchEntity: (entity: Entity, values: string[]) => boolean; + priority?: number; + }; + }>; + 'ai-catalog-filter:boost/provider': OverridableExtensionDefinition<{ + kind: 'ai-catalog-filter'; + name: 'provider'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + FilterDefinition, + 'ai-catalog-filter.definition', + {} + >; + inputs: {}; + params: { + urlParam: string; + label: string; + labelKey?: string; + getOptions: (entities: Entity[]) => { + id: string; + label: string; + }[]; + matchEntity: (entity: Entity, values: string[]) => boolean; + priority?: number; + }; + }>; + 'ai-catalog-filter:boost/tags': OverridableExtensionDefinition<{ + kind: 'ai-catalog-filter'; + name: 'tags'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + FilterDefinition, + 'ai-catalog-filter.definition', + {} + >; + inputs: {}; + params: { + urlParam: string; + label: string; + labelKey?: string; + getOptions: (entities: Entity[]) => { + id: string; + label: string; + }[]; + matchEntity: (entity: Entity, values: string[]) => boolean; + priority?: number; + }; + }>; 'entity-card:boost/adoption': OverridableExtensionDefinition<{ kind: 'entity-card'; name: 'adoption'; @@ -170,6 +294,7 @@ const boostPlugin: OverridableFrontendPlugin< }; output: | ExtensionDataRef + | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', @@ -177,7 +302,6 @@ const boostPlugin: OverridableFrontendPlugin< optional: true; } > - | ExtensionDataRef | ExtensionDataRef< (entity: Entity) => boolean, 'catalog.entity-filter-function', @@ -230,20 +354,17 @@ const boostPlugin: OverridableFrontendPlugin< filter?: string | FilterPredicate | ((entity: Entity) => boolean); }; }>; - 'page:boost': OverridableExtensionDefinition<{ - kind: 'page'; - name: undefined; + 'page:boost/ai-catalog': OverridableExtensionDefinition<{ config: { path: string | undefined; title: string | undefined; }; configInput: { - path?: string | undefined; - title?: string | undefined; + path?: string | undefined | undefined; + title?: string | undefined | undefined; }; output: | ExtensionDataRef - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', @@ -251,6 +372,7 @@ const boostPlugin: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< string, 'core.title', @@ -296,7 +418,21 @@ const boostPlugin: OverridableFrontendPlugin< internal: false; } >; + filters: ExtensionInput< + ConfigurableExtensionDataRef< + FilterDefinition, + 'ai-catalog-filter.definition', + {} + >, + { + singleton: false; + optional: false; + internal: false; + } + >; }; + kind: 'page'; + name: 'ai-catalog'; params: { path: string; title?: string; @@ -317,12 +453,12 @@ export const boostTranslationRef: TranslationRef< readonly 'nav.aiCatalog': string; readonly 'catalog.table.name': string; readonly 'catalog.table.type': string; - readonly 'catalog.table.description': string; - readonly 'catalog.table.provider': string; readonly 'catalog.table.owner': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.table.description': string; readonly 'catalog.filter.type': string; - readonly 'catalog.filter.provider': string; readonly 'catalog.filter.owner': string; + readonly 'catalog.filter.provider': string; readonly 'catalog.filter.tag': string; readonly 'catalog.page.title': string; readonly 'catalog.page.subtitle': string; @@ -359,4 +495,24 @@ export const boostTranslations: TranslationResource<'plugin.boost'>; // @public export const boostTranslationsModule: FrontendModule; + +// @public +export interface FilterDefinition { + getOptions: (entities: Entity[]) => { + id: string; + label: string; + }[]; + label: string; + labelKey?: string; + matchEntity: (entity: Entity, values: string[]) => boolean; + priority: number; + urlParam: string; +} + +// @public +export const filterDefinitionDataRef: ConfigurableExtensionDataRef< + FilterDefinition, + 'ai-catalog-filter.definition', + {} +>; ``` diff --git a/workspaces/boost/plugins/boost/src/blueprints/AiCatalogFilterBlueprint.test.ts b/workspaces/boost/plugins/boost/src/blueprints/AiCatalogFilterBlueprint.test.ts new file mode 100644 index 00000000000..2baddd6f5d2 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/blueprints/AiCatalogFilterBlueprint.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { createExtensionTester } from '@backstage/frontend-test-utils'; + +import { + AiCatalogFilterBlueprint, + filterDefinitionDataRef, +} from './AiCatalogFilterBlueprint'; + +const baseParams = { + urlParam: 'custom', + label: 'Custom', + getOptions: jest.fn(() => [{ id: 'a', label: 'A' }]), + matchEntity: jest.fn(() => true), +}; + +describe('AiCatalogFilterBlueprint', () => { + it('yields FilterDefinition with all provided fields', () => { + const extension = AiCatalogFilterBlueprint.make({ + name: 'test', + params: { + ...baseParams, + labelKey: 'catalog.filter.custom', + priority: 200, + }, + }); + + const tester = createExtensionTester(extension); + const def = tester.get(filterDefinitionDataRef); + + expect(def.urlParam).toBe('custom'); + expect(def.label).toBe('Custom'); + expect(def.labelKey).toBe('catalog.filter.custom'); + expect(def.getOptions).toBe(baseParams.getOptions); + expect(def.matchEntity).toBe(baseParams.matchEntity); + expect(def.priority).toBe(200); + }); + + it('defaults priority to 100 when omitted', () => { + const extension = AiCatalogFilterBlueprint.make({ + name: 'test', + params: baseParams, + }); + + const tester = createExtensionTester(extension); + const def = tester.get(filterDefinitionDataRef); + + expect(def.priority).toBe(100); + }); + + it.each(['q', 'view', 'page', 'pageSize'])( + 'throws for reserved urlParam "%s"', + reserved => { + const extension = AiCatalogFilterBlueprint.make({ + name: 'bad', + params: { ...baseParams, urlParam: reserved }, + }); + + expect(() => + createExtensionTester(extension).get(filterDefinitionDataRef), + ).toThrow(`urlParam '${reserved}' is reserved`); + }, + ); + + it('does not throw for non-reserved urlParam', () => { + const extension = AiCatalogFilterBlueprint.make({ + name: 'ok', + params: { ...baseParams, urlParam: 'lifecycle' }, + }); + + expect(() => createExtensionTester(extension)).not.toThrow(); + }); +}); + +describe('filterDefinitionDataRef', () => { + it('has the correct id', () => { + expect(filterDefinitionDataRef.id).toBe('ai-catalog-filter.definition'); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/blueprints/AiCatalogFilterBlueprint.ts b/workspaces/boost/plugins/boost/src/blueprints/AiCatalogFilterBlueprint.ts new file mode 100644 index 00000000000..0f3532ec8b3 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/blueprints/AiCatalogFilterBlueprint.ts @@ -0,0 +1,112 @@ +/* + * 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 { + createExtensionBlueprint, + createExtensionDataRef, +} from '@backstage/frontend-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; + +/** + * Describes a single filter in the AI Catalog browse sidebar. + * Filters are data — the FilterSidebar renders a generic Select for each. + * + * @public + */ +export interface FilterDefinition { + /** URL query parameter name (e.g., 'type', 'owner', 'tag'). */ + urlParam: string; + /** Plain text label for the filter heading. Used when `labelKey` is not set. */ + label: string; + /** Translation key resolved via boostTranslationRef. Takes precedence over `label`. */ + labelKey?: string; + /** Derive select options from the full set of loaded entities. */ + getOptions: (entities: Entity[]) => { id: string; label: string }[]; + /** Return true if the entity matches any of the selected values. */ + matchEntity: (entity: Entity, values: string[]) => boolean; + /** Render order in the sidebar. Lower numbers render first. */ + priority: number; +} + +/** + * Extension data ref carrying a FilterDefinition from a filter extension + * to the AI Catalog page. + * + * @public + */ +export const filterDefinitionDataRef = + createExtensionDataRef().with({ + id: 'ai-catalog-filter.definition', + }); + +const RESERVED_PARAMS = ['q', 'view', 'page', 'pageSize']; + +/** + * Blueprint for contributing a filter to the AI Catalog browse sidebar. + * + * Each filter is a plain FilterDefinition — no per-filter React component + * needed. The FilterSidebar renders a generic Select for each definition. + * + * @example + * ``` + * const myFilter = AiCatalogFilterBlueprint.make({ + * name: 'namespace', + * params: { + * urlParam: 'ns', + * label: 'Namespace', + * getOptions: entities => + * uniqueSorted(entities.map(e => e.metadata.namespace ?? 'default')), + * matchEntity: (entity, values) => + * values.includes(entity.metadata.namespace ?? 'default'), + * }, + * }); + * ``` + * + * Deployers can disable filters via app-config: + * ```yaml + * app: + * extensions: + * - ai-catalog-filter:boost/owner: false + * ``` + * + * @public + */ +export const AiCatalogFilterBlueprint = createExtensionBlueprint({ + kind: 'ai-catalog-filter', + attachTo: { id: 'page:boost/ai-catalog', input: 'filters' }, + output: [filterDefinitionDataRef], + dataRefs: { + filterDefinition: filterDefinitionDataRef, + }, + *factory(params: { + urlParam: string; + label: string; + labelKey?: string; + getOptions: (entities: Entity[]) => { id: string; label: string }[]; + matchEntity: (entity: Entity, values: string[]) => boolean; + priority?: number; + }) { + if (RESERVED_PARAMS.includes(params.urlParam)) { + throw new Error( + `FilterDefinition urlParam '${params.urlParam}' is reserved. Avoid: ${RESERVED_PARAMS.join(', ')}`, + ); + } + yield filterDefinitionDataRef({ + ...params, + priority: params.priority ?? 100, + }); + }, +}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx index 40aeac730f8..4e0b18aa301 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.test.tsx @@ -20,11 +20,24 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { screen, waitFor } from '@testing-library/react'; +import { + categoryFilterDefinition, + providerFilterDefinition, + ownerFilterDefinition, + tagsFilterDefinition, +} from '../../filters/builtInFilterDefinitions'; import { boostMessages } from '../../translations/ref'; import { AiCatalogPage } from './AiCatalogPage'; const { catalog: msg } = boostMessages; +const defaultFilters = [ + categoryFilterDefinition, + providerFilterDefinition, + ownerFilterDefinition, + tagsFilterDefinition, +]; + const mockEntities: Entity[] = [ { apiVersion: 'backstage.io/v1alpha1', @@ -65,7 +78,7 @@ function renderPage() { - + , ); } diff --git a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.tsx b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.tsx index 7b7e78dec19..b935f87ca04 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/AiCatalogPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Flex, Grid, @@ -28,6 +28,7 @@ import { import GridViewOutlined from '@mui/icons-material/GridViewOutlined'; import ViewListOutlined from '@mui/icons-material/ViewListOutlined'; +import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; import { useAiAssets } from '../../hooks/useAiAssets'; import { useUrlFilters } from '../../hooks/useUrlFilters'; import { useTranslation } from '../../hooks/useTranslation'; @@ -42,17 +43,25 @@ import { ErrorBoundary } from './ErrorBoundary'; import { FilterSidebar } from './FilterSidebar'; import styles from './AiCatalogPage.module.css'; -const AiCatalogPageContent = () => { +interface AiCatalogPageProps { + filters: FilterDefinition[]; +} + +const AiCatalogPageContent = ({ filters }: AiCatalogPageProps) => { const { t } = useTranslation(); - const urlState = useUrlFilters(); + + const filterParams = useMemo(() => filters.map(f => f.urlParam), [filters]); + const hasCategoryFilter = filters.some(f => f.urlParam === 'type'); + const urlState = useUrlFilters(filterParams); const { - filters, + search, searchInputValue, + filterValues, viewMode, page, pageSize, setSearch, - setCategory, + setFilter, setViewMode, setPage, setPageSize, @@ -65,18 +74,14 @@ const AiCatalogPageContent = () => { loading, error, retry, - } = useAiAssets(filters); + } = useAiAssets(search || undefined, filters, filterValues); const [sortDescriptor, setSortDescriptor] = useState( null, ); - const onSortChange = useCallback( - (descriptor: SortDescriptor) => setSortDescriptor(descriptor), - [], - ); const sortState = useMemo( - () => ({ descriptor: sortDescriptor, onSortChange }), - [sortDescriptor, onSortChange], + () => ({ descriptor: sortDescriptor, onSortChange: setSortDescriptor }), + [sortDescriptor], ); const sortedEntities = useMemo(() => { @@ -104,12 +109,7 @@ const AiCatalogPageContent = () => { if (loading) return ; if (error) return ; - const hasActiveFilters = - filters.search || - filters.category?.length || - filters.provider?.length || - filters.tags?.length || - filters.owner?.length; + const hasActiveFilters = search || filterValues.size > 0; if (allEntities.length === 0 && !hasActiveFilters) { return ; @@ -118,9 +118,10 @@ const AiCatalogPageContent = () => { return ( @@ -170,7 +171,11 @@ const AiCatalogPageContent = () => { setCategory([cat])} + onCategoryClick={ + hasCategoryFilter + ? cat => setFilter('type', [cat]) + : undefined + } /> ))} @@ -201,14 +206,14 @@ const AiCatalogPageContent = () => { ); }; -export const AiCatalogPage = () => { +export const AiCatalogPage = ({ filters }: AiCatalogPageProps) => { const { t } = useTranslation(); return ( - + ); }; diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx new file mode 100644 index 00000000000..10e1b61f9fc --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 { render, screen } from '@testing-library/react'; + +import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; +import { FilterSidebar } from './FilterSidebar'; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const mockFilter = (urlParam: string, label: string): FilterDefinition => ({ + urlParam, + label, + getOptions: () => [ + { id: 'a', label: 'Option A' }, + { id: 'b', label: 'Option B' }, + ], + matchEntity: () => true, + priority: 100, +}); + +describe('FilterSidebar', () => { + it('renders a Select for each filter', () => { + const filters = [mockFilter('type', 'Type'), mockFilter('owner', 'Owner')]; + + render( + , + ); + + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Owner')).toBeInTheDocument(); + }); + + it('returns null when filters array is empty', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders nav with aria-label', () => { + const filters = [mockFilter('type', 'Type')]; + + render( + , + ); + + expect(screen.getByRole('navigation')).toHaveAttribute( + 'aria-label', + 'catalog.page.title', + ); + }); + + it('uses labelKey for translation when provided', () => { + const filter: FilterDefinition = { + ...mockFilter('type', 'Fallback'), + labelKey: 'catalog.filter.type', + }; + + render( + , + ); + + expect(screen.getByText('catalog.filter.type')).toBeInTheDocument(); + }); + + it('uses plain label when labelKey is not set', () => { + const filter = mockFilter('ns', 'Namespace'); + + render( + , + ); + + expect(screen.getByText('Namespace')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx index 9fb801248d7..6af89d758db 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/FilterSidebar.tsx @@ -14,115 +14,75 @@ * limitations under the License. */ -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import type { Entity } from '@backstage/catalog-model'; import { Select } from '@backstage/ui'; -import type { Key } from 'react-aria-components'; +import type { FilterDefinition } from '../../blueprints/AiCatalogFilterBlueprint'; import { useTranslation } from '../../hooks/useTranslation'; -import { getAllCategories } from '../../utils/categoryMeta'; -import { getSpecField } from '../../utils/entityHelpers'; -import type { - UrlFilterActions, - UrlFilterState, -} from '../../hooks/useUrlFilters'; import styles from './FilterSidebar.module.css'; +type TranslationKey = Parameters['t']>[0]; + interface FilterSidebarProps { + filters: FilterDefinition[]; entities: Entity[]; - state: Pick; - actions: Pick< - UrlFilterActions, - 'setCategory' | 'setProvider' | 'setOwner' | 'setTag' - >; -} - -function uniqueOptions( - items: (string | undefined)[], -): { id: string; label: string }[] { - const set = new Set(); - for (const item of items) { - if (item) set.add(item); - } - return Array.from(set) - .sort((a, b) => a.localeCompare(b)) - .map(v => ({ id: v, label: v })); + values: Map; + onFilterChange: (urlParam: string, values: string[]) => void; } -export const FilterSidebar = ({ +function FilterSelect({ + filter, entities, - state, - actions: { setCategory, setProvider, setOwner, setTag }, -}: FilterSidebarProps) => { + selected, + onChange, +}: { + filter: FilterDefinition; + entities: Entity[]; + selected: string[]; + onChange: (values: string[]) => void; +}) { const { t } = useTranslation(); - - const categoryOptions = useMemo(() => getAllCategories(), []); - - const providerOptions = useMemo( - () => - uniqueOptions( - entities.map(e => e.metadata.annotations?.['rhdh.io/ai-asset-source']), - ), - [entities], + const label = filter.labelKey + ? t(filter.labelKey as TranslationKey) + : filter.label; + const options = useMemo( + () => filter.getOptions(entities), + [filter, entities], ); - const ownerOptions = useMemo( - () => uniqueOptions(entities.map(e => getSpecField(e, 'owner'))), - [entities], + return ( + - -