feat(PanelService): Left or right side panel to auto start as closed but auto open when needed (#3212)

* feat(SidePanel)
OHIF issue #3135
- Added a method to set a callback for a Panel to invoke when it is ready
to be shown (e.g. its data is loaded).
- Implemented such methods for both the segmentation and measurement panels.
- The SidePanel component now adds a callback to Panel components so that
it will automatically open a Panel that was initially closed and yet to
be opened.
- Updated the OHIF documentation accordingly.

* PR feedback
- added a PanelService that centralized much of the logic that existed in panel PanelModule
- the SidePanel subscribes to PanelService.EVENTS.ACTIVATE_PANEL for each of its child panels

* Removed the PanelMeasurementTableTracking setMeasurementPanelContentReadyCallback method.

* Made the forceActive flag in the PanelService optional and defaulted it to false.

* Fixed failing top level exports unit test.

* - PanelService subscriptions are now per panel (id) so subscribers do not
necessarily need to check the panel id in the event when it is fired
- PanelService activate panel trigger subscriptions are now returned
so that they can be (better) managed outside of the service
- updated/created the various documentation for panels and PanelService

* Clarified various documentation.
Moved the code to add the activate panel triggers out of the extensions
and into the longitudinal mode.

* Removed the openWhenPanelActivated flag.
PanelService now conforms to extending PubSubService like the other services.
Updated various documentation.

* Fixed failing e2e, mpr test.

* Renamed the ActivatePanelTriggers type properties.
The ExtensionManager now sets the id of various modules as a property on each of those modules.
This commit is contained in:
Joe Boccanfuso 2023-03-09 22:30:09 -05:00 committed by GitHub
parent e4e62e9e14
commit 5670a4d8d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 294 additions and 23 deletions

View File

@ -1,6 +1,8 @@
import { id } from './id';
import React from 'react';
import { Types } from '@ohif/core';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import PanelSegmentation from './panels/PanelSegmentation';
@ -39,13 +41,13 @@ const extension = {
commandsManager,
configuration = {},
}) => {},
/**
/**
* PanelModule should provide a list of panels that will be available in OHIF
* for Modes to consume and render. Each panel is defined by a {name,
* iconName, iconLabel, label, component} object. Example of a panel module
* is the StudyBrowserPanel that is provided by the default extension in OHIF.
*/
getPanelModule: ({ servicesManager, commandsManager, extensionManager }) => {
getPanelModule: ({ servicesManager, commandsManager, extensionManager }): Types.Panel[] => {
const wrappedPanelSegmentation = () => {
return (
<PanelSegmentation

View File

@ -4,7 +4,6 @@ import { SegmentationGroupTable } from '@ohif/ui';
import callInputDialog from './callInputDialog';
import { useTranslation } from 'react-i18next';
import callColorPickerDialog from './callColorPickerDialog';
export default function PanelSegmentation({
servicesManager,

View File

@ -158,6 +158,7 @@ function ViewerLayout({
const { content, entry } = getComponent(id);
return {
id: entry.id,
iconName: entry.iconName,
iconLabel: entry.iconLabel,
label: entry.label,
@ -227,6 +228,7 @@ function ViewerLayout({
side="left"
activeTabIndex={leftPanelDefaultClosed ? null : 0}
tabs={leftPanelComponents}
servicesManager={servicesManager}
/>
</ErrorBoundary>
) : null}
@ -248,6 +250,7 @@ function ViewerLayout({
side="right"
activeTabIndex={rightPanelDefaultClosed ? null : 0}
tabs={rightPanelComponents}
servicesManager={servicesManager}
/>
</ErrorBoundary>
) : null}

View File

@ -1,3 +1,4 @@
import { Types } from '@ohif/core';
import {
PanelMeasurementTableTracking,
PanelStudyBrowserTracking,
@ -11,7 +12,7 @@ function getPanelModule({
commandsManager,
extensionManager,
servicesManager,
}) {
}): Types.Panel[] {
return [
{
name: 'seriesList',
@ -24,6 +25,7 @@ function getPanelModule({
servicesManager,
}),
},
{
name: 'trackedMeasurements',
iconName: 'tab-linear',

View File

@ -57,6 +57,7 @@ const extensionDependencies = {
};
function modeFactory() {
let _activatePanelTriggersSubscriptions = [];
return {
// TODO: We're using this as a route segment
// We should not be.
@ -71,6 +72,8 @@ function modeFactory() {
measurementService,
toolbarService,
toolGroupService,
panelService,
segmentationService,
} = servicesManager.services;
measurementService.clearMeasurements();
@ -121,6 +124,28 @@ function modeFactory() {
'Crosshairs',
'MoreTools',
]);
// // ActivatePanel event trigger for when a segmentation or measurement is added.
// // Do not force activation so as to respect the state the user may have left the UI in.
// _activatePanelTriggersSubscriptions = [
// ...panelService.addActivatePanelTriggers(dicomSeg.panel, [
// {
// sourcePubSubService: segmentationService,
// sourceEvents: [
// segmentationService.EVENTS.SEGMENTATION_PIXEL_DATA_CREATED,
// ],
// },
// ]),
// ...panelService.addActivatePanelTriggers(tracked.measurements, [
// {
// sourcePubSubService: measurementService,
// sourceEvents: [
// measurementService.EVENTS.MEASUREMENT_ADDED,
// measurementService.EVENTS.RAW_MEASUREMENT_ADDED,
// ],
// },
// ]),
// ];
},
onModeExit: ({ servicesManager }) => {
const {
@ -131,6 +156,9 @@ function modeFactory() {
cornerstoneViewportService,
} = servicesManager.services;
_activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe());
_activatePanelTriggersSubscriptions = [];
toolbarService.reset();
toolGroupService.destroy();
syncGroupService.destroy();
@ -162,7 +190,7 @@ function modeFactory() {
props: {
leftPanels: [tracked.thumbnailList],
rightPanels: [dicomSeg.panel, tracked.measurements],
// rightPanelDefaultClosed: true, // optional prop to start with collapse panels
rightPanelDefaultClosed: true,
viewports: [
{
namespace: tracked.viewport,

View File

@ -274,9 +274,9 @@ export default class ExtensionManager {
// Default for most extension points,
// Just adds each entry ready for consumption by mode.
extensionModule.forEach(element => {
this.modulesMap[
`${extensionId}.${moduleType}.${element.name}`
] = element;
const id = `${extensionId}.${moduleType}.${element.name}`;
element.id = id;
this.modulesMap[id] = element;
});
break;
default:

View File

@ -40,6 +40,7 @@ describe('Top level exports', () => {
'DicomMetadataStore',
'pubSubServiceInterface',
'PubSubService',
'PanelService',
].sort();
const exports = Object.keys(OHIF).sort();

View File

@ -29,6 +29,7 @@ import {
PubSubService,
UserAuthenticationService,
CustomizationService,
PanelService,
} from './services';
import IWebApiDataSource from './DataSources/IWebApiDataSource';
@ -74,6 +75,7 @@ const OHIF = {
DicomMetadataStore,
pubSubServiceInterface,
PubSubService,
PanelService,
};
export {
@ -112,6 +114,7 @@ export {
pubSubServiceInterface,
PubSubService,
Types,
PanelService,
};
export { OHIF };

View File

@ -0,0 +1,61 @@
import { ActivatePanelTriggers } from '../../types';
import { Subscription } from '../../types/IPubSub';
import { PubSubService } from '../_shared/pubSubServiceInterface';
export const EVENTS = {
ACTIVATE_PANEL: 'event::panelService:activatePanel',
};
export default class PanelService extends PubSubService {
public static REGISTRATION = {
name: 'panelService',
create: (): PanelService => {
return new PanelService();
},
};
constructor() {
super(EVENTS);
}
/**
* Activates the panel with the given id. If the forceActive flag is false
* then it is up to the component containing the panel whether to activate
* it immediately or not. For instance, the panel might not be activated when
* the forceActive flag is false in the case where the user might have
* activated/displayed and then closed the panel already.
* Note that this method simply fires a broadcast event: ActivatePanelEvent.
* @param panelId the panel's id
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
*/
activatePanel(panelId: string, forceActive = false): void {
this._broadcastEvent(EVENTS.ACTIVATE_PANEL, { panelId, forceActive });
}
/**
* Adds a mapping of events (activatePanelTriggers.sourceEvents) broadcast by
* activatePanelTrigger.sourcePubSubService that
* when fired/broadcasted must in turn activate the panel with the given id.
* The subscriptions created are returned such that they can be managed and unsubscribed
* as appropriate.
* @param panelId the id of the panel to activate
* @param activatePanelTriggers an array of triggers
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
* @returns an array of the subscriptions subscribed to
*/
addActivatePanelTriggers(
panelId: string,
activatePanelTriggers: ActivatePanelTriggers[],
forceActive = false
): Subscription[] {
return activatePanelTriggers
.map(trigger =>
trigger.sourceEvents.map(eventName =>
trigger.sourcePubSubService.subscribe(eventName, () =>
this.activatePanel(panelId, forceActive)
)
)
)
.flat();
}
}

View File

@ -0,0 +1,3 @@
import PanelService from './PanelService';
export default PanelService;

View File

@ -17,6 +17,7 @@ import UserAuthenticationService from './UserAuthenticationService';
import CustomizationService from './CustomizationService';
import Services from '../types/Services';
import PanelService from './PanelService';
export {
Services,
@ -36,4 +37,5 @@ export {
pubSubServiceInterface,
PubSubService,
UserAuthenticationService,
PanelService,
};

View File

@ -1,6 +1,5 @@
import Consumer from './Consumer';
export default interface IPubSub {
subscribe: (eventName: string, callback: Consumer) => void;
_broadcastEvent: (
@ -10,3 +9,7 @@ export default interface IPubSub {
_unsubscribe: (eventName: string, listenerId: string) => void;
_isValidEvent: (eventName: string) => boolean;
}
export type Subscription = {
unsubscribe: () => void;
};

View File

@ -0,0 +1,30 @@
import { PubSubService } from "../services";
type Panel = {
id?: string;
name: string;
iconName: string;
iconLabel: string;
label: string;
component: React.FC;
};
type ActivatePanelTriggers = {
sourcePubSubService: PubSubService;
sourceEvents: string[]
}
interface PanelEvent {
panelId: string;
}
interface ActivatePanelEvent extends PanelEvent {
forceActive: boolean;
}
export type {
ActivatePanelEvent,
ActivatePanelTriggers,
Panel,
PanelEvent,
};

View File

@ -27,4 +27,5 @@ export default interface Services {
syncGroupService?: Record<string, unknown>;
cornerstoneCacheService?: Record<string, unknown>;
segmentationService?: Record<string, unknown>;
panelService?: Record<string, unknown>;
}

View File

@ -9,6 +9,8 @@ export * from './AppConfig';
export * from './Consumer';
export * from './Command';
export * from './StudyMetadata';
export * from './PanelModule';
export * from './IPubSub';
/**
* Export the types used within the various services and managers, but

View File

@ -25,6 +25,10 @@ optionally provide its own `context` value.
The `getPanelModule` receives an object containing the `ExtensionManager`'s
associated `ServicesManager` and `CommandsManager`.
An extension can also trigger to activate/open a panel via the `PanelService` -
either by explicitly calling `PanelService.activatePanel` or triggering panel
activation when some other event fires.
```jsx
import PanelMeasurementTable from './PanelMeasurementTable.js';
@ -64,7 +68,15 @@ providing the component with its.
New: You can easily add multiple panels to the left/right side of the viewer
using the mode configuration. As seen below, the `leftPanels` and `rightPanels`
accept an `Array` of the `IDs`.
accept an `Array` of the `IDs`. The mode configuration also allows for either (or
both) side panels to be closed by default. In the code below, the right panel
is closed by default. The mode can optionally add event triggers to
the `PanelService` that when fired will cause a side panel that was defaulted
closed to open. In the code below, the right side panel, that contains the
`trackedMeasurements` panel, is triggered to open when a measurement is added.
Note that once a default closed side panel has been opened once,
only a `PanelService.EVENTS.ACTIVATE_PANEL` event with `forceActive === true`
will cause it open (again).
```js
@ -79,6 +91,7 @@ const id = 'viewer'
const version = '3.0.0
function modeFactory({ modeConfiguration }) {
let _activatePanelTriggersSubscriptions = [];
return {
id,
routes: [
@ -94,12 +107,35 @@ function modeFactory({ modeConfiguration }) {
rightPanels: [
'@ohif/extension-measurement-tracking.panelModule.trackedMeasurements',
],
rightPanelDefaultClosed: true,
viewports,
},
};
},
},
],
onModeEnter: ({ servicesManager }) => {
const {
measurementService,
panelService,
} = servicesManager.services;
_activatePanelTriggersSubscriptions = [
...panelService.addActivatePanelTriggers('@ohif/extension-measurement-tracking.panelModule.trackedMeasurements', [
{
sourcePubSubService: measurementService,
sourceEvents: [
measurementService.EVENTS.MEASUREMENT_ADDED,
measurementService.EVENTS.RAW_MEASUREMENT_ADDED,
],
},
]),
];
},
onModeExit: () => {
_activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe());
_activatePanelTriggersSubscriptions = [];
},
extensions: extensionDependencies
};
}

View File

@ -0,0 +1,49 @@
---
sidebar_position: 8
sidebar_label: Panel Service
---
# Panel Service
## Overview
The Panel Service provides for activating/showing a panel that was registered
via the `getPanelModule` extension method. Such panels can be either explicitly
activated or implicitly triggered to activate when some other event occurs.
## Events
The following events are published in `PanelService`.
| Event | Description |
| --------------------- | ------------------------------------------------------ |
| ACTIVATE__PANEL | Fires a `ActivatePanelEvent` when a particular panel should be activated (i.e. shown). |
## API
### Panel Activation
- `activatePanel`: Fires the `ACTIVATE_PANEL` event for a particular panel (id).
An optional `forceActive` flag can be passed that when `true` "forces" a
panel to show. Ultimately, it is up to a panel's container whether it
is appropriate to activate/show the panel. For instance, if the user opened and then
closed a side panel that contains the panel to activate, that side panel
may decide that the user knows best and will not open the panel (again).
- `addActivatePanelTriggers`: Creates and returns event subscriptions that when
fired will activate the specified panel with an optional `forceActive` flag
(see `activatePanel`). This allows for panel activation to be directly triggered
by some other event(s). When the triggers are no longer needed, simply
unsubscribe to the returned subscriptions. For example, a panel
for tracking measurements might get activated every time the
`MeasurementService` fires a `MEASUREMENT_ADDED` event like this:
```js
panelService.addActivatePanelTriggers('measurement-tracking-panel-id', [
sourcePubSubService: measurementService,
sourceEvents: [
measurementService.EVENTS.MEASUREMENT_ADDED,
measurementService.EVENTS.RAW_MEASUREMENT_ADDED,
],
]);
```

View File

@ -20,6 +20,7 @@ We maintain the following non-ui Services:
- [Toolbar Service](../data/ToolBarService.md)
- [Measurement Service](../data/MeasurementService.md)
- [Customization Service](customization-service.md)
- [Panel Service](../data/PanelService.md)
## Service Architecture

View File

@ -1,6 +1,6 @@
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SwiperCore, {
A11y,
@ -11,6 +11,8 @@ import SwiperCore, {
} from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/react';
import { PanelService, ServicesManager, Types } from '@ohif/core';
import { Button, Icon, IconButton, Tooltip } from '../';
import 'swiper/css';
@ -67,15 +69,23 @@ const position = {
};
const SidePanel = ({
servicesManager,
side,
className,
activeTabIndex: activeTabIndexProp,
tabs,
}) => {
const panelService: PanelService = servicesManager.services.panelService;
const { t } = useTranslation('SidePanel');
// Tracks whether this SidePanel has been opened at least once since this SidePanel was inserted into the DOM.
// Thus going to the Study List page and back to the viewer resets this flag for a SidePanel.
const [hasBeenOpened, setHasBeenOpened] = useState(
activeTabIndexProp !== null
);
const [panelOpen, setPanelOpen] = useState(activeTabIndexProp !== null);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp || 0);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp ?? 0);
const swiperRef = useRef() as any;
const [swiper, setSwiper] = useState<any>();
@ -102,6 +112,41 @@ const SidePanel = ({
}
}, [swiper]);
const updatePanelOpen = useCallback((panelOpen: boolean) => {
setPanelOpen(panelOpen);
if (panelOpen) {
setHasBeenOpened(true);
}
}, []);
const updateActiveTabIndex = useCallback(
(activeTabIndex: number) => {
setActiveTabIndex(activeTabIndex);
updatePanelOpen(true);
},
[updatePanelOpen]
);
useEffect(() => {
const activatePanelSubscription = panelService.subscribe(
panelService.EVENTS.ACTIVATE_PANEL,
(activatePanelEvent: Types.ActivatePanelEvent) => {
if (!hasBeenOpened || activatePanelEvent.forceActive) {
const tabIndex = tabs.findIndex(
tab => tab.id === activatePanelEvent.panelId
);
if (tabIndex !== -1) {
updateActiveTabIndex(tabIndex);
}
}
}
);
return () => {
activatePanelSubscription.unsubscribe();
};
}, [tabs, hasBeenOpened, panelService, updateActiveTabIndex]);
const getCloseStateComponent = () => {
const _childComponents = Array.isArray(tabs) ? tabs : [tabs];
return (
@ -112,7 +157,7 @@ const SidePanel = ({
side === 'left' ? 'pr-2 justify-end' : 'pl-2 justify-start'
)}
onClick={() => {
setPanelOpen(prev => !prev);
updatePanelOpen(prev => !prev);
}}
data-cy={`side-panel-header-${side}`}
>
@ -142,8 +187,7 @@ const SidePanel = ({
size="initial"
className="text-primary-active"
onClick={() => {
setActiveTabIndex(index);
setPanelOpen(true);
updateActiveTabIndex(index);
}}
>
<Icon
@ -180,7 +224,7 @@ const SidePanel = ({
tabs.length === 1 && 'mb-1'
)}
onClick={() => {
setPanelOpen(prev => !prev);
updatePanelOpen(prev => !prev);
// slideToActivePanel();
}}
data-cy={`side-panel-header-${side}`}
@ -215,8 +259,7 @@ const SidePanel = ({
nextRef,
tabs,
activeTabIndex,
setActiveTabIndex,
setPanelOpen
updateActiveTabIndex
)}
{/** carousel navigation with the arrows */}
{/** only show carousel nav if tabs are more than 3 tabs */}
@ -250,6 +293,7 @@ SidePanel.defaultProps = {
};
SidePanel.propTypes = {
servicesManager: PropTypes.instanceOf(ServicesManager),
side: PropTypes.oneOf(['left', 'right']).isRequired,
className: PropTypes.string,
activeTabIndex: PropTypes.number,
@ -273,8 +317,7 @@ function _getMoreThanOneTabLayout(
nextRef: React.MutableRefObject<undefined>,
tabs: any,
activeTabIndex: any,
setActiveTabIndex: React.Dispatch<any>,
setPanelOpen: React.Dispatch<React.SetStateAction<boolean>>
updateActiveTabIndex
) {
return (
<div
@ -309,8 +352,7 @@ function _getMoreThanOneTabLayout(
)}
key={index}
onClick={() => {
setActiveTabIndex(index);
setPanelOpen(true);
updateActiveTabIndex(index);
}}
data-cy={`${obj.name}-btn`}
>

View File

@ -27,9 +27,10 @@ describe('OHIF MPR', () => {
});
it('should render correctly the MPR', () => {
cy.wait(1000);
cy.wait(250);
cy.get(':nth-child(3) > [data-cy="study-browser-thumbnail"]').dblclick();
cy.wait(250);
cy.get('[data-cy="MPR"]').click();
cy.get('[data-cy="thumbnail-viewport-labels"]').should('have.length', 3);

View File

@ -16,6 +16,7 @@ import {
UserAuthenticationService,
errorHandler,
CustomizationService,
PanelService,
// utils,
} from '@ohif/core';
@ -58,6 +59,7 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
HangingProtocolService.REGISTRATION,
CineService.REGISTRATION,
UserAuthenticationService.REGISTRATION,
PanelService.REGISTRATION,
]);
errorHandler.getHTTPErrorHandler = () => {