refactor: apply review suggestions

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2025-02-12 15:25:24 +01:00
parent 761b56605d
commit a3d93caa81
19 changed files with 761 additions and 448 deletions
+99
View File
@@ -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 (
<Grid container spacing={3}>
<Grid
className={classes.infoArea}
xs={12}
md={4}
item
>
<Grid container spacing={3}>
{cards
.filter(card => card.area === 'info')
.map((card, index) => (
<Grid key={index} xs={12} item>
{card.element}
</Grid>
))}
</Grid>
</Grid>
<Grid xs={12} md={8} item>
<Grid container spacing={3}>
{cards
.filter(card => card.area === 'peek')
.map((card, index) => (
<Grid key={index} className={classes.card} xs={12} md={6} item>
{card.element}
</Grid>
))}
{cards
.filter(card => !card.area || card.area === 'full')
.map((card, index) => (
<Grid key={index} className={classes.card} xs={12} md={6} item>
{card.element}
</Grid>
))}
</Grid>
</Grid>
</Grid>
);
}
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'
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
The `Overview` entity content now supports custom cards grid layouts.
+35
View File
@@ -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
```
+1 -1
View File
@@ -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'
+8 -32
View File
@@ -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 (
<Grid container spacing={3}>
<Grid
@@ -75,7 +56,6 @@ function StickyEntityContentOverviewLayout(
>
<Grid container spacing={3}>
{cards
.filter(catalogEntityFilter)
.filter(card => card.area === 'info')
.map((card, index) => (
<Grid key={index} xs={12} item>
@@ -87,16 +67,14 @@ function StickyEntityContentOverviewLayout(
<Grid xs={12} md={8} item>
<Grid container spacing={3}>
{cards
.filter(catalogEntityFilter)
.filter(card => card.area === 'glance')
.filter(card => card.area === 'peek')
.map((card, index) => (
<Grid key={index} className={classes.card} xs={12} md={6} item>
{card.element}
</Grid>
))}
{cards
.filter(catalogEntityFilter)
.filter(card => !card.area || card.area === 'main')
.filter(card => !card.area || card.area === 'full')
.map((card, index) => (
<Grid key={index} className={classes.card} xs={12} md={6} item>
{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,
},
}),
+24 -24
View File
@@ -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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
defaultArea?: 'main' | 'info' | 'glance' | undefined;
defaultArea?: 'full' | 'info' | 'peek' | undefined;
};
}>;
'entity-content:api-docs/definition': ExtensionDefinition<{
+4 -4
View File
@@ -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<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
defaultArea?: 'main' | 'info' | 'glance' | undefined;
defaultArea?: 'full' | 'info' | 'peek' | undefined;
};
}>;
'page:catalog-graph': ExtensionDefinition<{
+77 -95
View File
@@ -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<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
defaultArea?: 'main' | 'info' | 'glance' | undefined;
defaultArea?: 'full' | 'info' | 'peek' | undefined;
};
output:
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
@@ -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<string, 'catalog.entity-card-area', {}>;
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<string>;
defaultArea: string;
defaultFilter?: string | ((entity: Entity) => boolean) | undefined;
loader: () => Promise<
(props: OverviewEntityContentLayoutProps) => React_2.JSX.Element
>;
};
output:
| ConfigurableExtensionDataRef<string, 'catalog.entity-card-area', {}>
| 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'>,
@@ -52,6 +52,11 @@ describe('EntityCardBlueprint', () => {
"additionalProperties": false,
"properties": {
"area": {
"enum": [
"peek",
"info",
"full",
],
"type": "string",
},
"filter": {
@@ -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}'`,
);
}
},
});
@@ -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<typeof ExtensionComponent>) => {
return (
<ExtensionBoundary node={node}>
<ExtensionComponent {...props} />
</ExtensionBoundary>
);
},
);
},
});
@@ -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<string>
>().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<string>().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<string>;
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<typeof ExtensionComponent>) => {
return (
<ExtensionBoundary node={node}>
<ExtensionComponent {...props} />
</ExtensionBoundary>
);
},
);
},
});
@@ -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<string>().with({
export const entityCardAreaDataRef = createExtensionDataRef<
(typeof defaultEntityCardAreas)[number]
>().with({
id: 'catalog.entity-card-area',
});
@@ -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';
+49 -55
View File
@@ -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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<string, 'catalog.entity-card-area', {}>
| 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;
@@ -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;
}>;
}
@@ -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 () => <div>Info card</div>,
},
});
const otherCard = EntityCardBlueprint.make({
name: 'info',
params: {
loader: async () => <div>Other card</div>,
},
});
const customLayout = EntityCardLayoutBlueprint.make({
name: 'custom-layout',
params: {
loader:
async () =>
({ cards }) =>
(
<div>
<h3>Custom layout</h3>
<div id="info">
{cards
.filter(card => card.area === 'info')
.map((card, index) => (
<Fragment key={index}>{card.element}</Fragment>
))}
</div>
<div id="other">
{cards
.filter(card => card.area !== 'info')
.map((card, index) => (
<Fragment key={index}>{card.element}</Fragment>
))}
</div>
</div>
),
},
});
it('Should use the default layout', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogOverviewEntityContent),
);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
<EntityProvider entity={entityMock}>
{tester.reactElement()}
</EntityProvider>
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
<EntityProvider entity={entityMock}>
{tester.reactElement()}
</EntityProvider>
</TestApiProvider>,
{
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',
),
);
});
});
+83 -72
View File
@@ -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 (
<Layout
buildFilterFn={buildFilterFn}
cards={inputs.cards.map(card => ({
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 <Component />;
},
});
},
});
const DefaultLayoutComponent = (props: EntityCardLayoutProps) => {
return (
<ExtensionBoundary node={node}>
<LazyDefaultLayoutComponent {...props} />
</ExtensionBoundary>
);
};
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 (
<layout.Component
cards={cards.filter(card => card.filter(entity))}
/>
);
};
return <Component />;
},
});
},
});
export default [catalogOverviewEntityContent];
+16 -16
View File
@@ -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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
defaultArea?: 'main' | 'info' | 'glance' | undefined;
defaultArea?: 'full' | 'info' | 'peek' | undefined;
};
}>;
}