init: sudah ganti logo, hilangin setting, dan investigational use dialog

This commit is contained in:
one
2025-03-06 11:32:45 +07:00
commit 8f31d4ed41
2857 changed files with 355646 additions and 0 deletions

197
platform/app/src/App.tsx Normal file
View File

@@ -0,0 +1,197 @@
// External
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import i18n from '@ohif/i18n';
import { I18nextProvider } from 'react-i18next';
import { BrowserRouter } from 'react-router-dom';
import Compose from './routes/Mode/Compose';
import {
ExtensionManager,
CommandsManager,
HotkeysManager,
ServiceProvidersManager,
SystemContextProvider,
} from '@ohif/core';
import {
DialogProvider,
Modal,
ModalProvider,
ThemeWrapper,
ViewportDialogProvider,
CineProvider,
UserAuthenticationProvider,
} from '@ohif/ui';
import {
ThemeWrapper as ThemeWrapperNext,
NotificationProvider,
ViewportGridProvider,
TooltipProvider,
ToolboxProvider,
} from '@ohif/ui-next';
// Viewer Project
// TODO: Should this influence study list?
import { AppConfigProvider } from '@state';
import createRoutes from './routes';
import appInit from './appInit.js';
import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes';
import { ShepherdJourneyProvider } from 'react-shepherd';
let commandsManager: CommandsManager,
extensionManager: ExtensionManager,
servicesManager: AppTypes.ServicesManager,
serviceProvidersManager: ServiceProvidersManager,
hotkeysManager: HotkeysManager;
function App({
config = {
/**
* Relative route from domain root that OHIF instance is installed at.
* For example:
*
* Hosted at: https://ohif.org/where-i-host-the/viewer/
* Value: `/where-i-host-the/viewer/`
* */
routerBaseName: '/',
/**
*
*/
showLoadingIndicator: true,
showStudyList: true,
oidc: [],
extensions: [],
},
defaultExtensions = [],
defaultModes = [],
}) {
const [init, setInit] = useState(null);
useEffect(() => {
const run = async () => {
appInit(config, defaultExtensions, defaultModes).then(setInit).catch(console.error);
};
run();
}, []);
if (!init) {
return null;
}
// Set above for named export
commandsManager = init.commandsManager;
extensionManager = init.extensionManager;
servicesManager = init.servicesManager;
serviceProvidersManager = init.serviceProvidersManager;
hotkeysManager = init.hotkeysManager;
// Set appConfig
const appConfigState = init.appConfig;
const { routerBasename, modes, dataSources, oidc, showStudyList } = appConfigState;
// get the maximum 3D texture size
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2');
if (gl) {
const max3DTextureSize = gl.getParameter(gl.MAX_3D_TEXTURE_SIZE);
appConfigState.max3DTextureSize = max3DTextureSize;
}
const {
uiDialogService,
uiModalService,
uiViewportDialogService,
viewportGridService,
cineService,
userAuthenticationService,
uiNotificationService,
customizationService,
} = servicesManager.services;
const providers = [
[AppConfigProvider, { value: appConfigState }],
[UserAuthenticationProvider, { service: userAuthenticationService }],
[I18nextProvider, { i18n }],
[ThemeWrapperNext],
[ThemeWrapper],
[SystemContextProvider, { commandsManager, extensionManager, hotkeysManager, servicesManager }],
[ToolboxProvider],
[ViewportGridProvider, { service: viewportGridService }],
[ViewportDialogProvider, { service: uiViewportDialogService }],
[CineProvider, { service: cineService }],
[NotificationProvider, { service: uiNotificationService }],
[TooltipProvider],
[DialogProvider, { service: uiDialogService }],
[ModalProvider, { service: uiModalService, modal: Modal }],
[ShepherdJourneyProvider],
];
// Loop through and register each of the service providers registered with the ServiceProvidersManager.
const providersFromManager = Object.entries(serviceProvidersManager.providers);
if (providersFromManager.length > 0) {
providersFromManager.forEach(([serviceName, provider]) => {
providers.push([provider, { service: servicesManager.services[serviceName] }]);
});
}
const CombinedProviders = ({ children }) => Compose({ components: providers, children });
let authRoutes = null;
// Should there be a generic call to init on the extension manager?
customizationService.init(extensionManager);
// Use config to create routes
const appRoutes = createRoutes({
modes,
dataSources,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
routerBasename,
showStudyList,
});
if (oidc) {
authRoutes = (
<OpenIdConnectRoutes
oidc={oidc}
routerBasename={routerBasename}
userAuthenticationService={userAuthenticationService}
/>
);
}
return (
<CombinedProviders>
<BrowserRouter basename={routerBasename}>
{authRoutes}
{appRoutes}
</BrowserRouter>
</CombinedProviders>
);
}
App.propTypes = {
config: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({
routerBasename: PropTypes.string.isRequired,
oidc: PropTypes.array,
whiteLabeling: PropTypes.object,
extensions: PropTypes.array,
}),
]).isRequired,
/* Extensions that are "bundled" or "baked-in" to the application.
* These would be provided at build time as part of they entry point. */
defaultExtensions: PropTypes.array,
/* Modes that are "bundled" or "baked-in" to the application.
* These would be provided at build time as part of they entry point. */
defaultModes: PropTypes.array,
};
export default App;
export { commandsManager, extensionManager, servicesManager };

View File

@@ -0,0 +1,3 @@
// https://jestjs.io/docs/en/webpack#handling-static-assets
module.exports = 'test-file-stub';

View File

@@ -0,0 +1,11 @@
const _ = require('lodash');
const originalConsoleError = console.error;
// JSDom's CSS Parser has limited support for certain features
// This suppresses error warnings caused by it
console.error = function (msg) {
if (_.startsWith(msg, 'Error: Could not parse CSS stylesheet')) {
return;
}
originalConsoleError(msg);
};

147
platform/app/src/appInit.js Normal file
View File

@@ -0,0 +1,147 @@
import {
CommandsManager,
ExtensionManager,
ServicesManager,
ServiceProvidersManager,
HotkeysManager,
UINotificationService,
UIModalService,
UIDialogService,
UIViewportDialogService,
MeasurementService,
DisplaySetService,
ToolbarService,
ViewportGridService,
HangingProtocolService,
CineService,
UserAuthenticationService,
errorHandler,
CustomizationService,
PanelService,
WorkflowStepsService,
StudyPrefetcherService,
MultiMonitorService,
// utils,
} from '@ohif/core';
import loadModules, { loadModule as peerImport } from './pluginImports';
/**
* @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration
* @param {object[]} defaultExtensions - array of extension objects
*/
async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
const commandsManagerConfig = {
getAppState: () => {},
};
const commandsManager = new CommandsManager(commandsManagerConfig);
const servicesManager = new ServicesManager(commandsManager);
const serviceProvidersManager = new ServiceProvidersManager();
const hotkeysManager = new HotkeysManager(commandsManager, servicesManager);
const appConfig = {
...(typeof appConfigOrFunc === 'function'
? await appConfigOrFunc({ servicesManager, peerImport })
: appConfigOrFunc),
};
// Default the peer import function
appConfig.peerImport ||= peerImport;
const extensionManager = new ExtensionManager({
commandsManager,
servicesManager,
serviceProvidersManager,
hotkeysManager,
appConfig,
});
servicesManager.setExtensionManager(extensionManager);
servicesManager.registerServices([
[MultiMonitorService.REGISTRATION, appConfig.multimonitor],
UINotificationService.REGISTRATION,
UIModalService.REGISTRATION,
UIDialogService.REGISTRATION,
UIViewportDialogService.REGISTRATION,
MeasurementService.REGISTRATION,
DisplaySetService.REGISTRATION,
[CustomizationService.REGISTRATION, appConfig.customizationService],
ToolbarService.REGISTRATION,
ViewportGridService.REGISTRATION,
HangingProtocolService.REGISTRATION,
CineService.REGISTRATION,
UserAuthenticationService.REGISTRATION,
PanelService.REGISTRATION,
WorkflowStepsService.REGISTRATION,
[StudyPrefetcherService.REGISTRATION, appConfig.studyPrefetcher],
]);
errorHandler.getHTTPErrorHandler = () => {
if (typeof appConfig.httpErrorHandler === 'function') {
return appConfig.httpErrorHandler;
}
};
/**
* Example: [ext1, ext2, ext3]
* Example2: [[ext1, config], ext2, [ext3, config]]
*/
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);
// TODO: We no longer use `utils.addServer`
// TODO: We no longer init webWorkers at app level
// TODO: We no longer init the user Manager
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
const loadedModes = await loadModules([...(appConfig.modes || []), ...defaultModes]);
// This is the name for the loaded instance object
appConfig.loadedModes = [];
const modesById = new Set();
for (let i = 0; i < loadedModes.length; i++) {
let mode = loadedModes[i];
if (!mode) {
continue;
}
const { id } = mode;
if (mode.modeFactory) {
// If the appConfig contains configuration for this mode, use it.
const modeConfiguration =
appConfig.modesConfiguration && appConfig.modesConfiguration[id]
? appConfig.modesConfiguration[id]
: {};
mode = await mode.modeFactory({ modeConfiguration, loadModules });
}
if (modesById.has(id)) {
continue;
}
// Prevent duplication
modesById.add(id);
if (!mode || typeof mode !== 'object') {
continue;
}
appConfig.loadedModes.push(mode);
}
// Hack alert - don't touch the original modes definition,
// but there are still dependencies on having the appConfig modes defined
appConfig.modes = appConfig.loadedModes;
return {
appConfig,
commandsManager,
extensionManager,
servicesManager,
serviceProvidersManager,
hotkeysManager,
};
}
export default appInit;

View File

@@ -0,0 +1,7 @@
import React from 'react';
function EmptyViewport() {
return <div></div>;
}
export default EmptyViewport;

View File

@@ -0,0 +1,389 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import { Types, MeasurementService } from '@ohif/core';
import { ViewportGrid, ViewportPane } from '@ohif/ui';
import { useViewportGrid } from '@ohif/ui-next';
import EmptyViewport from './EmptyViewport';
import classNames from 'classnames';
import { useAppConfig } from '@state';
function ViewerViewportGrid(props: withAppTypes) {
const { servicesManager, viewportComponents = [], dataSource } = props;
const [viewportGrid, viewportGridService] = useViewportGrid();
const [appConfig] = useAppConfig();
const { layout, activeViewportId, viewports, isHangingProtocolLayout } = viewportGrid;
const { numCols, numRows } = layout;
const { ref: resizeRef } = useResizeDetector({
refreshMode: 'debounce',
refreshRate: 7,
refreshOptions: { leading: true },
onResize: () => {
viewportGridService.setViewportGridSizeChanged();
},
});
const layoutHash = useRef(null);
const {
displaySetService,
measurementService,
hangingProtocolService,
uiNotificationService,
customizationService,
} = servicesManager.services;
const generateLayoutHash = () => `${numCols}-${numRows}`;
/**
* This callback runs after the viewports structure has changed in any way.
* On initial display, that means if it has changed by applying a HangingProtocol,
* while subsequently it may mean by changing the stage or by manually adjusting
* the layout.
*/
const updateDisplaySetsFromProtocol = (
protocol: Types.HangingProtocol.Protocol,
stage,
activeStudyUID,
viewportMatchDetails
) => {
const availableDisplaySets = displaySetService.getActiveDisplaySets();
if (!availableDisplaySets.length) {
console.log('No available display sets', availableDisplaySets);
return;
}
// Match each viewport individually
const { layoutType } = stage.viewportStructure;
const stageProps = stage.viewportStructure.properties;
const { columns: numCols, rows: numRows, layoutOptions = [] } = stageProps;
/**
* This find or create viewport uses the hanging protocol results to
* specify the viewport match details, which specifies the size and
* setup of the various viewports.
*/
const findOrCreateViewport = pos => {
const viewportId = Array.from(viewportMatchDetails.keys())[pos];
const details = viewportMatchDetails.get(viewportId);
if (!details) {
console.log('No match details for viewport', viewportId);
return;
}
const { displaySetsInfo, viewportOptions } = details;
const displaySetUIDsToHang = [];
const displaySetUIDsToHangOptions = [];
displaySetsInfo.forEach(({ displaySetInstanceUID, displaySetOptions }) => {
if (displaySetInstanceUID) {
displaySetUIDsToHang.push(displaySetInstanceUID);
}
displaySetUIDsToHangOptions.push(displaySetOptions);
});
const computedViewportOptions = hangingProtocolService.getComputedOptions(
viewportOptions,
displaySetUIDsToHang
);
const computedDisplaySetOptions = hangingProtocolService.getComputedOptions(
displaySetUIDsToHangOptions,
displaySetUIDsToHang
);
return {
displaySetInstanceUIDs: displaySetUIDsToHang,
displaySetOptions: computedDisplaySetOptions,
viewportOptions: computedViewportOptions,
};
};
viewportGridService.setLayout({
numRows,
numCols,
layoutType,
layoutOptions,
findOrCreateViewport,
isHangingProtocolLayout: true,
});
};
const _getUpdatedViewports = useCallback(
(viewportId, displaySetInstanceUID) => {
if (!displaySetInstanceUID) {
return [];
}
let updatedViewports = [];
try {
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
viewportId,
displaySetInstanceUID,
isHangingProtocolLayout
);
} catch (error) {
console.warn(error);
uiNotificationService.show({
title: 'Drag and Drop',
message:
'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
type: 'error',
duration: 3000,
});
}
return updatedViewports;
},
[hangingProtocolService, uiNotificationService, isHangingProtocolLayout]
);
// Using Hanging protocol engine to match the displaySets
useEffect(() => {
const { unsubscribe } = hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
({ protocol, stage, activeStudyUID, viewportMatchDetails }) => {
updateDisplaySetsFromProtocol(protocol, stage, activeStudyUID, viewportMatchDetails);
}
);
return () => {
unsubscribe();
};
}, []);
// Check viewport readiness in useEffect
useEffect(() => {
const allReady = viewportGridService.getGridViewportsReady();
const sameLayoutHash = layoutHash.current === generateLayoutHash();
if (allReady && !sameLayoutHash) {
layoutHash.current = generateLayoutHash();
viewportGridService.publishViewportsReady();
}
}, [viewportGridService, generateLayoutHash]);
useEffect(() => {
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
({ viewportId, measurement, isConsumed }) => {
if (isConsumed) {
return;
}
// This occurs when no viewport has elected to consume the event
// so we need to change layouts into a layout which can consume
// the event.
const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement;
const updatedViewports = _getUpdatedViewports(viewportId, referencedDisplaySetInstanceUID);
if (!updatedViewports[0]) {
console.warn(
'ViewportGrid::Unable to navigate to viewport containing',
referencedDisplaySetInstanceUID
);
return;
}
// Arbitrarily assign the viewport to element 0
// TODO - this should perform a search to find the most suitable viewport.
updatedViewports[0] = { ...updatedViewports[0] };
const [viewport] = updatedViewports;
// Copy the viewport options to prevent modifying the internal data
viewport.viewportOptions = {
...viewport.viewportOptions,
orientation: 'acquisition',
// The preferred way to jump to the measurement view is to set the
// view reference, as this can hold information such as the orientation
// or zoom level required to display an annotation. The metadata attribute
// of the measurement is a viewReference, so use it to show the measurement.
// Longer term this should clear the view reference data
viewReference: measurement.metadata,
viewportType: measurement.metadata.volumeId ? 'volume' : null,
};
viewportGridService.setDisplaySetsForViewports(updatedViewports);
}
);
return () => {
unsubscribe();
};
}, [viewports]);
const onDropHandler = (viewportId, { displaySetInstanceUID }) => {
const customOnDropHandler = customizationService.getCustomization('customOnDropHandler');
const dropHandlerPromise = customOnDropHandler({
...props,
viewportId,
displaySetInstanceUID,
appConfig,
});
dropHandlerPromise.then(({ handled }) => {
if (!handled) {
const updatedViewports = _getUpdatedViewports(viewportId, displaySetInstanceUID);
viewportGridService.setDisplaySetsForViewports(updatedViewports);
}
});
};
const getViewportPanes = useCallback(() => {
const viewportPanes = [];
const numViewportPanes = viewportGridService.getNumViewportPanes();
for (let i = 0; i < numViewportPanes; i++) {
const paneMetadata = Array.from(viewports.values())[i] || {};
const {
displaySetInstanceUIDs,
viewportOptions,
displaySetOptions, // array of options for each display set in the viewport
x: viewportX,
y: viewportY,
width: viewportWidth,
height: viewportHeight,
viewportLabel,
} = paneMetadata;
const viewportId = viewportOptions.viewportId;
const isActive = activeViewportId === viewportId;
const displaySetInstanceUIDsToUse = displaySetInstanceUIDs || [];
// This is causing the viewport components re-render when the activeViewportId changes
const displaySets = displaySetInstanceUIDsToUse
.map(displaySetInstanceUID => {
return displaySetService.getDisplaySetByUID(displaySetInstanceUID) || {};
})
.filter(displaySet => {
return !displaySet?.unsupported;
});
const ViewportComponent = _getViewportComponent(
displaySets,
viewportComponents,
uiNotificationService
);
// look inside displaySets to see if they need reRendering
const displaySetsNeedsRerendering = displaySets.some(displaySet => {
return displaySet.needsRerendering;
});
const onInteractionHandler = event => {
if (isActive) {
return;
}
if (event && (appConfig?.activateViewportBeforeInteraction ?? true)) {
event.preventDefault();
event.stopPropagation();
}
viewportGridService.setActiveViewportId(viewportId);
};
viewportPanes[i] = (
<ViewportPane
// Note: It is highly important that the key is the viewportId here,
// since it is used to determine if the component should be re-rendered
// by React, and also in the hanging protocol and stage changes if the
// same viewportId is used, React, by default, will only move (not re-render)
// those components. For instance, if we have a 2x3 layout, and we move
// from 2x3 to 1x1 (second viewport), if the key is the viewportIndex,
// React will RE-RENDER the resulting viewport as the key will be different.
// however, if the key is the viewportId, React will only move the component
// and not re-render it.
key={viewportId}
acceptDropsFor="displayset"
onDrop={onDropHandler.bind(null, viewportId)}
onInteraction={onInteractionHandler}
customStyle={{
position: 'absolute',
top: viewportY * 100 + 0.2 + '%',
left: viewportX * 100 + 0.2 + '%',
width: viewportWidth * 100 - 0.3 + '%',
height: viewportHeight * 100 - 0.3 + '%',
}}
isActive={isActive}
>
<div
data-cy="viewport-pane"
className="flex h-full w-full min-w-[5px] flex-col"
>
<ViewportComponent
displaySets={displaySets}
viewportLabel={viewports.size > 1 ? viewportLabel : ''}
viewportId={viewportId}
dataSource={dataSource}
viewportOptions={viewportOptions}
displaySetOptions={displaySetOptions}
needsRerendering={displaySetsNeedsRerendering}
isHangingProtocolLayout={isHangingProtocolLayout}
onElementEnabled={() => {
viewportGridService.setViewportIsReady(viewportId, true);
}}
/>
</div>
</ViewportPane>
);
}
return viewportPanes;
}, [viewports, activeViewportId, viewportComponents, dataSource]);
/**
* Loading indicator until numCols and numRows are gotten from the HangingProtocolService
*/
if (!numRows || !numCols) {
return null;
}
return (
<div
ref={resizeRef}
className="h-full w-full"
>
<ViewportGrid
numRows={numRows}
numCols={numCols}
>
{getViewportPanes()}
</ViewportGrid>
</div>
);
}
function _getViewportComponent(displaySets, viewportComponents, uiNotificationService) {
if (!displaySets || !displaySets.length) {
return EmptyViewport;
}
// Todo: Do we have a viewport that has two different SOPClassHandlerIds?
const SOPClassHandlerId = displaySets[0].SOPClassHandlerId;
for (let i = 0; i < viewportComponents.length; i++) {
if (!viewportComponents[i]) {
throw new Error('viewport components not defined');
}
if (!viewportComponents[i].displaySetsToDisplay) {
throw new Error('displaySetsToDisplay is null');
}
if (viewportComponents[i].displaySetsToDisplay.includes(SOPClassHandlerId)) {
const { component } = viewportComponents[i];
return component;
}
}
console.log("Can't show displaySet", SOPClassHandlerId, displaySets[0]);
uiNotificationService.show({
title: 'Viewport Not Supported Yet',
message: `Cannot display SOPClassUID of ${displaySets[0].SOPClassUID} yet`,
type: 'error',
});
return EmptyViewport;
}
export default ViewerViewportGrid;

View File

@@ -0,0 +1,4 @@
import useDebounce from './useDebounce.js';
import useSearchParams from './useSearchParams';
export { useDebounce, useSearchParams };

View File

@@ -0,0 +1,31 @@
import { useState, useEffect } from 'react';
/**
* See: https://usehooks.com/useDebounce/
*
* @param {*} value
* @param {number} delay - delat in ms
*/
export default function useDebounce(value, delay) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler);
};
},
[value, delay] // Only re-call effect if value or delay changes
);
return debouncedValue;
}

View File

@@ -0,0 +1,30 @@
import { useLocation } from 'react-router';
/**
* It returns a URLSearchParams of the query parameters in the URL, where the keys are
* either lowercase or maintain their case based on the lowerCaseKeys parameter.
* This will automatically include the hash parameters as preferred parameters
* @param {lowerCaseKeys:boolean} true to return lower case keys; false (default) to maintain casing;
* @returns {URLSearchParams}
*/
export default function useSearchParams(options = { lowerCaseKeys: false }) {
const { lowerCaseKeys } = options;
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const hashParams = new URLSearchParams(location.hash?.substring(1) || '');
for (const [key, value] of hashParams) {
searchParams.set(key, value);
}
if (!lowerCaseKeys) {
return searchParams;
}
const lowerCaseSearchParams = new URLSearchParams();
for (const [key, value] of searchParams) {
lowerCaseSearchParams.set(key.toLowerCase(), value);
}
return lowerCaseSearchParams;
}

44
platform/app/src/index.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Entry point for development and production PWA builds.
*/
import 'regenerator-runtime/runtime';
import { createRoot } from 'react-dom/client';
import App from './App';
import React from 'react';
/**
* EXTENSIONS AND MODES
* =================
* pluginImports.js is dynamically generated from extension and mode
* configuration at build time.
*
* pluginImports.js imports all of the modes and extensions and adds them
* to the window for processing.
*/
import { modes as defaultModes, extensions as defaultExtensions } from './pluginImports';
import loadDynamicConfig from './loadDynamicConfig';
export { history } from './utils/history';
export { preserveQueryParameters, preserveQueryStrings } from './utils/preserveQueryParameters';
export { publicUrl } from './utils/publicUrl';
loadDynamicConfig(window.config).then(config_json => {
// Reset Dynamic config if defined
if (config_json !== null) {
window.config = config_json;
}
/**
* Combine our appConfiguration with installed extensions and modes.
* In the future appConfiguration may contain modes added at runtime.
* */
const appProps = {
config: window ? window.config : {},
defaultExtensions,
defaultModes,
};
const container = document.getElementById('root');
const root = createRoot(container);
root.render(React.createElement(App, appProps));
});

View File

@@ -0,0 +1,23 @@
export default async config => {
const useDynamicConfig = config.dangerouslyUseDynamicConfig;
// Check if dangerouslyUseDynamicConfig enabled
if (useDynamicConfig?.enabled) {
// If enabled then get configUrl query-string
let query = new URLSearchParams(window.location.search);
let configUrl = query.get('configUrl');
if (configUrl) {
// validate regex
const regex = useDynamicConfig.regex;
if (configUrl.match(regex)) {
const response = await fetch(configUrl);
return response.json();
} else {
return null;
}
}
}
return null;
};

View File

@@ -0,0 +1,23 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
function CallbackPage({ userManager, onRedirectSuccess }) {
const onRedirectError = error => {
throw new Error(error);
};
useEffect(() => {
userManager
.signinRedirectCallback()
.then(user => onRedirectSuccess(user))
.catch(error => onRedirectError(error));
}, [userManager, onRedirectSuccess]);
return null;
}
CallbackPage.propTypes = {
userManager: PropTypes.object.isRequired,
};
export default CallbackPage;

View File

@@ -0,0 +1,305 @@
/* eslint-disable react/jsx-props-no-spreading */
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Enums, ExtensionManager, MODULE_TYPES, log } from '@ohif/core';
//
import { extensionManager } from '../App';
import { useParams, useLocation } from 'react-router';
import { useNavigate } from 'react-router-dom';
import useSearchParams from '../hooks/useSearchParams';
/**
* Determines if two React Router location objects are the same.
*/
const areLocationsTheSame = (location0, location1) => {
return (
location0.pathname === location1.pathname &&
location0.search === location1.search &&
location0.hash === location1.hash
);
};
/**
* Uses route properties to determine the data source that should be passed
* to the child layout template. In some instances, initiates requests and
* passes data as props.
*
* @param {object} props
* @param {function} props.children - Layout Template React Component
*/
function DataSourceWrapper(props: withAppTypes) {
const { servicesManager } = props;
const navigate = useNavigate();
const { children: LayoutTemplate, ...rest } = props;
const params = useParams();
const location = useLocation();
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
const query = useSearchParams();
// Route props --> studies.mapParams
// mapParams --> studies.search
// studies.search --> studies.processResults
// studies.processResults --> <LayoutTemplate studies={} />
// But only for LayoutTemplate type of 'list'?
// Or no data fetching here, and just hand down my source
const STUDIES_LIMIT = 101;
const DEFAULT_DATA = {
studies: [],
total: 0,
resultsPerPage: 25,
pageNumber: 1,
location: 'Not a valid location, causes first load to occur',
};
const getInitialDataSourceName = useCallback(() => {
// TODO - get the variable from the props all the time...
let dataSourceName = lowerCaseSearchParams.get('datasources');
if (!dataSourceName && window.config.defaultDataSourceName) {
return '';
}
if (!dataSourceName) {
// Gets the first defined datasource with the right name
// Mostly for historical reasons - new configs should use the defaultDataSourceName
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
// TODO: Good usecase for flatmap?
const webApiDataSources = dataSourceModules.reduce((acc, curr) => {
const mods = [];
curr.module.forEach(mod => {
if (mod.type === 'webApi') {
mods.push(mod);
}
});
return acc.concat(mods);
}, []);
dataSourceName = webApiDataSources
.map(ds => ds.name)
.find(it => extensionManager.getDataSources(it)?.[0] !== undefined);
}
return dataSourceName;
}, []);
const [isDataSourceInitialized, setIsDataSourceInitialized] = useState(false);
// The path to the data source to be used in the URL for a mode (e.g. mode/dataSourcePath?StudyIntanceUIDs=1.2.3)
const [dataSourcePath, setDataSourcePath] = useState(() => {
const dataSourceName = getInitialDataSourceName();
return dataSourceName ? `/${dataSourceName}` : '';
});
const [dataSource, setDataSource] = useState(() => {
const dataSourceName = getInitialDataSourceName();
if (!dataSourceName) {
return extensionManager.getActiveDataSource()[0];
}
const dataSource = extensionManager.getDataSources(dataSourceName)?.[0];
if (!dataSource) {
throw new Error(`No data source found for ${dataSourceName}`);
}
return dataSource;
});
const [data, setData] = useState(DEFAULT_DATA);
const [isLoading, setIsLoading] = useState(false);
/**
* The effect to initialize the data source whenever it changes. Similar to
* whenever a different Mode is entered, the Mode's data source is initialized, so
* too this DataSourceWrapper must initialize its data source whenever a different
* data source is activated. Furthermore, a data source might be initialized
* several times as it gets activated/deactivated because the location URL
* might change and data sources initialize based on the URL.
*/
useEffect(() => {
const initializeDataSource = async () => {
await dataSource.initialize({ params, query });
setIsDataSourceInitialized(true);
};
initializeDataSource();
}, [dataSource]);
useEffect(() => {
const dataSourceChangedCallback = () => {
setIsLoading(false);
setIsDataSourceInitialized(false);
setDataSourcePath('');
setDataSource(extensionManager.getActiveDataSource()[0]);
// Setting data to DEFAULT_DATA triggers a new query just like it does for the initial load.
setData(DEFAULT_DATA);
};
const sub = extensionManager.subscribe(
ExtensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED,
dataSourceChangedCallback
);
return () => sub.unsubscribe();
}, []);
useEffect(() => {
if (!isDataSourceInitialized) {
return;
}
const queryFilterValues = _getQueryFilterValues(location.search, STUDIES_LIMIT);
// 204: no content
async function getData() {
setIsLoading(true);
log.time(Enums.TimingEnum.SEARCH_TO_LIST);
const studies = await dataSource.query.studies.search(queryFilterValues);
setData({
studies: studies || [],
total: studies.length,
resultsPerPage: queryFilterValues.resultsPerPage,
pageNumber: queryFilterValues.pageNumber,
location,
});
log.timeEnd(Enums.TimingEnum.SCRIPT_TO_VIEW);
log.timeEnd(Enums.TimingEnum.SEARCH_TO_LIST);
setIsLoading(false);
}
try {
// Cache invalidation :thinking:
// - Anytime change is not just next/previous page
// - And we didn't cross a result offset range
const isSamePage = data.pageNumber === queryFilterValues.pageNumber;
const previousOffset =
Math.floor((data.pageNumber * data.resultsPerPage) / STUDIES_LIMIT) * (STUDIES_LIMIT - 1);
const newOffset =
Math.floor(
(queryFilterValues.pageNumber * queryFilterValues.resultsPerPage) / STUDIES_LIMIT
) *
(STUDIES_LIMIT - 1);
// Simply checking data.location !== location is not sufficient because even though the location href (i.e. entire URL)
// has not changed, the React Router still provides a new location reference and would result in two study queries
// on initial load. Alternatively, window.location.href could be used.
const isLocationUpdated =
typeof data.location === 'string' || !areLocationsTheSame(data.location, location);
const isDataInvalid =
!isSamePage || (!isLoading && (newOffset !== previousOffset || isLocationUpdated));
if (isDataInvalid) {
getData().catch(e => {
console.error(e);
const { configurationAPI, friendlyName } = dataSource.getConfig();
// If there is a data source configuration API, then the Worklist will popup the dialog to attempt to configure it
// and attempt to resolve this issue.
if (configurationAPI) {
return;
}
servicesManager.services.uiModalService.show({
title: 'Data Source Connection Error',
containerDimensions: 'w-1/2',
content: () => {
return (
<div>
<p className="text-red-600">Error: {e.message}</p>
<p>
Please ensure the following data source is configured correctly or is running:
</p>
<div className="mt-2 font-bold">{friendlyName}</div>
</div>
);
},
});
});
}
} catch (ex) {
console.warn(ex);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, location, params, isLoading, setIsLoading, dataSource, isDataSourceInitialized]);
// queryFilterValues
// TODO: Better way to pass DataSource?
return (
<LayoutTemplate
{...rest}
data={data.studies}
dataPath={dataSourcePath}
dataTotal={data.total}
dataSource={dataSource}
isLoadingData={isLoading}
// To refresh the data, simply reset it to DEFAULT_DATA which invalidates it and triggers a new query to fetch the data.
onRefresh={() => setData(DEFAULT_DATA)}
/>
);
}
DataSourceWrapper.propTypes = {
/** Layout Component to wrap with a Data Source */
children: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired,
};
export default DataSourceWrapper;
/**
* Duplicated in `workList`
* Need generic that can be shared? Isn't this what qs is for?
* @param {*} query
*/
function _getQueryFilterValues(query, queryLimit) {
query = new URLSearchParams(query);
const newParams = new URLSearchParams();
for (const [key, value] of query) {
newParams.set(key.toLowerCase(), value);
}
query = newParams;
const pageNumber = _tryParseInt(query.get('pagenumber'), 1);
const resultsPerPage = _tryParseInt(query.get('resultsperpage'), 25);
const queryFilterValues = {
// DCM
patientId: query.get('mrn'),
patientName: query.get('patientname'),
studyDescription: query.get('description'),
modalitiesInStudy: query.get('modalities') && query.get('modalities').split(','),
accessionNumber: query.get('accession'),
//
startDate: query.get('startdate'),
endDate: query.get('enddate'),
page: _tryParseInt(query.get('page'), undefined),
pageNumber,
resultsPerPage,
// Rarely supported server-side
sortBy: query.get('sortby'),
sortDirection: query.get('sortdirection'),
// Offset...
offset: Math.floor((pageNumber * resultsPerPage) / queryLimit) * (queryLimit - 1),
config: query.get('configurl'),
};
// patientName: good
// studyDescription: good
// accessionNumber: good
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str !== null) {
if (str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
}
return retValue;
}
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Icons } from '@ohif/ui-next';
// this is a debug component that is used to list various things that might
// be useful for debugging such as cross origin errors, etc.
function Debug() {
return (
<div style={{ width: '100%', height: '100%' }}>
<div className="flex h-screen w-screen items-center justify-center">
<div className="bg-secondary-dark mx-auto space-y-2 rounded-lg py-8 px-8 drop-shadow-md">
<img
className="mx-auto block h-14"
src="./ohif-logo.svg"
alt="OHIF"
/>
<div className="space-y-2 pt-4 text-center">
<div className="flex flex-col items-center justify-center">
<p className="text-primary-active mt-4 text-xl font-semibold">Debug Information</p>
<div className="mt-4 flex items-center space-x-2">
<p className="text-md text-white">Cross Origin Isolated (COOP/COEP)</p>
<Icons.ByName
name={
window.crossOriginIsolated ? 'notifications-success' : 'notifications-error'
}
className="h-5 w-5"
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default Debug;

View File

@@ -0,0 +1,168 @@
import React, { useEffect, useRef } from 'react';
import classnames from 'classnames';
import { useNavigate } from 'react-router-dom';
import { DicomMetadataStore, MODULE_TYPES, useSystem } from '@ohif/core';
import Dropzone from 'react-dropzone';
import filesToStudies from './filesToStudies';
import { extensionManager } from '../../App';
import { Button } from '@ohif/ui';
import { Icons } from '@ohif/ui-next';
const getLoadButton = (onDrop, text, isDir) => {
return (
<Dropzone
onDrop={onDrop}
noDrag
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<Button
rounded="full"
variant="contained" // outlined
disabled={false}
endIcon={<Icons.LaunchArrow />}
className={classnames('font-medium', 'ml-2')}
onClick={() => {}}
>
{text}
{isDir ? (
<input
{...getInputProps()}
webkitdirectory="true"
mozdirectory="true"
/>
) : (
<input {...getInputProps()} />
)}
</Button>
</div>
)}
</Dropzone>
);
};
type LocalProps = {
modePath: string;
};
function Local({ modePath }: LocalProps) {
const { servicesManager } = useSystem();
const { customizationService } = servicesManager.services;
const navigate = useNavigate();
const dropzoneRef = useRef();
const [dropInitiated, setDropInitiated] = React.useState(false);
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
);
// Initializing the dicom local dataSource
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
const localDataSources = dataSourceModules.reduce((acc, curr) => {
const mods = [];
curr.module.forEach(mod => {
if (mod.type === 'localApi') {
mods.push(mod);
}
});
return acc.concat(mods);
}, []);
const firstLocalDataSource = localDataSources[0];
const dataSource = firstLocalDataSource.createDataSource({});
const microscopyExtensionLoaded = extensionManager.registeredExtensionIds.includes(
'@ohif/extension-dicom-microscopy'
);
const onDrop = async acceptedFiles => {
const studies = await filesToStudies(acceptedFiles, dataSource);
const query = new URLSearchParams();
if (microscopyExtensionLoaded) {
// TODO: for microscopy, we are forcing microscopy mode, which is not ideal.
// we should make the local drag and drop navigate to the worklist and
// there user can select microscopy mode
const smStudies = studies.filter(id => {
const study = DicomMetadataStore.getStudy(id);
return (
study.series.findIndex(s => s.Modality === 'SM' || s.instances[0].Modality === 'SM') >= 0
);
});
if (smStudies.length > 0) {
smStudies.forEach(id => query.append('StudyInstanceUIDs', id));
modePath = 'microscopy';
}
}
// Todo: navigate to work list and let user select a mode
studies.forEach(id => query.append('StudyInstanceUIDs', id));
query.append('datasources', 'dicomlocal');
navigate(`/${modePath}?${decodeURIComponent(query.toString())}`);
};
// Set body style
useEffect(() => {
document.body.classList.add('bg-black');
return () => {
document.body.classList.remove('bg-black');
};
}, []);
return (
<Dropzone
ref={dropzoneRef}
onDrop={acceptedFiles => {
setDropInitiated(true);
onDrop(acceptedFiles);
}}
noClick
>
{({ getRootProps }) => (
<div
{...getRootProps()}
style={{ width: '100%', height: '100%' }}
>
<div className="flex h-screen w-screen items-center justify-center">
<div className="bg-secondary-dark mx-auto space-y-2 rounded-lg py-8 px-8 drop-shadow-md">
<div className="flex items-center justify-center">
<Icons.OHIFLogoColorDarkBackground className="h-28" />
</div>
<div className="space-y-2 pt-4 text-center">
{dropInitiated ? (
<div className="flex flex-col items-center justify-center pt-48">
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
</div>
) : (
<div className="space-y-2">
<p className="text-base text-blue-300">
Note: You data is not uploaded to any server, it will stay in your local
browser application
</p>
<p className="text-xg text-primary-active pt-6 font-semibold">
Drag and Drop DICOM files here to load them in the Viewer
</p>
<p className="text-lg text-blue-300">Or click to </p>
</div>
)}
</div>
<div className="flex justify-around pt-4">
{getLoadButton(onDrop, 'Load files', false)}
{getLoadButton(onDrop, 'Load folders', true)}
</div>
</div>
</div>
</div>
)}
</Dropzone>
);
}
export default Local;

View File

@@ -0,0 +1,27 @@
import dcmjs from 'dcmjs';
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import FileLoader from './fileLoader';
const DICOMFileLoader = new (class extends FileLoader {
fileType = 'application/dicom';
loadFile(file, imageId) {
return dicomImageLoader.wadouri.loadFileRequest(imageId);
}
getDataset(image, imageId) {
const dicomData = dcmjs.data.DicomMessage.readFile(image);
const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomData.dict);
dataset.url = imageId;
dataset._meta = dcmjs.data.DicomMetaDictionary.namifyDataset(dicomData.meta);
dataset.AvailableTransferSyntaxUID =
dataset.AvailableTransferSyntaxUID || dataset._meta.TransferSyntaxUID?.Value?.[0];
return dataset;
}
})();
export default DICOMFileLoader;

View File

@@ -0,0 +1,6 @@
export default class FileLoader {
fileType;
loadFile(file, imageId) {}
getDataset(image, imageId) {}
getStudies(dataset, imageId) {}
}

View File

@@ -0,0 +1,39 @@
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import FileLoader from './fileLoader';
import PDFFileLoader from './pdfFileLoader';
import DICOMFileLoader from './dicomFileLoader';
class FileLoaderService extends FileLoader {
fileType;
loader;
constructor(file) {
super();
const fileType = file && file.type;
this.loader = this.getLoader(fileType);
this.fileType = this.loader.fileType;
}
addFile(file) {
return dicomImageLoader.wadouri.fileManager.add(file);
}
loadFile(file, imageId) {
return this.loader.loadFile(file, imageId);
}
getDataset(image, imageId) {
return this.loader.getDataset(image, imageId);
}
getLoader(fileType) {
if (fileType === 'application/pdf') {
return PDFFileLoader;
} else {
// Default to dicom loader
return DICOMFileLoader;
}
}
}
export default FileLoaderService;

View File

@@ -0,0 +1,22 @@
import FileLoaderService from './fileLoaderService';
import { DicomMetadataStore } from '@ohif/core';
const processFile = async file => {
try {
const fileLoaderService = new FileLoaderService(file);
const imageId = fileLoaderService.addFile(file);
const image = await fileLoaderService.loadFile(file, imageId);
const dicomJSONDataset = await fileLoaderService.getDataset(image, imageId);
DicomMetadataStore.addInstance(dicomJSONDataset);
} catch (error) {
console.log(error.name, ':Error when trying to load and process local files:', error.message);
}
};
export default async function filesToStudies(files) {
const processFilesPromises = files.map(processFile);
await Promise.all(processFilesPromises);
return DicomMetadataStore.getStudyInstanceUIDs();
}

View File

@@ -0,0 +1 @@
export { default } from './Local';

View File

@@ -0,0 +1,17 @@
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import FileLoader from './fileLoader';
const PDFFileLoader = new (class extends FileLoader {
fileType = 'application/pdf';
loadFile(file, imageId) {
return dicomImageLoader.wadouri.loadFileRequest(imageId);
}
getDataset(image, imageId) {
const dataset = {};
dataset.imageId = image.imageId || imageId;
return dataset;
}
})();
export default PDFFileLoader;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
/**
* Nests React components as ordered in array. We use this to
* simplify composition a Mode specify's in it's configuration
* for React Contexts that should wrap a Mode Route.
*/
export default function Compose(props) {
const { components = [], children } = props;
return (
<React.Fragment>
{components.reduceRight((acc, curr) => {
const [Comp, props] = Array.isArray(curr) ? [curr[0], curr[1]] : [curr, {}];
return <Comp {...props}>{acc}</Comp>;
}, children)}
</React.Fragment>
);
}
// https://juliuskoronci.medium.com/avoid-a-long-list-of-react-providers-c45a269d80c1
Compose.propTypes = {
components: PropTypes.array,
children: PropTypes.node.isRequired,
};

View File

@@ -0,0 +1,408 @@
import React, { useEffect, useState, useRef } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router';
import PropTypes from 'prop-types';
import { utils } from '@ohif/core';
import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui';
import { useSearchParams } from '@hooks';
import { useAppConfig } from '@state';
import ViewportGrid from '@components/ViewportGrid';
import Compose from './Compose';
import { history } from '../../utils/history';
import loadModules from '../../pluginImports';
import { defaultRouteInit } from './defaultRouteInit';
import { updateAuthServiceAndCleanUrl } from './updateAuthServiceAndCleanUrl';
const { getSplitParam } = utils;
export default function ModeRoute({
mode,
dataSourceName,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
}: withAppTypes) {
const [appConfig] = useAppConfig();
// Parse route params/querystring
const location = useLocation();
// The react router DOM placeholder map (see https://reactrouter.com/en/main/hooks/use-params).
const params = useParams();
// The URL's query search parameters where the keys casing is maintained
const query = useSearchParams();
mode?.onModeInit?.({
servicesManager,
extensionManager,
commandsManager,
appConfig,
query,
});
// The URL's query search parameters where the keys are all lower case.
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(null);
const [refresh, setRefresh] = useState(false);
const [ExtensionDependenciesLoaded, setExtensionDependenciesLoaded] = useState(false);
const layoutTemplateData = useRef(false);
const locationRef = useRef(null);
const isMounted = useRef(false);
// Expose the react router dom navigation.
history.navigate = useNavigate();
if (location !== locationRef.current) {
layoutTemplateData.current = null;
locationRef.current = location;
}
const {
displaySetService,
panelService,
hangingProtocolService,
userAuthenticationService,
customizationService,
} = servicesManager.services;
const { extensions, sopClassHandlers, hangingProtocol } = mode;
const runTimeHangingProtocolId = lowerCaseSearchParams.get('hangingprotocolid');
const runTimeStageId = lowerCaseSearchParams.get('stageid');
const token = lowerCaseSearchParams.get('token');
if (token) {
updateAuthServiceAndCleanUrl(token, location, userAuthenticationService);
}
// An undefined dataSourceName implies that the active data source that is already set in the ExtensionManager should be used.
if (dataSourceName !== undefined) {
extensionManager.setActiveDataSource(dataSourceName);
}
const dataSource = extensionManager.getActiveDataSource()[0];
// Only handling one route per mode for now
const route = mode.routes[0];
useEffect(() => {
const loadExtensions = async () => {
const loadedExtensions = await loadModules(Object.keys(extensions));
for (const extension of loadedExtensions) {
const { id: extensionId } = extension;
if (extensionManager.registeredExtensionIds.indexOf(extensionId) === -1) {
await extensionManager.registerExtension(extension);
}
}
if (isMounted.current) {
setExtensionDependenciesLoaded(true);
}
};
loadExtensions();
}, []);
useEffect(() => {
// Preventing state update for unmounted component
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
if (!ExtensionDependenciesLoaded) {
return;
}
// Todo: this should not be here, data source should not care about params
const initializeDataSource = async (params, query) => {
await dataSource.initialize({
params,
query,
});
setStudyInstanceUIDs(dataSource.getStudyInstanceUIDs({ params, query }));
};
initializeDataSource(params, query);
return () => {
layoutTemplateData.current = null;
};
}, [location, ExtensionDependenciesLoaded]);
useEffect(() => {
if (!ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
return;
}
const retrieveLayoutData = async () => {
const layoutData = await route.layoutTemplate({
location,
servicesManager,
studyInstanceUIDs,
});
if (isMounted.current) {
const { leftPanels = [], rightPanels = [], ...layoutProps } = layoutData.props;
panelService.reset();
panelService.addPanels(panelService.PanelPosition.Left, leftPanels);
panelService.addPanels(panelService.PanelPosition.Right, rightPanels);
// layoutProps contains all props but leftPanels and rightPanels
layoutData.props = layoutProps;
layoutTemplateData.current = layoutData;
setRefresh(!refresh);
}
};
if (Array.isArray(studyInstanceUIDs) && studyInstanceUIDs[0]) {
retrieveLayoutData();
}
return () => {
layoutTemplateData.current = null;
};
}, [studyInstanceUIDs, ExtensionDependenciesLoaded]);
useEffect(() => {
if (!layoutTemplateData.current || !ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
return;
}
const setupRouteInit = async () => {
// TODO: For some reason this is running before the Providers
// are calling setServiceImplementation
// TODO -> iterate through services.
// Extension
// Add SOPClassHandlers to a new SOPClassManager.
displaySetService.init(extensionManager, sopClassHandlers);
extensionManager.onModeEnter({
servicesManager,
extensionManager,
commandsManager,
appConfig,
});
// use the URL hangingProtocolId if it exists, otherwise use the one
// defined in the mode configuration
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
runTimeHangingProtocolId
)
? runTimeHangingProtocolId
: hangingProtocol;
// Determine the index of the stageId if the hangingProtocolIdToUse is defined
const stageIndex = Array.isArray(hangingProtocolIdToUse)
? -1
: hangingProtocolService.getStageIndex(hangingProtocolIdToUse, {
stageId: runTimeStageId || undefined,
});
// Ensure that the stage index is never negative
// If stageIndex is negative (e.g., if stage wasn't found), use 0 as the default
const stageIndexToUse = Math.max(0, stageIndex);
// Sets the active hanging protocols - if hangingProtocol is undefined,
// resets to default. Done before the onModeEnter to allow the onModeEnter
// to perform custom hanging protocol actions
hangingProtocolService.setActiveProtocolIds(hangingProtocolIdToUse);
mode?.onModeEnter({
servicesManager,
extensionManager,
commandsManager,
appConfig,
});
// Move hotkeys setup here, after onModeEnter
const hotkeys = customizationService.getCustomization('ohif.hotkeyBindings');
hotkeysManager.setDefaultHotKeys(hotkeys);
/**
* The next line should get all the query parameters provided by the URL
* - except the StudyInstanceUIDs - and create an object called filters
* used to filtering the study as the user wants otherwise it will return
* a empty object.
*
* Example:
* const filters = {
* seriesInstanceUID: 1.2.276.0.7230010.3.1.3.1791068887.5412.1620253993.114611
* }
*/
const filters =
Array.from(query.keys()).reduce((acc: Record<string, string>, val: string) => {
const lowerVal = val.toLowerCase();
// Not sure why the case matters here - it doesn't in the URL
if (lowerVal === 'seriesinstanceuids' || lowerVal === 'seriesinstanceuid') {
const seriesUIDs = getSplitParam(lowerVal, query);
return {
...acc,
seriesInstanceUID: seriesUIDs,
};
}
return { ...acc, [val]: getSplitParam(lowerVal, query) };
}, {}) ?? {};
let unsubs;
if (route.init) {
unsubs = await route.init(
{
servicesManager,
extensionManager,
hotkeysManager,
studyInstanceUIDs,
dataSource,
filters,
},
hangingProtocolIdToUse,
stageIndexToUse
);
}
return defaultRouteInit(
{
servicesManager,
studyInstanceUIDs,
dataSource,
filters,
appConfig,
},
hangingProtocolIdToUse,
stageIndexToUse
);
};
let unsubscriptions;
setupRouteInit().then(unsubs => {
unsubscriptions = unsubs;
mode?.onSetupRouteComplete?.({
servicesManager,
extensionManager,
commandsManager,
});
});
return () => {
// The mode.onModeExit must be done first to allow it to store
// information, and must be in a try/catch to ensure subscriptions
// are unsubscribed.
try {
mode?.onModeExit?.({
servicesManager,
extensionManager,
appConfig,
});
} catch (e) {
console.warn('mode exit failure', e);
}
// Clean up hotkeys
hotkeysManager.destroy();
// The unsubscriptions must occur before the extension onModeExit
// in order to prevent exceptions during cleanup caused by spurious events
if (unsubscriptions) {
unsubscriptions.forEach(unsub => {
unsub();
});
}
// The extension manager must be called after the mode, this is
// expected to cleanup the state to a standard setup.
extensionManager.onModeExit();
};
}, [
mode,
dataSourceName,
location,
ExtensionDependenciesLoaded,
route,
servicesManager,
extensionManager,
hotkeysManager,
studyInstanceUIDs,
refresh,
]);
if (!studyInstanceUIDs || !layoutTemplateData.current || !ExtensionDependenciesLoaded) {
return null;
}
const ViewportGridWithDataSource = props => {
return ViewportGrid({ ...props, dataSource });
};
const CombinedExtensionsContextProvider = createCombinedContextProvider(
extensionManager,
servicesManager,
commandsManager
);
const getLayoutComponent = props => {
const layoutTemplateModuleEntry = extensionManager.getModuleEntry(
layoutTemplateData.current.id
);
const LayoutComponent = layoutTemplateModuleEntry.component;
return <LayoutComponent {...props} />;
};
const LayoutComponent = getLayoutComponent({
...layoutTemplateData.current.props,
ViewportGridComp: ViewportGridWithDataSource,
});
return (
<ImageViewerProvider StudyInstanceUIDs={studyInstanceUIDs}>
{CombinedExtensionsContextProvider ? (
<CombinedExtensionsContextProvider>
<DragAndDropProvider>{LayoutComponent}</DragAndDropProvider>
</CombinedExtensionsContextProvider>
) : (
<DragAndDropProvider>{LayoutComponent}</DragAndDropProvider>
)}
</ImageViewerProvider>
);
}
/**
* Creates a combined context provider using the context modules from the extension manager.
* @param {object} extensionManager - The extension manager instance.
* @param {object} servicesManager - The services manager instance.
* @param {object} commandsManager - The commands manager instance.
* @returns {React.Component} - A React component that provides combined contexts to its children.
*/
function createCombinedContextProvider(extensionManager, servicesManager, commandsManager) {
const extensionsContextModules = extensionManager.getModulesByType(
extensionManager.constructor.MODULE_TYPES.CONTEXT
);
if (!extensionsContextModules?.length) {
return;
}
const contextModuleProviders = extensionsContextModules.flatMap(({ module }) => {
return module.map(aContextModule => {
return aContextModule.provider;
});
});
return ({ children }) => {
return Compose({ components: contextModuleProviders, children });
};
}
ModeRoute.propTypes = {
mode: PropTypes.object.isRequired,
dataSourceName: PropTypes.string,
extensionManager: PropTypes.object,
servicesManager: PropTypes.object,
hotkeysManager: PropTypes.object,
commandsManager: PropTypes.object,
};

View File

@@ -0,0 +1,149 @@
import getStudies from './studiesList';
import { DicomMetadataStore, log, utils, Enums } from '@ohif/core';
import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed';
const { getSplitParam } = utils;
/**
* Initialize the route.
*
* @param props.servicesManager to read services from
* @param props.studyInstanceUIDs for a list of studies to read
* @param props.dataSource to read the data from
* @param props.filters filters from query params to read the data from
* @returns array of subscriptions to cancel
*/
export async function defaultRouteInit(
{ servicesManager, studyInstanceUIDs, dataSource, filters, appConfig }: withAppTypes,
hangingProtocolId,
stageIndex
) {
const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } =
servicesManager.services;
/**
* Function to apply the hanging protocol when the minimum number of display sets were
* received or all display sets retrieval were completed
* @returns
*/
function applyHangingProtocol() {
const displaySets = displaySetService.getActiveDisplaySets();
if (!displaySets || !displaySets.length) {
return;
}
// Gets the studies list to use
const studies = getStudies(studyInstanceUIDs, displaySets);
// study being displayed, and is thus the "active" study.
const activeStudy = studies[0];
// run the hanging protocol matching on the displaySets with the predefined
// hanging protocol in the mode configuration
hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId, {
stageIndex,
});
}
const unsubscriptions = [];
const issuedWarningSeries = [];
const { unsubscribe: instanceAddedUnsubscribe } = DicomMetadataStore.subscribe(
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
function ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) {
const seriesMetadata = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID);
// checks if the series filter was used, if it exists
const seriesInstanceUIDs = filters?.seriesInstanceUID;
if (
seriesInstanceUIDs?.length &&
!isSeriesFilterUsed(seriesMetadata.instances, filters) &&
!issuedWarningSeries.includes(seriesInstanceUIDs[0])
) {
// stores the series instance filter so it shows only once the warning
issuedWarningSeries.push(seriesInstanceUIDs[0]);
uiNotificationService.show({
title: 'Series filter',
message: `Each of the series in filter: ${seriesInstanceUIDs} are not part of the current study. The entire study is being displayed`,
type: 'error',
duration: 7000,
});
}
displaySetService.makeDisplaySets(seriesMetadata.instances, { madeInClient });
}
);
unsubscriptions.push(instanceAddedUnsubscribe);
log.time(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
log.time(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE);
const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID =>
dataSource.retrieve.series.metadata({
StudyInstanceUID,
filters,
returnPromises: true,
sortCriteria: customizationService.getCustomization('sortingCriteria'),
})
);
// log the error if this fails, otherwise it's so difficult to tell what went wrong...
allRetrieves.forEach(retrieve => {
retrieve.catch(error => {
console.error(error);
});
});
// is displaysets from URL and has initialSOPInstanceUID or initialSeriesInstanceUID
// then we need to wait for all display sets to be retrieved before applying the hanging protocol
const params = new URLSearchParams(window.location.search);
const initialSeriesInstanceUID = getSplitParam('initialseriesinstanceuid', params);
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid', params);
let displaySetFromUrl = false;
if (initialSeriesInstanceUID || initialSOPInstanceUID) {
displaySetFromUrl = true;
}
await Promise.allSettled(allRetrieves).then(async promises => {
log.timeEnd(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE);
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES);
const allPromises = [];
const remainingPromises = [];
function startRemainingPromises(remainingPromises) {
remainingPromises.forEach(p => p.forEach(p => p.start()));
}
promises.forEach(promise => {
const retrieveSeriesMetadataPromise = promise.value;
if (!Array.isArray(retrieveSeriesMetadataPromise)) {
return;
}
if (displaySetFromUrl) {
const requiredSeriesPromises = retrieveSeriesMetadataPromise.map(promise =>
promise.start()
);
allPromises.push(Promise.allSettled(requiredSeriesPromises));
} else {
const { requiredSeries, remaining } = hangingProtocolService.filterSeriesRequiredForRun(
hangingProtocolId,
retrieveSeriesMetadataPromise
);
const requiredSeriesPromises = requiredSeries.map(promise => promise.start());
allPromises.push(Promise.allSettled(requiredSeriesPromises));
remainingPromises.push(remaining);
}
});
await Promise.allSettled(allPromises).then(applyHangingProtocol);
startRemainingPromises(remainingPromises);
applyHangingProtocol();
});
return unsubscriptions;
}

View File

@@ -0,0 +1 @@
export { default } from './Mode';

View File

@@ -0,0 +1,65 @@
import { DicomMetadataStore, Types } from '@ohif/core';
type StudyMetadata = Types.StudyMetadata;
/**
* Compare function for sorting
*
* @param a - some simple value (string, number, timestamp)
* @param b - some simple value
* @param defaultCompare - default return value as a fallback when a===b
* @returns - compare a and b, returning 1 if a<b -1 if a>b and defaultCompare otherwise
*/
const compare = (a, b, defaultCompare = 0): number => {
if (a === b) {
return defaultCompare;
}
if (a < b) {
return 1;
}
return -1;
};
/**
* The studies from display sets gets the studies in study date
* order or in study instance UID order - not very useful, but
* if not specifically specified then at least making it consistent is useful.
*/
const getStudiesfromDisplaySets = (displaysets): StudyMetadata[] => {
const studyMap = {};
const ret = displaySets.reduce((prev, curr) => {
const { StudyInstanceUID } = curr;
if (!studyMap[StudyInstanceUID]) {
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
studyMap[StudyInstanceUID] = study;
prev.push(study);
}
return prev;
}, []);
// Return the sorted studies, first on study date and second on study instance UID
ret.sort((a, b) => {
return compare(a.StudyDate, b.StudyDate, compare(a.StudyInstanceUID, b.StudyInstanceUID));
});
return ret;
};
/**
* The studies retrieve from the Uids is faster and gets the studies
* in the original order, as specified.
*/
const getStudiesFromUIDs = (studyUids: string[]): StudyMetadata[] => {
if (!studyUids?.length) {
return;
}
return studyUids.map(uid => DicomMetadataStore.getStudy(uid));
};
/** Gets the array of studies */
const getStudies = (studyUids?: string[], displaySets): StudyMetadata[] => {
return getStudiesFromUIDs(studyUids) || getStudiesfromDisplaySets(displaySets);
};
export default getStudies;
export { getStudies, getStudiesFromUIDs, getStudiesfromDisplaySets, compare };

View File

@@ -0,0 +1,35 @@
/**
* Updates the user authentication service with the provided token and cleans the token from the URL.
* @param token - The token to set in the user authentication service.
* @param location - The location object from the router.
* @param userAuthenticationService - The user authentication service instance.
*/
export function updateAuthServiceAndCleanUrl(
token: string,
location: any,
userAuthenticationService: any
): void {
if (!token) {
return;
}
// if a token is passed in, set the userAuthenticationService to use it
// for the Authorization header for all requests
userAuthenticationService.setServiceImplementation({
getAuthorizationHeader: () => ({
Authorization: 'Bearer ' + token,
}),
});
// Create a URL object with the current location
const urlObj = new URL(window.location.origin + window.location.pathname + location.search);
// Remove the token from the URL object
urlObj.searchParams.delete('token');
const cleanUrl = urlObj.toString();
// Update the browser's history without the token
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', cleanUrl);
}
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useAppConfig } from '@state';
const NotFound = ({ message = 'Sorry, this page does not exist.', showGoBackButton = true }) => {
const [appConfig] = useAppConfig();
const { showStudyList } = appConfig;
return (
<div className="flex h-full w-full items-center justify-center text-white">
<div>
<h4>{message}</h4>
{showGoBackButton && showStudyList && (
<h5>
<Link to={'/'}>Go back to the Study List</Link>
</h5>
)}
</div>
</div>
);
};
NotFound.propTypes = {
message: PropTypes.string,
showGoBackButton: PropTypes.bool,
};
export default NotFound;

View File

@@ -0,0 +1 @@
export { default } from './NotFound';

View File

@@ -0,0 +1,13 @@
import { useUserAuthentication } from '@ohif/ui';
export const PrivateRoute = ({ children, handleUnauthenticated }) => {
const [{ user, enabled }] = useUserAuthentication();
if (enabled && !user) {
return handleUnauthenticated();
}
return children;
};
export default PrivateRoute;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import PropTypes from 'prop-types';
function SignoutCallbackComponent({ userManager }) {
const navigate = useNavigate();
const onRedirectSuccess = (/* user */) => {
const { pathname, search = '' } = JSON.parse(sessionStorage.getItem('ohif-redirect-to'));
navigate(`${pathname}?${search}`);
};
const onRedirectError = error => {
throw new Error(error);
};
userManager
.signoutRedirectCallback()
.then(user => onRedirectSuccess(user))
.catch(error => onRedirectError(error));
return null;
}
SignoutCallbackComponent.propTypes = {
userManager: PropTypes.object.isRequired,
};
export default SignoutCallbackComponent;

View File

@@ -0,0 +1,707 @@
import React, { useState, useEffect, useMemo } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Link, useNavigate } from 'react-router-dom';
import moment from 'moment';
import qs from 'query-string';
import isEqual from 'lodash.isequal';
import { useTranslation } from 'react-i18next';
//
import filtersMeta from './filtersMeta.js';
import { useAppConfig } from '@state';
import { useDebounce, useSearchParams } from '@hooks';
import { utils, hotkeys } from '@ohif/core';
import publicUrl from '../../utils/publicUrl';
import {
StudyListExpandedRow,
EmptyStudies,
StudyListTable,
StudyListPagination,
StudyListFilter,
useModal,
AboutModal,
UserPreferences,
useSessionStorage,
InvestigationalUseDialog,
Button,
ButtonEnums,
} from '@ohif/ui';
import {
Header,
Icons,
Tooltip,
TooltipTrigger,
TooltipContent,
Clipboard,
Onboarding,
ScrollArea,
} from '@ohif/ui-next';
import { Types } from '@ohif/ui';
import i18n from '@ohif/i18n';
import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters';
const PatientInfoVisibility = Types.PatientInfoVisibility;
const { sortBySeriesDate } = utils;
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
const seriesInStudiesMap = new Map();
/**
* TODO:
* - debounce `setFilterValues` (150ms?)
*/
function WorkList({
data: studies,
dataTotal: studiesTotal,
isLoadingData,
dataSource,
hotkeysManager,
dataPath,
onRefresh,
servicesManager,
}: withAppTypes) {
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
const { show, hide } = useModal();
const { t } = useTranslation();
// ~ Modes
const [appConfig] = useAppConfig();
// ~ Filters
const searchParams = useSearchParams();
const navigate = useNavigate();
const STUDIES_LIMIT = 101;
const queryFilterValues = _getQueryFilterValues(searchParams);
const [sessionQueryFilterValues, updateSessionQueryFilterValues] = useSessionStorage({
key: 'queryFilterValues',
defaultValue: queryFilterValues,
// ToDo: useSessionStorage currently uses an unload listener to clear the filters from session storage
// so on systems that do not support unload events a user will NOT be able to alter any existing filter
// in the URL, load the page and have it apply.
clearOnUnload: true,
});
const [filterValues, _setFilterValues] = useState({
...defaultFilterValues,
...sessionQueryFilterValues,
});
const debouncedFilterValues = useDebounce(filterValues, 200);
const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues;
/*
* The default sort value keep the filters synchronized with runtime conditional sorting
* Only applied if no other sorting is specified and there are less than 101 studies
*/
const canSort = studiesTotal < STUDIES_LIMIT;
const shouldUseDefaultSort = sortBy === '' || !sortBy;
const sortModifier = sortDirection === 'descending' ? 1 : -1;
const defaultSortValues =
shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {};
const sortedStudies = useMemo(() => {
if (!canSort) {
return studies;
}
return [...studies].sort((s1, s2) => {
if (shouldUseDefaultSort) {
const ascendingSortModifier = -1;
return _sortStringDates(s1, s2, ascendingSortModifier);
}
const s1Prop = s1[sortBy];
const s2Prop = s2[sortBy];
if (typeof s1Prop === 'string' && typeof s2Prop === 'string') {
return s1Prop.localeCompare(s2Prop) * sortModifier;
} else if (typeof s1Prop === 'number' && typeof s2Prop === 'number') {
return (s1Prop > s2Prop ? 1 : -1) * sortModifier;
} else if (!s1Prop && s2Prop) {
return -1 * sortModifier;
} else if (!s2Prop && s1Prop) {
return 1 * sortModifier;
} else if (sortBy === 'studyDate') {
return _sortStringDates(s1, s2, sortModifier);
}
return 0;
});
}, [canSort, studies, shouldUseDefaultSort, sortBy, sortModifier]);
// ~ Rows & Studies
const [expandedRows, setExpandedRows] = useState([]);
const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]);
const numOfStudies = studiesTotal;
const querying = useMemo(() => {
return isLoadingData || expandedRows.length > 0;
}, [isLoadingData, expandedRows]);
const setFilterValues = val => {
if (filterValues.pageNumber === val.pageNumber) {
val.pageNumber = 1;
}
_setFilterValues(val);
updateSessionQueryFilterValues(val);
setExpandedRows([]);
};
const onPageNumberChange = newPageNumber => {
const oldPageNumber = filterValues.pageNumber;
const rollingPageNumberMod = Math.floor(101 / filterValues.resultsPerPage);
const rollingPageNumber = oldPageNumber % rollingPageNumberMod;
const isNextPage = newPageNumber > oldPageNumber;
const hasNextPage = Math.max(rollingPageNumber, 1) * resultsPerPage < numOfStudies;
if (isNextPage && !hasNextPage) {
return;
}
setFilterValues({ ...filterValues, pageNumber: newPageNumber });
};
const onResultsPerPageChange = newResultsPerPage => {
setFilterValues({
...filterValues,
pageNumber: 1,
resultsPerPage: Number(newResultsPerPage),
});
};
// Set body style
useEffect(() => {
document.body.classList.add('bg-black');
return () => {
document.body.classList.remove('bg-black');
};
}, []);
// Sync URL query parameters with filters
useEffect(() => {
if (!debouncedFilterValues) {
return;
}
const queryString = {};
Object.keys(defaultFilterValues).forEach(key => {
const defaultValue = defaultFilterValues[key];
const currValue = debouncedFilterValues[key];
// TODO: nesting/recursion?
if (key === 'studyDate') {
if (currValue.startDate && defaultValue.startDate !== currValue.startDate) {
queryString.startDate = currValue.startDate;
}
if (currValue.endDate && defaultValue.endDate !== currValue.endDate) {
queryString.endDate = currValue.endDate;
}
} else if (key === 'modalities' && currValue.length) {
queryString.modalities = currValue.join(',');
} else if (currValue !== defaultValue) {
queryString[key] = currValue;
}
});
preserveQueryStrings(queryString);
const search = qs.stringify(queryString, {
skipNull: true,
skipEmptyString: true,
});
navigate({
pathname: publicUrl,
search: search ? `?${search}` : undefined,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedFilterValues]);
// Query for series information
useEffect(() => {
const fetchSeries = async studyInstanceUid => {
try {
const series = await dataSource.query.series.search(studyInstanceUid);
seriesInStudiesMap.set(studyInstanceUid, sortBySeriesDate(series));
setStudiesWithSeriesData([...studiesWithSeriesData, studyInstanceUid]);
} catch (ex) {
// TODO: UI Notification Service
console.warn(ex);
}
};
// TODO: WHY WOULD YOU USE AN INDEX OF 1?!
// Note: expanded rows index begins at 1
for (let z = 0; z < expandedRows.length; z++) {
const expandedRowIndex = expandedRows[z] - 1;
const studyInstanceUid = sortedStudies[expandedRowIndex].studyInstanceUid;
if (studiesWithSeriesData.includes(studyInstanceUid)) {
continue;
}
fetchSeries(studyInstanceUid);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandedRows, studies]);
const isFiltering = (filterValues, defaultFilterValues) => {
return !isEqual(filterValues, defaultFilterValues);
};
const rollingPageNumberMod = Math.floor(101 / resultsPerPage);
const rollingPageNumber = (pageNumber - 1) % rollingPageNumberMod;
const offset = resultsPerPage * rollingPageNumber;
const offsetAndTake = offset + resultsPerPage;
const tableDataSource = sortedStudies.map((study, key) => {
const rowKey = key + 1;
const isExpanded = expandedRows.some(k => k === rowKey);
const {
studyInstanceUid,
accession,
modalities,
instances,
description,
mrn,
patientName,
date,
time,
} = study;
const studyDate =
date &&
moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true).isValid() &&
moment(date, ['YYYYMMDD', 'YYYY.MM.DD']).format(t('Common:localDateFormat', 'MMM-DD-YYYY'));
const studyTime =
time &&
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).isValid() &&
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).format(
t('Common:localTimeFormat', 'hh:mm A')
);
const makeCopyTooltipCell = textValue => {
if (!textValue) {
return '';
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-pointer truncate">{textValue}</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex items-center justify-between gap-2">
{textValue}
<Clipboard>{textValue}</Clipboard>
</div>
</TooltipContent>
</Tooltip>
);
};
return {
dataCY: `studyRow-${studyInstanceUid}`,
clickableCY: studyInstanceUid,
row: [
{
key: 'patientName',
content: patientName ? makeCopyTooltipCell(patientName) : null,
gridCol: 4,
},
{
key: 'mrn',
content: makeCopyTooltipCell(mrn),
gridCol: 3,
},
{
key: 'studyDate',
content: (
<>
{studyDate && <span className="mr-4">{studyDate}</span>}
{studyTime && <span>{studyTime}</span>}
</>
),
title: `${studyDate || ''} ${studyTime || ''}`,
gridCol: 5,
},
{
key: 'description',
content: makeCopyTooltipCell(description),
gridCol: 4,
},
{
key: 'modality',
content: modalities,
title: modalities,
gridCol: 3,
},
{
key: 'accession',
content: makeCopyTooltipCell(accession),
gridCol: 3,
},
{
key: 'instances',
content: (
<>
<Icons.GroupLayers
className={classnames('mr-2 inline-flex w-4', {
'text-primary-active': isExpanded,
'text-secondary-light': !isExpanded,
})}
/>
{instances}
</>
),
title: (instances || 0).toString(),
gridCol: 2,
},
],
// Todo: This is actually running for all rows, even if they are
// not clicked on.
expandedContent: (
<StudyListExpandedRow
seriesTableColumns={{
description: t('StudyList:Description'),
seriesNumber: t('StudyList:Series'),
modality: t('StudyList:Modality'),
instances: t('StudyList:Instances'),
}}
seriesTableDataSource={
seriesInStudiesMap.has(studyInstanceUid)
? seriesInStudiesMap.get(studyInstanceUid).map(s => {
return {
description: s.description || '(empty)',
seriesNumber: s.seriesNumber ?? '',
modality: s.modality || '',
instances: s.numSeriesInstances || '',
};
})
: []
}
>
<div className="flex flex-row gap-2">
{(appConfig.groupEnabledModesFirst
? appConfig.loadedModes.sort((a, b) => {
const isValidA = a.isValidMode({
modalities: modalities.replaceAll('/', '\\'),
study,
}).valid;
const isValidB = b.isValidMode({
modalities: modalities.replaceAll('/', '\\'),
study,
}).valid;
return isValidB - isValidA;
})
: appConfig.loadedModes
).map((mode, i) => {
const modalitiesToCheck = modalities.replaceAll('/', '\\');
const { valid: isValidMode, description: invalidModeDescription } = mode.isValidMode({
modalities: modalitiesToCheck,
study,
});
// TODO: Modes need a default/target route? We mostly support a single one for now.
// We should also be using the route path, but currently are not
// mode.routeName
// mode.routes[x].path
// Don't specify default data source, and it should just be picked up... (this may not currently be the case)
// How do we know which params to pass? Today, it's just StudyInstanceUIDs and configUrl if exists
const query = new URLSearchParams();
if (filterValues.configUrl) {
query.append('configUrl', filterValues.configUrl);
}
query.append('StudyInstanceUIDs', studyInstanceUid);
preserveQueryParameters(query);
return (
mode.displayName && (
<Link
className={isValidMode ? '' : 'cursor-not-allowed'}
key={i}
to={`${publicUrl}${mode.routeName}${dataPath || ''}?${query.toString()}`}
onClick={event => {
// In case any event bubbles up for an invalid mode, prevent the navigation.
// For example, the event bubbles up when the icon embedded in the disabled button is clicked.
if (!isValidMode) {
event.preventDefault();
}
}}
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
>
{/* TODO revisit the completely rounded style of buttons used for launching a mode from the worklist later - for now use LegacyButton*/}
<Button
type={ButtonEnums.type.primary}
size={ButtonEnums.size.medium}
disabled={!isValidMode}
startIconTooltip={
!isValidMode ? (
<div className="font-inter flex w-[206px] whitespace-normal text-left text-xs font-normal text-white">
{invalidModeDescription}
</div>
) : null
}
startIcon={
isValidMode ? (
<Icons.LaunchArrow className="!h-[20px] !w-[20px] text-black" />
) : (
<Icons.LaunchInfo className="!h-[20px] !w-[20px] text-black" />
)
}
onClick={() => {}}
dataCY={`mode-${mode.routeName}-${studyInstanceUid}`}
className={isValidMode ? 'text-[13px]' : 'bg-[#222d44] text-[13px]'}
>
{mode.displayName}
</Button>
</Link>
)
);
})}
</div>
</StudyListExpandedRow>
),
onClickRow: () =>
setExpandedRows(s => (isExpanded ? s.filter(n => rowKey !== n) : [...s, rowKey])),
isExpanded,
};
});
const hasStudies = numOfStudies > 0;
const versionNumber = process.env.VERSION_NUMBER;
const commitHash = process.env.COMMIT_HASH;
const menuOptions = [
{
title: t('Header:About'),
icon: 'info',
onClick: () =>
show({
content: AboutModal,
title: t('AboutModal:About OHIF Viewer'),
contentProps: { versionNumber, commitHash },
containerDimensions: 'max-w-4xl max-h-4xl',
}),
},
{
title: t('Header:Preferences'),
icon: 'settings',
onClick: () =>
show({
title: t('UserPreferencesModal:User preferences'),
content: UserPreferences,
contentProps: {
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults),
hotkeyDefinitions,
onCancel: hide,
currentLanguage: currentLanguage(),
availableLanguages,
defaultLanguage,
onSubmit: state => {
if (state.language.value !== currentLanguage().value) {
i18n.changeLanguage(state.language.value);
}
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
hide();
},
onReset: () => hotkeysManager.restoreDefaultBindings(),
hotkeysModule: hotkeys,
},
}),
},
];
if (appConfig.oidc) {
menuOptions.push({
icon: 'power-off',
title: t('Header:Logout'),
onClick: () => {
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
},
});
}
const { customizationService } = servicesManager.services;
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
);
const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent');
const uploadProps =
DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled
? {
title: 'Upload files',
closeButton: true,
shouldCloseOnEsc: false,
shouldCloseOnOverlayClick: false,
content: () => (
<DicomUploadComponent
dataSource={dataSource}
onComplete={() => {
hide();
onRefresh();
}}
onStarted={() => {
show({
...uploadProps,
// when upload starts, hide the default close button as closing the dialogue must be handled by the upload dialogue itself
closeButton: false,
});
}}
/>
),
}
: undefined;
const dataSourceConfigurationComponent = customizationService.getCustomization(
'ohif.dataSourceConfigurationComponent'
);
return (
<div className="flex h-screen flex-col bg-black">
<Header
isSticky
menuOptions={menuOptions}
isReturnEnabled={false}
WhiteLabeling={appConfig.whiteLabeling}
showPatientInfo={PatientInfoVisibility.DISABLED}
/>
<Onboarding />
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
<div className="flex h-full flex-col overflow-y-auto">
<ScrollArea>
<div className="flex grow flex-col">
<StudyListFilter
numOfStudies={pageNumber * resultsPerPage > 100 ? 101 : numOfStudies}
filtersMeta={filtersMeta}
filterValues={{ ...filterValues, ...defaultSortValues }}
onChange={setFilterValues}
clearFilters={() => setFilterValues(defaultFilterValues)}
isFiltering={isFiltering(filterValues, defaultFilterValues)}
onUploadClick={uploadProps ? () => show(uploadProps) : undefined}
getDataSourceConfigurationComponent={
dataSourceConfigurationComponent
? () => dataSourceConfigurationComponent()
: undefined
}
/>
</div>
{hasStudies ? (
<div className="flex grow flex-col">
<StudyListTable
tableDataSource={tableDataSource.slice(offset, offsetAndTake)}
numOfStudies={numOfStudies}
querying={querying}
filtersMeta={filtersMeta}
/>
<div className="grow">
<StudyListPagination
onChangePage={onPageNumberChange}
onChangePerPage={onResultsPerPageChange}
currentPage={pageNumber}
perPage={resultsPerPage}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-48">
{appConfig.showLoadingIndicator && isLoadingData ? (
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
) : (
<EmptyStudies />
)}
</div>
)}
</ScrollArea>
</div>
</div>
);
}
WorkList.propTypes = {
data: PropTypes.array.isRequired,
dataSource: PropTypes.shape({
query: PropTypes.object.isRequired,
getConfig: PropTypes.func,
}).isRequired,
isLoadingData: PropTypes.bool.isRequired,
servicesManager: PropTypes.object.isRequired,
};
const defaultFilterValues = {
patientName: '',
mrn: '',
studyDate: {
startDate: null,
endDate: null,
},
description: '',
modalities: [],
accession: '',
sortBy: '',
sortDirection: 'none',
pageNumber: 1,
resultsPerPage: 25,
datasources: '',
};
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str && str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
return retValue;
}
function _getQueryFilterValues(params) {
const newParams = new URLSearchParams();
for (const [key, value] of params) {
newParams.set(key.toLowerCase(), value);
}
params = newParams;
const queryFilterValues = {
patientName: params.get('patientname'),
mrn: params.get('mrn'),
studyDate: {
startDate: params.get('startdate') || null,
endDate: params.get('enddate') || null,
},
description: params.get('description'),
modalities: params.get('modalities') ? params.get('modalities').split(',') : [],
accession: params.get('accession'),
sortBy: params.get('sortby'),
sortDirection: params.get('sortdirection'),
pageNumber: _tryParseInt(params.get('pagenumber'), undefined),
resultsPerPage: _tryParseInt(params.get('resultsperpage'), undefined),
datasources: params.get('datasources'),
configUrl: params.get('configurl'),
};
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
}
function _sortStringDates(s1, s2, sortModifier) {
// TODO: Delimiters are non-standard. Should we support them?
const s1Date = moment(s1.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
const s2Date = moment(s2.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
if (s1Date.isValid() && s2Date.isValid()) {
return (s1Date.toISOString() > s2Date.toISOString() ? 1 : -1) * sortModifier;
} else if (s1Date.isValid()) {
return sortModifier;
} else if (s2Date.isValid()) {
return -1 * sortModifier;
}
}
export default WorkList;

View File

@@ -0,0 +1,129 @@
import i18n from 'i18next';
const filtersMeta = [
{
name: 'patientName',
displayName: i18n.t('StudyList:PatientName'),
inputType: 'Text',
isSortable: true,
gridCol: 4,
},
{
name: 'mrn',
displayName: i18n.t('StudyList:MRN'),
inputType: 'Text',
isSortable: true,
gridCol: 3,
},
{
name: 'studyDate',
displayName: i18n.t('StudyList:StudyDate'),
inputType: 'DateRange',
isSortable: true,
gridCol: 5,
},
{
name: 'description',
displayName: i18n.t('StudyList:Description'),
inputType: 'Text',
isSortable: true,
gridCol: 4,
},
{
name: 'modalities',
displayName: i18n.t('StudyList:Modality'),
inputType: 'MultiSelect',
inputProps: {
options: [
{ value: 'AR', label: 'AR' },
{ value: 'ASMT', label: 'ASMT' },
{ value: 'AU', label: 'AU' },
{ value: 'BDUS', label: 'BDUS' },
{ value: 'BI', label: 'BI' },
{ value: 'BMD', label: 'BMD' },
{ value: 'CR', label: 'CR' },
{ value: 'CT', label: 'CT' },
{ value: 'CTPROTOCOL', label: 'CTPROTOCOL' },
{ value: 'DG', label: 'DG' },
{ value: 'DOC', label: 'DOC' },
{ value: 'DX', label: 'DX' },
{ value: 'ECG', label: 'ECG' },
{ value: 'EPS', label: 'EPS' },
{ value: 'ES', label: 'ES' },
{ value: 'FID', label: 'FID' },
{ value: 'GM', label: 'GM' },
{ value: 'HC', label: 'HC' },
{ value: 'HD', label: 'HD' },
{ value: 'IO', label: 'IO' },
{ value: 'IOL', label: 'IOL' },
{ value: 'IVOCT', label: 'IVOCT' },
{ value: 'IVUS', label: 'IVUS' },
{ value: 'KER', label: 'KER' },
{ value: 'KO', label: 'KO' },
{ value: 'LEN', label: 'LEN' },
{ value: 'LS', label: 'LS' },
{ value: 'MG', label: 'MG' },
{ value: 'MR', label: 'MR' },
{ value: 'M3D', label: 'M3D' },
{ value: 'NM', label: 'NM' },
{ value: 'OAM', label: 'OAM' },
{ value: 'OCT', label: 'OCT' },
{ value: 'OP', label: 'OP' },
{ value: 'OPM', label: 'OPM' },
{ value: 'OPT', label: 'OPT' },
{ value: 'OPTBSV', label: 'OPTBSV' },
{ value: 'OPTENF', label: 'OPTENF' },
{ value: 'OPV', label: 'OPV' },
{ value: 'OSS', label: 'OSS' },
{ value: 'OT', label: 'OT' },
{ value: 'PLAN', label: 'PLAN' },
{ value: 'PR', label: 'PR' },
{ value: 'PT', label: 'PT' },
{ value: 'PX', label: 'PX' },
{ value: 'REG', label: 'REG' },
{ value: 'RESP', label: 'RESP' },
{ value: 'RF', label: 'RF' },
{ value: 'RG', label: 'RG' },
{ value: 'RTDOSE', label: 'RTDOSE' },
{ value: 'RTIMAGE', label: 'RTIMAGE' },
{ value: 'RTINTENT', label: 'RTINTENT' },
{ value: 'RTPLAN', label: 'RTPLAN' },
{ value: 'RTRAD', label: 'RTRAD' },
{ value: 'RTRECORD', label: 'RTRECORD' },
{ value: 'RTSEGANN', label: 'RTSEGANN' },
{ value: 'RTSTRUCT', label: 'RTSTRUCT' },
{ value: 'RWV', label: 'RWV' },
{ value: 'SEG', label: 'SEG' },
{ value: 'SM', label: 'SM' },
{ value: 'SMR', label: 'SMR' },
{ value: 'SR', label: 'SR' },
{ value: 'SRF', label: 'SRF' },
{ value: 'STAIN', label: 'STAIN' },
{ value: 'TEXTUREMAP', label: 'TEXTUREMAP' },
{ value: 'TG', label: 'TG' },
{ value: 'US', label: 'US' },
{ value: 'VA', label: 'VA' },
{ value: 'XA', label: 'XA' },
{ value: 'XC', label: 'XC' },
],
},
isSortable: true,
gridCol: 3,
},
{
name: 'accession',
displayName: i18n.t('StudyList:AccessionNumber'),
inputType: 'Text',
isSortable: true,
gridCol: 3,
},
{
name: 'instances',
displayName: i18n.t('StudyList:Instances'),
inputType: 'None',
isSortable: false,
gridCol: 2,
},
];
export default filtersMeta;

View File

@@ -0,0 +1 @@
export { default } from './WorkList';

View File

@@ -0,0 +1,90 @@
import React from 'react';
import ModeRoute from '@routes/Mode';
import publicUrl from '../utils/publicUrl';
/*
Routes uniquely define an entry point to:
- A mode
- Linked to a data source
- With a specified data set.
The full route template is:
/:modeId/:modeRoute/:sourceType/?queryParameters=example
Where:
:modeId - Is the mode selected.
:modeRoute - Is the route within the mode to select.
:sourceType - Is the data source identifier, which specifies which DataSource to use.
?queryParameters - Are query parameters as defined by data source.
A default source can be specified at the app level configuration, and then that source is used if :sourceType is omitted:
/:modeId/:modeRoute/?queryParameters=example
*/
export default function buildModeRoutes({
modes,
dataSources,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
}: withAppTypes) {
const routes = [];
const dataSourceNames = [];
dataSources.forEach(dataSource => {
const { sourceName } = dataSource;
if (!dataSourceNames.includes(sourceName)) {
dataSourceNames.push(sourceName);
}
});
modes.forEach(mode => {
// todo: for each route. add route to path.
dataSourceNames.forEach(dataSourceName => {
const path = `${publicUrl}${mode.routeName}/${dataSourceName}`;
// TODO move up.
const children = () => (
<ModeRoute
mode={mode}
dataSourceName={dataSourceName}
extensionManager={extensionManager}
servicesManager={servicesManager}
commandsManager={commandsManager}
hotkeysManager={hotkeysManager}
/>
);
routes.push({
path,
children,
private: true,
});
});
// Add active DataSource route.
// This is the DataSource route for the active data source defined in ExtensionManager.getActiveDataSource
const path = `${publicUrl}${mode.routeName}`;
// TODO move up.
const children = () => (
<ModeRoute
mode={mode}
extensionManager={extensionManager}
servicesManager={servicesManager}
commandsManager={commandsManager}
hotkeysManager={hotkeysManager}
/>
);
routes.push({
path,
children,
private: true,
});
});
return routes;
}

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { Routes, Route, Link } from 'react-router-dom';
import { ErrorBoundary } from '@ohif/ui-next';
// Route Components
import DataSourceWrapper from './DataSourceWrapper';
import WorkList from './WorkList';
import Local from './Local';
import Debug from './Debug';
import NotFound from './NotFound';
import buildModeRoutes from './buildModeRoutes';
import PrivateRoute from './PrivateRoute';
import PropTypes from 'prop-types';
import publicUrl from '../utils/publicUrl';
const NotFoundServer = ({
message = 'Unable to query for studies at this time. Check your data source configuration or network connection',
}) => {
return (
<div className="absolute flex h-full w-full items-center justify-center text-white">
<div>
<h4>{message}</h4>
</div>
</div>
);
};
NotFoundServer.propTypes = {
message: PropTypes.string,
};
const NotFoundStudy = () => {
return (
<div className="absolute flex h-full w-full items-center justify-center text-white">
<div>
<h4>
One or more of the requested studies are not available at this time. Return to the{' '}
<Link
className="text-primary-light"
to={'/'}
>
study list
</Link>{' '}
to select a different study to view.
</h4>
</div>
</div>
);
};
NotFoundStudy.propTypes = {
message: PropTypes.string,
};
// TODO: Include "routes" debug route if dev build
const bakedInRoutes = [
{
path: `${publicUrl}notfoundserver`,
children: NotFoundServer,
},
{
path: `${publicUrl}notfoundstudy`,
children: NotFoundStudy,
},
{
path: `${publicUrl}debug`,
children: Debug,
},
{
path: `${publicUrl}local`,
children: Local.bind(null, { modePath: '' }), // navigate to the worklist
},
{
path: `${publicUrl}localbasic`,
children: Local.bind(null, { modePath: 'viewer/dicomlocal' }),
},
];
// NOT FOUND (404)
const notFoundRoute = { component: NotFound };
const createRoutes = ({
modes,
dataSources,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
routerBasename,
showStudyList,
}: withAppTypes) => {
const routes =
buildModeRoutes({
modes,
dataSources,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
}) || [];
const { customizationService } = servicesManager.services;
const WorkListRoute = {
path: publicUrl,
children: DataSourceWrapper,
private: true,
props: { children: WorkList, servicesManager, extensionManager },
};
const customRoutes = customizationService.getCustomization('routes.customRoutes');
const allRoutes = [
...routes,
...(showStudyList ? [WorkListRoute] : []),
...(publicUrl !== '/' && showStudyList ? [{ ...WorkListRoute, path: publicUrl }] : []),
...(customRoutes?.routes || []),
...bakedInRoutes,
customRoutes?.notFoundRoute || notFoundRoute,
];
function RouteWithErrorBoundary({ route, ...rest }) {
// eslint-disable-next-line react/jsx-props-no-spreading
return (
<ErrorBoundary context={`Route ${route.path}`}>
<route.children
{...rest}
{...route.props}
route={route}
servicesManager={servicesManager}
extensionManager={extensionManager}
hotkeysManager={hotkeysManager}
/>
</ErrorBoundary>
);
}
const { userAuthenticationService } = servicesManager.services;
// All routes are private by default and then we let the user auth service
// to check if it is enabled or not
// Todo: I think we can remove the second public return below
return (
<Routes>
{allRoutes.map((route, i) => {
return route.private === true ? (
<Route
key={i}
path={route.path}
element={
<PrivateRoute
handleUnauthenticated={() => userAuthenticationService.handleUnauthenticated()}
>
<RouteWithErrorBoundary route={route} />
</PrivateRoute>
}
></Route>
) : (
<Route
key={i}
path={route.path}
element={<RouteWithErrorBoundary route={route} />}
/>
);
})}
</Routes>
);
};
export default createRoutes;

View File

@@ -0,0 +1,8 @@
describe('Sanity Test', () => {
test('how many marbles?', () => {
const expectedMarbles = 4;
const actualMarbles = 2 + 2;
expect(actualMarbles).toEqual(expectedMarbles);
});
});

View File

@@ -0,0 +1,71 @@
navigator.serviceWorker.getRegistrations().then(function (registrations) {
for (let registration of registrations) {
registration.unregister();
}
});
// https://developers.google.com/web/tools/workbox/guides/troubleshoot-and-debug
importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.0.0-beta.1/workbox-sw.js');
// Install newest
// https://developers.google.com/web/tools/workbox/modules/workbox-core
workbox.core.skipWaiting();
workbox.core.clientsClaim();
// Cache static assets that aren't precached
workbox.routing.registerRoute(
/\.(?:js|css|json5)$/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'static-resources',
})
);
// Cache the Google Fonts stylesheets with a stale-while-revalidate strategy.
workbox.routing.registerRoute(
/^https:\/\/fonts\.googleapis\.com/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'google-fonts-stylesheets',
})
);
// Cache the underlying font files with a cache-first strategy for 1 year.
workbox.routing.registerRoute(
/^https:\/\/fonts\.gstatic\.com/,
new workbox.strategies.CacheFirst({
cacheName: 'google-fonts-webfonts',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 Year
maxEntries: 30,
}),
],
})
);
// MESSAGE HANDLER
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
switch (event.data.type) {
case 'SKIP_WAITING':
// TODO: We'll eventually want this to be user prompted
// workbox.core.skipWaiting();
// workbox.core.clientsClaim();
// TODO: Global notification to indicate incoming reload
break;
default:
console.warn(`SW: Invalid message type: ${event.data.type}`);
}
}
});
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// TODO: Cache API
// https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api
// Store DICOMs?
// Clear Service Worker cache?
// navigator.storage.estimate().then(est => console.log(est)); (2GB?)

View File

@@ -0,0 +1,20 @@
import React, { useState, createContext, useContext } from 'react';
import PropTypes from 'prop-types';
const appConfigContext = createContext(null);
const { Provider } = appConfigContext;
export const useAppConfig = () => useContext(appConfigContext);
export function AppConfigProvider({ children, value: initAppConfig }) {
const [appConfig, setAppConfig] = useState(initAppConfig);
return <Provider value={[appConfig, setAppConfig]}>{children}</Provider>;
}
AppConfigProvider.propTypes = {
children: PropTypes.any,
value: PropTypes.any,
};
export default AppConfigProvider;

View File

@@ -0,0 +1,3 @@
import { AppConfigProvider, useAppConfig } from './appConfig.tsx';
export { AppConfigProvider, useAppConfig };

View File

@@ -0,0 +1,230 @@
import React from 'react';
import { useEffect } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router';
import CallbackPage from '../routes/CallbackPage';
import SignoutCallbackComponent from '../routes/SignoutCallbackComponent';
import LegacyClient from './legacyOIDCClient';
import NextClient from './nextOIDCClient';
function _isAbsoluteUrl(url) {
return url.includes('http://') || url.includes('https://');
}
function _makeAbsoluteIfNecessary(url, base_url) {
if (_isAbsoluteUrl(url)) {
return url;
}
/*
* Make sure base_url and url are not duplicating slashes.
*/
if (base_url[base_url.length - 1] === '/') {
base_url = base_url.slice(0, base_url.length - 1);
}
return base_url + url;
}
const initUserManager = (oidc, routerBasename) => {
if (!oidc || !oidc.length) {
return;
}
const firstOpenIdClient = oidc[0];
const { protocol, host } = window.location;
const baseUri = `${protocol}//${host}${routerBasename}`;
const redirect_uri = firstOpenIdClient.redirect_uri || '/callback';
const silent_redirect_uri = firstOpenIdClient.silent_redirect_uri || '/silent-refresh.html';
const post_logout_redirect_uri = firstOpenIdClient.post_logout_redirect_uri || '/';
const openIdConnectConfiguration = Object.assign({}, firstOpenIdClient, {
redirect_uri: _makeAbsoluteIfNecessary(redirect_uri, baseUri),
silent_redirect_uri: _makeAbsoluteIfNecessary(silent_redirect_uri, baseUri),
post_logout_redirect_uri: _makeAbsoluteIfNecessary(post_logout_redirect_uri, baseUri),
});
const client = firstOpenIdClient.response_type === 'code' ? NextClient : LegacyClient;
return client(openIdConnectConfiguration);
};
function LogoutComponent(props) {
const { userManager } = props;
localStorage.setItem('signoutEvent', 'true');
const location = useLocation();
const query = new URLSearchParams(location.search);
userManager.signoutRedirect({
post_logout_redirect_uri: query.get('redirect_uri'),
});
return null;
}
function LoginComponent(userManager) {
const queryParams = new URLSearchParams(location.search);
const iss = queryParams.get('iss');
const loginHint = queryParams.get('login_hint');
const targetLinkUri = queryParams.get('target_link_uri');
if (iss !== oidcAuthority) {
console.error('iss of /login does not match the oidc authority');
return null;
}
userManager.removeUser().then(() => {
if (targetLinkUri !== null) {
const ohifRedirectTo = {
pathname: new URL(targetLinkUri).pathname,
};
sessionStorage.setItem('ohif-redirect-to', JSON.stringify(ohifRedirectTo));
} else {
const ohifRedirectTo = {
pathname: '/',
};
sessionStorage.setItem('ohif-redirect-to', JSON.stringify(ohifRedirectTo));
}
if (loginHint !== null) {
userManager.signinRedirect({ login_hint: loginHint });
} else {
userManager.signinRedirect();
}
});
return null;
}
function OpenIdConnectRoutes({ oidc, routerBasename, userAuthenticationService }) {
const userManager = initUserManager(oidc, routerBasename);
const getAuthorizationHeader = () => {
const user = userAuthenticationService.getUser();
// if the user is null return early, next time
// we hit this function we will have a user
if (!user) {
return;
}
return {
Authorization: `Bearer ${user.access_token}`,
};
};
const handleUnauthenticated = () => {
// Note: Don't await the redirect. If you make this component async it
// causes a react error before redirect as it returns a promise of a component rather than a component.
userManager.signinRedirect();
// return null because this is used in a react component
return null;
};
const navigate = useNavigate();
//for multi-tab logout
useEffect(() => {
localStorage.removeItem('signoutEvent');
const storageEventListener = event => {
const signOutEvent = localStorage.getItem('signoutEvent');
if (signOutEvent) {
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
}
};
window.addEventListener('storage', storageEventListener);
return () => {
window.removeEventListener('storage', storageEventListener);
};
}, []);
useEffect(() => {
userAuthenticationService.set({ enabled: true });
userAuthenticationService.setServiceImplementation({
getAuthorizationHeader,
handleUnauthenticated,
});
}, []);
const oidcAuthority = oidc[0].authority;
const location = useLocation();
const { pathname, search } = location;
const redirectURI = userManager.settings._redirect_uri ?? userManager.settings.redirect_uri;
const silentRedirectURI =
userManager.settings._silent_redirect_uri ?? userManager.settings.silent_redirect_uri;
const postLogoutRedirectURI =
userManager.settings._post_logout_redirect_uri ?? userManager.settings.post_logout_redirect_uri;
const redirect_uri = new URL(redirectURI).pathname.replace(
routerBasename !== '/' ? routerBasename : '',
''
);
const silent_refresh_uri = new URL(silentRedirectURI).pathname; //.replace(routerBasename,'')
const post_logout_redirect_uri = new URL(postLogoutRedirectURI).pathname; //.replace(routerBasename,'');
// const pathnameRelative = pathname.replace(routerBasename,'');
if (pathname !== redirect_uri) {
sessionStorage.setItem('ohif-redirect-to', JSON.stringify({ pathname, search }));
}
return (
<Routes>
<Route
path={silent_refresh_uri}
onEnter={window.location.reload}
/>
<Route
path={post_logout_redirect_uri}
element={
<SignoutCallbackComponent
userManager={userManager}
successCallback={() => console.log('Signout successful')}
errorCallback={error => {
console.warn(error);
console.warn('Signout failed');
}}
/>
}
/>
<Route
path={redirect_uri}
element={
<CallbackPage
userManager={userManager}
onRedirectSuccess={user => {
const { pathname, search = '' } = JSON.parse(
sessionStorage.getItem('ohif-redirect-to')
);
userAuthenticationService.setUser(user);
navigate({
pathname,
search,
});
}}
/>
}
/>
<Route
path="/login"
element={
<LoginComponent
userManager={userManager}
oidcAuthority={oidcAuthority}
/>
}
/>
<Route
path="/logout"
element={<LogoutComponent userManager={userManager} />}
/>
</Routes>
);
}
export default OpenIdConnectRoutes;

View File

@@ -0,0 +1,9 @@
import { NavigateFunction } from 'react-router';
type History = {
navigate: NavigateFunction;
};
export const history: History = {
navigate: null,
};

View File

@@ -0,0 +1,16 @@
/**
* This function is used to check if the filter is used. Its intend is to
* warn the user in case of link with a SeriesInstanceUID was called
* @param instances
* @returns
*/
export default function isSeriesFilterUsed(instances, filters) {
const seriesInstanceUIDs = filters?.seriesInstanceUID;
if (!seriesInstanceUIDs) {
return true;
}
if (!instances.length) {
return false;
}
return seriesInstanceUIDs.includes(instances[0].SeriesInstanceUID);
}

View File

@@ -0,0 +1,30 @@
import { UserManager } from 'oidc-client';
/**
* Creates a userManager from oidcSettings
* LINK: https://github.com/IdentityModel/oidc-client-js/wiki#configuration
*
* @param {Object} oidcSettings
* @param {string} oidcSettings.authServerUrl,
* @param {string} oidcSettings.clientId,
* @param {string} oidcSettings.authRedirectUri,
* @param {string} oidcSettings.postLogoutRedirectUri,
* @param {string} oidcSettings.responseType,
* @param {string} oidcSettings.extraQueryParams,
*/
export default function getUserManagerForOpenIdConnectClient(oidcSettings) {
if (!oidcSettings) {
return;
}
const settings = {
...oidcSettings,
automaticSilentRenew: true,
revokeAccessTokenOnSignout: true,
filterProtocolClaims: true,
};
const userManager = new UserManager(settings);
return userManager;
}

View File

@@ -0,0 +1,38 @@
import { UserManager } from 'oidc-client-ts';
/**
* Creates a userManager from oidcSettings
* LINK: https://github.com/IdentityModel/oidc-client-js/wiki#configuration
*
* @param {Object} oidcSettings
* @param {string} oidcSettings.authServerUrl,
* @param {string} oidcSettings.clientId,
* @param {string} oidcSettings.authRedirectUri,
* @param {string} oidcSettings.postLogoutRedirectUri,
* @param {string} oidcSettings.responseType,
* @param {string} oidcSettings.extraQueryParams,
*/
export default function getUserManagerForOpenIdConnectClient(oidcSettings) {
if (!oidcSettings) {
return;
}
if (!oidcSettings.authority || !oidcSettings.client_id || !oidcSettings.redirect_uri) {
console.error('Missing required oidc settings: authority, client_id, redirect_uri');
return;
}
const settings = {
...oidcSettings,
// The next client always use the code flow with PKCE
response_type: 'code',
revokeTokensOnSignout: oidcSettings.revokeAccessTokenOnSignout ?? true,
filterProtocolClaims: true,
// the followings are default values in the lib so no need to set them
// automaticSilentRenew: true,
};
const userManager = new UserManager(settings);
return userManager;
}

View File

@@ -0,0 +1,26 @@
function preserve(query, current, key) {
const value = current.get(key);
if (value) {
query.append(key, value);
}
}
export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber'];
export function preserveQueryParameters(
query,
current = new URLSearchParams(window.location.search)
) {
for (const key of preserveKeys) {
preserve(query, current, key);
}
}
export function preserveQueryStrings(query, current = new URLSearchParams(window.location.search)) {
for (const key of preserveKeys) {
const value = current.get(key);
if (value) {
query[key] = value;
}
}
}

View File

@@ -0,0 +1,4 @@
const publicUrl = (window as any).PUBLIC_URL || '/';
export default publicUrl;
export { publicUrl };