catalog: initial implementation of default and configurable entity content filters
Co-authored-by: Philipp Hugenroth <philipph@spotify.com> Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user