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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Make the selected state of Select and CheckboxTree controllable from outside.
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user