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',
+ };
+ },
});