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.scss b/packages/libs/components/src/components/tidytree/TreeTable.scss new file mode 100644 index 0000000000..a0db3683bf --- /dev/null +++ b/packages/libs/components/src/components/tidytree/TreeTable.scss @@ -0,0 +1,6 @@ +.TreeTable { + --tree-table-row-height: 1em; + tr { + height: var(--tree-table-row-height); + } +} diff --git a/packages/libs/components/src/components/tidytree/TreeTable.tsx b/packages/libs/components/src/components/tidytree/TreeTable.tsx index 35eb2fc55f..86aaece058 100644 --- a/packages/libs/components/src/components/tidytree/TreeTable.tsx +++ b/packages/libs/components/src/components/tidytree/TreeTable.tsx @@ -5,15 +5,20 @@ 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 './TreeTable.scss'; 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 +30,18 @@ export interface TreeTableProps { * data and options for the table */ tableProps: MesaStateProps; + /** + * hide the tree (but keep its horizontal space); default = false + */ + hideTree?: boolean; + /** + * Passed as children to the `Mesa` component + */ + children?: React.ReactNode; } +const margin: [number, number, number, number] = [0, 10, 0, 10]; + /** * main props are * data: string; // Newick format tree @@ -42,50 +57,52 @@ 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 rowStyleClassName = useMemo( - () => - cx( - classNameStyle({ - height: rowHeight + 'px', - background: 'yellow', - }) - ), - [rowHeight] - ); + const { rowHeight, maxColumnWidth = 200, hideTree = false, children } = props; + const { rows, filteredRows } = props.tableProps; // tableState is just the tableProps with an extra CSS class // to make sure the height is consistent with the tree - const tableState: MesaStateProps = { - ...props.tableProps, - options: { - ...props.tableProps.options, - deriveRowClassName: (_) => rowStyleClassName, - }, - }; - - return ( -
+ const tableState: MesaStateProps = useMemo(() => { + const tree = hideTree ? null : ( - <> - - - -
- ); + ); + + return { + ...props.tableProps, + options: { + ...props.tableProps.options, + className: 'TreeTable', + style: { + '--tree-table-row-height': rowHeight + 'px', + } as React.CSSProperties, + 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`, + marginContent: tree, + }, + }; + }, [ + filteredRows?.length, + hideTree, + maxColumnWidth, + props.tableProps, + props.treeProps, + rowHeight, + rows.length, + ]); + + // 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 ; } 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..fa88237b2a 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 { @@ -47,8 +49,14 @@ class DataCell extends React.PureComponent { } } + setTitle(el) { + if (el == null) return; + el.title = el.scrollWidth <= el.clientWidth ? '' : el.innerText; + } + render() { - let { column, inline, options, isChildRow, childRowColSpan } = this.props; + let { column, inline, options, isChildRow, childRowColSpan, rowIndex } = + this.props; let { style, width, className, key } = column; let whiteSpace = !inline @@ -64,15 +72,24 @@ 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 props = { style, - children, + children: content, className, ...(isChildRow ? { colSpan: childRowColSpan } : null), }; - return column.hidden ? null : ; + return column.hidden ? null : ( + this.setTitle(e.target)} + onMouseLeave={() => this.setTitle()} + key={key + '_' + rowIndex} + {...props} + /> + ); } } 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/DataTable.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx index 95123e029a..799682ad59 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx @@ -16,7 +16,6 @@ class DataTable extends React.Component { super(props); this.widthCache = {}; this.state = { dynamicWidths: null, tableWrapperWidth: null }; - this.renderPlainTable = this.renderPlainTable.bind(this); this.renderStickyTable = this.renderStickyTable.bind(this); this.componentDidMount = this.componentDidMount.bind(this); this.getInnerCellWidth = this.getInnerCellWidth.bind(this); @@ -201,10 +200,8 @@ class DataTable extends React.Component { columns: stickyColumns, dynamicWidths, }); - const bodyWrapperStyle = { - maxHeight: options ? options.tableBodyMaxHeight : null, - }; const wrapperStyle = { + maxHeight: options ? options.tableBodyMaxHeight : null, minWidth: dynamicWidths ? combineWidths(columns.map(({ width }) => width)) : null, @@ -225,66 +222,49 @@ class DataTable extends React.Component { }; return (
(this.mainRef = node)} className="MesaComponent"> -
-
-
(this.headerNode = node)} - className={dataTableClass('Header')} - > - - -
-
-
(this.bodyNode = node)} - className={dataTableClass('Body')} - onScroll={this.handleTableBodyScroll} +
+
(this.headerNode = node)} + className={dataTableClass('Header')} + > + + +
+
+
(this.bodyNode = node)} + className={dataTableClass('Body')} + onScroll={this.handleTableBodyScroll} + > + (this.contentTable = node)} > -
(this.contentTable = node)} - > - {dynamicWidths == null ? : null} - -
-
+ {dynamicWidths == null ? : null} + +
-
-
- ); - } - - renderPlainTable() { - const { props } = this; - const { options, columns } = props; - - const stickyColumns = options.useStickyFirstNColumns - ? this.makeFirstNColumnsSticky(columns, options.useStickyFirstNColumns) - : columns; - const newProps = { - ...props, - columns: stickyColumns, - }; - - return ( -
-
- - - -
+ {this.props.options.marginContent && ( +
+ {this.props.options.marginContent} +
+ )}
); } render() { - const { shouldUseStickyHeader, renderStickyTable, renderPlainTable } = this; - return shouldUseStickyHeader() ? renderStickyTable() : renderPlainTable(); + return this.renderStickyTable(); } } diff --git a/packages/libs/coreui/src/components/Mesa/Ui/MesaController.jsx b/packages/libs/coreui/src/components/Mesa/Ui/MesaController.jsx index 93d490ae02..cf4f3d44ff 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/MesaController.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/MesaController.jsx @@ -97,8 +97,10 @@ class MesaController extends React.Component { const PageNav = this.renderPaginationMenu; const Empty = this.renderEmptyState; + const className = (options.className ?? '') + ' Mesa MesaComponent'; + return ( -
+
diff --git a/packages/libs/coreui/src/components/Mesa/Ui/RowCounter.jsx b/packages/libs/coreui/src/components/Mesa/Ui/RowCounter.jsx index ce66c5789d..4657e08294 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/RowCounter.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/RowCounter.jsx @@ -31,7 +31,10 @@ class RowCounter extends React.PureComponent { : start - 1 + rowsPerPage; let filterString = !filteredRowCount ? null : ( - (filtered from a total of {count}) + + {' '} + (filtered from a total of {count.toLocaleString()}) + ); const remainingRowCount = !filteredRowCount ? count @@ -39,7 +42,7 @@ class RowCounter extends React.PureComponent { let countString = ( - {remainingRowCount} {noun} + {remainingRowCount.toLocaleString()} {noun} ); let allResultsShown = 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/DataTable.scss b/packages/libs/coreui/src/components/Mesa/style/Ui/DataTable.scss index e87f3bc71f..bb4d37c72d 100644 --- a/packages/libs/coreui/src/components/Mesa/style/Ui/DataTable.scss +++ b/packages/libs/coreui/src/components/Mesa/style/Ui/DataTable.scss @@ -2,8 +2,34 @@ .DataTable { font-size: $fontSize; width: 100%; - display: block; margin-bottom: 1.5em; + display: grid; + grid-template-columns: auto 1fr; + grid-template-areas: + '. header' + 'margin body'; + z-index: 0; + + &--Sticky { + overflow: auto; + .DataTable-Header { + position: sticky; + top: 0; + z-index: 1; + } + } + + .DataTable-Header { + grid-area: header; + } + + .DataTable-Body { + grid-area: body; + } + + .DataTable-Margin { + grid-area: margin; + } table { width: 100%; 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..7b4b70ff12 100644 --- a/packages/libs/coreui/src/components/Mesa/types.ts +++ b/packages/libs/coreui/src/components/Mesa/types.ts @@ -36,7 +36,9 @@ 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; + style?: React.CSSProperties; errOnOverflow?: boolean; editableColumns?: boolean; overflowHeight?: string; @@ -67,6 +69,12 @@ export interface MesaStateProps< */ childRow?: (props: ChildRowProps) => ReactElement>; getRowId?: (row: Row) => string | number; + /** + * Renders the node in the left margin of the table. + * This can be useful for rendering a graphic that + * aligns with table rows, etc. + */ + marginContent?: React.ReactNode; }; actions?: MesaAction[]; eventHandlers?: { diff --git a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx index 59a1fd2a3f..79b5f0f63b 100644 --- a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx +++ b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx @@ -1,4 +1,12 @@ -import { ReactNode, useState, useEffect, useMemo } from 'react'; +import { + ReactNode, + useState, + useEffect, + useMemo, + forwardRef, + useImperativeHandle, + useCallback, +} from 'react'; import { Popover } from '@material-ui/core'; import SwissArmyButton from '../SwissArmyButton'; import { gray } from '../../../definitions/colors'; @@ -66,6 +74,11 @@ const defaultStyle: ButtonStyleSpec = { }, }; +export interface PopoverButtonHandle { + /** Closes the popover */ + close: () => void; +} + export interface PopoverButtonProps { /** Contents of the menu when opened */ children: ReactNode; @@ -87,87 +100,100 @@ 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', - type: 'button', - }} - /> - ); - - 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 = useCallback(() => { + setAnchorEl(null); + onClose && onClose(); + }, [onClose]); + + // Expose the `close()` method to external components via ref + useImperativeHandle( + ref, + () => ({ + close: onCloseHandler, + }), + [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/libs/coreui/src/components/inputs/SelectList.tsx b/packages/libs/coreui/src/components/inputs/SelectList.tsx index 1de1366795..8c3fd0a1ff 100644 --- a/packages/libs/coreui/src/components/inputs/SelectList.tsx +++ b/packages/libs/coreui/src/components/inputs/SelectList.tsx @@ -1,17 +1,25 @@ -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'; +import CheckboxList, { + CheckboxListProps, + Item, +} from './checkboxes/CheckboxList'; -export interface SelectListProps extends CheckboxListProps { +export interface SelectListProps + extends CheckboxListProps { children?: ReactNode; /** A button's content if/when no values are currently selected */ defaultButtonDisplayContent: ReactNode; 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({ +export default function SelectList({ name, items, value, @@ -21,30 +29,45 @@ export default function SelectList({ defaultButtonDisplayContent, isDisabled = false, isLoading = false, + instantUpdate = false, ...props }: SelectListProps) { const [selected, setSelected] = useState['value']>(value); const [buttonDisplayContent, setButtonDisplayContent] = useState( - value.length ? value.join(', ') : defaultButtonDisplayContent + getDisplayContent(value, items, defaultButtonDisplayContent) ); const onClose = () => { onChange(selected); setButtonDisplayContent( - selected.length ? selected.join(', ') : defaultButtonDisplayContent + getDisplayContent(selected, items, defaultButtonDisplayContent) ); }; + /** + * 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 + getDisplayContent(value, items, defaultButtonDisplayContent) ); - }, [value, defaultButtonDisplayContent]); + }, [value, items, defaultButtonDisplayContent]); const buttonLabel = ( ({ name={name} items={items} value={selected} - onChange={setSelected} + onChange={handleCheckboxListUpdate} linksPosition={linksPosition} {...props} /> @@ -84,3 +107,19 @@ export default function SelectList({ ); } + +// Returns button display content based on `value` array, mapping to display names from `items` when available. +// If no matching display name is found, uses the value itself. Returns `defaultContent` if `value` is empty. +function getDisplayContent( + value: T[], + items: Item[], + defaultContent: ReactNode +): ReactNode { + return value.length + ? value + .map( + (v) => items.find((item) => item.value === v)?.display ?? v + ) + .reduce((accum, elem) => (accum ? [accum, ',', elem] : elem), null) + : defaultContent; +} diff --git a/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx b/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx index 7d7655073e..567f82ee28 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,7 +117,7 @@ function SelectTree(props: SelectTreeProps) { /> ); - return ( + return hasPopoverButton ? ( (props: SelectTreeProps) { {wrapPopover ? wrapPopover(checkboxTree) : checkboxTree}
+ ) : ( + <>{wrapPopover ? wrapPopover(checkboxTree) : checkboxTree} ); } @@ -114,6 +156,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..218dae85ee 100644 --- a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx +++ b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx @@ -76,7 +76,7 @@ export type Item = { disabled?: boolean; }; -export type CheckboxListProps = { +export type CheckboxListProps = { /** Optional name attribute for the native input element */ name?: string; @@ -99,7 +99,7 @@ export type CheckboxListProps = { disabledCheckboxTooltipContent?: ReactNode; }; -export default function CheckboxList({ +export default function CheckboxList({ name, items, value, @@ -205,7 +205,7 @@ export default function CheckboxList({ ) : ( - + {/* use safeHtml for helpText to allow italic */} + {!helpText ? null : {safeHtml(helpText)}} +
+ ); } /** @@ -185,12 +122,8 @@ export default class RealTimeSearchBox extends Component { * @example * let allClassNames = className('Thing', 'active', 'blue'); * //=> 'Thing Thing__active Thing__blue' - * - * @param {string} base - * @param {string} ...modifiers - * @returns {string} */ -function classname(base: string, ...modifiers: string[]) { +function classname(base: string, ...modifiers: string[]): string { return modifiers.reduce((classnames, modifier) => { return modifier ? classnames + ' ' + base + '__' + modifier : classnames; }, base); diff --git a/packages/libs/wdk-client/src/StoreModules/RecordStoreModule.ts b/packages/libs/wdk-client/src/StoreModules/RecordStoreModule.ts index 0e8d41c859..7f16fd6fa7 100644 --- a/packages/libs/wdk-client/src/StoreModules/RecordStoreModule.ts +++ b/packages/libs/wdk-client/src/StoreModules/RecordStoreModule.ts @@ -40,9 +40,14 @@ import { RootState } from '../Core/State/Types'; import { EpicDependencies, ModuleEpic } from '../Core/Store'; import { getValue, preferences, setValue } from '../Preferences'; import { ServiceError } from '../Service/ServiceError'; -import { CategoryTreeNode, getId, getTargetType } from '../Utils/CategoryUtils'; +import { + CategoryTreeNode, + getId, + getRefName, + getTargetType, +} from '../Utils/CategoryUtils'; import { stateEffect } from '../Utils/ObserverUtils'; -import { filterNodes } from '../Utils/TreeUtils'; +import { filterNodes, getLeaves } from '../Utils/TreeUtils'; import { RecordClass, RecordInstance } from '../Utils/WdkModel'; export const key = 'record'; @@ -84,11 +89,22 @@ export function reduce(state: State = {} as State, action: Action): State { case RECORD_RECEIVED: { if (action.id !== state.requestId) return state; let { record, recordClass, categoryTree } = action.payload; + + const collapsedSections = getLeaves(categoryTree, (node) => node.children) + .filter( + ( + node + ): node is CategoryTreeNode & { properties: { name: string[] } } => + !!node.properties.scope?.includes('record-collapsed') && + !!node.properties.name + ) + .map((node) => getRefName(node)); + return { ...state, record, recordClass, - collapsedSections: [], + collapsedSections, navigationCategoriesExpanded: state.navigationCategoriesExpanded || [], isLoading: false, categoryTree, diff --git a/packages/libs/wdk-client/src/Utils/CategoryUtils.tsx b/packages/libs/wdk-client/src/Utils/CategoryUtils.tsx index b98bbe156a..bfa0a28599 100644 --- a/packages/libs/wdk-client/src/Utils/CategoryUtils.tsx +++ b/packages/libs/wdk-client/src/Utils/CategoryUtils.tsx @@ -25,6 +25,7 @@ export type TargetType = 'search' | 'attribute' | 'table'; export type Scope = | 'record' | 'record-internal' + | 'record-collapsed' | 'results' | 'results-internal' | 'download' diff --git a/packages/libs/wdk-client/src/Utils/DomUtils.ts b/packages/libs/wdk-client/src/Utils/DomUtils.ts index 8aa692e1a3..8547c4cfd1 100644 --- a/packages/libs/wdk-client/src/Utils/DomUtils.ts +++ b/packages/libs/wdk-client/src/Utils/DomUtils.ts @@ -47,6 +47,7 @@ function addScrollAnchor__loop( let containerRect = container.getBoundingClientRect(); const offsetParent = container.offsetParent || document.body; let parentSize = offsetParent.clientHeight; + let anchorNodePosition = anchorNode && getNodePosition(anchorNode); let animId: number; function loop() { @@ -69,7 +70,8 @@ function addScrollAnchor__loop( function updateAnchor() { anchorNode = findAnchorNode(container); - console.debug('updating anchorNode', anchorNode); + anchorNodePosition = anchorNode && getNodePosition(anchorNode); + console.debug('updating anchorNode', anchorNode, anchorNodePosition); } function parentSizeChanged(): boolean { @@ -88,7 +90,11 @@ function addScrollAnchor__loop( } function scrollToAnchor() { - if (anchorNode != null) { + if ( + anchorNode != null && + // only scroll to anchor node if its position has changed by a threshold + Math.abs(getNodePosition(anchorNode) - (anchorNodePosition ?? 0)) > 10 + ) { anchorNode.scrollIntoView(); console.debug('scrolling to anchorNode', anchorNode); } @@ -186,10 +192,10 @@ function monitorScroll(scrollHandler: () => void) { */ function findAnchorNode(element: Element) { // skip if element is below top of viewport - if (element.getBoundingClientRect().top > 0) return; + if (getNodePosition(element) > 0) return; return find( - (node: Element) => node.getBoundingClientRect().top > 0, + (node: Element) => getNodePosition(node) > 0, preorder(element, getElementChildren) ); } @@ -198,6 +204,10 @@ function getElementChildren(el: Element) { return Array.from(el.children); } +function getNodePosition(element: Element) { + return element.getBoundingClientRect().top; +} + /** * Is the top of the element visible in the element's scroll parent? * @param element diff --git a/packages/libs/wdk-client/src/Views/Answer/Answer.jsx b/packages/libs/wdk-client/src/Views/Answer/Answer.jsx index bfebb6dccb..39658c46bd 100644 --- a/packages/libs/wdk-client/src/Views/Answer/Answer.jsx +++ b/packages/libs/wdk-client/src/Views/Answer/Answer.jsx @@ -155,7 +155,7 @@ function useTableState(props) { () => ({ useStickyHeader: true, useStickyFirstNColumns, - tableBodyMaxHeight: 'unset', + tableBodyMaxHeight: '70vh', deriveRowClassName: deriveRowClassName && ((record) => deriveRowClassName({ recordClass, record })), diff --git a/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordFilter.tsx b/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordFilter.tsx index bfea69e9d7..27dcd09199 100644 --- a/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordFilter.tsx +++ b/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordFilter.tsx @@ -28,7 +28,7 @@ type RecordFilterSelectorProps = { interface RecordFilterProps extends Omit< RecordFilterSelectorProps, - 'toggleFilterFieldSelect' | 'containerClassName' + 'toggleFilterFieldSelector' | 'containerClassName' > { searchTerm: string; onSearchTermChange: (searchTerm: string) => void; diff --git a/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTable.jsx b/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTable.jsx index a710943871..6985d03596 100644 --- a/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTable.jsx +++ b/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTable.jsx @@ -73,6 +73,8 @@ class RecordTable extends Component { getOrderedData ); this.onSort = this.onSort.bind(this); + this.onSearchTermChange = this.onSearchTermChange.bind(this); + this.onColumnFilterChange = this.onColumnFilterChange.bind(this); this.wrappedChildRow = this.wrappedChildRow.bind(this); this.state = { searchTerm: this.props.searchTerm ?? '', @@ -86,6 +88,20 @@ class RecordTable extends Component { this.setState((state) => ({ ...state, sort: { columnKey, direction } })); } + onSearchTermChange(searchTerm) { + this.setState((state) => ({ + ...state, + searchTerm, + })); + } + + onColumnFilterChange(selectedColumnFilters) { + this.setState((state) => ({ + ...state, + selectedColumnFilters, + })); + } + wrappedChildRow(rowIndex, rowData) { const { childRow: ChildRow } = this.props; if (!ChildRow) return; @@ -324,24 +340,14 @@ class RecordTable extends Component { {mesaReadyRows.length > 1 && ( - this.setState((state) => ({ - ...state, - searchTerm, - })) - } + onSearchTermChange={this.onSearchTermChange} recordDisplayName={this.props.recordClass.displayNamePlural} filterAttributes={displayableAttributes.map((attr) => ({ value: attr.name, display: attr.displayName, }))} selectedColumnFilters={this.state.selectedColumnFilters} - onColumnFilterChange={(value) => - this.setState((state) => ({ - ...state, - selectedColumnFilters: value, - })) - } + onColumnFilterChange={this.onColumnFilterChange} /> )} diff --git a/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTableDescription.jsx b/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTableDescription.jsx index ea49f0a02e..2351870a9b 100644 --- a/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTableDescription.jsx +++ b/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTableDescription.jsx @@ -1,10 +1,66 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState } from 'react'; import { safeHtml, wrappable } from '../../../Utils/ComponentUtils'; +const containerStyle = { + display: 'flex', + flexWrap: 'wrap', + borderLeft: '.2em solid #79a3d7', + borderRight: '.2em solid #79a3d7', + padding: '.5em 1em', + background: '#ebf4ff', + gap: '1em', + marginBottom: '1em', +}; + function RecordTableDescription(props) { const { description } = props.table; - return description ?

{safeHtml(description)}

: null; + const [isOverflowing, setIsOverflowing] = useState(undefined); + const [isExpanded, setIsExpanded] = useState(false); + + if (!description) return null; + + return ( +
+ {safeHtml( + description, + { + ref: (el) => { + if (el == null || isOverflowing != null) { + return; + } + if ( + el.clientWidth >= el.scrollWidth || + el.clientHeight >= el.scrollHeight + ) { + setIsOverflowing(false); + } else { + setIsOverflowing(true); + } + }, + style: + isExpanded || isOverflowing === false + ? {} + : { + maxHeight: 'calc(2 * 1.2em)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + }, + 'div' + )} + {isOverflowing && ( + + )} +
+ ); } RecordTableDescription.propTypes = { diff --git a/packages/libs/wdk-client/src/Views/Records/RecordUtils.ts b/packages/libs/wdk-client/src/Views/Records/RecordUtils.ts index 19e0c4749f..840705a4be 100644 --- a/packages/libs/wdk-client/src/Views/Records/RecordUtils.ts +++ b/packages/libs/wdk-client/src/Views/Records/RecordUtils.ts @@ -85,7 +85,14 @@ export const isInternalNode = partial( 'scope', 'record-internal' ); -export const isNotInternalNode = partial(nodeHasProperty, 'scope', 'record'); +const isRecordNode = partial(nodeHasProperty, 'scope', 'record'); +const isRecordCollapsedNode = partial( + nodeHasProperty, + 'scope', + 'record-collapsed' +); +export const isNotInternalNode = (node: CategoryTreeNode) => + isRecordNode(node) || isRecordCollapsedNode(node); export const isAttributeNode = partial( nodeHasProperty, 'targetType', diff --git a/packages/sites/ortho-site/.eslintrc b/packages/sites/ortho-site/.eslintrc index a4419bd2f1..4ce17fe3de 100644 --- a/packages/sites/ortho-site/.eslintrc +++ b/packages/sites/ortho-site/.eslintrc @@ -1,3 +1,3 @@ { - "extends": "../../WDKClient/Client/.eslintrc" + "extends": "@veupathdb" } diff --git a/packages/sites/ortho-site/package.json b/packages/sites/ortho-site/package.json index e3ee06fec2..3b8b2e5587 100644 --- a/packages/sites/ortho-site/package.json +++ b/packages/sites/ortho-site/package.json @@ -92,5 +92,8 @@ }, "files": [ "dist" - ] + ], + "dependencies": { + "patristic": "^0.6.0" + } } diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/pfam-domains/PfamDomainArchitecture.tsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/pfam-domains/PfamDomainArchitecture.tsx index bc99fcc5c0..a9ed32ae1d 100644 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/pfam-domains/PfamDomainArchitecture.tsx +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/pfam-domains/PfamDomainArchitecture.tsx @@ -7,6 +7,7 @@ import './PfamDomainArchitecture.scss'; interface Props { length: number; domains: { start: number; end: number; pfamId: string }[]; + pfamDescriptions?: Map; style?: React.CSSProperties; } @@ -16,7 +17,12 @@ export interface Domain { pfamId: string; } -export function PfamDomainArchitecture({ length, domains, style }: Props) { +export function PfamDomainArchitecture({ + length, + domains, + style, + pfamDescriptions, +}: Props) { return (
@@ -24,7 +30,7 @@ export function PfamDomainArchitecture({ length, domains, style }: Props) { ))} @@ -32,8 +38,13 @@ export function PfamDomainArchitecture({ length, domains, style }: Props) { ); } -function makeDomainTitle({ start, end, pfamId }: Domain) { - return `${pfamId} (location: [${start} - ${end}])`; +function makeDomainTitle( + { start, end, pfamId }: Domain, + pfamDescriptions?: Map +) { + if (pfamDescriptions != null) + return `${pfamId} (${pfamDescriptions.get(pfamId)}) [${start} - ${end}]`; + else return `${pfamId} (location: [${start} - ${end}])`; } function makeDomainPositionStyling( diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/phyletic-distribution/PhyleticDistributionCheckbox.tsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/phyletic-distribution/PhyleticDistributionCheckbox.tsx index e7daaac55b..3b4086537d 100644 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/phyletic-distribution/PhyleticDistributionCheckbox.tsx +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/phyletic-distribution/PhyleticDistributionCheckbox.tsx @@ -3,7 +3,8 @@ import React, { useMemo, useState } from 'react'; import { orderBy } from 'lodash'; import { Checkbox } from '@veupathdb/wdk-client/lib/Components'; -import CheckboxTree, { +import { + CheckboxTreeStyleSpec, LinksPosition, } from '@veupathdb/coreui/lib/components/inputs/checkboxes/CheckboxTree/CheckboxTree'; import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; @@ -25,6 +26,7 @@ import { } from 'ortho-client/utils/taxons'; import './PhyleticDistributionCheckbox.scss'; +import { SelectTree } from '@veupathdb/coreui'; const cx = makeClassNameHelper('PhyleticDistributionCheckbox'); @@ -41,6 +43,7 @@ type SelectionConfig = | { selectable: true; onSpeciesSelected: (selection: string[]) => void; + selectedSpecies: string[]; }; export function PhyleticDistributionCheckbox({ @@ -71,40 +74,44 @@ export function PhyleticDistributionCheckbox({ ); return ( -
- - -   Hide zero counts - , - ]} - /> -
+ + +   Hide zero counts + , + ]} + /> ); } diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupRecordClasses.GroupRecordClass.scss b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupRecordClasses.GroupRecordClass.scss index 5761e1de28..94bcb08dc0 100644 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupRecordClasses.GroupRecordClass.scss +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupRecordClasses.GroupRecordClass.scss @@ -1,4 +1,7 @@ .wdk-RecordContainer__GroupRecordClasses\.GroupRecordClass { + --row-hl-bg-color: wheat; + --row-hl-fg-color: inherit; + .wdk-MissingMsaAttribute { color: darkred; } @@ -16,4 +19,29 @@ min-width: 0.41666666666em; } } + + #Sequences .Mesa.MesaComponent { + display: flex; + flex-wrap: wrap; + > .Toolbar { + &, + & > * { + width: 100%; + } + } + > .MesaComponent { + width: 100%; + > .DataTable { + max-width: unset; + width: unset; + overflow-x: unset; + + // Set background-color of selected rows + tr:has(> td.SelectionCell > div > input:checked) > td { + background-color: var(--row-hl-bg-color) !important; + color: var(--row-hl-fg-color) !important; + } + } + } + } } diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupRecordClasses.GroupRecordClass.tsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupRecordClasses.GroupRecordClass.tsx index 0e9c402600..28d8e3037b 100644 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupRecordClasses.GroupRecordClass.tsx +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupRecordClasses.GroupRecordClass.tsx @@ -19,6 +19,7 @@ import { PhyleticDistributionCheckbox } from 'ortho-client/components/phyletic-d import { PfamDomainArchitecture } from 'ortho-client/components/pfam-domains/PfamDomainArchitecture'; import { RecordTable_Sequences } from 'ortho-client/records/Sequences'; +import { RecordTable_GroupStats } from './GroupStats'; import { RecordAttributeProps, @@ -50,6 +51,7 @@ const MSA_ATTRIBUTE_NAME = 'msa'; const PFAMS_TABLE_NAME = 'PFams'; const PROTEIN_PFAMS_TABLE_NAME = 'ProteinPFams'; const SEQUENCES_TABLE_NAME = 'Sequences'; +const GROUP_STATS_TABLE_NAME = 'GroupStat'; const CORE_PERIPHERAL_ATTRIBUTE_NAME = 'core_peripheral'; const PROTEIN_LENGTH_ATTRIBUTE_NAME = 'protein_length'; @@ -364,4 +366,5 @@ const recordTableWrappers: Record< [PROTEIN_PFAMS_TABLE_NAME]: RecordTable_ProteinDomainArchitectures, [TAXON_COUNTS_TABLE_NAME]: RecordTable_TaxonCounts, [SEQUENCES_TABLE_NAME]: RecordTable_Sequences, + [GROUP_STATS_TABLE_NAME]: RecordTable_GroupStats, }; diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupStats.tsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupStats.tsx new file mode 100644 index 0000000000..75e3551e55 --- /dev/null +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupStats.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { RecordTable } from './SequenceRecordClasses.SequenceRecordClass'; +import { RecordTableProps, WrappedComponentProps } from './Types'; + +import eval_hist_img from './eval-hist.png'; + +export function RecordTable_GroupStats( + props: WrappedComponentProps +) { + const regularRecordTable = RecordTable(props); + + return ( +
+ {regularRecordTable} +
+ Histogram of median inter-group e-values for both core-only and core+peripheral proteins. The distributions of both peak at around 1e-20 to 1e-60 with a substantial tail out to e-values of 1e-300. +
+ This histogram is provided to aid the interpretation of E-values in + the adjoining table. E-values have been transformed using a negative + logarithm, so higher significance is represented further to the right. +
+
+
+ ); +} diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/RecordTable_TaxonCounts_Filter.tsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/RecordTable_TaxonCounts_Filter.tsx new file mode 100644 index 0000000000..55bf462d14 --- /dev/null +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/RecordTable_TaxonCounts_Filter.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from 'react'; +import { RecordTableProps, WrappedComponentProps } from './Types'; +import { Loading } from '@veupathdb/wdk-client/lib/Components'; +import { PhyleticDistributionCheckbox } from 'ortho-client/components/phyletic-distribution/PhyleticDistributionCheckbox'; +import { taxonCountsTableValueToMap } from './utils'; +import { useTaxonUiMetadata } from 'ortho-client/hooks/taxons'; + +export interface Props extends WrappedComponentProps { + selectedSpecies: string[]; + onSpeciesSelected: (taxons: string[]) => void; +} + +export function RecordTable_TaxonCounts_Filter({ + value, + selectedSpecies, + onSpeciesSelected, +}: Props) { + const selectionConfig = useMemo( + () => + ({ + selectable: true, + onSpeciesSelected, + selectedSpecies, + } as const), + [onSpeciesSelected, selectedSpecies] + ); + + const speciesCounts = useMemo( + () => taxonCountsTableValueToMap(value), + [value] + ); + + const taxonUiMetadata = useTaxonUiMetadata(); + + return taxonUiMetadata == null ? ( + + ) : ( + + ); +} diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.jsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.jsx deleted file mode 100644 index 2d7918955d..0000000000 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import lodash from 'lodash'; -import React, { useMemo, useRef, useState, useCallback } from 'react'; - -// function RecordTable_TaxonCounts({ value }: WrappedComponentProps) { -export function RecordTable_Sequences(props) { - const formRef = useRef(null); - const [selectedRowIds, setSelectedRowIds] = useState([]); - - const sortedValue = useSortedValue(props.value); - - const isRowSelected = useCallback( - ({ full_id }) => selectedRowIds.includes(full_id), - [selectedRowIds] - ); - - const onRowSelect = useCallback( - ({ full_id }) => setSelectedRowIds(selectedRowIds.concat(full_id)), - [selectedRowIds] - ); - - const onRowDeselect = useCallback( - ({ full_id }) => - setSelectedRowIds(selectedRowIds.filter((id) => id !== full_id)), - [selectedRowIds] - ); - - const onMultipleRowSelect = useCallback( - (rows) => - setSelectedRowIds( - selectedRowIds.concat(rows.map((row) => row['full_id'])) - ), - [selectedRowIds] - ); - - const onMultipleRowDeselect = useCallback( - (rows) => - setSelectedRowIds( - selectedRowIds.filter((row) => rows.includes(row['full_id'])) - ), - [selectedRowIds] - ); - - if (props.value.length === 0) { - return ; - } else { - const orthoTableProps = { - options: { - isRowSelected, - selectedNoun: 'protein', - selectedPluralNoun: 'proteins', - }, - eventHandlers: { - onRowSelect, - onRowDeselect, - onMultipleRowSelect, - onMultipleRowDeselect, - }, - actions: [ - { - selectionRequired: false, - element() { - return null; - }, - callback: () => null, - }, - ], - }; - - return ( -
- - {selectedRowIds.map((id) => ( - - ))} - -

- Please note: selecting a large number of proteins will take several - minutes to align. -

-
-

- Output format:   - -

- -
- - ); - } -} - -function useSortedValue(value) { - return useMemo(() => lodash.sortBy(value, 'sort_key'), [value]); -} 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 new file mode 100644 index 0000000000..1efea4f282 --- /dev/null +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.tsx @@ -0,0 +1,866 @@ +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'; +import { Loading, Link } from '@veupathdb/wdk-client/lib/Components'; +import { Branch, parseNewick } from 'patristic'; +import { + AttributeValue, + TableValue, +} from '@veupathdb/wdk-client/lib/Utils/WdkModel'; +import { + MesaColumn, + MesaStateProps, +} from '@veupathdb/coreui/lib/components/Mesa/types'; +import { groupBy, difference } from 'lodash'; +import { PfamDomainArchitecture } from 'ortho-client/components/pfam-domains/PfamDomainArchitecture'; +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, +} from '@veupathdb/coreui'; +import { RecordTable_TaxonCounts_Filter } from './RecordTable_TaxonCounts_Filter'; +import { formatAttributeValue } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; +import { RecordFilter } from '@veupathdb/wdk-client/lib/Views/Records/RecordTable/RecordFilter'; +import { + areTermsInStringRegexString, + parseSearchQueryString, +} from '@veupathdb/wdk-client/lib/Utils/SearchUtils'; + +type RowType = Record; + +const treeWidth = 200; +const maxColumnWidth = 200; +const maxArchitectureLength = maxColumnWidth - 10 - 10 - 1; // 10px padding each side plus a 1px border +const MIN_SEQUENCES_FOR_TREE = 3; +const MAX_SEQUENCES_FOR_TREE = 1000; + +const PFAM_ARCH_COLUMN_KEY = 'pfamArchitecture'; + +const highlightColor = '#feb640'; +const highlightColor50 = highlightColor + '7f'; + +export function RecordTable_Sequences( + props: WrappedComponentProps +) { + const [searchQuery, setSearchQuery] = useState(''); + 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([]); + + const [pfamFilterIds, setPfamFilterIds, volatilePfamFilterIds] = + useDeferredState([]); + + const [ + corePeripheralFilterValue, + setCorePeripheralFilterValue, + volatileCorePeripheralFilterValue, + ] = useDeferredState<('core' | 'peripheral')[]>([]); + + const groupName = props.record.id.find( + ({ name }) => name === 'group_name' + )?.value; + + if (!groupName) { + throw new Error('groupName is required but was not found in the record.'); + } + + const [highlightedNodes, setHighlightedNodes] = useState([]); + + const mesaRows = props.value; + const pfamRows = props.record.tables['PFams']; + + const numSequences = mesaRows.length; + + const treeResponse = useOrthoService( + (orthoService) => { + if (numSequences < MIN_SEQUENCES_FOR_TREE) + return Promise.resolve(undefined); + return orthoService.getGroupTree(groupName); + }, + [groupName, numSequences] + ); + + const treeUrl = useOrthoService( + async (orthoService) => orthoService.getGroupTreeUrl(groupName), + [groupName] + ); + + // deal with Pfam domain architectures + const proteinPfams = props.record.tables['ProteinPFams']; + const rowsByAccession = useMemo( + () => groupBy(proteinPfams, 'full_id'), + [proteinPfams] + ); + + const accessionToPfamIds = useMemo( + () => + proteinPfams.reduce((map, row) => { + const full_id = row['full_id'] as string; + if (!map.has(full_id)) map.set(full_id, new Set()); + map.set(full_id, map.get(full_id)!.add(row['accession'] as string)); + return map; + }, new Map>()), + [proteinPfams] + ); + + const pfamIdToDescription = useMemo( + () => + pfamRows.reduce((map, row) => { + const pfamId = row.accession as string; + const description = row.description as string; + return map.set(pfamId, description); + }, new Map()), + [pfamRows] + ); + + const maxProteinLength = useMemo( + () => + mesaRows.reduce((max, row) => { + const length = Number(row['length'] || ('0' as string)); + return length > max ? length : max; + }, 0), + [mesaRows] + ); + + const mesaColumns = useMemo((): MesaColumn[] => { + const mesaColumnsFromAttrs: MesaColumn[] = props.table.attributes + .filter(({ isDisplayable }) => isDisplayable) + .map(({ name, displayName, type }) => ({ + key: name, + name: displayName, + type: type === 'link' ? 'wdkLink' : type, + })); + + return [ + { + key: PFAM_ARCH_COLUMN_KEY, + name: 'Domain architecture', + renderCell: (cellProps) => { + const proteinId = cellProps.row.full_id as string; + const flatPfamData = rowsByAccession[proteinId]; + if (flatPfamData && flatPfamData.length > 0) { + const pfamDomains = flatPfamData.flatMap(extractPfamDomain); + const proteinLength = Number( + flatPfamData[0]['protein_length'] as string + ); + const architectureLength = Math.floor( + (maxArchitectureLength * proteinLength) / maxProteinLength + ); + return ( + + ); + } else { + return no PFAM domains; + } + }, + }, + ...mesaColumnsFromAttrs, + ]; + }, [ + maxProteinLength, + pfamIdToDescription, + props.table.attributes, + rowsByAccession, + ]); + + const [tablePageNumber, setTablePageNumber] = useState(1); + + const { tree, leaves, sortedRows } = useMemo(() => { + const tree = treeResponse == null ? undefined : parseNewick(treeResponse); + const leaves = tree && getLeaves(tree); + const sortedRows = leaves ? sortRows(leaves, mesaRows) : mesaRows; + return { tree, leaves, sortedRows }; + }, [treeResponse, mesaRows]); + + // do some validation on the tree w.r.t. the table + + // filter the rows of the table based on + // 1. user-entered text search + // 2. core-peripheral radio button + // 3. checked boxes in the Pfam legend + + const [ + selectedColumnFilters, + setSelectedColumnFilters, + volatileSelectedColumnFilters, + ] = useDeferredState([]); + + const filteredRows = useMemo(() => { + if ( + safeSearchRegexp != null || + corePeripheralFilterValue.length > 0 || + pfamFilterIds.length > 0 || + selectedSpecies.length > 0 || + proteinFilterIds.length > 0 + ) { + return sortedRows?.filter((row) => { + const rowCorePeripheral = ( + (row['core_peripheral'] as string) ?? '' + ).toLowerCase(); + const rowFullId = row['full_id'] as string; + const rowPfamIdsSet = accessionToPfamIds.get(rowFullId); + + const searchMatch = + safeSearchRegexp == null || + rowMatch(row, safeSearchRegexp, selectedColumnFilters); + const corePeripheralMatch = + corePeripheralFilterValue.length === 0 || + corePeripheralFilterValue.includes( + rowCorePeripheral.toLowerCase() as any + ); + const pfamIdMatch = + pfamFilterIds.length === 0 || + pfamFilterIds.some((pfamId) => rowPfamIdsSet?.has(pfamId)); + 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 && + proteinMatch + ); + }); + } + return sortedRows; + }, [ + selectedColumnFilters, + safeSearchRegexp, + sortedRows, + corePeripheralFilterValue, + 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 == null || + filteredRows.length === 0 + ) + return; + + if (filteredRows.length < leaves.length) { + const filteredRowIds = new Set( + filteredRows.map(({ full_id }) => full_id as string) + ); + + // must work on a copy of the tree because it's destructive + const treeCopy = tree.clone(); + let leavesRemoved = false; + do { + const leavesCopy = treeCopy.getLeaves(); + leavesRemoved = false; // Reset flag for each iteration + + for (const leaf of leavesCopy) { + if (!filteredRowIds.has(leaf.id)) { + leaf.remove(true); // remove leaf and remove any dangling ancestors + leavesRemoved = true; // A leaf was removed, so set flag to true + } + } + } while (leavesRemoved); // Continue looping if any leaf was removed + return treeCopy; + } + + return tree; + }, [tree, leaves, filteredRows]); + + // make a newick string from the filtered tree if needed + const finalNewick = useMemo(() => { + 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( + () => + mesaColumns + .map(({ key, name }) => ({ + value: key, + display: name ?? 'Unknown column', + })) + .filter(({ value }) => value !== PFAM_ARCH_COLUMN_KEY), + [mesaColumns] + ); + + const handleSpeciesSelection = useCallback( + (species: string[]) => { + setSelectedSpecies(species); + setTablePageNumber(1); + }, + [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 || !tree || !treeResponse) { + return ; + } + + if ( + numSequences >= MIN_SEQUENCES_FOR_TREE && + mesaRows != null && + sortedRows != null && + (mesaRows.length !== sortedRows.length || + mesaRows.length !== leaves?.length) + ) { + console.log( + 'Tree and protein list mismatch. A=Tree, B=Table. Summary below:' + ); + logIdMismatches( + (leaves ?? []).map((leaf) => leaf.id), + mesaRows.map((row) => + truncate_full_id_for_tree_comparison(row.full_id as string) + ) + ); + return ( + + A data processing error has occurred on our end. We apologize for + the inconvenience. If this problem persists, please{' '} + + contact us + + . + + ), + }} + /> + ); + } + + const rowHeight = 45; + const clustalDisabled = + highlightedNodes == null || highlightedNodes.length < 2; + + const rowCount = (filteredRows ?? sortedRows).length; + + const pfamFilter = pfamRows.length > 0 && ( + ({ + display: ( +
+ +
{formatAttributeValue(row.accession)}
+
{formatAttributeValue(row.description)}
+
+ {formatAttributeValue(row.num_proteins)} proteins +
+
+ ), + value: formatAttributeValue(row.accession), + }))} + value={volatilePfamFilterIds} + onChange={(ids) => { + setPfamFilterIds(ids); + setTablePageNumber(1); + }} + instantUpdate={true} + /> + ); + + const corePeripheralFilter = ( + + key={`corePeripheralFilter-${resetCounter}`} + defaultButtonDisplayContent="Core/Peripheral" + items={[ + { + display: 'Core', + value: 'core', + }, + { + display: 'Peripheral', + value: 'peripheral', + }, + ]} + value={volatileCorePeripheralFilterValue} + onChange={(value) => { + setCorePeripheralFilterValue(value); + setTablePageNumber(1); + }} + instantUpdate={true} + /> + ); + + const taxonFilter = + props.record.tables.TaxonCounts?.length > 0 ? ( + // eslint-disable-next-line react/jsx-pascal-case + + ) : null; + + const resetProteinFilterButton = ( + { + proteinFilterButtonRef.current?.close(); + setProteinFilterIds([]); + setTablePageNumber(1); + }} + /> + ); + + const updateProteinFilterIds = () => { + proteinFilterButtonRef.current?.close(); + setProteinFilterIds(highlightedNodes); + setHighlightedNodes([]); + setTablePageNumber(1); + }; + + 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 = ( + { + setSearchQuery(''); + setProteinFilterIds([]); + setPfamFilterIds([]); + setCorePeripheralFilterValue([]); + setSelectedSpecies([]); + setResetCounter((prev) => prev + 1); + setTablePageNumber(1); + }} + /> + ); + + if (filteredRows == null) return null; + + const warningText = + numSequences >= MIN_SEQUENCES_FOR_TREE && + (filteredRows.length > MAX_SEQUENCES_FOR_TREE || + filteredRows.length < MIN_SEQUENCES_FOR_TREE) ? ( + + To see a phylogenetic tree please use a filter to display between{' '} + {MIN_SEQUENCES_FOR_TREE.toLocaleString()} and{' '} + {MAX_SEQUENCES_FOR_TREE.toLocaleString()} sequences + + ) : filteredRows.length < sortedRows.length ? ( + + Note: The ortholog group's phylogeny has been pruned to display only the + currently filtered proteins. This may differ from a tree constructed{' '} + de novo using only these sequences. + + ) : undefined; + + return ( +
+ {warningText && ( +
+ {warningText} +
+ )} +
+ setSelectedColumnFilters(keys)} + /> +
+
+ +
+
+
+ Filters: + {proteinFilter} + {pfamFilter} + {corePeripheralFilter} + {taxonFilter} + {resetButton} +
+
+ {filteredRows && filteredRows?.length > Infinity ? ( +
+ Sorry, too many proteins selected:{' '} + {filteredRows.length.toLocaleString()}. Please use filters to select + up to {MAX_SEQUENCES_FOR_TREE.toLocaleString()} +
+ ) : ( + <> + MAX_SEQUENCES_FOR_TREE || + filteredRows?.length < MIN_SEQUENCES_FOR_TREE + } + maxColumnWidth={maxColumnWidth} + > +
+ + {highlightedNodes.map((id) => ( + + ))} +

+ Please note: selecting a large number of proteins will take + several minutes to align. +

+
+

+ Output format:   + +

+
+ + {clustalDisabled && ( + (You must select at least two proteins.) + )} +
+
+
+ + )} +

+ + Download raw newick file + +

+
+ ); +} + +function rowMatch(row: RowType, query: RegExp, keys?: string[]): boolean { + // Get the values to search in based on the optionally provided keys + const valuesToSearch = + keys && keys.length > 0 ? keys.map((key) => row[key]) : Object.values(row); + + return ( + valuesToSearch.find((value) => { + if (value != null) { + if (typeof value === 'string') return value.match(query); + else if ( + typeof value === 'object' && + 'displayText' in value && + typeof value.displayText === 'string' + ) + return value.displayText.match(query); + } + return false; + }) !== undefined + ); +} + +function createSafeSearchRegExp(input: string): RegExp | undefined { + if (input === '') return undefined; + const queryTerms = parseSearchQueryString(input); + const searchTermRegex = areTermsInStringRegexString(queryTerms); + return new RegExp(searchTermRegex, 'i'); +} + +function logIdMismatches(A: string[], B: string[]) { + const inAButNotB = difference(A, B); + const inBButNotA = difference(B, A); + + console.log(`Total unique IDs in A: ${new Set(A).size}`); + console.log(`Total unique IDs in B: ${new Set(B).size}`); + + console.log(`Number of IDs in A but not in B: ${inAButNotB.length}`); + console.log( + `First few IDs in A but not in B: ${inAButNotB.slice(0, 5).join(', ')}` + ); + + console.log(`Number of IDs in B but not in A: ${inBButNotA.length}`); + console.log( + `First few IDs in B but not in A: ${inBButNotA.slice(0, 5).join(', ')}` + ); +} + +function truncate_full_id_for_tree_comparison(full_id: string): string { + const truncated_id = (full_id as string).split(':')[0]; + return truncated_id; +} + +function getLeaves(tree: Branch): Branch[] { + return tree.getLeaves(); +} + +function sortRows(leaves: Branch[], mesaRows: TableValue): TableValue { + if (leaves == null) return mesaRows; + + // Some full_ids end in :RNA + // However, the Newick files seem to be omitting the colon and everything following it. + // (Colons are part of Newick format.) + // So we remove anything after a ':' and hope it works! + // This is the only place where we use the IDs from the tree file. + + // make a map for performance + const rowMap = new Map( + mesaRows.map((row) => [ + truncate_full_id_for_tree_comparison(row.full_id as string), + row, + ]) + ); + + return leaves + .map(({ id }) => rowMap.get(id)) + .filter((row): row is RowType => row != null); +} diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/eval-hist.png b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/eval-hist.png new file mode 100644 index 0000000000..082fec2e9d Binary files /dev/null and b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/eval-hist.png differ diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/services.tsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/services.tsx index 6b7219830f..4909d687c6 100644 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/services.tsx +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/services.tsx @@ -16,6 +16,8 @@ export function wrapWdkService(wdkService: WdkService): OrthoService { return { ...addMultiBlastService(wdkService), getGroupLayout: orthoServiceWrappers.getGroupLayout(wdkService), + getGroupTreeUrl: orthoServiceWrappers.getGroupTreeUrl(wdkService), + getGroupTree: orthoServiceWrappers.getGroupTree(wdkService), getProteomeSummary: orthoServiceWrappers.getProteomeSummary(wdkService), getTaxons: orthoServiceWrappers.getTaxons(wdkService), }; @@ -28,6 +30,14 @@ const orthoServiceWrappers = { method: 'get', path: `/group/${groupName}/layout`, }), + getGroupTreeUrl: (wdkService: WdkService) => (groupName: string) => + wdkService.serviceUrl + '/newick-protein-tree/' + groupName, + getGroupTree: (wdkService: WdkService) => (groupName: string) => + // this endpoint does not return json, so no need to use helper + // method `sendRequest` + window + .fetch(`${wdkService.serviceUrl}/newick-protein-tree/${groupName}`) + .then((resp) => resp.text()), getProteomeSummary: (wdkService: WdkService) => () => wdkService.sendRequest(proteomeSummaryRowsDecoder, { useCache: true, @@ -44,6 +54,8 @@ const orthoServiceWrappers = { export interface OrthoService extends WdkService { getGroupLayout: (groupName: string) => Promise; + getGroupTreeUrl: (groupName: string) => string; + getGroupTree: (groupName: string) => Promise; getProteomeSummary: () => Promise; getTaxons: () => Promise; } diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/store-modules/RecordStoreModule.ts b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/store-modules/RecordStoreModule.ts index b5599ce329..11431ea122 100644 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/store-modules/RecordStoreModule.ts +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/store-modules/RecordStoreModule.ts @@ -9,30 +9,6 @@ import { export const getAllFields = RecordStoreModule.getAllFields; -export function reduce( - state = {} as RecordStoreModule.State, - action: Action -): RecordStoreModule.State { - const nextState = RecordStoreModule.reduce(state, action); - - switch (action.type) { - case RecordActions.RECORD_RECEIVED: - return action.payload.recordClass.urlSegment === 'group' - ? { - ...nextState, - collapsedSections: RecordStoreModule.getAllFields(nextState).filter( - (name) => - name === SEQUENCES_TABLE_NAME || - name === PROTEIN_PFAMS_TABLE_NAME - ), - } - : nextState; - - default: - return nextState; - } -} - const { observeNavigationVisibilityPreference, observeNavigationVisibilityState, diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/types/images.d.ts b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/types/images.d.ts new file mode 100644 index 0000000000..1c5923252c --- /dev/null +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/types/images.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: string; + export default value; +} diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/types/patristic.d.ts b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/types/patristic.d.ts new file mode 100644 index 0000000000..c6a6e56d57 --- /dev/null +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/types/patristic.d.ts @@ -0,0 +1,22 @@ +declare module 'patristic' { + export class Branch { + id: string; + parent?: Branch | null; + length?: number; + children?: Branch[]; + value?: number; + depth?: number; + height?: number; + + constructor(data: Branch, children?: (data: any) => Branch[]); + addChild(data: Branch): Branch; + addParent(data: Branch, siblings?: Branch[]): Branch; + ancestors(): Branch[]; + clone(): Branch; + getLeaves(): Branch[]; + remove(pruneAncestors: boolean): Branch[]; + toNewick(): string; + } + + export function parseNewick(newickStr: string): Branch; +} diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/utils/tree.ts b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/utils/tree.ts new file mode 100644 index 0000000000..ff114a1c1e --- /dev/null +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/utils/tree.ts @@ -0,0 +1,3 @@ +import { string } from '@veupathdb/wdk-client/lib/Utils/Json'; + +export const groupTreeResponseDecoder = string; diff --git a/packages/sites/ortho-site/webpack.config.js b/packages/sites/ortho-site/webpack.config.js index 0f6ef873d5..7f4d03aa29 100644 --- a/packages/sites/ortho-site/webpack.config.js +++ b/packages/sites/ortho-site/webpack.config.js @@ -1,8 +1,9 @@ var configure = require('@veupathdb/site-webpack-config'); +const { addD3Shimming } = require('@veupathdb/components/webpack-shimming'); var additionalConfig = { entry: { - 'site-client': __dirname + '/webapp/wdkCustomization/js/client/main.js' + 'site-client': __dirname + '/webapp/wdkCustomization/js/client/main.js', }, module: { rules: [ @@ -12,18 +13,21 @@ var additionalConfig = { test: /\.jsx?$/, include: /node_modules\/@?react-leaflet/, use: [ - { loader: 'babel-loader', options: { configFile: './.babelrc' } } - ] + { loader: 'babel-loader', options: { configFile: './.babelrc' } }, + ], }, ], }, resolve: { alias: { 'ortho-client': __dirname + '/webapp/wdkCustomization/js/client', - 'ortho-images': __dirname + '/webapp/wdkCustomization/images' - } - } + 'ortho-images': __dirname + '/webapp/wdkCustomization/images', + }, + }, }; +// shimming of a specific version of d3 for CRC's tidytree JS library +addD3Shimming(additionalConfig.module.rules); + module.exports = configure(additionalConfig); module.exports.additionalConfig = additionalConfig; diff --git a/yarn.lock b/yarn.lock index f11d63216a..e10cc8b5ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8318,7 +8318,7 @@ __metadata: shape2geohash: ^1.2.5 stats-lite: ^2.2.0 storybook: ^6.5.14 - tidytree: "github:d-callan/TidyTree" + tidytree: "https://github.com/d-callan/TidyTree.git#commit=9063e2df3d93c72743702a6d8f43169a1461e5b0" typescript: 4.3.4 peerDependencies: "@emotion/react": ^11.10.0 @@ -8790,6 +8790,7 @@ __metadata: mini-css-extract-plugin: ^2.7.6 notistack: ^3.0.1 npm-run-all: ^4.1.5 + patristic: ^0.6.0 popper.js: ^1.16.1 react: ^18.3.1 react-cytoscapejs: ^2.0.0 @@ -28580,6 +28581,13 @@ __metadata: languageName: node linkType: hard +"patristic@npm:^0.6.0": + version: 0.6.0 + resolution: "patristic@npm:0.6.0" + checksum: b2253b1dcc9ca85e8a4c50f3d496d50bff45f8378f6a5b2e4ec03f69b2332c51d374bcd5e90b4387222ec98c8fb6b26b4788784a6df8234530557dac9e4b0b6f + languageName: node + linkType: hard + "pbf@npm:^3.2.1": version: 3.2.1 resolution: "pbf@npm:3.2.1" @@ -36298,13 +36306,13 @@ __metadata: languageName: node linkType: hard -"tidytree@github:d-callan/TidyTree": - version: 0.6.0 - resolution: "tidytree@https://github.com/d-callan/TidyTree.git#commit=01715074da658ba390fdc652dfd04aecb985cd37" +"tidytree@https://github.com/d-callan/TidyTree.git#commit=9063e2df3d93c72743702a6d8f43169a1461e5b0": + version: 0.7.0 + resolution: "tidytree@https://github.com/d-callan/TidyTree.git#commit=9063e2df3d93c72743702a6d8f43169a1461e5b0" dependencies: d3: ^7.6.1 patristic: "github:CDCgov/patristic" - checksum: a1411f00e012cd1e5aaf503de0d03425ea5aef95df257145d05aee58a0af5bbbeb6f453474094e2fada11ca0609d20339d43ce7674fa4bb5bff76387a145aa03 + checksum: 243e27ebe6061ff00f66f8de7168eebb09ab2070bdcf429a283ddab307be1adbdbca3623a2779f8bef0a81776956e7e538eb6110fedc9581804ba795017a5a23 languageName: node linkType: hard