Merge pull request #28758 from backstage/camilaibs/nfs-entity-page-layout
[NFS] Entity Page Content Layouts
This commit is contained in:
@@ -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'
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': minor
|
||||
---
|
||||
|
||||
The `Overview` entity content now supports custom cards grid layouts.
|
||||
@@ -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
|
||||
```
|
||||
@@ -26,11 +26,14 @@ app:
|
||||
title: Custom
|
||||
|
||||
# Entity page cards
|
||||
- entity-card:catalog/about
|
||||
- entity-card:catalog/about:
|
||||
config:
|
||||
area: info
|
||||
- entity-card:catalog/labels
|
||||
- entity-card:catalog/links:
|
||||
config:
|
||||
filter: kind:component has:links
|
||||
area: info
|
||||
# - entity-card:linguist/languages
|
||||
- entity-card:catalog-graph/relations:
|
||||
config:
|
||||
@@ -68,6 +71,11 @@ app:
|
||||
# - entity-content:azure-devops/pull-requests
|
||||
# - entity-content:azure-devops/git-tags
|
||||
|
||||
- entity-card-layout:app/sticky:
|
||||
config:
|
||||
# this layout will apply to entities of kind component
|
||||
filter: 'kind:component'
|
||||
|
||||
# scmAuthExtension: >-
|
||||
# createScmAuthExtension({
|
||||
# id: 'apis.scmAuth.addons.ghe',
|
||||
|
||||
@@ -44,6 +44,7 @@ import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha';
|
||||
import { convertLegacyPlugin } from '@backstage/core-compat-api';
|
||||
import { convertLegacyPageExtension } from '@backstage/core-compat-api';
|
||||
import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
|
||||
import { customEntityContentOverviewLayoutModule } from './EntityPages';
|
||||
|
||||
/*
|
||||
|
||||
@@ -131,6 +132,7 @@ const app = createApp({
|
||||
kubernetesPlugin,
|
||||
notFoundErrorPageModule,
|
||||
customHomePageModule,
|
||||
customEntityContentOverviewLayoutModule,
|
||||
...collectedLegacyPlugins,
|
||||
],
|
||||
/* Handled through config instead */
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import { createFrontendModule } from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
EntityCardLayoutBlueprint,
|
||||
EntityCardLayoutProps,
|
||||
} from '@backstage/plugin-catalog-react/alpha';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
infoArea: {
|
||||
order: 1,
|
||||
},
|
||||
card: {
|
||||
alignSelf: 'stretch',
|
||||
'& > *': {
|
||||
height: '100%',
|
||||
minHeight: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) {
|
||||
const { cards } = props;
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid
|
||||
className={classes.infoArea}
|
||||
xs={12}
|
||||
md={4}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: -16,
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
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 customEntityContentOverviewLayoutModule = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
EntityCardLayoutBlueprint.make({
|
||||
name: 'sticky',
|
||||
params: {
|
||||
loader: async () => StickyEntityContentOverviewLayout,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -108,9 +108,11 @@ const _default: FrontendPlugin<
|
||||
name: 'has-apis';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -131,11 +133,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:api-docs/definition': ExtensionDefinition<{
|
||||
@@ -143,9 +153,11 @@ const _default: FrontendPlugin<
|
||||
name: 'definition';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -166,11 +178,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:api-docs/consumed-apis': ExtensionDefinition<{
|
||||
@@ -178,9 +198,11 @@ const _default: FrontendPlugin<
|
||||
name: 'consumed-apis';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -201,11 +223,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:api-docs/provided-apis': ExtensionDefinition<{
|
||||
@@ -213,9 +243,11 @@ const _default: FrontendPlugin<
|
||||
name: 'provided-apis';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -236,11 +268,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:api-docs/consuming-components': ExtensionDefinition<{
|
||||
@@ -248,9 +288,11 @@ const _default: FrontendPlugin<
|
||||
name: 'consuming-components';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -271,11 +313,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:api-docs/providing-components': ExtensionDefinition<{
|
||||
@@ -283,9 +333,11 @@ const _default: FrontendPlugin<
|
||||
name: 'providing-components';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -306,11 +358,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-content:api-docs/definition': ExtensionDefinition<{
|
||||
|
||||
@@ -43,6 +43,7 @@ const _default: FrontendPlugin<
|
||||
height: number | undefined;
|
||||
} & {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
height?: number | undefined;
|
||||
@@ -58,6 +59,7 @@ const _default: FrontendPlugin<
|
||||
relationPairs?: [string, string][] | undefined;
|
||||
} & {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -78,6 +80,13 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {
|
||||
[x: string]: ExtensionInput<
|
||||
@@ -93,6 +102,7 @@ const _default: FrontendPlugin<
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'page:catalog-graph': ExtensionDefinition<{
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ResourcePermission } from '@backstage/plugin-permission-common';
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
@@ -98,6 +99,9 @@ export function convertLegacyEntityContentExtension(
|
||||
},
|
||||
): ExtensionDefinition;
|
||||
|
||||
// @alpha
|
||||
export const defaultEntityCardAreas: readonly ['peek', 'info', 'full'];
|
||||
|
||||
// @alpha
|
||||
export const defaultEntityContentGroups: {
|
||||
documentation: string;
|
||||
@@ -113,6 +117,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -129,13 +134,22 @@ export const EntityCardBlueprint: ExtensionBlueprint<{
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
dataRefs: {
|
||||
filterFunction: ConfigurableExtensionDataRef<
|
||||
@@ -148,9 +162,81 @@ export const EntityCardBlueprint: ExtensionBlueprint<{
|
||||
'catalog.entity-filter-expression',
|
||||
{}
|
||||
>;
|
||||
area: ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{}
|
||||
>;
|
||||
};
|
||||
}>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const EntityCardLayoutBlueprint: ExtensionBlueprint<{
|
||||
kind: 'entity-card-layout';
|
||||
name: undefined;
|
||||
params: {
|
||||
defaultFilter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
loader: () => Promise<
|
||||
(props: EntityCardLayoutProps) => React_2.JSX.Element
|
||||
>;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
(entity: Entity) => boolean,
|
||||
'catalog.entity-filter-function',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
string,
|
||||
'catalog.entity-filter-expression',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
(props: EntityCardLayoutProps) => React_2.JSX.Element,
|
||||
'catalog.entity-card-layout.component',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
config: {
|
||||
area: string | undefined;
|
||||
filter: string | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: string | undefined;
|
||||
};
|
||||
dataRefs: {
|
||||
filterFunction: ConfigurableExtensionDataRef<
|
||||
(entity: Entity) => boolean,
|
||||
'catalog.entity-filter-function',
|
||||
{}
|
||||
>;
|
||||
filterExpression: ConfigurableExtensionDataRef<
|
||||
string,
|
||||
'catalog.entity-filter-expression',
|
||||
{}
|
||||
>;
|
||||
component: ConfigurableExtensionDataRef<
|
||||
(props: EntityCardLayoutProps) => React_2.JSX.Element,
|
||||
'catalog.entity-card-layout.component',
|
||||
{}
|
||||
>;
|
||||
};
|
||||
}>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface EntityCardLayoutProps {
|
||||
// (undocumented)
|
||||
cards: Array<{
|
||||
area?: (typeof defaultEntityCardAreas)[number];
|
||||
element: React_2.JSX.Element;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const EntityContentBlueprint: ExtensionBlueprint<{
|
||||
kind: 'entity-content';
|
||||
|
||||
@@ -51,6 +51,14 @@ describe('EntityCardBlueprint', () => {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"area": {
|
||||
"enum": [
|
||||
"peek",
|
||||
"info",
|
||||
"full",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
},
|
||||
@@ -83,6 +91,15 @@ describe('EntityCardBlueprint', () => {
|
||||
"optional": [Function],
|
||||
"toString": [Function],
|
||||
},
|
||||
{
|
||||
"$$type": "@backstage/ExtensionDataRef",
|
||||
"config": {
|
||||
"optional": true,
|
||||
},
|
||||
"id": "catalog.entity-card-area",
|
||||
"optional": [Function],
|
||||
"toString": [Function],
|
||||
},
|
||||
],
|
||||
"override": [Function],
|
||||
"toString": [Function],
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import {
|
||||
entityFilterFunctionDataRef,
|
||||
entityFilterExpressionDataRef,
|
||||
entityCardAreaDataRef,
|
||||
defaultEntityCardAreas,
|
||||
} from './extensionData';
|
||||
|
||||
/**
|
||||
@@ -35,25 +37,30 @@ export const EntityCardBlueprint = createExtensionBlueprint({
|
||||
coreExtensionData.reactElement,
|
||||
entityFilterFunctionDataRef.optional(),
|
||||
entityFilterExpressionDataRef.optional(),
|
||||
entityCardAreaDataRef.optional(),
|
||||
],
|
||||
dataRefs: {
|
||||
filterFunction: entityFilterFunctionDataRef,
|
||||
filterExpression: entityFilterExpressionDataRef,
|
||||
area: entityCardAreaDataRef,
|
||||
},
|
||||
config: {
|
||||
schema: {
|
||||
filter: z => z.string().optional(),
|
||||
area: z => z.enum(defaultEntityCardAreas).optional(),
|
||||
},
|
||||
},
|
||||
*factory(
|
||||
{
|
||||
loader,
|
||||
filter,
|
||||
defaultArea,
|
||||
}: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?:
|
||||
| typeof entityFilterFunctionDataRef.T
|
||||
| typeof entityFilterExpressionDataRef.T;
|
||||
defaultArea?: (typeof defaultEntityCardAreas)[number];
|
||||
},
|
||||
{ node, config },
|
||||
) {
|
||||
@@ -66,5 +73,15 @@ export const EntityCardBlueprint = createExtensionBlueprint({
|
||||
} else if (typeof filter === 'function') {
|
||||
yield entityFilterFunctionDataRef(filter);
|
||||
}
|
||||
|
||||
const area = config.area ?? defaultArea;
|
||||
if (area) {
|
||||
yield entityCardAreaDataRef(area);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`DEPRECATION WARNING: Not providing defaultArea for entity cards is deprecated. Missing from '${node.spec.id}'`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -50,3 +50,16 @@ export const entityContentGroupDataRef = createExtensionDataRef<
|
||||
>().with({
|
||||
id: 'catalog.entity-content-group',
|
||||
});
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* Default entity content groups.
|
||||
*/
|
||||
export const defaultEntityCardAreas = ['peek', 'info', 'full'] as const;
|
||||
|
||||
/** @internal */
|
||||
export const entityCardAreaDataRef = createExtensionDataRef<
|
||||
(typeof defaultEntityCardAreas)[number]
|
||||
>().with({
|
||||
id: 'catalog.entity-card-area',
|
||||
});
|
||||
|
||||
@@ -15,4 +15,11 @@
|
||||
*/
|
||||
export { EntityCardBlueprint } from './EntityCardBlueprint';
|
||||
export { EntityContentBlueprint } from './EntityContentBlueprint';
|
||||
export { defaultEntityContentGroups } from './extensionData';
|
||||
export {
|
||||
EntityCardLayoutBlueprint,
|
||||
type EntityCardLayoutProps,
|
||||
} from './EntityCardLauyoutBlueprint';
|
||||
export {
|
||||
defaultEntityContentGroups,
|
||||
defaultEntityCardAreas,
|
||||
} from './extensionData';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
|
||||
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityCardLayoutProps } from '@backstage/plugin-catalog-react/alpha';
|
||||
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionInput } from '@backstage/frontend-plugin-api';
|
||||
@@ -217,9 +218,11 @@ const _default: FrontendPlugin<
|
||||
name: 'about';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -236,11 +239,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/links': ExtensionDefinition<{
|
||||
@@ -248,9 +259,11 @@ const _default: FrontendPlugin<
|
||||
name: 'links';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -267,11 +280,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/labels': ExtensionDefinition<{
|
||||
@@ -279,9 +300,11 @@ const _default: FrontendPlugin<
|
||||
name: 'labels';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -298,11 +321,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/depends-on-components': ExtensionDefinition<{
|
||||
@@ -310,9 +341,11 @@ const _default: FrontendPlugin<
|
||||
name: 'depends-on-components';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -329,11 +362,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/depends-on-resources': ExtensionDefinition<{
|
||||
@@ -341,9 +382,11 @@ const _default: FrontendPlugin<
|
||||
name: 'depends-on-resources';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -360,11 +403,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/has-components': ExtensionDefinition<{
|
||||
@@ -372,9 +423,11 @@ const _default: FrontendPlugin<
|
||||
name: 'has-components';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -391,11 +444,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/has-resources': ExtensionDefinition<{
|
||||
@@ -403,9 +464,11 @@ const _default: FrontendPlugin<
|
||||
name: 'has-resources';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -422,11 +485,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/has-subcomponents': ExtensionDefinition<{
|
||||
@@ -434,9 +505,11 @@ const _default: FrontendPlugin<
|
||||
name: 'has-subcomponents';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -453,11 +526,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/has-subdomains': ExtensionDefinition<{
|
||||
@@ -465,9 +546,11 @@ const _default: FrontendPlugin<
|
||||
name: 'has-subdomains';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -484,11 +567,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:catalog/has-systems': ExtensionDefinition<{
|
||||
@@ -496,9 +587,11 @@ const _default: FrontendPlugin<
|
||||
name: 'has-systems';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
@@ -515,11 +608,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-content:catalog/overview': ExtensionDefinition<{
|
||||
@@ -572,6 +673,31 @@ const _default: FrontendPlugin<
|
||||
}
|
||||
>;
|
||||
inputs: {
|
||||
layouts: ExtensionInput<
|
||||
| ConfigurableExtensionDataRef<
|
||||
(entity: Entity) => boolean,
|
||||
'catalog.entity-filter-function',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
string,
|
||||
'catalog.entity-filter-expression',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
(props: EntityCardLayoutProps) => JSX_2.Element,
|
||||
'catalog.entity-card-layout.component',
|
||||
{}
|
||||
>,
|
||||
{
|
||||
singleton: false;
|
||||
optional: false;
|
||||
}
|
||||
>;
|
||||
cards: ExtensionInput<
|
||||
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -587,6 +713,13 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>,
|
||||
{
|
||||
singleton: false;
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,41 +14,99 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { lazy as reactLazy } from 'react';
|
||||
import {
|
||||
coreExtensionData,
|
||||
createExtensionInput,
|
||||
ExtensionBoundary,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
|
||||
import {
|
||||
EntityCardBlueprint,
|
||||
EntityContentBlueprint,
|
||||
EntityCardLayoutBlueprint,
|
||||
EntityCardLayoutProps,
|
||||
} from '@backstage/plugin-catalog-react/alpha';
|
||||
import { buildFilterFn } from './filter/FilterWrapper';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
|
||||
export const catalogOverviewEntityContent =
|
||||
EntityContentBlueprint.makeWithOverrides({
|
||||
name: 'overview',
|
||||
inputs: {
|
||||
layouts: createExtensionInput([
|
||||
EntityCardLayoutBlueprint.dataRefs.filterFunction.optional(),
|
||||
EntityCardLayoutBlueprint.dataRefs.filterExpression.optional(),
|
||||
EntityCardLayoutBlueprint.dataRefs.component,
|
||||
]),
|
||||
cards: createExtensionInput([
|
||||
coreExtensionData.reactElement,
|
||||
EntityContentBlueprint.dataRefs.filterFunction.optional(),
|
||||
EntityContentBlueprint.dataRefs.filterExpression.optional(),
|
||||
EntityCardBlueprint.dataRefs.area.optional(),
|
||||
]),
|
||||
},
|
||||
factory: (originalFactory, { inputs }) => {
|
||||
factory: (originalFactory, { node, inputs }) => {
|
||||
return originalFactory({
|
||||
defaultPath: '/',
|
||||
defaultTitle: 'Overview',
|
||||
loader: async () =>
|
||||
import('./EntityOverviewPage').then(m => (
|
||||
<m.EntityOverviewPage
|
||||
cards={inputs.cards.map(c => ({
|
||||
element: c.get(coreExtensionData.reactElement),
|
||||
filterFunction: c.get(
|
||||
EntityContentBlueprint.dataRefs.filterFunction,
|
||||
),
|
||||
filterExpression: c.get(
|
||||
EntityContentBlueprint.dataRefs.filterExpression,
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)),
|
||||
loader: async () => {
|
||||
const LazyDefaultLayoutComponent = reactLazy(() =>
|
||||
import('./EntityOverviewPage').then(m => ({
|
||||
default: m.EntityOverviewPage,
|
||||
})),
|
||||
);
|
||||
|
||||
const DefaultLayoutComponent = (props: EntityCardLayoutProps) => {
|
||||
return (
|
||||
<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 />;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,9 +22,11 @@ const _default: FrontendPlugin<
|
||||
name: 'group-profile';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -45,11 +47,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:org/members-list': ExtensionDefinition<{
|
||||
@@ -57,9 +67,11 @@ const _default: FrontendPlugin<
|
||||
name: 'members-list';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -80,11 +92,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:org/ownership': ExtensionDefinition<{
|
||||
@@ -92,9 +112,11 @@ const _default: FrontendPlugin<
|
||||
name: 'ownership';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -115,11 +137,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
'entity-card:org/user-profile': ExtensionDefinition<{
|
||||
@@ -127,9 +157,11 @@ const _default: FrontendPlugin<
|
||||
name: 'user-profile';
|
||||
config: {
|
||||
filter: string | undefined;
|
||||
area: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
configInput: {
|
||||
filter?: string | undefined;
|
||||
area?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
@@ -150,11 +182,19 @@ const _default: FrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<
|
||||
'full' | 'info' | 'peek',
|
||||
'catalog.entity-card-area',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
filter?: string | ((entity: Entity) => boolean) | undefined;
|
||||
defaultArea?: 'full' | 'info' | 'peek' | undefined;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user