feat: responsive study list (#1068)
* feat: 🎸 Load spinner when selecting gcloud store. Add key on td * feat: 🎸 Code review. Translate loading study status * feat: 🎸 Code review. Minor code refactoring * fix: add missing study and index declerations * Newer version of react-dates (calendar input component) * Add useMedia hook * Add useDebounce hook * Export hooks for consumption * Pull search filter into own component * StudyListWithData to StudyListRoute * Rename pagination component * Rename Toolbar * CustomeDateRangePicker to plain ol' css * Remove unused styles * Misc * Magic to make our "OR" filters work * Play w/ Mobile styles * Fix pagination pushing out max width for viewport * Tidy up small and medium templates * Remove unecessary nesting * Better mobile header and logo styles * Fix studyList font size * Remove unused code * hookup on click * Fix table search selectors * Cypress uses medium width * Remove irrelevant test * Fix primary sort * Fix sort for our smaller displays * fix study list sometimes longer than specified rowsPerPage * Simplify date picker styles * restore study date filters * Fix about table styling * fix take value for search * Fix header sizing for viewer and list route * Remove dead code * Restore isLoading * Update platform/ui/src/components/studyList/StudyList.styl Co-Authored-By: Rodrigo Antinarelli <rodrigoantinarelli@gmail.com> * Attempt first usage of translations w/ hooks * Pull from react-i18n library * Fit presets on to a single line * add no results message * Show error message in studyList table * Update input selectors for e2e tests * Make sure messages span all columns * fix test selector for table results * Skip hover for result rows * fix imports * Date filtering w/ config * Default export * Pushed to PageComponent * Remove DropZone This previously only worked for null/undefined studies. I'm unable to trigger the state by testing the data. This "might" have happened when using a fresh/empty PACS; but even an empty array of studies would not show
This commit is contained in:
1 parent
70532a6cc6
commit
2cdef4b984
35 files changed
+1814
-1347
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { hot } from 'react-hot-loader/root';
|
||||
|
||||
// TODO: This should not be here
|
||||
import './config';
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/* Viewer Route */
|
||||
.entry-header {
|
||||
padding: 10px 10px 0;
|
||||
padding: 10px 15px;
|
||||
height: var(--top-bar-height);
|
||||
}
|
||||
|
||||
/* Home Page */
|
||||
.entry-header.header-big {
|
||||
background: rgba(21, 25, 30, 0.7);
|
||||
padding: 10px var(--study-list-padding);
|
||||
padding: 35px var(--study-list-padding);
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
@@ -29,7 +31,7 @@
|
||||
|
||||
.entry-header.header-big .header-brand {
|
||||
height: auto;
|
||||
padding: 25px 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-logo-image {
|
||||
@@ -41,7 +43,7 @@
|
||||
}
|
||||
|
||||
.entry-header.header-big .header-logo-image {
|
||||
margin: 0 20px 0 0;
|
||||
margin-right: 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
@@ -97,26 +99,45 @@
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.header-versionInfo {
|
||||
display: inline-block;
|
||||
color: black;
|
||||
background: #9ccef9;
|
||||
padding: 0px 8px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
.notification-bar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
width: 100%;
|
||||
background-color: #91b9cd;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-versionInfoHome {
|
||||
display: block;
|
||||
color: black;
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 0;
|
||||
background: #9ccef9;
|
||||
padding: 0px 8px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
@media only screen and (max-width: 768px) {
|
||||
.entry-header,
|
||||
.entry-header.header-big {
|
||||
padding: 30px 15px 10px 15px;
|
||||
}
|
||||
.entry-header.header-big .header-logo-image {
|
||||
margin: 0 10px 0 0;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.entry-header.header-big .header-logo-text {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
/* Account for notification bar height */
|
||||
.entry-header.header-big .header-brand {
|
||||
}
|
||||
|
||||
.dd-menu {
|
||||
}
|
||||
|
||||
/* Toggle Notification Bar */
|
||||
.notification-bar {
|
||||
display: block;
|
||||
}
|
||||
.header-menu .research-use {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import './Header.css';
|
||||
import './Header.css';
|
||||
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import React, { Component } from 'react';
|
||||
@@ -84,60 +83,59 @@ class Header extends Component {
|
||||
// TODO: reset `this.hotKeysData`
|
||||
}
|
||||
|
||||
// ANTD -- Hamburger, Drawer, Menu
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { appConfig = {} } = this.context;
|
||||
const showStudyList =
|
||||
appConfig.showStudyList !== undefined ? appConfig.showStudyList : true;
|
||||
return (
|
||||
<div className={`entry-header ${this.props.home ? 'header-big' : ''}`}>
|
||||
<div className="header-left-box">
|
||||
{this.props.location && this.props.location.studyLink && (
|
||||
<Link
|
||||
to={this.props.location.studyLink}
|
||||
className="header-btn header-viewerLink"
|
||||
>
|
||||
{t('Back to Viewer')}
|
||||
</Link>
|
||||
)}
|
||||
<>
|
||||
<div className="notification-bar">{t('INVESTIGATIONAL USE ONLY')}</div>
|
||||
<div className={`entry-header ${this.props.home ? 'header-big' : ''}`}>
|
||||
<div className="header-left-box">
|
||||
{this.props.location && this.props.location.studyLink && (
|
||||
<Link
|
||||
to={this.props.location.studyLink}
|
||||
className="header-btn header-viewerLink"
|
||||
>
|
||||
{t('Back to Viewer')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={`${
|
||||
this.props.home ? 'header-versionInfoHome' : 'header-versionInfo'
|
||||
}`}
|
||||
data-cy="header-version-info"
|
||||
>
|
||||
v{process.env.VERSION_NUMBER}
|
||||
</span>
|
||||
{this.props.children}
|
||||
|
||||
{this.props.children}
|
||||
{showStudyList && !this.props.home && (
|
||||
<Link
|
||||
className="header-btn header-studyListLinkSection"
|
||||
to={{
|
||||
pathname: '/',
|
||||
state: { studyLink: this.props.location.pathname },
|
||||
}}
|
||||
>
|
||||
{t('Study list')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStudyList && !this.props.home && (
|
||||
<Link
|
||||
className="header-btn header-studyListLinkSection"
|
||||
to={{
|
||||
pathname: '/',
|
||||
state: { studyLink: this.props.location.pathname },
|
||||
}}
|
||||
>
|
||||
{t('Study list')}
|
||||
</Link>
|
||||
)}
|
||||
<div className="header-menu">
|
||||
<span className="research-use">
|
||||
{t('INVESTIGATIONAL USE ONLY')}
|
||||
</span>
|
||||
<Dropdown title={t('Options')} list={this.options} align="right" />
|
||||
|
||||
{/* TODO: We need a Modal service */}
|
||||
<AboutModal
|
||||
{...this.state}
|
||||
onCancel={() =>
|
||||
this.setState({
|
||||
isOpen: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header-menu">
|
||||
<span className="research-use">{t('INVESTIGATIONAL USE ONLY')}</span>
|
||||
<Dropdown title={t('Options')} list={this.options} align="right" />
|
||||
<AboutModal
|
||||
{...this.state}
|
||||
onCancel={() =>
|
||||
this.setState({
|
||||
isOpen: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.header-brand:hover, .header-brand:active, .header-band:visited {
|
||||
.header-brand:hover,
|
||||
.header-brand:active,
|
||||
.header-band:visited {
|
||||
color: var(--text-primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -23,3 +25,9 @@
|
||||
width: 30px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.header-logo-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,13 @@ function OHIFLogo() {
|
||||
href="http://ohif.org"
|
||||
>
|
||||
<Icon name="ohif-logo" className="header-logo-image" />
|
||||
{/* Logo text would fit smaller displays at two lines:
|
||||
*
|
||||
* Open Health
|
||||
* Imaging Foundation
|
||||
*
|
||||
* Or as `OHIF` on really small displays
|
||||
*/}
|
||||
<Icon name="ohif-text-logo" className="header-logo-text" />
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -38,3 +38,10 @@
|
||||
transition: var(--sidepanel-transition);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
/* Account for "Investigational Use" banner height */
|
||||
.FlexboxLayout {
|
||||
height: calc(100% - var(--toolbar-height) - var(--top-bar-height) - 16px);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import StudyListWithData from './StudyListWithData.js';
|
||||
import StudyListRoute from './StudyListRoute.js';
|
||||
|
||||
const isActive = a => a.active === true;
|
||||
|
||||
@@ -16,6 +16,6 @@ const mapStateToProps = state => {
|
||||
const ConnectedStudyList = connect(
|
||||
mapStateToProps,
|
||||
null
|
||||
)(StudyListWithData);
|
||||
)(StudyListRoute);
|
||||
|
||||
export default ConnectedStudyList;
|
||||
@@ -0,0 +1,552 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import OHIF from '@ohif/core';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import {
|
||||
StudyList,
|
||||
PageToolbar,
|
||||
TablePagination,
|
||||
useDebounce,
|
||||
useMedia,
|
||||
} from '@ohif/ui';
|
||||
import ConnectedHeader from '../connectedComponents/ConnectedHeader.js';
|
||||
import * as RoutesUtil from '../routes/routesUtil';
|
||||
import moment from 'moment';
|
||||
import ConnectedDicomFilesUploader from '../googleCloud/ConnectedDicomFilesUploader';
|
||||
import ConnectedDicomStorePicker from '../googleCloud/ConnectedDicomStorePicker';
|
||||
import filesToStudies from '../lib/filesToStudies.js';
|
||||
|
||||
// Contexts
|
||||
import UserManagerContext from '../context/UserManagerContext';
|
||||
import WhiteLabellingContext from '../context/WhiteLabellingContext';
|
||||
import AppContext from '../context/AppContext';
|
||||
|
||||
const { urlUtil: UrlUtil } = OHIF.utils;
|
||||
|
||||
function StudyListRoute(props) {
|
||||
const { history, server, t, user, studyListFunctionsEnabled } = props;
|
||||
// ~~ STATE
|
||||
const [sort, setSort] = useState({
|
||||
fieldName: 'patientName',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [filterValues, setFilterValues] = useState({
|
||||
studyDateTo: null,
|
||||
studyDateFrom: null,
|
||||
patientName: '',
|
||||
patientId: '',
|
||||
accessionNumber: '',
|
||||
studyDate: '',
|
||||
modalities: '',
|
||||
studyDescription: '',
|
||||
//
|
||||
patientNameOrId: '',
|
||||
accessionOrModalityOrDescription: '',
|
||||
//
|
||||
allFields: '',
|
||||
});
|
||||
const [studies, setStudies] = useState([]);
|
||||
const [searchStatus, setSearchStatus] = useState({
|
||||
isSearchingForStudies: false,
|
||||
error: null,
|
||||
});
|
||||
const [activeModalId, setActiveModalId] = useState(null);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||
const [pageNumber, setPageNumber] = useState(0);
|
||||
// ~~ RESPONSIVE
|
||||
const displaySize = useMedia(
|
||||
['(min-width: 1750px)', '(min-width: 1000px)', '(min-width: 768px)'],
|
||||
['large', 'medium', 'small'],
|
||||
'small'
|
||||
);
|
||||
// ~~ DEBOUNCED INPUT
|
||||
const debouncedSort = useDebounce(sort, 200);
|
||||
const debouncedFilters = useDebounce(filterValues, 250);
|
||||
|
||||
// Google Cloud Adapter for DICOM Store Picking
|
||||
const { appConfig = {} } = AppContext;
|
||||
const isGoogleCHAIntegrationEnabled =
|
||||
!server && appConfig.enableGoogleCloudAdapter;
|
||||
if (isGoogleCHAIntegrationEnabled) {
|
||||
setActiveModalId('DicomStorePicker');
|
||||
}
|
||||
|
||||
// Called when relevant state/props are updated
|
||||
// Watches filters and sort, debounced
|
||||
useEffect(() => {
|
||||
const fetchStudies = async () => {
|
||||
try {
|
||||
setSearchStatus({ error: null, isSearchingForStudies: true });
|
||||
|
||||
const response = await getStudyList(
|
||||
server,
|
||||
debouncedFilters,
|
||||
debouncedSort,
|
||||
rowsPerPage,
|
||||
pageNumber,
|
||||
displaySize
|
||||
);
|
||||
|
||||
setStudies(response);
|
||||
setSearchStatus({ error: null, isSearchingForStudies: false });
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
setSearchStatus({ error: true, isFetching: false });
|
||||
}
|
||||
};
|
||||
|
||||
fetchStudies();
|
||||
}, [debouncedFilters, debouncedSort, rowsPerPage, pageNumber, displaySize]);
|
||||
|
||||
// TODO: Update Server
|
||||
// if (this.props.server !== prevProps.server) {
|
||||
// this.setState({
|
||||
// modalComponentId: null,
|
||||
// searchData: null,
|
||||
// studies: null,
|
||||
// });
|
||||
// }
|
||||
|
||||
const onDrop = async acceptedFiles => {
|
||||
try {
|
||||
const studiesFromFiles = await filesToStudies(acceptedFiles);
|
||||
setStudies(studiesFromFiles);
|
||||
} catch (error) {
|
||||
setSearchStatus({ isSearchingForStudies: false, error });
|
||||
}
|
||||
};
|
||||
|
||||
if (searchStatus.error) {
|
||||
return <div>Error: {JSON.stringify(searchStatus.error)}</div>;
|
||||
} else if (studies === [] && !activeModalId) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
let healthCareApiButtons = null;
|
||||
let healthCareApiWindows = null;
|
||||
|
||||
if (appConfig.enableGoogleCloudAdapter) {
|
||||
const isModalOpen = activeModalId === 'DicomStorePicker';
|
||||
updateURL(isModalOpen, appConfig, server, history);
|
||||
|
||||
healthCareApiWindows = (
|
||||
<ConnectedDicomStorePicker
|
||||
isOpen={activeModalId === 'DicomStorePicker'}
|
||||
onClose={() => setActiveModalId(null)}
|
||||
/>
|
||||
);
|
||||
|
||||
healthCareApiButtons = (
|
||||
<div
|
||||
className="form-inline btn-group pull-right"
|
||||
style={{ padding: '20px' }}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setActiveModalId('DicomStorePicker')}
|
||||
>
|
||||
{t('Change DICOM Store')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleSort(fieldName) {
|
||||
let sortFieldName = fieldName;
|
||||
let sortDirection = 'asc';
|
||||
|
||||
if (fieldName === sort.fieldName) {
|
||||
if (sort.direction === 'asc') {
|
||||
sortDirection = 'desc';
|
||||
} else {
|
||||
sortFieldName = null;
|
||||
sortDirection = null;
|
||||
}
|
||||
}
|
||||
|
||||
setSort({
|
||||
fieldName: sortFieldName,
|
||||
direction: sortDirection,
|
||||
});
|
||||
}
|
||||
|
||||
function handleFilterChange(fieldName, value) {
|
||||
const updatedFilterValues = Object.assign({}, filterValues);
|
||||
|
||||
updatedFilterValues[fieldName] = value;
|
||||
setFilterValues(updatedFilterValues);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WhiteLabellingContext.Consumer>
|
||||
{whiteLabelling => (
|
||||
<UserManagerContext.Consumer>
|
||||
{userManager => (
|
||||
<ConnectedHeader
|
||||
home={true}
|
||||
user={user}
|
||||
userManager={userManager}
|
||||
>
|
||||
{whiteLabelling.logoComponent}
|
||||
</ConnectedHeader>
|
||||
)}
|
||||
</UserManagerContext.Consumer>
|
||||
)}
|
||||
</WhiteLabellingContext.Consumer>
|
||||
<div className="study-list-header">
|
||||
<div className="header">
|
||||
<h1 style={{ fontWeight: 300, fontSize: '22px' }}>
|
||||
{t('StudyList')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{studyListFunctionsEnabled && (
|
||||
<PageToolbar
|
||||
onImport={() => setActiveModalId('DicomFilesUploader')}
|
||||
/>
|
||||
)}
|
||||
<span className="study-count">{studies.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-head-background" />
|
||||
<div className="study-list-container">
|
||||
{/* STUDY LIST OR DROP ZONE? */}
|
||||
<StudyList
|
||||
isLoading={searchStatus.isSearchingForStudies}
|
||||
hasError={searchStatus.error === true}
|
||||
// Rows
|
||||
studies={studies}
|
||||
onSelectItem={studyInstanceUID => {
|
||||
const viewerPath = RoutesUtil.parseViewerPath(appConfig, server, {
|
||||
studyInstanceUids: studyInstanceUID,
|
||||
});
|
||||
history.push(viewerPath);
|
||||
}}
|
||||
// Table Header
|
||||
sort={sort}
|
||||
onSort={handleSort}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
studyListDateFilterNumDays={appConfig.studyListDateFilterNumDays}
|
||||
>
|
||||
{studyListFunctionsEnabled ? (
|
||||
<ConnectedDicomFilesUploader
|
||||
isOpen={activeModalId === 'DicomFilesUploader'}
|
||||
onClose={() => setActiveModalId(null)}
|
||||
/>
|
||||
) : null}
|
||||
{healthCareApiButtons}
|
||||
{healthCareApiWindows}
|
||||
</StudyList>
|
||||
}{/* PAGINATION FOOTER */}
|
||||
<TablePagination
|
||||
currentPage={pageNumber}
|
||||
nextPageFunc={() => setPageNumber(pageNumber + 1)}
|
||||
prevPageFunc={() => setPageNumber(pageNumber - 1)}
|
||||
onRowsPerPageChange={rows => setRowsPerPage(rows)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
recordCount={studies.length}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
StudyListRoute.propTypes = {
|
||||
filters: PropTypes.object,
|
||||
patientId: PropTypes.string,
|
||||
server: PropTypes.object,
|
||||
user: PropTypes.object,
|
||||
history: PropTypes.object,
|
||||
studyListFunctionsEnabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
StudyListRoute.defaultProps = {
|
||||
studyListFunctionsEnabled: true,
|
||||
};
|
||||
|
||||
function updateURL(isModalOpen, appConfig, server, history) {
|
||||
if (isModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listPath = RoutesUtil.parseStudyListPath(appConfig, server);
|
||||
|
||||
if (UrlUtil.paramString.isValidPath(listPath)) {
|
||||
const { location = {} } = history;
|
||||
if (location.pathname !== listPath) {
|
||||
history.replace(listPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not ideal, but we use displaySize to determine how the filters should be used
|
||||
* to build the collection of promises we need to fetch a result set.
|
||||
*
|
||||
* @param {*} server
|
||||
* @param {*} filters
|
||||
* @param {object} sort
|
||||
* @param {string} sort.fieldName - field to sort by
|
||||
* @param {string} sort.direction - direction to sort
|
||||
* @param {number} rowsPerPage - Number of results to return
|
||||
* @param {number} pageNumber - Used to determine results offset
|
||||
* @param {string} displaySize - small, medium, large
|
||||
* @returns
|
||||
*/
|
||||
async function getStudyList(
|
||||
server,
|
||||
filters,
|
||||
sort,
|
||||
rowsPerPage,
|
||||
pageNumber,
|
||||
displaySize
|
||||
) {
|
||||
const {
|
||||
allFields,
|
||||
patientNameOrId,
|
||||
accessionOrModalityOrDescription,
|
||||
} = filters;
|
||||
const sortFieldName = sort.fieldName || 'patientName';
|
||||
const sortDirection = sort.direction || 'desc';
|
||||
const studyDateFrom =
|
||||
filters.studyDateFrom ||
|
||||
moment()
|
||||
.subtract(25000, 'days')
|
||||
.toDate();
|
||||
const studyDateTo = filters.studyDateTo || new Date();
|
||||
|
||||
const mappedFilters = {
|
||||
patientId: filters.patientId,
|
||||
patientName: filters.patientName,
|
||||
accessionNumber: filters.accessionNumber,
|
||||
studyDescription: filters.studyDescription,
|
||||
modalitiesInStudy: filters.modalities,
|
||||
// NEVER CHANGE
|
||||
studyDateFrom,
|
||||
studyDateTo,
|
||||
limit: rowsPerPage,
|
||||
offset: pageNumber * rowsPerPage,
|
||||
fuzzymatching: server.supportsFuzzyMatching === true,
|
||||
};
|
||||
|
||||
const studies = await _fetchStudies(server, mappedFilters, displaySize, {
|
||||
allFields,
|
||||
patientNameOrId,
|
||||
accessionOrModalityOrDescription,
|
||||
});
|
||||
|
||||
// Only the fields we use
|
||||
const mappedStudies = studies.map(study => {
|
||||
return {
|
||||
accessionNumber: study.accessionNumber, // "1"
|
||||
modalities: study.modalities, // "SEG\\MR"
|
||||
// numberOfStudyRelatedInstances: "3"
|
||||
// numberOfStudyRelatedSeries: "3"
|
||||
// patientBirthdate: undefined
|
||||
patientId: study.patientId, // "NOID"
|
||||
patientName: study.patientName, // "NAME^NONE"
|
||||
// patientSex: "M"
|
||||
// referringPhysicianName: undefined
|
||||
studyDate: study.studyDate, // "Jun 28, 2002"
|
||||
studyDescription: study.studyDescription, // "BRAIN"
|
||||
// studyId: "No Study ID"
|
||||
studyInstanceUid: study.studyInstanceUid, // "1.3.6.1.4.1.5962.99.1.3814087073.479799962.1489872804257.3.0"
|
||||
// studyTime: "160956.0"
|
||||
};
|
||||
});
|
||||
|
||||
// For our smaller displays, map our field name to a single
|
||||
// field we can actually sort by.
|
||||
const sortFieldNameMapping = {
|
||||
allFields: 'patientName',
|
||||
patientNameOrId: 'patientName',
|
||||
accessionOrModalityOrDescription: 'modalities',
|
||||
};
|
||||
const mappedSortFieldName =
|
||||
sortFieldNameMapping[sortFieldName] || sortFieldName;
|
||||
|
||||
const sortedStudies = _sortStudies(
|
||||
mappedStudies,
|
||||
mappedSortFieldName,
|
||||
sortDirection
|
||||
);
|
||||
|
||||
// Because we've merged multiple requests, we may have more than
|
||||
// our rows per page. Let's `take` that number from our sorted array.
|
||||
// This "might" cause paging issues.
|
||||
const numToTake =
|
||||
sortedStudies.length < rowsPerPage ? sortedStudies.length : rowsPerPage;
|
||||
const result = sortedStudies.slice(0, numToTake);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {object[]} studies - Array of studies to sort
|
||||
* @param {string} studies.studyDate - Date in 'MMM DD, YYYY' format
|
||||
* @param {string} field - name of properties on study to sort by
|
||||
* @param {string} order - 'asc' or 'desc'
|
||||
* @returns
|
||||
*/
|
||||
function _sortStudies(studies, field, order) {
|
||||
// Make sure our studyDate is in a valid format and create copy of studies array
|
||||
const sortedStudies = studies.map(study => {
|
||||
if (!moment(study.studyDate, 'MMM DD, YYYY', true).isValid()) {
|
||||
study.studyDate = moment(study.studyDate, 'YYYYMMDD').format(
|
||||
'MMM DD, YYYY'
|
||||
);
|
||||
}
|
||||
return study;
|
||||
});
|
||||
|
||||
// Sort by field
|
||||
sortedStudies.sort(function (a, b) {
|
||||
let fieldA = a[field];
|
||||
let fieldB = b[field];
|
||||
if (field === 'studyDate') {
|
||||
fieldA = moment(fieldA).toISOString();
|
||||
fieldB = moment(fieldB).toISOString();
|
||||
}
|
||||
|
||||
// Order
|
||||
if (order === 'desc') {
|
||||
if (fieldA < fieldB) {
|
||||
return -1;
|
||||
}
|
||||
if (fieldA > fieldB) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
} else {
|
||||
if (fieldA > fieldB) {
|
||||
return -1;
|
||||
}
|
||||
if (fieldA < fieldB) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedStudies;
|
||||
}
|
||||
|
||||
/**
|
||||
* We're forced to do this because DICOMWeb does not support "AND|OR" searches
|
||||
* across multiple fields. This allows us to make multiple requests, remove
|
||||
* duplicates, and return the result set as if it were supported
|
||||
*
|
||||
* @param {object} server
|
||||
* @param {Object} filters
|
||||
* @param {string} displaySize - small, medium, or large
|
||||
* @param {string} multi.allFields
|
||||
* @param {string} multi.patientNameOrId
|
||||
* @param {string} multi.accessionOrModalityOrDescription
|
||||
*/
|
||||
async function _fetchStudies(
|
||||
server,
|
||||
filters,
|
||||
displaySize,
|
||||
{ allFields, patientNameOrId, accessionOrModalityOrDescription }
|
||||
) {
|
||||
let queryFiltersArray = [filters];
|
||||
|
||||
if (displaySize === 'small') {
|
||||
const firstSet = _getQueryFiltersForValue(
|
||||
filters,
|
||||
[
|
||||
'patientId',
|
||||
'patientName',
|
||||
'accessionNumber',
|
||||
'studyDescription',
|
||||
'modalitiesInStudy',
|
||||
],
|
||||
allFields
|
||||
);
|
||||
|
||||
if (firstSet.length) {
|
||||
queryFiltersArray = firstSet;
|
||||
}
|
||||
} else if (displaySize === 'medium') {
|
||||
const firstSet = _getQueryFiltersForValue(
|
||||
filters,
|
||||
['patientId', 'patientName'],
|
||||
patientNameOrId
|
||||
);
|
||||
|
||||
const secondSet = _getQueryFiltersForValue(
|
||||
filters,
|
||||
['accessionNumber', 'studyDescription', 'modalitiesInStudy'],
|
||||
accessionOrModalityOrDescription
|
||||
);
|
||||
|
||||
if (firstSet.length || secondSet.length) {
|
||||
queryFiltersArray = firstSet.concat(secondSet);
|
||||
}
|
||||
}
|
||||
|
||||
const queryPromises = [];
|
||||
|
||||
queryFiltersArray.forEach(filter => {
|
||||
const searchStudiesPromise = OHIF.studies.searchStudies(server, filter);
|
||||
queryPromises.push(searchStudiesPromise);
|
||||
});
|
||||
|
||||
const lotsOfStudies = await Promise.all(queryPromises);
|
||||
const studies = [];
|
||||
|
||||
// Flatten and dedupe
|
||||
lotsOfStudies.forEach(arrayOfStudies => {
|
||||
if (arrayOfStudies) {
|
||||
arrayOfStudies.forEach(study => {
|
||||
if (!studies.some(s => s.studyInstanceUid === study.studyInstanceUid)) {
|
||||
studies.push(study);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return studies;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {*} filters
|
||||
* @param {*} fields - Array of string fields
|
||||
* @param {*} value
|
||||
*/
|
||||
function _getQueryFiltersForValue(filters, fields, value) {
|
||||
const queryFilters = [];
|
||||
|
||||
if (value === '' || !value) {
|
||||
return queryFilters;
|
||||
}
|
||||
|
||||
fields.forEach(field => {
|
||||
const filter = Object.assign(
|
||||
{
|
||||
patientId: '',
|
||||
patientName: '',
|
||||
accessionNumber: '',
|
||||
studyDescription: '',
|
||||
modalitiesInStudy: '',
|
||||
},
|
||||
filters
|
||||
);
|
||||
|
||||
filter[field] = value;
|
||||
queryFilters.push(filter);
|
||||
});
|
||||
|
||||
return queryFilters;
|
||||
}
|
||||
|
||||
export default withRouter(withTranslation('Common')(StudyListRoute));
|
||||
@@ -1,349 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import OHIF from '@ohif/core';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { StudyList } from '@ohif/ui';
|
||||
import ConnectedHeader from '../connectedComponents/ConnectedHeader.js';
|
||||
import * as RoutesUtil from '../routes/routesUtil';
|
||||
import moment from 'moment';
|
||||
import isEqual from 'lodash.isequal';
|
||||
import ConnectedDicomFilesUploader from '../googleCloud/ConnectedDicomFilesUploader';
|
||||
import ConnectedDicomStorePicker from '../googleCloud/ConnectedDicomStorePicker';
|
||||
import filesToStudies from '../lib/filesToStudies.js';
|
||||
|
||||
const { urlUtil: UrlUtil } = OHIF.utils;
|
||||
// Contexts
|
||||
import UserManagerContext from '../context/UserManagerContext';
|
||||
import WhiteLabellingContext from '../context/WhiteLabellingContext';
|
||||
import AppContext from '../context/AppContext';
|
||||
|
||||
class StudyListWithData extends Component {
|
||||
static contextType = AppContext;
|
||||
state = {
|
||||
searchOutdated: true,
|
||||
studies: [],
|
||||
searchingStudies: false,
|
||||
error: null,
|
||||
modalComponentId: null,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
filters: PropTypes.object,
|
||||
patientId: PropTypes.string,
|
||||
server: PropTypes.object,
|
||||
user: PropTypes.object,
|
||||
history: PropTypes.object,
|
||||
studyListFunctionsEnabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
studyListFunctionsEnabled: true,
|
||||
};
|
||||
|
||||
static rowsPerPage = 25;
|
||||
static defaultSort = { field: 'patientName', order: 'desc' };
|
||||
|
||||
static studyListDateFilterNumDays = 25000; // TODO: put this in the settings
|
||||
static defaultStudyDateFrom = moment()
|
||||
.subtract(StudyListWithData.studyListDateFilterNumDays, 'days')
|
||||
.toDate();
|
||||
static defaultStudyDateTo = new Date();
|
||||
static defaultSearchData = {
|
||||
currentPage: 0,
|
||||
rowsPerPage: StudyListWithData.rowsPerPage,
|
||||
studyDateFrom: StudyListWithData.defaultStudyDateFrom,
|
||||
studyDateTo: StudyListWithData.defaultStudyDateTo,
|
||||
sortData: StudyListWithData.defaultSort,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { appConfig = {} } = this.context;
|
||||
// TODO: Avoid using timepoints here
|
||||
//const params = { studyInstanceUids, seriesInstanceUids, timepointId, timepointsFilter={} };
|
||||
if (!this.props.server && appConfig.enableGoogleCloudAdapter) {
|
||||
this.setState({
|
||||
modalComponentId: 'DicomStorePicker',
|
||||
searchOutdated: false,
|
||||
});
|
||||
} else {
|
||||
this.searchForStudies({
|
||||
...StudyListWithData.defaultSearchData,
|
||||
...(this.props.filters || {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const hasNewServer = !isEqual(this.props.server, prevProps.server);
|
||||
|
||||
const { searchOutdated, searchingStudies } = this.state;
|
||||
|
||||
if (!searchingStudies) {
|
||||
if (hasNewServer) {
|
||||
const { appConfig = {} } = this.context;
|
||||
|
||||
const newState = {
|
||||
searchOutdated: true,
|
||||
studies: null,
|
||||
};
|
||||
if (appConfig.enableGoogleCloudAdapter) {
|
||||
newState.modalComponentId = null;
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
if (searchOutdated) {
|
||||
this.searchForStudies();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchForStudies = (searchData = StudyListWithData.defaultSearchData) => {
|
||||
const { server = {} } = this.props;
|
||||
const filter = {
|
||||
patientId: searchData.patientId,
|
||||
patientName: searchData.patientName,
|
||||
accessionNumber: searchData.accessionNumber,
|
||||
studyDescription: searchData.studyDescription,
|
||||
modalitiesInStudy: searchData.modalities,
|
||||
studyDateFrom: searchData.studyDateFrom,
|
||||
studyDateTo: searchData.studyDateTo,
|
||||
limit: searchData.rowsPerPage,
|
||||
offset: searchData.currentPage * searchData.rowsPerPage,
|
||||
};
|
||||
|
||||
if (server.supportsFuzzyMatching) {
|
||||
filter.fuzzymatching = true;
|
||||
}
|
||||
|
||||
// TODO: add sorting
|
||||
const promise = OHIF.studies.searchStudies(server, filter);
|
||||
|
||||
this.setState({
|
||||
searchingStudies: true,
|
||||
});
|
||||
promise
|
||||
.then(studies => {
|
||||
if (!studies) {
|
||||
studies = [];
|
||||
}
|
||||
|
||||
const { field, order } = searchData.sortData;
|
||||
let sortedStudies = studies.map(study => {
|
||||
if (!moment(study.studyDate, 'MMM DD, YYYY', true).isValid()) {
|
||||
study.studyDate = moment(study.studyDate, 'YYYYMMDD').format(
|
||||
'MMM DD, YYYY'
|
||||
);
|
||||
}
|
||||
return study;
|
||||
});
|
||||
|
||||
sortedStudies.sort(function(a, b) {
|
||||
let fieldA = a[field];
|
||||
let fieldB = b[field];
|
||||
if (field === 'studyDate') {
|
||||
fieldA = moment(fieldA).toISOString();
|
||||
fieldB = moment(fieldB).toISOString();
|
||||
}
|
||||
if (order === 'desc') {
|
||||
if (fieldA < fieldB) {
|
||||
return -1;
|
||||
}
|
||||
if (fieldA > fieldB) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
} else {
|
||||
if (fieldA > fieldB) {
|
||||
return -1;
|
||||
}
|
||||
if (fieldA < fieldB) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({
|
||||
studies: sortedStudies,
|
||||
searchingStudies: false,
|
||||
searchOutdated: false,
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({
|
||||
error: true,
|
||||
searchingStudies: false,
|
||||
searchOutdated: false,
|
||||
});
|
||||
|
||||
throw new Error(error);
|
||||
});
|
||||
};
|
||||
|
||||
onImport = () => {
|
||||
this.openModal('DicomFilesUploader');
|
||||
};
|
||||
|
||||
openModal = modalComponentId => {
|
||||
this.setState({
|
||||
modalComponentId,
|
||||
});
|
||||
};
|
||||
|
||||
closeModal = () => {
|
||||
this.setState({ modalComponentId: null });
|
||||
};
|
||||
|
||||
onSelectItem = studyInstanceUID => {
|
||||
const { appConfig = {} } = this.context;
|
||||
const { server } = this.props;
|
||||
const viewerPath = RoutesUtil.parseViewerPath(appConfig, server, {
|
||||
studyInstanceUids: studyInstanceUID,
|
||||
});
|
||||
|
||||
if (UrlUtil.paramString.isValidPath(viewerPath)) {
|
||||
this.props.history.push(viewerPath);
|
||||
}
|
||||
};
|
||||
|
||||
updateURL(modalOpened) {
|
||||
if (!modalOpened) {
|
||||
const { appConfig = {} } = this.context;
|
||||
const { server } = this.props;
|
||||
const listPath = RoutesUtil.parseStudyListPath(appConfig, server);
|
||||
|
||||
if (UrlUtil.paramString.isValidPath(listPath)) {
|
||||
const { location = {} } = this.props.history;
|
||||
if (location.pathname !== listPath) {
|
||||
this.props.history.replace(listPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSearch = searchData => {
|
||||
this.searchForStudies(searchData);
|
||||
};
|
||||
|
||||
closeModals = () => {
|
||||
this.setState({
|
||||
modalComponentId: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { appConfig = {} } = this.context;
|
||||
const onDrop = async acceptedFiles => {
|
||||
try {
|
||||
const studies = await filesToStudies(acceptedFiles);
|
||||
|
||||
this.setState({ studies });
|
||||
} catch (error) {
|
||||
this.setState({ error });
|
||||
}
|
||||
};
|
||||
|
||||
if (this.state.error) {
|
||||
return <div>Error: {JSON.stringify(this.state.error)}</div>;
|
||||
}
|
||||
|
||||
let healthCareApiButtons = null;
|
||||
let healthCareApiWindows = null;
|
||||
|
||||
if (appConfig.enableGoogleCloudAdapter) {
|
||||
const modalOpened = this.state.modalComponentId === 'DicomStorePicker';
|
||||
this.updateURL(modalOpened);
|
||||
|
||||
healthCareApiWindows = (
|
||||
<ConnectedDicomStorePicker
|
||||
isOpen={modalOpened}
|
||||
onClose={this.closeModals}
|
||||
/>
|
||||
);
|
||||
|
||||
healthCareApiButtons = (
|
||||
<div
|
||||
className="form-inline btn-group pull-right"
|
||||
style={{ padding: '20px' }}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => this.openModal('DicomStorePicker')}
|
||||
>
|
||||
{this.props.t('Change DICOM Store')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const studyList = (
|
||||
<div className="paginationArea">
|
||||
{this.state.studies || this.state.searchingStudies ? (
|
||||
<StudyList
|
||||
loading={this.state.searchingStudies}
|
||||
studies={this.state.studies}
|
||||
studyListFunctionsEnabled={this.props.studyListFunctionsEnabled}
|
||||
onImport={this.onImport}
|
||||
onSelectItem={this.onSelectItem}
|
||||
pageSize={this.rowsPerPage}
|
||||
defaultSort={StudyListWithData.defaultSort}
|
||||
studyListDateFilterNumDays={
|
||||
StudyListWithData.studyListDateFilterNumDays
|
||||
}
|
||||
onSearch={this.onSearch}
|
||||
>
|
||||
{this.props.studyListFunctionsEnabled ? (
|
||||
<ConnectedDicomFilesUploader
|
||||
isOpen={this.state.modalComponentId === 'DicomFilesUploader'}
|
||||
onClose={this.closeModals}
|
||||
/>
|
||||
) : null}
|
||||
{healthCareApiButtons}
|
||||
{healthCareApiWindows}
|
||||
</StudyList>
|
||||
) : (
|
||||
<Dropzone onDrop={onDrop}>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()} className={'drag-drop-instructions'}>
|
||||
<h3>
|
||||
{this.props.t(
|
||||
'Drag and Drop DICOM files here to load them in the Viewer'
|
||||
)}
|
||||
</h3>
|
||||
<h4>
|
||||
{this.props.t("Or click to load the browser's file selector")}
|
||||
</h4>
|
||||
<input {...getInputProps()} style={{ display: 'none' }} />
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<WhiteLabellingContext.Consumer>
|
||||
{whiteLabelling => (
|
||||
<UserManagerContext.Consumer>
|
||||
{userManager => (
|
||||
<ConnectedHeader
|
||||
home={true}
|
||||
user={this.props.user}
|
||||
userManager={userManager}
|
||||
>
|
||||
{whiteLabelling.logoComponent}
|
||||
</ConnectedHeader>
|
||||
)}
|
||||
</UserManagerContext.Consumer>
|
||||
)}
|
||||
</WhiteLabellingContext.Consumer>
|
||||
{studyList}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withTranslation('Common')(StudyListWithData));
|
||||
Reference in new issue
Block a user