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 moment from 'moment';
import debounce from 'lodash.debounce';
import isEqual from 'lodash.isequal';
import { withTranslation } from '../../utils/LanguageProvider';
const today = moment();
const lastWeek = moment().subtract(7, 'day');
const lastMonth = moment().subtract(1, 'month');
function getPaginationFragment(
props,
searchData,
nextPageCb,
prevPageCb,
changeRowsPerPageCb
) {
return (
<PaginationArea
pageOptions={props.pageOptions}
currentPage={searchData.currentPage}
nextPageFunc={nextPageCb}
prevPageFunc={prevPageCb}
onRowsPerPageChange={changeRowsPerPageCb}
rowsPerPage={searchData.rowsPerPage}
recordCount={props.studies.length}
/>
);
}
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 {
static propTypes = {
@ -111,24 +179,27 @@ class StudyList extends Component {
getBlurHandler(key) {
return event => {
this.delayedSearch.cancel();
this.setSearchData(key, event.target.value, this.search);
this.setSearchData(key, event.target.value);
};
}
setSearchData(key, value, callback) {
const searchData = this.state.searchData;
setSearchData(key, value) {
const searchData = { ...this.state.searchData };
searchData[key] = value;
this.setState({ searchData }, callback);
if (!isEqual(searchData[key], this.state.searchData[key])) {
this.setState({ ...this.state, searchData });
}
}
setSearchDataBatch(keyValues, callback) {
const searchData = this.state.searchData;
setSearchDataBatch(keyValues) {
const searchData = { ...this.state.searchData };
Object.keys(keyValues).forEach(key => {
searchData[key] = keyValues[key];
});
this.setState({ searchData }, callback);
this.setState({ searchData });
}
async onInputKeydown(event) {
@ -138,7 +209,7 @@ class StudyList extends Component {
this.delayedSearch.cancel();
// 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) {
currentPage = currentPage + 1;
this.delayedSearch.cancel();
this.setSearchData('currentPage', currentPage, this.search);
this.setSearchData('currentPage', currentPage);
}
prevPage(currentPage) {
currentPage = currentPage - 1;
this.delayedSearch.cancel();
this.setSearchData('currentPage', currentPage, this.search);
this.setSearchData('currentPage', currentPage);
}
onRowsPerPageChange(rowsPerPage) {
this.delayedSearch.cancel();
this.setSearchDataBatch({ rowsPerPage, currentPage: 0 }, this.search);
this.setSearchDataBatch({ rowsPerPage, currentPage: 0 });
}
onSortClick(field) {
@ -213,7 +260,7 @@ class StudyList extends Component {
}
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 });
}
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 (
<tr
key={study.studyInstanceUid}
key={trKey}
className={
this.state.highlightedItem === study.studyInstanceUid
? 'studylistStudy noselect active'
@ -241,47 +317,35 @@ class StudyList extends Component {
this.props.onSelectItem(study.studyInstanceUid);
}}
>
<td className={study.patientName ? 'patientName' : 'emptyCell'}>
{study.patientName || `(${this.props.t('Empty')})`}
</td>
<td className="patientId">{study.patientId}</td>
<td className="accessionNumber">{study.accessionNumber}</td>
<td className="studyDate">{study.studyDate}</td>
<td className="modalities">{study.modalities}</td>
<td className="studyDescription">{study.studyDescription}</td>
{getTableCell(
study,
'patientName',
`(${this.props.t('Empty')})`,
'emptyCell'
)}
{getTableCell(study, 'patientId')}
{getTableCell(study, 'accessionNumber')}
{getTableCell(study, 'studyDate')}
{getTableCell(study, 'modalities')}
{getTableCell(study, 'studyDescription')}
</tr>
);
}
componentDidUpdate(previousProps, previousState) {
if (!isEqual(previousState.searchData, this.state.searchData)) {
this.search();
}
}
renderTableBody(noListFragment) {
return !noListFragment && this.props.studies
? this.props.studies.map(this.getTableRow.bind(this))
: null;
}
render() {
const tableMeta = {
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,
},
};
const tableMeta = getTableMeta(this.props.t);
// Apply sort
const sortedFieldName = this.state.searchData.sortData.field;
@ -294,14 +358,20 @@ class StudyList extends Component {
// Sort Icons
const sortIcons = ['sort', 'sort-up', 'sort-down'];
const noListFragment = getNoListFragment(
this.props.t,
this.props.studies,
this.state.error,
this.props.loading || this.state.loading
);
const tableBody = this.renderTableBody(noListFragment);
const studiesNum = (this.props.studies && this.props.studies.length) || 0;
return (
<div className="StudyList">
<div className="studyListToolbar clearfix">
<div className="header pull-left">{this.props.t('StudyList')}</div>
<div className="studyCount pull-right">
{this.props.studies.length}
</div>
<div className="studyCount pull-right">{studiesNum}</div>
<div className="pull-right">
{this.props.studyListFunctionsEnabled ? (
<StudylistToolbar onImport={this.props.onImport} />
@ -367,22 +437,16 @@ class StudyList extends Component {
(this.state.focusedInput === 'endDate' ||
preset)
) {
this.setSearchDataBatch(
{
studyDateFrom: startDate.toDate(),
studyDateTo: endDate.toDate(),
},
this.search
);
this.setSearchDataBatch({
studyDateFrom: startDate.toDate(),
studyDateTo: endDate.toDate(),
});
this.setState({ focusedInput: false });
} else if (!startDate && !endDate) {
this.setSearchDataBatch(
{
studyDateFrom: null,
studyDateTo: null,
},
this.search
);
this.setSearchDataBatch({
studyDateFrom: null,
studyDateTo: null,
});
}
}}
focusedInput={this.state.focusedInput}
@ -398,26 +462,18 @@ class StudyList extends Component {
})}
</tr>
</thead>
<tbody id="studyListData">
{this.props.studies.map(study => {
return this.renderTableRow(study);
})}
</tbody>
<tbody id="studyListData">{tableBody}</tbody>
</table>
{this.renderIsLoading()}
{this.renderHasError()}
{this.renderNoMachingResults()}
<PaginationArea
pageOptions={this.props.pageOptions}
currentPage={this.state.searchData.currentPage}
nextPageFunc={this.nextPage}
prevPageFunc={this.prevPage}
onRowsPerPageChange={this.onRowsPerPageChange}
rowsPerPage={this.state.searchData.rowsPerPage}
recordCount={this.props.studies.length}
/>
{noListFragment
? noListFragment
: getPaginationFragment(
this.props,
this.state.searchData,
this.nextPage,
this.prevPage,
this.onRowsPerPageChange
)}
</div>
</div>
);

View File

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

View File

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

View File

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

View File

@ -35,8 +35,8 @@ export function initCommonElementsAliases() {
//Creating aliases for Routes
export function initRouteAliases() {
cy.server();
cy.route('GET', '/**/series/**').as('getStudySeries');
cy.route('GET', '/**/studies/**').as('getStudies');
cy.route('GET', '**/series**').as('getStudySeries');
cy.route('GET', '**/studies**').as('getStudies');
}
//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
*/
Cypress.Commands.add('openStudy', patientName => {
cy.initRouteAliases();
cy.visit('/');
cy.wrap(null).then(() => {
return new Cypress.Promise((resolve, reject) => {
cy.get('#patientName').type(patientName);
setTimeout(() => {
cy.get('#studyListData')
.contains(patientName)
.first()
.click();
resolve();
}, 2000);
});
});
cy.openStudyList();
cy.get('#patientName').type(patientName);
cy.wait('@getStudies');
cy.get('#studyListData .studylistStudy', { timeout: 5000 })
.contains(patientName)
.first().click();
});
/**
@ -82,6 +74,12 @@ Cypress.Commands.add('isPageLoaded', (url = '/viewer/') => {
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.
* Example of usage: cy.get(element-to-be-dragged).drag(dropzone-element)

View File

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

View File

@ -8,6 +8,7 @@ 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';
@ -20,8 +21,9 @@ import AppContext from '../context/AppContext';
class StudyListWithData extends Component {
static contextType = AppContext;
state = {
searchData: {},
searchOutdated: true,
studies: [],
searchingStudies: false,
error: null,
modalComponentId: null,
};
@ -62,6 +64,7 @@ class StudyListWithData extends Component {
if (!this.props.server && appConfig.enableGoogleCloudAdapter) {
this.setState({
modalComponentId: 'DicomStorePicker',
searchOutdated: false,
});
} else {
this.searchForStudies({
@ -71,21 +74,33 @@ class StudyListWithData extends Component {
}
}
componentDidUpdate(prevProps) {
if (!this.state.searchData && !this.state.studies) {
this.searchForStudies();
}
if (this.props.server !== prevProps.server) {
this.setState({
modalComponentId: null,
searchData: null,
studies: null,
});
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 { server = {} } = this.props;
const filter = {
patientId: searchData.patientId,
patientName: searchData.patientName,
@ -105,7 +120,9 @@ class StudyListWithData extends Component {
// TODO: add sorting
const promise = OHIF.studies.searchStudies(server, filter);
// Render the viewer when the data is ready
this.setState({
searchingStudies: true,
});
promise
.then(studies => {
if (!studies) {
@ -150,11 +167,15 @@ class StudyListWithData extends Component {
this.setState({
studies: sortedStudies,
searchingStudies: false,
searchOutdated: false,
});
})
.catch(error => {
this.setState({
error: true,
searchingStudies: false,
searchOutdated: false,
});
throw new Error(error);
@ -208,8 +229,6 @@ class StudyListWithData extends Component {
if (this.state.error) {
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;
@ -240,8 +259,9 @@ class StudyListWithData extends Component {
const studyList = (
<div className="paginationArea">
{this.state.studies ? (
{this.state.studies || this.state.searchingStudies ? (
<StudyList
loading={this.state.searchingStudies}
studies={this.state.studies}
studyListFunctionsEnabled={this.props.studyListFunctionsEnabled}
onImport={this.onImport}