diff --git a/.changeset/funny-trains-sniff.md b/.changeset/funny-trains-sniff.md new file mode 100644 index 0000000000..91dbd28c28 --- /dev/null +++ b/.changeset/funny-trains-sniff.md @@ -0,0 +1,25 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-catalog': minor +--- + +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. diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 20b10f18cc..f9725e41e8 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -79,6 +79,9 @@ export type CatalogReactComponentsNameToClassKey = { // @public (undocumented) export type CatalogReactEntityLifecyclePickerClassKey = 'input'; +// @public (undocumented) +export type CatalogReactEntityNamespacePickerClassKey = 'input'; + // @public (undocumented) export type CatalogReactEntityOwnerPickerClassKey = 'input'; @@ -131,6 +134,7 @@ export type DefaultEntityFilters = { text?: EntityTextFilter; orphan?: EntityOrphanFilter; error?: EntityErrorFilter; + namespace?: EntityNamespaceFilter; }; // @public @@ -228,6 +232,20 @@ 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: () => JSX.Element; + // @public export class EntityOrphanFilter implements EntityFilter { constructor(value: boolean); diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index b853c2d3fc..3a9a100357 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -118,6 +118,9 @@ export function EntityAutocompletePicker< return null; } + // Hide if there are 1 or fewer options; nothing to pick from + if (availableOptions.length <= 1) return null; + return ( 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..7dd13620c7 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx @@ -0,0 +1,245 @@ +/* + * 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 { 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 namespaces = ['namespace-1', 'namespace-2', 'namespace-3']; + +describe('', () => { + 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(), + ); + + fireEvent.click(screen.getByTestId('namespace-picker-expand')); + namespaces.forEach(namespace => { + expect(screen.getByText(namespace as string)).toBeInTheDocument(); + }); + }); + + it('renders unique namespaces in alphabetical order', async () => { + render( + + + + + , + ); + await waitFor(() => + 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', async () => { + const updateFilters = jest.fn(); + const queryParameters = { namespace: ['namespace-1'] }; + render( + + + + + , + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: new EntityNamespaceFilter(['namespace-1']), + }), + ); + }); + + it('adds namespaces to filters', async () => { + const updateFilters = jest.fn(); + render( + + + + + , + ); + await waitFor(() => + 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', async () => { + const updateFilters = jest.fn(); + render( + + + + + , + ); + await waitFor(() => + 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', async () => { + const updateFilters = jest.fn(); + const rendered = render( + + + + + , + ); + await waitFor(() => + 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', async () => { + const updateFilters = jest.fn(); + const mockCatalogApiRefNoNamespace = { + getEntityFacets: async () => ({ + facets: { + 'metadata.namespace': {}, + }, + }), + } as unknown as CatalogApi; + + render( + + + + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + namespace: undefined, + }), + ); + }); + 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(), + ); + }); +}); 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..c6b59f9275 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.tsx @@ -0,0 +1,47 @@ +/* + * 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 { makeStyles } from '@material-ui/core'; + +import React from 'react'; +import { EntityNamespaceFilter } from '../../filters'; +import { EntityAutocompletePicker } from '../EntityAutocompletePicker'; + +/** @public */ +export type CatalogReactEntityNamespacePickerClassKey = 'input'; + +const useStyles = makeStyles( + { + input: {}, + }, + { + name: 'CatalogReactEntityNamespacePicker', + }, +); + +/** @public */ +export const EntityNamespacePicker = () => { + const classes = useStyles(); + return ( + + ); +}; 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 ad9e917699..c717a35202 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -169,6 +169,22 @@ export class EntityLifecycleFilter implements EntityFilter { } } +/** + * Filters entities to those within the given namespace(s). + * @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 4173724ef5..b4c464a26c 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/api-report.md b/plugins/catalog/api-report.md index ca91296913..970d8d3251 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -160,6 +160,7 @@ export const CatalogTable: { } | undefined, ): TableColumn; + createNamespaceColumn(): TableColumn; }>; }; 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) { + { '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', diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 18080bb7b0..3d0cc75f7c 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -89,6 +89,12 @@ export const CatalogTable = (props: CatalogTableProps) => { ]; 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', + }; + }, });