init: sudah ganti logo, hilangin setting, dan investigational use dialog
This commit is contained in:
23
platform/app/src/routes/CallbackPage.tsx
Normal file
23
platform/app/src/routes/CallbackPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function CallbackPage({ userManager, onRedirectSuccess }) {
|
||||
const onRedirectError = error => {
|
||||
throw new Error(error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
userManager
|
||||
.signinRedirectCallback()
|
||||
.then(user => onRedirectSuccess(user))
|
||||
.catch(error => onRedirectError(error));
|
||||
}, [userManager, onRedirectSuccess]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
CallbackPage.propTypes = {
|
||||
userManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default CallbackPage;
|
||||
305
platform/app/src/routes/DataSourceWrapper.tsx
Normal file
305
platform/app/src/routes/DataSourceWrapper.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Enums, ExtensionManager, MODULE_TYPES, log } from '@ohif/core';
|
||||
//
|
||||
import { extensionManager } from '../App';
|
||||
import { useParams, useLocation } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSearchParams from '../hooks/useSearchParams';
|
||||
|
||||
/**
|
||||
* Determines if two React Router location objects are the same.
|
||||
*/
|
||||
const areLocationsTheSame = (location0, location1) => {
|
||||
return (
|
||||
location0.pathname === location1.pathname &&
|
||||
location0.search === location1.search &&
|
||||
location0.hash === location1.hash
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses route properties to determine the data source that should be passed
|
||||
* to the child layout template. In some instances, initiates requests and
|
||||
* passes data as props.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {function} props.children - Layout Template React Component
|
||||
*/
|
||||
function DataSourceWrapper(props: withAppTypes) {
|
||||
const { servicesManager } = props;
|
||||
const navigate = useNavigate();
|
||||
const { children: LayoutTemplate, ...rest } = props;
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
|
||||
const query = useSearchParams();
|
||||
// Route props --> studies.mapParams
|
||||
// mapParams --> studies.search
|
||||
// studies.search --> studies.processResults
|
||||
// studies.processResults --> <LayoutTemplate studies={} />
|
||||
// But only for LayoutTemplate type of 'list'?
|
||||
// Or no data fetching here, and just hand down my source
|
||||
const STUDIES_LIMIT = 101;
|
||||
const DEFAULT_DATA = {
|
||||
studies: [],
|
||||
total: 0,
|
||||
resultsPerPage: 25,
|
||||
pageNumber: 1,
|
||||
location: 'Not a valid location, causes first load to occur',
|
||||
};
|
||||
|
||||
const getInitialDataSourceName = useCallback(() => {
|
||||
// TODO - get the variable from the props all the time...
|
||||
let dataSourceName = lowerCaseSearchParams.get('datasources');
|
||||
|
||||
if (!dataSourceName && window.config.defaultDataSourceName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!dataSourceName) {
|
||||
// Gets the first defined datasource with the right name
|
||||
// Mostly for historical reasons - new configs should use the defaultDataSourceName
|
||||
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
|
||||
// TODO: Good usecase for flatmap?
|
||||
const webApiDataSources = dataSourceModules.reduce((acc, curr) => {
|
||||
const mods = [];
|
||||
curr.module.forEach(mod => {
|
||||
if (mod.type === 'webApi') {
|
||||
mods.push(mod);
|
||||
}
|
||||
});
|
||||
return acc.concat(mods);
|
||||
}, []);
|
||||
dataSourceName = webApiDataSources
|
||||
.map(ds => ds.name)
|
||||
.find(it => extensionManager.getDataSources(it)?.[0] !== undefined);
|
||||
}
|
||||
|
||||
return dataSourceName;
|
||||
}, []);
|
||||
|
||||
const [isDataSourceInitialized, setIsDataSourceInitialized] = useState(false);
|
||||
|
||||
// The path to the data source to be used in the URL for a mode (e.g. mode/dataSourcePath?StudyIntanceUIDs=1.2.3)
|
||||
const [dataSourcePath, setDataSourcePath] = useState(() => {
|
||||
const dataSourceName = getInitialDataSourceName();
|
||||
return dataSourceName ? `/${dataSourceName}` : '';
|
||||
});
|
||||
|
||||
const [dataSource, setDataSource] = useState(() => {
|
||||
const dataSourceName = getInitialDataSourceName();
|
||||
|
||||
if (!dataSourceName) {
|
||||
return extensionManager.getActiveDataSource()[0];
|
||||
}
|
||||
|
||||
const dataSource = extensionManager.getDataSources(dataSourceName)?.[0];
|
||||
if (!dataSource) {
|
||||
throw new Error(`No data source found for ${dataSourceName}`);
|
||||
}
|
||||
|
||||
return dataSource;
|
||||
});
|
||||
|
||||
const [data, setData] = useState(DEFAULT_DATA);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* The effect to initialize the data source whenever it changes. Similar to
|
||||
* whenever a different Mode is entered, the Mode's data source is initialized, so
|
||||
* too this DataSourceWrapper must initialize its data source whenever a different
|
||||
* data source is activated. Furthermore, a data source might be initialized
|
||||
* several times as it gets activated/deactivated because the location URL
|
||||
* might change and data sources initialize based on the URL.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const initializeDataSource = async () => {
|
||||
await dataSource.initialize({ params, query });
|
||||
setIsDataSourceInitialized(true);
|
||||
};
|
||||
|
||||
initializeDataSource();
|
||||
}, [dataSource]);
|
||||
|
||||
useEffect(() => {
|
||||
const dataSourceChangedCallback = () => {
|
||||
setIsLoading(false);
|
||||
setIsDataSourceInitialized(false);
|
||||
setDataSourcePath('');
|
||||
setDataSource(extensionManager.getActiveDataSource()[0]);
|
||||
// Setting data to DEFAULT_DATA triggers a new query just like it does for the initial load.
|
||||
setData(DEFAULT_DATA);
|
||||
};
|
||||
|
||||
const sub = extensionManager.subscribe(
|
||||
ExtensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED,
|
||||
dataSourceChangedCallback
|
||||
);
|
||||
return () => sub.unsubscribe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDataSourceInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryFilterValues = _getQueryFilterValues(location.search, STUDIES_LIMIT);
|
||||
|
||||
// 204: no content
|
||||
async function getData() {
|
||||
setIsLoading(true);
|
||||
log.time(Enums.TimingEnum.SEARCH_TO_LIST);
|
||||
const studies = await dataSource.query.studies.search(queryFilterValues);
|
||||
|
||||
setData({
|
||||
studies: studies || [],
|
||||
total: studies.length,
|
||||
resultsPerPage: queryFilterValues.resultsPerPage,
|
||||
pageNumber: queryFilterValues.pageNumber,
|
||||
location,
|
||||
});
|
||||
log.timeEnd(Enums.TimingEnum.SCRIPT_TO_VIEW);
|
||||
log.timeEnd(Enums.TimingEnum.SEARCH_TO_LIST);
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
// Cache invalidation :thinking:
|
||||
// - Anytime change is not just next/previous page
|
||||
// - And we didn't cross a result offset range
|
||||
const isSamePage = data.pageNumber === queryFilterValues.pageNumber;
|
||||
const previousOffset =
|
||||
Math.floor((data.pageNumber * data.resultsPerPage) / STUDIES_LIMIT) * (STUDIES_LIMIT - 1);
|
||||
const newOffset =
|
||||
Math.floor(
|
||||
(queryFilterValues.pageNumber * queryFilterValues.resultsPerPage) / STUDIES_LIMIT
|
||||
) *
|
||||
(STUDIES_LIMIT - 1);
|
||||
// Simply checking data.location !== location is not sufficient because even though the location href (i.e. entire URL)
|
||||
// has not changed, the React Router still provides a new location reference and would result in two study queries
|
||||
// on initial load. Alternatively, window.location.href could be used.
|
||||
const isLocationUpdated =
|
||||
typeof data.location === 'string' || !areLocationsTheSame(data.location, location);
|
||||
const isDataInvalid =
|
||||
!isSamePage || (!isLoading && (newOffset !== previousOffset || isLocationUpdated));
|
||||
|
||||
if (isDataInvalid) {
|
||||
getData().catch(e => {
|
||||
console.error(e);
|
||||
|
||||
const { configurationAPI, friendlyName } = dataSource.getConfig();
|
||||
// If there is a data source configuration API, then the Worklist will popup the dialog to attempt to configure it
|
||||
// and attempt to resolve this issue.
|
||||
if (configurationAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
servicesManager.services.uiModalService.show({
|
||||
title: 'Data Source Connection Error',
|
||||
containerDimensions: 'w-1/2',
|
||||
content: () => {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-red-600">Error: {e.message}</p>
|
||||
<p>
|
||||
Please ensure the following data source is configured correctly or is running:
|
||||
</p>
|
||||
<div className="mt-2 font-bold">{friendlyName}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (ex) {
|
||||
console.warn(ex);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, location, params, isLoading, setIsLoading, dataSource, isDataSourceInitialized]);
|
||||
// queryFilterValues
|
||||
|
||||
// TODO: Better way to pass DataSource?
|
||||
return (
|
||||
<LayoutTemplate
|
||||
{...rest}
|
||||
data={data.studies}
|
||||
dataPath={dataSourcePath}
|
||||
dataTotal={data.total}
|
||||
dataSource={dataSource}
|
||||
isLoadingData={isLoading}
|
||||
// To refresh the data, simply reset it to DEFAULT_DATA which invalidates it and triggers a new query to fetch the data.
|
||||
onRefresh={() => setData(DEFAULT_DATA)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DataSourceWrapper.propTypes = {
|
||||
/** Layout Component to wrap with a Data Source */
|
||||
children: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired,
|
||||
};
|
||||
|
||||
export default DataSourceWrapper;
|
||||
|
||||
/**
|
||||
* Duplicated in `workList`
|
||||
* Need generic that can be shared? Isn't this what qs is for?
|
||||
* @param {*} query
|
||||
*/
|
||||
function _getQueryFilterValues(query, queryLimit) {
|
||||
query = new URLSearchParams(query);
|
||||
const newParams = new URLSearchParams();
|
||||
for (const [key, value] of query) {
|
||||
newParams.set(key.toLowerCase(), value);
|
||||
}
|
||||
query = newParams;
|
||||
|
||||
const pageNumber = _tryParseInt(query.get('pagenumber'), 1);
|
||||
const resultsPerPage = _tryParseInt(query.get('resultsperpage'), 25);
|
||||
|
||||
const queryFilterValues = {
|
||||
// DCM
|
||||
patientId: query.get('mrn'),
|
||||
patientName: query.get('patientname'),
|
||||
studyDescription: query.get('description'),
|
||||
modalitiesInStudy: query.get('modalities') && query.get('modalities').split(','),
|
||||
accessionNumber: query.get('accession'),
|
||||
//
|
||||
startDate: query.get('startdate'),
|
||||
endDate: query.get('enddate'),
|
||||
page: _tryParseInt(query.get('page'), undefined),
|
||||
pageNumber,
|
||||
resultsPerPage,
|
||||
// Rarely supported server-side
|
||||
sortBy: query.get('sortby'),
|
||||
sortDirection: query.get('sortdirection'),
|
||||
// Offset...
|
||||
offset: Math.floor((pageNumber * resultsPerPage) / queryLimit) * (queryLimit - 1),
|
||||
config: query.get('configurl'),
|
||||
};
|
||||
|
||||
// patientName: good
|
||||
// studyDescription: good
|
||||
// accessionNumber: good
|
||||
|
||||
// Delete null/undefined keys
|
||||
Object.keys(queryFilterValues).forEach(
|
||||
key => queryFilterValues[key] == null && delete queryFilterValues[key]
|
||||
);
|
||||
|
||||
return queryFilterValues;
|
||||
|
||||
function _tryParseInt(str, defaultValue) {
|
||||
let retValue = defaultValue;
|
||||
if (str !== null) {
|
||||
if (str.length > 0) {
|
||||
if (!isNaN(str)) {
|
||||
retValue = parseInt(str);
|
||||
}
|
||||
}
|
||||
}
|
||||
return retValue;
|
||||
}
|
||||
}
|
||||
36
platform/app/src/routes/Debug.tsx
Normal file
36
platform/app/src/routes/Debug.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
// this is a debug component that is used to list various things that might
|
||||
// be useful for debugging such as cross origin errors, etc.
|
||||
function Debug() {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="bg-secondary-dark mx-auto space-y-2 rounded-lg py-8 px-8 drop-shadow-md">
|
||||
<img
|
||||
className="mx-auto block h-14"
|
||||
src="./ohif-logo.svg"
|
||||
alt="OHIF"
|
||||
/>
|
||||
<div className="space-y-2 pt-4 text-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="text-primary-active mt-4 text-xl font-semibold">Debug Information</p>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
<p className="text-md text-white">Cross Origin Isolated (COOP/COEP)</p>
|
||||
<Icons.ByName
|
||||
name={
|
||||
window.crossOriginIsolated ? 'notifications-success' : 'notifications-error'
|
||||
}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Debug;
|
||||
168
platform/app/src/routes/Local/Local.tsx
Normal file
168
platform/app/src/routes/Local/Local.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DicomMetadataStore, MODULE_TYPES, useSystem } from '@ohif/core';
|
||||
|
||||
import Dropzone from 'react-dropzone';
|
||||
import filesToStudies from './filesToStudies';
|
||||
|
||||
import { extensionManager } from '../../App';
|
||||
|
||||
import { Button } from '@ohif/ui';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
const getLoadButton = (onDrop, text, isDir) => {
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
noDrag
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<Button
|
||||
rounded="full"
|
||||
variant="contained" // outlined
|
||||
disabled={false}
|
||||
endIcon={<Icons.LaunchArrow />}
|
||||
className={classnames('font-medium', 'ml-2')}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{text}
|
||||
{isDir ? (
|
||||
<input
|
||||
{...getInputProps()}
|
||||
webkitdirectory="true"
|
||||
mozdirectory="true"
|
||||
/>
|
||||
) : (
|
||||
<input {...getInputProps()} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
|
||||
type LocalProps = {
|
||||
modePath: string;
|
||||
};
|
||||
|
||||
function Local({ modePath }: LocalProps) {
|
||||
const { servicesManager } = useSystem();
|
||||
const { customizationService } = servicesManager.services;
|
||||
const navigate = useNavigate();
|
||||
const dropzoneRef = useRef();
|
||||
const [dropInitiated, setDropInitiated] = React.useState(false);
|
||||
|
||||
const LoadingIndicatorProgress = customizationService.getCustomization(
|
||||
'ui.loadingIndicatorProgress'
|
||||
);
|
||||
|
||||
// Initializing the dicom local dataSource
|
||||
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
|
||||
const localDataSources = dataSourceModules.reduce((acc, curr) => {
|
||||
const mods = [];
|
||||
curr.module.forEach(mod => {
|
||||
if (mod.type === 'localApi') {
|
||||
mods.push(mod);
|
||||
}
|
||||
});
|
||||
return acc.concat(mods);
|
||||
}, []);
|
||||
|
||||
const firstLocalDataSource = localDataSources[0];
|
||||
const dataSource = firstLocalDataSource.createDataSource({});
|
||||
|
||||
const microscopyExtensionLoaded = extensionManager.registeredExtensionIds.includes(
|
||||
'@ohif/extension-dicom-microscopy'
|
||||
);
|
||||
|
||||
const onDrop = async acceptedFiles => {
|
||||
const studies = await filesToStudies(acceptedFiles, dataSource);
|
||||
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (microscopyExtensionLoaded) {
|
||||
// TODO: for microscopy, we are forcing microscopy mode, which is not ideal.
|
||||
// we should make the local drag and drop navigate to the worklist and
|
||||
// there user can select microscopy mode
|
||||
const smStudies = studies.filter(id => {
|
||||
const study = DicomMetadataStore.getStudy(id);
|
||||
return (
|
||||
study.series.findIndex(s => s.Modality === 'SM' || s.instances[0].Modality === 'SM') >= 0
|
||||
);
|
||||
});
|
||||
|
||||
if (smStudies.length > 0) {
|
||||
smStudies.forEach(id => query.append('StudyInstanceUIDs', id));
|
||||
|
||||
modePath = 'microscopy';
|
||||
}
|
||||
}
|
||||
|
||||
// Todo: navigate to work list and let user select a mode
|
||||
studies.forEach(id => query.append('StudyInstanceUIDs', id));
|
||||
query.append('datasources', 'dicomlocal');
|
||||
|
||||
navigate(`/${modePath}?${decodeURIComponent(query.toString())}`);
|
||||
};
|
||||
|
||||
// Set body style
|
||||
useEffect(() => {
|
||||
document.body.classList.add('bg-black');
|
||||
return () => {
|
||||
document.body.classList.remove('bg-black');
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
ref={dropzoneRef}
|
||||
onDrop={acceptedFiles => {
|
||||
setDropInitiated(true);
|
||||
onDrop(acceptedFiles);
|
||||
}}
|
||||
noClick
|
||||
>
|
||||
{({ getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="bg-secondary-dark mx-auto space-y-2 rounded-lg py-8 px-8 drop-shadow-md">
|
||||
<div className="flex items-center justify-center">
|
||||
<Icons.OHIFLogoColorDarkBackground className="h-28" />
|
||||
</div>
|
||||
<div className="space-y-2 pt-4 text-center">
|
||||
{dropInitiated ? (
|
||||
<div className="flex flex-col items-center justify-center pt-48">
|
||||
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-base text-blue-300">
|
||||
Note: You data is not uploaded to any server, it will stay in your local
|
||||
browser application
|
||||
</p>
|
||||
<p className="text-xg text-primary-active pt-6 font-semibold">
|
||||
Drag and Drop DICOM files here to load them in the Viewer
|
||||
</p>
|
||||
<p className="text-lg text-blue-300">Or click to </p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-around pt-4">
|
||||
{getLoadButton(onDrop, 'Load files', false)}
|
||||
{getLoadButton(onDrop, 'Load folders', true)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
|
||||
export default Local;
|
||||
27
platform/app/src/routes/Local/dicomFileLoader.js
Normal file
27
platform/app/src/routes/Local/dicomFileLoader.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import dcmjs from 'dcmjs';
|
||||
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||
import FileLoader from './fileLoader';
|
||||
|
||||
const DICOMFileLoader = new (class extends FileLoader {
|
||||
fileType = 'application/dicom';
|
||||
loadFile(file, imageId) {
|
||||
return dicomImageLoader.wadouri.loadFileRequest(imageId);
|
||||
}
|
||||
|
||||
getDataset(image, imageId) {
|
||||
const dicomData = dcmjs.data.DicomMessage.readFile(image);
|
||||
|
||||
const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomData.dict);
|
||||
|
||||
dataset.url = imageId;
|
||||
|
||||
dataset._meta = dcmjs.data.DicomMetaDictionary.namifyDataset(dicomData.meta);
|
||||
|
||||
dataset.AvailableTransferSyntaxUID =
|
||||
dataset.AvailableTransferSyntaxUID || dataset._meta.TransferSyntaxUID?.Value?.[0];
|
||||
|
||||
return dataset;
|
||||
}
|
||||
})();
|
||||
|
||||
export default DICOMFileLoader;
|
||||
6
platform/app/src/routes/Local/fileLoader.js
Normal file
6
platform/app/src/routes/Local/fileLoader.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default class FileLoader {
|
||||
fileType;
|
||||
loadFile(file, imageId) {}
|
||||
getDataset(image, imageId) {}
|
||||
getStudies(dataset, imageId) {}
|
||||
}
|
||||
39
platform/app/src/routes/Local/fileLoaderService.js
Normal file
39
platform/app/src/routes/Local/fileLoaderService.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||
|
||||
import FileLoader from './fileLoader';
|
||||
import PDFFileLoader from './pdfFileLoader';
|
||||
import DICOMFileLoader from './dicomFileLoader';
|
||||
|
||||
class FileLoaderService extends FileLoader {
|
||||
fileType;
|
||||
loader;
|
||||
constructor(file) {
|
||||
super();
|
||||
const fileType = file && file.type;
|
||||
this.loader = this.getLoader(fileType);
|
||||
this.fileType = this.loader.fileType;
|
||||
}
|
||||
|
||||
addFile(file) {
|
||||
return dicomImageLoader.wadouri.fileManager.add(file);
|
||||
}
|
||||
|
||||
loadFile(file, imageId) {
|
||||
return this.loader.loadFile(file, imageId);
|
||||
}
|
||||
|
||||
getDataset(image, imageId) {
|
||||
return this.loader.getDataset(image, imageId);
|
||||
}
|
||||
|
||||
getLoader(fileType) {
|
||||
if (fileType === 'application/pdf') {
|
||||
return PDFFileLoader;
|
||||
} else {
|
||||
// Default to dicom loader
|
||||
return DICOMFileLoader;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FileLoaderService;
|
||||
22
platform/app/src/routes/Local/filesToStudies.js
Normal file
22
platform/app/src/routes/Local/filesToStudies.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import FileLoaderService from './fileLoaderService';
|
||||
import { DicomMetadataStore } from '@ohif/core';
|
||||
|
||||
const processFile = async file => {
|
||||
try {
|
||||
const fileLoaderService = new FileLoaderService(file);
|
||||
const imageId = fileLoaderService.addFile(file);
|
||||
const image = await fileLoaderService.loadFile(file, imageId);
|
||||
const dicomJSONDataset = await fileLoaderService.getDataset(image, imageId);
|
||||
|
||||
DicomMetadataStore.addInstance(dicomJSONDataset);
|
||||
} catch (error) {
|
||||
console.log(error.name, ':Error when trying to load and process local files:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
export default async function filesToStudies(files) {
|
||||
const processFilesPromises = files.map(processFile);
|
||||
await Promise.all(processFilesPromises);
|
||||
|
||||
return DicomMetadataStore.getStudyInstanceUIDs();
|
||||
}
|
||||
1
platform/app/src/routes/Local/index.js
Normal file
1
platform/app/src/routes/Local/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Local';
|
||||
17
platform/app/src/routes/Local/pdfFileLoader.js
Normal file
17
platform/app/src/routes/Local/pdfFileLoader.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||
import FileLoader from './fileLoader';
|
||||
|
||||
const PDFFileLoader = new (class extends FileLoader {
|
||||
fileType = 'application/pdf';
|
||||
loadFile(file, imageId) {
|
||||
return dicomImageLoader.wadouri.loadFileRequest(imageId);
|
||||
}
|
||||
|
||||
getDataset(image, imageId) {
|
||||
const dataset = {};
|
||||
dataset.imageId = image.imageId || imageId;
|
||||
return dataset;
|
||||
}
|
||||
})();
|
||||
|
||||
export default PDFFileLoader;
|
||||
26
platform/app/src/routes/Mode/Compose.tsx
Normal file
26
platform/app/src/routes/Mode/Compose.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Nests React components as ordered in array. We use this to
|
||||
* simplify composition a Mode specify's in it's configuration
|
||||
* for React Contexts that should wrap a Mode Route.
|
||||
*/
|
||||
export default function Compose(props) {
|
||||
const { components = [], children } = props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{components.reduceRight((acc, curr) => {
|
||||
const [Comp, props] = Array.isArray(curr) ? [curr[0], curr[1]] : [curr, {}];
|
||||
return <Comp {...props}>{acc}</Comp>;
|
||||
}, children)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// https://juliuskoronci.medium.com/avoid-a-long-list-of-react-providers-c45a269d80c1
|
||||
Compose.propTypes = {
|
||||
components: PropTypes.array,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
408
platform/app/src/routes/Mode/Mode.tsx
Normal file
408
platform/app/src/routes/Mode/Mode.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router';
|
||||
import PropTypes from 'prop-types';
|
||||
import { utils } from '@ohif/core';
|
||||
import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui';
|
||||
import { useSearchParams } from '@hooks';
|
||||
import { useAppConfig } from '@state';
|
||||
import ViewportGrid from '@components/ViewportGrid';
|
||||
import Compose from './Compose';
|
||||
import { history } from '../../utils/history';
|
||||
import loadModules from '../../pluginImports';
|
||||
import { defaultRouteInit } from './defaultRouteInit';
|
||||
import { updateAuthServiceAndCleanUrl } from './updateAuthServiceAndCleanUrl';
|
||||
|
||||
const { getSplitParam } = utils;
|
||||
|
||||
export default function ModeRoute({
|
||||
mode,
|
||||
dataSourceName,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
}: withAppTypes) {
|
||||
const [appConfig] = useAppConfig();
|
||||
|
||||
// Parse route params/querystring
|
||||
const location = useLocation();
|
||||
|
||||
// The react router DOM placeholder map (see https://reactrouter.com/en/main/hooks/use-params).
|
||||
const params = useParams();
|
||||
// The URL's query search parameters where the keys casing is maintained
|
||||
const query = useSearchParams();
|
||||
|
||||
mode?.onModeInit?.({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
appConfig,
|
||||
query,
|
||||
});
|
||||
|
||||
// The URL's query search parameters where the keys are all lower case.
|
||||
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
|
||||
|
||||
const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(null);
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [ExtensionDependenciesLoaded, setExtensionDependenciesLoaded] = useState(false);
|
||||
|
||||
const layoutTemplateData = useRef(false);
|
||||
const locationRef = useRef(null);
|
||||
const isMounted = useRef(false);
|
||||
|
||||
// Expose the react router dom navigation.
|
||||
history.navigate = useNavigate();
|
||||
|
||||
if (location !== locationRef.current) {
|
||||
layoutTemplateData.current = null;
|
||||
locationRef.current = location;
|
||||
}
|
||||
|
||||
const {
|
||||
displaySetService,
|
||||
panelService,
|
||||
hangingProtocolService,
|
||||
userAuthenticationService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const { extensions, sopClassHandlers, hangingProtocol } = mode;
|
||||
|
||||
const runTimeHangingProtocolId = lowerCaseSearchParams.get('hangingprotocolid');
|
||||
const runTimeStageId = lowerCaseSearchParams.get('stageid');
|
||||
const token = lowerCaseSearchParams.get('token');
|
||||
|
||||
if (token) {
|
||||
updateAuthServiceAndCleanUrl(token, location, userAuthenticationService);
|
||||
}
|
||||
|
||||
// An undefined dataSourceName implies that the active data source that is already set in the ExtensionManager should be used.
|
||||
if (dataSourceName !== undefined) {
|
||||
extensionManager.setActiveDataSource(dataSourceName);
|
||||
}
|
||||
|
||||
const dataSource = extensionManager.getActiveDataSource()[0];
|
||||
|
||||
// Only handling one route per mode for now
|
||||
const route = mode.routes[0];
|
||||
|
||||
useEffect(() => {
|
||||
const loadExtensions = async () => {
|
||||
const loadedExtensions = await loadModules(Object.keys(extensions));
|
||||
for (const extension of loadedExtensions) {
|
||||
const { id: extensionId } = extension;
|
||||
if (extensionManager.registeredExtensionIds.indexOf(extensionId) === -1) {
|
||||
await extensionManager.registerExtension(extension);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted.current) {
|
||||
setExtensionDependenciesLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadExtensions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Preventing state update for unmounted component
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ExtensionDependenciesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Todo: this should not be here, data source should not care about params
|
||||
const initializeDataSource = async (params, query) => {
|
||||
await dataSource.initialize({
|
||||
params,
|
||||
query,
|
||||
});
|
||||
setStudyInstanceUIDs(dataSource.getStudyInstanceUIDs({ params, query }));
|
||||
};
|
||||
|
||||
initializeDataSource(params, query);
|
||||
return () => {
|
||||
layoutTemplateData.current = null;
|
||||
};
|
||||
}, [location, ExtensionDependenciesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retrieveLayoutData = async () => {
|
||||
const layoutData = await route.layoutTemplate({
|
||||
location,
|
||||
servicesManager,
|
||||
studyInstanceUIDs,
|
||||
});
|
||||
|
||||
if (isMounted.current) {
|
||||
const { leftPanels = [], rightPanels = [], ...layoutProps } = layoutData.props;
|
||||
|
||||
panelService.reset();
|
||||
panelService.addPanels(panelService.PanelPosition.Left, leftPanels);
|
||||
panelService.addPanels(panelService.PanelPosition.Right, rightPanels);
|
||||
|
||||
// layoutProps contains all props but leftPanels and rightPanels
|
||||
layoutData.props = layoutProps;
|
||||
|
||||
layoutTemplateData.current = layoutData;
|
||||
setRefresh(!refresh);
|
||||
}
|
||||
};
|
||||
if (Array.isArray(studyInstanceUIDs) && studyInstanceUIDs[0]) {
|
||||
retrieveLayoutData();
|
||||
}
|
||||
return () => {
|
||||
layoutTemplateData.current = null;
|
||||
};
|
||||
}, [studyInstanceUIDs, ExtensionDependenciesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutTemplateData.current || !ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setupRouteInit = async () => {
|
||||
// TODO: For some reason this is running before the Providers
|
||||
// are calling setServiceImplementation
|
||||
// TODO -> iterate through services.
|
||||
|
||||
// Extension
|
||||
|
||||
// Add SOPClassHandlers to a new SOPClassManager.
|
||||
displaySetService.init(extensionManager, sopClassHandlers);
|
||||
|
||||
extensionManager.onModeEnter({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
appConfig,
|
||||
});
|
||||
|
||||
// use the URL hangingProtocolId if it exists, otherwise use the one
|
||||
// defined in the mode configuration
|
||||
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
|
||||
runTimeHangingProtocolId
|
||||
)
|
||||
? runTimeHangingProtocolId
|
||||
: hangingProtocol;
|
||||
|
||||
// Determine the index of the stageId if the hangingProtocolIdToUse is defined
|
||||
const stageIndex = Array.isArray(hangingProtocolIdToUse)
|
||||
? -1
|
||||
: hangingProtocolService.getStageIndex(hangingProtocolIdToUse, {
|
||||
stageId: runTimeStageId || undefined,
|
||||
});
|
||||
// Ensure that the stage index is never negative
|
||||
// If stageIndex is negative (e.g., if stage wasn't found), use 0 as the default
|
||||
const stageIndexToUse = Math.max(0, stageIndex);
|
||||
|
||||
// Sets the active hanging protocols - if hangingProtocol is undefined,
|
||||
// resets to default. Done before the onModeEnter to allow the onModeEnter
|
||||
// to perform custom hanging protocol actions
|
||||
hangingProtocolService.setActiveProtocolIds(hangingProtocolIdToUse);
|
||||
|
||||
mode?.onModeEnter({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
appConfig,
|
||||
});
|
||||
|
||||
// Move hotkeys setup here, after onModeEnter
|
||||
const hotkeys = customizationService.getCustomization('ohif.hotkeyBindings');
|
||||
hotkeysManager.setDefaultHotKeys(hotkeys);
|
||||
|
||||
/**
|
||||
* The next line should get all the query parameters provided by the URL
|
||||
* - except the StudyInstanceUIDs - and create an object called filters
|
||||
* used to filtering the study as the user wants otherwise it will return
|
||||
* a empty object.
|
||||
*
|
||||
* Example:
|
||||
* const filters = {
|
||||
* seriesInstanceUID: 1.2.276.0.7230010.3.1.3.1791068887.5412.1620253993.114611
|
||||
* }
|
||||
*/
|
||||
const filters =
|
||||
Array.from(query.keys()).reduce((acc: Record<string, string>, val: string) => {
|
||||
const lowerVal = val.toLowerCase();
|
||||
// Not sure why the case matters here - it doesn't in the URL
|
||||
if (lowerVal === 'seriesinstanceuids' || lowerVal === 'seriesinstanceuid') {
|
||||
const seriesUIDs = getSplitParam(lowerVal, query);
|
||||
return {
|
||||
...acc,
|
||||
seriesInstanceUID: seriesUIDs,
|
||||
};
|
||||
}
|
||||
return { ...acc, [val]: getSplitParam(lowerVal, query) };
|
||||
}, {}) ?? {};
|
||||
|
||||
let unsubs;
|
||||
|
||||
if (route.init) {
|
||||
unsubs = await route.init(
|
||||
{
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
hotkeysManager,
|
||||
studyInstanceUIDs,
|
||||
dataSource,
|
||||
filters,
|
||||
},
|
||||
hangingProtocolIdToUse,
|
||||
stageIndexToUse
|
||||
);
|
||||
}
|
||||
|
||||
return defaultRouteInit(
|
||||
{
|
||||
servicesManager,
|
||||
studyInstanceUIDs,
|
||||
dataSource,
|
||||
filters,
|
||||
appConfig,
|
||||
},
|
||||
hangingProtocolIdToUse,
|
||||
stageIndexToUse
|
||||
);
|
||||
};
|
||||
|
||||
let unsubscriptions;
|
||||
setupRouteInit().then(unsubs => {
|
||||
unsubscriptions = unsubs;
|
||||
|
||||
mode?.onSetupRouteComplete?.({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
// The mode.onModeExit must be done first to allow it to store
|
||||
// information, and must be in a try/catch to ensure subscriptions
|
||||
// are unsubscribed.
|
||||
try {
|
||||
mode?.onModeExit?.({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('mode exit failure', e);
|
||||
}
|
||||
// Clean up hotkeys
|
||||
hotkeysManager.destroy();
|
||||
|
||||
// The unsubscriptions must occur before the extension onModeExit
|
||||
// in order to prevent exceptions during cleanup caused by spurious events
|
||||
if (unsubscriptions) {
|
||||
unsubscriptions.forEach(unsub => {
|
||||
unsub();
|
||||
});
|
||||
}
|
||||
// The extension manager must be called after the mode, this is
|
||||
// expected to cleanup the state to a standard setup.
|
||||
extensionManager.onModeExit();
|
||||
};
|
||||
}, [
|
||||
mode,
|
||||
dataSourceName,
|
||||
location,
|
||||
ExtensionDependenciesLoaded,
|
||||
route,
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
hotkeysManager,
|
||||
studyInstanceUIDs,
|
||||
refresh,
|
||||
]);
|
||||
|
||||
if (!studyInstanceUIDs || !layoutTemplateData.current || !ExtensionDependenciesLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ViewportGridWithDataSource = props => {
|
||||
return ViewportGrid({ ...props, dataSource });
|
||||
};
|
||||
|
||||
const CombinedExtensionsContextProvider = createCombinedContextProvider(
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager
|
||||
);
|
||||
|
||||
const getLayoutComponent = props => {
|
||||
const layoutTemplateModuleEntry = extensionManager.getModuleEntry(
|
||||
layoutTemplateData.current.id
|
||||
);
|
||||
const LayoutComponent = layoutTemplateModuleEntry.component;
|
||||
|
||||
return <LayoutComponent {...props} />;
|
||||
};
|
||||
|
||||
const LayoutComponent = getLayoutComponent({
|
||||
...layoutTemplateData.current.props,
|
||||
ViewportGridComp: ViewportGridWithDataSource,
|
||||
});
|
||||
|
||||
return (
|
||||
<ImageViewerProvider StudyInstanceUIDs={studyInstanceUIDs}>
|
||||
{CombinedExtensionsContextProvider ? (
|
||||
<CombinedExtensionsContextProvider>
|
||||
<DragAndDropProvider>{LayoutComponent}</DragAndDropProvider>
|
||||
</CombinedExtensionsContextProvider>
|
||||
) : (
|
||||
<DragAndDropProvider>{LayoutComponent}</DragAndDropProvider>
|
||||
)}
|
||||
</ImageViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a combined context provider using the context modules from the extension manager.
|
||||
* @param {object} extensionManager - The extension manager instance.
|
||||
* @param {object} servicesManager - The services manager instance.
|
||||
* @param {object} commandsManager - The commands manager instance.
|
||||
* @returns {React.Component} - A React component that provides combined contexts to its children.
|
||||
*/
|
||||
function createCombinedContextProvider(extensionManager, servicesManager, commandsManager) {
|
||||
const extensionsContextModules = extensionManager.getModulesByType(
|
||||
extensionManager.constructor.MODULE_TYPES.CONTEXT
|
||||
);
|
||||
|
||||
if (!extensionsContextModules?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contextModuleProviders = extensionsContextModules.flatMap(({ module }) => {
|
||||
return module.map(aContextModule => {
|
||||
return aContextModule.provider;
|
||||
});
|
||||
});
|
||||
|
||||
return ({ children }) => {
|
||||
return Compose({ components: contextModuleProviders, children });
|
||||
};
|
||||
}
|
||||
|
||||
ModeRoute.propTypes = {
|
||||
mode: PropTypes.object.isRequired,
|
||||
dataSourceName: PropTypes.string,
|
||||
extensionManager: PropTypes.object,
|
||||
servicesManager: PropTypes.object,
|
||||
hotkeysManager: PropTypes.object,
|
||||
commandsManager: PropTypes.object,
|
||||
};
|
||||
149
platform/app/src/routes/Mode/defaultRouteInit.ts
Normal file
149
platform/app/src/routes/Mode/defaultRouteInit.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import getStudies from './studiesList';
|
||||
import { DicomMetadataStore, log, utils, Enums } from '@ohif/core';
|
||||
import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed';
|
||||
|
||||
const { getSplitParam } = utils;
|
||||
|
||||
/**
|
||||
* Initialize the route.
|
||||
*
|
||||
* @param props.servicesManager to read services from
|
||||
* @param props.studyInstanceUIDs for a list of studies to read
|
||||
* @param props.dataSource to read the data from
|
||||
* @param props.filters filters from query params to read the data from
|
||||
* @returns array of subscriptions to cancel
|
||||
*/
|
||||
export async function defaultRouteInit(
|
||||
{ servicesManager, studyInstanceUIDs, dataSource, filters, appConfig }: withAppTypes,
|
||||
hangingProtocolId,
|
||||
stageIndex
|
||||
) {
|
||||
const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } =
|
||||
servicesManager.services;
|
||||
/**
|
||||
* Function to apply the hanging protocol when the minimum number of display sets were
|
||||
* received or all display sets retrieval were completed
|
||||
* @returns
|
||||
*/
|
||||
function applyHangingProtocol() {
|
||||
const displaySets = displaySetService.getActiveDisplaySets();
|
||||
|
||||
if (!displaySets || !displaySets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gets the studies list to use
|
||||
const studies = getStudies(studyInstanceUIDs, displaySets);
|
||||
|
||||
// study being displayed, and is thus the "active" study.
|
||||
const activeStudy = studies[0];
|
||||
|
||||
// run the hanging protocol matching on the displaySets with the predefined
|
||||
// hanging protocol in the mode configuration
|
||||
hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId, {
|
||||
stageIndex,
|
||||
});
|
||||
}
|
||||
|
||||
const unsubscriptions = [];
|
||||
const issuedWarningSeries = [];
|
||||
const { unsubscribe: instanceAddedUnsubscribe } = DicomMetadataStore.subscribe(
|
||||
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
|
||||
function ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) {
|
||||
const seriesMetadata = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID);
|
||||
|
||||
// checks if the series filter was used, if it exists
|
||||
const seriesInstanceUIDs = filters?.seriesInstanceUID;
|
||||
if (
|
||||
seriesInstanceUIDs?.length &&
|
||||
!isSeriesFilterUsed(seriesMetadata.instances, filters) &&
|
||||
!issuedWarningSeries.includes(seriesInstanceUIDs[0])
|
||||
) {
|
||||
// stores the series instance filter so it shows only once the warning
|
||||
issuedWarningSeries.push(seriesInstanceUIDs[0]);
|
||||
uiNotificationService.show({
|
||||
title: 'Series filter',
|
||||
message: `Each of the series in filter: ${seriesInstanceUIDs} are not part of the current study. The entire study is being displayed`,
|
||||
type: 'error',
|
||||
duration: 7000,
|
||||
});
|
||||
}
|
||||
|
||||
displaySetService.makeDisplaySets(seriesMetadata.instances, { madeInClient });
|
||||
}
|
||||
);
|
||||
|
||||
unsubscriptions.push(instanceAddedUnsubscribe);
|
||||
|
||||
log.time(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
|
||||
log.time(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE);
|
||||
|
||||
const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID =>
|
||||
dataSource.retrieve.series.metadata({
|
||||
StudyInstanceUID,
|
||||
filters,
|
||||
returnPromises: true,
|
||||
sortCriteria: customizationService.getCustomization('sortingCriteria'),
|
||||
})
|
||||
);
|
||||
|
||||
// log the error if this fails, otherwise it's so difficult to tell what went wrong...
|
||||
allRetrieves.forEach(retrieve => {
|
||||
retrieve.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
|
||||
// is displaysets from URL and has initialSOPInstanceUID or initialSeriesInstanceUID
|
||||
// then we need to wait for all display sets to be retrieved before applying the hanging protocol
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const initialSeriesInstanceUID = getSplitParam('initialseriesinstanceuid', params);
|
||||
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid', params);
|
||||
|
||||
let displaySetFromUrl = false;
|
||||
if (initialSeriesInstanceUID || initialSOPInstanceUID) {
|
||||
displaySetFromUrl = true;
|
||||
}
|
||||
|
||||
await Promise.allSettled(allRetrieves).then(async promises => {
|
||||
log.timeEnd(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
|
||||
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE);
|
||||
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES);
|
||||
|
||||
const allPromises = [];
|
||||
const remainingPromises = [];
|
||||
|
||||
function startRemainingPromises(remainingPromises) {
|
||||
remainingPromises.forEach(p => p.forEach(p => p.start()));
|
||||
}
|
||||
|
||||
promises.forEach(promise => {
|
||||
const retrieveSeriesMetadataPromise = promise.value;
|
||||
if (!Array.isArray(retrieveSeriesMetadataPromise)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (displaySetFromUrl) {
|
||||
const requiredSeriesPromises = retrieveSeriesMetadataPromise.map(promise =>
|
||||
promise.start()
|
||||
);
|
||||
allPromises.push(Promise.allSettled(requiredSeriesPromises));
|
||||
} else {
|
||||
const { requiredSeries, remaining } = hangingProtocolService.filterSeriesRequiredForRun(
|
||||
hangingProtocolId,
|
||||
retrieveSeriesMetadataPromise
|
||||
);
|
||||
const requiredSeriesPromises = requiredSeries.map(promise => promise.start());
|
||||
allPromises.push(Promise.allSettled(requiredSeriesPromises));
|
||||
remainingPromises.push(remaining);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(allPromises).then(applyHangingProtocol);
|
||||
startRemainingPromises(remainingPromises);
|
||||
applyHangingProtocol();
|
||||
});
|
||||
|
||||
return unsubscriptions;
|
||||
}
|
||||
1
platform/app/src/routes/Mode/index.js
Normal file
1
platform/app/src/routes/Mode/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Mode';
|
||||
65
platform/app/src/routes/Mode/studiesList.ts
Normal file
65
platform/app/src/routes/Mode/studiesList.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { DicomMetadataStore, Types } from '@ohif/core';
|
||||
|
||||
type StudyMetadata = Types.StudyMetadata;
|
||||
|
||||
/**
|
||||
* Compare function for sorting
|
||||
*
|
||||
* @param a - some simple value (string, number, timestamp)
|
||||
* @param b - some simple value
|
||||
* @param defaultCompare - default return value as a fallback when a===b
|
||||
* @returns - compare a and b, returning 1 if a<b -1 if a>b and defaultCompare otherwise
|
||||
*/
|
||||
const compare = (a, b, defaultCompare = 0): number => {
|
||||
if (a === b) {
|
||||
return defaultCompare;
|
||||
}
|
||||
if (a < b) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
/**
|
||||
* The studies from display sets gets the studies in study date
|
||||
* order or in study instance UID order - not very useful, but
|
||||
* if not specifically specified then at least making it consistent is useful.
|
||||
*/
|
||||
const getStudiesfromDisplaySets = (displaysets): StudyMetadata[] => {
|
||||
const studyMap = {};
|
||||
|
||||
const ret = displaySets.reduce((prev, curr) => {
|
||||
const { StudyInstanceUID } = curr;
|
||||
if (!studyMap[StudyInstanceUID]) {
|
||||
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
|
||||
studyMap[StudyInstanceUID] = study;
|
||||
prev.push(study);
|
||||
}
|
||||
return prev;
|
||||
}, []);
|
||||
// Return the sorted studies, first on study date and second on study instance UID
|
||||
ret.sort((a, b) => {
|
||||
return compare(a.StudyDate, b.StudyDate, compare(a.StudyInstanceUID, b.StudyInstanceUID));
|
||||
});
|
||||
return ret;
|
||||
};
|
||||
|
||||
/**
|
||||
* The studies retrieve from the Uids is faster and gets the studies
|
||||
* in the original order, as specified.
|
||||
*/
|
||||
const getStudiesFromUIDs = (studyUids: string[]): StudyMetadata[] => {
|
||||
if (!studyUids?.length) {
|
||||
return;
|
||||
}
|
||||
return studyUids.map(uid => DicomMetadataStore.getStudy(uid));
|
||||
};
|
||||
|
||||
/** Gets the array of studies */
|
||||
const getStudies = (studyUids?: string[], displaySets): StudyMetadata[] => {
|
||||
return getStudiesFromUIDs(studyUids) || getStudiesfromDisplaySets(displaySets);
|
||||
};
|
||||
|
||||
export default getStudies;
|
||||
|
||||
export { getStudies, getStudiesFromUIDs, getStudiesfromDisplaySets, compare };
|
||||
35
platform/app/src/routes/Mode/updateAuthServiceAndCleanUrl.ts
Normal file
35
platform/app/src/routes/Mode/updateAuthServiceAndCleanUrl.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Updates the user authentication service with the provided token and cleans the token from the URL.
|
||||
* @param token - The token to set in the user authentication service.
|
||||
* @param location - The location object from the router.
|
||||
* @param userAuthenticationService - The user authentication service instance.
|
||||
*/
|
||||
export function updateAuthServiceAndCleanUrl(
|
||||
token: string,
|
||||
location: any,
|
||||
userAuthenticationService: any
|
||||
): void {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if a token is passed in, set the userAuthenticationService to use it
|
||||
// for the Authorization header for all requests
|
||||
userAuthenticationService.setServiceImplementation({
|
||||
getAuthorizationHeader: () => ({
|
||||
Authorization: 'Bearer ' + token,
|
||||
}),
|
||||
});
|
||||
|
||||
// Create a URL object with the current location
|
||||
const urlObj = new URL(window.location.origin + window.location.pathname + location.search);
|
||||
|
||||
// Remove the token from the URL object
|
||||
urlObj.searchParams.delete('token');
|
||||
const cleanUrl = urlObj.toString();
|
||||
|
||||
// Update the browser's history without the token
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
}
|
||||
}
|
||||
30
platform/app/src/routes/NotFound/NotFound.tsx
Normal file
30
platform/app/src/routes/NotFound/NotFound.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useAppConfig } from '@state';
|
||||
|
||||
const NotFound = ({ message = 'Sorry, this page does not exist.', showGoBackButton = true }) => {
|
||||
const [appConfig] = useAppConfig();
|
||||
const { showStudyList } = appConfig;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
<div>
|
||||
<h4>{message}</h4>
|
||||
{showGoBackButton && showStudyList && (
|
||||
<h5>
|
||||
<Link to={'/'}>Go back to the Study List</Link>
|
||||
</h5>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NotFound.propTypes = {
|
||||
message: PropTypes.string,
|
||||
showGoBackButton: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
1
platform/app/src/routes/NotFound/index.js
Normal file
1
platform/app/src/routes/NotFound/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './NotFound';
|
||||
13
platform/app/src/routes/PrivateRoute.tsx
Normal file
13
platform/app/src/routes/PrivateRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useUserAuthentication } from '@ohif/ui';
|
||||
|
||||
export const PrivateRoute = ({ children, handleUnauthenticated }) => {
|
||||
const [{ user, enabled }] = useUserAuthentication();
|
||||
|
||||
if (enabled && !user) {
|
||||
return handleUnauthenticated();
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default PrivateRoute;
|
||||
30
platform/app/src/routes/SignoutCallbackComponent.tsx
Normal file
30
platform/app/src/routes/SignoutCallbackComponent.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function SignoutCallbackComponent({ userManager }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onRedirectSuccess = (/* user */) => {
|
||||
const { pathname, search = '' } = JSON.parse(sessionStorage.getItem('ohif-redirect-to'));
|
||||
|
||||
navigate(`${pathname}?${search}`);
|
||||
};
|
||||
|
||||
const onRedirectError = error => {
|
||||
throw new Error(error);
|
||||
};
|
||||
|
||||
userManager
|
||||
.signoutRedirectCallback()
|
||||
.then(user => onRedirectSuccess(user))
|
||||
.catch(error => onRedirectError(error));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
SignoutCallbackComponent.propTypes = {
|
||||
userManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default SignoutCallbackComponent;
|
||||
707
platform/app/src/routes/WorkList/WorkList.tsx
Normal file
707
platform/app/src/routes/WorkList/WorkList.tsx
Normal file
@@ -0,0 +1,707 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import qs from 'query-string';
|
||||
import isEqual from 'lodash.isequal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
//
|
||||
import filtersMeta from './filtersMeta.js';
|
||||
import { useAppConfig } from '@state';
|
||||
import { useDebounce, useSearchParams } from '@hooks';
|
||||
import { utils, hotkeys } from '@ohif/core';
|
||||
import publicUrl from '../../utils/publicUrl';
|
||||
|
||||
import {
|
||||
StudyListExpandedRow,
|
||||
EmptyStudies,
|
||||
StudyListTable,
|
||||
StudyListPagination,
|
||||
StudyListFilter,
|
||||
useModal,
|
||||
AboutModal,
|
||||
UserPreferences,
|
||||
useSessionStorage,
|
||||
InvestigationalUseDialog,
|
||||
Button,
|
||||
ButtonEnums,
|
||||
} from '@ohif/ui';
|
||||
|
||||
import {
|
||||
Header,
|
||||
Icons,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
Clipboard,
|
||||
Onboarding,
|
||||
ScrollArea,
|
||||
} from '@ohif/ui-next';
|
||||
|
||||
import { Types } from '@ohif/ui';
|
||||
|
||||
import i18n from '@ohif/i18n';
|
||||
import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters';
|
||||
|
||||
const PatientInfoVisibility = Types.PatientInfoVisibility;
|
||||
|
||||
const { sortBySeriesDate } = utils;
|
||||
|
||||
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
|
||||
|
||||
const seriesInStudiesMap = new Map();
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - debounce `setFilterValues` (150ms?)
|
||||
*/
|
||||
function WorkList({
|
||||
data: studies,
|
||||
dataTotal: studiesTotal,
|
||||
isLoadingData,
|
||||
dataSource,
|
||||
hotkeysManager,
|
||||
dataPath,
|
||||
onRefresh,
|
||||
servicesManager,
|
||||
}: withAppTypes) {
|
||||
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
|
||||
const { show, hide } = useModal();
|
||||
const { t } = useTranslation();
|
||||
// ~ Modes
|
||||
const [appConfig] = useAppConfig();
|
||||
// ~ Filters
|
||||
const searchParams = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const STUDIES_LIMIT = 101;
|
||||
const queryFilterValues = _getQueryFilterValues(searchParams);
|
||||
const [sessionQueryFilterValues, updateSessionQueryFilterValues] = useSessionStorage({
|
||||
key: 'queryFilterValues',
|
||||
defaultValue: queryFilterValues,
|
||||
// ToDo: useSessionStorage currently uses an unload listener to clear the filters from session storage
|
||||
// so on systems that do not support unload events a user will NOT be able to alter any existing filter
|
||||
// in the URL, load the page and have it apply.
|
||||
clearOnUnload: true,
|
||||
});
|
||||
const [filterValues, _setFilterValues] = useState({
|
||||
...defaultFilterValues,
|
||||
...sessionQueryFilterValues,
|
||||
});
|
||||
|
||||
const debouncedFilterValues = useDebounce(filterValues, 200);
|
||||
const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues;
|
||||
|
||||
/*
|
||||
* The default sort value keep the filters synchronized with runtime conditional sorting
|
||||
* Only applied if no other sorting is specified and there are less than 101 studies
|
||||
*/
|
||||
|
||||
const canSort = studiesTotal < STUDIES_LIMIT;
|
||||
const shouldUseDefaultSort = sortBy === '' || !sortBy;
|
||||
const sortModifier = sortDirection === 'descending' ? 1 : -1;
|
||||
const defaultSortValues =
|
||||
shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {};
|
||||
|
||||
const sortedStudies = useMemo(() => {
|
||||
if (!canSort) {
|
||||
return studies;
|
||||
}
|
||||
|
||||
return [...studies].sort((s1, s2) => {
|
||||
if (shouldUseDefaultSort) {
|
||||
const ascendingSortModifier = -1;
|
||||
return _sortStringDates(s1, s2, ascendingSortModifier);
|
||||
}
|
||||
|
||||
const s1Prop = s1[sortBy];
|
||||
const s2Prop = s2[sortBy];
|
||||
|
||||
if (typeof s1Prop === 'string' && typeof s2Prop === 'string') {
|
||||
return s1Prop.localeCompare(s2Prop) * sortModifier;
|
||||
} else if (typeof s1Prop === 'number' && typeof s2Prop === 'number') {
|
||||
return (s1Prop > s2Prop ? 1 : -1) * sortModifier;
|
||||
} else if (!s1Prop && s2Prop) {
|
||||
return -1 * sortModifier;
|
||||
} else if (!s2Prop && s1Prop) {
|
||||
return 1 * sortModifier;
|
||||
} else if (sortBy === 'studyDate') {
|
||||
return _sortStringDates(s1, s2, sortModifier);
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}, [canSort, studies, shouldUseDefaultSort, sortBy, sortModifier]);
|
||||
|
||||
// ~ Rows & Studies
|
||||
const [expandedRows, setExpandedRows] = useState([]);
|
||||
const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]);
|
||||
const numOfStudies = studiesTotal;
|
||||
const querying = useMemo(() => {
|
||||
return isLoadingData || expandedRows.length > 0;
|
||||
}, [isLoadingData, expandedRows]);
|
||||
|
||||
const setFilterValues = val => {
|
||||
if (filterValues.pageNumber === val.pageNumber) {
|
||||
val.pageNumber = 1;
|
||||
}
|
||||
_setFilterValues(val);
|
||||
updateSessionQueryFilterValues(val);
|
||||
setExpandedRows([]);
|
||||
};
|
||||
|
||||
const onPageNumberChange = newPageNumber => {
|
||||
const oldPageNumber = filterValues.pageNumber;
|
||||
const rollingPageNumberMod = Math.floor(101 / filterValues.resultsPerPage);
|
||||
const rollingPageNumber = oldPageNumber % rollingPageNumberMod;
|
||||
const isNextPage = newPageNumber > oldPageNumber;
|
||||
const hasNextPage = Math.max(rollingPageNumber, 1) * resultsPerPage < numOfStudies;
|
||||
|
||||
if (isNextPage && !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFilterValues({ ...filterValues, pageNumber: newPageNumber });
|
||||
};
|
||||
|
||||
const onResultsPerPageChange = newResultsPerPage => {
|
||||
setFilterValues({
|
||||
...filterValues,
|
||||
pageNumber: 1,
|
||||
resultsPerPage: Number(newResultsPerPage),
|
||||
});
|
||||
};
|
||||
|
||||
// Set body style
|
||||
useEffect(() => {
|
||||
document.body.classList.add('bg-black');
|
||||
return () => {
|
||||
document.body.classList.remove('bg-black');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync URL query parameters with filters
|
||||
useEffect(() => {
|
||||
if (!debouncedFilterValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = {};
|
||||
Object.keys(defaultFilterValues).forEach(key => {
|
||||
const defaultValue = defaultFilterValues[key];
|
||||
const currValue = debouncedFilterValues[key];
|
||||
|
||||
// TODO: nesting/recursion?
|
||||
if (key === 'studyDate') {
|
||||
if (currValue.startDate && defaultValue.startDate !== currValue.startDate) {
|
||||
queryString.startDate = currValue.startDate;
|
||||
}
|
||||
if (currValue.endDate && defaultValue.endDate !== currValue.endDate) {
|
||||
queryString.endDate = currValue.endDate;
|
||||
}
|
||||
} else if (key === 'modalities' && currValue.length) {
|
||||
queryString.modalities = currValue.join(',');
|
||||
} else if (currValue !== defaultValue) {
|
||||
queryString[key] = currValue;
|
||||
}
|
||||
});
|
||||
|
||||
preserveQueryStrings(queryString);
|
||||
|
||||
const search = qs.stringify(queryString, {
|
||||
skipNull: true,
|
||||
skipEmptyString: true,
|
||||
});
|
||||
navigate({
|
||||
pathname: publicUrl,
|
||||
search: search ? `?${search}` : undefined,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedFilterValues]);
|
||||
|
||||
// Query for series information
|
||||
useEffect(() => {
|
||||
const fetchSeries = async studyInstanceUid => {
|
||||
try {
|
||||
const series = await dataSource.query.series.search(studyInstanceUid);
|
||||
seriesInStudiesMap.set(studyInstanceUid, sortBySeriesDate(series));
|
||||
setStudiesWithSeriesData([...studiesWithSeriesData, studyInstanceUid]);
|
||||
} catch (ex) {
|
||||
// TODO: UI Notification Service
|
||||
console.warn(ex);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: WHY WOULD YOU USE AN INDEX OF 1?!
|
||||
// Note: expanded rows index begins at 1
|
||||
for (let z = 0; z < expandedRows.length; z++) {
|
||||
const expandedRowIndex = expandedRows[z] - 1;
|
||||
const studyInstanceUid = sortedStudies[expandedRowIndex].studyInstanceUid;
|
||||
|
||||
if (studiesWithSeriesData.includes(studyInstanceUid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fetchSeries(studyInstanceUid);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [expandedRows, studies]);
|
||||
|
||||
const isFiltering = (filterValues, defaultFilterValues) => {
|
||||
return !isEqual(filterValues, defaultFilterValues);
|
||||
};
|
||||
|
||||
const rollingPageNumberMod = Math.floor(101 / resultsPerPage);
|
||||
const rollingPageNumber = (pageNumber - 1) % rollingPageNumberMod;
|
||||
const offset = resultsPerPage * rollingPageNumber;
|
||||
const offsetAndTake = offset + resultsPerPage;
|
||||
const tableDataSource = sortedStudies.map((study, key) => {
|
||||
const rowKey = key + 1;
|
||||
const isExpanded = expandedRows.some(k => k === rowKey);
|
||||
const {
|
||||
studyInstanceUid,
|
||||
accession,
|
||||
modalities,
|
||||
instances,
|
||||
description,
|
||||
mrn,
|
||||
patientName,
|
||||
date,
|
||||
time,
|
||||
} = study;
|
||||
const studyDate =
|
||||
date &&
|
||||
moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true).isValid() &&
|
||||
moment(date, ['YYYYMMDD', 'YYYY.MM.DD']).format(t('Common:localDateFormat', 'MMM-DD-YYYY'));
|
||||
const studyTime =
|
||||
time &&
|
||||
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).isValid() &&
|
||||
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).format(
|
||||
t('Common:localTimeFormat', 'hh:mm A')
|
||||
);
|
||||
|
||||
const makeCopyTooltipCell = textValue => {
|
||||
if (!textValue) {
|
||||
return '';
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-pointer truncate">{textValue}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{textValue}
|
||||
<Clipboard>{textValue}</Clipboard>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
dataCY: `studyRow-${studyInstanceUid}`,
|
||||
clickableCY: studyInstanceUid,
|
||||
row: [
|
||||
{
|
||||
key: 'patientName',
|
||||
content: patientName ? makeCopyTooltipCell(patientName) : null,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
key: 'mrn',
|
||||
content: makeCopyTooltipCell(mrn),
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'studyDate',
|
||||
content: (
|
||||
<>
|
||||
{studyDate && <span className="mr-4">{studyDate}</span>}
|
||||
{studyTime && <span>{studyTime}</span>}
|
||||
</>
|
||||
),
|
||||
title: `${studyDate || ''} ${studyTime || ''}`,
|
||||
gridCol: 5,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
content: makeCopyTooltipCell(description),
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
key: 'modality',
|
||||
content: modalities,
|
||||
title: modalities,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'accession',
|
||||
content: makeCopyTooltipCell(accession),
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
key: 'instances',
|
||||
content: (
|
||||
<>
|
||||
<Icons.GroupLayers
|
||||
className={classnames('mr-2 inline-flex w-4', {
|
||||
'text-primary-active': isExpanded,
|
||||
'text-secondary-light': !isExpanded,
|
||||
})}
|
||||
/>
|
||||
{instances}
|
||||
</>
|
||||
),
|
||||
title: (instances || 0).toString(),
|
||||
gridCol: 2,
|
||||
},
|
||||
],
|
||||
// Todo: This is actually running for all rows, even if they are
|
||||
// not clicked on.
|
||||
expandedContent: (
|
||||
<StudyListExpandedRow
|
||||
seriesTableColumns={{
|
||||
description: t('StudyList:Description'),
|
||||
seriesNumber: t('StudyList:Series'),
|
||||
modality: t('StudyList:Modality'),
|
||||
instances: t('StudyList:Instances'),
|
||||
}}
|
||||
seriesTableDataSource={
|
||||
seriesInStudiesMap.has(studyInstanceUid)
|
||||
? seriesInStudiesMap.get(studyInstanceUid).map(s => {
|
||||
return {
|
||||
description: s.description || '(empty)',
|
||||
seriesNumber: s.seriesNumber ?? '',
|
||||
modality: s.modality || '',
|
||||
instances: s.numSeriesInstances || '',
|
||||
};
|
||||
})
|
||||
: []
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
{(appConfig.groupEnabledModesFirst
|
||||
? appConfig.loadedModes.sort((a, b) => {
|
||||
const isValidA = a.isValidMode({
|
||||
modalities: modalities.replaceAll('/', '\\'),
|
||||
study,
|
||||
}).valid;
|
||||
const isValidB = b.isValidMode({
|
||||
modalities: modalities.replaceAll('/', '\\'),
|
||||
study,
|
||||
}).valid;
|
||||
|
||||
return isValidB - isValidA;
|
||||
})
|
||||
: appConfig.loadedModes
|
||||
).map((mode, i) => {
|
||||
const modalitiesToCheck = modalities.replaceAll('/', '\\');
|
||||
|
||||
const { valid: isValidMode, description: invalidModeDescription } = mode.isValidMode({
|
||||
modalities: modalitiesToCheck,
|
||||
study,
|
||||
});
|
||||
// TODO: Modes need a default/target route? We mostly support a single one for now.
|
||||
// We should also be using the route path, but currently are not
|
||||
// mode.routeName
|
||||
// mode.routes[x].path
|
||||
// Don't specify default data source, and it should just be picked up... (this may not currently be the case)
|
||||
// How do we know which params to pass? Today, it's just StudyInstanceUIDs and configUrl if exists
|
||||
const query = new URLSearchParams();
|
||||
if (filterValues.configUrl) {
|
||||
query.append('configUrl', filterValues.configUrl);
|
||||
}
|
||||
query.append('StudyInstanceUIDs', studyInstanceUid);
|
||||
preserveQueryParameters(query);
|
||||
|
||||
return (
|
||||
mode.displayName && (
|
||||
<Link
|
||||
className={isValidMode ? '' : 'cursor-not-allowed'}
|
||||
key={i}
|
||||
to={`${publicUrl}${mode.routeName}${dataPath || ''}?${query.toString()}`}
|
||||
onClick={event => {
|
||||
// In case any event bubbles up for an invalid mode, prevent the navigation.
|
||||
// For example, the event bubbles up when the icon embedded in the disabled button is clicked.
|
||||
if (!isValidMode) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
|
||||
>
|
||||
{/* TODO revisit the completely rounded style of buttons used for launching a mode from the worklist later - for now use LegacyButton*/}
|
||||
<Button
|
||||
type={ButtonEnums.type.primary}
|
||||
size={ButtonEnums.size.medium}
|
||||
disabled={!isValidMode}
|
||||
startIconTooltip={
|
||||
!isValidMode ? (
|
||||
<div className="font-inter flex w-[206px] whitespace-normal text-left text-xs font-normal text-white">
|
||||
{invalidModeDescription}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
startIcon={
|
||||
isValidMode ? (
|
||||
<Icons.LaunchArrow className="!h-[20px] !w-[20px] text-black" />
|
||||
) : (
|
||||
<Icons.LaunchInfo className="!h-[20px] !w-[20px] text-black" />
|
||||
)
|
||||
}
|
||||
onClick={() => {}}
|
||||
dataCY={`mode-${mode.routeName}-${studyInstanceUid}`}
|
||||
className={isValidMode ? 'text-[13px]' : 'bg-[#222d44] text-[13px]'}
|
||||
>
|
||||
{mode.displayName}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</StudyListExpandedRow>
|
||||
),
|
||||
onClickRow: () =>
|
||||
setExpandedRows(s => (isExpanded ? s.filter(n => rowKey !== n) : [...s, rowKey])),
|
||||
isExpanded,
|
||||
};
|
||||
});
|
||||
|
||||
const hasStudies = numOfStudies > 0;
|
||||
const versionNumber = process.env.VERSION_NUMBER;
|
||||
const commitHash = process.env.COMMIT_HASH;
|
||||
|
||||
const menuOptions = [
|
||||
{
|
||||
title: t('Header:About'),
|
||||
icon: 'info',
|
||||
onClick: () =>
|
||||
show({
|
||||
content: AboutModal,
|
||||
title: t('AboutModal:About OHIF Viewer'),
|
||||
contentProps: { versionNumber, commitHash },
|
||||
containerDimensions: 'max-w-4xl max-h-4xl',
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('Header:Preferences'),
|
||||
icon: 'settings',
|
||||
onClick: () =>
|
||||
show({
|
||||
title: t('UserPreferencesModal:User preferences'),
|
||||
content: UserPreferences,
|
||||
contentProps: {
|
||||
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults),
|
||||
hotkeyDefinitions,
|
||||
onCancel: hide,
|
||||
currentLanguage: currentLanguage(),
|
||||
availableLanguages,
|
||||
defaultLanguage,
|
||||
onSubmit: state => {
|
||||
if (state.language.value !== currentLanguage().value) {
|
||||
i18n.changeLanguage(state.language.value);
|
||||
}
|
||||
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
|
||||
hide();
|
||||
},
|
||||
onReset: () => hotkeysManager.restoreDefaultBindings(),
|
||||
hotkeysModule: hotkeys,
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
if (appConfig.oidc) {
|
||||
menuOptions.push({
|
||||
icon: 'power-off',
|
||||
title: t('Header:Logout'),
|
||||
onClick: () => {
|
||||
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { customizationService } = servicesManager.services;
|
||||
const LoadingIndicatorProgress = customizationService.getCustomization(
|
||||
'ui.loadingIndicatorProgress'
|
||||
);
|
||||
const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent');
|
||||
|
||||
const uploadProps =
|
||||
DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled
|
||||
? {
|
||||
title: 'Upload files',
|
||||
closeButton: true,
|
||||
shouldCloseOnEsc: false,
|
||||
shouldCloseOnOverlayClick: false,
|
||||
content: () => (
|
||||
<DicomUploadComponent
|
||||
dataSource={dataSource}
|
||||
onComplete={() => {
|
||||
hide();
|
||||
onRefresh();
|
||||
}}
|
||||
onStarted={() => {
|
||||
show({
|
||||
...uploadProps,
|
||||
// when upload starts, hide the default close button as closing the dialogue must be handled by the upload dialogue itself
|
||||
closeButton: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const dataSourceConfigurationComponent = customizationService.getCustomization(
|
||||
'ohif.dataSourceConfigurationComponent'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-black">
|
||||
<Header
|
||||
isSticky
|
||||
menuOptions={menuOptions}
|
||||
isReturnEnabled={false}
|
||||
WhiteLabeling={appConfig.whiteLabeling}
|
||||
showPatientInfo={PatientInfoVisibility.DISABLED}
|
||||
/>
|
||||
<Onboarding />
|
||||
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<ScrollArea>
|
||||
<div className="flex grow flex-col">
|
||||
<StudyListFilter
|
||||
numOfStudies={pageNumber * resultsPerPage > 100 ? 101 : numOfStudies}
|
||||
filtersMeta={filtersMeta}
|
||||
filterValues={{ ...filterValues, ...defaultSortValues }}
|
||||
onChange={setFilterValues}
|
||||
clearFilters={() => setFilterValues(defaultFilterValues)}
|
||||
isFiltering={isFiltering(filterValues, defaultFilterValues)}
|
||||
onUploadClick={uploadProps ? () => show(uploadProps) : undefined}
|
||||
getDataSourceConfigurationComponent={
|
||||
dataSourceConfigurationComponent
|
||||
? () => dataSourceConfigurationComponent()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{hasStudies ? (
|
||||
<div className="flex grow flex-col">
|
||||
<StudyListTable
|
||||
tableDataSource={tableDataSource.slice(offset, offsetAndTake)}
|
||||
numOfStudies={numOfStudies}
|
||||
querying={querying}
|
||||
filtersMeta={filtersMeta}
|
||||
/>
|
||||
<div className="grow">
|
||||
<StudyListPagination
|
||||
onChangePage={onPageNumberChange}
|
||||
onChangePerPage={onResultsPerPageChange}
|
||||
currentPage={pageNumber}
|
||||
perPage={resultsPerPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pt-48">
|
||||
{appConfig.showLoadingIndicator && isLoadingData ? (
|
||||
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
|
||||
) : (
|
||||
<EmptyStudies />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
WorkList.propTypes = {
|
||||
data: PropTypes.array.isRequired,
|
||||
dataSource: PropTypes.shape({
|
||||
query: PropTypes.object.isRequired,
|
||||
getConfig: PropTypes.func,
|
||||
}).isRequired,
|
||||
isLoadingData: PropTypes.bool.isRequired,
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const defaultFilterValues = {
|
||||
patientName: '',
|
||||
mrn: '',
|
||||
studyDate: {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
},
|
||||
description: '',
|
||||
modalities: [],
|
||||
accession: '',
|
||||
sortBy: '',
|
||||
sortDirection: 'none',
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 25,
|
||||
datasources: '',
|
||||
};
|
||||
|
||||
function _tryParseInt(str, defaultValue) {
|
||||
let retValue = defaultValue;
|
||||
if (str && str.length > 0) {
|
||||
if (!isNaN(str)) {
|
||||
retValue = parseInt(str);
|
||||
}
|
||||
}
|
||||
return retValue;
|
||||
}
|
||||
|
||||
function _getQueryFilterValues(params) {
|
||||
const newParams = new URLSearchParams();
|
||||
for (const [key, value] of params) {
|
||||
newParams.set(key.toLowerCase(), value);
|
||||
}
|
||||
params = newParams;
|
||||
|
||||
const queryFilterValues = {
|
||||
patientName: params.get('patientname'),
|
||||
mrn: params.get('mrn'),
|
||||
studyDate: {
|
||||
startDate: params.get('startdate') || null,
|
||||
endDate: params.get('enddate') || null,
|
||||
},
|
||||
description: params.get('description'),
|
||||
modalities: params.get('modalities') ? params.get('modalities').split(',') : [],
|
||||
accession: params.get('accession'),
|
||||
sortBy: params.get('sortby'),
|
||||
sortDirection: params.get('sortdirection'),
|
||||
pageNumber: _tryParseInt(params.get('pagenumber'), undefined),
|
||||
resultsPerPage: _tryParseInt(params.get('resultsperpage'), undefined),
|
||||
datasources: params.get('datasources'),
|
||||
configUrl: params.get('configurl'),
|
||||
};
|
||||
|
||||
// Delete null/undefined keys
|
||||
Object.keys(queryFilterValues).forEach(
|
||||
key => queryFilterValues[key] == null && delete queryFilterValues[key]
|
||||
);
|
||||
|
||||
return queryFilterValues;
|
||||
}
|
||||
|
||||
function _sortStringDates(s1, s2, sortModifier) {
|
||||
// TODO: Delimiters are non-standard. Should we support them?
|
||||
const s1Date = moment(s1.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
|
||||
const s2Date = moment(s2.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
|
||||
|
||||
if (s1Date.isValid() && s2Date.isValid()) {
|
||||
return (s1Date.toISOString() > s2Date.toISOString() ? 1 : -1) * sortModifier;
|
||||
} else if (s1Date.isValid()) {
|
||||
return sortModifier;
|
||||
} else if (s2Date.isValid()) {
|
||||
return -1 * sortModifier;
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkList;
|
||||
129
platform/app/src/routes/WorkList/filtersMeta.js
Normal file
129
platform/app/src/routes/WorkList/filtersMeta.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
const filtersMeta = [
|
||||
{
|
||||
name: 'patientName',
|
||||
displayName: i18n.t('StudyList:PatientName'),
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
name: 'mrn',
|
||||
displayName: i18n.t('StudyList:MRN'),
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
name: 'studyDate',
|
||||
displayName: i18n.t('StudyList:StudyDate'),
|
||||
inputType: 'DateRange',
|
||||
isSortable: true,
|
||||
gridCol: 5,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
displayName: i18n.t('StudyList:Description'),
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 4,
|
||||
},
|
||||
{
|
||||
name: 'modalities',
|
||||
displayName: i18n.t('StudyList:Modality'),
|
||||
inputType: 'MultiSelect',
|
||||
inputProps: {
|
||||
options: [
|
||||
{ value: 'AR', label: 'AR' },
|
||||
{ value: 'ASMT', label: 'ASMT' },
|
||||
{ value: 'AU', label: 'AU' },
|
||||
{ value: 'BDUS', label: 'BDUS' },
|
||||
{ value: 'BI', label: 'BI' },
|
||||
{ value: 'BMD', label: 'BMD' },
|
||||
{ value: 'CR', label: 'CR' },
|
||||
{ value: 'CT', label: 'CT' },
|
||||
{ value: 'CTPROTOCOL', label: 'CTPROTOCOL' },
|
||||
{ value: 'DG', label: 'DG' },
|
||||
{ value: 'DOC', label: 'DOC' },
|
||||
{ value: 'DX', label: 'DX' },
|
||||
{ value: 'ECG', label: 'ECG' },
|
||||
{ value: 'EPS', label: 'EPS' },
|
||||
{ value: 'ES', label: 'ES' },
|
||||
{ value: 'FID', label: 'FID' },
|
||||
{ value: 'GM', label: 'GM' },
|
||||
{ value: 'HC', label: 'HC' },
|
||||
{ value: 'HD', label: 'HD' },
|
||||
{ value: 'IO', label: 'IO' },
|
||||
{ value: 'IOL', label: 'IOL' },
|
||||
{ value: 'IVOCT', label: 'IVOCT' },
|
||||
{ value: 'IVUS', label: 'IVUS' },
|
||||
{ value: 'KER', label: 'KER' },
|
||||
{ value: 'KO', label: 'KO' },
|
||||
{ value: 'LEN', label: 'LEN' },
|
||||
{ value: 'LS', label: 'LS' },
|
||||
{ value: 'MG', label: 'MG' },
|
||||
{ value: 'MR', label: 'MR' },
|
||||
{ value: 'M3D', label: 'M3D' },
|
||||
{ value: 'NM', label: 'NM' },
|
||||
{ value: 'OAM', label: 'OAM' },
|
||||
{ value: 'OCT', label: 'OCT' },
|
||||
{ value: 'OP', label: 'OP' },
|
||||
{ value: 'OPM', label: 'OPM' },
|
||||
{ value: 'OPT', label: 'OPT' },
|
||||
{ value: 'OPTBSV', label: 'OPTBSV' },
|
||||
{ value: 'OPTENF', label: 'OPTENF' },
|
||||
{ value: 'OPV', label: 'OPV' },
|
||||
{ value: 'OSS', label: 'OSS' },
|
||||
{ value: 'OT', label: 'OT' },
|
||||
{ value: 'PLAN', label: 'PLAN' },
|
||||
{ value: 'PR', label: 'PR' },
|
||||
{ value: 'PT', label: 'PT' },
|
||||
{ value: 'PX', label: 'PX' },
|
||||
{ value: 'REG', label: 'REG' },
|
||||
{ value: 'RESP', label: 'RESP' },
|
||||
{ value: 'RF', label: 'RF' },
|
||||
{ value: 'RG', label: 'RG' },
|
||||
{ value: 'RTDOSE', label: 'RTDOSE' },
|
||||
{ value: 'RTIMAGE', label: 'RTIMAGE' },
|
||||
{ value: 'RTINTENT', label: 'RTINTENT' },
|
||||
{ value: 'RTPLAN', label: 'RTPLAN' },
|
||||
{ value: 'RTRAD', label: 'RTRAD' },
|
||||
{ value: 'RTRECORD', label: 'RTRECORD' },
|
||||
{ value: 'RTSEGANN', label: 'RTSEGANN' },
|
||||
{ value: 'RTSTRUCT', label: 'RTSTRUCT' },
|
||||
{ value: 'RWV', label: 'RWV' },
|
||||
{ value: 'SEG', label: 'SEG' },
|
||||
{ value: 'SM', label: 'SM' },
|
||||
{ value: 'SMR', label: 'SMR' },
|
||||
{ value: 'SR', label: 'SR' },
|
||||
{ value: 'SRF', label: 'SRF' },
|
||||
{ value: 'STAIN', label: 'STAIN' },
|
||||
{ value: 'TEXTUREMAP', label: 'TEXTUREMAP' },
|
||||
{ value: 'TG', label: 'TG' },
|
||||
{ value: 'US', label: 'US' },
|
||||
{ value: 'VA', label: 'VA' },
|
||||
{ value: 'XA', label: 'XA' },
|
||||
{ value: 'XC', label: 'XC' },
|
||||
],
|
||||
},
|
||||
isSortable: true,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
name: 'accession',
|
||||
displayName: i18n.t('StudyList:AccessionNumber'),
|
||||
inputType: 'Text',
|
||||
isSortable: true,
|
||||
gridCol: 3,
|
||||
},
|
||||
{
|
||||
name: 'instances',
|
||||
displayName: i18n.t('StudyList:Instances'),
|
||||
inputType: 'None',
|
||||
isSortable: false,
|
||||
gridCol: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export default filtersMeta;
|
||||
1
platform/app/src/routes/WorkList/index.js
Normal file
1
platform/app/src/routes/WorkList/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './WorkList';
|
||||
90
platform/app/src/routes/buildModeRoutes.tsx
Normal file
90
platform/app/src/routes/buildModeRoutes.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import ModeRoute from '@routes/Mode';
|
||||
import publicUrl from '../utils/publicUrl';
|
||||
|
||||
/*
|
||||
Routes uniquely define an entry point to:
|
||||
- A mode
|
||||
- Linked to a data source
|
||||
- With a specified data set.
|
||||
|
||||
The full route template is:
|
||||
|
||||
/:modeId/:modeRoute/:sourceType/?queryParameters=example
|
||||
|
||||
Where:
|
||||
:modeId - Is the mode selected.
|
||||
:modeRoute - Is the route within the mode to select.
|
||||
:sourceType - Is the data source identifier, which specifies which DataSource to use.
|
||||
?queryParameters - Are query parameters as defined by data source.
|
||||
|
||||
A default source can be specified at the app level configuration, and then that source is used if :sourceType is omitted:
|
||||
|
||||
/:modeId/:modeRoute/?queryParameters=example
|
||||
*/
|
||||
export default function buildModeRoutes({
|
||||
modes,
|
||||
dataSources,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
}: withAppTypes) {
|
||||
const routes = [];
|
||||
const dataSourceNames = [];
|
||||
|
||||
dataSources.forEach(dataSource => {
|
||||
const { sourceName } = dataSource;
|
||||
if (!dataSourceNames.includes(sourceName)) {
|
||||
dataSourceNames.push(sourceName);
|
||||
}
|
||||
});
|
||||
|
||||
modes.forEach(mode => {
|
||||
// todo: for each route. add route to path.
|
||||
dataSourceNames.forEach(dataSourceName => {
|
||||
const path = `${publicUrl}${mode.routeName}/${dataSourceName}`;
|
||||
|
||||
// TODO move up.
|
||||
const children = () => (
|
||||
<ModeRoute
|
||||
mode={mode}
|
||||
dataSourceName={dataSourceName}
|
||||
extensionManager={extensionManager}
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
hotkeysManager={hotkeysManager}
|
||||
/>
|
||||
);
|
||||
|
||||
routes.push({
|
||||
path,
|
||||
children,
|
||||
private: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Add active DataSource route.
|
||||
// This is the DataSource route for the active data source defined in ExtensionManager.getActiveDataSource
|
||||
const path = `${publicUrl}${mode.routeName}`;
|
||||
|
||||
// TODO move up.
|
||||
const children = () => (
|
||||
<ModeRoute
|
||||
mode={mode}
|
||||
extensionManager={extensionManager}
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
hotkeysManager={hotkeysManager}
|
||||
/>
|
||||
);
|
||||
|
||||
routes.push({
|
||||
path,
|
||||
children,
|
||||
private: true,
|
||||
});
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
170
platform/app/src/routes/index.tsx
Normal file
170
platform/app/src/routes/index.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link } from 'react-router-dom';
|
||||
import { ErrorBoundary } from '@ohif/ui-next';
|
||||
|
||||
// Route Components
|
||||
import DataSourceWrapper from './DataSourceWrapper';
|
||||
import WorkList from './WorkList';
|
||||
import Local from './Local';
|
||||
import Debug from './Debug';
|
||||
import NotFound from './NotFound';
|
||||
import buildModeRoutes from './buildModeRoutes';
|
||||
import PrivateRoute from './PrivateRoute';
|
||||
import PropTypes from 'prop-types';
|
||||
import publicUrl from '../utils/publicUrl';
|
||||
|
||||
const NotFoundServer = ({
|
||||
message = 'Unable to query for studies at this time. Check your data source configuration or network connection',
|
||||
}) => {
|
||||
return (
|
||||
<div className="absolute flex h-full w-full items-center justify-center text-white">
|
||||
<div>
|
||||
<h4>{message}</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NotFoundServer.propTypes = {
|
||||
message: PropTypes.string,
|
||||
};
|
||||
|
||||
const NotFoundStudy = () => {
|
||||
return (
|
||||
<div className="absolute flex h-full w-full items-center justify-center text-white">
|
||||
<div>
|
||||
<h4>
|
||||
One or more of the requested studies are not available at this time. Return to the{' '}
|
||||
<Link
|
||||
className="text-primary-light"
|
||||
to={'/'}
|
||||
>
|
||||
study list
|
||||
</Link>{' '}
|
||||
to select a different study to view.
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NotFoundStudy.propTypes = {
|
||||
message: PropTypes.string,
|
||||
};
|
||||
|
||||
// TODO: Include "routes" debug route if dev build
|
||||
const bakedInRoutes = [
|
||||
{
|
||||
path: `${publicUrl}notfoundserver`,
|
||||
children: NotFoundServer,
|
||||
},
|
||||
{
|
||||
path: `${publicUrl}notfoundstudy`,
|
||||
children: NotFoundStudy,
|
||||
},
|
||||
{
|
||||
path: `${publicUrl}debug`,
|
||||
children: Debug,
|
||||
},
|
||||
{
|
||||
path: `${publicUrl}local`,
|
||||
children: Local.bind(null, { modePath: '' }), // navigate to the worklist
|
||||
},
|
||||
{
|
||||
path: `${publicUrl}localbasic`,
|
||||
children: Local.bind(null, { modePath: 'viewer/dicomlocal' }),
|
||||
},
|
||||
];
|
||||
|
||||
// NOT FOUND (404)
|
||||
const notFoundRoute = { component: NotFound };
|
||||
|
||||
const createRoutes = ({
|
||||
modes,
|
||||
dataSources,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
routerBasename,
|
||||
showStudyList,
|
||||
}: withAppTypes) => {
|
||||
const routes =
|
||||
buildModeRoutes({
|
||||
modes,
|
||||
dataSources,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
}) || [];
|
||||
|
||||
const { customizationService } = servicesManager.services;
|
||||
|
||||
const WorkListRoute = {
|
||||
path: publicUrl,
|
||||
children: DataSourceWrapper,
|
||||
private: true,
|
||||
props: { children: WorkList, servicesManager, extensionManager },
|
||||
};
|
||||
|
||||
const customRoutes = customizationService.getCustomization('routes.customRoutes');
|
||||
|
||||
const allRoutes = [
|
||||
...routes,
|
||||
...(showStudyList ? [WorkListRoute] : []),
|
||||
...(publicUrl !== '/' && showStudyList ? [{ ...WorkListRoute, path: publicUrl }] : []),
|
||||
...(customRoutes?.routes || []),
|
||||
...bakedInRoutes,
|
||||
customRoutes?.notFoundRoute || notFoundRoute,
|
||||
];
|
||||
|
||||
function RouteWithErrorBoundary({ route, ...rest }) {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return (
|
||||
<ErrorBoundary context={`Route ${route.path}`}>
|
||||
<route.children
|
||||
{...rest}
|
||||
{...route.props}
|
||||
route={route}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
hotkeysManager={hotkeysManager}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const { userAuthenticationService } = servicesManager.services;
|
||||
|
||||
// All routes are private by default and then we let the user auth service
|
||||
// to check if it is enabled or not
|
||||
// Todo: I think we can remove the second public return below
|
||||
return (
|
||||
<Routes>
|
||||
{allRoutes.map((route, i) => {
|
||||
return route.private === true ? (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
element={
|
||||
<PrivateRoute
|
||||
handleUnauthenticated={() => userAuthenticationService.handleUnauthenticated()}
|
||||
>
|
||||
<RouteWithErrorBoundary route={route} />
|
||||
</PrivateRoute>
|
||||
}
|
||||
></Route>
|
||||
) : (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
element={<RouteWithErrorBoundary route={route} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default createRoutes;
|
||||
Reference in New Issue
Block a user