From 46b9ae82ba111a7f9ecfed0bcb419357ab413cc3 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Tue, 13 Oct 2020 09:45:25 +0200 Subject: [PATCH] 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 --- .../core/src/components/Select/Select.tsx | 48 ++++---- .../core/src/components/Table/Filters.tsx | 2 +- packages/core/src/components/Table/Table.tsx | 107 ++++++++++++++---- packages/core/src/components/Table/index.ts | 4 +- .../ApiExplorerPage/ApiExplorerPage.test.tsx | 2 +- .../ApiExplorerPage/ApiExplorerPage.tsx | 1 - .../ApiExplorerTable.test.tsx | 8 +- .../ApiExplorerTable/ApiExplorerTable.tsx | 27 ++++- 8 files changed, 133 insertions(+), 66 deletions(-) diff --git a/packages/core/src/components/Select/Select.tsx b/packages/core/src/components/Select/Select.tsx index a2efcc7725..1c691340b2 100644 --- a/packages/core/src/components/Select/Select.tsx +++ b/packages/core/src/components/Select/Select.tsx @@ -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( 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) => { + setOpen(previous => { + if (multiple && !(event.target instanceof HTMLElement)) { return true; } return !previous; }); }; - const handleClickAway = (event: React.ChangeEvent) => { - 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={} renderValue={selected => multiple && (value as any[]).length !== 0 ? ( @@ -181,7 +177,7 @@ export const SelectComponent = (props: SelectProps) => { ) } IconComponent={() => - !canOpen ? : + !isOpen ? : } MenuProps={{ anchorOrigin: { @@ -200,7 +196,7 @@ export const SelectComponent = (props: SelectProps) => { )} {items && items.map(item => ( - + {multiple && ( (theme => ({ }, })); -type Without = Pick>; +export type Without = Pick>; export type Filter = { type: 'select' | 'checkbox-tree' | 'multiple-select'; diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 5d0b7d8344..87ad50e58f 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -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) => ( @@ -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(theme => ({ header: { padding: theme.spacing(1, 2, 1, 2.5), @@ -234,11 +253,21 @@ export function Table({ 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({ 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 => { + const distinctValues = new Set(); + 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 => ({ + label: filter.column, + subCategories: [...extractDistinctValues(filter.column)].map(v => ({ + label: v, + options: [], + })), + }); + + const constructSelect = ( + filter: TableFilter, + ): Without => { + 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), })); }; diff --git a/packages/core/src/components/Table/index.ts b/packages/core/src/components/Table/index.ts index c10399dc59..9cb8478688 100644 --- a/packages/core/src/components/Table/index.ts +++ b/packages/core/src/components/Table/index.ts @@ -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'; diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index e386f281e8..46cf014134 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -72,6 +72,6 @@ describe('ApiCatalogPage', () => { // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { const { findByText } = renderWrapped(); - expect(await findByText(/APIs \(2\)/)).toBeInTheDocument(); + expect(await findByText(/Backstage API Explorer/)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index b6440edc3a..c6a8d62b58 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -44,7 +44,6 @@ export const ApiExplorerPage = () => { All your APIs { wrapInTestApp( { const rendered = render( wrapInTestApp( - + , ), ); - expect(rendered.getByText(/APIs \(3\)/)).toBeInTheDocument(); expect(rendered.getByText(/api1/)).toBeInTheDocument(); expect(rendered.getByText(/api2/)).toBeInTheDocument(); expect(rendered.getByText(/api3/)).toBeInTheDocument(); diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 485fcc216d..f3be9f664b 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -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[] = [ }, ]; +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 ( - + ); };