diff --git a/packages/libs/components/package.json b/packages/libs/components/package.json index c8d6c7cdf3..7dd2e9b92d 100755 --- a/packages/libs/components/package.json +++ b/packages/libs/components/package.json @@ -46,7 +46,7 @@ "react-spring": "^9.7.1", "react-transition-group": "^4.4.1", "shape2geohash": "^1.2.5", - "tidytree": "github:d-callan/TidyTree" + "tidytree": "https://github.com/d-callan/TidyTree.git#commit=9063e2df3d93c72743702a6d8f43169a1461e5b0" }, "files": [ "lib", diff --git a/packages/libs/components/src/components/tidytree/HorizontalDendrogram.tsx b/packages/libs/components/src/components/tidytree/HorizontalDendrogram.tsx index 54ccefc3fc..f5ffb2e646 100644 --- a/packages/libs/components/src/components/tidytree/HorizontalDendrogram.tsx +++ b/packages/libs/components/src/components/tidytree/HorizontalDendrogram.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef } from 'react'; +import { CSSProperties, useEffect, useLayoutEffect, useRef } from 'react'; import { TidyTree as TidyTreeJS } from 'tidytree'; export interface HorizontalDendrogramProps { @@ -19,6 +19,7 @@ export interface HorizontalDendrogramProps { for now just default to all zero margins (left-most edges */ margin?: [number, number, number, number]; + interactive?: boolean; }; /// The remaining props are handled with a redraw: /// @@ -31,10 +32,21 @@ export interface HorizontalDendrogramProps { * width of tree in pixels */ width: number; + /** + * hopefully temporary prop that we can get rid of when we understand the + * horizontal layout behaviour of the tree (with respect to number of nodes) + * which will come with testing with more examples. Defaults to 1.0 + * update: possibly wasn't needed in the end! + */ + hStretch?: number; /** * number of pixels height taken per leaf */ rowHeight: number; + /** + * CSS styles for the container div + */ + containerStyles?: CSSProperties; /** * which leaf nodes to highlight */ @@ -43,6 +55,10 @@ export interface HorizontalDendrogramProps { * highlight whole subtrees ('monophyletic') or just leaves ('none') */ highlightMode?: 'monophyletic' | 'none'; + /** + * highlight color (optional - default is tidytree's yellow/orange) + */ + highlightColor?: string; } /** @@ -57,9 +73,12 @@ export function HorizontalDendrogram({ leafCount, rowHeight, width, - options: { ruler = false, margin = [0, 0, 0, 0] }, + options: { ruler = false, margin = [0, 0, 0, 0], interactive = true }, highlightedNodeIds, highlightMode, + highlightColor, + hStretch = 1.0, + containerStyles, }: HorizontalDendrogramProps) { const containerRef = useRef(null); const tidyTreeRef = useRef(); @@ -80,13 +99,15 @@ export function HorizontalDendrogram({ equidistantLeaves: true, ruler, margin, + hStretch, animation: 0, // it's naff and it reveals edge lengths/weights momentarily + interactive, }); tidyTreeRef.current = instance; return function cleanup() { instance.destroy(); }; - }, [data, ruler, margin]); + }, [data, ruler, margin, hStretch, interactive, containerRef]); // redraw when the container size changes // useLayoutEffect ensures that the redraw is not called for brand new TidyTreeJS objects @@ -106,12 +127,15 @@ export function HorizontalDendrogram({ tidyTreeRef.current.setColorOptions({ nodeColorMode: 'predicate', branchColorMode: highlightMode ?? 'none', + highlightColor: highlightColor, leavesOnly: true, predicate: (node) => highlightedNodeIds.includes(node.__data__.data.id), + defaultBranchColor: '#333', }); // no redraw needed, setColorOptions does it } - }, [highlightedNodeIds, highlightMode, tidyTreeRef]); + }, [highlightedNodeIds, highlightMode, tidyTreeRef, data]); + // `data` not used in effect but needed to trigger recoloring const containerHeight = leafCount * rowHeight; return ( @@ -119,6 +143,7 @@ export function HorizontalDendrogram({ style={{ width: width + 'px', height: containerHeight + 'px', + ...containerStyles, }} ref={containerRef} /> diff --git a/packages/libs/components/src/components/tidytree/TreeTable.tsx b/packages/libs/components/src/components/tidytree/TreeTable.tsx index 35eb2fc55f..ea22835553 100644 --- a/packages/libs/components/src/components/tidytree/TreeTable.tsx +++ b/packages/libs/components/src/components/tidytree/TreeTable.tsx @@ -5,15 +5,19 @@ import { } from '../../components/tidytree/HorizontalDendrogram'; import Mesa from '@veupathdb/coreui/lib/components/Mesa'; import { MesaStateProps } from '../../../../coreui/lib/components/Mesa/types'; -import { css as classNameStyle, cx } from '@emotion/css'; -import { css as globalStyle, Global } from '@emotion/react'; +import { css, cx } from '@emotion/css'; export interface TreeTableProps { /** * number of pixels vertical space for each row of the table and tree * (for the table this is a minimum height, so make sure table content doesn't wrap) + * required; no default; minimum seems to be 42; suggested value: 45 */ rowHeight: number; + /** + * number of pixels max width for table columns; defaults to 200 + */ + maxColumnWidth?: number; /** * data and options for the tree */ @@ -25,8 +29,14 @@ export interface TreeTableProps { * data and options for the table */ tableProps: MesaStateProps; + /** + * hide the tree (but keep its horizontal space); default = false + */ + hideTree?: boolean; } +const margin: [number, number, number, number] = [0, 10, 0, 10]; + /** * main props are * data: string; // Newick format tree @@ -42,16 +52,23 @@ export interface TreeTableProps { * - allow additional Mesa props and options to be passed */ export default function TreeTable(props: TreeTableProps) { - const { rowHeight } = props; - const { rows } = props.tableProps; + const { rowHeight, maxColumnWidth = 200, hideTree = false } = props; + const { rows, filteredRows } = props.tableProps; const rowStyleClassName = useMemo( () => cx( - classNameStyle({ - height: rowHeight + 'px', - background: 'yellow', - }) + // minimum height for table rows + css` + height: ${rowHeight}px; + + & td { + &:hover { + cursor: pointer; + position: relative; + } + } + ` ), [rowHeight] ); @@ -62,30 +79,60 @@ export default function TreeTable(props: TreeTableProps) { ...props.tableProps, options: { ...props.tableProps.options, - deriveRowClassName: (_) => rowStyleClassName, + deriveRowClassName: mergeDeriveRowClassName( + props.tableProps.options?.deriveRowClassName, + (_) => rowStyleClassName + ), + inline: true, + // TO DO: explore event delegation to avoid each tooltip having handlers + // replace inline mode's inline styling with emotion classes + inlineUseTooltips: true, + inlineMaxHeight: `${rowHeight}px`, + inlineMaxWidth: `${maxColumnWidth}px`, }, }; + // if `hideTree` is used more dynamically than at present + // (for example if the user sorts the table) + // then the table container styling will need + // { marginLeft: hideTree ? props.treeProps.width : 0 } + // to stop the table jumping around horizontally return (
- - <> - + )} +
- +
); } + +function mergeDeriveRowClassName( + func1: ((row: RowType) => string | undefined) | undefined, + func2: ((row: RowType) => string | undefined) | undefined +): ((row: RowType) => string | undefined) | undefined { + if (func1 == null && func2 == null) return undefined; + return (row: RowType) => { + const className1 = func1 && func1(row); + const className2 = func2 && func2(row); + // Combine the class names that are defined + return [className1, className2].filter(Boolean).join(' ') || undefined; + }; +} diff --git a/packages/libs/components/src/components/tidytree/tidytree.d.ts b/packages/libs/components/src/components/tidytree/tidytree.d.ts index 254f32e428..c2be3d971f 100644 --- a/packages/libs/components/src/components/tidytree/tidytree.d.ts +++ b/packages/libs/components/src/components/tidytree/tidytree.d.ts @@ -15,6 +15,7 @@ interface ColorOptions { branchColorMode: 'monophyletic' | 'none'; highlightColor?: string; defaultNodeColor?: string; + defaultBranchColor?: string; } declare module 'tidytree' { diff --git a/packages/libs/components/src/components/widgets/RadioButtonGroup.tsx b/packages/libs/components/src/components/widgets/RadioButtonGroup.tsx index 7dec8ecb84..3b0fe98827 100755 --- a/packages/libs/components/src/components/widgets/RadioButtonGroup.tsx +++ b/packages/libs/components/src/components/widgets/RadioButtonGroup.tsx @@ -40,6 +40,8 @@ export type RadioButtonGroupProps = { * if a Map is used, then the values are used in Tooltips to explain why each option is disabled */ disabledList?: string[] | Map; + /** capitalize of the labels; default: true */ + capitalizeLabels?: boolean; }; /** @@ -62,6 +64,7 @@ export default function RadioButtonGroup({ margins, itemMarginRight, disabledList, + capitalizeLabels = true, }: RadioButtonGroupProps) { const isDisabled = (option: string) => { if (!disabledList) return false; @@ -141,7 +144,7 @@ export default function RadioButtonGroup({ marginRight: itemMarginRight, fontSize: '0.75em', fontWeight: 400, - textTransform: 'capitalize', + textTransform: capitalizeLabels ? 'capitalize' : undefined, minWidth: minWidth, }} /> diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx index 62fa58732d..da6b8cccd2 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import Templates from '../Templates'; import { makeClassifier } from '../Utils/Utils'; +import { Tooltip } from '../../../components/info/Tooltip'; + const dataCellClass = makeClassifier('DataCell'); class DataCell extends React.PureComponent { @@ -64,7 +66,29 @@ class DataCell extends React.PureComponent { width = width ? { width, maxWidth: width, minWidth: width } : {}; style = Object.assign({}, style, width, whiteSpace); className = dataCellClass() + (className ? ' ' + className : ''); - const children = this.renderContent(); + + const content = this.renderContent(); + const columnName = column.name ?? column.key; + + // Ideally the tooltip would also be conditional on there + // being actual content, but this is not trivial without + // copy-pasting the getValue logic from this.renderContent(). + // Verdict: not worth it + const children = options.inlineUseTooltips ? ( + + {columnName && {columnName}:} + {content} + + } + > + {content} + + ) : ( + content + ); + const props = { style, children, diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx index d53ec90a5d..6382798461 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx @@ -30,22 +30,22 @@ class DataRow extends React.PureComponent { expandRow() { const { options } = this.props; - if (!options.inline) return; + if (!options.inline || options.inlineUseTooltips) return; this.setState({ expanded: true }); } collapseRow() { const { options } = this.props; - if (!options.inline) return; + if (!options.inline || options.inlineUseTooltips) return; this.setState({ expanded: false }); } handleRowClick() { const { row, rowIndex, options } = this.props; - const { inline, onRowClick } = options; + const { inline, onRowClick, inlineUseTooltips } = options; if (!inline && !onRowClick) return; - - if (inline) this.setState({ expanded: !this.state.expanded }); + if (inline && !inlineUseTooltips) + this.setState({ expanded: !this.state.expanded }); if (typeof onRowClick === 'function') onRowClick(row, rowIndex); } diff --git a/packages/libs/coreui/src/components/Mesa/Ui/TableSearch.jsx b/packages/libs/coreui/src/components/Mesa/Ui/TableSearch.jsx index 4febc2197e..64fabbf22a 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/TableSearch.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/TableSearch.jsx @@ -18,7 +18,7 @@ class TableSearch extends React.PureComponent { clearSearchQuery() { const query = null; const { onSearch } = this.props; - if (onSearch) onSearch(query); + if (onSearch) onSearch(''); } render() { diff --git a/packages/libs/coreui/src/components/Mesa/Ui/TableToolbar.jsx b/packages/libs/coreui/src/components/Mesa/Ui/TableToolbar.jsx index 522d87282a..3853b92e66 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/TableToolbar.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/TableToolbar.jsx @@ -23,12 +23,18 @@ class TableToolbar extends React.PureComponent { } renderSearch() { - const { uiState, eventHandlers } = this.props; + const { uiState, eventHandlers, options } = this.props; const { onSearch } = eventHandlers; const { searchQuery } = uiState; if (!onSearch) return null; - return ; + return ( + + ); } renderCounter() { diff --git a/packages/libs/coreui/src/components/Mesa/style/Ui/TableToolbar.scss b/packages/libs/coreui/src/components/Mesa/style/Ui/TableToolbar.scss index f2a8283559..67cc35d9bd 100644 --- a/packages/libs/coreui/src/components/Mesa/style/Ui/TableToolbar.scss +++ b/packages/libs/coreui/src/components/Mesa/style/Ui/TableToolbar.scss @@ -18,6 +18,14 @@ .TableToolbar-Info { font-size: 80%; padding: 0px 20px 0px 10px; + + .RowCounter { + display: flex; + justify-content: flex-start; + flex-direction: row; + flex-wrap: wrap; + column-gap: 1em; + } } .TableToolbar-Children { diff --git a/packages/libs/coreui/src/components/Mesa/types.ts b/packages/libs/coreui/src/components/Mesa/types.ts index 54e792ae5a..b7bb7fc266 100644 --- a/packages/libs/coreui/src/components/Mesa/types.ts +++ b/packages/libs/coreui/src/components/Mesa/types.ts @@ -36,6 +36,7 @@ export interface MesaStateProps< inline?: boolean; inlineMaxWidth?: string; inlineMaxHeight?: string; + inlineUseTooltips?: boolean; // don't use onClick to show the full contents, use an onMouseOver tooltip instead className?: string; errOnOverflow?: boolean; editableColumns?: boolean; diff --git a/packages/libs/coreui/src/components/inputs/SelectList.tsx b/packages/libs/coreui/src/components/inputs/SelectList.tsx index 1de1366795..ce495361d3 100644 --- a/packages/libs/coreui/src/components/inputs/SelectList.tsx +++ b/packages/libs/coreui/src/components/inputs/SelectList.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useState } from 'react'; import PopoverButton from '../buttons/PopoverButton/PopoverButton'; import CheckboxList, { CheckboxListProps } from './checkboxes/CheckboxList'; @@ -9,6 +9,10 @@ export interface SelectListProps extends CheckboxListProps { isDisabled?: boolean; /** Are contents loading? */ isLoading?: boolean; + /** If true, don't wait for component to close before calling `onChange` + * with latest selection. + */ + instantUpdate?: boolean; } export default function SelectList({ @@ -21,6 +25,7 @@ export default function SelectList({ defaultButtonDisplayContent, isDisabled = false, isLoading = false, + instantUpdate = false, ...props }: SelectListProps) { const [selected, setSelected] = useState['value']>(value); @@ -35,12 +40,26 @@ export default function SelectList({ ); }; + /** + * Keep caller up to date with any selection changes, if required by `instantUpdate` + */ + const handleCheckboxListUpdate = useCallback( + (newSelection: SelectListProps['value']) => { + setSelected(newSelection); + if (instantUpdate) { + onChange(newSelection); + } + }, + [instantUpdate, setSelected, onChange] + ); + /** * Need to ensure that the state syncs with parent component in the event of an external * clearSelection button, as is the case in EDA's line plot controls */ useEffect(() => { setSelected(value); + if (instantUpdate) return; // we don't want the button text changing on every click setButtonDisplayContent( value.length ? value.join(', ') : defaultButtonDisplayContent ); @@ -75,7 +94,7 @@ export default function SelectList({ name={name} items={items} value={selected} - onChange={setSelected} + onChange={handleCheckboxListUpdate} linksPosition={linksPosition} {...props} /> diff --git a/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx b/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx index 19cc9c7d6e..437abe7b3a 100644 --- a/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx +++ b/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx @@ -8,8 +8,11 @@ import CheckboxTree, { export interface SelectTreeProps extends CheckboxTreeProps { buttonDisplayContent: ReactNode; shouldCloseOnSelection?: boolean; + hasPopoverButton?: boolean; // default=true wrapPopover?: (checkboxTree: ReactNode) => ReactNode; isDisabled?: boolean; + /** update `selectedList` state instantly when a selection is made (default: true) */ + instantUpdate?: boolean; } function SelectTree(props: SelectTreeProps) { @@ -18,7 +21,19 @@ function SelectTree(props: SelectTreeProps) { ? props.currentList.join(', ') : props.buttonDisplayContent ); - const { selectedList, shouldCloseOnSelection, wrapPopover } = props; + const { + selectedList, + onSelectionChange, + shouldCloseOnSelection, + hasPopoverButton = true, + instantUpdate = true, + wrapPopover, + } = props; + + // This local state is updated whenever a checkbox is clicked in the species tree. + // When `instantUpdate` is false, pass the final value to `onSelectionChange` when the popover closes. + // When it is true we call `onSelectionChange` whenever `localSelectedList` changes + const [localSelectedList, setLocalSelectedList] = useState(selectedList); /** Used as a hack to "auto close" the popover when shouldCloseOnSelection is true */ const [key, setKey] = useState(''); @@ -27,12 +42,37 @@ function SelectTree(props: SelectTreeProps) { if (!shouldCloseOnSelection) return; setKey(selectedList.join(', ')); onClose(); - }, [shouldCloseOnSelection, selectedList]); + }, [shouldCloseOnSelection, localSelectedList]); + + // live updates to caller when needed + useEffect(() => { + if (!instantUpdate) return; + onSelectionChange(localSelectedList); + }, [onSelectionChange, localSelectedList]); + + function truncatedButtonContent(selectedList: string[]) { + return ( + + {selectedList.join(', ')} + + ); + } const onClose = () => { setButtonDisplayContent( - selectedList.length ? selectedList.join(', ') : props.buttonDisplayContent + localSelectedList.length + ? truncatedButtonContent(localSelectedList) + : props.buttonDisplayContent ); + if (!instantUpdate) onSelectionChange(localSelectedList); }; const checkboxTree = ( @@ -49,12 +89,12 @@ function SelectTree(props: SelectTreeProps) { renderNode={props.renderNode} expandedList={props.expandedList} isSelectable={props.isSelectable} - selectedList={selectedList} + selectedList={localSelectedList} filteredList={props.filteredList} customCheckboxes={props.customCheckboxes} isMultiPick={props.isMultiPick} name={props.name} - onSelectionChange={props.onSelectionChange} + onSelectionChange={setLocalSelectedList} currentList={props.currentList} defaultList={props.defaultList} isSearchable={props.isSearchable} @@ -77,15 +117,19 @@ function SelectTree(props: SelectTreeProps) { /> ); - return ( + return hasPopoverButton ? ( - {wrapPopover ? wrapPopover(checkboxTree) : checkboxTree} +
+ {wrapPopover ? wrapPopover(checkboxTree) : checkboxTree} +
+ ) : ( + <>{wrapPopover ? wrapPopover(checkboxTree) : checkboxTree} ); } @@ -106,6 +150,7 @@ const defaultProps = { searchPredicate: () => true, linksPosition: LinksPosition.Both, isDisabled: false, + instantUpdate: true, // Set default value to true }; SelectTree.defaultProps = defaultProps; diff --git a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx index 9dc1c29a4a..a945e10340 100644 --- a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx +++ b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx @@ -205,7 +205,7 @@ export default function CheckboxList({ ) : ( -