feat: 🎸 Improve usability of Google Cloud adapter, including direct routes to studies (#989)

This commit is contained in:
ladeirarodolfo 2019-10-11 04:59:38 -03:00 committed by Erik Ziegler
parent c6f306c0de
commit 2bc361cdca
18 changed files with 413 additions and 138 deletions

View File

@ -1,11 +1,13 @@
import actions from './actions.js';
import reducers from './reducers';
import localStorage from './localStorage.js';
import sessionStorage from './sessionStorage.js';
const redux = {
reducers,
actions,
localStorage,
sessionStorage,
};
export default redux;

View File

@ -2,7 +2,7 @@ import redux from './index.js';
describe('redux exports', () => {
test('have not changed', () => {
const expectedExports = ['actions', 'reducers', 'localStorage'].sort();
const expectedExports = ['actions', 'reducers', 'localStorage', 'sessionStorage'].sort();
const exports = Object.keys(redux).sort();

View File

@ -1,6 +1,8 @@
const LocalStorageApi = window.localStorage;
const localStorageKey = 'state';
export const loadState = () => {
try {
const serializedState = window.localStorage.getItem('state');
const serializedState = LocalStorageApi.getItem(localStorageKey);
if (!serializedState) {
return undefined;
}
@ -14,7 +16,7 @@ export const loadState = () => {
export const saveState = state => {
try {
const serializedState = JSON.stringify(state);
localStorage.setItem('state', serializedState);
LocalStorageApi.setItem(localStorageKey, serializedState);
} catch (e) {}
};

View File

@ -0,0 +1,28 @@
const SessionStorageApi = window.sessionStorage;
const sessionStorageKey = 'state';
export const loadState = () => {
try {
const serializedState = SessionStorageApi.getItem(sessionStorageKey);
if (!serializedState) {
return undefined;
}
return JSON.parse(serializedState);
} catch (e) {
return undefined;
}
};
export const saveState = state => {
try {
const serializedState = JSON.stringify(state);
SessionStorageApi.setItem(sessionStorageKey, serializedState);
} catch (e) {}
};
const sessionStorage = {
saveState,
loadState,
};
export default sessionStorage;

View File

@ -9,6 +9,7 @@ import studyMetadataManager from './studyMetadataManager';
import updateMetaDataManager from './updateMetaDataManager.js';
import writeScript from './writeScript.js';
import DicomLoaderService from './dicomLoaderService.js';
import * as urlUtil from './urlUtil';
const utils = {
guid,
@ -23,6 +24,7 @@ const utils = {
updateMetaDataManager,
DICOMTagDescriptions,
DicomLoaderService,
urlUtil,
};
export {
@ -38,6 +40,7 @@ export {
updateMetaDataManager,
DICOMTagDescriptions,
DicomLoaderService,
urlUtil,
};
export default utils;

View File

@ -15,6 +15,7 @@ describe('Top level exports', () => {
'updateMetaDataManager',
'DICOMTagDescriptions',
'DicomLoaderService',
'urlUtil'
].sort();
const exports = Object.keys(utils.default).sort();

View File

@ -0,0 +1,67 @@
import lib from 'query-string';
const PARAM_SEPARATOR = ';';
const PARAM_PATTERN_IDENTIFIER = ':';
function toLowerCaseFirstLetter(word) {
return word[0].toLowerCase() + word.slice(1);
}
const getFilters = (location = {}) => {
const { search } = location;
if (!search) {
return;
}
const searchParameters = parse(search);
const filters = {};
Object.entries(searchParameters).forEach(([key, value]) => {
filters[toLowerCaseFirstLetter(key)] = value;
});
return filters;
};
const decode = (strToDecode = '') => {
try {
const decoded = window.atob(strToDecode);
return decoded;
} catch (e) {
return strToDecode;
}
};
const parse = toParse => {
if (toParse) {
return lib.parse(toParse);
}
return {};
};
const parseParam = paramStr => {
const _paramDecoded = decode(paramStr);
if (_paramDecoded && typeof _paramDecoded === 'string') {
return _paramDecoded.split(PARAM_SEPARATOR);
}
};
const replaceParam = (path = '', paramKey, paramValue) => {
const paramPattern = `${PARAM_PATTERN_IDENTIFIER}${paramKey}`;
if (paramValue) {
return path.replace(paramPattern, paramValue);
}
return path;
};
const queryString = {
getQueryFilters: getFilters,
};
const paramString = {
parseParam: parseParam,
replaceParam: replaceParam,
};
export { parse, queryString, paramString };

View File

@ -8,44 +8,18 @@ import { connect } from 'react-redux';
import { ViewerbaseDragDropContext } from '@ohif/ui';
import { SignoutCallbackComponent } from 'redux-oidc';
import asyncComponent from './components/AsyncComponent.js';
import * as RoutesUtil from './routes/routesUtil';
import NotFound from './routes/NotFound.js';
import { Bar, Container } from './components/LoadingBar/';
import './OHIFStandaloneViewer.css';
import './variables.css';
import './theme-tide.css';
// Contexts
import AppContext from './context/AppContext';
// Dynamic Import Routes (CodeSplitting)
const IHEInvokeImageDisplay = asyncComponent(() =>
import(
/* webpackChunkName: "IHEInvokeImageDisplay" */ './routes/IHEInvokeImageDisplay.js'
)
);
const ViewerRouting = asyncComponent(() =>
import(/* webpackChunkName: "ViewerRouting" */ './routes/ViewerRouting.js')
);
const StudyListRouting = asyncComponent(() =>
import(
/* webpackChunkName: "StudyListRouting" */ './studylist/StudyListRouting.js'
)
);
const StandaloneRouting = asyncComponent(() =>
import(
/* webpackChunkName: "StandaloneRouting" */ './routes/StandaloneRouting.js'
)
);
const CallbackPage = asyncComponent(() =>
import(/* webpackChunkName: "CallbackPage" */ './routes/CallbackPage.js')
);
const ViewerLocalFileData = asyncComponent(() =>
import(
/* webpackChunkName: "ViewerLocalFileData" */ './connectedComponents/ViewerLocalFileData.js'
)
);
const reload = () => window.location.reload();
class OHIFStandaloneViewer extends Component {
static contextType = AppContext;
@ -86,7 +60,11 @@ class OHIFStandaloneViewer extends Component {
return (
<Switch>
<Route exact path="/silent-refresh.html" onEnter={reload} />
<Route
exact
path="/silent-refresh.html"
onEnter={RoutesUtil.reload}
/>
<Route
exact
path="/logout-redirect"
@ -128,41 +106,7 @@ class OHIFStandaloneViewer extends Component {
*
* See http://reactcommunity.org/react-transition-group/with-react-router/
*/
const routes = [
{
path: '/local',
Component: ViewerLocalFileData,
},
{
path: '/viewer',
Component: StandaloneRouting,
},
{
path: '/viewer/:studyInstanceUids',
Component: ViewerRouting,
},
{
path: '/study/:studyInstanceUids/series/:seriesInstanceUids',
Component: ViewerRouting,
},
{
path: '/IHEInvokeImageDisplay',
Component: IHEInvokeImageDisplay,
},
];
const showStudyList =
appConfig.showStudyList !== undefined ? appConfig.showStudyList : true;
if (showStudyList) {
routes.push({
path: '/studylist',
Component: StudyListRouting,
});
routes.push({
path: '/',
Component: StudyListRouting,
});
}
const routes = RoutesUtil.getRoutes(appConfig);
const currentPath = this.props.location.pathname;
const noMatchingRoutes = !routes.find(r =>
@ -184,8 +128,8 @@ class OHIFStandaloneViewer extends Component {
</Container>
)}
</NProgress>
<Route exact path="/silent-refresh.html" onEnter={reload} />
<Route exact path="/logout-redirect.html" onEnter={reload} />
<Route exact path="/silent-refresh.html" onEnter={RoutesUtil.reload} />
<Route exact path="/logout-redirect.html" onEnter={RoutesUtil.reload} />
{!noMatchingRoutes &&
routes.map(({ path, Component }) => (
<Route key={path} exact path={path}>

View File

@ -0,0 +1,77 @@
import React, { useContext } from 'react';
import GoogleCloudApi from '../googleCloud/api/GoogleCloudApi';
import * as GoogleCloudUtilServers from '../googleCloud/utils/getServers';
import { useSelector, useDispatch } from 'react-redux';
// Contexts
import AppContext from '../context/AppContext';
const getActiveServer = servers => {
const isActive = a => a.active === true;
return servers && servers.servers && servers.servers.find(isActive);
};
const getServers = (appConfig, project, location, dataset, dicomStore) => {
let servers = [];
if (appConfig.enableGoogleCloudAdapter) {
const pathUrl = GoogleCloudApi.getUrlBaseDicomWeb(
project,
location,
dataset,
dicomStore
);
const data = {
project,
location,
dataset,
dicomStore,
wadoUriRoot: pathUrl,
qidoRoot: pathUrl,
wadoRoot: pathUrl,
};
servers = GoogleCloudUtilServers.getServers(data, dicomStore);
}
return servers;
};
const updateServer = (
appConfig,
dispatch,
project,
location,
dataset,
dicomStore
) => {
const servers = getServers(appConfig, project, location, dataset, dicomStore);
if (servers && servers.length) {
const action = {
type: 'SET_SERVERS',
servers,
};
dispatch(action);
}
};
export default function useServer({
project,
location,
dataset,
dicomStore,
} = {}) {
// Hooks
const servers = useSelector(state => state && state.servers);
const dispatch = useDispatch();
const { appConfig = {} } = useContext(AppContext);
const server = getActiveServer(servers);
if (!server) {
updateServer(appConfig, dispatch, project, location, dataset, dicomStore);
} else {
return server;
}
}

View File

@ -4,6 +4,7 @@ import Modal from 'react-bootstrap-modal';
import DatasetSelector from './DatasetSelector';
import './googleCloud.css';
import { withTranslation } from 'react-i18next';
import * as GoogleCloudUtilServers from './utils/getServers';
class DicomStorePickerModal extends Component {
static propTypes = {
@ -19,21 +20,7 @@ class DicomStorePickerModal extends Component {
};
handleEvent = data => {
const servers = [
{
name: data.dicomStore,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
qidoSupportsIncludeField: false,
type: 'dicomWeb',
qidoRoot: data.qidoRoot,
wadoRoot: data.wadoRoot,
wadoUriRoot: data.wadoUriRoot,
active: true,
supportsFuzzyMatching: false,
},
];
const servers = GoogleCloudUtilServers.getServers(data, data.dicomstore);
this.props.setServers(servers);
};

View File

@ -22,6 +22,13 @@ class GoogleCloudApi {
return this.urlBase + `/projects`;
}
getUrlBaseDicomWeb(project, location, dataset, dicomStore) {
return (
this.urlBase +
`/projects/${project}/locations/${location}/datasets/${dataset}/dicomStores/${dicomStore}/dicomWeb`
);
}
async doRequest(urlStr, config = {}, params = {}) {
const url = new URL(urlStr);
let data = null;

View File

@ -0,0 +1,32 @@
const getServers = (data, name) => {
const {
wadoUriRoot,
qidoRoot,
wadoRoot,
dataset = '',
dicomStore = '',
location = '',
project = '',
} = data;
return [
{
name: name,
dataset,
dicomStore,
location,
project,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
type: 'dicomWeb',
active: true,
wadoUriRoot,
qidoRoot,
wadoRoot,
supportsFuzzyMatching: false,
qidoSupportsIncludeField: false,
},
];
};
export { getServers };

View File

@ -1,29 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import queryString from 'query-string';
import ConnectedViewerRetrieveStudyData from '../connectedComponents/ConnectedViewerRetrieveStudyData.js';
function decodeStudyUids(studyUids) {
const decodedData = window.atob(studyUids);
return decodedData.split(';');
}
function getQueryParameters(location) {
if (location) {
return queryString.parse(location.search);
}
return {};
}
import OHIF from '@ohif/core';
const { urlUtil: UrlUtil } = OHIF.utils;
function IHEInvokeImageDisplay({ location }) {
const {
// patientID,
requestType,
studyUID,
} = getQueryParameters(location);
} = UrlUtil.parse(location.search);
switch (requestType) {
case 'STUDY':
@ -36,7 +23,7 @@ function IHEInvokeImageDisplay({ location }) {
case 'STUDYBASE64':
return (
<ConnectedViewerRetrieveStudyData
studyInstanceUids={decodeStudyUids(studyUID)}
studyInstanceUids={UrlUtil.paramString.parseParam(studyUID)}
/>
);

View File

@ -1,26 +1,47 @@
import React from 'react';
import PropTypes from 'prop-types';
import ConnectedViewerRetrieveStudyData from '../connectedComponents/ConnectedViewerRetrieveStudyData';
import useServer from '../customHooks/useServer';
import OHIF from '@ohif/core';
const { urlUtil: UrlUtil } = OHIF.utils;
function ViewerRouting({ match }) {
const { studyInstanceUids, seriesInstanceUids } = match.params;
/**
* Get array of seriesUIDs from param or from queryString
* @param {*} seriesInstanceUIDs
* @param {*} location
*/
const getSeriesInstanceUIDs = (seriesInstanceUIDs, routeLocation) => {
const queryFilters = UrlUtil.queryString.getQueryFilters(routeLocation);
const querySeriesUIDs = queryFilters && queryFilters['SeriesInstanceUID'];
const _seriesInstanceUIDs = seriesInstanceUIDs || querySeriesUIDs;
let studyUIDs;
let seriesUIDs;
return UrlUtil.paramString.parseParam(_seriesInstanceUIDs);
};
if (studyInstanceUids && !seriesInstanceUids) {
studyUIDs = studyInstanceUids.split(';');
} else if (studyInstanceUids && seriesInstanceUids) {
studyUIDs = [studyInstanceUids];
seriesUIDs = match.params.seriesInstanceUids.split(';');
function ViewerRouting({ match: routeMatch, location: routeLocation }) {
const {
project,
location,
dataset,
dicomStore,
studyInstanceUids,
seriesInstanceUids,
} = routeMatch.params;
const server = useServer({ project, location, dataset, dicomStore });
const studyUIDs = UrlUtil.paramString.parseParam(studyInstanceUids);
const seriesUIDs = getSeriesInstanceUIDs(seriesInstanceUids, routeLocation);
if (server && studyUIDs) {
return (
<ConnectedViewerRetrieveStudyData
studyInstanceUids={studyUIDs}
seriesInstanceUids={seriesUIDs}
/>
);
}
return (
<ConnectedViewerRetrieveStudyData
studyInstanceUids={studyUIDs}
seriesInstanceUids={seriesUIDs}
/>
);
return null;
}
ViewerRouting.propTypes = {
@ -28,6 +49,10 @@ ViewerRouting.propTypes = {
params: PropTypes.shape({
studyInstanceUids: PropTypes.string.isRequired,
seriesInstanceUids: PropTypes.string,
dataset: PropTypes.string,
dicomStore: PropTypes.string,
location: PropTypes.string,
project: PropTypes.string,
}),
}),
};

View File

@ -0,0 +1,114 @@
import asyncComponent from '../components/AsyncComponent.js';
import OHIF from '@ohif/core';
const { urlUtil: UrlUtil } = OHIF.utils;
// Dynamic Import Routes (CodeSplitting)
const IHEInvokeImageDisplay = asyncComponent(() =>
import(
/* webpackChunkName: "IHEInvokeImageDisplay" */ './IHEInvokeImageDisplay.js'
)
);
const ViewerRouting = asyncComponent(() =>
import(/* webpackChunkName: "ViewerRouting" */ './ViewerRouting.js')
);
const StudyListRouting = asyncComponent(() =>
import(
/* webpackChunkName: "StudyListRouting" */ '../studylist/StudyListRouting.js'
)
);
const StandaloneRouting = asyncComponent(() =>
import(/* webpackChunkName: "StandaloneRouting" */ './StandaloneRouting.js')
);
const ViewerLocalFileData = asyncComponent(() =>
import(
/* webpackChunkName: "ViewerLocalFileData" */ '../connectedComponents/ViewerLocalFileData.js'
)
);
const reload = () => window.location.reload();
const ROUTES_DEF = {
default: {
viewer: {
path: '/viewer/:studyInstanceUids',
component: ViewerRouting,
},
standaloneViewer: {
path: '/viewer',
component: StandaloneRouting,
},
list: {
path: ['/studylist', '/'],
component: StudyListRouting,
condition: appConfig => {
return appConfig.showStudyList !== undefined
? appConfig.showStudyList
: true;
},
},
local: {
path: '/local',
component: ViewerLocalFileData,
},
IHEInvokeImageDisplay: {
path: '/IHEInvokeImageDisplay',
},
},
gcloud: {
viewer: {
path:
'/projects/:project/locations/:location/datasets/:dataset/dicomStores/:dicomStore/study/:studyInstanceUids',
component: ViewerRouting,
condition: appConfig => {
return !!appConfig.enableGoogleCloudAdapter;
},
},
},
};
const getRoutes = appConfig => {
const routes = [];
for (let keyConfig in ROUTES_DEF) {
const routesConfig = ROUTES_DEF[keyConfig];
for (let routeKey in routesConfig) {
const route = routesConfig[routeKey];
const validRoute =
typeof route.condition === 'function'
? route.condition(appConfig)
: true;
if (validRoute) {
routes.push({
path: route.path,
Component: route.component,
});
}
}
}
return routes;
};
const parseViewerPath = (appConfig = {}, server = {}, params) => {
let viewerPath = ROUTES_DEF.default.viewer.path;
if (appConfig.enableGoogleCloudAdapter) {
viewerPath = ROUTES_DEF.gcloud.viewer.path;
}
const _paramsCopy = Object.assign({}, server, params);
for (let key in _paramsCopy) {
viewerPath = UrlUtil.paramString.replaceParam(
viewerPath,
key,
_paramsCopy[key]
);
}
return viewerPath;
};
export { getRoutes, parseViewerPath, reload };

View File

@ -13,7 +13,7 @@ import thunkMiddleware from 'redux-thunk';
// Combine our @ohif/core, ui, and oidc reducers
// Set init data, using values found in localStorage
const { reducers, localStorage } = redux;
const { reducers, localStorage, sessionStorage } = redux;
const middleware = [thunkMiddleware];
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
@ -21,9 +21,14 @@ reducers.ui = layoutReducers;
reducers.oidc = oidcReducer;
const rootReducer = combineReducers(reducers);
const preloadedState = {
...localStorage.loadState(),
...sessionStorage.loadState(),
};
const store = createStore(
rootReducer,
localStorage.loadState(), // preloadedState
preloadedState,
composeEnhancers(applyMiddleware(...middleware))
);
@ -33,6 +38,9 @@ store.subscribe(() => {
localStorage.saveState({
preferences: store.getState().preferences,
});
sessionStorage.saveState({
servers: store.getState().servers,
});
});
export default store;

View File

@ -1,33 +1,18 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import queryString from 'query-string';
import ConnectedStudyList from './ConnectedStudyList';
import OHIF from '@ohif/core';
const { urlUtil: UrlUtil } = OHIF.utils;
// Contexts
import AppContext from '../context/AppContext';
// TODO: Move to @ohif/ui
function toLowerCaseFirstLetter(word) {
return word[0].toLowerCase() + word.slice(1);
}
function getFilters({ search }) {
const searchParameters = queryString.parse(search);
const filters = {};
Object.entries(searchParameters).forEach(([key, value]) => {
filters[toLowerCaseFirstLetter(key)] = value;
});
return filters;
}
function StudyListRouting({ location }) {
const { appConfig = {} } = useContext(AppContext);
const filters = location ? getFilters(location) : undefined;
const filters = UrlUtil.queryString.getQueryFilters(location);
let studyListFunctionsEnabled = false;
if (appConfig.studyListFunctionsEnabled) {

View File

@ -6,6 +6,7 @@ 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 ConnectedDicomFilesUploader from '../googleCloud/ConnectedDicomFilesUploader';
import ConnectedDicomStorePicker from '../googleCloud/ConnectedDicomStorePicker';
@ -175,7 +176,12 @@ class StudyListWithData extends Component {
};
onSelectItem = studyInstanceUID => {
this.props.history.push(`/viewer/${studyInstanceUID}`);
const { appConfig = {} } = this.context;
const { server } = this.props;
const viewerPath = RoutesUtil.parseViewerPath(appConfig, server, {
studyInstanceUids: studyInstanceUID,
});
this.props.history.push(viewerPath);
};
onSearch = searchData => {