diff --git a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx index 3dfe53f36c..63cb4dbf04 100644 --- a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx +++ b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx @@ -1,4 +1,11 @@ -import { ReactNode, useState, useEffect, useMemo } from 'react'; +import { + ReactNode, + useState, + useEffect, + useMemo, + forwardRef, + useImperativeHandle, +} from 'react'; import { Popover } from '@material-ui/core'; import SwissArmyButton from '../SwissArmyButton'; import { gray } from '../../../definitions/colors'; @@ -66,6 +73,11 @@ const defaultStyle: ButtonStyleSpec = { }, }; +export interface PopoverButtonHandle { + /** Closes the popover */ + close: () => void; +} + export interface PopoverButtonProps { /** Contents of the menu when opened */ children: ReactNode; @@ -87,86 +99,96 @@ export interface PopoverButtonProps { /** * Renders a button that display `children` in a popover widget. */ -export default function PopoverButton(props: PopoverButtonProps) { - const { - children, - buttonDisplayContent, - onClose, - setIsPopoverOpen, - isDisabled = false, - styleOverrides = {}, - } = props; - const [anchorEl, setAnchorEl] = useState(null); - - const finalStyle = useMemo( - () => merge({}, defaultStyle, styleOverrides), - [styleOverrides] - ); - - const onCloseHandler = () => { - setAnchorEl(null); - onClose && onClose(); - }; - - useEffect(() => { - if (!setIsPopoverOpen) return; - if (anchorEl) { - setIsPopoverOpen(true); - } else { - setIsPopoverOpen(false); - } - }, [anchorEl, setIsPopoverOpen]); - - const menu = ( - - {children} - - ); - - const button = ( - setAnchorEl(event.currentTarget)} - disabled={isDisabled} - styleSpec={finalStyle} - icon={ArrowDown} - iconPosition="right" - additionalAriaProperties={{ - 'aria-controls': 'dropdown', - 'aria-haspopup': 'true', - }} - /> - ); - - return ( -
{ - // prevent click event from propagating to ancestor nodes - event.stopPropagation(); - }} - > - {button} - {menu} -
- ); -} + +const PopoverButton = forwardRef( + function PopoverButton(props, ref) { + const { + children, + buttonDisplayContent, + onClose, + setIsPopoverOpen, + isDisabled = false, + styleOverrides = {}, + } = props; + const [anchorEl, setAnchorEl] = useState(null); + + const finalStyle = useMemo( + () => merge({}, defaultStyle, styleOverrides), + [styleOverrides] + ); + + const onCloseHandler = () => { + setAnchorEl(null); + onClose && onClose(); + }; + + // Expose the `close()` method to external components via ref + useImperativeHandle(ref, () => ({ + close: onCloseHandler, + })); + + useEffect(() => { + if (!setIsPopoverOpen) return; + if (anchorEl) { + setIsPopoverOpen(true); + } else { + setIsPopoverOpen(false); + } + }, [anchorEl, setIsPopoverOpen]); + + const menu = ( + + {children} + + ); + + const button = ( + setAnchorEl(event.currentTarget)} + disabled={isDisabled} + styleSpec={finalStyle} + icon={ArrowDown} + iconPosition="right" + additionalAriaProperties={{ + 'aria-controls': 'dropdown', + 'aria-haspopup': 'true', + }} + /> + ); + + return ( +
{ + // prevent click event from propagating to ancestor nodes + event.stopPropagation(); + }} + > + {button} + {menu} +
+ ); + } +); + +export default PopoverButton; diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.tsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.tsx index 3589617aef..b584b3a12a 100644 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.tsx +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.tsx @@ -1,4 +1,11 @@ -import React, { CSSProperties, useCallback, useMemo, useState } from 'react'; +import React, { + CSSProperties, + useCallback, + useDeferredValue, + useMemo, + useRef, + useState, +} from 'react'; import TreeTable from '@veupathdb/components/lib/components/tidytree/TreeTable'; import { RecordTableProps, WrappedComponentProps } from './Types'; import { useOrthoService } from 'ortho-client/hooks/orthoService'; @@ -17,9 +24,14 @@ import { PfamDomainArchitecture } from 'ortho-client/components/pfam-domains/Pfa import { extractPfamDomain } from 'ortho-client/records/utils'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; import { RowCounter } from '@veupathdb/coreui/lib/components/Mesa'; +import PopoverButton, { + PopoverButtonHandle, +} from '@veupathdb/coreui/lib/components/buttons/PopoverButton/PopoverButton'; import { PfamDomain } from 'ortho-client/components/pfam-domains/PfamDomain'; import { + FilledButton, FloatingButton, + OutlinedButton, SelectList, Undo, useDeferredState, @@ -45,13 +57,15 @@ export function RecordTable_Sequences( props: WrappedComponentProps ) { const [searchQuery, setSearchQuery] = useState(''); - const safeSearchRegexp = useMemo( - () => createSafeSearchRegExp(searchQuery), - [searchQuery] + const safeSearchRegexp = useDeferredValue( + useMemo(() => createSafeSearchRegExp(searchQuery), [searchQuery]) ); const [resetCounter, setResetCounter] = useState(0); // used for forcing re-render of filter buttons + const [proteinFilterIds, setProteinFilterIds, volatileProteinFilterIds] = + useDeferredState([]); + const [selectedSpecies, setSelectedSpecies, volatileSelectedSpecies] = useDeferredState([]); @@ -91,7 +105,10 @@ export function RecordTable_Sequences( // deal with Pfam domain architectures const proteinPfams = props.record.tables['ProteinPFams']; - const rowsByAccession = groupBy(proteinPfams, 'full_id'); + const rowsByAccession = useMemo( + () => groupBy(proteinPfams, 'full_id'), + [proteinPfams] + ); const accessionToPfamIds = useMemo( () => @@ -185,16 +202,19 @@ export function RecordTable_Sequences( // 2. core-peripheral radio button // 3. checked boxes in the Pfam legend - const [selectedColumnFilters, setSelectedColumnFilters] = useState( - [] - ); + const [ + selectedColumnFilters, + setSelectedColumnFilters, + volatileSelectedColumnFilters, + ] = useDeferredState([]); const filteredRows = useMemo(() => { if ( - searchQuery !== '' || - corePeripheralFilterValue != null || + safeSearchRegexp != null || + corePeripheralFilterValue.length > 0 || pfamFilterIds.length > 0 || - selectedSpecies.length > 0 + selectedSpecies.length > 0 || + proteinFilterIds.length > 0 ) { return sortedRows?.filter((row) => { const rowCorePeripheral = ( @@ -204,7 +224,7 @@ export function RecordTable_Sequences( const rowPfamIdsSet = accessionToPfamIds.get(rowFullId); const searchMatch = - searchQuery === '' || + safeSearchRegexp == null || rowMatch(row, safeSearchRegexp, selectedColumnFilters); const corePeripheralMatch = corePeripheralFilterValue.length === 0 || @@ -217,15 +237,21 @@ export function RecordTable_Sequences( const speciesMatch = selectedSpecies.length === 0 || selectedSpecies.some((specie) => row.taxon_abbrev === specie); + const proteinMatch = + proteinFilterIds.length === 0 || + proteinFilterIds.some((proteinId) => rowFullId === proteinId); return ( - searchMatch && corePeripheralMatch && pfamIdMatch && speciesMatch + searchMatch && + corePeripheralMatch && + pfamIdMatch && + speciesMatch && + proteinMatch ); }); } - return undefined; + return sortedRows; }, [ - searchQuery, selectedColumnFilters, safeSearchRegexp, sortedRows, @@ -233,13 +259,20 @@ export function RecordTable_Sequences( accessionToPfamIds, pfamFilterIds, selectedSpecies, + proteinFilterIds, ]); // now filter the tree if needed - takes a couple of seconds for large trees const filteredTree = useMemo(() => { - if (leaves == null || tree == null || filteredRows?.length === 0) return; - - if (filteredRows != null && filteredRows.length < leaves.length) { + if ( + leaves == null || + tree == null || + filteredRows == null || + filteredRows.length === 0 + ) + return; + + if (filteredRows.length < leaves.length) { const filteredRowIds = new Set( filteredRows.map(({ full_id }) => full_id as string) ); @@ -266,16 +299,17 @@ export function RecordTable_Sequences( // make a newick string from the filtered tree if needed const finalNewick = useMemo(() => { - if (filteredTree === tree && treeResponse != null) { - return treeResponse; // no filtering so return what we read from the back end - } else if ( - filteredTree != null && - filteredRows != null && - filteredRows.length > 0 - ) { - return filteredTree.toNewick(); // make new newick data from the filtered tree - } else return; - }, [filteredTree, treeResponse, tree, filteredRows]); + if (treeResponse != null) { + if (filteredTree != null) { + if (filteredTree === tree) { + return treeResponse; // no filtering so return what we read from the back end + } else { + return filteredTree.toNewick(); // make new newick data from the filtered tree + } + } + } + return; + }, [filteredTree, treeResponse, tree]); // list of column keys and display names to show in the checkbox dropdown in the table text search box (RecordFilter) const filterAttributes = useMemo( @@ -297,18 +331,74 @@ export function RecordTable_Sequences( [setSelectedSpecies, setTablePageNumber] ); + const firstRowIndex = (tablePageNumber - 1) * MAX_SEQUENCES_FOR_TREE; + + const mesaState: MesaStateProps | undefined = useMemo(() => { + if (sortedRows == null) return; + return { + options: { + isRowSelected: (row: RowType) => + highlightedNodes.includes(row.full_id as string), + useStickyHeader: true, + tableBodyMaxHeight: 'calc(100vh - 200px)', // 200px accounts for header/footer + }, + uiState: { + pagination: { + currentPage: tablePageNumber, + rowsPerPage: MAX_SEQUENCES_FOR_TREE, + totalRows: filteredRows?.length ?? 0, + }, + }, + rows: sortedRows, + filteredRows: filteredRows?.slice( + firstRowIndex, + firstRowIndex + MAX_SEQUENCES_FOR_TREE + ), + columns: mesaColumns, + eventHandlers: { + onRowSelect: (row: RowType) => + setHighlightedNodes((prev) => [...prev, row.full_id as string]), + onRowDeselect: (row: RowType) => + setHighlightedNodes((prev) => + prev.filter((id) => id !== row.full_id) + ), + onPageChange: (page: number) => setTablePageNumber(page), + }, + }; + }, [ + sortedRows, + filteredRows, + highlightedNodes, + tablePageNumber, + firstRowIndex, + mesaColumns, + setHighlightedNodes, + setTablePageNumber, + ]); + + const treeProps = useMemo( + () => ({ + data: finalNewick, + width: treeWidth, + highlightMode: 'monophyletic' as const, + highlightColor, + highlightedNodeIds: highlightedNodes, + }), + [finalNewick, treeWidth, highlightColor, highlightedNodes] + ); + + const proteinFilterButtonRef = useRef(null); + + // None shall pass! (hooks, at least) + if ( + !mesaState || !sortedRows || (numSequences >= MIN_SEQUENCES_FOR_TREE && numSequences <= MAX_SEQUENCES_FOR_TREE && (tree == null || treeResponse == null)) ) { - return ( - <> -
Loading...
- - - ); // The loading spinner does not show :-( + return ; } if ( @@ -336,45 +426,6 @@ export function RecordTable_Sequences( ); } - const firstRowIndex = (tablePageNumber - 1) * MAX_SEQUENCES_FOR_TREE; - - const mesaState: MesaStateProps = { - options: { - isRowSelected: (row: RowType) => - highlightedNodes.includes(row.full_id as string), - useStickyHeader: true, - tableBodyMaxHeight: 'calc(100vh - 200px)', // 200px accounts for header/footer - }, - uiState: { - pagination: { - currentPage: tablePageNumber, - rowsPerPage: MAX_SEQUENCES_FOR_TREE, - totalRows: filteredRows?.length ?? 0, - }, - }, - rows: sortedRows, - filteredRows: filteredRows?.slice( - firstRowIndex, - firstRowIndex + MAX_SEQUENCES_FOR_TREE - ), - columns: mesaColumns, - eventHandlers: { - onRowSelect: (row: RowType) => - setHighlightedNodes((prev) => [...prev, row.full_id as string]), - onRowDeselect: (row: RowType) => - setHighlightedNodes((prev) => prev.filter((id) => id !== row.full_id)), - onPageChange: (page: number) => setTablePageNumber(page), - }, - }; - - const treeProps = { - data: finalNewick, - width: treeWidth, - highlightMode: 'monophyletic' as const, - highlightColor, - highlightedNodeIds: highlightedNodes, - }; - const rowHeight = 45; const clustalDisabled = highlightedNodes == null || highlightedNodes.length < 2; @@ -457,6 +508,92 @@ export function RecordTable_Sequences( /> ) : null; + const resetProteinFilterButton = ( + { + proteinFilterButtonRef.current?.close(); + setProteinFilterIds([]); + }} + /> + ); + + const updateProteinFilterIds = () => { + proteinFilterButtonRef.current?.close(); + setProteinFilterIds(highlightedNodes); + setHighlightedNodes([]); + }; + + const proteinFilter = ( + 0 + ? ` (${volatileProteinFilterIds.length})` + : '' + }${highlightedNodes.length > 0 ? '*' : ''}`} + > +
+ {highlightedNodes.length === 0 ? ( + volatileProteinFilterIds.length === 0 ? ( +
+ Select some proteins using the checkboxes in the table below. +
+ ) : ( + <> +
+ You are filtering on{' '} + {volatileProteinFilterIds.length.toLocaleString()} proteins. +
+ {resetProteinFilterButton} + + ) + ) : volatileProteinFilterIds.length === 0 ? ( + <> +
+ * You have checked {highlightedNodes.length.toLocaleString()}{' '} + proteins in the table. +
+ + + ) : highlightedNodes.length < volatileProteinFilterIds.length ? ( + <> +
+ * You have checked {highlightedNodes.length.toLocaleString()}{' '} + proteins in the table that is already filtered on{' '} + {volatileProteinFilterIds.length.toLocaleString()} proteins. +
+ + {resetProteinFilterButton} + + ) : ( + <> +
+ You have checked all the proteins that are currently being + filtered on. Either uncheck one or more proteins or reset the + filter entirely using the button below. +
+ {resetProteinFilterButton} + + )} +
+
+ ); + const resetButton = ( { + setProteinFilterIds([]); setPfamFilterIds([]); setCorePeripheralFilterValue([]); setSelectedSpecies([]); @@ -527,7 +666,7 @@ export function RecordTable_Sequences( onSearchTermChange={setSearchQuery} recordDisplayName="Proteins" filterAttributes={filterAttributes} - selectedColumnFilters={selectedColumnFilters} + selectedColumnFilters={volatileSelectedColumnFilters} onColumnFilterChange={(keys) => setSelectedColumnFilters(keys)} />
@@ -547,10 +686,11 @@ export function RecordTable_Sequences( flexDirection: 'row', gap: '1em', alignItems: 'center', - justifyContent: 'flex-end', + marginLeft: 'auto', }} > Filters: + {proteinFilter} {pfamFilter} {corePeripheralFilter} {taxonFilter} @@ -639,7 +779,8 @@ function rowMatch(row: RowType, query: RegExp, keys?: string[]): boolean { ); } -function createSafeSearchRegExp(input: string): RegExp { +function createSafeSearchRegExp(input: string): RegExp | undefined { + if (input === '') return undefined; try { // Attempt to create a RegExp from the user input directly return new RegExp(input, 'i');