diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index a68bdcb2f2..ed082cc244 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -14,7 +14,7 @@ jobs: with: node-version: '14' cache: 'yarn' - - uses: nrwl/nx-set-shas@v3 + - uses: nrwl/nx-set-shas@v3.0.0 - run: yarn - run: yarn nx affected --target=build-npm-modules --parallel=3 - run: yarn nx affected --target=compile:check diff --git a/Dockerfile b/Dockerfile index 09458f0c80..2c7ff4212d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,12 +36,9 @@ COPY packages packages ARG NODE_OPTIONS=--max-old-space-size=4096 # Build the client bundles -RUN echo "Building with NODE_OPTIONS=$NODE_OPTIONS" +# RUN echo "Building with NODE_OPTIONS=$NODE_OPTIONS" RUN yarn \ - && yarn nx bundle:npm @veupathdb/clinepi-site \ - && yarn nx bundle:npm @veupathdb/genomics-site \ - && yarn nx bundle:npm @veupathdb/mbio-site \ - && yarn nx bundle:npm @veupathdb/ortho-site + && yarn nx run-many --target=bundle:npm --parallel=1 # # # # # # # # # # # # # # # # # diff --git a/nx.json b/nx.json index 0683171766..013a429ac5 100644 --- a/nx.json +++ b/nx.json @@ -37,6 +37,11 @@ "^build-npm-modules" ] }, + "compile:check": { + "dependsOn": [ + "^build-npm-modules" + ] + }, "bundle:dev": { "dependsOn": [ "^build-npm-modules" diff --git a/packages/libs/components/package.json b/packages/libs/components/package.json index dd20ef2f9b..ee9207d06f 100755 --- a/packages/libs/components/package.json +++ b/packages/libs/components/package.json @@ -12,6 +12,7 @@ "@typescript-eslint/parser": "^5.46.0", "@veupathdb/coreui": "workspace:^", "@visx/axis": "^3.1.0", + "@visx/brush": "^3.0.1", "@visx/gradient": "^1.0.0", "@visx/group": "^1.0.0", "@visx/hierarchy": "^1.0.0", diff --git a/packages/libs/components/src/components/plotControls/EzTimeFilter.tsx b/packages/libs/components/src/components/plotControls/EzTimeFilter.tsx new file mode 100755 index 0000000000..777f9496d5 --- /dev/null +++ b/packages/libs/components/src/components/plotControls/EzTimeFilter.tsx @@ -0,0 +1,252 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import React, { useEffect, useMemo } from 'react'; +import { scaleTime, scaleLinear } from '@visx/scale'; +import { Brush } from '@visx/brush'; +// add ResizeTriggerAreas type +import { Bounds, ResizeTriggerAreas } from '@visx/brush/lib/types'; +import { Group } from '@visx/group'; +import { max, extent } from 'd3-array'; +import { BrushHandleRenderProps } from '@visx/brush/lib/BrushHandle'; +import { AxisBottom } from '@visx/axis'; +import { millisecondTodate } from '../../utils/date-format-change'; +import { Bar } from '@visx/shape'; +import { debounce } from 'lodash'; + +export type EZTimeFilterDataProp = { + x: string; + y: number; +}; + +export type EzTimeFilterProps = { + /** Ez time filter data */ + data: EZTimeFilterDataProp[]; + /** current state of selectedRange */ + selectedRange: { start: string; end: string } | undefined; + /** update function selectedRange */ + setSelectedRange: ( + selectedRange: { start: string; end: string } | undefined + ) => void; + /** width */ + width?: number; + /** height */ + height?: number; + /** color of the selected range */ + brushColor?: string; + /** axis tick and tick label color */ + axisColor?: string; + /** opacity of selected brush */ + brushOpacity?: number; + /** debounce rate in millisecond */ + debounceRateMs?: number; + /** all user-interaction disabled */ + disabled?: boolean; +}; + +// using forwardRef +function EzTimeFilter(props: EzTimeFilterProps) { + const { + data, + // set default width and height + width = 720, + height = 100, + brushColor = 'lightblue', + axisColor = '#000', + brushOpacity = 0.4, + selectedRange, + setSelectedRange, + // set a default debounce time in milliseconds + debounceRateMs = 500, + disabled = false, + } = props; + + const resizeTriggerAreas: ResizeTriggerAreas[] = disabled + ? [] + : ['left', 'right']; + + // define default values + const margin = { top: 0, bottom: 10, left: 10, right: 10 }; + const selectedBrushStyle = { + fill: disabled ? 'lightgray' : brushColor, + stroke: disabled ? 'lightgray' : brushColor, + fillOpacity: brushOpacity, + // need to set this to be 1? + strokeOpacity: 1, + }; + + // axis props + const axisBottomTickLabelProps = { + textAnchor: 'middle' as const, + fontFamily: 'Arial', + fontSize: 10, + fill: axisColor, + }; + + // accessors for data + const getXData = (d: EZTimeFilterDataProp) => new Date(d.x); + const getYData = (d: EZTimeFilterDataProp) => d.y; + + const onBrushChange = useMemo( + () => + debounce((domain: Bounds | null) => { + if (!domain) return; + + const { x0, x1 } = domain; + + const selectedDomain = { + // x0 and x1 are millisecond value + start: millisecondTodate(x0), + end: millisecondTodate(x1), + }; + + setSelectedRange(selectedDomain); + }, debounceRateMs), + [setSelectedRange] + ); + + // Cancel any pending onBrushChange requests when this component is unmounted + useEffect(() => { + return () => { + onBrushChange.cancel(); + }; + }, []); + + // bounds + const xBrushMax = Math.max(width - margin.left - margin.right, 0); + // take 80 % of given height considering axis tick/tick labels at the bottom + const yBrushMax = Math.max(0.8 * height - margin.top - margin.bottom, 0); + + // scaling + const xBrushScale = useMemo( + () => + scaleTime({ + range: [0, xBrushMax], + domain: + data != null ? (extent(data, getXData) as [Date, Date]) : undefined, + }), + [data, xBrushMax] + ); + + const yBrushScale = useMemo( + () => + scaleLinear({ + range: [yBrushMax, 0], + domain: [0, max(data, getYData) || 1], + // set zero: false so that it does not include zero line in the middle of y-axis + // this is useful when all data have zeros + zero: false, + }), + [data, yBrushMax] + ); + + // initial selectedRange position + const initialBrushPosition = useMemo( + () => + selectedRange != null + ? { + start: { x: xBrushScale(new Date(selectedRange.start)) }, + end: { x: xBrushScale(new Date(selectedRange.end)) }, + } + : undefined, + [selectedRange, xBrushScale] + ); + + // compute bar width manually as scaleTime is used for Bar chart + const barWidth = xBrushMax / data.length; + + // data bar color + const defaultColor = '#333'; + + // this makes/fakes the brush as a controlled component + const brushKey = + initialBrushPosition != null + ? initialBrushPosition.start + ':' + initialBrushPosition.end + : 'no_brush'; + + return ( +
+ + + {/* use Bar chart */} + {data.map((d, i) => { + const barHeight = yBrushMax - yBrushScale(getYData(d)); + return ( + + barHeight = 0; dataY = 1 -> barHeight = 60 + // Thus, to mimick the bar shape of the ez time filter mockup, + // starting y-coordinate for dataY = 1 sets to be 1/4*yBrushMax. + // And, the height prop is set to be 1/2*yBrushMax so that + // the bottom side of a bar has 1/4*yBrushMax space with respect to the x-axis line + y={barHeight === 0 ? yBrushMax : (1 / 4) * yBrushMax} + height={(1 / 2) * barHeight} + // set the last data's barWidth to be 0 so that it does not overflow to dragging area + width={i === data.length - 1 ? 0 : barWidth} + fill={defaultColor} + /> + + ); + })} + 520 ? 10 : 5} + stroke={axisColor} + tickStroke={axisColor} + tickLabelProps={axisBottomTickLabelProps} + /> + {} : () => setSelectedRange(undefined)} + selectedBoxStyle={selectedBrushStyle} + useWindowMoveEvents + disableDraggingSelection={disabled} + renderBrushHandle={(props) => } + /> + + +
+ ); +} + +// define brush handle shape and position +function BrushHandle({ x, height, isBrushActive }: BrushHandleRenderProps) { + const pathWidth = 8; + const pathHeight = 15; + if (!isBrushActive) { + return null; + } + return ( + + + + ); +} + +export default EzTimeFilter; diff --git a/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx b/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx index d52ba6414d..d52a84e216 100644 --- a/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx +++ b/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx @@ -30,11 +30,13 @@ export default function PlotBubbleLegend({ // The largest circle's value will be the first number that's larger than // legendMax and has only one significant digit. Each smaller circle will // be half the size of the last (rounded and >= 1) - const legendMaxLog10 = Math.floor(Math.log10(legendMax)); + const roundedOneSigFig = Number(legendMax.toPrecision(1)); const largestCircleValue = legendMax <= 10 ? legendMax - : (Number(legendMax.toPrecision(1)[0]) + 1) * 10 ** legendMaxLog10; + : roundedOneSigFig < legendMax + ? roundedOneSigFig + 10 ** Math.floor(Math.log10(legendMax)) // effectively rounding up + : roundedOneSigFig; // no need to adjust - already rounded up const circleValues = _.uniq( range(numCircles) .map((i) => Math.round(largestCircleValue / 2 ** i)) diff --git a/packages/libs/components/src/components/widgets/LabelledGroup.tsx b/packages/libs/components/src/components/widgets/LabelledGroup.tsx index 00236683f1..d0ab09bea3 100644 --- a/packages/libs/components/src/components/widgets/LabelledGroup.tsx +++ b/packages/libs/components/src/components/widgets/LabelledGroup.tsx @@ -10,6 +10,8 @@ export interface LabelledGroupProps { label: ReactNode; /** Additional styles to apply to the widget container. */ containerStyles?: React.CSSProperties; + /** Aligns children horizontally beneath the label; defaults to false */ + alignChildrenHorizontally?: boolean; } /** @@ -18,7 +20,12 @@ export interface LabelledGroupProps { * But renders nothing if no children are contained within it. */ export default function LabelledGroup(props: LabelledGroupProps) { - const { children, label, containerStyles } = props; + const { + children, + label, + containerStyles, + alignChildrenHorizontally = false, + } = props; // don't render anything if all the children (or no children) are null if (every(React.Children.toArray(children), (child) => child == null)) @@ -40,11 +47,22 @@ export default function LabelledGroup(props: LabelledGroupProps) { }} > {/* wrapper div to prevent from inline-flex */} -
+
{label && ( {label} diff --git a/packages/libs/components/src/plots/VolcanoPlot.tsx b/packages/libs/components/src/plots/VolcanoPlot.tsx index c117697b2e..1347097f46 100755 --- a/packages/libs/components/src/plots/VolcanoPlot.tsx +++ b/packages/libs/components/src/plots/VolcanoPlot.tsx @@ -79,12 +79,16 @@ export interface VolcanoPlotProps { showSpinner?: boolean; /** used to determine truncation logic */ rawDataMinMaxValues: RawDataMinMaxValues; + /** The maximum possible y axis value. Points with pValue=0 will get plotted at -log10(minPValueCap). */ + minPValueCap?: number; } const EmptyVolcanoPlotData: VolcanoPlotData = [ { log2foldChange: '0', pValue: '1' }, ]; +const MARGIN_DEFAULT = 50; + interface TruncationRectangleProps { x1: number; x2: number; @@ -134,6 +138,7 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { truncationBarFill, showSpinner = false, rawDataMinMaxValues, + minPValueCap = 2e-300, } = props; // Use ref forwarding to enable screenshotting of the plot for thumbnail versions. @@ -155,42 +160,33 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { const { min: dataYMin, max: dataYMax } = rawDataMinMaxValues.y; // Set mins, maxes of axes in the plot using axis range props + // The y axis max should not be allowed to exceed -log10(minPValueCap) const xAxisMin = independentAxisRange?.min ?? 0; const xAxisMax = independentAxisRange?.max ?? 0; const yAxisMin = dependentAxisRange?.min ?? 0; - const yAxisMax = dependentAxisRange?.max ?? 0; + const yAxisMax = dependentAxisRange?.max + ? dependentAxisRange.max > -Math.log10(minPValueCap) + ? -Math.log10(minPValueCap) + : dependentAxisRange.max + : 0; - /** - * Accessors - tell visx which value of the data point we should use and where. - */ - - // For the actual volcano plot data - const dataAccessors = { - xAccessor: (d: VolcanoPlotDataPoint) => Number(d?.log2foldChange), - yAccessor: (d: VolcanoPlotDataPoint) => -Math.log10(Number(d?.pValue)), - }; - - // For all other situations where we need to access point values. For example - // threshold lines and annotations. - const xyAccessors = { - xAccessor: (d: VisxPoint) => { - return d?.x; - }, - yAccessor: (d: VisxPoint) => { - return d?.y; - }, - }; + // Do we need to show the special annotation for the case when the y axis is maxxed out? + const showCappedDataAnnotation = yAxisMax === -Math.log10(minPValueCap); // Truncation indicators // If we have truncation indicators, we'll need to expand the plot range just a tad to // ensure the truncation bars appear. The folowing showTruncationBar variables will // be either 0 (do not show bar) or 1 (show bar). + // The y axis has special logic because it gets capped at -log10(minPValueCap) const showXMinTruncationBar = Number(dataXMin < xAxisMin); const showXMaxTruncationBar = Number(dataXMax > xAxisMax); const xTruncationBarWidth = 0.02 * (xAxisMax - xAxisMin); const showYMinTruncationBar = Number(-Math.log10(dataYMax) < yAxisMin); - const showYMaxTruncationBar = Number(-Math.log10(dataYMin) > yAxisMax); + const showYMaxTruncationBar = + dataYMin === 0 + ? Number(-Math.log10(minPValueCap) > yAxisMax) + : Number(-Math.log10(dataYMin) > yAxisMax); const yTruncationBarHeight = 0.02 * (yAxisMax - yAxisMin); /** @@ -205,6 +201,30 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { -Math.log10(Number(significanceThreshold)) > yAxisMin && -Math.log10(Number(significanceThreshold)) < yAxisMax; + /** + * Accessors - tell visx which value of the data point we should use and where. + */ + + // For the actual volcano plot data. Y axis points are capped at -Math.log10(minPValueCap) + const dataAccessors = { + xAccessor: (d: VolcanoPlotDataPoint) => Number(d?.log2foldChange), + yAccessor: (d: VolcanoPlotDataPoint) => + d.pValue === '0' + ? -Math.log10(minPValueCap) + : -Math.log10(Number(d?.pValue)), + }; + + // For all other situations where we need to access point values. For example + // threshold lines and annotations. + const xyAccessors = { + xAccessor: (d: VisxPoint) => { + return d?.x; + }, + yAccessor: (d: VisxPoint) => { + return d?.y; + }, + }; + return ( // Relative positioning so that tooltips are positioned correctly (tooltips are positioned absolutely)
) { zero: false, }} findNearestDatumOverride={findNearestDatumXY} + margin={{ + top: MARGIN_DEFAULT, + right: showCappedDataAnnotation ? 150 : MARGIN_DEFAULT, + left: MARGIN_DEFAULT, + bottom: MARGIN_DEFAULT, + }} > {/* Set up the axes and grid lines. XYChart magically lays them out correctly */} @@ -318,6 +344,31 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { )} + {/* infinity y data annotation line */} + {showCappedDataAnnotation && ( + + + + + )} + {/* The data itself */} {/* Wrapping in a group in order to change the opacity of points. The GlyphSeries is somehow a bunch of glyphs which are so there should be a way to pass opacity diff --git a/packages/libs/components/src/stories/plotControls/EzTimeFilter.stories.tsx b/packages/libs/components/src/stories/plotControls/EzTimeFilter.stories.tsx new file mode 100755 index 0000000000..2d50ee2eb1 --- /dev/null +++ b/packages/libs/components/src/stories/plotControls/EzTimeFilter.stories.tsx @@ -0,0 +1,385 @@ +import React, { useState } from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { LinePlotProps } from '../../plots/LinePlot'; +import EzTimeFilter, { + EZTimeFilterDataProp, +} from '../../components/plotControls/EzTimeFilter'; +import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers'; + +export default { + title: 'Plot Controls/EzTimeFilter', + component: EzTimeFilter, +} as Meta; + +// GEMS1 Case Control; x: Enrollment date; y: Weight +const LineplotData = { + series: [ + { + x: [ + '2007-11-01', + '2007-12-01', + '2008-01-01', + '2008-02-01', + '2008-03-01', + '2008-04-01', + '2008-05-01', + '2008-06-01', + '2008-07-01', + '2008-08-01', + '2008-09-01', + '2008-10-01', + '2008-11-01', + '2008-12-01', + '2009-01-01', + '2009-02-01', + '2009-03-01', + '2009-04-01', + '2009-05-01', + '2009-06-01', + '2009-07-01', + '2009-08-01', + '2009-09-01', + '2009-10-01', + '2009-11-01', + '2009-12-01', + '2010-01-01', + '2010-02-01', + '2010-03-01', + '2010-04-01', + '2010-05-01', + '2010-06-01', + '2010-07-01', + '2010-08-01', + '2010-09-01', + '2010-10-01', + '2010-11-01', + '2010-12-01', + '2011-01-01', + '2011-02-01', + '2011-03-01', + ], + y: [ + 9.4667, 8.9338, 8.7881, 8.8275, 8.9666, 8.9188, 8.8984, 8.5207, 8.8826, + 8.8885, 8.918, 9.0428, 9.2326, 9.2678, 9.2778, 9.2791, 9.3129, 9.3575, + 9.547, 9.1614, 8.8183, 9.0783, 9.3669, 9.1692, 9.2234, 9.2269, 9.3905, + 9.3198, 9.0729, 9.4823, 9.2846, 9.2275, 9.0953, 9.2941, 9.2566, 9.6933, + 9.5211, 9.4618, 8.697, 8.718, 7.8882, + ], + binLabel: [ + '2007-11-01 - 2007-12-01', + '2007-12-01 - 2008-01-01', + '2008-01-01 - 2008-02-01', + '2008-02-01 - 2008-03-01', + '2008-03-01 - 2008-04-01', + '2008-04-01 - 2008-05-01', + '2008-05-01 - 2008-06-01', + '2008-06-01 - 2008-07-01', + '2008-07-01 - 2008-08-01', + '2008-08-01 - 2008-09-01', + '2008-09-01 - 2008-10-01', + '2008-10-01 - 2008-11-01', + '2008-11-01 - 2008-12-01', + '2008-12-01 - 2009-01-01', + '2009-01-01 - 2009-02-01', + '2009-02-01 - 2009-03-01', + '2009-03-01 - 2009-04-01', + '2009-04-01 - 2009-05-01', + '2009-05-01 - 2009-06-01', + '2009-06-01 - 2009-07-01', + '2009-07-01 - 2009-08-01', + '2009-08-01 - 2009-09-01', + '2009-09-01 - 2009-10-01', + '2009-10-01 - 2009-11-01', + '2009-11-01 - 2009-12-01', + '2009-12-01 - 2010-01-01', + '2010-01-01 - 2010-02-01', + '2010-02-01 - 2010-03-01', + '2010-03-01 - 2010-04-01', + '2010-04-01 - 2010-05-01', + '2010-05-01 - 2010-06-01', + '2010-06-01 - 2010-07-01', + '2010-07-01 - 2010-08-01', + '2010-08-01 - 2010-09-01', + '2010-09-01 - 2010-10-01', + '2010-10-01 - 2010-11-01', + '2010-11-01 - 2010-12-01', + '2010-12-01 - 2011-01-01', + '2011-01-01 - 2011-02-01', + '2011-02-01 - 2011-03-01', + '2011-03-01 - 2011-04-01', + ], + binSampleSize: [ + { + N: 18, + }, + { + N: 804, + }, + { + N: 1186, + }, + { + N: 1475, + }, + { + N: 1665, + }, + { + N: 1609, + }, + { + N: 1706, + }, + { + N: 1803, + }, + { + N: 1943, + }, + { + N: 1874, + }, + { + N: 1475, + }, + { + N: 1503, + }, + { + N: 1545, + }, + { + N: 1393, + }, + { + N: 1641, + }, + { + N: 1893, + }, + { + N: 1913, + }, + { + N: 1916, + }, + { + N: 1882, + }, + { + N: 1967, + }, + { + N: 1699, + }, + { + N: 1697, + }, + { + N: 1263, + }, + { + N: 1774, + }, + { + N: 1620, + }, + { + N: 1450, + }, + { + N: 1893, + }, + { + N: 1628, + }, + { + N: 1552, + }, + { + N: 1722, + }, + { + N: 1698, + }, + { + N: 1604, + }, + { + N: 1544, + }, + { + N: 1368, + }, + { + N: 1302, + }, + { + N: 1250, + }, + { + N: 1254, + }, + { + N: 751, + }, + { + N: 527, + }, + { + N: 267, + }, + { + N: 51, + }, + ], + name: 'Data', + mode: 'lines+markers', + opacity: 0.7, + marker: { + color: 'rgb(136,34,85)', + symbol: 'circle', + }, + }, + ], +}; + +export const TimeFilter: Story = (args: any) => { + // converting lineplot data to visx format + const timeFilterData: EZTimeFilterDataProp[] = LineplotData.series[0].x.map( + (value, index) => { + // return { x: value, y: LineplotData.series[0].y[index] }; + return { x: value, y: LineplotData.series[0].y[index] >= 9 ? 1 : 0 }; + } + ); + + // set initial selectedRange + const [selectedRange, setSelectedRange] = useState< + { start: string; end: string } | undefined + >({ + start: timeFilterData[0].x, + end: timeFilterData[timeFilterData.length - 1].x, + }); + + // set time filter width + const timeFilterWidth = 750; + + // set initial position: shrink + const [defaultPosition, setDefaultPosition] = useState({ + x: window.innerWidth / 2 - timeFilterWidth / 2, + y: 0, + }); + + // set DraggablePanel key + const [key, setKey] = useState(0); + + // set button text + const [buttonText, setButtonText] = useState('Expand'); + + const expandPosition = () => { + setButtonText('Shrink'); + setKey((currentKey) => currentKey + 1); + setDefaultPosition({ + x: window.innerWidth / 2 - timeFilterWidth / 2, + y: 50, + }); + }; + + const resetPosition = () => { + setButtonText('Expand'); + setKey((currentKey) => currentKey + 1); + setDefaultPosition({ + x: window.innerWidth / 2 - timeFilterWidth / 2, + y: 0, + }); + // initialize range + setSelectedRange({ + start: timeFilterData[0].x, + end: timeFilterData[timeFilterData.length - 1].x, + }); + }; + + // set constant values + const defaultSymbolSize = 0.8; + const defaultColor = '#333'; + + return ( + +
+
+ {/* display start to end value */} +
+ {selectedRange?.start} ~ {selectedRange?.end} +
+
+ + {/* add a Expand or something like that to change position */} +
+ {/* reset position to hide panel title */} +
+ +
+
+
+
+ ); +}; diff --git a/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx b/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx index 5eca64288d..06d1188a5f 100755 --- a/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx +++ b/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx @@ -49,6 +49,8 @@ const dataSetVolcano: VEuPathDBVolcanoPlotData = { '-8', '-4', '-3', + '-8.2', + '7', ], pValue: [ '0.001', @@ -63,9 +65,26 @@ const dataSetVolcano: VEuPathDBVolcanoPlotData = { '0.001', '0.0001', '0.002', + '0', + '0', + ], + adjustedPValue: ['0.01', '0.001', '0.01', '0.001', '0.02', '0', '0'], + pointID: [ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'buzz', + 'lightyear', ], - adjustedPValue: ['0.01', '0.001', '0.01', '0.001', '0.02'], - pointID: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'], }, }; @@ -112,7 +131,7 @@ const Template: Story = (args) => { }) .map((d) => ({ ...d, - pointID: d.pointID ? [d.pointID] : undefined, + pointIDs: d.pointID ? [d.pointID] : undefined, significanceColor: assignSignificanceColor( Number(d.log2foldChange), Number(d.pValue), @@ -191,11 +210,15 @@ Simple.args = { export const ManyPoints = Template.bind({}); ManyPoints.args = { data: dataSetVolcanoManyPoints, - markerBodyOpacity: 0.5, + markerBodyOpacity: 0.8, log2FoldChangeThreshold: 3, significanceThreshold: 0.01, independentAxisRange: { min: -9, max: 9 }, dependentAxisRange: { min: 0, max: 9 }, + comparisonLabels: [ + 'up in super long group name', + 'up in other long group name', + ], }; // Test truncation indicators diff --git a/packages/libs/components/src/types/plots/addOns.ts b/packages/libs/components/src/types/plots/addOns.ts index 68131409d8..00480ff4bb 100644 --- a/packages/libs/components/src/types/plots/addOns.ts +++ b/packages/libs/components/src/types/plots/addOns.ts @@ -334,9 +334,9 @@ export type SignificanceColors = { low: string; }; export const significanceColors: SignificanceColors = { - inconclusive: '#B5B8B4', - high: '#AC3B4E', - low: '#0E8FAB', + inconclusive: '#8f8f8f', + high: '#a8484b', + low: '#007F5C', }; /** truncated axis flags */ diff --git a/packages/libs/components/src/utils/date-format-change.ts b/packages/libs/components/src/utils/date-format-change.ts new file mode 100644 index 0000000000..0c71991303 --- /dev/null +++ b/packages/libs/components/src/utils/date-format-change.ts @@ -0,0 +1,9 @@ +// milliseconds to mm/dd/yyyy +export function millisecondTodate(value: number) { + const date = new Date(value); // Date 2011-05-09T06:08:45.178Z + const year = date.getFullYear(); + const month = ('0' + (date.getMonth() + 1)).slice(-2); + const day = ('0' + date.getDate()).slice(-2); + + return `${year}-${month}-${day}`; +} diff --git a/packages/libs/coreui/src/components/containers/DraggablePanel/index.tsx b/packages/libs/coreui/src/components/containers/DraggablePanel/index.tsx index 7206c84a3a..8cdc54e2c6 100644 --- a/packages/libs/coreui/src/components/containers/DraggablePanel/index.tsx +++ b/packages/libs/coreui/src/components/containers/DraggablePanel/index.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, ReactNode, useEffect, useState } from 'react'; +import { CSSProperties, ReactNode, useEffect, useState, useMemo } from 'react'; import Draggable, { DraggableEvent, DraggableData } from 'react-draggable'; import { css } from '@emotion/react'; import useResizeObserver from 'use-resize-observer'; @@ -113,7 +113,7 @@ export default function DraggablePanel({ width: width, }); }, - [height, width, onPanelResize] + [height, width] ); const finalPosition = confineToParentContainer diff --git a/packages/libs/coreui/src/components/inputs/SelectList.tsx b/packages/libs/coreui/src/components/inputs/SelectList.tsx index aa6b80bfdc..1de1366795 100644 --- a/packages/libs/coreui/src/components/inputs/SelectList.tsx +++ b/packages/libs/coreui/src/components/inputs/SelectList.tsx @@ -7,6 +7,8 @@ export interface SelectListProps extends CheckboxListProps { /** A button's content if/when no values are currently selected */ defaultButtonDisplayContent: ReactNode; isDisabled?: boolean; + /** Are contents loading? */ + isLoading?: boolean; } export default function SelectList({ @@ -18,6 +20,7 @@ export default function SelectList({ children, defaultButtonDisplayContent, isDisabled = false, + isLoading = false, ...props }: SelectListProps) { const [selected, setSelected] = useState['value']>(value); @@ -67,6 +70,7 @@ export default function SelectList({ margin: '0.5em', }} > + {isLoading &&
Loading...
} ); } + +// single panel case +export const SinglePanel: Story = (args) => { + const panelTitle = 'Panel 1'; + + const [panelOpen, setPanelOpen] = useState(true); + + const draggablePanelWidth = 500; + const draggablePanelHeight = 200; + + function handleOnPanelDismiss() { + setPanelOpen(!panelOpen); + } + + return ( +
+
+ +
+ + {/* This is just nonsense to fill the panel with content */} +
+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Saepe labore + ut quia harum expedita distinctio eius deserunt, officiis inventore + velit. Voluptatibus unde eum animi alias, illum eligendi ullam facilis + consectetur? +
+
+
+ ); +}; diff --git a/packages/libs/eda/src/lib/core/api/DataClient/types.ts b/packages/libs/eda/src/lib/core/api/DataClient/types.ts index 4333667f77..c9bae7c184 100755 --- a/packages/libs/eda/src/lib/core/api/DataClient/types.ts +++ b/packages/libs/eda/src/lib/core/api/DataClient/types.ts @@ -902,6 +902,17 @@ export const BinRange = type({ binLabel: string, }); +export type LabeledRange = TypeOf; +export const LabeledRange = intersection([ + type({ + label: string, + }), + partial({ + max: string, + min: string, + }), +]); + export type ContinousVariableMetadataResponse = TypeOf< typeof ContinousVariableMetadataResponse >; 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 7fd36dfecb..e5ac58d28e 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 @@ -150,14 +150,20 @@ export function AbundanceConfiguration(props: ComputationConfigProps) { ); const collectionVarItems = useMemo(() => { - return collections.map((collectionVar) => ({ - value: { - variableId: collectionVar.id, - entityId: collectionVar.entityId, - }, - display: - collectionVar.entityDisplayName + ' > ' + collectionVar.displayName, - })); + return collections + .filter((collectionVar) => { + return collectionVar.normalizationMethod + ? collectionVar.normalizationMethod !== 'NULL' + : true; + }) + .map((collectionVar) => ({ + value: { + variableId: collectionVar.id, + entityId: collectionVar.entityId, + }, + display: + collectionVar.entityDisplayName + ' > ' + collectionVar.displayName, + })); }, [collections]); const selectedCollectionVar = useMemo(() => { diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/alphaDiv.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/alphaDiv.tsx index 87d4cf74a0..eac9b0c18d 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/alphaDiv.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/alphaDiv.tsx @@ -133,14 +133,20 @@ export function AlphaDivConfiguration(props: ComputationConfigProps) { ); const collectionVarItems = useMemo(() => { - return collections.map((collectionVar) => ({ - value: { - variableId: collectionVar.id, - entityId: collectionVar.entityId, - }, - display: - collectionVar.entityDisplayName + ' > ' + collectionVar.displayName, - })); + return collections + .filter((collectionVar) => { + return collectionVar.normalizationMethod + ? collectionVar.normalizationMethod !== 'NULL' + : true; + }) + .map((collectionVar) => ({ + value: { + variableId: collectionVar.id, + entityId: collectionVar.entityId, + }, + display: + collectionVar.entityDisplayName + ' > ' + collectionVar.displayName, + })); }, [collections]); const selectedCollectionVar = useMemo(() => { diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/betadiv.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/betadiv.tsx index 09051bbd23..67022b1381 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/betadiv.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/betadiv.tsx @@ -136,14 +136,20 @@ export function BetaDivConfiguration(props: ComputationConfigProps) { ); const collectionVarItems = useMemo(() => { - return collections.map((collectionVar) => ({ - value: { - variableId: collectionVar.id, - entityId: collectionVar.entityId, - }, - display: - collectionVar.entityDisplayName + ' > ' + collectionVar.displayName, - })); + return collections + .filter((collectionVar) => { + return collectionVar.normalizationMethod + ? collectionVar.normalizationMethod !== 'NULL' + : true; + }) + .map((collectionVar) => ({ + value: { + variableId: collectionVar.id, + entityId: collectionVar.entityId, + }, + display: + collectionVar.entityDisplayName + ' > ' + collectionVar.displayName, + })); }, [collections]); const selectedCollectionVar = useMemo(() => { diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx index cc83944b96..d3b8a91438 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx @@ -1,4 +1,10 @@ -import { useCollectionVariables, useStudyMetadata } from '../../..'; +import { + ContinuousVariableDataShape, + LabeledRange, + useCollectionVariables, + usePromise, + useStudyMetadata, +} from '../../..'; import { VariableDescriptor } from '../../../types/variable'; import { volcanoPlotVisualization } from '../../visualizations/implementations/VolcanoPlotVisualization'; import { ComputationConfigProps, ComputationPlugin } from '../Types'; @@ -7,8 +13,11 @@ import { useConfigChangeHandler, assertComputationWithConfig } from '../Utils'; import * as t from 'io-ts'; import { Computation } from '../../../types/visualization'; import SingleSelect from '@veupathdb/coreui/lib/components/inputs/SingleSelect'; -import { useFindEntityAndVariable } from '../../../hooks/workspace'; -import { useMemo } from 'react'; +import { + useDataClient, + useFindEntityAndVariable, +} from '../../../hooks/workspace'; +import { useCallback, useMemo } from 'react'; import { ComputationStepContainer } from '../ComputationStepContainer'; import VariableTreeDropdown from '../../variableTrees/VariableTreeDropdown'; import { ValuePicker } from '../../visualizations/implementations/ValuePicker'; @@ -19,6 +28,10 @@ import { SwapHorizOutlined } from '@material-ui/icons'; import './Plugins.scss'; import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; import { Tooltip } from '@material-ui/core'; +import { + GetBinRangesProps, + getBinRanges, +} from '../../../../map/analysis/utils/defaultOverlayConfig'; const cx = makeClassNameHelper('AppStepConfigurationContainer'); @@ -44,8 +57,8 @@ export type DifferentialAbundanceConfig = t.TypeOf< const Comparator = t.intersection([ t.partial({ - groupA: t.array(t.string), - groupB: t.array(t.string), + groupA: t.array(LabeledRange), + groupB: t.array(LabeledRange), }), t.type({ variable: VariableDescriptor, @@ -111,6 +124,7 @@ function DifferentialAbundanceConfigDescriptionComponent({ collectionVariable ) ); + return (

@@ -150,6 +164,7 @@ export function DifferentialAbundanceConfiguration( const configuration = computation.descriptor .configuration as DifferentialAbundanceConfig; const studyMetadata = useStudyMetadata(); + const dataClient = useDataClient(); const toggleStarredVariable = useToggleStarredVariable(props.analysisState); const filters = analysisState.analysis?.descriptor.subset.descriptor; const findEntityAndVariable = useFindEntityAndVariable(filters); @@ -175,14 +190,22 @@ export function DifferentialAbundanceConfiguration( ); const collectionVarItems = useMemo(() => { - return collections.map((collectionVar) => ({ - value: { - variableId: collectionVar.id, - entityId: collectionVar.entityId, - }, - display: - collectionVar.entityDisplayName + ' > ' + collectionVar.displayName, - })); + return collections + .filter((collectionVar) => { + return collectionVar.normalizationMethod + ? !collectionVar.isProportion && + collectionVar.normalizationMethod === 'NULL' && + !collectionVar.displayName?.includes('pathway') + : true; + }) + .map((collectionVar) => ({ + value: { + variableId: collectionVar.id, + entityId: collectionVar.entityId, + }, + display: + collectionVar.entityDisplayName + ' > ' + collectionVar.displayName, + })); }, [collections]); const selectedCollectionVar = useMemo(() => { @@ -207,10 +230,56 @@ export function DifferentialAbundanceConfiguration( } }, [configuration, findEntityAndVariable]); + // If the variable is continuous, ask the backend for a list of bins + const continuousVariableBins = usePromise( + useCallback(async () => { + if ( + !ContinuousVariableDataShape.is( + selectedComparatorVariable?.variable.dataShape + ) + ) + return; + + const binRangeProps: GetBinRangesProps = { + studyId: studyMetadata.id, + ...configuration.comparator?.variable, + filters: filters ?? [], + dataClient, + binningMethod: 'quantile', + }; + const bins = await getBinRanges(binRangeProps); + return bins; + }, [ + dataClient, + configuration?.comparator?.variable, + filters, + selectedComparatorVariable, + studyMetadata.id, + ]) + ); + const disableSwapGroupValuesButton = !configuration?.comparator?.groupA && !configuration?.comparator?.groupB; const disableGroupValueSelectors = !configuration?.comparator?.variable; + // Create the options for groupA and groupB. Organizing into the LabeledRange[] format + // here in order to keep the later code clean. + const groupValueOptions = continuousVariableBins.value + ? continuousVariableBins.value.map((bin): LabeledRange => { + return { + min: bin.binStart, + max: bin.binEnd, + label: bin.binLabel, + }; + }) + : selectedComparatorVariable?.variable.vocabulary?.map( + (value): LabeledRange => { + return { + label: value, + }; + } + ); + return ( Group A option.label) + : undefined } - selectedValues={configuration?.comparator?.groupA} - disabledValues={configuration?.comparator?.groupB} - onSelectedValuesChange={(newValues) => + selectedValues={configuration?.comparator?.groupA?.map( + (entry) => entry.label + )} + disabledValues={configuration?.comparator?.groupB?.map( + (entry) => entry.label + )} + onSelectedValuesChange={(newValues) => { changeConfigHandler('comparator', { variable: configuration?.comparator?.variable ?? undefined, - groupA: newValues.length ? newValues : undefined, + groupA: newValues.length + ? groupValueOptions?.filter((option) => + newValues.includes(option.label) + ) + : undefined, groupB: configuration?.comparator?.groupB ?? undefined, - }) - } + }); + }} disabledCheckboxTooltipContent="Values cannot overlap between groups" showClearSelectionButton={false} disableInput={disableGroupValueSelectors} + isLoading={continuousVariableBins.pending} /> changeConfigHandler('comparator', { variable: @@ -329,21 +410,32 @@ export function DifferentialAbundanceConfiguration( Group B option.label) + : undefined } - selectedValues={configuration?.comparator?.groupB} - disabledValues={configuration?.comparator?.groupA} + selectedValues={configuration?.comparator?.groupB?.map( + (entry) => entry.label + )} + disabledValues={configuration?.comparator?.groupA?.map( + (entry) => entry.label + )} onSelectedValuesChange={(newValues) => changeConfigHandler('comparator', { variable: configuration?.comparator?.variable ?? undefined, groupA: configuration?.comparator?.groupA ?? undefined, - groupB: newValues.length ? newValues : undefined, + groupB: newValues.length + ? groupValueOptions?.filter((option) => + newValues.includes(option.label) + ) + : undefined, }) } disabledCheckboxTooltipContent="Values cannot overlap between groups" showClearSelectionButton={false} disableInput={disableGroupValueSelectors} + isLoading={continuousVariableBins.pending} />

diff --git a/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx b/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx index b8025aa81a..ec349af50f 100644 --- a/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx +++ b/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx @@ -240,12 +240,21 @@ export function TableFilter({ ); const handleSearch = useCallback( - (_: unknown, searchTerm: string) => { + /** + * shouldResetPaging is true when the number of filtered rows is no longer enough to render + * rows on the currentPage + * + * Example: + * We are on page 3 and each page has 50 rows. If our search returns 100 or less rows, page 3 + * would no longer have any rows to display. Thus, we reset the currentPage to 1. + */ + (_: unknown, searchTerm: string, shouldResetPaging: boolean = false) => { analysisState.setVariableUISettings((currentState) => ({ ...currentState, [uiStateKey]: { ...uiState, searchTerm, + ...(shouldResetPaging ? { currentPage: 1 } : {}), }, })); }, diff --git a/packages/libs/eda/src/lib/core/components/visualizations/InputVariables.tsx b/packages/libs/eda/src/lib/core/components/visualizations/InputVariables.tsx index e167b1ff75..a9f8333288 100644 --- a/packages/libs/eda/src/lib/core/components/visualizations/InputVariables.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/InputVariables.tsx @@ -40,8 +40,17 @@ export interface InputSpec { * Can be used to override an input role's default title assigned in sectionInfo * when we want the behavior/logic of an existing role but with a different * title. Example: 2x2 mosaic's 'axis' variables. + * Note that the first input (of potentially many) found with this property sets the title. + * See also `noTitle` - because passing `null` to this doesn't get rid of the element. */ titleOverride?: ReactNode; + /** + * To have no title at all. Default false; Same one-to-many issues as titleOverride + */ + noTitle?: boolean; + /** + * apply custom styling to the input container + */ styleOverride?: React.CSSProperties; /** * If an input is pre-populated and cannot be null, set this as true in order to prevent any @@ -222,13 +231,18 @@ export function InputVariables(props: Props) { className={classes.inputGroup} style={{ order: sectionInfo[inputRole ?? 'default'].order }} > -
-

- {inputs.find( - (input) => input.role === inputRole && input.titleOverride - )?.titleOverride ?? sectionInfo[inputRole ?? 'default'].title} -

-
+ {!inputs.find( + (input) => input.role === inputRole && input.noTitle + ) && ( +
+

+ {inputs.find( + (input) => input.role === inputRole && input.titleOverride + )?.titleOverride ?? + sectionInfo[inputRole ?? 'default'].title} +

+
+ )} {inputs .filter((input) => input.role === inputRole) .map((input) => ( 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 65b42ce8e0..5727cca624 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/Visualizations.scss +++ b/packages/libs/eda/src/lib/core/components/visualizations/Visualizations.scss @@ -145,6 +145,7 @@ display: flex; flex-direction: column; align-items: center; + width: 13em; button { height: 10em; width: 10em; @@ -165,7 +166,6 @@ font-weight: 500; text-align: center; font-size: 1.2em; - max-width: 200px; } } &-FullScreenContainer { 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 3dd09a3af2..9e1925e924 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 @@ -768,10 +768,15 @@ function LineplotViz(props: VisualizationProps) { response.completeCasesTable ); - const xAxisVocabulary = fixLabelsForNumberVariables( - xAxisVariable?.vocabulary, - xAxisVariable - ); + // This is used for reordering series data. + // We don't want to do this for non-continuous variables. + const xAxisVocabulary = + xAxisVariable.dataShape === 'continuous' + ? [] + : fixLabelsForNumberVariables( + xAxisVariable?.vocabulary, + xAxisVariable + ); const overlayVocabulary = (overlayVariable && options?.getOverlayVocabulary?.()) ?? fixLabelsForNumberVariables( diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/ValuePicker.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/ValuePicker.tsx index 0aefb33d93..4aa00f306d 100644 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/ValuePicker.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/ValuePicker.tsx @@ -11,6 +11,8 @@ export type ValuePickerProps = { disabledCheckboxTooltipContent?: ReactNode; disableInput?: boolean; showClearSelectionButton?: boolean; + /** Show loading spinner */ + isLoading?: boolean; }; const EMPTY_ALLOWED_VALUES_ARRAY: string[] = []; @@ -25,6 +27,7 @@ export function ValuePicker({ disabledCheckboxTooltipContent, disableInput = false, showClearSelectionButton = true, + isLoading = false, }: ValuePickerProps) { const items = allowedValues.map((value) => ({ display: {value}, @@ -41,6 +44,7 @@ export function ValuePicker({ value={selectedValues} disabledCheckboxTooltipContent={disabledCheckboxTooltipContent} isDisabled={disableInput} + isLoading={isLoading} /> {showClearSelectionButton && ( ) { // Standard volcano plots have -log10(raw p value) as the y axis const yAxisMin = -Math.log10(dataYMax); const yAxisMax = -Math.log10(dataYMin); + // Add a little padding to prevent clipping the glyph representing the extreme points return { min: Math.floor(yAxisMin - (yAxisMax - yAxisMin) * AXIS_PADDING_FACTOR), @@ -339,6 +340,7 @@ function VolcanoPlotViz(props: VisualizationProps) { dependentAxisRange, significanceThreshold, log2FoldChangeThreshold, + entities, ]); // For the legend, we need the counts of the data @@ -381,8 +383,14 @@ function VolcanoPlotViz(props: VisualizationProps) { computationConfiguration.comparator?.groupA && computationConfiguration.comparator?.groupB ? [ - 'Up in ' + computationConfiguration.comparator.groupA.join(', '), - 'Up in ' + computationConfiguration.comparator.groupB.join(', '), + 'Up in ' + + computationConfiguration.comparator.groupA + .map((entry) => entry.label) + .join(','), + 'Up in ' + + computationConfiguration.comparator.groupB + .map((entry) => entry.label) + .join(','), ] : []; @@ -561,17 +569,17 @@ function VolcanoPlotViz(props: VisualizationProps) { markerColor: significanceColors['inconclusive'], }, { - label: `Up regulated in ${computationConfiguration.comparator.groupB?.join( - ', ' - )} (${countsData[significanceColors['high']]})`, + label: `Up regulated in ${computationConfiguration.comparator.groupB + ?.map((entry) => entry.label) + .join(',')} (${countsData[significanceColors['high']]})`, marker: 'circle', hasData: true, markerColor: significanceColors['high'], }, { - label: `Up regulated in ${computationConfiguration.comparator.groupA?.join( - ', ' - )} (${countsData[significanceColors['low']]})`, + label: `Up regulated in ${computationConfiguration.comparator.groupA + ?.map((entry) => entry.label) + .join(',')} (${countsData[significanceColors['low']]})`, marker: 'circle', hasData: true, markerColor: significanceColors['low'], @@ -588,7 +596,7 @@ function VolcanoPlotViz(props: VisualizationProps) { return (
- + updateVizConfig({ log2FoldChangeThreshold: Number(newValue) }) @@ -596,7 +604,7 @@ function VolcanoPlotViz(props: VisualizationProps) { label="log2(Fold Change)" minValue={0} value={vizConfig.log2FoldChangeThreshold ?? DEFAULT_FC_THRESHOLD} - containerStyles={{ flex: 1 }} + containerStyles={{ marginRight: 10 }} /> ) { } minValue={0} value={vizConfig.significanceThreshold ?? DEFAULT_SIG_THRESHOLD} - containerStyles={{ flex: 1 }} + containerStyles={{ marginLeft: 10 }} step={0.001} /> diff --git a/packages/libs/eda/src/lib/core/components/visualizations/options/types.ts b/packages/libs/eda/src/lib/core/components/visualizations/options/types.ts index 275750918e..e8cc10e6ad 100644 --- a/packages/libs/eda/src/lib/core/components/visualizations/options/types.ts +++ b/packages/libs/eda/src/lib/core/components/visualizations/options/types.ts @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import { OverlayConfig } from '../../../api/DataClient'; import { Filter } from '../../../types/filter'; import { VariableDescriptor } from '../../../types/variable'; @@ -11,7 +12,7 @@ export interface OverlayOptions { getOverlayVariable?: ( computeConfig: unknown ) => VariableDescriptor | undefined; - getOverlayVariableHelp?: () => string; + getOverlayVariableHelp?: () => ReactNode; getOverlayType?: () => OverlayConfig['overlayType'] | undefined; getOverlayVocabulary?: () => string[] | undefined; getCheckedLegendItems?: (computeConfig: unknown) => string[] | undefined; diff --git a/packages/libs/eda/src/lib/core/hooks/study.ts b/packages/libs/eda/src/lib/core/hooks/study.ts index cd71db1403..8e407edffa 100644 --- a/packages/libs/eda/src/lib/core/hooks/study.ts +++ b/packages/libs/eda/src/lib/core/hooks/study.ts @@ -27,6 +27,10 @@ import SubsettingClient from '../api/SubsettingClient'; // Hooks import { useStudyRecord } from '..'; +import { useStudyAccessApi } from '@veupathdb/study-data-access/lib/study-access/studyAccessHooks'; +import { getWdkStudyRecords } from '../utils/study-records'; +import { useDeepValue } from './immutability'; +import { usePermissions } from '@veupathdb/study-data-access/lib/data-restriction/permissionsHooks'; const STUDY_RECORD_CLASS_NAME = 'dataset'; @@ -61,7 +65,13 @@ export function useWdkStudyRecord(datasetId: string): HookValue | undefined { ) .map(getNodeId) .toArray() - .concat(['bulk_download_url', 'request_needs_approval', 'is_public']) + .concat([ + 'dataset_id', + 'bulk_download_url', + 'request_needs_approval', + 'is_public', + 'study_access', + ]) .filter((attribute) => attribute in studyRecordClass.attributesMap); const studyRecord = await wdkService .getRecord( @@ -99,44 +109,30 @@ export function useWdkStudyRecord(datasetId: string): HookValue | undefined { ); } -const DEFAULT_STUDY_ATTRIBUTES = ['dataset_id', 'eda_study_id']; -const DEFAULT_STUDY_TABLES: string[] = []; -const EMPTY_ARRAY: string[] = []; +interface WdkStudyRecordsOptions { + attributes?: AnswerJsonFormatConfig['attributes']; + tables?: AnswerJsonFormatConfig['tables']; + searchName?: string; +} export function useWdkStudyRecords( - attributes: AnswerJsonFormatConfig['attributes'] = EMPTY_ARRAY, - tables: AnswerJsonFormatConfig['tables'] = EMPTY_ARRAY + subsettingClient: SubsettingClient, + options?: WdkStudyRecordsOptions ): StudyRecord[] | undefined { + const studyAccessApi = useStudyAccessApi(); + const stableOptions = useDeepValue(options); return useWdkService( - async (wdkService) => { - const recordClass = await wdkService.findRecordClass('dataset'); - const finalAttributes = DEFAULT_STUDY_ATTRIBUTES.concat( - attributes - ).filter((attribute) => attribute in recordClass.attributesMap); - const finalTables = DEFAULT_STUDY_TABLES.concat(tables).filter( - (table) => table in recordClass.tablesMap - ); - return wdkService.getAnswerJson( + (wdkService) => + getWdkStudyRecords( { - searchName: 'Studies', - searchConfig: { - parameters: {}, - }, + studyAccessApi, + subsettingClient, + wdkService, }, - { - attributes: finalAttributes, - tables: finalTables, - sorting: [ - { - attributeName: 'display_name', - direction: 'ASC', - }, - ], - } - ); - }, - [attributes, tables] - )?.records.filter((record) => record.attributes.eda_study_id != null); + stableOptions + ), + [studyAccessApi, subsettingClient, stableOptions] + ); } /** @@ -191,10 +187,13 @@ export function isStubEntity(entity: StudyEntity) { } export function useStudyMetadata(datasetId: string, client: SubsettingClient) { + const permissionsResponse = usePermissions(); return useWdkServiceWithRefresh( async (wdkService) => { + if (permissionsResponse.loading) return; + const { permissions } = permissionsResponse; const recordClass = await wdkService.findRecordClass('dataset'); - const attributes = ['dataset_id', 'eda_study_id', 'study_access'].filter( + const attributes = ['dataset_id', 'study_access'].filter( (attribute) => attribute in recordClass.attributesMap ); const studyRecord = await wdkService @@ -224,22 +223,20 @@ export function useStudyMetadata(datasetId: string, client: SubsettingClient) { tableErrors: [], } as RecordInstance; }); - if (typeof studyRecord.attributes.eda_study_id !== 'string') - throw new Error( - 'Could not find study with associated dataset id `' + datasetId + '`.' - ); + const studyId = + permissions.perDataset[studyRecord.attributes.dataset_id as string] + ?.studyId; + if (studyId == null) throw new Error('Not an eda study'); try { - return await client.getStudyMetadata( - studyRecord.attributes.eda_study_id - ); + return await client.getStudyMetadata(studyId); } catch (error) { console.error(error); return { - id: studyRecord.attributes.eda_study_id, + id: studyId, rootEntity: STUB_ENTITY, }; } }, - [datasetId, client] + [datasetId, client, permissionsResponse] ); } diff --git a/packages/libs/eda/src/lib/core/types/study.ts b/packages/libs/eda/src/lib/core/types/study.ts index ea54f20561..fecdf8125c 100644 --- a/packages/libs/eda/src/lib/core/types/study.ts +++ b/packages/libs/eda/src/lib/core/types/study.ts @@ -220,6 +220,9 @@ export const CollectionVariableTreeNode = t.intersection([ units: t.string, entityId: t.string, entityDisplayName: t.string, + isCompositional: t.boolean, + isProportion: t.boolean, + normalizationMethod: t.string, }), ]); diff --git a/packages/libs/eda/src/lib/core/utils/study-records.ts b/packages/libs/eda/src/lib/core/utils/study-records.ts new file mode 100644 index 0000000000..f9c878a61b --- /dev/null +++ b/packages/libs/eda/src/lib/core/utils/study-records.ts @@ -0,0 +1,76 @@ +// utils for getting study records + +import { cachedPermissionCheck } from '@veupathdb/study-data-access/lib/data-restriction/permissionsHooks'; +import { getStudyId } from '@veupathdb/study-data-access/lib/shared/studies'; +import { StudyAccessApi } from '@veupathdb/study-data-access/lib/study-access/api'; +import { WdkService } from '@veupathdb/wdk-client/lib/Core'; +import { AnswerJsonFormatConfig } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; +import { SubsettingClient } from '../api'; +import { StudyRecord } from '../types/study'; + +interface WdkStudyRecordsDeps { + wdkService: WdkService; + subsettingClient: SubsettingClient; + studyAccessApi: StudyAccessApi; +} + +interface WdkStudyRecordsOptions { + attributes?: AnswerJsonFormatConfig['attributes']; + tables?: AnswerJsonFormatConfig['tables']; + searchName?: string; +} + +const DEFAULT_STUDY_ATTRIBUTES = ['dataset_id']; +const DEFAULT_STUDY_TABLES: string[] = []; +const EMPTY_ARRAY: string[] = []; + +export async function getWdkStudyRecords( + deps: WdkStudyRecordsDeps, + options?: WdkStudyRecordsOptions +): Promise { + const { wdkService, subsettingClient, studyAccessApi } = deps; + const attributes = options?.attributes ?? EMPTY_ARRAY; + const tables = options?.tables ?? EMPTY_ARRAY; + const searchName = options?.searchName ?? 'Studies'; + + const [permissions, recordClass] = await Promise.all([ + cachedPermissionCheck(await wdkService.getCurrentUser(), studyAccessApi), + wdkService.findRecordClass('dataset'), + ]); + const finalAttributes = DEFAULT_STUDY_ATTRIBUTES.concat(attributes).filter( + (attribute) => attribute in recordClass.attributesMap + ); + const finalTables = DEFAULT_STUDY_TABLES.concat(tables).filter( + (table) => table in recordClass.tablesMap + ); + const [edaStudies, answer] = await Promise.all([ + subsettingClient.getStudies(), + wdkService.getAnswerJson( + { + searchName, + searchConfig: { + parameters: {}, + }, + }, + { + attributes: finalAttributes, + tables: finalTables, + sorting: [ + { + attributeName: 'display_name', + direction: 'ASC', + }, + ], + } + ), + ]); + const studyIds = new Set(edaStudies.map((s) => s.id)); + return answer.records.filter((record) => { + const datasetId = getStudyId(record); + if (datasetId == null) { + return false; + } + const studyId = permissions.perDataset[datasetId]?.studyId; + return studyId && studyIds.has(studyId); + }); +} diff --git a/packages/libs/eda/src/lib/map/MapVeuContainer.tsx b/packages/libs/eda/src/lib/map/MapVeuContainer.tsx index cee77499ee..acca2b5b88 100644 --- a/packages/libs/eda/src/lib/map/MapVeuContainer.tsx +++ b/packages/libs/eda/src/lib/map/MapVeuContainer.tsx @@ -24,6 +24,7 @@ import './MapVEu.scss'; import { SiteInformationProps } from '.'; import { StudyList } from './StudyList'; import { PublicAnalysesRoute } from '../workspace/PublicAnalysesRoute'; +import { ImportAnalysis } from '../workspace/ImportAnalysis'; interface Props { edaServiceUrl: string; @@ -64,10 +65,35 @@ export function MapVeuContainer(mapVeuContainerProps: Props) { /> )} /> - } /> + } + /> } + render={() => ( + + )} + /> + + ) => { + return ( + + ); + }} /> Loading...
; return (
diff --git a/packages/libs/eda/src/lib/map/analysis/DraggableVisualization.tsx b/packages/libs/eda/src/lib/map/analysis/DraggableVisualization.tsx index 06f2ea186f..4b627fe844 100644 --- a/packages/libs/eda/src/lib/map/analysis/DraggableVisualization.tsx +++ b/packages/libs/eda/src/lib/map/analysis/DraggableVisualization.tsx @@ -70,7 +70,7 @@ export default function DraggableVisualization({ panelTitle={activeVizOverview?.displayName || ''} defaultPosition={{ x: 535, - y: 142, + y: 220, }} onPanelDismiss={() => setActiveVisualizationId(undefined)} > diff --git a/packages/libs/eda/src/lib/map/analysis/EZTimeFilter.tsx b/packages/libs/eda/src/lib/map/analysis/EZTimeFilter.tsx new file mode 100755 index 0000000000..4cc4aaa36a --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/EZTimeFilter.tsx @@ -0,0 +1,227 @@ +import { useMemo, useCallback, useState } from 'react'; +import { H6, Toggle } from '@veupathdb/coreui'; +import EzTimeFilterWidget, { + EZTimeFilterDataProp, +} from '@veupathdb/components/lib/components/plotControls/EzTimeFilter'; +import { InputVariables } from '../../core/components/visualizations/InputVariables'; +import { VariablesByInputName } from '../../core/utils/data-element-constraints'; +import { usePromise } from '../../core'; +import { + DateVariable, + NumberVariable, + StudyEntity, +} from '../../core/types/study'; +import { VariableDescriptor } from '../../core/types/variable'; + +import { SubsettingClient } from '../../core/api'; +import Spinner from '@veupathdb/components/lib/components/Spinner'; +import { useFindEntityAndVariable, Filter } from '../../core'; +import { zip } from 'lodash'; +import { AppState } from './appState'; +import { timeSliderVariableConstraints } from './config/eztimeslider'; + +interface Props { + studyId: string; + entities: StudyEntity[]; + // to handle filters + subsettingClient: SubsettingClient; + filters: Filter[] | undefined; + starredVariables: VariableDescriptor[]; + toggleStarredVariable: (targetVariableId: VariableDescriptor) => void; + + config: NonNullable; + updateConfig: (newConfig: NonNullable) => void; +} + +export default function EZTimeFilter({ + studyId, + entities, + subsettingClient, + filters, + starredVariables, + toggleStarredVariable, + config, + updateConfig, +}: Props) { + const findEntityAndVariable = useFindEntityAndVariable(); + const [minimized, setMinimized] = useState(true); + + const { variable, active, selectedRange } = config; + const variableMetadata = findEntityAndVariable(variable); + + // data request to distribution for time slider + const getTimeSliderData = usePromise( + useCallback(async () => { + // no data request if no variable is available + if ( + variableMetadata == null || + variable == null || + !( + NumberVariable.is(variableMetadata.variable) || + DateVariable.is(variableMetadata.variable) + ) + ) + return; + + const binSpec = { + displayRangeMin: + variableMetadata.variable.distributionDefaults.rangeMin + + (variableMetadata.variable.type === 'date' ? 'T00:00:00Z' : ''), + displayRangeMax: + variableMetadata.variable.distributionDefaults.rangeMax + + (variableMetadata.variable.type === 'date' ? 'T00:00:00Z' : ''), + binWidth: variableMetadata.variable.distributionDefaults.binWidth ?? 1, + binUnits: + 'binUnits' in variableMetadata.variable.distributionDefaults + ? variableMetadata.variable.distributionDefaults.binUnits + : undefined, + }; + const distributionResponse = await subsettingClient.getDistribution( + studyId, + variable.entityId, + variable.variableId, + { + valueSpec: 'count', + filters: filters ?? [], + binSpec, + } + ); + + return { + x: distributionResponse.histogram.map((d) => d.binStart), + // conditionally set y-values to be 1 (with data) and 0 (no data) + y: distributionResponse.histogram.map((d) => (d.value >= 1 ? 1 : 0)), + }; + }, [variableMetadata?.variable, variable, subsettingClient, filters]) + ); + + // converting data to visx format + const timeFilterData: EZTimeFilterDataProp[] = useMemo( + () => + !getTimeSliderData.pending && getTimeSliderData.value != null + ? zip(getTimeSliderData.value.x, getTimeSliderData.value.y) + .map(([xValue, yValue]) => ({ x: xValue, y: yValue })) + // and a type guard filter to avoid any `!` assertions. + .filter( + (val): val is EZTimeFilterDataProp => + val.x != null && val.y != null + ) + : [], + [getTimeSliderData] + ); + + // set time slider width and y position + const timeFilterWidth = 750; + + // inputVariables onChange function + function handleInputVariablesOnChange(selection: VariablesByInputName) { + if (!selection.overlayVariable) { + console.error( + `Expected overlayVariable to be defined but got ${typeof selection.overlayVariable}` + ); + return; + } + + updateConfig({ + variable: selection.overlayVariable, + selectedRange: undefined, + active: true, + }); + } + + // if no variable in a study is suitable to time slider, do not show time slider + return variable != null && variableMetadata != null ? ( +
setMinimized(false)} + onMouseLeave={() => setMinimized(true)} + > +
+
+
+ {variableMetadata.variable.displayName + + (active && selectedRange + ? ` [${selectedRange?.start} to ${selectedRange?.end}]` + : ' (all dates)')} +
+
+ {/* display start to end value + TO DO: make these date inputs? + {selectedRange && ( +
+ {selectedRange?.start} ~ {selectedRange?.end} +
+ )} + */} +
+ updateConfig({ ...config, active })} + /> +
+
+ {/* display data loading spinner while requesting data to the backend */} + {getTimeSliderData.pending && ( +
+ +
+ )} + {/* conditional loading for EzTimeFilter */} + {!getTimeSliderData.pending && + getTimeSliderData.value != null && + timeFilterData.length > 0 && ( + + updateConfig({ ...config, selectedRange }) + } + width={timeFilterWidth - 30} + height={75} + // fill color of the selectedRange + brushColor={'lightpink'} + brushOpacity={0.4} + // axis tick and tick label color + axisColor={'#000'} + // disable user-interaction + disabled={!active} + /> + )} + {!minimized && ( +
+ +
+ )} +
+ ) : null; +} diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx old mode 100644 new mode 100755 index 77c79d2118..c2db15cc17 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -6,7 +6,11 @@ import { BubbleOverlayConfig, CategoricalVariableDataShape, DEFAULT_ANALYSIS_NAME, + DateRangeFilter, + DateVariable, EntityDiagram, + NumberRangeFilter, + NumberVariable, OverlayConfig, PromiseResult, useAnalysis, @@ -75,9 +79,9 @@ import { BubbleMarkerConfigurationMenu, } from './MarkerConfiguration'; import { - BarPlotMarker, - DonutMarker, - BubbleMarker, + BarPlotMarkerIcon, + DonutMarkerIcon, + BubbleMarkerIcon, } from './MarkerConfiguration/icons'; import { leastAncestralEntity } from '../../core/utils/data-element-constraints'; import { getDefaultOverlayConfig } from './utils/defaultOverlayConfig'; @@ -92,14 +96,14 @@ import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers'; import { TabbedDisplayProps } from '@veupathdb/coreui/lib/components/grids/TabbedDisplay'; import { GeoConfig } from '../../core/types/geoConfig'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; -import BubbleMarkerComponent, { +import BubbleMarker, { BubbleMarkerProps, } from '@veupathdb/components/lib/map/BubbleMarker'; -import DonutMarkerComponent, { +import DonutMarker, { DonutMarkerProps, DonutMarkerStandalone, } from '@veupathdb/components/lib/map/DonutMarker'; -import ChartMarkerComponent, { +import ChartMarker, { ChartMarkerProps, ChartMarkerStandalone, } from '@veupathdb/components/lib/map/ChartMarker'; @@ -111,6 +115,8 @@ import { SideNavigationItems } from './MapSideNavigation'; import { DraggablePanelCoordinatePair } from '@veupathdb/coreui/lib/components/containers/DraggablePanel'; import _ from 'lodash'; +import EZTimeFilter from './EZTimeFilter'; + enum MapSideNavItemLabels { Download = 'Download', Filter = 'Filter', @@ -157,6 +163,7 @@ export function MapAnalysis(props: Props) { const analysisState = useAnalysis(props.analysisId, 'pass'); const appStateAndSetters = useAppState('@@mapApp@@', analysisState); const geoConfigs = useGeoConfig(useStudyEntities()); + if (geoConfigs == null || geoConfigs.length === 0) return ( { - const viewportFilters = appState.boundsZoomLevel - ? filtersFromBoundingBox( - appState.boundsZoomLevel.bounds, - { - variableId: geoConfig.latitudeVariableId, - entityId: geoConfig.entity.id, - }, - { - variableId: geoConfig.longitudeVariableId, - entityId: geoConfig.entity.id, - } - ) - : []; + const timeFilter: NumberRangeFilter | DateRangeFilter | undefined = + useMemo(() => { + if (appState.timeSliderConfig == null) return undefined; + + const { active, variable, selectedRange } = appState.timeSliderConfig; + + const { variable: timeVariableMetadata } = + findEntityAndVariable(variable) ?? {}; + + return active && variable && selectedRange + ? DateVariable.is(timeVariableMetadata) + ? { + type: 'dateRange', + ...variable, + min: selectedRange.start + 'T00:00:00Z', + max: selectedRange.end + 'T00:00:00Z', + } + : NumberVariable.is(timeVariableMetadata) + ? { + type: 'numberRange', // this is temporary - I think we should NOT handle non-date variables when we roll this out + ...variable, // TO DO: remove number variable handling + min: Number(selectedRange.start.split(/-/)[0]), // just take the year number + max: Number(selectedRange.end.split(/-/)[0]), // from the YYYY-MM-DD returned from the widget + } + : undefined + : undefined; + }, [appState.timeSliderConfig, findEntityAndVariable]); + + const viewportFilters = useMemo( + () => + appState.boundsZoomLevel + ? filtersFromBoundingBox( + appState.boundsZoomLevel.bounds, + { + variableId: geoConfig.latitudeVariableId, + entityId: geoConfig.entity.id, + }, + { + variableId: geoConfig.longitudeVariableId, + entityId: geoConfig.entity.id, + } + ) + : [], + [ + appState.boundsZoomLevel, + geoConfig.entity.id, + geoConfig.latitudeVariableId, + geoConfig.longitudeVariableId, + ] + ); + + // needed for floaters + const filtersIncludingViewportAndTimeSlider = useMemo(() => { return [ ...(props.analysisState.analysis?.descriptor.subset.descriptor ?? []), ...viewportFilters, + ...(timeFilter != null ? [timeFilter] : []), ]; }, [ - appState.boundsZoomLevel, - geoConfig.entity.id, - geoConfig.latitudeVariableId, - geoConfig.longitudeVariableId, props.analysisState.analysis?.descriptor.subset.descriptor, + viewportFilters, + timeFilter, ]); + // needed for markers + const filtersIncludingTimeSlider = useMemo(() => { + return [ + ...(props.analysisState.analysis?.descriptor.subset.descriptor ?? []), + ...(timeFilter != null ? [timeFilter] : []), + ]; + }, [props.analysisState.analysis?.descriptor.subset.descriptor, timeFilter]); + const allFilteredCategoricalValues = usePromise( useCallback(async (): Promise => { /** @@ -323,7 +383,7 @@ function MapAnalysisImpl(props: ImplProps) { subsettingClient, studyId, overlayVariable, - filters: filtersIncludingViewport, + filters: filtersIncludingViewportAndTimeSlider, // TO DO: decide whether to filter on time slider here }); }, [ overlayVariable, @@ -331,7 +391,7 @@ function MapAnalysisImpl(props: ImplProps) { overlayEntity, subsettingClient, studyId, - filtersIncludingViewport, + filtersIncludingViewportAndTimeSlider, ]) ); @@ -413,7 +473,7 @@ function MapAnalysisImpl(props: ImplProps) { boundsZoomLevel: appState.boundsZoomLevel, geoConfig: geoConfig, studyId, - filters, + filters: filtersIncludingTimeSlider, markerType, selectedOverlayVariable: activeMarkerConfiguration?.selectedVariable, overlayConfig: activeOverlayConfig.value, @@ -494,11 +554,11 @@ function MapAnalysisImpl(props: ImplProps) { () => markersData?.map((markerProps) => markerType === 'pie' ? ( - + ) : markerType === 'bubble' ? ( - + ) : ( - + ) ) || [], [markersData, markerType] @@ -509,7 +569,6 @@ function MapAnalysisImpl(props: ImplProps) { return !user.isGuest; }); - const history = useHistory(); function showLoginForm() { const currentUrl = window.location.href; const loginUrl = `${props.siteInformationProps.loginUrl}?destination=${currentUrl}`; @@ -535,7 +594,14 @@ function MapAnalysisImpl(props: ImplProps) { ); const plugins = useStandaloneVizPlugins({ - selectedOverlayConfig: activeOverlayConfig.value, + selectedOverlayConfig: + activeMarkerConfigurationType === 'bubble' + ? undefined + : activeOverlayConfig.value, + overlayHelp: + activeMarkerConfigurationType === 'bubble' + ? 'Overlay variables are not available for this map type' + : undefined, }); const subsetVariableAndEntity = useMemo(() => { @@ -633,7 +699,7 @@ function MapAnalysisImpl(props: ImplProps) { type: 'item', id: 'single-variable-pie', labelText: MarkerTypeLabels.pie, - rightIcon: , + rightIcon: , leftIcon: activeMarkerConfigurationType === 'pie' ? : null, onActive: () => { @@ -655,7 +721,7 @@ function MapAnalysisImpl(props: ImplProps) { type: 'pie', displayName: MarkerTypeLabels.pie, icon: ( - ), @@ -742,7 +808,7 @@ function MapAnalysisImpl(props: ImplProps) { activeMarkerConfigurationType === 'barplot' ? ( ) : null, - rightIcon: , + rightIcon: , onActive: () => { setActiveMarkerConfigurationType('barplot'); }, @@ -762,7 +828,7 @@ function MapAnalysisImpl(props: ImplProps) { type: 'barplot', displayName: MarkerTypeLabels.barplot, icon: ( - ), @@ -845,7 +911,7 @@ function MapAnalysisImpl(props: ImplProps) { type: 'item', id: 'single-variable-bubble', labelText: MarkerTypeLabels.bubble, - rightIcon: , + rightIcon: , leftIcon: activeMarkerConfigurationType === 'bubble' ? ( @@ -862,7 +928,7 @@ function MapAnalysisImpl(props: ImplProps) { type: 'bubble', displayName: MarkerTypeLabels.bubble, icon: ( - ), @@ -1241,7 +1307,26 @@ function MapAnalysisImpl(props: ImplProps) { totalVisibleEntityCount } overlayActive={overlayVariable != null} - /> + > + {/* child elements will be distributed across, 'hanging' below the header */} + {/* Time slider component - only if prerequisite variable is available */} + {appState.timeSliderConfig && + appState.timeSliderConfig.variable && ( + + )} +
setIsSidePanelExpanded(!appState.isSidePanelExpanded) } @@ -1359,7 +1445,7 @@ function MapAnalysisImpl(props: ImplProps) { totalCounts={totalCounts} filteredCounts={filteredCounts} toggleStarredVariable={toggleStarredVariable} - filters={filtersIncludingViewport} + filters={filtersIncludingViewportAndTimeSlider} // onTouch={moveVizToTop} zIndexForStackingContext={getZIndexByPanelTitle( DraggablePanelIds.VIZ_PANEL diff --git a/packages/libs/eda/src/lib/map/analysis/MapHeader.scss b/packages/libs/eda/src/lib/map/analysis/MapHeader.scss index 7aa2bcd877..e77298a133 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapHeader.scss +++ b/packages/libs/eda/src/lib/map/analysis/MapHeader.scss @@ -62,17 +62,16 @@ align-items: flex-start; flex-direction: column; - &__SaveableTextEditorContainer { - cursor: text; - font-size: 19px; + .wdk-SaveableTextEditor { + font-style: italic; } &__StudyName { cursor: default; + margin-right: 1em; } &__AnalysisTitle { - font-style: italic; padding: 0; font-size: 19px; } diff --git a/packages/libs/eda/src/lib/map/analysis/MapHeader.tsx b/packages/libs/eda/src/lib/map/analysis/MapHeader.tsx index 1e1bfd6d8b..92eca3576f 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapHeader.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapHeader.tsx @@ -26,6 +26,9 @@ export type MapNavigationProps = { totalEntityInSubsetCount: number | undefined; visibleEntityCount: number | undefined; overlayActive: boolean; + /** children of this component will be rendered in flex children + distributed across the bottom edge of the header, hanging down like tabs */ + children: ReactNode; }; /** @@ -44,6 +47,7 @@ export function MapHeader({ totalEntityInSubsetCount = 0, visibleEntityCount = 0, overlayActive, + children, }: MapNavigationProps) { const mapHeader = makeClassNameHelper('MapHeader'); const { format } = new Intl.NumberFormat(); @@ -138,10 +142,28 @@ export function MapHeader({
)} + {children} ); } +function HangingTabs({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + type HeaderContentProps = { analysisName?: string; filterList?: ReactNode; @@ -158,30 +180,19 @@ function HeaderContent({ return (
-
- void) => { - return ( -

- e.stopPropagation()} - className={headerContent('__StudyName')} - > - {safeHtml(studyName, { style: { fontWeight: 'bold' } })}:{' '} - - {analysisName} -

- ); - }} - maxLength={ANALYSIS_NAME_MAX_LENGTH} - onSave={onAnalysisNameEdit} - value={analysisName} - /> +
+

+ MapVEu — + + {safeHtml(studyName)} + + +

{filterList}
diff --git a/packages/libs/eda/src/lib/map/analysis/MapSidePanel.tsx b/packages/libs/eda/src/lib/map/analysis/MapSidePanel.tsx index 72fee5096f..a1130f775d 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapSidePanel.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapSidePanel.tsx @@ -1,11 +1,13 @@ import { ChevronRight } from '@veupathdb/coreui'; -import { Launch, LockOpen } from '@material-ui/icons'; +import { Launch, LockOpen, Person } from '@material-ui/icons'; import { mapSidePanelBackgroundColor, mapSidePanelBorder, SiteInformationProps, } from '..'; +import { Link } from 'react-router-dom'; + export type MapSidePanelProps = { isExpanded: boolean; children: React.ReactNode; @@ -14,6 +16,7 @@ export type MapSidePanelProps = { /** Content to render in sidePanel drawer */ sidePanelDrawerContents?: React.ReactNode; siteInformationProps: SiteInformationProps; + isUserLoggedIn: boolean | undefined; }; const bottomLinkStyles: React.CSSProperties = { @@ -34,6 +37,7 @@ export function MapSidePanel({ isExpanded, onToggleIsExpanded, siteInformationProps, + isUserLoggedIn, }: MapSidePanelProps) { const sideMenuExpandButtonWidth = 20; @@ -168,10 +172,23 @@ export function MapSidePanel({
  • - - -

    Login

    -
    + {isUserLoggedIn == null ? null : isUserLoggedIn ? ( + + +

    My profile

    + + ) : ( + + +

    Login

    + + )}
  • diff --git a/packages/libs/eda/src/lib/map/analysis/MapVizManagement.tsx b/packages/libs/eda/src/lib/map/analysis/MapVizManagement.tsx index 4536b1da4d..c9eb0c2e70 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapVizManagement.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapVizManagement.tsx @@ -241,7 +241,12 @@ function VisualizationsList({ analysisState.deleteVisualization( viz.visualizationId ); - setActiveVisualizationId(undefined); + if ( + activeVisualization?.visualizationId === + viz.visualizationId + ) { + setActiveVisualizationId(undefined); + } }} > 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 ae67024c10..150b39e35d 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx @@ -16,7 +16,6 @@ import { CategoricalMarkerConfigurationTable } from './CategoricalMarkerConfigur import { CategoricalMarkerPreview } from './CategoricalMarkerPreview'; import Barplot from '@veupathdb/components/lib/plots/Barplot'; import { SubsettingClient } from '../../../core/api'; -import LabelledGroup from '@veupathdb/components/lib/components/widgets/LabelledGroup'; import { Toggle } from '@veupathdb/coreui'; import { SharedMarkerConfigurations } from './PieMarkerConfigurationMenu'; import { useUncontrolledSelections } from '../hooks/uncontrolledSelections'; @@ -25,6 +24,7 @@ import { SelectedCountsOption, SelectedValues, } from '../appState'; +import { gray } from '@veupathdb/coreui/lib/definitions/colors'; interface MarkerConfiguration { type: T; @@ -185,26 +185,31 @@ export function BarPlotMarkerConfigurationMenu({ > Color:

    - + {/* limit inputVariables width */} +
    + +
    - +
    Summary marker (all filtered data) - +
    {overlayConfiguration?.overlayType === 'categorical' ? ( <> - +
    +
    + Marker X-axis controls +
    - - +
    +
    +
    + Marker Y-axis controls +
    - +
    {overlayConfiguration?.overlayType === 'categorical' && ( - +
    + +
    )} {overlayConfiguration?.overlayType === 'continuous' && barplotData.value && (
    @@ -301,8 +324,8 @@ export function BarPlotMarkerConfigurationMenu({ marginBottom: 0, }} containerStyles={{ - height: 250, - width: 400, + height: '300px', + maxWidth: '360px', }} />
    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 99bacf8cad..84885d2f0a 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx @@ -13,6 +13,7 @@ import { aggregationHelp, AggregationInputs, } from '../../../core/components/visualizations/implementations/LineplotVisualization'; +import { DataElementConstraint } from '../../../core/types/visualization'; type AggregatorOption = typeof aggregatorOptions[number]; const aggregatorOptions = ['mean', 'median'] as const; @@ -189,7 +190,22 @@ export function BubbleMarkerConfigurationMenu({ onChange={handleInputVariablesOnChange} starredVariables={starredVariables} toggleStarredVariable={toggleStarredVariable} - constraints={constraints} + constraints={ + // TEMPORARILY disable date vars; TO DO for dates - remove! + constraints?.map((constraint) => { + return Object.fromEntries( + Object.keys(constraint).map((key) => [ + key, + { + ...constraint[key], + allowedTypes: constraint[key]?.allowedTypes?.filter( + (t) => t !== 'date' + ) ?? ['string', 'number', 'integer'], + } as DataElementConstraint, // assertion seems required due to spread operator + ]) + ); + }) + } flexDirection="column" />
    diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx index 9059f8d10f..07bce43224 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx @@ -169,6 +169,10 @@ export function CategoricalMarkerConfigurationTable({ */ key: 'label', name: 'Values', + style: { + wordBreak: 'break-word', + hyphens: 'auto', + }, sortable: true, renderCell: (data: { row: AllValuesDefinition }) => ( <>{data.row.label} @@ -194,14 +198,14 @@ export function CategoricalMarkerConfigurationTable({ return (
    ({ options={['filtered', 'visible']} optionLabels={['Filtered', 'Visible']} buttonColor={'primary'} - // margins={['0em', '0', '0', '1em']} + margins={['1em', '0', '0', '0em']} onOptionSelected={handleCountsSelection} />
    diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx index 4b1f0e2f96..c15e144a9b 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx @@ -19,7 +19,7 @@ type Props = { }; export const sharedStandaloneMarkerProperties = { - markerScale: 3, + markerScale: 2.5, containerStyles: { width: 'fit-content', height: 'fit-content', 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 29e30a9994..044a66505d 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx @@ -17,7 +17,6 @@ import { CategoricalMarkerPreview } from './CategoricalMarkerPreview'; import Barplot from '@veupathdb/components/lib/plots/Barplot'; import { SubsettingClient } from '../../../core/api'; import RadioButtonGroup from '@veupathdb/components/lib/components/widgets/RadioButtonGroup'; -import LabelledGroup from '@veupathdb/components/lib/components/widgets/LabelledGroup'; import { useUncontrolledSelections } from '../hooks/uncontrolledSelections'; import { BinningMethod, @@ -172,26 +171,31 @@ export function PieMarkerConfigurationMenu({ > Color:

    - + {/* limit inputVariables width */} +
    + +
    - +
    Summary marker (all filtered data) - +
    {overlayConfiguration?.overlayType === 'categorical' ? ( - + {overlayConfiguration?.overlayType === 'continuous' && ( - + )} {overlayConfiguration?.overlayType === 'categorical' && (
    diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BarPlotMarker.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BarPlotMarkerIcon.tsx similarity index 96% rename from packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BarPlotMarker.tsx rename to packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BarPlotMarkerIcon.tsx index ad424f5f01..1a68c11db9 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BarPlotMarker.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BarPlotMarkerIcon.tsx @@ -1,5 +1,5 @@ import { SVGProps } from 'react'; -export function BarPlotMarker(props: SVGProps) { +export function BarPlotMarkerIcon(props: SVGProps) { return ( ) { +export function BarPlotMarkersIcon(props: SVGProps) { return ( ) { +export function BubbleMarkerIcon(props: SVGProps) { return ( // ) { +export function DonutMarkerIcon(props: SVGProps) { return ( ) { +export function DonutMarkersIcon(props: SVGProps) { return ( undefined) ); - const studyMetadata = useStudyMetadata(); const getDefaultVariableDescriptor = useGetDefaultVariableDescriptor(); - const defaultVariable = getDefaultVariableDescriptor( - studyMetadata.rootEntity.id - ); + const defaultVariable = getDefaultVariableDescriptor(); + + const getDefaultTimeVariableDescriptor = + useGetDefaultTimeVariableDescriptor(); + const defaultTimeVariable = getDefaultTimeVariableDescriptor(); const defaultAppState: AppState = useMemo( () => ({ @@ -133,6 +143,11 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { mouseMode: 'default', activeMarkerConfigurationType: 'pie', isSidePanelExpanded: true, + timeSliderConfig: { + variable: defaultTimeVariable, + active: true, + selectedRange: undefined, + }, markerConfigurations: [ { type: 'pie', @@ -159,10 +174,14 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { }, ], }), - [defaultVariable] + [defaultVariable, defaultTimeVariable] ); + // make some backwards compatability updates to the appstate retrieved from the back end + const appStateCheckedRef = useRef(false); + useEffect(() => { + if (appStateCheckedRef.current) return; if (analysis) { if (!appState) { setVariableUISettings((prev) => ({ @@ -179,11 +198,16 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { ) ); - if (missingMarkerConfigs.length > 0) { + const timeSliderConfigIsMissing = appState.timeSliderConfig == null; + + if (missingMarkerConfigs.length > 0 || timeSliderConfigIsMissing) { setVariableUISettings((prev) => ({ ...prev, [uiStateKey]: { ...appState, + ...(timeSliderConfigIsMissing + ? { timeSliderConfig: defaultAppState.timeSliderConfig } + : {}), markerConfigurations: [ ...appState.markerConfigurations, ...missingMarkerConfigs, @@ -192,6 +216,7 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { })); } } + appStateCheckedRef.current = true; } }, [analysis, appState, setVariableUISettings, uiStateKey, defaultAppState]); @@ -226,5 +251,6 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { setIsSidePanelExpanded: useSetter('isSidePanelExpanded'), setSubsetVariableAndEntity: useSetter('subsetVariableAndEntity'), setViewport: useSetter('viewport'), + setTimeSliderConfig: useSetter('timeSliderConfig'), }; } diff --git a/packages/libs/eda/src/lib/map/analysis/config/eztimeslider.ts b/packages/libs/eda/src/lib/map/analysis/config/eztimeslider.ts new file mode 100644 index 0000000000..8d0931eb21 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/config/eztimeslider.ts @@ -0,0 +1,18 @@ +import { DataElementConstraintRecord } from '../../../core/utils/data-element-constraints'; + +export const timeSliderVariableConstraints: DataElementConstraintRecord[] = [ + { + overlayVariable: { + isRequired: true, + minNumVars: 1, + maxNumVars: 1, + // TODO: testing with SCORE S. mansoni Cluster Randomized Trial study + // however, this study does not have date variable, thus temporarily use below for test purpose + // i.e., additionally allowing 'integer' + // allowedTypes: ['date', 'integer'], + // TODO: below two are correct ones + allowedTypes: ['date'], + // isTemporal: true, + }, + }, +]; diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/eztimeslider.ts b/packages/libs/eda/src/lib/map/analysis/hooks/eztimeslider.ts new file mode 100644 index 0000000000..714db9070b --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/hooks/eztimeslider.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; +import { Variable, useStudyEntities } from '../../../core'; +import { filterVariablesByConstraint } from '../../../core/utils/data-element-constraints'; +import { timeSliderVariableConstraints } from '../config/eztimeslider'; +import { preorder } from '@veupathdb/wdk-client/lib/Utils/TreeUtils'; + +export function useGetDefaultTimeVariableDescriptor() { + const entities = useStudyEntities(); + // filter constraint for time slider inputVariables component + + return useCallback( + function getDefaultTimeVariableDescriptor() { + const temporalVariableTree = filterVariablesByConstraint( + entities[0], + timeSliderVariableConstraints[0]['overlayVariable'] + ); + + // take the first suitable variable from the filtered variable tree + + // first find the first entity with some variables that passed the filter + const defaultTimeSliderEntity = Array.from( + preorder(temporalVariableTree, (node) => node.children ?? []) + ).find((entity) => entity.variables.some(Variable.is)); + + // then take the first variable from it + const defaultTimeSliderVariable = defaultTimeSliderEntity?.variables.find( + Variable.is + ); + + return defaultTimeSliderEntity != null && + defaultTimeSliderVariable != null + ? { + entityId: defaultTimeSliderEntity.id, + variableId: defaultTimeSliderVariable.id, + } + : undefined; + }, + [entities, timeSliderVariableConstraints] + ); +} diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts index 2abe58a43d..b239a33681 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { ReactNode, useMemo } from 'react'; import * as t from 'io-ts'; import { ComputationPlugin } from '../../../core/components/computations/Types'; import { ZeroConfigWithButton } from '../../../core/components/computations/ZeroConfiguration'; @@ -33,12 +33,14 @@ import _ from 'lodash'; interface Props { selectedOverlayConfig?: OverlayConfig | BubbleOverlayConfig; + overlayHelp?: ReactNode; } type StandaloneVizOptions = LayoutOptions & OverlayOptions; export function useStandaloneVizPlugins({ selectedOverlayConfig, + overlayHelp = 'The overlay variable can be selected via the top-right panel.', }: Props): Record { return useMemo(() => { function vizWithOptions( @@ -67,8 +69,7 @@ export function useStandaloneVizPlugins({ return overlayValues; } }, - getOverlayVariableHelp: () => - 'The overlay variable can be selected via the top-right panel.', + getOverlayVariableHelp: () => overlayHelp, }); } diff --git a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts index 9c44d8a44c..b60f795c2b 100644 --- a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts +++ b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts @@ -154,7 +154,7 @@ async function getMostFrequentValues({ : [...sortedValues.slice(0, numValues), UNSELECTED_TOKEN]; } -type GetBinRangesProps = { +export type GetBinRangesProps = { studyId: string; variableId: string; entityId: string; @@ -164,7 +164,7 @@ type GetBinRangesProps = { }; // get the equal spaced bin definitions (for now at least) -async function getBinRanges({ +export async function getBinRanges({ studyId, variableId, entityId, diff --git a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx index d55c85c1ba..286e525558 100644 --- a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx +++ b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx @@ -115,6 +115,7 @@ export function AllAnalyses(props: Props) { synchronizeWithUrl, updateDocumentTitle, activeAnalysisId, + subsettingClient, } = props; const user = useWdkService((wdkService) => wdkService.getCurrentUser(), []); const history = useHistory(); @@ -169,7 +170,9 @@ export function AllAnalyses(props: Props) { removePinnedAnalysis, } = usePinnedAnalyses(analysisClient); - const datasets = useWdkStudyRecords(WDK_STUDY_RECORD_ATTRIBUTES); + const datasets = useWdkStudyRecords(subsettingClient, { + attributes: WDK_STUDY_RECORD_ATTRIBUTES, + }); const { analyses, deleteAnalyses, updateAnalysis, loading, error } = useAnalysisList(analysisClient); diff --git a/packages/libs/eda/src/lib/workspace/PublicAnalysesRoute.tsx b/packages/libs/eda/src/lib/workspace/PublicAnalysesRoute.tsx index 9cd421b38b..6c5a30b91b 100644 --- a/packages/libs/eda/src/lib/workspace/PublicAnalysesRoute.tsx +++ b/packages/libs/eda/src/lib/workspace/PublicAnalysesRoute.tsx @@ -9,18 +9,21 @@ import { AnalysisClient, usePublicAnalysisList } from '../core'; import { useWdkStudyRecords } from '../core/hooks/study'; import { PublicAnalyses } from './PublicAnalyses'; +import SubsettingClient from '../core/api/SubsettingClient'; export interface Props { analysisClient: AnalysisClient; + subsettingClient: SubsettingClient; exampleAnalysesAuthor?: number; } export function PublicAnalysesRoute({ analysisClient, + subsettingClient, exampleAnalysesAuthor, }: Props) { const publicAnalysisListState = usePublicAnalysisList(analysisClient); - const studyRecords = useWdkStudyRecords(); + const studyRecords = useWdkStudyRecords(subsettingClient); const location = useLocation(); const makeAnalysisLink = useCallback( diff --git a/packages/libs/eda/src/lib/workspace/StudyList.tsx b/packages/libs/eda/src/lib/workspace/StudyList.tsx index cd8ddf9598..5da035c592 100644 --- a/packages/libs/eda/src/lib/workspace/StudyList.tsx +++ b/packages/libs/eda/src/lib/workspace/StudyList.tsx @@ -10,19 +10,23 @@ import { usePermissions } from '@veupathdb/study-data-access/lib/data-restrictio import { useWdkStudyRecords } from '../core/hooks/study'; import { getStudyAccess } from '@veupathdb/study-data-access/lib/shared/studies'; +import { SubsettingClient } from '../core/api'; interface StudyListProps { baseUrl: string; + subsettingClient: SubsettingClient; } /** * Displays a list of links to various available studies. */ export function StudyList(props: StudyListProps) { - const { baseUrl } = props; + const { baseUrl, subsettingClient } = props; const studyRecordAttributes = useMemo(() => ['study_access'], []); - const datasets = useWdkStudyRecords(studyRecordAttributes); + const datasets = useWdkStudyRecords(subsettingClient, { + attributes: studyRecordAttributes, + }); const permissions = usePermissions(); @@ -34,18 +38,16 @@ export function StudyList(props: StudyListProps) {

    EDA Workspace

    Choose a study

      - {datasets - .filter((dataset) => dataset.attributes.eda_study_id != null) - .map((dataset) => { - return ( -
    • - - {safeHtml(dataset.displayName)} [ - {getStudyAccess(dataset)}] - -
    • - ); - })} + {datasets.map((dataset) => { + return ( +
    • + + {safeHtml(dataset.displayName)} [ + {getStudyAccess(dataset)}] + +
    • + ); + })}
    ); diff --git a/packages/libs/eda/src/lib/workspace/WorkspaceRouter.tsx b/packages/libs/eda/src/lib/workspace/WorkspaceRouter.tsx index 4115654bdb..ad93da0128 100644 --- a/packages/libs/eda/src/lib/workspace/WorkspaceRouter.tsx +++ b/packages/libs/eda/src/lib/workspace/WorkspaceRouter.tsx @@ -183,13 +183,16 @@ export function WorkspaceRouter({ } + render={() => ( + + )} /> ( )} diff --git a/packages/libs/http-utils/src/FetchClient.ts b/packages/libs/http-utils/src/FetchClient.ts index c305aebef0..6e0ff116c8 100644 --- a/packages/libs/http-utils/src/FetchClient.ts +++ b/packages/libs/http-utils/src/FetchClient.ts @@ -104,11 +104,14 @@ export abstract class FetchClient { return await transformResponse(responseBody); } - const fetchError = new FetchClientError( - `${request.method.toUpperCase()} ${request.url}: ${response.status} ${ - response.statusText - }${'\n'}${await response.text()}` - ); + const { status, statusText } = response; + const { headers, method, url } = request; + const traceid = headers.get('traceid'); + const fetchError = new FetchClientError(` + ${status} ${statusText}: ${method.toUpperCase()} ${url} + ${traceid != null ? 'Traceid: ' + traceid : ''} + + ${await response.text()}`); this.onNonSuccessResponse?.(fetchError); throw fetchError; } diff --git a/packages/libs/study-data-access/src/data-restriction/DataRestrictionDaemon.jsx b/packages/libs/study-data-access/src/data-restriction/DataRestrictionDaemon.jsx index 050d4ac59f..490951d846 100644 --- a/packages/libs/study-data-access/src/data-restriction/DataRestrictionDaemon.jsx +++ b/packages/libs/study-data-access/src/data-restriction/DataRestrictionDaemon.jsx @@ -27,9 +27,9 @@ function DataRestrictionDaemon(props) { useEffect(() => { clearRestrictions(); - }, [location.pathname]); + }, [location.pathname, clearRestrictions]); - const permissionsValue = usePermissions(); + const permissionsValue = usePermissions({ force: true }); if (dataRestriction == null || user == null || permissionsValue.loading) return null; diff --git a/packages/libs/study-data-access/src/data-restriction/permissionsHooks.ts b/packages/libs/study-data-access/src/data-restriction/permissionsHooks.ts index 258733cf09..a7975b5a09 100644 --- a/packages/libs/study-data-access/src/data-restriction/permissionsHooks.ts +++ b/packages/libs/study-data-access/src/data-restriction/permissionsHooks.ts @@ -1,7 +1,5 @@ import { useMemo } from 'react'; -import { defaultMemoize } from 'reselect'; - import { useWdkService } from '@veupathdb/wdk-client/lib/Hooks/WdkServiceHook'; import { User } from '@veupathdb/wdk-client/lib/Utils/WdkUser'; @@ -13,22 +11,28 @@ export type AsyncUserPermissions = | { loading: true } | { loading: false; permissions: UserPermissions }; -const memoizedPermissionsCheck = defaultMemoize(function ( - user: User, - studyAccessApi: StudyAccessApi -) { - return checkPermissions(user, studyAccessApi); -}); +// Caches permissions until the location changes +export const cachedPermissionCheck = (function () { + let result: Promise; + let lastLocation = window.location.href; + return function cachedPermissionCheck( + user: User, + studyAccessApi: StudyAccessApi + ): Promise { + if (result == null || lastLocation !== window.location.href) { + lastLocation = window.location.href; + result = checkPermissions(user, studyAccessApi); + } + return result; + }; +})(); export function usePermissions(): AsyncUserPermissions { const studyAccessApi = useStudyAccessApi(); const permissions = useWdkService( async (wdkService) => - memoizedPermissionsCheck( - await wdkService.getCurrentUser({ force: true }), - studyAccessApi - ), + cachedPermissionCheck(await wdkService.getCurrentUser(), studyAccessApi), [studyAccessApi] ); diff --git a/packages/libs/study-data-access/src/study-access/components/StudyAccess.tsx b/packages/libs/study-data-access/src/study-access/components/StudyAccess.tsx index 50929ab488..a88d9ebf9f 100644 --- a/packages/libs/study-data-access/src/study-access/components/StudyAccess.tsx +++ b/packages/libs/study-data-access/src/study-access/components/StudyAccess.tsx @@ -40,8 +40,8 @@ export function StudyAccess({
    - + {openDialogConfig && }
    ); diff --git a/packages/libs/study-data-access/src/study-access/studyAccessHooks.tsx b/packages/libs/study-data-access/src/study-access/studyAccessHooks.tsx index a3a31cfde9..9924289165 100644 --- a/packages/libs/study-data-access/src/study-access/studyAccessHooks.tsx +++ b/packages/libs/study-data-access/src/study-access/studyAccessHooks.tsx @@ -289,7 +289,7 @@ export function useStaffTableSectionConfig( } : { status: 'success', - title: 'Staff', + title: 'VEuPathDB Staff', value: { rows: value.result.data.map(({ user, staffId, isOwner }) => ({ userId: user.userId, @@ -545,7 +545,7 @@ export function useEndUserTableSectionConfig( } : { status: 'success', - title: 'End Users', + title: 'Data Requesters', value: { rows: value.result.data.map( ({ @@ -738,7 +738,7 @@ export function useHistoryTableSectionConfig( } : { status: 'success', - title: 'End User Table Updates', + title: 'Data Requester Table Updates', value: { rows: value.result.results .filter( @@ -1127,7 +1127,7 @@ function makeProviderTableActions( element: ( ), callback: () => { @@ -1189,7 +1189,7 @@ function makeProviderTableActions( disabled={selection.length === 0} > - Remove {selection.length === 1 ? 'Provider' : 'Providers'} + Remove {selection.length === 1 ? 'Team member' : 'Team members'} ), callback: async (selection: ProviderTableFullRow[]) => { @@ -1226,7 +1226,7 @@ function makeEndUserTableActions( element: ( ), callback: () => { @@ -1297,7 +1297,8 @@ function makeEndUserTableActions( disabled={selection.length === 0} > - Remove {selection.length === 1 ? 'End User' : 'End Users'} + Remove{' '} + {selection.length === 1 ? 'Data Requester' : 'Data Requesters'} ), callback: async (selection: EndUserTableFullRow[]) => { diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx index f47567d44b..220722c6e4 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx @@ -390,6 +390,23 @@ class MembershipTable extends React.PureComponent { } handleSearchTermChange(searchTerm) { + // When we are not on page 1, we need to determine if our currentPage position remains viable + // or if it should get reset to page 1 (see note in TableFilter.tsx's handleSearch callback definition) + if (this.props.activeFieldState.currentPage !== 1) { + const numberOfFilteredRows = filterBySearchTerm( + this.getRows(), + searchTerm + ).length; + const shouldResetPaging = + numberOfFilteredRows <= + this.props.activeFieldState.rowsPerPage * + (this.props.activeFieldState.currentPage - 1); + this.props.onMemberSearch( + this.props.activeField, + searchTerm, + shouldResetPaging + ); + } this.props.onMemberSearch(this.props.activeField, searchTerm); } diff --git a/packages/libs/wdk-client/src/Components/InputControls/SaveableTextEditor.tsx b/packages/libs/wdk-client/src/Components/InputControls/SaveableTextEditor.tsx index 8196196fd2..bb55b85705 100644 --- a/packages/libs/wdk-client/src/Components/InputControls/SaveableTextEditor.tsx +++ b/packages/libs/wdk-client/src/Components/InputControls/SaveableTextEditor.tsx @@ -24,7 +24,9 @@ interface Props extends InputPropsWithoutOnChange { onSave: (value: string) => void; multiLine?: boolean; className?: string; - displayValue?: (value: string, handleEdit: () => void) => React.ReactNode; + displayValue?: + | React.ReactNode + | ((value: string, handleEdit: () => void) => React.ReactNode); emptyText?: string; } diff --git a/packages/libs/wdk-client/src/StoreModules/QuestionStoreModule.ts b/packages/libs/wdk-client/src/StoreModules/QuestionStoreModule.ts index 6c026a4ab9..ec875f85fa 100644 --- a/packages/libs/wdk-client/src/StoreModules/QuestionStoreModule.ts +++ b/packages/libs/wdk-client/src/StoreModules/QuestionStoreModule.ts @@ -535,34 +535,6 @@ const observeLoadQuestionSuccess: QuestionEpic = (action$) => ) ); -const observeStoreUpdatedParams: QuestionEpic = ( - action$, - state$, - { paramValueStore } -) => - action$.pipe( - ofType(UPDATE_PARAM_VALUE), - mergeMap(async (action: UpdateParamValueAction) => { - const searchName = action.payload.searchName; - const questionState = state$.value.question.questions[searchName]; - - if (questionState == null) { - return EMPTY; - } - - const { globalParamMapping, paramValues: newParamValues } = questionState; - - await updateLastParamValues( - paramValueStore, - searchName, - newParamValues, - globalParamMapping - ); - return EMPTY; - }), - mergeAll() - ); - type ActionAffectingGroupCount = | ChangeGroupVisibilityAction | UpdateParamValueAction; @@ -757,13 +729,6 @@ const observeQuestionSubmit: QuestionEpic = (action$, state$, services) => wdkWeight: Number.isNaN(weight) ? DEFAULT_STEP_WEIGHT : weight, }; - updateLastParamValues( - services.paramValueStore, - searchName, - paramValues, - globalParamMapping - ); - if (submissionMetadata.type === 'edit-step') { return of( requestReviseStep( @@ -957,6 +922,16 @@ const observeQuestionSubmit: QuestionEpic = (action$, state$, services) => ) ); }) + .then((nextAction) => { + const { paramValues, globalParamMapping, question } = questionState; + updateLastParamValues( + services.paramValueStore, + question.urlSegment, + paramValues, + globalParamMapping + ); + return nextAction; + }) ).pipe( mergeAll(), catchError((error: any) => @@ -989,7 +964,6 @@ export const observeQuestion: QuestionEpic = combineEpics( observeLoadQuestion, observeLoadQuestionSuccess, observeAutoRun, - observeStoreUpdatedParams, observeUpdateDependentParams, observeLoadGroupCount, observeQuestionSubmit, @@ -1063,13 +1037,6 @@ async function loadQuestion( const wdkWeight = step == null ? undefined : step.searchConfig.wdkWeight; - await updateLastParamValues( - paramValueStore, - searchName, - paramValues, - globalParamMapping - ); - return questionLoaded({ autoRun, prepopulateWithLastParamValues, @@ -1137,7 +1104,7 @@ function extracParamValues( return pick(initialParamData, paramNames); } -function updateLastParamValues( +export function updateLastParamValues( paramValueStore: ParamValueStore, searchName: string, newParamValues: ParameterValues, diff --git a/packages/libs/wdk-client/src/Views/Question/Params/EnumParam.tsx b/packages/libs/wdk-client/src/Views/Question/Params/EnumParam.tsx index 6aa380ccb9..98cbb17391 100644 --- a/packages/libs/wdk-client/src/Views/Question/Params/EnumParam.tsx +++ b/packages/libs/wdk-client/src/Views/Question/Params/EnumParam.tsx @@ -25,6 +25,7 @@ import { isMultiPick, toMultiValueString, toMultiValueArray, + countInBounds, } from '../../../Views/Question/Params/EnumParamUtils'; // TODO: Move TreeBox state into TreeBoxEnumParam component @@ -39,11 +40,16 @@ export default createParamModule({ function isParamValueValid(context: Context) { let value = context.paramValues[context.parameter.name]; - return ( - typeof value === 'string' && - (context.parameter.type !== 'multi-pick-vocabulary' || - isValidEnumJson(value)) - ); + if (context.parameter.type === 'multi-pick-vocabulary') { + if (!isValidEnumJson(value)) return false; + const typedValue = toMultiValueArray(value); + return countInBounds( + typedValue.length, + context.parameter.minSelectedCount, + context.parameter.maxSelectedCount + ); + } + return typeof value === 'string'; } function isType(parameter: Parameter): parameter is EnumParam { diff --git a/packages/libs/wdk-client/src/Views/Question/Params/EnumParamUtils.ts b/packages/libs/wdk-client/src/Views/Question/Params/EnumParamUtils.ts index c380c1da38..cf9a7ccc46 100644 --- a/packages/libs/wdk-client/src/Views/Question/Params/EnumParamUtils.ts +++ b/packages/libs/wdk-client/src/Views/Question/Params/EnumParamUtils.ts @@ -37,9 +37,14 @@ export function isMultiPick(parameter: Parameter): boolean { } export function isValidEnumJson(value: string): boolean { - const validationResult = enumJsonDecoder(value); - - return validationResult.status === 'ok'; + try { + const parsedValue = JSON.parse(value); + const validationResult = enumJsonDecoder(parsedValue); + + return validationResult.status === 'ok'; + } catch { + return false; + } } const enumJsonDecoder = arrayOf(string); diff --git a/packages/libs/wdk-client/src/Views/Question/Params/SelectionInfo.tsx b/packages/libs/wdk-client/src/Views/Question/Params/SelectionInfo.tsx index 98ffb25bcd..ebe9cf5c30 100644 --- a/packages/libs/wdk-client/src/Views/Question/Params/SelectionInfo.tsx +++ b/packages/libs/wdk-client/src/Views/Question/Params/SelectionInfo.tsx @@ -22,7 +22,7 @@ export default function SelectionInfo(props: Props) { ? `${ isSingleSelect ? '' : 'between ' + minSelectedCount + ' and ' }${maxSelectedCount} ${valueDescription(maxSelectedCount)} required` - : hasMin && selectedCount > 0 + : hasMin && (selectedCount > 0 || minSelectedCount === 1) ? `at least ${minSelectedCount} ${valueDescription( minSelectedCount )} required` diff --git a/packages/libs/web-common/src/components/Announcements.jsx b/packages/libs/web-common/src/components/Announcements.jsx index 794f9629fb..d1a6daac5e 100644 --- a/packages/libs/web-common/src/components/Announcements.jsx +++ b/packages/libs/web-common/src/components/Announcements.jsx @@ -360,7 +360,7 @@ const siteAnnouncements = [ > GWHAMMI00000000 - may differ from this, especially for non-coding genes. + ) may differ from this, especially for non-coding genes.
    ); } @@ -392,7 +392,7 @@ const siteAnnouncements = [ > GWHAMMK00000000 - may differ from this, especially for non-coding genes. + ) may differ from this, especially for non-coding genes.
    ); } @@ -424,7 +424,7 @@ const siteAnnouncements = [ > GWHAMMH00000000.1 - may differ from this, especially for non-coding genes. + ) may differ from this, especially for non-coding genes. ); } diff --git a/packages/libs/web-common/src/components/SiteSearch/SiteSearch.tsx b/packages/libs/web-common/src/components/SiteSearch/SiteSearch.tsx index 933f041f76..2cd26cec9a 100644 --- a/packages/libs/web-common/src/components/SiteSearch/SiteSearch.tsx +++ b/packages/libs/web-common/src/components/SiteSearch/SiteSearch.tsx @@ -77,6 +77,7 @@ import { Link, useHistory } from 'react-router-dom'; import { CellProps, Column } from 'react-table'; import { CommonModal } from '@veupathdb/wdk-client/lib/Components'; import './SiteSearch.scss'; +import { usePermissions } from '@veupathdb/study-data-access/lib/data-restriction/permissionsHooks'; interface Props { loading: boolean; @@ -1416,14 +1417,14 @@ function VariableValueStudyTable(props: { summaryField: SiteSearchDocumentTypeField; }) { const { document, summaryField } = props; - const datasets = useDatasets(); + const permissionsResult = usePermissions(); function makeLink(studyId: string, entityId?: string, variableId?: string) { - if (datasets == null) return ''; - const dataset = datasets?.records.find( - (d) => d.attributes.eda_study_id === studyId - ); - // if (dataset == null) throw new Error("Cannot find dataset with eda_study_id = '" + studyId + "'."); - const base = makeEdaRoute(dataset?.id[0].value) + '/new'; + if (permissionsResult.loading) return ''; + const datasetId = + Object.entries(permissionsResult.permissions.perDataset).find( + ([_datasetId, entry]) => entry?.studyId === studyId + )?.[0] ?? studyId; + const base = makeEdaRoute(datasetId) + '/new'; if (entityId == null) return base; if (variableId == null) return base + `/variables/${entityId}`; return base + `/variables/${entityId}/${variableId}`; @@ -1616,9 +1617,7 @@ const getDatasetsOnce = memoize((wdkService: WdkService) => parameters: {}, }, }, - { - attributes: ['eda_study_id'], - } + {} ) ); @@ -1627,14 +1626,12 @@ function useDatasets() { } function useDatasetId(edaStudyId: string) { - const datasets = useDatasets(); - if (datasets == null) return; - const dataset = datasets.records.find( - (d) => d.attributes.eda_study_id === edaStudyId - ); - // if (dataset == null) throw new Error("Could not find a dataset with eda_study_id = " + edaStudyId); - if (dataset == null) return edaStudyId; - return dataset.id[0].value; + const permissionsResult = usePermissions(); + if (permissionsResult.loading) return; + const datasetId = Object.entries( + permissionsResult.permissions.perDataset + ).find(([_datasetId, entry]) => entry?.studyId === edaStudyId)?.[0]; + return datasetId ?? edaStudyId; } interface ColumnDef { diff --git a/packages/libs/web-common/src/styles/AllSites.scss b/packages/libs/web-common/src/styles/AllSites.scss index 694b37ad8c..2997708bd3 100644 --- a/packages/libs/web-common/src/styles/AllSites.scss +++ b/packages/libs/web-common/src/styles/AllSites.scss @@ -58,7 +58,7 @@ html { .main-stack footer { position: relative; - z-index: 1; + z-index: 0; } ._BodyLayer { diff --git a/packages/libs/web-common/src/wrapWdkService.js b/packages/libs/web-common/src/wrapWdkService.js index 81207d6946..6b5a33ff6d 100644 --- a/packages/libs/web-common/src/wrapWdkService.js +++ b/packages/libs/web-common/src/wrapWdkService.js @@ -1,10 +1,9 @@ -import { useEda } from './config'; import { ok } from '@veupathdb/wdk-client/lib/Utils/Json'; export default (wdkService) => ({ ...wdkService, - async getStudies(attributes, tables = []) { - const datasets = await wdkService.sendRequest(ok, { + getStudies(attributes, tables = []) { + return wdkService.sendRequest(ok, { useCache: true, cacheId: 'studies', method: 'post', @@ -24,15 +23,6 @@ export default (wdkService) => ({ }, }), }); - - if (useEda) { - // TODO Mark non-eda studies as prerelease, instead of removing - datasets.records = datasets.records.filter( - (record) => record.attributes.eda_study_id != null - ); - } - - return datasets; }, getSiteMessages: () => wdkService.sendRequest(ok, { diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.tsx index 57684001a9..80466313b0 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.tsx @@ -1,7 +1,14 @@ -import React, { useMemo, useState } from 'react'; -import { useHistory, useLocation, useRouteMatch } from 'react-router'; +import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; import { parseQueryString } from '@veupathdb/wdk-client/lib/Core/RouteEntry'; +import { RootState } from '@veupathdb/wdk-client/lib/Core/State/Types'; +import { useWdkDependenciesEffect } from '@veupathdb/wdk-client/lib/Hooks/WdkDependenciesEffect'; +import { updateLastParamValues } from '@veupathdb/wdk-client/lib/StoreModules/QuestionStoreModule'; import { SearchConfig } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; +import { isParamValueValid } from '@veupathdb/wdk-client/lib/Views/Question/Params'; +import { isEqual } from 'lodash'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { useHistory, useLocation, useRouteMatch } from 'react-router'; import { DownloadsFilter } from './DownloadsFilter'; import { DownloadsTable } from './DownloadsTable'; import './Downloads.scss'; @@ -11,7 +18,6 @@ const TABLE_QUESTION_NAME = 'GetAllFileRecords'; const BULK_QUESTION_NAME = 'GetFileRecordsByID'; export function Downloads() { - const [searchConfig, setSearchConfig] = useState(); const location = useLocation(); const history = useHistory(); const match = useRouteMatch(); @@ -20,6 +26,43 @@ export function Downloads() { [history, location, match] ); + const { searchConfig, isValid } = useSelector( + (state: RootState) => { + const questionState = state.question.questions[TABLE_QUESTION_NAME]; + const searchConfig: SearchConfig | undefined = + questionState?.paramValues && { + parameters: questionState.paramValues, + }; + const isValid = questionState?.paramValues + ? questionState.question.parameters.every((parameter) => + isParamValueValid( + { + searchName: TABLE_QUESTION_NAME, + paramValues: questionState?.paramValues, + parameter, + }, + questionState.paramUIState[parameter.name] + ) + ) + : true; + return { searchConfig, isValid }; + }, + (left, right) => isEqual(left, right) + ); + + useWdkDependenciesEffect( + ({ paramValueStore }) => { + if (searchConfig == null || !isValid) return; + updateLastParamValues( + paramValueStore, + TABLE_QUESTION_NAME, + searchConfig?.parameters, + undefined + ); + }, + [searchConfig, isValid] + ); + return (

    Download Data Files

    @@ -31,17 +74,24 @@ export function Downloads() {
    - {searchConfig && ( + {!isValid ? ( + + ) : searchConfig ? ( - )} + ) : null} ); } diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsFilter.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsFilter.tsx index a51d55a0bc..621253c53c 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsFilter.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsFilter.tsx @@ -1,20 +1,16 @@ +import { mapValues } from 'lodash'; +import React, { useMemo } from 'react'; import { SubmissionMetadata } from '@veupathdb/wdk-client/lib/Actions/QuestionActions'; import { QuestionController } from '@veupathdb/wdk-client/lib/Controllers'; -import { RootState } from '@veupathdb/wdk-client/lib/Core/State/Types'; -import { SearchConfig } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; import { Props as FormProps, renderDefaultParamGroup, } from '@veupathdb/wdk-client/lib/Views/Question/DefaultQuestionForm'; -import React, { useEffect, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { mapValues } from 'lodash'; interface Props { recordName: string; questionName: string; initialParamData: Record; - onChange: (searchConfig: SearchConfig) => void; } const submissionMetadata: SubmissionMetadata = { @@ -23,17 +19,7 @@ const submissionMetadata: SubmissionMetadata = { }; export function DownloadsFilter(props: Props) { - const { recordName, questionName, initialParamData, onChange } = props; - const paramValues = useSelector( - (state: RootState) => state.question.questions[questionName]?.paramValues - ); - useEffect(() => { - if (paramValues) { - onChange({ - parameters: paramValues, - }); - } - }, [paramValues, onChange]); + const { recordName, questionName, initialParamData } = props; return ( => { - if (!showInteractiveMaps) return []; - try { - const anwser = await wdkService.getAnswerJson( - { - searchName: 'AllDatasets', - searchConfig: { - parameters: {}, - }, - }, - { - attributes: ['eda_study_id'], - } - ); - return anwser.records - .filter((record) => record.attributes.eda_study_id != null) - .map((record) => ({ - key: `map-${record.id[0].value}`, - display: record.displayName, - type: 'reactRoute', - url: `/workspace/maps/${record.id[0].value}/new`, - target: '_blank', - })); - } catch (error) { - console.error(error); - return [ - { - key: 'maps-error', - display: ( - <> - Could not load map data - - ), - type: 'custom', - }, - ]; - } - }, - [showInteractiveMaps] + const mapMenuItemsQuestion = useSelector((state: RootState) => + state.globalData.questions?.find( + (q) => q.urlSegment === QUESTION_FOR_MAP_DATASETS + ) + ); + const mapStudy = useWdkService( + (wdkService) => + wdkService + .getRecord('dataset', [{ name: 'dataset_id', value: 'DS_480c976ef9' }]) + .catch(() => {}), + [] ); + // const showInteractiveMaps = mapMenuItemsQuestion != null; + // const mapMenuItems = useMapMenuItems(mapMenuItemsQuestion); + const showInteractiveMaps = projectId === VectorBase && !!useEda; // type: reactRoute, webAppRoute, externalLink, subMenu, custom const fullMenuItemEntries: HeaderMenuItemEntry[] = [ @@ -600,24 +586,39 @@ const useHeaderMenuItems = ( include: [EuPathDB, UniDB], }, }, + // { + // key: 'maps-alpha', + // display: ( + // <> + // Interactive maps BETA + // + // ), + // type: 'subMenu', + // metadata: { + // test: () => showInteractiveMaps, + // }, + // items: mapMenuItems ?? [ + // { + // key: 'maps-loading', + // type: 'custom', + // display: , + // }, + // ], + // }, { - key: 'maps-alpha', + type: 'reactRoute', display: ( <> - Interactive maps BETA + MapVEu - {safeHtml(mapStudy?.displayName ?? '')}{' '} + BETA ), - type: 'subMenu', + key: 'map--mega-study', + url: '/workspace/maps/DS_480c976ef9/new', + target: '_blank', metadata: { - test: () => showInteractiveMaps, + test: () => showInteractiveMaps && mapStudy != null, }, - items: mapMenuItems ?? [ - { - key: 'maps-loading', - type: 'custom', - display: , - }, - ], }, { key: 'pubcrawler', @@ -1192,3 +1193,47 @@ const VEuPathDBSnackbarProvider = makeSnackbarProvider( export const VEuPathDBHomePage = connect(mapStateToProps)( VEuPathDBHomePageView ); + +function useMapMenuItems(question?: Question) { + const { wdkService } = useNonNullableContext(WdkDependenciesContext); + const studyAccessApi = useStudyAccessApi(); + const subsettingClient = useMemo( + () => new SubsettingClient({ baseUrl: edaServiceUrl }, wdkService), + [wdkService] + ); + const [mapMenuItems, setMapMenuItems] = useState(); + useEffect(() => { + if (question == null) return; + getWdkStudyRecords( + { studyAccessApi, subsettingClient, wdkService }, + { searchName: question.urlSegment } + ).then( + (records) => { + const menuItems = records.map( + (record): HeaderMenuItem => ({ + key: `map-${record.id[0].value}`, + display: record.displayName, + type: 'reactRoute', + url: `/workspace/maps/${record.id[0].value}/new`, + }) + ); + setMapMenuItems(menuItems); + }, + (error) => { + console.error(error); + setMapMenuItems([ + { + key: 'map-error', + type: 'custom', + display: ( + <> + Unable to load map datasets. + + ), + }, + ]); + } + ); + }, [question, studyAccessApi, subsettingClient, wdkService]); + return mapMenuItems; +} diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/records/GeneRecordClasses.GeneRecordClass.jsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/records/GeneRecordClasses.GeneRecordClass.jsx index cbe7dcb025..6e5a251fa3 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/records/GeneRecordClasses.GeneRecordClass.jsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/records/GeneRecordClasses.GeneRecordClass.jsx @@ -296,6 +296,7 @@ export function RecordTable(props) { ); + case 'LOPITtryp': case 'GOTerms': return ; diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/record-page-new-feature.scss b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/record-page-new-feature.scss index a22ca1e786..3cd2d530d3 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/record-page-new-feature.scss +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/record-page-new-feature.scss @@ -1,6 +1,4 @@ // This CSS adds the NEW icon next to the attribute name on record pages -#alphafold_url, -#AlphaFoldLinkouts, #Cellxgene { .wdk-CollapsibleSectionHeader:after { content: url('~@veupathdb/wdk-client/lib/Core/Style/images/new-feature.png'); diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/styles/home-page-layout.scss b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/styles/home-page-layout.scss index bf1ed39d0a..53f3ae55ee 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/styles/home-page-layout.scss +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/styles/home-page-layout.scss @@ -55,6 +55,7 @@ body.vpdb-Body .vpdb-RootContainer { position: fixed; bottom: 0; width: 100%; + z-index: 1; } .vpdb-Footer__thin { diff --git a/yarn.lock b/yarn.lock index bff2e0e8ea..a73a767f59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8082,6 +8082,7 @@ __metadata: "@veupathdb/react-scripts": "workspace:^" "@veupathdb/tsconfig": "workspace:^" "@visx/axis": ^3.1.0 + "@visx/brush": ^3.0.1 "@visx/gradient": ^1.0.0 "@visx/group": ^1.0.0 "@visx/hierarchy": ^1.0.0 @@ -9093,6 +9094,23 @@ __metadata: languageName: node linkType: hard +"@visx/brush@npm:^3.0.1": + version: 3.0.1 + resolution: "@visx/brush@npm:3.0.1" + dependencies: + "@visx/drag": 3.0.1 + "@visx/event": 3.0.1 + "@visx/group": 3.0.0 + "@visx/scale": 3.0.0 + "@visx/shape": 3.0.0 + classnames: ^2.3.1 + prop-types: ^15.6.1 + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + checksum: 7e7d5a4b9663ca8b623245e7ba4dc63eeee912ca28cf0fff9ca15672e24984a2445b45fd8e8281ad309621dac0e7bad5413eedb1a93da66eba9572f0c75bfb03 + languageName: node + linkType: hard + "@visx/clip-path@npm:1.7.0": version: 1.7.0 resolution: "@visx/clip-path@npm:1.7.0" @@ -9148,6 +9166,20 @@ __metadata: languageName: node linkType: hard +"@visx/drag@npm:3.0.1": + version: 3.0.1 + resolution: "@visx/drag@npm:3.0.1" + dependencies: + "@types/react": "*" + "@visx/event": 3.0.1 + "@visx/point": 3.0.1 + prop-types: ^15.5.10 + peerDependencies: + react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 + checksum: 256e4a7b649e29b2112f77a57a657cd3aedb76b605ebc8b49e6c3d4b152fea5d81de7820060dc569dda0c81a0e7b6668ce9fe071536198b26cf2ebd655a6e6a9 + languageName: node + linkType: hard + "@visx/drag@npm:3.3.0": version: 3.3.0 resolution: "@visx/drag@npm:3.3.0" @@ -9172,6 +9204,16 @@ __metadata: languageName: node linkType: hard +"@visx/event@npm:3.0.1": + version: 3.0.1 + resolution: "@visx/event@npm:3.0.1" + dependencies: + "@types/react": "*" + "@visx/point": 3.0.1 + checksum: 0cb4dd578bbe54bd428a0cfae9e64db141b039c2c6c1412d1cd1d04e1613d193212b031124948ca3b7eed877cdea87d161cc3a008b973c6f217e21a8a2dd27b0 + languageName: node + linkType: hard + "@visx/event@npm:3.3.0": version: 3.3.0 resolution: "@visx/event@npm:3.3.0"