refactor(ui): Migrate components to ui-next (#4930)

Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
This commit is contained in:
AlirezaandDan Rukas authored and GitHub committed 2025-04-08 12:06:07 -04:00
1 parent 1b8729dded
commit 2bdf4afa4d
80 files changed
+2196 -521

No files matched your search

@@ -40,8 +40,11 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, forwardRef) => {
const Comp = asChild ? Slot : 'button';
const dataCY = props.dataCY || `${props.name}-btn`;
return (
<Comp
data-cy={dataCY}
className={cn(buttonVariants({ variant, size }), className)}
ref={forwardRef}
{...props}
@@ -13,7 +13,7 @@ const Command = React.forwardRef<
<CommandPrimitive
ref={ref}
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
'bg-background text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
@@ -40,10 +40,10 @@ const CommandInput = React.forwardRef<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b px-3"
className="border-input flex items-center border-b px-3"
cmdk-input-wrapper=""
>
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<MagnifyingGlassIcon className="text-primary mr-2 h-4 w-4 shrink-0" />
<CommandPrimitive.Input
ref={ref}
className={cn(
@@ -76,7 +76,7 @@ const CommandEmpty = React.forwardRef<
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-base"
className="py-5 text-center text-base"
{...props}
/>
));
@@ -105,7 +105,7 @@ const CommandSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('bg-border -mx-1 h-px', className)}
className={cn('bg-input -mx-1 h-px', className)}
{...props}
/>
));
@@ -118,7 +118,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-base outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
'aria-selected:bg-primary/30 aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-base outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className
)}
{...props}
@@ -65,7 +65,7 @@ function Header({
onClick={onClickReturn}
data-cy="return-to-work-list"
>
{isReturnEnabled && <Icons.ArrowLeft className="text-primary w-8" />}
{isReturnEnabled && <Icons.ArrowLeft className="text-primary ml-1 h-7 w-7" />}
<div className="ml-1">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Icons.OHIFLogo />}
</div>
@@ -0,0 +1,103 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Icons } from '@ohif/ui-next';
import { Button } from '../Button';
export enum showDialogOption {
NeverShowDialog = 'never',
AlwaysShowDialog = 'always',
ShowOnceAndConfigure = 'configure',
}
const InvestigationalUseDialog = ({
dialogConfiguration = {
option: showDialogOption.AlwaysShowDialog,
},
}) => {
const { option, days } = dialogConfiguration;
const [isHidden, setIsHidden] = useState(true);
useEffect(() => {
const dialogLocalState = localStorage.getItem('investigationalUseDialog');
const dialogSessionState = sessionStorage.getItem('investigationalUseDialog');
switch (option) {
case showDialogOption.NeverShowDialog:
setIsHidden(true);
break;
case showDialogOption.AlwaysShowDialog:
setIsHidden(!!dialogSessionState);
break;
case showDialogOption.ShowOnceAndConfigure:
if (dialogLocalState) {
const { expiryDate } = JSON.parse(dialogLocalState);
const isExpired = new Date() > new Date(expiryDate);
setIsHidden(!isExpired);
} else {
setIsHidden(false);
}
break;
default:
setIsHidden(true);
}
}, [option, days]);
const handleConfirmAndHide = () => {
const expiryDate = new Date();
switch (option) {
case showDialogOption.ShowOnceAndConfigure:
expiryDate.setDate(expiryDate.getDate() + days);
localStorage.setItem('investigationalUseDialog', JSON.stringify({ expiryDate }));
break;
case showDialogOption.AlwaysShowDialog:
sessionStorage.setItem('investigationalUseDialog', 'hidden');
break;
}
setIsHidden(true);
};
if (isHidden) {
return null;
}
return (
<div className="fixed bottom-2 z-50 flex h-[86px] w-full justify-center">
<div className="bg-secondary-dark border-primary-dark flex w-[90%] items-center justify-between rounded-lg border-2 pl-[22px] pr-[22px] pt-[10px] pb-[10px] shadow-lg">
<div className="flex items-center gap-4">
<Icons.InvestigationalUse className="h-18 w-18" />
<div className="flex flex-col">
<div className="text-[19px] text-white">
OHIF Viewer is{' '}
<span className="text-primary-light">for investigational use only</span>
</div>
<div className="text-[13px] text-white">
<span
className="text-primary-active cursor-pointer"
onClick={() => window.open('https://ohif.org/', '_blank')}
>
Learn more about OHIF Viewer
</span>
</div>
</div>
</div>
<Button
onClick={handleConfirmAndHide}
className="bg-primary-main"
dataCY="confirm-and-hide-button"
>
Confirm and Hide
</Button>
</div>
</div>
);
};
InvestigationalUseDialog.propTypes = {
dialogConfiguration: PropTypes.shape({
option: PropTypes.oneOf(Object.values(showDialogOption)).isRequired,
days: PropTypes.number,
}),
};
export default InvestigationalUseDialog;
@@ -0,0 +1,3 @@
import InvestigationalUseDialog from './InvestigationalUseDialog';
export default InvestigationalUseDialog;
@@ -0,0 +1,92 @@
import React, { useState, useEffect } from 'react';
import { CheckIcon } from '@radix-ui/react-icons';
import cloneDeep from 'lodash.clonedeep';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../Command/Command'; // Assuming Command is in ../Command relative path
import { cn } from '../../lib/utils'; // Assuming cn utility is here
import { ScrollArea } from '../ScrollArea/ScrollArea'; // Import ScrollArea
interface PropType {
labellingDoneCallback: (label: string) => void;
measurementData: any; // Keep any for now as per instructions
labelData: LabelInfo[]; // Use LabelInfo[] for better type safety
exclusive: boolean;
hide: () => void; // Add hide prop explicitly
placeholder?: string; // Optional placeholder for input
emptyMessage?: string; // Optional message for empty results
initialLabel?: string; // Optional initial selected label
}
export interface LabelInfo {
label: string;
value: string;
}
// Convert to Functional Component
const LabellingFlow: React.FC<PropType> = ({
labellingDoneCallback,
measurementData,
labelData,
exclusive,
hide,
placeholder = 'Type label or search...',
emptyMessage = 'No labels found.',
initialLabel,
}) => {
// State for the selected value
const [value, setValue] = useState<string | undefined>(initialLabel);
const [currentItems, setCurrentItems] = useState<LabelInfo[]>([]);
// Update items when labelData changes
useEffect(() => {
if (labelData) {
setCurrentItems(cloneDeep(labelData));
}
}, [labelData]);
// Original component used measurementData?.label in constructor,
// let's try to initialize based on that if initialLabel is not provided.
useEffect(() => {
if (!initialLabel && measurementData?.label) {
setValue(measurementData.label);
}
}, [initialLabel, measurementData]);
return (
<Command className="border-input border">
<CommandInput placeholder={placeholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<ScrollArea className="h-[300px]">
<CommandGroup>
{currentItems.map(item => (
<CommandItem
key={item.value}
value={item.value}
onSelect={currentValue => {
const newValue = currentValue === value ? '' : currentValue;
setValue(newValue);
labellingDoneCallback(newValue);
hide();
}}
>
<CheckIcon
className={cn('mr-2 h-4 w-4', value === item.value ? 'opacity-100' : 'opacity-0')}
/>
{item.label}
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
);
};
export default LabellingFlow;
@@ -0,0 +1,2 @@
import LabellingFlow from './LabellingFlow';
export default LabellingFlow;
@@ -0,0 +1,40 @@
.LineChart svg {
color: #ddd;
border: solid 1px #383f42;
}
.LineChart svg .background {
stroke: none;
fill: #151c20;
}
.LineChart svg .axis.label {
fill: #ddd;
font-size: 12px;
}
.LineChart svg .axis .tick line {
stroke: #ddd;
}
.LineChart svg .axis .tick text {
fill: #ddd;
}
.LineChart svg .axis.grid .domain {
stroke: none;
}
.LineChart svg .axis.grid .tick line {
stroke: #383f42;
}
.LineChart svg .line {
stroke-width: 1px;
fill: none;
}
.LineChart svg .legend .legend-background {
fill: none;
stroke: #383f42;
}
@@ -0,0 +1,121 @@
import React, { useEffect, useLayoutEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as d3Selection from 'd3-selection';
import { lineChart } from './d3LineChart';
import './LineChart.css';
const LineChart = ({
width: widthProp,
height: heightProp,
axis,
series,
showAxisLabels = true,
showAxisGrid = true,
showLegend = false,
legendWidth = 120,
transparentChartBackground = false,
containerClassName,
chartContainerClassName,
}: {
title: string;
width: number;
height: number;
showAxisGrid: boolean;
showAxisLabels: boolean;
showLegend: boolean;
legendWidth: number;
transparentChartBackground: boolean;
containerClassName: string;
chartContainerClassName: string;
}): JSX.Element => {
const chartContainerRef = useRef(null);
const [d3SVGContainer, setD3SVGRef] = useState(null);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
const chartContainer = chartContainerRef.current;
const containerWidth = chartContainer.offsetWidth;
const containerHeight = chartContainer.offsetHeight;
const d3Container = d3Selection
.create('svg')
.attr('viewBox', [0, 0, containerWidth, containerHeight])
.style('max-width', `${containerWidth}px`)
.style('overflow', 'visible');
chartContainer.append(d3Container.node());
setD3SVGRef(d3Container);
setWidth(containerWidth);
setHeight(containerHeight);
}, [chartContainerRef]);
useEffect(() => {
if (!d3SVGContainer) {
return;
}
lineChart.addLineChartNode({
d3SVGRef: d3SVGContainer,
axis,
series,
width,
height,
showAxisLabels,
showAxisGrid,
showLegend,
legendWidth,
transparentChartBackground,
});
}, [
d3SVGContainer,
axis,
series,
width,
height,
showAxisLabels,
showAxisGrid,
transparentChartBackground,
showLegend,
legendWidth,
]);
return (
<div
className={classnames(
'LineChart text-white',
{
[`w-[${widthProp}px]`]: !!widthProp,
[`h-[${heightProp}px]`]: !!heightProp,
},
{
'w-full': !widthProp,
'h-full': !heightProp,
},
containerClassName
)}
>
<div
id="chartContainer"
ref={chartContainerRef}
className={classnames('h-full w-full', chartContainerClassName)}
></div>
</div>
);
};
LineChart.propTypes = {
title: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
showAxisLabels: PropTypes.bool,
showAxisGrid: PropTypes.bool,
showLegend: PropTypes.bool,
legendWidth: PropTypes.number,
transparentChartBackground: PropTypes.bool,
containerClassName: PropTypes.string,
chartContainerClassName: PropTypes.string,
};
export default LineChart;
@@ -0,0 +1,365 @@
/**
* It adds attributes to root element from passed attributes list
*
* @param {object} root svg element to be changed
* @param {object} attributes list of attributes to be added on root element
* @modifies {root}
*
*/
const _addAttributes = (root, attributes = []) => {
for (const attr of attributes) {
const { key, value } = attr;
if (key && value) {
if (key === 'class') {
root.classed(value, true);
} else {
root.attr(key, value);
}
}
}
};
/**
* It formats axis labels. It adds units if existing
*
* @param {Object} def
* @param {string} def.label string representing axis label
* @param {string} [def.unit] string representing axis unit
* @return {string} formatted label
*/
const _formatAxisLabel = ({ label, unit }) => {
let formattedLabel = label;
if (unit) {
formattedLabel += `(${unit})`;
}
return formattedLabel;
};
/**
* It appends chart svg element container to root element
*
* @param {object} root svg element to be changed
* @param {number} width width of container
* @param {number} height height of container
* @param {number} translateX value to translate on axis x
* @param {number} translateY value to translate on axis y
* @modifies {root}
*
* @return {object} appended chart container (svg element)
*/
const _addChartContainer = (root, width, height, translateX, translateY) => {
root.append('rect').attr('class', 'background').attr('width', width).attr('height', height);
return root
.append('g')
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', 'translate(' + translateX + ',' + translateY + ')');
};
const _addLegend = (root, width, height, posX, posY) => {
const legendContainer = root
.append('g')
.attr('class', 'legend')
.attr('transform', 'translate(' + posX + ',' + posY + ')');
legendContainer
.append('rect')
.attr('class', 'legend-background')
.attr('width', width)
.attr('height', height);
return legendContainer;
};
const _setLegendLabels = (root, labels, colors, itemHeight, itemWidth, textEllipses) => {
const yOffset = itemHeight / 2;
const textLeft = 20;
// Add one dot in the legend for each
root
.selectAll('labelDots')
.data(labels)
.enter()
.append('circle')
.attr('cx', 10)
.attr('cy', (_d, i) => yOffset + i * itemHeight)
.attr('r', 5)
.style('fill', (_d, i) => colors[i]);
root
.selectAll('labelText')
.data(labels)
.enter()
.append('text')
.attr('x', textLeft)
.attr('y', (_d, i) => yOffset + i * itemHeight)
.style('fill', (_d, i) => colors[i])
.append('tspan')
.style('alignment-baseline', 'middle')
.text(d => d)
.each(textEllipses(itemWidth - textLeft, 1));
};
/**
* It appends axis svg element to root element
*
* @param {object} root svg element to be changed
* @param {TimecoursePointDef} axisDef definition of given axis
* @param {number} posX position X to place content
* @param {number} posY position Y to place content
* @param {number} axisExtraAttrs list of extra attributes to be added on content
* @param {function} axisBuilder callback function to create axis graphics
* @param {boolean} showAxisLabels flag to display labels or not
* @param {number} labelPosX position X to place label content
* @param {number} labelPosY position Y to place label content
* @param {object} labelExtraAttrs list of extra attributes to be added on label content
* @modifies {root}
*
* @return {object} axis svg element
*/
const _addAxis = (
root,
axisDef,
posX = 0,
posY = 0,
axisExtraAttrs = [],
axisBuilder,
showAxisLabels,
labelPosX,
labelPosY,
labelExtraAttrs = [],
showAxisGrid
) => {
const { type } = axisDef;
const showAxisGridClass = showAxisGrid ? 'grid' : '';
const translateValue = `${posX}, ${posY}`;
const axisContent = root
.append('g')
.attr('class', `${type} ${showAxisGridClass} axis`)
.attr('transform', `translate(${translateValue})`);
_addAttributes(axisContent, axisExtraAttrs);
axisContent.call(axisBuilder());
if (showAxisLabels) {
const label = _formatAxisLabel(axisDef);
// text label for the x axis
if (label) {
const labelContent = root
.append('text')
.attr('class', 'label axis')
.attr('x', labelPosX)
.attr('y', labelPosY)
.style('text-anchor', 'middle')
.text(label);
_addAttributes(labelContent, labelExtraAttrs);
}
}
return axisContent;
};
/**
* It scales axis graphics
*
* @param {object} sourceGraphicsXAxis original d3 graphics to be proccessed (x Axis)
* @param {object} sourceGraphicsYAxis original d3 graphics to be proccessed (y Axis)
* @param {object} xAxisGenerator d3 X axis generator
* @param {object} yAxisGenerator d3 Y axis generator
* @param {object} newXAxisScale d3 continuos scale for X Axis
* @param {object} newYAxisScale d3 continuos scale for Y Axis
* @modifies {sourceGraphicsXAxis|sourceGraphicsYAxis}
*/
const _scaleAxisGraphics = (
sourceGraphicsXAxis,
sourceGraphicsYAxis,
xAxisGenerator,
yAxisGenerator,
newXAxisScale,
newYAxisScale
) => {
const newXAxis = xAxisGenerator.scale(newXAxisScale);
const newYAxis = yAxisGenerator.scale(newYAxisScale);
sourceGraphicsXAxis.call(newXAxis);
sourceGraphicsYAxis.call(newYAxis);
};
/**
* It appends background svg element to root element and also a clip path for chart boundaries
*
* @param {object} root svg element to be changed
* @param {number} width content`s width
* @param {number} height content`s height
* @param {boolean} transparent background to be transparent or not
* @param {number} offset content`s clip offset
* @modifies {root}
*
*/
const _addChartClipPath = (root, width, height, transparent = false, offset = 6) => {
const translateOffset = -offset / 2;
root
.append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('transform', `translate(${translateOffset}, ${translateOffset})`)
.attr('width', width + offset)
.attr('height', height + offset);
root
.append('rect')
.attr('class', 'background')
.attr('width', width)
.attr('height', height)
.classed('transparent', transparent);
};
const _addSeries = (root, seriesIndex, style) => {
return root
.append('g')
.attr('id', `series_${seriesIndex}`)
.attr('class', 'series')
.attr('stroke', style.color || '#ffffff');
};
/**
* It appends line chart svg element to root element
*
* @param {object} root svg element to be changed
* @param {object} dataset dataset to tie to content
* @param {string} dAttrValue path d attribute
* @modifies {root}
*
* @return {object} svg line element line
*/
const _addLine = (root, dataset, dAttrValue) => {
return root
.append('path')
.attr('clip-path', 'url(#clip)')
.datum(dataset)
.attr('class', 'line')
.attr('d', dAttrValue);
};
/**
* It updates line chart svg element with new dataset and dAttrValue value
*
* @param {object} root svg element to be changed
* @param {object} dataset dataset to tie to content
* @param {string} dAttrValue path d attribute
* @modifies {root}
*
*/
const _updateLine = (root, dataset = [], dAttrValue) => {
const line = root.select('.line');
if (dataset && dataset.length > 0) {
line.datum(dataset);
}
line.attr('d', dAttrValue);
};
/**
* It appends points svg element to root element
*
* @param {object} root svg element to be changed
* @param {object} dataset dataset to tie to content
* @param {number} cxAttrValue cx attribute
* @param {number} cyAttrValue cy attribute
* @modifies {root}
*
* @return {object} points svg element
*
*/
const _addPoints = (root, dataset, cxAttrValue, cyAttrValue) => {
return root
.selectAll('.dot')
.data(dataset)
.enter()
.append('circle')
.attr('id', (point, index) => {
return `point-${index}`;
})
.attr('class', 'dot')
.attr('clip-path', 'url(#clip)')
.attr('cx', cxAttrValue)
.attr('cy', cyAttrValue)
.attr('r', 2);
};
/**
* It return points from root parent
*
* @param {object} root svg element to be changed
* @return {object} points svg element
*
*/
const _getPoints = root => {
return root.selectAll('.dot');
};
/**
* It updates points svg element with new dataset, cxAttrValue and cyAttrValue
*
* @param {object} root svg element to be changed
* @param {object} dataset dataset to tie to content
* @param {number} cxAttrValue cx attribute
* @param {number} cyAttrValue cy attribute
* @modifies {root}
*
*/
const _updatePoints = (root, dataset = [], cxAttrValue, cyAttrValue) => {
const dot = root.selectAll('.dot');
dot.data(dataset).attr('cx', cxAttrValue).attr('cy', cyAttrValue);
};
/**
* It removes children nodes from root param.
*
* @param {object} root svg element to remove children content
* @modifies {root}
*
*/
const _removeChartContents = root => {
root.selectAll('*').remove();
};
const chart = {
axis: {
addNode: _addAxis,
scaleGraphics: _scaleAxisGraphics,
},
series: {
addNode: _addSeries,
},
lines: {
addNode: _addLine,
updateNode: _updateLine,
},
points: {
addNode: _addPoints,
updateNode: _updatePoints,
getPoints: _getPoints,
},
background: {
addNode: _addChartClipPath,
},
container: {
addNode: _addChartContainer,
},
legend: {
addNode: _addLegend,
setLabels: _setLegendLabels,
},
removeContents: _removeChartContents,
};
export default chart;
@@ -0,0 +1,149 @@
import * as d3Shape from 'd3-shape';
import * as d3Zoom from 'd3-zoom';
import chart from './chart';
const d3ZoomNativeEvents = [
'wheel.zoom',
'mousedown.zoom',
'dblclick.zoom',
'touchstart.zoom',
'touchmove.zoom',
'touchend.zoom touchcancel.zoom',
];
/**
* Object to override native zoom events.
* It defines a specific callback for a event name.
*
* @type {Object<string, function>} zoomEvents <zoom d3 event name, callback method>
*/
const zoomEvents = {
'dblclick.zoom': (root, zoom) => {
_resetZoom(root, zoom);
},
};
/**
* It resets given root
*
* @param {object} root svg element to be reset
* @param {object} zoom d3 zoom object
*
* @modifies {root}
*/
const _resetZoom = (root, zoom) => {
if (root) {
root.transition().duration(750).call(zoom.transform, d3Zoom.zoomIdentity);
}
};
const DEFAULT_MAX_ZOOM_SCALE = 20;
const _zoom = d3Zoom.zoom().scaleExtent([1, DEFAULT_MAX_ZOOM_SCALE]);
const _bindZoom = (
root,
gX,
gY,
xAxisScale,
yAxisScale,
xAxisGenerator,
yAxisGenerator,
parseXPoint,
parseYPoint,
datasets
) => {
_zoom.on('zoom', _zoomListener); // .filter(zoomEventsFilter);
_removeListeners();
_setListeners();
function _removeListeners() {
for (const eventName of d3ZoomNativeEvents) {
root.on(eventName, null);
}
}
function _setListeners() {
const keys = Object.keys(zoomEvents);
for (const key of keys) {
root.call(_zoom).on(key, zoomEvents[key].bind(undefined, root, _zoom));
}
}
function _zoomListener(event) {
const transformedXAxisScale = event.transform.rescaleX(xAxisScale);
const transformedYAxisScale = event.transform.rescaleY(yAxisScale);
chart.axis.scaleGraphics(
gX,
gY,
xAxisGenerator,
yAxisGenerator,
transformedXAxisScale,
transformedYAxisScale
);
datasets.forEach((dataset, seriesIndex) => {
const seriesContainer = root.select(`#series_${seriesIndex}`);
// create line
const line = d3Shape
.line()
.x(parseXPoint(transformedXAxisScale))
.y(parseYPoint(transformedYAxisScale));
chart.lines.updateNode(seriesContainer, dataset, line);
chart.points.updateNode(
seriesContainer,
dataset,
parseXPoint(transformedXAxisScale),
parseYPoint(transformedYAxisScale)
);
});
}
return _zoom;
};
const bindMouseEvents = (
root,
gX,
gY,
xAxisScale,
yAxisScale,
xAxisGenerator,
yAxisGenerator,
parseXPoint,
parseYPoint,
datasets
) => {
if (!root) {
return;
}
_bindZoom(
root,
gX,
gY,
xAxisScale,
yAxisScale,
xAxisGenerator,
yAxisGenerator,
parseXPoint,
parseYPoint,
datasets
);
};
const events = {
bindMouseEvents,
external: {
resetZoom: root => {
_resetZoom(root, _zoom);
},
},
};
export default events;
@@ -0,0 +1,3 @@
import * as lineChart from './lineChart';
export { lineChart };
@@ -0,0 +1,272 @@
import * as d3Array from 'd3-array';
import * as d3Axis from 'd3-axis';
import * as d3Scale from 'd3-scale';
import * as d3ScaleChromatic from 'd3-scale-chromatic';
import * as d3Selection from 'd3-selection';
import * as d3Shape from 'd3-shape';
import chart from './chart';
import events from './events';
const {
external: { resetZoom },
} = events;
/**
* @typedef TimecoursePoint It defines a tuple of timecourse value (time X intensity)
* @type {array}
* @property {number} 0 indicates x|y value on the pair (x,y)
* @property {number} 1 indicates x|y value on the pair (x,y)
*/
/**
* @typedef TimecoursePointDef It defines the shape of a given TimecoursePoint Axis
* @type {object}
* @property {string} label label for given TimecoursePoint Axis
* @property {string} [unit] unit for given TimecoursePoint Axis
* @property {string} type defines the type of given TimecoursePoint Axis
* @property {number} indexRef refers the index into given TimecoursePoint for the current Axis Definition
*/
/**
* It gets max value of an array, considering value of param index
*
* @param {TimecoursePoint[]} array array of items to be evaluated
* @param {number} index index of each array`s item to be evaluated
* @return {any} max value
*/
function _getMaxValue(array, index) {
return d3Array.max(array, arrayItem => {
return arrayItem[index];
});
}
function _getMinValue(array, index) {
return d3Array.min(array, arrayItem => {
return arrayItem[index];
});
}
const LEGEND = { width: 100, margin: 10 };
const MARGIN = { top: 20, right: 20, bottom: 50, left: 50 };
function _createAxisScale(domainBottom, domainUpper, rangeBottom, rangeUpper) {
return d3Scale
.scaleLinear()
.domain([domainBottom, domainUpper * 1.05])
.range([rangeBottom, rangeUpper]);
}
const _getSeriesColor = series => {
const seriesLabels = series.reduce((labels, series) => [...labels, series.label], []);
return d3Scale.scaleOrdinal().domain(seriesLabels).range(d3ScaleChromatic.schemeSet2);
};
const _updateSeriesColors = series => {
const seriesColor = _getSeriesColor(series);
return series.map(series => ({
...series,
color: series.color ?? seriesColor(series.label),
}));
};
const _textEllipses = (width, padding = 0) => {
return function (...args) {
const self = d3Selection.select(this);
let textLength = self.node().getComputedTextLength();
let text = self.text();
while (textLength > width - 2 * padding && text.length > 0) {
text = text.slice(0, -1);
self.text(text + '...');
textLength = self.node().getComputedTextLength();
}
};
};
const _addLegend = (root, series, chartWidth, chartHeight, legendWidth) => {
const legendItemHeight = 25;
const legendHeight = legendItemHeight * series.length;
const legendContainer = chart.legend.addNode(
root,
legendWidth,
legendHeight,
chartWidth + LEGEND.margin,
chartHeight / 2 - legendHeight / 2
);
const seriesLabels = series.reduce((labels, series) => [...labels, series.label], []);
const seriesColors = series.reduce((colors, series) => [...colors, series.color], []);
chart.legend.setLabels(
legendContainer,
seriesLabels,
seriesColors,
legendItemHeight,
legendWidth,
_textEllipses
);
};
/**
* It creates a svg chart containing lines, dots, axis, labels
*
* @param {object} d3SVGRef svg content reference to append chart
* @param {Object<string, TimecoursePointDef>} axis definition of axis
* @param {object} points list of points to be created
* @param {number} width width for whole content including lines, dots, axis, labels
* @param {number} height height for whole content including lines, dots, axis, labels
* @param {boolean} showAxisLabels flag to display labels or not
*
* @modifies {d3SVGRef}
*/
const addLineChartNode = ({
d3SVGRef,
axis,
series,
width,
height,
legendWidth = LEGEND.width,
showAxisLabels = true,
showAxisGrid = false,
showLegend = false,
transparentChartBackground = false,
}) => {
const marginRight = showLegend ? legendWidth + 2 * LEGEND.margin : MARGIN.right;
const _width = width - MARGIN.left - marginRight;
const _height = height - MARGIN.top - MARGIN.bottom;
const { x: XAxis, y: YAxis } = axis;
series = _updateSeriesColors(series);
let maxX = -Infinity;
let minX = Infinity;
let maxY = -Infinity;
let minY = Infinity;
series.forEach(currentSeries => {
minX = Math.min(minX, _getMinValue(currentSeries.points, XAxis.indexRef));
maxX = Math.max(maxX, _getMaxValue(currentSeries.points, XAxis.indexRef));
minY = Math.min(minY, _getMinValue(currentSeries.points, YAxis.indexRef));
maxY = Math.max(maxY, _getMaxValue(currentSeries.points, YAxis.indexRef));
});
minX = axis?.x?.range?.min ?? minX;
maxX = axis?.x?.range?.max ?? maxX;
minY = axis?.y?.range?.min ?? minY;
maxY = axis?.y?.range?.max ?? maxY;
const xAxisScale = _createAxisScale(minX, maxX, 0, _width);
const yAxisScale = _createAxisScale(minY, maxY, _height, 0);
const parseXPoint = axisScale => point => {
return (axisScale || xAxisScale)(point.x);
};
const parseYPoint = axisScale => point => {
return (axisScale || yAxisScale)(point.y);
};
// Remove old D3 elements
chart.removeContents(d3SVGRef);
const chartWrapper = chart.container.addNode(d3SVGRef, width, height, MARGIN.left, MARGIN.top);
// add background
chart.background.addNode(chartWrapper, _width, _height, transparentChartBackground);
// call the x axis in a group tag
const xAxisGenerator = d3Axis.axisBottom(xAxisScale);
if (showAxisGrid) {
xAxisGenerator.tickSize(-_height).tickPadding(10);
}
const gXAxis = chart.axis.addNode(
chartWrapper,
XAxis,
undefined,
_height,
undefined,
() => xAxisGenerator,
showAxisLabels,
_width / 2,
_height + MARGIN.bottom / 2 + 10,
undefined,
showAxisGrid
);
const yAxisGenerator = d3Axis.axisLeft(yAxisScale);
if (showAxisGrid) {
yAxisGenerator.tickSize(-_width).tickPadding(10);
}
// add y axis
const gYAxis = chart.axis.addNode(
chartWrapper,
YAxis,
undefined,
undefined,
undefined,
() => yAxisGenerator,
showAxisLabels,
0 - _height / 2,
0 - MARGIN.left,
[
{ key: 'transform', value: 'rotate(-90)' },
{ key: 'dy', value: '1em' },
],
showAxisGrid
);
const datasets = [];
series.forEach((currentSeries, seriesIndex) => {
const { points } = currentSeries;
const line = d3Shape.line().x(parseXPoint(xAxisScale)).y(parseYPoint(yAxisScale));
const dataset = points.map((point, pointIndex) => {
return { x: point[0], y: point[1], seriesIndex, pointIndex };
});
const seriesContainer = chart.series.addNode(chartWrapper, seriesIndex, {
color: currentSeries.color || '#00ff00',
});
chart.lines.addNode(seriesContainer, dataset, line);
// add chart points
chart.points.addNode(
seriesContainer,
dataset,
parseXPoint(xAxisScale),
parseYPoint(yAxisScale)
);
datasets.push(dataset);
});
if (showLegend) {
_addLegend(chartWrapper, series, _width, _height, legendWidth);
}
// bind events
events.bindMouseEvents(
chartWrapper,
gXAxis,
gYAxis,
xAxisScale,
yAxisScale,
xAxisGenerator,
yAxisGenerator,
parseXPoint,
parseYPoint,
datasets
);
return chartWrapper;
};
export { addLineChartNode, resetZoom };
@@ -0,0 +1 @@
export { default } from './LineChart';
@@ -0,0 +1,29 @@
import React from 'react';
import classNames from 'classnames';
import ProgressLoadingBar from '../ProgressLoadingBar';
import { Icons } from '../Icons';
/**
* A React component that renders a loading indicator.
* if progress is not provided, it will render an infinite loading indicator
* if progress is provided, it will render a progress bar
* Optionally a textBlock can be provided to display a message
*/
function LoadingIndicatorProgress({ className, textBlock, progress }) {
return (
<div
className={classNames(
'absolute top-0 left-0 z-50 flex flex-col items-center justify-center space-y-5',
className
)}
>
<Icons.LoadingOHIFMark className="h-12 w-12 text-white" />
<div className="w-48">
<ProgressLoadingBar progress={progress} />
</div>
{textBlock}
</div>
);
}
export default LoadingIndicatorProgress;
@@ -0,0 +1,2 @@
import LoadingIndicatorProgress from './LoadingIndicatorProgress';
export default LoadingIndicatorProgress;
@@ -0,0 +1,49 @@
import React from 'react';
import LoadingIndicatorProgress from '../LoadingIndicatorProgress';
interface Props {
className?: string;
totalNumbers: number | null;
percentComplete: number | null;
loadingText?: string;
targetText?: string;
}
/**
* A React component that renders a loading indicator but accepts a totalNumbers
* and percentComplete to display a more detailed message.
*/
function LoadingIndicatorTotalPercent({
className,
totalNumbers,
percentComplete,
loadingText = 'Loading...',
targetText = 'segments',
}: Props): JSX.Element {
const progress = percentComplete;
const totalNumbersText = totalNumbers !== null ? `${totalNumbers}` : '';
const numTargetsLoadedText =
percentComplete !== null ? Math.floor((percentComplete * totalNumbers) / 100) : '';
const textBlock =
!totalNumbers && percentComplete === null ? (
<div className="text-sm text-white">{loadingText}</div>
) : !totalNumbers && percentComplete !== null ? (
<div className="text-sm text-white">Loaded {percentComplete}%</div>
) : (
<div className="text-sm text-white">
Loaded {numTargetsLoadedText} of {totalNumbersText} {targetText}
</div>
);
return (
<LoadingIndicatorProgress
className={className}
progress={progress}
textBlock={textBlock}
/>
);
}
export default LoadingIndicatorTotalPercent;
@@ -0,0 +1,2 @@
import LoadingIndicatorTotalPercent from './LoadingIndicatorTotalPercent';
export default LoadingIndicatorTotalPercent;
@@ -0,0 +1,27 @@
import React, { ReactElement } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types';
const ProgressDiscreteBar = ({ options }: { options: ProgressDropdownOption[] }): ReactElement => {
return (
<div className="flex">
{options.map((option, i) => (
<div
key={i}
className={classnames('mr-1 h-1 grow first:rounded-l-sm last:mr-0 last:rounded-r-sm', {
'bg-black': !option.activated && !option.completed,
'bg-primary-main': option.activated && !option.completed,
'bg-primary-light': option.completed,
})}
/>
))}
</div>
);
};
ProgressDiscreteBar.propTypes = {
options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired,
};
export default ProgressDiscreteBar;
@@ -0,0 +1,164 @@
import React, { ReactNode, useEffect, useCallback, useState, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import ProgressDiscreteBar from './ProgressDiscreteBar';
import ProgressItemDetail from './ProgressItemDetail';
import ProgressItem from './ProgressItem';
import { Icons } from '../Icons';
import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types';
const ProgressDropdown = ({
options: optionsProps,
value,
children,
dropDownWidth = '170',
onChange,
}: {
options: ProgressDropdownOption[];
value?: string;
children?: ReactNode;
onChange?: ({ selectedOption }) => void;
}): JSX.Element => {
const element = useRef(null);
const [open, setOpen] = useState(false);
const toggleOpen = () => setOpen(s => !s);
const [options, setOptions] = useState(optionsProps);
const [selectedOption, setSelectedOption] = useState(undefined);
const selectedOptionIndex = useMemo(
() => options.findIndex(option => option.value === selectedOption?.value),
[options, selectedOption]
);
const canMoveNext = useMemo(
() => selectedOptionIndex < options.length - 1,
[selectedOptionIndex, options]
);
const handleOptionSelected = useCallback(
(newSelectedOption?: ProgressDropdownOption): void => {
if (newSelectedOption?.value === selectedOption?.value) {
return;
}
setOpen(false);
setSelectedOption(newSelectedOption);
if (newSelectedOption) {
newSelectedOption.activated = true;
newSelectedOption.onSelect?.();
}
if (onChange) {
onChange({ selectedOption: newSelectedOption });
}
},
[selectedOption, onChange]
);
const handleNextButtonClick = useCallback(() => {
if (canMoveNext) {
handleOptionSelected(options[selectedOptionIndex + 1]);
}
}, [options, selectedOptionIndex, canMoveNext, handleOptionSelected]);
// Update the options in case the options from props has changed
useEffect(() => setOptions(optionsProps), [optionsProps]);
// Updates the selected item based on the value from props
useEffect(() => {
if (!value) {
return;
}
const newOption = value ? options.find(option => option.value === value) : undefined;
handleOptionSelected(newOption);
}, [value, options, handleOptionSelected]);
// Listen to any click event outside of the dropdown context to hide the options
useEffect(() => {
const handleDocumentClick = e => {
if (element.current && !element.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener('click', handleDocumentClick);
if (!open) {
document.removeEventListener('click', handleDocumentClick);
}
}, [open]);
return (
<div
ref={element}
className="relative grow select-none text-[0px] text-white"
>
<div>
<div className="mb-1.5 flex h-[26px]">
<div
className="bg-secondary-dark border-primary-main flex grow cursor-pointer rounded border"
style={{ width: `${dropDownWidth}px` }}
onClick={toggleOpen}
>
<div className="flex grow">
{selectedOption && <ProgressItemDetail option={selectedOption} />}
{!selectedOption && <div className="ml-1 grow text-base leading-6">{children}</div>}
</div>
<Icons.ChevronDown className="text-primary-active mt-1.5 ml-1 mr-2" />
</div>
<button
className={classnames('ml-1.5 w-[26px] rounded text-base', {
'bg-primary-main': canMoveNext,
'bg-primary-dark pointer-events-none': !canMoveNext,
})}
>
<Icons.ArrowRight
className={classnames('relative left-0.5 h-6 w-6 text-white', {
'text-white': canMoveNext,
'text-secondary-light': !canMoveNext,
})}
onClick={handleNextButtonClick}
/>
</button>
</div>
<div
className={classnames(
'absolute top-7 left-0 right-8 z-10 mt-0.5 origin-top',
'bg-primary-dark overflow-hidden transition-[max-height] duration-300',
'border-secondary-main rounded border shadow',
{
hidden: !open,
'max-h-[500px]': open,
}
)}
>
{options.map((option, index) => (
<ProgressItem
key={index}
option={option}
onSelect={() => handleOptionSelected(option)}
/>
))}
</div>
<div>
<ProgressDiscreteBar options={options} />
</div>
</div>
</div>
);
};
ProgressDropdown.propTypes = {
options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired,
value: PropTypes.string,
onChange: PropTypes.func,
children: PropTypes.node,
dropDownWidth: PropTypes.string,
};
export default ProgressDropdown;
@@ -0,0 +1,31 @@
import React, { ReactElement } from 'react';
import PropTypes from 'prop-types';
import ProgressItemDetail from './ProgressItemDetail';
import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types';
const ProgressItem = ({
option,
onSelect,
}: {
option: ProgressDropdownOption;
onSelect: (option: ProgressDropdownOption) => void;
}): ReactElement => {
const { value } = option;
return (
<div
key={value}
className={'hover:bg-secondary-main flex cursor-pointer py-1 transition duration-1000'}
onClick={() => onSelect(option)}
>
<ProgressItemDetail option={option} />
</div>
);
};
ProgressItem.propTypes = {
option: ProgressDropdownOptionPropType.isRequired,
onSelect: PropTypes.func,
};
export default ProgressItem;
@@ -0,0 +1,94 @@
import React, { useState, useMemo, ReactElement } from 'react';
import { Icons } from '../Icons';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../Tooltip';
import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types';
const MAX_TOOLTIP_LENGTH = 150;
const iconClassNames = 'grow-0 text-primary-light h-4 w-4 mt-1 mr-2 mb-0 ml-1';
const ProgressItemDetail = ({ option }: { option: ProgressDropdownOption }): ReactElement => {
const { label, info, completed } = option;
const [truncate, setTruncate] = useState(true);
const handleOnHideTooltip = () => setTruncate(true);
let icon;
if (completed) {
icon = 'status-tracked';
} else if (info) {
icon = 'launch-info';
}
const tooltipText = useMemo(() => {
if (!truncate || !info || info.length <= MAX_TOOLTIP_LENGTH) {
return info;
}
const handleReadMoreClick = e => {
setTruncate(false);
e.stopPropagation();
e.preventDefault();
};
return (
<>
{info.slice(0, MAX_TOOLTIP_LENGTH)}
<button
className="text-primary-active font-bold"
onClick={handleReadMoreClick}
>
&nbsp;Read more...
</button>
</>
);
}, [info, truncate]);
const handleOpenChange = (open: boolean) => {
if (!open) {
handleOnHideTooltip();
}
};
const iconContent = (
<>
{icon && (
<div>
<Icons.ByName
name={icon}
className={iconClassNames}
/>
</div>
)}
{!icon && <div className={iconClassNames} />}
</>
);
return (
<>
{info && (
<TooltipProvider delayDuration={200}>
<Tooltip onOpenChange={handleOpenChange}>
<TooltipTrigger asChild>{iconContent}</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className={'max-w-xs'}
>
{tooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!info && iconContent}
<div className="grow overflow-hidden text-ellipsis whitespace-nowrap text-base leading-6">
{label}
</div>
</>
);
};
ProgressItemDetail.propTypes = {
option: ProgressDropdownOptionPropType.isRequired,
};
export default ProgressItemDetail;
@@ -0,0 +1 @@
export { default } from './ProgressDropdown';
@@ -0,0 +1,19 @@
import PropTypes from 'prop-types';
export type ProgressDropdownOption = {
label: string;
value: string;
info?: string;
activated?: boolean;
completed?: boolean;
onSelect?: () => void;
};
export const ProgressDropdownOptionPropType = PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
info: PropTypes.string,
activated: PropTypes.bool,
completed: PropTypes.bool,
onSelect: PropTypes.func,
});
@@ -0,0 +1,26 @@
.loading {
background-color: #091731;
height: 8px;
border-radius: 4px;
overflow: hidden;
position: relative;
width: 100%;
}
.infinite-loading-bar {
animation: side2side 2s ease-in-out infinite;
height: 100%;
position: absolute;
border-radius: 4px;
width: 50%;
}
@keyframes side2side {
0%,
100% {
transform: translateX(-50%);
}
50% {
transform: translateX(150%);
}
}
@@ -0,0 +1,32 @@
import React, { ReactElement } from 'react';
import './ProgressLoadingBar.css';
export type ProgressLoadingBarProps = {
progress?: number;
};
/**
* A React component that renders a loading progress bar.
* If progress is not provided, it will render an infinite loading bar
* If progress is provided, it will render a progress bar
* The progress text can be optionally displayed to the left of the bar.
*/
function ProgressLoadingBar({ progress }: ProgressLoadingBarProps): ReactElement {
return (
<div className="loading">
{progress === undefined || progress === null ? (
<div className="infinite-loading-bar bg-primary-light"></div>
) : (
<div
className="bg-primary-light"
style={{
width: `${progress}%`,
height: '8px',
}}
></div>
)}
</div>
);
}
export default ProgressLoadingBar;
@@ -0,0 +1,2 @@
import ProgressLoadingBar from './ProgressLoadingBar';
export default ProgressLoadingBar;
@@ -59,9 +59,11 @@ function ToolButton(props: ToolButtonProps) {
className
);
const defaultTooltip = tooltip || label;
const defaultTooltip = label;
const disabledTooltip = disabled && disabledText ? disabledText : null;
const hasTooltip = defaultTooltip || disabledTooltip;
const hasSecondaryTooltip = tooltip || disabledTooltip;
const showTooltip = hasSecondaryTooltip || defaultTooltip;
return (
<Tooltip>
@@ -85,7 +87,7 @@ function ToolButton(props: ToolButtonProps) {
}}
variant="ghost"
size="icon"
aria-label={hasTooltip ? defaultTooltip : undefined}
aria-label={defaultTooltip}
disabled={disabled}
>
<Icons.ByName
@@ -95,12 +97,19 @@ function ToolButton(props: ToolButtonProps) {
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{hasTooltip && (
<>
<div>{defaultTooltip}</div>
{disabledTooltip && <div className="text-muted-foreground">{disabledTooltip}</div>}
</>
<TooltipContent
side="bottom"
className="text-wrap w-auto max-w-sm whitespace-normal break-words"
>
{showTooltip && (
<div className="space-y-1">
{defaultTooltip && <div className="text-sm">{defaultTooltip}</div>}
{disabledTooltip ? (
<div className="text-muted-foreground text-xs">{disabledTooltip}</div>
) : (
tooltip && <div className="text-muted-foreground text-xs">{tooltip}</div>
)}
</div>
)}
</TooltipContent>
</Tooltip>
@@ -0,0 +1,128 @@
import React, { useEffect, useRef } from 'react';
import classnames from 'classnames';
import { Button } from '../Button';
import { Icons } from '../Icons';
type NotificationAction = {
id: string;
text: string;
value: any;
type?: 'primary' | 'secondary';
size?: 'sm' | 'md';
};
export type ViewportDialogProps = {
id: string;
type: 'error' | 'warning' | 'info' | 'success';
message: string | React.ReactNode;
actions: NotificationAction[];
onSubmit: (e: any) => void;
onOutsideClick?: () => void;
onKeyPress?: (e: React.KeyboardEvent) => void;
};
const ViewportDialog: React.FC<ViewportDialogProps> = ({
id,
type = 'info',
message,
actions,
onSubmit,
onOutsideClick = () => {},
onKeyPress,
}) => {
const notificationRef = useRef(null);
useEffect(() => {
const notificationElement = notificationRef.current;
const handleClick = function (event) {
const isClickInside = notificationElement.contains(event.target);
if (!isClickInside) {
onOutsideClick();
}
};
// Both a mouse down and up listeners are desired so as to avoid missing events
// from elements that have pointer-events:none (e.g. the active viewport).
document.addEventListener('mousedown', handleClick);
document.addEventListener('mouseup', handleClick);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('mouseup', handleClick);
};
}, [onOutsideClick]);
useEffect(() => {
notificationRef.current.focus();
}, []);
const iconsByType = {
error: {
icon: 'info',
color: 'text-red-700',
},
warning: {
icon: 'notificationwarning-diamond',
color: 'text-yellow-500',
},
info: {
icon: 'notifications-info',
color: 'text-primary-main',
},
success: {
icon: 'info',
color: 'text-green-500',
},
};
const getIconData = () => {
return (
iconsByType[type] || {
icon: '',
color: '',
}
);
};
const { icon, color } = getIconData();
return (
<div
ref={notificationRef}
className="border-input bg-muted mx-2 mt-2 flex flex-col rounded-md border p-2 outline-none"
data-cy={id}
onKeyDown={onKeyPress}
tabIndex={0}
>
<div className="flex grow items-center">
<Icons.ByName
name={icon}
className={classnames('h-5 w-5', color)}
/>
<span className="text-foreground ml-2 text-base">{message}</span>
</div>
<div className="mt-2 flex flex-wrap justify-end gap-2">
{actions?.map((action, index) => {
return (
<Button
name={action.id}
key={index}
variant={action.type === 'secondary' ? 'secondary' : 'default'}
size={action.size === 'sm' ? 'sm' : 'default'}
onClick={() => {
onSubmit(action.value);
}}
className="min-w-16"
>
{action.text}
</Button>
);
})}
</div>
</div>
);
};
export default ViewportDialog;
@@ -0,0 +1,2 @@
import ViewportDialog from './ViewportDialog';
export default ViewportDialog;
+14 -2
View File
@@ -62,7 +62,11 @@ import Modal from './Modal/Modal';
import { FooterAction } from './FooterAction';
import { InputFilter } from './InputFilter';
import { WindowLevel, WindowLevelHistogram } from './OHIFPanels';
import ProgressDropdown from './ProgressDropdown';
import LoadingIndicatorProgress from './LoadingIndicatorProgress';
import LoadingIndicatorTotalPercent from './LoadingIndicatorTotalPercent';
import ProgressLoadingBar from './ProgressLoadingBar';
import ViewportDialog from './ViewportDialog';
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -119,6 +123,9 @@ export { MeasurementTable } from './MeasurementTable';
export * from './ColorCircle';
export { default as AllInOneMenu } from './AllInOneMenu';
export * from './AllInOneMenu';
export { default as LineChart } from './LineChart';
export { default as InvestigationalUseDialog } from './InvestigationalUseDialog';
export { default as LabellingFlow } from './Labelling';
// Segmentation Context Exports
export { useSegmentationTableContext, useSegmentationExpanded, useSegmentStatistics };
@@ -255,5 +262,10 @@ export {
InputFilter,
WindowLevel,
WindowLevelHistogram,
ProgressDropdown,
LoadingIndicatorProgress,
LoadingIndicatorTotalPercent,
ProgressLoadingBar,
ViewportDialog,
CinePlayer,
};
};
@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
// import TouchBackend from 'react-dnd-touch-backend';
// TODO: this is false when it should not be :thinking:
const isTouchDevice =
typeof window !== `undefined` && !!('ontouchstart' in window || navigator.maxTouchPoints);
/**
* Relevant:
* https://github.com/react-dnd/react-dnd/issues/186#issuecomment-335429067
* https://github.com/react-dnd/react-dnd/issues/186#issuecomment-282789420
*
* Docs:
* http://react-dnd.github.io/react-dnd/docs/api/drag-drop-context
*/
function DragAndDropProvider({ children }) {
const backend = HTML5Backend; // isTouchDevice ? TouchBackend : HTML5Backend;
const opts = {}; // isTouchDevice ? { enableMouseEvents: true } : {};
console.log('using... touch backend?', isTouchDevice);
return (
<DndProvider
backend={backend}
opts={opts}
>
{children}
</DndProvider>
);
}
DragAndDropProvider.propTypes = {
children: PropTypes.any,
};
export default DragAndDropProvider;
@@ -0,0 +1,17 @@
import React, { ReactNode, createContext, useContext, useMemo } from 'react';
interface ImageViewerProviderProps {
StudyInstanceUIDs: string[];
children: ReactNode;
}
export const ImageViewerContext = createContext(null);
export const useImageViewer = () => useContext(ImageViewerContext);
export function ImageViewerProvider({ StudyInstanceUIDs, children }: ImageViewerProviderProps) {
const value = useMemo(() => {
return { StudyInstanceUIDs };
}, [StudyInstanceUIDs]);
return <ImageViewerContext.Provider value={value}>{children}</ImageViewerContext.Provider>;
}
@@ -0,0 +1,133 @@
import React, { createContext, useCallback, useContext, useEffect, useReducer } from 'react';
import PropTypes from 'prop-types';
const DEFAULT_STATE = {
user: null,
enabled: false,
};
export const UserAuthenticationContext = createContext(DEFAULT_STATE);
export function UserAuthenticationProvider({ children, service }) {
const userAuthenticationReducer = (state, action) => {
switch (action.type) {
case 'SET_USER': {
return {
...state,
...{ user: action.payload.user },
};
}
case 'RESET': {
return {
user: null,
};
}
case 'SET': {
return {
...state,
...action.payload,
};
}
default:
return action.payload;
}
};
const [userAuthenticationState, dispatch] = useReducer(userAuthenticationReducer, DEFAULT_STATE);
const getState = useCallback(() => userAuthenticationState, [userAuthenticationState]);
const setUser = useCallback(
user =>
dispatch({
type: 'SET_USER',
payload: {
user,
},
}),
[dispatch]
);
const getUser = useCallback(() => userAuthenticationState.user, [userAuthenticationState]);
const reset = useCallback(
() =>
dispatch({
type: 'RESET',
payload: {},
}),
[dispatch]
);
const set = useCallback(
payload =>
dispatch({
type: 'SET',
payload,
}),
[dispatch]
);
/**
* Sets the implementation of the UserAuthenticationService that can be used by extensions.
*
* @returns void
*/
// TODO: should this be a useEffect or not?
useEffect(() => {
if (service) {
service.setServiceImplementation({
getState,
setUser,
getUser,
reset,
set,
});
}
}, [getState, service, setUser, getUser, reset, set]);
// TODO: This may not be correct, but I think we need to set the implementation for the service
// immediately when this runs, since otherwise the authentication redirects will fail.
// (useEffect only runs after the child components - in this case, routing logic - has failed)
if (service) {
service.setServiceImplementation({
getState,
setUser,
getUser,
reset,
set,
});
}
const api = {
getState,
setUser,
getUser,
getAuthorizationHeader: service.getAuthorizationHeader,
handleUnauthenticated: service.handleUnauthenticated,
reset,
set,
};
return (
<UserAuthenticationContext.Provider value={[userAuthenticationState, api]}>
{children}
</UserAuthenticationContext.Provider>
);
}
export default UserAuthenticationProvider;
const UserAuthenticationConsumer = UserAuthenticationContext.Consumer;
export { UserAuthenticationConsumer };
UserAuthenticationProvider.propTypes = {
children: PropTypes.any,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}).isRequired,
};
export const useUserAuthentication = () => useContext(UserAuthenticationContext);
@@ -0,0 +1,52 @@
import React, { useState, createContext, useContext, useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
const DEFAULT_STATE = {
message: undefined,
type: 'info', // "error" | "warning" | "info" | "success"
actions: undefined, // array of { type, text, value }
onSubmit: () => {
console.log('btn value?');
},
onOutsideClick: () => {
console.warn('default: onOutsideClick');
},
onDismiss: () => {
console.log('dismiss? -1');
},
onKeyPress: () => {
console.log('key pressed?');
},
};
const ViewportDialogContext = createContext(null);
const { Provider } = ViewportDialogContext;
export const useViewportDialog = () => useContext(ViewportDialogContext);
const ViewportDialogProvider = ({ children, service }) => {
const [viewportDialogState, setViewportDialogState] = useState(DEFAULT_STATE);
const show = useCallback(
params => setViewportDialogState({ ...viewportDialogState, ...params }),
[viewportDialogState]
);
const hide = useCallback(() => setViewportDialogState(DEFAULT_STATE), []);
useEffect(() => {
if (service) {
service.setServiceImplementation({ hide, show });
}
}, [hide, service, show]);
return <Provider value={[viewportDialogState, { show, hide }]}>{children}</Provider>;
};
ViewportDialogProvider.propTypes = {
/** Children that will be wrapped with Modal Context */
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}),
};
export default ViewportDialogProvider;
+10 -1
View File
@@ -3,6 +3,11 @@ import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './Vi
import { ModalProvider, useModal } from './ModalProvider';
import { DialogProvider, useDialog } from './DialogProvider';
import ManagedDialog from './ManagedDialog';
import ViewportDialogProvider from './ViewportDialogProvider';
import { useViewportDialog } from './ViewportDialogProvider';
import { UserAuthenticationProvider, useUserAuthentication } from './UserAuthenticationProvider';
import { ImageViewerContext, ImageViewerProvider, useImageViewer } from './ImageViewerProvider';
import DragAndDropProvider from './DragAndDropProvider';
import CineProvider, { useCine } from './CineProvider';
export { useNotification, NotificationProvider };
@@ -10,4 +15,8 @@ export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
export { ModalProvider, useModal };
export { DialogProvider, useDialog };
export { ManagedDialog };
export { CineProvider, useCine };
export { ViewportDialogProvider, useViewportDialog };
export { UserAuthenticationProvider, useUserAuthentication };
export { ImageViewerContext, ImageViewerProvider, useImageViewer };
export { DragAndDropProvider };
export { CineProvider, useCine };
+1
View File
@@ -1,5 +1,6 @@
import * as utils from './utils';
export * from './components';
export * from './contextProviders';
export * as Types from './types';
export { utils };