chore(build): try to build demo and docs (#3434)

This commit is contained in:
Alireza authored and GitHub committed 2023-06-06 13:34:26 -04:00
1 parent 869b8ced05
commit 828690cd3b
329 files changed
+1896 -1010

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
// External
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import i18n from '@ohif/i18n';
import { I18nextProvider } from 'react-i18next';
import { BrowserRouter } from 'react-router-dom';
import Compose from './routes/Mode/Compose';
import {
ServicesManager,
ExtensionManager,
CommandsManager,
HotkeysManager,
} from '@ohif/core';
import {
DialogProvider,
Modal,
ModalProvider,
SnackbarProvider,
ThemeWrapper,
ViewportDialogProvider,
ViewportGridProvider,
CineProvider,
UserAuthenticationProvider,
} from '@ohif/ui';
// Viewer Project
// TODO: Should this influence study list?
import { AppConfigProvider } from '@state';
import createRoutes from './routes';
import appInit from './appInit.js';
import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes';
let commandsManager: CommandsManager,
extensionManager: ExtensionManager,
servicesManager: ServicesManager,
hotkeysManager: HotkeysManager;
function App({ config, defaultExtensions, defaultModes }) {
const [init, setInit] = useState(null);
useEffect(() => {
const run = async () => {
appInit(config, defaultExtensions, defaultModes)
.then(setInit)
.catch(console.error);
};
run();
}, []);
if (!init) {
return null;
}
// Set above for named export
commandsManager = init.commandsManager;
extensionManager = init.extensionManager;
servicesManager = init.servicesManager;
hotkeysManager = init.hotkeysManager;
// Set appConfig
const appConfigState = init.appConfig;
const {
routerBasename,
modes,
dataSources,
oidc,
showStudyList,
} = appConfigState;
const {
uiDialogService,
uiModalService,
uiNotificationService,
uiViewportDialogService,
viewportGridService,
cineService,
userAuthenticationService,
customizationService,
} = servicesManager.services;
const providers = [
[AppConfigProvider, { value: appConfigState }],
[UserAuthenticationProvider, { service: userAuthenticationService }],
[I18nextProvider, { i18n }],
[ThemeWrapper],
[ViewportGridProvider, { service: viewportGridService }],
[ViewportDialogProvider, { service: uiViewportDialogService }],
[CineProvider, { service: cineService }],
[SnackbarProvider, { service: uiNotificationService }],
[DialogProvider, { service: uiDialogService }],
[ModalProvider, { service: uiModalService, modal: Modal }],
];
const CombinedProviders = ({ children }) =>
Compose({ components: providers, children });
let authRoutes = null;
// Should there be a generic call to init on the extension manager?
customizationService.init(extensionManager);
// Use config to create routes
const appRoutes = createRoutes({
modes,
dataSources,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
routerBasename,
showStudyList,
});
if (oidc) {
authRoutes = (
<OpenIdConnectRoutes
oidc={oidc}
routerBasename={routerBasename}
userAuthenticationService={userAuthenticationService}
/>
);
}
return (
<CombinedProviders>
<BrowserRouter basename={routerBasename}>
{authRoutes}
{appRoutes}
</BrowserRouter>
</CombinedProviders>
);
}
App.propTypes = {
config: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({
routerBasename: PropTypes.string.isRequired,
oidc: PropTypes.array,
whiteLabeling: PropTypes.object,
extensions: PropTypes.array,
}),
]).isRequired,
/* Extensions that are "bundled" or "baked-in" to the application.
* These would be provided at build time as part of they entry point. */
defaultExtensions: PropTypes.array,
};
App.defaultProps = {
config: {
/**
* Relative route from domain root that OHIF instance is installed at.
* For example:
*
* Hosted at: https://ohif.org/where-i-host-the/viewer/
* Value: `/where-i-host-the/viewer/`
* */
routerBaseName: '/',
/**
*
*/
showLoadingIndicator: true,
showStudyList: true,
oidc: [],
extensions: [],
},
defaultExtensions: [],
};
export default App;
export { commandsManager, extensionManager, servicesManager };
+3
View File
@@ -0,0 +1,3 @@
// https://jestjs.io/docs/en/webpack#handling-static-assets
module.exports = 'test-file-stub';
@@ -0,0 +1,9 @@
const _ = require('lodash');
const originalConsoleError = console.error;
// JSDom's CSS Parser has limited support for certain features
// This supresses error warnings caused by it
console.error = function(msg) {
if (_.startsWith(msg, 'Error: Could not parse CSS stylesheet')) return;
originalConsoleError(msg);
};
+138
View File
@@ -0,0 +1,138 @@
import {
CommandsManager,
ExtensionManager,
ServicesManager,
HotkeysManager,
UINotificationService,
UIModalService,
UIDialogService,
UIViewportDialogService,
MeasurementService,
StateSyncService,
DisplaySetService,
ToolbarService,
ViewportGridService,
HangingProtocolService,
CineService,
UserAuthenticationService,
errorHandler,
CustomizationService,
PanelService,
// utils,
} from '@ohif/core';
import loadModules from './pluginImports';
/**
* @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration
* @param {object[]} defaultExtensions - array of extension objects
*/
async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
const commandsManagerConfig = {
getAppState: () => {},
};
const commandsManager = new CommandsManager(commandsManagerConfig);
const servicesManager = new ServicesManager(commandsManager);
const hotkeysManager = new HotkeysManager(commandsManager, servicesManager);
const appConfig = {
...(typeof appConfigOrFunc === 'function'
? await appConfigOrFunc({ servicesManager })
: appConfigOrFunc),
};
const extensionManager = new ExtensionManager({
commandsManager,
servicesManager,
hotkeysManager,
appConfig,
});
servicesManager.registerServices([
UINotificationService.REGISTRATION,
UIModalService.REGISTRATION,
UIDialogService.REGISTRATION,
UIViewportDialogService.REGISTRATION,
MeasurementService.REGISTRATION,
DisplaySetService.REGISTRATION,
[CustomizationService.REGISTRATION, appConfig.customizationService],
ToolbarService.REGISTRATION,
ViewportGridService.REGISTRATION,
HangingProtocolService.REGISTRATION,
CineService.REGISTRATION,
UserAuthenticationService.REGISTRATION,
PanelService.REGISTRATION,
StateSyncService.REGISTRATION,
]);
errorHandler.getHTTPErrorHandler = () => {
if (typeof appConfig.httpErrorHandler === 'function') {
return appConfig.httpErrorHandler;
}
};
/**
* Example: [ext1, ext2, ext3]
* Example2: [[ext1, config], ext2, [ext3, config]]
*/
const loadedExtensions = await loadModules([
...defaultExtensions,
...appConfig.extensions,
]);
await extensionManager.registerExtensions(
loadedExtensions,
appConfig.dataSources
);
// TODO: We no longer use `utils.addServer`
// TODO: We no longer init webWorkers at app level
// TODO: We no longer init the user Manager
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
const loadedModes = await loadModules([
...(appConfig.modes || []),
...defaultModes,
]);
// This is the name for the loaded istance object
appConfig.loadedModes = [];
const modesById = new Set();
for (let i = 0; i < loadedModes.length; i++) {
let mode = loadedModes[i];
if (!mode) continue;
const { id } = mode;
if (mode.modeFactory) {
// If the appConfig contains configuration for this mode, use it.
const modeConfig =
appConfig.modeConfig && appConfig.modeConfig[i]
? appConfig.modeConfig[id]
: {};
mode = mode.modeFactory(modeConfig);
}
if (modesById.has(id)) continue;
// Prevent duplication
modesById.add(id);
if (!mode || typeof mode !== 'object') continue;
appConfig.loadedModes.push(mode);
}
// Hack alert - don't touch the original modes definition,
// but there are still dependencies on having the appConfig modes defined
appConfig.modes = appConfig.loadedModes;
return {
appConfig,
commandsManager,
extensionManager,
servicesManager,
hotkeysManager,
};
}
export default appInit;
@@ -0,0 +1,7 @@
import React from 'react';
function EmptyViewport() {
return <div></div>;
}
export default EmptyViewport;
@@ -0,0 +1,407 @@
import React, { useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { ServicesManager, Types, MeasurementService } from '@ohif/core';
import { ViewportGrid, ViewportPane, useViewportGrid } from '@ohif/ui';
import EmptyViewport from './EmptyViewport';
import classNames from 'classnames';
function ViewerViewportGrid(props) {
const { servicesManager, viewportComponents, dataSource } = props;
const [viewportGrid, viewportGridService] = useViewportGrid();
const { layout, activeViewportIndex, viewports } = viewportGrid;
const { numCols, numRows } = layout;
// TODO -> Need some way of selecting which displaySets hit the viewports.
const {
displaySetService,
measurementService,
hangingProtocolService,
uiNotificationService,
} = (servicesManager as ServicesManager).services;
/**
* This callback runs after the viewports structure has changed in any way.
* On initial display, that means if it has changed by applying a HangingProtocol,
* while subsequently it may mean by changing the stage or by manually adjusting
* the layout.
*/
const updateDisplaySetsFromProtocol = (
protocol: Types.HangingProtocol.Protocol,
stage,
activeStudyUID,
viewportMatchDetails
) => {
const availableDisplaySets = displaySetService.getActiveDisplaySets();
if (!availableDisplaySets.length) {
console.log('No available display sets', availableDisplaySets);
return;
}
// Match each viewport individually
const { layoutType } = stage.viewportStructure;
const stageProps = stage.viewportStructure.properties;
const { columns: numCols, rows: numRows, layoutOptions = [] } = stageProps;
/**
* This find or create viewport uses the hanging protocol results to
* specify the viewport match details, which specifies the size and
* setup of the various viewports.
*/
const findOrCreateViewport = viewportIndex => {
const details = viewportMatchDetails.get(viewportIndex);
if (!details) {
console.log('No match details for viewport', viewportIndex);
return;
}
const { displaySetsInfo, viewportOptions } = details;
const displaySetUIDsToHang = [];
const displaySetUIDsToHangOptions = [];
displaySetsInfo.forEach(
({ displaySetInstanceUID, displaySetOptions }) => {
if (displaySetInstanceUID) {
displaySetUIDsToHang.push(displaySetInstanceUID);
}
displaySetUIDsToHangOptions.push(displaySetOptions);
}
);
const computedViewportOptions = hangingProtocolService.getComputedOptions(
viewportOptions,
displaySetUIDsToHang
);
const computedDisplaySetOptions = hangingProtocolService.getComputedOptions(
displaySetUIDsToHangOptions,
displaySetUIDsToHang
);
return {
displaySetInstanceUIDs: displaySetUIDsToHang,
displaySetOptions: computedDisplaySetOptions,
viewportOptions: computedViewportOptions,
};
};
viewportGridService.setLayout({
numRows,
numCols,
layoutType,
layoutOptions,
findOrCreateViewport,
});
};
const _getUpdatedViewports = useCallback(
(viewportIndex, displaySetInstanceUID) => {
let updatedViewports = [];
try {
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
viewportIndex,
displaySetInstanceUID
);
} catch (error) {
console.warn(error);
uiNotificationService.show({
title: 'Drag and Drop',
message:
'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
type: 'info',
duration: 3000,
});
}
return updatedViewports;
},
[hangingProtocolService, uiNotificationService]
);
// Using Hanging protocol engine to match the displaySets
useEffect(() => {
const { unsubscribe } = hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
({ protocol, stage, activeStudyUID, viewportMatchDetails }) => {
updateDisplaySetsFromProtocol(
protocol,
stage,
activeStudyUID,
viewportMatchDetails
);
}
);
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
({ viewportIndex, measurement, isConsumed }) => {
if (isConsumed) return;
// This occurs when no viewport has elected to consume the event
// so we need to change layouts into a layout which can consume
// the event.
const { displaySetInstanceUID: referencedDisplaySetInstanceUID } =
measurement;
const updatedViewports = _getUpdatedViewports(
viewportIndex,
referencedDisplaySetInstanceUID
);
// Arbitrarily assign the viewport to element 0
const viewport = updatedViewports?.[0];
if (!viewport) {
console.warn(
'ViewportGrid::Unable to navigate to viewport containing',
referencedDisplaySetInstanceUID
);
return;
}
viewport.viewportOptions ||= {};
viewport.viewportOptions.orientation = 'acquisition';
const displaySet = displaySetService.getDisplaySetByUID(
referencedDisplaySetInstanceUID
);
// jump straight to the initial image index if we can
if (displaySet.images && measurement.SOPInstanceUID) {
for (let index = 0; index < displaySet.images.length; index++) {
const image = displaySet.images[index];
if (image.SOPInstanceUID === measurement.SOPInstanceUID) {
viewport.viewportOptions.initialImageOptions = {
index,
};
break;
}
}
}
viewportGridService.setDisplaySetsForViewports(updatedViewports);
}
);
return () => {
unsubscribe();
};
}, [viewports]);
/**
const onDoubleClick = viewportIndex => {
// TODO -> Disabled for now.
// onNewImage on a cornerstone viewport is firing setDisplaySetsForViewport.
// Which it really really shouldn't. We need a larger fix for jump to
// measurements and all cornerstone "imageIndex" state to fix this.
if (cachedLayout) {
viewportGridService.set({
numCols: cachedLayout.numCols,
numRows: cachedLayout.numRows,
activeViewportIndex: cachedLayout.activeViewportIndex,
viewports: cachedLayout.viewports,
cachedLayout: null,
});
return;
}
const cachedViewports = viewports.map(viewport => {
return {
displaySetInstanceUID: viewport.displaySetInstanceUID,
};
});
viewportGridService.set({
numCols: 1,
numRows: 1,
activeViewportIndex: 0,
viewports: [
{
displaySetInstanceUID: viewports[viewportIndex].displaySetInstanceUID,
imageIndex: undefined,
},
],
cachedLayout: {
numCols,
numRows,
viewports: cachedViewports,
activeViewportIndex: viewportIndex,
},
});
};
*/
const onDropHandler = (viewportIndex, { displaySetInstanceUID }) => {
const updatedViewports = _getUpdatedViewports(
viewportIndex,
displaySetInstanceUID
);
viewportGridService.setDisplaySetsForViewports(updatedViewports);
};
const getViewportPanes = useCallback(() => {
const viewportPanes = [];
const numViewportPanes = viewportGridService.getNumViewportPanes();
for (let i = 0; i < numViewportPanes; i++) {
const viewportIndex = i;
const isActive = activeViewportIndex === viewportIndex;
const paneMetadata = viewports[i] || {};
const viewportId = paneMetadata.viewportId || `viewport-${i}`;
if (!paneMetadata.viewportId) {
paneMetadata.viewportId = viewportId;
}
const {
displaySetInstanceUIDs,
viewportOptions,
displaySetOptions, // array of options for each display set in the viewport
x: viewportX,
y: viewportY,
width: viewportWidth,
height: viewportHeight,
viewportLabel,
} = paneMetadata;
const displaySetInstanceUIDsToUse = displaySetInstanceUIDs || [];
// This is causing the viewport components re-render when the activeViewportIndex changes
const displaySets = displaySetInstanceUIDsToUse.map(
displaySetInstanceUID => {
return (
displaySetService.getDisplaySetByUID(displaySetInstanceUID) || {}
);
}
);
const ViewportComponent = _getViewportComponent(
displaySets,
viewportComponents,
uiNotificationService
);
// look inside displaySets to see if they need reRendering
const displaySetsNeedsRerendering = displaySets.some(displaySet => {
return displaySet.needsRerendering;
});
const onInteractionHandler = event => {
if (isActive) return;
if (event) {
event.preventDefault();
event.stopPropagation();
}
viewportGridService.setActiveViewportIndex(viewportIndex);
};
// TEMP -> Double click disabled for now
// onDoubleClick={() => onDoubleClick(viewportIndex)}
viewportPanes[i] = (
<ViewportPane
key={viewportId}
acceptDropsFor="displayset"
onDrop={onDropHandler.bind(null, viewportIndex)}
onInteraction={onInteractionHandler}
customStyle={{
position: 'absolute',
top: viewportY * 100 + 0.2 + '%',
left: viewportX * 100 + 0.2 + '%',
width: viewportWidth * 100 - 0.3 + '%',
height: viewportHeight * 100 - 0.3 + '%',
}}
isActive={isActive}
>
<div
data-cy="viewport-pane"
className={classNames('h-full w-full flex flex-col', {
'pointer-events-none': !isActive,
})}
>
<ViewportComponent
displaySets={displaySets}
viewportIndex={viewportIndex}
viewportLabel={viewports.length > 1 ? viewportLabel : ''}
dataSource={dataSource}
viewportOptions={viewportOptions}
displaySetOptions={displaySetOptions}
needsRerendering={displaySetsNeedsRerendering}
/>
</div>
</ViewportPane>
);
}
return viewportPanes;
}, [viewports, activeViewportIndex, viewportComponents, dataSource]);
/**
* Loading indicator until numCols and numRows are gotten from the HangingProtocolService
*/
if (!numRows || !numCols) {
return null;
}
return (
<ViewportGrid numRows={numRows} numCols={numCols}>
{/* {ViewportPanes} */}
{getViewportPanes()}
</ViewportGrid>
);
}
ViewerViewportGrid.propTypes = {
viewportComponents: PropTypes.array.isRequired,
servicesManager: PropTypes.instanceOf(ServicesManager),
};
ViewerViewportGrid.defaultProps = {
viewportComponents: [],
};
function _getViewportComponent(
displaySets,
viewportComponents,
uiNotificationService
) {
if (!displaySets || !displaySets.length) {
return EmptyViewport;
}
// Todo: Do we have a viewport that has two different SOPClassHandlerIds?
const SOPClassHandlerId = displaySets[0].SOPClassHandlerId;
for (let i = 0; i < viewportComponents.length; i++) {
if (!viewportComponents[i]) {
throw new Error('viewport components not defined');
}
if (!viewportComponents[i].displaySetsToDisplay) {
throw new Error('displaySetsToDisplay is null');
}
if (
viewportComponents[i].displaySetsToDisplay.includes(SOPClassHandlerId)
) {
const { component } = viewportComponents[i];
return component;
}
}
console.log("Can't show displaySet", SOPClassHandlerId, displaySets[0]);
uiNotificationService.show({
title: 'Viewport Not Supported Yet',
message: `Cannot display SOPClassId of ${displaySets[0].SOPClassUID} yet`,
type: 'error',
});
return EmptyViewport;
}
export default ViewerViewportGrid;
+5
View File
@@ -0,0 +1,5 @@
import useDebounce from './useDebounce.js';
import useQuery from './useQuery.js';
import useSearchParams from './useSearchParams.js';
export { useDebounce, useQuery, useSearchParams };
+31
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;
}
+5
View File
@@ -0,0 +1,5 @@
import { useLocation } from 'react-router-dom';
export default function useQuery() {
return new URLSearchParams(useLocation().search);
}
+17
View File
@@ -0,0 +1,17 @@
import useQuery from './useQuery';
/**
* It returns a Map of the query parameters in the URL, where the keys are
* lowercase
* @returns A function that returns a Map of the query parameters.
*/
export default function useSearchParams() {
const query = useQuery();
// make query params case-insensitive
const searchParams = new Map();
for (const [key, value] of query) {
searchParams.set(key.toLowerCase(), value);
}
return searchParams;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Entry point for development and production PWA builds.
*/
import 'regenerator-runtime/runtime';
import App from './App';
import React from 'react';
import ReactDOM from 'react-dom';
import { history } from './utils/history';
/**
* EXTENSIONS AND MODES
* =================
* pluginImports.js is dynamically generated from extension and mode
* configuration at build time.
*
* pluginImports.js imports all of the modes and extensions and adds them
* to the window for processing.
*/
import {
modes as defaultModes,
extensions as defaultExtensions,
} from './pluginImports';
import loadDynamicConfig from './loadDynamicConfig';
loadDynamicConfig(window.config).then(config_json => {
// Reset Dynamic config if defined
if (config_json !== null) {
window.config = config_json;
}
/**
* Combine our appConfiguration with installed extensions and modes.
* In the future appConfiguration may contain modes added at runtime.
* */
const appProps = {
config: window ? window.config : {},
defaultExtensions,
defaultModes,
};
/** Create App */
const app = React.createElement(App, appProps, null);
/** Render */
ReactDOM.render(app, document.getElementById('root'));
});
export { history };
+23
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;
};
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
function CallbackPage({ userManager, onRedirectSuccess }) {
const onRedirectError = error => {
throw new Error(error);
};
userManager
.signinRedirectCallback()
.then(user => onRedirectSuccess(user))
.catch(error => onRedirectError(error));
return null;
}
CallbackPage.propTypes = {
userManager: PropTypes.object.isRequired,
};
export default CallbackPage;
@@ -0,0 +1,198 @@
/* eslint-disable react/jsx-props-no-spreading */
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { MODULE_TYPES } from '@ohif/core';
//
import { extensionManager } from '../App.tsx';
import { useParams, useLocation } from 'react-router';
/**
* Uses route properties to determine the data source that should be passed
* to the child layout template. In some instances, initiates requests and
* passes data as props.
*
* @param {object} props
* @param {function} props.children - Layout Template React Component
*/
function DataSourceWrapper(props) {
const { children: LayoutTemplate, ...rest } = props;
const params = useParams();
const location = useLocation();
// TODO - get the variable from the props all the time...
let dataSourceName = new URLSearchParams(location.search).get('datasources');
const dataPath = dataSourceName ? `/${dataSourceName}` : '';
if (!dataSourceName && window.config.defaultDataSourceName) {
dataSourceName = window.config.defaultDataSourceName;
} else if (!dataSourceName) {
// Gets the first defined datasource with the right name
// Mostly for historical reasons - new configs should use the defaultDataSourceName
const dataSourceModules =
extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
// TODO: Good usecase for flatmap?
const webApiDataSources = dataSourceModules.reduce((acc, curr) => {
const mods = [];
curr.module.forEach(mod => {
if (mod.type === 'webApi') {
mods.push(mod);
}
});
return acc.concat(mods);
}, []);
dataSourceName = webApiDataSources
.map(ds => ds.name)
.find(it => extensionManager.getDataSources(it)?.[0] !== undefined);
}
const dataSource = extensionManager.getDataSources(dataSourceName)?.[0];
if (!dataSource) {
throw new Error(`No data source found for ${dataSourceName}`);
}
// Route props --> studies.mapParams
// mapParams --> studies.search
// studies.search --> studies.processResults
// studies.processResults --> <LayoutTemplate studies={} />
// But only for LayoutTemplate type of 'list'?
// Or no data fetching here, and just hand down my source
const STUDIES_LIMIT = 101;
const DEFAULT_DATA = {
studies: [],
total: 0,
resultsPerPage: 25,
pageNumber: 1,
location: 'Not a valid location, causes first load to occur',
};
const [data, setData] = useState(DEFAULT_DATA);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const queryFilterValues = _getQueryFilterValues(
location.search,
STUDIES_LIMIT
);
// 204: no content
async function getData() {
setIsLoading(true);
const studies = await dataSource.query.studies.search(queryFilterValues);
setData({
studies: studies || [],
total: studies.length,
resultsPerPage: queryFilterValues.resultsPerPage,
pageNumber: queryFilterValues.pageNumber,
location,
});
setIsLoading(false);
}
try {
// Cache invalidation :thinking:
// - Anytime change is not just next/previous page
// - And we didn't cross a result offset range
const isSamePage = data.pageNumber === queryFilterValues.pageNumber;
const previousOffset =
Math.floor((data.pageNumber * data.resultsPerPage) / STUDIES_LIMIT) *
(STUDIES_LIMIT - 1);
const newOffset =
Math.floor(
(queryFilterValues.pageNumber * queryFilterValues.resultsPerPage) /
STUDIES_LIMIT
) *
(STUDIES_LIMIT - 1);
const isLocationUpdated = data.location !== location;
const isDataInvalid =
!isSamePage ||
(!isLoading && (newOffset !== previousOffset || isLocationUpdated));
if (isDataInvalid) {
getData();
}
} catch (ex) {
console.warn(ex);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, location, params, isLoading, setIsLoading]);
// queryFilterValues
// TODO: Better way to pass DataSource?
return (
<LayoutTemplate
{...rest}
data={data.studies}
dataPath={dataPath}
dataTotal={data.total}
dataSource={dataSource}
isLoadingData={isLoading}
// To refresh the data, simply reset it to DEFAULT_DATA which invalidates it and triggers a new query to fetch the data.
onRefresh={() => setData(DEFAULT_DATA)}
/>
);
}
DataSourceWrapper.propTypes = {
/** Layout Component to wrap with a Data Source */
children: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired,
};
export default DataSourceWrapper;
/**
* Duplicated in `workList`
* Need generic that can be shared? Isn't this what qs is for?
* @param {*} query
*/
function _getQueryFilterValues(query, queryLimit) {
query = new URLSearchParams(query);
const pageNumber = _tryParseInt(query.get('pageNumber'), 1);
const resultsPerPage = _tryParseInt(query.get('resultsPerPage'), 25);
const queryFilterValues = {
// DCM
patientId: query.get('mrn'),
patientName: query.get('patientName'),
studyDescription: query.get('description'),
modalitiesInStudy:
query.get('modalities') && query.get('modalities').split(','),
accessionNumber: query.get('accession'),
//
startDate: query.get('startDate'),
endDate: query.get('endDate'),
page: _tryParseInt(query.get('page'), undefined),
pageNumber,
resultsPerPage,
// Rarely supported server-side
sortBy: query.get('sortBy'),
sortDirection: query.get('sortDirection'),
// Offset...
offset:
Math.floor((pageNumber * resultsPerPage) / queryLimit) * (queryLimit - 1),
config: query.get('configUrl'),
};
// patientName: good
// studyDescription: good
// accessionNumber: good
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str !== null) {
if (str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
}
return retValue;
}
}
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import { Icon } from '@ohif/ui';
// this is a debug component that is used to list various things that might
// be useful for debugging such as cross origin errors, etc.
function Debug() {
return (
<div style={{ width: '100%', height: '100%' }}>
<div className="h-screen w-screen flex justify-center items-center ">
<div className="py-8 px-8 mx-auto bg-secondary-dark drop-shadow-md space-y-2 rounded-lg">
<img
className="block mx-auto h-14"
src="./ohif-logo.svg"
alt="OHIF"
/>
<div className="text-center space-y-2 pt-4">
<div className="flex flex-col justify-center items-center">
<p className="text-xl text-primary-active font-semibold mt-4">
Debug Information
</p>
<div className="flex space-x-2 mt-4 items-center">
<p className="text-md text-white">
Cross Origin Isolated (COOP/COEP)
</p>
<Icon
name={
window.crossOriginIsolated
? 'notifications-success'
: 'notifications-error'
}
className="w-5 h-5"
/>
{!window.crossOriginIsolated && (
<div className="text-md text-white flex-1">
We use SharedArrayBuffer to render volume data (e.g., MPR).
If you are seeing this message, it means that your browser
has not enabled COOP/COEP. Please see the following link for
more information:{' '}
<a
href="https://web.dev/coop-coep/"
target="_blank"
rel="noopener noreferrer"
className="text-primary-active"
>
Learn More
</a>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default Debug;
+161
View File
@@ -0,0 +1,161 @@
import React, { useEffect, useRef } from 'react';
import classnames from 'classnames';
import { useNavigate } from 'react-router-dom';
import { DicomMetadataStore, MODULE_TYPES } from '@ohif/core';
import Dropzone from 'react-dropzone';
import filesToStudies from './filesToStudies';
import { extensionManager } from '../../App.tsx';
import { Icon, Button, LoadingIndicatorProgress } from '@ohif/ui';
const getLoadButton = (onDrop, text, isDir) => {
return (
<Dropzone onDrop={onDrop} noDrag>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<Button
rounded="full"
variant="contained" // outlined
disabled={false}
endIcon={<Icon name="launch-arrow" />} // launch-arrow | launch-info
className={classnames('font-medium', 'ml-2')}
onClick={() => {}}
>
{text}
{isDir ? (
<input
{...getInputProps()}
webkitdirectory="true"
mozdirectory="true"
/>
) : (
<input {...getInputProps()} />
)}
</Button>
</div>
)}
</Dropzone>
);
};
type LocalProps = {
modePath: string;
};
function Local({ modePath }: LocalProps) {
const navigate = useNavigate();
const dropzoneRef = useRef();
const [dropInitiated, setDropInitiated] = React.useState(false);
// Initializing the dicom local dataSource
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
const localDataSources = dataSourceModules.reduce((acc, curr) => {
const mods = [];
curr.module.forEach(mod => {
if (mod.type === 'localApi') {
mods.push(mod);
}
});
return acc.concat(mods);
}, []);
const firstLocalDataSource = localDataSources[0];
const dataSource = firstLocalDataSource.createDataSource({});
const microscopyExtensionLoaded = extensionManager.registeredExtensionIds.includes(
'@ohif/extension-dicom-microscopy'
);
const onDrop = async acceptedFiles => {
const studies = await filesToStudies(acceptedFiles, dataSource);
const query = new URLSearchParams();
if (microscopyExtensionLoaded) {
// TODO: for microscopy, we are forcing microscopy mode, which is not ideal.
// we should make the local drag and drop navigate to the worklist and
// there user can select microscopy mode
const smStudies = studies.filter(id => {
const study = DicomMetadataStore.getStudy(id);
return (
study.series.findIndex(
s => s.Modality === 'SM' || s.instances[0].Modality === 'SM'
) >= 0
);
});
if (smStudies.length > 0) {
smStudies.forEach(id => query.append('StudyInstanceUIDs', id));
modePath = 'microscopy';
}
}
// Todo: navigate to work list and let user select a mode
studies.forEach(id => query.append('StudyInstanceUIDs', id));
query.append('datasources', 'dicomlocal');
navigate(`/${modePath}?${decodeURIComponent(query.toString())}`);
};
// Set body style
useEffect(() => {
document.body.classList.add('bg-black');
return () => {
document.body.classList.remove('bg-black');
};
}, []);
return (
<Dropzone
ref={dropzoneRef}
onDrop={acceptedFiles => {
setDropInitiated(true);
onDrop(acceptedFiles);
}}
noClick
>
{({ getRootProps }) => (
<div {...getRootProps()} style={{ width: '100%', height: '100%' }}>
<div className="h-screen w-screen flex justify-center items-center ">
<div className="py-8 px-8 mx-auto bg-secondary-dark drop-shadow-md space-y-2 rounded-lg">
<img
className="block mx-auto h-14"
src="./ohif-logo.svg"
alt="OHIF"
/>
<div className="text-center space-y-2 pt-4">
{dropInitiated ? (
<div className="flex flex-col items-center justify-center pt-48">
<LoadingIndicatorProgress
className={'w-full h-full bg-black'}
/>
</div>
) : (
<div className="space-y-2">
<p className="text-blue-300 text-base">
Note: You data is not uploaded to any server, it will stay
in your local browser application
</p>
<p className="text-xg text-primary-active font-semibold pt-6">
Drag and Drop DICOM files here to load them in the Viewer
</p>
<p className="text-blue-300 text-lg">Or click to </p>
</div>
)}
</div>
<div className="flex justify-around pt-4 ">
{getLoadButton(onDrop, 'Load files', false)}
{getLoadButton(onDrop, 'Load folders', true)}
</div>
</div>
</div>
</div>
)}
</Dropzone>
);
}
export default Local;
@@ -0,0 +1,32 @@
import dcmjs from 'dcmjs';
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import FileLoader from './fileLoader';
const DICOMFileLoader = new (class extends FileLoader {
fileType = 'application/dicom';
loadFile(file, imageId) {
return dicomImageLoader.wadouri.loadFileRequest(imageId);
}
getDataset(image, imageId) {
const dicomData = dcmjs.data.DicomMessage.readFile(image);
const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset(
dicomData.dict
);
dataset.url = imageId;
dataset._meta = dcmjs.data.DicomMetaDictionary.namifyDataset(
dicomData.meta
);
dataset.AvailableTransferSyntaxUID =
dataset.AvailableTransferSyntaxUID ||
dataset._meta.TransferSyntaxUID?.Value?.[0];
return dataset;
}
})();
export default DICOMFileLoader;
@@ -0,0 +1,6 @@
export default class FileLoader {
fileType;
loadFile(file, imageId) { }
getDataset(image, imageId) { }
getStudies(dataset, imageId) { }
}
@@ -0,0 +1,39 @@
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import FileLoader from './fileLoader';
import PDFFileLoader from './pdfFileLoader';
import DICOMFileLoader from './dicomFileLoader';
class FileLoaderService extends FileLoader {
fileType;
loader;
constructor(file) {
super();
const fileType = file && file.type;
this.loader = this.getLoader(fileType);
this.fileType = this.loader.fileType;
}
addFile(file) {
return dicomImageLoader.wadouri.fileManager.add(file);
}
loadFile(file, imageId) {
return this.loader.loadFile(file, imageId);
}
getDataset(image, imageId) {
return this.loader.getDataset(image, imageId);
}
getLoader(fileType) {
if (fileType === 'application/pdf') {
return PDFFileLoader;
} else {
// Default to dicom loader
return DICOMFileLoader;
}
}
}
export default FileLoaderService;
@@ -0,0 +1,26 @@
import FileLoaderService from './fileLoaderService';
import { DicomMetadataStore } from '@ohif/core';
const processFile = async file => {
try {
const fileLoaderService = new FileLoaderService(file);
const imageId = fileLoaderService.addFile(file);
const image = await fileLoaderService.loadFile(file, imageId);
const dicomJSONDataset = await fileLoaderService.getDataset(image, imageId);
DicomMetadataStore.addInstance(dicomJSONDataset);
} catch (error) {
console.log(
error.name,
':Error when trying to load and process local files:',
error.message
);
}
};
export default async function filesToStudies(files) {
const processFilesPromises = files.map(processFile);
await Promise.all(processFilesPromises);
return DicomMetadataStore.getStudyInstanceUIDs();
}
+1
View File
@@ -0,0 +1 @@
export { default } from './Local';
@@ -0,0 +1,17 @@
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import FileLoader from './fileLoader';
const PDFFileLoader = new (class extends FileLoader {
fileType = 'application/pdf';
loadFile(file, imageId) {
return dicomImageLoader.wadouri.loadFileRequest(imageId);
}
getDataset(image, imageId) {
const dataset = {};
dataset.imageId = image.imageId || imageId;
return dataset;
}
})();
export default PDFFileLoader;
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
/**
* Nests React components as ordered in array. We use this to
* simplify composition a Mode specify's in it's configuration
* for React Contexts that should wrap a Mode Route.
*/
export default function Compose(props) {
const { components = [], children } = props;
return (
<React.Fragment>
{components.reduceRight((acc, curr) => {
const [Comp, props] = Array.isArray(curr)
? [curr[0], curr[1]]
: [curr, {}];
return <Comp {...props}>{acc}</Comp>;
}, children)}
</React.Fragment>
);
}
// https://juliuskoronci.medium.com/avoid-a-long-list-of-react-providers-c45a269d80c1
Compose.propTypes = {
components: PropTypes.array,
children: PropTypes.node.isRequired,
};
+463
View File
@@ -0,0 +1,463 @@
import React, { useEffect, useState, useRef } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router';
import PropTypes from 'prop-types';
// TODO: DicomMetadataStore should be injected?
import { DicomMetadataStore, ServicesManager, utils } from '@ohif/core';
import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui';
import { useQuery, useSearchParams } from '@hooks';
import ViewportGrid from '@components/ViewportGrid';
import Compose from './Compose';
import getStudies from './studiesList';
import { history } from '../../utils/history';
import loadModules from '../../pluginImports';
const { getSplitParam } = utils;
/**
* Initialize the route.
*
* @param props.servicesManager to read services from
* @param props.studyInstanceUIDs for a list of studies to read
* @param props.dataSource to read the data from
* @param props.filters filters from query params to read the data from
* @returns array of subscriptions to cancel
*/
function defaultRouteInit(
{ servicesManager, studyInstanceUIDs, dataSource, filters },
hangingProtocolId
) {
const {
displaySetService,
hangingProtocolService,
} = servicesManager.services;
const unsubscriptions = [];
const {
unsubscribe: instanceAddedUnsubscribe,
} = DicomMetadataStore.subscribe(
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
function({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) {
const seriesMetadata = DicomMetadataStore.getSeries(
StudyInstanceUID,
SeriesInstanceUID
);
displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient);
}
);
unsubscriptions.push(instanceAddedUnsubscribe);
const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID =>
dataSource.retrieve.series.metadata({
StudyInstanceUID,
filters,
})
);
// The hanging protocol matching service is fairly expensive to run multiple
// times, and doesn't allow partial matches to be made (it will simply fail
// to display anything if a required match fails), so we wait here until all metadata
// is retrieved (which will synchronously trigger the display set creation)
// until we run the hanging protocol matching service.
Promise.allSettled(allRetrieves).then(() => {
const displaySets = displaySetService.getActiveDisplaySets();
if (!displaySets || !displaySets.length) {
return;
}
// Gets the studies list to use
const studies = getStudies(studyInstanceUIDs, displaySets);
// study being displayed, and is thus the "active" study.
const activeStudy = studies[0];
// run the hanging protocol matching on the displaySets with the predefined
// hanging protocol in the mode configuration
hangingProtocolService.run(
{ studies, activeStudy, displaySets },
hangingProtocolId
);
});
return unsubscriptions;
}
export default function ModeRoute({
mode,
dataSourceName,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
}) {
// Parse route params/querystring
const location = useLocation();
const query = useQuery();
const params = useParams();
const searchParams = useSearchParams();
const [studyInstanceUIDs, setStudyInstanceUIDs] = useState();
const [refresh, setRefresh] = useState(false);
const [
ExtensionDependenciesLoaded,
setExtensionDependenciesLoaded,
] = useState(false);
const layoutTemplateData = useRef(false);
const locationRef = useRef(null);
const isMounted = useRef(false);
// Expose the react router dom navigation.
history.navigate = useNavigate();
if (location !== locationRef.current) {
layoutTemplateData.current = null;
locationRef.current = location;
}
const {
displaySetService,
hangingProtocolService,
userAuthenticationService,
} = (servicesManager as ServicesManager).services;
const {
extensions,
sopClassHandlers,
hotkeys: hotkeyObj,
hangingProtocol,
} = mode;
const runTimeHangingProtocolId = searchParams.get('hangingprotocolid');
const token = searchParams.get('token');
if (token) {
// if a token is passed in, set the userAuthenticationService to use it
// for the Authorization header for all requests
userAuthenticationService.setServiceImplementation({
getAuthorizationHeader: () => ({
Authorization: 'Bearer ' + token,
}),
});
// Create a URL object with the current location
const urlObj = new URL(
window.location.origin + location.pathname + location.search
);
// Remove the token from the URL object
urlObj.searchParams.delete('token');
const cleanUrl = urlObj.toString();
// Update the browser's history without the token
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', cleanUrl);
}
}
// Preserve the old array interface for hotkeys
const hotkeys = Array.isArray(hotkeyObj) ? hotkeyObj : hotkeyObj?.hotkeys;
const hotkeyName = hotkeyObj?.name || 'hotkey-definitions-v2';
if (dataSourceName === undefined) {
dataSourceName = extensionManager.defaultDataSourceName;
}
extensionManager.setActiveDataSource(dataSourceName);
const dataSource = extensionManager.getActiveDataSource()[0];
// Only handling one route per mode for now
const route = mode.routes[0];
// For each extension, look up their context modules
// TODO: move to extension manager.
let contextModules = [];
Object.keys(extensions).forEach(extensionId => {
const allRegisteredModuleIds = Object.keys(extensionManager.modulesMap);
const moduleIds = allRegisteredModuleIds.filter(id =>
id.includes(`${extensionId}.contextModule.`)
);
if (!moduleIds || !moduleIds.length) {
return;
}
const modules = moduleIds.map(extensionManager.getModuleEntry);
contextModules = contextModules.concat(modules);
});
const contextModuleProviders = contextModules.map(a => a.provider);
const CombinedContextProvider = ({ children }) =>
Compose({ components: contextModuleProviders, children });
function ViewportGridWithDataSource(props) {
return ViewportGrid({ ...props, dataSource });
}
useEffect(() => {
const loadExtensions = async () => {
const loadedExtensions = await loadModules(Object.keys(extensions));
for (const extension of loadedExtensions) {
const { id: extensionId } = extension;
if (
extensionManager.registeredExtensionIds.indexOf(extensionId) === -1
) {
await extensionManager.registerExtension(extension);
}
}
setExtensionDependenciesLoaded(true);
};
loadExtensions();
}, []);
useEffect(() => {
// Preventing state update for unmounted component
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
if (!ExtensionDependenciesLoaded) {
return;
}
// Todo: this should not be here, data source should not care about params
const initializeDataSource = async (params, query) => {
const studyInstanceUIDs = await dataSource.initialize({
params,
query,
});
setStudyInstanceUIDs(studyInstanceUIDs);
};
initializeDataSource(params, query);
return () => {
layoutTemplateData.current = null;
};
}, [location, ExtensionDependenciesLoaded]);
useEffect(() => {
if (!ExtensionDependenciesLoaded) {
return;
}
const retrieveLayoutData = async () => {
const layoutData = await route.layoutTemplate({
location,
servicesManager,
studyInstanceUIDs,
});
if (isMounted.current) {
layoutTemplateData.current = layoutData;
setRefresh(!refresh);
}
};
if (studyInstanceUIDs?.length && studyInstanceUIDs[0] !== undefined) {
retrieveLayoutData();
}
return () => {
layoutTemplateData.current = null;
};
}, [studyInstanceUIDs, ExtensionDependenciesLoaded]);
useEffect(() => {
if (!hotkeys || !ExtensionDependenciesLoaded) {
return;
}
hotkeysManager.setDefaultHotKeys(hotkeys);
const userPreferredHotkeys = JSON.parse(localStorage.getItem(hotkeyName));
if (userPreferredHotkeys?.length) {
hotkeysManager.setHotkeys(userPreferredHotkeys, hotkeyName);
} else {
hotkeysManager.setHotkeys(hotkeys, hotkeyName);
}
return () => {
hotkeysManager.destroy();
};
}, [ExtensionDependenciesLoaded]);
useEffect(() => {
if (!layoutTemplateData.current || !ExtensionDependenciesLoaded) {
return;
}
const setupRouteInit = async () => {
// TODO: For some reason this is running before the Providers
// are calling setServiceImplementation
// TODO -> iterate through services.
// Extension
// Add SOPClassHandlers to a new SOPClassManager.
displaySetService.init(extensionManager, sopClassHandlers);
extensionManager.onModeEnter({
servicesManager,
extensionManager,
commandsManager,
});
// use the URL hangingProtocolId if it exists, otherwise use the one
// defined in the mode configuration
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
runTimeHangingProtocolId
)
? runTimeHangingProtocolId
: hangingProtocol;
// Sets the active hanging protocols - if hangingProtocol is undefined,
// resets to default. Done before the onModeEnter to allow the onModeEnter
// to perform custom hanging protocol actions
hangingProtocolService.setActiveProtocolIds(hangingProtocolIdToUse);
mode?.onModeEnter({
servicesManager,
extensionManager,
commandsManager,
});
/**
* The next line should get all the query parameters provided by the URL
* - except the StudyInstanceUIDs - and create an object called filters
* used to filtering the study as the user wants otherwise it will return
* a empty object.
*
* Example:
* const filters = {
* seriesInstanceUID: 1.2.276.0.7230010.3.1.3.1791068887.5412.1620253993.114611
* }
*/
const filters =
Array.from(query.keys()).reduce(
(acc: Record<string, string>, val: string) => {
const lowerVal = val.toLowerCase();
if (lowerVal !== 'studyinstanceuids') {
// Not sure why the case matters here - it doesn't in the URL
if (lowerVal === 'seriesinstanceuid') {
const seriesUIDs = getSplitParam(lowerVal, query);
return {
...acc,
seriesInstanceUID: seriesUIDs,
};
}
return { ...acc, [val]: getSplitParam(lowerVal, query) };
}
},
{}
) ?? {};
if (route.init) {
return await route.init(
{
servicesManager,
extensionManager,
hotkeysManager,
studyInstanceUIDs,
dataSource,
filters,
},
hangingProtocolIdToUse
);
}
return defaultRouteInit(
{
servicesManager,
studyInstanceUIDs,
dataSource,
filters,
},
hangingProtocolIdToUse
);
};
let unsubscriptions;
setupRouteInit().then(unsubs => {
unsubscriptions = unsubs;
});
return () => {
// The mode.onModeExit must be done first to allow it to store
// information, and must be in a try/catch to ensure subscriptions
// are unsubscribed.
try {
mode?.onModeExit?.({
servicesManager,
extensionManager,
});
} catch (e) {
console.warn('mode exit failure', e);
}
// The unsubscriptions must occur before the extension onModeExit
// in order to prevent exceptions during cleanup caused by spurious events
unsubscriptions.forEach(unsub => {
unsub();
});
// The extension manager must be called after the mode, this is
// expected to cleanup the state to a standard setup.
extensionManager.onModeExit();
};
}, [
mode,
dataSourceName,
location,
ExtensionDependenciesLoaded,
route,
servicesManager,
extensionManager,
hotkeysManager,
studyInstanceUIDs,
refresh,
]);
const renderLayoutData = props => {
const layoutTemplateModuleEntry = extensionManager.getModuleEntry(
layoutTemplateData.current.id
);
const LayoutComponent = layoutTemplateModuleEntry.component;
return <LayoutComponent {...props} />;
};
return (
<ImageViewerProvider
// initialState={{ StudyInstanceUIDs: StudyInstanceUIDs }}
StudyInstanceUIDs={studyInstanceUIDs}
// reducer={reducer}
>
<CombinedContextProvider>
<DragAndDropProvider>
{layoutTemplateData.current &&
studyInstanceUIDs?.[0] !== undefined &&
ExtensionDependenciesLoaded &&
renderLayoutData({
...layoutTemplateData.current.props,
ViewportGridComp: ViewportGridWithDataSource,
})}
</DragAndDropProvider>
</CombinedContextProvider>
</ImageViewerProvider>
);
}
ModeRoute.propTypes = {
mode: PropTypes.object.isRequired,
dataSourceName: PropTypes.string,
extensionManager: PropTypes.object,
servicesManager: PropTypes.object,
hotkeysManager: PropTypes.object,
};
+1
View File
@@ -0,0 +1 @@
export { default } from './Mode';
@@ -0,0 +1,65 @@
import { DicomMetadataStore, Types } from '@ohif/core';
type StudyMetadata = Types.StudyMetadata;
/**
* Compare function for sorting
*
* @param a - some simple value (string, number, timestamp)
* @param b - some simple value
* @param defaultCompare - default return value as a fallback when a===b
* @returns - compare a and b, returning 1 if a<b -1 if a>b and defaultCompare otherwise
*/
const compare = (a, b, defaultCompare = 0): number => {
if (a === b) return defaultCompare;
if (a < b) return 1;
return -1;
};
/**
* The studies from display sets gets the studies in study date
* order or in study instance UID order - not very useful, but
* if not specifically specified then at least making it consistent is useful.
*/
const getStudiesfromDisplaySets = (displaysets): StudyMetadata[] => {
const studyMap = {};
const ret = displaySets.reduce((prev, curr) => {
const { StudyInstanceUID } = curr;
if (!studyMap[StudyInstanceUID]) {
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
studyMap[StudyInstanceUID] = study;
prev.push(study);
}
return prev;
}, []);
// Return the sorted studies, first on study date and second on study instance UID
ret.sort((a, b) => {
return compare(
a.StudyDate,
b.StudyDate,
compare(a.StudyInstanceUID, b.StudyInstanceUID)
);
});
return ret;
};
/**
* The studies retrieve from the Uids is faster and gets the studies
* in the original order, as specified.
*/
const getStudiesFromUIDs = (studyUids: string[]): StudyMetadata[] => {
if (!studyUids?.length) return;
return studyUids.map(uid => DicomMetadataStore.getStudy(uid));
};
/** Gets the array of studies */
const getStudies = (studyUids?: string[], displaySets): StudyMetadata[] => {
return (
getStudiesFromUIDs(studyUids) || getStudiesfromDisplaySets(displaySets)
);
};
export default getStudies;
export { getStudies, getStudiesFromUIDs, getStudiesfromDisplaySets, compare };
@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useAppConfig } from '@state';
const NotFound = ({
message = 'Sorry, this page does not exist.',
showGoBackButton = true,
}) => {
const [appConfig] = useAppConfig();
const { showStudyList } = appConfig;
return (
<div className="w-full h-full flex justify-center items-center text-white">
<div>
<h4>{message}</h4>
{showGoBackButton && showStudyList && (
<h5>
<Link to={'/'}>Go back to the Study List</Link>
</h5>
)}
</div>
</div>
);
};
NotFound.propTypes = {
message: PropTypes.string,
showGoBackButton: PropTypes.bool,
};
export default NotFound;
@@ -0,0 +1 @@
export { default } from './NotFound';
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import { useUserAuthentication } from '@ohif/ui';
export const PrivateRoute = ({ children, handleUnauthenticated }) => {
const [{ user, enabled }] = useUserAuthentication();
if (enabled && !user) {
return handleUnauthenticated();
}
return children;
};
export default PrivateRoute;
@@ -0,0 +1,32 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import PropTypes from 'prop-types';
function SignoutCallbackComponent({ userManager }) {
const navigate = useNavigate();
const onRedirectSuccess = (/* user */) => {
const { pathname, search = '' } = JSON.parse(
sessionStorage.getItem('ohif-redirect-to')
);
navigate(`${pathname}?${search}`);
};
const onRedirectError = error => {
throw new Error(error);
};
userManager
.signoutRedirectCallback()
.then(user => onRedirectSuccess(user))
.catch(error => onRedirectError(error));
return null;
}
SignoutCallbackComponent.propTypes = {
userManager: PropTypes.object.isRequired,
};
export default SignoutCallbackComponent;
@@ -0,0 +1,602 @@
import React, { useState, useEffect } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Link, useNavigate } from 'react-router-dom';
import moment from 'moment';
import qs from 'query-string';
import isEqual from 'lodash.isequal';
import { useTranslation } from 'react-i18next';
//
import filtersMeta from './filtersMeta.js';
import { useAppConfig } from '@state';
import { useDebounce, useSearchParams } from '@hooks';
import { utils, hotkeys, ServicesManager } from '@ohif/core';
import {
Icon,
StudyListExpandedRow,
Button,
EmptyStudies,
StudyListTable,
StudyListPagination,
StudyListFilter,
TooltipClipboard,
Header,
useModal,
AboutModal,
UserPreferences,
LoadingIndicatorProgress,
} from '@ohif/ui';
import i18n from '@ohif/i18n';
const { sortBySeriesDate } = utils;
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
const seriesInStudiesMap = new Map();
/**
* TODO:
* - debounce `setFilterValues` (150ms?)
*/
function WorkList({
data: studies,
dataTotal: studiesTotal,
isLoadingData,
dataSource,
hotkeysManager,
dataPath,
onRefresh,
servicesManager,
}) {
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
const { show, hide } = useModal();
const { t } = useTranslation();
// ~ Modes
const [appConfig] = useAppConfig();
// ~ Filters
const searchParams = useSearchParams();
const navigate = useNavigate();
const STUDIES_LIMIT = 101;
const queryFilterValues = _getQueryFilterValues(searchParams);
const [filterValues, _setFilterValues] = useState({
...defaultFilterValues,
...queryFilterValues,
});
const debouncedFilterValues = useDebounce(filterValues, 200);
const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues;
/*
* The default sort value keep the filters synchronized with runtime conditional sorting
* Only applied if no other sorting is specified and there are less than 101 studies
*/
const canSort = studiesTotal < STUDIES_LIMIT;
const shouldUseDefaultSort = sortBy === '' || !sortBy;
const sortModifier = sortDirection === 'descending' ? 1 : -1;
const defaultSortValues =
shouldUseDefaultSort && canSort
? { sortBy: 'studyDate', sortDirection: 'ascending' }
: {};
const sortedStudies = studies;
if (canSort) {
studies.sort((s1, s2) => {
if (shouldUseDefaultSort) {
const ascendingSortModifier = -1;
return _sortStringDates(s1, s2, ascendingSortModifier);
}
const s1Prop = s1[sortBy];
const s2Prop = s2[sortBy];
if (typeof s1Prop === 'string' && typeof s2Prop === 'string') {
return s1Prop.localeCompare(s2Prop) * sortModifier;
} else if (typeof s1Prop === 'number' && typeof s2Prop === 'number') {
return (s1Prop > s2Prop ? 1 : -1) * sortModifier;
} else if (!s1Prop && s2Prop) {
return -1 * sortModifier;
} else if (!s2Prop && s1Prop) {
return 1 * sortModifier;
} else if (sortBy === 'studyDate') {
return _sortStringDates(s1, s2, sortModifier);
}
return 0;
});
}
// ~ Rows & Studies
const [expandedRows, setExpandedRows] = useState([]);
const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]);
const numOfStudies = studiesTotal;
const setFilterValues = val => {
if (filterValues.pageNumber === val.pageNumber) {
val.pageNumber = 1;
}
_setFilterValues(val);
setExpandedRows([]);
};
const onPageNumberChange = newPageNumber => {
const oldPageNumber = filterValues.pageNumber;
const rollingPageNumberMod = Math.floor(101 / filterValues.resultsPerPage);
const rollingPageNumber = oldPageNumber % rollingPageNumberMod;
const isNextPage = newPageNumber > oldPageNumber;
const hasNextPage =
Math.max(rollingPageNumber, 1) * resultsPerPage < numOfStudies;
if (isNextPage && !hasNextPage) {
return;
}
setFilterValues({ ...filterValues, pageNumber: newPageNumber });
};
const onResultsPerPageChange = newResultsPerPage => {
setFilterValues({
...filterValues,
pageNumber: 1,
resultsPerPage: Number(newResultsPerPage),
});
};
// Set body style
useEffect(() => {
document.body.classList.add('bg-black');
return () => {
document.body.classList.remove('bg-black');
};
}, []);
// Sync URL query parameters with filters
useEffect(() => {
if (!debouncedFilterValues) {
return;
}
const queryString = {};
Object.keys(defaultFilterValues).forEach(key => {
const defaultValue = defaultFilterValues[key];
const currValue = debouncedFilterValues[key];
// TODO: nesting/recursion?
if (key === 'studyDate') {
if (
currValue.startDate &&
defaultValue.startDate !== currValue.startDate
) {
queryString.startDate = currValue.startDate;
}
if (currValue.endDate && defaultValue.endDate !== currValue.endDate) {
queryString.endDate = currValue.endDate;
}
} else if (key === 'modalities' && currValue.length) {
queryString.modalities = currValue.join(',');
} else if (currValue !== defaultValue) {
queryString[key] = currValue;
}
});
const search = qs.stringify(queryString, {
skipNull: true,
skipEmptyString: true,
});
navigate({
pathname: '/',
search: search ? `?${search}` : undefined,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedFilterValues]);
// Query for series information
useEffect(() => {
const fetchSeries = async studyInstanceUid => {
try {
const series = await dataSource.query.series.search(studyInstanceUid);
seriesInStudiesMap.set(studyInstanceUid, sortBySeriesDate(series));
setStudiesWithSeriesData([...studiesWithSeriesData, studyInstanceUid]);
} catch (ex) {
// TODO: UI Notification Service
console.warn(ex);
}
};
// TODO: WHY WOULD YOU USE AN INDEX OF 1?!
// Note: expanded rows index begins at 1
for (let z = 0; z < expandedRows.length; z++) {
const expandedRowIndex = expandedRows[z] - 1;
const studyInstanceUid = sortedStudies[expandedRowIndex].studyInstanceUid;
if (studiesWithSeriesData.includes(studyInstanceUid)) {
continue;
}
fetchSeries(studyInstanceUid);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandedRows, studies]);
const isFiltering = (filterValues, defaultFilterValues) => {
return !isEqual(filterValues, defaultFilterValues);
};
const rollingPageNumberMod = Math.floor(101 / resultsPerPage);
const rollingPageNumber = (pageNumber - 1) % rollingPageNumberMod;
const offset = resultsPerPage * rollingPageNumber;
const offsetAndTake = offset + resultsPerPage;
const tableDataSource = sortedStudies.map((study, key) => {
const rowKey = key + 1;
const isExpanded = expandedRows.some(k => k === rowKey);
const {
studyInstanceUid,
accession,
modalities,
instances,
description,
mrn,
patientName,
date,
time,
} = study;
const studyDate =
date &&
moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true).isValid() &&
moment(date, ['YYYYMMDD', 'YYYY.MM.DD']).format('MMM-DD-YYYY');
const studyTime =
time &&
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).isValid() &&
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).format('hh:mm A');
return {
row: [
{
key: 'patientName',
content: patientName ? (
<TooltipClipboard>{patientName}</TooltipClipboard>
) : (
<span className="text-gray-700">(Empty)</span>
),
gridCol: 4,
},
{
key: 'mrn',
content: <TooltipClipboard>{mrn}</TooltipClipboard>,
gridCol: 3,
},
{
key: 'studyDate',
content: (
<>
{studyDate && <span className="mr-4">{studyDate}</span>}
{studyTime && <span>{studyTime}</span>}
</>
),
title: `${studyDate || ''} ${studyTime || ''}`,
gridCol: 5,
},
{
key: 'description',
content: <TooltipClipboard>{description}</TooltipClipboard>,
gridCol: 4,
},
{
key: 'modality',
content: modalities,
title: modalities,
gridCol: 3,
},
{
key: 'accession',
content: <TooltipClipboard>{accession}</TooltipClipboard>,
gridCol: 3,
},
{
key: 'instances',
content: (
<>
<Icon
name="group-layers"
className={classnames('inline-flex mr-2 w-4', {
'text-primary-active': isExpanded,
'text-secondary-light': !isExpanded,
})}
/>
{instances}
</>
),
title: (instances || 0).toString(),
gridCol: 4,
},
],
// Todo: This is actually running for all rows, even if they are
// not clicked on.
expandedContent: (
<StudyListExpandedRow
seriesTableColumns={{
description: 'Description',
seriesNumber: 'Series',
modality: 'Modality',
instances: 'Instances',
}}
seriesTableDataSource={
seriesInStudiesMap.has(studyInstanceUid)
? seriesInStudiesMap.get(studyInstanceUid).map(s => {
return {
description: s.description || '(empty)',
seriesNumber: s.seriesNumber ?? '',
modality: s.modality || '',
instances: s.numSeriesInstances || '',
};
})
: []
}
>
{appConfig.loadedModes.map((mode, i) => {
const isFirst = i === 0;
const modalitiesToCheck = modalities.replaceAll('/', '\\');
const isValidMode = mode.isValidMode({
modalities: modalitiesToCheck,
study,
});
// TODO: Modes need a default/target route? We mostly support a single one for now.
// We should also be using the route path, but currently are not
// mode.routeName
// mode.routes[x].path
// Don't specify default data source, and it should just be picked up... (this may not currently be the case)
// How do we know which params to pass? Today, it's just StudyInstanceUIDs and configUrl if exists
const query = new URLSearchParams();
if (filterValues.configUrl) {
query.append('configUrl', filterValues.configUrl);
}
query.append('StudyInstanceUIDs', studyInstanceUid);
return (
<Link
key={i}
to={`${dataPath ? '../../' : ''}${mode.routeName}${dataPath ||
''}?${query.toString()}`}
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
>
<Button
rounded="full"
variant={isValidMode ? 'contained' : 'disabled'}
disabled={!isValidMode}
endIcon={<Icon name="launch-arrow" />} // launch-arrow | launch-info
className={classnames('font-medium ', { 'ml-2': !isFirst })}
onClick={() => {}}
>
{t(`Modes:${mode.displayName}`)}
</Button>
</Link>
);
})}
</StudyListExpandedRow>
),
onClickRow: () =>
setExpandedRows(s =>
isExpanded ? s.filter(n => rowKey !== n) : [...s, rowKey]
),
isExpanded,
};
});
const hasStudies = numOfStudies > 0;
const versionNumber = process.env.VERSION_NUMBER;
const commitHash = process.env.COMMIT_HASH;
const menuOptions = [
{
title: t('Header:About'),
icon: 'info',
onClick: () =>
show({
content: AboutModal,
title: 'About OHIF Viewer',
contentProps: { versionNumber, commitHash },
}),
},
{
title: t('Header:Preferences'),
icon: 'settings',
onClick: () =>
show({
title: t('UserPreferencesModal:User Preferences'),
content: UserPreferences,
contentProps: {
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(
hotkeyDefaults
),
hotkeyDefinitions,
onCancel: hide,
currentLanguage: currentLanguage(),
availableLanguages,
defaultLanguage,
onSubmit: state => {
i18n.changeLanguage(state.language.value);
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
hide();
},
onReset: () => hotkeysManager.restoreDefaultBindings(),
hotkeysModule: hotkeys,
},
}),
},
];
if (appConfig.oidc) {
menuOptions.push({
icon: 'power-off',
title: t('Header:Logout'),
onClick: () => {
navigate(
`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`
);
},
});
}
const { customizationService } = servicesManager.services;
const { component: dicomUploadComponent } =
customizationService.get('dicomUploadComponent') ?? {};
const uploadProps =
dicomUploadComponent && dataSource.getConfig().dicomUploadEnabled
? {
title: 'Upload files',
closeButton: true,
shouldCloseOnEsc: false,
shouldCloseOnOverlayClick: false,
content: dicomUploadComponent.bind(null, {
dataSource,
onComplete: () => {
hide();
onRefresh();
},
onStarted: () => {
show({
...uploadProps,
// when upload starts, hide the default close button as closing the dialogue must be handled by the upload dialogue itself
closeButton: false,
});
},
}),
}
: undefined;
return (
<div className="bg-black h-screen flex flex-col ">
<Header
isSticky
menuOptions={menuOptions}
isReturnEnabled={false}
WhiteLabeling={appConfig.whiteLabeling}
/>
<div className="overflow-y-auto ohif-scrollbar flex flex-col grow">
<StudyListFilter
numOfStudies={pageNumber * resultsPerPage > 100 ? 101 : numOfStudies}
filtersMeta={filtersMeta}
filterValues={{ ...filterValues, ...defaultSortValues }}
onChange={setFilterValues}
clearFilters={() => setFilterValues(defaultFilterValues)}
isFiltering={isFiltering(filterValues, defaultFilterValues)}
onUploadClick={uploadProps ? () => show(uploadProps) : undefined}
/>
{hasStudies ? (
<div className="grow flex flex-col">
<StudyListTable
tableDataSource={tableDataSource.slice(offset, offsetAndTake)}
numOfStudies={numOfStudies}
filtersMeta={filtersMeta}
/>
<div className="grow">
<StudyListPagination
onChangePage={onPageNumberChange}
onChangePerPage={onResultsPerPageChange}
currentPage={pageNumber}
perPage={resultsPerPage}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-48">
{appConfig.showLoadingIndicator && isLoadingData ? (
<LoadingIndicatorProgress className={'w-full h-full bg-black'} />
) : (
<EmptyStudies />
)}
</div>
)}
</div>
</div>
);
}
WorkList.propTypes = {
data: PropTypes.array.isRequired,
dataSource: PropTypes.shape({
query: PropTypes.object.isRequired,
getConfig: PropTypes.func,
}).isRequired,
isLoadingData: PropTypes.bool.isRequired,
servicesManager: PropTypes.instanceOf(ServicesManager),
};
const defaultFilterValues = {
patientName: '',
mrn: '',
studyDate: {
startDate: null,
endDate: null,
},
description: '',
modalities: [],
accession: '',
sortBy: '',
sortDirection: 'none',
pageNumber: 1,
resultsPerPage: 25,
datasources: '',
configUrl: null,
};
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str && str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
return retValue;
}
function _getQueryFilterValues(params) {
const queryFilterValues = {
patientName: params.get('patientname'),
mrn: params.get('mrn'),
studyDate: {
startDate: params.get('startdate') || null,
endDate: params.get('enddate') || null,
},
description: params.get('description'),
modalities: params.get('modalities')
? params.get('modalities').split(',')
: [],
accession: params.get('accession'),
sortBy: params.get('sortby'),
sortDirection: params.get('sortdirection'),
pageNumber: _tryParseInt(params.get('pagenumber'), undefined),
resultsPerPage: _tryParseInt(params.get('resultsperpage'), undefined),
datasources: params.get('datasources'),
configUrl: params.get('configurl'),
};
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
}
function _sortStringDates(s1, s2, sortModifier) {
// TODO: Delimiters are non-standard. Should we support them?
const s1Date = moment(s1.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
const s2Date = moment(s2.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
if (s1Date.isValid() && s2Date.isValid()) {
return (
(s1Date.toISOString() > s2Date.toISOString() ? 1 : -1) * sortModifier
);
} else if (s1Date.isValid()) {
return sortModifier;
} else if (s2Date.isValid()) {
return -1 * sortModifier;
}
}
export default WorkList;
@@ -0,0 +1,127 @@
const filtersMeta = [
{
name: 'patientName',
displayName: 'PatientName',
inputType: 'Text',
isSortable: true,
gridCol: 4,
},
{
name: 'mrn',
displayName: 'MRN',
inputType: 'Text',
isSortable: true,
gridCol: 3,
},
{
name: 'studyDate',
displayName: 'StudyDate',
inputType: 'DateRange',
isSortable: true,
gridCol: 5,
},
{
name: 'description',
displayName: 'StudyDescription',
inputType: 'Text',
isSortable: true,
gridCol: 4,
},
{
name: 'modalities',
displayName: 'Modality',
inputType: 'MultiSelect',
inputProps: {
options: [
{ value: 'AR', label: 'AR' },
{ value: 'ASMT', label: 'ASMT' },
{ value: 'AU', label: 'AU' },
{ value: 'BDUS', label: 'BDUS' },
{ value: 'BI', label: 'BI' },
{ value: 'BMD', label: 'BMD' },
{ value: 'CR', label: 'CR' },
{ value: 'CT', label: 'CT' },
{ value: 'CTPROTOCOL', label: 'CTPROTOCOL' },
{ value: 'DG', label: 'DG' },
{ value: 'DOC', label: 'DOC' },
{ value: 'DX', label: 'DX' },
{ value: 'ECG', label: 'ECG' },
{ value: 'EPS', label: 'EPS' },
{ value: 'ES', label: 'ES' },
{ value: 'FID', label: 'FID' },
{ value: 'GM', label: 'GM' },
{ value: 'HC', label: 'HC' },
{ value: 'HD', label: 'HD' },
{ value: 'IO', label: 'IO' },
{ value: 'IOL', label: 'IOL' },
{ value: 'IVOCT', label: 'IVOCT' },
{ value: 'IVUS', label: 'IVUS' },
{ value: 'KER', label: 'KER' },
{ value: 'KO', label: 'KO' },
{ value: 'LEN', label: 'LEN' },
{ value: 'LS', label: 'LS' },
{ value: 'MG', label: 'MG' },
{ value: 'MR', label: 'MR' },
{ value: 'M3D', label: 'M3D' },
{ value: 'NM', label: 'NM' },
{ value: 'OAM', label: 'OAM' },
{ value: 'OCT', label: 'OCT' },
{ value: 'OP', label: 'OP' },
{ value: 'OPM', label: 'OPM' },
{ value: 'OPT', label: 'OPT' },
{ value: 'OPTBSV', label: 'OPTBSV' },
{ value: 'OPTENF', label: 'OPTENF' },
{ value: 'OPV', label: 'OPV' },
{ value: 'OSS', label: 'OSS' },
{ value: 'OT', label: 'OT' },
{ value: 'PLAN', label: 'PLAN' },
{ value: 'PR', label: 'PR' },
{ value: 'PT', label: 'PT' },
{ value: 'PX', label: 'PX' },
{ value: 'REG', label: 'REG' },
{ value: 'RESP', label: 'RESP' },
{ value: 'RF', label: 'RF' },
{ value: 'RG', label: 'RG' },
{ value: 'RTDOSE', label: 'RTDOSE' },
{ value: 'RTIMAGE', label: 'RTIMAGE' },
{ value: 'RTINTENT', label: 'RTINTENT' },
{ value: 'RTPLAN', label: 'RTPLAN' },
{ value: 'RTRAD', label: 'RTRAD' },
{ value: 'RTRECORD', label: 'RTRECORD' },
{ value: 'RTSEGANN', label: 'RTSEGANN' },
{ value: 'RTSTRUCT', label: 'RTSTRUCT' },
{ value: 'RWV', label: 'RWV' },
{ value: 'SEG', label: 'SEG' },
{ value: 'SM', label: 'SM' },
{ value: 'SMR', label: 'SMR' },
{ value: 'SR', label: 'SR' },
{ value: 'SRF', label: 'SRF' },
{ value: 'STAIN', label: 'STAIN' },
{ value: 'TEXTUREMAP', label: 'TEXTUREMAP' },
{ value: 'TG', label: 'TG' },
{ value: 'US', label: 'US' },
{ value: 'VA', label: 'VA' },
{ value: 'XA', label: 'XA' },
{ value: 'XC', label: 'XC' },
],
},
isSortable: true,
gridCol: 3,
},
{
name: 'accession',
displayName: 'AccessionNumber',
inputType: 'Text',
isSortable: true,
gridCol: 3,
},
{
name: 'instances',
displayName: 'Instances',
inputType: 'None',
isSortable: false,
gridCol: 2,
},
];
export default filtersMeta;
@@ -0,0 +1 @@
export { default } from './WorkList';
@@ -0,0 +1,91 @@
import React from 'react';
import ModeRoute from '@routes/Mode';
/*
Routes uniquely define an entry point to:
- A mode
- Linked to a data source
- With a specified data set.
The full route template is:
/:modeId/:modeRoute/:sourceType/?queryParameters=example
Where:
:modeId - Is the mode selected.
:modeRoute - Is the route within the mode to select.
:sourceType - Is the data source identifier, which specifies which DataSource to use.
?queryParameters - Are query parameters as defined by data source.
A default source can be specified at the app level configuration, and then that source is used if :sourceType is omitted:
/:modeId/:modeRoute/?queryParameters=example
*/
export default function buildModeRoutes({
modes,
dataSources,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
}) {
const routes = [];
const dataSourceNames = [];
dataSources.forEach(dataSource => {
const { sourceName } = dataSource;
if (!dataSourceNames.includes(sourceName)) {
dataSourceNames.push(sourceName);
}
});
modes.forEach(mode => {
// todo: for each route. add route to path.
dataSourceNames.forEach(dataSourceName => {
const path = `/${mode.routeName}/${dataSourceName}`;
// TODO move up.
const children = () => (
<ModeRoute
mode={mode}
dataSourceName={dataSourceName}
extensionManager={extensionManager}
servicesManager={servicesManager}
commandsManager={commandsManager}
hotkeysManager={hotkeysManager}
/>
);
routes.push({
path,
children,
private: true,
});
});
const defaultDataSourceName = extensionManager.defaultDataSourceName;
// Add default DataSource route.
const path = `/${mode.routeName}`;
// TODO move up.
const children = () => (
<ModeRoute
mode={mode}
dataSourceName={defaultDataSourceName}
extensionManager={extensionManager}
servicesManager={servicesManager}
commandsManager={commandsManager}
hotkeysManager={hotkeysManager}
/>
);
routes.push({
path,
children,
private: true, // todo: all mode routes are private for now
});
});
return routes;
}
+123
View File
@@ -0,0 +1,123 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from '@ohif/ui';
// Route Components
import DataSourceWrapper from './DataSourceWrapper';
import WorkList from './WorkList';
import Local from './Local';
import Debug from './Debug';
import NotFound from './NotFound';
import buildModeRoutes from './buildModeRoutes';
import PrivateRoute from './PrivateRoute';
// TODO: Include "routes" debug route if dev build
const bakedInRoutes = [
{
path: '/debug',
children: Debug,
},
{
path: '/local',
children: Local.bind(null, { modePath: '' }), // navigate to the worklist
},
{
path: '/localbasic',
children: Local.bind(null, { modePath: 'viewer/dicomlocal' }),
},
];
// NOT FOUND (404)
const notFoundRoute = { component: NotFound };
const createRoutes = ({
modes,
dataSources,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
routerBasename,
showStudyList,
}) => {
const routes =
buildModeRoutes({
modes,
dataSources,
extensionManager,
servicesManager,
commandsManager,
hotkeysManager,
}) || [];
const { customizationService } = servicesManager.services;
const WorkListRoute = {
path: '/',
children: DataSourceWrapper,
private: true,
props: { children: WorkList, servicesManager },
};
const customRoutes = customizationService.getGlobalCustomization(
'customRoutes'
);
const allRoutes = [
...routes,
...(showStudyList ? [WorkListRoute] : []),
...(customRoutes?.routes || []),
...bakedInRoutes,
customRoutes?.notFoundRoute || notFoundRoute,
];
function RouteWithErrorBoundary({ route, ...rest }) {
// eslint-disable-next-line react/jsx-props-no-spreading
return (
<ErrorBoundary context={`Route ${route.path}`} fallbackRoute="/">
<route.children
{...rest}
{...route.props}
route={route}
servicesManager={servicesManager}
extensionManager={extensionManager}
hotkeysManager={hotkeysManager}
/>
</ErrorBoundary>
);
}
const { userAuthenticationService } = servicesManager.services;
// Note: PrivateRoutes in react-router-dom 6.x should be defined within
// a Route element
return (
<Routes>
{allRoutes.map((route, i) => {
return route.private === true ? (
<Route
key={i}
exact
path={route.path}
element={
<PrivateRoute
handleUnauthenticated={
userAuthenticationService.handleUnauthenticated
}
>
<RouteWithErrorBoundary route={route} />
</PrivateRoute>
}
></Route>
) : (
<Route
key={i}
path={route.path}
element={<RouteWithErrorBoundary route={route} />}
/>
);
})}
</Routes>
);
};
export default createRoutes;
+8
View File
@@ -0,0 +1,8 @@
describe('Sanity Test', () => {
test('how many marbles?', () => {
const expectedMarbles = 4;
const actualMarbles = 2 + 2;
expect(actualMarbles).toEqual(expectedMarbles);
});
});
+67
View File
@@ -0,0 +1,67 @@
// https://developers.google.com/web/tools/workbox/guides/troubleshoot-and-debug
importScripts(
'https://storage.googleapis.com/workbox-cdn/releases/5.0.0-beta.1/workbox-sw.js'
);
// Install newest
// https://developers.google.com/web/tools/workbox/modules/workbox-core
workbox.core.skipWaiting();
workbox.core.clientsClaim();
// Cache static assets that aren't precached
workbox.routing.registerRoute(
/\.(?:js|css|json5)$/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'static-resources',
})
);
// Cache the Google Fonts stylesheets with a stale-while-revalidate strategy.
workbox.routing.registerRoute(
/^https:\/\/fonts\.googleapis\.com/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'google-fonts-stylesheets',
})
);
// Cache the underlying font files with a cache-first strategy for 1 year.
workbox.routing.registerRoute(
/^https:\/\/fonts\.gstatic\.com/,
new workbox.strategies.CacheFirst({
cacheName: 'google-fonts-webfonts',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 Year
maxEntries: 30,
}),
],
})
);
// MESSAGE HANDLER
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
switch (event.data.type) {
case 'SKIP_WAITING':
// TODO: We'll eventually want this to be user prompted
// workbox.core.skipWaiting();
// workbox.core.clientsClaim();
// TODO: Global notification to indicate incoming reload
break;
default:
console.warn(`SW: Invalid message type: ${event.data.type}`);
}
}
});
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// TODO: Cache API
// https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api
// Store DICOMs?
// Clear Service Worker cache?
// navigator.storage.estimate().then(est => console.log(est)); (2GB?)
+20
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]}>{children}</Provider>;
}
AppConfigProvider.propTypes = {
children: PropTypes.any,
value: PropTypes.any,
};
export default AppConfigProvider;
+3
View File
@@ -0,0 +1,3 @@
import { AppConfigProvider, useAppConfig } from './appConfig.tsx';
export { AppConfigProvider, useAppConfig };
@@ -0,0 +1,230 @@
import React from 'react';
import { useEffect } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router';
import CallbackPage from '../routes/CallbackPage';
import SignoutCallbackComponent from '../routes/SignoutCallbackComponent';
import getUserManagerForOpenIdConnectClient from './getUserManagerForOpenIdConnectClient.js';
function _isAbsoluteUrl(url) {
return url.includes('http://') || url.includes('https://');
}
function _makeAbsoluteIfNecessary(url, base_url) {
if (_isAbsoluteUrl(url)) {
return url;
}
/*
* Make sure base_url and url are not duplicating slashes.
*/
if (base_url[base_url.length - 1] === '/') {
base_url = base_url.slice(0, base_url.length - 1);
}
return base_url + url;
}
const initUserManager = (oidc, routerBasename) => {
if (!oidc || !oidc.length) {
return;
}
const firstOpenIdClient = oidc[0];
const { protocol, host } = window.location;
const baseUri = `${protocol}//${host}${routerBasename}`;
const redirect_uri = firstOpenIdClient.redirect_uri || '/callback';
const silent_redirect_uri =
firstOpenIdClient.silent_redirect_uri || '/silent-refresh.html';
const post_logout_redirect_uri =
firstOpenIdClient.post_logout_redirect_uri || '/';
const openIdConnectConfiguration = Object.assign({}, firstOpenIdClient, {
redirect_uri: _makeAbsoluteIfNecessary(redirect_uri, baseUri),
silent_redirect_uri: _makeAbsoluteIfNecessary(silent_redirect_uri, baseUri),
post_logout_redirect_uri: _makeAbsoluteIfNecessary(
post_logout_redirect_uri,
baseUri
),
});
return getUserManagerForOpenIdConnectClient(openIdConnectConfiguration);
};
function LogoutComponent(props) {
const { userManager } = props;
localStorage.setItem('signoutEvent', 'true');
const location = useLocation();
const query = new URLSearchParams(location.search);
userManager.signoutRedirect({
post_logout_redirect_uri: query.get('redirect_uri'),
});
return null;
}
function LoginComponent(userManager) {
const queryParams = new URLSearchParams(location.search);
const iss = queryParams.get('iss');
const loginHint = queryParams.get('login_hint');
const targetLinkUri = queryParams.get('target_link_uri');
if (iss !== oidcAuthority) {
console.error('iss of /login does not match the oidc authority');
return null;
}
userManager.removeUser().then(() => {
if (targetLinkUri !== null) {
const ohifRedirectTo = {
pathname: new URL(targetLinkUri).pathname,
};
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify(ohifRedirectTo)
);
} else {
const ohifRedirectTo = {
pathname: '/',
};
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify(ohifRedirectTo)
);
}
if (loginHint !== null) {
userManager.signinRedirect({ login_hint: loginHint });
} else {
userManager.signinRedirect();
}
});
return null;
}
function OpenIdConnectRoutes({
oidc,
routerBasename,
userAuthenticationService,
}) {
const userManager = initUserManager(oidc, routerBasename);
const getAuthorizationHeader = () => {
const user = userAuthenticationService.getUser();
return {
Authorization: `Bearer ${user.access_token}`,
};
};
const handleUnauthenticated = () => {
userManager.signinRedirect();
// return null because this is used in a react component
return null;
};
const navigate = useNavigate();
//for multi-tab logout
useEffect(() => {
localStorage.removeItem('signoutEvent');
const storageEventListener = event => {
const signOutEvent = localStorage.getItem('signoutEvent');
if (signOutEvent) {
navigate(
`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`
);
}
};
window.addEventListener('storage', storageEventListener);
return () => {
window.removeEventListener('storage', storageEventListener);
};
}, []);
useEffect(() => {
userAuthenticationService.set({ enabled: true });
userAuthenticationService.setServiceImplementation({
getAuthorizationHeader,
handleUnauthenticated,
});
}, []);
const oidcAuthority = oidc[0].authority;
const location = useLocation();
const { pathname, search } = location;
const redirect_uri = new URL(userManager.settings._redirect_uri).pathname; //.replace(routerBasename,'')
const silent_refresh_uri = new URL(userManager.settings._silent_redirect_uri)
.pathname; //.replace(routerBasename,'')
const post_logout_redirect_uri = new URL(
userManager.settings._post_logout_redirect_uri
).pathname; //.replace(routerBasename,'');
// const pathnameRelative = pathname.replace(routerBasename,'');
if (pathname !== redirect_uri) {
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify({ pathname, search })
);
}
return (
<Routes basename={routerBasename}>
<Route path={silent_refresh_uri} onEnter={window.location.reload} />
<Route
path={post_logout_redirect_uri}
element={
<SignoutCallbackComponent
userManager={userManager}
successCallback={() => console.log('Signout successful')}
errorCallback={error => {
console.warn(error);
console.warn('Signout failed');
}}
/>
}
/>
<Route
path={redirect_uri}
element={
<CallbackPage
userManager={userManager}
onRedirectSuccess={user => {
const { pathname, search = '' } = JSON.parse(
sessionStorage.getItem('ohif-redirect-to')
);
userAuthenticationService.setUser(user);
navigate({
pathname,
search,
});
}}
/>
}
/>
<Route
path="/login"
element={
<LoginComponent
userManager={userManager}
oidcAuthority={oidcAuthority}
/>
}
/>
<Route
path="/logout"
element={<LogoutComponent userManager={userManager} />}
/>
</Routes>
);
}
export default OpenIdConnectRoutes;
@@ -0,0 +1,31 @@
import { UserManager } from 'oidc-client';
/**
* Creates a userManager from oidcSettings
* LINK: https://github.com/IdentityModel/oidc-client-js/wiki#configuration
*
* @param {Object} oidcSettings
* @param {string} oidcSettings.authServerUrl,
* @param {string} oidcSettings.clientId,
* @param {string} oidcSettings.authRedirectUri,
* @param {string} oidcSettings.postLogoutRedirectUri,
* @param {string} oidcSettings.responseType,
* @param {string} oidcSettings.extraQueryParams,
*/
export default function getUserManagerForOpenIdConnectClient(oidcSettings) {
if (!oidcSettings) {
return;
}
const settings = {
...oidcSettings,
automaticSilentRenew: true,
revokeAccessTokenOnSignout: true,
filterProtocolClaims: true,
loadUserInfo: true,
};
const userManager = new UserManager(settings);
return userManager;
}
+9
View File
@@ -0,0 +1,9 @@
import { NavigateFunction } from 'react-router';
type History = {
navigate: NavigateFunction;
};
export const history: History = {
navigate: null,
};