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

+369 -456
View File
@@ -1,484 +1,397 @@
import './StudyList.styl';
import React, { Component } from 'react';
import CustomDateRangePicker from './CustomDateRangePicker.js';
import { Icon } from './../../elements/Icon';
import { PaginationArea } from './PaginationArea.js';
import React from 'react';
import classNames from 'classnames';
import TableSearchFilter from './TableSearchFilter.js';
import useMedia from '../../hooks/useMedia.js';
import PropTypes from 'prop-types';
import ColorHash from './internal/color-hash.js';
import { StudyListLoadingText } from './StudyListLoadingText.js';
import { StudylistToolbar } from './StudyListToolbar.js';
import { isInclusivelyBeforeDay } from 'react-dates';
import moment from 'moment';
import debounce from 'lodash.debounce';
import isEqual from 'lodash.isequal';
import { withTranslation } from '../../utils/LanguageProvider';
const today = moment();
const lastWeek = moment().subtract(7, 'day');
const lastMonth = moment().subtract(1, 'month');
function getPaginationFragment(
props,
searchData,
nextPageCb,
prevPageCb,
changeRowsPerPageCb
) {
return (
<PaginationArea
pageOptions={props.pageOptions}
currentPage={searchData.currentPage}
nextPageFunc={nextPageCb}
prevPageFunc={prevPageCb}
onRowsPerPageChange={changeRowsPerPageCb}
rowsPerPage={searchData.rowsPerPage}
recordCount={props.studies.length}
/>
);
}
const colorHash = new ColorHash();
function getTableMeta(translate) {
return {
patientName: {
displayText: translate('PatientName'),
sort: 0,
/**
*
*
* @param {*} props
* @returns
*/
function StudyList(props) {
const {
isLoading,
hasError,
studies,
sort,
onSort: handleSort,
filterValues,
onFilterChange: handleFilterChange,
onSelectItem: handleSelectItem,
t,
//
studyListDateFilterNumDays,
} = props;
const largeTableMeta = [
{
displayText: t('PatientName'),
fieldName: 'patientName',
inputType: 'text',
size: 330,
},
patientId: {
displayText: translate('MRN'),
sort: 0,
{
displayText: t('MRN'),
fieldName: 'patientId',
inputType: 'text',
size: 378,
},
accessionNumber: {
displayText: translate('AccessionNumber'),
sort: 0,
{
displayText: t('AccessionNumber'),
fieldName: 'accessionNumber',
inputType: 'text',
size: 180,
},
studyDate: {
displayText: translate('StudyDate'),
{
displayText: t('StudyDate'),
fieldName: 'studyDate',
inputType: 'date-range',
sort: 0,
},
modalities: {
displayText: translate('Modality'),
sort: 0,
},
studyDescription: {
displayText: translate('StudyDescription'),
sort: 0,
},
};
}
function getNoListFragment(translate, studies, error, loading) {
if (loading) {
return (
<div className="loading">
<StudyListLoadingText />
</div>
);
} else if (error) {
return (
<div className="notFound">
{translate('There was an error fetching studies')}
</div>
);
} else if (!studies.length) {
return <div className="notFound">{translate('No matching results')}</div>;
}
}
class StudyList extends Component {
static propTypes = {
studies: PropTypes.array.isRequired,
onSelectItem: PropTypes.func.isRequired,
onSearch: PropTypes.func.isRequired,
currentPage: PropTypes.number,
rowsPerPage: PropTypes.number,
studyListDateFilterNumDays: PropTypes.number,
studyListFunctionsEnabled: PropTypes.bool,
defaultSort: PropTypes.shape({
field: PropTypes.string.isRequired,
order: PropTypes.oneOf(['desc', 'asc']).isRequired,
}),
onImport: PropTypes.func,
pageOptions: PropTypes.array,
};
static defaultProps = {
currentPage: 0,
rowsPerPage: 25,
studyListDateFilterNumDays: 7,
};
static studyDatePresets = [
{
text: 'Today',
start: today,
end: today,
size: 300,
},
{
text: 'Last 7 days',
start: lastWeek,
end: today,
displayText: t('Modality'),
fieldName: 'modalities',
inputType: 'text',
size: 114,
},
{
text: 'Last 30 days',
start: lastMonth,
end: today,
displayText: t('StudyDescription'),
fieldName: 'studyDescription',
inputType: 'text',
size: 335,
},
];
constructor(props) {
super(props);
const mediumTableMeta = [
{
displayText: 'Patient / MRN',
fieldName: 'patientNameOrId',
inputType: 'text',
size: 250,
},
{
displayText: 'Description',
fieldName: 'accessionOrModalityOrDescription',
inputType: 'text',
size: 350,
},
{
displayText: t('StudyDate'),
fieldName: 'studyDate',
inputType: 'date-range',
size: 300,
},
];
const sortData = {
field: undefined,
order: undefined,
};
const smallTableMeta = [
{
displayText: 'Search',
fieldName: 'allFields',
inputType: 'text',
size: 100,
},
];
// init from props
if (props.defaultSort) {
sortData.field = props.defaultSort.field;
// todo: -1, 0, 1?
sortData.order = props.defaultSort.order; // asc, desc
}
const tableMeta = useMedia(
['(min-width: 1750px)', '(min-width: 1000px)', '(min-width: 768px)'],
[largeTableMeta, mediumTableMeta, smallTableMeta],
smallTableMeta
);
this.defaultStartDate = moment().subtract(
this.props.studyListDateFilterNumDays,
'days'
);
this.defaultEndDate = moment();
const totalSize = tableMeta
.map(field => field.size)
.reduce((prev, next) => prev + next);
this.state = {
loading: false,
error: false,
searchData: {
sortData,
currentPage: this.props.currentPage,
rowsPerPage: this.props.rowsPerPage,
studyDateFrom: this.defaultStartDate,
studyDateTo: this.defaultEndDate,
},
highlightedItem: '',
};
return (
<table className="table table--striped table--hoverable">
<colgroup>
{tableMeta.map((field, i) => {
const size = field.size;
const percentWidth = (size / totalSize) * 100.0;
this.getChangeHandler = this.getChangeHandler.bind(this);
this.getBlurHandler = this.getBlurHandler.bind(this);
this.onInputKeydown = this.onInputKeydown.bind(this);
this.nextPage = this.nextPage.bind(this);
this.prevPage = this.prevPage.bind(this);
this.onRowsPerPageChange = this.onRowsPerPageChange.bind(this);
this.delayedSearch = debounce(this.search, 250);
}
getChangeHandler(key) {
return event => {
this.delayedSearch.cancel();
this.setSearchData(key, event.target.value, this.delayedSearch);
};
}
getBlurHandler(key) {
return event => {
this.delayedSearch.cancel();
this.setSearchData(key, event.target.value);
};
}
setSearchData(key, value) {
const searchData = { ...this.state.searchData };
searchData[key] = value;
if (!isEqual(searchData[key], this.state.searchData[key])) {
this.setState({ ...this.state, searchData });
}
}
setSearchDataBatch(keyValues) {
const searchData = { ...this.state.searchData };
Object.keys(keyValues).forEach(key => {
searchData[key] = keyValues[key];
});
this.setState({ searchData });
}
async onInputKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
this.delayedSearch.cancel();
// reset the page because user is doing a new search
this.setSearchData('currentPage', 0);
}
}
async search() {
try {
this.setState({ loading: true, error: false });
await this.props.onSearch(this.state.searchData);
} catch (error) {
this.setState({ error: true });
throw new Error(error);
} finally {
this.setState({ loading: false });
}
}
nextPage(currentPage) {
currentPage = currentPage + 1;
this.delayedSearch.cancel();
this.setSearchData('currentPage', currentPage);
}
prevPage(currentPage) {
currentPage = currentPage - 1;
this.delayedSearch.cancel();
this.setSearchData('currentPage', currentPage);
}
onRowsPerPageChange(rowsPerPage) {
this.delayedSearch.cancel();
this.setSearchDataBatch({ rowsPerPage, currentPage: 0 });
}
onSortClick(field) {
return () => {
let order;
const sort = this.state.searchData.sortData;
const isSortedField = sort.field === field;
if (isSortedField) {
if (sort.order === 'asc') {
order = 'desc';
} else {
order = undefined;
field = undefined;
}
} else {
order = 'asc';
}
this.delayedSearch.cancel();
this.setSearchData('sortData', { field, order });
};
}
onHighlightItem(studyItemUid) {
this.setState({ highlightedItem: studyItemUid });
}
getTableRow(study, index) {
const trKey = `trStudy${index}${study.studyInstanceUid}`;
if (!study) {
return;
}
const getTableCell = (
study,
studyKey,
emptyValue = '',
emptyClass = ''
) => {
const componentKey = `td${studyKey}`;
const isValidValue = study && typeof study[studyKey] === 'string';
let className = emptyClass;
let value = emptyValue;
if (isValidValue) {
className = studyKey;
value = study[studyKey];
}
return (
<td key={componentKey} className={className}>
{value}
</td>
);
};
return (
<tr
key={trKey}
className={
this.state.highlightedItem === study.studyInstanceUid
? 'studylistStudy noselect active'
: 'studylistStudy noselect'
}
onMouseDown={event => {
// middle/wheel click
if (event.button === 1) {
this.props.onSelectItem(study.studyInstanceUid);
}
}}
onClick={() => {
this.onHighlightItem(study.studyInstanceUid);
this.props.onSelectItem(study.studyInstanceUid);
}}
>
{getTableCell(
study,
'patientName',
`(${this.props.t('Empty')})`,
'emptyCell'
return <col key={i} style={{ width: `${percentWidth}%` }} />;
})}
</colgroup>
<thead className="table-head">
<tr className="filters">
<TableSearchFilter
meta={tableMeta}
values={filterValues}
onSort={handleSort}
onValueChange={handleFilterChange}
sortFieldName={sort.fieldName}
sortDirection={sort.direction}
studyListDateFilterNumDays={studyListDateFilterNumDays}
/>
</tr>
</thead>
<tbody className="table-body" data-cy="study-list-results">
{/* I'm not in love with this approach, but it's the quickest way for now
*
* - Display different content based on loading, empty, results state
*
* This is not ideal because it create a jump in focus. For loading especially,
* We should keep our current results visible while we load the new ones.
*/}
{/* LOADING */}
{isLoading && (
<tr className="no-hover">
<td colspan={tableMeta.length}>
<StudyListLoadingText />
</td>
</tr>
)}
{getTableCell(study, 'patientId')}
{getTableCell(study, 'accessionNumber')}
{getTableCell(study, 'studyDate')}
{getTableCell(study, 'modalities')}
{getTableCell(study, 'studyDescription')}
</tr>
);
}
componentDidUpdate(previousProps, previousState) {
if (!isEqual(previousState.searchData, this.state.searchData)) {
this.search();
}
}
renderTableBody(noListFragment) {
return !noListFragment && this.props.studies
? this.props.studies.map(this.getTableRow.bind(this))
: null;
}
render() {
const tableMeta = getTableMeta(this.props.t);
// Apply sort
const sortedFieldName = this.state.searchData.sortData.field;
const sortedField = tableMeta[sortedFieldName];
if (sortedField) {
const sortOrder = this.state.searchData.sortData.order;
sortedField.sort = sortOrder === 'asc' ? 1 : 2;
}
// Sort Icons
const sortIcons = ['sort', 'sort-up', 'sort-down'];
const noListFragment = getNoListFragment(
this.props.t,
this.props.studies,
this.state.error,
this.props.loading || this.state.loading
);
const tableBody = this.renderTableBody(noListFragment);
const studiesNum = (this.props.studies && this.props.studies.length) || 0;
return (
<div className="StudyList">
<div className="studyListToolbar clearfix">
<div className="header pull-left">{this.props.t('StudyList')}</div>
<div className="studyCount pull-right">{studiesNum}</div>
<div className="pull-right">
{this.props.studyListFunctionsEnabled ? (
<StudylistToolbar onImport={this.props.onImport} />
) : null}
</div>
{this.props.children}
</div>
<div className="theadBackground" />
<div id="studyListContainer">
<table id="tblStudyList" className="studylistResult table noselect">
<thead>
<tr>
{Object.keys(tableMeta).map((fieldName, i) => {
const field = tableMeta[fieldName];
return (
<React.Fragment key={i}>
<th className={fieldName}>
<div
id={`_${fieldName}`}
className="display-text"
onClick={this.onSortClick(fieldName)}
>
<span>{field.displayText}</span>
<Icon
name={sortIcons[field.sort]}
style={{ fontSize: '12px' }}
/>
</div>
{!field.inputType && (
<input
type="text"
className="form-control studylist-search"
id={fieldName}
value={this.state[fieldName]}
onKeyDown={this.onInputKeydown}
onChange={this.getChangeHandler(fieldName)}
onBlur={this.getBlurHandler(fieldName)}
/>
)}
{field.inputType === 'date-range' && (
<div style={{ display: 'block' }}>
<CustomDateRangePicker
presets={StudyList.studyDatePresets}
showClearDates={true}
startDateId="studyListStartDate"
endDateId="studyListEndDate"
startDate={this.defaultStartDate}
endDate={this.defaultEndDate}
hideKeyboardShortcutsPanel={true}
anchorDirection="left"
isOutsideRange={day =>
!isInclusivelyBeforeDay(day, moment())
}
onDatesChange={({
startDate,
endDate,
preset = false,
}) => {
if (
startDate &&
endDate &&
(this.state.focusedInput === 'endDate' ||
preset)
) {
this.setSearchDataBatch({
studyDateFrom: startDate.toDate(),
studyDateTo: endDate.toDate(),
});
this.setState({ focusedInput: false });
} else if (!startDate && !endDate) {
this.setSearchDataBatch({
studyDateFrom: null,
studyDateTo: null,
});
}
}}
focusedInput={this.state.focusedInput}
onFocusChange={focusedInput => {
this.setState({ focusedInput });
}}
/>
</div>
)}
</th>
</React.Fragment>
);
})}
</tr>
</thead>
<tbody id="studyListData">{tableBody}</tbody>
</table>
{noListFragment
? noListFragment
: getPaginationFragment(
this.props,
this.state.searchData,
this.nextPage,
this.prevPage,
this.onRowsPerPageChange
)}
</div>
</div>
);
}
{!isLoading && hasError && (
<tr className="no-hover">
<td colspan={tableMeta.length}>
<div className="notFound">
{t('There was an error fetching studies')}
</div>
</td>
</tr>
)}
{/* EMPTY */}
{!isLoading && !studies.length && (
<tr className="no-hover">
<td colspan={tableMeta.length}>
<div className="notFound">{t('No matching results')}</div>
</td>
</tr>
)}
{!isLoading &&
studies.map((study, index) => (
<TableRow
key={`${study.studyInstanceUid}-${index}`}
onClick={studyInstanceUid => handleSelectItem(studyInstanceUid)}
accessionNumber={study.accessionNumber || ''}
modalities={study.modalities}
patientId={study.patientId || ''}
patientName={study.patientName || ''}
studyDate={study.studyDate}
studyDescription={study.studyDescription || ''}
studyInstanceUid={study.studyInstanceUid}
t={t}
/>
))}
</tbody>
</table>
);
}
StudyList.propTypes = {
isLoading: PropTypes.bool.isRequired,
hasError: PropTypes.bool.isRequired,
studies: PropTypes.array.isRequired,
onSelectItem: PropTypes.func.isRequired,
// ~~ SORT
sort: PropTypes.shape({
fieldName: PropTypes.string,
direction: PropTypes.oneOf(['desc', 'asc', null]),
}).isRequired,
onSort: PropTypes.func.isRequired,
// ~~ FILTERS
filterValues: PropTypes.shape({
patientName: PropTypes.string.isRequired,
patientId: PropTypes.string.isRequired,
accessionNumber: PropTypes.string.isRequired,
studyDate: PropTypes.string.isRequired,
modalities: PropTypes.string.isRequired,
studyDescription: PropTypes.string.isRequired,
patientNameOrId: PropTypes.string.isRequired,
accessionOrModalityOrDescription: PropTypes.string.isRequired,
allFields: PropTypes.string.isRequired,
}).isRequired,
onFilterChange: PropTypes.func.isRequired,
//
studyListDateFilterNumDays: PropTypes.number,
};
StudyList.defaultProps = {};
function TableRow(props) {
const {
accessionNumber,
isHighlighted,
modalities,
patientId,
patientName,
studyDate,
studyDescription,
studyInstanceUid,
onClick: handleClick,
t,
} = props;
const largeRowTemplate = (
<tr
onClick={() => handleClick(studyInstanceUid)}
className={classNames({ active: isHighlighted })}
>
<td className={classNames({ emptyCell: !patientName })}>
{patientName || `(${t('Empty')})`}
</td>
<td>{patientId}</td>
<td>{accessionNumber}</td>
<td>{studyDate}</td>
<td>{modalities}</td>
<td>{studyDescription}</td>
</tr>
);
const mediumRowTemplate = (
<tr
onClick={() => handleClick(studyInstanceUid)}
className={classNames({ active: isHighlighted })}
>
<td className={classNames({ emptyCell: !patientName })}>
{patientName || `(${t('Empty')})`}
<div style={{ color: '#60656f' }}>{patientId}</div>
</td>
<td>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{/* DESCRIPTION */}
<div
className="hide-xs"
style={{
whiteSpace: 'pre-wrap',
flexGrow: 1,
}}
>
{studyDescription}
</div>
{/* MODALITY & ACCESSION */}
<div
style={{
display: 'flex',
flexDirection: 'column',
minWidth: '80px',
maxWidth: '100px',
}}
>
<div
style={{
backgroundColor: colorHash.hex(modalities),
borderRadius: '16px',
padding: '2px 8px 0px 8px',
fontWeight: 500,
marginBottom: '4px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
}}
aria-label={modalities}
title={modalities}
>
{modalities}
</div>
<div
style={{
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
}}
aria-label={accessionNumber}
title={accessionNumber}
>
{accessionNumber}
</div>
</div>
</div>
</td>
{/* DATE */}
<td style={{ textAlign: 'center' }}>{studyDate}</td>
</tr>
);
const smallRowTemplate = (
<tr
onClick={() => handleClick(studyInstanceUid)}
className={classNames({ active: isHighlighted })}
>
<td style={{ position: 'relative', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{/* NAME AND ID */}
<div
className={classNames({ emptyCell: !patientName })}
style={{ width: '150px', minWidth: '150px' }}
>
<div style={{ fontWeight: 500, paddingTop: '3px' }}>
{patientName || `(${t('Empty')})`}
</div>
<div style={{ color: '#60656f' }}>{patientId}</div>
</div>
{/* DESCRIPTION */}
<div
className="hide-xs"
style={{
whiteSpace: 'pre-wrap',
flexGrow: 1,
paddingLeft: '35px',
}}
>
{studyDescription}
</div>
{/* MODALITY & DATE */}
<div
style={{
display: 'flex',
flexDirection: 'column',
minWidth: '80px',
}}
>
<div
style={{
backgroundColor: colorHash.hex(modalities),
borderRadius: '16px',
padding: '2px 8px 0px 8px',
fontWeight: 500,
marginBottom: '4px',
}}
>
{modalities}
</div>
<div>{studyDate}</div>
</div>
</div>
</td>
</tr>
);
const rowTemplate = useMedia(
['(min-width: 1750px)', '(min-width: 1000px)', '(min-width: 768px)'],
[largeRowTemplate, mediumRowTemplate, smallRowTemplate],
smallRowTemplate
);
return rowTemplate;
}
TableRow.propTypes = {
accessionNumber: PropTypes.string.isRequired,
isHighlighted: PropTypes.bool,
modalities: PropTypes.string.isRequired,
patientId: PropTypes.string.isRequired,
patientName: PropTypes.string.isRequired,
studyDate: PropTypes.string.isRequired,
studyDescription: PropTypes.string.isRequired,
studyInstanceUid: PropTypes.string.isRequired,
};
TableRow.defaultProps = {
isHighlighted: false,
};
const connectedComponent = withTranslation('StudyList')(StudyList);
export { connectedComponent as StudyList };