fix(overlay-menu): Adding, changing and removing segmentation overlays should update the overlay menu instantly (#5181)
@ -36,6 +36,9 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
|
|||||||
foregroundDisplaySets,
|
foregroundDisplaySets,
|
||||||
} = useViewportDisplaySets(viewportId);
|
} = useViewportDisplaySets(viewportId);
|
||||||
|
|
||||||
|
const [optimisticOverlayDisplaySets, setOptimisticOverlayDisplaySets] =
|
||||||
|
useState(overlayDisplaySets);
|
||||||
|
|
||||||
const [thresholdOpacityEnabled, setThresholdOpacityEnabled] = useState(false);
|
const [thresholdOpacityEnabled, setThresholdOpacityEnabled] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -77,6 +80,18 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
|
|||||||
* Remove a display set layer
|
* Remove a display set layer
|
||||||
*/
|
*/
|
||||||
const handleRemoveDisplaySetLayer = (displaySetInstanceUID: string) => {
|
const handleRemoveDisplaySetLayer = (displaySetInstanceUID: string) => {
|
||||||
|
const optimisticOverlayDisplaySetsIndex = optimisticOverlayDisplaySets.findIndex(
|
||||||
|
displaySet => displaySet.displaySetInstanceUID === displaySetInstanceUID
|
||||||
|
);
|
||||||
|
|
||||||
|
if (optimisticOverlayDisplaySetsIndex !== -1) {
|
||||||
|
setOptimisticOverlayDisplaySets(prevOptimisticOverlayDisplaySets => {
|
||||||
|
return prevOptimisticOverlayDisplaySets.filter(
|
||||||
|
displaySet => displaySet.displaySetInstanceUID !== displaySetInstanceUID
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
commandsManager.runCommand('removeDisplaySetLayer', {
|
commandsManager.runCommand('removeDisplaySetLayer', {
|
||||||
viewportId,
|
viewportId,
|
||||||
displaySetInstanceUID,
|
displaySetInstanceUID,
|
||||||
@ -110,6 +125,17 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (selectedDisplaySet) {
|
if (selectedDisplaySet) {
|
||||||
|
setOptimisticOverlayDisplaySets(prevOptimisticOverlayDisplaySets => {
|
||||||
|
const currentDisplaySetIndex = prevOptimisticOverlayDisplaySets.findIndex(
|
||||||
|
displaySet => displaySet.displaySetInstanceUID === currentDisplaySet.displaySetInstanceUID
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...prevOptimisticOverlayDisplaySets.slice(0, currentDisplaySetIndex),
|
||||||
|
selectedDisplaySet,
|
||||||
|
...prevOptimisticOverlayDisplaySets.slice(currentDisplaySetIndex + 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
handleReplaceDisplaySetLayer(
|
handleReplaceDisplaySetLayer(
|
||||||
currentDisplaySet.displaySetInstanceUID,
|
currentDisplaySet.displaySetInstanceUID,
|
||||||
selectedDisplaySet.displaySetInstanceUID
|
selectedDisplaySet.displaySetInstanceUID
|
||||||
@ -150,6 +176,10 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (selectedDisplaySet) {
|
if (selectedDisplaySet) {
|
||||||
|
setOptimisticOverlayDisplaySets(prevOptimisticOverlayDisplaySets => [
|
||||||
|
...prevOptimisticOverlayDisplaySets,
|
||||||
|
selectedDisplaySet,
|
||||||
|
]);
|
||||||
handleAddDisplaySetAsLayer(selectedDisplaySet.displaySetInstanceUID);
|
handleAddDisplaySetAsLayer(selectedDisplaySet.displaySetInstanceUID);
|
||||||
// Remove this pending segmentation from the list
|
// Remove this pending segmentation from the list
|
||||||
setPendingSegmentations(pendingSegmentations.filter(id => id !== pendingId));
|
setPendingSegmentations(pendingSegmentations.filter(id => id !== pendingId));
|
||||||
@ -184,7 +214,10 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-popover flex h-full w-[275px] flex-col rounded rounded-md p-1.5">
|
<div
|
||||||
|
className="bg-popover flex h-full w-[275px] flex-col rounded rounded-md p-1.5"
|
||||||
|
data-cy={`viewport-data-overlay-menu-${viewportId}`}
|
||||||
|
>
|
||||||
{/* Top buttons row */}
|
{/* Top buttons row */}
|
||||||
<div className={`flex`}>
|
<div className={`flex`}>
|
||||||
<Button
|
<Button
|
||||||
@ -216,7 +249,7 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
|
|||||||
<div className="">
|
<div className="">
|
||||||
{/* Overlays Segmentation section */}
|
{/* Overlays Segmentation section */}
|
||||||
<div className="my-2 ml-1">
|
<div className="my-2 ml-1">
|
||||||
{overlayDisplaySets.map((displaySet, index) => (
|
{optimisticOverlayDisplaySets.map(displaySet => (
|
||||||
<div
|
<div
|
||||||
key={displaySet.displaySetInstanceUID}
|
key={displaySet.displaySetInstanceUID}
|
||||||
className="mb-1 flex items-center"
|
className="mb-1 flex items-center"
|
||||||
@ -227,7 +260,11 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
|
|||||||
onValueChange={value => handleOverlaySelectionChange(displaySet, value)}
|
onValueChange={value => handleOverlaySelectionChange(displaySet, value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="flex-1">
|
<SelectTrigger className="flex-1">
|
||||||
<SelectValue>{displaySet.label?.toUpperCase()}</SelectValue>
|
<SelectValue
|
||||||
|
data-cy={`overlay-ds-select-value-${displaySet.label?.toUpperCase()}`}
|
||||||
|
>
|
||||||
|
{displaySet.label?.toUpperCase()}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* Include both potential overlays and the current overlay */}
|
{/* Include both potential overlays and the current overlay */}
|
||||||
@ -255,12 +292,14 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="ml-2 flex-shrink-0"
|
className="ml-2 flex-shrink-0"
|
||||||
|
dataCY={`overlay-ds-more-button-${displaySet.label?.toUpperCase()}`}
|
||||||
>
|
>
|
||||||
<Icons.More className="h-4 w-4" />
|
<Icons.More className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
data-cy={`overlay-ds-remove-button-${displaySet.label?.toUpperCase()}`}
|
||||||
onClick={() => handleRemoveDisplaySetLayer(displaySet.displaySetInstanceUID)}
|
onClick={() => handleRemoveDisplaySetLayer(displaySet.displaySetInstanceUID)}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSystem, utils } from '@ohif/core';
|
import { useSystem, utils } from '@ohif/core';
|
||||||
import { useViewportGrid } from '@ohif/ui-next';
|
import { useViewportGrid } from '@ohif/ui-next';
|
||||||
import {
|
import {
|
||||||
@ -122,12 +122,34 @@ export function useViewportDisplaySets(
|
|||||||
|
|
||||||
// Get all available segmentations (only if needed)
|
// Get all available segmentations (only if needed)
|
||||||
const needsSegmentations = includeOverlay;
|
const needsSegmentations = includeOverlay;
|
||||||
const segmentationRepresentations = useMemo(
|
|
||||||
() =>
|
const [segmentationRepresentations, setSegmentationRepresentations] = useState(
|
||||||
needsSegmentations ? segmentationService.getSegmentationRepresentations(viewportIdToUse) : [],
|
needsSegmentations ? segmentationService.getSegmentationRepresentations(viewportIdToUse) : []
|
||||||
[segmentationService, viewportIdToUse, needsSegmentations]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSegmentationRepresentations(
|
||||||
|
needsSegmentations ? segmentationService.getSegmentationRepresentations(viewportIdToUse) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsubscribeArr = needsSegmentations
|
||||||
|
? [
|
||||||
|
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED,
|
||||||
|
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_REMOVED,
|
||||||
|
].map(event =>
|
||||||
|
segmentationService.subscribe(event, () => {
|
||||||
|
setSegmentationRepresentations(
|
||||||
|
segmentationService.getSegmentationRepresentations(viewportIdToUse)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeArr.forEach(item => item.unsubscribe());
|
||||||
|
};
|
||||||
|
}, [segmentationService, viewportIdToUse, needsSegmentations]);
|
||||||
|
|
||||||
const overlayDisplaySets = useMemo(() => {
|
const overlayDisplaySets = useMemo(() => {
|
||||||
if (!includeOverlay) {
|
if (!includeOverlay) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
78
tests/DataOverlayMenu.spec.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { test } from 'playwright-test-coverage';
|
||||||
|
import { visitStudy, checkForScreenshot, screenShotPaths } from './utils';
|
||||||
|
import { press } from './utils/keyboardUtils';
|
||||||
|
|
||||||
|
test('should display added, selected and removed segmentation promptly', async ({ page }) => {
|
||||||
|
const studyInstanceUID = '1.3.6.1.4.1.32722.99.99.239341353911714368772597187099978969331';
|
||||||
|
const mode = 'segmentation';
|
||||||
|
await visitStudy(page, studyInstanceUID, mode, 2000);
|
||||||
|
|
||||||
|
// Add a segmentation overlay and ensure the overlay menu reflects this change.
|
||||||
|
await page.getByTestId('dataOverlayMenu-default-btn').click();
|
||||||
|
await page.getByTestId('AddSegmentationDataOverlay-default').click();
|
||||||
|
await page.getByText('SELECT A SEGMENTATION').click();
|
||||||
|
await page.getByTestId('2d-tta_nnU-Net_Segmentation').click();
|
||||||
|
|
||||||
|
await checkForScreenshot({
|
||||||
|
page,
|
||||||
|
screenshotPath:
|
||||||
|
screenShotPaths.dataOverlayMenu.overlayMenuWith2d_tta_nnU_Net_SegmentationSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide the overlay menu.
|
||||||
|
await page.getByTestId('dataOverlayMenu-default-btn').click();
|
||||||
|
|
||||||
|
// navigate to the 51st image and ensure the correct overlay is displayed
|
||||||
|
await press({ page, key: 'ArrowDown', nTimes: 50 });
|
||||||
|
|
||||||
|
await checkForScreenshot({
|
||||||
|
page,
|
||||||
|
screenshotPath: screenShotPaths.dataOverlayMenu.overlay2d_tta_nnU_Net_Segmentation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the overlay menu.
|
||||||
|
await page.getByTestId('dataOverlayMenu-default-btn').click();
|
||||||
|
|
||||||
|
// Change the segmentation overlay to a different one and ensure the overlay menu reflects this change.
|
||||||
|
await page.getByTestId('overlay-ds-select-value-2D-TTA_NNU-NET_SEGMENTATION').click();
|
||||||
|
await page.getByTestId('Segmentation-SEG').click();
|
||||||
|
|
||||||
|
await checkForScreenshot({
|
||||||
|
page,
|
||||||
|
screenshotPath: screenShotPaths.dataOverlayMenu.overlayMenuWithSegmentationSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide the overlay menu.
|
||||||
|
await page.getByTestId('dataOverlayMenu-default-btn').click();
|
||||||
|
|
||||||
|
// navigate to the 51st image and ensure the correct overlay is displayed
|
||||||
|
await press({ page, key: 'ArrowDown', nTimes: 50 });
|
||||||
|
|
||||||
|
await checkForScreenshot({
|
||||||
|
page,
|
||||||
|
screenshotPath: screenShotPaths.dataOverlayMenu.overlaySegmentation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the overlay menu.
|
||||||
|
await page.getByTestId('dataOverlayMenu-default-btn').click();
|
||||||
|
|
||||||
|
// Remove the segmentation overlay and ensure the overlay menu reflects this change.
|
||||||
|
await page.getByTestId('overlay-ds-more-button-SEGMENTATION').click();
|
||||||
|
await page.getByTestId('overlay-ds-remove-button-SEGMENTATION').click();
|
||||||
|
|
||||||
|
await checkForScreenshot({
|
||||||
|
page,
|
||||||
|
screenshotPath: screenShotPaths.dataOverlayMenu.overlayMenuWithSegmentationOverlaysRemoved,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide the overlay menu.
|
||||||
|
await page.getByTestId('dataOverlayMenu-default-btn').click();
|
||||||
|
|
||||||
|
// navigate to the 51st image and ensure no overlay is displayed
|
||||||
|
await press({ page, key: 'ArrowDown', nTimes: 50 });
|
||||||
|
|
||||||
|
await checkForScreenshot({
|
||||||
|
page,
|
||||||
|
screenshotPath: screenShotPaths.dataOverlayMenu.noOverlay,
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,11 +10,12 @@ test.beforeEach(async ({ page }) => {
|
|||||||
test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay menu', async ({
|
test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay menu', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByTestId('side-panel-header-right').click();
|
|
||||||
|
|
||||||
await page.getByTestId('Layout').click();
|
await page.getByTestId('Layout').click();
|
||||||
await page.getByTestId('MPR').click();
|
await page.getByTestId('MPR').click();
|
||||||
|
|
||||||
|
// Wait 5 seconds for MPR to load. This is necessary in particular when screen shots are added or replaced.
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
await checkForScreenshot(
|
await checkForScreenshot(
|
||||||
page,
|
page,
|
||||||
page,
|
page,
|
||||||
@ -31,6 +32,9 @@ test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay me
|
|||||||
// Hide the overlay menu.
|
// Hide the overlay menu.
|
||||||
await page.getByTestId('dataOverlayMenu-mpr-sagittal-btn').click();
|
await page.getByTestId('dataOverlayMenu-mpr-sagittal-btn').click();
|
||||||
|
|
||||||
|
// Wait 5 seconds for RT to load. This is necessary in particular when screen shots are added or replaced.
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
await checkForScreenshot(
|
await checkForScreenshot(
|
||||||
page,
|
page,
|
||||||
page,
|
page,
|
||||||
|
|||||||
BIN
tests/screenshots/chromium/DataOverlayMenu.spec.ts/noOverlay.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 274 KiB |
@ -3,7 +3,7 @@ import { Locator, Page } from 'playwright';
|
|||||||
|
|
||||||
type CheckForScreenshotProps = {
|
type CheckForScreenshotProps = {
|
||||||
page: Page;
|
page: Page;
|
||||||
locator: Locator | Page;
|
locator?: Locator | Page;
|
||||||
screenshotPath: string;
|
screenshotPath: string;
|
||||||
attempts?: number;
|
attempts?: number;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
@ -14,7 +14,7 @@ type CheckForScreenshotProps = {
|
|||||||
const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
|
const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
|
||||||
const {
|
const {
|
||||||
page,
|
page,
|
||||||
locator,
|
locator = page,
|
||||||
screenshotPath,
|
screenshotPath,
|
||||||
attempts = 10,
|
attempts = 10,
|
||||||
delay = 500,
|
delay = 500,
|
||||||
|
|||||||
@ -164,6 +164,15 @@ const screenShotPaths = {
|
|||||||
jumpToMeasurementAfterSeriesChange:
|
jumpToMeasurementAfterSeriesChange:
|
||||||
'jumpToMeasurementMPR-jumpToMeasurementAfterSeriesChange.png',
|
'jumpToMeasurementMPR-jumpToMeasurementAfterSeriesChange.png',
|
||||||
},
|
},
|
||||||
|
dataOverlayMenu: {
|
||||||
|
overlayMenuWithSegmentationSelected: 'overlayMenuWithSegmentationSelected.png',
|
||||||
|
overlayMenuWith2d_tta_nnU_Net_SegmentationSelected:
|
||||||
|
'overlayMenuWith2d_tta_nnU_Net_SegmentationSelected.png',
|
||||||
|
overlayMenuWithSegmentationOverlaysRemoved: 'overlayMenuWithSegmentationOverlaysRemoved.png',
|
||||||
|
overlay2d_tta_nnU_Net_Segmentation: 'overlay2d_tta_nnU_Net_Segmentation.png',
|
||||||
|
overlaySegmentation: 'overlaySegmentation.png',
|
||||||
|
noOverlay: 'noOverlay.png',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export { screenShotPaths };
|
export { screenShotPaths };
|
||||||
|
|||||||