feat: 🎸 Load spinner when selecting gcloud store. Add key on td (#1034)

* 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

* test: Improviment on VTK beforeEach hook (#1057)

* feat: 🎸 Some improvements on table cell and react lifecycle

* feat: 🎸 Prevent component update on a not searchData changing

* feat: 🎸 Some cypress improv to pass ci

* feat: 🎸 Code reviewe and also fixing list for gcloud

* feat: 🎸 Code review. Add spinner on first load of store

* feat: 🎸 Code review. Centering loading list spinner icon
This commit is contained in:
ladeirarodolfo 2019-10-22 15:40:23 -03:00 committed by Danny Brown
parent dea0ceab57
commit e62f403fe9
8 changed files with 234 additions and 148 deletions

View File

@ -11,11 +11,79 @@ import { StudylistToolbar } from './StudyListToolbar.js';
import { isInclusivelyBeforeDay } from 'react-dates'; import { isInclusivelyBeforeDay } from 'react-dates';
import moment from 'moment'; import moment from 'moment';
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';
import isEqual from 'lodash.isequal';
import { withTranslation } from '../../utils/LanguageProvider'; import { withTranslation } from '../../utils/LanguageProvider';
const today = moment(); const today = moment();
const lastWeek = moment().subtract(7, 'day'); const lastWeek = moment().subtract(7, 'day');
const lastMonth = moment().subtract(1, 'month'); 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}
/>
);
}
function getTableMeta(translate) {
return {
patientName: {
displayText: translate('PatientName'),
sort: 0,
},
patientId: {
displayText: translate('MRN'),
sort: 0,
},
accessionNumber: {
displayText: translate('AccessionNumber'),
sort: 0,
},
studyDate: {
displayText: translate('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 { class StudyList extends Component {
static propTypes = { static propTypes = {
@ -111,24 +179,27 @@ class StudyList extends Component {
getBlurHandler(key) { getBlurHandler(key) {
return event => { return event => {
this.delayedSearch.cancel(); this.delayedSearch.cancel();
this.setSearchData(key, event.target.value, this.search); this.setSearchData(key, event.target.value);
}; };
} }
setSearchData(key, value, callback) { setSearchData(key, value) {
const searchData = this.state.searchData; const searchData = { ...this.state.searchData };
searchData[key] = value; searchData[key] = value;
this.setState({ searchData }, callback);
if (!isEqual(searchData[key], this.state.searchData[key])) {
this.setState({ ...this.state, searchData });
}
} }
setSearchDataBatch(keyValues, callback) { setSearchDataBatch(keyValues) {
const searchData = this.state.searchData; const searchData = { ...this.state.searchData };
Object.keys(keyValues).forEach(key => { Object.keys(keyValues).forEach(key => {
searchData[key] = keyValues[key]; searchData[key] = keyValues[key];
}); });
this.setState({ searchData }, callback); this.setState({ searchData });
} }
async onInputKeydown(event) { async onInputKeydown(event) {
@ -138,7 +209,7 @@ class StudyList extends Component {
this.delayedSearch.cancel(); this.delayedSearch.cancel();
// reset the page because user is doing a new search // reset the page because user is doing a new search
this.setSearchData('currentPage', 0, this.search); this.setSearchData('currentPage', 0);
} }
} }
@ -154,45 +225,21 @@ class StudyList extends Component {
} }
} }
renderNoMachingResults() {
if (!this.props.studies.length && !this.state.error) {
return <div className="notFound">No matching results</div>;
}
}
renderHasError() {
if (this.state.error) {
return (
<div className="notFound">There was an error fetching studies</div>
);
}
}
renderIsLoading() {
if (this.state.loading) {
return (
<div className="loading">
<StudyListLoadingText />
</div>
);
}
}
nextPage(currentPage) { nextPage(currentPage) {
currentPage = currentPage + 1; currentPage = currentPage + 1;
this.delayedSearch.cancel(); this.delayedSearch.cancel();
this.setSearchData('currentPage', currentPage, this.search); this.setSearchData('currentPage', currentPage);
} }
prevPage(currentPage) { prevPage(currentPage) {
currentPage = currentPage - 1; currentPage = currentPage - 1;
this.delayedSearch.cancel(); this.delayedSearch.cancel();
this.setSearchData('currentPage', currentPage, this.search); this.setSearchData('currentPage', currentPage);
} }
onRowsPerPageChange(rowsPerPage) { onRowsPerPageChange(rowsPerPage) {
this.delayedSearch.cancel(); this.delayedSearch.cancel();
this.setSearchDataBatch({ rowsPerPage, currentPage: 0 }, this.search); this.setSearchDataBatch({ rowsPerPage, currentPage: 0 });
} }
onSortClick(field) { onSortClick(field) {
@ -213,7 +260,7 @@ class StudyList extends Component {
} }
this.delayedSearch.cancel(); this.delayedSearch.cancel();
this.setSearchData('sortData', { field, order }, this.search); this.setSearchData('sortData', { field, order });
}; };
} }
@ -221,10 +268,39 @@ class StudyList extends Component {
this.setState({ highlightedItem: studyItemUid }); this.setState({ highlightedItem: studyItemUid });
} }
renderTableRow(study) { 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 ( return (
<tr <tr
key={study.studyInstanceUid} key={trKey}
className={ className={
this.state.highlightedItem === study.studyInstanceUid this.state.highlightedItem === study.studyInstanceUid
? 'studylistStudy noselect active' ? 'studylistStudy noselect active'
@ -241,47 +317,35 @@ class StudyList extends Component {
this.props.onSelectItem(study.studyInstanceUid); this.props.onSelectItem(study.studyInstanceUid);
}} }}
> >
<td className={study.patientName ? 'patientName' : 'emptyCell'}> {getTableCell(
{study.patientName || `(${this.props.t('Empty')})`} study,
</td> 'patientName',
`(${this.props.t('Empty')})`,
<td className="patientId">{study.patientId}</td> 'emptyCell'
<td className="accessionNumber">{study.accessionNumber}</td> )}
<td className="studyDate">{study.studyDate}</td> {getTableCell(study, 'patientId')}
<td className="modalities">{study.modalities}</td> {getTableCell(study, 'accessionNumber')}
<td className="studyDescription">{study.studyDescription}</td> {getTableCell(study, 'studyDate')}
{getTableCell(study, 'modalities')}
{getTableCell(study, 'studyDescription')}
</tr> </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() { render() {
const tableMeta = { const tableMeta = getTableMeta(this.props.t);
patientName: {
displayText: this.props.t('PatientName'),
sort: 0,
},
patientId: {
displayText: this.props.t('MRN'),
sort: 0,
},
accessionNumber: {
displayText: this.props.t('AccessionNumber'),
sort: 0,
},
studyDate: {
displayText: this.props.t('StudyDate'),
inputType: 'date-range',
sort: 0,
},
modalities: {
displayText: this.props.t('Modality'),
sort: 0,
},
studyDescription: {
displayText: this.props.t('StudyDescription'),
sort: 0,
},
};
// Apply sort // Apply sort
const sortedFieldName = this.state.searchData.sortData.field; const sortedFieldName = this.state.searchData.sortData.field;
@ -294,14 +358,20 @@ class StudyList extends Component {
// Sort Icons // Sort Icons
const sortIcons = ['sort', 'sort-up', 'sort-down']; 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 ( return (
<div className="StudyList"> <div className="StudyList">
<div className="studyListToolbar clearfix"> <div className="studyListToolbar clearfix">
<div className="header pull-left">{this.props.t('StudyList')}</div> <div className="header pull-left">{this.props.t('StudyList')}</div>
<div className="studyCount pull-right"> <div className="studyCount pull-right">{studiesNum}</div>
{this.props.studies.length}
</div>
<div className="pull-right"> <div className="pull-right">
{this.props.studyListFunctionsEnabled ? ( {this.props.studyListFunctionsEnabled ? (
<StudylistToolbar onImport={this.props.onImport} /> <StudylistToolbar onImport={this.props.onImport} />
@ -367,22 +437,16 @@ class StudyList extends Component {
(this.state.focusedInput === 'endDate' || (this.state.focusedInput === 'endDate' ||
preset) preset)
) { ) {
this.setSearchDataBatch( this.setSearchDataBatch({
{ studyDateFrom: startDate.toDate(),
studyDateFrom: startDate.toDate(), studyDateTo: endDate.toDate(),
studyDateTo: endDate.toDate(), });
},
this.search
);
this.setState({ focusedInput: false }); this.setState({ focusedInput: false });
} else if (!startDate && !endDate) { } else if (!startDate && !endDate) {
this.setSearchDataBatch( this.setSearchDataBatch({
{ studyDateFrom: null,
studyDateFrom: null, studyDateTo: null,
studyDateTo: null, });
},
this.search
);
} }
}} }}
focusedInput={this.state.focusedInput} focusedInput={this.state.focusedInput}
@ -398,26 +462,18 @@ class StudyList extends Component {
})} })}
</tr> </tr>
</thead> </thead>
<tbody id="studyListData"> <tbody id="studyListData">{tableBody}</tbody>
{this.props.studies.map(study => {
return this.renderTableRow(study);
})}
</tbody>
</table> </table>
{this.renderIsLoading()} {noListFragment
{this.renderHasError()} ? noListFragment
{this.renderNoMachingResults()} : getPaginationFragment(
this.props,
<PaginationArea this.state.searchData,
pageOptions={this.props.pageOptions} this.nextPage,
currentPage={this.state.searchData.currentPage} this.prevPage,
nextPageFunc={this.nextPage} this.onRowsPerPageChange
prevPageFunc={this.prevPage} )}
onRowsPerPageChange={this.onRowsPerPageChange}
rowsPerPage={this.state.searchData.rowsPerPage}
recordCount={this.props.studies.length}
/>
</div> </div>
</div> </div>
); );

View File

@ -82,9 +82,14 @@ placeholder-color(c)
position: absolute position: absolute
z-index: 2 z-index: 2
.loading-text .loading
color: var(--table-text-secondary-color) display: flex;
font-size: 30px justify-content: center;
.loading-text
color: var(--table-text-secondary-color)
font-size: 30px
width: fit-content;
.notFound .notFound
color: var(--table-text-secondary-color) color: var(--table-text-secondary-color)

View File

@ -1,12 +1,17 @@
import { Icon } from './../../elements/Icon'; import { Icon } from './../../elements/Icon';
import React from 'react'; import React from 'react';
import { withTranslation } from '../../utils/LanguageProvider';
function StudyListLoadingText() { function StudyListLoadingText({ t: translate }) {
return ( return (
<div className="loading-text"> <div className="loading-text">
Loading... <Icon name="circle-notch" animation="pulse" /> {translate('Loading')}... <Icon name="circle-notch" animation="pulse" />
</div> </div>
); );
} }
export { StudyListLoadingText }; const connectedComponent = withTranslation('StudyListLoadingText')(
StudyListLoadingText
);
export { connectedComponent as StudyListLoadingText };

View File

@ -1,6 +1,6 @@
describe('OHIFStandaloneViewer', () => { describe('OHIFStandaloneViewer', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/'); cy.openStudyList();
}); });
it('loads route with at least 2 rows', () => { it('loads route with at least 2 rows', () => {

View File

@ -35,8 +35,8 @@ export function initCommonElementsAliases() {
//Creating aliases for Routes //Creating aliases for Routes
export function initRouteAliases() { export function initRouteAliases() {
cy.server(); cy.server();
cy.route('GET', '/**/series/**').as('getStudySeries'); cy.route('GET', '**/series**').as('getStudySeries');
cy.route('GET', '/**/studies/**').as('getStudies'); cy.route('GET', '**/studies**').as('getStudies');
} }
//Creating aliases for VTK tools buttons //Creating aliases for VTK tools buttons

View File

@ -39,20 +39,12 @@ import {
* @param {string} PatientName - Patient name that we would like to search for * @param {string} PatientName - Patient name that we would like to search for
*/ */
Cypress.Commands.add('openStudy', patientName => { Cypress.Commands.add('openStudy', patientName => {
cy.initRouteAliases(); cy.openStudyList();
cy.visit('/'); cy.get('#patientName').type(patientName);
cy.wrap(null).then(() => { cy.wait('@getStudies');
return new Cypress.Promise((resolve, reject) => { cy.get('#studyListData .studylistStudy', { timeout: 5000 })
cy.get('#patientName').type(patientName); .contains(patientName)
setTimeout(() => { .first().click();
cy.get('#studyListData')
.contains(patientName)
.first()
.click();
resolve();
}, 2000);
});
});
}); });
/** /**
@ -82,6 +74,12 @@ Cypress.Commands.add('isPageLoaded', (url = '/viewer/') => {
return cy.location('pathname', { timeout: 60000 }).should('include', url); return cy.location('pathname', { timeout: 60000 }).should('include', url);
}); });
Cypress.Commands.add('openStudyList', patientName => {
cy.initRouteAliases();
cy.visit('/');
cy.wait('@getStudies');
});
/** /**
* Command to perform a drag and drop action. Before using this command, we must get the element that should be dragged first. * Command to perform a drag and drop action. Before using this command, we must get the element that should be dragged first.
* Example of usage: cy.get(element-to-be-dragged).drag(dropzone-element) * Example of usage: cy.get(element-to-be-dragged).drag(dropzone-element)

View File

@ -22,6 +22,8 @@ class DicomStorePickerModal extends Component {
handleEvent = data => { handleEvent = data => {
const servers = GoogleCloudUtilServers.getServers(data, data.dicomstore); const servers = GoogleCloudUtilServers.getServers(data, data.dicomstore);
this.props.setServers(servers); this.props.setServers(servers);
// Force auto close
this.props.onClose();
}; };
render() { render() {

View File

@ -8,6 +8,7 @@ import { StudyList } from '@ohif/ui';
import ConnectedHeader from '../connectedComponents/ConnectedHeader.js'; import ConnectedHeader from '../connectedComponents/ConnectedHeader.js';
import * as RoutesUtil from '../routes/routesUtil'; import * as RoutesUtil from '../routes/routesUtil';
import moment from 'moment'; import moment from 'moment';
import isEqual from 'lodash.isequal';
import ConnectedDicomFilesUploader from '../googleCloud/ConnectedDicomFilesUploader'; import ConnectedDicomFilesUploader from '../googleCloud/ConnectedDicomFilesUploader';
import ConnectedDicomStorePicker from '../googleCloud/ConnectedDicomStorePicker'; import ConnectedDicomStorePicker from '../googleCloud/ConnectedDicomStorePicker';
import filesToStudies from '../lib/filesToStudies.js'; import filesToStudies from '../lib/filesToStudies.js';
@ -20,8 +21,9 @@ import AppContext from '../context/AppContext';
class StudyListWithData extends Component { class StudyListWithData extends Component {
static contextType = AppContext; static contextType = AppContext;
state = { state = {
searchData: {}, searchOutdated: true,
studies: [], studies: [],
searchingStudies: false,
error: null, error: null,
modalComponentId: null, modalComponentId: null,
}; };
@ -62,6 +64,7 @@ class StudyListWithData extends Component {
if (!this.props.server && appConfig.enableGoogleCloudAdapter) { if (!this.props.server && appConfig.enableGoogleCloudAdapter) {
this.setState({ this.setState({
modalComponentId: 'DicomStorePicker', modalComponentId: 'DicomStorePicker',
searchOutdated: false,
}); });
} else { } else {
this.searchForStudies({ this.searchForStudies({
@ -71,21 +74,33 @@ class StudyListWithData extends Component {
} }
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps, prevState) {
if (!this.state.searchData && !this.state.studies) { const hasNewServer = !isEqual(this.props.server, prevProps.server);
this.searchForStudies();
} const { searchOutdated, searchingStudies } = this.state;
if (this.props.server !== prevProps.server) {
this.setState({ if (!searchingStudies) {
modalComponentId: null, if (hasNewServer) {
searchData: null, const { appConfig = {} } = this.context;
studies: null,
}); const newState = {
searchOutdated: true,
studies: null,
};
if (appConfig.enableGoogleCloudAdapter) {
newState.modalComponentId = null;
}
this.setState(newState);
}
if (searchOutdated) {
this.searchForStudies();
}
} }
} }
searchForStudies = (searchData = StudyListWithData.defaultSearchData) => { searchForStudies = (searchData = StudyListWithData.defaultSearchData) => {
const { server } = this.props; const { server = {} } = this.props;
const filter = { const filter = {
patientId: searchData.patientId, patientId: searchData.patientId,
patientName: searchData.patientName, patientName: searchData.patientName,
@ -105,7 +120,9 @@ class StudyListWithData extends Component {
// TODO: add sorting // TODO: add sorting
const promise = OHIF.studies.searchStudies(server, filter); const promise = OHIF.studies.searchStudies(server, filter);
// Render the viewer when the data is ready this.setState({
searchingStudies: true,
});
promise promise
.then(studies => { .then(studies => {
if (!studies) { if (!studies) {
@ -150,11 +167,15 @@ class StudyListWithData extends Component {
this.setState({ this.setState({
studies: sortedStudies, studies: sortedStudies,
searchingStudies: false,
searchOutdated: false,
}); });
}) })
.catch(error => { .catch(error => {
this.setState({ this.setState({
error: true, error: true,
searchingStudies: false,
searchOutdated: false,
}); });
throw new Error(error); throw new Error(error);
@ -208,8 +229,6 @@ class StudyListWithData extends Component {
if (this.state.error) { if (this.state.error) {
return <div>Error: {JSON.stringify(this.state.error)}</div>; return <div>Error: {JSON.stringify(this.state.error)}</div>;
} else if (this.state.studies === null && !this.state.modalComponentId) {
return <div>Loading...</div>;
} }
let healthCareApiButtons = null; let healthCareApiButtons = null;
@ -240,8 +259,9 @@ class StudyListWithData extends Component {
const studyList = ( const studyList = (
<div className="paginationArea"> <div className="paginationArea">
{this.state.studies ? ( {this.state.studies || this.state.searchingStudies ? (
<StudyList <StudyList
loading={this.state.searchingStudies}
studies={this.state.studies} studies={this.state.studies}
studyListFunctionsEnabled={this.props.studyListFunctionsEnabled} studyListFunctionsEnabled={this.props.studyListFunctionsEnabled}
onImport={this.onImport} onImport={this.onImport}