Add filters to API Explorer (#2830)

* fix: support nested fields in table filter

Add support for nested fields like metadata.name to the table filters. Previously only top-level fields were allowed. In addition, support for filtering on fields that are arrays is introduced (like tags). Filters are now distinct.

* feat: introduce table filters to the api explorer

* fix: allow to close select in multiple mode

* fix: export TableFilter
This commit is contained in:
Oliver Sand
2020-10-13 09:45:25 +02:00
committed by GitHub
parent b041aab8ad
commit 46b9ae82ba
8 changed files with 133 additions and 66 deletions
+22 -26
View File
@@ -14,25 +14,23 @@
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import {
Checkbox,
Chip,
ClickAwayListener,
FormControl,
InputBase,
MenuItem,
Select,
Typography,
} from '@material-ui/core';
import {
createStyles,
makeStyles,
withStyles,
Theme,
withStyles,
} from '@material-ui/core/styles';
import {
FormControl,
Select,
MenuItem,
InputBase,
Chip,
Typography,
Checkbox,
ClickAwayListener,
} from '@material-ui/core';
import React, { useEffect, useState } from 'react';
import ClosedDropdown from './static/ClosedDropdown';
import OpenedDropdown from './static/OpenedDropdown';
@@ -111,7 +109,7 @@ export const SelectComponent = (props: SelectProps) => {
const [value, setValue] = useState<any[] | string | number>(
multiple ? [] : '',
);
const [canOpen, setCanOpen] = React.useState(false);
const [isOpen, setOpen] = useState(false);
useEffect(() => {
setValue(multiple ? [] : '');
@@ -122,19 +120,17 @@ export const SelectComponent = (props: SelectProps) => {
onChange(event.target.value);
};
const selectHandleOnOpen = () => {
setCanOpen(previous => {
if (multiple) {
const handleClick = (event: React.ChangeEvent<any>) => {
setOpen(previous => {
if (multiple && !(event.target instanceof HTMLElement)) {
return true;
}
return !previous;
});
};
const handleClickAway = (event: React.ChangeEvent<any>) => {
if (event.target.id !== 'menu-item') {
setCanOpen(false);
}
const handleClickAway = () => {
setOpen(false);
};
const handleDelete = (selectedValue: string | number) => () => {
@@ -154,8 +150,8 @@ export const SelectComponent = (props: SelectProps) => {
displayEmpty
multiple={multiple}
onChange={handleChange}
onClick={selectHandleOnOpen}
open={canOpen}
onClick={handleClick}
open={isOpen}
input={<BootstrapInput />}
renderValue={selected =>
multiple && (value as any[]).length !== 0 ? (
@@ -181,7 +177,7 @@ export const SelectComponent = (props: SelectProps) => {
)
}
IconComponent={() =>
!canOpen ? <ClosedDropdown /> : <OpenedDropdown />
!isOpen ? <ClosedDropdown /> : <OpenedDropdown />
}
MenuProps={{
anchorOrigin: {
@@ -200,7 +196,7 @@ export const SelectComponent = (props: SelectProps) => {
)}
{items &&
items.map(item => (
<MenuItem id="menu-item" key={item.value} value={item.value}>
<MenuItem key={item.value} value={item.value}>
{multiple && (
<Checkbox
color="primary"
@@ -50,7 +50,7 @@ const useSubvalueCellStyles = makeStyles<BackstageTheme>(theme => ({
},
}));
type Without<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Without<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Filter = {
type: 'select' | 'checkbox-tree' | 'multiple-select';
+84 -23
View File
@@ -45,7 +45,9 @@ import MTable, {
Options,
} from 'material-table';
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
import { Filters, SelectedFilters } from './Filters';
import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
import { SelectProps } from '../Select/Select';
import { Filter, Filters, SelectedFilters, Without } from './Filters';
const tableIcons = {
Add: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
@@ -101,6 +103,23 @@ const tableIcons = {
)),
};
// TODO: Material table might already have such a function internally that we can use?
function extractValueByField(data: any, field: string): any | undefined {
const path = field.split('.');
let value = data[path[0]];
for (let i = 1; i < path.length; ++i) {
if (value === undefined) {
return value;
}
const f = path[i];
value = value[f];
}
return value;
}
const useHeaderStyles = makeStyles<BackstageTheme>(theme => ({
header: {
padding: theme.spacing(1, 2, 1, 2.5),
@@ -234,11 +253,21 @@ export function Table<T extends object = {}>({
el =>
!!Object.entries(selectedFilters)
.filter(([, value]) => !!value.length)
.every(([key, value]) => {
if (Array.isArray(value)) {
return value.includes(el[getFieldByTitle(key)]);
.every(([key, filterValue]) => {
const fieldValue = extractValueByField(
el,
getFieldByTitle(key) as string,
);
if (Array.isArray(fieldValue) && Array.isArray(filterValue)) {
return fieldValue.some(v => filterValue.includes(v));
} else if (Array.isArray(fieldValue)) {
return fieldValue.includes(filterValue);
} else if (Array.isArray(filterValue)) {
return filterValue.includes(fieldValue);
}
return el[getFieldByTitle(key)] === value;
return fieldValue === filterValue;
}),
);
setTableData(newData);
@@ -248,29 +277,61 @@ export function Table<T extends object = {}>({
setSelectedFiltersLength(selectedFiltersArray.flat().length);
}, [data, selectedFilters, getFieldByTitle]);
const constructFilters = (filterConfig: TableFilter[], dataValue: any[]) => {
const extractColumnData = (column: string | keyof T) =>
dataValue.map(el => ({ label: el[column], options: [] }));
const constructFilters = (
filterConfig: TableFilter[],
dataValue: any[],
): Filter[] => {
const extractDistinctValues = (field: string | keyof T): Set<any> => {
const distinctValues = new Set<any>();
const addValue = (value: any) => {
if (value !== undefined && value !== null) {
distinctValues.add(value);
}
};
dataValue.forEach(el => {
const value = extractValueByField(el, getFieldByTitle(field) as string);
if (Array.isArray(value)) {
(value as []).forEach(addValue);
} else {
addValue(value);
}
});
return distinctValues;
};
const constructCheckboxTree = (
filter: TableFilter,
): Without<CheckboxTreeProps, 'onChange'> => ({
label: filter.column,
subCategories: [...extractDistinctValues(filter.column)].map(v => ({
label: v,
options: [],
})),
});
const constructSelect = (
filter: TableFilter,
): Without<SelectProps, 'onChange'> => {
return {
placeholder: 'All results',
label: filter.column,
multiple: filter.type === 'multiple-select',
items: [...extractDistinctValues(filter.column)].map(value => ({
label: value,
value,
})),
};
};
return filterConfig.map(filter => ({
type: filter.type,
element:
filter.type === 'checkbox-tree'
? {
label: filter.column,
subCategories: extractColumnData(
getFieldByTitle(filter.column) || '',
),
}
: {
placeholder: 'All results',
label: filter.column,
multiple: filter.type === 'multiple-select',
items: dataValue.map(el => ({
label: el[getFieldByTitle(filter.column) || ''],
value: el[getFieldByTitle(filter.column) || ''],
})),
},
? constructCheckboxTree(filter)
: constructSelect(filter),
}));
};
+2 -2
View File
@@ -14,6 +14,6 @@
* limitations under the License.
*/
export { Table } from './Table';
export type { TableColumn, TableProps } from './Table';
export { SubvalueCell } from './SubvalueCell';
export { Table } from './Table';
export type { TableColumn, TableFilter, TableProps } from './Table';
@@ -72,6 +72,6 @@ describe('ApiCatalogPage', () => {
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const { findByText } = renderWrapped(<ApiExplorerPage />);
expect(await findByText(/APIs \(2\)/)).toBeInTheDocument();
expect(await findByText(/Backstage API Explorer/)).toBeInTheDocument();
});
});
@@ -44,7 +44,6 @@ export const ApiExplorerPage = () => {
<SupportButton>All your APIs</SupportButton>
</ContentHeader>
<ApiExplorerTable
titlePreamble="APIs"
entities={matchingEntities!}
loading={loading}
error={error}
@@ -53,7 +53,6 @@ describe('ApiCatalogTable component', () => {
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable
titlePreamble="APIs"
entities={[]}
loading={false}
error={{ code: 'error' }}
@@ -71,15 +70,10 @@ describe('ApiCatalogTable component', () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable
titlePreamble="APIs"
entities={entites}
loading={false}
/>
<ApiExplorerTable entities={entites} loading={false} />
</ApiProvider>,
),
);
expect(rendered.getByText(/APIs \(3\)/)).toBeInTheDocument();
expect(rendered.getByText(/api1/)).toBeInTheDocument();
expect(rendered.getByText(/api2/)).toBeInTheDocument();
expect(rendered.getByText(/api3/)).toBeInTheDocument();
@@ -15,7 +15,7 @@
*/
import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model';
import { Table, TableColumn, useApi } from '@backstage/core';
import { Table, TableFilter, TableColumn, useApi } from '@backstage/core';
import { Chip, Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
@@ -90,9 +90,27 @@ const columns: TableColumn<Entity>[] = [
},
];
const filters: TableFilter[] = [
{
column: 'Owner',
type: 'select',
},
{
column: 'Type',
type: 'multiple-select',
},
{
column: 'Lifecycle',
type: 'multiple-select',
},
{
column: 'Tags',
type: 'checkbox-tree',
},
];
type ExplorerTableProps = {
entities: Entity[];
titlePreamble: string;
loading: boolean;
error?: any;
};
@@ -101,7 +119,6 @@ export const ApiExplorerTable = ({
entities,
loading,
error,
titlePreamble,
}: ExplorerTableProps) => {
if (error) {
return (
@@ -114,7 +131,7 @@ export const ApiExplorerTable = ({
}
return (
<Table<Entity>
<Table
isLoading={loading}
columns={columns}
options={{
@@ -123,8 +140,8 @@ export const ApiExplorerTable = ({
loadingType: 'linear',
showEmptyDataSourceMessage: !loading,
}}
title={`${titlePreamble} (${(entities && entities.length) || 0})`}
data={entities}
filters={filters}
/>
);
};