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:
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user