diff --git a/platform/ui/package.json b/platform/ui/package.json index 6f4ff0a21f2..f028359dc01 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -48,7 +48,7 @@ "moment": "2.24.0", "prop-types": "15.6.2", "react-bootstrap-modal": "4.2.0", - "react-dates": "18.4.1", + "react-dates": "21.2.1", "react-dnd": "9.3.4", "react-dnd-html5-backend": "^9.3.4", "react-dnd-touch-backend": "^9.3.4", diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index 92748e4d513..56a3c34a8e1 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -16,7 +16,12 @@ import { QuickSwitch } from './quickSwitch'; import { RoundedButtonGroup } from './roundedButtonGroup'; import { SelectTree } from './selectTree'; import { SimpleDialog } from './simpleDialog'; -import { StudyList } from './studyList'; +import { + PageToolbar, + StudyList, + TableSearchFilter, + TablePagination, +} from './studyList'; import { ToolbarSection } from './toolbarSection'; import { Tooltip } from './tooltip'; @@ -32,12 +37,15 @@ export { OverlayTrigger, QuickSwitch, RoundedButtonGroup, + PageToolbar, SelectTree, SimpleDialog, StudyBrowser, StudyList, TableList, TableListItem, + TableSearchFilter, + TablePagination, ThumbnailEntry, ToolbarSection, Tooltip, diff --git a/platform/ui/src/components/studyList/CustomDateRangePicker.css b/platform/ui/src/components/studyList/CustomDateRangePicker.css new file mode 100644 index 00000000000..2ad51893905 --- /dev/null +++ b/platform/ui/src/components/studyList/CustomDateRangePicker.css @@ -0,0 +1,83 @@ +/* NOTE: the order of these styles DO matter */ + +/* Will edit everything selected including everything between a range of dates */ +.CalendarDay__selected_span { + background: var(--table-text-secondary-color); + color: #fff; + border-color: #e4e7e7; +} + +/* Will edit selected date or the endpoints of a range of dates */ +.CalendarDay__selected { + background: var(--table-text-secondary-color); + color: #fff; + border-color: #e4e7e7; +} + +/* Will edit when hovered over. _span style also has this property */ +.CalendarDay__selected:hover { + background: var(--table-text-secondary-color); + color: #fff; + border-color: #e4e7e7; +} + +/* Will edit when the second date (end date) in a range of dates +is not yet selected. Edits the dates between your mouse and said date */ +.CalendarDay__hovered_span:hover, +.CalendarDay__hovered_span { + background: var(--table-text-secondary-color); + color: #fff; + border-color: #e4e7e7; +} + +/* EXTERIOR INPUT STYLE */ +/* Container - placement */ +.DateRangePicker { + height: 40px; + margin: 0 5px 20px 5px; + cursor: pointer; + border: none; + width: 100%; +} +/* Container - visual */ +.DateRangePickerInput { + width: calc(100% - 10px); /* Just use padding? */ + background-color: var(--input-background-color); + border-color: var(--input-background-color); + color: var(--input-placeholder-color); + height: 40px; +} + +.DateRangePickerInput.DateRangePickerInput__withBorder { + border-radius: 4px; + background-color: var(--input-background-color); +} + +/* Input Container */ +.DateInput { + width: 97px; + height: 38px; + border-radius: 4px; + background-color: var(--input-background-color); +} + +/* Actual Input Element */ +.DateInput > .DateInput_input { + border-color: transparent; + background-color: transparent; + color: var(--input-placeholder-color); + height: 38px; + font-size: 10pt; + padding: 0; +} + +/* PRESETS */ +.PresetDateRangePicker_panel { + display: flex; + justify-content: space-between; +} + +.PresetDateRangePicker_button { + margin: 0; + padding: 4px 8px; +} diff --git a/platform/ui/src/components/studyList/CustomDateRangePicker.js b/platform/ui/src/components/studyList/CustomDateRangePicker.js index b850685e1f3..4d8fc335b42 100644 --- a/platform/ui/src/components/studyList/CustomDateRangePicker.js +++ b/platform/ui/src/components/studyList/CustomDateRangePicker.js @@ -2,13 +2,12 @@ // https://github.com/airbnb/react-dates#initialize import 'react-dates/initialize'; import 'react-dates/lib/css/_datepicker.css'; +import './CustomDateRangePicker.css'; import React from 'react'; import PropTypes from 'prop-types'; import { DateRangePicker } from 'react-dates'; -import './CustomDateRangePicker.styl'; - export default class CustomDateRangePicker extends React.Component { static propTypes = { presets: PropTypes.arrayOf( @@ -85,15 +84,13 @@ export default class CustomDateRangePicker extends React.Component { } = this.props; return ( -
- -
+ ); } } diff --git a/platform/ui/src/components/studyList/CustomDateRangePicker.styl b/platform/ui/src/components/studyList/CustomDateRangePicker.styl deleted file mode 100644 index 1112a0c90a6..00000000000 --- a/platform/ui/src/components/studyList/CustomDateRangePicker.styl +++ /dev/null @@ -1,84 +0,0 @@ -.CalendarDay__selected_span { - background: var(--calendar-main-color); - color: var(--calendar-day-color); - border: 1px solid; - border-color: var(--calendar-day-border-color); -} - -.CalendarDay__default:hover { - color: var(--calendar-main-color); -} - -.CalendarDay__selected_span:active, .CalendarDay__selected_span:hover { - background: var(--calendar-day-active-hover-background-color); - border-color: var(--calendar-day-border-color); - color: var(--calendar-day-color); -} - -.CalendarDay__selected { - background: var(--calendar-main-color); - color: var(--calendar-day-color); - border-color: var(--calendar-day-border-color); -} - -.CalendarDay__selected:hover { - background: var(--calendar-main-color); - color: var(--calendar-day-color); - border-color: var(--calendar-day-border-color); -} - -.CalendarDay__hovered_span:hover, -.CalendarDay__hovered_span { - background: var(--calendar-main-color); - color: var(--calendar-day-color); - border-color:var(--calendar-day-color); -} - -.DateInput { - width: 97px -} - -.DateInput_input { - background-color: var(--input-background-color) - border-color: var(--input-background-color) - color: var(--input-placeholder-color) - height: 38px - font-size: 10pt -} - -.DateRangePickerInput { - background-color: var(--input-background-color) - border-color: var(--input-background-color) - color: var(--input-placeholder-color) - height: 40px -} - -.PresetDateRangePicker_panel: { - padding: 0 22px 11px 22px; -} - -.PresetDateRangePicker_button { - position: relative; - height: 100%; - textAlign: center; - background: none; - border: 2px solid var(--calendar-main-color); - color: var(--calendar-main-color); - padding: 4px 12px; - marginRight: 8; - font: inherit; - fontWeight: 700; - lineHeight: normal; - overflow: visible; - boxSizing: border-box; - cursor: pointer; - - :active: { - outline: 0; - } -} - -.PresetDateRangePicker_button__selected: { - color: var(--calendar-day-color); - background: var(--calendar-main-color); -}; diff --git a/platform/ui/src/components/studyList/StudyListToolbar.js b/platform/ui/src/components/studyList/PageToolbar.js similarity index 86% rename from platform/ui/src/components/studyList/StudyListToolbar.js rename to platform/ui/src/components/studyList/PageToolbar.js index ee6fd5900d4..d0f44580027 100644 --- a/platform/ui/src/components/studyList/StudyListToolbar.js +++ b/platform/ui/src/components/studyList/PageToolbar.js @@ -1,11 +1,9 @@ -import './StudyListToolbar.styl'; - import React, { PureComponent } from 'react'; import { Icon } from './../../elements/Icon'; import PropTypes from 'prop-types'; -class StudylistToolbar extends PureComponent { +class PageToolbar extends PureComponent { static propTypes = { onImport: PropTypes.func, }; @@ -37,4 +35,4 @@ class StudylistToolbar extends PureComponent { } } -export { StudylistToolbar }; +export { PageToolbar }; diff --git a/platform/ui/src/components/studyList/PaginationArea.styl b/platform/ui/src/components/studyList/PaginationArea.styl index deb89b373a0..bfab2fe0553 100644 --- a/platform/ui/src/components/studyList/PaginationArea.styl +++ b/platform/ui/src/components/studyList/PaginationArea.styl @@ -1,64 +1,63 @@ .pagination-area - color: var(--text-secondary-color); - font-size: 13px - font-weight: normal !important + display: flex; + color: var(--text-secondary-color); + font-size: 13px + font-weight: normal !important - label - font-weight: normal + label + font-weight: normal - select - margin: 5px - background-color: var(--primary-background-color) - color: white + select + margin: 5px + background-color: var(--primary-background-color) + color: white - .row - display: flex; - .rows-dropdown - width: 25%; - padding-right: 15px; - padding-left: 15px; + .rows-dropdown + width: 25%; + padding-right: 15px; + padding-left: 15px; - .pagination-buttons - width: 75%; - padding-right: 15px; - padding-left: 15px; + .pagination-buttons + width: 75%; + padding-right: 15px; + padding-left: 15px; - .form-group - margin-bottom: 15px; + .form-group + margin-bottom: 15px; - .rows-per-page label.wrapperLabel - display: inline-table !important - margin: 0 4px +.rows-per-page label.wrapperLabel + display: inline-table !important + margin: 0 4px - select - margin: 0px 4px 0px 4px - width: 42px + select + margin: 0px 4px 0px 4px + width: 42px - .page-buttons +.page-buttons + margin: 0 + text-align: right + font-weight: normal + ul.pagination-control margin: 0 - text-align: right - font-weight: normal - ul.pagination-control - margin: 0 - li - display: table-cell - padding: 5px 2px + li + display: table-cell + padding: 5px 2px - button - padding: 4px 8px - background-color: var(--primary-background-color) - border-color: var(--ui-gray) - color: var(--ui-gray-darkest) - color: white - text-decoration: none + button + padding: 4px 8px + background-color: var(--primary-background-color) + border-color: var(--ui-gray) + color: var(--ui-gray-darkest) + color: white + text-decoration: none - &:hover:enabled - color: var(--active-color) + &:hover:enabled + color: var(--active-color) - .active - button - background-color: var(--ui-gray) - border-color: #ddd - color: white + .active + button + background-color: var(--ui-gray) + border-color: #ddd + color: white diff --git a/platform/ui/src/components/studyList/StudyList.js b/platform/ui/src/components/studyList/StudyList.js index a09623f8519..f300335fc9e 100644 --- a/platform/ui/src/components/studyList/StudyList.js +++ b/platform/ui/src/components/studyList/StudyList.js @@ -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 ( - - ); -} - -function getTableMeta(translate) { - return { - patientName: { - displayText: translate('PatientName'), - sort: 0, +const colorHash = new ColorHash(); + +/** + * + * + * @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, + size: 300, }, - modalities: { - displayText: translate('Modality'), - sort: 0, + { + displayText: t('Modality'), + fieldName: 'modalities', + inputType: 'text', + size: 114, }, - studyDescription: { - displayText: translate('StudyDescription'), - sort: 0, + { + displayText: t('StudyDescription'), + fieldName: 'studyDescription', + inputType: 'text', + size: 335, }, - }; -} - -function getNoListFragment(translate, studies, error, loading) { - if (loading) { - return ( -
- -
- ); - } else if (error) { - return ( -
- {translate('There was an error fetching studies')} -
- ); - } else if (!studies.length) { - return
{translate('No matching results')}
; - } -} - -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 = [ + const mediumTableMeta = [ { - text: 'Today', - start: today, - end: today, + displayText: 'Patient / MRN', + fieldName: 'patientNameOrId', + inputType: 'text', + size: 250, }, { - text: 'Last 7 days', - start: lastWeek, - end: today, + displayText: 'Description', + fieldName: 'accessionOrModalityOrDescription', + inputType: 'text', + size: 350, }, { - text: 'Last 30 days', - start: lastMonth, - end: today, + displayText: t('StudyDate'), + fieldName: 'studyDate', + inputType: 'date-range', + size: 300, }, ]; - constructor(props) { - super(props); - - const sortData = { - field: undefined, - order: undefined, - }; - - // init from props - if (props.defaultSort) { - sortData.field = props.defaultSort.field; - // todo: -1, 0, 1? - sortData.order = props.defaultSort.order; // asc, desc - } - - this.defaultStartDate = moment().subtract( - this.props.studyListDateFilterNumDays, - 'days' - ); - this.defaultEndDate = moment(); - - this.state = { - loading: false, - error: false, - searchData: { - sortData, - currentPage: this.props.currentPage, - rowsPerPage: this.props.rowsPerPage, - studyDateFrom: this.defaultStartDate, - studyDateTo: this.defaultEndDate, - }, - highlightedItem: '', - }; - - 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; + const smallTableMeta = [ + { + displayText: 'Search', + fieldName: 'allFields', + inputType: 'text', + size: 100, + }, + ]; - if (isValidValue) { - className = studyKey; - value = study[studyKey]; - } + const tableMeta = useMedia( + ['(min-width: 1750px)', '(min-width: 1000px)', '(min-width: 768px)'], + [largeTableMeta, mediumTableMeta, smallTableMeta], + smallTableMeta + ); - return ( - - {value} - - ); - }; + const totalSize = tableMeta + .map(field => field.size) + .reduce((prev, next) => prev + next); - return ( - { - // 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 ( + + + {tableMeta.map((field, i) => { + const size = field.size; + const percentWidth = (size / totalSize) * 100.0; + + return ; + })} + + + + + + + + {/* 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 && ( + + + )} - {getTableCell(study, 'patientId')} - {getTableCell(study, 'accessionNumber')} - {getTableCell(study, 'studyDate')} - {getTableCell(study, 'modalities')} - {getTableCell(study, 'studyDescription')} - - ); - } - - componentDidUpdate(previousProps, previousState) { - if (!isEqual(previousState.searchData, this.state.searchData)) { - this.search(); - } - } + {!isLoading && hasError && ( + + + + )} + {/* EMPTY */} + {!isLoading && !studies.length && ( + + + + )} + {!isLoading && + studies.map((study, index) => ( + 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} + /> + ))} + +
+ +
+
+ {t('There was an error fetching studies')} +
+
+
{t('No matching results')}
+
+ ); +} - renderTableBody(noListFragment) { - return !noListFragment && this.props.studies - ? this.props.studies.map(this.getTableRow.bind(this)) - : null; - } +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 = ( + handleClick(studyInstanceUid)} + className={classNames({ active: isHighlighted })} + > + + {patientName || `(${t('Empty')})`} + + {patientId} + {accessionNumber} + {studyDate} + {modalities} + {studyDescription} + + ); - render() { - const tableMeta = getTableMeta(this.props.t); + const mediumRowTemplate = ( + handleClick(studyInstanceUid)} + className={classNames({ active: isHighlighted })} + > + + {patientName || `(${t('Empty')})`} +
{patientId}
+ + +
+ {/* DESCRIPTION */} +
+ {studyDescription} +
- // Apply sort - const sortedFieldName = this.state.searchData.sortData.field; - const sortedField = tableMeta[sortedFieldName]; + {/* MODALITY & ACCESSION */} +
+
+ {modalities} +
+
+ {accessionNumber} +
+
+
+ + {/* DATE */} + {studyDate} + + ); - if (sortedField) { - const sortOrder = this.state.searchData.sortData.order; - sortedField.sort = sortOrder === 'asc' ? 1 : 2; - } + const smallRowTemplate = ( + handleClick(studyInstanceUid)} + className={classNames({ active: isHighlighted })} + > + +
+ {/* NAME AND ID */} +
+
+ {patientName || `(${t('Empty')})`} +
+
{patientId}
+
- // 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; + {/* DESCRIPTION */} +
+ {studyDescription} +
- return ( -
-
-
{this.props.t('StudyList')}
-
{studiesNum}
-
- {this.props.studyListFunctionsEnabled ? ( - - ) : null} + {/* MODALITY & DATE */} +
+
+ {modalities} +
+
{studyDate}
- {this.props.children}
-
-
- - - - {Object.keys(tableMeta).map((fieldName, i) => { - const field = tableMeta[fieldName]; + + + ); - return ( - - - - ); - })} - - - {tableBody} -
-
- {field.displayText} - -
- {!field.inputType && ( - - )} - {field.inputType === 'date-range' && ( -
- - !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 }); - }} - /> -
- )} -
+ const rowTemplate = useMedia( + ['(min-width: 1750px)', '(min-width: 1000px)', '(min-width: 768px)'], + [largeRowTemplate, mediumRowTemplate, smallRowTemplate], + smallRowTemplate + ); - {noListFragment - ? noListFragment - : getPaginationFragment( - this.props, - this.state.searchData, - this.nextPage, - this.prevPage, - this.onRowsPerPageChange - )} -
-
- ); - } + 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 }; diff --git a/platform/ui/src/components/studyList/StudyList.styl b/platform/ui/src/components/studyList/StudyList.styl index c4cf6a7670c..0f72e91a0ae 100644 --- a/platform/ui/src/components/studyList/StudyList.styl +++ b/platform/ui/src/components/studyList/StudyList.styl @@ -4,6 +4,7 @@ $study-list-padding = 8%; $study-list-toolbar-height = 75px; $body-cell-height = 40px; +$body-cell-top-bottom-padding = 16px; $tablePaddingMediumScreen = 5px $tablePaddingBigScreen = 3% @@ -20,21 +21,41 @@ placeholder-color(c) &:-ms-input-placeholder color: c -.DateRangePicker - height: 40px - margin: 0 5px 20px 5px - cursor: pointer - border: none - position: block +.study-list-header + .addNewStudy + margin: 0 10px -.studyListToolbar + label + font-weight: 400 + cursor: pointer + display: inline-block; + max-width: 100%; + margin-bottom: 5px; + + input + width: 0.1px; + height: 0.1px + opacity: 0 + overflow: hidden + position: absolute + z-index: -1 + + color: var(--text-secondary-color) + + &:hover + color: var(--hover-color) + + &:active + color: var(--active-color) + +.study-list-header + display: flex; + justify-content: space-between; background-color: var(--ui-gray-darker) height: $study-list-toolbar-height margin-bottom: 2px padding: 0 $study-list-padding - - &>div - display: inline-block + line-height: $study-list-toolbar-height .header font-size: 22px @@ -42,17 +63,20 @@ placeholder-color(c) color: var(--table-text-secondary-color) line-height: $study-list-toolbar-height - .studylistToolbar - height: $study-list-toolbar-height - line-height: $study-list-toolbar-height + .actions + display: flex; - .studyCount + .study-count color: var(--large-numbers-color) font-size: 40px font-weight: 100 line-height: $study-list-toolbar-height -.theadBackground +/* + * Dark gray background with blue border + * Spans width of page to create a distinct area for table filters + */ +.table-head-background height: 121px position: absolute width: 100% @@ -76,20 +100,110 @@ placeholder-color(c) height: 1px z-index: 2 -#studyListContainer + +.study-list-container width: 100% padding: 0 $study-list-padding position: absolute z-index: 2 - .loading - display: flex; - justify-content: center; +table.table + width: 100%; + margin-bottom: 20px; + border-spacing: 0; + border-collapse: collapse; + table-layout: fixed; + + > tbody tr + padding: 5px + background-color: black + + /* Striped Variant */ + &.table--striped > tbody tr:nth-child(even) + background-color: var(--ui-gray-darker) - .loading-text - color: var(--table-text-secondary-color) - font-size: 30px - width: fit-content; + /* Hover Variant */ + &.table--hoverable > tbody tr + &:hover, &:active, &.active + background-color: var(--table-hover-color) + &.no-hover + &:hover, &:active, &.active + background-color: var(--ui-gray-darker) + +.study-list-container > table.table > tr + height: 20px + +.study-list-container > table.table > thead + ::-webkit-datetime-edit-year-field:not([aria-valuenow]), + ::-webkit-datetime-edit-month-field:not([aria-valuenow]), + ::-webkit-datetime-edit-day-field:not([aria-valuenow]) + color: transparent + +.study-list-container > table.table > thead > tr > th + padding: 0; + border-bottom: 1px solid var(--ui-border-color-active); + width: 100%; + text-align: left; + border-top: 0; + +.study-list-container > table.table > tbody > tr > td + padding: $body-cell-top-bottom-padding 8px; + height: $body-cell-height + color: var(--table-text-primary-color) + font-weight: 300 + word-wrap: break-word; + + &.emptyCell + color: var(--ui-gray-light) + +.study-list-container > table.table > thead > tr > th.studyDate + min-width: 230px + +.study-list-container + .filters + label + display: flex; + align-items: center; + cursor: pointer; + width: 100%; + min-width: 95px; + margin: 0 auto; + color: var(--table-text-primary-color); + font-weight: 400; + padding: 20px; + user-select: none; + font-size: 15px; + + &:hover + color: var(--active-color); + + &.active, &:active + color: var(--active-color); + i + margin: 0 5px; + + input + height: 40px + margin: 0 5px 20px 5px + padding: 0 20px + cursor: pointer + border: none + background-color: var(--input-background-color) + color: var(--input-placeholder-color) + font-size: 10pt + font-weight: normal + border-radius: 4px + width: calc(100% - 10px); /* Just use padding? */ + transition(all 0.15s ease) + placeholder-color(var(--input-placeholder-color)) + + &:active, &:hover + background-color: var(--input-background-color) + + .loading-text + color: var(--table-text-secondary-color) + text-align: center; + font-size: 30px .notFound color: var(--table-text-secondary-color) @@ -97,191 +211,22 @@ placeholder-color(c) font-weight: 200 text-align: center - table#tblStudyList - width: 100%; - max-width: 100%; - margin-bottom: 20px; - border-spacing: 0; - border-collapse: collapse; - - > tr - height: 20px - - > thead - white-space: nowrap - - tr - th - padding: 0 - border-bottom: 1px solid var(--ui-border-color-active); - width: 100%; - text-align: left; - border-top: 0; - - &.studyDate - min-width: 230px - - .display-text - display: inline-block - cursor: pointer - width: 100% - min-width: 95px - margin: 0 auto - color: var(--table-text-primary-color) - font-weight: 400 - padding: 20px - -webkit-user-select: none - -moz-user-select: none - -ms-user-select: none - user-select: none - - span - font-size: 15px - float: left - - i - margin: 0 5px - - &:hover - color: var(--active-color) - - &.active, &:active - color: var(--active-color) - - input.studylist-search - height: 40px - margin: 0 5px 20px 5px - padding: 0 20px - cursor: pointer - border: none - background-color: var(--input-background-color) - color: var(--input-placeholder-color) - font-size: 10pt - font-weight: normal - width: calc(100% - 10px) - border-radius: 4px - - transition(all 0.15s ease) - placeholder-color(var(--input-placeholder-color)) - - &.invisible - visibility: hidden - - &:active, &:hover - background-color: var(--input-background-color) - - ::-webkit-datetime-edit-year-field:not([aria-valuenow]), - ::-webkit-datetime-edit-month-field:not([aria-valuenow]), - ::-webkit-datetime-edit-day-field:not([aria-valuenow]) - color: transparent - - > tbody - tr - padding: 5px - background-color: black - - &:nth-child(even) - background-color: var(--ui-gray-darker) - - td - padding: 8px; - height: $body-cell-height - line-height: $body-cell-height - color: var(--table-text-primary-color) - font-weight: 300 - border-top: 1px solid var(--ui-gray-lighter) - border-bottom: 1px solid var(--ui-gray-lighter) - white-space: nowrap - transition(all 0.1s ease) - - &.emptyCell - color: #516873 - - &.emptyValue - color: var(--ui-gray-light) - - &:hover, &:active, &.active - background-color: var(--table-hover-color) - color: var(--text-primary-color) - - td - // This selector is necessary to override bootstrap's 'table' class - border-top: 1px solid var(--ui-gray-lighter) - border-bottom: 1px solid var(--ui-gray-lighter) - background-color: var(--table-hover-color) - -@media only screen and (max-width: 1362px) - #studyListContainer - padding: 0 $tablePaddingLargeScreen - - table#tblStudyList - thead, - tbody - tr - th, - td - &:first-child - padding-left: $tablePaddingLargeScreen - - &:last-child - padding-right: $tablePaddingLargeScreen - -@media only screen and (max-width: 1161px) - #studyListContainer - padding: 0 $tablePaddingBigWidth - - table#tblStudyList - thead, - tbody - tr - th, - td - &:first-child - padding-left: $tablePaddingBigScreen - - &:last-child - padding-right: $tablePaddingBigScreen - -@media only screen and (max-width: 1069px) - .theadBackground - height: 101px - - .studylist-pagination > .row - margin-right: 0; - - .studyListToolbar - padding: 0 $tablePaddingMediumScreen - - #studyListContainer - padding: 0 - - table#tblStudyList - thead > tr > th - - &:first-child - padding-left: $tablePaddingMediumScreen +@media only screen and (max-width: 768px) + .study-list-header + padding: 0 16px; + + .study-list-container + padding: 0; - &:last-child - padding-right: $tablePaddingMediumScreen + .study-list-container > table.table > thead > tr > th + padding: 0 13px; - input.worklist-search - padding: 10px + .study-list-container > table.table > tbody > tr > td + padding: 8px; - .display-text - padding: 10px 5px - - i - width: auto - - tbody > tr > td + .study-list-container .filters label + padding: 8px; - &:first-child - padding-left: $tablePaddingMediumScreen - - &:last-child - padding-right: $tablePaddingMediumScreen - - .worklistPagination - .row - margin-left: 0 - margin-right: 0 +@media only screen and (max-width: 500px) + .hide-xs + display: none; diff --git a/platform/ui/src/components/studyList/StudyListLoadingText.js b/platform/ui/src/components/studyList/StudyListLoadingText.js index 22c0dad7421..9afa23770d5 100644 --- a/platform/ui/src/components/studyList/StudyListLoadingText.js +++ b/platform/ui/src/components/studyList/StudyListLoadingText.js @@ -1,5 +1,6 @@ -import { Icon } from './../../elements/Icon'; import React from 'react'; +import { Icon } from './../../elements/Icon'; +// TODO: useTranslation import { withTranslation } from '../../utils/LanguageProvider'; function StudyListLoadingText({ t: translate }) { diff --git a/platform/ui/src/components/studyList/StudyListToolbar.styl b/platform/ui/src/components/studyList/StudyListToolbar.styl deleted file mode 100644 index c810bdb3146..00000000000 --- a/platform/ui/src/components/studyList/StudyListToolbar.styl +++ /dev/null @@ -1,26 +0,0 @@ -.studyListToolbar - .addNewStudy - margin: 0 10px - - label - font-weight: 400 - cursor: pointer - display: inline-block; - max-width: 100%; - margin-bottom: 5px; - - input - width: 0.1px; - height: 0.1px - opacity: 0 - overflow: hidden - position: absolute - z-index: -1 - - color: var(--text-secondary-color) - - &:hover - color: var(--hover-color) - - &:active - color: var(--active-color) diff --git a/platform/ui/src/components/studyList/PaginationArea.js b/platform/ui/src/components/studyList/TablePagination.js similarity index 80% rename from platform/ui/src/components/studyList/PaginationArea.js rename to platform/ui/src/components/studyList/TablePagination.js index 85a7f581f6a..9fa4452a444 100644 --- a/platform/ui/src/components/studyList/PaginationArea.js +++ b/platform/ui/src/components/studyList/TablePagination.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import './PaginationArea.styl'; import { withTranslation } from '../../utils/LanguageProvider'; -class PaginationArea extends PureComponent { +class TablePagination extends PureComponent { static defaultProps = { pageOptions: [5, 10, 25, 50, 100], rowsPerPage: 25, @@ -11,7 +11,8 @@ class PaginationArea extends PureComponent { }; static propTypes = { - pageOptions: PropTypes.array.isRequired, + /* Values to show in "rows per page" select dropdown */ + pageOptions: PropTypes.array, rowsPerPage: PropTypes.number.isRequired, currentPage: PropTypes.number.isRequired, nextPageFunc: PropTypes.func, @@ -89,17 +90,11 @@ class PaginationArea extends PureComponent { render() { return ( -
-
-
-
- {this.renderRowsPerPageDropdown()} -
-
-
- {this.renderPaginationButtons()} -
-
+
+
{this.renderRowsPerPageDropdown()}
+
+
+ {this.renderPaginationButtons()}
@@ -107,5 +102,5 @@ class PaginationArea extends PureComponent { } } -const connectedComponent = withTranslation('Common')(PaginationArea); -export { connectedComponent as PaginationArea }; +const connectedComponent = withTranslation('Common')(TablePagination); +export { connectedComponent as TablePagination }; diff --git a/platform/ui/src/components/studyList/TableSearchFilter.js b/platform/ui/src/components/studyList/TableSearchFilter.js new file mode 100644 index 00000000000..6c422e79797 --- /dev/null +++ b/platform/ui/src/components/studyList/TableSearchFilter.js @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { isInclusivelyBeforeDay } from 'react-dates'; +import CustomDateRangePicker from './CustomDateRangePicker.js'; +import { Icon } from './../../elements/Icon'; +import { useTranslation } from 'react-i18next'; + + + +function TableSearchFilter(props) { + const { + meta, + values, + onSort, + onValueChange, + sortFieldName, + sortDirection, + // TODO: Rename + studyListDateFilterNumDays + } = props; + const [focusedInput, setFocusedInput] = useState(null); + const [t] = useTranslation(); // 'Common'? + + const sortIcons = ['sort', 'sort-up', 'sort-down']; + const sortIconForSortField = + sortDirection === 'asc' ? sortIcons[1] : sortIcons[2]; + // + const today = moment(); + const lastWeek = moment().subtract(7, 'day'); + const lastMonth = moment().subtract(1, 'month'); + const defaultStartDate = moment().subtract(studyListDateFilterNumDays, 'days'); + const defaultEndDate = today; + const studyDatePresets = [ + { + text: t('Today'), + start: today, + end: today, + }, + { + text: t('Last 7 days'), + start: lastWeek, + end: today, + }, + { + text: t('Last 30 days'), + start: lastMonth, + end: today, + }, + ]; + + return meta.map((field, i) => { + const { displayText, fieldName, inputType } = field; + const isSortField = sortFieldName === fieldName; + const sortIcon = isSortField ? sortIconForSortField : sortIcons[0]; + + return ( + + + {inputType === 'text' && ( + onValueChange(fieldName, e.target.value)} + /> + )} + {inputType === 'date-range' && ( + // https://github.com/airbnb/react-dates + { + onValueChange('studyDateTo', startDate); + onValueChange('studyDateFrom', endDate); + }} + focusedInput={focusedInput} + onFocusChange={updatedVal => setFocusedInput(updatedVal)} + // Optional + numberOfMonths={1} // For med and small screens? 2 for large? + showClearDates={true} + anchorDirection="left" + presets={studyDatePresets} + hideKeyboardShortcutsPanel={true} + isOutsideRange={day => !isInclusivelyBeforeDay(day, moment())} + /> + )} + + ); + }); +} + +TableSearchFilter.propTypes = { + meta: PropTypes.arrayOf( + PropTypes.shape({ + displayText: PropTypes.string.isRequired, + fieldName: PropTypes.string.isRequired, + inputType: PropTypes.oneOf(['text', 'date-range']).isRequired, + size: PropTypes.number.isRequired, + }) + ).isRequired, + values: PropTypes.object.isRequired, + onSort: PropTypes.func.isRequired, + sortFieldName: PropTypes.string, + sortDirection: PropTypes.oneOf([null, 'asc', 'desc']), +}; + +TableSearchFilter.defaultProps = {}; + +export { TableSearchFilter }; +export default TableSearchFilter; diff --git a/platform/ui/src/components/studyList/index.js b/platform/ui/src/components/studyList/index.js index 45f24409284..af907892731 100644 --- a/platform/ui/src/components/studyList/index.js +++ b/platform/ui/src/components/studyList/index.js @@ -1 +1,4 @@ export { StudyList } from './StudyList.js'; +export { TableSearchFilter } from './TableSearchFilter.js'; +export { TablePagination } from './TablePagination.js'; +export { PageToolbar } from './PageToolbar.js'; diff --git a/platform/ui/src/components/studyList/internal/bkdr-hash.js b/platform/ui/src/components/studyList/internal/bkdr-hash.js new file mode 100644 index 00000000000..888f7aeab0e --- /dev/null +++ b/platform/ui/src/components/studyList/internal/bkdr-hash.js @@ -0,0 +1,24 @@ +/** + * BKDR Hash (modified version) + * + * @param {String} str string to hash + * @returns {Number} + */ +function BKDRHash(str) { + const seed = 131; + const seed2 = 137; + let hash = 0; + // make hash more sensitive for short string like 'a', 'b', 'c' + str += 'x'; + // Note: Number.MAX_SAFE_INTEGER equals 9007199254740991 + var MAX_SAFE_INTEGER = parseInt(9007199254740991 / seed2); + for (var i = 0; i < str.length; i++) { + if (hash > MAX_SAFE_INTEGER) { + hash = parseInt(hash / seed2); + } + hash = hash * seed + str.charCodeAt(i); + } + return hash; +} + +export default BKDRHash; diff --git a/platform/ui/src/components/studyList/internal/color-hash.js b/platform/ui/src/components/studyList/internal/color-hash.js new file mode 100644 index 00000000000..fc2eea7dde9 --- /dev/null +++ b/platform/ui/src/components/studyList/internal/color-hash.js @@ -0,0 +1,145 @@ +import BKDRHash from './bkdr-hash'; + +/** + * Convert RGB Array to HEX + * + * @param {Array} RGBArray - [R, G, B] + * @returns {String} 6 digits hex starting with # + */ +function RGB2HEX(RGBArray) { + let hex = '#'; + RGBArray.forEach(function(value) { + if (value < 16) { + hex += 0; + } + hex += value.toString(16); + }); + return hex; +} + +/** + * Convert HSL to RGB + * + * @see {@link http://zh.wikipedia.org/wiki/HSL和HSV色彩空间} for further information. + * @param {Number} H Hue ∈ [0, 360) + * @param {Number} S Saturation ∈ [0, 1] + * @param {Number} L Lightness ∈ [0, 1] + * @returns {Array} R, G, B ∈ [0, 255] + */ +function HSL2RGB(H, S, L) { + H /= 360; + + const q = L < 0.5 ? L * (1 + S) : L + S - L * S; + const p = 2 * L - q; + + return [H + 1 / 3, H, H - 1 / 3].map(function(color) { + if (color < 0) { + color++; + } + if (color > 1) { + color--; + } + if (color < 1 / 6) { + color = p + (q - p) * 6 * color; + } else if (color < 0.5) { + color = q; + } else if (color < 2 / 3) { + color = p + (q - p) * 6 * (2 / 3 - color); + } else { + color = p; + } + return Math.round(color * 255); + }); +} + +function isArray(o) { + return Object.prototype.toString.call(o) === '[object Array]'; +} + +/** + * Color Hash Class + * + * @class + */ +const ColorHash = function(options = {}) { + const LS = [options.lightness, options.saturation].map(function(param) { + param = param || [0.35, 0.5, 0.65]; // note that 3 is a prime + return isArray(param) ? param.concat() : [param]; + }); + + this.L = LS[0]; + this.S = LS[1]; + + if (typeof options.hue === 'number') { + options.hue = { min: options.hue, max: options.hue }; + } + if (typeof options.hue === 'object' && !isArray(options.hue)) { + options.hue = [options.hue]; + } + if (typeof options.hue === 'undefined') { + options.hue = []; + } + this.hueRanges = options.hue.map(function(range) { + return { + min: typeof range.min === 'undefined' ? 0 : range.min, + max: typeof range.max === 'undefined' ? 360 : range.max, + }; + }); + + this.hash = options.hash || BKDRHash; +}; + +/** + * Returns the hash in [h, s, l]. + * Note that H ∈ [0, 360); S ∈ [0, 1]; L ∈ [0, 1]; + * + * @param {String} str string to hash + * @returns {Array} [h, s, l] + */ +ColorHash.prototype.hsl = function(str) { + var H, S, L; + var hash = this.hash(str); + + if (this.hueRanges.length) { + var range = this.hueRanges[hash % this.hueRanges.length]; + var hueResolution = 727; // note that 727 is a prime + H = + (((hash / this.hueRanges.length) % hueResolution) * + (range.max - range.min)) / + hueResolution + + range.min; + } else { + H = hash % 359; // note that 359 is a prime + } + hash = parseInt(hash / 360); + S = this.S[hash % this.S.length]; + hash = parseInt(hash / this.S.length); + L = this.L[hash % this.L.length]; + + return [H, S, L]; +}; + +/** + * Returns the hash in [r, g, b]. + * Note that R, G, B ∈ [0, 255] + * + * @param {String} str string to hash + * @returns {Array} [r, g, b] + */ +ColorHash.prototype.rgb = function(str) { + var hsl = this.hsl(str); + return HSL2RGB.apply(this, hsl); +}; + +/** + * Returns the hash in hex + * + * @param {String} str string to hash + * @returns {String} hex with # + */ +ColorHash.prototype.hex = function(str) { + var rgb = this.rgb(str); + return RGB2HEX(rgb); +}; + +export default ColorHash; diff --git a/platform/ui/src/components/userPreferencesModal/AboutModal.js b/platform/ui/src/components/userPreferencesModal/AboutModal.js index d71ab918f0f..43aa9203192 100644 --- a/platform/ui/src/components/userPreferencesModal/AboutModal.js +++ b/platform/ui/src/components/userPreferencesModal/AboutModal.js @@ -62,7 +62,7 @@ class AboutModal extends Component { renderTableRow(item) { return ( - + {item.name} {item.link ? ( diff --git a/platform/ui/src/design/styles/common/global.styl b/platform/ui/src/design/styles/common/global.styl index a0576f86bba..af81a088281 100644 --- a/platform/ui/src/design/styles/common/global.styl +++ b/platform/ui/src/design/styles/common/global.styl @@ -42,19 +42,6 @@ h3, h1 margin-top: 20px; margin-bottom: 10px; -pre - display: block; - padding: 9.5px; - margin: 0 0 10px; - font-size: 13px; - line-height: 1.42857143; - color: #333; - word-break: break-all; - word-wrap: break-word; - background-color: #f5f5f5; - border: 1px solid #ccc; - border-radius: 4px; - button, input, select, textarea font-family: inherit; font-size: inherit; diff --git a/platform/ui/src/hooks/index.js b/platform/ui/src/hooks/index.js new file mode 100644 index 00000000000..332bc509bd5 --- /dev/null +++ b/platform/ui/src/hooks/index.js @@ -0,0 +1,4 @@ +import useMedia from './useMedia.js'; +import useDebounce from './useDebounce.js'; + +export { useDebounce, useMedia }; diff --git a/platform/ui/src/hooks/useDebounce.js b/platform/ui/src/hooks/useDebounce.js new file mode 100644 index 00000000000..ed1d7f8d950 --- /dev/null +++ b/platform/ui/src/hooks/useDebounce.js @@ -0,0 +1,41 @@ +import React, { useState, useEffect } from 'react'; + +/** + * A hook to set a value + * + * @param {*} value - A value to "set" + * @param {number} delay - The debounce delay for setting the value + * @returns + */ +export default function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect( + () => { + // Set debouncedValue to value (passed in) after the specified delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // Return a cleanup function that will be called every time useEffect is + // re-called. useEffect will only be re-called if value changes (see the + // inputs array below). + // + // This is how we prevent debouncedValue from changing if value is changed + // within the delay period. Timeout gets cleared and restarted. + // + // To put it in context, if the user is typing within our app's search + // box, we don't want the debouncedValue to update until they've stopped + // typing for more than 500ms. + return () => { + clearTimeout(handler); + }; + }, + // Only re-call effect if value changes + // You could also add the "delay" var to inputs array if you need to be + // able to change that dynamically. + [value] + ); + + return debouncedValue; +} diff --git a/platform/ui/src/hooks/useMedia.js b/platform/ui/src/hooks/useMedia.js new file mode 100644 index 00000000000..1c6ab0c291f --- /dev/null +++ b/platform/ui/src/hooks/useMedia.js @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; + +/** + * + * @example + * const currentViewportSize = useMedia( + * // Media queries + * ['(min-width: 1500px)', '(min-width: 1000px)', '(min-width: 600px)'], + * // Value to return for matched media query + * ['large', 'medium', 'small'], + * // Default value + * 'medium' + * ); + * @param {string[]} queries + * @param {*} values + * @param {*} defaultValue + * @returns + */ +function useMedia(queries, values, defaultValue) { + // Array containing a media query list for each query + const mediaQueryLists = queries.map(q => window.matchMedia(q)); + + // Function that gets value based on matching media query + const getValue = () => { + // Get index of first media query that matches + const index = mediaQueryLists.findIndex(mql => mql.matches); + + // Return related value or defaultValue if none + return typeof values[index] !== 'undefined' ? values[index] : defaultValue; + }; + + // State and setter for matched value + const [value, setValue] = useState(getValue); + + useEffect( + () => { + // Event listener callback + // Note: By defining getValue outside of useEffect we ensure that it has ... + // ... current values of hook args (as this hook callback is created once on mount). + const handler = () => setValue(getValue); + + // Set a listener for each media query with above handler as callback. + mediaQueryLists.forEach(mql => mql.addListener(handler)); + + // Remove listeners on cleanup + return () => mediaQueryLists.forEach(mql => mql.removeListener(handler)); + }, + [] // Empty array ensures effect is only run on mount and unmount + ); + + return value; +} + +export default useMedia; diff --git a/platform/ui/src/index.js b/platform/ui/src/index.js index 61cd90214e4..f184f3eabd3 100644 --- a/platform/ui/src/index.js +++ b/platform/ui/src/index.js @@ -8,6 +8,7 @@ import { MeasurementTableItem, Overlay, OverlayTrigger, + PageToolbar, QuickSwitch, RoundedButtonGroup, SelectTree, @@ -16,6 +17,8 @@ import { StudyList, TableList, TableListItem, + TableSearchFilter, + TablePagination, ThumbnailEntry, ToolbarSection, Tooltip, @@ -23,6 +26,7 @@ import { UserPreferences, UserPreferencesModal, } from './components'; +import { useDebounce, useMedia } from './hooks'; // Elements import { @@ -69,6 +73,7 @@ export { Overlay, OverlayTrigger, PlayClipButton, + PageToolbar, QuickSwitch, Range, RoundedButtonGroup, @@ -81,6 +86,8 @@ export { StudyList, TableList, TableListItem, + TableSearchFilter, + TablePagination, ThumbnailEntry, Toolbar, ToolbarButton, @@ -93,4 +100,7 @@ export { SnackbarProvider, useSnackbarContext, withSnackbar, + // Hooks + useDebounce, + useMedia, }; diff --git a/platform/viewer/cypress/integration/common/OHIFStandaloneViewer.spec.js b/platform/viewer/cypress/integration/common/OHIFStandaloneViewer.spec.js index 44964f08c79..62854791939 100644 --- a/platform/viewer/cypress/integration/common/OHIFStandaloneViewer.spec.js +++ b/platform/viewer/cypress/integration/common/OHIFStandaloneViewer.spec.js @@ -7,17 +7,8 @@ describe('OHIFStandaloneViewer', () => { cy.screenshot(); cy.percyCanvasSnapshot('Study List'); - cy.get('#studyListData tr') + cy.get('[data-cy="study-list-results"] tr') .its('length') .should('be.gt', 2); }); - - it('first 2 rows has values', () => { - cy.get('#studyListData > :nth-child(1) > .patientId', { - timeout: 15000, - }).should('be.visible'); - cy.get('#studyListData > :nth-child(2) > .patientId', { - timeout: 15000, - }).should('be.visible'); - }); }); diff --git a/platform/viewer/cypress/integration/common/ViewerRouting.spec.js b/platform/viewer/cypress/integration/common/ViewerRouting.spec.js index b39bbb9bb2c..b63d8d31b35 100644 --- a/platform/viewer/cypress/integration/common/ViewerRouting.spec.js +++ b/platform/viewer/cypress/integration/common/ViewerRouting.spec.js @@ -2,7 +2,9 @@ describe('ViewerRouting', () => { beforeEach(() => { cy.visit('/'); cy.contains('Study List'); - cy.get('#studyListData > :nth-child(1) > .patientId').click(); + cy.get( + '[data-cy="study-list-results"]> :nth-child(1) > .patientId' + ).click(); }); // it('thumbnails list has more than 2 items', () => { diff --git a/platform/viewer/cypress/support/commands.js b/platform/viewer/cypress/support/commands.js index 5a39271fe78..b2a2ba846ea 100644 --- a/platform/viewer/cypress/support/commands.js +++ b/platform/viewer/cypress/support/commands.js @@ -40,9 +40,9 @@ import { */ Cypress.Commands.add('openStudy', patientName => { cy.openStudyList(); - cy.get('#patientName').type(patientName); + cy.get('#filter-patientNameOrId').type(patientName); cy.wait('@getStudies'); - cy.get('#studyListData .studylistStudy', { timeout: 5000 }) + cy.get('[data-cy="study-list-results"]', { timeout: 5000 }) .contains(patientName) .first() .click({ force: true }); @@ -56,11 +56,12 @@ Cypress.Commands.add('openStudy', patientName => { Cypress.Commands.add('openStudyModality', modality => { cy.initRouteAliases(); cy.visit('/'); - cy.get('#modalities') + + cy.get('#filter-accessionOrModalityOrDescription') .type(modality) .wait(2000); - cy.get('#studyListData') + cy.get('[data-cy="study-list-results"]') .contains(modality) .first() .click(); diff --git a/platform/viewer/src/App.js b/platform/viewer/src/App.js index 9ddb4367981..e42d2bb3533 100644 --- a/platform/viewer/src/App.js +++ b/platform/viewer/src/App.js @@ -1,5 +1,6 @@ import { hot } from 'react-hot-loader/root'; +// TODO: This should not be here import './config'; import { diff --git a/platform/viewer/src/components/Header/Header.css b/platform/viewer/src/components/Header/Header.css index aaa741ec13c..0ced9e7c453 100644 --- a/platform/viewer/src/components/Header/Header.css +++ b/platform/viewer/src/components/Header/Header.css @@ -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; - font-weight: bold; -} - -.header-versionInfoHome { - display: block; - color: black; +.notification-bar { + display: none; position: absolute; - bottom: 16px; - right: 0; - background: #9ccef9; - padding: 0px 8px; - border-radius: 16px; - font-size: 12px; + height: 20px; + line-height: 20px; + width: 100%; + background-color: #91b9cd; + color: #ffffff; font-weight: bold; + text-align: center; +} + +@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; + } } diff --git a/platform/viewer/src/components/Header/Header.js b/platform/viewer/src/components/Header/Header.js index 9f0d1106899..ab84520f713 100644 --- a/platform/viewer/src/components/Header/Header.js +++ b/platform/viewer/src/components/Header/Header.js @@ -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 ( -
-
- {this.props.location && this.props.location.studyLink && ( - - {t('Back to Viewer')} - - )} - - - v{process.env.VERSION_NUMBER} - - - {this.props.children} - - {showStudyList && !this.props.home && ( - - {t('Study list')} - - )} -
- -
- {t('INVESTIGATIONAL USE ONLY')} - - - this.setState({ - isOpen: false, - }) - } - /> + <> +
{t('INVESTIGATIONAL USE ONLY')}
+
+
+ {this.props.location && this.props.location.studyLink && ( + + {t('Back to Viewer')} + + )} + + {this.props.children} + + {showStudyList && !this.props.home && ( + + {t('Study list')} + + )} +
+ +
+ + {t('INVESTIGATIONAL USE ONLY')} + + + + {/* TODO: We need a Modal service */} + + this.setState({ + isOpen: false, + }) + } + /> +
-
+ ); } } diff --git a/platform/viewer/src/components/OHIFLogo/OHIFLogo.css b/platform/viewer/src/components/OHIFLogo/OHIFLogo.css index daf1cd154a5..e5268b38afc 100644 --- a/platform/viewer/src/components/OHIFLogo/OHIFLogo.css +++ b/platform/viewer/src/components/OHIFLogo/OHIFLogo.css @@ -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; + } +} diff --git a/platform/viewer/src/components/OHIFLogo/OHIFLogo.js b/platform/viewer/src/components/OHIFLogo/OHIFLogo.js index d2e0bd8198b..a99ba0c36d2 100644 --- a/platform/viewer/src/components/OHIFLogo/OHIFLogo.js +++ b/platform/viewer/src/components/OHIFLogo/OHIFLogo.js @@ -12,6 +12,13 @@ function OHIFLogo() { href="http://ohif.org" > + {/* Logo text would fit smaller displays at two lines: + * + * Open Health + * Imaging Foundation + * + * Or as `OHIF` on really small displays + */} ); diff --git a/platform/viewer/src/components/SidePanel.css b/platform/viewer/src/components/SidePanel.css index 45325b0de17..620f4bc80da 100644 --- a/platform/viewer/src/components/SidePanel.css +++ b/platform/viewer/src/components/SidePanel.css @@ -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); + } +} diff --git a/platform/viewer/src/studylist/ConnectedStudyList.js b/platform/viewer/src/studylist/ConnectedStudyList.js index 6dffbc6a922..de6a13ee088 100644 --- a/platform/viewer/src/studylist/ConnectedStudyList.js +++ b/platform/viewer/src/studylist/ConnectedStudyList.js @@ -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; diff --git a/platform/viewer/src/studylist/StudyListRoute.js b/platform/viewer/src/studylist/StudyListRoute.js new file mode 100644 index 00000000000..29e43d30fb6 --- /dev/null +++ b/platform/viewer/src/studylist/StudyListRoute.js @@ -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
Error: {JSON.stringify(searchStatus.error)}
; + } else if (studies === [] && !activeModalId) { + return
Loading...
; + } + + let healthCareApiButtons = null; + let healthCareApiWindows = null; + + if (appConfig.enableGoogleCloudAdapter) { + const isModalOpen = activeModalId === 'DicomStorePicker'; + updateURL(isModalOpen, appConfig, server, history); + + healthCareApiWindows = ( + setActiveModalId(null)} + /> + ); + + healthCareApiButtons = ( +
+ +
+ ); + } + + 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 ( + <> + + {whiteLabelling => ( + + {userManager => ( + + {whiteLabelling.logoComponent} + + )} + + )} + +
+
+

+ {t('StudyList')} +

+
+
+ {studyListFunctionsEnabled && ( + setActiveModalId('DicomFilesUploader')} + /> + )} + {studies.length} +
+
+ +
+
+ {/* STUDY LIST OR DROP ZONE? */} + { + 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 ? ( + setActiveModalId(null)} + /> + ) : null} + {healthCareApiButtons} + {healthCareApiWindows} + + }{/* PAGINATION FOOTER */} + setPageNumber(pageNumber + 1)} + prevPageFunc={() => setPageNumber(pageNumber - 1)} + onRowsPerPageChange={rows => setRowsPerPage(rows)} + rowsPerPage={rowsPerPage} + recordCount={studies.length} + /> +
+ + ); +} + +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)); diff --git a/platform/viewer/src/studylist/StudyListWithData.js b/platform/viewer/src/studylist/StudyListWithData.js deleted file mode 100644 index 135be828f43..00000000000 --- a/platform/viewer/src/studylist/StudyListWithData.js +++ /dev/null @@ -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
Error: {JSON.stringify(this.state.error)}
; - } - - let healthCareApiButtons = null; - let healthCareApiWindows = null; - - if (appConfig.enableGoogleCloudAdapter) { - const modalOpened = this.state.modalComponentId === 'DicomStorePicker'; - this.updateURL(modalOpened); - - healthCareApiWindows = ( - - ); - - healthCareApiButtons = ( -
- -
- ); - } - - const studyList = ( -
- {this.state.studies || this.state.searchingStudies ? ( - - {this.props.studyListFunctionsEnabled ? ( - - ) : null} - {healthCareApiButtons} - {healthCareApiWindows} - - ) : ( - - {({ getRootProps, getInputProps }) => ( -
-

- {this.props.t( - 'Drag and Drop DICOM files here to load them in the Viewer' - )} -

-

- {this.props.t("Or click to load the browser's file selector")} -

- -
- )} -
- )} -
- ); - return ( - <> - - {whiteLabelling => ( - - {userManager => ( - - {whiteLabelling.logoComponent} - - )} - - )} - - {studyList} - - ); - } -} - -export default withRouter(withTranslation('Common')(StudyListWithData)); diff --git a/yarn.lock b/yarn.lock index 2043f0736d0..4bc69dc5ac7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3164,7 +3164,7 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" -airbnb-prop-types@^2.10.0, airbnb-prop-types@^2.15.0, airbnb-prop-types@^2.8.1: +airbnb-prop-types@^2.10.0, airbnb-prop-types@^2.14.0, airbnb-prop-types@^2.15.0, airbnb-prop-types@^2.8.1: version "2.15.0" resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.15.0.tgz#5287820043af1eb469f5b0af0d6f70da6c52aaef" integrity sha512-jUh2/hfKsRjNFC4XONQrxo/n/3GG4Tn6Hl0WlFQN5PY9OMC9loSCoAYKnZsWaP8wEfd5xcrPloK0Zg6iS1xwVA== @@ -7164,6 +7164,14 @@ env-variable@0.0.x: resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88" integrity sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA== +enzyme-shallow-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.0.tgz#d8e4603495e6ea279038eef05a4bf4887b55dc69" + integrity sha512-VUf+q5o1EIv2ZaloNQQtWCJM9gpeux6vudGVH6vLmfPXFLRuxl5+Aq3U260wof9nn0b0i+P5OEUXm1vnxkRpXQ== + dependencies: + has "^1.0.3" + object-is "^1.0.1" + err-code@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/err-code/-/err-code-1.1.2.tgz#06e0116d3028f6aef4806849eb0ea6a748ae6960" @@ -7951,7 +7959,7 @@ fb-watchman@^2.0.0: dependencies: bser "^2.0.0" -fbjs@^0.8.0, fbjs@^0.8.4: +fbjs@^0.8.0: version "0.8.17" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= @@ -15053,6 +15061,13 @@ raf-schd@^4.0.2: resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== +raf@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + dependencies: + performance-now "^2.1.0" + ramda@0.24.1: version "0.24.1" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.24.1.tgz#c3b7755197f35b8dc3502228262c4c91ddb6b857" @@ -15124,14 +15139,6 @@ re-resizable@^4.11.0: resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-4.11.0.tgz#d5df10bda445c4ec0945751a223bf195afb61890" integrity sha512-dye+7rERqNf/6mDT1iwps+4Gf42420xuZgygF33uX178DxffqcyeuHbBuJ382FIcB5iP6mMZOhfW7kI0uXwb/Q== -react-addons-shallow-compare@^15.6.2: - version "15.6.2" - resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz#198a00b91fc37623db64a28fd17b596ba362702f" - integrity sha1-GYoAuR/DdiPbZKKP0XtZa6NicC8= - dependencies: - fbjs "^0.8.4" - object-assign "^4.1.0" - react-bootstrap-modal@4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/react-bootstrap-modal/-/react-bootstrap-modal-4.2.0.tgz#010b3ca9230a3c147fa5fc6aa39b6971e34151bd" @@ -15168,25 +15175,26 @@ react-cornerstone-viewport@2.x.x: prop-types "^15.7.2" react-resize-detector "^4.2.1" -react-dates@18.4.1: - version "18.4.1" - resolved "https://registry.yarnpkg.com/react-dates/-/react-dates-18.4.1.tgz#82aa0bb4faaa9cdb9547d61c791af2180beda20e" - integrity sha512-ew6HiORfbJkEGlJ+5SMC5GtgI87zj2BqNv8tRsdnPtgLMt5fY2Z9dUFxc+XATeRHs+wOm4ku0dlKWpuqBzYapQ== +react-dates@21.2.1: + version "21.2.1" + resolved "https://registry.yarnpkg.com/react-dates/-/react-dates-21.2.1.tgz#a979ed6876326ccfbf754a019bc95458cc061ad8" + integrity sha512-jGZGQjiYur6lTvnhgQvnOLR2ACmV10LDZow3anNSAFAuKsWny3NKrnlXJ+gdRMv3amCF8TrMwsa+3cplv3OWIQ== dependencies: - airbnb-prop-types "^2.10.0" + airbnb-prop-types "^2.15.0" consolidated-events "^1.1.1 || ^2.0.0" + enzyme-shallow-equal "^1.0.0" is-touch-device "^1.0.1" lodash "^4.1.1" object.assign "^4.1.0" - object.values "^1.0.4" - prop-types "^15.6.1" - react-addons-shallow-compare "^15.6.2" + object.values "^1.1.0" + prop-types "^15.7.2" + raf "^3.4.1" react-moment-proptypes "^1.6.0" - react-outside-click-handler "^1.2.0" - react-portal "^4.1.5" - react-with-direction "^1.3.0" - react-with-styles "^3.2.0" - react-with-styles-interface-css "^4.0.2" + react-outside-click-handler "^1.2.4" + react-portal "^4.2.0" + react-with-direction "^1.3.1" + react-with-styles "^4.0.1" + react-with-styles-interface-css "^6.0.0" react-dev-utils@^5.0.2: version "5.0.3" @@ -15422,7 +15430,7 @@ react-moment-proptypes@^1.6.0: dependencies: moment ">=1.6.0" -react-outside-click-handler@^1.2.0: +react-outside-click-handler@^1.2.4: version "1.3.0" resolved "https://registry.yarnpkg.com/react-outside-click-handler/-/react-outside-click-handler-1.3.0.tgz#3831d541ac059deecd38ec5423f81e80ad60e115" integrity sha512-Te/7zFU0oHpAnctl//pP3hEAeobfeHMyygHB8MnjP6sX5OR8KHT1G3jmLsV3U9RnIYo+Yn+peJYWu+D5tUS8qQ== @@ -15453,7 +15461,7 @@ react-perfect-scrollbar@^1.5.0: perfect-scrollbar "^1.4.0" prop-types "^15.6.1" -react-portal@^4.1.5: +react-portal@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-4.2.0.tgz#5400831cdb0ae64dccb8128121cf076089ab1afd" integrity sha512-Zf+vGQ/VEAb5XAy+muKEn48yhdCNYPZaB1BWg1xc8sAZWD8pXTgPtQT4ihBdmWzsfCq8p8/kqf0GWydSBqc+Eg== @@ -15566,7 +15574,7 @@ react-with-direction@1.3.0: object.values "^1.0.4" prop-types "^15.6.0" -react-with-direction@^1.3.0: +react-with-direction@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/react-with-direction/-/react-with-direction-1.3.1.tgz#9fd414564f0ffe6947e5ff176f6132dd83f8b8df" integrity sha512-aGcM21ZzhqeXFvDCfPj0rVNYuaVXfTz5D3Rbn0QMz/unZe+CCiLHthrjQWO7s6qdfXORgYFtmS7OVsRgSk5LXQ== @@ -15580,23 +15588,24 @@ react-with-direction@^1.3.0: object.values "^1.0.4" prop-types "^15.6.2" -react-with-styles-interface-css@^4.0.2: - version "4.0.3" - resolved "https://registry.yarnpkg.com/react-with-styles-interface-css/-/react-with-styles-interface-css-4.0.3.tgz#c4a61277b2b8e4126b2cd25eca3ac4097bd2af09" - integrity sha512-wE43PIyjal2dexxyyx4Lhbcb+E42amoYPnkunRZkb9WTA+Z+9LagbyxwsI352NqMdFmghR0opg29dzDO4/YXbw== +react-with-styles-interface-css@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/react-with-styles-interface-css/-/react-with-styles-interface-css-6.0.0.tgz#b53da7fa8359d452cb934cface8738acaef7b5fe" + integrity sha512-6khSG1Trf4L/uXOge/ZAlBnq2O2PEXlQEqAhCRbvzaQU4sksIkdwpCPEl6d+DtP3+IdhyffTWuHDO9lhe1iYvA== dependencies: array.prototype.flat "^1.2.1" global-cache "^1.2.1" -react-with-styles@^3.2.0: - version "3.2.3" - resolved "https://registry.yarnpkg.com/react-with-styles/-/react-with-styles-3.2.3.tgz#b058584065bb36c0d80ccc911725492692db8a61" - integrity sha512-MTI1UOvMHABRLj5M4WpODfwnveHaip6X7QUMI2x6zovinJiBXxzhA9AJP7MZNaKqg1JRFtHPXZdroUC8KcXwlQ== +react-with-styles@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/react-with-styles/-/react-with-styles-4.0.1.tgz#22b5170d39769b643dd650cc183e9419650af12d" + integrity sha512-J77k4E2Dcm4SpfbQEet2KyRjZJtPCWD1PVkOB5vIOJewg/VEIMkb2MVVxusLTh1nwtfo7cnxY6gHytF6NcBkLQ== dependencies: + airbnb-prop-types "^2.14.0" hoist-non-react-statics "^3.2.1" object.assign "^4.1.0" - prop-types "^15.6.2" - react-with-direction "^1.3.0" + prop-types "^15.7.2" + react-with-direction "^1.3.1" react@^16.8.6: version "16.11.0"