implement text based entity card/content filters

Co-authored-by: Vincenzo Scamporlino <vincenzos@spotify.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2023-11-22 17:03:32 +01:00
parent 0108a01385
commit e223f2264d
22 changed files with 529 additions and 89 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-common': minor
---
Added a `parseFilterExpression` function to interpret visibility filters
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Properly support both function- and string-form visibility filter expressions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-react': patch
---
Breaking alpha-API change to entity visibility filter functions to accept a bare entity as their first argument, instead of an object with an entity property.
Functions that accept such filters now also support the string expression form of filters.
+1 -2
View File
@@ -14,8 +14,7 @@ app:
- entity.cards.labels
- entity.cards.links:
config:
filter:
- isKind: component
filter: kind:component
# Entity page content
- entity.content.techdocs
@@ -4,6 +4,7 @@
```ts
import { BasicPermission } from '@backstage/plugin-permission-common';
import { Entity } from '@backstage/catalog-model';
import { ResourcePermission } from '@backstage/plugin-permission-common';
// @alpha
@@ -38,6 +39,11 @@ export const catalogPermissions: (
| ResourcePermission<'catalog-entity'>
)[];
// @alpha
export function parseFilterExpression(
expression: string,
): (entity: Entity) => boolean;
// @alpha
export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity';
+1
View File
@@ -46,6 +46,7 @@
},
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-search-common": "workspace:^"
},
+1
View File
@@ -26,3 +26,4 @@ export {
catalogPermissions,
} from './permissions';
export type { CatalogEntityPermission } from './permissions';
export { parseFilterExpression } from './filter';
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { parseFilterExpression } from './parseFilterExpression';
@@ -0,0 +1,48 @@
/*
* 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 { InputError } from '@backstage/errors';
import { EntityMatcherFn } from './types';
/**
* Matches on the non-empty presence of different parts of the entity
*/
export function createHasMatcher(parameters: string[]): EntityMatcherFn {
const allowedMatchers: Record<string, EntityMatcherFn> = {
labels: entity => {
return Object.keys(entity.metadata.labels ?? {}).length > 0;
},
annotations: entity => {
return Object.keys(entity.metadata.annotations ?? {}).length > 0;
},
links: entity => {
return (entity.metadata.links ?? []).length > 0;
},
};
const matchers = parameters.map(parameter => {
const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')];
if (!matcher) {
const known = Object.keys(allowedMatchers).map(m => `'${m}'`);
throw new InputError(
`'${parameter}' is not a valid parameter for 'has' filter expressions, expected one of ${known}`,
);
}
return matcher;
});
return entity => matchers.some(matcher => matcher(entity));
}
@@ -0,0 +1,41 @@
/*
* 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 { InputError } from '@backstage/errors';
import { EntityMatcherFn } from './types';
/**
* Matches on different semantic properties of the entity
*/
export function createIsMatcher(parameters: string[]): EntityMatcherFn {
const allowedMatchers: Record<string, EntityMatcherFn> = {
orphan: entity =>
Boolean(entity.metadata.annotations?.['backstage.io/orphan']),
};
const matchers = parameters.map(parameter => {
const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')];
if (!matcher) {
const known = Object.keys(allowedMatchers).map(m => `'${m}'`);
throw new InputError(
`'${parameter}' is not a valid parameter for 'is' filter expressions, expected one of ${known}`,
);
}
return matcher;
});
return entity => matchers.some(matcher => matcher(entity));
}
@@ -0,0 +1,25 @@
/*
* 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 { EntityMatcherFn } from './types';
/**
* Matches on kind
*/
export function createKindMatcher(parameters: string[]): EntityMatcherFn {
const items = parameters.map(p => p.toLocaleLowerCase('en-US'));
return entity => items.includes(entity.kind.toLocaleLowerCase('en-US'));
}
@@ -0,0 +1,31 @@
/*
* 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 { EntityMatcherFn } from './types';
/**
* Matches on spec.type
*/
export function createTypeMatcher(parameters: string[]): EntityMatcherFn {
const items = parameters.map(p => p.toLocaleLowerCase('en-US'));
return entity => {
const value = entity.spec?.type;
return (
typeof value === 'string' &&
items.includes(value.toLocaleLowerCase('en-US'))
);
};
}
@@ -0,0 +1,19 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
export type EntityMatcherFn = (entity: Entity) => boolean;
@@ -0,0 +1,169 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import {
parseFilterExpression,
splitFilterExpression,
} from './parseFilterExpression';
describe('parseFilterExpression', () => {
it('supports "kind" expressions', () => {
const component = { kind: 'Component' } as unknown as Entity;
const user = { kind: 'User' } as unknown as Entity;
const resource = { kind: 'Resource' } as unknown as Entity;
expect(parseFilterExpression('kind:component')(component)).toBe(true);
expect(parseFilterExpression('kind:componenT')(component)).toBe(true);
expect(parseFilterExpression('kind:user')(component)).toBe(false);
// match ANY of the parameters
expect(parseFilterExpression('kind:user,component')(user)).toBe(true);
expect(parseFilterExpression('kind:user,component')(component)).toBe(true);
expect(parseFilterExpression('kind:user,component')(resource)).toBe(false);
});
it('supports "is" expressions', () => {
const empty = {
metadata: {},
} as unknown as Entity;
const orphan = {
metadata: {
annotations: { ['backstage.io/orphan']: 'true' },
},
} as unknown as Entity;
expect(parseFilterExpression('is:orphan')(empty)).toBe(false);
expect(parseFilterExpression('is:orphan')(orphan)).toBe(true);
expect(() =>
parseFilterExpression('is:orphan,bar'),
).toThrowErrorMatchingInlineSnapshot(
`"'bar' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'"`,
);
});
it('supports "has" expressions', () => {
const empty1 = {
metadata: {},
} as unknown as Entity;
const empty2 = {
metadata: {
labels: {},
annotations: {},
links: [],
},
} as unknown as Entity;
const labels = {
metadata: {
labels: { a: 'b' },
},
} as unknown as Entity;
const annotations = {
metadata: {
annotations: { a: 'b' },
},
} as unknown as Entity;
const links = {
metadata: { links: [{}] },
} as unknown as Entity;
expect(parseFilterExpression('has:labels')(empty1)).toBe(false);
expect(parseFilterExpression('has:labels')(empty2)).toBe(false);
expect(parseFilterExpression('has:labels')(labels)).toBe(true);
expect(parseFilterExpression('has:annotations')(empty1)).toBe(false);
expect(parseFilterExpression('has:annotations')(empty2)).toBe(false);
expect(parseFilterExpression('has:annotations')(annotations)).toBe(true);
expect(parseFilterExpression('has:links')(empty1)).toBe(false);
expect(parseFilterExpression('has:links')(empty2)).toBe(false);
expect(parseFilterExpression('has:links')(links)).toBe(true);
// match ANY of the parameters
expect(parseFilterExpression('has:labels,links')(empty1)).toBe(false);
expect(parseFilterExpression('has:labels,links')(empty2)).toBe(false);
expect(parseFilterExpression('has:labels,links')(labels)).toBe(true);
expect(parseFilterExpression('has:labels,links')(links)).toBe(true);
expect(parseFilterExpression('has:labels,links')(annotations)).toBe(false);
expect(() =>
parseFilterExpression('has:labels,bar'),
).toThrowErrorMatchingInlineSnapshot(
`"'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','annotations','links'"`,
);
});
it('rejects unknown keys', () => {
expect(() =>
parseFilterExpression('unknown:foo'),
).toThrowErrorMatchingInlineSnapshot(
`"'unknown' is not a valid filter expression key, expected one of 'kind','type','is','has'"`,
);
});
it('rejects malformed inputs', () => {
expect(() => parseFilterExpression(':')).toThrowErrorMatchingInlineSnapshot(
`"':' is not a valid filter expression, expected 'key:parameter' form"`,
);
expect(() =>
parseFilterExpression(':a'),
).toThrowErrorMatchingInlineSnapshot(
`"':a' is not a valid filter expression, expected 'key:parameter' form"`,
);
expect(() =>
parseFilterExpression('a:'),
).toThrowErrorMatchingInlineSnapshot(
`"'a:' is not a valid filter expression, expected 'key:parameter' form"`,
);
});
});
describe('splitFilterExpression', () => {
it('properly splits into expression atoms', () => {
expect(splitFilterExpression('')).toEqual([]);
expect(splitFilterExpression(' ')).toEqual([]);
expect(splitFilterExpression('kind:component')).toEqual([
{ key: 'kind', parameters: ['component'] },
]);
expect(splitFilterExpression('kind:component,user')).toEqual([
{ key: 'kind', parameters: ['component', 'user'] },
]);
expect(splitFilterExpression('kind:component,user type:foo')).toEqual([
{ key: 'kind', parameters: ['component', 'user'] },
{ key: 'type', parameters: ['foo'] },
]);
expect(splitFilterExpression('with:multiple:colons')).toEqual([
{ key: 'with', parameters: ['multiple:colons'] },
]);
});
it('rejects malformed inputs', () => {
expect(() => splitFilterExpression(':')).toThrowErrorMatchingInlineSnapshot(
`"':' is not a valid filter expression, expected 'key:parameter' form"`,
);
expect(() =>
splitFilterExpression(':a'),
).toThrowErrorMatchingInlineSnapshot(
`"':a' is not a valid filter expression, expected 'key:parameter' form"`,
);
expect(() =>
splitFilterExpression('a:'),
).toThrowErrorMatchingInlineSnapshot(
`"'a:' is not a valid filter expression, expected 'key:parameter' form"`,
);
});
});
@@ -0,0 +1,99 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { InputError } from '@backstage/errors';
import { EntityMatcherFn } from './matrchers/types';
import { createKindMatcher } from './matrchers/createKindMatcher';
import { createTypeMatcher } from './matrchers/createTypeMatcher';
import { createIsMatcher } from './matrchers/createIsMatcher';
import { createHasMatcher } from './matrchers/createHasMatcher';
const rootMatcherFactories: Record<
string,
(parameters: string[]) => EntityMatcherFn
> = {
kind: createKindMatcher,
type: createTypeMatcher,
is: createIsMatcher,
has: createHasMatcher,
};
/**
* Parses a filter expression that decides whether to render an entity component
* or not. Returns a function that matches entities based on that expression.
*
* @alpha
* @remarks
*
* Filter strings are on the form `kind:user,group is:orphan`. There's
* effectively an AND between the space separated parts, and an OR between comma
* separated parameters. So the example filter string semantically means
* "entities that are of either User or Group kind, and also are orphans".
*/
export function parseFilterExpression(
expression: string,
): (entity: Entity) => boolean {
const parts = splitFilterExpression(expression);
const matchers = parts.map(part => {
const factory = rootMatcherFactories[part.key];
if (!factory) {
const known = Object.keys(rootMatcherFactories).map(m => `'${m}'`);
throw new InputError(
`'${part.key}' is not a valid filter expression key, expected one of ${known}`,
);
}
return factory(part.parameters);
});
return (entity: Entity) => {
return matchers.every(matcher => {
try {
return matcher(entity);
} catch {
return false;
}
});
};
}
export function splitFilterExpression(
expression: string,
): Array<{ key: string; parameters: string[] }> {
const words = expression
.split(' ')
.map(w => w.trim())
.filter(Boolean);
const result = new Array<{ key: string; parameters: string[] }>();
for (const word of words) {
const match = word.match(/^([^:]+):(.+)$/);
if (!match) {
throw new InputError(
`'${word}' is not a valid filter expression, expected 'key:parameter' form`,
);
}
const key = match[1];
const parameters = match[2].split(',');
result.push({ key, parameters });
}
return result;
}
+5 -15
View File
@@ -24,17 +24,12 @@ export function createEntityCardExtension<
};
disabled?: boolean;
inputs?: TInputs;
filter?: (ctx: { entity: Entity }) => boolean;
filter?: typeof entityFilterExtensionDataRef.T;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<{
filter?:
| {
isKind?: string | undefined;
isType?: string | undefined;
}[]
| undefined;
filter?: string | undefined;
}>;
// @alpha (undocumented)
@@ -51,19 +46,14 @@ export function createEntityContentExtension<
routeRef?: RouteRef;
defaultPath: string;
defaultTitle: string;
filter?: (ctx: { entity: Entity }) => boolean;
filter?: typeof entityFilterExtensionDataRef.T;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<{
title: string;
path: string;
filter?:
| {
isKind?: string | undefined;
isType?: string | undefined;
}[]
| undefined;
filter?: string | undefined;
}>;
// @alpha (undocumented)
@@ -74,7 +64,7 @@ export const entityContentTitleExtensionDataRef: ConfigurableExtensionDataRef<
// @alpha (undocumented)
export const entityFilterExtensionDataRef: ConfigurableExtensionDataRef<
(ctx: { entity: Entity }) => boolean,
string | ((entity: Entity) => boolean),
{}
>;
+12 -58
View File
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import React, { lazy } from 'react';
import {
AnyExtensionInputMap,
ExtensionBoundary,
@@ -25,12 +24,13 @@ import {
createExtensionDataRef,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import React, { lazy } from 'react';
import { Entity } from '@backstage/catalog-model';
// 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';
export { isOwnerOf } from './utils';
/** @alpha */
export const entityContentTitleExtensionDataRef =
@@ -38,41 +38,9 @@ export const entityContentTitleExtensionDataRef =
/** @alpha */
export const entityFilterExtensionDataRef = createExtensionDataRef<
(ctx: { entity: Entity }) => boolean
string | ((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<
@@ -82,7 +50,7 @@ export function createEntityCardExtension<
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
filter?: (ctx: { entity: Entity }) => boolean;
filter?: typeof entityFilterExtensionDataRef.T;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
@@ -98,19 +66,12 @@ export function createEntityCardExtension<
disabled: options.disabled ?? true,
output: {
element: coreExtensionData.reactElement,
filter: entityFilterExtensionDataRef,
filter: entityFilterExtensionDataRef.optional(),
},
inputs: options.inputs,
configSchema: createSchemaFromZod(z =>
z.object({
filter: z
.array(
z.object({
isKind: z.string().optional(),
isType: z.string().optional(),
}),
)
.optional(),
filter: z.string().optional(),
}),
),
factory({ config, inputs, node }) {
@@ -126,7 +87,7 @@ export function createEntityCardExtension<
<ExtensionComponent />
</ExtensionBoundary>
),
filter: buildFilter(config, options.filter),
filter: config.filter ?? options.filter,
};
},
});
@@ -143,7 +104,7 @@ export function createEntityContentExtension<
routeRef?: RouteRef;
defaultPath: string;
defaultTitle: string;
filter?: (ctx: { entity: Entity }) => boolean;
filter?: typeof entityFilterExtensionDataRef.T;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
@@ -162,21 +123,14 @@ export function createEntityContentExtension<
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: entityContentTitleExtensionDataRef,
filter: entityFilterExtensionDataRef,
filter: entityFilterExtensionDataRef.optional(),
},
inputs: options.inputs,
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(),
filter: z.string().optional(),
}),
),
factory({ config, inputs, node }) {
@@ -195,7 +149,7 @@ export function createEntityContentExtension<
<ExtensionComponent />
</ExtensionBoundary>
),
filter: buildFilter(config, options.filter),
filter: config.filter ?? options.filter,
};
},
});
@@ -14,29 +14,51 @@
* limitations under the License.
*/
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import { parseFilterExpression } from '@backstage/plugin-catalog-common/alpha';
import { useEntity } from '@backstage/plugin-catalog-react';
import Grid from '@material-ui/core/Grid';
import React, { useMemo } from 'react';
interface EntityOverviewPageProps {
cards: Array<{
element: React.JSX.Element;
filter: (ctx: { entity: Entity }) => boolean;
filter?: string | ((entity: Entity) => boolean);
}>;
}
function CardWrapper(props: {
entity: Entity;
element: React.JSX.Element;
filter?: string | ((entity: Entity) => boolean);
}) {
const { entity, element, filter } = props;
const filterFn = useMemo<(subject: Entity) => boolean>(() => {
if (!filter) {
return () => true;
} else if (typeof filter === 'function') {
return subject => filter(subject);
}
return parseFilterExpression(filter);
}, [filter]);
return filterFn(entity) ? <>{element}</> : null;
}
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>
))}
{props.cards.map((card, index) => (
<Grid key={index} item md={6} xs={12}>
<CardWrapper
entity={entity}
element={card.element}
filter={card.filter}
/>
</Grid>
))}
</Grid>
);
}
+2 -2
View File
@@ -27,7 +27,7 @@ export const EntityAboutCard = createEntityCardExtension({
export const EntityLinksCard = createEntityCardExtension({
id: 'links',
filter: ({ entity }) => Boolean(entity.metadata.links),
filter: 'has:links',
loader: async () =>
import('../components/EntityLinksCard').then(m => {
return <m.EntityLinksCard variant="gridItem" />;
@@ -36,7 +36,7 @@ export const EntityLinksCard = createEntityCardExtension({
export const EntityLabelsCard = createEntityCardExtension({
id: 'labels',
filter: ({ entity }) => Boolean(entity.metadata.labels),
filter: 'has:labels',
loader: async () =>
import('../components/EntityLabelsCard').then(m => (
<m.EntityLabelsCard variant="gridItem" />
+1 -1
View File
@@ -32,7 +32,7 @@ export const OverviewEntityContent = createEntityContentExtension({
inputs: {
cards: createExtensionInput({
element: coreExtensionData.reactElement,
filter: entityFilterExtensionDataRef,
filter: entityFilterExtensionDataRef.optional(),
}),
},
loader: async ({ inputs }) =>
+1 -1
View File
@@ -22,8 +22,8 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react';
import {
createComponentRouteRef,
createFromTemplateRouteRef,
unregisterRedirectRouteRef,
rootRouteRef,
unregisterRedirectRouteRef,
viewTechDocRouteRef,
} from '../routes';
+1
View File
@@ -5831,6 +5831,7 @@ __metadata:
dependencies:
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
languageName: unknown