From a3d93caa81e543930655d9d5d46f846701d72541 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 12 Feb 2025 15:25:24 +0100 Subject: [PATCH] refactor: apply review suggestions Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- .changeset/ninety-teachers-tease.md | 99 ++++++++ .changeset/real-hounds-mate.md | 5 + .changeset/selfish-cheetahs-sip.md | 35 +++ packages/app-next/app-config.yaml | 2 +- packages/app-next/src/EntityPages.tsx | 40 +-- plugins/api-docs/report-alpha.api.md | 48 ++-- plugins/catalog-graph/report-alpha.api.md | 8 +- plugins/catalog-react/report-alpha.api.md | 172 ++++++------- .../blueprints/EntityCardBlueprint.test.tsx | 5 + .../alpha/blueprints/EntityCardBlueprint.ts | 11 +- .../blueprints/EntityCardLauyoutBlueprint.tsx | 99 ++++++++ .../OverviewEntityContentLauyoutBlueprint.tsx | 137 ---------- .../src/alpha/blueprints/extensionData.tsx | 6 +- .../src/alpha/blueprints/index.ts | 11 +- plugins/catalog/report-alpha.api.md | 104 ++++---- .../catalog/src/alpha/EntityOverviewPage.tsx | 3 - .../catalog/src/alpha/entityContents.test.tsx | 237 ++++++++++++++++++ plugins/catalog/src/alpha/entityContents.tsx | 155 ++++++------ plugins/org/report-alpha.api.md | 32 +-- 19 files changed, 761 insertions(+), 448 deletions(-) create mode 100644 .changeset/ninety-teachers-tease.md create mode 100644 .changeset/real-hounds-mate.md create mode 100644 .changeset/selfish-cheetahs-sip.md create mode 100644 plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx delete mode 100644 plugins/catalog-react/src/alpha/blueprints/OverviewEntityContentLauyoutBlueprint.tsx create mode 100644 plugins/catalog/src/alpha/entityContents.test.tsx 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 6b8fb5ad57..c814d8b8a7 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -71,7 +71,7 @@ app: # - entity-content:azure-devops/pull-requests # - entity-content:azure-devops/git-tags - - catalog-overview-entity-content-layout:app/sticky: + - entity-card-layout:app/sticky: config: # this layout will apply to entities of kind component filter: 'kind:component' diff --git a/packages/app-next/src/EntityPages.tsx b/packages/app-next/src/EntityPages.tsx index bee31f2c3c..13c6895187 100644 --- a/packages/app-next/src/EntityPages.tsx +++ b/packages/app-next/src/EntityPages.tsx @@ -14,15 +14,13 @@ * limitations under the License. */ -import React, { useCallback } from 'react'; +import React from 'react'; import Grid from '@material-ui/core/Grid'; import { createFrontendModule } from '@backstage/frontend-plugin-api'; import { - OverviewEntityContentLauyoutBlueprint, - OverviewEntityContentLayoutProps, + EntityCardLayoutBlueprint, + EntityCardLayoutProps, } from '@backstage/plugin-catalog-react/alpha'; -import { Entity } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; import { makeStyles } from '@material-ui/core/styles'; const useStyles = makeStyles(theme => ({ @@ -40,26 +38,9 @@ const useStyles = makeStyles(theme => ({ }, })); -function StickyEntityContentOverviewLayout( - props: OverviewEntityContentLayoutProps, -) { - const { cards, buildFilterFn } = props; - +function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { + const { cards } = props; const classes = useStyles(); - - const { entity } = useEntity(); - - const catalogEntityFilter = useCallback( - (options: { - filterFunction?: (entity: Entity) => boolean; - filterExpression?: string; - }) => { - const { filterFunction, filterExpression } = options; - return buildFilterFn(filterFunction, filterExpression)(entity); - }, - [entity, buildFilterFn], - ); - return ( {cards - .filter(catalogEntityFilter) .filter(card => card.area === 'info') .map((card, index) => ( @@ -87,16 +67,14 @@ function StickyEntityContentOverviewLayout( {cards - .filter(catalogEntityFilter) - .filter(card => card.area === 'glance') + .filter(card => card.area === 'peek') .map((card, index) => ( {card.element} ))} {cards - .filter(catalogEntityFilter) - .filter(card => !card.area || card.area === 'main') + .filter(card => !card.area || card.area === 'full') .map((card, index) => ( {card.element} @@ -111,11 +89,9 @@ function StickyEntityContentOverviewLayout( export const customEntityContentOverviewLayoutModule = createFrontendModule({ pluginId: 'app', extensions: [ - OverviewEntityContentLauyoutBlueprint.make({ + EntityCardLayoutBlueprint.make({ name: 'sticky', params: { - areas: ['info', 'glance', 'main'], - defaultArea: 'main', loader: async () => StickyEntityContentOverviewLayout, }, }), diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index d0af0eb55d..32b00d741f 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -108,11 +108,11 @@ const _default: FrontendPlugin< name: 'has-apis'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -135,7 +135,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -145,7 +145,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/definition': ExtensionDefinition<{ @@ -153,11 +153,11 @@ const _default: FrontendPlugin< name: 'definition'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -180,7 +180,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -190,7 +190,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/consumed-apis': ExtensionDefinition<{ @@ -198,11 +198,11 @@ const _default: FrontendPlugin< name: 'consumed-apis'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -225,7 +225,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -235,7 +235,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/provided-apis': ExtensionDefinition<{ @@ -243,11 +243,11 @@ const _default: FrontendPlugin< name: 'provided-apis'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -270,7 +270,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -280,7 +280,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/consuming-components': ExtensionDefinition<{ @@ -288,11 +288,11 @@ const _default: FrontendPlugin< name: 'consuming-components'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -315,7 +315,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -325,7 +325,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:api-docs/providing-components': ExtensionDefinition<{ @@ -333,11 +333,11 @@ const _default: FrontendPlugin< name: 'providing-components'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -360,7 +360,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -370,7 +370,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | 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 f9f942ba71..a7612ec0f5 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -43,7 +43,7 @@ const _default: FrontendPlugin< height: number | undefined; } & { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { height?: number | undefined; @@ -59,7 +59,7 @@ const _default: FrontendPlugin< relationPairs?: [string, string][] | undefined; } & { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -82,7 +82,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -102,7 +102,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | 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 5d9304566e..22806998fd 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -99,6 +99,9 @@ export function convertLegacyEntityContentExtension( }, ): ExtensionDefinition; +// @alpha +export const defaultEntityCardAreas: readonly ['peek', 'info', 'full']; + // @alpha export const defaultEntityContentGroups: { documentation: string; @@ -114,7 +117,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -133,7 +136,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -142,7 +145,65 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ inputs: {}; config: { filter: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; + }; + configInput: { + filter?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; + }; + dataRefs: { + filterFunction: ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + {} + >; + filterExpression: ConfigurableExtensionDataRef< + string, + '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; @@ -159,10 +220,23 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ 'catalog.entity-filter-expression', {} >; - area: ConfigurableExtensionDataRef; + 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'; @@ -252,98 +326,6 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; -// @alpha (undocumented) -export const OverviewEntityContentLauyoutBlueprint: ExtensionBlueprint<{ - kind: 'catalog-overview-entity-content-layout'; - name: undefined; - params: { - areas: Array; - defaultArea: string; - defaultFilter?: string | ((entity: Entity) => boolean) | undefined; - loader: () => Promise< - (props: OverviewEntityContentLayoutProps) => React_2.JSX.Element - >; - }; - output: - | ConfigurableExtensionDataRef - | ConfigurableExtensionDataRef< - string[], - 'catalog.overview-entity-content.layout.areas', - {} - > - | ConfigurableExtensionDataRef< - (props: OverviewEntityContentLayoutProps) => React_2.JSX.Element, - 'catalog.overview-entity-content.layout.page-component', - {} - > - | ConfigurableExtensionDataRef< - (entity: Entity) => boolean, - 'catalog.overview-entity-content.layout.filter-function', - { - optional: true; - } - > - | ConfigurableExtensionDataRef< - string, - 'catalog.overview-entity-content.layout.filter-expression', - { - optional: true; - } - >; - inputs: {}; - config: { - area: string | undefined; - filter: string | undefined; - }; - configInput: { - filter?: string | undefined; - area?: string | undefined; - }; - dataRefs: { - areas: ConfigurableExtensionDataRef< - string[], - 'catalog.overview-entity-content.layout.areas', - {} - >; - defaultArea: ConfigurableExtensionDataRef< - string, - 'catalog.entity-card-area', - {} - >; - filterFunction: ConfigurableExtensionDataRef< - (entity: Entity) => boolean, - 'catalog.overview-entity-content.layout.filter-function', - {} - >; - filterExpression: ConfigurableExtensionDataRef< - string, - 'catalog.overview-entity-content.layout.filter-expression', - {} - >; - component: ConfigurableExtensionDataRef< - (props: OverviewEntityContentLayoutProps) => React_2.JSX.Element, - 'catalog.overview-entity-content.layout.page-component', - {} - >; - }; -}>; - -// @alpha (undocumented) -export interface OverviewEntityContentLayoutProps { - // (undocumented) - buildFilterFn: ( - filterFunction?: (entity: Entity) => boolean, - filterExpression?: string, - ) => (entity: Entity) => boolean; - // (undocumented) - cards: Array<{ - area?: string; - element: React_2.JSX.Element; - filterFunction?: (entity: Entity) => boolean; - filterExpression?: string; - }>; -} - // @alpha export function useEntityPermission( permission: ResourcePermission<'catalog-entity'>, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index eba76c6e05..e534db8575 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -52,6 +52,11 @@ describe('EntityCardBlueprint', () => { "additionalProperties": false, "properties": { "area": { + "enum": [ + "peek", + "info", + "full", + ], "type": "string", }, "filter": { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 0a935d494e..1b2fa4edcc 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -23,7 +23,7 @@ import { entityFilterFunctionDataRef, entityFilterExpressionDataRef, entityCardAreaDataRef, - defaultEntityCardArea, + defaultEntityCardAreas, } from './extensionData'; /** @@ -47,7 +47,7 @@ export const EntityCardBlueprint = createExtensionBlueprint({ config: { schema: { filter: z => z.string().optional(), - area: z => z.string().optional(), + area: z => z.enum(defaultEntityCardAreas).optional(), }, }, *factory( @@ -60,7 +60,7 @@ export const EntityCardBlueprint = createExtensionBlueprint({ filter?: | typeof entityFilterFunctionDataRef.T | typeof entityFilterExpressionDataRef.T; - defaultArea?: (typeof defaultEntityCardArea)[number]; + defaultArea?: (typeof defaultEntityCardAreas)[number]; }, { node, config }, ) { @@ -77,6 +77,11 @@ export const EntityCardBlueprint = createExtensionBlueprint({ 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/OverviewEntityContentLauyoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/OverviewEntityContentLauyoutBlueprint.tsx deleted file mode 100644 index 2c5a7a510a..0000000000 --- a/plugins/catalog-react/src/alpha/blueprints/OverviewEntityContentLauyoutBlueprint.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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 { Entity } from '@backstage/catalog-model'; -import { - createExtensionDataRef, - createExtensionBlueprint, - ExtensionBoundary, -} from '@backstage/frontend-plugin-api'; -import React, { lazy as reactLazy, ComponentProps } from 'react'; -import { EntityCardBlueprint } from './EntityCardBlueprint'; - -/** @alpha */ -export interface OverviewEntityContentLayoutProps { - buildFilterFn: ( - filterFunction?: (entity: Entity) => boolean, - filterExpression?: string, - ) => (entity: Entity) => boolean; - cards: Array<{ - area?: string; - element: React.JSX.Element; - filterFunction?: (entity: Entity) => boolean; - filterExpression?: string; - }>; -} - -const catalogOverviewEntityContentLayoutAreasDataRef = createExtensionDataRef< - Array ->().with({ - id: 'catalog.overview-entity-content.layout.areas', -}); - -const catalogOverviewEntityContentLayoutFilterFunctionDataRef = - createExtensionDataRef<(entity: Entity) => boolean>().with({ - id: 'catalog.overview-entity-content.layout.filter-function', - }); - -const catalogOverviewEntityContentLayoutFilterExpressionDataRef = - createExtensionDataRef().with({ - id: 'catalog.overview-entity-content.layout.filter-expression', - }); - -const catalogOverviewEntityContentLayoutComponentDataRef = - createExtensionDataRef< - (props: OverviewEntityContentLayoutProps) => React.JSX.Element - >().with({ - id: 'catalog.overview-entity-content.layout.page-component', - }); - -/** @alpha */ -export const OverviewEntityContentLauyoutBlueprint = createExtensionBlueprint({ - kind: 'catalog-overview-entity-content-layout', - attachTo: { id: 'entity-content:catalog/overview', input: 'layouts' }, - output: [ - catalogOverviewEntityContentLayoutAreasDataRef, - EntityCardBlueprint.dataRefs.area, - catalogOverviewEntityContentLayoutFilterFunctionDataRef.optional(), - catalogOverviewEntityContentLayoutFilterExpressionDataRef.optional(), - catalogOverviewEntityContentLayoutComponentDataRef, - ], - dataRefs: { - areas: catalogOverviewEntityContentLayoutAreasDataRef, - defaultArea: EntityCardBlueprint.dataRefs.area, - filterFunction: catalogOverviewEntityContentLayoutFilterFunctionDataRef, - filterExpression: catalogOverviewEntityContentLayoutFilterExpressionDataRef, - component: catalogOverviewEntityContentLayoutComponentDataRef, - }, - config: { - schema: { - area: z => z.string().optional(), - filter: z => z.string().optional(), - }, - }, - *factory( - { - loader, - areas, - defaultArea, - defaultFilter = () => false, // do not use the layout if a default filter is undefined - }: { - areas: Array; - defaultArea: (typeof areas)[number]; // fixed typescript area type - defaultFilter?: - | typeof catalogOverviewEntityContentLayoutFilterFunctionDataRef.T - | typeof catalogOverviewEntityContentLayoutFilterExpressionDataRef.T; - loader: () => Promise< - (props: OverviewEntityContentLayoutProps) => React.JSX.Element - >; - }, - { node, config }, - ) { - yield catalogOverviewEntityContentLayoutAreasDataRef(areas); - - yield EntityCardBlueprint.dataRefs.area(config.area ?? defaultArea); - - if (config.filter) { - yield catalogOverviewEntityContentLayoutFilterExpressionDataRef( - config.filter, - ); - } else if (typeof defaultFilter === 'string') { - yield catalogOverviewEntityContentLayoutFilterExpressionDataRef( - defaultFilter, - ); - } else if (typeof defaultFilter === 'function') { - yield catalogOverviewEntityContentLayoutFilterFunctionDataRef( - defaultFilter, - ); - } - - const ExtensionComponent = reactLazy(() => - loader().then(component => ({ default: component })), - ); - - yield catalogOverviewEntityContentLayoutComponentDataRef( - (props: ComponentProps) => { - return ( - - - - ); - }, - ); - }, -}); diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index 1d714e8164..b76e970dfb 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -55,9 +55,11 @@ export const entityContentGroupDataRef = createExtensionDataRef< * @alpha * Default entity content groups. */ -export const defaultEntityCardArea = ['info', 'glance', 'main'] as const; +export const defaultEntityCardAreas = ['peek', 'info', 'full'] as const; /** @internal */ -export const entityCardAreaDataRef = createExtensionDataRef().with({ +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 744eb729bd..bda94c8d04 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -16,7 +16,10 @@ export { EntityCardBlueprint } from './EntityCardBlueprint'; export { EntityContentBlueprint } from './EntityContentBlueprint'; export { - OverviewEntityContentLauyoutBlueprint, - type OverviewEntityContentLayoutProps, -} from './OverviewEntityContentLauyoutBlueprint'; -export { defaultEntityContentGroups } from './extensionData'; + 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 eaa47846d1..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'; @@ -17,7 +18,6 @@ import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; -import { OverviewEntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha'; import { SearchResultItemExtensionPredicate } from '@backstage/plugin-search-react/alpha'; @@ -218,11 +218,11 @@ const _default: FrontendPlugin< name: 'about'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -241,7 +241,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -251,7 +251,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/links': ExtensionDefinition<{ @@ -259,11 +259,11 @@ const _default: FrontendPlugin< name: 'links'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -282,7 +282,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -292,7 +292,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/labels': ExtensionDefinition<{ @@ -300,11 +300,11 @@ const _default: FrontendPlugin< name: 'labels'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -323,7 +323,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -333,7 +333,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/depends-on-components': ExtensionDefinition<{ @@ -341,11 +341,11 @@ const _default: FrontendPlugin< name: 'depends-on-components'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -364,7 +364,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -374,7 +374,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/depends-on-resources': ExtensionDefinition<{ @@ -382,11 +382,11 @@ const _default: FrontendPlugin< name: 'depends-on-resources'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -405,7 +405,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -415,7 +415,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-components': ExtensionDefinition<{ @@ -423,11 +423,11 @@ const _default: FrontendPlugin< name: 'has-components'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -446,7 +446,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -456,7 +456,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-resources': ExtensionDefinition<{ @@ -464,11 +464,11 @@ const _default: FrontendPlugin< name: 'has-resources'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -487,7 +487,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -497,7 +497,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-subcomponents': ExtensionDefinition<{ @@ -505,11 +505,11 @@ const _default: FrontendPlugin< name: 'has-subcomponents'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -528,7 +528,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -538,7 +538,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-subdomains': ExtensionDefinition<{ @@ -546,11 +546,11 @@ const _default: FrontendPlugin< name: 'has-subdomains'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -569,7 +569,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -579,7 +579,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:catalog/has-systems': ExtensionDefinition<{ @@ -587,11 +587,11 @@ const _default: FrontendPlugin< name: 'has-systems'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -610,7 +610,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -620,7 +620,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-content:catalog/overview': ExtensionDefinition<{ @@ -674,30 +674,24 @@ const _default: FrontendPlugin< >; inputs: { layouts: ExtensionInput< - | ConfigurableExtensionDataRef - | ConfigurableExtensionDataRef< - string[], - 'catalog.overview-entity-content.layout.areas', - {} - > - | ConfigurableExtensionDataRef< - (props: OverviewEntityContentLayoutProps) => JSX_2.Element, - 'catalog.overview-entity-content.layout.page-component', - {} - > | ConfigurableExtensionDataRef< (entity: Entity) => boolean, - 'catalog.overview-entity-content.layout.filter-function', + 'catalog.entity-filter-function', { optional: true; } > | ConfigurableExtensionDataRef< string, - 'catalog.overview-entity-content.layout.filter-expression', + 'catalog.entity-filter-expression', { optional: true; } + > + | ConfigurableExtensionDataRef< + (props: EntityCardLayoutProps) => JSX_2.Element, + 'catalog.entity-card-layout.component', + {} >, { singleton: false; @@ -721,7 +715,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; 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 c3277ab3a8..7ad209ec39 100644 --- a/plugins/catalog/src/alpha/entityContents.tsx +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -14,90 +14,101 @@ * 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 { EntityCardBlueprint, EntityContentBlueprint, - OverviewEntityContentLauyoutBlueprint, + EntityCardLayoutBlueprint, + EntityCardLayoutProps, } from '@backstage/plugin-catalog-react/alpha'; import { buildFilterFn } from './filter/FilterWrapper'; import { useEntity } from '@backstage/plugin-catalog-react'; -const catalogOverviewEntityContent = EntityContentBlueprint.makeWithOverrides({ - name: 'overview', - inputs: { - layouts: createExtensionInput([ - OverviewEntityContentLauyoutBlueprint.dataRefs.areas, - OverviewEntityContentLauyoutBlueprint.dataRefs.defaultArea, - OverviewEntityContentLauyoutBlueprint.dataRefs.filterFunction.optional(), - OverviewEntityContentLauyoutBlueprint.dataRefs.filterExpression.optional(), - OverviewEntityContentLauyoutBlueprint.dataRefs.component, - ]), - cards: createExtensionInput([ - coreExtensionData.reactElement, - EntityContentBlueprint.dataRefs.filterFunction.optional(), - EntityContentBlueprint.dataRefs.filterExpression.optional(), - EntityCardBlueprint.dataRefs.area.optional(), - ]), - }, - factory: (originalFactory, { inputs }) => { - return originalFactory({ - defaultPath: '/', - defaultTitle: 'Overview', - loader: async () => { - const defaultLayout = await import('./EntityOverviewPage').then( - m => m.EntityOverviewPage, - ); - - const Component = () => { - const { entity } = useEntity(); - - // Use the first layout that matches the entity filter - const layout = inputs.layouts.find(l => { - const filterFunction = l.get( - OverviewEntityContentLauyoutBlueprint.dataRefs.filterFunction, - ); - const filterExpression = l.get( - OverviewEntityContentLauyoutBlueprint.dataRefs.filterExpression, - ); - - return buildFilterFn(filterFunction, filterExpression)(entity); - }); - - const Layout = - layout?.get( - OverviewEntityContentLauyoutBlueprint.dataRefs.component, - ) ?? defaultLayout; - - return ( - ({ - element: card.get(coreExtensionData.reactElement), - filterFunction: card.get( - EntityContentBlueprint.dataRefs.filterFunction, - ), - filterExpression: card.get( - EntityContentBlueprint.dataRefs.filterExpression, - ), - area: - card.get(EntityCardBlueprint.dataRefs.area) ?? - layout?.get( - OverviewEntityContentLauyoutBlueprint.dataRefs.defaultArea, - ), - }))} - /> +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, { node, inputs }) => { + return originalFactory({ + defaultPath: '/', + defaultTitle: 'Overview', + loader: async () => { + const LazyDefaultLayoutComponent = reactLazy(() => + import('./EntityOverviewPage').then(m => ({ + default: m.EntityOverviewPage, + })), ); - }; - return ; - }, - }); - }, -}); + 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 ; + }, + }); + }, + }); export default [catalogOverviewEntityContent]; diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index 1c1bb4b4a4..74d2fdf68c 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -22,11 +22,11 @@ const _default: FrontendPlugin< name: 'group-profile'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -49,7 +49,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -59,7 +59,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:org/members-list': ExtensionDefinition<{ @@ -67,11 +67,11 @@ const _default: FrontendPlugin< name: 'members-list'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -94,7 +94,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -104,7 +104,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:org/ownership': ExtensionDefinition<{ @@ -112,11 +112,11 @@ const _default: FrontendPlugin< name: 'ownership'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -139,7 +139,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -149,7 +149,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; 'entity-card:org/user-profile': ExtensionDefinition<{ @@ -157,11 +157,11 @@ const _default: FrontendPlugin< name: 'user-profile'; config: { filter: string | undefined; - area: string | undefined; + area: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + area?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -184,7 +184,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string, + 'full' | 'info' | 'peek', 'catalog.entity-card-area', { optional: true; @@ -194,7 +194,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'main' | 'info' | 'glance' | undefined; + defaultArea?: 'full' | 'info' | 'peek' | undefined; }; }>; }