From 9d3eb2fc315b523f05f069587d3d882dce373253 Mon Sep 17 00:00:00 2001 From: Colin Diesh Date: Mon, 2 Oct 2023 16:51:41 -0400 Subject: [PATCH] Make faceted track selector facet filters responsive to adjacent filter selections (#3956) --- .../components/faceted/FacetFilter.tsx | 86 ++++++++++ .../components/faceted/FacetFilters.tsx | 149 +++++------------- .../components/faceted/FacetedHeader.tsx | 24 ++- .../components/faceted/FacetedSelector.tsx | 106 ++++++++----- 4 files changed, 211 insertions(+), 154 deletions(-) create mode 100644 plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetFilter.tsx diff --git a/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetFilter.tsx b/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetFilter.tsx new file mode 100644 index 0000000000..187fd001a7 --- /dev/null +++ b/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetFilter.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import { + Typography, + FormControl, + Select, + IconButton, + Tooltip, +} from '@mui/material' +import { makeStyles } from 'tss-react/mui' + +// icon +import ClearIcon from '@mui/icons-material/Clear' +import MinimizeIcon from '@mui/icons-material/Minimize' +import AddIcon from '@mui/icons-material/Add' + +const useStyles = makeStyles()(theme => ({ + facet: { + margin: 0, + marginLeft: theme.spacing(2), + }, + select: { + marginBottom: theme.spacing(2), + }, +})) + +export default function FacetFilter({ + column, + vals, + width, + dispatch, + filters, +}: { + column: { field: string } + vals: [string, number][] + width: number + dispatch: (arg: { key: string; val: string[] }) => void + filters: Record +}) { + const { classes } = useStyles() + const [visible, setVisible] = useState(true) + return ( + +
+ {column.field} + + dispatch({ key: column.field, val: [] })} + size="small" + > + + + + + setVisible(!visible)} size="small"> + {visible ? : } + + +
+ {visible ? ( + + ) : null} +
+ ) +} diff --git a/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetFilters.tsx b/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetFilters.tsx index 775de94267..200bb76408 100644 --- a/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetFilters.tsx +++ b/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetFilters.tsx @@ -1,94 +1,5 @@ -import React, { useState } from 'react' -import { - Typography, - FormControl, - Select, - IconButton, - Tooltip, -} from '@mui/material' -import { makeStyles } from 'tss-react/mui' - -// icon -import ClearIcon from '@mui/icons-material/Clear' -import MinimizeIcon from '@mui/icons-material/Minimize' -import AddIcon from '@mui/icons-material/Add' - -const useStyles = makeStyles()(theme => ({ - facet: { - margin: 0, - marginLeft: theme.spacing(2), - }, - select: { - marginBottom: theme.spacing(2), - }, -})) - -function FacetFilter({ - column, - vals, - width, - dispatch, - filters, -}: { - column: { field: string } - vals: [string, number][] - width: number - dispatch: (arg: { key: string; val: string[] }) => void - filters: Record -}) { - const { classes } = useStyles() - const [visible, setVisible] = useState(true) - return ( - -
- {column.field} - - { - dispatch({ key: column.field, val: [] }) - }} - size="small" - > - - - - - setVisible(!visible)} size="small"> - {visible ? : } - - -
- {visible ? ( - - ) : null} -
- ) -} +import React from 'react' +import FacetFilter from './FacetFilter' export default function FacetFilters({ rows, @@ -104,11 +15,31 @@ export default function FacetFilters({ width: number }) { const facets = columns.slice(1) - const uniqs = facets.map(() => new Map()) - for (const row of rows) { - for (const [index, column] of facets.entries()) { - const elt = uniqs[index] - const key = `${row[column.field] || ''}` + const uniqs = new Map( + facets.map(f => [f.field, new Map()] as const), + ) + + // this code "stages the facet filters" in order that the user has selected + // them, which relies on the js behavior that the order of the returned keys is + // related to the insertion order. + const filterKeys = Object.keys(filters) + const facetKeys = facets.map(f => f.field) + const ret = new Set() + for (const entry of filterKeys) { + // give non-empty filters priority + if (filters[entry]?.length) { + ret.add(entry) + } + } + for (const entry of facetKeys) { + ret.add(entry) + } + + let currentRows = rows + for (const facet of ret) { + const elt = uniqs.get(facet)! + for (const row of currentRows) { + const key = `${row[facet] || ''}` const val = elt.get(key) // we don't allow filtering on empty yet if (key) { @@ -119,20 +50,26 @@ export default function FacetFilters({ } } } + const filter = filters[facet]?.length ? new Set(filters[facet]) : undefined + currentRows = currentRows.filter(row => { + return filter !== undefined ? filter.has(row[facet] as string) : true + }) } return (
- {facets.map((column, index) => ( - - ))} + {facets.map(column => { + return ( + + ) + })}
) } diff --git a/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetedHeader.tsx b/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetedHeader.tsx index 58e60cdd74..ae9a98c18f 100644 --- a/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetedHeader.tsx +++ b/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetedHeader.tsx @@ -13,22 +13,26 @@ import { HierarchicalTrackSelectorModel } from '../../model' export default function FacetedHeader({ setFilterText, setUseShoppingCart, - setHideSparse, + setShowSparse, + setShowFilters, setShowOptions, showOptions, - hideSparse, + showSparse, + showFilters, useShoppingCart, filterText, model, }: { setFilterText: (arg: string) => void setUseShoppingCart: (arg: boolean) => void - setHideSparse: (arg: boolean) => void + setShowSparse: (arg: boolean) => void + setShowFilters: (arg: boolean) => void setShowOptions: (arg: boolean) => void filterText: string showOptions: boolean useShoppingCart: boolean - hideSparse: boolean + showSparse: boolean + showFilters: boolean model: HierarchicalTrackSelectorModel }) { const [anchorEl, setAnchorEl] = useState(null) @@ -78,9 +82,15 @@ export default function FacetedHeader({ checked: useShoppingCart, }, { - label: 'Hide sparse metadata columns', - onClick: () => setHideSparse(!hideSparse), - checked: hideSparse, + label: 'Show sparse metadata columns', + onClick: () => setShowSparse(!showSparse), + checked: showSparse, + type: 'checkbox', + }, + { + label: 'Show facet filters', + onClick: () => setShowFilters(!showFilters), + checked: showFilters, type: 'checkbox', }, { diff --git a/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetedSelector.tsx b/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetedSelector.tsx index 8ec14452e4..ddea7e920a 100644 --- a/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetedSelector.tsx +++ b/plugins/data-management/src/HierarchicalTrackSelectorWidget/components/faceted/FacetedSelector.tsx @@ -16,11 +16,14 @@ import { getSession, measureGridWidth, useDebounce, + useLocalStorage, } from '@jbrowse/core/util' import { AnyConfigurationModel, readConfObject, } from '@jbrowse/core/configuration' +import { useResizeBar } from '@jbrowse/core/ui/useResizeBar' +import { makeStyles } from 'tss-react/mui' // icons import MoreHoriz from '@mui/icons-material/MoreHoriz' @@ -31,8 +34,6 @@ import { matches } from '../../util' import FacetedHeader from './FacetedHeader' import FacetFilters from './FacetFilters' import { getRootKeys } from './util' -import { useResizeBar } from '@jbrowse/core/ui/useResizeBar' -import { makeStyles } from 'tss-react/mui' const nonMetadataKeys = ['category', 'adapter', 'description'] as const @@ -63,11 +64,18 @@ const FacetedSelector = observer(function FacetedSelector({ const { ref, scrollLeft } = useResizeBar() const [filterText, setFilterText] = useState('') - const [showOptions, setShowOptions] = useState(false) + const [showOptions, setShowOptions] = useLocalStorage( + 'facet-showTableOptions', + false, + ) const [info, setInfo] = useState() const [useShoppingCart, setUseShoppingCart] = useState(false) - const [hideSparse, setHideSparse] = useState(true) - const [panelWidth, setPanelWidth] = useState(400) + const [showSparse, setShowSparse] = useLocalStorage('facet-showSparse', false) + const [showFilters, setShowFilters] = useLocalStorage( + 'facet-showFilters', + true, + ) + const [panelWidth, setPanelWidth] = useLocalStorage('facet-panelWidth', 400) const session = getSession(model) const filterDebounced = useDebounce(filterText, 400) const tracks = view.tracks as AnyConfigurationModel[] @@ -75,9 +83,7 @@ const FacetedSelector = observer(function FacetedSelector({ ( state: Record, update: { key: string; val: string[] }, - ) => { - return { ...state, [update.key]: update.val } - }, + ) => ({ ...state, [update.key]: update.val }), {}, ) @@ -104,19 +110,19 @@ const FacetedSelector = observer(function FacetedSelector({ const filteredNonMetadataKeys = useMemo( () => nonMetadataKeys.filter(f => - !hideSparse ? true : rows.map(r => r[f]).filter(f => !!f).length > 5, + showSparse ? true : rows.map(r => r[f]).filter(f => !!f).length > 5, ), - [hideSparse, rows], + [showSparse, rows], ) const filteredMetadataKeys = useMemo( () => [...new Set(rows.flatMap(row => getRootKeys(row.metadata)))].filter(f => - !hideSparse + showSparse ? true : rows.map(r => r.metadata[f]).filter(f => !!f).length > 5, ), - [hideSparse, rows], + [showSparse, rows], ) const fields = useMemo( @@ -186,7 +192,7 @@ const FacetedSelector = observer(function FacetedSelector({ ]), ), })) - }, [filteredMetadataKeys, visible, filteredNonMetadataKeys, hideSparse, rows]) + }, [filteredMetadataKeys, visible, filteredNonMetadataKeys, showSparse, rows]) const widthsDebounced = useDebounce(widths, 200) @@ -241,9 +247,16 @@ const FacetedSelector = observer(function FacetedSelector({ })), ] - const shownTrackIds = tracks.map(t => t.configuration.trackId as string) + const shownTrackIds = new Set( + tracks.map(t => t.configuration.trackId as string), + ) - const arrFilters = Object.entries(filters).filter(f => f[1].length > 0) + const arrFilters = Object.entries(filters) + .filter(f => f[1].length > 0) + .map(([key, val]) => [key, new Set(val)] as const) + const filteredRows = rows.filter(row => + arrFilters.every(([key, val]) => val.has(row[key] as string)), + ) return ( <> {info ? ( @@ -259,11 +272,13 @@ const FacetedSelector = observer(function FacetedSelector({ /> ) : null} - arrFilters.every(([key, val]) => val.includes(row[key])), - )} + rows={filteredRows} columnVisibilityModel={visible} onColumnVisibilityModelChange={newModel => setVisible(newModel)} columnHeaderHeight={35} @@ -313,12 +326,12 @@ const FacetedSelector = observer(function FacetedSelector({ onRowSelectionModelChange={userSelectedIds => { if (!useShoppingCart) { const a1 = shownTrackIds - const a2 = userSelectedIds as string[] + const a2 = new Set(userSelectedIds as string[]) // synchronize the user selection with the view // see share https://stackoverflow.com/a/33034768/2129219 transaction(() => { - a1.filter(x => !a2.includes(x)).map(t => view.hideTrack(t)) - a2.filter(x => !a1.includes(x)).map(t => view.showTrack(t)) + ;[...a1].filter(x => !a2.has(x)).map(t => view.hideTrack(t)) + ;[...a2].filter(x => !a1.has(x)).map(t => view.showTrack(t)) }) } else { const root = getRoot(model) @@ -330,7 +343,9 @@ const FacetedSelector = observer(function FacetedSelector({ } }} rowSelectionModel={ - useShoppingCart ? selection.map(s => s.trackId) : shownTrackIds + useShoppingCart + ? selection.map(s => s.trackId) + : [...shownTrackIds] } slots={{ toolbar: showOptions ? GridToolbar : null }} slotProps={{ @@ -340,22 +355,31 @@ const FacetedSelector = observer(function FacetedSelector({ rowHeight={25} /> - setPanelWidth(panelWidth - dist)} - style={{ background: 'grey', width: 5 }} - /> -
- -
+ + {showFilters ? ( + <> + setPanelWidth(panelWidth - dist)} + style={{ marginLeft: 5, background: 'grey', width: 5 }} + /> +
+ +
+ + ) : null} )