chore(build): try to build demo and docs (#3434)
This commit is contained in:
1 parent
869b8ced05
commit
828690cd3b
329 files changed
+1896
-1010
No files matched your search
@@ -0,0 +1,170 @@
|
||||
// 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 {
|
||||
ServicesManager,
|
||||
ExtensionManager,
|
||||
CommandsManager,
|
||||
HotkeysManager,
|
||||
} from '@ohif/core';
|
||||
import {
|
||||
DialogProvider,
|
||||
Modal,
|
||||
ModalProvider,
|
||||
SnackbarProvider,
|
||||
ThemeWrapper,
|
||||
ViewportDialogProvider,
|
||||
ViewportGridProvider,
|
||||
CineProvider,
|
||||
UserAuthenticationProvider,
|
||||
} from '@ohif/ui';
|
||||
// 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';
|
||||
|
||||
let commandsManager: CommandsManager,
|
||||
extensionManager: ExtensionManager,
|
||||
servicesManager: ServicesManager,
|
||||
hotkeysManager: HotkeysManager;
|
||||
|
||||
function App({ config, 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;
|
||||
hotkeysManager = init.hotkeysManager;
|
||||
|
||||
// Set appConfig
|
||||
const appConfigState = init.appConfig;
|
||||
const {
|
||||
routerBasename,
|
||||
modes,
|
||||
dataSources,
|
||||
oidc,
|
||||
showStudyList,
|
||||
} = appConfigState;
|
||||
|
||||
const {
|
||||
uiDialogService,
|
||||
uiModalService,
|
||||
uiNotificationService,
|
||||
uiViewportDialogService,
|
||||
viewportGridService,
|
||||
cineService,
|
||||
userAuthenticationService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const providers = [
|
||||
[AppConfigProvider, { value: appConfigState }],
|
||||
[UserAuthenticationProvider, { service: userAuthenticationService }],
|
||||
[I18nextProvider, { i18n }],
|
||||
[ThemeWrapper],
|
||||
[ViewportGridProvider, { service: viewportGridService }],
|
||||
[ViewportDialogProvider, { service: uiViewportDialogService }],
|
||||
[CineProvider, { service: cineService }],
|
||||
[SnackbarProvider, { service: uiNotificationService }],
|
||||
[DialogProvider, { service: uiDialogService }],
|
||||
[ModalProvider, { service: uiModalService, modal: Modal }],
|
||||
];
|
||||
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,
|
||||
};
|
||||
|
||||
App.defaultProps = {
|
||||
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: [],
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
export { commandsManager, extensionManager, servicesManager };
|
||||
@@ -0,0 +1,3 @@
|
||||
// https://jestjs.io/docs/en/webpack#handling-static-assets
|
||||
|
||||
module.exports = 'test-file-stub';
|
||||
@@ -0,0 +1,9 @@
|
||||
const _ = require('lodash');
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
// JSDom's CSS Parser has limited support for certain features
|
||||
// This supresses error warnings caused by it
|
||||
console.error = function(msg) {
|
||||
if (_.startsWith(msg, 'Error: Could not parse CSS stylesheet')) return;
|
||||
originalConsoleError(msg);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
CommandsManager,
|
||||
ExtensionManager,
|
||||
ServicesManager,
|
||||
HotkeysManager,
|
||||
UINotificationService,
|
||||
UIModalService,
|
||||
UIDialogService,
|
||||
UIViewportDialogService,
|
||||
MeasurementService,
|
||||
StateSyncService,
|
||||
DisplaySetService,
|
||||
ToolbarService,
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
CineService,
|
||||
UserAuthenticationService,
|
||||
errorHandler,
|
||||
CustomizationService,
|
||||
PanelService,
|
||||
// utils,
|
||||
} from '@ohif/core';
|
||||
|
||||
import loadModules 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 hotkeysManager = new HotkeysManager(commandsManager, servicesManager);
|
||||
|
||||
const appConfig = {
|
||||
...(typeof appConfigOrFunc === 'function'
|
||||
? await appConfigOrFunc({ servicesManager })
|
||||
: appConfigOrFunc),
|
||||
};
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
commandsManager,
|
||||
servicesManager,
|
||||
hotkeysManager,
|
||||
appConfig,
|
||||
});
|
||||
|
||||
servicesManager.registerServices([
|
||||
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,
|
||||
StateSyncService.REGISTRATION,
|
||||
]);
|
||||
|
||||
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 istance 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 modeConfig =
|
||||
appConfig.modeConfig && appConfig.modeConfig[i]
|
||||
? appConfig.modeConfig[id]
|
||||
: {};
|
||||
|
||||
mode = mode.modeFactory(modeConfig);
|
||||
}
|
||||
|
||||
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,
|
||||
hotkeysManager,
|
||||
};
|
||||
}
|
||||
|
||||
export default appInit;
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
function EmptyViewport() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
export default EmptyViewport;
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ServicesManager, Types, MeasurementService } from '@ohif/core';
|
||||
import { ViewportGrid, ViewportPane, useViewportGrid } from '@ohif/ui';
|
||||
import EmptyViewport from './EmptyViewport';
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
||||
function ViewerViewportGrid(props) {
|
||||
const { servicesManager, viewportComponents, dataSource } = props;
|
||||
const [viewportGrid, viewportGridService] = useViewportGrid();
|
||||
|
||||
const { layout, activeViewportIndex, viewports } = viewportGrid;
|
||||
const { numCols, numRows } = layout;
|
||||
|
||||
// TODO -> Need some way of selecting which displaySets hit the viewports.
|
||||
const {
|
||||
displaySetService,
|
||||
measurementService,
|
||||
hangingProtocolService,
|
||||
uiNotificationService,
|
||||
} = (servicesManager as ServicesManager).services;
|
||||
|
||||
/**
|
||||
* 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 = viewportIndex => {
|
||||
const details = viewportMatchDetails.get(viewportIndex);
|
||||
if (!details) {
|
||||
console.log('No match details for viewport', viewportIndex);
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
const _getUpdatedViewports = useCallback(
|
||||
(viewportIndex, displaySetInstanceUID) => {
|
||||
let updatedViewports = [];
|
||||
try {
|
||||
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
|
||||
viewportIndex,
|
||||
displaySetInstanceUID
|
||||
);
|
||||
} 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: 'info',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedViewports;
|
||||
},
|
||||
[hangingProtocolService, uiNotificationService]
|
||||
);
|
||||
|
||||
// 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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = measurementService.subscribe(
|
||||
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
|
||||
({ viewportIndex, 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(
|
||||
viewportIndex,
|
||||
referencedDisplaySetInstanceUID
|
||||
);
|
||||
// Arbitrarily assign the viewport to element 0
|
||||
const viewport = updatedViewports?.[0];
|
||||
|
||||
if (!viewport) {
|
||||
console.warn(
|
||||
'ViewportGrid::Unable to navigate to viewport containing',
|
||||
referencedDisplaySetInstanceUID
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
viewport.viewportOptions ||= {};
|
||||
viewport.viewportOptions.orientation = 'acquisition';
|
||||
|
||||
const displaySet = displaySetService.getDisplaySetByUID(
|
||||
referencedDisplaySetInstanceUID
|
||||
);
|
||||
// jump straight to the initial image index if we can
|
||||
if (displaySet.images && measurement.SOPInstanceUID) {
|
||||
for (let index = 0; index < displaySet.images.length; index++) {
|
||||
const image = displaySet.images[index];
|
||||
if (image.SOPInstanceUID === measurement.SOPInstanceUID) {
|
||||
viewport.viewportOptions.initialImageOptions = {
|
||||
index,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
viewportGridService.setDisplaySetsForViewports(updatedViewports);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [viewports]);
|
||||
|
||||
/**
|
||||
const onDoubleClick = viewportIndex => {
|
||||
// TODO -> Disabled for now.
|
||||
// onNewImage on a cornerstone viewport is firing setDisplaySetsForViewport.
|
||||
// Which it really really shouldn't. We need a larger fix for jump to
|
||||
// measurements and all cornerstone "imageIndex" state to fix this.
|
||||
if (cachedLayout) {
|
||||
viewportGridService.set({
|
||||
numCols: cachedLayout.numCols,
|
||||
numRows: cachedLayout.numRows,
|
||||
activeViewportIndex: cachedLayout.activeViewportIndex,
|
||||
viewports: cachedLayout.viewports,
|
||||
cachedLayout: null,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedViewports = viewports.map(viewport => {
|
||||
return {
|
||||
displaySetInstanceUID: viewport.displaySetInstanceUID,
|
||||
};
|
||||
});
|
||||
|
||||
viewportGridService.set({
|
||||
numCols: 1,
|
||||
numRows: 1,
|
||||
activeViewportIndex: 0,
|
||||
viewports: [
|
||||
{
|
||||
displaySetInstanceUID: viewports[viewportIndex].displaySetInstanceUID,
|
||||
imageIndex: undefined,
|
||||
},
|
||||
],
|
||||
cachedLayout: {
|
||||
numCols,
|
||||
numRows,
|
||||
viewports: cachedViewports,
|
||||
activeViewportIndex: viewportIndex,
|
||||
},
|
||||
});
|
||||
};
|
||||
*/
|
||||
|
||||
const onDropHandler = (viewportIndex, { displaySetInstanceUID }) => {
|
||||
const updatedViewports = _getUpdatedViewports(
|
||||
viewportIndex,
|
||||
displaySetInstanceUID
|
||||
);
|
||||
viewportGridService.setDisplaySetsForViewports(updatedViewports);
|
||||
};
|
||||
|
||||
const getViewportPanes = useCallback(() => {
|
||||
const viewportPanes = [];
|
||||
|
||||
const numViewportPanes = viewportGridService.getNumViewportPanes();
|
||||
for (let i = 0; i < numViewportPanes; i++) {
|
||||
const viewportIndex = i;
|
||||
const isActive = activeViewportIndex === viewportIndex;
|
||||
const paneMetadata = viewports[i] || {};
|
||||
const viewportId = paneMetadata.viewportId || `viewport-${i}`;
|
||||
if (!paneMetadata.viewportId) {
|
||||
paneMetadata.viewportId = viewportId;
|
||||
}
|
||||
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 displaySetInstanceUIDsToUse = displaySetInstanceUIDs || [];
|
||||
|
||||
// This is causing the viewport components re-render when the activeViewportIndex changes
|
||||
const displaySets = displaySetInstanceUIDsToUse.map(
|
||||
displaySetInstanceUID => {
|
||||
return (
|
||||
displaySetService.getDisplaySetByUID(displaySetInstanceUID) || {}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
viewportGridService.setActiveViewportIndex(viewportIndex);
|
||||
};
|
||||
|
||||
// TEMP -> Double click disabled for now
|
||||
// onDoubleClick={() => onDoubleClick(viewportIndex)}
|
||||
|
||||
viewportPanes[i] = (
|
||||
<ViewportPane
|
||||
key={viewportId}
|
||||
acceptDropsFor="displayset"
|
||||
onDrop={onDropHandler.bind(null, viewportIndex)}
|
||||
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={classNames('h-full w-full flex flex-col', {
|
||||
'pointer-events-none': !isActive,
|
||||
})}
|
||||
>
|
||||
<ViewportComponent
|
||||
displaySets={displaySets}
|
||||
viewportIndex={viewportIndex}
|
||||
viewportLabel={viewports.length > 1 ? viewportLabel : ''}
|
||||
dataSource={dataSource}
|
||||
viewportOptions={viewportOptions}
|
||||
displaySetOptions={displaySetOptions}
|
||||
needsRerendering={displaySetsNeedsRerendering}
|
||||
/>
|
||||
</div>
|
||||
</ViewportPane>
|
||||
);
|
||||
}
|
||||
|
||||
return viewportPanes;
|
||||
}, [viewports, activeViewportIndex, viewportComponents, dataSource]);
|
||||
|
||||
/**
|
||||
* Loading indicator until numCols and numRows are gotten from the HangingProtocolService
|
||||
*/
|
||||
if (!numRows || !numCols) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewportGrid numRows={numRows} numCols={numCols}>
|
||||
{/* {ViewportPanes} */}
|
||||
{getViewportPanes()}
|
||||
</ViewportGrid>
|
||||
);
|
||||
}
|
||||
|
||||
ViewerViewportGrid.propTypes = {
|
||||
viewportComponents: PropTypes.array.isRequired,
|
||||
servicesManager: PropTypes.instanceOf(ServicesManager),
|
||||
};
|
||||
|
||||
ViewerViewportGrid.defaultProps = {
|
||||
viewportComponents: [],
|
||||
};
|
||||
|
||||
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 SOPClassId of ${displaySets[0].SOPClassUID} yet`,
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return EmptyViewport;
|
||||
}
|
||||
|
||||
export default ViewerViewportGrid;
|
||||
@@ -0,0 +1,5 @@
|
||||
import useDebounce from './useDebounce.js';
|
||||
import useQuery from './useQuery.js';
|
||||
import useSearchParams from './useSearchParams.js';
|
||||
|
||||
export { useDebounce, useQuery, useSearchParams };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export default function useQuery() {
|
||||
return new URLSearchParams(useLocation().search);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import useQuery from './useQuery';
|
||||
|
||||
/**
|
||||
* It returns a Map of the query parameters in the URL, where the keys are
|
||||
* lowercase
|
||||
* @returns A function that returns a Map of the query parameters.
|
||||
*/
|
||||
export default function useSearchParams() {
|
||||
const query = useQuery();
|
||||
// make query params case-insensitive
|
||||
const searchParams = new Map();
|
||||
for (const [key, value] of query) {
|
||||
searchParams.set(key.toLowerCase(), value);
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Entry point for development and production PWA builds.
|
||||
*/
|
||||
import 'regenerator-runtime/runtime';
|
||||
import App from './App';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { history } from './utils/history';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/** Create App */
|
||||
const app = React.createElement(App, appProps, null);
|
||||
/** Render */
|
||||
ReactDOM.render(app, document.getElementById('root'));
|
||||
});
|
||||
|
||||
export { history };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function CallbackPage({ userManager, onRedirectSuccess }) {
|
||||
const onRedirectError = error => {
|
||||
throw new Error(error);
|
||||
};
|
||||
|
||||
userManager
|
||||
.signinRedirectCallback()
|
||||
.then(user => onRedirectSuccess(user))
|
||||
.catch(error => onRedirectError(error));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
CallbackPage.propTypes = {
|
||||
userManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default CallbackPage;
|
||||
@@ -0,0 +1,198 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MODULE_TYPES } from '@ohif/core';
|
||||
//
|
||||
import { extensionManager } from '../App.tsx';
|
||||
import { useParams, useLocation } from 'react-router';
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const { children: LayoutTemplate, ...rest } = props;
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
|
||||
// TODO - get the variable from the props all the time...
|
||||
let dataSourceName = new URLSearchParams(location.search).get('datasources');
|
||||
const dataPath = dataSourceName ? `/${dataSourceName}` : '';
|
||||
|
||||
if (!dataSourceName && window.config.defaultDataSourceName) {
|
||||
dataSourceName = window.config.defaultDataSourceName;
|
||||
} else 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);
|
||||
}
|
||||
const dataSource = extensionManager.getDataSources(dataSourceName)?.[0];
|
||||
if (!dataSource) {
|
||||
throw new Error(`No data source found for ${dataSourceName}`);
|
||||
}
|
||||
|
||||
// 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 [data, setData] = useState(DEFAULT_DATA);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const queryFilterValues = _getQueryFilterValues(
|
||||
location.search,
|
||||
STUDIES_LIMIT
|
||||
);
|
||||
|
||||
// 204: no content
|
||||
async function getData() {
|
||||
setIsLoading(true);
|
||||
const studies = await dataSource.query.studies.search(queryFilterValues);
|
||||
|
||||
setData({
|
||||
studies: studies || [],
|
||||
total: studies.length,
|
||||
resultsPerPage: queryFilterValues.resultsPerPage,
|
||||
pageNumber: queryFilterValues.pageNumber,
|
||||
location,
|
||||
});
|
||||
|
||||
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);
|
||||
const isLocationUpdated = data.location !== location;
|
||||
const isDataInvalid =
|
||||
!isSamePage ||
|
||||
(!isLoading && (newOffset !== previousOffset || isLocationUpdated));
|
||||
|
||||
if (isDataInvalid) {
|
||||
getData();
|
||||
}
|
||||
} catch (ex) {
|
||||
console.warn(ex);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, location, params, isLoading, setIsLoading]);
|
||||
// queryFilterValues
|
||||
|
||||
// TODO: Better way to pass DataSource?
|
||||
return (
|
||||
<LayoutTemplate
|
||||
{...rest}
|
||||
data={data.studies}
|
||||
dataPath={dataPath}
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@ohif/ui';
|
||||
|
||||
// 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="h-screen w-screen flex justify-center items-center ">
|
||||
<div className="py-8 px-8 mx-auto bg-secondary-dark drop-shadow-md space-y-2 rounded-lg">
|
||||
<img
|
||||
className="block mx-auto h-14"
|
||||
src="./ohif-logo.svg"
|
||||
alt="OHIF"
|
||||
/>
|
||||
<div className="text-center space-y-2 pt-4">
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<p className="text-xl text-primary-active font-semibold mt-4">
|
||||
Debug Information
|
||||
</p>
|
||||
<div className="flex space-x-2 mt-4 items-center">
|
||||
<p className="text-md text-white">
|
||||
Cross Origin Isolated (COOP/COEP)
|
||||
</p>
|
||||
<Icon
|
||||
name={
|
||||
window.crossOriginIsolated
|
||||
? 'notifications-success'
|
||||
: 'notifications-error'
|
||||
}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
{!window.crossOriginIsolated && (
|
||||
<div className="text-md text-white flex-1">
|
||||
We use SharedArrayBuffer to render volume data (e.g., MPR).
|
||||
If you are seeing this message, it means that your browser
|
||||
has not enabled COOP/COEP. Please see the following link for
|
||||
more information:{' '}
|
||||
<a
|
||||
href="https://web.dev/coop-coep/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-active"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Debug;
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DicomMetadataStore, MODULE_TYPES } from '@ohif/core';
|
||||
|
||||
import Dropzone from 'react-dropzone';
|
||||
import filesToStudies from './filesToStudies';
|
||||
|
||||
import { extensionManager } from '../../App.tsx';
|
||||
|
||||
import { Icon, Button, LoadingIndicatorProgress } from '@ohif/ui';
|
||||
|
||||
const getLoadButton = (onDrop, text, isDir) => {
|
||||
return (
|
||||
<Dropzone onDrop={onDrop} noDrag>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<Button
|
||||
rounded="full"
|
||||
variant="contained" // outlined
|
||||
disabled={false}
|
||||
endIcon={<Icon name="launch-arrow" />} // launch-arrow | launch-info
|
||||
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 navigate = useNavigate();
|
||||
const dropzoneRef = useRef();
|
||||
const [dropInitiated, setDropInitiated] = React.useState(false);
|
||||
|
||||
// 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="h-screen w-screen flex justify-center items-center ">
|
||||
<div className="py-8 px-8 mx-auto bg-secondary-dark drop-shadow-md space-y-2 rounded-lg">
|
||||
<img
|
||||
className="block mx-auto h-14"
|
||||
src="./ohif-logo.svg"
|
||||
alt="OHIF"
|
||||
/>
|
||||
<div className="text-center space-y-2 pt-4">
|
||||
{dropInitiated ? (
|
||||
<div className="flex flex-col items-center justify-center pt-48">
|
||||
<LoadingIndicatorProgress
|
||||
className={'w-full h-full bg-black'}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-blue-300 text-base">
|
||||
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 font-semibold pt-6">
|
||||
Drag and Drop DICOM files here to load them in the Viewer
|
||||
</p>
|
||||
<p className="text-blue-300 text-lg">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;
|
||||
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
@@ -0,0 +1,6 @@
|
||||
export default class FileLoader {
|
||||
fileType;
|
||||
loadFile(file, imageId) { }
|
||||
getDataset(image, imageId) { }
|
||||
getStudies(dataset, imageId) { }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,26 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Local';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,463 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router';
|
||||
import PropTypes from 'prop-types';
|
||||
// TODO: DicomMetadataStore should be injected?
|
||||
import { DicomMetadataStore, ServicesManager, utils } from '@ohif/core';
|
||||
import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui';
|
||||
import { useQuery, useSearchParams } from '@hooks';
|
||||
import ViewportGrid from '@components/ViewportGrid';
|
||||
import Compose from './Compose';
|
||||
import getStudies from './studiesList';
|
||||
import { history } from '../../utils/history';
|
||||
import loadModules from '../../pluginImports';
|
||||
|
||||
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
|
||||
*/
|
||||
function defaultRouteInit(
|
||||
{ servicesManager, studyInstanceUIDs, dataSource, filters },
|
||||
hangingProtocolId
|
||||
) {
|
||||
const {
|
||||
displaySetService,
|
||||
hangingProtocolService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const unsubscriptions = [];
|
||||
const {
|
||||
unsubscribe: instanceAddedUnsubscribe,
|
||||
} = DicomMetadataStore.subscribe(
|
||||
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
|
||||
function({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) {
|
||||
const seriesMetadata = DicomMetadataStore.getSeries(
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID
|
||||
);
|
||||
|
||||
displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient);
|
||||
}
|
||||
);
|
||||
|
||||
unsubscriptions.push(instanceAddedUnsubscribe);
|
||||
|
||||
const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID =>
|
||||
dataSource.retrieve.series.metadata({
|
||||
StudyInstanceUID,
|
||||
filters,
|
||||
})
|
||||
);
|
||||
|
||||
// The hanging protocol matching service is fairly expensive to run multiple
|
||||
// times, and doesn't allow partial matches to be made (it will simply fail
|
||||
// to display anything if a required match fails), so we wait here until all metadata
|
||||
// is retrieved (which will synchronously trigger the display set creation)
|
||||
// until we run the hanging protocol matching service.
|
||||
|
||||
Promise.allSettled(allRetrieves).then(() => {
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
return unsubscriptions;
|
||||
}
|
||||
|
||||
export default function ModeRoute({
|
||||
mode,
|
||||
dataSourceName,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
}) {
|
||||
// Parse route params/querystring
|
||||
const location = useLocation();
|
||||
const query = useQuery();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [studyInstanceUIDs, setStudyInstanceUIDs] = useState();
|
||||
|
||||
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,
|
||||
hangingProtocolService,
|
||||
userAuthenticationService,
|
||||
} = (servicesManager as ServicesManager).services;
|
||||
|
||||
const {
|
||||
extensions,
|
||||
sopClassHandlers,
|
||||
hotkeys: hotkeyObj,
|
||||
hangingProtocol,
|
||||
} = mode;
|
||||
|
||||
const runTimeHangingProtocolId = searchParams.get('hangingprotocolid');
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (token) {
|
||||
// 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 + 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the old array interface for hotkeys
|
||||
const hotkeys = Array.isArray(hotkeyObj) ? hotkeyObj : hotkeyObj?.hotkeys;
|
||||
const hotkeyName = hotkeyObj?.name || 'hotkey-definitions-v2';
|
||||
|
||||
if (dataSourceName === undefined) {
|
||||
dataSourceName = extensionManager.defaultDataSourceName;
|
||||
}
|
||||
|
||||
extensionManager.setActiveDataSource(dataSourceName);
|
||||
|
||||
const dataSource = extensionManager.getActiveDataSource()[0];
|
||||
|
||||
// Only handling one route per mode for now
|
||||
const route = mode.routes[0];
|
||||
|
||||
// For each extension, look up their context modules
|
||||
// TODO: move to extension manager.
|
||||
let contextModules = [];
|
||||
|
||||
Object.keys(extensions).forEach(extensionId => {
|
||||
const allRegisteredModuleIds = Object.keys(extensionManager.modulesMap);
|
||||
const moduleIds = allRegisteredModuleIds.filter(id =>
|
||||
id.includes(`${extensionId}.contextModule.`)
|
||||
);
|
||||
|
||||
if (!moduleIds || !moduleIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modules = moduleIds.map(extensionManager.getModuleEntry);
|
||||
contextModules = contextModules.concat(modules);
|
||||
});
|
||||
|
||||
const contextModuleProviders = contextModules.map(a => a.provider);
|
||||
const CombinedContextProvider = ({ children }) =>
|
||||
Compose({ components: contextModuleProviders, children });
|
||||
|
||||
function ViewportGridWithDataSource(props) {
|
||||
return ViewportGrid({ ...props, dataSource });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
const studyInstanceUIDs = await dataSource.initialize({
|
||||
params,
|
||||
query,
|
||||
});
|
||||
setStudyInstanceUIDs(studyInstanceUIDs);
|
||||
};
|
||||
|
||||
initializeDataSource(params, query);
|
||||
return () => {
|
||||
layoutTemplateData.current = null;
|
||||
};
|
||||
}, [location, ExtensionDependenciesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ExtensionDependenciesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retrieveLayoutData = async () => {
|
||||
const layoutData = await route.layoutTemplate({
|
||||
location,
|
||||
servicesManager,
|
||||
studyInstanceUIDs,
|
||||
});
|
||||
if (isMounted.current) {
|
||||
layoutTemplateData.current = layoutData;
|
||||
setRefresh(!refresh);
|
||||
}
|
||||
};
|
||||
if (studyInstanceUIDs?.length && studyInstanceUIDs[0] !== undefined) {
|
||||
retrieveLayoutData();
|
||||
}
|
||||
return () => {
|
||||
layoutTemplateData.current = null;
|
||||
};
|
||||
}, [studyInstanceUIDs, ExtensionDependenciesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hotkeys || !ExtensionDependenciesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
hotkeysManager.setDefaultHotKeys(hotkeys);
|
||||
|
||||
const userPreferredHotkeys = JSON.parse(localStorage.getItem(hotkeyName));
|
||||
|
||||
if (userPreferredHotkeys?.length) {
|
||||
hotkeysManager.setHotkeys(userPreferredHotkeys, hotkeyName);
|
||||
} else {
|
||||
hotkeysManager.setHotkeys(hotkeys, hotkeyName);
|
||||
}
|
||||
|
||||
return () => {
|
||||
hotkeysManager.destroy();
|
||||
};
|
||||
}, [ExtensionDependenciesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutTemplateData.current || !ExtensionDependenciesLoaded) {
|
||||
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,
|
||||
});
|
||||
|
||||
// use the URL hangingProtocolId if it exists, otherwise use the one
|
||||
// defined in the mode configuration
|
||||
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
|
||||
runTimeHangingProtocolId
|
||||
)
|
||||
? runTimeHangingProtocolId
|
||||
: hangingProtocol;
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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();
|
||||
if (lowerVal !== 'studyinstanceuids') {
|
||||
// Not sure why the case matters here - it doesn't in the URL
|
||||
if (lowerVal === 'seriesinstanceuid') {
|
||||
const seriesUIDs = getSplitParam(lowerVal, query);
|
||||
return {
|
||||
...acc,
|
||||
seriesInstanceUID: seriesUIDs,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...acc, [val]: getSplitParam(lowerVal, query) };
|
||||
}
|
||||
},
|
||||
{}
|
||||
) ?? {};
|
||||
|
||||
if (route.init) {
|
||||
return await route.init(
|
||||
{
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
hotkeysManager,
|
||||
studyInstanceUIDs,
|
||||
dataSource,
|
||||
filters,
|
||||
},
|
||||
hangingProtocolIdToUse
|
||||
);
|
||||
}
|
||||
|
||||
return defaultRouteInit(
|
||||
{
|
||||
servicesManager,
|
||||
studyInstanceUIDs,
|
||||
dataSource,
|
||||
filters,
|
||||
},
|
||||
hangingProtocolIdToUse
|
||||
);
|
||||
};
|
||||
|
||||
let unsubscriptions;
|
||||
setupRouteInit().then(unsubs => {
|
||||
unsubscriptions = unsubs;
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('mode exit failure', e);
|
||||
}
|
||||
// The unsubscriptions must occur before the extension onModeExit
|
||||
// in order to prevent exceptions during cleanup caused by spurious events
|
||||
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,
|
||||
]);
|
||||
|
||||
const renderLayoutData = props => {
|
||||
const layoutTemplateModuleEntry = extensionManager.getModuleEntry(
|
||||
layoutTemplateData.current.id
|
||||
);
|
||||
const LayoutComponent = layoutTemplateModuleEntry.component;
|
||||
|
||||
return <LayoutComponent {...props} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageViewerProvider
|
||||
// initialState={{ StudyInstanceUIDs: StudyInstanceUIDs }}
|
||||
StudyInstanceUIDs={studyInstanceUIDs}
|
||||
// reducer={reducer}
|
||||
>
|
||||
<CombinedContextProvider>
|
||||
<DragAndDropProvider>
|
||||
{layoutTemplateData.current &&
|
||||
studyInstanceUIDs?.[0] !== undefined &&
|
||||
ExtensionDependenciesLoaded &&
|
||||
renderLayoutData({
|
||||
...layoutTemplateData.current.props,
|
||||
ViewportGridComp: ViewportGridWithDataSource,
|
||||
})}
|
||||
</DragAndDropProvider>
|
||||
</CombinedContextProvider>
|
||||
</ImageViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ModeRoute.propTypes = {
|
||||
mode: PropTypes.object.isRequired,
|
||||
dataSourceName: PropTypes.string,
|
||||
extensionManager: PropTypes.object,
|
||||
servicesManager: PropTypes.object,
|
||||
hotkeysManager: PropTypes.object,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Mode';
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,33 @@
|
||||
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="w-full h-full flex justify-center items-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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './NotFound';
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useUserAuthentication } from '@ohif/ui';
|
||||
|
||||
export const PrivateRoute = ({ children, handleUnauthenticated }) => {
|
||||
const [{ user, enabled }] = useUserAuthentication();
|
||||
|
||||
if (enabled && !user) {
|
||||
return handleUnauthenticated();
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default PrivateRoute;
|
||||
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
@@ -0,0 +1,602 @@
|
||||
import React, { useState, useEffect } 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, ServicesManager } from '@ohif/core';
|
||||
|
||||
import {
|
||||
Icon,
|
||||
StudyListExpandedRow,
|
||||
Button,
|
||||
EmptyStudies,
|
||||
StudyListTable,
|
||||
StudyListPagination,
|
||||
StudyListFilter,
|
||||
TooltipClipboard,
|
||||
Header,
|
||||
useModal,
|
||||
AboutModal,
|
||||
UserPreferences,
|
||||
LoadingIndicatorProgress,
|
||||
} from '@ohif/ui';
|
||||
|
||||
import i18n from '@ohif/i18n';
|
||||
|
||||
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,
|
||||
}) {
|
||||
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 [filterValues, _setFilterValues] = useState({
|
||||
...defaultFilterValues,
|
||||
...queryFilterValues,
|
||||
});
|
||||
|
||||
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 = studies;
|
||||
|
||||
if (canSort) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// ~ Rows & Studies
|
||||
const [expandedRows, setExpandedRows] = useState([]);
|
||||
const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]);
|
||||
const numOfStudies = studiesTotal;
|
||||
|
||||
const setFilterValues = val => {
|
||||
if (filterValues.pageNumber === val.pageNumber) {
|
||||
val.pageNumber = 1;
|
||||
}
|
||||
_setFilterValues(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;
|
||||
}
|
||||
});
|
||||
|
||||
const search = qs.stringify(queryString, {
|
||||
skipNull: true,
|
||||
skipEmptyString: true,
|
||||
});
|
||||
|
||||
navigate({
|
||||
pathname: '/',
|
||||
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('MMM-DD-YYYY');
|
||||
const studyTime =
|
||||
time &&
|
||||
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).isValid() &&
|
||||
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).format('hh:mm A');
|
||||
|
||||
return {
|
||||
row: [
|
||||
{
|
||||
key: 'patientName',
|
||||
content: patientName ? (
|
||||
<TooltipClipboard>{patientName}</TooltipClipboard>
|
||||
) : (
|
||||
<span className="text-gray-700">(Empty)</span>
|
||||
),
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
key: 'mrn',
|
||||
content: <TooltipClipboard>{mrn}</TooltipClipboard>,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'studyDate',
|
||||
content: (
|
||||
<>
|
||||
{studyDate && <span className="mr-4">{studyDate}</span>}
|
||||
{studyTime && <span>{studyTime}</span>}
|
||||
</>
|
||||
),
|
||||
title: `${studyDate || ''} ${studyTime || ''}`,
|
||||
gridCol: 5,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
content: <TooltipClipboard>{description}</TooltipClipboard>,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
key: 'modality',
|
||||
content: modalities,
|
||||
title: modalities,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'accession',
|
||||
content: <TooltipClipboard>{accession}</TooltipClipboard>,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'instances',
|
||||
content: (
|
||||
<>
|
||||
<Icon
|
||||
name="group-layers"
|
||||
className={classnames('inline-flex mr-2 w-4', {
|
||||
'text-primary-active': isExpanded,
|
||||
'text-secondary-light': !isExpanded,
|
||||
})}
|
||||
/>
|
||||
{instances}
|
||||
</>
|
||||
),
|
||||
title: (instances || 0).toString(),
|
||||
gridCol: 4,
|
||||
},
|
||||
],
|
||||
// Todo: This is actually running for all rows, even if they are
|
||||
// not clicked on.
|
||||
expandedContent: (
|
||||
<StudyListExpandedRow
|
||||
seriesTableColumns={{
|
||||
description: 'Description',
|
||||
seriesNumber: 'Series',
|
||||
modality: 'Modality',
|
||||
instances: 'Instances',
|
||||
}}
|
||||
seriesTableDataSource={
|
||||
seriesInStudiesMap.has(studyInstanceUid)
|
||||
? seriesInStudiesMap.get(studyInstanceUid).map(s => {
|
||||
return {
|
||||
description: s.description || '(empty)',
|
||||
seriesNumber: s.seriesNumber ?? '',
|
||||
modality: s.modality || '',
|
||||
instances: s.numSeriesInstances || '',
|
||||
};
|
||||
})
|
||||
: []
|
||||
}
|
||||
>
|
||||
{appConfig.loadedModes.map((mode, i) => {
|
||||
const isFirst = i === 0;
|
||||
|
||||
const modalitiesToCheck = modalities.replaceAll('/', '\\');
|
||||
|
||||
const isValidMode = 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);
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={`${dataPath ? '../../' : ''}${mode.routeName}${dataPath ||
|
||||
''}?${query.toString()}`}
|
||||
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
|
||||
>
|
||||
<Button
|
||||
rounded="full"
|
||||
variant={isValidMode ? 'contained' : 'disabled'}
|
||||
disabled={!isValidMode}
|
||||
endIcon={<Icon name="launch-arrow" />} // launch-arrow | launch-info
|
||||
className={classnames('font-medium ', { 'ml-2': !isFirst })}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{t(`Modes:${mode.displayName}`)}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</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: 'About OHIF Viewer',
|
||||
contentProps: { versionNumber, commitHash },
|
||||
}),
|
||||
},
|
||||
{
|
||||
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 => {
|
||||
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 { component: dicomUploadComponent } =
|
||||
customizationService.get('dicomUploadComponent') ?? {};
|
||||
const uploadProps =
|
||||
dicomUploadComponent && dataSource.getConfig().dicomUploadEnabled
|
||||
? {
|
||||
title: 'Upload files',
|
||||
closeButton: true,
|
||||
shouldCloseOnEsc: false,
|
||||
shouldCloseOnOverlayClick: false,
|
||||
content: dicomUploadComponent.bind(null, {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="bg-black h-screen flex flex-col ">
|
||||
<Header
|
||||
isSticky
|
||||
menuOptions={menuOptions}
|
||||
isReturnEnabled={false}
|
||||
WhiteLabeling={appConfig.whiteLabeling}
|
||||
/>
|
||||
<div className="overflow-y-auto ohif-scrollbar flex flex-col grow">
|
||||
<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}
|
||||
/>
|
||||
{hasStudies ? (
|
||||
<div className="grow flex flex-col">
|
||||
<StudyListTable
|
||||
tableDataSource={tableDataSource.slice(offset, offsetAndTake)}
|
||||
numOfStudies={numOfStudies}
|
||||
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={'w-full h-full bg-black'} />
|
||||
) : (
|
||||
<EmptyStudies />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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.instanceOf(ServicesManager),
|
||||
};
|
||||
|
||||
const defaultFilterValues = {
|
||||
patientName: '',
|
||||
mrn: '',
|
||||
studyDate: {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
},
|
||||
description: '',
|
||||
modalities: [],
|
||||
accession: '',
|
||||
sortBy: '',
|
||||
sortDirection: 'none',
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 25,
|
||||
datasources: '',
|
||||
configUrl: null,
|
||||
};
|
||||
|
||||
function _tryParseInt(str, defaultValue) {
|
||||
let retValue = defaultValue;
|
||||
if (str && str.length > 0) {
|
||||
if (!isNaN(str)) {
|
||||
retValue = parseInt(str);
|
||||
}
|
||||
}
|
||||
return retValue;
|
||||
}
|
||||
|
||||
function _getQueryFilterValues(params) {
|
||||
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;
|
||||
@@ -0,0 +1,127 @@
|
||||
const filtersMeta = [
|
||||
{
|
||||
name: 'patientName',
|
||||
displayName: 'PatientName',
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
name: 'mrn',
|
||||
displayName: 'MRN',
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
name: 'studyDate',
|
||||
displayName: 'StudyDate',
|
||||
inputType: 'DateRange',
|
||||
isSortable: true,
|
||||
gridCol: 5,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
displayName: 'StudyDescription',
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
name: 'modalities',
|
||||
displayName: '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: 'AccessionNumber',
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
name: 'instances',
|
||||
displayName: 'Instances',
|
||||
inputType: 'None',
|
||||
isSortable: false,
|
||||
gridCol: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export default filtersMeta;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './WorkList';
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import ModeRoute from '@routes/Mode';
|
||||
|
||||
/*
|
||||
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,
|
||||
}) {
|
||||
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 = `/${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,
|
||||
});
|
||||
});
|
||||
|
||||
const defaultDataSourceName = extensionManager.defaultDataSourceName;
|
||||
|
||||
// Add default DataSource route.
|
||||
const path = `/${mode.routeName}`;
|
||||
|
||||
// TODO move up.
|
||||
const children = () => (
|
||||
<ModeRoute
|
||||
mode={mode}
|
||||
dataSourceName={defaultDataSourceName}
|
||||
extensionManager={extensionManager}
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
hotkeysManager={hotkeysManager}
|
||||
/>
|
||||
);
|
||||
|
||||
routes.push({
|
||||
path,
|
||||
children,
|
||||
private: true, // todo: all mode routes are private for now
|
||||
});
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { ErrorBoundary } from '@ohif/ui';
|
||||
|
||||
// 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';
|
||||
|
||||
// TODO: Include "routes" debug route if dev build
|
||||
const bakedInRoutes = [
|
||||
{
|
||||
path: '/debug',
|
||||
children: Debug,
|
||||
},
|
||||
{
|
||||
path: '/local',
|
||||
children: Local.bind(null, { modePath: '' }), // navigate to the worklist
|
||||
},
|
||||
{
|
||||
path: '/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,
|
||||
}) => {
|
||||
const routes =
|
||||
buildModeRoutes({
|
||||
modes,
|
||||
dataSources,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
}) || [];
|
||||
|
||||
const { customizationService } = servicesManager.services;
|
||||
|
||||
const WorkListRoute = {
|
||||
path: '/',
|
||||
children: DataSourceWrapper,
|
||||
private: true,
|
||||
props: { children: WorkList, servicesManager },
|
||||
};
|
||||
|
||||
const customRoutes = customizationService.getGlobalCustomization(
|
||||
'customRoutes'
|
||||
);
|
||||
const allRoutes = [
|
||||
...routes,
|
||||
...(showStudyList ? [WorkListRoute] : []),
|
||||
...(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}`} fallbackRoute="/">
|
||||
<route.children
|
||||
{...rest}
|
||||
{...route.props}
|
||||
route={route}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
hotkeysManager={hotkeysManager}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const { userAuthenticationService } = servicesManager.services;
|
||||
|
||||
// Note: PrivateRoutes in react-router-dom 6.x should be defined within
|
||||
// a Route element
|
||||
return (
|
||||
<Routes>
|
||||
{allRoutes.map((route, i) => {
|
||||
return route.private === true ? (
|
||||
<Route
|
||||
key={i}
|
||||
exact
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
describe('Sanity Test', () => {
|
||||
test('how many marbles?', () => {
|
||||
const expectedMarbles = 4;
|
||||
const actualMarbles = 2 + 2;
|
||||
|
||||
expect(actualMarbles).toEqual(expectedMarbles);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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?)
|
||||
@@ -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]}>{children}</Provider>;
|
||||
}
|
||||
|
||||
AppConfigProvider.propTypes = {
|
||||
children: PropTypes.any,
|
||||
value: PropTypes.any,
|
||||
};
|
||||
|
||||
export default AppConfigProvider;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { AppConfigProvider, useAppConfig } from './appConfig.tsx';
|
||||
|
||||
export { AppConfigProvider, useAppConfig };
|
||||
@@ -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 getUserManagerForOpenIdConnectClient from './getUserManagerForOpenIdConnectClient.js';
|
||||
|
||||
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
|
||||
),
|
||||
});
|
||||
|
||||
return getUserManagerForOpenIdConnectClient(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();
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${user.access_token}`,
|
||||
};
|
||||
};
|
||||
|
||||
const handleUnauthenticated = () => {
|
||||
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 redirect_uri = new URL(userManager.settings._redirect_uri).pathname; //.replace(routerBasename,'')
|
||||
const silent_refresh_uri = new URL(userManager.settings._silent_redirect_uri)
|
||||
.pathname; //.replace(routerBasename,'')
|
||||
const post_logout_redirect_uri = new URL(
|
||||
userManager.settings._post_logout_redirect_uri
|
||||
).pathname; //.replace(routerBasename,'');
|
||||
|
||||
// const pathnameRelative = pathname.replace(routerBasename,'');
|
||||
|
||||
if (pathname !== redirect_uri) {
|
||||
sessionStorage.setItem(
|
||||
'ohif-redirect-to',
|
||||
JSON.stringify({ pathname, search })
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes basename={routerBasename}>
|
||||
<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;
|
||||
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
loadUserInfo: true,
|
||||
};
|
||||
|
||||
const userManager = new UserManager(settings);
|
||||
|
||||
return userManager;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NavigateFunction } from 'react-router';
|
||||
|
||||
type History = {
|
||||
navigate: NavigateFunction;
|
||||
};
|
||||
|
||||
export const history: History = {
|
||||
navigate: null,
|
||||
};
|
||||
Reference in new issue
Block a user