From d19d87dc131c1f13102ad23ef7e2d8f8a8ecee28 Mon Sep 17 00:00:00 2001 From: David An Date: Mon, 13 Feb 2023 14:08:41 -0800 Subject: [PATCH 01/13] add namespace filter and column Signed-off-by: David An --- .../EntityNamespacePicker.tsx | 139 ++++++++++++++++++ .../components/EntityNamespacePicker/index.ts | 18 +++ plugins/catalog-react/src/components/index.ts | 1 + plugins/catalog-react/src/filters.ts | 16 ++ .../src/hooks/useEntityListProvider.tsx | 2 + .../CatalogPage/DefaultCatalogPage.tsx | 2 + .../components/CatalogTable/CatalogTable.tsx | 19 ++- .../src/components/CatalogTable/columns.tsx | 7 + 8 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx create mode 100644 plugins/catalog-react/src/components/EntityNamespacePicker/index.ts diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx new file mode 100644 index 0000000000..30df7fc175 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx @@ -0,0 +1,139 @@ +/* + * Copyright 2021 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 { + Box, + Checkbox, + FormControlLabel, + makeStyles, + TextField, + Typography, +} from '@material-ui/core'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { Autocomplete } from '@material-ui/lab'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useEntityList } from '../../hooks/useEntityListProvider'; +import { EntityNamespaceFilter } from '../../filters'; + +/** @public */ +export type CatalogReactEntityNamespacePickerClassKey = 'input'; + +const useStyles = makeStyles( + { + input: {}, + }, + { + name: 'CatalogReactEntityNamespacePicker', + }, +); + +const icon = ; +const checkedIcon = ; + +/** @public */ +export const EntityNamespacePicker = (props: { initialFilter?: string[] }) => { + const { initialFilter = [] } = props; + const classes = useStyles(); + const { + updateFilters, + backendEntities, + filters, + queryParameters: { namespace: namespaceParameter }, + } = useEntityList(); + + const queryParamNamespace = useMemo( + () => [namespaceParameter].flat().filter(Boolean) as string[], + [namespaceParameter], + ); + + const [selectedNamespace, setSelectedNamespace] = useState( + queryParamNamespace.length + ? queryParamNamespace + : filters.namespace?.values ?? initialFilter, + ); + + // Set selected namespace on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamNamespace.length) { + setSelectedNamespace(queryParamNamespace); + } + }, [queryParamNamespace]); + + const availableNamespace = useMemo( + () => + [ + ...new Set( + backendEntities + .map((e: Entity) => e.metadata.namespace) + .filter(Boolean) as string[], + ), + ].sort(), + [backendEntities], + ); + + useEffect(() => { + updateFilters({ + namespace: + selectedNamespace.length && availableNamespace.length + ? new EntityNamespaceFilter(selectedNamespace) + : undefined, + }); + }, [selectedNamespace, updateFilters, availableNamespace]); + + if (!availableNamespace.length) return null; + + if (availableNamespace.every(namespace => namespace === 'default')) + return null; + + return ( + + + Namespace + setSelectedNamespace(value)} + renderOption={(option, { selected }) => ( + + } + label={option} + /> + )} + size="small" + popupIcon={} + renderInput={params => ( + + )} + /> + + + ); +}; diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/index.ts b/plugins/catalog-react/src/components/EntityNamespacePicker/index.ts new file mode 100644 index 0000000000..d08536ea2c --- /dev/null +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 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 { EntityNamespacePicker } from './EntityNamespacePicker'; +export type { CatalogReactEntityNamespacePickerClassKey } from './EntityNamespacePicker'; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index a4380ddef0..6d8aa393ed 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -29,3 +29,4 @@ export * from './InspectEntityDialog'; export * from './UnregisterEntityDialog'; export * from './UserListPicker'; export * from './EntityProcessingStatusPicker'; +export * from './EntityNamespacePicker'; diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts index 056b3b92c1..2fc2b3b382 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -149,6 +149,22 @@ export class EntityLifecycleFilter implements EntityFilter { } } +/** + * Filters entities on namespace. + * @public + */ +export class EntityNamespaceFilter implements EntityFilter { + constructor(readonly values: string[]) {} + + filterEntity(entity: Entity): boolean { + return this.values.some(v => entity.metadata.namespace === v); + } + + toQueryValue(): string[] { + return this.values; + } +} + /** * Filters entities based on whatever the user has starred or owns them. * @public diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index b5dfb4d8f7..3a20c814c0 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -40,6 +40,7 @@ import { EntityTextFilter, EntityTypeFilter, UserListFilter, + EntityNamespaceFilter, } from '../filters'; import { EntityFilter } from '../types'; import { reduceCatalogFilters, reduceEntityFilters } from '../utils'; @@ -56,6 +57,7 @@ export type DefaultEntityFilters = { text?: EntityTextFilter; orphan?: EntityOrphanFilter; error?: EntityErrorFilter; + namespace?: EntityNamespaceFilter; }; /** @public */ diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index 644453f5fb..b0fb52b4f0 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -35,6 +35,7 @@ import { UserListFilterKind, UserListPicker, EntityKindPicker, + EntityNamespacePicker, } from '@backstage/plugin-catalog-react'; import React, { ReactNode } from 'react'; import { createComponentRouteRef } from '../../routes'; @@ -90,6 +91,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { + { ]; function createEntitySpecificColumns(): TableColumn[] { + const baseColumns = [ + columnFactories.createSystemColumn(), + columnFactories.createOwnerColumn(), + columnFactories.createSpecTypeColumn(), + columnFactories.createSpecLifecycleColumn(), + ]; switch (filters.kind?.value) { case 'user': return []; @@ -104,15 +110,14 @@ export const CatalogTable = (props: CatalogTableProps) => { columnFactories.createSpecTargetsColumn(), ]; default: - return [ - columnFactories.createSystemColumn(), - columnFactories.createOwnerColumn(), - columnFactories.createSpecTypeColumn(), - columnFactories.createSpecLifecycleColumn(), - ]; + return entities.every( + entity => entity.metadata.namespace === 'default', + ) + ? baseColumns + : [...baseColumns, columnFactories.createNamespaceColumn()]; } } - }, [filters.kind?.value]); + }, [filters.kind?.value, entities]); const showTypeColumn = filters.type === undefined; // TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 7f3cd5ef82..fc6e1aaf6b 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -193,4 +193,11 @@ export const columnFactories = Object.freeze({ width: 'auto', }; }, + createNamespaceColumn(): TableColumn { + return { + title: 'Namespace', + field: 'entity.metadata.namespace', + width: 'auto', + }; + }, }); From 41cfee7db16bf3155fd79a571e3e515b958cccc3 Mon Sep 17 00:00:00 2001 From: David An Date: Tue, 14 Feb 2023 13:44:05 -0800 Subject: [PATCH 02/13] add test Signed-off-by: David An --- .../EntityNamespacePicker.test.tsx | 234 ++++++++++++++++++ .../CatalogTable/CatalogTable.test.tsx | 5 + 2 files changed, 239 insertions(+) create mode 100644 plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx new file mode 100644 index 0000000000..12acdc9f46 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx @@ -0,0 +1,234 @@ +/* + * Copyright 2021 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 { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { EntityNamespaceFilter } from '../../filters'; +import { EntityNamespacePicker } from './EntityNamespacePicker'; + +const sampleEntities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-1', + namespace: 'namespace-1', + }, + spec: { + lifecycle: 'production', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-2', + namespace: 'namespace-2', + }, + spec: { + lifecycle: 'experimental', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-3', + namespace: 'namespace-3', + }, + spec: { + lifecycle: 'experimental', + }, + }, +]; + +describe('', () => { + it('renders all namespaces', () => { + render( + + + , + ); + expect(screen.getByText('Namespace')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('namespace-picker-expand')); + sampleEntities + .map(e => e.metadata.namespace!) + .forEach(namespace => { + expect(screen.getByText(namespace as string)).toBeInTheDocument(); + }); + }); + + it('renders unique namespaces in alphabetical order', () => { + render( + + + , + ); + expect(screen.getByText('Namespace')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('namespace-picker-expand')); + + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ + 'namespace-1', + 'namespace-2', + 'namespace-3', + ]); + }); + + it('respects the query parameter filter value', () => { + const updateFilters = jest.fn(); + const queryParameters = { namespace: ['namespace-1'] }; + render( + + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-1']), + }); + }); + + it('adds namespaces to filters', () => { + const updateFilters = jest.fn(); + render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: undefined, + }); + + fireEvent.click(screen.getByTestId('namespace-picker-expand')); + fireEvent.click(screen.getByText('namespace-2')); + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-2']), + }); + }); + + it('removes namespaces from filters', () => { + const updateFilters = jest.fn(); + render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-2']), + }); + fireEvent.click(screen.getByTestId('namespace-picker-expand')); + expect(screen.getByLabelText('namespace-2')).toBeChecked(); + + fireEvent.click(screen.getByLabelText('namespace-2')); + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: undefined, + }); + }); + + it('responds to external queryParameters changes', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-1']), + }); + rendered.rerender( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-2']), + }); + }); + it('removes namespaces from filters if there are no available namespaces', () => { + const updateFilters = jest.fn(); + render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: undefined, + }); + }); + it('responds to initialFilter prop', () => { + const updateFilters = jest.fn(); + render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-2']), + }); + }); +}); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index f8a655413e..c80b340349 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -186,6 +186,7 @@ describe('CatalogTable component', () => { 'Owner', 'Type', 'Lifecycle', + 'Namespace', 'Description', 'Tags', 'Actions', @@ -199,6 +200,7 @@ describe('CatalogTable component', () => { 'Owner', 'Type', 'Lifecycle', + 'Namespace', 'Description', 'Tags', 'Actions', @@ -231,6 +233,7 @@ describe('CatalogTable component', () => { 'Owner', 'Type', 'Lifecycle', + 'Namespace', 'Description', 'Tags', 'Actions', @@ -256,6 +259,7 @@ describe('CatalogTable component', () => { 'Owner', 'Type', 'Lifecycle', + 'Namespace', 'Description', 'Tags', 'Actions', @@ -269,6 +273,7 @@ describe('CatalogTable component', () => { 'Owner', 'Type', 'Lifecycle', + 'Namespace', 'Description', 'Tags', 'Actions', From 2258dcae970b168aed5653a22e4ac4f6e5d769b2 Mon Sep 17 00:00:00 2001 From: David An Date: Tue, 14 Feb 2023 13:54:44 -0800 Subject: [PATCH 03/13] changeset added Signed-off-by: David An --- .changeset/funny-trains-sniff.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/funny-trains-sniff.md diff --git a/.changeset/funny-trains-sniff.md b/.changeset/funny-trains-sniff.md new file mode 100644 index 0000000000..1021ad061d --- /dev/null +++ b/.changeset/funny-trains-sniff.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-catalog': minor +--- + +Added namespace filter and column in default catalog table From bd58707a4afdfc6fd5203451030725d5795f7f15 Mon Sep 17 00:00:00 2001 From: David An Date: Tue, 14 Feb 2023 15:52:59 -0800 Subject: [PATCH 04/13] update api report Signed-off-by: David An --- plugins/catalog-react/api-report.md | 20 ++++++++++++++++++++ plugins/catalog/api-report.md | 1 + 2 files changed, 21 insertions(+) diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 095b6832a4..5273fad76e 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -84,6 +84,9 @@ export type CatalogReactComponentsNameToClassKey = { // @public (undocumented) export type CatalogReactEntityLifecyclePickerClassKey = 'input'; +// @public (undocumented) +export type CatalogReactEntityNamespacePickerClassKey = 'input'; + // @public (undocumented) export type CatalogReactEntityOwnerPickerClassKey = 'input'; @@ -136,6 +139,7 @@ export type DefaultEntityFilters = { text?: EntityTextFilter; orphan?: EntityOrphanFilter; error?: EntityErrorFilter; + namespace?: EntityNamespaceFilter; }; // @public @@ -233,6 +237,22 @@ export type EntityLoadingStatus = { refresh?: VoidFunction; }; +// @public +export class EntityNamespaceFilter implements EntityFilter { + constructor(values: string[]); + // (undocumented) + filterEntity(entity: Entity): boolean; + // (undocumented) + toQueryValue(): string[]; + // (undocumented) + readonly values: string[]; +} + +// @public (undocumented) +export const EntityNamespacePicker: (props: { + initialFilter?: string[]; +}) => JSX.Element | null; + // @public export class EntityOrphanFilter implements EntityFilter { constructor(value: boolean); diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 6d746f6a7c..6479163e33 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -158,6 +158,7 @@ export const CatalogTable: { } | undefined, ): TableColumn; + createNamespaceColumn(): TableColumn; }>; }; From 01641467adc0d3995f1b6ca991ab7c48542e046d Mon Sep 17 00:00:00 2001 From: daan Date: Fri, 24 Mar 2023 12:14:35 -0700 Subject: [PATCH 05/13] Update plugins/catalog-react/src/filters.ts Co-authored-by: Tim Hansen Signed-off-by: daan --- plugins/catalog-react/src/filters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts index 2fc2b3b382..ad4f43805e 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -150,7 +150,7 @@ export class EntityLifecycleFilter implements EntityFilter { } /** - * Filters entities on namespace. + * Filters entities to those within the given namespace(s). * @public */ export class EntityNamespaceFilter implements EntityFilter { From 6b71aa51045c068bdb36d41d61254db0e07d4ac5 Mon Sep 17 00:00:00 2001 From: David An Date: Tue, 28 Mar 2023 10:19:21 -0600 Subject: [PATCH 06/13] hide filter when only option is default Signed-off-by: David An --- .../EntityAutocompletePicker/EntityAutocompletePicker.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index b81d681e6d..71a4029b39 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -121,6 +121,9 @@ export function EntityAutocompletePicker< return null; } + // hides picker if only option available is 'default' + if (availableOptions.every(namespace => namespace === 'default')) return null; + return ( From 1c9a625425f9deb870ea0354fccadd32c6ed47c6 Mon Sep 17 00:00:00 2001 From: David An Date: Tue, 28 Mar 2023 10:20:29 -0600 Subject: [PATCH 07/13] use EntityAutocompletePicker component Signed-off-by: David An --- .../EntityNamespacePicker.tsx | 116 ++---------------- 1 file changed, 12 insertions(+), 104 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx index 30df7fc175..98bcc610b2 100644 --- a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx @@ -14,22 +14,11 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; -import { - Box, - Checkbox, - FormControlLabel, - makeStyles, - TextField, - Typography, -} from '@material-ui/core'; -import CheckBoxIcon from '@material-ui/icons/CheckBox'; -import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import { Autocomplete } from '@material-ui/lab'; -import React, { useEffect, useMemo, useState } from 'react'; -import { useEntityList } from '../../hooks/useEntityListProvider'; +import { makeStyles } from '@material-ui/core'; + +import React from 'react'; import { EntityNamespaceFilter } from '../../filters'; +import { EntityAutocompletePicker } from '../EntityAutocompletePicker'; /** @public */ export type CatalogReactEntityNamespacePickerClassKey = 'input'; @@ -43,97 +32,16 @@ const useStyles = makeStyles( }, ); -const icon = ; -const checkedIcon = ; - /** @public */ -export const EntityNamespacePicker = (props: { initialFilter?: string[] }) => { - const { initialFilter = [] } = props; +export const EntityNamespacePicker = () => { const classes = useStyles(); - const { - updateFilters, - backendEntities, - filters, - queryParameters: { namespace: namespaceParameter }, - } = useEntityList(); - - const queryParamNamespace = useMemo( - () => [namespaceParameter].flat().filter(Boolean) as string[], - [namespaceParameter], - ); - - const [selectedNamespace, setSelectedNamespace] = useState( - queryParamNamespace.length - ? queryParamNamespace - : filters.namespace?.values ?? initialFilter, - ); - - // Set selected namespace on query parameter updates; this happens at initial page load and from - // external updates to the page location. - useEffect(() => { - if (queryParamNamespace.length) { - setSelectedNamespace(queryParamNamespace); - } - }, [queryParamNamespace]); - - const availableNamespace = useMemo( - () => - [ - ...new Set( - backendEntities - .map((e: Entity) => e.metadata.namespace) - .filter(Boolean) as string[], - ), - ].sort(), - [backendEntities], - ); - - useEffect(() => { - updateFilters({ - namespace: - selectedNamespace.length && availableNamespace.length - ? new EntityNamespaceFilter(selectedNamespace) - : undefined, - }); - }, [selectedNamespace, updateFilters, availableNamespace]); - - if (!availableNamespace.length) return null; - - if (availableNamespace.every(namespace => namespace === 'default')) - return null; - return ( - - - Namespace - setSelectedNamespace(value)} - renderOption={(option, { selected }) => ( - - } - label={option} - /> - )} - size="small" - popupIcon={} - renderInput={params => ( - - )} - /> - - + ); }; From f38ce29b61988e075122a13f65b528586d3b22b4 Mon Sep 17 00:00:00 2001 From: David An Date: Tue, 28 Mar 2023 11:50:34 -0600 Subject: [PATCH 08/13] add notes about picker and filter Signed-off-by: David An --- .changeset/funny-trains-sniff.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.changeset/funny-trains-sniff.md b/.changeset/funny-trains-sniff.md index 1021ad061d..91dbd28c28 100644 --- a/.changeset/funny-trains-sniff.md +++ b/.changeset/funny-trains-sniff.md @@ -3,4 +3,23 @@ '@backstage/plugin-catalog': minor --- -Added namespace filter and column in default catalog table +Added an entity namespace filter and column on the default catalog page. + +If you have a custom version of the catalog page, you can add this filter in your CatalogPage code: + +```ts + + + + + + /* if you want namespace picker */ + + + + + + +``` + +The namespace column can be added using `createNamespaceColumn();`. This is only needed if you customized the columns for CatalogTable. From 4a16ac1f0b15ab9887c4b1ea92de658acb48e477 Mon Sep 17 00:00:00 2001 From: David An Date: Tue, 28 Mar 2023 13:29:40 -0600 Subject: [PATCH 09/13] fix test Signed-off-by: David An --- .../EntityNamespacePicker.test.tsx | 282 +++++++++--------- 1 file changed, 134 insertions(+), 148 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx index 12acdc9f46..c92d7f6236 100644 --- a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx @@ -14,77 +14,58 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityNamespaceFilter } from '../../filters'; import { EntityNamespacePicker } from './EntityNamespacePicker'; +import { TestApiProvider } from '@backstage/test-utils'; +import { catalogApiRef } from '../../api'; +import { CatalogApi } from '@backstage/catalog-client'; -const sampleEntities: Entity[] = [ - { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'component-1', - namespace: 'namespace-1', - }, - spec: { - lifecycle: 'production', - }, - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'component-2', - namespace: 'namespace-2', - }, - spec: { - lifecycle: 'experimental', - }, - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'component-3', - namespace: 'namespace-3', - }, - spec: { - lifecycle: 'experimental', - }, - }, -]; +const namespaces = ['namespace-1', 'namespace-2', 'namespace-3']; describe('', () => { - it('renders all namespaces', () => { + const mockCatalogApiRef = { + getEntityFacets: async () => ({ + facets: { + 'metadata.namespace': namespaces.map((value, idx) => ({ + value, + count: idx, + })), + }, + }), + } as unknown as CatalogApi; + + it('renders all namespaces', async () => { render( - - - , + + + + + , + ); + await waitFor(() => + expect(screen.getByText('Namespace')).toBeInTheDocument(), ); - expect(screen.getByText('Namespace')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('namespace-picker-expand')); - sampleEntities - .map(e => e.metadata.namespace!) - .forEach(namespace => { - expect(screen.getByText(namespace as string)).toBeInTheDocument(); - }); + namespaces.forEach(namespace => { + expect(screen.getByText(namespace as string)).toBeInTheDocument(); + }); }); - it('renders unique namespaces in alphabetical order', () => { + it('renders unique namespaces in alphabetical order', async () => { render( - - - , + + + + + , + ); + await waitFor(() => + expect(screen.getByText('Namespace')).toBeInTheDocument(), ); - expect(screen.getByText('Namespace')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('namespace-picker-expand')); @@ -95,43 +76,47 @@ describe('', () => { ]); }); - it('respects the query parameter filter value', () => { + it('respects the query parameter filter value', async () => { const updateFilters = jest.fn(); const queryParameters = { namespace: ['namespace-1'] }; render( - - - , + + + + + , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - namespace: new EntityNamespaceFilter(['namespace-1']), - }); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-1']), + }), + ); }); - it('adds namespaces to filters', () => { + it('adds namespaces to filters', async () => { const updateFilters = jest.fn(); render( - - - , + + + + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: undefined, + }), ); - expect(updateFilters).toHaveBeenLastCalledWith({ - namespace: undefined, - }); fireEvent.click(screen.getByTestId('namespace-picker-expand')); fireEvent.click(screen.getByText('namespace-2')); @@ -140,23 +125,26 @@ describe('', () => { }); }); - it('removes namespaces from filters', () => { + it('removes namespaces from filters', async () => { const updateFilters = jest.fn(); render( - - - , + + + + + , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - namespace: new EntityNamespaceFilter(['namespace-2']), - }); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-2']), + }), + ); + fireEvent.click(screen.getByTestId('namespace-picker-expand')); expect(screen.getByLabelText('namespace-2')).toBeChecked(); @@ -166,69 +154,67 @@ describe('', () => { }); }); - it('responds to external queryParameters changes', () => { + it('responds to external queryParameters changes', async () => { const updateFilters = jest.fn(); const rendered = render( - - - , + + + + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-1']), + }), ); - expect(updateFilters).toHaveBeenLastCalledWith({ - namespace: new EntityNamespaceFilter(['namespace-1']), - }); rendered.rerender( - - - , + + + + + , ); expect(updateFilters).toHaveBeenLastCalledWith({ namespace: new EntityNamespaceFilter(['namespace-2']), }); }); - it('removes namespaces from filters if there are no available namespaces', () => { + it('removes namespaces from filters if there are no available namespaces', async () => { const updateFilters = jest.fn(); + const mockCatalogApiRefNoNamespace = { + getEntityFacets: async () => ({ + facets: { + 'metadata.tags': {}, + }, + }), + } as unknown as CatalogApi; + render( - - - , + + + + + , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - namespace: undefined, - }); - }); - it('responds to initialFilter prop', () => { - const updateFilters = jest.fn(); - render( - - - , + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: undefined, + }), ); - expect(updateFilters).toHaveBeenLastCalledWith({ - namespace: new EntityNamespaceFilter(['namespace-2']), - }); }); }); From 8e1674d94c53ee72ce05e41516c056d985d55d9d Mon Sep 17 00:00:00 2001 From: David An Date: Tue, 28 Mar 2023 14:08:57 -0600 Subject: [PATCH 10/13] update namespace picker api report Signed-off-by: David An --- plugins/catalog-react/api-report.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 829a4b7fb6..0e69bca58c 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -244,9 +244,7 @@ export class EntityNamespaceFilter implements EntityFilter { } // @public (undocumented) -export const EntityNamespacePicker: (props: { - initialFilter?: string[]; -}) => JSX.Element | null; +export const EntityNamespacePicker: () => JSX.Element; // @public export class EntityOrphanFilter implements EntityFilter { From 9b3ab37e1e9b75a9b68e82c92e6c7456ea1dce32 Mon Sep 17 00:00:00 2001 From: daan Date: Wed, 29 Mar 2023 14:28:09 -0700 Subject: [PATCH 11/13] Update plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx Co-authored-by: Tim Hansen Signed-off-by: daan --- .../EntityAutocompletePicker/EntityAutocompletePicker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index 71a4029b39..709c1b33f8 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -121,8 +121,8 @@ export function EntityAutocompletePicker< return null; } - // hides picker if only option available is 'default' - if (availableOptions.every(namespace => namespace === 'default')) return null; + // Hide if there are 1 or fewer options; nothing to pick from + if (availableOptions.length <= 1) return null; return ( From 235950ed3073ac05d1ddbab46cd5f5d78ce006e2 Mon Sep 17 00:00:00 2001 From: daan Date: Wed, 29 Mar 2023 14:28:19 -0700 Subject: [PATCH 12/13] Update plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx Co-authored-by: Tim Hansen Signed-off-by: daan --- .../components/EntityNamespacePicker/EntityNamespacePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx index 98bcc610b2..c6b59f9275 100644 --- a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. From 8232ad4144764cf29f11673c7015bcc187e665a6 Mon Sep 17 00:00:00 2001 From: David An Date: Wed, 29 Mar 2023 16:07:06 -0600 Subject: [PATCH 13/13] added a case when there is 1 option available Signed-off-by: David An --- .../EntityNamespacePicker.test.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx index c92d7f6236..7dd13620c7 100644 --- a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx @@ -194,7 +194,7 @@ describe('', () => { const mockCatalogApiRefNoNamespace = { getEntityFacets: async () => ({ facets: { - 'metadata.tags': {}, + 'metadata.namespace': {}, }, }), } as unknown as CatalogApi; @@ -217,4 +217,29 @@ describe('', () => { }), ); }); + it('namespace picker is invisible if there are only 1 available option', async () => { + const defaultNamespaces = ['default', 'default', 'default']; + const mockCatalogApiRefDefaultNamespace = { + getEntityFacets: async () => ({ + facets: { + 'metadata.namespace': defaultNamespaces.map((value, idx) => ({ + value, + count: idx, + })), + }, + }), + } as unknown as CatalogApi; + render( + + + + + , + ); + await waitFor(() => + expect(screen.queryByText('Namespace')).not.toBeInTheDocument(), + ); + }); });