Merge pull request #16348 from DavidAn830/daan/add-filter

Adds namespace filter and column to default catalog table
This commit is contained in:
Ben Lambert
2023-04-24 14:11:27 +02:00
committed by GitHub
14 changed files with 402 additions and 7 deletions
+25
View File
@@ -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
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityTagPicker />
/* if you want namespace picker */
<EntityNamespacePicker />
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<CatalogTable columns={columns} actions={actions} />
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
```
The namespace column can be added using `createNamespaceColumn();`. This is only needed if you customized the columns for CatalogTable.
+18
View File
@@ -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<TEntity extends Entity = Entity> = {
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);
@@ -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 (
<Box pb={1} pt={1}>
<Typography variant="button" component="label">
@@ -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('<EntityNamespacePicker/>', () => {
const mockCatalogApiRef = {
getEntityFacets: async () => ({
facets: {
'metadata.namespace': namespaces.map((value, idx) => ({
value,
count: idx,
})),
},
}),
} as unknown as CatalogApi;
it('renders all namespaces', async () => {
render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider value={{}}>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider value={{}}>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters,
}}
>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
namespace: new EntityNamespaceFilter(['namespace-1']),
}),
);
});
it('adds namespaces to filters', async () => {
const updateFilters = jest.fn();
render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider
value={{
updateFilters,
}}
>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider
value={{
updateFilters,
filters: { namespace: new EntityNamespaceFilter(['namespace-2']) },
}}
>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { namespace: ['namespace-1'] },
}}
>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
namespace: new EntityNamespaceFilter(['namespace-1']),
}),
);
rendered.rerender(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { namespace: ['namespace-2'] },
}}
>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRefNoNamespace]]}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { namespace: ['namespace-1'] },
}}
>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
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(
<TestApiProvider
apis={[[catalogApiRef, mockCatalogApiRefDefaultNamespace]]}
>
<MockEntityListContextProvider value={{}}>
<EntityNamespacePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.queryByText('Namespace')).not.toBeInTheDocument(),
);
});
});
@@ -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 (
<EntityAutocompletePicker
label="Namespace"
name="namespace"
path="metadata.namespace"
Filter={EntityNamespaceFilter}
InputProps={{ className: classes.input }}
/>
);
};
@@ -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';
@@ -29,3 +29,4 @@ export * from './InspectEntityDialog';
export * from './UnregisterEntityDialog';
export * from './UserListPicker';
export * from './EntityProcessingStatusPicker';
export * from './EntityNamespacePicker';
+16
View File
@@ -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
@@ -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 */
+1
View File
@@ -160,6 +160,7 @@ export const CatalogTable: {
}
| undefined,
): TableColumn<CatalogTableRow>;
createNamespaceColumn(): TableColumn<CatalogTableRow>;
}>;
};
@@ -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) {
<EntityLifecyclePicker />
<EntityTagPicker />
<EntityProcessingStatusPicker />
<EntityNamespacePicker />
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<CatalogTable
@@ -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',
@@ -89,6 +89,12 @@ export const CatalogTable = (props: CatalogTableProps) => {
];
function createEntitySpecificColumns(): TableColumn<CatalogTableRow>[] {
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
@@ -193,4 +193,11 @@ export const columnFactories = Object.freeze({
width: 'auto',
};
},
createNamespaceColumn(): TableColumn<CatalogTableRow> {
return {
title: 'Namespace',
field: 'entity.metadata.namespace',
width: 'auto',
};
},
});