Skip to content

Commit

Permalink
Persist Table Filters in the API Explorer (#2936)
Browse files Browse the repository at this point in the history
* 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...
  • Loading branch information
Fox32 committed Nov 9, 2020
1 parent 091765a commit 0c0798f
Show file tree
Hide file tree
Showing 14 changed files with 428 additions and 75 deletions.
5 changes: 5 additions & 0 deletions .changeset/fluffy-dragons-cry.md
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions .changeset/odd-parents-push.md
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions .changeset/seven-monkeys-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---

Make the selected state of Select and CheckboxTree controllable from outside.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import React from 'react';
import React, { useState } from 'react';
import { CheckboxTree } from '.';

const CHECKBOX_TREE_ITEMS = [
Expand Down Expand Up @@ -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}
/>
);
};
90 changes: 77 additions & 13 deletions packages/core/src/components/CheckboxTree/CheckboxTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand All @@ -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 = (
Expand Down Expand Up @@ -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;
}
Expand All @@ -183,35 +225,57 @@ 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
}, [state]);

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}>
Expand Down
38 changes: 27 additions & 11 deletions packages/core/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>) => {
Expand Down Expand Up @@ -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={
Expand All @@ -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>
)
}
Expand Down
Loading

0 comments on commit 0c0798f

Please sign in to comment.