Merge pull request #20710 from backstage/rugvip/entity-filters

catalog: add basic support for default and configured filters for entity page content
This commit is contained in:
Patrik Oldsberg
2023-10-24 10:48:41 +02:00
committed by GitHub
6 changed files with 185 additions and 87 deletions
+7 -2
View File
@@ -10,10 +10,15 @@ app:
- apis.plugin.graphiql.browse.gitlab: true
# Entity page cards
- 'entity.cards.about'
- entity.cards.about
- entity.cards.labels
- entity.cards.links:
config:
filter:
- isKind: component
# Entity page content
- 'entity.content.techdocs'
- entity.content.techdocs
# scmAuthExtension: >-
# createScmAuthExtension({
+39 -32
View File
@@ -10,13 +10,11 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionInputValues } from '@backstage/frontend-plugin-api';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
export function createEntityCardExtension<
TConfig,
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
@@ -26,44 +24,47 @@ export function createEntityCardExtension<
};
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
filter?: (ctx: { entity: Entity }) => boolean;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<TConfig>;
}): Extension<{
filter?:
| {
isKind?: string | undefined;
isType?: string | undefined;
}[]
| undefined;
}>;
// @alpha (undocumented)
export function createEntityContentExtension<
TConfig extends {
path: string;
title: string;
},
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
defaultTitle: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
>(options: {
id: string;
attachTo?: {
id: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig>;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
defaultPath: string;
defaultTitle: string;
filter?: (ctx: { entity: Entity }) => boolean;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<{
title: string;
path: string;
filter?:
| {
isKind?: string | undefined;
isType?: string | undefined;
}[]
| undefined;
}>;
// @alpha (undocumented)
export const entityContentTitleExtensionDataRef: ConfigurableExtensionDataRef<
@@ -71,6 +72,12 @@ export const entityContentTitleExtensionDataRef: ConfigurableExtensionDataRef<
{}
>;
// @alpha (undocumented)
export const entityFilterExtensionDataRef: ConfigurableExtensionDataRef<
(ctx: { entity: Entity }) => boolean,
{}
>;
// @alpha
export function isOwnerOf(owner: Entity, entity: Entity): boolean;
+86 -42
View File
@@ -17,10 +17,8 @@
import React, { lazy } from 'react';
import {
AnyExtensionInputMap,
Extension,
ExtensionBoundary,
ExtensionInputValues,
PortableSchema,
RouteRef,
coreExtensionData,
createExtension,
@@ -29,6 +27,7 @@ import {
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { Expand } from '../../../packages/frontend-plugin-api/src/types';
import { Entity } from '@backstage/catalog-model';
export { isOwnerOf } from './utils';
export { useEntityPermission } from './hooks/useEntityPermission';
@@ -37,21 +36,57 @@ export { useEntityPermission } from './hooks/useEntityPermission';
export const entityContentTitleExtensionDataRef =
createExtensionDataRef<string>('plugin.catalog.entity.content.title');
/** @alpha */
export const entityFilterExtensionDataRef = createExtensionDataRef<
(ctx: { entity: Entity }) => boolean
>('plugin.catalog.entity.filter');
function applyFilter(a?: string, b?: string): boolean {
if (!a) {
return true;
}
return a.toLocaleLowerCase('en-US') === b?.toLocaleLowerCase('en-US');
}
// TODO: Only two hardcoded isKind and isType filters are available for now
// This is just an initial config filter implementation and needs to be revisited
function buildFilter(
config: { filter?: { isKind?: string; isType?: string }[] },
filterFunc?: (ctx: { entity: Entity }) => boolean,
) {
return (ctx: { entity: Entity }) => {
const configuredFilterMatch = config.filter?.some(filter => {
const kindMatch = applyFilter(filter.isKind, ctx.entity.kind);
const typeMatch = applyFilter(
filter.isType,
ctx.entity.spec?.type?.toString(),
);
return kindMatch && typeMatch;
});
if (configuredFilterMatch) {
return true;
}
if (filterFunc) {
return filterFunc(ctx);
}
return true;
};
}
// TODO: Figure out how to merge with provided config schema
/** @alpha */
export function createEntityCardExtension<
TConfig,
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
filter?: (ctx: { entity: Entity }) => boolean;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<TConfig> {
}) {
const id = `entity.cards.${options.id}`;
return createExtension({
@@ -63,13 +98,25 @@ export function createEntityCardExtension<
disabled: options.disabled ?? true,
output: {
element: coreExtensionData.reactElement,
filter: entityFilterExtensionDataRef,
},
inputs: options.inputs,
configSchema: options.configSchema,
configSchema: createSchemaFromZod(z =>
z.object({
filter: z
.array(
z.object({
isKind: z.string().optional(),
isType: z.string().optional(),
}),
)
.optional(),
}),
),
factory({ bind, config, inputs, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config, inputs })
.loader({ inputs })
.then(element => ({ default: () => element })),
);
@@ -79,6 +126,7 @@ export function createEntityCardExtension<
<ExtensionComponent />
</ExtensionBoundary>
),
filter: buildFilter(config, options.filter),
});
},
});
@@ -86,41 +134,22 @@ export function createEntityCardExtension<
/** @alpha */
export function createEntityContentExtension<
TConfig extends { path: string; title: string },
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
defaultTitle: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig> {
>(options: {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
defaultPath: string;
defaultTitle: string;
filter?: (ctx: { entity: Entity }) => boolean;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}) {
const id = `entity.content.${options.id}`;
const configSchema =
'configSchema' in options
? options.configSchema
: (createSchemaFromZod(z =>
z.object({
path: z.string().default(options.defaultPath),
title: z.string().default(options.defaultTitle),
}),
) as PortableSchema<TConfig>);
return createExtension({
id,
attachTo: options.attachTo ?? {
@@ -133,13 +162,27 @@ export function createEntityContentExtension<
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: entityContentTitleExtensionDataRef,
filter: entityFilterExtensionDataRef,
},
inputs: options.inputs,
configSchema,
configSchema: createSchemaFromZod(z =>
z.object({
path: z.string().default(options.defaultPath),
title: z.string().default(options.defaultTitle),
filter: z
.array(
z.object({
isKind: z.string().optional(),
isType: z.string().optional(),
}),
)
.optional(),
}),
),
factory({ bind, config, inputs, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config, inputs })
.loader({ inputs })
.then(element => ({ default: () => element })),
);
@@ -152,6 +195,7 @@ export function createEntityContentExtension<
<ExtensionComponent />
</ExtensionBoundary>
),
filter: buildFilter(config, options.filter),
});
},
});
@@ -0,0 +1,42 @@
/*
* Copyright 2023 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 { useEntity } from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import Grid from '@material-ui/core/Grid';
interface EntityOverviewPageProps {
cards: Array<{
element: React.JSX.Element;
filter: (ctx: { entity: Entity }) => boolean;
}>;
}
export function EntityOverviewPage(props: EntityOverviewPageProps) {
const { entity } = useEntity();
return (
<Grid container spacing={3} alignItems="stretch">
{props.cards
.filter(card => card.filter({ entity }))
.map(card => (
<Grid item md={6} xs={12}>
{card.element}
</Grid>
))}
</Grid>
);
}
@@ -27,6 +27,7 @@ export const EntityAboutCard = createEntityCardExtension({
export const EntityLinksCard = createEntityCardExtension({
id: 'links',
filter: ({ entity }) => Boolean(entity.metadata.links),
loader: async () =>
import('../components/EntityLinksCard').then(m => {
return <m.EntityLinksCard variant="gridItem" />;
@@ -35,6 +36,7 @@ export const EntityLinksCard = createEntityCardExtension({
export const EntityLabelsCard = createEntityCardExtension({
id: 'labels',
filter: ({ entity }) => Boolean(entity.metadata.labels),
loader: async () =>
import('../components/EntityLabelsCard').then(m => (
<m.EntityLabelsCard variant="gridItem" />
+9 -11
View File
@@ -15,12 +15,14 @@
*/
import React from 'react';
import { Grid } from '@material-ui/core';
import {
coreExtensionData,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { createEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
import {
createEntityContentExtension,
entityFilterExtensionDataRef,
} from '@backstage/plugin-catalog-react/alpha';
export const OverviewEntityContent = createEntityContentExtension({
id: 'overview',
@@ -30,17 +32,13 @@ export const OverviewEntityContent = createEntityContentExtension({
inputs: {
cards: createExtensionInput({
element: coreExtensionData.reactElement,
filter: entityFilterExtensionDataRef,
}),
},
loader: async ({ inputs }) => (
<Grid container spacing={3} alignItems="stretch">
{inputs.cards.map(card => (
<Grid item md={6} xs={12}>
{card.element}
</Grid>
))}
</Grid>
),
loader: async ({ inputs }) =>
import('./EntityOverviewPage').then(m => (
<m.EntityOverviewPage cards={inputs.cards} />
)),
});
export default [OverviewEntityContent];