From f8cfb2a357ea2142c41a67890614b5eab4555e1a Mon Sep 17 00:00:00 2001 From: bobular Date: Thu, 14 Nov 2024 20:10:01 +0000 Subject: [PATCH 1/4] OrthoMCL - Custom tree-table for OrthoGroup page (#904) * made a start - WIP * re-render sequence table with Mesa * tweak comments * WIP * loosely wire in TreeTable and get shimmimg working * add patristic and basic type support * made a start with controlling table row height but should investigate the Mesa inline option * added Mesa table tooltips for td contents when in 'inline' mode (basically fixed-height mode) * use Mesa's inline option and tidy up * Revert "added Mesa table tooltips for td contents when in 'inline' mode (basically fixed-height mode)" This reverts commit f215762e18da269ff8a08539c90d16488d61c87f. * remove placeholder td title * restore whitespace in DataCell.tsx * upgrade tidytree and use new interactive option * table and tree order are the same * added basic checkboxes * WIP clustal form * that's hopefully clustal sorted - but can't test with local ortho-site * improve 'must select two proteins' verbiage * de-complicate .DataTable margin-bottom override * rename TreeResponse to GroupTreeResponse * add PFam domain architecture column * ugly search working as a demo on description column * add warning banner for data issue and tweak pfam column heading * used RealTimeSearchBox and made regexps safer and reorganised so we can useMemo for the table filtering * more memo and remove some commented code * add tree filtering * tree highlighting works with filtering now, dependency issues fixed * add core/peripheral filtering * added pfam legend - needs wiring still * wired up pfam checkboxes to table search * added the row count * conditionally render pfam legend and add heading * Remove vert scrollbar from tidytree table * remove code that collapsed the main section of the orthogroup page * no longer request trees for 1 or 2 sequences; fix RowCounter props change; fix some imports * fudge to sort table based on dodgy colon-containing full_ids * make tree darker and make tree/table check more robust * provide PFam descriptions in domain cartoon tooltips * sort out row counts * add better console logging for tree/table mismatches * made tree-table comparison for console more robust * add inlineUseTooltips options prop but not working fully yet * attempted better handling of very large groups * looks good now * removed full_id column (from display only) and added Accession aka sequence_link column and enabled the link * Use flex for label to allow for block children * Use SelectList for Pfam filter * Explicit empty PFam legend * Use similar style for core/peripheral filter * Add species filter to protein table * prevent overflow in SelectTree button with many items selected; also use flex-wrap to prevent large buttons crowding the search box * add shouldOnlyUpdateOnClose option to SelectTree * remove some unused imports * add MAX_SEQUENCES_FOR_TREE logic * selected row highlighting * tone down the row background color * don't indent search bar and filters * add instantUpdate option to SelectList * revert taxon filter to instant-update * instantUpdate for core/peripheral filter * Pfam architectures are now scaled by protein length * removed core-only filter for larger groups * improved table sort and filter performance - added comments about further optimisations * rename shouldOnlyUpdateOnClose to instantUpdate * remove an effect * missed a setSelected and dependency * final tweaks to button label behaviour * add reset button, revisit filter button layout * placeholder wrapper for image next to group stats * restore the buttonless phyletic distribution * improved comment/documentation * Adding update evalue histogram * static histogram image now shows, with elaborate width-setting for caption below * make caption rendering smoother - no horizontal shifting of the figure * moved png file again * simplified to hardcoded dimensions * Address typescript errors * remove taxon_abbrev column * rename Taxon column to Clade * rename Species filter button to Organism * improve filter button layout, especially when buttons have expanded content * fix Omit prop typo that wasn't detected because only consumer so far was a JSX file * main proteins table now has fully-fledged search box with column selector * moved styling to .scss file * revert column changes now that model is updated OrthoMCLModel#5 * added new WDK record scope - record-collapsed * added Dave's suggestions * merge in npm action change * cherry picking missed this line * Orthogroup tree table perf (#1233) * Only call anchorNode.scrollIntoView() if the position has changed * Update note to handle MIN_SEQUENCES_FOR_TREE * useDeferredValue partially working * onSpeciesSelected wasn't stable, is now * replace debouncing with deferred value for CheckboxTree searchTerm * tidy up * new useDeferredState hook * Use fixed dimensions for organism filter of protein table * the very basics * straw man ready * forgot the reset button * DRYed up a bit * fixed the edge case as described in PR, improved search box responsiveness * text search field selectors now more responsive * added asterisk and improved filter positioning * fixed broken logic with corePeripheral filter * memoized rowsByAccession and mesaState and fixed some logic errors * memoize treeProps just for tidiness * replaced the key hack with ref approach * Orthogroup tree table - tooltips (#1253) * new warning added, no new component, sorry * improved tree error warning with link to contact form * use display names in SelectList button labels * standardised search behaviour with help text * fixed broken protein popover with useImperativeHandle deps array * protein filter and paged table interaction fixed * fixed label display issue in EDA * Convert to function component with simple debounce logic using setTimeout * Make callbacks stable across renders * target _blank * fix import path * show table for 1 or 2 protein groups, not the error. Changes to CheckboxList/SelectList generics 1 of 2 * changes to CheckboxList/SelectList generics 2 of 2 * reset button also cancels text search * error banner was showing instead of loading spinner for large groups, now it isn't * use const for min seqs in tree * Remove style override that is no longer necessary --------- Co-authored-by: Dave Falke Co-authored-by: Richard Demko --- packages/libs/components/package.json | 2 +- .../tidytree/HorizontalDendrogram.tsx | 33 +- .../src/components/tidytree/TreeTable.scss | 6 + .../src/components/tidytree/TreeTable.tsx | 99 +- .../src/components/tidytree/tidytree.d.ts | 1 + .../components/widgets/RadioButtonGroup.tsx | 5 +- .../src/components/Mesa/Ui/DataCell.jsx | 25 +- .../coreui/src/components/Mesa/Ui/DataRow.jsx | 10 +- .../src/components/Mesa/Ui/DataTable.jsx | 92 +- .../src/components/Mesa/Ui/MesaController.jsx | 4 +- .../src/components/Mesa/Ui/RowCounter.jsx | 7 +- .../src/components/Mesa/Ui/TableSearch.jsx | 2 +- .../src/components/Mesa/Ui/TableToolbar.jsx | 10 +- .../components/Mesa/style/Ui/DataTable.scss | 28 +- .../Mesa/style/Ui/TableToolbar.scss | 8 + .../libs/coreui/src/components/Mesa/types.ts | 8 + .../buttons/PopoverButton/PopoverButton.tsx | 196 ++-- .../src/components/inputs/SelectList.tsx | 57 +- .../inputs/SelectTree/SelectTree.tsx | 55 +- .../inputs/checkboxes/CheckboxList.tsx | 6 +- .../checkboxes/CheckboxTree/CheckboxTree.tsx | 25 +- packages/libs/coreui/src/hooks.tsx | 33 +- packages/libs/coreui/src/index.ts | 3 + .../SearchBox/RealTimeSearchBox.tsx | 195 ++-- .../src/StoreModules/RecordStoreModule.ts | 22 +- .../wdk-client/src/Utils/CategoryUtils.tsx | 1 + .../libs/wdk-client/src/Utils/DomUtils.ts | 18 +- .../wdk-client/src/Views/Answer/Answer.jsx | 2 +- .../Records/RecordTable/RecordFilter.tsx | 2 +- .../Views/Records/RecordTable/RecordTable.jsx | 30 +- .../RecordTable/RecordTableDescription.jsx | 60 +- .../src/Views/Records/RecordUtils.ts | 9 +- packages/sites/ortho-site/.eslintrc | 2 +- packages/sites/ortho-site/package.json | 5 +- .../pfam-domains/PfamDomainArchitecture.tsx | 19 +- .../PhyleticDistributionCheckbox.tsx | 77 +- .../GroupRecordClasses.GroupRecordClass.scss | 28 + .../GroupRecordClasses.GroupRecordClass.tsx | 3 + .../js/client/records/GroupStats.tsx | 44 + .../RecordTable_TaxonCounts_Filter.tsx | 44 + .../js/client/records/Sequences.jsx | 112 --- .../js/client/records/Sequences.tsx | 866 ++++++++++++++++++ .../js/client/records/eval-hist.png | Bin 0 -> 24039 bytes .../wdkCustomization/js/client/services.tsx | 12 + .../client/store-modules/RecordStoreModule.ts | 24 - .../js/client/types/images.d.ts | 4 + .../js/client/types/patristic.d.ts | 22 + .../wdkCustomization/js/client/utils/tree.ts | 3 + packages/sites/ortho-site/webpack.config.js | 16 +- yarn.lock | 18 +- 50 files changed, 1767 insertions(+), 586 deletions(-) create mode 100644 packages/libs/components/src/components/tidytree/TreeTable.scss create mode 100644 packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/GroupStats.tsx create mode 100644 packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/RecordTable_TaxonCounts_Filter.tsx delete mode 100644 packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.jsx create mode 100644 packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/Sequences.tsx create mode 100644 packages/sites/ortho-site/webapp/wdkCustomization/js/client/records/eval-hist.png create mode 100644 packages/sites/ortho-site/webapp/wdkCustomization/js/client/types/images.d.ts create mode 100644 packages/sites/ortho-site/webapp/wdkCustomization/js/client/types/patristic.d.ts create mode 100644 packages/sites/ortho-site/webapp/wdkCustomization/js/client/utils/tree.ts 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 0000000000000000000000000000000000000000..082fec2e9d4734b0a670be177ec40d127f811bbd GIT binary patch literal 24039 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 (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 From bf7a289ef5a780ca5d47e230be091a2d7170fa33 Mon Sep 17 00:00:00 2001 From: Dave Falke Date: Fri, 15 Nov 2024 10:54:32 -0500 Subject: [PATCH 2/4] Updates user datasets multi blast (#1272) * Make multi-blast workspace heading consistent with user-datasets * fix link and increase font-size for formInfo * fix link * Check protocol of url * Adjust font size of form info --- .../multi-blast/src/lib/components/BlastWorkspace.tsx | 10 +++++----- .../user-datasets/src/lib/Components/UploadForm.scss | 1 + .../user-datasets/src/lib/Components/UploadForm.tsx | 11 +++++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/libs/multi-blast/src/lib/components/BlastWorkspace.tsx b/packages/libs/multi-blast/src/lib/components/BlastWorkspace.tsx index c1225e30d6..36a668ad90 100644 --- a/packages/libs/multi-blast/src/lib/components/BlastWorkspace.tsx +++ b/packages/libs/multi-blast/src/lib/components/BlastWorkspace.tsx @@ -28,7 +28,7 @@ export function BlastWorkspace(props: Props) { const { helpPageUrl, workspaceUrl, - workspaceHeading = 'BLAST', + workspaceHeading = 'My BLAST Jobs', workspaceShortName = 'BLAST', submitButtonText = 'BLAST', } = props; @@ -41,12 +41,12 @@ export function BlastWorkspace(props: Props) { routeBase={workspaceUrl} items={[ { - display: 'New job', - route: '/new', + display: 'All', + route: '/all', }, { - display: 'My jobs', - route: '/all', + display: 'New job', + route: '/new', }, { display: 'Help', diff --git a/packages/libs/user-datasets/src/lib/Components/UploadForm.scss b/packages/libs/user-datasets/src/lib/Components/UploadForm.scss index 93c6d2a9a5..ef786c185b 100644 --- a/packages/libs/user-datasets/src/lib/Components/UploadForm.scss +++ b/packages/libs/user-datasets/src/lib/Components/UploadForm.scss @@ -33,6 +33,7 @@ .formInfo { width: 80%; text-align: justify; + font-size: 1.2em; } select { max-width: 450px; diff --git a/packages/libs/user-datasets/src/lib/Components/UploadForm.tsx b/packages/libs/user-datasets/src/lib/Components/UploadForm.tsx index 396702394f..10ec0abc41 100644 --- a/packages/libs/user-datasets/src/lib/Components/UploadForm.tsx +++ b/packages/libs/user-datasets/src/lib/Components/UploadForm.tsx @@ -398,7 +398,7 @@ function UploadForm({ <> Before uploading your dataset, please ensure your data is formatted according to the instructions listed in the{' '} - "Help" tab. + "Help" tab. ), }} @@ -628,7 +628,9 @@ function validateForm( ) { return { valid: false, - errors: ['The provided data URL does not seem valid'], + errors: [ + 'The provided data URL does not seem valid. A valid URL must start with "http://" or "https://".', + ], }; } @@ -655,12 +657,13 @@ function isCompleteDataUploadSelection( // https://stackoverflow.com/a/43467144 function isValidUrl(string: string) { + let url: URL; try { - new URL(string); + url = new URL(string); } catch (_) { return false; } - return true; + return url.protocol === 'http' || url.protocol === 'https'; } export default UploadForm; From cdbf6f3e80f2290b409943b2ec281777066a9a00 Mon Sep 17 00:00:00 2001 From: Dave Falke Date: Fri, 15 Nov 2024 11:42:45 -0500 Subject: [PATCH 3/4] Keyboard a11y improvements (#1274) * Enable focus ring * Restore focus to button when popover is closed * do not remove outline style by default * prevent focus loss when toggling expansion state of internal node via keyboard * make user menu fully keyboard accessible * Make tooltip container keyboard accessible * Improve focus ring styling for basket button in result table * Make step edit button fully keyboard accessible * Make organism filter toggle button fully keyboard accessible * Add UIThemeProvider to ortho-site * Improve focus styling for featured tools --- .../Mesa/Components/MesaTooltip.tsx | 1 + .../buttons/PopoverButton/PopoverButton.tsx | 5 +- .../buttons/SwissArmyButton/index.tsx | 2 +- .../checkboxes/CheckboxTree/CheckboxTree.tsx | 1 + .../CheckboxTree/CheckboxTreeNode.tsx | 43 +++++------ .../components/theming/UIThemeProvider.tsx | 16 ++++- .../ResultTableSummaryView.scss | 1 + .../src/Views/Strategy/StepBoxes.css | 3 +- .../web-common/src/App/UserMenu/UserMenu.jsx | 56 +++------------ .../web-common/src/App/UserMenu/UserMenu.scss | 18 +++-- .../components/homepage/FeaturedTools.scss | 6 ++ .../js/client/components/OrganismFilter.scss | 6 ++ .../js/client/components/OrganismFilter.tsx | 8 ++- .../client/components/layout/OrthoMCLPage.tsx | 71 +++++++++++-------- 14 files changed, 126 insertions(+), 111 deletions(-) diff --git a/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx b/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx index ca5656a597..aa1b948790 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx +++ b/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx @@ -63,6 +63,7 @@ const MesaTooltip = ({ enterDelay={showDelay} className={(className ?? '') + (corner ? ` ${corner}` : '')} style={finalStyle} + tabIndex={0} > {children} diff --git a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx index 79b5f0f63b..07dcc8db43 100644 --- a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx +++ b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx @@ -119,9 +119,12 @@ const PopoverButton = forwardRef( ); const onCloseHandler = useCallback(() => { + setTimeout(() => { + anchorEl?.focus(); // return focus to button + }); setAnchorEl(null); onClose && onClose(); - }, [onClose]); + }, [anchorEl, onClose]); // Expose the `close()` method to external components via ref useImperativeHandle( diff --git a/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx b/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx index c38b106cc2..7fe308af0c 100644 --- a/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx +++ b/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx @@ -110,7 +110,7 @@ export default function SwissArmyButton({ styleSpec[styleState].color ?? 'transparent' } !important`, borderRadius: styleSpec[styleState].border?.radius ?? 5, - outlineStyle: styleSpec[styleState].border?.style ?? 'none', + outlineStyle: styleSpec[styleState].border?.style, outlineColor: styleSpec[styleState].border?.color, outlineWidth: styleSpec[styleState].border?.width, outlineOffset: styleSpec[styleState].border?.width diff --git a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxTree/CheckboxTree.tsx b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxTree/CheckboxTree.tsx index da26060416..01c1ca0c05 100644 --- a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxTree/CheckboxTree.tsx +++ b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxTree/CheckboxTree.tsx @@ -968,6 +968,7 @@ function CheckboxTree(props: CheckboxTreeProps) { }, '.arrow-container': { height: '1em', + 'outline-offset': '-1px', }, '.arrow-icon': { fill: '#aaa', diff --git a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxTree/CheckboxTreeNode.tsx b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxTree/CheckboxTreeNode.tsx index cdbad9a63f..1f251c584b 100644 --- a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxTree/CheckboxTreeNode.tsx +++ b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxTree/CheckboxTreeNode.tsx @@ -161,33 +161,28 @@ export default function CheckboxTreeNode({ {isLeafNode ? null : isActiveSearch ? ( // this retains the space of the expansion toggle icons for easier formatting
- ) : isExpanded ? ( -
- { - e.stopPropagation(); - toggleExpansion(node); - }} - onKeyDown={(e) => - e.key === 'Enter' ? toggleExpansion(node) : null - } - /> -
) : ( -
- { - e.stopPropagation(); +
{ + e.stopPropagation(); + toggleExpansion(node); + }} + onKeyDown={(e) => { + const toggleKeys = isExpanded + ? ['Enter', 'ArrowLeft'] + : ['Enter', 'ArrowRight']; + if (toggleKeys.includes(e.key)) { toggleExpansion(node); - }} - onKeyDown={(e) => - e.key === 'Enter' ? toggleExpansion(node) : null } - /> + }} + > + {isExpanded ? ( + + ) : ( + + )}
)} {!isSelectable || (!isMultiPick && !isLeafNode) ? ( diff --git a/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx b/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx index 16dd37832f..668a387186 100644 --- a/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx +++ b/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx @@ -1,5 +1,5 @@ import { ReactNode } from 'react'; -import { ThemeProvider } from '@emotion/react'; +import { css, Global, ThemeProvider } from '@emotion/react'; import { useCoreUIFonts } from '../../hooks'; import { UITheme } from './types'; @@ -14,5 +14,17 @@ export default function UIThemeProvider({ children, }: UIThemeProviderProps) { useCoreUIFonts(); - return {children}; + return ( + + + {children} + + ); } diff --git a/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTableSummaryView.scss b/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTableSummaryView.scss index bf954c3cc3..c4c5b57208 100644 --- a/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTableSummaryView.scss +++ b/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTableSummaryView.scss @@ -118,6 +118,7 @@ padding: 5px; background: none; border: none; + outline-offset: -5px; } .RemoveColumnButton { diff --git a/packages/libs/wdk-client/src/Views/Strategy/StepBoxes.css b/packages/libs/wdk-client/src/Views/Strategy/StepBoxes.css index 96271199c6..aaff2e3493 100644 --- a/packages/libs/wdk-client/src/Views/Strategy/StepBoxes.css +++ b/packages/libs/wdk-client/src/Views/Strategy/StepBoxes.css @@ -419,7 +419,8 @@ button.StepBoxes--EditButton:hover { background: yellow; } -.StrategyPanel--Panel:hover button.StepBoxes--EditButton { +.StrategyPanel--Panel:hover button.StepBoxes--EditButton, +.StrategyPanel--Panel:focus-within button.StepBoxes--EditButton { display: inline; } diff --git a/packages/libs/web-common/src/App/UserMenu/UserMenu.jsx b/packages/libs/web-common/src/App/UserMenu/UserMenu.jsx index bd3073ddcc..d04e25e929 100644 --- a/packages/libs/web-common/src/App/UserMenu/UserMenu.jsx +++ b/packages/libs/web-common/src/App/UserMenu/UserMenu.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import './UserMenu.scss'; @@ -7,45 +8,22 @@ import { IconAlt as Icon } from '@veupathdb/wdk-client/lib/Components'; class UserMenu extends React.Component { constructor(props) { super(props); - this.state = { isEntered: false, isHovered: false }; this.renderMenu = this.renderMenu.bind(this); - this.onMouseEnter = this.onMouseEnter.bind(this); - this.onMouseLeave = this.onMouseLeave.bind(this); - } - - onMouseEnter(event) { - this.setState({ isEntered: true, isHovered: true }); - } - - onMouseLeave(event) { - this.setState({ isEntered: false }); - - setTimeout(() => { - if (!this.state.isEntered) { - this.setState({ isHovered: false }); - } - }, 500); } renderMenu() { - const { user, actions, webAppUrl } = this.props; - const { showLoginForm } = actions; - const { isHovered } = this.state; - const { properties } = user.properties ? user : { properties: null }; - const { firstName, lastName } = properties - ? properties - : { firstName: '', lastName: '' }; + const { user } = this.props; const items = user.isGuest ? [ { icon: 'sign-in', text: 'Login', - onClick: () => actions.showLoginForm(window.location.href), + route: '/user/login', }, { icon: 'user-plus', text: 'Register', - href: webAppUrl + '/app/user/registration', + route: '/user/registration', target: '_blank', }, ] @@ -53,33 +31,26 @@ class UserMenu extends React.Component { { icon: 'vcard', text: 'My Profile', - href: webAppUrl + '/app/user/profile', + route: '/user/profile', }, { icon: 'power-off', text: 'Log Out', - onClick: () => actions.showLogoutWarning(window.location.href), + route: '/user/logout', }, ]; return ( -
+
{items.map((item, key) => { - const { onClick, href, target } = item; + const { route, target } = item; const className = 'UserMenu-Pane-Item'; - let props = { - className, - onClick: onClick ? onClick : () => null, - }; - if (href) props = Object.assign({}, props, { href, target }); - const Element = href ? 'a' : 'div'; - return ( - + {item.text} - + ); })}
@@ -87,7 +58,6 @@ class UserMenu extends React.Component { } render() { - const { onMouseEnter, onMouseLeave } = this; const { user } = this.props; if (!user) return null; @@ -96,11 +66,7 @@ class UserMenu extends React.Component { const Menu = this.renderMenu; return ( -
+
{typeof isGuest === 'undefined' diff --git a/packages/libs/web-common/src/App/UserMenu/UserMenu.scss b/packages/libs/web-common/src/App/UserMenu/UserMenu.scss index 6273f28450..15d5ef1ab8 100644 --- a/packages/libs/web-common/src/App/UserMenu/UserMenu.scss +++ b/packages/libs/web-common/src/App/UserMenu/UserMenu.scss @@ -21,9 +21,22 @@ $white: #e0e0e0; padding: 5px; font-style: italic; } + + &:focus-within, + &:hover { + .UserMenu-Pane { + opacity: 1; + pointer-events: all; + transition: none; + } + } } .UserMenu-Pane { + transition: opacity 500ms 500ms; + opacity: 0; + pointer-events: none; + right: 0; top: 100%; color: black; @@ -33,17 +46,12 @@ $white: #e0e0e0; min-width: 150px; position: absolute; border-radius: 10px; - transition: all 0.2s; line-height: 1em; background-color: $white; box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); .fa { color: $red; } - &.inert { - opacity: 0; - pointer-events: none; - } &::after { top: -3px; width: 10px; diff --git a/packages/libs/web-common/src/components/homepage/FeaturedTools.scss b/packages/libs/web-common/src/components/homepage/FeaturedTools.scss index abac5a284a..a1d90f70b0 100644 --- a/packages/libs/web-common/src/components/homepage/FeaturedTools.scss +++ b/packages/libs/web-common/src/components/homepage/FeaturedTools.scss @@ -132,6 +132,12 @@ border: 0.2em solid #00304c; } } + + &:focus, + &__selected:focus { + outline: none; + text-decoration: underline; + } } } diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/OrganismFilter.scss b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/OrganismFilter.scss index f8136e573a..802ad3c1fa 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/OrganismFilter.scss +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/OrganismFilter.scss @@ -89,6 +89,12 @@ white-space: nowrap; width: 18em; + &:hover, + &:focus { + background-color: #396aa4; + background-image: none; + } + &Text { margin: 0 2em; } diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/OrganismFilter.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/OrganismFilter.tsx index 249e1a26b8..4e3478cc74 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/OrganismFilter.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/OrganismFilter.tsx @@ -123,11 +123,15 @@ type ExpansionBarProps = { function ExpansionBar(props: ExpansionBarProps) { return ( -
+
+ ); } diff --git a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/layout/OrthoMCLPage.tsx b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/layout/OrthoMCLPage.tsx index eef8cc5544..4cf9377536 100644 --- a/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/layout/OrthoMCLPage.tsx +++ b/packages/sites/ortho-site/webapp/wdkCustomization/js/client/components/layout/OrthoMCLPage.tsx @@ -40,6 +40,8 @@ import { } from 'ortho-client/hooks/searchCheckboxTree'; import './OrthoMCLPage.scss'; +import { colors } from '@veupathdb/coreui'; +import { UIThemeProvider } from '@veupathdb/coreui/lib/components/theming'; const cx = makeClassNameHelper('vpdb-'); @@ -86,37 +88,46 @@ export const OrthoMCLPage: FunctionComponent = (props) => { ); return ( - - -
- -
- -
- + + + +
+ +
+ +
+ +
+
{props.children}
+ +
+
+ + +
-
{props.children}
- -
-
- - - -
- - + + + ); }; From 95c2e152e5725ffc7ec8f243de0958c36bdbeaf6 Mon Sep 17 00:00:00 2001 From: Dave Falke Date: Fri, 15 Nov 2024 11:56:46 -0500 Subject: [PATCH 4/4] Keyboard a11y improvements focus visible (#1275) * use type=button for PopoverButton * use :focus-visible instead of :focus --- .../src/components/buttons/PopoverButton/PopoverButton.tsx | 1 + packages/libs/coreui/src/components/theming/UIThemeProvider.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx index 07dcc8db43..bf018fefd4 100644 --- a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx +++ b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx @@ -177,6 +177,7 @@ const PopoverButton = forwardRef( additionalAriaProperties={{ 'aria-controls': 'dropdown', 'aria-haspopup': 'true', + type: 'button', }} /> ); diff --git a/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx b/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx index 668a387186..e6481d115a 100644 --- a/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx +++ b/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx @@ -18,7 +18,7 @@ export default function UIThemeProvider({