Persist Table Filters in the API Explorer (#2936)

* fix: allow changing the categories of a checkbox tree

* feat: allow setting the current selection in the checkbox tree

* feat: allow setting the current selection in the select

* feat: add a way to access the tables internal state (filters, search, ...)

* feat: add useQueryParams hook

* feat: persist the table state of the api explorer in the url

* Use react-use instead of writing own hooks

* Resolve review comments

* Rename selectedChilds to selecetedChildren

* Add changesets

* Support passing a separate state name to useQueryParamState

This allows to use the useQueryParamState hook multiple time per route and have a separate state.

* refactor: fix typo...
This commit is contained in:
Oliver Sand
2020-11-09 18:19:47 +01:00
committed by GitHub
parent 091765a32e
commit 0c0798f082
14 changed files with 427 additions and 74 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Extend the table to share its current filter state. The filter state can be used together with the new `useQueryParamState` hook to store the current filter state to the browser history and restore it after navigating to other routes.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-api-docs': patch
---
Persist table state of the API Explorer to the browser history. This allows to navigate between pages and come back to the previous filter state.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Make the selected state of Select and CheckboxTree controllable from outside.
+1
View File
@@ -44,6 +44,7 @@
"d3-shape": "^2.0.0",
"d3-zoom": "^2.0.0",
"dagre": "^0.8.5",
"qs": "^6.9.4",
"immer": "^7.0.9",
"lodash": "^4.17.15",
"material-table": "^1.69.1",
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import React, { useState } from 'react';
import { CheckboxTree } from '.';
const CHECKBOX_TREE_ITEMS = [
@@ -71,3 +71,35 @@ export const Default = () => (
subCategories={CHECKBOX_TREE_ITEMS}
/>
);
export const DynamicTree = () => {
function generateTree(showMore: boolean = false) {
const t = [
{
label: 'Show more',
options: [],
},
];
if (showMore) {
t.push({
label: 'More',
options: [],
});
}
return t;
}
const [tree, setTree] = useState(generateTree());
return (
<CheckboxTree
onChange={state => {
setTree(generateTree(state.some(c => c.category === 'Show more')));
}}
label="default"
subCategories={tree}
/>
);
};
@@ -14,20 +14,22 @@
* limitations under the License.
*/
/* eslint-disable guard-for-in */
import React, { useEffect, useReducer } from 'react';
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import {
Checkbox,
Collapse,
List,
ListItem,
ListItemIcon,
Checkbox,
ListItemText,
Collapse,
Typography,
} from '@material-ui/core';
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import produce from 'immer';
import { isEqual } from 'lodash';
import React, { useEffect, useReducer } from 'react';
import { usePrevious } from 'react-use';
type IndexedObject<T> = {
[key: string]: T;
@@ -96,11 +98,14 @@ type Option = {
isChecked?: boolean;
};
type Selection = { category?: string; selectedChildren?: string[] }[];
export type CheckboxTreeProps = {
subCategories: SubCategory[];
label: string;
triggerReset?: boolean;
onChange: (arg: any) => any;
selected?: Selection;
onChange: (arg: Selection) => any;
};
/* REDUCER */
@@ -114,6 +119,11 @@ type Action =
| { type: 'checkOption'; payload: checkOptionPayload }
| { type: 'checkCategory'; payload: string }
| { type: 'toggleCategory'; payload: string }
| {
type: 'updateCategories';
payload: IndexedObject<SubCategoryWithIndexedOptions>;
}
| { type: 'updateSelected'; payload: Selection }
| { type: 'triggerReset' };
const reducer = (
@@ -157,6 +167,38 @@ const reducer = (
}
});
}
case 'updateCategories': {
return produce(state, newState => {
for (const category in newState) {
delete newState[category];
}
for (const category in action.payload) {
newState[category] = action.payload[category];
if (state[category]) {
newState[category].isChecked = state[category].isChecked;
newState[category].isOpen = state[category].isOpen;
}
}
});
}
case 'updateSelected': {
return produce(state, newState => {
for (const category in newState) {
const selection = action.payload.find(s => s.category === category);
if (selection) {
newState[category].isChecked = true;
for (const option in newState[category].options) {
newState[category].options[option].isChecked =
selection.selectedChildren?.includes(option) || false;
}
}
}
});
}
default:
return state;
}
@@ -183,23 +225,30 @@ const indexer = (
};
}, {});
export const CheckboxTree = (props: CheckboxTreeProps) => {
const { onChange } = props;
export const CheckboxTree = ({
subCategories,
label,
selected,
onChange,
triggerReset,
}: CheckboxTreeProps) => {
const classes = useStyles();
const [state, dispatch] = useReducer(reducer, indexer(props.subCategories));
const [state, dispatch] = useReducer(reducer, indexer(subCategories));
const handleOpen = (event: any, value: any) => {
event.stopPropagation();
dispatch({ type: 'toggleCategory', payload: value });
};
const previousSubCategories = usePrevious(subCategories);
useEffect(() => {
const values = Object.values(state).map(category => ({
category: category.isChecked ? category.label : null,
selectedChilds: Object.values(category.options)
category: category.isChecked ? category.label : undefined,
selectedChildren: Object.values(category.options)
.filter(option => option.isChecked)
.map(option => option.value),
.map(option => option.label),
}));
onChange(values);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -207,11 +256,26 @@ export const CheckboxTree = (props: CheckboxTreeProps) => {
useEffect(() => {
dispatch({ type: 'triggerReset' });
}, [props.triggerReset]);
}, [triggerReset]);
useEffect(() => {
if (selected) {
dispatch({ type: 'updateSelected', payload: selected });
}
}, [selected]);
useEffect(() => {
if (!isEqual(subCategories, previousSubCategories)) {
dispatch({
type: 'updateCategories',
payload: indexer(subCategories),
});
}
}, [subCategories, previousSubCategories]);
return (
<div>
<Typography variant="button">{props.label}</Typography>
<Typography variant="button">{label}</Typography>
<List className={classes.root}>
{Object.values(state).map(item => (
<div key={item.label}>
+27 -11
View File
@@ -94,30 +94,46 @@ type Item = {
value: string | number;
};
type Selection = string | string[] | number | number[];
export type SelectProps = {
multiple?: boolean;
items: Item[];
label: string;
placeholder?: string;
onChange: (arg: any) => any;
selected?: Selection;
onChange: (arg: Selection) => void;
triggerReset?: boolean;
};
export const SelectComponent = (props: SelectProps) => {
const { multiple, items, label, placeholder, onChange } = props;
export const SelectComponent = ({
multiple,
items,
label,
placeholder,
selected,
onChange,
triggerReset,
}: SelectProps) => {
const classes = useStyles();
const [value, setValue] = useState<any[] | string | number>(
multiple ? [] : '',
const [value, setValue] = useState<Selection>(
selected || (multiple ? [] : ''),
);
const [isOpen, setOpen] = useState(false);
useEffect(() => {
setValue(multiple ? [] : '');
}, [props.triggerReset, multiple]);
}, [triggerReset, multiple]);
useEffect(() => {
if (selected !== undefined) {
setValue(selected);
}
}, [selected]);
const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
setValue(event.target.value as any);
onChange(event.target.value);
setValue(event.target.value as Selection);
onChange(event.target.value as Selection);
};
const handleClick = (event: React.ChangeEvent<any>) => {
@@ -153,10 +169,10 @@ export const SelectComponent = (props: SelectProps) => {
onClick={handleClick}
open={isOpen}
input={<BootstrapInput />}
renderValue={selected =>
renderValue={s =>
multiple && (value as any[]).length !== 0 ? (
<div className={classes.chips}>
{(selected as string[]).map(selectedValue => (
{(s as string[]).map(selectedValue => (
<Chip
key={items.find(el => el.value === selectedValue)?.value}
label={
@@ -172,7 +188,7 @@ export const SelectComponent = (props: SelectProps) => {
<Typography>
{(value as any[]).length === 0
? placeholder || ''
: items.find(el => el.value === selected)?.label}
: items.find(el => el.value === s)?.label}
</Typography>
)
}
+23 -13
View File
@@ -65,6 +65,7 @@ export type SelectedFilters = {
type Props = {
filters: Filter[];
selectedFilters?: SelectedFilters;
onChangeFilters: (arg: any) => any;
};
@@ -73,21 +74,20 @@ export const Filters = (props: Props) => {
const { onChangeFilters } = props;
const [filters, setFilters] = useState(props.filters);
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>({});
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>({
...props.selectedFilters,
});
const [reset, triggerReset] = useState(false);
// Trigger re-rendering
const handleClick = () => {
setSelectedFilters({});
setFilters([...props.filters]);
triggerReset(el => !el);
};
useEffect(() => {
onChangeFilters(selectedFilters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFilters]);
}, [selectedFilters, onChangeFilters]);
// As material table doesn't provide a way to add a column filter tab we will make our own filter logic
return (
@@ -99,29 +99,38 @@ export const Filters = (props: Props) => {
</Button>
</div>
<div className={classes.filters}>
{filters?.length &&
filters.map(filter =>
{props.filters?.length &&
props.filters.map(filter =>
filter.type === 'checkbox-tree' ? (
<CheckboxTree
triggerReset={reset}
key={filter.element.label}
{...(filter.element as CheckboxTreeProps)}
selected={
selectedFilters[filter.element.label]
? (selectedFilters[filter.element.label] as string[]).map(
s => ({
category: s,
}),
)
: undefined
}
onChange={el =>
setSelectedFilters({
...selectedFilters,
[filter.element.label]: el
.filter(
(checkboxFilter: any) =>
checkboxFilter.category !== null ||
checkboxFilter.selectedChilds.length,
checkboxFilter.category ||
checkboxFilter.selectedChildren.length,
)
.map((checkboxFilter: any) =>
checkboxFilter.category !== null
checkboxFilter.category
? [
...checkboxFilter.selectedChilds,
...checkboxFilter.selectedChildren,
checkboxFilter.category,
]
: checkboxFilter.selectedChilds,
: checkboxFilter.selectedChildren,
)
.flat(),
})
@@ -132,10 +141,11 @@ export const Filters = (props: Props) => {
triggerReset={reset}
key={filter.element.label}
{...(filter.element as SelectProps)}
selected={selectedFilters[filter.element.label]}
onChange={el =>
setSelectedFilters({
...selectedFilters,
[filter.element.label]: el,
[filter.element.label]: el as any,
})
}
/>
+134 -34
View File
@@ -16,10 +16,10 @@
import { BackstageTheme } from '@backstage/theme';
import {
IconButton,
makeStyles,
Typography,
useTheme,
IconButton,
} from '@material-ui/core';
// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
import AddBox from '@material-ui/icons/AddBox';
@@ -37,6 +37,7 @@ import Remove from '@material-ui/icons/Remove';
import SaveAlt from '@material-ui/icons/SaveAlt';
import Search from '@material-ui/icons/Search';
import ViewColumn from '@material-ui/icons/ViewColumn';
import { isEqual, transform } from 'lodash';
import MTable, {
Column,
MaterialTableProps,
@@ -44,7 +45,13 @@ import MTable, {
MTableToolbar,
Options,
} from 'material-table';
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
import React, {
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
import { SelectProps } from '../Select/Select';
import { Filter, Filters, SelectedFilters, Without } from './Filters';
@@ -188,6 +195,20 @@ function convertColumns<T extends object>(
});
}
function removeDefaultValues(state: any, defaultState: any): any {
return transform(state, (result, value, key) => {
if (!isEqual(value, defaultState[key])) {
result[key] = value;
}
});
}
const defaultInitialState = {
search: '',
filtersOpen: false,
filters: {},
};
export interface TableColumn<T extends object = {}> extends Column<T> {
highlight?: boolean;
width?: string;
@@ -198,11 +219,19 @@ export type TableFilter = {
type: 'select' | 'multiple-select' | 'checkbox-tree';
};
export type TableState = {
search?: string;
filtersOpen?: boolean;
filters?: SelectedFilters;
};
export interface TableProps<T extends object = {}>
extends MaterialTableProps<T> {
columns: TableColumn<T>[];
subtitle?: string;
filters?: TableFilter[];
initialState?: TableState;
onStateChange?: (state: TableState) => any;
}
export function Table<T extends object = {}>({
@@ -211,6 +240,8 @@ export function Table<T extends object = {}>({
title,
subtitle,
filters,
initialState,
onStateChange,
...props
}: TableProps<T>) {
const headerClasses = useHeaderStyles();
@@ -222,13 +253,43 @@ export function Table<T extends object = {}>({
const theme = useTheme<BackstageTheme>();
const [filtersOpen, toggleFilters] = useState(false);
const calculatedInitialState = { ...defaultInitialState, ...initialState };
const [filtersOpen, toggleFilters] = useState(
calculatedInitialState.filtersOpen,
);
const [selectedFiltersLength, setSelectedFiltersLength] = useState(0);
const [tableData, setTableData] = useState(data as any[]);
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>();
const [selectedFilters, setSelectedFilters] = useState(
calculatedInitialState.filters,
);
const MTColumns = convertColumns(columns, theme);
const [search, setSearch] = useState(calculatedInitialState.search);
const toolbarRef = useRef<any>();
useEffect(() => {
if (toolbarRef.current) {
toolbarRef.current.onSearchChange(search);
}
}, [search, toolbarRef]);
useEffect(() => {
if (onStateChange) {
const state = removeDefaultValues(
{
search,
filtersOpen,
filters: selectedFilters,
},
defaultInitialState,
);
onStateChange(state);
}
}, [search, filtersOpen, selectedFilters, onStateChange]);
const defaultOptions: Options<T> = {
headerStyle: {
textTransform: 'uppercase',
@@ -248,7 +309,7 @@ export function Table<T extends object = {}>({
}
const selectedFiltersArray = Object.values(selectedFilters);
if (selectedFiltersArray.flat().length) {
if (data && selectedFiltersArray.flat().length) {
const newData = (data as any[]).filter(
el =>
!!Object.entries(selectedFilters)
@@ -279,7 +340,7 @@ export function Table<T extends object = {}>({
const constructFilters = (
filterConfig: TableFilter[],
dataValue: any[],
dataValue: any[] | undefined,
): Filter[] => {
const extractDistinctValues = (field: string | keyof T): Set<any> => {
const distinctValues = new Set<any>();
@@ -289,15 +350,20 @@ export function Table<T extends object = {}>({
}
};
dataValue.forEach(el => {
const value = extractValueByField(el, getFieldByTitle(field) as string);
if (dataValue) {
dataValue.forEach(el => {
const value = extractValueByField(
el,
getFieldByTitle(field) as string,
);
if (Array.isArray(value)) {
(value as []).forEach(addValue);
} else {
addValue(value);
}
});
if (Array.isArray(value)) {
(value as []).forEach(addValue);
} else {
addValue(value);
}
});
}
return distinctValues;
};
@@ -335,11 +401,63 @@ export function Table<T extends object = {}>({
}));
};
const Toolbar = useCallback(
toolbarProps => {
const onSearchChanged = (searchText: string) => {
toolbarProps.onSearchChanged(searchText);
setSearch(searchText);
};
if (filters?.length) {
return (
<div className={filtersClasses.root}>
<div className={filtersClasses.root}>
<IconButton
onClick={() => toggleFilters(el => !el)}
aria-label="filter list"
>
<FilterList />
</IconButton>
<Typography className={filtersClasses.title}>
Filters ({selectedFiltersLength})
</Typography>
</div>
<MTableToolbar
classes={toolbarClasses}
{...toolbarProps}
ref={toolbarRef}
onSearchChanged={onSearchChanged}
/>
</div>
);
}
return (
<MTableToolbar
classes={toolbarClasses}
{...toolbarProps}
ref={toolbarRef}
onSearchChanged={onSearchChanged}
/>
);
},
[
filters?.length,
selectedFiltersLength,
toggleFilters,
toolbarClasses,
filtersClasses,
setSearch,
toolbarRef,
],
);
return (
<div className={tableClasses.root}>
{filtersOpen && filters?.length && (
{filtersOpen && data && filters?.length && (
<Filters
filters={constructFilters(filters, data as any[])}
selectedFilters={selectedFilters}
onChangeFilters={setSelectedFilters}
/>
)}
@@ -348,25 +466,7 @@ export function Table<T extends object = {}>({
Header: headerProps => (
<MTableHeader classes={headerClasses} {...headerProps} />
),
Toolbar: toolbarProps =>
filters?.length ? (
<div className={filtersClasses.root}>
<div className={filtersClasses.root}>
<IconButton
onClick={() => toggleFilters(el => !el)}
aria-label="filter list"
>
<FilterList />
</IconButton>
<Typography className={filtersClasses.title}>
Filters ({selectedFiltersLength})
</Typography>
</div>
<MTableToolbar classes={toolbarClasses} {...toolbarProps} />
</div>
) : (
<MTableToolbar classes={toolbarClasses} {...toolbarProps} />
),
Toolbar,
}}
options={{ ...defaultOptions, ...options }}
columns={MTColumns}
+1 -1
View File
@@ -16,4 +16,4 @@
export { SubvalueCell } from './SubvalueCell';
export { Table } from './Table';
export type { TableColumn, TableFilter, TableProps } from './Table';
export type { TableColumn, TableFilter, TableProps, TableState } from './Table';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { useQueryParamState } from './useQueryParamState';
@@ -0,0 +1,84 @@
/*
* Copyright 2020 Spotify AB
*
* 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 qs from 'qs';
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useDebounce } from 'react-use';
function stringify(queryParams: any): string {
// Even though these setting don't look nice (e.g. escaped brackets), we should keep
// them this way. The current syntax handles all cases, including variable types with
// arrays or strings.
return qs.stringify(queryParams, {
strictNullHandling: true,
});
}
function parse(queryString: string): any {
return qs.parse(queryString, {
ignoreQueryPrefix: true,
strictNullHandling: true,
});
}
function extractState(queryString: string, stateName: string): any | undefined {
const queryParams = parse(queryString);
return queryParams[stateName];
}
function joinQueryString(
queryString: string,
stateName: string,
state: any,
): string {
const queryParams = {
...parse(queryString),
[stateName]: state,
};
return stringify(queryParams);
}
type SetQueryParams<T> = (params: T) => void;
export function useQueryParamState<T>(
stateName: string,
): [T | undefined, SetQueryParams<T>] {
const navigate = useNavigate();
const location = useLocation();
const [queryParamState, setQueryParamState] = useState<T>(
extractState(location.search, stateName),
);
useDebounce(
() => {
const queryString = joinQueryString(
location.search,
stateName,
queryParamState,
);
if (location.search !== queryString) {
navigate({ ...location, search: `?${queryString}` }, { replace: true });
}
},
100,
[queryParamState],
);
return [queryParamState, setQueryParamState];
}
+1
View File
@@ -19,3 +19,4 @@ export * from '@backstage/core-api';
export * from './api-wrappers';
export * from './components';
export * from './layout';
export * from './hooks';
@@ -15,7 +15,14 @@
*/
import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model';
import { Table, TableFilter, TableColumn, useApi } from '@backstage/core';
import {
Table,
TableColumn,
TableFilter,
TableState,
useApi,
useQueryParamState,
} from '@backstage/core';
import { Chip, Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
@@ -120,6 +127,10 @@ export const ApiExplorerTable = ({
loading,
error,
}: ExplorerTableProps) => {
const [queryParamState, setQueryParamState] = useQueryParamState<TableState>(
'apiTable',
);
if (error) {
return (
<div>
@@ -142,6 +153,8 @@ export const ApiExplorerTable = ({
}}
data={entities}
filters={filters}
initialState={queryParamState}
onStateChange={setQueryParamState}
/>
);
};