# Requirements: QSP-38296 — Use Smartscape title as component display name

## Status

✅ Approved — Re-baselined for regeneration on 2026-03-16.

---

## Summary

Replace the Backstage catalog ID (e.g. `component:dt-platform/edge-service`) with the human-readable Smartscape `title` field as the displayed component name across the Overview page, Components page, and AppScorePage heading. The catalog ID continues to be used for navigation URLs, routing, and data fetching — the title is a **display-only** change. Both DQL data queries are extended with a `CATALOG_COMPONENT` Smartscape lookup to resolve the title. If a component has no title, the catalog ID is used as a fallback.

## Acceptance Criteria

- [ ] AC-1: On the **Overview page**, asset-level rows in the capability → team → asset tree display the Smartscape `title` instead of the catalog ID.
- [ ] AC-2: On the **Components page**, the "Name" column displays the Smartscape `title` instead of the catalog ID. The "Asset ID" column remains unchanged.
- [ ] AC-3: On the **AppScorePage**, the heading displays the Smartscape `title` instead of the uppercased catalog ID.
- [ ] AC-4: **Navigation** from the Components table to AppScorePage still uses the catalog ID in the URL (`/components/<catalogId>`). Routes and URL params are unchanged.
- [ ] AC-5: If a component has **no title** in Smartscape (null/undefined), the catalog ID is shown as a fallback on all affected pages.
- [ ] AC-6: **Filtering** on the Components page continues to work — the free-text search matches against all fields (including the title and the catalog ID).
- [ ] AC-7: **Filtering** on the Overview page continues to work — the capability search filter is unaffected.

## Data Model

### Event Schema

**Source 1 — `QUERY_CAPABILITY_TEAM_RELATIONS`** (Overview page):
- Smartscape `CATALOG_GROUP` nodes joined with bizevents `juno.scorecard.component.report`.
- **New**: a `lookup` on `CATALOG_COMPONENT` to resolve `title` from the catalog ID.
- The `title` is embedded in the `asset` record's `name` field with a fallback to the catalog ID via `if(isNotNull(...))`.
- The lookup source field is `right.asset` (the `assetId` from the bizevents subquery which holds the catalog ID like `component:default/email`).

**Source 2 — `QUERY_OVERVIEW_ASSETS`** (Components page):
- Bizevents `juno.scorecard.component.report`.
- **New**: a `lookup` on `CATALOG_COMPONENT` to resolve `title` from `assetName` (the `backstage.component` field, which is the catalog ID).
- A new `displayName` field is added: `if(isNotNull(component.title), component.title, else: assetName)`.

### DQL Lookup Pattern (from `component-smartscape-node.ts`)

The existing `src/api/catalog/component-smartscape-node.ts` demonstrates the `CATALOG_COMPONENT` lookup pattern:

```dql
smartscapeNodes "CATALOG_COMPONENT"
  | fieldsAdd catalogId = concat(kind,":",namespace,"/",name)
  | fields catalogId, title
```

This constructs the catalog ID (`kind:namespace/name` e.g. `component:default/email`) from Smartscape node fields and provides the `title`. Use this as an inline lookup subquery in both DQL queries.

### TypeScript Interfaces

```typescript
// src/app/api/fetch-all-assets-from-component-report.ts
// CHANGED: added displayName field
export interface Asset {
  assetId: string;
  assetName: string;      // catalog ID (e.g. "component:dt-platform/edge-service")
  displayName: string;    // Smartscape title, or catalog ID as fallback
  entityOwner: string;
  componentType: string;
}
```

```typescript
// src/app/pages/Components.tsx
// CHANGED: added catalogId field for navigation
export type OverviewColumnData = {
  name: string;           // Smartscape title (fallback: catalog ID) — "Name" column
  catalogId: string;      // catalog ID — used for navigation URL, not displayed as a column
  assetId: string;        // Smartscape entity ID — "Asset ID" column
  owner: string;
  grade: Grade;
  componentType: string;
};
```

```typescript
// src/app/models/datatable-entries.model.ts
// UNCHANGED — IdentifierNode stays the same. The `name` field now carries the title instead of the catalog ID.
export interface IdentifierNode {
  name: string;   // now: Smartscape title (fallback: catalog ID)
  id: string;
  type: 'capability' | 'team' | 'asset' | 'other';
}
```

## UX / UI Specification

### Page Layout

No layout changes. The title replaces the catalog ID in existing UI elements:

| Page | Element | Before | After |
|---|---|---|---|
| Overview | Asset row in "Capability / Owner" column | `component:dt-platform/edge-service` | `Edge Service` |
| Components | "Name" column cell | `component:dt-platform/edge-service` | `Edge Service` |
| AppScorePage | Page heading | `COMPONENT:DT-PLATFORM/EDGE-SERVICE` | `Edge Service` |

### Component Breakdown

No new UI components. Changes are to data layer + utils extraction + minor wiring:

| Component / File | Change |
|---|---|
| `OverviewIdentifierCell.tsx` | No change — already renders `value.name`. |
| `Components.tsx` | Use `mapAssetsToOverviewColumnData` from utils. Navigation: use `buildComponentNavigationUrl(catalogId, name)`. |
| `AppScorePage.tsx` | Read title from `useSearchParams().get('title')`, fall back to URL param `name` via `resolveDisplayName`. Display title in heading (no `.toUpperCase()`). |
| `ComponentsTabContent.tsx` | Replace inline mapping with `mapAssetsToOverviewColumnData` from utils. |
| `components-constants.ts` | No change — column definitions stay the same. |
| `overview-constants.ts` | No change. |

### States

- **Loading**: No change — existing loading states remain.
- **Empty**: No change — existing empty states remain.
- **Error**: No change — existing error handling remains.
- **Data**: Asset names now display Smartscape titles. If title is missing, the catalog ID is shown (no visible degradation).

## API Contracts

### App Function Signatures

No app function changes. The DQL queries run client-side via `queryGrail` (SDK `queryExecutionClient`). The `fetchCapabilityRelations()` and `fetchAllAssets()` functions return updated data shapes from the modified DQL queries.

```typescript
// UNCHANGED signature, updated return data
async function fetchCapabilityRelations(): Promise<CapabilityRelation[]>

// UNCHANGED signature, updated return data (Asset now includes displayName)
async function fetchAllAssets(): Promise<Asset[]>
```

### Cache Strategy

No change. `fetchCapabilityRelations` uses existing `fetchWithCache` with `CACHE_VALID_UNTIL = 'now+5m'` and `CACHE_KEY = 'capability-relations'`. The cache shape changes (title now in the data), but TTL and invalidation are unchanged. The cache will naturally refresh.

## DQL Queries

### Query 1: `QUERY_CAPABILITY_TEAM_RELATIONS` (Overview page)

```dql
smartscapeNodes "CATALOG_GROUP"
| filter entity.type == "team"
| traverse { child_of }, { "CATALOG_GROUP" }, fieldsKeep: { name }
| fieldsAdd team = dt.traverse.history[0][name]
| fieldsRename { name, capabilityId }, { title, capability }
| fields capability, capabilityId, team
| join [
  fetch bizevents, scanLimitGBytes: -1
  | filter event.type == "juno.scorecard.component.report" AND isNotNull(assetId) AND isNotNull(backstage.component)
  | sort timestamp desc
  | fields asset = assetId, assetId = backstage.component, entityOwner, componentType = asset.type
  | dedup asset
  | fieldsAdd team = arrayLast(splitString(entityOwner, "/"))
  | fieldsKeep team, asset, assetId, entityOwner
], on: {team}, kind:outer
| filterOut isNull(right.assetId)
| lookup [smartscapeNodes "CATALOG_COMPONENT"
    | fieldsAdd catalogId = concat(kind,":",namespace,"/",name)
    | fields catalogId, title],
  sourceField: right.asset, lookupField: catalogId, prefix: "component."
| fields capability = if(isNull(capabilityId),
                         record(name = "Not defined", id = "not-defined", type = "other"),
                         else: record(name = capability, id = capabilityId, type = "capability")),
         team = record(name = right.entityOwner, id = team, type = "team"),
         asset = record(name = if(isNotNull(component.title), component.title, else: right.assetId), id = right.asset, type = "asset")
| sort capability, team, asset
```

**Changes from current HEAD:**
- Added `lookup` on `CATALOG_COMPONENT` after `| filterOut isNull(right.assetId)`.
- The lookup matches `right.asset` (the `assetId` from bizevents which is the catalog ID) against `catalogId` (constructed via `concat(kind,":",namespace,"/",name)`).
- Changed `asset` record's `name` from `right.assetId` to `if(isNotNull(component.title), component.title, else: right.assetId)`.

### Query 2: `QUERY_OVERVIEW_ASSETS` (Components page)

```dql
fetch bizevents, scanLimitGBytes: -1
  | filter event.type == "juno.scorecard.component.report" AND isNotNull(assetId) AND isNotNull(backstage.component)
  | sort timestamp desc
  | fields assetId, assetName = backstage.component, entityOwner, componentType = asset.type
  | dedup assetId
  | lookup [smartscapeNodes "CATALOG_COMPONENT"
      | fieldsAdd catalogId = concat(kind,":",namespace,"/",name)
      | fields catalogId, title],
    sourceField: assetId, lookupField: catalogId, prefix: "component."
  | fieldsAdd displayName = if(isNotNull(component.title), component.title, else: assetName)
  | fields assetId, assetName, displayName, entityOwner, componentType
```

**Changes from current HEAD:**
- Added `lookup` on `CATALOG_COMPONENT` after `| dedup assetId`.
- The lookup matches `assetId` (which is `backstage.component`, the catalog ID) against `catalogId`.
- Added `displayName` field: `if(isNotNull(component.title), component.title, else: assetName)`.
- Added `displayName` to the final `fields` output. Kept `assetName` (the catalog ID) for navigation.

## Utils Extraction

### `src/app/utils/componentMappingUtils.ts`

Following the project's utils extraction rule (see copilot-instructions.md §4), extract three pure functions:

```typescript
import { type Asset } from '../api/fetch-all-assets-from-component-report.ts';
import { type OverviewColumnData } from '../pages/Components.tsx';

/**
 * Maps API Asset[] to OverviewColumnData[] for DataTable rendering.
 * - `name` = displayName (Smartscape title, or catalog ID fallback)
 * - `catalogId` = assetName (Backstage catalog ID, for navigation URLs)
 */
export function mapAssetsToOverviewColumnData(apiData: Asset[]): OverviewColumnData[]

/**
 * Builds the navigation URL for a component detail page.
 * Format: `/components/<catalogId>?title=<encodedDisplayName>`
 */
export function buildComponentNavigationUrl(catalogId: string, displayName: string): string

/**
 * Resolves the display name for the AppScorePage heading.
 * Uses the `?title=` query param if present, falls back to the URL path param.
 */
export function resolveDisplayName(titleParam: string | null, fallbackName: string): string
```

### `src/app/utils/componentMappingUtils.spec.ts`

Unit tests for all three functions:

- `mapAssetsToOverviewColumnData`: maps displayName → name, assetName → catalogId, handles empty input, always sets grade to 'A'.
- `buildComponentNavigationUrl`: produces correct URL with encoded title, handles special characters.
- `resolveDisplayName`: returns titleParam when present, falls back to fallbackName when null, returns empty string for empty titleParam.

## Implementation Plan

### Step 1: Modify `QUERY_CAPABILITY_TEAM_RELATIONS` (Overview DQL)

- **Files**: `src/app/api/fetch-assets-for-overview.ts`
- **Changes**:
  1. Add the `lookup` on `CATALOG_COMPONENT` after `| filterOut isNull(right.assetId)`.
  2. Change the `asset` record's `name` to use `if(isNotNull(component.title), component.title, else: right.assetId)`.
- **Verification**: `npm run typecheck`.

### Step 2: Modify `QUERY_OVERVIEW_ASSETS` and `Asset` interface (Components DQL)

- **Files**: `src/app/api/fetch-all-assets-from-component-report.ts`
- **Changes**:
  1. Add the `lookup` on `CATALOG_COMPONENT` after `| dedup assetId`.
  2. Add `displayName` field with fallback logic.
  3. Add `displayName` to the `Asset` interface.
  4. Add `displayName` to the final `fields` output.
- **Verification**: `npm run typecheck`.

### Step 3: Create `componentMappingUtils.ts`

- **Files**: `src/app/utils/componentMappingUtils.ts` (new)
- **Changes**: Create with `mapAssetsToOverviewColumnData`, `buildComponentNavigationUrl`, `resolveDisplayName`.
- **Verification**: `npm run typecheck`.

### Step 4: Update `Components.tsx` data mapping and navigation

- **Files**: `src/app/pages/Components.tsx`
- **Changes**:
  1. Add `catalogId` to `OverviewColumnData` type.
  2. Remove inline `mapToOverviewColumnData()` — import `mapAssetsToOverviewColumnData` from utils.
  3. Change navigation: `<Navigate to={buildComponentNavigationUrl(catalogId, name)} />`.
- **Verification**: `npm run typecheck`. Verify Components page shows titles in "Name" column. Verify clicking a row navigates to the correct `/components/<catalogId>?title=<encoded>` URL.

### Step 5: Update `AppScorePage.tsx` heading

- **Files**: `src/app/pages/AppScorePage.tsx`
- **Changes**:
  1. Add `useSearchParams` import from `react-router-dom`.
  2. Read `searchParams.get('title')` as the display name.
  3. Use `resolveDisplayName(searchParams.get('title'), name)` from utils.
  4. Display the resolved name in the heading — remove `.toUpperCase()`.
- **Verification**: Navigate from Components table → verify heading shows the title. Navigate directly via URL → verify heading falls back to catalog ID.

### Step 6: Update `ComponentsTabContent.tsx`

- **Files**: `src/app/components/overview-aggregation-view/ComponentsTabContent.tsx`
- **Changes**:
  1. Remove inline `tableData` mapping.
  2. Import `mapAssetsToOverviewColumnData` from utils.
  3. Use it in the `useMemo`.
- **Verification**: `npm run typecheck`.

### Step 7: Write/update unit tests

- **Files**: `src/app/utils/componentMappingUtils.spec.ts` (new), `src/app/api/fetch-all-assets-from-component-report.spec.ts`, `src/app/api/fetch-assets-for-overview.spec.ts`, `src/app/utils/filter.utils.spec.ts`
- **Changes**:
  1. `componentMappingUtils.spec.ts`: Full test coverage for all 3 functions.
  2. `fetch-all-assets-from-component-report.spec.ts`: Update existing test mocked records to include `displayName`. Add tests asserting `CATALOG_COMPONENT` lookup and `displayName` in query string.
  3. `fetch-assets-for-overview.spec.ts`: Update existing test mocked asset names (now titles instead of catalog IDs). Add tests asserting `CATALOG_COMPONENT` lookup and `component.title` fallback in query string.
  4. `filter.utils.spec.ts`: Add `OverviewColumnData`-shaped test data with `catalogId`. Add tests for filter matching against `catalogId` and display name.
- **Verification**: `npm run test:unit -- --testPathPattern="(componentMappingUtils|fetch-all-assets|fetch-assets-for-overview|filterUtils)"`.

### Step 8: Update component test mocks and assertions

- **Files**: `component/mocks/grail/components-grail-response.ts`, `component/mocks/components-mocks.ts`, `component/tests/pages/components-page.spec.tsx`
- **Changes**:
  1. `components-grail-response.ts`: Add `displayName` field to every mocked record (human-readable title).
  2. `components-mocks.ts`: Add `catalogId` field to every `OverviewColumnData` entry.
  3. `components-page.spec.tsx`: Update filter test assertions — first column now shows display name (e.g. `'Jira'`), not catalog ID (e.g. `'jira'`).
- **Verification**: `npm run test:ct`.

### Step 9: Run full validation

- **Verification**:
  - `npm run typecheck` — zero errors.
  - `npm run lint` — zero errors.
  - `npm run test:unit` — all unit tests pass.
  - `npm run test:ct` — all component tests pass.

## Edge Cases & Error Handling

| Edge case | Handling |
|---|---|
| Component has no `title` in Smartscape | DQL `if(isNotNull(component.title), ...)` falls back to catalog ID. No client-side logic needed. |
| Component not found in `CATALOG_COMPONENT` | `lookup` produces null for `component.title`. Fallback to catalog ID via `if(isNotNull(...))`. |
| Direct URL navigation to AppScorePage (no `?title=` param) | `searchParams.get('title')` returns null → `resolveDisplayName` returns fallback `name` (catalog ID from URL path). |
| `filterOverAllFields` matches title | The title is in the `name` field of `OverviewColumnData`, which is already searched by `Object.values(entry).join()`. |
| `filterOverAllFields` matches catalogId | `catalogId` is a field on `OverviewColumnData`, so `Object.values(entry).join()` includes it. Users can still search by catalog ID. |

## Out of Scope

| Item | Reason |
|---|---|
| Renaming the URL param from `:name` to `:catalogId` in routing | URLs remain unchanged. Could be a follow-up cleanup. |
| Showing title in browser tab / document title | Not mentioned in requirements. |
| Fetching title on AppScorePage via a separate Grail query | URL query param + fallback is sufficient. A dedicated fetch can be added later when AppScorePage gets real data (currently uses mocked data). |
| Caching title separately | The title is embedded in the existing cached query results. No separate cache needed. |

