diff --git a/.changeset/ninety-teachers-tease.md b/.changeset/ninety-teachers-tease.md new file mode 100644 index 0000000000..5cc34b6503 --- /dev/null +++ b/.changeset/ninety-teachers-tease.md @@ -0,0 +1,99 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Introduces a new `EntityCardLayoutBlueprint` that creates custom entity content layouts. + +The layout components receive card elements and can render them as they see fit. Cards is an array of objects with the following properties: + +- element: `JSx.Element`; +- area: `"peek" | "info" | "full" | undefined`; + +### Usage example + +Creating a custom overview tab layout: + +```tsx +import { + EntityCardLayoutProps, + EntityCardLayoutBlueprint, +} from '@backstage/plugin-catalog-react/alpha'; +// ... + +function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { + const { cards } = props; + const classes = useStyles(); + return ( + + + + {cards + .filter(card => card.area === 'info') + .map((card, index) => ( + + {card.element} + + ))} + + + + + {cards + .filter(card => card.area === 'peek') + .map((card, index) => ( + + {card.element} + + ))} + {cards + .filter(card => !card.area || card.area === 'full') + .map((card, index) => ( + + {card.element} + + ))} + + + + ); +} + +export const customEntityContentOverviewStickyLayoutModule = createFrontendModule({ + pluginId: 'app', + extensions: [ + EntityCardLayoutBlueprint.make({ + name: 'sticky', + params: { + // (optional) defaults the `() => false` filter function + defaultFilter: 'kind:template' + loader: async () => StickyEntityContentOverviewLayout, + }, + }), + ], +``` + +Disabling the custom layout: + +```yaml +# app-config.yaml +app: + extensions: + - entity-card-layout:app/sticky: false +``` + +Overriding the custom layout filter: + +```yaml +# app-config.yaml +app: + extensions: + - entity-card-layout:app/sticky: + config: + # This layout will be used only with component entities + filter: 'kind:component' +``` diff --git a/.changeset/real-hounds-mate.md b/.changeset/real-hounds-mate.md new file mode 100644 index 0000000000..31d98601df --- /dev/null +++ b/.changeset/real-hounds-mate.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +The `Overview` entity content now supports custom cards grid layouts. diff --git a/.changeset/selfish-cheetahs-sip.md b/.changeset/selfish-cheetahs-sip.md new file mode 100644 index 0000000000..590ad59d05 --- /dev/null +++ b/.changeset/selfish-cheetahs-sip.md @@ -0,0 +1,35 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Add an optional `area` parameter to `EntityCard` extensions. A card's area value determines where it should be rendered by the entity content layout, as well as its maximum size. + +We are initially supporting only three areas: + +- `peek`: used for cards containing infrastucture information (e.g. last builds, deployments, etc.). +- `info`: used for cards that contain entity metadata (e.g. about, links); +- `full`: Contains information that plugins add to an entity (e.g. PagerDuty incidents and on-call escalation). + +### Usage examples + +Defining a default area when creating a card: + +```diff +const myCard = EntityCardBlueprint.make({ + name: 'myCard', + params: { ++ defaultArea: 'info', + loader: import('./MyCard).then(m => { default: m.MyCard }), + }, +}); +``` + +Changing the card area via `app-config.yaml` file: + +```diff +app: + extensions: ++ - entity-card:myPlugin/myCard: ++ config: ++ area: info +``` diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index 6a757f3cb2..c814d8b8a7 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -26,11 +26,14 @@ app: title: Custom # Entity page cards - - entity-card:catalog/about + - entity-card:catalog/about: + config: + area: info - entity-card:catalog/labels - entity-card:catalog/links: config: filter: kind:component has:links + area: info # - entity-card:linguist/languages - entity-card:catalog-graph/relations: config: @@ -68,6 +71,11 @@ app: # - entity-content:azure-devops/pull-requests # - entity-content:azure-devops/git-tags + - entity-card-layout:app/sticky: + config: + # this layout will apply to entities of kind component + filter: 'kind:component' + # scmAuthExtension: >- # createScmAuthExtension({ # id: 'apis.scmAuth.addons.ghe', diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 1992d5b35f..bb2998fb00 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -44,6 +44,7 @@ import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha'; import { convertLegacyPlugin } from '@backstage/core-compat-api'; import { convertLegacyPageExtension } from '@backstage/core-compat-api'; import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha'; +import { customEntityContentOverviewLayoutModule } from './EntityPages'; /* @@ -131,6 +132,7 @@ const app = createApp({ kubernetesPlugin, notFoundErrorPageModule, customHomePageModule, + customEntityContentOverviewLayoutModule, ...collectedLegacyPlugins, ], /* Handled through config instead */ diff --git a/packages/app-next/src/EntityPages.tsx b/packages/app-next/src/EntityPages.tsx new file mode 100644 index 0000000000..13c6895187 --- /dev/null +++ b/packages/app-next/src/EntityPages.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 React from 'react'; +import Grid from '@material-ui/core/Grid'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { + EntityCardLayoutBlueprint, + EntityCardLayoutProps, +} from '@backstage/plugin-catalog-react/alpha'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + [theme.breakpoints.up('sm')]: { + infoArea: { + order: 1, + }, + card: { + alignSelf: 'stretch', + '& > *': { + height: '100%', + minHeight: 400, + }, + }, + }, +})); + +function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { + const { cards } = props; + const classes = useStyles(); + return ( + + + + {cards + .filter(card => card.area === 'info') + .map((card, index) => ( + + {card.element} + + ))} + + + + + {cards + .filter(card => card.area === 'peek') + .map((card, index) => ( + + {card.element} + + ))} + {cards + .filter(card => !card.area || card.area === 'full') + .map((card, index) => ( + + {card.element} + + ))} + + + + ); +} + +export const customEntityContentOverviewLayoutModule = createFrontendModule({ + pluginId: 'app', + extensions: [ + EntityCardLayoutBlueprint.make({ + name: 'sticky', + params: { + loader: async () => StickyEntityContentOverviewLayout, + }, + }), + ], +}); diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index c1529c3028..32b00d741f 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -108,9 +108,11 @@ const _default: FrontendPlugin< name: 'has-apis'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -131,11 +133,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/definition': ExtensionDefinition<{ @@ -143,9 +153,11 @@ const _default: FrontendPlugin< name: 'definition'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -166,11 +178,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/consumed-apis': ExtensionDefinition<{ @@ -178,9 +198,11 @@ const _default: FrontendPlugin< name: 'consumed-apis'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -201,11 +223,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/provided-apis': ExtensionDefinition<{ @@ -213,9 +243,11 @@ const _default: FrontendPlugin< name: 'provided-apis'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -236,11 +268,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/consuming-components': ExtensionDefinition<{ @@ -248,9 +288,11 @@ const _default: FrontendPlugin< name: 'consuming-components'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -271,11 +313,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/providing-components': ExtensionDefinition<{ @@ -283,9 +333,11 @@ const _default: FrontendPlugin< name: 'providing-components'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -306,11 +358,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-content:api-docs/definition': ExtensionDefinition<{ diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index f4699e9ceb..a7612ec0f5 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -43,6 +43,7 @@ const _default: FrontendPlugin< height: number | undefined; } & { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { height?: number | undefined; @@ -58,6 +59,7 @@ const _default: FrontendPlugin< relationPairs?: [string, string][] | undefined; } & { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -78,6 +80,13 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: { [x: string]: ExtensionInput< @@ -93,6 +102,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'page:catalog-graph': ExtensionDefinition<{ diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 259e6b4d4c..22806998fd 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -12,6 +12,7 @@ import { Entity } from '@backstage/catalog-model'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; +import { default as React_2 } from 'react'; import { ResourcePermission } from '@backstage/plugin-permission-common'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; @@ -98,6 +99,9 @@ export function convertLegacyEntityContentExtension( }, ): ExtensionDefinition; +// @alpha +export const defaultEntityCardAreas: readonly ['peek', 'info', 'full']; + // @alpha export const defaultEntityContentGroups: { documentation: string; @@ -113,6 +117,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -129,13 +134,22 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; dataRefs: { filterFunction: ConfigurableExtensionDataRef< @@ -148,9 +162,81 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ 'catalog.entity-filter-expression', {} >; + area: ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + {} + >; }; }>; +// @alpha (undocumented) +export const EntityCardLayoutBlueprint: ExtensionBlueprint<{ + kind: 'entity-card-layout'; + name: undefined; + params: { + defaultFilter?: string | ((entity: Entity) => boolean) | undefined; + loader: () => Promise< + (props: EntityCardLayoutProps) => React_2.JSX.Element + >; + }; + output: + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + (props: EntityCardLayoutProps) => React_2.JSX.Element, + 'catalog.entity-card-layout.component', + {} + >; + inputs: {}; + config: { + area: string | undefined; + filter: string | undefined; + }; + configInput: { + filter?: string | undefined; + area?: string | undefined; + }; + dataRefs: { + filterFunction: ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + {} + >; + filterExpression: ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + {} + >; + component: ConfigurableExtensionDataRef< + (props: EntityCardLayoutProps) => React_2.JSX.Element, + 'catalog.entity-card-layout.component', + {} + >; + }; +}>; + +// @alpha (undocumented) +export interface EntityCardLayoutProps { + // (undocumented) + cards: Array<{ + area?: (typeof defaultEntityCardAreas)[number]; + element: React_2.JSX.Element; + }>; +} + // @alpha export const EntityContentBlueprint: ExtensionBlueprint<{ kind: 'entity-content'; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 9f012f6055..e534db8575 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -51,6 +51,14 @@ describe('EntityCardBlueprint', () => { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { + "area": { + "enum": [ + "peek", + "info", + "full", + ], + "type": "string", + }, "filter": { "type": "string", }, @@ -83,6 +91,15 @@ describe('EntityCardBlueprint', () => { "optional": [Function], "toString": [Function], }, + { + "$$type": "@backstage/ExtensionDataRef", + "config": { + "optional": true, + }, + "id": "catalog.entity-card-area", + "optional": [Function], + "toString": [Function], + }, ], "override": [Function], "toString": [Function], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 82ff2df44a..1b2fa4edcc 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -22,6 +22,8 @@ import { import { entityFilterFunctionDataRef, entityFilterExpressionDataRef, + entityCardAreaDataRef, + defaultEntityCardAreas, } from './extensionData'; /** @@ -35,25 +37,30 @@ export const EntityCardBlueprint = createExtensionBlueprint({ coreExtensionData.reactElement, entityFilterFunctionDataRef.optional(), entityFilterExpressionDataRef.optional(), + entityCardAreaDataRef.optional(), ], dataRefs: { filterFunction: entityFilterFunctionDataRef, filterExpression: entityFilterExpressionDataRef, + area: entityCardAreaDataRef, }, config: { schema: { filter: z => z.string().optional(), + area: z => z.enum(defaultEntityCardAreas).optional(), }, }, *factory( { loader, filter, + defaultArea, }: { loader: () => Promise; filter?: | typeof entityFilterFunctionDataRef.T | typeof entityFilterExpressionDataRef.T; + defaultArea?: (typeof defaultEntityCardAreas)[number]; }, { node, config }, ) { @@ -66,5 +73,15 @@ export const EntityCardBlueprint = createExtensionBlueprint({ } else if (typeof filter === 'function') { yield entityFilterFunctionDataRef(filter); } + + const area = config.area ?? defaultArea; + if (area) { + yield entityCardAreaDataRef(area); + } else { + // eslint-disable-next-line no-console + console.warn( + `DEPRECATION WARNING: Not providing defaultArea for entity cards is deprecated. Missing from '${node.spec.id}'`, + ); + } }, }); diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx new file mode 100644 index 0000000000..3aaba93813 --- /dev/null +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { + createExtensionDataRef, + createExtensionBlueprint, + ExtensionBoundary, +} from '@backstage/frontend-plugin-api'; +import { + entityFilterExpressionDataRef, + entityFilterFunctionDataRef, + defaultEntityCardAreas, +} from './extensionData'; +import React, { lazy as reactLazy, ComponentProps } from 'react'; + +/** @alpha */ +export interface EntityCardLayoutProps { + cards: Array<{ + area?: (typeof defaultEntityCardAreas)[number]; + element: React.JSX.Element; + }>; +} + +const entityCardLayoutComponentDataRef = createExtensionDataRef< + (props: EntityCardLayoutProps) => React.JSX.Element +>().with({ + id: 'catalog.entity-card-layout.component', +}); + +/** @alpha */ +export const EntityCardLayoutBlueprint = createExtensionBlueprint({ + kind: 'entity-card-layout', + attachTo: { id: 'entity-content:catalog/overview', input: 'layouts' }, + output: [ + entityFilterFunctionDataRef.optional(), + entityFilterExpressionDataRef.optional(), + entityCardLayoutComponentDataRef, + ], + dataRefs: { + filterFunction: entityFilterFunctionDataRef, + filterExpression: entityFilterExpressionDataRef, + component: entityCardLayoutComponentDataRef, + }, + config: { + schema: { + area: z => z.string().optional(), + filter: z => z.string().optional(), + }, + }, + *factory( + { + loader, + defaultFilter, + }: { + defaultFilter?: + | typeof entityFilterFunctionDataRef.T + | typeof entityFilterExpressionDataRef.T; + loader: () => Promise< + (props: EntityCardLayoutProps) => React.JSX.Element + >; + }, + { node, config }, + ) { + if (config.filter) { + yield entityFilterExpressionDataRef(config.filter); + } else if (typeof defaultFilter === 'string') { + yield entityFilterExpressionDataRef(defaultFilter); + } else if (typeof defaultFilter === 'function') { + yield entityFilterFunctionDataRef(defaultFilter); + } + + const ExtensionComponent = reactLazy(() => + loader().then(component => ({ default: component })), + ); + + yield entityCardLayoutComponentDataRef( + (props: ComponentProps) => { + return ( + + + + ); + }, + ); + }, +}); diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index 69bd4d47c3..b76e970dfb 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -50,3 +50,16 @@ export const entityContentGroupDataRef = createExtensionDataRef< >().with({ id: 'catalog.entity-content-group', }); + +/** + * @alpha + * Default entity content groups. + */ +export const defaultEntityCardAreas = ['peek', 'info', 'full'] as const; + +/** @internal */ +export const entityCardAreaDataRef = createExtensionDataRef< + (typeof defaultEntityCardAreas)[number] +>().with({ + id: 'catalog.entity-card-area', +}); diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 1a3b9941f7..bda94c8d04 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -15,4 +15,11 @@ */ export { EntityCardBlueprint } from './EntityCardBlueprint'; export { EntityContentBlueprint } from './EntityContentBlueprint'; -export { defaultEntityContentGroups } from './extensionData'; +export { + EntityCardLayoutBlueprint, + type EntityCardLayoutProps, +} from './EntityCardLauyoutBlueprint'; +export { + defaultEntityContentGroups, + defaultEntityCardAreas, +} from './extensionData'; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index ec53d87a31..c78595b836 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -10,6 +10,7 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityCardLayoutProps } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; @@ -217,9 +218,11 @@ const _default: FrontendPlugin< name: 'about'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -236,11 +239,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/links': ExtensionDefinition<{ @@ -248,9 +259,11 @@ const _default: FrontendPlugin< name: 'links'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -267,11 +280,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/labels': ExtensionDefinition<{ @@ -279,9 +300,11 @@ const _default: FrontendPlugin< name: 'labels'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -298,11 +321,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/depends-on-components': ExtensionDefinition<{ @@ -310,9 +341,11 @@ const _default: FrontendPlugin< name: 'depends-on-components'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -329,11 +362,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/depends-on-resources': ExtensionDefinition<{ @@ -341,9 +382,11 @@ const _default: FrontendPlugin< name: 'depends-on-resources'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -360,11 +403,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-components': ExtensionDefinition<{ @@ -372,9 +423,11 @@ const _default: FrontendPlugin< name: 'has-components'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -391,11 +444,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-resources': ExtensionDefinition<{ @@ -403,9 +464,11 @@ const _default: FrontendPlugin< name: 'has-resources'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -422,11 +485,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-subcomponents': ExtensionDefinition<{ @@ -434,9 +505,11 @@ const _default: FrontendPlugin< name: 'has-subcomponents'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -453,11 +526,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-subdomains': ExtensionDefinition<{ @@ -465,9 +546,11 @@ const _default: FrontendPlugin< name: 'has-subdomains'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -484,11 +567,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-systems': ExtensionDefinition<{ @@ -496,9 +587,11 @@ const _default: FrontendPlugin< name: 'has-systems'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -515,11 +608,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-content:catalog/overview': ExtensionDefinition<{ @@ -572,6 +673,31 @@ const _default: FrontendPlugin< } >; inputs: { + layouts: ExtensionInput< + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + (props: EntityCardLayoutProps) => JSX_2.Element, + 'catalog.entity-card-layout.component', + {} + >, + { + singleton: false; + optional: false; + } + >; cards: ExtensionInput< | ConfigurableExtensionDataRef | ConfigurableExtensionDataRef< @@ -587,6 +713,13 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >, { singleton: false; diff --git a/plugins/catalog/src/alpha/EntityOverviewPage.tsx b/plugins/catalog/src/alpha/EntityOverviewPage.tsx index dbc95f8d10..64f194604f 100644 --- a/plugins/catalog/src/alpha/EntityOverviewPage.tsx +++ b/plugins/catalog/src/alpha/EntityOverviewPage.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import Grid from '@material-ui/core/Grid'; import React from 'react'; @@ -36,8 +35,6 @@ import { interface EntityOverviewPageProps { cards: Array<{ element: React.JSX.Element; - filterFunction?: (entity: Entity) => boolean; - filterExpression?: string; }>; } diff --git a/plugins/catalog/src/alpha/entityContents.test.tsx b/plugins/catalog/src/alpha/entityContents.test.tsx new file mode 100644 index 0000000000..5dfdca9d73 --- /dev/null +++ b/plugins/catalog/src/alpha/entityContents.test.tsx @@ -0,0 +1,237 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 React, { Fragment } from 'react'; +import { screen, waitFor } from '@testing-library/react'; +// import userEvent from '@testing-library/user-event'; +import { + createExtensionTester, + renderInTestApp, + TestApiProvider, +} from '@backstage/frontend-test-utils'; +import { catalogOverviewEntityContent } from './entityContents'; +import { + EntityCardBlueprint, + EntityCardLayoutBlueprint, +} from '@backstage/plugin-catalog-react/alpha'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { + catalogApiRef, + EntityProvider, + MockStarredEntitiesApi, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; + +describe('Overview content', () => { + const entityMock = { + metadata: { + namespace: 'default', + annotations: { + 'backstage.io/managed-by-location': + 'file:/Users/camilal/Workspace/backstage/packages/catalog-model/examples/components/artist-lookup-component.yaml', + 'backstage.io/managed-by-origin-location': + 'file:/Users/camilal/Workspace/backstage/packages/catalog-model/examples/all.yaml', + 'backstage.io/source-template': 'template:default/springboot-template', + 'backstage.io/linguist': + 'https://github.com/backstage/backstage/tree/master/plugins/playlist', + }, + name: 'artist-lookup', + description: 'Artist Lookup', + tags: ['java', 'data'], + links: [ + { + url: 'https://example.com/user', + title: 'Examples Users', + icon: 'user', + }, + { + url: 'https://example.com/group', + title: 'Example Group', + icon: 'group', + }, + { + url: 'https://example.com/cloud', + title: 'Link with Cloud Icon', + icon: 'cloud', + }, + { + url: 'https://example.com/dashboard', + title: 'Dashboard', + icon: 'dashboard', + }, + { url: 'https://example.com/help', title: 'Support', icon: 'help' }, + { url: 'https://example.com/web', title: 'Website', icon: 'web' }, + { + url: 'https://example.com/alert', + title: 'Alerts', + icon: 'alert', + }, + ], + uid: '0dc69d61-4715-4912-bd7d-a0d44b421db0', + etag: 'dcebc518ac79e77356cb34df119a523de51cd522', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'experimental', + owner: 'team-a', + system: 'artist-engagement-portal', + dependsOn: ['resource:artists-db'], + apiConsumedBy: ['component:www-artist'], + }, + relations: [ + { type: 'apiConsumedBy', targetRef: 'component:default/www-artist' }, + { type: 'dependsOn', targetRef: 'resource:default/artists-db' }, + { type: 'ownedBy', targetRef: 'group:default/team-a' }, + { + type: 'partOf', + targetRef: 'system:default/artist-engagement-portal', + }, + ], + }; + + const mockCatalogApi = catalogApiMock.mock({ + getEntityByRef: async () => entityMock, + }); + + const mockStarredEntitiesApi = new MockStarredEntitiesApi(); + + const infoCard = EntityCardBlueprint.make({ + name: 'info-card', + params: { + defaultArea: 'info', + loader: async () =>
Info card
, + }, + }); + + const otherCard = EntityCardBlueprint.make({ + name: 'info', + params: { + loader: async () =>
Other card
, + }, + }); + + const customLayout = EntityCardLayoutBlueprint.make({ + name: 'custom-layout', + params: { + loader: + async () => + ({ cards }) => + ( +
+

Custom layout

+
+ {cards + .filter(card => card.area === 'info') + .map((card, index) => ( + {card.element} + ))} +
+
+ {cards + .filter(card => card.area !== 'info') + .map((card, index) => ( + {card.element} + ))} +
+
+ ), + }, + }); + + it('Should use the default layout', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogOverviewEntityContent), + ); + + await renderInTestApp( + + + {tester.reactElement()} + + , + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + }, + ); + + await waitFor(() => + expect( + screen.queryByRole('heading', { name: /Custom layout/ }), + ).not.toBeInTheDocument(), + ); + }); + + it('Should render a custom layout', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogOverviewEntityContent), + ) + .add(customLayout) + .add(infoCard) + .add(otherCard); + + await renderInTestApp( + + + {tester.reactElement()} + + , + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + }, + ); + + await waitFor(() => + expect( + screen.getByRole('heading', { name: /Custom layout/ }), + ).toBeInTheDocument(), + ); + + await waitFor(() => + expect(screen.getByText('Info card').parentNode).toHaveAttribute( + 'id', + 'info', + ), + ); + await waitFor(() => + expect(screen.getByText('Other card').parentNode).toHaveAttribute( + 'id', + 'other', + ), + ); + }); +}); diff --git a/plugins/catalog/src/alpha/entityContents.tsx b/plugins/catalog/src/alpha/entityContents.tsx index 259b92492c..7ad209ec39 100644 --- a/plugins/catalog/src/alpha/entityContents.tsx +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -14,41 +14,99 @@ * limitations under the License. */ -import React from 'react'; +import React, { lazy as reactLazy } from 'react'; import { coreExtensionData, createExtensionInput, + ExtensionBoundary, } from '@backstage/frontend-plugin-api'; -import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; +import { + EntityCardBlueprint, + EntityContentBlueprint, + EntityCardLayoutBlueprint, + EntityCardLayoutProps, +} from '@backstage/plugin-catalog-react/alpha'; +import { buildFilterFn } from './filter/FilterWrapper'; +import { useEntity } from '@backstage/plugin-catalog-react'; export const catalogOverviewEntityContent = EntityContentBlueprint.makeWithOverrides({ name: 'overview', inputs: { + layouts: createExtensionInput([ + EntityCardLayoutBlueprint.dataRefs.filterFunction.optional(), + EntityCardLayoutBlueprint.dataRefs.filterExpression.optional(), + EntityCardLayoutBlueprint.dataRefs.component, + ]), cards: createExtensionInput([ coreExtensionData.reactElement, EntityContentBlueprint.dataRefs.filterFunction.optional(), EntityContentBlueprint.dataRefs.filterExpression.optional(), + EntityCardBlueprint.dataRefs.area.optional(), ]), }, - factory: (originalFactory, { inputs }) => { + factory: (originalFactory, { node, inputs }) => { return originalFactory({ defaultPath: '/', defaultTitle: 'Overview', - loader: async () => - import('./EntityOverviewPage').then(m => ( - ({ - element: c.get(coreExtensionData.reactElement), - filterFunction: c.get( - EntityContentBlueprint.dataRefs.filterFunction, - ), - filterExpression: c.get( - EntityContentBlueprint.dataRefs.filterExpression, - ), - }))} - /> - )), + loader: async () => { + const LazyDefaultLayoutComponent = reactLazy(() => + import('./EntityOverviewPage').then(m => ({ + default: m.EntityOverviewPage, + })), + ); + + const DefaultLayoutComponent = (props: EntityCardLayoutProps) => { + return ( + + + + ); + }; + + const layouts = [ + ...inputs.layouts.map(layout => ({ + filter: buildFilterFn( + layout.get(EntityCardLayoutBlueprint.dataRefs.filterFunction), + layout.get(EntityCardLayoutBlueprint.dataRefs.filterExpression), + ), + Component: layout.get( + EntityCardLayoutBlueprint.dataRefs.component, + ), + })), + { + filter: buildFilterFn(), + Component: DefaultLayoutComponent, + }, + ]; + + const cards = inputs.cards.map(card => ({ + element: card.get(coreExtensionData.reactElement), + area: card.get(EntityCardBlueprint.dataRefs.area), + filter: buildFilterFn( + card.get(EntityContentBlueprint.dataRefs.filterFunction), + card.get(EntityContentBlueprint.dataRefs.filterExpression), + ), + })); + + const Component = () => { + const { entity } = useEntity(); + + // Use the first layout that matches the entity filter + const layout = layouts.find(l => l.filter(entity)); + if (!layout) { + throw new Error('No layout found for entity'); // Shouldn't be able to happen + } + + return ( + card.filter(entity))} + /> + ); + }; + + return ; + }, }); }, }); diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index 1040b61733..74d2fdf68c 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -22,9 +22,11 @@ const _default: FrontendPlugin< name: 'group-profile'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -45,11 +47,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:org/members-list': ExtensionDefinition<{ @@ -57,9 +67,11 @@ const _default: FrontendPlugin< name: 'members-list'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -80,11 +92,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:org/ownership': ExtensionDefinition<{ @@ -92,9 +112,11 @@ const _default: FrontendPlugin< name: 'ownership'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -115,11 +137,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:org/user-profile': ExtensionDefinition<{ @@ -127,9 +157,11 @@ const _default: FrontendPlugin< name: 'user-profile'; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -150,11 +182,19 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + 'full' | 'info' | 'peek', + 'catalog.entity-card-area', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; }