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:
Danny BrownandRodrigo Antinarelli authored and GitHub committed 2019-10-28 14:47:57 -04:00
1 parent 70532a6cc6
commit 2cdef4b984
35 files changed
+1814 -1347

No files matched your search

+1
View File
@@ -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;
}
}
+43 -45
View File
@@ -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));