287 lines
9.5 KiB
TypeScript
287 lines
9.5 KiB
TypeScript
/* eslint-disable react/jsx-props-no-spreading */
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
import PropTypes from 'prop-types';
|
|
import { ExtensionManager, MODULE_TYPES, Types, log } from '@ohif/core';
|
|
//
|
|
import { extensionManager } from '../App.tsx';
|
|
import { useParams, useLocation } from 'react-router';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import useSearchParams from '../hooks/useSearchParams.ts';
|
|
|
|
const { TimingEnum } = Types;
|
|
|
|
/**
|
|
* 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) {
|
|
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(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(TimingEnum.SCRIPT_TO_VIEW);
|
|
log.timeEnd(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);
|
|
// 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 (dataSource.getConfig().configurationAPI) {
|
|
return;
|
|
}
|
|
|
|
// No data source configuration API, so navigate to the not found server page.
|
|
navigate('/notfoundserver', '_self');
|
|
});
|
|
}
|
|
} 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 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;
|
|
}
|
|
}
|