From e223f2264d0d3e69f934ac1d8805ee33e690c7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 22 Nov 2023 17:03:32 +0100 Subject: [PATCH] implement text based entity card/content filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Vincenzo Scamporlino Signed-off-by: Fredrik Adelöw --- .changeset/stale-jokes-hunt.md | 5 + .changeset/thick-snails-travel.md | 5 + .changeset/thirty-fireants-cheer.md | 7 + packages/app-next/app-config.yaml | 3 +- plugins/catalog-common/api-report-alpha.md | 6 + plugins/catalog-common/package.json | 1 + plugins/catalog-common/src/alpha.ts | 1 + plugins/catalog-common/src/filter/index.ts | 17 ++ .../src/filter/matrchers/createHasMatcher.ts | 48 +++++ .../src/filter/matrchers/createIsMatcher.ts | 41 +++++ .../src/filter/matrchers/createKindMatcher.ts | 25 +++ .../src/filter/matrchers/createTypeMatcher.ts | 31 ++++ .../src/filter/matrchers/types.ts | 19 ++ .../src/filter/parseFilterExpression.test.ts | 169 ++++++++++++++++++ .../src/filter/parseFilterExpression.ts | 99 ++++++++++ plugins/catalog-react/api-report-alpha.md | 20 +-- plugins/catalog-react/src/alpha.tsx | 70 ++------ .../catalog/src/alpha/EntityOverviewPage.tsx | 42 +++-- plugins/catalog/src/alpha/entityCards.tsx | 4 +- plugins/catalog/src/alpha/entityContents.tsx | 2 +- plugins/catalog/src/alpha/plugin.tsx | 2 +- yarn.lock | 1 + 22 files changed, 529 insertions(+), 89 deletions(-) create mode 100644 .changeset/stale-jokes-hunt.md create mode 100644 .changeset/thick-snails-travel.md create mode 100644 .changeset/thirty-fireants-cheer.md create mode 100644 plugins/catalog-common/src/filter/index.ts create mode 100644 plugins/catalog-common/src/filter/matrchers/createHasMatcher.ts create mode 100644 plugins/catalog-common/src/filter/matrchers/createIsMatcher.ts create mode 100644 plugins/catalog-common/src/filter/matrchers/createKindMatcher.ts create mode 100644 plugins/catalog-common/src/filter/matrchers/createTypeMatcher.ts create mode 100644 plugins/catalog-common/src/filter/matrchers/types.ts create mode 100644 plugins/catalog-common/src/filter/parseFilterExpression.test.ts create mode 100644 plugins/catalog-common/src/filter/parseFilterExpression.ts diff --git a/.changeset/stale-jokes-hunt.md b/.changeset/stale-jokes-hunt.md new file mode 100644 index 0000000000..cbea2dd229 --- /dev/null +++ b/.changeset/stale-jokes-hunt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-common': minor +--- + +Added a `parseFilterExpression` function to interpret visibility filters diff --git a/.changeset/thick-snails-travel.md b/.changeset/thick-snails-travel.md new file mode 100644 index 0000000000..af27df78e5 --- /dev/null +++ b/.changeset/thick-snails-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Properly support both function- and string-form visibility filter expressions diff --git a/.changeset/thirty-fireants-cheer.md b/.changeset/thirty-fireants-cheer.md new file mode 100644 index 0000000000..abda872570 --- /dev/null +++ b/.changeset/thirty-fireants-cheer.md @@ -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. diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index a46e119ac0..a90fe630b5 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -14,8 +14,7 @@ app: - entity.cards.labels - entity.cards.links: config: - filter: - - isKind: component + filter: kind:component # Entity page content - entity.content.techdocs diff --git a/plugins/catalog-common/api-report-alpha.md b/plugins/catalog-common/api-report-alpha.md index 12e0806a9c..573b35d3b3 100644 --- a/plugins/catalog-common/api-report-alpha.md +++ b/plugins/catalog-common/api-report-alpha.md @@ -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'; diff --git a/plugins/catalog-common/package.json b/plugins/catalog-common/package.json index 45720ee32a..ac21b76c2a 100644 --- a/plugins/catalog-common/package.json +++ b/plugins/catalog-common/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@backstage/catalog-model": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-search-common": "workspace:^" }, diff --git a/plugins/catalog-common/src/alpha.ts b/plugins/catalog-common/src/alpha.ts index 2dfc028332..015769c8a7 100644 --- a/plugins/catalog-common/src/alpha.ts +++ b/plugins/catalog-common/src/alpha.ts @@ -26,3 +26,4 @@ export { catalogPermissions, } from './permissions'; export type { CatalogEntityPermission } from './permissions'; +export { parseFilterExpression } from './filter'; diff --git a/plugins/catalog-common/src/filter/index.ts b/plugins/catalog-common/src/filter/index.ts new file mode 100644 index 0000000000..c5ad803f4c --- /dev/null +++ b/plugins/catalog-common/src/filter/index.ts @@ -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'; diff --git a/plugins/catalog-common/src/filter/matrchers/createHasMatcher.ts b/plugins/catalog-common/src/filter/matrchers/createHasMatcher.ts new file mode 100644 index 0000000000..7b9eebba1c --- /dev/null +++ b/plugins/catalog-common/src/filter/matrchers/createHasMatcher.ts @@ -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 = { + 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)); +} diff --git a/plugins/catalog-common/src/filter/matrchers/createIsMatcher.ts b/plugins/catalog-common/src/filter/matrchers/createIsMatcher.ts new file mode 100644 index 0000000000..3802d6dbce --- /dev/null +++ b/plugins/catalog-common/src/filter/matrchers/createIsMatcher.ts @@ -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 = { + 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)); +} diff --git a/plugins/catalog-common/src/filter/matrchers/createKindMatcher.ts b/plugins/catalog-common/src/filter/matrchers/createKindMatcher.ts new file mode 100644 index 0000000000..071746453e --- /dev/null +++ b/plugins/catalog-common/src/filter/matrchers/createKindMatcher.ts @@ -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')); +} diff --git a/plugins/catalog-common/src/filter/matrchers/createTypeMatcher.ts b/plugins/catalog-common/src/filter/matrchers/createTypeMatcher.ts new file mode 100644 index 0000000000..d0e0f39c98 --- /dev/null +++ b/plugins/catalog-common/src/filter/matrchers/createTypeMatcher.ts @@ -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')) + ); + }; +} diff --git a/plugins/catalog-common/src/filter/matrchers/types.ts b/plugins/catalog-common/src/filter/matrchers/types.ts new file mode 100644 index 0000000000..0fff91b3d5 --- /dev/null +++ b/plugins/catalog-common/src/filter/matrchers/types.ts @@ -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; diff --git a/plugins/catalog-common/src/filter/parseFilterExpression.test.ts b/plugins/catalog-common/src/filter/parseFilterExpression.test.ts new file mode 100644 index 0000000000..d8543c4e10 --- /dev/null +++ b/plugins/catalog-common/src/filter/parseFilterExpression.test.ts @@ -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"`, + ); + }); +}); diff --git a/plugins/catalog-common/src/filter/parseFilterExpression.ts b/plugins/catalog-common/src/filter/parseFilterExpression.ts new file mode 100644 index 0000000000..d2b46f582e --- /dev/null +++ b/plugins/catalog-common/src/filter/parseFilterExpression.ts @@ -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; +} diff --git a/plugins/catalog-react/api-report-alpha.md b/plugins/catalog-react/api-report-alpha.md index ed851f810a..e284f80b8e 100644 --- a/plugins/catalog-react/api-report-alpha.md +++ b/plugins/catalog-react/api-report-alpha.md @@ -24,17 +24,12 @@ export function createEntityCardExtension< }; disabled?: boolean; inputs?: TInputs; - filter?: (ctx: { entity: Entity }) => boolean; + filter?: typeof entityFilterExtensionDataRef.T; loader: (options: { inputs: Expand>; }) => Promise; }): 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>; }) => Promise; }): 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), {} >; diff --git a/plugins/catalog-react/src/alpha.tsx b/plugins/catalog-react/src/alpha.tsx index a339816742..604c769fe5 100644 --- a/plugins/catalog-react/src/alpha.tsx +++ b/plugins/catalog-react/src/alpha.tsx @@ -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>; }) => Promise; @@ -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< ), - 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>; }) => Promise; @@ -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< ), - filter: buildFilter(config, options.filter), + filter: config.filter ?? options.filter, }; }, }); diff --git a/plugins/catalog/src/alpha/EntityOverviewPage.tsx b/plugins/catalog/src/alpha/EntityOverviewPage.tsx index baa93845ae..02ec044975 100644 --- a/plugins/catalog/src/alpha/EntityOverviewPage.tsx +++ b/plugins/catalog/src/alpha/EntityOverviewPage.tsx @@ -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 ( - {props.cards - .filter(card => card.filter({ entity })) - .map(card => ( - - {card.element} - - ))} + {props.cards.map((card, index) => ( + + + + ))} ); } diff --git a/plugins/catalog/src/alpha/entityCards.tsx b/plugins/catalog/src/alpha/entityCards.tsx index c7b75679a7..572e2caa3c 100644 --- a/plugins/catalog/src/alpha/entityCards.tsx +++ b/plugins/catalog/src/alpha/entityCards.tsx @@ -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 ; @@ -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 => ( diff --git a/plugins/catalog/src/alpha/entityContents.tsx b/plugins/catalog/src/alpha/entityContents.tsx index 9bef4a5e0d..0bbee9bb7d 100644 --- a/plugins/catalog/src/alpha/entityContents.tsx +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -32,7 +32,7 @@ export const OverviewEntityContent = createEntityContentExtension({ inputs: { cards: createExtensionInput({ element: coreExtensionData.reactElement, - filter: entityFilterExtensionDataRef, + filter: entityFilterExtensionDataRef.optional(), }), }, loader: async ({ inputs }) => diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 7140259df0..99aacd27da 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -22,8 +22,8 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { createComponentRouteRef, createFromTemplateRouteRef, - unregisterRedirectRouteRef, rootRouteRef, + unregisterRedirectRouteRef, viewTechDocRouteRef, } from '../routes'; diff --git a/yarn.lock b/yarn.lock index 2399f06ac5..90be768746 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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