refactor(catalog,api-docs): migrate entity table cards to BUI (#33260)

* feat(catalog-react): add EntityDataTable and EntityRelationCard components

Add EntityDataTable (BUI Table wrapper for entity data) and
EntityRelationCard (card shell + data fetching + table) as shared
building blocks for entity relation table cards. Includes BUI column
factories, column presets for common entity types, and a new
translation key for the empty state help link.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(catalog): migrate 8 entity table cards to EntityRelationCard

Rewrite HasComponentsCard, HasResourcesCard, HasSubcomponentsCard,
HasSubdomainsCard, HasSystemsCard, DependsOnComponentsCard,
DependsOnResourcesCard, and DependencyOfComponentsCard as thin
wrappers around EntityRelationCard from catalog-react. Remove variant
and tableOptions props.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(api-docs): migrate 5 entity table cards to EntityRelationCard

Rewrite ConsumedApisCard, ProvidedApisCard, HasApisCard,
ConsumingComponentsCard, and ProvidingComponentsCard to use
EntityRelationCard from catalog-react. Add BUI column presets for
API-specific columns alongside existing MUI presets. Remove variant
and tableOptions props.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: add API reports and changesets for entity table card migration

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(catalog-react,api-docs): refine entity card migration

Use BUI Alert for error states and BUI Link for empty state help
links in EntityRelationCard instead of core-components. Replace MUI
ToggleButton with BUI ButtonIcon for the API definition column.
Add trailing periods to empty state translation messages. Fix tests
asserting on removed external link icon SVG.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(catalog-react): add column sorting to EntityDataTable

Introduce EntityColumnConfig extending ColumnConfig with an optional
sortValue getter. EntityDataTable now uses useTable with a sortFn
that reads sortValue from each column, restoring the per-column
sorting that the old core-components Table provided by default. All
built-in column factories set isSortable and sortValue.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(api-docs): use dynamic entity kind in empty state messages

Restore entity.kind interpolation in ConsumedApisCard, ProvidedApisCard,
and HasApisCard empty state translations instead of hardcoded strings.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(catalog-react): use data length for useTable pageSize

Replace pageSize: Infinity with tableData.length to avoid potential
edge cases in pagination math.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(catalog-react): avoid this binding in column factories

Use columnFactories.createEntityRelationColumn instead of
this.createEntityRelationColumn so the methods work when
destructured.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: update alpha API reports

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(create-app): remove variant prop from migrated entity cards in template

The create-app template EntityPage still passed variant="gridItem"
to cards that no longer accept it.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* test(catalog-react): add tests for EntityDataTable and EntityRelationCard

Cover rendering with data, empty state, error state, and column
sorting for EntityDataTable. Cover title rendering, empty state
with help link, related entity display, and error state for
EntityRelationCard.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(catalog,api-docs): add backwards compatibility for legacy props

Support both old (variant, columns, tableOptions) and new (columnConfig)
props via a discriminated union type. When legacy props are detected the
old MUI-based implementation is used; otherwise the new BUI-based
EntityRelationCard renders. This avoids immediate breaking changes and
provides a migration path. Changesets downgraded from major/minor to
minor/patch accordingly.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(catalog-react,catalog,api-docs): address review feedback

Remove BaseProps types and use inline union in component signatures.
Move EntityDataTable, EntityRelationCard, and related types to alpha
exports. Group column presets and help links into entityColumnPresets
object. Update all consumers and API reports.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

---------

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-17 15:33:45 +01:00
committed by GitHub
parent 8e559b427d
commit c548a0ff86
53 changed files with 2135 additions and 287 deletions
@@ -60,7 +60,7 @@ describe('<ConsumedApisCard />', () => {
relations: [],
};
const { getByText, getByRole, container } = await renderInTestApp(
const { getByText, getByRole } = await renderInTestApp(
<Wrapper>
<EntityProvider entity={entity}>
<ConsumedApisCard />
@@ -76,16 +76,11 @@ describe('<ConsumedApisCard />', () => {
expect(getByText(/Consumed APIs/i)).toBeInTheDocument();
expect(getByText(/does not consume any APIs/i)).toBeInTheDocument();
// Also render external link icon
const externalLink = getByRole('link');
expect(externalLink).toHaveAttribute(
'href',
'https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional',
);
const externalLinkIcon: HTMLElement | null = container.querySelector(
'svg[class*="externalLink"]',
);
expect(externalLink).toContainElement(externalLinkIcon);
});
it('shows consumed APIs', async () => {
@@ -21,7 +21,11 @@ import {
useEntity,
useRelatedEntities,
} from '@backstage/plugin-catalog-react';
import { getApiEntityColumns } from './presets';
import {
EntityRelationCard,
EntityColumnConfig,
} from '@backstage/plugin-catalog-react/alpha';
import { getApiEntityColumns, getApiEntityColumnConfig } from './presets';
import {
CodeSnippet,
InfoCard,
@@ -35,15 +39,34 @@ import {
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { apiDocsTranslationRef } from '../../translation';
/** @public */
export interface ConsumedApisCardProps {
title?: string;
columnConfig?: EntityColumnConfig[];
}
/**
* Props for the legacy MUI-based rendering.
* @deprecated Use {@link ConsumedApisCardProps} instead.
* @public
*/
export const ConsumedApisCard = (props: {
variant?: InfoCardVariants;
export interface ConsumedApisCardLegacyProps {
title?: string;
/** @deprecated Use `columnConfig` instead. */
variant?: InfoCardVariants;
/** @deprecated Use `columnConfig` instead. */
columns?: TableColumn<ApiEntity>[];
/** @deprecated Use `columnConfig` instead. */
tableOptions?: TableOptions;
}) => {
}
function isLegacyProps(
props: ConsumedApisCardProps | ConsumedApisCardLegacyProps,
): props is ConsumedApisCardLegacyProps {
return 'variant' in props || 'columns' in props || 'tableOptions' in props;
}
function ConsumedApisCardLegacy(props: ConsumedApisCardLegacyProps) {
const { t } = useTranslationRef(apiDocsTranslationRef);
const {
variant = 'gridItem',
@@ -102,4 +125,38 @@ export const ConsumedApisCard = (props: {
entities={entities as ApiEntity[]}
/>
);
}
/**
* @public
*/
export const ConsumedApisCard = (
props: ConsumedApisCardProps | ConsumedApisCardLegacyProps,
) => {
const { t } = useTranslationRef(apiDocsTranslationRef);
const { entity } = useEntity();
if (isLegacyProps(props)) {
return <ConsumedApisCardLegacy {...props} />;
}
const {
title = t('consumedApisCard.title'),
columnConfig = getApiEntityColumnConfig(t),
} = props;
return (
<EntityRelationCard
title={title}
relationType={RELATION_CONSUMES_API}
columnConfig={columnConfig}
emptyState={{
message: t('consumedApisCard.emptyContent.title', {
entity: entity.kind.toLocaleLowerCase('en-US'),
}),
helpLink:
'https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional',
}}
/>
);
};
@@ -21,8 +21,12 @@ import {
useEntity,
useRelatedEntities,
} from '@backstage/plugin-catalog-react';
import {
EntityRelationCard,
EntityColumnConfig,
} from '@backstage/plugin-catalog-react/alpha';
import { useMemo } from 'react';
import { createSpecApiTypeColumn } from './presets';
import { createSpecApiTypeColumn, getHasApisColumnConfig } from './presets';
import {
CodeSnippet,
InfoCard,
@@ -36,15 +40,34 @@ import {
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { apiDocsTranslationRef } from '../../translation';
/** @public */
export interface HasApisCardProps {
title?: string;
columnConfig?: EntityColumnConfig[];
}
/**
* Props for the legacy MUI-based rendering.
* @deprecated Use {@link HasApisCardProps} instead.
* @public
*/
export const HasApisCard = (props: {
variant?: InfoCardVariants;
export interface HasApisCardLegacyProps {
title?: string;
/** @deprecated Use `columnConfig` instead. */
variant?: InfoCardVariants;
/** @deprecated Use `columnConfig` instead. */
columns?: TableColumn<ApiEntity>[];
/** @deprecated Use `columnConfig` instead. */
tableOptions?: TableOptions;
}) => {
}
function isLegacyProps(
props: HasApisCardProps | HasApisCardLegacyProps,
): props is HasApisCardLegacyProps {
return 'variant' in props || 'columns' in props || 'tableOptions' in props;
}
function HasApisCardLegacy(props: HasApisCardLegacyProps) {
const { t } = useTranslationRef(apiDocsTranslationRef);
const presetColumns: TableColumn<ApiEntity>[] = useMemo(() => {
return [
@@ -110,4 +133,39 @@ export const HasApisCard = (props: {
entities={entities as ApiEntity[]}
/>
);
}
/**
* @public
*/
export const HasApisCard = (
props: HasApisCardProps | HasApisCardLegacyProps,
) => {
const { t } = useTranslationRef(apiDocsTranslationRef);
const { entity } = useEntity();
if (isLegacyProps(props)) {
return <HasApisCardLegacy {...props} />;
}
const {
title = t('hasApisCard.title'),
columnConfig = getHasApisColumnConfig(t),
} = props;
return (
<EntityRelationCard
title={title}
entityKind="API"
relationType={RELATION_HAS_PART}
columnConfig={columnConfig}
emptyState={{
message: t('hasApisCard.emptyContent.title', {
entity: entity.kind.toLocaleLowerCase('en-US'),
}),
helpLink:
'https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api',
}}
/>
);
};
@@ -60,7 +60,7 @@ describe('<ProvidedApisCard />', () => {
relations: [],
};
const { getByText, getByRole, container } = await renderInTestApp(
const { getByText, getByRole } = await renderInTestApp(
<Wrapper>
<EntityProvider entity={entity}>
<ProvidedApisCard />
@@ -77,16 +77,11 @@ describe('<ProvidedApisCard />', () => {
expect(getByText(/does not provide any APIs/i)).toBeInTheDocument();
expect(getByText(/Learn how to change this/)).toBeInTheDocument();
// Also render external link icon
const externalLink = getByRole('link');
expect(externalLink).toHaveAttribute(
'href',
'https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional',
);
const externalLinkIcon: HTMLElement | null = container.querySelector(
'svg[class*="externalLink"]',
);
expect(externalLink).toContainElement(externalLinkIcon);
});
it('shows consumed APIs', async () => {
@@ -21,7 +21,11 @@ import {
useEntity,
useRelatedEntities,
} from '@backstage/plugin-catalog-react';
import { getApiEntityColumns } from './presets';
import {
EntityRelationCard,
EntityColumnConfig,
} from '@backstage/plugin-catalog-react/alpha';
import { getApiEntityColumns, getApiEntityColumnConfig } from './presets';
import {
CodeSnippet,
InfoCard,
@@ -35,15 +39,34 @@ import {
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { apiDocsTranslationRef } from '../../translation';
/** @public */
export interface ProvidedApisCardProps {
title?: string;
columnConfig?: EntityColumnConfig[];
}
/**
* Props for the legacy MUI-based rendering.
* @deprecated Use {@link ProvidedApisCardProps} instead.
* @public
*/
export const ProvidedApisCard = (props: {
variant?: InfoCardVariants;
export interface ProvidedApisCardLegacyProps {
title?: string;
/** @deprecated Use `columnConfig` instead. */
variant?: InfoCardVariants;
/** @deprecated Use `columnConfig` instead. */
columns?: TableColumn<ApiEntity>[];
/** @deprecated Use `columnConfig` instead. */
tableOptions?: TableOptions;
}) => {
}
function isLegacyProps(
props: ProvidedApisCardProps | ProvidedApisCardLegacyProps,
): props is ProvidedApisCardLegacyProps {
return 'variant' in props || 'columns' in props || 'tableOptions' in props;
}
function ProvidedApisCardLegacy(props: ProvidedApisCardLegacyProps) {
const { t } = useTranslationRef(apiDocsTranslationRef);
const {
variant = 'gridItem',
@@ -102,4 +125,38 @@ export const ProvidedApisCard = (props: {
entities={entities as ApiEntity[]}
/>
);
}
/**
* @public
*/
export const ProvidedApisCard = (
props: ProvidedApisCardProps | ProvidedApisCardLegacyProps,
) => {
const { t } = useTranslationRef(apiDocsTranslationRef);
const { entity } = useEntity();
if (isLegacyProps(props)) {
return <ProvidedApisCardLegacy {...props} />;
}
const {
title = t('providedApisCard.title'),
columnConfig = getApiEntityColumnConfig(t),
} = props;
return (
<EntityRelationCard
title={title}
relationType={RELATION_PROVIDES_API}
columnConfig={columnConfig}
emptyState={{
message: t('providedApisCard.emptyContent.title', {
entity: entity.kind.toLocaleLowerCase('en-US'),
}),
helpLink:
'https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional',
}}
/>
);
};
@@ -15,5 +15,14 @@
*/
export { ConsumedApisCard } from './ConsumedApisCard';
export type {
ConsumedApisCardLegacyProps,
ConsumedApisCardProps,
} from './ConsumedApisCard';
export { HasApisCard } from './HasApisCard';
export type { HasApisCardLegacyProps, HasApisCardProps } from './HasApisCard';
export { ProvidedApisCard } from './ProvidedApisCard';
export type {
ProvidedApisCardLegacyProps,
ProvidedApisCardProps,
} from './ProvidedApisCard';
@@ -17,8 +17,12 @@
import { ApiEntity } from '@backstage/catalog-model';
import { TableColumn } from '@backstage/core-components';
import { EntityTable } from '@backstage/plugin-catalog-react';
import ExtensionIcon from '@material-ui/icons/Extension';
import ToggleButton from '@material-ui/lab/ToggleButton';
import {
EntityColumnConfig,
entityDataTableColumns,
} from '@backstage/plugin-catalog-react/alpha';
import { ButtonIcon, Cell } from '@backstage/ui';
import { RiPuzzleLine } from '@remixicon/react';
import { useState } from 'react';
import { ApiTypeTitle } from '../ApiDefinitionCard';
import { ApiDefinitionDialog } from '../ApiDefinitionDialog';
@@ -28,6 +32,7 @@ import {
} from '@backstage/core-plugin-api/alpha';
import { apiDocsTranslationRef } from '../../translation';
/** @deprecated Use `getApiEntityColumnConfig` instead. */
export function createSpecApiTypeColumn(
t: TranslationFunction<typeof apiDocsTranslationRef.T>,
): TableColumn<ApiEntity> {
@@ -43,13 +48,13 @@ const ApiDefinitionButton = ({ apiEntity }: { apiEntity: ApiEntity }) => {
const { t } = useTranslationRef(apiDocsTranslationRef);
return (
<>
<ToggleButton
<ButtonIcon
aria-label={t('apiDefinitionDialog.toggleButtonAriaLabel')}
onClick={() => setDialogOpen(!dialogOpen)}
value={dialogOpen}
>
<ExtensionIcon />
</ToggleButton>
onPress={() => setDialogOpen(!dialogOpen)}
variant="tertiary"
size="small"
icon={<RiPuzzleLine />}
/>
<ApiDefinitionDialog
entity={apiEntity}
open={dialogOpen}
@@ -68,6 +73,7 @@ function createApiDefinitionColumn(
};
}
/** @deprecated Use `getApiEntityColumnConfig` instead. */
export const getApiEntityColumns = (
t: TranslationFunction<typeof apiDocsTranslationRef.T>,
): TableColumn<ApiEntity>[] => {
@@ -81,3 +87,62 @@ export const getApiEntityColumns = (
createApiDefinitionColumn(t),
];
};
// Column config presets
function createSpecApiTypeColumnConfig(
t: TranslationFunction<typeof apiDocsTranslationRef.T>,
): EntityColumnConfig {
return {
id: 'apiType',
label: t('apiEntityColumns.typeTitle'),
isSortable: true,
cell: entity => (
<Cell>
<ApiTypeTitle apiEntity={entity as unknown as ApiEntity} />
</Cell>
),
sortValue: entity =>
(entity.spec as Record<string, string> | undefined)?.type ?? '',
};
}
function createApiDefinitionColumnConfig(
t: TranslationFunction<typeof apiDocsTranslationRef.T>,
): EntityColumnConfig {
return {
id: 'apiDefinition',
label: t('apiEntityColumns.apiDefinitionTitle'),
cell: entity => (
<Cell>
<ApiDefinitionButton apiEntity={entity as unknown as ApiEntity} />
</Cell>
),
};
}
export function getApiEntityColumnConfig(
t: TranslationFunction<typeof apiDocsTranslationRef.T>,
): EntityColumnConfig[] {
return [
entityDataTableColumns.createEntityRefColumn({ defaultKind: 'API' }),
entityDataTableColumns.createSystemColumn(),
entityDataTableColumns.createOwnerColumn(),
createSpecApiTypeColumnConfig(t),
entityDataTableColumns.createSpecLifecycleColumn(),
entityDataTableColumns.createMetadataDescriptionColumn(),
createApiDefinitionColumnConfig(t),
];
}
export function getHasApisColumnConfig(
t: TranslationFunction<typeof apiDocsTranslationRef.T>,
): EntityColumnConfig[] {
return [
entityDataTableColumns.createEntityRefColumn({ defaultKind: 'API' }),
entityDataTableColumns.createOwnerColumn(),
createSpecApiTypeColumnConfig(t),
entityDataTableColumns.createSpecLifecycleColumn(),
entityDataTableColumns.createMetadataDescriptionColumn(),
];
}
@@ -24,6 +24,11 @@ import {
useEntity,
useRelatedEntities,
} from '@backstage/plugin-catalog-react';
import {
EntityRelationCard,
EntityColumnConfig,
entityColumnPresets,
} from '@backstage/plugin-catalog-react/alpha';
import {
CodeSnippet,
InfoCard,
@@ -37,15 +42,36 @@ import {
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { apiDocsTranslationRef } from '../../translation';
/** @public */
export interface ConsumingComponentsCardProps {
title?: string;
columnConfig?: EntityColumnConfig[];
}
/**
* Props for the legacy MUI-based rendering.
* @deprecated Use {@link ConsumingComponentsCardProps} instead.
* @public
*/
export const ConsumingComponentsCard = (props: {
variant?: InfoCardVariants;
export interface ConsumingComponentsCardLegacyProps {
title?: string;
/** @deprecated Use `columnConfig` instead. */
variant?: InfoCardVariants;
/** @deprecated Use `columnConfig` instead. */
columns?: TableColumn<ComponentEntity>[];
/** @deprecated Use `columnConfig` instead. */
tableOptions?: TableOptions;
}) => {
}
function isLegacyProps(
props: ConsumingComponentsCardProps | ConsumingComponentsCardLegacyProps,
): props is ConsumingComponentsCardLegacyProps {
return 'variant' in props || 'columns' in props || 'tableOptions' in props;
}
function ConsumingComponentsCardLegacy(
props: ConsumingComponentsCardLegacyProps,
) {
const { t } = useTranslationRef(apiDocsTranslationRef);
const {
variant = 'gridItem',
@@ -99,4 +125,35 @@ export const ConsumingComponentsCard = (props: {
entities={entities as ComponentEntity[]}
/>
);
}
/**
* @public
*/
export const ConsumingComponentsCard = (
props: ConsumingComponentsCardProps | ConsumingComponentsCardLegacyProps,
) => {
const { t } = useTranslationRef(apiDocsTranslationRef);
if (isLegacyProps(props)) {
return <ConsumingComponentsCardLegacy {...props} />;
}
const {
title = t('consumingComponentsCard.title'),
columnConfig = entityColumnPresets.component.columns,
} = props;
return (
<EntityRelationCard
title={title}
relationType={RELATION_API_CONSUMED_BY}
columnConfig={columnConfig}
emptyState={{
message: t('consumingComponentsCard.emptyContent.title'),
helpLink:
'https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional',
}}
/>
);
};
@@ -24,6 +24,11 @@ import {
useEntity,
useRelatedEntities,
} from '@backstage/plugin-catalog-react';
import {
EntityRelationCard,
EntityColumnConfig,
entityColumnPresets,
} from '@backstage/plugin-catalog-react/alpha';
import {
CodeSnippet,
InfoCard,
@@ -38,12 +43,35 @@ import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { apiDocsTranslationRef } from '../../translation';
/** @public */
export const ProvidingComponentsCard = (props: {
variant?: InfoCardVariants;
export interface ProvidingComponentsCardProps {
title?: string;
columnConfig?: EntityColumnConfig[];
}
/**
* Props for the legacy MUI-based rendering.
* @deprecated Use {@link ProvidingComponentsCardProps} instead.
* @public
*/
export interface ProvidingComponentsCardLegacyProps {
title?: string;
/** @deprecated Use `columnConfig` instead. */
variant?: InfoCardVariants;
/** @deprecated Use `columnConfig` instead. */
columns?: TableColumn<ComponentEntity>[];
/** @deprecated Use `columnConfig` instead. */
tableOptions?: TableOptions;
}) => {
}
function isLegacyProps(
props: ProvidingComponentsCardProps | ProvidingComponentsCardLegacyProps,
): props is ProvidingComponentsCardLegacyProps {
return 'variant' in props || 'columns' in props || 'tableOptions' in props;
}
function ProvidingComponentsCardLegacy(
props: ProvidingComponentsCardLegacyProps,
) {
const { t } = useTranslationRef(apiDocsTranslationRef);
const {
variant = 'gridItem',
@@ -97,4 +125,33 @@ export const ProvidingComponentsCard = (props: {
entities={entities as ComponentEntity[]}
/>
);
}
/** @public */
export const ProvidingComponentsCard = (
props: ProvidingComponentsCardProps | ProvidingComponentsCardLegacyProps,
) => {
const { t } = useTranslationRef(apiDocsTranslationRef);
if (isLegacyProps(props)) {
return <ProvidingComponentsCardLegacy {...props} />;
}
const {
title = t('providingComponentsCard.title'),
columnConfig = entityColumnPresets.component.columns,
} = props;
return (
<EntityRelationCard
title={title}
relationType={RELATION_API_PROVIDED_BY}
columnConfig={columnConfig}
emptyState={{
message: t('providingComponentsCard.emptyContent.title'),
helpLink:
'https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional',
}}
/>
);
};
@@ -15,4 +15,12 @@
*/
export { ConsumingComponentsCard } from './ConsumingComponentsCard';
export type {
ConsumingComponentsCardLegacyProps,
ConsumingComponentsCardProps,
} from './ConsumingComponentsCard';
export { ProvidingComponentsCard } from './ProvidingComponentsCard';
export type {
ProvidingComponentsCardLegacyProps,
ProvidingComponentsCardProps,
} from './ProvidingComponentsCard';
+1 -1
View File
@@ -90,6 +90,6 @@ export const apiDocsTranslationRef = createTranslationRef({
title: 'No component provides this API.',
},
},
apisCardHelpLinkTitle: 'Learn how to change this',
apisCardHelpLinkTitle: 'Learn how to change this.',
},
});