diff --git a/.changeset/fluffy-dragons-cry.md b/.changeset/fluffy-dragons-cry.md new file mode 100644 index 0000000000..32a202b417 --- /dev/null +++ b/.changeset/fluffy-dragons-cry.md @@ -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. diff --git a/.changeset/odd-parents-push.md b/.changeset/odd-parents-push.md new file mode 100644 index 0000000000..5d997b7a45 --- /dev/null +++ b/.changeset/odd-parents-push.md @@ -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. diff --git a/.changeset/seven-monkeys-run.md b/.changeset/seven-monkeys-run.md new file mode 100644 index 0000000000..7e66daa86f --- /dev/null +++ b/.changeset/seven-monkeys-run.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Make the selected state of Select and CheckboxTree controllable from outside. diff --git a/packages/core/package.json b/packages/core/package.json index 0ddc77f1e4..aaedf2ca6f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx index d5a33f0f9b..db590e6524 100644 --- a/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx +++ b/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx @@ -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 ( + { + setTree(generateTree(state.some(c => c.category === 'Show more'))); + }} + label="default" + subCategories={tree} + /> + ); +}; diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.tsx index 6825704971..d6d3410913 100644 --- a/packages/core/src/components/CheckboxTree/CheckboxTree.tsx +++ b/packages/core/src/components/CheckboxTree/CheckboxTree.tsx @@ -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 = { [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; + } + | { 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 (
- {props.label} + {label} {Object.values(state).map(item => (
diff --git a/packages/core/src/components/Select/Select.tsx b/packages/core/src/components/Select/Select.tsx index 1c691340b2..1e6dba76e0 100644 --- a/packages/core/src/components/Select/Select.tsx +++ b/packages/core/src/components/Select/Select.tsx @@ -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( - multiple ? [] : '', + const [value, setValue] = useState( + 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) => { @@ -153,10 +169,10 @@ export const SelectComponent = (props: SelectProps) => { onClick={handleClick} open={isOpen} input={} - renderValue={selected => + renderValue={s => multiple && (value as any[]).length !== 0 ? (
- {(selected as string[]).map(selectedValue => ( + {(s as string[]).map(selectedValue => ( el.value === selectedValue)?.value} label={ @@ -172,7 +188,7 @@ export const SelectComponent = (props: SelectProps) => { {(value as any[]).length === 0 ? placeholder || '' - : items.find(el => el.value === selected)?.label} + : items.find(el => el.value === s)?.label} ) } diff --git a/packages/core/src/components/Table/Filters.tsx b/packages/core/src/components/Table/Filters.tsx index fdff04e478..138fec4736 100644 --- a/packages/core/src/components/Table/Filters.tsx +++ b/packages/core/src/components/Table/Filters.tsx @@ -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({}); + const [selectedFilters, setSelectedFilters] = useState({ + ...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) => {
- {filters?.length && - filters.map(filter => + {props.filters?.length && + props.filters.map(filter => filter.type === 'checkbox-tree' ? ( ({ + 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, }) } /> diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 87ad50e58f..852f4a1547 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -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( }); } +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 extends Column { 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 extends MaterialTableProps { columns: TableColumn[]; subtitle?: string; filters?: TableFilter[]; + initialState?: TableState; + onStateChange?: (state: TableState) => any; } export function Table({ @@ -211,6 +240,8 @@ export function Table({ title, subtitle, filters, + initialState, + onStateChange, ...props }: TableProps) { const headerClasses = useHeaderStyles(); @@ -222,13 +253,43 @@ export function Table({ const theme = useTheme(); - 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(); + const [selectedFilters, setSelectedFilters] = useState( + calculatedInitialState.filters, + ); const MTColumns = convertColumns(columns, theme); + const [search, setSearch] = useState(calculatedInitialState.search); + const toolbarRef = useRef(); + + 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 = { headerStyle: { textTransform: 'uppercase', @@ -248,7 +309,7 @@ export function Table({ } 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({ const constructFilters = ( filterConfig: TableFilter[], - dataValue: any[], + dataValue: any[] | undefined, ): Filter[] => { const extractDistinctValues = (field: string | keyof T): Set => { const distinctValues = new Set(); @@ -289,15 +350,20 @@ export function Table({ } }; - 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({ })); }; + const Toolbar = useCallback( + toolbarProps => { + const onSearchChanged = (searchText: string) => { + toolbarProps.onSearchChanged(searchText); + setSearch(searchText); + }; + + if (filters?.length) { + return ( +
+
+ toggleFilters(el => !el)} + aria-label="filter list" + > + + + + Filters ({selectedFiltersLength}) + +
+ +
+ ); + } + + return ( + + ); + }, + [ + filters?.length, + selectedFiltersLength, + toggleFilters, + toolbarClasses, + filtersClasses, + setSearch, + toolbarRef, + ], + ); + return (
- {filtersOpen && filters?.length && ( + {filtersOpen && data && filters?.length && ( )} @@ -348,25 +466,7 @@ export function Table({ Header: headerProps => ( ), - Toolbar: toolbarProps => - filters?.length ? ( -
-
- toggleFilters(el => !el)} - aria-label="filter list" - > - - - - Filters ({selectedFiltersLength}) - -
- -
- ) : ( - - ), + Toolbar, }} options={{ ...defaultOptions, ...options }} columns={MTColumns} diff --git a/packages/core/src/components/Table/index.ts b/packages/core/src/components/Table/index.ts index 9cb8478688..83432ffc70 100644 --- a/packages/core/src/components/Table/index.ts +++ b/packages/core/src/components/Table/index.ts @@ -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'; diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts new file mode 100644 index 0000000000..a77a0eb8a4 --- /dev/null +++ b/packages/core/src/hooks/index.ts @@ -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'; diff --git a/packages/core/src/hooks/useQueryParamState.ts b/packages/core/src/hooks/useQueryParamState.ts new file mode 100644 index 0000000000..9317dcd3e1 --- /dev/null +++ b/packages/core/src/hooks/useQueryParamState.ts @@ -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 = (params: T) => void; + +export function useQueryParamState( + stateName: string, +): [T | undefined, SetQueryParams] { + const navigate = useNavigate(); + const location = useLocation(); + const [queryParamState, setQueryParamState] = useState( + 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]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4c0d42bbb2..43168635cb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,3 +19,4 @@ export * from '@backstage/core-api'; export * from './api-wrappers'; export * from './components'; export * from './layout'; +export * from './hooks'; diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index f3be9f664b..e12d172e41 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -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( + 'apiTable', + ); + if (error) { return (
@@ -142,6 +153,8 @@ export const ApiExplorerTable = ({ }} data={entities} filters={filters} + initialState={queryParamState} + onStateChange={setQueryParamState} /> ); };