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

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;