Merge pull request #16348 from DavidAn830/daan/add-filter
Adds namespace filter and column to default catalog table
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
+3
@@ -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">
|
||||
|
||||
+245
@@ -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';
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user