diff --git a/packages/libs/components/src/map/MapVEuMap.tsx b/packages/libs/components/src/map/MapVEuMap.tsx index d1f666b837..fa5dae11c4 100755 --- a/packages/libs/components/src/map/MapVEuMap.tsx +++ b/packages/libs/components/src/map/MapVEuMap.tsx @@ -28,6 +28,7 @@ import { Map, DomEvent, LatLngBounds } from 'leaflet'; import domToImage from 'dom-to-image'; import { makeSharedPromise } from '../utils/promise-utils'; import { Undo } from '@veupathdb/coreui'; +import { mouseEventHasModifierKey } from './BoundsDriftMarker'; // define Viewport type export type Viewport = { @@ -366,9 +367,10 @@ function MapVEuMapEvents(props: MapVEuMapEventsProps) { baselayerchange: (e: { name: string }) => { onBaseLayerChanged && onBaseLayerChanged(e.name as BaseLayerChoice); }, - // map click event: remove selected highlight markers - click: () => { - if (onMapClick != null) onMapClick(); + // map click event: remove selected markers and close side panel + click: (e) => { + if (onMapClick != null && !mouseEventHasModifierKey(e.originalEvent)) + onMapClick(); }, }); diff --git a/packages/libs/components/src/map/SemanticMarkers.tsx b/packages/libs/components/src/map/SemanticMarkers.tsx index 9e27739318..a41bd3e7ae 100644 --- a/packages/libs/components/src/map/SemanticMarkers.tsx +++ b/packages/libs/components/src/map/SemanticMarkers.tsx @@ -8,10 +8,9 @@ import { } from 'react'; import { AnimationFunction, Bounds } from './Types'; import { BoundsDriftMarkerProps } from './BoundsDriftMarker'; -import { useMap, useMapEvents } from 'react-leaflet'; +import { useMap } from 'react-leaflet'; import { LatLngBounds } from 'leaflet'; import { debounce, isEqual } from 'lodash'; -import { mouseEventHasModifierKey } from './BoundsDriftMarker'; import AreaSelect from './AreaSelect'; export interface SemanticMarkersProps { @@ -50,18 +49,6 @@ export default function SemanticMarkers({ // react-leaflet v3 const map = useMap(); - // cancel marker selection with a single click on the map - useMapEvents({ - click: (e) => { - // excluding a combination of special keys and mouse click - if ( - setSelectedMarkers != null && - !mouseEventHasModifierKey(e.originalEvent) - ) - setSelectedMarkers(undefined); - }, - }); - const [prevRecenteredMarkers, setPrevRecenteredMarkers] = useState[]>(markers); diff --git a/packages/libs/components/src/plots/BipartiteNetwork.css b/packages/libs/components/src/plots/BipartiteNetwork.css index 09f7ff547c..3048000b39 100644 --- a/packages/libs/components/src/plots/BipartiteNetwork.css +++ b/packages/libs/components/src/plots/BipartiteNetwork.css @@ -2,3 +2,21 @@ font-size: 1em; font-weight: 500; } + +.bpnet-hover-dropdown { + display: none; +} + +.visx-network-node:hover .bpnet-hover-dropdown { + display: unset; +} + +.visx-network-node .hover-trigger:hover { + display: unset; + fill: #00000017; +} + +.NodeWithLabel_Node, +.NodeWithLabel_Label { + cursor: pointer; +} diff --git a/packages/libs/components/src/plots/BipartiteNetwork.tsx b/packages/libs/components/src/plots/BipartiteNetwork.tsx index e277b7995a..547b34427c 100755 --- a/packages/libs/components/src/plots/BipartiteNetwork.tsx +++ b/packages/libs/components/src/plots/BipartiteNetwork.tsx @@ -1,5 +1,5 @@ import { BipartiteNetworkData, NodeData } from '../types/plots/network'; -import { partition } from 'lodash'; +import { orderBy, partition } from 'lodash'; import { LabelPosition, Link, NodeWithLabel } from './Network'; import { Graph } from '@visx/network'; import { Text } from '@visx/text'; @@ -11,13 +11,18 @@ import { useImperativeHandle, useRef, useCallback, + useState, + useMemo, + useEffect, } from 'react'; import Spinner from '../components/Spinner'; import { ToImgopts } from 'plotly.js'; import { gray } from '@veupathdb/coreui/lib/definitions/colors'; -import './BipartiteNetwork.css'; import { ExportPlotToImageButton } from './ExportPlotToImageButton'; import { plotToImage } from './visxVEuPathDB'; +import { GlyphTriangle } from '@visx/visx'; + +import './BipartiteNetwork.css'; export interface BipartiteNetworkSVGStyles { width?: number; // svg width @@ -26,6 +31,12 @@ export interface BipartiteNetworkSVGStyles { columnPadding?: number; // space between the left of the svg and the left column, also the right of the svg and the right column. } +export interface NodeMenuAction { + label: ReactNode; + onClick?: () => void; + href?: string; +} + export interface BipartiteNetworkProps { /** Bipartite network data */ data: BipartiteNetworkData | undefined; @@ -47,6 +58,8 @@ export interface BipartiteNetworkProps { labelTruncationLength?: number; /** Additional error messaging to show when the network is empty */ emptyNetworkContent?: ReactNode; + /** Entries for the actions that appear in the menu when you click a node */ + getNodeMenuActions?: (nodeId: string) => NodeMenuAction[]; } // Show a few gray nodes when there is no real data. @@ -79,8 +92,12 @@ function BipartiteNetwork( showSpinner = false, labelTruncationLength = 20, emptyNetworkContent, + getNodeMenuActions: getNodeActions, } = props; + const [highlightedNodeId, setHighlightedNodeId] = useState(); + const [activeNodeId, setActiveNodeId] = useState(); + // Use ref forwarding to enable screenshotting of the plot for thumbnail versions. const plotRef = useRef(null); @@ -112,65 +129,179 @@ function BipartiteNetwork( // In order to assign coordinates to each node, we'll separate the // nodes based on their partition, then will use their order in the partition // (given by partitionXNodeIDs) to finally assign the coordinates. - const nodesByPartition: NodeData[][] = partition(data.nodes, (node) => { - return data.partitions[0].nodeIds.includes(node.id); - }); - - const nodesByPartitionWithCoordinates = nodesByPartition.map( - (partition, partitionIndex) => { - const partitionWithCoordinates = partition.map((node) => { - // Find the index of the node in the partition - const indexInPartition = data.partitions[ - partitionIndex - ].nodeIds.findIndex((id) => id === node.id); - - return { - // partitionIndex of 0 refers to the left-column nodes whereas 1 refers to right-column nodes - x: partitionIndex === 0 ? column1Position : column2Position, - y: svgStyles.topPadding + svgStyles.nodeSpacing * indexInPartition, - labelPosition: - partitionIndex === 0 ? 'left' : ('right' as LabelPosition), - ...node, - }; - }); - return partitionWithCoordinates; - } + const nodesByPartition: NodeData[][] = useMemo( + () => + partition(data.nodes, (node) => { + return data.partitions[0].nodeIds.includes(node.id); + }), + [data.nodes, data.partitions] + ); + + const nodesByPartitionWithCoordinates = useMemo( + () => + nodesByPartition.map((partition, partitionIndex) => { + const partitionWithCoordinates = partition.map((node) => { + // Find the index of the node in the partition + const indexInPartition = data.partitions[ + partitionIndex + ].nodeIds.findIndex((id) => id === node.id); + + return { + // partitionIndex of 0 refers to the left-column nodes whereas 1 refers to right-column nodes + x: partitionIndex === 0 ? column1Position : column2Position, + y: svgStyles.topPadding + svgStyles.nodeSpacing * indexInPartition, + labelPosition: + partitionIndex === 0 ? 'left' : ('right' as LabelPosition), + ...node, + }; + }); + return partitionWithCoordinates; + }), + [ + column1Position, + column2Position, + data.partitions, + nodesByPartition, + svgStyles.nodeSpacing, + svgStyles.topPadding, + ] ); // Assign coordinates to links based on the newly created node coordinates - const linksWithCoordinates = data.links.map((link) => { - const sourceNode = nodesByPartitionWithCoordinates[0].find( - (node) => node.id === link.source.id - ); - const targetNode = nodesByPartitionWithCoordinates[1].find( - (node) => node.id === link.target.id - ); - return { - ...link, - source: { - x: sourceNode?.x, - y: sourceNode?.y, - ...link.source, - }, - target: { - x: targetNode?.x, - y: targetNode?.y, - ...link.target, - }, - }; - }); + const linksWithCoordinates = useMemo( + () => + // Put highlighted links on top of gray links. + orderBy( + data.links.map((link) => { + const sourceNode = nodesByPartitionWithCoordinates[0].find( + (node) => node.id === link.source.id + ); + const targetNode = nodesByPartitionWithCoordinates[1].find( + (node) => node.id === link.target.id + ); + return { + ...link, + source: { + x: sourceNode?.x, + y: sourceNode?.y, + ...link.source, + }, + target: { + x: targetNode?.x, + y: targetNode?.y, + ...link.target, + }, + color: + highlightedNodeId != null && + sourceNode?.id !== highlightedNodeId && + targetNode?.id !== highlightedNodeId + ? '#eee' + : link.color, + }; + }), + // Links that are added later will be on top. + // If a link is grayed out, it will be sorted before other links. + // In theory, it's possible to have a false positive here; + // but that's okay, because the overlapping colors will be the same. + (link) => (link.color === '#eee' ? 1 : -1) + ), + [data.links, highlightedNodeId, nodesByPartitionWithCoordinates] + ); const plotRect = plotRef.current?.getBoundingClientRect(); const imageHeight = plotRect?.height; const imageWidth = plotRect?.width; + const nodes = useMemo( + () => + nodesByPartitionWithCoordinates[0] + .concat(nodesByPartitionWithCoordinates[1]) + .map((node) => ({ + ...node, + actions: getNodeActions?.(node.id), + })), + [getNodeActions, nodesByPartitionWithCoordinates] + ); + + const activeNode = nodes.find((node) => node.id === activeNodeId); + + useEffect(() => { + const element = document.querySelector('.bpnet-plot-container'); + if (element == null) return; + + element.addEventListener('click', handler); + + return () => { + element.removeEventListener('click', handler); + }; + + function handler() { + setActiveNodeId(undefined); + } + }, [containerClass]); + return ( <>
-
+ {activeNode?.actions?.length && ( +
+ {activeNode.actions.map((action) => ( +
+ {action.href ? ( + + {action.label} + + ) : ( + + )} +
+ ))} +
+ )} +
{nodesByPartitionWithCoordinates[0].length > 0 ? ( } + linkComponent={({ link }) => { + return ; + }} nodeComponent={({ node }) => { - const nodeWithLabelProps = { - node: node, - labelPosition: node.labelPosition, - truncationLength: labelTruncationLength, - }; - return ; + const isHighlighted = highlightedNodeId === node.id; + const rectWidth = + (node.r ?? 6) * 2 + // node diameter + (node.label?.length ?? 0) * 6 + // label width + (12 + 6) + // button + space + (12 + 12 + 12); // paddingLeft + space-between-node-and-label + paddingRight + const rectX = + node.labelPosition === 'left' ? -rectWidth + 12 : -12; + const glyphLeft = + node.labelPosition === 'left' ? rectX + 12 : rectWidth - 24; + return ( + <> + {node.actions?.length && ( + + + + setActiveNodeId(node.id)} + /> + + )} + { + setHighlightedNodeId((id) => + id === node.id ? undefined : node.id + ); + }} + fontWeight={isHighlighted ? 600 : 400} + /> + + ); }} /> diff --git a/packages/libs/components/src/plots/Network.css b/packages/libs/components/src/plots/Network.css index eb66b0f0bf..da3992a358 100644 --- a/packages/libs/components/src/plots/Network.css +++ b/packages/libs/components/src/plots/Network.css @@ -1,3 +1,3 @@ -.NodeWithLabel { +.NodeWithLabel_Node { cursor: default; } diff --git a/packages/libs/components/src/plots/Network.tsx b/packages/libs/components/src/plots/Network.tsx index c51722b757..2a79138bab 100755 --- a/packages/libs/components/src/plots/Network.tsx +++ b/packages/libs/components/src/plots/Network.tsx @@ -61,15 +61,13 @@ export function NodeWithLabel(props: NodeWithLabelProps) { } return ( - <> + {/* Note that Text becomes a tspan */} {label && truncateWithEllipsis(label, truncationLength)} {label} - + ); } diff --git a/packages/libs/components/src/stories/plots/BipartiteNetwork.stories.tsx b/packages/libs/components/src/stories/plots/BipartiteNetwork.stories.tsx index 8aa3e6f7d7..7cf86a5edd 100755 --- a/packages/libs/components/src/stories/plots/BipartiteNetwork.stories.tsx +++ b/packages/libs/components/src/stories/plots/BipartiteNetwork.stories.tsx @@ -8,6 +8,7 @@ import { import BipartiteNetwork, { BipartiteNetworkProps, BipartiteNetworkSVGStyles, + NodeMenuAction, } from '../../plots/BipartiteNetwork'; import { twoColorPalette } from '../../types/plots/addOns'; import { Text } from '@visx/text'; @@ -27,6 +28,8 @@ interface TemplateProps { svgStyleOverrides?: BipartiteNetworkSVGStyles; labelTruncationLength?: number; emptyNetworkContent?: ReactNode; + getNodeMenuActions?: BipartiteNetworkProps['getNodeMenuActions']; + isSelectable?: boolean; } // Template for showcasing our BipartiteNetwork component. @@ -43,6 +46,8 @@ const Template: Story = (args) => { }, 2000); }, []); + const [selectedNodeIds, setSelectedNodeIds] = useState([]); + const bipartiteNetworkProps: BipartiteNetworkProps = { data: args.data, partition1Name: args.partition1Name, @@ -52,6 +57,13 @@ const Template: Story = (args) => { svgStyleOverrides: args.svgStyleOverrides, labelTruncationLength: args.labelTruncationLength, emptyNetworkContent: args.emptyNetworkContent, + getNodeMenuActions: args.getNodeMenuActions, + ...(args.isSelectable + ? { + selectedNodeIds, + setSelectedNodeIds, + } + : {}), }; return ( <> @@ -139,6 +151,46 @@ WithStyle.args = { labelTruncationLength: 5, }; +function getNodeActions(nodeId: string): NodeMenuAction[] { + return [ + { + label: 'Click me!!', + onClick() { + alert('You clicked node ' + nodeId); + }, + }, + { + label: 'Click me, too!!', + onClick() { + alert('You clicked node ' + nodeId); + }, + }, + ]; +} + +export const WithActions = Template.bind({}); +WithActions.args = { + data: simpleData, + containerStyles: { + marginLeft: '200px', + }, + partition1Name: 'Partition 1', + partition2Name: 'Partition 2', + getNodeMenuActions: getNodeActions, +}; + +export const WithSelection = Template.bind({}); +WithSelection.args = { + data: simpleData, + containerStyles: { + marginLeft: '200px', + }, + partition1Name: 'Partition 1', + partition2Name: 'Partition 2', + getNodeMenuActions: getNodeActions, + isSelectable: true, +}; + // With a network that has no nodes or links const noNodesData = genBipartiteNetwork(0, 0); const emptyNetworkContent = ( diff --git a/packages/libs/coreui/src/components/banners/Banner.tsx b/packages/libs/coreui/src/components/banners/Banner.tsx index 4e3102c804..8e9f4c9875 100644 --- a/packages/libs/coreui/src/components/banners/Banner.tsx +++ b/packages/libs/coreui/src/components/banners/Banner.tsx @@ -41,12 +41,13 @@ export type BannerProps = { showMoreLinkColor?: string; // is showMoreLink bold? isShowMoreLinkBold?: boolean; - // banner margin, padding, text font size + // banner margin, padding, text font size, width spacing?: { margin?: CSSProperties['margin']; padding?: CSSProperties['padding']; }; fontSize?: CSSProperties['fontSize']; + width?: CSSProperties['width']; // implementing Banner timeout showBanner?: boolean; setShowBanner?: (newValue: boolean) => void; @@ -119,6 +120,7 @@ export default function Banner(props: BannerComponentProps) { additionalMessage, spacing, fontSize, + width, showBanner = true, setShowBanner, autoHideDuration, @@ -186,7 +188,7 @@ export default function Banner(props: BannerComponentProps) { box-sizing: border-box; border-radius: ${CollapsibleContent != null ? '0' : '7px'}; margin: ${spacing?.margin != null ? spacing.margin : '10px 0'}; - width: 100%; + width: ${width ?? '100%'}; padding: ${spacing?.padding != null ? spacing.padding : '10px'}; align-items: center; font-family: 'Roboto', 'Helvetica Neue', Helvetica, 'Segoe UI', diff --git a/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx b/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx index 140628fc53..c38b106cc2 100644 --- a/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx +++ b/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx @@ -20,7 +20,7 @@ export type SwissArmyButtonProps = Omit< /** Basic button with a variety of customization options. */ export default function SwissArmyButton({ text, - textTransform = 'uppercase', + textTransform, onPress, disabled = false, tooltip, diff --git a/packages/libs/coreui/src/components/inputs/Menu.tsx b/packages/libs/coreui/src/components/inputs/Menu.tsx new file mode 100644 index 0000000000..c18d71ef2c --- /dev/null +++ b/packages/libs/coreui/src/components/inputs/Menu.tsx @@ -0,0 +1,228 @@ +import { css } from '@emotion/react'; +import { uniqueId } from 'lodash'; +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { CheckIcon } from '../icons'; +import { Item } from './checkboxes/CheckboxList'; + +export interface ItemGroup { + label: ReactNode; + items: Item[]; +} + +interface Props { + /** An array of options to be used in the dropdown container */ + items: (Item | ItemGroup)[]; + onSelect: (value: T) => void; + value?: T; +} + +export function Menu(props: Props) { + const { items, value, onSelect } = props; + + /** + * 1. Find the index of the value prop in the items array to set focused state in the dropdown + * 2. If a value is not found, defaults to the first item in the dropdown + */ + const flatItems = items.flatMap((item) => + 'label' in item ? item.items : [item] + ); + const selectedValueIndex = flatItems.findIndex( + (item) => value === item.value + ); + const defaultOrSelectedValueIndex = + selectedValueIndex !== -1 ? selectedValueIndex : 0; + const [indexOfFocusedElement, setIndexOfFocusedElement] = useState( + defaultOrSelectedValueIndex + ); + + const [key, setKey] = useState(''); + + const handleSelection = (newValue: T) => { + onSelect(newValue); + setKey(uniqueId()); + }; + + /** Update focused element when index of selected value changes */ + useEffect(() => { + setIndexOfFocusedElement(defaultOrSelectedValueIndex); + }, [defaultOrSelectedValueIndex]); + + const onKeyDown = (key: string, newValue: T) => { + if (key === 'Enter') { + handleSelection(newValue); + } + + const hasNextUpIndex = indexOfFocusedElement !== 0; + if (key === 'ArrowUp' && hasNextUpIndex) { + setIndexOfFocusedElement((prev) => prev - 1); + } + + const hasNextDownIndex = indexOfFocusedElement !== flatItems.length - 1; + if (key === 'ArrowDown' && hasNextDownIndex) { + setIndexOfFocusedElement((prev) => prev + 1); + } + }; + + return ( +
    + { + items.reduce( + ({ items, aggregateIndex }, item) => ({ + items: [ + ...items, + 'label' in item ? ( + + ) : ( + + key={aggregateIndex} + item={item} + onSelect={handleSelection} + onKeyDown={onKeyDown} + shouldFocus={aggregateIndex === indexOfFocusedElement} + isSelected={value === item.value} + /> + ), + ], + aggregateIndex: + aggregateIndex + ('label' in item ? item.items.length : 1), + }), + { items: [] as ReactNode[], aggregateIndex: 0 } + ).items + } +
+ ); +} + +interface OptionGroupProps { + itemGroup: ItemGroup; + onSelect: (value: T) => void; + onKeyDown: (key: string, value: T) => void; + value?: T; + indexOffset: number; + indexOfFocusedElement: number; +} +function OptionGroup(props: OptionGroupProps) { + const { + itemGroup, + onKeyDown, + onSelect, + value, + indexOffset, + indexOfFocusedElement, + } = props; + return ( +
    +
  • +
    + {itemGroup.label} +
    + {itemGroup.items.map((item, index) => { + return ( +
  • +
+ ); +} + +interface OptionProps { + item: Item; + onSelect: (value: T) => void; + onKeyDown: (key: string, value: T) => void; + shouldFocus: boolean; + isSelected: boolean; +} + +const checkIconContainer = { height: 16, width: 16 }; + +export function Option({ + item, + onSelect, + onKeyDown, + shouldFocus, + isSelected, +}: OptionProps) { + const optionRef = useRef(null); + + if (shouldFocus && optionRef.current) { + optionRef.current.focus(); + } + + return ( +
  • onSelect(item.value)} + onKeyDown={(e) => { + e.preventDefault(); + onKeyDown(e.key, item.value); + }} + > + + {isSelected ? : undefined} + + {item.display} +
  • + ); +} diff --git a/packages/libs/eda/src/lib/core/components/computations/Utils.ts b/packages/libs/eda/src/lib/core/components/computations/Utils.ts index 42ac1c45a0..9f6fa7fa02 100644 --- a/packages/libs/eda/src/lib/core/components/computations/Utils.ts +++ b/packages/libs/eda/src/lib/core/components/computations/Utils.ts @@ -94,7 +94,9 @@ export function isTaxonomicVariableCollection( ): boolean { return ( isNotAbsoluteAbundanceVariableCollection(variableCollection) && - variableCollection.normalizationMethod === 'sumToUnity' + (variableCollection.member + ? variableCollection.member === 'taxon' + : variableCollection.normalizationMethod === 'sumToUnity') // if we have a member annotation, use that. Old datasets may not have this annotation, hence the fall back normalizationMethod check. ); } @@ -107,7 +109,11 @@ export function isTaxonomicVariableCollection( export function isFunctionalCollection( variableCollection: CollectionVariableTreeNode ): boolean { - return variableCollection.normalizationMethod === 'RPK'; // reads per kilobase + // Use the member annotation if available. Otherwise, use the fallback normalizationMethod annotation. + return variableCollection.member + ? variableCollection.member === 'pathway' || + variableCollection.member === 'gene' + : variableCollection.normalizationMethod === 'RPK'; // reads per kilobase } /** diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/abundance.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/abundance.tsx index 2d27c1d173..d2c70f686f 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/abundance.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/abundance.tsx @@ -55,13 +55,10 @@ export const plugin: ComputationPlugin = { getPlotSubtitle(config) { if (AbundanceConfig.is(config) && config.rankingMethod) { return ( - <> -
    - - Ranked abundance: X-axis variables with {config.rankingMethod} = - 0 removed. Showing up to the top ten variables. - - + + Ranked abundance: X-axis variables with {config.rankingMethod} = 0 + removed. Showing up to the top ten variables. + ); } }, @@ -84,13 +81,10 @@ export const plugin: ComputationPlugin = { getPlotSubtitle(config) { if (AbundanceConfig.is(config)) { return ( - <> -
    - - Ranked abundance: Overlay variables with {config.rankingMethod}{' '} - = 0 removed. Showing up to the top eight variables. - - + + Ranked abundance: Overlay variables with {config.rankingMethod} = + 0 removed. Showing up to the top eight variables. + ); } }, diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/correlation.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/correlation.tsx new file mode 100644 index 0000000000..4df1cb0abd --- /dev/null +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/correlation.tsx @@ -0,0 +1,550 @@ +import { useEffect, useMemo } from 'react'; +import { VariableTreeNode, useFindEntityAndVariableCollection } from '../../..'; +import { ComputationConfigProps, ComputationPlugin } from '../Types'; +import { partial } from 'lodash'; +import { + useConfigChangeHandler, + assertComputationWithConfig, + isNotAbsoluteAbundanceVariableCollection, +} from '../Utils'; +import { Computation } from '../../../types/visualization'; +import { ComputationStepContainer } from '../ComputationStepContainer'; +import './Plugins.scss'; +import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; +import { H6 } from '@veupathdb/coreui'; +import { bipartiteNetworkVisualization } from '../../visualizations/implementations/BipartiteNetworkVisualization'; +import { VariableCollectionSelectList } from '../../variableSelectors/VariableCollectionSingleSelect'; +import SingleSelect, { + ItemGroup, +} from '@veupathdb/coreui/lib/components/inputs/SingleSelect'; +import { + entityTreeToArray, + findEntityAndVariableCollection, + isVariableCollectionDescriptor, +} from '../../../utils/study-metadata'; +import { IsEnabledInPickerParams } from '../../visualizations/VisualizationTypes'; +import { ancestorEntitiesForEntityId } from '../../../utils/data-element-constraints'; +import { NumberInput } from '@veupathdb/components/lib/components/widgets/NumberAndDateInputs'; +import ExpandablePanel from '@veupathdb/coreui/lib/components/containers/ExpandablePanel'; +import { variableCollectionsAreUnique } from '../../../utils/visualization'; +import PluginError from '../../visualizations/PluginError'; +import { + CompleteCorrelationConfig, + CorrelationConfig, +} from '../../../types/apps'; + +const cx = makeClassNameHelper('AppStepConfigurationContainer'); + +/** + * Correlation + * + * The Correlation app takes all collections and offers correlation between any pair of unique collections + * or between a collection and continuous metadata variables. This is the most general of the correlation + * plugins to date. + * + * As of 03/2024, this correlation plugin is used for genomics (except vectorbase) sites + * to help them understand WGCNA outputs and their relationship to metadata. + */ + +export const plugin: ComputationPlugin = { + configurationComponent: CorrelationConfiguration, + configurationDescriptionComponent: CorrelationConfigDescriptionComponent, + createDefaultConfiguration: () => ({ + prefilterThresholds: { + proportionNonZero: DEFAULT_PROPORTION_NON_ZERO_THRESHOLD, + variance: DEFAULT_VARIANCE_THRESHOLD, + standardDeviation: DEFAULT_STANDARD_DEVIATION_THRESHOLD, + }, + }), + isConfigurationComplete: (configuration) => { + // First, the configuration must be complete + if (!CompleteCorrelationConfig.is(configuration)) return false; + + // Also, if both data1 and data2 are collections, they must be unique + if (configuration.data2?.dataType === 'collection') { + return ( + isVariableCollectionDescriptor(configuration.data1?.collectionSpec) && + isVariableCollectionDescriptor(configuration.data2?.collectionSpec) && + variableCollectionsAreUnique([ + configuration.data1?.collectionSpec, + configuration.data2?.collectionSpec, + ]) + ); + } + return true; + }, + visualizationPlugins: { + bipartitenetwork: bipartiteNetworkVisualization.withOptions({ + getLegendTitle(config) { + if (CorrelationConfig.is(config)) { + return ['absolute correlation coefficient', 'correlation direction']; + } else { + return []; + } + }, + makeGetNodeMenuActions(studyMetadata) { + const entities = entityTreeToArray(studyMetadata.rootEntity); + const variables = entities.flatMap((e) => e.variables); + const collections = entities.flatMap( + (entity) => entity.collections ?? [] + ); + const hostCollection = collections.find( + (c) => c.id === 'EUPATH_0005050' + ); + const parasiteCollection = collections.find( + (c) => c.id === 'EUPATH_0005051' + ); + return function getNodeActions(nodeId: string) { + const [, variableId] = nodeId.split('.'); + const variable = variables.find((v) => v.id === variableId); + if (variable == null) return []; + + const href = parasiteCollection?.memberVariableIds.includes( + variable.id + ) + ? `https://qa.plasmodb.org/plasmo/app/search/transcript/GenesByRNASeqpfal3D7_Lee_Gambian_ebi_rnaSeq_RSRCWGCNAModules?param.wgcnaParam=${variable.displayName.toLowerCase()}&autoRun=1` + : hostCollection?.memberVariableIds.includes(variable.id) + ? `https://qa.hostdb.org/hostdb/app/search/transcript/GenesByRNASeqhsapREF_Lee_Gambian_ebi_rnaSeq_RSRCWGCNAModules?param.wgcnaParam=${variable.displayName.toLowerCase()}&autoRun=1` + : undefined; + if (href == null) return []; + return [ + { + label: 'See list of genes', + href, + }, + ]; + }; + }, + getParitionNames(studyMetadata, config) { + if (CorrelationConfig.is(config)) { + const entities = entityTreeToArray(studyMetadata.rootEntity); + const partition1Name = findEntityAndVariableCollection( + entities, + config.data1?.collectionSpec + )?.variableCollection.displayName; + const partition2Name = + config.data2?.dataType === 'collection' + ? findEntityAndVariableCollection( + entities, + config.data2?.collectionSpec + )?.variableCollection.displayName + : 'Continuous metadata variables'; + return { partition1Name, partition2Name }; + } + }, + }), // Must match name in data service and in visualization.tsx + }, + isEnabledInPicker: isEnabledInPicker, + studyRequirements: + 'These visualizations are only available for studies with compatible metadata.', +}; + +// Renders on the thumbnail page to give a summary of the app instance +function CorrelationConfigDescriptionComponent({ + computation, +}: { + computation: Computation; +}) { + const findEntityAndVariableCollection = useFindEntityAndVariableCollection(); + assertComputationWithConfig(computation, CorrelationConfig); + + const { data1, data2, correlationMethod } = + computation.descriptor.configuration; + + const entityAndCollectionVariableTreeNode = findEntityAndVariableCollection( + data1?.collectionSpec + ); + + const entityAndCollectionVariable2TreeNode = findEntityAndVariableCollection( + data2?.collectionSpec + ); + + const correlationMethodDisplayName = correlationMethod + ? CORRELATION_METHODS.find((method) => method.value === correlationMethod) + ?.displayName + : undefined; + + return ( +
    +

    + Data 1:{' '} + + {entityAndCollectionVariableTreeNode ? ( + `${entityAndCollectionVariableTreeNode.entity.displayName} > ${entityAndCollectionVariableTreeNode.variableCollection.displayName}` + ) : ( + Not selected + )} + +

    +

    + Data 2:{' '} + + {data2?.dataType === 'metadata' ? ( + 'Continuous metadata variables' + ) : entityAndCollectionVariable2TreeNode ? ( + `${entityAndCollectionVariable2TreeNode.entity.displayName} > ${entityAndCollectionVariable2TreeNode.variableCollection.displayName}` + ) : ( + Not selected + )} + +

    +

    + Method:{' '} + + {correlationMethod ? ( + correlationMethodDisplayName + ) : ( + Not selected + )} + +

    +
    + ); +} + +const CORRELATION_METHODS = [ + { value: 'spearman', displayName: 'Spearman' }, + { value: 'pearson', displayName: 'Pearson' }, +]; +const DEFAULT_PROPORTION_NON_ZERO_THRESHOLD = 0.05; +const DEFAULT_VARIANCE_THRESHOLD = 0; +const DEFAULT_STANDARD_DEVIATION_THRESHOLD = 0; + +// Shows as Step 1 in the full screen visualization page +export function CorrelationConfiguration(props: ComputationConfigProps) { + const { + computationAppOverview, + computation, + analysisState, + visualizationId, + } = props; + + const configuration = computation.descriptor + .configuration as CorrelationConfig; + + assertComputationWithConfig(computation, CorrelationConfig); + + const changeConfigHandler = useConfigChangeHandler( + analysisState, + computation, + visualizationId + ); + + // Content for the expandable help section + // Note the text is dependent on the context, for example in genomics we'll use different + // language than in mbio. + const helpContent = ( +
    +
    What is correlation?
    +

    + The correlation between two variables (genes, sample metadata, etc.) + describes the degree to which their presence in samples co-fluctuate. + For example, the Age and Shoe Size of children are correlated since as a + child ages, their feet grow. +

    +

    Here we look for correlation between:

    +
      +
    1. Eigengene profiles derived from modules in a WGCNA analysis
    2. +
    3. + Continuous metadata variables that are compatable, i.e. on an entity + that is 1-1 with the assay entity, or other eigengene profiles. +
    4. +
    +

    +
    Inputs:
    +

    +

      +
    • + Data 1. A set of eigengene profiles, either from + the host or pathogen. +
    • +
    • + Data 2. All compatable metdata, or a second set of + eigengene profiles, either from the host or pathogen. +
    • +
    • + Method. The type of correlation to compute. The + Pearson method looks for linear trends in the data, while the + Spearman method looks for a monotonic relationship. For Spearman and + Pearson correlation, we use the rcorr function from the Hmisc + package. +
    • +
    • + Prevalence Prefilter. Remove variables that do not + have a set percentage of non-zero values across samples. Removing + rarely occurring features before calculating correlation can prevent + some spurious results. +
    • +
    +

    +

    +
    Outputs:
    +

    + For each pair of variables, the correlation computation returns +

      +
    • + Correlation coefficient. A value between [-1, 1] + that describes the similarity of the input variables. Positive + values indicate that both variables rise and fall together, whereas + negative values indicate that as one rises, the other falls. +
    • +
    • + P Value. A measure of the probability of observing + the result by chance. +
    • +
    +

    +

    +
    More Questions?
    +

    + Check out the{' '} + + correlation function + {' '} + in our{' '} + + microbiomeComputations + {' '} + R package. +

    +
    + ); + + const correlationMethodSelectorText = useMemo(() => { + if (configuration.correlationMethod) { + return ( + CORRELATION_METHODS.find( + (method) => method.value === configuration.correlationMethod + )?.displayName ?? 'Select a method' + ); + } else { + return 'Select a method'; + } + }, [configuration.correlationMethod]); + + const metadataItemGroup: ItemGroup = { + label: 'Metadata', + items: [ + { + value: 'metadata', + display: 'Continuous metadata variables', + }, + ], + }; + + return ( + +
    +
    +
    +
    Input Data
    +
    + Data 1 + { + if (isVariableCollectionDescriptor(value)) + changeConfigHandler('data1', { + dataType: 'collection', + collectionSpec: value, + }); + }} + collectionPredicate={isNotAbsoluteAbundanceVariableCollection} + /> + Data 2 + { + if (isVariableCollectionDescriptor(value)) { + changeConfigHandler('data2', { + dataType: 'collection', + collectionSpec: value, + }); + } else { + changeConfigHandler('data2', { + dataType: 'metadata', + }); + } + }} + collectionPredicate={isNotAbsoluteAbundanceVariableCollection} + additionalItemGroups={[metadataItemGroup]} + /> +
    +
    +
    +
    Correlation Method
    +
    + Method + ({ + value: method.value, + display: method.displayName, + }))} + onSelect={partial(changeConfigHandler, 'correlationMethod')} + /> +
    +
    +
    +
    Prefilter Data
    +
    + Prevalence: + + Keep if abundance is non-zero in at least{' '} + + { + changeConfigHandler('prefilterThresholds', { + proportionNonZero: + // save as decimal point, not % + newValue != null + ? Number((newValue as number) / 100) + : DEFAULT_PROPORTION_NON_ZERO_THRESHOLD, + variance: + configuration.prefilterThresholds?.variance ?? + DEFAULT_VARIANCE_THRESHOLD, + standardDeviation: + configuration.prefilterThresholds?.standardDeviation ?? + DEFAULT_STANDARD_DEVIATION_THRESHOLD, + }); + }} + containerStyles={{ width: '5.5em' }} + /> + % of samples +
    +
    +
    +
    + +
    + +
    +
    + ); +} + +// The correlation assay x metadata app is only available for studies +// with appropriate metadata. Specifically, the study +// must have at least one continuous metadata variable that is on a one-to-one path +// from the assay entity. +// We made some assumptions to simplify logic. +// 1. Curated studies have one parent for all assay entities. +// 2. All assay entities are one-to-one with their parent +// 3. Studies with at least 2 entities are curated, so we can check for assay entities using our assay ids. +// 4. Assay entities have no relevant metadata within their own entity. +// See PR #74 in service-eda-compute for the matching logic on the backend. +function isEnabledInPicker({ + studyMetadata, +}: IsEnabledInPickerParams): boolean { + if (!studyMetadata) return false; + + const entities = entityTreeToArray(studyMetadata.rootEntity); + + // Ensure there are collections in this study. Otherwise, disable app + const studyHasCollections = entities.some( + (entity) => !!entity.collections?.length + ); + if (!studyHasCollections) return false; + + // Find metadata variables. + let metadataVariables: VariableTreeNode[]; + if (entities.length > 1) { + // Then we're in a curated study. So we can expect to find an entity with an id in ASSAY_ENTITIES, + // which we can use to limit our metadata search to only appropriate entities. + + // Step 1. Find the first assay node. Right now Assays are the only entities with collections, + // so we can just grab the first entity we see that has a collection. + const firstAssayEntityIndex = entities.findIndex( + (entity) => !!entity.collections?.length + ); + if (firstAssayEntityIndex === -1) return false; + + // Step 2. Find all ancestor entites of the assayEntity that are on a one-to-one path with assayEntity. + // Step 2a. Grab ancestor entities. + const ancestorEntities = ancestorEntitiesForEntityId( + entities[firstAssayEntityIndex].id, + entities + ).reverse(); // Reverse so that the ancestorEntities[0] is the assay and higher indices are further up the tree. + + // Step 2b. Trim the ancestorEntities so that we only keep those that are on + // a 1:1 path. Once we find an ancestor that is many to one with its parent, we + // know we've hit the end of the 1:1 path. + const lastOneToOneAncestorIndex = ancestorEntities.findIndex( + (entity) => entity.isManyToOneWithParent + ); + const oneToOneAncestors = ancestorEntities.slice( + 1, // removing the assay itself since we assume assay entities have no metadata + lastOneToOneAncestorIndex + 1 + ); + + // Step 3. Grab variables from the ancestors. + metadataVariables = oneToOneAncestors.flatMap((entity) => entity.variables); + } else { + // Then there is only one entity in the study. User datasets only have one entity. + // Regardless, in the one entity case we want to consider all variables that are not + // part of a collection as candidate metadata variables for this app. + + // Find all variables in any collection, then remove them from the + // list of all variables to get a list of metadata variables. + const variablesInACollection = entities[0].collections?.flatMap( + (collection) => { + return collection.memberVariableIds; + } + ); + metadataVariables = entities[0].variables.filter((variable) => { + return !variablesInACollection?.includes(variable.id); + }); + } + + // Final filter - keep only the variables that are numeric and continuous. Support for dates coming soon! + const hasContinuousVariable = metadataVariables.some( + (variable) => + 'dataShape' in variable && + variable.dataShape === 'continuous' && + (variable.type === 'number' || variable.type === 'integer') // Can remove this line once the backend supports dates. + ); + + return hasContinuousVariable; +} diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayAssay.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayAssay.tsx index 73f6f2e4ce..200248b097 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayAssay.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayAssay.tsx @@ -1,18 +1,12 @@ import { useEffect, useMemo } from 'react'; -import { - FeaturePrefilterThresholds, - useFindEntityAndVariableCollection, -} from '../../..'; -import { VariableCollectionDescriptor } from '../../../types/variable'; +import { useFindEntityAndVariableCollection } from '../../..'; import { ComputationConfigProps, ComputationPlugin } from '../Types'; import { partial } from 'lodash'; import { useConfigChangeHandler, assertComputationWithConfig, - isNotAbsoluteAbundanceVariableCollection, - partialToCompleteCodec, - isTaxonomicVariableCollection, isFunctionalCollection, + isTaxonomicVariableCollection, } from '../Utils'; import * as t from 'io-ts'; import { Computation } from '../../../types/visualization'; @@ -22,13 +16,24 @@ import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUt import { H6 } from '@veupathdb/coreui'; import { bipartiteNetworkVisualization } from '../../visualizations/implementations/BipartiteNetworkVisualization'; import { variableCollectionsAreUnique } from '../../../utils/visualization'; -import PluginError from '../../visualizations/PluginError'; import { VariableCollectionSelectList } from '../../variableSelectors/VariableCollectionSingleSelect'; import SingleSelect from '@veupathdb/coreui/lib/components/inputs/SingleSelect'; import { IsEnabledInPickerParams } from '../../visualizations/VisualizationTypes'; -import { entityTreeToArray } from '../../../utils/study-metadata'; +import { + entityTreeToArray, + isVariableCollectionDescriptor, + findEntityAndVariableCollection, +} from '../../../utils/study-metadata'; +import { + CompleteCorrelationConfig, + CorrelationConfig, +} from '../../../types/apps'; import { NumberInput } from '@veupathdb/components/lib/components/widgets/NumberAndDateInputs'; import { ExpandablePanel } from '@veupathdb/coreui'; +import { + preorder, + preorderSeq, +} from '@veupathdb/wdk-client/lib/Utils/TreeUtils'; const cx = makeClassNameHelper('AppStepConfigurationContainer'); @@ -43,45 +48,52 @@ const cx = makeClassNameHelper('AppStepConfigurationContainer'); * taxa with pathways or genes. */ -export type CorrelationAssayAssayConfig = t.TypeOf< - typeof CorrelationAssayAssayConfig ->; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const CorrelationAssayAssayConfig = t.partial({ - collectionVariable1: VariableCollectionDescriptor, - collectionVariable2: VariableCollectionDescriptor, - correlationMethod: t.string, - prefilterThresholds: FeaturePrefilterThresholds, -}); - -const CompleteCorrelationAssayAssayConfig = partialToCompleteCodec( - CorrelationAssayAssayConfig -); - export const plugin: ComputationPlugin = { configurationComponent: CorrelationAssayAssayConfiguration, configurationDescriptionComponent: CorrelationAssayAssayConfigDescriptionComponent, - createDefaultConfiguration: () => ({}), + createDefaultConfiguration: () => ({ + prefilterThresholds: { + proportionNonZero: DEFAULT_PROPORTION_NON_ZERO_THRESHOLD, + variance: DEFAULT_VARIANCE_THRESHOLD, + standardDeviation: DEFAULT_STANDARD_DEVIATION_THRESHOLD, + }, + }), isConfigurationComplete: (configuration) => { + // Configuration must be complete and have unique values for data1 and data2. return ( - CompleteCorrelationAssayAssayConfig.is(configuration) && + CompleteCorrelationConfig.is(configuration) && + isVariableCollectionDescriptor(configuration.data1?.collectionSpec) && + isVariableCollectionDescriptor(configuration.data2?.collectionSpec) && variableCollectionsAreUnique([ - configuration.collectionVariable1, - configuration.collectionVariable2, + configuration.data1?.collectionSpec, + configuration.data2?.collectionSpec, ]) ); }, visualizationPlugins: { bipartitenetwork: bipartiteNetworkVisualization.withOptions({ getLegendTitle(config) { - if (CorrelationAssayAssayConfig.is(config)) { + if (CorrelationConfig.is(config)) { return ['absolute correlation coefficient', 'correlation direction']; } else { return []; } }, + getParitionNames(studyMetadata, config) { + if (CorrelationConfig.is(config)) { + const entities = entityTreeToArray(studyMetadata.rootEntity); + const partition1Name = findEntityAndVariableCollection( + entities, + config.data1?.collectionSpec + )?.variableCollection.displayName; + const partition2Name = findEntityAndVariableCollection( + entities, + config.data2?.collectionSpec + )?.variableCollection.displayName; + return { partition1Name, partition2Name }; + } + }, }), // Must match name in data service and in visualization.tsx }, isEnabledInPicker: isEnabledInPicker, @@ -96,15 +108,17 @@ function CorrelationAssayAssayConfigDescriptionComponent({ computation: Computation; }) { const findEntityAndVariableCollection = useFindEntityAndVariableCollection(); - assertComputationWithConfig(computation, CorrelationAssayAssayConfig); + assertComputationWithConfig(computation, CorrelationConfig); - const { collectionVariable1, collectionVariable2, correlationMethod } = + const { data1, data2, correlationMethod } = computation.descriptor.configuration; - const entityAndCollectionVariableTreeNode1 = - findEntityAndVariableCollection(collectionVariable1); - const entityAndCollectionVariableTreeNode2 = - findEntityAndVariableCollection(collectionVariable2); + const entityAndCollectionVariableTreeNode1 = findEntityAndVariableCollection( + data1?.collectionSpec + ); + const entityAndCollectionVariableTreeNode2 = findEntityAndVariableCollection( + data2?.collectionSpec + ); const correlationMethodDisplayName = correlationMethod ? CORRELATION_METHODS.find((method) => method.value === correlationMethod) @@ -166,7 +180,7 @@ export function CorrelationAssayAssayConfiguration( visualizationId, } = props; - assertComputationWithConfig(computation, CorrelationAssayAssayConfig); + assertComputationWithConfig(computation, CorrelationConfig); const { configuration } = computation.descriptor; @@ -176,21 +190,6 @@ export function CorrelationAssayAssayConfiguration( visualizationId ); - // set initial prefilterThresholds - useEffect(() => { - changeConfigHandler('prefilterThresholds', { - proportionNonZero: - configuration.prefilterThresholds?.proportionNonZero ?? - DEFAULT_PROPORTION_NON_ZERO_THRESHOLD, - variance: - configuration.prefilterThresholds?.variance ?? - DEFAULT_VARIANCE_THRESHOLD, - standardDeviation: - configuration.prefilterThresholds?.standardDeviation ?? - DEFAULT_STANDARD_DEVIATION_THRESHOLD, - }); - }, []); - // Content for the expandable help section const helpContent = (
    @@ -290,22 +289,29 @@ export function CorrelationAssayAssayConfiguration(
    Input Data
    - {/* Taxonomic level */} - Data 1 + Taxonomic level { + if (isVariableCollectionDescriptor(value)) + changeConfigHandler('data1', { + dataType: 'collection', + collectionSpec: value, + }); + }} + collectionPredicate={isTaxonomicVariableCollection} /> - {/* Functional data - */} - Data 2 + Functional data { + if (isVariableCollectionDescriptor(value)) + changeConfigHandler('data2', { + dataType: 'collection', + collectionSpec: value, + }); + }} + collectionPredicate={isFunctionalCollection} />
    @@ -362,18 +368,6 @@ export function CorrelationAssayAssayConfiguration(
    -
    - -
    entity.id === 'OBI_0002623' && !!entity.collections?.length - // ); // OBI_0002623 = Metagenomic sequencing assay + const entities = entityTreeToArray(studyMetadata.rootEntity); - // return hasMetagenomicData; + // Check that the metagenomic entity exists _and_ that it has + // at least one collection. + const hasMetagenomicData = entities.some( + (entity) => entity.id === 'OBI_0002623' && !!entity.collections?.length + ); // OBI_0002623 = Metagenomic sequencing assay - /** end of temporary change */ + return hasMetagenomicData; return true; } diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayMetadata.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayMetadata.tsx index 47eee065cf..0f15b144ec 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayMetadata.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayMetadata.tsx @@ -1,17 +1,11 @@ import { useEffect, useMemo } from 'react'; -import { - FeaturePrefilterThresholds, - VariableTreeNode, - useFindEntityAndVariableCollection, -} from '../../..'; -import { VariableCollectionDescriptor } from '../../../types/variable'; +import { VariableTreeNode, useFindEntityAndVariableCollection } from '../../..'; import { ComputationConfigProps, ComputationPlugin } from '../Types'; import { partial } from 'lodash'; import { useConfigChangeHandler, assertComputationWithConfig, isNotAbsoluteAbundanceVariableCollection, - partialToCompleteCodec, } from '../Utils'; import * as t from 'io-ts'; import { Computation } from '../../../types/visualization'; @@ -22,11 +16,19 @@ import { H6 } from '@veupathdb/coreui'; import { bipartiteNetworkVisualization } from '../../visualizations/implementations/BipartiteNetworkVisualization'; import { VariableCollectionSelectList } from '../../variableSelectors/VariableCollectionSingleSelect'; import SingleSelect from '@veupathdb/coreui/lib/components/inputs/SingleSelect'; -import { entityTreeToArray } from '../../../utils/study-metadata'; +import { + entityTreeToArray, + isVariableCollectionDescriptor, + findEntityAndVariableCollection, +} from '../../../utils/study-metadata'; import { IsEnabledInPickerParams } from '../../visualizations/VisualizationTypes'; import { ancestorEntitiesForEntityId } from '../../../utils/data-element-constraints'; import { NumberInput } from '@veupathdb/components/lib/components/widgets/NumberAndDateInputs'; import ExpandablePanel from '@veupathdb/coreui/lib/components/containers/ExpandablePanel'; +import { + CompleteCorrelationConfig, + CorrelationConfig, +} from '../../../types/apps'; const cx = makeClassNameHelper('AppStepConfigurationContainer'); @@ -42,36 +44,41 @@ const cx = makeClassNameHelper('AppStepConfigurationContainer'); * when those roll out we'll be able to do a little refactoring to make the code a bit nicer. */ -export type CorrelationAssayMetadataConfig = t.TypeOf< - typeof CorrelationAssayMetadataConfig ->; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const CorrelationAssayMetadataConfig = t.partial({ - collectionVariable: VariableCollectionDescriptor, - correlationMethod: t.string, - prefilterThresholds: FeaturePrefilterThresholds, -}); - -const CompleteCorrelationAssayMetadataConfig = partialToCompleteCodec( - CorrelationAssayMetadataConfig -); - export const plugin: ComputationPlugin = { configurationComponent: CorrelationAssayMetadataConfiguration, configurationDescriptionComponent: CorrelationAssayMetadataConfigDescriptionComponent, - createDefaultConfiguration: () => ({}), - isConfigurationComplete: CompleteCorrelationAssayMetadataConfig.is, + createDefaultConfiguration: () => ({ + data2: { + dataType: 'metadata', + }, + prefilterThresholds: { + proportionNonZero: DEFAULT_PROPORTION_NON_ZERO_THRESHOLD, + variance: DEFAULT_VARIANCE_THRESHOLD, + standardDeviation: DEFAULT_STANDARD_DEVIATION_THRESHOLD, + }, + }), + isConfigurationComplete: CompleteCorrelationConfig.is, visualizationPlugins: { bipartitenetwork: bipartiteNetworkVisualization.withOptions({ getLegendTitle(config) { - if (CorrelationAssayMetadataConfig.is(config)) { + if (CorrelationConfig.is(config)) { return ['absolute correlation coefficient', 'correlation direction']; } else { return []; } }, + getParitionNames(studyMetadata, config) { + if (CorrelationConfig.is(config)) { + const entities = entityTreeToArray(studyMetadata.rootEntity); + const partition1Name = findEntityAndVariableCollection( + entities, + config.data1?.collectionSpec + )?.variableCollection.displayName; + const partition2Name = 'Continuous metadata variables'; + return { partition1Name, partition2Name }; + } + }, }), // Must match name in data service and in visualization.tsx }, isEnabledInPicker: isEnabledInPicker, @@ -86,13 +93,13 @@ function CorrelationAssayMetadataConfigDescriptionComponent({ computation: Computation; }) { const findEntityAndVariableCollection = useFindEntityAndVariableCollection(); - assertComputationWithConfig(computation, CorrelationAssayMetadataConfig); + assertComputationWithConfig(computation, CorrelationConfig); - const { collectionVariable, correlationMethod } = - computation.descriptor.configuration; + const { data1, correlationMethod } = computation.descriptor.configuration; - const entityAndCollectionVariableTreeNode = - findEntityAndVariableCollection(collectionVariable); + const entityAndCollectionVariableTreeNode = findEntityAndVariableCollection( + data1?.collectionSpec + ); const correlationMethodDisplayName = correlationMethod ? CORRELATION_METHODS.find((method) => method.value === correlationMethod) @@ -145,9 +152,9 @@ export function CorrelationAssayMetadataConfiguration( } = props; const configuration = computation.descriptor - .configuration as CorrelationAssayMetadataConfig; + .configuration as CorrelationConfig; - assertComputationWithConfig(computation, CorrelationAssayMetadataConfig); + assertComputationWithConfig(computation, CorrelationConfig); const changeConfigHandler = useConfigChangeHandler( analysisState, @@ -155,21 +162,6 @@ export function CorrelationAssayMetadataConfiguration( visualizationId ); - // set initial prefilterThresholds - useEffect(() => { - changeConfigHandler('prefilterThresholds', { - proportionNonZero: - configuration.prefilterThresholds?.proportionNonZero ?? - DEFAULT_PROPORTION_NON_ZERO_THRESHOLD, - variance: - configuration.prefilterThresholds?.variance ?? - DEFAULT_VARIANCE_THRESHOLD, - standardDeviation: - configuration.prefilterThresholds?.standardDeviation ?? - DEFAULT_STANDARD_DEVIATION_THRESHOLD, - }); - }, []); - // Content for the expandable help section const helpContent = (
    @@ -274,8 +266,14 @@ export function CorrelationAssayMetadataConfiguration(
    Data 1 { + if (isVariableCollectionDescriptor(value)) + changeConfigHandler('data1', { + dataType: 'collection', + collectionSpec: value, + }); + }} collectionPredicate={isNotAbsoluteAbundanceVariableCollection} /> Data 2 (fixed) diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/index.ts b/packages/libs/eda/src/lib/core/components/computations/plugins/index.ts index 02b582b1d9..c03060b752 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/index.ts +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/index.ts @@ -6,8 +6,9 @@ import { plugin as distributions } from './distributions'; import { plugin as countsandproportions } from './countsAndProportions'; import { plugin as abundance } from './abundance'; import { plugin as differentialabundance } from './differentialabundance'; -import { plugin as correlationassaymetadata } from './correlationAssayMetadata'; -import { plugin as correlationassayassay } from './correlationAssayAssay'; +import { plugin as correlationassaymetadata } from './correlationAssayMetadata'; // mbio +import { plugin as correlationassayassay } from './correlationAssayAssay'; // mbio +import { plugin as correlation } from './correlation'; // genomics (- vb) import { plugin as xyrelationships } from './xyRelationships'; export const plugins: Record = { abundance, @@ -16,6 +17,7 @@ export const plugins: Record = { differentialabundance, correlationassaymetadata, correlationassayassay, + correlation, countsandproportions, distributions, pass, diff --git a/packages/libs/eda/src/lib/core/components/variableSelectors/VariableCollectionSingleSelect.tsx b/packages/libs/eda/src/lib/core/components/variableSelectors/VariableCollectionSingleSelect.tsx index 3d31a47077..d0ad9d9781 100644 --- a/packages/libs/eda/src/lib/core/components/variableSelectors/VariableCollectionSingleSelect.tsx +++ b/packages/libs/eda/src/lib/core/components/variableSelectors/VariableCollectionSingleSelect.tsx @@ -6,19 +6,25 @@ import SingleSelect, { import { Item } from '@veupathdb/coreui/lib/components/inputs/checkboxes/CheckboxList'; import { useStudyEntities } from '../../hooks/workspace'; import { VariableCollectionDescriptor } from '../../types/variable'; +import { isVariableCollectionDescriptor } from '../../utils/study-metadata'; interface Props { + /** Optional logic to filter out unwanted collections */ collectionPredicate?: (collection: CollectionVariableTreeNode) => boolean; - value?: VariableCollectionDescriptor; - onSelect: (value?: VariableCollectionDescriptor) => void; + /** Selected value */ + value?: VariableCollectionDescriptor | string; + /** Function to apply when a value is selected */ + onSelect: (value?: VariableCollectionDescriptor | string) => void; + /** Optionally add additional non-collection items to the dropdown */ + additionalItemGroups?: ItemGroup[]; } export function VariableCollectionSelectList(props: Props) { - const { collectionPredicate, onSelect, value } = props; + const { collectionPredicate, onSelect, value, additionalItemGroups } = props; const entities = useStudyEntities(); const items = useMemo(() => { - return entities + const collectionItems = entities .filter( (e): e is StudyEntity & Required> => !!e.collections?.length @@ -38,7 +44,11 @@ export function VariableCollectionSelectList(props: Props) { }; }) .filter((itemGroup) => itemGroup.items.length > 0); // Remove entites that had all their collections fail the collection predicate. - }, [entities, collectionPredicate]); + + return additionalItemGroups + ? [...collectionItems, ...additionalItemGroups] + : collectionItems; + }, [entities, collectionPredicate, additionalItemGroups]); const handleSelect = useCallback( (value?: string) => { @@ -46,26 +56,44 @@ export function VariableCollectionSelectList(props: Props) { onSelect(); return; } - const [entityId, collectionId] = value.split(':'); - onSelect({ entityId, collectionId }); + if (value.includes(':')) { + const [entityId, collectionId] = value.split(':'); + onSelect({ entityId, collectionId }); + } else { + onSelect(value); + } }, [onSelect] ); const display = useMemo(() => { if (value == null) return 'Select the data'; - const collection = entities - .find((e) => e.id === value.entityId) - ?.collections?.find((c) => c.id === value.collectionId); - return ( - collection?.displayName ?? `Unknown collection: ${value.collectionId}` - ); - }, [entities, value]); + + // Handle different types of values we may see (either VariableCollectionDescriptors or strings) + if (isVariableCollectionDescriptor(value)) { + const collection = entities + .find((e) => e.id === value.entityId) + ?.collections?.find((c) => c.id === value.collectionId); + return ( + collection?.displayName ?? `Unknown collection: ${value.collectionId}` + ); + } else { + const valueDisplay = items + .flatMap((group) => group.items) + .find((item) => item.value === value)?.display; + return valueDisplay; + } + }, [entities, value, items]); return ( items={items} - value={value && `${value.entityId}:${value.collectionId}`} + value={ + value && + (isVariableCollectionDescriptor(value) + ? `${value.entityId}:${value.collectionId}` + : value) + } onSelect={handleSelect} buttonDisplayContent={display} /> diff --git a/packages/libs/eda/src/lib/core/components/visualizations/OutputEntityTitle.tsx b/packages/libs/eda/src/lib/core/components/visualizations/OutputEntityTitle.tsx index b1fd1af228..0c67fdb48a 100644 --- a/packages/libs/eda/src/lib/core/components/visualizations/OutputEntityTitle.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/OutputEntityTitle.tsx @@ -6,26 +6,40 @@ import { makeEntityDisplayName } from '../../utils/study-metadata'; import { ReactNode } from 'react'; interface Props { + /** StudyEntity. Will use the display name of the entity for the title */ entity?: StudyEntity; + /** If present, use in the title in place of the entity display name */ + entityDisplayNameOverride?: string; + /** Value to be used in the title. Usually the number of points in the plot */ outputSize?: number; + /** Optional subtitle to show below the title */ subtitle?: ReactNode; } const cx = makeClassNameHelper('OutputEntityTitle'); const cxSubtitle = makeClassNameHelper('OutputEntitySubtitle'); -export function OutputEntityTitle({ entity, outputSize, subtitle }: Props) { +export function OutputEntityTitle({ + entity, + entityDisplayNameOverride, + outputSize, + subtitle, +}: Props) { return (

    {outputSize != null && <>{outputSize.toLocaleString()} } - - {entity != null - ? makeEntityDisplayName( - entity, - outputSize == null || outputSize !== 1 - ) - : 'No entity selected'} - + {entityDisplayNameOverride != null ? ( + {entityDisplayNameOverride} + ) : ( + + {entity != null + ? makeEntityDisplayName( + entity, + outputSize == null || outputSize !== 1 + ) + : 'No entity selected'} + + )} {subtitle && (

    {subtitle} diff --git a/packages/libs/eda/src/lib/core/components/visualizations/PluginError.tsx b/packages/libs/eda/src/lib/core/components/visualizations/PluginError.tsx index 51956105a3..628a4cfcb8 100644 --- a/packages/libs/eda/src/lib/core/components/visualizations/PluginError.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/PluginError.tsx @@ -6,6 +6,7 @@ interface Props { error?: unknown; customCases?: Case[]; outputSize?: number; + bannerType?: 'warning' | 'error'; } type Case = (error: unknown) => string | ReactFragment | undefined; @@ -17,7 +18,12 @@ const defaultCases: Case[] = [ const emptyCaseMessage = 'The visualization cannot be made because there is no plottable data for selected variable(s) in the current subset.'; -export default function PluginError({ error, customCases, outputSize }: Props) { +export default function PluginError({ + error, + customCases, + outputSize, + bannerType = 'warning', +}: Props) { // TO DO: errors from back end should arrive with a separate response code property // FOR NOW: flatten entire error to a string const fallbackErrorMessage = @@ -36,7 +42,7 @@ export default function PluginError({ error, customCases, outputSize }: Props) { return errorContent ? ( diff --git a/packages/libs/eda/src/lib/core/components/visualizations/Visualizations.scss b/packages/libs/eda/src/lib/core/components/visualizations/Visualizations.scss index f8463a73e5..bf48188bac 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/Visualizations.scss +++ b/packages/libs/eda/src/lib/core/components/visualizations/Visualizations.scss @@ -439,7 +439,7 @@ $data-table-thick-border: 2px solid #262626; .OutputEntitySubtitle { font-style: italic; font-size: 0.9em; - margin-top: -10px; + margin-top: 0.4em; } // R-square table for Scatter plot's Best fit option diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/BipartiteNetworkVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/BipartiteNetworkVisualization.tsx index 20d628ba2a..246891bb02 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/BipartiteNetworkVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/BipartiteNetworkVisualization.tsx @@ -12,6 +12,7 @@ import { RequestOptions } from '../options/types'; // Bipartite network imports import BipartiteNetwork, { BipartiteNetworkProps, + NodeMenuAction, } from '@veupathdb/components/lib/plots/BipartiteNetwork'; import BipartiteNetworkSVG from './selectorIcons/BipartiteNetworkSVG'; import { @@ -30,8 +31,6 @@ import { } from '../../../hooks/workspace'; import { fixVarIdLabel } from '../../../utils/visualization'; import DataClient from '../../../api/DataClient'; -import { CorrelationAssayMetadataConfig } from '../../computations/plugins/correlationAssayMetadata'; -import { CorrelationAssayAssayConfig } from '../../computations/plugins/correlationAssayAssay'; import { OutputEntityTitle } from '../OutputEntityTitle'; import { scaleLinear } from 'd3'; import PlotLegend from '@veupathdb/components/lib/components/plotControls/PlotLegend'; @@ -44,6 +43,8 @@ import { NumberOrDate } from '@veupathdb/components/lib/types/general'; import { useVizConfig } from '../../../hooks/visualizations'; import { FacetedPlotLayout } from '../../layouts/FacetedPlotLayout'; import { H6 } from '@veupathdb/coreui'; +import { CorrelationConfig } from '../../../types/apps'; +import { StudyMetadata } from '../../..'; // end imports // Defaults @@ -85,7 +86,20 @@ interface Options extends LayoutOptions, TitleOptions, LegendOptions, - RequestOptions {} + RequestOptions { + makeGetNodeMenuActions?: ( + studyMetadata: StudyMetadata + ) => ((nodeId: string) => NodeMenuAction[]) | undefined; + getParitionNames?: ( + studyMetadata: StudyMetadata, + config: unknown + ) => + | Partial<{ + parition1Name: string; + partition2Name: string; + }> + | undefined; +} // Bipartite Network Visualization // The bipartite network takes no input variables, because the received data will complete the plot. @@ -109,11 +123,8 @@ function BipartiteNetworkViz(props: VisualizationProps) { const entities = useStudyEntities(filters); const dataClient: DataClient = useDataClient(); - const computationConfiguration: - | CorrelationAssayMetadataConfig - | CorrelationAssayAssayConfig = computation.descriptor.configuration as - | CorrelationAssayMetadataConfig - | CorrelationAssayAssayConfig; + const computationConfiguration: CorrelationConfig = computation.descriptor + .configuration as CorrelationConfig; const [vizConfig, updateVizConfig] = useVizConfig( visualization.descriptor.configuration, @@ -228,8 +239,30 @@ function BipartiteNetworkViz(props: VisualizationProps) { } ); + const nodesById = new Map(nodesWithLabels.map((n) => [n.id, n])); + + // sort node data by label + // this is mutating the original paritions arrray :shrug: + const orderedPartitions = data.value.bipartitenetwork.data.partitions.map( + (partition) => { + return { + ...partition, + nodeIds: partition.nodeIds.concat().sort((a, b) => { + const nodeA = nodesById.get(a); + const nodeB = nodesById.get(b); + if (nodeA == null || nodeB == null) return 0; + return nodeA.label.localeCompare(nodeB.label, 'en', { + numeric: true, + sensitivity: 'base', + }); + }), + }; + } + ); + return { ...data.value.bipartitenetwork.data, + partitions: orderedPartitions, nodes: nodesWithLabels, links: data.value.bipartitenetwork.data.links.map((link) => { return { @@ -242,12 +275,21 @@ function BipartiteNetworkViz(props: VisualizationProps) { }; }, [data.value, entities, minDataWeight, maxDataWeight]); + const getNodeMenuActions = options?.makeGetNodeMenuActions?.(studyMetadata); + // plot subtitle - const plotSubtitle = - 'Showing links with an absolute correlation coefficient above ' + - vizConfig.correlationCoefThreshold?.toString() + - ' and a p-value below ' + - vizConfig.significanceThreshold?.toString(); + const plotSubtitle = ( +
    +

    + {`Showing links with an absolute correlation coefficient above ${vizConfig.correlationCoefThreshold?.toString()} and a p-value below ${vizConfig.significanceThreshold?.toString()}`} +

    +

    + Click on a node to highlight its edges. + {getNodeMenuActions && + ' A dropdown menu will appear on mouseover, if additional actions are available.'} +

    +
    + ); const finalPlotContainerStyles = useMemo( () => ({ @@ -297,6 +339,8 @@ function BipartiteNetworkViz(props: VisualizationProps) { svgStyleOverrides: bipartiteNetworkSVGStyles, labelTruncationLength: 40, emptyNetworkContent, + getNodeMenuActions, + ...options?.getParitionNames?.(studyMetadata, computationConfiguration), }; const plotNode = ( diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx index 9bb04f7b8e..09b1231eec 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx @@ -525,6 +525,68 @@ function HistogramViz(props: VisualizationProps) { }, [filters, xAxisVariable, vizConfig.xAxisVariable, subsettingClient]) ); + // Note: Histogram distribution data contains statistical values such as summary.min/max, + // however, it does not fully respect multiple filters. + // Similarly, distribution data also partially reflect filtered data. + // A solution is to compute both min/max values from data-based and summary-based ones, + // then take max of min values and min of max values, + // which will result in correct min/max value for multiple filters + // More specifically, data-based min and summary-based max are correct values + const dataBasedIndependentAxisMinMax = useMemo(() => { + return histogramDefaultIndependentAxisMinMax(distributionDataPromise); + }, [distributionDataPromise]); + + const summaryBasedIndependentAxisMinMax = useMemo(() => { + if ( + !distributionDataPromise.pending && + distributionDataPromise.value != null + ) { + const min = distributionDataPromise.value.series[0]?.summary?.min; + const max = distributionDataPromise.value.series[0]?.summary?.max; + + if (min != null && max != null) { + if (DateVariable.is(xAxisVariable)) { + return { + min: (min as string).split('T')[0], + max: (max as string).split('T')[0], + }; + } else { + return { min, max }; + } + } + } + return undefined; + }, [distributionDataPromise]); + + const independentAxisMinMax = useMemo(() => { + return { + min: max([ + dataBasedIndependentAxisMinMax?.min, + summaryBasedIndependentAxisMinMax?.min, + ]), + max: min([ + dataBasedIndependentAxisMinMax?.max, + summaryBasedIndependentAxisMinMax?.max, + ]), + }; + }, [distributionDataPromise]); + + // Note: defaultIndependentRange in the Histogram Viz should keep its initial range + // regardless of the change of the data to ensure the truncation behavior + // Thus, pass an additional prop to useDefaultAxisRange() if Histogram Viz + const defaultIndependentRange = useDefaultAxisRange( + xAxisVariable, + vizConfig.independentAxisValueSpec === 'Full' + ? undefined + : independentAxisMinMax?.min, + undefined, + vizConfig.independentAxisValueSpec === 'Full' + ? undefined + : independentAxisMinMax?.max, + undefined, + vizConfig.independentAxisValueSpec + ); + const dataRequestConfig: DataRequestConfig = useDeepValue( pick(vizConfig, [ 'valueSpec', @@ -577,6 +639,7 @@ function HistogramViz(props: VisualizationProps) { dataRequestConfig, xAxisVariable, outputEntity, + defaultIndependentRange, options?.getRequestParams ); const response = await dataClient.getHistogram( @@ -669,68 +732,6 @@ function HistogramViz(props: VisualizationProps) { return [checkData, isEmptyData]; }, [data.value]); - // Note: Histogram distribution data contains statistical values such as summary.min/max, - // however, it does not fully respect multiple filters. - // Similarly, distribution data also partially reflect filtered data. - // A solution is to compute both min/max values from data-based and summary-based ones, - // then take max of min values and min of max values, - // which will result in correct min/max value for multiple filters - // More specifically, data-based min and summary-based max are correct values - const dataBasedIndependentAxisMinMax = useMemo(() => { - return histogramDefaultIndependentAxisMinMax(distributionDataPromise); - }, [distributionDataPromise]); - - const summaryBasedIndependentAxisMinMax = useMemo(() => { - if ( - !distributionDataPromise.pending && - distributionDataPromise.value != null - ) { - const min = distributionDataPromise.value.series[0]?.summary?.min; - const max = distributionDataPromise.value.series[0]?.summary?.max; - - if (min != null && max != null) { - if (DateVariable.is(xAxisVariable)) { - return { - min: (min as string).split('T')[0], - max: (max as string).split('T')[0], - }; - } else { - return { min, max }; - } - } - } - return undefined; - }, [distributionDataPromise]); - - const independentAxisMinMax = useMemo(() => { - return { - min: max([ - dataBasedIndependentAxisMinMax?.min, - summaryBasedIndependentAxisMinMax?.min, - ]), - max: min([ - dataBasedIndependentAxisMinMax?.max, - summaryBasedIndependentAxisMinMax?.max, - ]), - }; - }, [distributionDataPromise]); - - // Note: defaultIndependentRange in the Histogram Viz should keep its initial range - // regardless of the change of the data to ensure the truncation behavior - // Thus, pass an additional prop to useDefaultAxisRange() if Histogram Viz - const defaultIndependentRange = useDefaultAxisRange( - xAxisVariable, - vizConfig.independentAxisValueSpec === 'Full' - ? undefined - : independentAxisMinMax?.min, - undefined, - vizConfig.independentAxisValueSpec === 'Full' - ? undefined - : independentAxisMinMax?.max, - undefined, - vizConfig.independentAxisValueSpec - ); - // separate minPosMax from dependentMinPosMax const minPosMax = useMemo( () => histogramDefaultDependentAxisMinMax(data), @@ -1535,6 +1536,7 @@ function getRequestParams( config: DataRequestConfig, variable: Variable, outputEntity: StudyEntity, + defaultIndependentRange: NumberOrDateRange | undefined, customMakeRequestParams?: ( props: RequestOptionProps & FloatingHistogramExtraProps ) => HistogramRequestParams @@ -1569,6 +1571,14 @@ function getRequestParams( xMin: config?.independentAxisRange?.min, xMax: config?.independentAxisRange?.max, } + : // send back end a viewport to prevent edge-case 500s with single-valued variables when a binWidth is provided + binWidth && + defaultIndependentRange?.min != null && + defaultIndependentRange?.max != null + ? { + xMin: defaultIndependentRange?.min, + xMax: defaultIndependentRange?.max, + } : undefined; return ( diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx index 47bcf478e4..e08ed0bee5 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx @@ -2130,6 +2130,28 @@ function getRequestParams( ? 'TRUE' : 'FALSE'; + // If a binWidth is passed to the back end, we should send a viewport too. + // This helps prevent a 500 in edge-case scenarios where the variable is single-valued. + // It doesn't really matter what the viewport is, but we send either the custom range + // or the variable's automatically annotated range. + // The back end requires strings for some reason. + const viewport = binWidth + ? vizConfig.independentAxisRange != null + ? { + xMin: String(vizConfig.independentAxisRange.min), + xMax: String(vizConfig.independentAxisRange.max), + } + : (xAxisVariableMetadata.type === 'integer' || + xAxisVariableMetadata.type === 'number' || + xAxisVariableMetadata.type === 'date') && + xAxisVariableMetadata.distributionDefaults != null + ? { + xMin: String(xAxisVariableMetadata.distributionDefaults.rangeMin), + xMax: String(xAxisVariableMetadata.distributionDefaults.rangeMax), + } + : undefined + : undefined; + return ( customMakeRequestParams?.({ studyId, @@ -2148,6 +2170,7 @@ function getRequestParams( xAxisVariable: xAxisVariable!, // these will never be undefined because yAxisVariable: yAxisVariable!, // data requests are only made when they have been chosen by user ...binSpec, + viewport, overlayVariable: overlayVariable, facetVariable: facetVariable ? [facetVariable] : [], showMissingness: showMissingness ? 'TRUE' : 'FALSE', diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx index e5eb78ba92..bf86519ff0 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx @@ -14,6 +14,7 @@ import { usePromise } from '../../../hooks/promise'; import { useUpdateThumbnailEffect } from '../../../hooks/thumbnails'; import { useDataClient, + useFindEntityAndVariableCollection, useStudyEntities, useStudyMetadata, } from '../../../hooks/workspace'; @@ -46,7 +47,7 @@ import { yellow } from '@material-ui/core/colors'; import PlotLegend from '@veupathdb/components/lib/components/plotControls/PlotLegend'; import { significanceColors } from '@veupathdb/components/lib/types/plots'; import { NumberOrDateRange, NumberRange } from '../../../types/general'; -import { max, min } from 'lodash'; +import { capitalize, max, min } from 'lodash'; // plot controls import SliderWidget, { @@ -697,10 +698,17 @@ function VolcanoPlotViz(props: VisualizationProps) {
    ); + // If available, grab the annotated display name to describe the points + const findEntityAndVariableCollection = useFindEntityAndVariableCollection(); + const pointsDisplayName = capitalize( + findEntityAndVariableCollection(computationConfiguration.collectionVariable) + ?.variableCollection.memberPlural + ); + const legendNode = finalData && countsData && ( ) { outputSize={finalData?.statistics.length} /> - {!hideInputsAndControls && } + {!hideInputsAndControls && ( + + )} + + + + + + + + + + + + + + + + ); +} diff --git a/packages/libs/eda/src/lib/core/hooks/study.ts b/packages/libs/eda/src/lib/core/hooks/study.ts index 1e634df88d..9beabef9d6 100644 --- a/packages/libs/eda/src/lib/core/hooks/study.ts +++ b/packages/libs/eda/src/lib/core/hooks/study.ts @@ -191,7 +191,11 @@ export function useStudyMetadata(datasetId: string, client: SubsettingClient) { if (permissionsResponse.loading) return; const { permissions } = permissionsResponse; const studyId = permissions.perDataset[datasetId]?.studyId; - if (studyId == null) throw new Error('Not an eda study'); + if (studyId == null) { + throw new Error( + `An EDA Study ID could not be found for the Data Set ${datasetId}.` + ); + } try { return await client.getStudyMetadata(studyId); } catch (error) { diff --git a/packages/libs/eda/src/lib/core/types/apps.ts b/packages/libs/eda/src/lib/core/types/apps.ts new file mode 100644 index 0000000000..b25b44c5c1 --- /dev/null +++ b/packages/libs/eda/src/lib/core/types/apps.ts @@ -0,0 +1,27 @@ +/* eslint-disable @typescript-eslint/no-redeclare */ +import * as t from 'io-ts'; +import { VariableCollectionDescriptor } from './variable'; +import { FeaturePrefilterThresholds } from '../api/DataClient/types'; +import { partialToCompleteCodec } from '../components/computations/Utils'; + +export type CorrelationInputData = t.TypeOf; +export const CorrelationInputData = t.intersection([ + t.type({ + dataType: t.string, + }), + t.partial({ + collectionSpec: VariableCollectionDescriptor, + }), +]); + +export type CorrelationConfig = t.TypeOf; + +export const CorrelationConfig = t.partial({ + data1: CorrelationInputData, + data2: CorrelationInputData, + correlationMethod: t.string, + prefilterThresholds: FeaturePrefilterThresholds, +}); + +export const CompleteCorrelationConfig = + partialToCompleteCodec(CorrelationConfig); diff --git a/packages/libs/eda/src/lib/core/types/study.ts b/packages/libs/eda/src/lib/core/types/study.ts index 69291a52cd..6eff7c782e 100644 --- a/packages/libs/eda/src/lib/core/types/study.ts +++ b/packages/libs/eda/src/lib/core/types/study.ts @@ -230,6 +230,8 @@ export const CollectionVariableTreeNode = t.intersection([ isProportion: t.boolean, normalizationMethod: t.string, distributionDefaults: NumberDistributionDefaults, + member: t.string, + memberPlural: t.string, }), ]); diff --git a/packages/libs/eda/src/lib/core/utils/geoVariables.ts b/packages/libs/eda/src/lib/core/utils/geoVariables.ts index df10766aee..0088d016b9 100644 --- a/packages/libs/eda/src/lib/core/utils/geoVariables.ts +++ b/packages/libs/eda/src/lib/core/utils/geoVariables.ts @@ -1,5 +1,6 @@ import { GeoConfig } from '../types/geoConfig'; import { StudyEntity } from '../types/study'; +import { findLast } from 'lodash'; /** * Given an entity note in the study tree, check to see if it has the following variables @@ -45,3 +46,45 @@ export function entityToGeoConfig( } return undefined; } + +/** + * Given the geoConfigs (one per entity with geo variables) and an entity ID, + * return the geoConfig/entity that is furthest from the root that is the parent of the provided entity ID. + * + * Assumes `geoConfigs` is in root to leaf order. + */ +export function findLeastAncestralGeoConfig( + geoConfigs: GeoConfig[], + entityId: string +): GeoConfig { + return ( + findLast(geoConfigs, ({ entity }) => + entityHasChildEntityWithId(entity, entityId) + ) ?? geoConfigs[0] + ); +} + +// Check if the specified entity or any of its descendants has the given entityId. +function entityHasChildEntityWithId( + entity: StudyEntity, + entityId: string +): boolean { + return ( + entity.id === entityId || + (entity.children?.some((child) => + entityHasChildEntityWithId(child, entityId) + ) ?? + false) + ); +} + +// simple convenience function +// defaults to root-most geoConfig for safety. +export function getGeoConfig( + geoConfigs: GeoConfig[], + entityId?: string +): GeoConfig { + return ( + geoConfigs.find(({ entity: { id } }) => id === entityId) ?? geoConfigs[0] + ); +} diff --git a/packages/libs/eda/src/lib/core/utils/study-metadata.ts b/packages/libs/eda/src/lib/core/utils/study-metadata.ts index b95aaa571d..077feb4d97 100644 --- a/packages/libs/eda/src/lib/core/utils/study-metadata.ts +++ b/packages/libs/eda/src/lib/core/utils/study-metadata.ts @@ -57,6 +57,9 @@ export function isVariableCollectionDescriptor( if (!object) { return false; } + if (typeof object !== 'object') { + return false; + } return 'entityId' in object && 'collectionId' in object; } diff --git a/packages/libs/eda/src/lib/core/utils/study-records.ts b/packages/libs/eda/src/lib/core/utils/study-records.ts index f9c878a61b..1095b12796 100644 --- a/packages/libs/eda/src/lib/core/utils/study-records.ts +++ b/packages/libs/eda/src/lib/core/utils/study-records.ts @@ -18,6 +18,7 @@ interface WdkStudyRecordsOptions { attributes?: AnswerJsonFormatConfig['attributes']; tables?: AnswerJsonFormatConfig['tables']; searchName?: string; + hasMap?: boolean; } const DEFAULT_STUDY_ATTRIBUTES = ['dataset_id']; @@ -64,7 +65,10 @@ export async function getWdkStudyRecords( } ), ]); - const studyIds = new Set(edaStudies.map((s) => s.id)); + const filteredStudies = options?.hasMap + ? edaStudies.filter((study) => study.hasMap) + : edaStudies; + const studyIds = new Set(filteredStudies.map((s) => s.id)); return answer.records.filter((record) => { const datasetId = getStudyId(record); if (datasetId == null) { diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx index f90b216578..3d978b47fe 100755 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -80,6 +80,7 @@ import { Page } from '@veupathdb/wdk-client/lib/Components'; import { AnalysisError } from '../../core/components/AnalysisError'; import useSnackbar from '@veupathdb/coreui/lib/components/notifications/useSnackbar'; import SettingsButton from '@veupathdb/coreui/lib/components/containers/DraggablePanel/SettingsButton'; +import { getGeoConfig } from '../../core/utils/geoVariables'; enum MapSideNavItemLabels { Download = 'Download', @@ -231,10 +232,7 @@ function MapAnalysisImpl(props: ImplProps) { const dataClient = useDataClient(); const downloadClient = useDownloadClient(); const subsettingClient = useSubsettingClient(); - const geoConfig = geoConfigs[0]; const history = useHistory(); - const [hideVizInputsAndControls, setHideVizInputsAndControls] = - useState(false); // FIXME use the sharingUrl prop to construct this const sharingUrl = new URL(`../${analysisId}/import`, window.location.href) @@ -248,6 +246,11 @@ function MapAnalysisImpl(props: ImplProps) { (markerConfig) => markerConfig.type === activeMarkerConfigurationType ); + const geoConfig = getGeoConfig( + geoConfigs, + activeMarkerConfiguration?.geoEntityId + ); + const updateMarkerConfigurations = useCallback( (updatedConfiguration: MarkerConfiguration) => { const nextMarkerConfigurations = markerConfigurations.map( @@ -408,8 +411,6 @@ function MapAnalysisImpl(props: ImplProps) { geoConfigs={geoConfigs} configuration={activeMarkerConfiguration} updateConfiguration={updateMarkerConfigurations as any} - hideVizInputsAndControls={hideVizInputsAndControls} - setHideVizInputsAndControls={setHideVizInputsAndControls} setIsSidePanelExpanded={setIsSidePanelExpanded} /> ); @@ -439,8 +440,6 @@ function MapAnalysisImpl(props: ImplProps) { geoConfigs={geoConfigs} configuration={activeMarkerConfiguration} updateConfiguration={updateMarkerConfigurations as any} - hideVizInputsAndControls={hideVizInputsAndControls} - setHideVizInputsAndControls={setHideVizInputsAndControls} setIsSidePanelExpanded={setIsSidePanelExpanded} /> ); @@ -468,8 +467,6 @@ function MapAnalysisImpl(props: ImplProps) { geoConfigs={geoConfigs} configuration={activeMarkerConfiguration} updateConfiguration={updateMarkerConfigurations as any} - hideVizInputsAndControls={hideVizInputsAndControls} - setHideVizInputsAndControls={setHideVizInputsAndControls} setIsSidePanelExpanded={setIsSidePanelExpanded} /> ); @@ -780,11 +777,17 @@ function MapAnalysisImpl(props: ImplProps) {
    ); - // close left-side panel when map events happen - const closePanel = useCallback( - () => setIsSidePanelExpanded(false), - [setIsSidePanelExpanded] - ); + const deselectMarkersAndClosePanel = useCallback(() => { + updateMarkerConfigurations({ + ...(activeMarkerConfiguration as MarkerConfiguration), + selectedMarkers: undefined, + }); + setIsSidePanelExpanded(false); + }, [ + updateMarkerConfigurations, + activeMarkerConfiguration, + setIsSidePanelExpanded, + ]); const activeMapTypePlugin = activeMarkerConfiguration?.type === 'barplot' @@ -814,8 +817,6 @@ function MapAnalysisImpl(props: ImplProps) { updateConfiguration: updateMarkerConfigurations as any, totalCounts, filteredCounts, - hideVizInputsAndControls, - setHideVizInputsAndControls, setStudyDetailsPanelConfig, headerButtons: HeaderButtons, }; @@ -902,10 +903,7 @@ function MapAnalysisImpl(props: ImplProps) { } // pass defaultViewport & isStandAloneMap props for custom zoom control defaultViewport={defaultViewport} - // close left-side panel when map events happen - onMapClick={closePanel} - onMapDrag={closePanel} - onMapZoom={closePanel} + onMapClick={deselectMarkersAndClosePanel} > {activeMapTypePlugin?.MapLayerComponent && ( void; + setActiveVisualizationId: (id?: string, isNew?: boolean) => void; apps: ComputationAppOverview[]; plugins: Partial>; // visualizationPlugins: Partial>; geoConfigs: GeoConfig[]; mapType?: MarkerConfiguration['type']; - setHideVizInputsAndControls: (value: boolean) => void; setIsSidePanelExpanded: MapTypeConfigPanelProps['setIsSidePanelExpanded']; } @@ -42,15 +41,13 @@ export default function MapVizManagement({ setActiveVisualizationId, plugins, mapType, - setHideVizInputsAndControls, setIsSidePanelExpanded, }: Props) { const [isVizSelectorVisible, setIsVizSelectorVisible] = useState(false); function onVisualizationCreated(visualizationId: string) { setIsVizSelectorVisible(false); - setActiveVisualizationId(visualizationId); - setHideVizInputsAndControls(false); + setActiveVisualizationId(visualizationId, true); setIsSidePanelExpanded(false); } diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx index 2b82275301..050b972401 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx @@ -27,6 +27,8 @@ import { } from '../appState'; import { gray } from '@veupathdb/coreui/lib/definitions/colors'; import { SharedMarkerConfigurations } from '../mapTypes/shared'; +import { GeoConfig } from '../../../core/types/geoConfig'; +import { findLeastAncestralGeoConfig } from '../../../core/utils/geoVariables'; interface MarkerConfiguration { type: T; @@ -65,6 +67,7 @@ interface Props * Only defined and used in categorical table if selectedCountsOption is 'visible' */ allVisibleCategoricalValues: AllValuesDefinition[] | undefined; + geoConfigs: GeoConfig[]; } // TODO: generalize this and PieMarkerConfigMenu into MarkerConfigurationMenu. Lots of code repetition... @@ -84,6 +87,7 @@ export function BarPlotMarkerConfigurationMenu({ continuousMarkerPreview, allFilteredCategoricalValues, allVisibleCategoricalValues, + geoConfigs, }: Props) { /** * Used to track the CategoricalMarkerConfigurationTable's selection state, which allows users to @@ -154,12 +158,19 @@ export function BarPlotMarkerConfigurationMenu({ return; } + const geoConfig = findLeastAncestralGeoConfig( + geoConfigs, + selection.overlayVariable.entityId + ); + onChange({ ...configuration, selectedVariable: selection.overlayVariable, selectedValues: undefined, + geoEntityId: geoConfig.entity.id, }); } + function handlePlotModeSelection(option: string) { onChange({ ...configuration, diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx index 882a98a505..34bde9bc66 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx @@ -16,6 +16,8 @@ import { DataElementConstraint } from '../../../core/types/visualization'; // TO import { SharedMarkerConfigurations } from '../mapTypes/shared'; import { invalidProportionText } from '../utils/defaultOverlayConfig'; import { BubbleLegendPositionConfig, PanelConfig } from '../appState'; +import { GeoConfig } from '../../../core/types/geoConfig'; +import { findLeastAncestralGeoConfig } from '../../../core/utils/geoVariables'; type AggregatorOption = typeof aggregatorOptions[number]; const aggregatorOptions = ['mean', 'median'] as const; @@ -47,6 +49,7 @@ interface Props configuration: BubbleMarkerConfiguration; overlayConfiguration: BubbleOverlayConfig | undefined; isValidProportion: boolean | undefined; // undefined when not categorical mode + geoConfigs: GeoConfig[]; } export function BubbleMarkerConfigurationMenu({ @@ -58,6 +61,7 @@ export function BubbleMarkerConfigurationMenu({ toggleStarredVariable, constraints, isValidProportion, + geoConfigs, }: Props) { function handleInputVariablesOnChange(selection: VariablesByInputName) { if (!selection.overlayVariable) { @@ -67,11 +71,17 @@ export function BubbleMarkerConfigurationMenu({ return; } + const geoConfig = findLeastAncestralGeoConfig( + geoConfigs, + selection.overlayVariable.entityId + ); + onChange({ ...configuration, selectedVariable: selection.overlayVariable, numeratorValues: undefined, denominatorValues: undefined, + geoEntityId: geoConfig.entity.id, }); } diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx index a68fe22949..e2a9739421 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx @@ -25,6 +25,8 @@ import { SelectedValues, } from '../appState'; import { SharedMarkerConfigurations } from '../mapTypes/shared'; +import { GeoConfig } from '../../../core/types/geoConfig'; +import { findLeastAncestralGeoConfig } from '../../../core/utils/geoVariables'; interface MarkerConfiguration { type: T; @@ -61,6 +63,7 @@ interface Props * Only defined and used in categorical table if selectedCountsOption is 'visible' */ allVisibleCategoricalValues: AllValuesDefinition[] | undefined; + geoConfigs: GeoConfig[]; } // TODO: generalize this and BarPlotMarkerConfigMenu into MarkerConfigurationMenu. Lots of code repetition... @@ -80,6 +83,7 @@ export function PieMarkerConfigurationMenu({ continuousMarkerPreview, allFilteredCategoricalValues, allVisibleCategoricalValues, + geoConfigs, }: Props) { /** * Used to track the CategoricalMarkerConfigurationTable's selection state, which allows users to @@ -149,12 +153,23 @@ export function PieMarkerConfigurationMenu({ return; } + // With each variable change we set the geo entity for the user to the least ancestral + // entity on the path from root to the chosen variable. + // However, we could make this choosable via the UI in the future. + // (That's one reason why we're storing it in appState.) + const geoConfig = findLeastAncestralGeoConfig( + geoConfigs, + selection.overlayVariable.entityId + ); + onChange({ ...configuration, selectedVariable: selection.overlayVariable, selectedValues: undefined, + geoEntityId: geoConfig.entity.id, }); } + function handleBinningMethodSelection(option: string) { onChange({ ...configuration, diff --git a/packages/libs/eda/src/lib/map/analysis/appState.ts b/packages/libs/eda/src/lib/map/analysis/appState.ts index 7efbed7136..689f039b3d 100644 --- a/packages/libs/eda/src/lib/map/analysis/appState.ts +++ b/packages/libs/eda/src/lib/map/analysis/appState.ts @@ -20,6 +20,7 @@ import { DEFAULT_DRAGGABLE_LEGEND_POSITION } from './DraggableLegendPanel'; const defaultVisualizationPanelConfig = { isVisible: false, + hideVizControl: false, position: DEFAULT_DRAGGABLE_VIZ_POSITION, dimensions: DEFAULT_DRAGGABLE_VIZ_DIMENSIONS, }; @@ -37,14 +38,19 @@ const PanelPositionConfig = t.type({ y: t.number, }); -const PanelConfig = t.type({ - isVisible: t.boolean, - position: PanelPositionConfig, - dimensions: t.type({ - height: t.union([t.number, t.string]), - width: t.union([t.number, t.string]), +const PanelConfig = t.intersection([ + t.type({ + isVisible: t.boolean, + position: PanelPositionConfig, + dimensions: t.type({ + height: t.union([t.number, t.string]), + width: t.union([t.number, t.string]), + }), }), -}); + t.partial({ + hideVizControl: t.boolean, + }), +]); const BubbleLegendPositionConfig = t.type({ variable: PanelPositionConfig, @@ -93,6 +99,7 @@ export const MarkerConfiguration = t.intersection([ }), t.partial({ activeVisualizationId: t.string, + geoEntityId: t.string, }), t.union([ t.intersection([ @@ -232,6 +239,7 @@ export function useAppState( active: true, selectedRange: undefined, }, + hideVizControl: false, ...(isMegaStudy ? { studyDetailsPanelConfig: { diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx index d514cecfa8..5f20f69c5f 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx @@ -56,6 +56,7 @@ import { getErrorOverlayComponent, getLegendErrorMessage, selectedMarkersLittleFilter, + useFloatingPanelHandlers, } from '../shared'; import { useFindEntityAndVariable, @@ -79,7 +80,6 @@ import { useLittleFilters } from '../../littleFilters'; import TimeSliderQuickFilter from '../../TimeSliderQuickFilter'; import { MapTypeHeaderStudyDetails } from '../MapTypeHeaderStudyDetails'; import { SubStudies } from '../../SubStudies'; -import { PanelConfig } from '../../appState'; const displayName = 'Bar plots'; @@ -260,6 +260,7 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { analysisState.analysis?.descriptor.starredVariables ?? [] } toggleStarredVariable={toggleStarredVariable} + geoConfigs={geoConfigs} /> ); @@ -273,7 +274,7 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { }; const setActiveVisualizationId = useCallback( - (activeVisualizationId?: string) => { + (activeVisualizationId?: string, isNew?: boolean) => { if (configuration == null) return; updateConfiguration({ ...configuration, @@ -281,6 +282,7 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { visualizationPanelConfig: { ...visualizationPanelConfig, isVisible: !!activeVisualizationId, + ...(isNew ? { hideVizControl: false } : {}), }, }); }, @@ -311,7 +313,6 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { plugins={plugins} geoConfigs={geoConfigs} mapType="barplot" - setHideVizInputsAndControls={props.setHideVizInputsAndControls} setIsSidePanelExpanded={setIsSidePanelExpanded} /> ), @@ -486,52 +487,13 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { substudyFilterFuncs ); - const updateLegendPosition = useCallback( - (position: PanelConfig['position']) => { - updateConfiguration({ - ...configuration, - legendPanelConfig: position, - }); - }, - [updateConfiguration, configuration] - ); - - const updateVisualizationPosition = useCallback( - (position: PanelConfig['position']) => { - updateConfiguration({ - ...configuration, - visualizationPanelConfig: { - ...visualizationPanelConfig, - position, - }, - }); - }, - [updateConfiguration, configuration, visualizationPanelConfig] - ); - - const updateVisualizationDimensions = useCallback( - (dimensions: PanelConfig['dimensions']) => { - updateConfiguration({ - ...configuration, - visualizationPanelConfig: { - ...visualizationPanelConfig, - dimensions, - }, - }); - }, - [updateConfiguration, configuration, visualizationPanelConfig] - ); - - const onPanelDismiss = useCallback(() => { - updateConfiguration({ - ...configuration, - activeVisualizationId: undefined, - visualizationPanelConfig: { - ...visualizationPanelConfig, - isVisible: false, - }, - }); - }, [updateConfiguration, configuration, visualizationPanelConfig]); + const { + updateLegendPosition, + updateVisualizationPosition, + updateVisualizationDimensions, + onPanelDismiss, + setHideVizControl, + } = useFloatingPanelHandlers({ configuration, updateConfiguration }); return ( <> @@ -563,26 +525,30 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { )}
    - + {visualizationPanelConfig?.isVisible && ( + + )} ); } diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx index 8e1537edd5..d56a08e9c1 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx @@ -47,6 +47,7 @@ import { getErrorOverlayComponent, useSelectedMarkerSnackbars, selectedMarkersLittleFilter, + useFloatingPanelHandlers, } from '../shared'; import { MapTypeConfigPanelProps, @@ -90,7 +91,6 @@ function BubbleMapConfigurationPanel(props: MapTypeConfigPanelProps) { filters, geoConfigs, setIsSidePanelExpanded, - setHideVizInputsAndControls, } = props; const toggleStarredVariable = useToggleStarredVariable(analysisState); @@ -106,7 +106,7 @@ function BubbleMapConfigurationPanel(props: MapTypeConfigPanelProps) { )?.dataElementConstraints; const setActiveVisualizationId = useCallback( - (activeVisualizationId?: string) => { + (activeVisualizationId?: string, isNew?: boolean) => { if (markerConfiguration == null) return; updateConfiguration({ ...markerConfiguration, @@ -114,6 +114,7 @@ function BubbleMapConfigurationPanel(props: MapTypeConfigPanelProps) { visualizationPanelConfig: { ...visualizationPanelConfig, isVisible: !!activeVisualizationId, + ...(isNew ? { hideVizControl: false } : {}), }, }); }, @@ -144,6 +145,7 @@ function BubbleMapConfigurationPanel(props: MapTypeConfigPanelProps) { toggleStarredVariable={toggleStarredVariable} constraints={markerVariableConstraints} isValidProportion={isValidProportion} + geoConfigs={geoConfigs} /> ); @@ -176,7 +178,6 @@ function BubbleMapConfigurationPanel(props: MapTypeConfigPanelProps) { plugins={plugins} geoConfigs={geoConfigs} mapType="bubble" - setHideVizInputsAndControls={setHideVizInputsAndControls} setIsSidePanelExpanded={setIsSidePanelExpanded} /> ), @@ -297,7 +298,6 @@ function BubbleLegendsAndFloater(props: MapTypeMapLayerProps) { updateConfiguration, headerButtons, setStudyDetailsPanelConfig, - setHideVizInputsAndControls, } = props; const configuration = props.configuration as BubbleMarkerConfiguration; @@ -354,6 +354,15 @@ function BubbleLegendsAndFloater(props: MapTypeMapLayerProps) { substudyFilterFuncs ); + // use all the handlers except updateLegendPosition + const { + updateVisualizationPosition, + updateVisualizationDimensions, + onPanelDismiss, + setHideVizControl, + } = useFloatingPanelHandlers({ configuration, updateConfiguration }); + + // and use the two specialized ones for the legends const updateVariableLegendPosition = useCallback( (position: PanelConfig['position']) => { updateConfiguration({ @@ -380,43 +389,6 @@ function BubbleLegendsAndFloater(props: MapTypeMapLayerProps) { [updateConfiguration, configuration, legendPanelConfig] ); - const updateVisualizationPosition = useCallback( - (position: PanelConfig['position']) => { - updateConfiguration({ - ...configuration, - visualizationPanelConfig: { - ...visualizationPanelConfig, - position, - }, - }); - }, - [updateConfiguration, configuration, visualizationPanelConfig] - ); - - const updateVisualizationDimensions = useCallback( - (dimensions: PanelConfig['dimensions']) => { - updateConfiguration({ - ...configuration, - visualizationPanelConfig: { - ...visualizationPanelConfig, - dimensions, - }, - }); - }, - [updateConfiguration, configuration, visualizationPanelConfig] - ); - - const onPanelDismiss = useCallback(() => { - updateConfiguration({ - ...configuration, - activeVisualizationId: undefined, - visualizationPanelConfig: { - ...visualizationPanelConfig, - isVisible: false, - }, - }); - }, [updateConfiguration, configuration, visualizationPanelConfig]); - return ( <> {appState.studyDetailsPanelConfig?.isVisible && ( @@ -476,26 +448,30 @@ function BubbleLegendsAndFloater(props: MapTypeMapLayerProps) { )}
    - + {visualizationPanelConfig?.isVisible && ( + + )} ); } diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx index 8db240d16c..1f38cc1911 100755 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx @@ -52,6 +52,7 @@ import { getLegendErrorMessage, useSelectedMarkerSnackbars, selectedMarkersLittleFilter, + useFloatingPanelHandlers, } from '../shared'; import { MapTypeConfigPanelProps, @@ -72,7 +73,6 @@ import { MapTypeHeaderStudyDetails } from '../MapTypeHeaderStudyDetails'; import { SubStudies } from '../../SubStudies'; import { useLittleFilters } from '../../littleFilters'; import TimeSliderQuickFilter from '../../TimeSliderQuickFilter'; -import { PanelConfig } from '../../appState'; const displayName = 'Donuts'; @@ -95,7 +95,6 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { studyId, studyEntities, filters, - setHideVizInputsAndControls, setIsSidePanelExpanded, } = props; @@ -222,6 +221,7 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { analysisState.analysis?.descriptor.starredVariables ?? [] } toggleStarredVariable={toggleStarredVariable} + geoConfigs={geoConfigs} /> ); @@ -239,7 +239,7 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { }); const setActiveVisualizationId = useCallback( - (activeVisualizationId?: string) => { + (activeVisualizationId?: string, isNew?: boolean) => { if (configuration == null) return; updateConfiguration({ ...configuration, @@ -247,6 +247,7 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { visualizationPanelConfig: { ...configuration.visualizationPanelConfig, isVisible: !!activeVisualizationId, + ...(isNew ? { hideVizControl: false } : {}), }, }); }, @@ -273,7 +274,6 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { plugins={plugins} geoConfigs={geoConfigs} mapType="pie" - setHideVizInputsAndControls={setHideVizInputsAndControls} setIsSidePanelExpanded={setIsSidePanelExpanded} /> ), @@ -400,7 +400,6 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { filters, headerButtons, setStudyDetailsPanelConfig, - setHideVizInputsAndControls, } = props; const configuration = props.configuration as PieMarkerConfiguration; @@ -455,52 +454,13 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { const toggleStarredVariable = useToggleStarredVariable(props.analysisState); const noDataError = getLegendErrorMessage(data.error); - const updateLegendPosition = useCallback( - (position: PanelConfig['position']) => { - updateConfiguration({ - ...configuration, - legendPanelConfig: position, - }); - }, - [updateConfiguration, configuration] - ); - - const updateVisualizationPosition = useCallback( - (position: PanelConfig['position']) => { - updateConfiguration({ - ...configuration, - visualizationPanelConfig: { - ...visualizationPanelConfig, - position, - }, - }); - }, - [updateConfiguration, configuration, visualizationPanelConfig] - ); - - const updateVisualizationDimensions = useCallback( - (dimensions: PanelConfig['dimensions']) => { - updateConfiguration({ - ...configuration, - visualizationPanelConfig: { - ...visualizationPanelConfig, - dimensions, - }, - }); - }, - [updateConfiguration, configuration, visualizationPanelConfig] - ); - - const onPanelDismiss = useCallback(() => { - updateConfiguration({ - ...configuration, - activeVisualizationId: undefined, - visualizationPanelConfig: { - ...visualizationPanelConfig, - isVisible: false, - }, - }); - }, [updateConfiguration, configuration, visualizationPanelConfig]); + const { + updateLegendPosition, + updateVisualizationPosition, + updateVisualizationDimensions, + onPanelDismiss, + setHideVizControl, + } = useFloatingPanelHandlers({ configuration, updateConfiguration }); return ( <> @@ -536,25 +496,29 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { )} - + {visualizationPanelConfig?.isVisible && ( + + )} ); } diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx index b93a3b41d2..e1e07b823b 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx @@ -39,6 +39,15 @@ import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; import { NoDataError } from '../../../core/api/DataClient/NoDataError'; import { useCallback, useState } from 'react'; import useSnackbar from '@veupathdb/coreui/lib/components/notifications/useSnackbar'; +import { + BubbleLegendPositionConfig, + PanelConfig, + PanelPositionConfig, +} from '../appState'; +import { + findLeastAncestralGeoConfig, + getGeoConfig, +} from '../../../core/utils/geoVariables'; export const defaultAnimation = { method: 'geohash', @@ -63,6 +72,7 @@ export interface SharedMarkerConfigurations { selectedVariable: VariableDescriptor; activeVisualizationId?: string; selectedMarkers?: string[]; + geoEntityId?: string; } export function useCommonData( @@ -71,7 +81,10 @@ export function useCommonData( studyEntities: StudyEntity[], boundsZoomLevel?: BoundsViewport ) { - const geoConfig = geoConfigs[0]; + const geoConfig = findLeastAncestralGeoConfig( + geoConfigs, + selectedVariable.entityId + ); const { entity: overlayEntity, variable: overlayVariable } = findEntityAndVariable(studyEntities, selectedVariable) ?? {}; @@ -428,6 +441,93 @@ export function useSelectedMarkerSnackbars( ); } +/** + * DRY up floating visualization handlers + */ + +interface MinimalPanelConfig { + visualizationPanelConfig: PanelConfig; + legendPanelConfig: PanelPositionConfig | BubbleLegendPositionConfig; +} + +interface UseFloatingPanelHandlersProps { + configuration: M; + updateConfiguration: (configuration: M) => void; +} + +export function useFloatingPanelHandlers({ + updateConfiguration, + configuration, +}: UseFloatingPanelHandlersProps) { + const updateLegendPosition = useCallback( + (position: M['legendPanelConfig']) => { + updateConfiguration({ + ...configuration, + legendPanelConfig: position, + }); + }, + [updateConfiguration, configuration] + ); + + const updateVisualizationPosition = useCallback( + (position: PanelConfig['position']) => { + updateConfiguration({ + ...configuration, + visualizationPanelConfig: { + ...configuration.visualizationPanelConfig, + position, + }, + }); + }, + [updateConfiguration, configuration] + ); + + const updateVisualizationDimensions = useCallback( + (dimensions: PanelConfig['dimensions']) => { + updateConfiguration({ + ...configuration, + visualizationPanelConfig: { + ...configuration.visualizationPanelConfig, + dimensions, + }, + }); + }, + [updateConfiguration, configuration] + ); + + const onPanelDismiss = useCallback(() => { + updateConfiguration({ + ...configuration, + activeVisualizationId: undefined, + visualizationPanelConfig: { + ...configuration.visualizationPanelConfig, + isVisible: false, + }, + }); + }, [updateConfiguration, configuration]); + + const setHideVizControl = useCallback( + (hideValue?: boolean) => { + updateConfiguration({ + ...configuration, + visualizationPanelConfig: { + ...configuration.visualizationPanelConfig, + hideVizControl: hideValue, + }, + }); + }, + [updateConfiguration, configuration] + ); + + return { + updateLegendPosition, + updateVisualizationPosition, + updateVisualizationDimensions, + onPanelDismiss, + setHideVizControl, + }; +} + /** * little filter helpers */ @@ -452,10 +552,20 @@ export function timeSliderLittleFilter(props: UseLittleFiltersProps): Filter[] { export function viewportLittleFilters(props: UseLittleFiltersProps): Filter[] { const { - appState: { boundsZoomLevel }, + appState: { + boundsZoomLevel, + markerConfigurations, + activeMarkerConfigurationType, + }, geoConfigs, } = props; - const geoConfig = geoConfigs[0]; // not ideal... + + const { geoEntityId } = + markerConfigurations.find( + (markerConfig) => markerConfig.type === activeMarkerConfigurationType + ) ?? {}; + + const geoConfig = getGeoConfig(geoConfigs, geoEntityId); return boundsZoomLevel == null ? [] : filtersFromBoundingBox( @@ -606,7 +716,7 @@ export function selectedMarkersLittleFilter( // only return a filter if there are selectedMarkers if (selectedMarkers && selectedMarkers.length > 0) { const { entity, zoomLevelToAggregationLevel, aggregationVariableIds } = - geoConfigs[0]; + getGeoConfig(geoConfigs, activeMarkerConfiguration.geoEntityId); const geoAggregationVariableId = aggregationVariableIds?.[zoomLevelToAggregationLevel(zoom) - 1]; if (entity && geoAggregationVariableId) diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts index 404bd33fad..c28674f83a 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts @@ -24,8 +24,6 @@ export interface MapTypeConfigPanelProps { geoConfigs: GeoConfig[]; configuration: unknown; updateConfiguration: (configuration: unknown) => void; - hideVizInputsAndControls: boolean; - setHideVizInputsAndControls: (hide: boolean) => void; setIsSidePanelExpanded: (isExpanded: boolean) => void; } @@ -41,10 +39,6 @@ export interface MapTypeMapLayerProps { updateConfiguration: (configuration: unknown) => void; totalCounts: PromiseHookState; filteredCounts: PromiseHookState; - // TO DO: the hideVizInputsAndControls props are currently required - // and sent to plugin components that don't need it - we should also address this - hideVizInputsAndControls: boolean; - setHideVizInputsAndControls: (hide: boolean) => void; setSelectedMarkers?: React.Dispatch>; setStudyDetailsPanelConfig: (config: PanelConfig) => void; setTimeSliderConfig?: ( diff --git a/packages/libs/eda/src/lib/workspace/EDAWorkspaceHeading.tsx b/packages/libs/eda/src/lib/workspace/EDAWorkspaceHeading.tsx index 62c5f5623e..189d181030 100644 --- a/packages/libs/eda/src/lib/workspace/EDAWorkspaceHeading.tsx +++ b/packages/libs/eda/src/lib/workspace/EDAWorkspaceHeading.tsx @@ -47,6 +47,9 @@ export function EDAWorkspaceHeading({ const analysisId = getAnalysisId(analysis); const permissionsValue = usePermissions(); + + const studyAccess = getStudyAccess(studyRecord); + const showButtons = !permissionsValue.loading && Boolean( @@ -78,6 +81,8 @@ export function EDAWorkspaceHeading({ )}
    {!permissionsValue.loading && + !studyMetadata.isUserStudy && + studyAccess?.toLowerCase() !== 'public' && shouldOfferLinkToDashboard( permissionsValue.permissions, studyRecord.id[0].value diff --git a/packages/libs/eda/src/lib/workspace/Subsetting/SubsetDownloadModal.tsx b/packages/libs/eda/src/lib/workspace/Subsetting/SubsetDownloadModal.tsx index 64bf898eb4..f85cd7745c 100644 --- a/packages/libs/eda/src/lib/workspace/Subsetting/SubsetDownloadModal.tsx +++ b/packages/libs/eda/src/lib/workspace/Subsetting/SubsetDownloadModal.tsx @@ -122,18 +122,22 @@ export default function SubsetDownloadModal({ const theme = useUITheme(); const primaryColor = theme?.palette.primary.hue[theme.palette.primary.level]; - // In order to show a sortable preview we need a wide table of data. - // Wide tables can only be up to 1000 columns. So if there are at least 1000 - // vars, (1) do not ask for the wide table and (2) tell the user that we can't - // show a preview but everything is okay. - const canLoadTablePreview = currentEntity.variables.length < 1000; - // Various Custom Hooks const studyRecord = useStudyRecord(); const studyMetadata = useStudyMetadata(); const subsettingClient = useSubsettingClient(); const featuredFields = useFeaturedFields(entities, 'download'); + // In order to show a sortable preview we need a wide table of data. + // Wide tables can only be up to 1000 columns. So if there are at least 1000 + // vars, (1) do not ask for the wide table and (2) tell the user that we can't + // show a preview but everything is okay. + // TEMP: we also don't have wide tables for user studies; so let's apply the same + // UI for user studies + const { isUserStudy } = studyMetadata; + const canLoadTablePreview = + currentEntity.variables.length < 1000 && !isUserStudy; + const scopedFeaturedFields = useMemo( () => featuredFields.filter((field) => diff --git a/packages/libs/study-data-access/src/study-access/permission.ts b/packages/libs/study-data-access/src/study-access/permission.ts index 12854af618..50f9054b15 100644 --- a/packages/libs/study-data-access/src/study-access/permission.ts +++ b/packages/libs/study-data-access/src/study-access/permission.ts @@ -84,12 +84,9 @@ export function canAccessDashboard( export function shouldOfferLinkToDashboard( userPermissions: UserPermissions, - datasetId?: string + datasetId: string ) { - return ( - isOwner(userPermissions) || - (datasetId != null && isManager(userPermissions, datasetId)) - ); + return isOwner(userPermissions) || isManager(userPermissions, datasetId); } export function shouldDisplayStaffTable(userPermissions: UserPermissions) { diff --git a/packages/libs/user-datasets/src/lib/Components/Detail/UserDatasetDetail.jsx b/packages/libs/user-datasets/src/lib/Components/Detail/UserDatasetDetail.jsx index 6d93947b55..4d29e7bfeb 100644 --- a/packages/libs/user-datasets/src/lib/Components/Detail/UserDatasetDetail.jsx +++ b/packages/libs/user-datasets/src/lib/Components/Detail/UserDatasetDetail.jsx @@ -14,9 +14,7 @@ import { bytesToHuman } from '@veupathdb/wdk-client/lib/Utils/Converters'; import NotFound from '@veupathdb/wdk-client/lib/Views/NotFound/NotFound'; import SharingModal from '../Sharing/UserDatasetSharingModal'; -import UserDatasetStatus, { - failedImportAndInstallStatuses, -} from '../UserDatasetStatus'; +import UserDatasetStatus from '../UserDatasetStatus'; import { makeClassifier, normalizePercentage } from '../UserDatasetUtils'; import { ThemedGrantAccessButton } from '../ThemedGrantAccessButton'; import { ThemedDeleteButton } from '../ThemedDeleteButton'; @@ -529,14 +527,7 @@ class UserDatasetDetail extends React.Component { installStatusForCurrentProject?.metaStatus, installStatusForCurrentProject?.dataStatus, ].every((status) => status === 'complete'); - const hasFailed = [ - userDataset.status.import, - installStatusForCurrentProject?.metaStatus, - installStatusForCurrentProject?.dataStatus, - ].some((status) => failedImportAndInstallStatuses.includes(status)); - const failedImport = - status.import === 'failed' || status.import === 'invalid'; const isIncompatible = installStatusForCurrentProject?.dataStatus === 'missing-dependency'; @@ -557,22 +548,10 @@ class UserDatasetDetail extends React.Component { {projectId}. It is installed for use.

    ); - } else if (hasFailed) { - return ( - // if we're installable but failed import or install, let's tell user -

    - This {dataNoun.singular.toLowerCase()} failed to{' '} - {failedImport ? 'upload' : 'install'}. -

    - ); } else { - return ( - // if we've made it here, we're installable and either import or install is in progress -

    - This {dataNoun.singular.toLowerCase()} is being processed. Please - check again soon. -

    - ); + // instead of attempting to provide very granular messaging for when things are neither + // compatible nor incompatible, let's let the dataset page's Status messaging handle this + return null; } } diff --git a/packages/libs/user-datasets/src/lib/Components/UploadForm.tsx b/packages/libs/user-datasets/src/lib/Components/UploadForm.tsx index 58405f6572..3e5bf1c38c 100644 --- a/packages/libs/user-datasets/src/lib/Components/UploadForm.tsx +++ b/packages/libs/user-datasets/src/lib/Components/UploadForm.tsx @@ -7,9 +7,10 @@ import React, { useState, } from 'react'; +import { Link } from 'react-router-dom'; + import { keyBy } from 'lodash'; -import Icon from '@veupathdb/wdk-client/lib/Components/Icon/IconAlt'; import { TextBox, TextArea, @@ -31,6 +32,7 @@ import { } from '../Utils/types'; import { Modal } from '@veupathdb/coreui'; +import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; import './UploadForm.scss'; @@ -380,6 +382,18 @@ function UploadForm({ {errorMessages.length > 0 && }

    {datasetUploadType.uploadTitle}

    + + Before uploading your dataset, please ensure your data is + formatted according to the instructions listed in the{' '} + "Help" tab. + + ), + }} + />
    Name @@ -539,17 +553,19 @@ function FieldLabel({ children, required, ...labelProps }: FieldLabelProps) { function ErrorMessage({ errors }: { errors: string[] }) { return ( -
    -
    - -   Could not upload data set -
    - {errors.map((error, ix) => ( -
    - {error} -
    - ))} -
    + + Could not upload data set + {errors.map((error, index) => ( +
    {error}
    + ))} +
    + ), + }} + /> ); } diff --git a/packages/libs/user-datasets/src/lib/Components/UserDatasetStatus.tsx b/packages/libs/user-datasets/src/lib/Components/UserDatasetStatus.tsx index 7014f8621d..862ea637e3 100644 --- a/packages/libs/user-datasets/src/lib/Components/UserDatasetStatus.tsx +++ b/packages/libs/user-datasets/src/lib/Components/UserDatasetStatus.tsx @@ -17,14 +17,6 @@ interface Props { dataNoun: DataNoun; } -export const failedImportAndInstallStatuses = [ - 'invalid', - 'failed', - 'failed-validation', - 'failed-installation', - 'ready-for-reinstall', -]; - const orderedStatuses = [ 'failed-validation', 'missing-dependency', diff --git a/packages/libs/user-datasets/src/lib/Controllers/UserDatasetNewUploadController.tsx b/packages/libs/user-datasets/src/lib/Controllers/UserDatasetNewUploadController.tsx index e2ade1f302..22405042b0 100644 --- a/packages/libs/user-datasets/src/lib/Controllers/UserDatasetNewUploadController.tsx +++ b/packages/libs/user-datasets/src/lib/Controllers/UserDatasetNewUploadController.tsx @@ -101,7 +101,9 @@ export default function UserDatasetUploadController({ // callback to redirect to new dataset page (datasetId: typeof datasetIdType) => baseUrl && - transitioner.transitionToInternalPage(`${baseUrl}/${datasetId}`) + transitioner.transitionToInternalPage(`${baseUrl}/${datasetId}`), + // callback to handle bad uploads + (error: string) => dispatch(receiveBadUpload(error)) ); return requestUploadMessages(); } catch (err) { diff --git a/packages/libs/user-datasets/src/lib/Service/api.ts b/packages/libs/user-datasets/src/lib/Service/api.ts index d6a2350b37..ece062f0fc 100644 --- a/packages/libs/user-datasets/src/lib/Service/api.ts +++ b/packages/libs/user-datasets/src/lib/Service/api.ts @@ -65,7 +65,8 @@ export class UserDatasetApi extends FetchClientWithCredentials { addUserDataset = async ( formSubmission: FormSubmission, dispatchUploadProgress?: (progress: number | null) => void, - dispatchPageRedirect?: (datasetId: typeof datasetIdType) => void + dispatchPageRedirect?: (datasetId: typeof datasetIdType) => void, + dispatchBadUpload?: (error: string) => void ) => { const newUserDatasetConfig = await makeNewUserDatasetConfig( this.wdkService, @@ -90,7 +91,7 @@ export class UserDatasetApi extends FetchClientWithCredentials { }); xhr.addEventListener('readystatechange', () => { - if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { try { const response = JSON.parse(xhr.response); dispatchUploadProgress && dispatchUploadProgress(null); @@ -100,6 +101,12 @@ export class UserDatasetApi extends FetchClientWithCredentials { console.error(error); } } + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status >= 400) { + const error = new Error(xhr.response); + dispatchUploadProgress && dispatchUploadProgress(null); + dispatchBadUpload && dispatchBadUpload(String(error)); + console.log(error); + } }); const fileBody = new FormData(); diff --git a/packages/libs/user-datasets/src/lib/Utils/upload-config.tsx b/packages/libs/user-datasets/src/lib/Utils/upload-config.tsx index 1aa1a2153b..5cfa584c72 100644 --- a/packages/libs/user-datasets/src/lib/Utils/upload-config.tsx +++ b/packages/libs/user-datasets/src/lib/Utils/upload-config.tsx @@ -14,8 +14,7 @@ export const uploadTypeConfig: DatasetUploadTypeConfig =

    We accept any file in the{' '} BIOM format, either JSON-based - (BIOM 1.0) or HDF5 (BIOM 2.0+). The maximum allowed file size is 10 - MB. + (BIOM 1.0) or HDF5 (BIOM 2.0+).

    If possible, try including taxonomic information and rich sample @@ -26,12 +25,12 @@ export const uploadTypeConfig: DatasetUploadTypeConfig = ), uploadMethodConfig: { file: { - maxSizeBytes: 1e7, // 10 megabytes + maxSizeBytes: 100 * 1000 * 1000, // 100MB render: ({ fieldNode }) => ( <> {fieldNode}

    - File must be 10 MB or smaller. + File must be less than 100MB
    ), diff --git a/packages/libs/wdk-client/src/Service/Decoders/QuestionDecoders.ts b/packages/libs/wdk-client/src/Service/Decoders/QuestionDecoders.ts index 6c5a49d145..1c181bb5d8 100644 --- a/packages/libs/wdk-client/src/Service/Decoders/QuestionDecoders.ts +++ b/packages/libs/wdk-client/src/Service/Decoders/QuestionDecoders.ts @@ -64,7 +64,8 @@ const paramSharedDecoder = ), Decode.combine( Decode.field('allowEmptyValue', Decode.boolean), - Decode.field('visibleHelp', Decode.optional(Decode.string)) + Decode.field('visibleHelp', Decode.optional(Decode.string)), + Decode.field('visibleHelpPosition', Decode.optional(Decode.string)) ) ); @@ -326,6 +327,7 @@ const questionSharedDecoder = Decode.combine( Decode.field('reviseBuild', Decode.optional(Decode.string)) ), Decode.combine( + Decode.field('searchVisibleHelp', Decode.optional(Decode.string)), Decode.field('urlSegment', Decode.string), Decode.field('groups', Decode.arrayOf(paramGroupDecoder)), Decode.field('defaultAttributes', Decode.arrayOf(Decode.string)), diff --git a/packages/libs/wdk-client/src/Utils/WdkModel.ts b/packages/libs/wdk-client/src/Utils/WdkModel.ts index 4d04d0454f..84f93c4134 100644 --- a/packages/libs/wdk-client/src/Utils/WdkModel.ts +++ b/packages/libs/wdk-client/src/Utils/WdkModel.ts @@ -60,6 +60,7 @@ interface ParameterBase extends NamedModelEntity { dependentParams: string[]; allowEmptyValue: boolean; visibleHelp?: string; + visibleHelpPosition?: string; } export interface StringParam extends ParameterBase { @@ -273,6 +274,7 @@ export interface Question extends UrlModelEntity { queryName?: string; isCacheable: boolean; isBeta?: boolean; + searchVisibleHelp?: string; } export interface QuestionWithParameters extends Question { diff --git a/packages/libs/wdk-client/src/Views/Question/DefaultQuestionForm.scss b/packages/libs/wdk-client/src/Views/Question/DefaultQuestionForm.scss index 42985b66e5..02a519d687 100644 --- a/packages/libs/wdk-client/src/Views/Question/DefaultQuestionForm.scss +++ b/packages/libs/wdk-client/src/Views/Question/DefaultQuestionForm.scss @@ -42,6 +42,15 @@ padding-bottom: 0.5rem; } + &ParameterControlContainer { + &_VisibleHelpRight { + display: grid; + align-items: center; + grid-template-columns: auto auto; + grid-template-rows: auto; + } + } + &ParameterHeading { h2 { font-size: 1.6em; @@ -55,6 +64,17 @@ &ParameterControl { padding: 1.3rem; + .ControlLeft { + grid-column: 1; + grid-row: 1; + } + } + + &VisibleHelpContainer { + &_Right { + grid-column: 2; + grid-row: 1; + } } &VisibleHelp { diff --git a/packages/libs/wdk-client/src/Views/Question/DefaultQuestionForm.tsx b/packages/libs/wdk-client/src/Views/Question/DefaultQuestionForm.tsx index c89d5a2424..8ba8d506ad 100644 --- a/packages/libs/wdk-client/src/Views/Question/DefaultQuestionForm.tsx +++ b/packages/libs/wdk-client/src/Views/Question/DefaultQuestionForm.tsx @@ -273,7 +273,9 @@ export function QuestionHeader(props: QuestionHeaderProps) { return props.showHeader ? (

    - {props.headerText} {props.isBeta && } + {/** NOTE: Remove the WGCNA hardcoding when appropriate */} + {props.headerText}{' '} + {(props.isBeta || props.headerText.includes('WGCNA')) && }

    ) : ( @@ -330,12 +332,24 @@ export function DefaultGroup(props: DefaultGroupProps) { uiState={uiState} onVisibilityChange={onVisibilityChange} > - + <> + {question.searchVisibleHelp !== undefined && ( + + )} + + ); } @@ -422,18 +436,55 @@ export function ParameterList(props: ParameterListProps) { !!paramDependenciesUpdating[parameter.name] } /> - {parameter.visibleHelp !== undefined && ( - - )} -
    - {parameterElements[parameter.name]} +
    + {parameter.visibleHelp !== undefined && ( +
    + +
    + )} +
    + {parameterElements[parameter.name]} +
    ))} 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 17e7f0c201..ecaf3d296a 100644 --- a/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTable.jsx +++ b/packages/libs/wdk-client/src/Views/Records/RecordTable/RecordTable.jsx @@ -113,8 +113,9 @@ class RecordTable extends Component { const displayableAttributes = this.getDisplayableAttributes(this.props); const columns = this.getColumns(this.props); const data = this.getOrderedData(this.props); - const isOrthologTable = this.props.table.name === 'Orthologs'; - const clustalInputRow = isOrthologTable + const isOrthologTableWithData = + this.props.table.name === 'Orthologs' && value.length > 0; + const clustalInputRow = isOrthologTableWithData ? columns.find((c) => c.name === 'clustalInput') : undefined; @@ -239,7 +240,7 @@ class RecordTable extends Component { eventHandlers: { onSort: this.onSort, onExpandedRowsChange, - ...(isOrthologTable + ...(isOrthologTableWithData ? { ...this.props.orthoTableProps.eventHandlers } : {}), }, @@ -254,7 +255,7 @@ class RecordTable extends Component { className: 'wdk-DataTableContainer', getRowId: getSortIndex, showCount: mesaReadyRows.length > 1, - ...(isOrthologTable + ...(isOrthologTableWithData ? { ...this.props.orthoTableProps.options, selectColumnHeadingDetails: { @@ -264,7 +265,7 @@ class RecordTable extends Component { } : {}), }, - ...(isOrthologTable + ...(isOrthologTableWithData ? { actions: this.props.orthoTableProps.actions, } diff --git a/packages/libs/web-common/package.json b/packages/libs/web-common/package.json index 6a0394040e..c6cbc217eb 100644 --- a/packages/libs/web-common/package.json +++ b/packages/libs/web-common/package.json @@ -55,7 +55,6 @@ "@veupathdb/react-scripts": "workspace:^", "@veupathdb/study-data-access": "workspace:^", "@veupathdb/user-datasets": "workspace:^", - "@veupathdb/user-datasets-legacy": "workspace:^", "bubleify": "^2.0.0", "icon-font-generator": "^2.1.11", "ify-loader": "^1.1.0", diff --git a/packages/libs/web-common/src/bootstrap.js b/packages/libs/web-common/src/bootstrap.js index 5ebdeb64ab..f5d2018dbd 100644 --- a/packages/libs/web-common/src/bootstrap.js +++ b/packages/libs/web-common/src/bootstrap.js @@ -69,6 +69,7 @@ export function initialize(options = {}) { additionalMiddleware, } = options; + fixForwardSlashes(); unaliasWebappUrl(); removeJsessionid(); preventButtonOutlineOnClick(); @@ -103,6 +104,14 @@ export function initialize(options = {}) { return context; } +function fixForwardSlashes() { + const { pathname } = window.location; + if (pathname.includes('//') || pathname.endsWith('/')) { + const newPathname = (pathname + '/').replace(/[/]+/g, '/').slice(0, -1); + window.history.replaceState(null, '', newPathname); + } +} + /** * Replace apache alaias `/a` with the webapp url. */ diff --git a/packages/libs/web-common/src/components/Announcements.jsx b/packages/libs/web-common/src/components/Announcements.jsx index 873bea0637..88f049f76d 100644 --- a/packages/libs/web-common/src/components/Announcements.jsx +++ b/packages/libs/web-common/src/components/Announcements.jsx @@ -241,9 +241,133 @@ const siteAnnouncements = [ }, */ + //VectorBase, fuscipes: https://redmine.apidb.org/issues/53163 b68 1 year (April 2024 to April 2025): + { + id: 'fuscipes', + renderDisplay: (props) => { + if ( + (props.projectId == 'VectorBase' || props.projectId == 'EuPathDB') && + (props.location.pathname.indexOf('/record/dataset/TMPTX_gfusIAEA2018') > + -1 || + props.location.pathname.indexOf('/record/dataset/DS_c3e414cbf3') > + -1 || + props.location.pathname.indexOf('/record/gene/GQX74_') > -1 || + props.location.pathname.indexOf('/record/dataset/TMPTX_gfusIAEA') > + -1 || + props.location.pathname.indexOf('/record/dataset/DS_2f0c4b9ff0') > + -1 || + props.location.pathname.indexOf('/record/gene/GFUI0') > -1) + ) { + return ( +
    + + Glossina fuscipes IAEA 2018 + {' '} + is the new reference genome for this tsetse fly species, + which means the 'omics' data sets are only aligned to this strain + and all the site functionality is activated.{' '} + + Glossina fuscipes IAEA + {' '} + will remain available on VectorBase, but with limited functionality. + Please{' '} + + contact us + {' '} + if you have any questions, suggestions or feedback. +
    + ); + } + return null; + }, + }, + + //VectorBase, sinensis: https://redmine.apidb.org/issues/53172 b68 for 1 year (Nov 2023 to Nov 2024): + { + id: 'sinensis', + renderDisplay: (props) => { + if ( + (props.projectId == 'VectorBase' || props.projectId == 'EuPathDB') && + (props.location.pathname.indexOf('/record/dataset/TMPTX_asinChina') > + -1 || + props.location.pathname.indexOf('/record/dataset/DS_e7fe24aea7') > + -1 || + props.location.pathname.indexOf('/record/gene/ASIC0') > -1 || + props.location.pathname.indexOf( + '/record/dataset/TMPTX_asinSINENSIS' + ) > -1 || + props.location.pathname.indexOf('/record/dataset/DS_4011a1b1a3') > + -1 || + props.location.pathname.indexOf('/record/gene/ASIS0') > -1) + ) { + return ( +
    + + Anopheles sinensis China + {' '} + is the new reference genome for this mosquito species, which + means the 'omics' data sets are only aligned to this strain and all + the site functionality is activated.{' '} + + Anopheles sinensis SINENSIS + {' '} + will remain available on VectorBase, but with limited functionality. + Please{' '} + + contact us + {' '} + if you have any questions, suggestions or feedback. +
    + ); + } + return null; + }, + }, + + //VectorBase, glabrata: https://redmine.apidb.org/issues/53159 b68 for 1 year (April 2024 to April 2025) + { + id: 'glabrata', + renderDisplay: (props) => { + if ( + (props.projectId == 'VectorBase' || props.projectId == 'EuPathDB') && + (props.location.pathname.indexOf('/record/dataset/TMPTX_bglaXG47') > + -1 || + props.location.pathname.indexOf('/record/dataset/DS_b56fe6b141') > + -1 || + props.location.pathname.indexOf('/record/gene/BGLAX_') > -1 || + props.location.pathname.indexOf('/record/dataset/TMPTX_bglaBB02') > + -1 || + props.location.pathname.indexOf('/record/dataset/DS_6841b75d56') > + -1 || + props.location.pathname.indexOf('/record/gene/BGLB0') > -1) + ) { + return ( +
    + + Biomphalaria glabrata XG47 + {' '} + is the new reference genome for this snail species, which + means the 'omics' data sets are only aligned to this strain and all + the site functionality is activated.{' '} + + Biomphalaria glabrata BB02 + {' '} + will remain available on VectorBase, but with limited functionality. + Please{' '} + + contact us + {' '} + if you have any questions, suggestions or feedback. +
    + ); + } + return null; + }, + }, + //VectorBase, aziemanni: https://redmine.apidb.org/issues/53767 b68 { - id: 'aziemanni ', + id: 'aziemanni', renderDisplay: (props) => { if ( (props.projectId == 'VectorBase' || props.projectId == 'EuPathDB') && @@ -281,7 +405,9 @@ const siteAnnouncements = [ }, }, - //VectorBase, aquasalis: https://redmine.apidb.org/issues/53436 Jan 18 2024 -patched prod 66- for a year + //VectorBase, aquasalis: https://redmine.apidb.org/issues/53436 + // Jan 18 2024 -patched prod 66- for a year + // b68 may 1 2024: for 1 year (Jan 2024 to Jan 2025) or until fixed by data provider { id: 'aquasalis', renderDisplay: (props) => { @@ -298,9 +424,9 @@ const siteAnnouncements = [ Anopheles aquasalis AaquGF1 {' '} - has a confusion in chromosome nomenclature. The current X and Y - designations should be replaced with XL (X long arm) and XR (X short - arm), respectively. While the matter is being addressed in the + has a confusion in chromosome nomenclature. The current X and + Y designations should be replaced with XL (X long arm) and XR (X + short arm), respectively. While the matter is being addressed in the primary databases/INSDC and will subsequently be handled at VectorBase, feel free to{' '} diff --git a/packages/libs/web-common/src/components/homepage/Header.tsx b/packages/libs/web-common/src/components/homepage/Header.tsx index 8af802ef55..1ba3592a84 100644 --- a/packages/libs/web-common/src/components/homepage/Header.tsx +++ b/packages/libs/web-common/src/components/homepage/Header.tsx @@ -374,7 +374,7 @@ const HeaderMenuItemContent = ({ focusType={focusType} setSelectedItems={setSelectedItems} setFocusType={setFocusType} - dismissSubmenus={dismissSubmenus} + dismissSubmenus={() => null} />
    diff --git a/packages/libs/web-common/src/hooks/diyStudySummaries.tsx b/packages/libs/web-common/src/hooks/diyStudySummaries.tsx index d79a7c9d09..88c5af9c8f 100644 --- a/packages/libs/web-common/src/hooks/diyStudySummaries.tsx +++ b/packages/libs/web-common/src/hooks/diyStudySummaries.tsx @@ -6,8 +6,7 @@ import { keyBy } from 'lodash'; import { Link } from '@veupathdb/wdk-client/lib/Components'; import { useWdkService } from '@veupathdb/wdk-client/lib/Hooks/WdkServiceHook'; -// import { assertIsVdiCompatibleWdkService } from '@veupathdb/user-datasets/lib/Service'; -import { assertIsUserDatasetCompatibleWdkService } from '@veupathdb/user-datasets-legacy/lib/Service/UserDatasetWrappers'; +import { assertIsVdiCompatibleWdkService } from '@veupathdb/user-datasets/lib/Service'; import { useDiyDatasets } from './diyDatasets'; @@ -71,7 +70,7 @@ export function useDiyStudySummaryRows(): UserStudySummaryRow[] | undefined { const currentUserDatasets = useWdkService( async (wdkService) => { - assertIsUserDatasetCompatibleWdkService(wdkService); + assertIsVdiCompatibleWdkService(wdkService); if (currentUser == null) { return undefined; } @@ -95,7 +94,10 @@ export function useDiyStudySummaryRows(): UserStudySummaryRow[] | undefined { return undefined; } - const currentUserDatasetsById = keyBy(currentUserDatasets, ({ id }) => id); + const currentUserDatasetsById = keyBy( + currentUserDatasets, + (ud) => ud.datasetId + ); return diyDatasets.flatMap((diyDataset) => { const userDataset = currentUserDatasetsById[diyDataset.userDatasetId]; @@ -109,15 +111,12 @@ export function useDiyStudySummaryRows(): UserStudySummaryRow[] | undefined { name: diyDataset.name, userDatasetWorkspaceUrl: diyDataset.userDatasetsRoute, edaWorkspaceUrl: `${diyDataset.baseEdaRoute}/new`, - summary: userDataset.meta.summary ?? '', + summary: userDataset.summary ?? '', owner: - userDataset.ownerUserId === currentUser.id + userDataset.owner.userId === currentUser.id ? 'Me' - : userDataset.owner, - sharedWith: - userDataset.sharedWith - ?.map(({ userDisplayName }) => userDisplayName) - ?.join(', ') ?? '', + : formatUser(userDataset.owner), + sharedWith: userDataset.shares?.map(formatUser)?.join(', ') ?? '', }, ]; }); @@ -125,3 +124,16 @@ export function useDiyStudySummaryRows(): UserStudySummaryRow[] | undefined { return userStudySummaryRows; } + +function formatUser(user: { + firstName?: string; + lastName?: string; + organization?: string; +}) { + const { firstName, lastName, organization } = user; + const name = + firstName == null && lastName == null + ? 'Unknown user' + : `${firstName} ${lastName}`; + return name + (organization ? `(${organization})` : ''); +} diff --git a/packages/sites/clinepi-site/package.json b/packages/sites/clinepi-site/package.json index 766260009a..d7ddfb01a6 100644 --- a/packages/sites/clinepi-site/package.json +++ b/packages/sites/clinepi-site/package.json @@ -43,7 +43,7 @@ "@veupathdb/site-tsconfig": "workspace:^", "@veupathdb/site-webpack-config": "workspace:^", "@veupathdb/study-data-access": "workspace:^", - "@veupathdb/user-datasets-legacy": "workspace:^", + "@veupathdb/user-datasets": "workspace:^", "@veupathdb/wdk-client": "workspace:^", "@veupathdb/web-common": "workspace:^", "babel-loader": "^8.3.0", diff --git a/packages/sites/clinepi-site/webapp/js/client/controllers/UserDatasetRouter.tsx b/packages/sites/clinepi-site/webapp/js/client/controllers/UserDatasetRouter.tsx index 2d5c0b940c..161b669cc0 100644 --- a/packages/sites/clinepi-site/webapp/js/client/controllers/UserDatasetRouter.tsx +++ b/packages/sites/clinepi-site/webapp/js/client/controllers/UserDatasetRouter.tsx @@ -1 +1 @@ -export { UserDatasetRouter as default } from '@veupathdb/user-datasets-legacy/lib/Controllers/UserDatasetRouter'; +export { UserDatasetRouter as default } from '@veupathdb/user-datasets/lib/Controllers/UserDatasetRouter'; diff --git a/packages/sites/clinepi-site/webapp/js/client/routes/userDatasetRoutes.tsx b/packages/sites/clinepi-site/webapp/js/client/routes/userDatasetRoutes.tsx index 6b0c1ab044..a0fb273a04 100644 --- a/packages/sites/clinepi-site/webapp/js/client/routes/userDatasetRoutes.tsx +++ b/packages/sites/clinepi-site/webapp/js/client/routes/userDatasetRoutes.tsx @@ -8,9 +8,9 @@ import { RouteEntry } from '@veupathdb/wdk-client/lib/Core/RouteEntry'; import { makeEdaRoute, makeMapRoute } from '@veupathdb/web-common/lib/routes'; import { diyUserDatasetIdToWdkRecordId } from '@veupathdb/wdk-client/lib/Utils/diyDatasets'; -import { UserDatasetDetailProps } from '@veupathdb/user-datasets-legacy/lib/Controllers/UserDatasetDetailController'; +import { UserDatasetDetailProps } from '@veupathdb/user-datasets/lib/Controllers/UserDatasetDetailController'; -import { uploadTypeConfig } from '@veupathdb/user-datasets-legacy/lib/Utils/upload-config'; +import { uploadTypeConfig } from '@veupathdb/user-datasets/lib/Utils/upload-config'; import { communitySite, @@ -25,9 +25,7 @@ import { useStudyMetadata } from '@veupathdb/eda/lib/core/hooks/study'; const IsaDatasetDetail = React.lazy( () => - import( - '@veupathdb/user-datasets-legacy/lib/Components/Detail/IsaDatasetDetail' - ) + import('@veupathdb/user-datasets/lib/Components/Detail/IsaDatasetDetail') ); const UserDatasetRouter = React.lazy( @@ -58,7 +56,9 @@ export const userDatasetRoutes: RouteEntry[] = [ const detailComponentsByTypeName = useMemo( () => ({ - ISA: function ClinEpiIsaDatasetDetail(props: UserDatasetDetailProps) { + isasimple: function ClinEpiIsaDatasetDetail( + props: UserDatasetDetailProps + ) { const wdkDatasetId = diyUserDatasetIdToWdkRecordId( props.userDataset.id ); @@ -100,6 +100,11 @@ export const userDatasetRoutes: RouteEntry[] = [ ]; function useEdaStudyMetadata(wdkDatasetId: string) { - const subsettingClient = useConfiguredSubsettingClient(edaServiceUrl); - return useStudyMetadata(wdkDatasetId, subsettingClient); + try { + const subsettingClient = useConfiguredSubsettingClient(edaServiceUrl); + return useStudyMetadata(wdkDatasetId, subsettingClient); + } catch (error) { + console.error(error); + return undefined; + } } diff --git a/packages/sites/clinepi-site/webapp/js/client/wrapStoreModules.js b/packages/sites/clinepi-site/webapp/js/client/wrapStoreModules.js index 8980a1143a..5d7ca05a1c 100644 --- a/packages/sites/clinepi-site/webapp/js/client/wrapStoreModules.js +++ b/packages/sites/clinepi-site/webapp/js/client/wrapStoreModules.js @@ -2,7 +2,7 @@ import { compose, identity, set } from 'lodash/fp'; import { useUserDatasetsWorkspace } from '@veupathdb/web-common/lib/config'; -import { wrapStoreModules as addUserDatasetStoreModules } from '@veupathdb/user-datasets-legacy/lib/StoreModules'; +import { wrapStoreModules as addUserDatasetStoreModules } from '@veupathdb/user-datasets/lib/StoreModules'; import * as accessRequest from './store-modules/AccessRequestStoreModule'; import * as record from './store-modules/RecordStoreModule'; diff --git a/packages/sites/clinepi-site/webapp/js/client/wrapWdkService.ts b/packages/sites/clinepi-site/webapp/js/client/wrapWdkService.ts index 2f228ded3e..4663b01eff 100644 --- a/packages/sites/clinepi-site/webapp/js/client/wrapWdkService.ts +++ b/packages/sites/clinepi-site/webapp/js/client/wrapWdkService.ts @@ -1,20 +1,16 @@ import { flowRight, identity, partial } from 'lodash'; import { - endpoint, - datasetImportUrl, useUserDatasetsWorkspace, - // vdiServiceUrl, + vdiServiceUrl, } from '@veupathdb/web-common/lib/config'; -import { wrapWdkService as addUserDatasetServices } from '@veupathdb/user-datasets-legacy/lib/Service'; +import { wrapWdkService as addUserDatasetServices } from '@veupathdb/user-datasets/lib/Service'; export default flowRight( useUserDatasetsWorkspace ? partial(addUserDatasetServices, { - datasetImportUrl, - fullWdkServiceUrl: `${window.location.origin}${endpoint}`, - // vdiServiceUrl, + vdiServiceUrl, }) : identity ); diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx index 51cf00cce0..a2b95abdfb 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx @@ -49,6 +49,7 @@ import { import { useUserDatasetsWorkspace, edaServiceUrl, + showUnreleasedData, } from '@veupathdb/web-common/lib/config'; import { useAnnouncementsState } from '@veupathdb/web-common/lib/hooks/announcements'; import { useCommunitySiteRootUrl } from '@veupathdb/web-common/lib/hooks/staticData'; @@ -63,13 +64,14 @@ import { Props as PageProps } from '@veupathdb/wdk-client/lib/Components/Layout/ import { PageDescription } from './PageDescription'; import { makeVpdbClassNameHelper } from './Utils'; -import './VEuPathDBHomePage.scss'; import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; import SubsettingClient from '@veupathdb/eda/lib/core/api/SubsettingClient'; import { WdkDependenciesContext } from '@veupathdb/wdk-client/lib/Hooks/WdkDependenciesEffect'; import { useNonNullableContext } from '@veupathdb/wdk-client/lib/Hooks/NonNullableContext'; import { Question } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; -import { Warning } from '@veupathdb/coreui'; +import { Tooltip, Warning } from '@veupathdb/coreui'; + +import './VEuPathDBHomePage.scss'; const vpdbCx = makeVpdbClassNameHelper(''); @@ -610,19 +612,6 @@ const useHeaderMenuItems = ( type: 'reactRoute', url: '/fasta-tool', }, - { - key: 'study-explorer', - display: ( - <> - WGCNA Study explorer NEW - - ), - type: 'reactRoute', - url: '/workspace/analyses/DS_82dc5abc7f/new', - metadata: { - include: [PlasmoDB, HostDB, EuPathDB, UniDB], - }, - }, { key: 'webservices', display: 'Web services', @@ -1205,18 +1194,44 @@ function useMapMenuItems(question?: Question) { if (question == null || studyAccessApi == null) return; getWdkStudyRecords( { studyAccessApi, subsettingClient, wdkService }, - { searchName: question.urlSegment } + { + searchName: question.urlSegment, + attributes: ['is_public'], + // hasMap: true, + } ).then( (records) => { - const menuItems = records.map( - (record): HeaderMenuItemEntry => ({ - key: `map-${record.id[0].value}`, - display: record.displayName, - type: 'reactRoute', - url: `/workspace/maps/${record.id[0].value}/new`, - }) - ); - setMapMenuItems(menuItems); + const menuItems = records + .filter( + (record) => + record.attributes.is_public === 'true' || showUnreleasedData + ) + .map( + (record): HeaderMenuItemEntry => ({ + key: `map-${record.id[0].value}`, + display: + record.attributes.is_public === 'true' ? ( + record.displayName + ) : ( + +
    + 🚧 {record.displayName} +
    +
    + ), + type: 'reactRoute', + url: `/workspace/maps/${record.id[0].value}/new`, + }) + ); + if (menuItems.length > 0) setMapMenuItems(menuItems); + else + setMapMenuItems([ + { + key: 'map-empty', + type: 'custom', + display: 'No map datasets found', + }, + ]); }, (error) => { console.error(error); diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/InternalGeneDataset.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/InternalGeneDataset.tsx index c8cf469200..cd139a6ebb 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/InternalGeneDataset.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/InternalGeneDataset.tsx @@ -9,7 +9,12 @@ import React, { import { useSelector } from 'react-redux'; import { useLocation, useHistory } from 'react-router'; -import { Loading, Link, HelpIcon } from '@veupathdb/wdk-client/lib/Components'; +import { + Loading, + Link, + HelpIcon, + BetaIcon, +} from '@veupathdb/wdk-client/lib/Components'; import { TabbedDisplay, Tooltip } from '@veupathdb/coreui'; import { CommonResultTable as InternalGeneDatasetTable } from '@veupathdb/wdk-client/lib/Components/Shared/CommonResultTable'; import { useIsRefOverflowingVertically } from '@veupathdb/wdk-client/lib/Hooks/Overflow'; @@ -270,7 +275,7 @@ function InternalGeneDatasetContent(props: Props) { />
    Legend: -
    +
    {displayCategoryOrder.map((categoryName) => ( } > - +
    {displayCategoriesByName[categoryName].shortDisplayName} - {displayCategoriesByName[categoryName].displayName} - + {/** NOTE: Remove the styles related to the hardcoded beta icon */} + + {displayCategoriesByName[categoryName].displayName} + + {displayCategoriesByName[categoryName].displayName === + 'WGCNA' && ( +
    +
    + +
    +
    + )} +
    ))}
    diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/routes.jsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/routes.jsx index 8028afce4f..e16e06c7d2 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/routes.jsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/routes.jsx @@ -40,6 +40,9 @@ import { Srt } from './components/Srt'; // Matches urlSegment. const RECORD_CLASSES_WITHOUT_PROJECT_ID = ['dataset', 'sample']; +// Used to hardcode a redirect to eda workspace from the datatsets table +const GAMBIAN_WGCNA_DATASET = 'DS_82dc5abc7f'; + const projectRegExp = new RegExp('/' + projectId + '$'); /** @@ -172,6 +175,15 @@ export const wrapRoutes = (ebrcRoutes) => [ ), }, + // hardcodes a redirect from the datasets table to the EDA "study explorer" + { + path: `/record/dataset/${GAMBIAN_WGCNA_DATASET}`, + exact: true, + component: () => ( + + ), + }, + { path: '/fasta-tool', exact: false, diff --git a/packages/sites/genomics-site/webpack.config.local.mjs b/packages/sites/genomics-site/webpack.config.local.mjs index 85ca39d09c..b9013e4193 100644 --- a/packages/sites/genomics-site/webpack.config.local.mjs +++ b/packages/sites/genomics-site/webpack.config.local.mjs @@ -52,6 +52,7 @@ export default configure({ edaServiceUrl: process.env.EDA_SERVICE_ENDPOINT, edaSingleAppMode: process.env.EDA_SINGLE_APP_MODE, vdiServiceUrl: process.env.VDI_SERVICE_ENDPOINT, + showUnreleasedData: process.env.SHOW_UNRELEASED_DATA === 'true', }) }), new HtmlWebpackPlugin({ diff --git a/packages/sites/mbio-site/package.json b/packages/sites/mbio-site/package.json index 6d6ad5a46c..47a898421c 100644 --- a/packages/sites/mbio-site/package.json +++ b/packages/sites/mbio-site/package.json @@ -43,7 +43,7 @@ "@veupathdb/site-tsconfig": "workspace:^", "@veupathdb/site-webpack-config": "workspace:^", "@veupathdb/study-data-access": "workspace:^", - "@veupathdb/user-datasets-legacy": "workspace:^", + "@veupathdb/user-datasets": "workspace:^", "@veupathdb/wdk-client": "workspace:^", "@veupathdb/web-common": "workspace:^", "babel-loader": "^8.3.0", diff --git a/packages/sites/mbio-site/webapp/wdkCustomization/js/client/controllers/UserDatasetRouter.ts b/packages/sites/mbio-site/webapp/wdkCustomization/js/client/controllers/UserDatasetRouter.ts index 2d5c0b940c..161b669cc0 100644 --- a/packages/sites/mbio-site/webapp/wdkCustomization/js/client/controllers/UserDatasetRouter.ts +++ b/packages/sites/mbio-site/webapp/wdkCustomization/js/client/controllers/UserDatasetRouter.ts @@ -1 +1 @@ -export { UserDatasetRouter as default } from '@veupathdb/user-datasets-legacy/lib/Controllers/UserDatasetRouter'; +export { UserDatasetRouter as default } from '@veupathdb/user-datasets/lib/Controllers/UserDatasetRouter'; diff --git a/packages/sites/mbio-site/webapp/wdkCustomization/js/client/routes/userDatasetRoutes.tsx b/packages/sites/mbio-site/webapp/wdkCustomization/js/client/routes/userDatasetRoutes.tsx index 41aa3db7ba..8bf8364dc2 100644 --- a/packages/sites/mbio-site/webapp/wdkCustomization/js/client/routes/userDatasetRoutes.tsx +++ b/packages/sites/mbio-site/webapp/wdkCustomization/js/client/routes/userDatasetRoutes.tsx @@ -8,9 +8,9 @@ import { RouteEntry } from '@veupathdb/wdk-client/lib/Core/RouteEntry'; import { makeEdaRoute } from '@veupathdb/web-common/lib/routes'; import { diyUserDatasetIdToWdkRecordId } from '@veupathdb/wdk-client/lib/Utils/diyDatasets'; -import { UserDatasetDetailProps } from '@veupathdb/user-datasets-legacy/lib/Controllers/UserDatasetDetailController'; +import { UserDatasetDetailProps } from '@veupathdb/user-datasets/lib/Controllers/UserDatasetDetailController'; -import { uploadTypeConfig } from '@veupathdb/user-datasets-legacy/lib/Utils/upload-config'; +import { uploadTypeConfig } from '@veupathdb/user-datasets/lib/Utils/upload-config'; import { communitySite, projectId } from '@veupathdb/web-common/lib/config'; @@ -18,9 +18,7 @@ import ExternalContentController from '@veupathdb/web-common/lib/controllers/Ext const BiomDatasetDetail = React.lazy( () => - import( - '@veupathdb/user-datasets-legacy/lib/Components/Detail/BiomDatasetDetail' - ) + import('@veupathdb/user-datasets/lib/Components/Detail/BiomDatasetDetail') ); const UserDatasetRouter = React.lazy( @@ -51,7 +49,7 @@ export const userDatasetRoutes: RouteEntry[] = [ const detailComponentsByTypeName = useMemo( () => ({ - BIOM: function MbioBiomDatasetDetail(props: UserDatasetDetailProps) { + biom: function MbioBiomDatasetDetail(props: UserDatasetDetailProps) { const wdkDatasetId = diyUserDatasetIdToWdkRecordId( props.userDataset.id ); diff --git a/packages/sites/mbio-site/webapp/wdkCustomization/js/client/wrapStoreModules.js b/packages/sites/mbio-site/webapp/wdkCustomization/js/client/wrapStoreModules.js index 4bb14f4df5..a545466ab4 100644 --- a/packages/sites/mbio-site/webapp/wdkCustomization/js/client/wrapStoreModules.js +++ b/packages/sites/mbio-site/webapp/wdkCustomization/js/client/wrapStoreModules.js @@ -4,7 +4,7 @@ import { getLeaves } from '@veupathdb/wdk-client/lib/Utils/TreeUtils'; import { useUserDatasetsWorkspace } from '@veupathdb/web-common/lib/config'; -import { wrapStoreModules as addUserDatasetStoreModules } from '@veupathdb/user-datasets-legacy/lib/StoreModules'; +import { wrapStoreModules as addUserDatasetStoreModules } from '@veupathdb/user-datasets/lib/StoreModules'; /** Compose reducer functions from right to left */ const composeReducers = diff --git a/packages/sites/mbio-site/webapp/wdkCustomization/js/client/wrapWdkService.ts b/packages/sites/mbio-site/webapp/wdkCustomization/js/client/wrapWdkService.ts index 2f228ded3e..4663b01eff 100644 --- a/packages/sites/mbio-site/webapp/wdkCustomization/js/client/wrapWdkService.ts +++ b/packages/sites/mbio-site/webapp/wdkCustomization/js/client/wrapWdkService.ts @@ -1,20 +1,16 @@ import { flowRight, identity, partial } from 'lodash'; import { - endpoint, - datasetImportUrl, useUserDatasetsWorkspace, - // vdiServiceUrl, + vdiServiceUrl, } from '@veupathdb/web-common/lib/config'; -import { wrapWdkService as addUserDatasetServices } from '@veupathdb/user-datasets-legacy/lib/Service'; +import { wrapWdkService as addUserDatasetServices } from '@veupathdb/user-datasets/lib/Service'; export default flowRight( useUserDatasetsWorkspace ? partial(addUserDatasetServices, { - datasetImportUrl, - fullWdkServiceUrl: `${window.location.origin}${endpoint}`, - // vdiServiceUrl, + vdiServiceUrl, }) : identity ); diff --git a/yarn.lock b/yarn.lock index 3dbce5c3b5..51dee91b79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8162,7 +8162,7 @@ __metadata: "@veupathdb/site-tsconfig": "workspace:^" "@veupathdb/site-webpack-config": "workspace:^" "@veupathdb/study-data-access": "workspace:^" - "@veupathdb/user-datasets-legacy": "workspace:^" + "@veupathdb/user-datasets": "workspace:^" "@veupathdb/wdk-client": "workspace:^" "@veupathdb/web-common": "workspace:^" babel-loader: ^8.3.0 @@ -8590,7 +8590,7 @@ __metadata: "@veupathdb/site-tsconfig": "workspace:^" "@veupathdb/site-webpack-config": "workspace:^" "@veupathdb/study-data-access": "workspace:^" - "@veupathdb/user-datasets-legacy": "workspace:^" + "@veupathdb/user-datasets": "workspace:^" "@veupathdb/wdk-client": "workspace:^" "@veupathdb/web-common": "workspace:^" babel-loader: ^8.3.0 @@ -8935,7 +8935,7 @@ __metadata: languageName: unknown linkType: soft -"@veupathdb/user-datasets-legacy@workspace:^, @veupathdb/user-datasets-legacy@workspace:packages/libs/user-datasets-legacy": +"@veupathdb/user-datasets-legacy@workspace:packages/libs/user-datasets-legacy": version: 0.0.0-use.local resolution: "@veupathdb/user-datasets-legacy@workspace:packages/libs/user-datasets-legacy" dependencies: @@ -9182,7 +9182,6 @@ __metadata: "@veupathdb/react-scripts": "workspace:^" "@veupathdb/study-data-access": "workspace:^" "@veupathdb/user-datasets": "workspace:^" - "@veupathdb/user-datasets-legacy": "workspace:^" "@veupathdb/wdk-client": "workspace:^" bubleify: ^2.0.0 custom-event-polyfill: ^1.0.7