diff --git a/docs/dynamic-plugins/frontend-plugin-wiring.md b/docs/dynamic-plugins/frontend-plugin-wiring.md index d35b080a39..34070171ef 100644 --- a/docs/dynamic-plugins/frontend-plugin-wiring.md +++ b/docs/dynamic-plugins/frontend-plugin-wiring.md @@ -746,3 +746,189 @@ The required options mirror the [AppTheme](https://backstage.io/docs/reference/c - `variant` Whether the theme is `light` or `dark`, can only be one of these values. - `icon` a string reference to a system or [app icon](#extend-internal-library-of-available-icons) - `importName` name of the exported theme provider function, the function signature should match `({ children }: { children: ReactNode }): React.JSX.Element` + +## Customizing Catalog Table Columns + +The catalog table displayed on the `CatalogIndexPage` can be customized to show, hide, or add columns. This allows platform engineers to remove unnecessary columns and surface custom entity metadata directly in the catalog list view. + +### Configuration + +Column customization is configured in the `app-config.yaml` under the `catalog.table.columns` section: + +```yaml +# app-config.yaml +catalog: + table: + columns: + # Option 1: Include only specific columns (replaces defaults) + include: + - name + - owner + - type + - lifecycle + + # Option 2: Exclude specific columns from defaults + exclude: + - createdAt + + # Add custom columns based on entity metadata + custom: + - title: "Security Tier" + field: "metadata.annotations['custom/security-tier']" + width: 150 + sortable: true + defaultValue: "N/A" +``` + +### Available Built-in Column IDs + +The following column IDs can be used in `include` or `exclude` lists: + +| Column ID | Description | Maps to | +| ------------- | ---------------------------------------- | ---------------------------------------------------- | +| `name` | Entity name with link | `CatalogTable.columns.createNameColumn()` | +| `owner` | Entity owner | `CatalogTable.columns.createOwnerColumn()` | +| `type` | Entity spec.type | `CatalogTable.columns.createSpecTypeColumn()` | +| `lifecycle` | Entity spec.lifecycle | `CatalogTable.columns.createSpecLifecycleColumn()` | +| `description` | Entity metadata.description | `CatalogTable.columns.createMetadataDescriptionColumn()` | +| `tags` | Entity metadata.tags | `CatalogTable.columns.createTagsColumn()` | +| `namespace` | Entity metadata.namespace | `CatalogTable.columns.createNamespaceColumn()` | +| `system` | Entity spec.system | `CatalogTable.columns.createSystemColumn()` | +| `createdAt` | Created timestamp from annotations | Custom RHDH column | + +### Configuration Options + +#### `include` (array of strings) + +When specified, only the listed columns will be displayed. This completely replaces the default column set. + +```yaml +catalog: + table: + columns: + include: + - name + - owner + - type +``` + +#### `exclude` (array of strings) + +When specified, the listed columns will be removed from the default set. Cannot be used together with `include` (if both are specified, `include` takes precedence). + +```yaml +catalog: + table: + columns: + exclude: + - createdAt + - description +``` + +#### `custom` (array of custom column definitions) + +Define additional columns based on entity metadata fields. Custom columns are appended after the built-in columns. + +Each custom column supports the following properties: + +| Property | Required | Type | Description | +| -------------- | -------- | ----------------- | --------------------------------------------------------------------------- | +| `title` | Yes | string | The column header text | +| `field` | Yes | string | The entity field path (e.g., `spec.team`, `metadata.annotations['key']`) | +| `width` | No | number | Column width in pixels | +| `sortable` | No | boolean | Whether the column is sortable (default: `true`) | +| `defaultValue` | No | string | Value to display when the field is empty or undefined | +| `kind` | No | string or array | Entity kind(s) to apply this column to (e.g., `API`, `['Component', 'API']`) | + +### Field Path Syntax + +The `field` property supports accessing nested entity properties using dot notation: + +- Simple paths: `spec.type`, `metadata.name` +- Annotation paths: `metadata.annotations['backstage.io/techdocs-ref']` +- Custom annotations: `metadata.annotations['company.com/cost-center']` + +### Examples + +#### Hide the "Created At" column + +```yaml +catalog: + table: + columns: + exclude: + - createdAt +``` + +#### Minimal view with only essential columns + +```yaml +catalog: + table: + columns: + include: + - name + - owner + - type +``` + +#### Add a custom metadata column + +```yaml +catalog: + table: + columns: + custom: + - title: "Cost Center" + field: "metadata.annotations['company.com/cost-center']" + sortable: true + defaultValue: "Unknown" +``` + +#### Kind-specific columns + +Show a column only for specific entity kinds: + +```yaml +catalog: + table: + columns: + custom: + - title: "API Version" + field: "spec.definition.version" + kind: API + - title: "Team" + field: "spec.team" + kind: + - Component + - Resource +``` + +#### Complete customization example + +```yaml +catalog: + table: + columns: + exclude: + - createdAt + - description + custom: + - title: "Security Tier" + field: "metadata.annotations['custom/security-tier']" + width: 120 + sortable: true + defaultValue: "Not Set" + - title: "Cost Center" + field: "metadata.labels['cost-center']" + sortable: true +``` + +### Default Behavior + +When no `catalog.table.columns` configuration is provided: + +1. All default columns from Backstage's `CatalogTable.defaultColumnsFunc()` are displayed +2. The "Created At" column is included (for backward compatibility with RHDH) + +This ensures existing RHDH deployments continue to work without any configuration changes. diff --git a/e2e-tests/playwright/e2e/catalog-column-customization.spec.ts b/e2e-tests/playwright/e2e/catalog-column-customization.spec.ts new file mode 100644 index 0000000000..80144a50f5 --- /dev/null +++ b/e2e-tests/playwright/e2e/catalog-column-customization.spec.ts @@ -0,0 +1,188 @@ +import { Page, expect, test } from "@playwright/test"; +import { UIhelper } from "../utils/ui-helper"; +import { Common, setupBrowser } from "../utils/common"; +import { + getTranslations, + getCurrentLanguage, +} from "../e2e/localization/locale"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +let page: Page; + +/** + * E2E tests for catalog table column customization feature. + * + * These tests verify that platform engineers can configure catalog table columns + * via app-config.yaml to: + * - Hide default columns (like "Created At") + * - Add custom columns based on entity metadata + * - Sort columns correctly + * + * Note: These tests require specific app-config.yaml configuration to be applied + * before running. The configuration should include catalog.table.columns settings. + * + * Example configuration for testing: + * ```yaml + * catalog: + * table: + * columns: + * exclude: + * - createdAt + * custom: + * - title: "Security Tier" + * field: "metadata.annotations['custom/security-tier']" + * sortable: true + * ``` + */ +test.describe("Catalog Column Customization", () => { + let uiHelper: UIhelper; + let common: Common; + + test.beforeAll(async ({ browser }, testInfo) => { + test.info().annotations.push({ + type: "component", + description: "catalog-columns", + }); + + page = (await setupBrowser(browser, testInfo)).page; + + common = new Common(page); + uiHelper = new UIhelper(page); + + await common.loginAsGuest(); + }); + + test.beforeEach(async () => { + await uiHelper.openSidebar(t["rhdh"][lang]["menuItem.catalog"]); + await uiHelper.verifyHeading( + t["catalog"][lang]["indexPage.title"].replace("{{orgName}}", "My Org"), + ); + await uiHelper.openCatalogSidebar("Component"); + }); + + test("Default columns are visible in catalog table", async () => { + // Verify that default columns are present + await uiHelper.verifyColumnHeading(["Name"], true); + await uiHelper.verifyColumnHeading(["Owner"], true); + await uiHelper.verifyColumnHeading(["Type"], true); + }); + + test("Created At column is visible by default", async () => { + // By default (without exclude configuration), Created At should be visible + await uiHelper.verifyColumnHeading(["Created At"], true); + }); + + test("Column headers are clickable for sorting", async () => { + // Test that the Name column can be clicked for sorting + const nameColumn = page.getByRole("columnheader", { + name: "Name", + exact: true, + }); + + await expect(nameColumn).toBeVisible(); + await nameColumn.click(); + + // After clicking, the column should show a sort indicator + // The sort functionality is handled by material-table internally + await expect(nameColumn).toBeVisible(); + }); + + test("Catalog table displays entity data correctly", async () => { + // Search for a known entity to verify data is displayed + await uiHelper.searchInputPlaceholder("backstage"); + + // Verify that search results are displayed + const tableRows = page + .getByRole("row") + .filter({ has: page.getByRole("cell") }); + await expect(tableRows.first()).toBeVisible(); + }); + + /** + * Test for custom column visibility. + * This test should be run with app-config that includes custom columns. + * + * Required configuration: + * ```yaml + * catalog: + * table: + * columns: + * custom: + * - title: "Security Tier" + * field: "metadata.annotations['custom/security-tier']" + * ``` + */ + test.skip("Custom columns from configuration are visible", async () => { + // This test is skipped by default as it requires specific configuration + // When running with custom column configuration, enable this test + + // Verify custom column header is present + await uiHelper.verifyColumnHeading(["Security Tier"], true); + }); + + /** + * Test for excluded columns. + * This test should be run with app-config that excludes the Created At column. + * + * Required configuration: + * ```yaml + * catalog: + * table: + * columns: + * exclude: + * - createdAt + * ``` + */ + test.skip("Excluded columns are not visible", async () => { + // This test is skipped by default as it requires specific configuration + // When running with exclude configuration, enable this test + + // Verify Created At column is NOT present when excluded + const createdAtColumn = page.getByRole("columnheader", { + name: "Created At", + exact: true, + }); + await expect(createdAtColumn).not.toBeVisible(); + }); + + /** + * Test for include-only mode. + * This test should be run with app-config that specifies only certain columns. + * + * Required configuration: + * ```yaml + * catalog: + * table: + * columns: + * include: + * - name + * - owner + * ``` + */ + test.skip("Only included columns are visible in include mode", async () => { + // This test is skipped by default as it requires specific configuration + + // Verify only the included columns are present + await uiHelper.verifyColumnHeading(["Name"], true); + await uiHelper.verifyColumnHeading(["Owner"], true); + + // Verify other columns are NOT present + const typeColumn = page.getByRole("columnheader", { + name: "Type", + exact: true, + }); + await expect(typeColumn).not.toBeVisible(); + + const createdAtColumn = page.getByRole("columnheader", { + name: "Created At", + exact: true, + }); + await expect(createdAtColumn).not.toBeVisible(); + }); + + test.afterAll(async () => { + await page.close(); + }); +}); diff --git a/packages/app/config.d.ts b/packages/app/config.d.ts index 78eb4828d9..aa05804779 100644 --- a/packages/app/config.d.ts +++ b/packages/app/config.d.ts @@ -342,4 +342,71 @@ export interface Config { */ persistence: 'browser' | 'database'; }; + + /** + * Catalog configuration options + * @deepVisibility frontend + */ + catalog?: { + /** + * Configuration for the catalog table display + * @deepVisibility frontend + */ + table?: { + /** + * Column configuration for the catalog table + * @deepVisibility frontend + */ + columns?: { + /** + * List of column IDs to include. When specified, only these columns will be shown. + * Available built-in columns: name, owner, type, lifecycle, description, tags, namespace, system, createdAt + * @visibility frontend + */ + include?: string[]; + /** + * List of column IDs to exclude from the default columns. + * Available built-in columns: name, owner, type, lifecycle, description, tags, namespace, system, createdAt + * @visibility frontend + */ + exclude?: string[]; + /** + * Custom columns to add to the catalog table + * @deepVisibility frontend + */ + custom?: Array<{ + /** + * The column header title + * @visibility frontend + */ + title: string; + /** + * The entity field path to display (e.g., "metadata.annotations['custom/field']" or "spec.team") + * @visibility frontend + */ + field: string; + /** + * Optional column width in pixels + * @visibility frontend + */ + width?: number; + /** + * Whether the column should be sortable + * @visibility frontend + */ + sortable?: boolean; + /** + * Default value to display when the field is empty or undefined + * @visibility frontend + */ + defaultValue?: string; + /** + * Optional entity kind(s) to apply this column to. If not specified, applies to all kinds. + * @visibility frontend + */ + kind?: string | string[]; + }>; + }; + }; + }; } diff --git a/packages/app/src/components/AppBase/AppBase.tsx b/packages/app/src/components/AppBase/AppBase.tsx index ba6e4330cd..bcd622d696 100644 --- a/packages/app/src/components/AppBase/AppBase.tsx +++ b/packages/app/src/components/AppBase/AppBase.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; import { Route } from 'react-router-dom'; import { FlatRoutes } from '@backstage/core-app-api'; @@ -8,13 +8,7 @@ import { OAuthRequestDialog, } from '@backstage/core-components'; import { ApiExplorerPage } from '@backstage/plugin-api-docs'; -import { - CatalogEntityPage, - CatalogIndexPage, - CatalogTable, - CatalogTableColumnsFunc, - CatalogTableRow, -} from '@backstage/plugin-catalog'; +import { CatalogEntityPage, CatalogIndexPage } from '@backstage/plugin-catalog'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha'; import { CatalogGraphPage } from '@backstage/plugin-catalog-graph'; import { CatalogImportPage } from '@backstage/plugin-catalog-import'; @@ -26,6 +20,7 @@ import { UserSettingsPage } from '@backstage/plugin-user-settings'; import DynamicRootContext from '@red-hat-developer-hub/plugin-utils'; +import { createCatalogColumnsFunc } from '../../utils/catalog'; import getDynamicRootConfig from '../../utils/dynamicUI/getDynamicRootConfig'; import { entityPage } from '../catalog/EntityPage'; import { CustomCatalogFilters } from '../catalog/filters/CustomCatalogFilters'; @@ -45,38 +40,14 @@ const AppBase = () => { entityTabOverrides, providerSettings, scaffolderFieldExtensions, + catalogTableColumns, } = useContext(DynamicRootContext); - const myCustomColumnsFunc: CatalogTableColumnsFunc = entityListContext => [ - ...CatalogTable.defaultColumnsFunc(entityListContext), - { - title: 'Created At', - customSort: (a: CatalogTableRow, b: CatalogTableRow): any => { - const timestampA = - a.entity.metadata.annotations?.['backstage.io/createdAt']; - const timestampB = - b.entity.metadata.annotations?.['backstage.io/createdAt']; - - const dateA = - timestampA && timestampA !== '' - ? new Date(timestampA).toISOString() - : ''; - const dateB = - timestampB && timestampB !== '' - ? new Date(timestampB).toISOString() - : ''; - - return dateA.localeCompare(dateB); - }, - render: (data: CatalogTableRow) => { - const date = - data.entity.metadata.annotations?.['backstage.io/createdAt']; - return !isNaN(new Date(date || '') as any) - ? data.entity.metadata.annotations?.['backstage.io/createdAt'] - : ''; - }, - }, - ]; + // Create catalog columns function based on configuration + const catalogColumnsFunc = useMemo( + () => createCatalogColumnsFunc(catalogTableColumns), + [catalogTableColumns], + ); return ( @@ -93,7 +64,7 @@ const AppBase = () => { element={ } /> } diff --git a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx index 4e394dccf8..48a4693d5d 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx @@ -25,6 +25,7 @@ import { import { useThemes } from '@red-hat-developer-hub/backstage-plugin-theme'; import { I18nextTranslationApi } from '@red-hat-developer-hub/backstage-plugin-translations'; import DynamicRootContext, { + CatalogColumnConfig, ComponentRegistry, DynamicRootConfig, EntityTabOverrides, @@ -101,6 +102,7 @@ export const DynamicRoot = ({ staticPluginStore = {}, scalprumConfig, translationConfig, + catalogColumnConfig, baseUrl, }: { afterInit: () => Promise<{ default: React.ComponentType }>; @@ -111,6 +113,7 @@ export const DynamicRoot = ({ scalprumConfig: AppsConfig; baseUrl: string; translationConfig?: TranslationConfig; + catalogColumnConfig?: CatalogColumnConfig; }) => { const app = useRef(); const [ChildComponent, setChildComponent] = useState< @@ -627,6 +630,7 @@ export const DynamicRoot = ({ scaffolderFieldExtensionComponents; dynamicRootConfig.techdocsAddons = techdocsAddonComponents; dynamicRootConfig.translationResources = allTranslationResources; + dynamicRootConfig.catalogTableColumns = catalogColumnConfig; // make the dynamic UI configuration available to DynamicRootContext consumers setComponentRegistry({ AppProvider: app.current.getProvider(), @@ -639,6 +643,7 @@ export const DynamicRoot = ({ scaffolderFieldExtensions: scaffolderFieldExtensionComponents, techdocsAddons: techdocsAddonComponents, translationRefs, + catalogTableColumns: catalogColumnConfig, }); afterInit().then(({ default: Component }) => { setChildComponent(() => Component); @@ -653,6 +658,7 @@ export const DynamicRoot = ({ staticPluginStore, themes, translationConfig, + catalogColumnConfig, baseUrl, ]); diff --git a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx index 8bbe9b100c..5c4165f983 100644 --- a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx +++ b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx @@ -11,6 +11,7 @@ import { AppsConfig } from '@scalprum/core'; import { ScalprumProvider } from '@scalprum/react-core'; import { TranslationConfig } from '../../types/types'; +import { CatalogColumnConfig } from '../../utils/catalog'; import { DynamicPluginConfig } from '../../utils/dynamicUI/extractDynamicConfig'; import overrideBaseUrlConfigs from '../../utils/dynamicUI/overrideBaseUrlConfigs'; import { DynamicRoot, StaticPlugins } from './DynamicRoot'; @@ -38,6 +39,7 @@ const ScalprumRoot = ({ baseUrl: string; scalprumConfig?: AppsConfig; translationConfig?: TranslationConfig; + catalogColumnConfig?: CatalogColumnConfig; }> => { const appConfig = overrideBaseUrlConfigs(await defaultConfigLoader()); const reader = ConfigReader.fromConfigs([ @@ -48,6 +50,7 @@ const ScalprumRoot = ({ const dynamicPlugins = reader.get('dynamicPlugins'); let scalprumConfig: AppsConfig = {}; let translationConfig: TranslationConfig | undefined = undefined; + let catalogColumnConfig: CatalogColumnConfig | undefined = undefined; try { scalprumConfig = await fetch(`${baseUrl}/api/scalprum/plugins`).then( r => r.json(), @@ -67,19 +70,35 @@ const ScalprumRoot = ({ ${JSON.stringify(err)}`, ); } + try { + catalogColumnConfig = reader.getOptional( + 'catalog.table.columns', + ); + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + `Failed to load catalog column configuration: ${JSON.stringify(err)}`, + ); + } return { dynamicPlugins, baseUrl, scalprumConfig, translationConfig, + catalogColumnConfig, }; }, ); if (loading && !value) { return ; } - const { dynamicPlugins, baseUrl, scalprumConfig, translationConfig } = - value || {}; + const { + dynamicPlugins, + baseUrl, + scalprumConfig, + translationConfig, + catalogColumnConfig, + } = value || {}; const scalprumApiHolder = { dynamicRootConfig: { dynamicRoutes: [], @@ -90,6 +109,7 @@ const ScalprumRoot = ({ techdocsAddons: [], providerSettings: [], translationRefs: [], + catalogTableColumns: catalogColumnConfig, } as DynamicRootConfig, }; return ( @@ -117,6 +137,7 @@ const ScalprumRoot = ({ staticPluginStore={plugins} scalprumConfig={scalprumConfig ?? {}} translationConfig={translationConfig} + catalogColumnConfig={catalogColumnConfig} baseUrl={baseUrl as string} /> diff --git a/packages/app/src/utils/catalog/createCatalogColumns.test.ts b/packages/app/src/utils/catalog/createCatalogColumns.test.ts new file mode 100644 index 0000000000..28fba6f3fb --- /dev/null +++ b/packages/app/src/utils/catalog/createCatalogColumns.test.ts @@ -0,0 +1,436 @@ +import { Entity } from '@backstage/catalog-model'; +import { CatalogTableRow } from '@backstage/plugin-catalog'; +import { EntityListContextProps } from '@backstage/plugin-catalog-react'; + +import { + CatalogColumnConfig, + createCatalogColumnsFunc, + createCreatedAtColumn, + createCustomColumn, + CustomColumnConfig, +} from './createCatalogColumns'; + +// Mock CatalogTable +jest.mock('@backstage/plugin-catalog', () => ({ + CatalogTable: { + defaultColumnsFunc: jest.fn(() => [ + { title: 'Name', field: 'entity.metadata.name' }, + { title: 'Owner', field: 'entity.spec.owner' }, + { title: 'Type', field: 'entity.spec.type' }, + ]), + columns: { + createNameColumn: jest.fn(() => ({ + title: 'Name', + field: 'entity.metadata.name', + })), + createOwnerColumn: jest.fn(() => ({ + title: 'Owner', + field: 'entity.spec.owner', + })), + createSpecTypeColumn: jest.fn(() => ({ + title: 'Type', + field: 'entity.spec.type', + })), + createSpecLifecycleColumn: jest.fn(() => ({ + title: 'Lifecycle', + field: 'entity.spec.lifecycle', + })), + createMetadataDescriptionColumn: jest.fn(() => ({ + title: 'Description', + field: 'entity.metadata.description', + })), + createTagsColumn: jest.fn(() => ({ + title: 'Tags', + field: 'entity.metadata.tags', + })), + createNamespaceColumn: jest.fn(() => ({ + title: 'Namespace', + field: 'entity.metadata.namespace', + })), + createSystemColumn: jest.fn(() => ({ + title: 'System', + field: 'entity.spec.system', + })), + }, + }, +})); + +const createMockEntity = (overrides: Partial = {}): Entity => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-entity', + annotations: { + 'backstage.io/createdAt': '2024-01-15T10:30:00Z', + 'custom/security-tier': 'tier-1', + }, + ...overrides.metadata, + }, + spec: { + type: 'service', + owner: 'team-a', + team: 'Platform', + ...overrides.spec, + }, + ...overrides, +}); + +const createMockCatalogTableRow = ( + entityOverrides: Partial = {}, +): CatalogTableRow => ({ + entity: createMockEntity(entityOverrides), + resolved: { + name: 'test-entity', + entityRef: 'component:default/test-entity', + ownedByRelationsTitle: 'team-a', + ownedByRelations: [], + partOfSystemRelationTitle: undefined, + partOfSystemRelations: [], + }, +}); + +const createMockEntityListContext = ( + kindValue?: string, +): EntityListContextProps => + ({ + filters: { + kind: kindValue ? { value: kindValue } : undefined, + }, + }) as unknown as EntityListContextProps; + +describe('createCreatedAtColumn', () => { + it('creates a column with correct title', () => { + const column = createCreatedAtColumn(); + expect(column.title).toBe('Created At'); + }); + + it('renders the createdAt annotation value', () => { + const column = createCreatedAtColumn(); + const row = createMockCatalogTableRow(); + const result = column.render?.(row, 'row'); + expect(result).toBe('2024-01-15T10:30:00Z'); + }); + + it('renders empty string for invalid date', () => { + const column = createCreatedAtColumn(); + const row = createMockCatalogTableRow({ + metadata: { + name: 'test', + annotations: { + 'backstage.io/createdAt': 'invalid-date', + }, + }, + }); + const result = column.render?.(row, 'row'); + expect(result).toBe(''); + }); + + it('renders empty string when annotation is missing', () => { + const column = createCreatedAtColumn(); + const row = createMockCatalogTableRow({ + metadata: { name: 'test', annotations: {} }, + }); + const result = column.render?.(row, 'row'); + expect(result).toBe(''); + }); + + it('sorts by date correctly', () => { + const column = createCreatedAtColumn(); + const rowA = createMockCatalogTableRow({ + metadata: { + name: 'a', + annotations: { 'backstage.io/createdAt': '2024-01-01T00:00:00Z' }, + }, + }); + const rowB = createMockCatalogTableRow({ + metadata: { + name: 'b', + annotations: { 'backstage.io/createdAt': '2024-06-01T00:00:00Z' }, + }, + }); + const result = column.customSort?.(rowA, rowB, 'row'); + expect(result).toBeLessThan(0); + }); +}); + +describe('createCustomColumn', () => { + it('creates a column with correct title', () => { + const config: CustomColumnConfig = { + title: 'Security Tier', + field: "metadata.annotations['custom/security-tier']", + }; + const column = createCustomColumn(config); + expect(column.title).toBe('Security Tier'); + }); + + it('renders value from annotation path', () => { + const config: CustomColumnConfig = { + title: 'Security Tier', + field: "metadata.annotations['custom/security-tier']", + }; + const column = createCustomColumn(config); + const row = createMockCatalogTableRow(); + const result = column.render?.(row, 'row'); + expect(result).toBe('tier-1'); + }); + + it('renders value from spec path', () => { + const config: CustomColumnConfig = { + title: 'Team', + field: 'spec.team', + }; + const column = createCustomColumn(config); + const row = createMockCatalogTableRow(); + const result = column.render?.(row, 'row'); + expect(result).toBe('Platform'); + }); + + it('renders default value when field is missing', () => { + const config: CustomColumnConfig = { + title: 'Missing Field', + field: 'spec.nonexistent', + defaultValue: 'N/A', + }; + const column = createCustomColumn(config); + const row = createMockCatalogTableRow(); + const result = column.render?.(row, 'row'); + expect(result).toBe('N/A'); + }); + + it('renders empty string when field is missing and no default', () => { + const config: CustomColumnConfig = { + title: 'Missing Field', + field: 'spec.nonexistent', + }; + const column = createCustomColumn(config); + const row = createMockCatalogTableRow(); + const result = column.render?.(row, 'row'); + expect(result).toBe(''); + }); + + it('sets width when provided', () => { + const config: CustomColumnConfig = { + title: 'Test', + field: 'spec.team', + width: 150, + }; + const column = createCustomColumn(config); + expect(column.width).toBe('150px'); + }); + + it('is sortable by default', () => { + const config: CustomColumnConfig = { + title: 'Test', + field: 'spec.team', + }; + const column = createCustomColumn(config); + expect(column.customSort).toBeDefined(); + expect(column.sorting).toBeUndefined(); + }); + + it('disables sorting when sortable is false', () => { + const config: CustomColumnConfig = { + title: 'Test', + field: 'spec.team', + sortable: false, + }; + const column = createCustomColumn(config); + expect(column.sorting).toBe(false); + }); +}); + +describe('createCatalogColumnsFunc', () => { + it('returns default columns plus createdAt when no config provided', () => { + const columnsFunc = createCatalogColumnsFunc(); + const columns = columnsFunc(createMockEntityListContext()); + + // Should have 3 default columns + 1 createdAt column + expect(columns).toHaveLength(4); + expect(columns[3].title).toBe('Created At'); + }); + + it('returns default columns plus createdAt when empty config provided', () => { + const config: CatalogColumnConfig = {}; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + expect(columns).toHaveLength(4); + expect(columns[3].title).toBe('Created At'); + }); + + describe('include mode', () => { + it('only includes specified columns', () => { + const config: CatalogColumnConfig = { + include: ['name', 'owner'], + }; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + expect(columns).toHaveLength(2); + expect(columns[0].title).toBe('Name'); + expect(columns[1].title).toBe('Owner'); + }); + + it('includes createdAt when specified', () => { + const config: CatalogColumnConfig = { + include: ['name', 'createdAt'], + }; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + expect(columns).toHaveLength(2); + expect(columns[0].title).toBe('Name'); + expect(columns[1].title).toBe('Created At'); + }); + + it('ignores unknown column IDs', () => { + const config: CatalogColumnConfig = { + include: ['name', 'unknownColumn'], + }; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + expect(columns).toHaveLength(1); + expect(columns[0].title).toBe('Name'); + }); + }); + + describe('exclude mode', () => { + it('excludes specified columns', () => { + const config: CatalogColumnConfig = { + exclude: ['createdAt'], + }; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + // Should have 3 default columns, createdAt excluded + expect(columns).toHaveLength(3); + expect(columns.find(c => c.title === 'Created At')).toBeUndefined(); + }); + + it('excludes multiple columns', () => { + const config: CatalogColumnConfig = { + exclude: ['createdAt', 'owner'], + }; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + expect(columns).toHaveLength(2); + expect(columns.find(c => c.title === 'Created At')).toBeUndefined(); + expect(columns.find(c => c.title === 'Owner')).toBeUndefined(); + }); + }); + + describe('custom columns', () => { + it('adds custom columns to the end', () => { + const config: CatalogColumnConfig = { + custom: [ + { + title: 'Security Tier', + field: "metadata.annotations['custom/security-tier']", + }, + ], + }; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + // 3 default + 1 createdAt + 1 custom + expect(columns).toHaveLength(5); + expect(columns[4].title).toBe('Security Tier'); + }); + + it('filters custom columns by kind', () => { + const config: CatalogColumnConfig = { + custom: [ + { + title: 'API Version', + field: 'spec.definition.version', + kind: 'API', + }, + { + title: 'Team', + field: 'spec.team', + }, + ], + }; + + // When viewing Components, API-specific column should not appear + const columnsFunc = createCatalogColumnsFunc(config); + const componentColumns = columnsFunc( + createMockEntityListContext('component'), + ); + expect( + componentColumns.find(c => c.title === 'API Version'), + ).toBeUndefined(); + expect(componentColumns.find(c => c.title === 'Team')).toBeDefined(); + + // When viewing APIs, API-specific column should appear + const apiColumns = columnsFunc(createMockEntityListContext('api')); + expect(apiColumns.find(c => c.title === 'API Version')).toBeDefined(); + expect(apiColumns.find(c => c.title === 'Team')).toBeDefined(); + }); + + it('supports multiple kinds for a single column', () => { + const config: CatalogColumnConfig = { + custom: [ + { + title: 'Shared Column', + field: 'spec.shared', + kind: ['Component', 'API'], + }, + ], + }; + const columnsFunc = createCatalogColumnsFunc(config); + + const componentColumns = columnsFunc( + createMockEntityListContext('component'), + ); + expect( + componentColumns.find(c => c.title === 'Shared Column'), + ).toBeDefined(); + + const apiColumns = columnsFunc(createMockEntityListContext('api')); + expect(apiColumns.find(c => c.title === 'Shared Column')).toBeDefined(); + + const systemColumns = columnsFunc(createMockEntityListContext('system')); + expect( + systemColumns.find(c => c.title === 'Shared Column'), + ).toBeUndefined(); + }); + }); + + describe('combined configuration', () => { + it('excludes columns and adds custom columns', () => { + const config: CatalogColumnConfig = { + exclude: ['createdAt', 'type'], + custom: [ + { + title: 'Custom Field', + field: 'spec.custom', + }, + ], + }; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + // 3 default - 1 type excluded + 1 custom (createdAt also excluded) + expect(columns).toHaveLength(3); + expect(columns.find(c => c.title === 'Type')).toBeUndefined(); + expect(columns.find(c => c.title === 'Created At')).toBeUndefined(); + expect(columns.find(c => c.title === 'Custom Field')).toBeDefined(); + }); + + it('include takes precedence over exclude', () => { + const config: CatalogColumnConfig = { + include: ['name', 'owner'], + exclude: ['name'], // This should be ignored when include is specified + }; + const columnsFunc = createCatalogColumnsFunc(config); + const columns = columnsFunc(createMockEntityListContext()); + + expect(columns).toHaveLength(2); + expect(columns[0].title).toBe('Name'); + expect(columns[1].title).toBe('Owner'); + }); + }); +}); diff --git a/packages/app/src/utils/catalog/createCatalogColumns.ts b/packages/app/src/utils/catalog/createCatalogColumns.ts new file mode 100644 index 0000000000..bbdee4f3be --- /dev/null +++ b/packages/app/src/utils/catalog/createCatalogColumns.ts @@ -0,0 +1,230 @@ +import { TableColumn } from '@backstage/core-components'; +import { + CatalogTable, + CatalogTableColumnsFunc, + CatalogTableRow, +} from '@backstage/plugin-catalog'; +import { EntityListContextProps } from '@backstage/plugin-catalog-react'; + +import { + CatalogColumnConfig, + CustomColumnConfig, +} from '@red-hat-developer-hub/plugin-utils'; +import get from 'lodash/get'; + +// Re-export types for convenience +export type { CatalogColumnConfig, CustomColumnConfig }; + +/** + * Mapping of column IDs to their builder functions + * Using a more flexible type to accommodate different Backstage column builder signatures + */ +type ColumnBuilderFunc = (options?: unknown) => TableColumn; + +type ColumnBuilderMap = { + [key: string]: ColumnBuilderFunc; +}; + +/** + * Built-in column ID mappings to CatalogTable.columns builder functions + */ +const BUILTIN_COLUMN_BUILDERS: ColumnBuilderMap = { + name: CatalogTable.columns.createNameColumn as ColumnBuilderFunc, + owner: CatalogTable.columns.createOwnerColumn as ColumnBuilderFunc, + type: CatalogTable.columns.createSpecTypeColumn as ColumnBuilderFunc, + lifecycle: CatalogTable.columns + .createSpecLifecycleColumn as ColumnBuilderFunc, + description: CatalogTable.columns + .createMetadataDescriptionColumn as ColumnBuilderFunc, + tags: CatalogTable.columns.createTagsColumn as ColumnBuilderFunc, + namespace: CatalogTable.columns.createNamespaceColumn as ColumnBuilderFunc, + system: CatalogTable.columns.createSystemColumn as ColumnBuilderFunc, +}; + +/** + * Creates the "Created At" column based on entity annotations + */ +export function createCreatedAtColumn(): TableColumn { + return { + title: 'Created At', + field: 'entity.metadata.annotations.backstage.io/createdAt', + customSort: (a: CatalogTableRow, b: CatalogTableRow): number => { + const timestampA = + a.entity.metadata.annotations?.['backstage.io/createdAt']; + const timestampB = + b.entity.metadata.annotations?.['backstage.io/createdAt']; + + const dateA = + timestampA && timestampA !== '' + ? new Date(timestampA).toISOString() + : ''; + const dateB = + timestampB && timestampB !== '' + ? new Date(timestampB).toISOString() + : ''; + + return dateA.localeCompare(dateB); + }, + render: (data: CatalogTableRow) => { + const date = data.entity.metadata.annotations?.['backstage.io/createdAt']; + return !isNaN(new Date(date || '').getTime()) ? date || '' : ''; + }, + }; +} + +/** + * Safely extracts a value from an entity using a field path. + * Uses lodash get for reliable nested property access. + * + * Supports paths like: + * - "metadata.name" + * - "metadata.annotations['custom/field']" + * - "spec.team" + * + * @param entity - The catalog entity to extract the value from + * @param fieldPath - The dot-notation path to the field (supports bracket notation) + * @returns The string value at the path, or undefined if not found + */ +function getEntityFieldValue( + entity: CatalogTableRow['entity'], + fieldPath: string, +): string | undefined { + const value = get(entity, fieldPath); + + if (value === null || value === undefined) { + return undefined; + } + + return String(value); +} + +/** + * Creates a custom column from configuration + */ +export function createCustomColumn( + config: CustomColumnConfig, +): TableColumn { + const column: TableColumn = { + title: config.title, + field: `entity.${config.field}`, + render: (data: CatalogTableRow) => { + const value = getEntityFieldValue(data.entity, config.field); + return value ?? config.defaultValue ?? ''; + }, + }; + + if (config.width) { + column.width = `${config.width}px`; + } + + if (config.sortable !== false) { + column.customSort = (a: CatalogTableRow, b: CatalogTableRow): number => { + const valueA = getEntityFieldValue(a.entity, config.field) ?? ''; + const valueB = getEntityFieldValue(b.entity, config.field) ?? ''; + return valueA.localeCompare(valueB); + }; + } else { + column.sorting = false; + } + + return column; +} + +/** + * Checks if a custom column should be applied to the current entity kind + */ +function shouldApplyCustomColumn( + config: CustomColumnConfig, + currentKind?: string, +): boolean { + if (!config.kind) { + return true; + } + + if (!currentKind) { + return true; + } + + const kinds = Array.isArray(config.kind) ? config.kind : [config.kind]; + return kinds.some(k => k.toLowerCase() === currentKind.toLowerCase()); +} + +/** + * Gets the column ID from a built-in column + */ +function getColumnId(column: TableColumn): string | undefined { + // Map known column titles to their IDs + const titleToIdMap: Record = { + Name: 'name', + Owner: 'owner', + Type: 'type', + Lifecycle: 'lifecycle', + Description: 'description', + Tags: 'tags', + Namespace: 'namespace', + System: 'system', + 'Created At': 'createdAt', + }; + + return titleToIdMap[column.title as string]; +} + +/** + * Creates the columns function based on configuration + */ +export function createCatalogColumnsFunc( + config?: CatalogColumnConfig, +): CatalogTableColumnsFunc { + return (entityListContext: EntityListContextProps) => { + const currentKind = entityListContext.filters.kind?.value; + + // If no config provided, use default behavior with Created At column + if (!config || (!config.include && !config.exclude && !config.custom)) { + return [ + ...CatalogTable.defaultColumnsFunc(entityListContext), + createCreatedAtColumn(), + ]; + } + + let columns: TableColumn[]; + + // Handle include mode - only show specified columns + if (config.include && config.include.length > 0) { + columns = []; + for (const columnId of config.include) { + if (columnId === 'createdAt') { + columns.push(createCreatedAtColumn()); + } else if (BUILTIN_COLUMN_BUILDERS[columnId]) { + columns.push(BUILTIN_COLUMN_BUILDERS[columnId]()); + } + } + } else { + // Start with default columns + Created At + columns = [ + ...CatalogTable.defaultColumnsFunc(entityListContext), + createCreatedAtColumn(), + ]; + + // Handle exclude mode - remove specified columns + if (config.exclude && config.exclude.length > 0) { + columns = columns.filter(column => { + const columnId = getColumnId(column); + return !columnId || !config.exclude?.includes(columnId); + }); + } + } + + // Add custom columns + if (config.custom && config.custom.length > 0) { + for (const customConfig of config.custom) { + if (shouldApplyCustomColumn(customConfig, currentKind)) { + columns.push(createCustomColumn(customConfig)); + } + } + } + + return columns; + }; +} + +export default createCatalogColumnsFunc; diff --git a/packages/app/src/utils/catalog/index.ts b/packages/app/src/utils/catalog/index.ts new file mode 100644 index 0000000000..e572bd3d24 --- /dev/null +++ b/packages/app/src/utils/catalog/index.ts @@ -0,0 +1,9 @@ +export { + createCatalogColumnsFunc, + createCreatedAtColumn, + createCustomColumn, +} from './createCatalogColumns'; +export type { + CatalogColumnConfig, + CustomColumnConfig, +} from './createCatalogColumns'; diff --git a/packages/plugin-utils/src/context/DynamicRootContext.tsx b/packages/plugin-utils/src/context/DynamicRootContext.tsx index 46a90f94ed..a0a6e48f5d 100644 --- a/packages/plugin-utils/src/context/DynamicRootContext.tsx +++ b/packages/plugin-utils/src/context/DynamicRootContext.tsx @@ -13,4 +13,5 @@ export const DynamicRootContext = createContext({ scaffolderFieldExtensions: [], techdocsAddons: [], translationRefs: [], + catalogTableColumns: undefined, }); diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index f5afff0ac3..9248f3d3f5 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -106,6 +106,36 @@ export type ProviderSetting = { provider: string; }; +/** + * Configuration for a custom catalog table column + */ +export type CustomColumnConfig = { + /** The column header title */ + title: string; + /** The entity field path to display (e.g., "metadata.annotations['custom/field']" or "spec.team") */ + field: string; + /** Optional column width in pixels */ + width?: number; + /** Whether the column should be sortable */ + sortable?: boolean; + /** Default value to display when the field is empty or undefined */ + defaultValue?: string; + /** Optional entity kind(s) to apply this column to. If not specified, applies to all kinds. */ + kind?: string | string[]; +}; + +/** + * Configuration for catalog table columns + */ +export type CatalogColumnConfig = { + /** List of column IDs to include. When specified, only these columns will be shown. */ + include?: string[]; + /** List of column IDs to exclude from the default columns. */ + exclude?: string[]; + /** Custom columns to add to the catalog table */ + custom?: CustomColumnConfig[]; +}; + export type DynamicRootConfig = { dynamicRoutes: ResolvedDynamicRoute[]; entityTabOverrides: EntityTabOverrides; @@ -115,6 +145,7 @@ export type DynamicRootConfig = { scaffolderFieldExtensions: ScaffolderFieldExtension[]; techdocsAddons: TechdocsAddon[]; translationRefs: TranslationRef[]; + catalogTableColumns?: CatalogColumnConfig; }; export type ComponentRegistry = {