init: sudah ganti logo, hilangin setting, dan investigational use dialog
This commit is contained in:
197
platform/app/src/App.tsx
Normal file
197
platform/app/src/App.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
// External
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import i18n from '@ohif/i18n';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import Compose from './routes/Mode/Compose';
|
||||
import {
|
||||
ExtensionManager,
|
||||
CommandsManager,
|
||||
HotkeysManager,
|
||||
ServiceProvidersManager,
|
||||
SystemContextProvider,
|
||||
} from '@ohif/core';
|
||||
import {
|
||||
DialogProvider,
|
||||
Modal,
|
||||
ModalProvider,
|
||||
ThemeWrapper,
|
||||
ViewportDialogProvider,
|
||||
CineProvider,
|
||||
UserAuthenticationProvider,
|
||||
} from '@ohif/ui';
|
||||
import {
|
||||
ThemeWrapper as ThemeWrapperNext,
|
||||
NotificationProvider,
|
||||
ViewportGridProvider,
|
||||
TooltipProvider,
|
||||
ToolboxProvider,
|
||||
} from '@ohif/ui-next';
|
||||
// Viewer Project
|
||||
// TODO: Should this influence study list?
|
||||
import { AppConfigProvider } from '@state';
|
||||
import createRoutes from './routes';
|
||||
import appInit from './appInit.js';
|
||||
import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes';
|
||||
import { ShepherdJourneyProvider } from 'react-shepherd';
|
||||
|
||||
let commandsManager: CommandsManager,
|
||||
extensionManager: ExtensionManager,
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
serviceProvidersManager: ServiceProvidersManager,
|
||||
hotkeysManager: HotkeysManager;
|
||||
|
||||
function App({
|
||||
config = {
|
||||
/**
|
||||
* Relative route from domain root that OHIF instance is installed at.
|
||||
* For example:
|
||||
*
|
||||
* Hosted at: https://ohif.org/where-i-host-the/viewer/
|
||||
* Value: `/where-i-host-the/viewer/`
|
||||
* */
|
||||
routerBaseName: '/',
|
||||
/**
|
||||
*
|
||||
*/
|
||||
showLoadingIndicator: true,
|
||||
showStudyList: true,
|
||||
oidc: [],
|
||||
extensions: [],
|
||||
},
|
||||
defaultExtensions = [],
|
||||
defaultModes = [],
|
||||
}) {
|
||||
const [init, setInit] = useState(null);
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
appInit(config, defaultExtensions, defaultModes).then(setInit).catch(console.error);
|
||||
};
|
||||
|
||||
run();
|
||||
}, []);
|
||||
|
||||
if (!init) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set above for named export
|
||||
commandsManager = init.commandsManager;
|
||||
extensionManager = init.extensionManager;
|
||||
servicesManager = init.servicesManager;
|
||||
serviceProvidersManager = init.serviceProvidersManager;
|
||||
hotkeysManager = init.hotkeysManager;
|
||||
|
||||
// Set appConfig
|
||||
const appConfigState = init.appConfig;
|
||||
const { routerBasename, modes, dataSources, oidc, showStudyList } = appConfigState;
|
||||
|
||||
// get the maximum 3D texture size
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl2');
|
||||
|
||||
if (gl) {
|
||||
const max3DTextureSize = gl.getParameter(gl.MAX_3D_TEXTURE_SIZE);
|
||||
appConfigState.max3DTextureSize = max3DTextureSize;
|
||||
}
|
||||
|
||||
const {
|
||||
uiDialogService,
|
||||
uiModalService,
|
||||
uiViewportDialogService,
|
||||
viewportGridService,
|
||||
cineService,
|
||||
userAuthenticationService,
|
||||
uiNotificationService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const providers = [
|
||||
[AppConfigProvider, { value: appConfigState }],
|
||||
[UserAuthenticationProvider, { service: userAuthenticationService }],
|
||||
[I18nextProvider, { i18n }],
|
||||
[ThemeWrapperNext],
|
||||
[ThemeWrapper],
|
||||
[SystemContextProvider, { commandsManager, extensionManager, hotkeysManager, servicesManager }],
|
||||
[ToolboxProvider],
|
||||
[ViewportGridProvider, { service: viewportGridService }],
|
||||
[ViewportDialogProvider, { service: uiViewportDialogService }],
|
||||
[CineProvider, { service: cineService }],
|
||||
[NotificationProvider, { service: uiNotificationService }],
|
||||
[TooltipProvider],
|
||||
[DialogProvider, { service: uiDialogService }],
|
||||
[ModalProvider, { service: uiModalService, modal: Modal }],
|
||||
[ShepherdJourneyProvider],
|
||||
];
|
||||
|
||||
// Loop through and register each of the service providers registered with the ServiceProvidersManager.
|
||||
const providersFromManager = Object.entries(serviceProvidersManager.providers);
|
||||
if (providersFromManager.length > 0) {
|
||||
providersFromManager.forEach(([serviceName, provider]) => {
|
||||
providers.push([provider, { service: servicesManager.services[serviceName] }]);
|
||||
});
|
||||
}
|
||||
|
||||
const CombinedProviders = ({ children }) => Compose({ components: providers, children });
|
||||
|
||||
let authRoutes = null;
|
||||
|
||||
// Should there be a generic call to init on the extension manager?
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
// Use config to create routes
|
||||
const appRoutes = createRoutes({
|
||||
modes,
|
||||
dataSources,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
routerBasename,
|
||||
showStudyList,
|
||||
});
|
||||
|
||||
if (oidc) {
|
||||
authRoutes = (
|
||||
<OpenIdConnectRoutes
|
||||
oidc={oidc}
|
||||
routerBasename={routerBasename}
|
||||
userAuthenticationService={userAuthenticationService}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CombinedProviders>
|
||||
<BrowserRouter basename={routerBasename}>
|
||||
{authRoutes}
|
||||
{appRoutes}
|
||||
</BrowserRouter>
|
||||
</CombinedProviders>
|
||||
);
|
||||
}
|
||||
|
||||
App.propTypes = {
|
||||
config: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({
|
||||
routerBasename: PropTypes.string.isRequired,
|
||||
oidc: PropTypes.array,
|
||||
whiteLabeling: PropTypes.object,
|
||||
extensions: PropTypes.array,
|
||||
}),
|
||||
]).isRequired,
|
||||
/* Extensions that are "bundled" or "baked-in" to the application.
|
||||
* These would be provided at build time as part of they entry point. */
|
||||
defaultExtensions: PropTypes.array,
|
||||
/* Modes that are "bundled" or "baked-in" to the application.
|
||||
* These would be provided at build time as part of they entry point. */
|
||||
defaultModes: PropTypes.array,
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
export { commandsManager, extensionManager, servicesManager };
|
||||
3
platform/app/src/__mocks__/fileMock.js
Normal file
3
platform/app/src/__mocks__/fileMock.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// https://jestjs.io/docs/en/webpack#handling-static-assets
|
||||
|
||||
module.exports = 'test-file-stub';
|
||||
11
platform/app/src/__tests__/globalSetup.js
Normal file
11
platform/app/src/__tests__/globalSetup.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const _ = require('lodash');
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
// JSDom's CSS Parser has limited support for certain features
|
||||
// This suppresses error warnings caused by it
|
||||
console.error = function (msg) {
|
||||
if (_.startsWith(msg, 'Error: Could not parse CSS stylesheet')) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(msg);
|
||||
};
|
||||
147
platform/app/src/appInit.js
Normal file
147
platform/app/src/appInit.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
CommandsManager,
|
||||
ExtensionManager,
|
||||
ServicesManager,
|
||||
ServiceProvidersManager,
|
||||
HotkeysManager,
|
||||
UINotificationService,
|
||||
UIModalService,
|
||||
UIDialogService,
|
||||
UIViewportDialogService,
|
||||
MeasurementService,
|
||||
DisplaySetService,
|
||||
ToolbarService,
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
CineService,
|
||||
UserAuthenticationService,
|
||||
errorHandler,
|
||||
CustomizationService,
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
MultiMonitorService,
|
||||
// utils,
|
||||
} from '@ohif/core';
|
||||
|
||||
import loadModules, { loadModule as peerImport } from './pluginImports';
|
||||
|
||||
/**
|
||||
* @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration
|
||||
* @param {object[]} defaultExtensions - array of extension objects
|
||||
*/
|
||||
async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
|
||||
const commandsManagerConfig = {
|
||||
getAppState: () => {},
|
||||
};
|
||||
|
||||
const commandsManager = new CommandsManager(commandsManagerConfig);
|
||||
const servicesManager = new ServicesManager(commandsManager);
|
||||
const serviceProvidersManager = new ServiceProvidersManager();
|
||||
const hotkeysManager = new HotkeysManager(commandsManager, servicesManager);
|
||||
|
||||
const appConfig = {
|
||||
...(typeof appConfigOrFunc === 'function'
|
||||
? await appConfigOrFunc({ servicesManager, peerImport })
|
||||
: appConfigOrFunc),
|
||||
};
|
||||
// Default the peer import function
|
||||
appConfig.peerImport ||= peerImport;
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
commandsManager,
|
||||
servicesManager,
|
||||
serviceProvidersManager,
|
||||
hotkeysManager,
|
||||
appConfig,
|
||||
});
|
||||
|
||||
servicesManager.setExtensionManager(extensionManager);
|
||||
|
||||
servicesManager.registerServices([
|
||||
[MultiMonitorService.REGISTRATION, appConfig.multimonitor],
|
||||
UINotificationService.REGISTRATION,
|
||||
UIModalService.REGISTRATION,
|
||||
UIDialogService.REGISTRATION,
|
||||
UIViewportDialogService.REGISTRATION,
|
||||
MeasurementService.REGISTRATION,
|
||||
DisplaySetService.REGISTRATION,
|
||||
[CustomizationService.REGISTRATION, appConfig.customizationService],
|
||||
ToolbarService.REGISTRATION,
|
||||
ViewportGridService.REGISTRATION,
|
||||
HangingProtocolService.REGISTRATION,
|
||||
CineService.REGISTRATION,
|
||||
UserAuthenticationService.REGISTRATION,
|
||||
PanelService.REGISTRATION,
|
||||
WorkflowStepsService.REGISTRATION,
|
||||
[StudyPrefetcherService.REGISTRATION, appConfig.studyPrefetcher],
|
||||
]);
|
||||
|
||||
errorHandler.getHTTPErrorHandler = () => {
|
||||
if (typeof appConfig.httpErrorHandler === 'function') {
|
||||
return appConfig.httpErrorHandler;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: [ext1, ext2, ext3]
|
||||
* Example2: [[ext1, config], ext2, [ext3, config]]
|
||||
*/
|
||||
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
|
||||
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);
|
||||
|
||||
// TODO: We no longer use `utils.addServer`
|
||||
// TODO: We no longer init webWorkers at app level
|
||||
// TODO: We no longer init the user Manager
|
||||
|
||||
if (!appConfig.modes) {
|
||||
throw new Error('No modes are defined! Check your app-config.js');
|
||||
}
|
||||
|
||||
const loadedModes = await loadModules([...(appConfig.modes || []), ...defaultModes]);
|
||||
|
||||
// This is the name for the loaded instance object
|
||||
appConfig.loadedModes = [];
|
||||
const modesById = new Set();
|
||||
for (let i = 0; i < loadedModes.length; i++) {
|
||||
let mode = loadedModes[i];
|
||||
if (!mode) {
|
||||
continue;
|
||||
}
|
||||
const { id } = mode;
|
||||
|
||||
if (mode.modeFactory) {
|
||||
// If the appConfig contains configuration for this mode, use it.
|
||||
const modeConfiguration =
|
||||
appConfig.modesConfiguration && appConfig.modesConfiguration[id]
|
||||
? appConfig.modesConfiguration[id]
|
||||
: {};
|
||||
|
||||
mode = await mode.modeFactory({ modeConfiguration, loadModules });
|
||||
}
|
||||
|
||||
if (modesById.has(id)) {
|
||||
continue;
|
||||
}
|
||||
// Prevent duplication
|
||||
modesById.add(id);
|
||||
if (!mode || typeof mode !== 'object') {
|
||||
continue;
|
||||
}
|
||||
appConfig.loadedModes.push(mode);
|
||||
}
|
||||
// Hack alert - don't touch the original modes definition,
|
||||
// but there are still dependencies on having the appConfig modes defined
|
||||
appConfig.modes = appConfig.loadedModes;
|
||||
|
||||
return {
|
||||
appConfig,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
serviceProvidersManager,
|
||||
hotkeysManager,
|
||||
};
|
||||
}
|
||||
|
||||
export default appInit;
|
||||
7
platform/app/src/components/EmptyViewport.tsx
Normal file
7
platform/app/src/components/EmptyViewport.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
function EmptyViewport() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
export default EmptyViewport;
|
||||
389
platform/app/src/components/ViewportGrid.tsx
Normal file
389
platform/app/src/components/ViewportGrid.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import { Types, MeasurementService } from '@ohif/core';
|
||||
import { ViewportGrid, ViewportPane } from '@ohif/ui';
|
||||
import { useViewportGrid } from '@ohif/ui-next';
|
||||
import EmptyViewport from './EmptyViewport';
|
||||
import classNames from 'classnames';
|
||||
import { useAppConfig } from '@state';
|
||||
|
||||
function ViewerViewportGrid(props: withAppTypes) {
|
||||
const { servicesManager, viewportComponents = [], dataSource } = props;
|
||||
const [viewportGrid, viewportGridService] = useViewportGrid();
|
||||
const [appConfig] = useAppConfig();
|
||||
|
||||
const { layout, activeViewportId, viewports, isHangingProtocolLayout } = viewportGrid;
|
||||
const { numCols, numRows } = layout;
|
||||
const { ref: resizeRef } = useResizeDetector({
|
||||
refreshMode: 'debounce',
|
||||
refreshRate: 7,
|
||||
refreshOptions: { leading: true },
|
||||
onResize: () => {
|
||||
viewportGridService.setViewportGridSizeChanged();
|
||||
},
|
||||
});
|
||||
const layoutHash = useRef(null);
|
||||
|
||||
const {
|
||||
displaySetService,
|
||||
measurementService,
|
||||
hangingProtocolService,
|
||||
uiNotificationService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const generateLayoutHash = () => `${numCols}-${numRows}`;
|
||||
|
||||
/**
|
||||
* This callback runs after the viewports structure has changed in any way.
|
||||
* On initial display, that means if it has changed by applying a HangingProtocol,
|
||||
* while subsequently it may mean by changing the stage or by manually adjusting
|
||||
* the layout.
|
||||
|
||||
*/
|
||||
const updateDisplaySetsFromProtocol = (
|
||||
protocol: Types.HangingProtocol.Protocol,
|
||||
stage,
|
||||
activeStudyUID,
|
||||
viewportMatchDetails
|
||||
) => {
|
||||
const availableDisplaySets = displaySetService.getActiveDisplaySets();
|
||||
|
||||
if (!availableDisplaySets.length) {
|
||||
console.log('No available display sets', availableDisplaySets);
|
||||
return;
|
||||
}
|
||||
|
||||
// Match each viewport individually
|
||||
const { layoutType } = stage.viewportStructure;
|
||||
const stageProps = stage.viewportStructure.properties;
|
||||
const { columns: numCols, rows: numRows, layoutOptions = [] } = stageProps;
|
||||
|
||||
/**
|
||||
* This find or create viewport uses the hanging protocol results to
|
||||
* specify the viewport match details, which specifies the size and
|
||||
* setup of the various viewports.
|
||||
*/
|
||||
const findOrCreateViewport = pos => {
|
||||
const viewportId = Array.from(viewportMatchDetails.keys())[pos];
|
||||
const details = viewportMatchDetails.get(viewportId);
|
||||
if (!details) {
|
||||
console.log('No match details for viewport', viewportId);
|
||||
return;
|
||||
}
|
||||
|
||||
const { displaySetsInfo, viewportOptions } = details;
|
||||
const displaySetUIDsToHang = [];
|
||||
const displaySetUIDsToHangOptions = [];
|
||||
|
||||
displaySetsInfo.forEach(({ displaySetInstanceUID, displaySetOptions }) => {
|
||||
if (displaySetInstanceUID) {
|
||||
displaySetUIDsToHang.push(displaySetInstanceUID);
|
||||
}
|
||||
|
||||
displaySetUIDsToHangOptions.push(displaySetOptions);
|
||||
});
|
||||
|
||||
const computedViewportOptions = hangingProtocolService.getComputedOptions(
|
||||
viewportOptions,
|
||||
displaySetUIDsToHang
|
||||
);
|
||||
|
||||
const computedDisplaySetOptions = hangingProtocolService.getComputedOptions(
|
||||
displaySetUIDsToHangOptions,
|
||||
displaySetUIDsToHang
|
||||
);
|
||||
|
||||
return {
|
||||
displaySetInstanceUIDs: displaySetUIDsToHang,
|
||||
displaySetOptions: computedDisplaySetOptions,
|
||||
viewportOptions: computedViewportOptions,
|
||||
};
|
||||
};
|
||||
|
||||
viewportGridService.setLayout({
|
||||
numRows,
|
||||
numCols,
|
||||
layoutType,
|
||||
layoutOptions,
|
||||
findOrCreateViewport,
|
||||
isHangingProtocolLayout: true,
|
||||
});
|
||||
};
|
||||
|
||||
const _getUpdatedViewports = useCallback(
|
||||
(viewportId, displaySetInstanceUID) => {
|
||||
if (!displaySetInstanceUID) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let updatedViewports = [];
|
||||
try {
|
||||
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
|
||||
viewportId,
|
||||
displaySetInstanceUID,
|
||||
isHangingProtocolLayout
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
uiNotificationService.show({
|
||||
title: 'Drag and Drop',
|
||||
message:
|
||||
'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
|
||||
type: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedViewports;
|
||||
},
|
||||
[hangingProtocolService, uiNotificationService, isHangingProtocolLayout]
|
||||
);
|
||||
|
||||
// Using Hanging protocol engine to match the displaySets
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = hangingProtocolService.subscribe(
|
||||
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
|
||||
({ protocol, stage, activeStudyUID, viewportMatchDetails }) => {
|
||||
updateDisplaySetsFromProtocol(protocol, stage, activeStudyUID, viewportMatchDetails);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check viewport readiness in useEffect
|
||||
useEffect(() => {
|
||||
const allReady = viewportGridService.getGridViewportsReady();
|
||||
const sameLayoutHash = layoutHash.current === generateLayoutHash();
|
||||
if (allReady && !sameLayoutHash) {
|
||||
layoutHash.current = generateLayoutHash();
|
||||
viewportGridService.publishViewportsReady();
|
||||
}
|
||||
}, [viewportGridService, generateLayoutHash]);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = measurementService.subscribe(
|
||||
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
|
||||
({ viewportId, measurement, isConsumed }) => {
|
||||
if (isConsumed) {
|
||||
return;
|
||||
}
|
||||
// This occurs when no viewport has elected to consume the event
|
||||
// so we need to change layouts into a layout which can consume
|
||||
// the event.
|
||||
const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement;
|
||||
|
||||
const updatedViewports = _getUpdatedViewports(viewportId, referencedDisplaySetInstanceUID);
|
||||
if (!updatedViewports[0]) {
|
||||
console.warn(
|
||||
'ViewportGrid::Unable to navigate to viewport containing',
|
||||
referencedDisplaySetInstanceUID
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Arbitrarily assign the viewport to element 0
|
||||
// TODO - this should perform a search to find the most suitable viewport.
|
||||
updatedViewports[0] = { ...updatedViewports[0] };
|
||||
const [viewport] = updatedViewports;
|
||||
|
||||
// Copy the viewport options to prevent modifying the internal data
|
||||
viewport.viewportOptions = {
|
||||
...viewport.viewportOptions,
|
||||
orientation: 'acquisition',
|
||||
// The preferred way to jump to the measurement view is to set the
|
||||
// view reference, as this can hold information such as the orientation
|
||||
// or zoom level required to display an annotation. The metadata attribute
|
||||
// of the measurement is a viewReference, so use it to show the measurement.
|
||||
// Longer term this should clear the view reference data
|
||||
viewReference: measurement.metadata,
|
||||
viewportType: measurement.metadata.volumeId ? 'volume' : null,
|
||||
};
|
||||
|
||||
viewportGridService.setDisplaySetsForViewports(updatedViewports);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [viewports]);
|
||||
|
||||
const onDropHandler = (viewportId, { displaySetInstanceUID }) => {
|
||||
const customOnDropHandler = customizationService.getCustomization('customOnDropHandler');
|
||||
const dropHandlerPromise = customOnDropHandler({
|
||||
...props,
|
||||
viewportId,
|
||||
displaySetInstanceUID,
|
||||
appConfig,
|
||||
});
|
||||
|
||||
dropHandlerPromise.then(({ handled }) => {
|
||||
if (!handled) {
|
||||
const updatedViewports = _getUpdatedViewports(viewportId, displaySetInstanceUID);
|
||||
viewportGridService.setDisplaySetsForViewports(updatedViewports);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getViewportPanes = useCallback(() => {
|
||||
const viewportPanes = [];
|
||||
|
||||
const numViewportPanes = viewportGridService.getNumViewportPanes();
|
||||
for (let i = 0; i < numViewportPanes; i++) {
|
||||
const paneMetadata = Array.from(viewports.values())[i] || {};
|
||||
const {
|
||||
displaySetInstanceUIDs,
|
||||
viewportOptions,
|
||||
displaySetOptions, // array of options for each display set in the viewport
|
||||
x: viewportX,
|
||||
y: viewportY,
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
viewportLabel,
|
||||
} = paneMetadata;
|
||||
|
||||
const viewportId = viewportOptions.viewportId;
|
||||
const isActive = activeViewportId === viewportId;
|
||||
|
||||
const displaySetInstanceUIDsToUse = displaySetInstanceUIDs || [];
|
||||
|
||||
// This is causing the viewport components re-render when the activeViewportId changes
|
||||
const displaySets = displaySetInstanceUIDsToUse
|
||||
.map(displaySetInstanceUID => {
|
||||
return displaySetService.getDisplaySetByUID(displaySetInstanceUID) || {};
|
||||
})
|
||||
.filter(displaySet => {
|
||||
return !displaySet?.unsupported;
|
||||
});
|
||||
|
||||
const ViewportComponent = _getViewportComponent(
|
||||
displaySets,
|
||||
viewportComponents,
|
||||
uiNotificationService
|
||||
);
|
||||
|
||||
// look inside displaySets to see if they need reRendering
|
||||
const displaySetsNeedsRerendering = displaySets.some(displaySet => {
|
||||
return displaySet.needsRerendering;
|
||||
});
|
||||
|
||||
const onInteractionHandler = event => {
|
||||
if (isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event && (appConfig?.activateViewportBeforeInteraction ?? true)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
viewportGridService.setActiveViewportId(viewportId);
|
||||
};
|
||||
|
||||
viewportPanes[i] = (
|
||||
<ViewportPane
|
||||
// Note: It is highly important that the key is the viewportId here,
|
||||
// since it is used to determine if the component should be re-rendered
|
||||
// by React, and also in the hanging protocol and stage changes if the
|
||||
// same viewportId is used, React, by default, will only move (not re-render)
|
||||
// those components. For instance, if we have a 2x3 layout, and we move
|
||||
// from 2x3 to 1x1 (second viewport), if the key is the viewportIndex,
|
||||
// React will RE-RENDER the resulting viewport as the key will be different.
|
||||
// however, if the key is the viewportId, React will only move the component
|
||||
// and not re-render it.
|
||||
key={viewportId}
|
||||
acceptDropsFor="displayset"
|
||||
onDrop={onDropHandler.bind(null, viewportId)}
|
||||
onInteraction={onInteractionHandler}
|
||||
customStyle={{
|
||||
position: 'absolute',
|
||||
top: viewportY * 100 + 0.2 + '%',
|
||||
left: viewportX * 100 + 0.2 + '%',
|
||||
width: viewportWidth * 100 - 0.3 + '%',
|
||||
height: viewportHeight * 100 - 0.3 + '%',
|
||||
}}
|
||||
isActive={isActive}
|
||||
>
|
||||
<div
|
||||
data-cy="viewport-pane"
|
||||
className="flex h-full w-full min-w-[5px] flex-col"
|
||||
>
|
||||
<ViewportComponent
|
||||
displaySets={displaySets}
|
||||
viewportLabel={viewports.size > 1 ? viewportLabel : ''}
|
||||
viewportId={viewportId}
|
||||
dataSource={dataSource}
|
||||
viewportOptions={viewportOptions}
|
||||
displaySetOptions={displaySetOptions}
|
||||
needsRerendering={displaySetsNeedsRerendering}
|
||||
isHangingProtocolLayout={isHangingProtocolLayout}
|
||||
onElementEnabled={() => {
|
||||
viewportGridService.setViewportIsReady(viewportId, true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ViewportPane>
|
||||
);
|
||||
}
|
||||
|
||||
return viewportPanes;
|
||||
}, [viewports, activeViewportId, viewportComponents, dataSource]);
|
||||
|
||||
/**
|
||||
* Loading indicator until numCols and numRows are gotten from the HangingProtocolService
|
||||
*/
|
||||
if (!numRows || !numCols) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={resizeRef}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<ViewportGrid
|
||||
numRows={numRows}
|
||||
numCols={numCols}
|
||||
>
|
||||
{getViewportPanes()}
|
||||
</ViewportGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function _getViewportComponent(displaySets, viewportComponents, uiNotificationService) {
|
||||
if (!displaySets || !displaySets.length) {
|
||||
return EmptyViewport;
|
||||
}
|
||||
|
||||
// Todo: Do we have a viewport that has two different SOPClassHandlerIds?
|
||||
const SOPClassHandlerId = displaySets[0].SOPClassHandlerId;
|
||||
|
||||
for (let i = 0; i < viewportComponents.length; i++) {
|
||||
if (!viewportComponents[i]) {
|
||||
throw new Error('viewport components not defined');
|
||||
}
|
||||
if (!viewportComponents[i].displaySetsToDisplay) {
|
||||
throw new Error('displaySetsToDisplay is null');
|
||||
}
|
||||
if (viewportComponents[i].displaySetsToDisplay.includes(SOPClassHandlerId)) {
|
||||
const { component } = viewportComponents[i];
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Can't show displaySet", SOPClassHandlerId, displaySets[0]);
|
||||
uiNotificationService.show({
|
||||
title: 'Viewport Not Supported Yet',
|
||||
message: `Cannot display SOPClassUID of ${displaySets[0].SOPClassUID} yet`,
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return EmptyViewport;
|
||||
}
|
||||
|
||||
export default ViewerViewportGrid;
|
||||
4
platform/app/src/hooks/index.js
Normal file
4
platform/app/src/hooks/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import useDebounce from './useDebounce.js';
|
||||
import useSearchParams from './useSearchParams';
|
||||
|
||||
export { useDebounce, useSearchParams };
|
||||
31
platform/app/src/hooks/useDebounce.js
Normal file
31
platform/app/src/hooks/useDebounce.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* See: https://usehooks.com/useDebounce/
|
||||
*
|
||||
* @param {*} value
|
||||
* @param {number} delay - delat in ms
|
||||
*/
|
||||
export default function useDebounce(value, delay) {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// Update debounced value after delay
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
// This is how we prevent debounced value from updating if value is changed ...
|
||||
// .. within the delay period. Timeout gets cleared and restarted.
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay] // Only re-call effect if value or delay changes
|
||||
);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
30
platform/app/src/hooks/useSearchParams.ts
Normal file
30
platform/app/src/hooks/useSearchParams.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
/**
|
||||
* It returns a URLSearchParams of the query parameters in the URL, where the keys are
|
||||
* either lowercase or maintain their case based on the lowerCaseKeys parameter.
|
||||
* This will automatically include the hash parameters as preferred parameters
|
||||
* @param {lowerCaseKeys:boolean} true to return lower case keys; false (default) to maintain casing;
|
||||
* @returns {URLSearchParams}
|
||||
*/
|
||||
export default function useSearchParams(options = { lowerCaseKeys: false }) {
|
||||
const { lowerCaseKeys } = options;
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hashParams = new URLSearchParams(location.hash?.substring(1) || '');
|
||||
|
||||
for (const [key, value] of hashParams) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
if (!lowerCaseKeys) {
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
const lowerCaseSearchParams = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of searchParams) {
|
||||
lowerCaseSearchParams.set(key.toLowerCase(), value);
|
||||
}
|
||||
|
||||
return lowerCaseSearchParams;
|
||||
}
|
||||
44
platform/app/src/index.js
Normal file
44
platform/app/src/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Entry point for development and production PWA builds.
|
||||
*/
|
||||
import 'regenerator-runtime/runtime';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* EXTENSIONS AND MODES
|
||||
* =================
|
||||
* pluginImports.js is dynamically generated from extension and mode
|
||||
* configuration at build time.
|
||||
*
|
||||
* pluginImports.js imports all of the modes and extensions and adds them
|
||||
* to the window for processing.
|
||||
*/
|
||||
import { modes as defaultModes, extensions as defaultExtensions } from './pluginImports';
|
||||
import loadDynamicConfig from './loadDynamicConfig';
|
||||
export { history } from './utils/history';
|
||||
export { preserveQueryParameters, preserveQueryStrings } from './utils/preserveQueryParameters';
|
||||
export { publicUrl } from './utils/publicUrl';
|
||||
|
||||
loadDynamicConfig(window.config).then(config_json => {
|
||||
// Reset Dynamic config if defined
|
||||
if (config_json !== null) {
|
||||
window.config = config_json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine our appConfiguration with installed extensions and modes.
|
||||
* In the future appConfiguration may contain modes added at runtime.
|
||||
* */
|
||||
const appProps = {
|
||||
config: window ? window.config : {},
|
||||
defaultExtensions,
|
||||
defaultModes,
|
||||
};
|
||||
|
||||
const container = document.getElementById('root');
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(App, appProps));
|
||||
});
|
||||
23
platform/app/src/loadDynamicConfig.js
Normal file
23
platform/app/src/loadDynamicConfig.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default async config => {
|
||||
const useDynamicConfig = config.dangerouslyUseDynamicConfig;
|
||||
|
||||
// Check if dangerouslyUseDynamicConfig enabled
|
||||
if (useDynamicConfig?.enabled) {
|
||||
// If enabled then get configUrl query-string
|
||||
let query = new URLSearchParams(window.location.search);
|
||||
let configUrl = query.get('configUrl');
|
||||
|
||||
if (configUrl) {
|
||||
// validate regex
|
||||
const regex = useDynamicConfig.regex;
|
||||
|
||||
if (configUrl.match(regex)) {
|
||||
const response = await fetch(configUrl);
|
||||
return response.json();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
23
platform/app/src/routes/CallbackPage.tsx
Normal file
23
platform/app/src/routes/CallbackPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function CallbackPage({ userManager, onRedirectSuccess }) {
|
||||
const onRedirectError = error => {
|
||||
throw new Error(error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
userManager
|
||||
.signinRedirectCallback()
|
||||
.then(user => onRedirectSuccess(user))
|
||||
.catch(error => onRedirectError(error));
|
||||
}, [userManager, onRedirectSuccess]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
CallbackPage.propTypes = {
|
||||
userManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default CallbackPage;
|
||||
305
platform/app/src/routes/DataSourceWrapper.tsx
Normal file
305
platform/app/src/routes/DataSourceWrapper.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Enums, ExtensionManager, MODULE_TYPES, log } from '@ohif/core';
|
||||
//
|
||||
import { extensionManager } from '../App';
|
||||
import { useParams, useLocation } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSearchParams from '../hooks/useSearchParams';
|
||||
|
||||
/**
|
||||
* Determines if two React Router location objects are the same.
|
||||
*/
|
||||
const areLocationsTheSame = (location0, location1) => {
|
||||
return (
|
||||
location0.pathname === location1.pathname &&
|
||||
location0.search === location1.search &&
|
||||
location0.hash === location1.hash
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses route properties to determine the data source that should be passed
|
||||
* to the child layout template. In some instances, initiates requests and
|
||||
* passes data as props.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {function} props.children - Layout Template React Component
|
||||
*/
|
||||
function DataSourceWrapper(props: withAppTypes) {
|
||||
const { servicesManager } = props;
|
||||
const navigate = useNavigate();
|
||||
const { children: LayoutTemplate, ...rest } = props;
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
|
||||
const query = useSearchParams();
|
||||
// Route props --> studies.mapParams
|
||||
// mapParams --> studies.search
|
||||
// studies.search --> studies.processResults
|
||||
// studies.processResults --> <LayoutTemplate studies={} />
|
||||
// But only for LayoutTemplate type of 'list'?
|
||||
// Or no data fetching here, and just hand down my source
|
||||
const STUDIES_LIMIT = 101;
|
||||
const DEFAULT_DATA = {
|
||||
studies: [],
|
||||
total: 0,
|
||||
resultsPerPage: 25,
|
||||
pageNumber: 1,
|
||||
location: 'Not a valid location, causes first load to occur',
|
||||
};
|
||||
|
||||
const getInitialDataSourceName = useCallback(() => {
|
||||
// TODO - get the variable from the props all the time...
|
||||
let dataSourceName = lowerCaseSearchParams.get('datasources');
|
||||
|
||||
if (!dataSourceName && window.config.defaultDataSourceName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!dataSourceName) {
|
||||
// Gets the first defined datasource with the right name
|
||||
// Mostly for historical reasons - new configs should use the defaultDataSourceName
|
||||
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
|
||||
// TODO: Good usecase for flatmap?
|
||||
const webApiDataSources = dataSourceModules.reduce((acc, curr) => {
|
||||
const mods = [];
|
||||
curr.module.forEach(mod => {
|
||||
if (mod.type === 'webApi') {
|
||||
mods.push(mod);
|
||||
}
|
||||
});
|
||||
return acc.concat(mods);
|
||||
}, []);
|
||||
dataSourceName = webApiDataSources
|
||||
.map(ds => ds.name)
|
||||
.find(it => extensionManager.getDataSources(it)?.[0] !== undefined);
|
||||
}
|
||||
|
||||
return dataSourceName;
|
||||
}, []);
|
||||
|
||||
const [isDataSourceInitialized, setIsDataSourceInitialized] = useState(false);
|
||||
|
||||
// The path to the data source to be used in the URL for a mode (e.g. mode/dataSourcePath?StudyIntanceUIDs=1.2.3)
|
||||
const [dataSourcePath, setDataSourcePath] = useState(() => {
|
||||
const dataSourceName = getInitialDataSourceName();
|
||||
return dataSourceName ? `/${dataSourceName}` : '';
|
||||
});
|
||||
|
||||
const [dataSource, setDataSource] = useState(() => {
|
||||
const dataSourceName = getInitialDataSourceName();
|
||||
|
||||
if (!dataSourceName) {
|
||||
return extensionManager.getActiveDataSource()[0];
|
||||
}
|
||||
|
||||
const dataSource = extensionManager.getDataSources(dataSourceName)?.[0];
|
||||
if (!dataSource) {
|
||||
throw new Error(`No data source found for ${dataSourceName}`);
|
||||
}
|
||||
|
||||
return dataSource;
|
||||
});
|
||||
|
||||
const [data, setData] = useState(DEFAULT_DATA);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* The effect to initialize the data source whenever it changes. Similar to
|
||||
* whenever a different Mode is entered, the Mode's data source is initialized, so
|
||||
* too this DataSourceWrapper must initialize its data source whenever a different
|
||||
* data source is activated. Furthermore, a data source might be initialized
|
||||
* several times as it gets activated/deactivated because the location URL
|
||||
* might change and data sources initialize based on the URL.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const initializeDataSource = async () => {
|
||||
await dataSource.initialize({ params, query });
|
||||
setIsDataSourceInitialized(true);
|
||||
};
|
||||
|
||||
initializeDataSource();
|
||||
}, [dataSource]);
|
||||
|
||||
useEffect(() => {
|
||||
const dataSourceChangedCallback = () => {
|
||||
setIsLoading(false);
|
||||
setIsDataSourceInitialized(false);
|
||||
setDataSourcePath('');
|
||||
setDataSource(extensionManager.getActiveDataSource()[0]);
|
||||
// Setting data to DEFAULT_DATA triggers a new query just like it does for the initial load.
|
||||
setData(DEFAULT_DATA);
|
||||
};
|
||||
|
||||
const sub = extensionManager.subscribe(
|
||||
ExtensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED,
|
||||
dataSourceChangedCallback
|
||||
);
|
||||
return () => sub.unsubscribe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDataSourceInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryFilterValues = _getQueryFilterValues(location.search, STUDIES_LIMIT);
|
||||
|
||||
// 204: no content
|
||||
async function getData() {
|
||||
setIsLoading(true);
|
||||
log.time(Enums.TimingEnum.SEARCH_TO_LIST);
|
||||
const studies = await dataSource.query.studies.search(queryFilterValues);
|
||||
|
||||
setData({
|
||||
studies: studies || [],
|
||||
total: studies.length,
|
||||
resultsPerPage: queryFilterValues.resultsPerPage,
|
||||
pageNumber: queryFilterValues.pageNumber,
|
||||
location,
|
||||
});
|
||||
log.timeEnd(Enums.TimingEnum.SCRIPT_TO_VIEW);
|
||||
log.timeEnd(Enums.TimingEnum.SEARCH_TO_LIST);
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
// Cache invalidation :thinking:
|
||||
// - Anytime change is not just next/previous page
|
||||
// - And we didn't cross a result offset range
|
||||
const isSamePage = data.pageNumber === queryFilterValues.pageNumber;
|
||||
const previousOffset =
|
||||
Math.floor((data.pageNumber * data.resultsPerPage) / STUDIES_LIMIT) * (STUDIES_LIMIT - 1);
|
||||
const newOffset =
|
||||
Math.floor(
|
||||
(queryFilterValues.pageNumber * queryFilterValues.resultsPerPage) / STUDIES_LIMIT
|
||||
) *
|
||||
(STUDIES_LIMIT - 1);
|
||||
// Simply checking data.location !== location is not sufficient because even though the location href (i.e. entire URL)
|
||||
// has not changed, the React Router still provides a new location reference and would result in two study queries
|
||||
// on initial load. Alternatively, window.location.href could be used.
|
||||
const isLocationUpdated =
|
||||
typeof data.location === 'string' || !areLocationsTheSame(data.location, location);
|
||||
const isDataInvalid =
|
||||
!isSamePage || (!isLoading && (newOffset !== previousOffset || isLocationUpdated));
|
||||
|
||||
if (isDataInvalid) {
|
||||
getData().catch(e => {
|
||||
console.error(e);
|
||||
|
||||
const { configurationAPI, friendlyName } = dataSource.getConfig();
|
||||
// If there is a data source configuration API, then the Worklist will popup the dialog to attempt to configure it
|
||||
// and attempt to resolve this issue.
|
||||
if (configurationAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
servicesManager.services.uiModalService.show({
|
||||
title: 'Data Source Connection Error',
|
||||
containerDimensions: 'w-1/2',
|
||||
content: () => {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-red-600">Error: {e.message}</p>
|
||||
<p>
|
||||
Please ensure the following data source is configured correctly or is running:
|
||||
</p>
|
||||
<div className="mt-2 font-bold">{friendlyName}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (ex) {
|
||||
console.warn(ex);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, location, params, isLoading, setIsLoading, dataSource, isDataSourceInitialized]);
|
||||
// queryFilterValues
|
||||
|
||||
// TODO: Better way to pass DataSource?
|
||||
return (
|
||||
<LayoutTemplate
|
||||
{...rest}
|
||||
data={data.studies}
|
||||
dataPath={dataSourcePath}
|
||||
dataTotal={data.total}
|
||||
dataSource={dataSource}
|
||||
isLoadingData={isLoading}
|
||||
// To refresh the data, simply reset it to DEFAULT_DATA which invalidates it and triggers a new query to fetch the data.
|
||||
onRefresh={() => setData(DEFAULT_DATA)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DataSourceWrapper.propTypes = {
|
||||
/** Layout Component to wrap with a Data Source */
|
||||
children: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired,
|
||||
};
|
||||
|
||||
export default DataSourceWrapper;
|
||||
|
||||
/**
|
||||
* Duplicated in `workList`
|
||||
* Need generic that can be shared? Isn't this what qs is for?
|
||||
* @param {*} query
|
||||
*/
|
||||
function _getQueryFilterValues(query, queryLimit) {
|
||||
query = new URLSearchParams(query);
|
||||
const newParams = new URLSearchParams();
|
||||
for (const [key, value] of query) {
|
||||
newParams.set(key.toLowerCase(), value);
|
||||
}
|
||||
query = newParams;
|
||||
|
||||
const pageNumber = _tryParseInt(query.get('pagenumber'), 1);
|
||||
const resultsPerPage = _tryParseInt(query.get('resultsperpage'), 25);
|
||||
|
||||
const queryFilterValues = {
|
||||
// DCM
|
||||
patientId: query.get('mrn'),
|
||||
patientName: query.get('patientname'),
|
||||
studyDescription: query.get('description'),
|
||||
modalitiesInStudy: query.get('modalities') && query.get('modalities').split(','),
|
||||
accessionNumber: query.get('accession'),
|
||||
//
|
||||
startDate: query.get('startdate'),
|
||||
endDate: query.get('enddate'),
|
||||
page: _tryParseInt(query.get('page'), undefined),
|
||||
pageNumber,
|
||||
resultsPerPage,
|
||||
// Rarely supported server-side
|
||||
sortBy: query.get('sortby'),
|
||||
sortDirection: query.get('sortdirection'),
|
||||
// Offset...
|
||||
offset: Math.floor((pageNumber * resultsPerPage) / queryLimit) * (queryLimit - 1),
|
||||
config: query.get('configurl'),
|
||||
};
|
||||
|
||||
// patientName: good
|
||||
// studyDescription: good
|
||||
// accessionNumber: good
|
||||
|
||||
// Delete null/undefined keys
|
||||
Object.keys(queryFilterValues).forEach(
|
||||
key => queryFilterValues[key] == null && delete queryFilterValues[key]
|
||||
);
|
||||
|
||||
return queryFilterValues;
|
||||
|
||||
function _tryParseInt(str, defaultValue) {
|
||||
let retValue = defaultValue;
|
||||
if (str !== null) {
|
||||
if (str.length > 0) {
|
||||
if (!isNaN(str)) {
|
||||
retValue = parseInt(str);
|
||||
}
|
||||
}
|
||||
}
|
||||
return retValue;
|
||||
}
|
||||
}
|
||||
36
platform/app/src/routes/Debug.tsx
Normal file
36
platform/app/src/routes/Debug.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
// this is a debug component that is used to list various things that might
|
||||
// be useful for debugging such as cross origin errors, etc.
|
||||
function Debug() {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="bg-secondary-dark mx-auto space-y-2 rounded-lg py-8 px-8 drop-shadow-md">
|
||||
<img
|
||||
className="mx-auto block h-14"
|
||||
src="./ohif-logo.svg"
|
||||
alt="OHIF"
|
||||
/>
|
||||
<div className="space-y-2 pt-4 text-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="text-primary-active mt-4 text-xl font-semibold">Debug Information</p>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
<p className="text-md text-white">Cross Origin Isolated (COOP/COEP)</p>
|
||||
<Icons.ByName
|
||||
name={
|
||||
window.crossOriginIsolated ? 'notifications-success' : 'notifications-error'
|
||||
}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Debug;
|
||||
168
platform/app/src/routes/Local/Local.tsx
Normal file
168
platform/app/src/routes/Local/Local.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DicomMetadataStore, MODULE_TYPES, useSystem } from '@ohif/core';
|
||||
|
||||
import Dropzone from 'react-dropzone';
|
||||
import filesToStudies from './filesToStudies';
|
||||
|
||||
import { extensionManager } from '../../App';
|
||||
|
||||
import { Button } from '@ohif/ui';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
const getLoadButton = (onDrop, text, isDir) => {
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
noDrag
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<Button
|
||||
rounded="full"
|
||||
variant="contained" // outlined
|
||||
disabled={false}
|
||||
endIcon={<Icons.LaunchArrow />}
|
||||
className={classnames('font-medium', 'ml-2')}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{text}
|
||||
{isDir ? (
|
||||
<input
|
||||
{...getInputProps()}
|
||||
webkitdirectory="true"
|
||||
mozdirectory="true"
|
||||
/>
|
||||
) : (
|
||||
<input {...getInputProps()} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
|
||||
type LocalProps = {
|
||||
modePath: string;
|
||||
};
|
||||
|
||||
function Local({ modePath }: LocalProps) {
|
||||
const { servicesManager } = useSystem();
|
||||
const { customizationService } = servicesManager.services;
|
||||
const navigate = useNavigate();
|
||||
const dropzoneRef = useRef();
|
||||
const [dropInitiated, setDropInitiated] = React.useState(false);
|
||||
|
||||
const LoadingIndicatorProgress = customizationService.getCustomization(
|
||||
'ui.loadingIndicatorProgress'
|
||||
);
|
||||
|
||||
// Initializing the dicom local dataSource
|
||||
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
|
||||
const localDataSources = dataSourceModules.reduce((acc, curr) => {
|
||||
const mods = [];
|
||||
curr.module.forEach(mod => {
|
||||
if (mod.type === 'localApi') {
|
||||
mods.push(mod);
|
||||
}
|
||||
});
|
||||
return acc.concat(mods);
|
||||
}, []);
|
||||
|
||||
const firstLocalDataSource = localDataSources[0];
|
||||
const dataSource = firstLocalDataSource.createDataSource({});
|
||||
|
||||
const microscopyExtensionLoaded = extensionManager.registeredExtensionIds.includes(
|
||||
'@ohif/extension-dicom-microscopy'
|
||||
);
|
||||
|
||||
const onDrop = async acceptedFiles => {
|
||||
const studies = await filesToStudies(acceptedFiles, dataSource);
|
||||
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (microscopyExtensionLoaded) {
|
||||
// TODO: for microscopy, we are forcing microscopy mode, which is not ideal.
|
||||
// we should make the local drag and drop navigate to the worklist and
|
||||
// there user can select microscopy mode
|
||||
const smStudies = studies.filter(id => {
|
||||
const study = DicomMetadataStore.getStudy(id);
|
||||
return (
|
||||
study.series.findIndex(s => s.Modality === 'SM' || s.instances[0].Modality === 'SM') >= 0
|
||||
);
|
||||
});
|
||||
|
||||
if (smStudies.length > 0) {
|
||||
smStudies.forEach(id => query.append('StudyInstanceUIDs', id));
|
||||
|
||||
modePath = 'microscopy';
|
||||
}
|
||||
}
|
||||
|
||||
// Todo: navigate to work list and let user select a mode
|
||||
studies.forEach(id => query.append('StudyInstanceUIDs', id));
|
||||
query.append('datasources', 'dicomlocal');
|
||||
|
||||
navigate(`/${modePath}?${decodeURIComponent(query.toString())}`);
|
||||
};
|
||||
|
||||
// Set body style
|
||||
useEffect(() => {
|
||||
document.body.classList.add('bg-black');
|
||||
return () => {
|
||||
document.body.classList.remove('bg-black');
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
ref={dropzoneRef}
|
||||
onDrop={acceptedFiles => {
|
||||
setDropInitiated(true);
|
||||
onDrop(acceptedFiles);
|
||||
}}
|
||||
noClick
|
||||
>
|
||||
{({ getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="bg-secondary-dark mx-auto space-y-2 rounded-lg py-8 px-8 drop-shadow-md">
|
||||
<div className="flex items-center justify-center">
|
||||
<Icons.OHIFLogoColorDarkBackground className="h-28" />
|
||||
</div>
|
||||
<div className="space-y-2 pt-4 text-center">
|
||||
{dropInitiated ? (
|
||||
<div className="flex flex-col items-center justify-center pt-48">
|
||||
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-base text-blue-300">
|
||||
Note: You data is not uploaded to any server, it will stay in your local
|
||||
browser application
|
||||
</p>
|
||||
<p className="text-xg text-primary-active pt-6 font-semibold">
|
||||
Drag and Drop DICOM files here to load them in the Viewer
|
||||
</p>
|
||||
<p className="text-lg text-blue-300">Or click to </p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-around pt-4">
|
||||
{getLoadButton(onDrop, 'Load files', false)}
|
||||
{getLoadButton(onDrop, 'Load folders', true)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
|
||||
export default Local;
|
||||
27
platform/app/src/routes/Local/dicomFileLoader.js
Normal file
27
platform/app/src/routes/Local/dicomFileLoader.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import dcmjs from 'dcmjs';
|
||||
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||
import FileLoader from './fileLoader';
|
||||
|
||||
const DICOMFileLoader = new (class extends FileLoader {
|
||||
fileType = 'application/dicom';
|
||||
loadFile(file, imageId) {
|
||||
return dicomImageLoader.wadouri.loadFileRequest(imageId);
|
||||
}
|
||||
|
||||
getDataset(image, imageId) {
|
||||
const dicomData = dcmjs.data.DicomMessage.readFile(image);
|
||||
|
||||
const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomData.dict);
|
||||
|
||||
dataset.url = imageId;
|
||||
|
||||
dataset._meta = dcmjs.data.DicomMetaDictionary.namifyDataset(dicomData.meta);
|
||||
|
||||
dataset.AvailableTransferSyntaxUID =
|
||||
dataset.AvailableTransferSyntaxUID || dataset._meta.TransferSyntaxUID?.Value?.[0];
|
||||
|
||||
return dataset;
|
||||
}
|
||||
})();
|
||||
|
||||
export default DICOMFileLoader;
|
||||
6
platform/app/src/routes/Local/fileLoader.js
Normal file
6
platform/app/src/routes/Local/fileLoader.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default class FileLoader {
|
||||
fileType;
|
||||
loadFile(file, imageId) {}
|
||||
getDataset(image, imageId) {}
|
||||
getStudies(dataset, imageId) {}
|
||||
}
|
||||
39
platform/app/src/routes/Local/fileLoaderService.js
Normal file
39
platform/app/src/routes/Local/fileLoaderService.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||
|
||||
import FileLoader from './fileLoader';
|
||||
import PDFFileLoader from './pdfFileLoader';
|
||||
import DICOMFileLoader from './dicomFileLoader';
|
||||
|
||||
class FileLoaderService extends FileLoader {
|
||||
fileType;
|
||||
loader;
|
||||
constructor(file) {
|
||||
super();
|
||||
const fileType = file && file.type;
|
||||
this.loader = this.getLoader(fileType);
|
||||
this.fileType = this.loader.fileType;
|
||||
}
|
||||
|
||||
addFile(file) {
|
||||
return dicomImageLoader.wadouri.fileManager.add(file);
|
||||
}
|
||||
|
||||
loadFile(file, imageId) {
|
||||
return this.loader.loadFile(file, imageId);
|
||||
}
|
||||
|
||||
getDataset(image, imageId) {
|
||||
return this.loader.getDataset(image, imageId);
|
||||
}
|
||||
|
||||
getLoader(fileType) {
|
||||
if (fileType === 'application/pdf') {
|
||||
return PDFFileLoader;
|
||||
} else {
|
||||
// Default to dicom loader
|
||||
return DICOMFileLoader;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FileLoaderService;
|
||||
22
platform/app/src/routes/Local/filesToStudies.js
Normal file
22
platform/app/src/routes/Local/filesToStudies.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import FileLoaderService from './fileLoaderService';
|
||||
import { DicomMetadataStore } from '@ohif/core';
|
||||
|
||||
const processFile = async file => {
|
||||
try {
|
||||
const fileLoaderService = new FileLoaderService(file);
|
||||
const imageId = fileLoaderService.addFile(file);
|
||||
const image = await fileLoaderService.loadFile(file, imageId);
|
||||
const dicomJSONDataset = await fileLoaderService.getDataset(image, imageId);
|
||||
|
||||
DicomMetadataStore.addInstance(dicomJSONDataset);
|
||||
} catch (error) {
|
||||
console.log(error.name, ':Error when trying to load and process local files:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
export default async function filesToStudies(files) {
|
||||
const processFilesPromises = files.map(processFile);
|
||||
await Promise.all(processFilesPromises);
|
||||
|
||||
return DicomMetadataStore.getStudyInstanceUIDs();
|
||||
}
|
||||
1
platform/app/src/routes/Local/index.js
Normal file
1
platform/app/src/routes/Local/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Local';
|
||||
17
platform/app/src/routes/Local/pdfFileLoader.js
Normal file
17
platform/app/src/routes/Local/pdfFileLoader.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||
import FileLoader from './fileLoader';
|
||||
|
||||
const PDFFileLoader = new (class extends FileLoader {
|
||||
fileType = 'application/pdf';
|
||||
loadFile(file, imageId) {
|
||||
return dicomImageLoader.wadouri.loadFileRequest(imageId);
|
||||
}
|
||||
|
||||
getDataset(image, imageId) {
|
||||
const dataset = {};
|
||||
dataset.imageId = image.imageId || imageId;
|
||||
return dataset;
|
||||
}
|
||||
})();
|
||||
|
||||
export default PDFFileLoader;
|
||||
26
platform/app/src/routes/Mode/Compose.tsx
Normal file
26
platform/app/src/routes/Mode/Compose.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Nests React components as ordered in array. We use this to
|
||||
* simplify composition a Mode specify's in it's configuration
|
||||
* for React Contexts that should wrap a Mode Route.
|
||||
*/
|
||||
export default function Compose(props) {
|
||||
const { components = [], children } = props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{components.reduceRight((acc, curr) => {
|
||||
const [Comp, props] = Array.isArray(curr) ? [curr[0], curr[1]] : [curr, {}];
|
||||
return <Comp {...props}>{acc}</Comp>;
|
||||
}, children)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// https://juliuskoronci.medium.com/avoid-a-long-list-of-react-providers-c45a269d80c1
|
||||
Compose.propTypes = {
|
||||
components: PropTypes.array,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
408
platform/app/src/routes/Mode/Mode.tsx
Normal file
408
platform/app/src/routes/Mode/Mode.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router';
|
||||
import PropTypes from 'prop-types';
|
||||
import { utils } from '@ohif/core';
|
||||
import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui';
|
||||
import { useSearchParams } from '@hooks';
|
||||
import { useAppConfig } from '@state';
|
||||
import ViewportGrid from '@components/ViewportGrid';
|
||||
import Compose from './Compose';
|
||||
import { history } from '../../utils/history';
|
||||
import loadModules from '../../pluginImports';
|
||||
import { defaultRouteInit } from './defaultRouteInit';
|
||||
import { updateAuthServiceAndCleanUrl } from './updateAuthServiceAndCleanUrl';
|
||||
|
||||
const { getSplitParam } = utils;
|
||||
|
||||
export default function ModeRoute({
|
||||
mode,
|
||||
dataSourceName,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
}: withAppTypes) {
|
||||
const [appConfig] = useAppConfig();
|
||||
|
||||
// Parse route params/querystring
|
||||
const location = useLocation();
|
||||
|
||||
// The react router DOM placeholder map (see https://reactrouter.com/en/main/hooks/use-params).
|
||||
const params = useParams();
|
||||
// The URL's query search parameters where the keys casing is maintained
|
||||
const query = useSearchParams();
|
||||
|
||||
mode?.onModeInit?.({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
appConfig,
|
||||
query,
|
||||
});
|
||||
|
||||
// The URL's query search parameters where the keys are all lower case.
|
||||
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
|
||||
|
||||
const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(null);
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [ExtensionDependenciesLoaded, setExtensionDependenciesLoaded] = useState(false);
|
||||
|
||||
const layoutTemplateData = useRef(false);
|
||||
const locationRef = useRef(null);
|
||||
const isMounted = useRef(false);
|
||||
|
||||
// Expose the react router dom navigation.
|
||||
history.navigate = useNavigate();
|
||||
|
||||
if (location !== locationRef.current) {
|
||||
layoutTemplateData.current = null;
|
||||
locationRef.current = location;
|
||||
}
|
||||
|
||||
const {
|
||||
displaySetService,
|
||||
panelService,
|
||||
hangingProtocolService,
|
||||
userAuthenticationService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const { extensions, sopClassHandlers, hangingProtocol } = mode;
|
||||
|
||||
const runTimeHangingProtocolId = lowerCaseSearchParams.get('hangingprotocolid');
|
||||
const runTimeStageId = lowerCaseSearchParams.get('stageid');
|
||||
const token = lowerCaseSearchParams.get('token');
|
||||
|
||||
if (token) {
|
||||
updateAuthServiceAndCleanUrl(token, location, userAuthenticationService);
|
||||
}
|
||||
|
||||
// An undefined dataSourceName implies that the active data source that is already set in the ExtensionManager should be used.
|
||||
if (dataSourceName !== undefined) {
|
||||
extensionManager.setActiveDataSource(dataSourceName);
|
||||
}
|
||||
|
||||
const dataSource = extensionManager.getActiveDataSource()[0];
|
||||
|
||||
// Only handling one route per mode for now
|
||||
const route = mode.routes[0];
|
||||
|
||||
useEffect(() => {
|
||||
const loadExtensions = async () => {
|
||||
const loadedExtensions = await loadModules(Object.keys(extensions));
|
||||
for (const extension of loadedExtensions) {
|
||||
const { id: extensionId } = extension;
|
||||
if (extensionManager.registeredExtensionIds.indexOf(extensionId) === -1) {
|
||||
await extensionManager.registerExtension(extension);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted.current) {
|
||||
setExtensionDependenciesLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadExtensions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Preventing state update for unmounted component
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ExtensionDependenciesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Todo: this should not be here, data source should not care about params
|
||||
const initializeDataSource = async (params, query) => {
|
||||
await dataSource.initialize({
|
||||
params,
|
||||
query,
|
||||
});
|
||||
setStudyInstanceUIDs(dataSource.getStudyInstanceUIDs({ params, query }));
|
||||
};
|
||||
|
||||
initializeDataSource(params, query);
|
||||
return () => {
|
||||
layoutTemplateData.current = null;
|
||||
};
|
||||
}, [location, ExtensionDependenciesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retrieveLayoutData = async () => {
|
||||
const layoutData = await route.layoutTemplate({
|
||||
location,
|
||||
servicesManager,
|
||||
studyInstanceUIDs,
|
||||
});
|
||||
|
||||
if (isMounted.current) {
|
||||
const { leftPanels = [], rightPanels = [], ...layoutProps } = layoutData.props;
|
||||
|
||||
panelService.reset();
|
||||
panelService.addPanels(panelService.PanelPosition.Left, leftPanels);
|
||||
panelService.addPanels(panelService.PanelPosition.Right, rightPanels);
|
||||
|
||||
// layoutProps contains all props but leftPanels and rightPanels
|
||||
layoutData.props = layoutProps;
|
||||
|
||||
layoutTemplateData.current = layoutData;
|
||||
setRefresh(!refresh);
|
||||
}
|
||||
};
|
||||
if (Array.isArray(studyInstanceUIDs) && studyInstanceUIDs[0]) {
|
||||
retrieveLayoutData();
|
||||
}
|
||||
return () => {
|
||||
layoutTemplateData.current = null;
|
||||
};
|
||||
}, [studyInstanceUIDs, ExtensionDependenciesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutTemplateData.current || !ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setupRouteInit = async () => {
|
||||
// TODO: For some reason this is running before the Providers
|
||||
// are calling setServiceImplementation
|
||||
// TODO -> iterate through services.
|
||||
|
||||
// Extension
|
||||
|
||||
// Add SOPClassHandlers to a new SOPClassManager.
|
||||
displaySetService.init(extensionManager, sopClassHandlers);
|
||||
|
||||
extensionManager.onModeEnter({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
appConfig,
|
||||
});
|
||||
|
||||
// use the URL hangingProtocolId if it exists, otherwise use the one
|
||||
// defined in the mode configuration
|
||||
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
|
||||
runTimeHangingProtocolId
|
||||
)
|
||||
? runTimeHangingProtocolId
|
||||
: hangingProtocol;
|
||||
|
||||
// Determine the index of the stageId if the hangingProtocolIdToUse is defined
|
||||
const stageIndex = Array.isArray(hangingProtocolIdToUse)
|
||||
? -1
|
||||
: hangingProtocolService.getStageIndex(hangingProtocolIdToUse, {
|
||||
stageId: runTimeStageId || undefined,
|
||||
});
|
||||
// Ensure that the stage index is never negative
|
||||
// If stageIndex is negative (e.g., if stage wasn't found), use 0 as the default
|
||||
const stageIndexToUse = Math.max(0, stageIndex);
|
||||
|
||||
// Sets the active hanging protocols - if hangingProtocol is undefined,
|
||||
// resets to default. Done before the onModeEnter to allow the onModeEnter
|
||||
// to perform custom hanging protocol actions
|
||||
hangingProtocolService.setActiveProtocolIds(hangingProtocolIdToUse);
|
||||
|
||||
mode?.onModeEnter({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
appConfig,
|
||||
});
|
||||
|
||||
// Move hotkeys setup here, after onModeEnter
|
||||
const hotkeys = customizationService.getCustomization('ohif.hotkeyBindings');
|
||||
hotkeysManager.setDefaultHotKeys(hotkeys);
|
||||
|
||||
/**
|
||||
* The next line should get all the query parameters provided by the URL
|
||||
* - except the StudyInstanceUIDs - and create an object called filters
|
||||
* used to filtering the study as the user wants otherwise it will return
|
||||
* a empty object.
|
||||
*
|
||||
* Example:
|
||||
* const filters = {
|
||||
* seriesInstanceUID: 1.2.276.0.7230010.3.1.3.1791068887.5412.1620253993.114611
|
||||
* }
|
||||
*/
|
||||
const filters =
|
||||
Array.from(query.keys()).reduce((acc: Record<string, string>, val: string) => {
|
||||
const lowerVal = val.toLowerCase();
|
||||
// Not sure why the case matters here - it doesn't in the URL
|
||||
if (lowerVal === 'seriesinstanceuids' || lowerVal === 'seriesinstanceuid') {
|
||||
const seriesUIDs = getSplitParam(lowerVal, query);
|
||||
return {
|
||||
...acc,
|
||||
seriesInstanceUID: seriesUIDs,
|
||||
};
|
||||
}
|
||||
return { ...acc, [val]: getSplitParam(lowerVal, query) };
|
||||
}, {}) ?? {};
|
||||
|
||||
let unsubs;
|
||||
|
||||
if (route.init) {
|
||||
unsubs = await route.init(
|
||||
{
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
hotkeysManager,
|
||||
studyInstanceUIDs,
|
||||
dataSource,
|
||||
filters,
|
||||
},
|
||||
hangingProtocolIdToUse,
|
||||
stageIndexToUse
|
||||
);
|
||||
}
|
||||
|
||||
return defaultRouteInit(
|
||||
{
|
||||
servicesManager,
|
||||
studyInstanceUIDs,
|
||||
dataSource,
|
||||
filters,
|
||||
appConfig,
|
||||
},
|
||||
hangingProtocolIdToUse,
|
||||
stageIndexToUse
|
||||
);
|
||||
};
|
||||
|
||||
let unsubscriptions;
|
||||
setupRouteInit().then(unsubs => {
|
||||
unsubscriptions = unsubs;
|
||||
|
||||
mode?.onSetupRouteComplete?.({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
// The mode.onModeExit must be done first to allow it to store
|
||||
// information, and must be in a try/catch to ensure subscriptions
|
||||
// are unsubscribed.
|
||||
try {
|
||||
mode?.onModeExit?.({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('mode exit failure', e);
|
||||
}
|
||||
// Clean up hotkeys
|
||||
hotkeysManager.destroy();
|
||||
|
||||
// The unsubscriptions must occur before the extension onModeExit
|
||||
// in order to prevent exceptions during cleanup caused by spurious events
|
||||
if (unsubscriptions) {
|
||||
unsubscriptions.forEach(unsub => {
|
||||
unsub();
|
||||
});
|
||||
}
|
||||
// The extension manager must be called after the mode, this is
|
||||
// expected to cleanup the state to a standard setup.
|
||||
extensionManager.onModeExit();
|
||||
};
|
||||
}, [
|
||||
mode,
|
||||
dataSourceName,
|
||||
location,
|
||||
ExtensionDependenciesLoaded,
|
||||
route,
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
hotkeysManager,
|
||||
studyInstanceUIDs,
|
||||
refresh,
|
||||
]);
|
||||
|
||||
if (!studyInstanceUIDs || !layoutTemplateData.current || !ExtensionDependenciesLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ViewportGridWithDataSource = props => {
|
||||
return ViewportGrid({ ...props, dataSource });
|
||||
};
|
||||
|
||||
const CombinedExtensionsContextProvider = createCombinedContextProvider(
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager
|
||||
);
|
||||
|
||||
const getLayoutComponent = props => {
|
||||
const layoutTemplateModuleEntry = extensionManager.getModuleEntry(
|
||||
layoutTemplateData.current.id
|
||||
);
|
||||
const LayoutComponent = layoutTemplateModuleEntry.component;
|
||||
|
||||
return <LayoutComponent {...props} />;
|
||||
};
|
||||
|
||||
const LayoutComponent = getLayoutComponent({
|
||||
...layoutTemplateData.current.props,
|
||||
ViewportGridComp: ViewportGridWithDataSource,
|
||||
});
|
||||
|
||||
return (
|
||||
<ImageViewerProvider StudyInstanceUIDs={studyInstanceUIDs}>
|
||||
{CombinedExtensionsContextProvider ? (
|
||||
<CombinedExtensionsContextProvider>
|
||||
<DragAndDropProvider>{LayoutComponent}</DragAndDropProvider>
|
||||
</CombinedExtensionsContextProvider>
|
||||
) : (
|
||||
<DragAndDropProvider>{LayoutComponent}</DragAndDropProvider>
|
||||
)}
|
||||
</ImageViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a combined context provider using the context modules from the extension manager.
|
||||
* @param {object} extensionManager - The extension manager instance.
|
||||
* @param {object} servicesManager - The services manager instance.
|
||||
* @param {object} commandsManager - The commands manager instance.
|
||||
* @returns {React.Component} - A React component that provides combined contexts to its children.
|
||||
*/
|
||||
function createCombinedContextProvider(extensionManager, servicesManager, commandsManager) {
|
||||
const extensionsContextModules = extensionManager.getModulesByType(
|
||||
extensionManager.constructor.MODULE_TYPES.CONTEXT
|
||||
);
|
||||
|
||||
if (!extensionsContextModules?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contextModuleProviders = extensionsContextModules.flatMap(({ module }) => {
|
||||
return module.map(aContextModule => {
|
||||
return aContextModule.provider;
|
||||
});
|
||||
});
|
||||
|
||||
return ({ children }) => {
|
||||
return Compose({ components: contextModuleProviders, children });
|
||||
};
|
||||
}
|
||||
|
||||
ModeRoute.propTypes = {
|
||||
mode: PropTypes.object.isRequired,
|
||||
dataSourceName: PropTypes.string,
|
||||
extensionManager: PropTypes.object,
|
||||
servicesManager: PropTypes.object,
|
||||
hotkeysManager: PropTypes.object,
|
||||
commandsManager: PropTypes.object,
|
||||
};
|
||||
149
platform/app/src/routes/Mode/defaultRouteInit.ts
Normal file
149
platform/app/src/routes/Mode/defaultRouteInit.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import getStudies from './studiesList';
|
||||
import { DicomMetadataStore, log, utils, Enums } from '@ohif/core';
|
||||
import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed';
|
||||
|
||||
const { getSplitParam } = utils;
|
||||
|
||||
/**
|
||||
* Initialize the route.
|
||||
*
|
||||
* @param props.servicesManager to read services from
|
||||
* @param props.studyInstanceUIDs for a list of studies to read
|
||||
* @param props.dataSource to read the data from
|
||||
* @param props.filters filters from query params to read the data from
|
||||
* @returns array of subscriptions to cancel
|
||||
*/
|
||||
export async function defaultRouteInit(
|
||||
{ servicesManager, studyInstanceUIDs, dataSource, filters, appConfig }: withAppTypes,
|
||||
hangingProtocolId,
|
||||
stageIndex
|
||||
) {
|
||||
const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } =
|
||||
servicesManager.services;
|
||||
/**
|
||||
* Function to apply the hanging protocol when the minimum number of display sets were
|
||||
* received or all display sets retrieval were completed
|
||||
* @returns
|
||||
*/
|
||||
function applyHangingProtocol() {
|
||||
const displaySets = displaySetService.getActiveDisplaySets();
|
||||
|
||||
if (!displaySets || !displaySets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gets the studies list to use
|
||||
const studies = getStudies(studyInstanceUIDs, displaySets);
|
||||
|
||||
// study being displayed, and is thus the "active" study.
|
||||
const activeStudy = studies[0];
|
||||
|
||||
// run the hanging protocol matching on the displaySets with the predefined
|
||||
// hanging protocol in the mode configuration
|
||||
hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId, {
|
||||
stageIndex,
|
||||
});
|
||||
}
|
||||
|
||||
const unsubscriptions = [];
|
||||
const issuedWarningSeries = [];
|
||||
const { unsubscribe: instanceAddedUnsubscribe } = DicomMetadataStore.subscribe(
|
||||
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
|
||||
function ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) {
|
||||
const seriesMetadata = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID);
|
||||
|
||||
// checks if the series filter was used, if it exists
|
||||
const seriesInstanceUIDs = filters?.seriesInstanceUID;
|
||||
if (
|
||||
seriesInstanceUIDs?.length &&
|
||||
!isSeriesFilterUsed(seriesMetadata.instances, filters) &&
|
||||
!issuedWarningSeries.includes(seriesInstanceUIDs[0])
|
||||
) {
|
||||
// stores the series instance filter so it shows only once the warning
|
||||
issuedWarningSeries.push(seriesInstanceUIDs[0]);
|
||||
uiNotificationService.show({
|
||||
title: 'Series filter',
|
||||
message: `Each of the series in filter: ${seriesInstanceUIDs} are not part of the current study. The entire study is being displayed`,
|
||||
type: 'error',
|
||||
duration: 7000,
|
||||
});
|
||||
}
|
||||
|
||||
displaySetService.makeDisplaySets(seriesMetadata.instances, { madeInClient });
|
||||
}
|
||||
);
|
||||
|
||||
unsubscriptions.push(instanceAddedUnsubscribe);
|
||||
|
||||
log.time(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
|
||||
log.time(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE);
|
||||
|
||||
const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID =>
|
||||
dataSource.retrieve.series.metadata({
|
||||
StudyInstanceUID,
|
||||
filters,
|
||||
returnPromises: true,
|
||||
sortCriteria: customizationService.getCustomization('sortingCriteria'),
|
||||
})
|
||||
);
|
||||
|
||||
// log the error if this fails, otherwise it's so difficult to tell what went wrong...
|
||||
allRetrieves.forEach(retrieve => {
|
||||
retrieve.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
|
||||
// is displaysets from URL and has initialSOPInstanceUID or initialSeriesInstanceUID
|
||||
// then we need to wait for all display sets to be retrieved before applying the hanging protocol
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const initialSeriesInstanceUID = getSplitParam('initialseriesinstanceuid', params);
|
||||
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid', params);
|
||||
|
||||
let displaySetFromUrl = false;
|
||||
if (initialSeriesInstanceUID || initialSOPInstanceUID) {
|
||||
displaySetFromUrl = true;
|
||||
}
|
||||
|
||||
await Promise.allSettled(allRetrieves).then(async promises => {
|
||||
log.timeEnd(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
|
||||
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE);
|
||||
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES);
|
||||
|
||||
const allPromises = [];
|
||||
const remainingPromises = [];
|
||||
|
||||
function startRemainingPromises(remainingPromises) {
|
||||
remainingPromises.forEach(p => p.forEach(p => p.start()));
|
||||
}
|
||||
|
||||
promises.forEach(promise => {
|
||||
const retrieveSeriesMetadataPromise = promise.value;
|
||||
if (!Array.isArray(retrieveSeriesMetadataPromise)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (displaySetFromUrl) {
|
||||
const requiredSeriesPromises = retrieveSeriesMetadataPromise.map(promise =>
|
||||
promise.start()
|
||||
);
|
||||
allPromises.push(Promise.allSettled(requiredSeriesPromises));
|
||||
} else {
|
||||
const { requiredSeries, remaining } = hangingProtocolService.filterSeriesRequiredForRun(
|
||||
hangingProtocolId,
|
||||
retrieveSeriesMetadataPromise
|
||||
);
|
||||
const requiredSeriesPromises = requiredSeries.map(promise => promise.start());
|
||||
allPromises.push(Promise.allSettled(requiredSeriesPromises));
|
||||
remainingPromises.push(remaining);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(allPromises).then(applyHangingProtocol);
|
||||
startRemainingPromises(remainingPromises);
|
||||
applyHangingProtocol();
|
||||
});
|
||||
|
||||
return unsubscriptions;
|
||||
}
|
||||
1
platform/app/src/routes/Mode/index.js
Normal file
1
platform/app/src/routes/Mode/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Mode';
|
||||
65
platform/app/src/routes/Mode/studiesList.ts
Normal file
65
platform/app/src/routes/Mode/studiesList.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { DicomMetadataStore, Types } from '@ohif/core';
|
||||
|
||||
type StudyMetadata = Types.StudyMetadata;
|
||||
|
||||
/**
|
||||
* Compare function for sorting
|
||||
*
|
||||
* @param a - some simple value (string, number, timestamp)
|
||||
* @param b - some simple value
|
||||
* @param defaultCompare - default return value as a fallback when a===b
|
||||
* @returns - compare a and b, returning 1 if a<b -1 if a>b and defaultCompare otherwise
|
||||
*/
|
||||
const compare = (a, b, defaultCompare = 0): number => {
|
||||
if (a === b) {
|
||||
return defaultCompare;
|
||||
}
|
||||
if (a < b) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
/**
|
||||
* The studies from display sets gets the studies in study date
|
||||
* order or in study instance UID order - not very useful, but
|
||||
* if not specifically specified then at least making it consistent is useful.
|
||||
*/
|
||||
const getStudiesfromDisplaySets = (displaysets): StudyMetadata[] => {
|
||||
const studyMap = {};
|
||||
|
||||
const ret = displaySets.reduce((prev, curr) => {
|
||||
const { StudyInstanceUID } = curr;
|
||||
if (!studyMap[StudyInstanceUID]) {
|
||||
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
|
||||
studyMap[StudyInstanceUID] = study;
|
||||
prev.push(study);
|
||||
}
|
||||
return prev;
|
||||
}, []);
|
||||
// Return the sorted studies, first on study date and second on study instance UID
|
||||
ret.sort((a, b) => {
|
||||
return compare(a.StudyDate, b.StudyDate, compare(a.StudyInstanceUID, b.StudyInstanceUID));
|
||||
});
|
||||
return ret;
|
||||
};
|
||||
|
||||
/**
|
||||
* The studies retrieve from the Uids is faster and gets the studies
|
||||
* in the original order, as specified.
|
||||
*/
|
||||
const getStudiesFromUIDs = (studyUids: string[]): StudyMetadata[] => {
|
||||
if (!studyUids?.length) {
|
||||
return;
|
||||
}
|
||||
return studyUids.map(uid => DicomMetadataStore.getStudy(uid));
|
||||
};
|
||||
|
||||
/** Gets the array of studies */
|
||||
const getStudies = (studyUids?: string[], displaySets): StudyMetadata[] => {
|
||||
return getStudiesFromUIDs(studyUids) || getStudiesfromDisplaySets(displaySets);
|
||||
};
|
||||
|
||||
export default getStudies;
|
||||
|
||||
export { getStudies, getStudiesFromUIDs, getStudiesfromDisplaySets, compare };
|
||||
35
platform/app/src/routes/Mode/updateAuthServiceAndCleanUrl.ts
Normal file
35
platform/app/src/routes/Mode/updateAuthServiceAndCleanUrl.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Updates the user authentication service with the provided token and cleans the token from the URL.
|
||||
* @param token - The token to set in the user authentication service.
|
||||
* @param location - The location object from the router.
|
||||
* @param userAuthenticationService - The user authentication service instance.
|
||||
*/
|
||||
export function updateAuthServiceAndCleanUrl(
|
||||
token: string,
|
||||
location: any,
|
||||
userAuthenticationService: any
|
||||
): void {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if a token is passed in, set the userAuthenticationService to use it
|
||||
// for the Authorization header for all requests
|
||||
userAuthenticationService.setServiceImplementation({
|
||||
getAuthorizationHeader: () => ({
|
||||
Authorization: 'Bearer ' + token,
|
||||
}),
|
||||
});
|
||||
|
||||
// Create a URL object with the current location
|
||||
const urlObj = new URL(window.location.origin + window.location.pathname + location.search);
|
||||
|
||||
// Remove the token from the URL object
|
||||
urlObj.searchParams.delete('token');
|
||||
const cleanUrl = urlObj.toString();
|
||||
|
||||
// Update the browser's history without the token
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
}
|
||||
}
|
||||
30
platform/app/src/routes/NotFound/NotFound.tsx
Normal file
30
platform/app/src/routes/NotFound/NotFound.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useAppConfig } from '@state';
|
||||
|
||||
const NotFound = ({ message = 'Sorry, this page does not exist.', showGoBackButton = true }) => {
|
||||
const [appConfig] = useAppConfig();
|
||||
const { showStudyList } = appConfig;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
<div>
|
||||
<h4>{message}</h4>
|
||||
{showGoBackButton && showStudyList && (
|
||||
<h5>
|
||||
<Link to={'/'}>Go back to the Study List</Link>
|
||||
</h5>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NotFound.propTypes = {
|
||||
message: PropTypes.string,
|
||||
showGoBackButton: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
1
platform/app/src/routes/NotFound/index.js
Normal file
1
platform/app/src/routes/NotFound/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './NotFound';
|
||||
13
platform/app/src/routes/PrivateRoute.tsx
Normal file
13
platform/app/src/routes/PrivateRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useUserAuthentication } from '@ohif/ui';
|
||||
|
||||
export const PrivateRoute = ({ children, handleUnauthenticated }) => {
|
||||
const [{ user, enabled }] = useUserAuthentication();
|
||||
|
||||
if (enabled && !user) {
|
||||
return handleUnauthenticated();
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default PrivateRoute;
|
||||
30
platform/app/src/routes/SignoutCallbackComponent.tsx
Normal file
30
platform/app/src/routes/SignoutCallbackComponent.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function SignoutCallbackComponent({ userManager }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onRedirectSuccess = (/* user */) => {
|
||||
const { pathname, search = '' } = JSON.parse(sessionStorage.getItem('ohif-redirect-to'));
|
||||
|
||||
navigate(`${pathname}?${search}`);
|
||||
};
|
||||
|
||||
const onRedirectError = error => {
|
||||
throw new Error(error);
|
||||
};
|
||||
|
||||
userManager
|
||||
.signoutRedirectCallback()
|
||||
.then(user => onRedirectSuccess(user))
|
||||
.catch(error => onRedirectError(error));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
SignoutCallbackComponent.propTypes = {
|
||||
userManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default SignoutCallbackComponent;
|
||||
707
platform/app/src/routes/WorkList/WorkList.tsx
Normal file
707
platform/app/src/routes/WorkList/WorkList.tsx
Normal file
@@ -0,0 +1,707 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import qs from 'query-string';
|
||||
import isEqual from 'lodash.isequal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
//
|
||||
import filtersMeta from './filtersMeta.js';
|
||||
import { useAppConfig } from '@state';
|
||||
import { useDebounce, useSearchParams } from '@hooks';
|
||||
import { utils, hotkeys } from '@ohif/core';
|
||||
import publicUrl from '../../utils/publicUrl';
|
||||
|
||||
import {
|
||||
StudyListExpandedRow,
|
||||
EmptyStudies,
|
||||
StudyListTable,
|
||||
StudyListPagination,
|
||||
StudyListFilter,
|
||||
useModal,
|
||||
AboutModal,
|
||||
UserPreferences,
|
||||
useSessionStorage,
|
||||
InvestigationalUseDialog,
|
||||
Button,
|
||||
ButtonEnums,
|
||||
} from '@ohif/ui';
|
||||
|
||||
import {
|
||||
Header,
|
||||
Icons,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
Clipboard,
|
||||
Onboarding,
|
||||
ScrollArea,
|
||||
} from '@ohif/ui-next';
|
||||
|
||||
import { Types } from '@ohif/ui';
|
||||
|
||||
import i18n from '@ohif/i18n';
|
||||
import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters';
|
||||
|
||||
const PatientInfoVisibility = Types.PatientInfoVisibility;
|
||||
|
||||
const { sortBySeriesDate } = utils;
|
||||
|
||||
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
|
||||
|
||||
const seriesInStudiesMap = new Map();
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - debounce `setFilterValues` (150ms?)
|
||||
*/
|
||||
function WorkList({
|
||||
data: studies,
|
||||
dataTotal: studiesTotal,
|
||||
isLoadingData,
|
||||
dataSource,
|
||||
hotkeysManager,
|
||||
dataPath,
|
||||
onRefresh,
|
||||
servicesManager,
|
||||
}: withAppTypes) {
|
||||
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
|
||||
const { show, hide } = useModal();
|
||||
const { t } = useTranslation();
|
||||
// ~ Modes
|
||||
const [appConfig] = useAppConfig();
|
||||
// ~ Filters
|
||||
const searchParams = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const STUDIES_LIMIT = 101;
|
||||
const queryFilterValues = _getQueryFilterValues(searchParams);
|
||||
const [sessionQueryFilterValues, updateSessionQueryFilterValues] = useSessionStorage({
|
||||
key: 'queryFilterValues',
|
||||
defaultValue: queryFilterValues,
|
||||
// ToDo: useSessionStorage currently uses an unload listener to clear the filters from session storage
|
||||
// so on systems that do not support unload events a user will NOT be able to alter any existing filter
|
||||
// in the URL, load the page and have it apply.
|
||||
clearOnUnload: true,
|
||||
});
|
||||
const [filterValues, _setFilterValues] = useState({
|
||||
...defaultFilterValues,
|
||||
...sessionQueryFilterValues,
|
||||
});
|
||||
|
||||
const debouncedFilterValues = useDebounce(filterValues, 200);
|
||||
const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues;
|
||||
|
||||
/*
|
||||
* The default sort value keep the filters synchronized with runtime conditional sorting
|
||||
* Only applied if no other sorting is specified and there are less than 101 studies
|
||||
*/
|
||||
|
||||
const canSort = studiesTotal < STUDIES_LIMIT;
|
||||
const shouldUseDefaultSort = sortBy === '' || !sortBy;
|
||||
const sortModifier = sortDirection === 'descending' ? 1 : -1;
|
||||
const defaultSortValues =
|
||||
shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {};
|
||||
|
||||
const sortedStudies = useMemo(() => {
|
||||
if (!canSort) {
|
||||
return studies;
|
||||
}
|
||||
|
||||
return [...studies].sort((s1, s2) => {
|
||||
if (shouldUseDefaultSort) {
|
||||
const ascendingSortModifier = -1;
|
||||
return _sortStringDates(s1, s2, ascendingSortModifier);
|
||||
}
|
||||
|
||||
const s1Prop = s1[sortBy];
|
||||
const s2Prop = s2[sortBy];
|
||||
|
||||
if (typeof s1Prop === 'string' && typeof s2Prop === 'string') {
|
||||
return s1Prop.localeCompare(s2Prop) * sortModifier;
|
||||
} else if (typeof s1Prop === 'number' && typeof s2Prop === 'number') {
|
||||
return (s1Prop > s2Prop ? 1 : -1) * sortModifier;
|
||||
} else if (!s1Prop && s2Prop) {
|
||||
return -1 * sortModifier;
|
||||
} else if (!s2Prop && s1Prop) {
|
||||
return 1 * sortModifier;
|
||||
} else if (sortBy === 'studyDate') {
|
||||
return _sortStringDates(s1, s2, sortModifier);
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}, [canSort, studies, shouldUseDefaultSort, sortBy, sortModifier]);
|
||||
|
||||
// ~ Rows & Studies
|
||||
const [expandedRows, setExpandedRows] = useState([]);
|
||||
const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]);
|
||||
const numOfStudies = studiesTotal;
|
||||
const querying = useMemo(() => {
|
||||
return isLoadingData || expandedRows.length > 0;
|
||||
}, [isLoadingData, expandedRows]);
|
||||
|
||||
const setFilterValues = val => {
|
||||
if (filterValues.pageNumber === val.pageNumber) {
|
||||
val.pageNumber = 1;
|
||||
}
|
||||
_setFilterValues(val);
|
||||
updateSessionQueryFilterValues(val);
|
||||
setExpandedRows([]);
|
||||
};
|
||||
|
||||
const onPageNumberChange = newPageNumber => {
|
||||
const oldPageNumber = filterValues.pageNumber;
|
||||
const rollingPageNumberMod = Math.floor(101 / filterValues.resultsPerPage);
|
||||
const rollingPageNumber = oldPageNumber % rollingPageNumberMod;
|
||||
const isNextPage = newPageNumber > oldPageNumber;
|
||||
const hasNextPage = Math.max(rollingPageNumber, 1) * resultsPerPage < numOfStudies;
|
||||
|
||||
if (isNextPage && !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFilterValues({ ...filterValues, pageNumber: newPageNumber });
|
||||
};
|
||||
|
||||
const onResultsPerPageChange = newResultsPerPage => {
|
||||
setFilterValues({
|
||||
...filterValues,
|
||||
pageNumber: 1,
|
||||
resultsPerPage: Number(newResultsPerPage),
|
||||
});
|
||||
};
|
||||
|
||||
// Set body style
|
||||
useEffect(() => {
|
||||
document.body.classList.add('bg-black');
|
||||
return () => {
|
||||
document.body.classList.remove('bg-black');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync URL query parameters with filters
|
||||
useEffect(() => {
|
||||
if (!debouncedFilterValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = {};
|
||||
Object.keys(defaultFilterValues).forEach(key => {
|
||||
const defaultValue = defaultFilterValues[key];
|
||||
const currValue = debouncedFilterValues[key];
|
||||
|
||||
// TODO: nesting/recursion?
|
||||
if (key === 'studyDate') {
|
||||
if (currValue.startDate && defaultValue.startDate !== currValue.startDate) {
|
||||
queryString.startDate = currValue.startDate;
|
||||
}
|
||||
if (currValue.endDate && defaultValue.endDate !== currValue.endDate) {
|
||||
queryString.endDate = currValue.endDate;
|
||||
}
|
||||
} else if (key === 'modalities' && currValue.length) {
|
||||
queryString.modalities = currValue.join(',');
|
||||
} else if (currValue !== defaultValue) {
|
||||
queryString[key] = currValue;
|
||||
}
|
||||
});
|
||||
|
||||
preserveQueryStrings(queryString);
|
||||
|
||||
const search = qs.stringify(queryString, {
|
||||
skipNull: true,
|
||||
skipEmptyString: true,
|
||||
});
|
||||
navigate({
|
||||
pathname: publicUrl,
|
||||
search: search ? `?${search}` : undefined,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedFilterValues]);
|
||||
|
||||
// Query for series information
|
||||
useEffect(() => {
|
||||
const fetchSeries = async studyInstanceUid => {
|
||||
try {
|
||||
const series = await dataSource.query.series.search(studyInstanceUid);
|
||||
seriesInStudiesMap.set(studyInstanceUid, sortBySeriesDate(series));
|
||||
setStudiesWithSeriesData([...studiesWithSeriesData, studyInstanceUid]);
|
||||
} catch (ex) {
|
||||
// TODO: UI Notification Service
|
||||
console.warn(ex);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: WHY WOULD YOU USE AN INDEX OF 1?!
|
||||
// Note: expanded rows index begins at 1
|
||||
for (let z = 0; z < expandedRows.length; z++) {
|
||||
const expandedRowIndex = expandedRows[z] - 1;
|
||||
const studyInstanceUid = sortedStudies[expandedRowIndex].studyInstanceUid;
|
||||
|
||||
if (studiesWithSeriesData.includes(studyInstanceUid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fetchSeries(studyInstanceUid);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [expandedRows, studies]);
|
||||
|
||||
const isFiltering = (filterValues, defaultFilterValues) => {
|
||||
return !isEqual(filterValues, defaultFilterValues);
|
||||
};
|
||||
|
||||
const rollingPageNumberMod = Math.floor(101 / resultsPerPage);
|
||||
const rollingPageNumber = (pageNumber - 1) % rollingPageNumberMod;
|
||||
const offset = resultsPerPage * rollingPageNumber;
|
||||
const offsetAndTake = offset + resultsPerPage;
|
||||
const tableDataSource = sortedStudies.map((study, key) => {
|
||||
const rowKey = key + 1;
|
||||
const isExpanded = expandedRows.some(k => k === rowKey);
|
||||
const {
|
||||
studyInstanceUid,
|
||||
accession,
|
||||
modalities,
|
||||
instances,
|
||||
description,
|
||||
mrn,
|
||||
patientName,
|
||||
date,
|
||||
time,
|
||||
} = study;
|
||||
const studyDate =
|
||||
date &&
|
||||
moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true).isValid() &&
|
||||
moment(date, ['YYYYMMDD', 'YYYY.MM.DD']).format(t('Common:localDateFormat', 'MMM-DD-YYYY'));
|
||||
const studyTime =
|
||||
time &&
|
||||
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).isValid() &&
|
||||
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).format(
|
||||
t('Common:localTimeFormat', 'hh:mm A')
|
||||
);
|
||||
|
||||
const makeCopyTooltipCell = textValue => {
|
||||
if (!textValue) {
|
||||
return '';
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-pointer truncate">{textValue}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{textValue}
|
||||
<Clipboard>{textValue}</Clipboard>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
dataCY: `studyRow-${studyInstanceUid}`,
|
||||
clickableCY: studyInstanceUid,
|
||||
row: [
|
||||
{
|
||||
key: 'patientName',
|
||||
content: patientName ? makeCopyTooltipCell(patientName) : null,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
key: 'mrn',
|
||||
content: makeCopyTooltipCell(mrn),
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'studyDate',
|
||||
content: (
|
||||
<>
|
||||
{studyDate && <span className="mr-4">{studyDate}</span>}
|
||||
{studyTime && <span>{studyTime}</span>}
|
||||
</>
|
||||
),
|
||||
title: `${studyDate || ''} ${studyTime || ''}`,
|
||||
gridCol: 5,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
content: makeCopyTooltipCell(description),
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
key: 'modality',
|
||||
content: modalities,
|
||||
title: modalities,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'accession',
|
||||
content: makeCopyTooltipCell(accession),
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'instances',
|
||||
content: (
|
||||
<>
|
||||
<Icons.GroupLayers
|
||||
className={classnames('mr-2 inline-flex w-4', {
|
||||
'text-primary-active': isExpanded,
|
||||
'text-secondary-light': !isExpanded,
|
||||
})}
|
||||
/>
|
||||
{instances}
|
||||
</>
|
||||
),
|
||||
title: (instances || 0).toString(),
|
||||
gridCol: 2,
|
||||
},
|
||||
],
|
||||
// Todo: This is actually running for all rows, even if they are
|
||||
// not clicked on.
|
||||
expandedContent: (
|
||||
<StudyListExpandedRow
|
||||
seriesTableColumns={{
|
||||
description: t('StudyList:Description'),
|
||||
seriesNumber: t('StudyList:Series'),
|
||||
modality: t('StudyList:Modality'),
|
||||
instances: t('StudyList:Instances'),
|
||||
}}
|
||||
seriesTableDataSource={
|
||||
seriesInStudiesMap.has(studyInstanceUid)
|
||||
? seriesInStudiesMap.get(studyInstanceUid).map(s => {
|
||||
return {
|
||||
description: s.description || '(empty)',
|
||||
seriesNumber: s.seriesNumber ?? '',
|
||||
modality: s.modality || '',
|
||||
instances: s.numSeriesInstances || '',
|
||||
};
|
||||
})
|
||||
: []
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
{(appConfig.groupEnabledModesFirst
|
||||
? appConfig.loadedModes.sort((a, b) => {
|
||||
const isValidA = a.isValidMode({
|
||||
modalities: modalities.replaceAll('/', '\\'),
|
||||
study,
|
||||
}).valid;
|
||||
const isValidB = b.isValidMode({
|
||||
modalities: modalities.replaceAll('/', '\\'),
|
||||
study,
|
||||
}).valid;
|
||||
|
||||
return isValidB - isValidA;
|
||||
})
|
||||
: appConfig.loadedModes
|
||||
).map((mode, i) => {
|
||||
const modalitiesToCheck = modalities.replaceAll('/', '\\');
|
||||
|
||||
const { valid: isValidMode, description: invalidModeDescription } = mode.isValidMode({
|
||||
modalities: modalitiesToCheck,
|
||||
study,
|
||||
});
|
||||
// TODO: Modes need a default/target route? We mostly support a single one for now.
|
||||
// We should also be using the route path, but currently are not
|
||||
// mode.routeName
|
||||
// mode.routes[x].path
|
||||
// Don't specify default data source, and it should just be picked up... (this may not currently be the case)
|
||||
// How do we know which params to pass? Today, it's just StudyInstanceUIDs and configUrl if exists
|
||||
const query = new URLSearchParams();
|
||||
if (filterValues.configUrl) {
|
||||
query.append('configUrl', filterValues.configUrl);
|
||||
}
|
||||
query.append('StudyInstanceUIDs', studyInstanceUid);
|
||||
preserveQueryParameters(query);
|
||||
|
||||
return (
|
||||
mode.displayName && (
|
||||
<Link
|
||||
className={isValidMode ? '' : 'cursor-not-allowed'}
|
||||
key={i}
|
||||
to={`${publicUrl}${mode.routeName}${dataPath || ''}?${query.toString()}`}
|
||||
onClick={event => {
|
||||
// In case any event bubbles up for an invalid mode, prevent the navigation.
|
||||
// For example, the event bubbles up when the icon embedded in the disabled button is clicked.
|
||||
if (!isValidMode) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
|
||||
>
|
||||
{/* TODO revisit the completely rounded style of buttons used for launching a mode from the worklist later - for now use LegacyButton*/}
|
||||
<Button
|
||||
type={ButtonEnums.type.primary}
|
||||
size={ButtonEnums.size.medium}
|
||||
disabled={!isValidMode}
|
||||
startIconTooltip={
|
||||
!isValidMode ? (
|
||||
<div className="font-inter flex w-[206px] whitespace-normal text-left text-xs font-normal text-white">
|
||||
{invalidModeDescription}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
startIcon={
|
||||
isValidMode ? (
|
||||
<Icons.LaunchArrow className="!h-[20px] !w-[20px] text-black" />
|
||||
) : (
|
||||
<Icons.LaunchInfo className="!h-[20px] !w-[20px] text-black" />
|
||||
)
|
||||
}
|
||||
onClick={() => {}}
|
||||
dataCY={`mode-${mode.routeName}-${studyInstanceUid}`}
|
||||
className={isValidMode ? 'text-[13px]' : 'bg-[#222d44] text-[13px]'}
|
||||
>
|
||||
{mode.displayName}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</StudyListExpandedRow>
|
||||
),
|
||||
onClickRow: () =>
|
||||
setExpandedRows(s => (isExpanded ? s.filter(n => rowKey !== n) : [...s, rowKey])),
|
||||
isExpanded,
|
||||
};
|
||||
});
|
||||
|
||||
const hasStudies = numOfStudies > 0;
|
||||
const versionNumber = process.env.VERSION_NUMBER;
|
||||
const commitHash = process.env.COMMIT_HASH;
|
||||
|
||||
const menuOptions = [
|
||||
{
|
||||
title: t('Header:About'),
|
||||
icon: 'info',
|
||||
onClick: () =>
|
||||
show({
|
||||
content: AboutModal,
|
||||
title: t('AboutModal:About OHIF Viewer'),
|
||||
contentProps: { versionNumber, commitHash },
|
||||
containerDimensions: 'max-w-4xl max-h-4xl',
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('Header:Preferences'),
|
||||
icon: 'settings',
|
||||
onClick: () =>
|
||||
show({
|
||||
title: t('UserPreferencesModal:User preferences'),
|
||||
content: UserPreferences,
|
||||
contentProps: {
|
||||
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults),
|
||||
hotkeyDefinitions,
|
||||
onCancel: hide,
|
||||
currentLanguage: currentLanguage(),
|
||||
availableLanguages,
|
||||
defaultLanguage,
|
||||
onSubmit: state => {
|
||||
if (state.language.value !== currentLanguage().value) {
|
||||
i18n.changeLanguage(state.language.value);
|
||||
}
|
||||
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
|
||||
hide();
|
||||
},
|
||||
onReset: () => hotkeysManager.restoreDefaultBindings(),
|
||||
hotkeysModule: hotkeys,
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
if (appConfig.oidc) {
|
||||
menuOptions.push({
|
||||
icon: 'power-off',
|
||||
title: t('Header:Logout'),
|
||||
onClick: () => {
|
||||
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { customizationService } = servicesManager.services;
|
||||
const LoadingIndicatorProgress = customizationService.getCustomization(
|
||||
'ui.loadingIndicatorProgress'
|
||||
);
|
||||
const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent');
|
||||
|
||||
const uploadProps =
|
||||
DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled
|
||||
? {
|
||||
title: 'Upload files',
|
||||
closeButton: true,
|
||||
shouldCloseOnEsc: false,
|
||||
shouldCloseOnOverlayClick: false,
|
||||
content: () => (
|
||||
<DicomUploadComponent
|
||||
dataSource={dataSource}
|
||||
onComplete={() => {
|
||||
hide();
|
||||
onRefresh();
|
||||
}}
|
||||
onStarted={() => {
|
||||
show({
|
||||
...uploadProps,
|
||||
// when upload starts, hide the default close button as closing the dialogue must be handled by the upload dialogue itself
|
||||
closeButton: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const dataSourceConfigurationComponent = customizationService.getCustomization(
|
||||
'ohif.dataSourceConfigurationComponent'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-black">
|
||||
<Header
|
||||
isSticky
|
||||
menuOptions={menuOptions}
|
||||
isReturnEnabled={false}
|
||||
WhiteLabeling={appConfig.whiteLabeling}
|
||||
showPatientInfo={PatientInfoVisibility.DISABLED}
|
||||
/>
|
||||
<Onboarding />
|
||||
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<ScrollArea>
|
||||
<div className="flex grow flex-col">
|
||||
<StudyListFilter
|
||||
numOfStudies={pageNumber * resultsPerPage > 100 ? 101 : numOfStudies}
|
||||
filtersMeta={filtersMeta}
|
||||
filterValues={{ ...filterValues, ...defaultSortValues }}
|
||||
onChange={setFilterValues}
|
||||
clearFilters={() => setFilterValues(defaultFilterValues)}
|
||||
isFiltering={isFiltering(filterValues, defaultFilterValues)}
|
||||
onUploadClick={uploadProps ? () => show(uploadProps) : undefined}
|
||||
getDataSourceConfigurationComponent={
|
||||
dataSourceConfigurationComponent
|
||||
? () => dataSourceConfigurationComponent()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{hasStudies ? (
|
||||
<div className="flex grow flex-col">
|
||||
<StudyListTable
|
||||
tableDataSource={tableDataSource.slice(offset, offsetAndTake)}
|
||||
numOfStudies={numOfStudies}
|
||||
querying={querying}
|
||||
filtersMeta={filtersMeta}
|
||||
/>
|
||||
<div className="grow">
|
||||
<StudyListPagination
|
||||
onChangePage={onPageNumberChange}
|
||||
onChangePerPage={onResultsPerPageChange}
|
||||
currentPage={pageNumber}
|
||||
perPage={resultsPerPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pt-48">
|
||||
{appConfig.showLoadingIndicator && isLoadingData ? (
|
||||
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
|
||||
) : (
|
||||
<EmptyStudies />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
WorkList.propTypes = {
|
||||
data: PropTypes.array.isRequired,
|
||||
dataSource: PropTypes.shape({
|
||||
query: PropTypes.object.isRequired,
|
||||
getConfig: PropTypes.func,
|
||||
}).isRequired,
|
||||
isLoadingData: PropTypes.bool.isRequired,
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const defaultFilterValues = {
|
||||
patientName: '',
|
||||
mrn: '',
|
||||
studyDate: {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
},
|
||||
description: '',
|
||||
modalities: [],
|
||||
accession: '',
|
||||
sortBy: '',
|
||||
sortDirection: 'none',
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 25,
|
||||
datasources: '',
|
||||
};
|
||||
|
||||
function _tryParseInt(str, defaultValue) {
|
||||
let retValue = defaultValue;
|
||||
if (str && str.length > 0) {
|
||||
if (!isNaN(str)) {
|
||||
retValue = parseInt(str);
|
||||
}
|
||||
}
|
||||
return retValue;
|
||||
}
|
||||
|
||||
function _getQueryFilterValues(params) {
|
||||
const newParams = new URLSearchParams();
|
||||
for (const [key, value] of params) {
|
||||
newParams.set(key.toLowerCase(), value);
|
||||
}
|
||||
params = newParams;
|
||||
|
||||
const queryFilterValues = {
|
||||
patientName: params.get('patientname'),
|
||||
mrn: params.get('mrn'),
|
||||
studyDate: {
|
||||
startDate: params.get('startdate') || null,
|
||||
endDate: params.get('enddate') || null,
|
||||
},
|
||||
description: params.get('description'),
|
||||
modalities: params.get('modalities') ? params.get('modalities').split(',') : [],
|
||||
accession: params.get('accession'),
|
||||
sortBy: params.get('sortby'),
|
||||
sortDirection: params.get('sortdirection'),
|
||||
pageNumber: _tryParseInt(params.get('pagenumber'), undefined),
|
||||
resultsPerPage: _tryParseInt(params.get('resultsperpage'), undefined),
|
||||
datasources: params.get('datasources'),
|
||||
configUrl: params.get('configurl'),
|
||||
};
|
||||
|
||||
// Delete null/undefined keys
|
||||
Object.keys(queryFilterValues).forEach(
|
||||
key => queryFilterValues[key] == null && delete queryFilterValues[key]
|
||||
);
|
||||
|
||||
return queryFilterValues;
|
||||
}
|
||||
|
||||
function _sortStringDates(s1, s2, sortModifier) {
|
||||
// TODO: Delimiters are non-standard. Should we support them?
|
||||
const s1Date = moment(s1.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
|
||||
const s2Date = moment(s2.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
|
||||
|
||||
if (s1Date.isValid() && s2Date.isValid()) {
|
||||
return (s1Date.toISOString() > s2Date.toISOString() ? 1 : -1) * sortModifier;
|
||||
} else if (s1Date.isValid()) {
|
||||
return sortModifier;
|
||||
} else if (s2Date.isValid()) {
|
||||
return -1 * sortModifier;
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkList;
|
||||
129
platform/app/src/routes/WorkList/filtersMeta.js
Normal file
129
platform/app/src/routes/WorkList/filtersMeta.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
const filtersMeta = [
|
||||
{
|
||||
name: 'patientName',
|
||||
displayName: i18n.t('StudyList:PatientName'),
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
name: 'mrn',
|
||||
displayName: i18n.t('StudyList:MRN'),
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
name: 'studyDate',
|
||||
displayName: i18n.t('StudyList:StudyDate'),
|
||||
inputType: 'DateRange',
|
||||
isSortable: true,
|
||||
gridCol: 5,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
displayName: i18n.t('StudyList:Description'),
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
name: 'modalities',
|
||||
displayName: i18n.t('StudyList:Modality'),
|
||||
inputType: 'MultiSelect',
|
||||
inputProps: {
|
||||
options: [
|
||||
{ value: 'AR', label: 'AR' },
|
||||
{ value: 'ASMT', label: 'ASMT' },
|
||||
{ value: 'AU', label: 'AU' },
|
||||
{ value: 'BDUS', label: 'BDUS' },
|
||||
{ value: 'BI', label: 'BI' },
|
||||
{ value: 'BMD', label: 'BMD' },
|
||||
{ value: 'CR', label: 'CR' },
|
||||
{ value: 'CT', label: 'CT' },
|
||||
{ value: 'CTPROTOCOL', label: 'CTPROTOCOL' },
|
||||
{ value: 'DG', label: 'DG' },
|
||||
{ value: 'DOC', label: 'DOC' },
|
||||
{ value: 'DX', label: 'DX' },
|
||||
{ value: 'ECG', label: 'ECG' },
|
||||
{ value: 'EPS', label: 'EPS' },
|
||||
{ value: 'ES', label: 'ES' },
|
||||
{ value: 'FID', label: 'FID' },
|
||||
{ value: 'GM', label: 'GM' },
|
||||
{ value: 'HC', label: 'HC' },
|
||||
{ value: 'HD', label: 'HD' },
|
||||
{ value: 'IO', label: 'IO' },
|
||||
{ value: 'IOL', label: 'IOL' },
|
||||
{ value: 'IVOCT', label: 'IVOCT' },
|
||||
{ value: 'IVUS', label: 'IVUS' },
|
||||
{ value: 'KER', label: 'KER' },
|
||||
{ value: 'KO', label: 'KO' },
|
||||
{ value: 'LEN', label: 'LEN' },
|
||||
{ value: 'LS', label: 'LS' },
|
||||
{ value: 'MG', label: 'MG' },
|
||||
{ value: 'MR', label: 'MR' },
|
||||
{ value: 'M3D', label: 'M3D' },
|
||||
{ value: 'NM', label: 'NM' },
|
||||
{ value: 'OAM', label: 'OAM' },
|
||||
{ value: 'OCT', label: 'OCT' },
|
||||
{ value: 'OP', label: 'OP' },
|
||||
{ value: 'OPM', label: 'OPM' },
|
||||
{ value: 'OPT', label: 'OPT' },
|
||||
{ value: 'OPTBSV', label: 'OPTBSV' },
|
||||
{ value: 'OPTENF', label: 'OPTENF' },
|
||||
{ value: 'OPV', label: 'OPV' },
|
||||
{ value: 'OSS', label: 'OSS' },
|
||||
{ value: 'OT', label: 'OT' },
|
||||
{ value: 'PLAN', label: 'PLAN' },
|
||||
{ value: 'PR', label: 'PR' },
|
||||
{ value: 'PT', label: 'PT' },
|
||||
{ value: 'PX', label: 'PX' },
|
||||
{ value: 'REG', label: 'REG' },
|
||||
{ value: 'RESP', label: 'RESP' },
|
||||
{ value: 'RF', label: 'RF' },
|
||||
{ value: 'RG', label: 'RG' },
|
||||
{ value: 'RTDOSE', label: 'RTDOSE' },
|
||||
{ value: 'RTIMAGE', label: 'RTIMAGE' },
|
||||
{ value: 'RTINTENT', label: 'RTINTENT' },
|
||||
{ value: 'RTPLAN', label: 'RTPLAN' },
|
||||
{ value: 'RTRAD', label: 'RTRAD' },
|
||||
{ value: 'RTRECORD', label: 'RTRECORD' },
|
||||
{ value: 'RTSEGANN', label: 'RTSEGANN' },
|
||||
{ value: 'RTSTRUCT', label: 'RTSTRUCT' },
|
||||
{ value: 'RWV', label: 'RWV' },
|
||||
{ value: 'SEG', label: 'SEG' },
|
||||
{ value: 'SM', label: 'SM' },
|
||||
{ value: 'SMR', label: 'SMR' },
|
||||
{ value: 'SR', label: 'SR' },
|
||||
{ value: 'SRF', label: 'SRF' },
|
||||
{ value: 'STAIN', label: 'STAIN' },
|
||||
{ value: 'TEXTUREMAP', label: 'TEXTUREMAP' },
|
||||
{ value: 'TG', label: 'TG' },
|
||||
{ value: 'US', label: 'US' },
|
||||
{ value: 'VA', label: 'VA' },
|
||||
{ value: 'XA', label: 'XA' },
|
||||
{ value: 'XC', label: 'XC' },
|
||||
],
|
||||
},
|
||||
isSortable: true,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
name: 'accession',
|
||||
displayName: i18n.t('StudyList:AccessionNumber'),
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
name: 'instances',
|
||||
displayName: i18n.t('StudyList:Instances'),
|
||||
inputType: 'None',
|
||||
isSortable: false,
|
||||
gridCol: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export default filtersMeta;
|
||||
1
platform/app/src/routes/WorkList/index.js
Normal file
1
platform/app/src/routes/WorkList/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './WorkList';
|
||||
90
platform/app/src/routes/buildModeRoutes.tsx
Normal file
90
platform/app/src/routes/buildModeRoutes.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import ModeRoute from '@routes/Mode';
|
||||
import publicUrl from '../utils/publicUrl';
|
||||
|
||||
/*
|
||||
Routes uniquely define an entry point to:
|
||||
- A mode
|
||||
- Linked to a data source
|
||||
- With a specified data set.
|
||||
|
||||
The full route template is:
|
||||
|
||||
/:modeId/:modeRoute/:sourceType/?queryParameters=example
|
||||
|
||||
Where:
|
||||
:modeId - Is the mode selected.
|
||||
:modeRoute - Is the route within the mode to select.
|
||||
:sourceType - Is the data source identifier, which specifies which DataSource to use.
|
||||
?queryParameters - Are query parameters as defined by data source.
|
||||
|
||||
A default source can be specified at the app level configuration, and then that source is used if :sourceType is omitted:
|
||||
|
||||
/:modeId/:modeRoute/?queryParameters=example
|
||||
*/
|
||||
export default function buildModeRoutes({
|
||||
modes,
|
||||
dataSources,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
}: withAppTypes) {
|
||||
const routes = [];
|
||||
const dataSourceNames = [];
|
||||
|
||||
dataSources.forEach(dataSource => {
|
||||
const { sourceName } = dataSource;
|
||||
if (!dataSourceNames.includes(sourceName)) {
|
||||
dataSourceNames.push(sourceName);
|
||||
}
|
||||
});
|
||||
|
||||
modes.forEach(mode => {
|
||||
// todo: for each route. add route to path.
|
||||
dataSourceNames.forEach(dataSourceName => {
|
||||
const path = `${publicUrl}${mode.routeName}/${dataSourceName}`;
|
||||
|
||||
// TODO move up.
|
||||
const children = () => (
|
||||
<ModeRoute
|
||||
mode={mode}
|
||||
dataSourceName={dataSourceName}
|
||||
extensionManager={extensionManager}
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
hotkeysManager={hotkeysManager}
|
||||
/>
|
||||
);
|
||||
|
||||
routes.push({
|
||||
path,
|
||||
children,
|
||||
private: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Add active DataSource route.
|
||||
// This is the DataSource route for the active data source defined in ExtensionManager.getActiveDataSource
|
||||
const path = `${publicUrl}${mode.routeName}`;
|
||||
|
||||
// TODO move up.
|
||||
const children = () => (
|
||||
<ModeRoute
|
||||
mode={mode}
|
||||
extensionManager={extensionManager}
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
hotkeysManager={hotkeysManager}
|
||||
/>
|
||||
);
|
||||
|
||||
routes.push({
|
||||
path,
|
||||
children,
|
||||
private: true,
|
||||
});
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
170
platform/app/src/routes/index.tsx
Normal file
170
platform/app/src/routes/index.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link } from 'react-router-dom';
|
||||
import { ErrorBoundary } from '@ohif/ui-next';
|
||||
|
||||
// Route Components
|
||||
import DataSourceWrapper from './DataSourceWrapper';
|
||||
import WorkList from './WorkList';
|
||||
import Local from './Local';
|
||||
import Debug from './Debug';
|
||||
import NotFound from './NotFound';
|
||||
import buildModeRoutes from './buildModeRoutes';
|
||||
import PrivateRoute from './PrivateRoute';
|
||||
import PropTypes from 'prop-types';
|
||||
import publicUrl from '../utils/publicUrl';
|
||||
|
||||
const NotFoundServer = ({
|
||||
message = 'Unable to query for studies at this time. Check your data source configuration or network connection',
|
||||
}) => {
|
||||
return (
|
||||
<div className="absolute flex h-full w-full items-center justify-center text-white">
|
||||
<div>
|
||||
<h4>{message}</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NotFoundServer.propTypes = {
|
||||
message: PropTypes.string,
|
||||
};
|
||||
|
||||
const NotFoundStudy = () => {
|
||||
return (
|
||||
<div className="absolute flex h-full w-full items-center justify-center text-white">
|
||||
<div>
|
||||
<h4>
|
||||
One or more of the requested studies are not available at this time. Return to the{' '}
|
||||
<Link
|
||||
className="text-primary-light"
|
||||
to={'/'}
|
||||
>
|
||||
study list
|
||||
</Link>{' '}
|
||||
to select a different study to view.
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NotFoundStudy.propTypes = {
|
||||
message: PropTypes.string,
|
||||
};
|
||||
|
||||
// TODO: Include "routes" debug route if dev build
|
||||
const bakedInRoutes = [
|
||||
{
|
||||
path: `${publicUrl}notfoundserver`,
|
||||
children: NotFoundServer,
|
||||
},
|
||||
{
|
||||
path: `${publicUrl}notfoundstudy`,
|
||||
children: NotFoundStudy,
|
||||
},
|
||||
{
|
||||
path: `${publicUrl}debug`,
|
||||
children: Debug,
|
||||
},
|
||||
{
|
||||
path: `${publicUrl}local`,
|
||||
children: Local.bind(null, { modePath: '' }), // navigate to the worklist
|
||||
},
|
||||
{
|
||||
path: `${publicUrl}localbasic`,
|
||||
children: Local.bind(null, { modePath: 'viewer/dicomlocal' }),
|
||||
},
|
||||
];
|
||||
|
||||
// NOT FOUND (404)
|
||||
const notFoundRoute = { component: NotFound };
|
||||
|
||||
const createRoutes = ({
|
||||
modes,
|
||||
dataSources,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
routerBasename,
|
||||
showStudyList,
|
||||
}: withAppTypes) => {
|
||||
const routes =
|
||||
buildModeRoutes({
|
||||
modes,
|
||||
dataSources,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
}) || [];
|
||||
|
||||
const { customizationService } = servicesManager.services;
|
||||
|
||||
const WorkListRoute = {
|
||||
path: publicUrl,
|
||||
children: DataSourceWrapper,
|
||||
private: true,
|
||||
props: { children: WorkList, servicesManager, extensionManager },
|
||||
};
|
||||
|
||||
const customRoutes = customizationService.getCustomization('routes.customRoutes');
|
||||
|
||||
const allRoutes = [
|
||||
...routes,
|
||||
...(showStudyList ? [WorkListRoute] : []),
|
||||
...(publicUrl !== '/' && showStudyList ? [{ ...WorkListRoute, path: publicUrl }] : []),
|
||||
...(customRoutes?.routes || []),
|
||||
...bakedInRoutes,
|
||||
customRoutes?.notFoundRoute || notFoundRoute,
|
||||
];
|
||||
|
||||
function RouteWithErrorBoundary({ route, ...rest }) {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return (
|
||||
<ErrorBoundary context={`Route ${route.path}`}>
|
||||
<route.children
|
||||
{...rest}
|
||||
{...route.props}
|
||||
route={route}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
hotkeysManager={hotkeysManager}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const { userAuthenticationService } = servicesManager.services;
|
||||
|
||||
// All routes are private by default and then we let the user auth service
|
||||
// to check if it is enabled or not
|
||||
// Todo: I think we can remove the second public return below
|
||||
return (
|
||||
<Routes>
|
||||
{allRoutes.map((route, i) => {
|
||||
return route.private === true ? (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
element={
|
||||
<PrivateRoute
|
||||
handleUnauthenticated={() => userAuthenticationService.handleUnauthenticated()}
|
||||
>
|
||||
<RouteWithErrorBoundary route={route} />
|
||||
</PrivateRoute>
|
||||
}
|
||||
></Route>
|
||||
) : (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
element={<RouteWithErrorBoundary route={route} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default createRoutes;
|
||||
8
platform/app/src/sanity.test.js
Normal file
8
platform/app/src/sanity.test.js
Normal file
@@ -0,0 +1,8 @@
|
||||
describe('Sanity Test', () => {
|
||||
test('how many marbles?', () => {
|
||||
const expectedMarbles = 4;
|
||||
const actualMarbles = 2 + 2;
|
||||
|
||||
expect(actualMarbles).toEqual(expectedMarbles);
|
||||
});
|
||||
});
|
||||
71
platform/app/src/service-worker.js
Normal file
71
platform/app/src/service-worker.js
Normal file
@@ -0,0 +1,71 @@
|
||||
navigator.serviceWorker.getRegistrations().then(function (registrations) {
|
||||
for (let registration of registrations) {
|
||||
registration.unregister();
|
||||
}
|
||||
});
|
||||
|
||||
// https://developers.google.com/web/tools/workbox/guides/troubleshoot-and-debug
|
||||
importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.0.0-beta.1/workbox-sw.js');
|
||||
|
||||
// Install newest
|
||||
// https://developers.google.com/web/tools/workbox/modules/workbox-core
|
||||
workbox.core.skipWaiting();
|
||||
workbox.core.clientsClaim();
|
||||
|
||||
// Cache static assets that aren't precached
|
||||
workbox.routing.registerRoute(
|
||||
/\.(?:js|css|json5)$/,
|
||||
new workbox.strategies.StaleWhileRevalidate({
|
||||
cacheName: 'static-resources',
|
||||
})
|
||||
);
|
||||
|
||||
// Cache the Google Fonts stylesheets with a stale-while-revalidate strategy.
|
||||
workbox.routing.registerRoute(
|
||||
/^https:\/\/fonts\.googleapis\.com/,
|
||||
new workbox.strategies.StaleWhileRevalidate({
|
||||
cacheName: 'google-fonts-stylesheets',
|
||||
})
|
||||
);
|
||||
|
||||
// Cache the underlying font files with a cache-first strategy for 1 year.
|
||||
workbox.routing.registerRoute(
|
||||
/^https:\/\/fonts\.gstatic\.com/,
|
||||
new workbox.strategies.CacheFirst({
|
||||
cacheName: 'google-fonts-webfonts',
|
||||
plugins: [
|
||||
new workbox.cacheableResponse.CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new workbox.expiration.ExpirationPlugin({
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 Year
|
||||
maxEntries: 30,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// MESSAGE HANDLER
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
switch (event.data.type) {
|
||||
case 'SKIP_WAITING':
|
||||
// TODO: We'll eventually want this to be user prompted
|
||||
// workbox.core.skipWaiting();
|
||||
// workbox.core.clientsClaim();
|
||||
// TODO: Global notification to indicate incoming reload
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`SW: Invalid message type: ${event.data.type}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// TODO: Cache API
|
||||
// https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api
|
||||
// Store DICOMs?
|
||||
// Clear Service Worker cache?
|
||||
// navigator.storage.estimate().then(est => console.log(est)); (2GB?)
|
||||
20
platform/app/src/state/appConfig.tsx
Normal file
20
platform/app/src/state/appConfig.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { useState, createContext, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const appConfigContext = createContext(null);
|
||||
const { Provider } = appConfigContext;
|
||||
|
||||
export const useAppConfig = () => useContext(appConfigContext);
|
||||
|
||||
export function AppConfigProvider({ children, value: initAppConfig }) {
|
||||
const [appConfig, setAppConfig] = useState(initAppConfig);
|
||||
|
||||
return <Provider value={[appConfig, setAppConfig]}>{children}</Provider>;
|
||||
}
|
||||
|
||||
AppConfigProvider.propTypes = {
|
||||
children: PropTypes.any,
|
||||
value: PropTypes.any,
|
||||
};
|
||||
|
||||
export default AppConfigProvider;
|
||||
3
platform/app/src/state/index.js
Normal file
3
platform/app/src/state/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { AppConfigProvider, useAppConfig } from './appConfig.tsx';
|
||||
|
||||
export { AppConfigProvider, useAppConfig };
|
||||
230
platform/app/src/utils/OpenIdConnectRoutes.tsx
Normal file
230
platform/app/src/utils/OpenIdConnectRoutes.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router';
|
||||
import CallbackPage from '../routes/CallbackPage';
|
||||
import SignoutCallbackComponent from '../routes/SignoutCallbackComponent';
|
||||
import LegacyClient from './legacyOIDCClient';
|
||||
import NextClient from './nextOIDCClient';
|
||||
|
||||
function _isAbsoluteUrl(url) {
|
||||
return url.includes('http://') || url.includes('https://');
|
||||
}
|
||||
|
||||
function _makeAbsoluteIfNecessary(url, base_url) {
|
||||
if (_isAbsoluteUrl(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
/*
|
||||
* Make sure base_url and url are not duplicating slashes.
|
||||
*/
|
||||
if (base_url[base_url.length - 1] === '/') {
|
||||
base_url = base_url.slice(0, base_url.length - 1);
|
||||
}
|
||||
|
||||
return base_url + url;
|
||||
}
|
||||
|
||||
const initUserManager = (oidc, routerBasename) => {
|
||||
if (!oidc || !oidc.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstOpenIdClient = oidc[0];
|
||||
const { protocol, host } = window.location;
|
||||
const baseUri = `${protocol}//${host}${routerBasename}`;
|
||||
|
||||
const redirect_uri = firstOpenIdClient.redirect_uri || '/callback';
|
||||
const silent_redirect_uri = firstOpenIdClient.silent_redirect_uri || '/silent-refresh.html';
|
||||
const post_logout_redirect_uri = firstOpenIdClient.post_logout_redirect_uri || '/';
|
||||
|
||||
const openIdConnectConfiguration = Object.assign({}, firstOpenIdClient, {
|
||||
redirect_uri: _makeAbsoluteIfNecessary(redirect_uri, baseUri),
|
||||
silent_redirect_uri: _makeAbsoluteIfNecessary(silent_redirect_uri, baseUri),
|
||||
post_logout_redirect_uri: _makeAbsoluteIfNecessary(post_logout_redirect_uri, baseUri),
|
||||
});
|
||||
|
||||
const client = firstOpenIdClient.response_type === 'code' ? NextClient : LegacyClient;
|
||||
|
||||
return client(openIdConnectConfiguration);
|
||||
};
|
||||
|
||||
function LogoutComponent(props) {
|
||||
const { userManager } = props;
|
||||
localStorage.setItem('signoutEvent', 'true');
|
||||
const location = useLocation();
|
||||
const query = new URLSearchParams(location.search);
|
||||
userManager.signoutRedirect({
|
||||
post_logout_redirect_uri: query.get('redirect_uri'),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function LoginComponent(userManager) {
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const iss = queryParams.get('iss');
|
||||
const loginHint = queryParams.get('login_hint');
|
||||
const targetLinkUri = queryParams.get('target_link_uri');
|
||||
if (iss !== oidcAuthority) {
|
||||
console.error('iss of /login does not match the oidc authority');
|
||||
return null;
|
||||
}
|
||||
|
||||
userManager.removeUser().then(() => {
|
||||
if (targetLinkUri !== null) {
|
||||
const ohifRedirectTo = {
|
||||
pathname: new URL(targetLinkUri).pathname,
|
||||
};
|
||||
sessionStorage.setItem('ohif-redirect-to', JSON.stringify(ohifRedirectTo));
|
||||
} else {
|
||||
const ohifRedirectTo = {
|
||||
pathname: '/',
|
||||
};
|
||||
sessionStorage.setItem('ohif-redirect-to', JSON.stringify(ohifRedirectTo));
|
||||
}
|
||||
|
||||
if (loginHint !== null) {
|
||||
userManager.signinRedirect({ login_hint: loginHint });
|
||||
} else {
|
||||
userManager.signinRedirect();
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function OpenIdConnectRoutes({ oidc, routerBasename, userAuthenticationService }) {
|
||||
const userManager = initUserManager(oidc, routerBasename);
|
||||
|
||||
const getAuthorizationHeader = () => {
|
||||
const user = userAuthenticationService.getUser();
|
||||
|
||||
// if the user is null return early, next time
|
||||
// we hit this function we will have a user
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${user.access_token}`,
|
||||
};
|
||||
};
|
||||
|
||||
const handleUnauthenticated = () => {
|
||||
// Note: Don't await the redirect. If you make this component async it
|
||||
// causes a react error before redirect as it returns a promise of a component rather than a component.
|
||||
userManager.signinRedirect();
|
||||
|
||||
// return null because this is used in a react component
|
||||
return null;
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
//for multi-tab logout
|
||||
useEffect(() => {
|
||||
localStorage.removeItem('signoutEvent');
|
||||
const storageEventListener = event => {
|
||||
const signOutEvent = localStorage.getItem('signoutEvent');
|
||||
if (signOutEvent) {
|
||||
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', storageEventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', storageEventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
userAuthenticationService.set({ enabled: true });
|
||||
|
||||
userAuthenticationService.setServiceImplementation({
|
||||
getAuthorizationHeader,
|
||||
handleUnauthenticated,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const oidcAuthority = oidc[0].authority;
|
||||
|
||||
const location = useLocation();
|
||||
const { pathname, search } = location;
|
||||
|
||||
const redirectURI = userManager.settings._redirect_uri ?? userManager.settings.redirect_uri;
|
||||
const silentRedirectURI =
|
||||
userManager.settings._silent_redirect_uri ?? userManager.settings.silent_redirect_uri;
|
||||
const postLogoutRedirectURI =
|
||||
userManager.settings._post_logout_redirect_uri ?? userManager.settings.post_logout_redirect_uri;
|
||||
|
||||
const redirect_uri = new URL(redirectURI).pathname.replace(
|
||||
routerBasename !== '/' ? routerBasename : '',
|
||||
''
|
||||
);
|
||||
const silent_refresh_uri = new URL(silentRedirectURI).pathname; //.replace(routerBasename,'')
|
||||
const post_logout_redirect_uri = new URL(postLogoutRedirectURI).pathname; //.replace(routerBasename,'');
|
||||
|
||||
// const pathnameRelative = pathname.replace(routerBasename,'');
|
||||
|
||||
if (pathname !== redirect_uri) {
|
||||
sessionStorage.setItem('ohif-redirect-to', JSON.stringify({ pathname, search }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path={silent_refresh_uri}
|
||||
onEnter={window.location.reload}
|
||||
/>
|
||||
<Route
|
||||
path={post_logout_redirect_uri}
|
||||
element={
|
||||
<SignoutCallbackComponent
|
||||
userManager={userManager}
|
||||
successCallback={() => console.log('Signout successful')}
|
||||
errorCallback={error => {
|
||||
console.warn(error);
|
||||
console.warn('Signout failed');
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={redirect_uri}
|
||||
element={
|
||||
<CallbackPage
|
||||
userManager={userManager}
|
||||
onRedirectSuccess={user => {
|
||||
const { pathname, search = '' } = JSON.parse(
|
||||
sessionStorage.getItem('ohif-redirect-to')
|
||||
);
|
||||
|
||||
userAuthenticationService.setUser(user);
|
||||
|
||||
navigate({
|
||||
pathname,
|
||||
search,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<LoginComponent
|
||||
userManager={userManager}
|
||||
oidcAuthority={oidcAuthority}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/logout"
|
||||
element={<LogoutComponent userManager={userManager} />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenIdConnectRoutes;
|
||||
9
platform/app/src/utils/history.ts
Normal file
9
platform/app/src/utils/history.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NavigateFunction } from 'react-router';
|
||||
|
||||
type History = {
|
||||
navigate: NavigateFunction;
|
||||
};
|
||||
|
||||
export const history: History = {
|
||||
navigate: null,
|
||||
};
|
||||
16
platform/app/src/utils/isSeriesFilterUsed.ts
Normal file
16
platform/app/src/utils/isSeriesFilterUsed.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* This function is used to check if the filter is used. Its intend is to
|
||||
* warn the user in case of link with a SeriesInstanceUID was called
|
||||
* @param instances
|
||||
* @returns
|
||||
*/
|
||||
export default function isSeriesFilterUsed(instances, filters) {
|
||||
const seriesInstanceUIDs = filters?.seriesInstanceUID;
|
||||
if (!seriesInstanceUIDs) {
|
||||
return true;
|
||||
}
|
||||
if (!instances.length) {
|
||||
return false;
|
||||
}
|
||||
return seriesInstanceUIDs.includes(instances[0].SeriesInstanceUID);
|
||||
}
|
||||
30
platform/app/src/utils/legacyOIDCClient.ts
Normal file
30
platform/app/src/utils/legacyOIDCClient.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { UserManager } from 'oidc-client';
|
||||
|
||||
/**
|
||||
* Creates a userManager from oidcSettings
|
||||
* LINK: https://github.com/IdentityModel/oidc-client-js/wiki#configuration
|
||||
*
|
||||
* @param {Object} oidcSettings
|
||||
* @param {string} oidcSettings.authServerUrl,
|
||||
* @param {string} oidcSettings.clientId,
|
||||
* @param {string} oidcSettings.authRedirectUri,
|
||||
* @param {string} oidcSettings.postLogoutRedirectUri,
|
||||
* @param {string} oidcSettings.responseType,
|
||||
* @param {string} oidcSettings.extraQueryParams,
|
||||
*/
|
||||
export default function getUserManagerForOpenIdConnectClient(oidcSettings) {
|
||||
if (!oidcSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = {
|
||||
...oidcSettings,
|
||||
automaticSilentRenew: true,
|
||||
revokeAccessTokenOnSignout: true,
|
||||
filterProtocolClaims: true,
|
||||
};
|
||||
|
||||
const userManager = new UserManager(settings);
|
||||
|
||||
return userManager;
|
||||
}
|
||||
38
platform/app/src/utils/nextOIDCClient.ts
Normal file
38
platform/app/src/utils/nextOIDCClient.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { UserManager } from 'oidc-client-ts';
|
||||
|
||||
/**
|
||||
* Creates a userManager from oidcSettings
|
||||
* LINK: https://github.com/IdentityModel/oidc-client-js/wiki#configuration
|
||||
*
|
||||
* @param {Object} oidcSettings
|
||||
* @param {string} oidcSettings.authServerUrl,
|
||||
* @param {string} oidcSettings.clientId,
|
||||
* @param {string} oidcSettings.authRedirectUri,
|
||||
* @param {string} oidcSettings.postLogoutRedirectUri,
|
||||
* @param {string} oidcSettings.responseType,
|
||||
* @param {string} oidcSettings.extraQueryParams,
|
||||
*/
|
||||
export default function getUserManagerForOpenIdConnectClient(oidcSettings) {
|
||||
if (!oidcSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oidcSettings.authority || !oidcSettings.client_id || !oidcSettings.redirect_uri) {
|
||||
console.error('Missing required oidc settings: authority, client_id, redirect_uri');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = {
|
||||
...oidcSettings,
|
||||
// The next client always use the code flow with PKCE
|
||||
response_type: 'code',
|
||||
revokeTokensOnSignout: oidcSettings.revokeAccessTokenOnSignout ?? true,
|
||||
filterProtocolClaims: true,
|
||||
// the followings are default values in the lib so no need to set them
|
||||
// automaticSilentRenew: true,
|
||||
};
|
||||
|
||||
const userManager = new UserManager(settings);
|
||||
|
||||
return userManager;
|
||||
}
|
||||
26
platform/app/src/utils/preserveQueryParameters.ts
Normal file
26
platform/app/src/utils/preserveQueryParameters.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
function preserve(query, current, key) {
|
||||
const value = current.get(key);
|
||||
if (value) {
|
||||
query.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber'];
|
||||
|
||||
export function preserveQueryParameters(
|
||||
query,
|
||||
current = new URLSearchParams(window.location.search)
|
||||
) {
|
||||
for (const key of preserveKeys) {
|
||||
preserve(query, current, key);
|
||||
}
|
||||
}
|
||||
|
||||
export function preserveQueryStrings(query, current = new URLSearchParams(window.location.search)) {
|
||||
for (const key of preserveKeys) {
|
||||
const value = current.get(key);
|
||||
if (value) {
|
||||
query[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
4
platform/app/src/utils/publicUrl.ts
Normal file
4
platform/app/src/utils/publicUrl.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
const publicUrl = (window as any).PUBLIC_URL || '/';
|
||||
|
||||
export default publicUrl;
|
||||
export { publicUrl };
|
||||
Reference in New Issue
Block a user