feat(cloud data source config): GUI and API for configuring a cloud data source with Google cloud healthcare implementation (#3589)

This commit is contained in:
Joe Boccanfuso 2023-08-25 14:19:17 -04:00 committed by GitHub
parent a2ef2b0fcb
commit a336992971
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1155 additions and 56 deletions

View File

@ -18,7 +18,9 @@ export default function _getStatusComponent({ isHydrated, onStatusClick }) {
);
break;
case false:
StatusIcon = () => <Icon name="status-untracked" />;
StatusIcon = () => (
<Icon className="text-aqua-pale" name="status-untracked" />
);
ToolTipMessage = () => <div>Click LOAD to load RTSTRUCT.</div>;
}

View File

@ -18,7 +18,9 @@ export default function _getStatusComponent({ isHydrated, onStatusClick }) {
);
break;
case false:
StatusIcon = () => <Icon name="status-untracked" />;
StatusIcon = () => (
<Icon className="text-aqua-pale" name="status-untracked" />
);
ToolTipMessage = () => <div>Click LOAD to load segmentation.</div>;
}

View File

@ -511,7 +511,9 @@ function _getStatusComponent({
);
break;
case 3:
StatusIcon = () => <Icon name="status-untracked" />;
StatusIcon = () => (
<Icon className="text-aqua-pale" name="status-untracked" />
);
ToolTipMessage = () => (
<div>{`Click ${loadStr} to restore measurements.`}</div>

View File

@ -0,0 +1,109 @@
import React, { ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, useModal } from '@ohif/ui';
import { ExtensionManager, ServicesManager, Types } from '@ohif/core';
import DataSourceConfigurationModalComponent from './DataSourceConfigurationModalComponent';
type DataSourceConfigurationComponentProps = {
servicesManager: ServicesManager;
extensionManager: ExtensionManager;
};
function DataSourceConfigurationComponent({
servicesManager,
extensionManager,
}: DataSourceConfigurationComponentProps): ReactElement {
const { t } = useTranslation('DataSourceConfiguration');
const { show, hide } = useModal();
const { customizationService } = servicesManager.services;
const [configurationAPI, setConfigurationAPI] = useState<
Types.BaseDataSourceConfigurationAPI
>();
const [configuredItems, setConfiguredItems] = useState<
Array<Types.BaseDataSourceConfigurationAPIItem>
>();
useEffect(() => {
let shouldUpdate = true;
const dataSourceChangedCallback = async () => {
const activeDataSourceDef = extensionManager.getActiveDataSourceDefinition();
if (!activeDataSourceDef.configuration.configurationAPI) {
return;
}
const { factory: configurationAPIFactory } =
customizationService.get(
activeDataSourceDef.configuration.configurationAPI
) ?? {};
if (!configurationAPIFactory) {
return;
}
const configAPI = configurationAPIFactory(activeDataSourceDef.sourceName);
setConfigurationAPI(configAPI);
configAPI.getConfiguredItems().then(list => {
if (shouldUpdate) {
setConfiguredItems(list);
}
});
};
const sub = extensionManager.subscribe(
extensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED,
dataSourceChangedCallback
);
dataSourceChangedCallback();
return () => {
shouldUpdate = false;
sub.unsubscribe();
};
}, []);
return configuredItems ? (
<div className="flex text-aqua-pale overflow-hidden items-center">
<Icon
name="settings"
className="cursor-pointer shrink-0 w-3.5 h-3.5 mr-2.5"
onClick={() =>
show({
content: DataSourceConfigurationModalComponent,
title: t('Configure Data Source'),
contentProps: {
configurationAPI,
configuredItems,
onHide: hide,
},
})
}
></Icon>
{configuredItems.map((item, itemIndex) => {
return (
<div key={itemIndex} className="flex overflow-hidden">
<div
key={itemIndex}
className="text-ellipsis whitespace-nowrap overflow-hidden"
>
{item.name}
</div>
{itemIndex !== configuredItems.length - 1 && (
<div className="px-2.5">|</div>
)}
</div>
);
})}
</div>
) : (
<></>
);
}
export default DataSourceConfigurationComponent;

View File

@ -0,0 +1,209 @@
import classNames from 'classnames';
import React, { ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '@ohif/ui';
import { Types } from '@ohif/core';
import ItemListComponent from './ItemListComponent';
const NO_WRAP_ELLIPSIS_CLASS_NAMES =
'text-ellipsis whitespace-nowrap overflow-hidden';
type DataSourceConfigurationModalComponentProps = {
configurationAPI: Types.BaseDataSourceConfigurationAPI;
configuredItems: Array<Types.BaseDataSourceConfigurationAPIItem>;
onHide: () => void;
};
function DataSourceConfigurationModalComponent({
configurationAPI,
configuredItems,
onHide,
}: DataSourceConfigurationModalComponentProps) {
const { t } = useTranslation('DataSourceConfiguration');
const [itemList, setItemList] = useState<
Array<Types.BaseDataSourceConfigurationAPIItem>
>();
const [selectedItems, setSelectedItems] = useState(configuredItems);
// Determines whether to show the full configuration for the data source.
// This typically occurs when the configuration component is first displayed.
const [showFullConfig, setShowFullConfig] = useState(true);
const [errorMessage, setErrorMessage] = useState<string>();
const [itemLabels] = useState(configurationAPI.getItemLabels());
/**
* The index of the selected item that is considered current and for which
* its sub-items should be displayed in the items list component. When the
* full/existing configuration for a data source is to be shown, the current
* selected item is the second to last in the `selectedItems` list.
*/
const currentSelectedItemIndex = showFullConfig
? selectedItems.length - 2
: selectedItems.length - 1;
useEffect(() => {
let shouldUpdate = true;
setErrorMessage(null);
// Clear out the former/old list while we fetch the next sub item list.
setItemList(null);
if (selectedItems.length === 0) {
configurationAPI
.initialize()
.then(items => {
if (shouldUpdate) {
setItemList(items);
}
})
.catch(error => setErrorMessage(error.message));
} else if (!showFullConfig && selectedItems.length === itemLabels.length) {
// The last item to configure the data source (path) has been selected.
configurationAPI.setCurrentItem(selectedItems[selectedItems.length - 1]);
// We can hide the modal dialog now.
onHide();
} else {
configurationAPI
.setCurrentItem(selectedItems[currentSelectedItemIndex])
.then(items => {
if (shouldUpdate) {
setItemList(items);
}
})
.catch(error => setErrorMessage(error.message));
}
return () => {
shouldUpdate = false;
};
}, [
selectedItems,
configurationAPI,
onHide,
itemLabels,
showFullConfig,
currentSelectedItemIndex,
]);
const getSelectedItemCursorClasses = itemIndex =>
itemIndex !== itemLabels.length - 1 && itemIndex < selectedItems.length
? 'cursor-pointer'
: 'cursor-auto';
const getSelectedItemBackgroundClasses = itemIndex =>
itemIndex < selectedItems.length
? classNames(
'bg-black/[.4]',
itemIndex !== itemLabels.length - 1
? 'hover:bg-transparent active:bg-secondary-dark'
: ''
)
: 'bg-transparent';
const getSelectedItemBorderClasses = itemIndex =>
itemIndex === currentSelectedItemIndex + 1
? classNames('border-2', 'border-solid', 'border-primary-light')
: itemIndex < selectedItems.length
? 'border border-solid border-primary-active hover:border-primary-light active:border-white'
: 'border border-dashed border-secondary-light';
const getSelectedItemTextClasses = itemIndex =>
itemIndex <= selectedItems.length
? 'text-primary-light'
: 'text-primary-active';
const getErrorComponent = (): ReactElement => {
return (
<div className="flex flex-col gap-4 min-h-[1px] grow">
<div className="text-primary-light text-[20px]">
{t(`Error fetching ${itemLabels[selectedItems.length]} list`)}
</div>
<div className="bg-black text-[14px] grow p-4">{errorMessage}</div>
</div>
);
};
const getSelectedItemsComponent = (): ReactElement => {
return (
<div className="flex gap-4">
{itemLabels.map((itemLabel, itemLabelIndex) => {
return (
<div
key={itemLabel}
className={classNames(
'rounded-md p-3.5 flex flex-col gap-1 shrink min-w-[1px] basis-[200px]',
getSelectedItemCursorClasses(itemLabelIndex),
getSelectedItemBackgroundClasses(itemLabelIndex),
getSelectedItemBorderClasses(itemLabelIndex),
getSelectedItemTextClasses(itemLabelIndex)
)}
onClick={
(showFullConfig && itemLabelIndex < currentSelectedItemIndex) ||
itemLabelIndex <= currentSelectedItemIndex
? () => {
setShowFullConfig(false);
setSelectedItems(theList =>
theList.slice(0, itemLabelIndex)
);
}
: undefined
}
>
<div className="flex gap-2 items-center text-">
{itemLabelIndex < selectedItems.length ? (
<Icon name="status-tracked" />
) : (
<Icon name="status-untracked" />
)}
<div className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES)}>
{t(itemLabel)}
</div>
</div>
{itemLabelIndex < selectedItems.length ? (
<div
className={classNames(
'text-white text-[14px]',
NO_WRAP_ELLIPSIS_CLASS_NAMES
)}
>
{selectedItems[itemLabelIndex].name}
</div>
) : (
<br></br>
)}
</div>
);
})}
</div>
);
};
return (
<div className="h-[calc(100vh-300px)] flex flex-col pt-0.5 gap-4 select-none">
{getSelectedItemsComponent()}
<div className="w-full h-0.5 shrink-0 bg-black"></div>
{errorMessage ? (
getErrorComponent()
) : (
<ItemListComponent
itemLabel={itemLabels[currentSelectedItemIndex + 1]}
itemList={itemList}
onItemClicked={item => {
setShowFullConfig(false);
setSelectedItems(theList => [
...theList.slice(0, currentSelectedItemIndex + 1),
item,
]);
}}
></ItemListComponent>
)}
</div>
);
}
export default DataSourceConfigurationModalComponent;

View File

@ -0,0 +1,93 @@
import classNames from 'classnames';
import React, { ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Icon,
InputFilterText,
LoadingIndicatorProgress,
} from '@ohif/ui';
import { Types } from '@ohif/core';
type ItemListComponentProps = {
itemLabel: string;
itemList: Array<Types.BaseDataSourceConfigurationAPIItem>;
onItemClicked: (item: Types.BaseDataSourceConfigurationAPIItem) => void;
};
function ItemListComponent({
itemLabel,
itemList,
onItemClicked,
}: ItemListComponentProps): ReactElement {
const { t } = useTranslation('DataSourceConfiguration');
const [filterValue, setFilterValue] = useState('');
useEffect(() => {
setFilterValue('');
}, [itemList]);
return (
<div className="flex flex-col gap-4 min-h-[1px] grow">
<div className="flex justify-between items-center">
<div className="text-primary-light text-[20px]">
{t(`Select ${itemLabel}`)}
</div>
<InputFilterText
className="grow max-w-[40%]"
value={filterValue}
onDebounceChange={setFilterValue}
placeholder={t(`Search ${itemLabel} list`)}
></InputFilterText>
</div>
<div className="flex flex-col relative min-h-[1px] grow text-[14px] bg-black">
{itemList == null ? (
<LoadingIndicatorProgress className={'w-full h-full'} />
) : itemList.length === 0 ? (
<div className="flex flex-col h-full px-6 py-4 items-center justify-center text-primary-light">
<Icon name="magnifier" className="mb-4" />
<span>{t(`No ${itemLabel} available`)}</span>
</div>
) : (
<>
<div className="bg-secondary-dark text-white px-3 py-1.5">
{t(itemLabel)}
</div>
<div className="overflow-auto ohif-scrollbar">
{itemList
.filter(
item =>
!filterValue ||
item.name.toLowerCase().includes(filterValue.toLowerCase())
)
.map(item => {
const border =
'rounded border-transparent border-b-secondary-light border-[1px] hover:border-primary-light';
return (
<div
className={classNames(
'group mx-2 px-6 py-2 flex justify-between items-center hover:text-primary-light hover:bg-primary-dark',
border
)}
key={item.id}
>
<div>{item.name}</div>
<Button
onClick={() => onItemClicked(item)}
className="invisible group-hover:visible"
endIcon={<Icon name="arrow-left" />}
>
{t('Select')}
</Button>
</div>
);
})}
</div>
</>
)}
</div>
</div>
);
}
export default ItemListComponent;

View File

@ -0,0 +1,234 @@
import { ExtensionManager, Types } from '@ohif/core';
/**
* This file contains the implementations of BaseDataSourceConfigurationAPIItem
* and BaseDataSourceConfigurationAPI for the Google cloud healthcare API. To
* better understand this implementation and/or to implement custom implementations,
* see the platform\core\src\types\DataSourceConfigurationAPI.ts and its JS doc
* comments as a guide.
*/
/**
* The various Google Cloud Healthcare path item types.
*/
enum ItemType {
projects = 0,
locations = 1,
datasets = 2,
dicomStores = 3,
}
interface NamedItem {
name: string;
}
interface Project extends NamedItem {
projectId: string;
}
const initialUrl = 'https://cloudresourcemanager.googleapis.com/v1';
const baseHealthcareUrl = 'https://healthcare.googleapis.com/v1';
class GoogleCloudDataSourceConfigurationAPIItem
implements Types.BaseDataSourceConfigurationAPIItem {
id: string;
name: string;
url: string;
itemType: ItemType;
}
class GoogleCloudDataSourceConfigurationAPI
implements Types.BaseDataSourceConfigurationAPI {
private _extensionManager: ExtensionManager;
private _fetchOptions: { method: string; headers: unknown };
private _dataSourceName: string;
constructor(dataSourceName, servicesManager, extensionManager) {
this._dataSourceName = dataSourceName;
this._extensionManager = extensionManager;
const userAuthenticationService =
servicesManager.services.userAuthenticationService;
this._fetchOptions = {
method: 'GET',
headers: userAuthenticationService.getAuthorizationHeader(),
};
}
getItemLabels = () => ['Project', 'Location', 'Data set', 'DICOM store'];
async initialize(): Promise<Types.BaseDataSourceConfigurationAPIItem[]> {
const url = `${initialUrl}/projects`;
const projects = (await GoogleCloudDataSourceConfigurationAPI._doFetch(
url,
ItemType.projects,
this._fetchOptions
)) as Array<Project>;
if (!projects?.length) {
return [];
}
const projectItems = projects.map(project => {
return {
id: project.projectId,
name: project.name,
itemType: ItemType.projects,
url: `${baseHealthcareUrl}/projects/${project.projectId}`,
};
});
return projectItems;
}
async setCurrentItem(
anItem: Types.BaseDataSourceConfigurationAPIItem
): Promise<Types.BaseDataSourceConfigurationAPIItem[]> {
const googleCloudItem = anItem as GoogleCloudDataSourceConfigurationAPIItem;
if (googleCloudItem.itemType === ItemType.dicomStores) {
// Last configurable item, so update the data source configuration.
const url = `${googleCloudItem.url}/dicomWeb`;
const dataSourceDefCopy = JSON.parse(
JSON.stringify(
this._extensionManager.getDataSourceDefinition(this._dataSourceName)
)
);
dataSourceDefCopy.configuration = {
...dataSourceDefCopy.configuration,
wadoUriRoot: url,
qidoRoot: url,
wadoRoot: url,
};
this._extensionManager.updateDataSourceConfiguration(
dataSourceDefCopy.sourceName,
dataSourceDefCopy.configuration
);
return [];
}
const subItemType = googleCloudItem.itemType + 1;
const subItemField = `${ItemType[subItemType]}`;
const url = `${googleCloudItem.url}/${subItemField}`;
const fetchedSubItems = await GoogleCloudDataSourceConfigurationAPI._doFetch(
url,
subItemType,
this._fetchOptions
);
if (!fetchedSubItems?.length) {
return [];
}
const subItems = fetchedSubItems.map(subItem => {
const nameSplit = subItem.name.split('/');
return {
id: subItem.name,
name: nameSplit[nameSplit.length - 1],
itemType: subItemType,
url: `${baseHealthcareUrl}/${subItem.name}`,
};
});
return subItems;
}
async getConfiguredItems(): Promise<
Array<GoogleCloudDataSourceConfigurationAPIItem>
> {
const dataSourceDefinition = this._extensionManager.getDataSourceDefinition(
this._dataSourceName
);
const url = dataSourceDefinition.configuration.wadoUriRoot;
const projectsIndex = url.indexOf('projects');
const urlSplit = url.substring(projectsIndex).split('/');
const configuredItems = [];
for (let itemType = 0; itemType < 4; itemType += 1) {
if (itemType === ItemType.projects) {
const projectId = urlSplit[1];
const projectUrl = `${initialUrl}/projects/${projectId}`;
const data = await GoogleCloudDataSourceConfigurationAPI._doFetch(
projectUrl,
ItemType.projects,
this._fetchOptions
);
const project = data[0] as Project;
configuredItems.push({
id: project.projectId,
name: project.name,
itemType: itemType,
url: `${baseHealthcareUrl}/projects/${project.projectId}`,
});
} else {
const relativePath = urlSplit.slice(0, itemType * 2 + 2).join('/');
configuredItems.push({
id: relativePath,
name: urlSplit[itemType * 2 + 1],
itemType: itemType,
url: `${baseHealthcareUrl}/${relativePath}`,
});
}
}
return configuredItems;
}
/**
* Fetches an array of items the specified item type.
* @param urlStr the fetch url
* @param fetchItemType the type to fetch
* @param fetchOptions the header options for the fetch (e.g. authorization header)
* @param fetchSearchParams any search query params; currently only used for paging results
* @returns an array of items of the specified type
*/
private static async _doFetch(
urlStr: string,
fetchItemType: ItemType,
fetchOptions = {},
fetchSearchParams: Record<string, string> = {}
): Promise<Array<Project> | Array<NamedItem>> {
try {
const url = new URL(urlStr);
url.search = new URLSearchParams(fetchSearchParams).toString();
const response = await fetch(url, fetchOptions);
const data = await response.json();
if (response.status >= 200 && response.status < 300 && data != null) {
if (data.nextPageToken != null) {
fetchSearchParams.pageToken = data.nextPageToken;
const subPageData = await this._doFetch(
urlStr,
fetchItemType,
fetchOptions,
fetchSearchParams
);
data[ItemType[fetchItemType]] = data[ItemType[fetchItemType]].concat(
subPageData
);
}
if (data[ItemType[fetchItemType]]) {
return data[ItemType[fetchItemType]];
} else if (data.name) {
return [data];
} else {
return [];
}
} else {
const message =
data?.error?.message ||
`Error returned from Google Cloud Healthcare: ${response.status} - ${response.statusText}`;
throw new Error(message);
}
} catch (err) {
const message = err?.message || 'Error occurred during fetch request.';
throw new Error(message);
}
}
}
export { GoogleCloudDataSourceConfigurationAPI };

View File

@ -1,10 +1,9 @@
import dcmjs from 'dcmjs';
import moment from 'moment';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { classes } from '@ohif/core';
import { Icon, InputRange, Select, Typography } from '@ohif/ui';
import { InputRange, Select, Typography, InputFilterText } from '@ohif/ui';
import debounce from 'lodash.debounce';
import classNames from 'classnames';
import DicomTagTable from './DicomTagTable';
import './DicomTagBrowser.css';
@ -34,8 +33,6 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
setInstanceNumber(1);
};
const searchInputRef = useRef(null);
const activeDisplaySet = displaySets.find(
ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID
);
@ -158,33 +155,11 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
</div>
<div className="w-full h-1 bg-black"></div>
<div className="flex flex-row my-3 w-1/2">
{/* TODO - refactor the following into its own reusable component */}
<label className="relative block w-full mr-8">
<span className="absolute inset-y-0 left-0 flex items-center pl-2">
<Icon name="icon-search"></Icon>
</span>
<input
ref={searchInputRef}
type="text"
className="block bg-black w-full shadow transition duration-300 appearance-none border border-inputfield-main focus:border-inputfield-focus focus:outline-none disabled:border-inputfield-disabled rounded w-full py-2 px-9 text-base leading-tight placeholder:text-inputfield-placeholder"
placeholder="Search metadata..."
onChange={event => debouncedSetFilterValue(event.target.value)}
autoComplete="off"
></input>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
<Icon
name="icon-clear-field"
className={classNames(
'cursor-pointer',
filterValue ? '' : 'hidden'
)}
onClick={() => {
searchInputRef.current.value = '';
debouncedSetFilterValue('');
}}
></Icon>
</span>
</label>
<InputFilterText
className="block w-full mr-8"
placeholder="Search metadata..."
onDebounceChange={setFilterValue}
></InputFilterText>
</div>
<DicomTagTable rows={filteredRows} />
</div>

View File

@ -1,6 +1,8 @@
import { CustomizationService } from '@ohif/core';
import React from 'react';
import DataSourceSelector from './Panels/DataSourceSelector';
import DataSourceConfigurationComponent from './Components/DataSourceConfigurationComponent';
import { GoogleCloudDataSourceConfigurationAPI } from './DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI';
/**
*
@ -11,7 +13,10 @@ import DataSourceSelector from './Panels/DataSourceSelector';
* custom page for the user to view their profile, or to add a custom
* page for login etc.
*/
export default function getCustomizationModule() {
export default function getCustomizationModule({
servicesManager,
extensionManager,
}) {
return [
{
name: 'helloPage',
@ -136,6 +141,26 @@ export default function getCustomizationModule() {
return clonedObject;
},
},
{
// the generic GUI component to configure a data source using an instance of a BaseDataSourceConfigurationAPI
id: 'ohif.dataSourceConfigurationComponent',
component: DataSourceConfigurationComponent.bind(null, {
servicesManager,
extensionManager,
}),
},
{
// The factory for creating an instance of a BaseDataSourceConfigurationAPI for Google Cloud Healthcare
id: 'ohif.dataSourceConfigurationAPI.google',
factory: (dataSourceName: string) =>
new GoogleCloudDataSourceConfigurationAPI(
dataSourceName,
servicesManager,
extensionManager
),
},
],
},
];

View File

@ -384,7 +384,7 @@ function _getStatusComponent(isTracked) {
</div>
}
>
<Icon name={trackedIcon} className="text-primary-light" />
<Icon name={trackedIcon} className="text-aqua-pale" />
</Tooltip>
</div>
);

View File

@ -55,6 +55,7 @@ window.config = {
supportsWildcard: false,
dicomUploadEnabled: true,
omitQuotationForMultipartRequest: true,
configurationAPI: 'ohif.dataSourceConfigurationAPI.google',
},
},
{

View File

@ -485,6 +485,9 @@ function WorkList({
}
: undefined;
const { component: dataSourceConfigurationComponent } =
customizationService.get('ohif.dataSourceConfigurationComponent') ?? {};
return (
<div className="bg-black h-screen flex flex-col ">
<Header
@ -502,6 +505,11 @@ function WorkList({
clearFilters={() => setFilterValues(defaultFilterValues)}
isFiltering={isFiltering(filterValues, defaultFilterValues)}
onUploadClick={uploadProps ? () => show(uploadProps) : undefined}
getDataSourceConfigurationComponent={
dataSourceConfigurationComponent
? () => dataSourceConfigurationComponent()
: undefined
}
/>
{hasStudies ? (
<div className="grow flex flex-col">

View File

@ -354,7 +354,7 @@ export default class ExtensionManager extends PubSubService {
* @param dataSourceName the data source name
* @returns the data source definition
*/
getDataSourceDef = dataSourceName => {
getDataSourceDefinition = dataSourceName => {
if (dataSourceName === undefined) {
// Default to the activeDataSource
dataSourceName = this.activeDataSource;
@ -363,6 +363,13 @@ export default class ExtensionManager extends PubSubService {
return this.dataSourceDefs[dataSourceName];
};
/**
* Gets the data source definition for the active data source.
*/
getActiveDataSourceDefinition = () => {
return this.getDataSourceDefinition(this.activeDataSource);
};
/**
* @private
* @param {string} moduleType

View File

@ -0,0 +1,68 @@
export interface BaseDataSourceConfigurationAPIItem {
id: string;
name: string;
}
/**
* The interface to use to configure an associated data source. Typically an
* instance of this interface is associated with a data source that the instance
* understands and can alter the data source's configuration.
*/
export interface BaseDataSourceConfigurationAPI {
/**
* Gets the i18n labels (i.e. the i18n lookup keys) for each of the configurable items
* of the data source configuration API.
* For example, for the Google Cloud Healthcare API, this would be
* ['Project', 'Location', 'Data set', 'DICOM store'].
* Besides the configurable item labels themselves, several other string look ups
* are used base on EACH of the labels returned by this method.
* For instance, for the label {itemLabel}, the following strings are fetched for
* translation...
* 1. `No {itemLabel} available`
* - used to indicate no such items are available
* - for example, for Google, `No Project available` would be 'No projects available'
* 2. `Select {itemLabel}`
* - used to direct selection of the item
* - for example, for Google, `Select Project` would be 'Select a project'
* 3. `Error fetching {itemLabel} list`
* - used to indicate an error occurred fetching the list of items
* - usually accompanied by the error itself
* - for example, for Google, `Error fetching Project list` would be 'Error fetching projects'
* 4. `Search {itemLabel} list`
* - used as the placeholder text for filtering a list of items
* - for example, for Google, `Search Project list` would be 'Search projects'
*/
getItemLabels(): Array<string>;
/**
* Initializes the data source configuration API and returns the top-level sub-items
* that can be chosen to begin the process of configuring the data source.
* For example, for the Google Cloud Healthcare API, this would perform the initial request
* to fetch the top level projects for the logged in user account.
*/
initialize(): Promise<Array<BaseDataSourceConfigurationAPIItem>>;
/**
* Sets the current path item and returns the sub-items of that item
* that can be further chosen to configure a data source.
* When setting the last configurable item of the data source (path), this method
* returns an empty list AND configures the active data source with the selected
* items path.
* For example, for the Google Cloud Healthcare API, this would take the current item
* (say a data set) and queries and returns its sub-items (i.e. all of the DICOM stores
* contained in that data set). Furthermore, whenever the item to set is a DICOM store,
* the Google Cloud Healthcare API implementation would update the OHIF data source
* associated with this instance to point to that DICOM store.
* @param item the item to set as current
*/
setCurrentItem(
item: BaseDataSourceConfigurationAPIItem
): Promise<Array<BaseDataSourceConfigurationAPIItem>>;
/**
* Gets the list of items currently configured for the data source associated with
* this API instance. The resultant array must be the same length as the result of
* `getItemLabels`.
*/
getConfiguredItems(): Promise<Array<BaseDataSourceConfigurationAPIItem>>;
}

View File

@ -3,6 +3,10 @@ import * as HangingProtocol from './HangingProtocol';
import Services from './Services';
import Hotkey from '../classes/Hotkey';
import { DataSourceDefinition } from './DataSource';
import {
BaseDataSourceConfigurationAPI,
BaseDataSourceConfigurationAPIItem,
} from './DataSourceConfigurationAPI';
export * from '../services/CustomizationService/types';
// Separate out some generic types
@ -19,4 +23,12 @@ export * from './Color';
* Export the types used within the various services and managers, but
* not the services/managers themselves, which are exported at the top level.
*/
export { Extensions, HangingProtocol, Services, Hotkey, DataSourceDefinition };
export {
Extensions,
HangingProtocol,
Services,
Hotkey,
DataSourceDefinition,
BaseDataSourceConfigurationAPI,
BaseDataSourceConfigurationAPIItem,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,172 @@
---
sidebar_position: 6
sidebar_label: Configuration UI
---
# Configuration UI
OHIF provides for a generic mechanism for configuring a data source. This is
most useful for those organizations with several data sources
that share common (path) hierarchies. For example, an organization may have several DICOM stores
in the Google Cloud Healthcare realm where each is organized into various projects,
location, data sets and DICOM stores.
By implementing the `BaseDataSourceConfigurationAPI` and
`BaseDataSourceConfigurationAPIItem` in an [OHIF extension](../../platform/extensions/index.md), a data source can
be made configurable via the generic UI as is depicted below for a
Google Cloud Healthcare data source.
![Data source configuration UI](../../assets/img/data-source-configuration-ui.png)
## `BaseDataSourceConfigurationAPIItem` interface
Each (path) item of a data source is represented by an instance of this interface.
At the very least each of these items must expose two properties:
|Property |Description|
|---------|-----------|
|id|a string that uniquely identifies the item|
|name|a human readable name for the item|
Note that information such as where in the path hierarchy the item exists
has been omitted, but can be added in any concrete class that might implement this
interface. For example, the the Google Cloud Healthcare implementation of this
interface (`GoogleCloudDataSourceConfigurationAPIItem`) adds an `itemType`
(i.e. projects, locations, datasets, or dicomStores) and `url`.
## `BaseDataSourceConfigurationAPI` interface
The implementation of this interface is at the heart of the configuration process.
It possesses several methods for building up a data source path based on various
`BaseDataSourceConfigurationAPIItem` objects that are set via calls to the `setCurrentItem`
method.
The constructor for the concrete class implementation should accept whatever
parameters are necessary for configuring the data source. One argument
to the constructor must be the string identifying the name of the data source
to be configured. Furthermore, considering that the `ExtensionManager` possesses
API to configure and update data sources, it too will likely be an argument to
the constructor. See [Creation via Customization Module](#creation-via-customization-module)
for more information on how the constructor is invoked via a factory method.
For an example implementation of this interface see `GoogleCloudDataSourceConfigurationAPI`.
### Interface Methods
Each of the following subsections lists a method of the interface with a description
detailing what the method should do.
#### `getItemLabels`
Gets the i18n labels (i.e. the i18n lookup keys) for each of the configurable items
of the data source configuration API. For example, for the Google Cloud Healthcare
API, this would be `['Project', 'Location', 'Data set', 'DICOM store']`.
Besides the configurable item labels themselves, several other string look ups
are used base on EACH of the labels returned by this method.
For instance, for the label `{itemLabel}``, the following strings are fetched for
translation...
1. `No {itemLabel} available`
- used to indicate no such items are available
- for example, for Google, `No Project available` would be 'No projects available'
2. `Select {itemLabel}`
- used to direct selection of the item
- for example, for Google, `Select Project` would be 'Select a project'
3. `Error fetching {itemLabel} list`
- used to indicate an error occurred fetching the list of items
- usually accompanied by the error itself
- for example, for Google, `Error fetching Project list` would be 'Error fetching projects'
4. `Search {itemLabel} list`
- used as the placeholder text for filtering a list of items
- for example, for Google, `Search Project list` would be 'Search projects'
#### `initialize`
Initializes the cloud server API and returns the top-level sub-items
that can be chosen to begin the process of configuring a data source.
For example, for the Google Cloud Healthcare API, this would perform the initial request
to fetch the top level projects for the logged in user account.
#### `setCurrentItem`
Sets the current path item that is passed as an argument to the method and
returns the sub-items of that item
that can be further chosen to configure a data source.
When setting the last configurable item of the data source (path), this method
returns an empty list AND configures the active data source with the selected
items path.
For example, for the Google Cloud Healthcare API, this would take the current item
(say a data set) and queries and returns its sub-items (i.e. all of the DICOM stores
contained in that data set). Furthermore, whenever the item to set is a DICOM store,
the Google Cloud Healthcare API implementation would update the OHIF data source
associated with this instance to point to that DICOM store.
#### `getConfiguredItems`
Gets the list of items currently configured for the data source associated with
this API instance. The resultant array must be the same length as the result of
`getItemLabels`. Furthermore the items returned should correspond (index-wise)
with the labels returned from `getItemLabels`.
## Creation via Customization Module
The generic UI (i.e. `DataSourceConfigurationComponent`) uses the
[OHIF UI customization service](../../platform/services/ui/customization-service.md) to
instantiate the `BaseDataSourceConfigurationAPI` instance to configure a data source.
A UI configurable data source should have a `configurationAPI` field as part of
its `configuration` in the OHIF config file. The `configurationAPI` value is the
customization id of the customization module that provides the factory method
to instantiate the `BaseDataSourceConfigurationAPI` instance.
For example, the following is a snippet of a Google Cloud Healthcare data source configuration.
```js
dataSources: [
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'google-dicomweb',
configuration: {
name: 'GCP',
wadoUriRoot: 'https://healthcare.googleapis.com/v1/projects/ohif-cloud-healthcare/locations/us-east4/...',
...
configurationAPI: 'ohif.dataSourceConfigurationAPI.google',
...
},
},
]
```
This suggests that the factory method is provided by the `'ohif.dataSourceConfigurationAPI.google'`
customization module. That customization module is provided by the `default` extension's
`getCustomizationModule` and looks something like the following snippet of code. Notice that
the factory method's name MUST be `factory` and accept one argument - the data source name.
Furthermore note how the constructor is invoked with anything required by the concrete configuration
API class.
```js
export default function getCustomizationModule({
servicesManager,
extensionManager,
}) {
return [
{
name: 'default',
value: [
{
// The factory for creating an instance of a BaseDataSourceConfigurationAPI for Google Cloud Healthcare
id: 'ohif.dataSourceConfigurationAPI.google',
factory: (dataSourceName: string) =>
new GoogleCloudDataSourceConfigurationAPI(
dataSourceName,
servicesManager,
extensionManager
),
},
],
},
];
}
```

View File

@ -1,5 +1,5 @@
---
sidebar_position: 4
sidebar_position: 5
sidebar_label: Static Files
---

View File

@ -0,0 +1,24 @@
{
"Configure Data Source":"Configure Data Source",
"Data set": "Data set",
"DICOM store": "DICOM store",
"Location": "Location",
"Project": "Project",
"Error fetching Data set list": "Error fetching data sets",
"Error fetching DICOM store list": "Error fetching DICOM stores",
"Error fetching Location list": "Error fetching locations",
"Error fetching Project list": "Error fetching projects",
"No Project available": "No projects available",
"No Location available": "No locations available",
"No Data set available": "No data sets available",
"No DICOM store available": "No DICOM stores available",
"Select": "Select",
"Search Data set list": "Search data sets",
"Search DICOM store list": "Search DICOM stores",
"Search Location list": "Search locations",
"Search Project list": "Search projects",
"Select Data set": "Select a data Set",
"Select DICOM store": "Select a DICOM store",
"Select Location": "Select a location",
"Select Project": "Select a project"
}

View File

@ -2,6 +2,7 @@ import AboutModal from './AboutModal.json';
import Buttons from './Buttons.json';
import CineDialog from './CineDialog.json';
import Common from './Common.json';
import DataSourceConfiguration from './DataSourceConfiguration.json';
import DatePicker from './DatePicker.json';
import Header from './Header.json';
import MeasurementTable from './MeasurementTable.json';
@ -18,6 +19,7 @@ export default {
Buttons,
CineDialog,
Common,
DataSourceConfiguration,
DatePicker,
Header,
MeasurementTable,

View File

@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M0 0h16v16H0z"/>
<rect stroke="#7BB2CE" fill="#0D0E24" x=".5" y=".5" width="15" height="15" rx="7.5"/>
<rect stroke="currentColor" fill="#0D0E24" x=".5" y=".5" width="15" height="15" rx="7.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 268 B

After

Width:  |  Height:  |  Size: 273 B

View File

@ -1,4 +1,4 @@
import Button, {ButtonType, ButtonSize} from '../Button';
import {Button, ButtonEnums} from '../../../components';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import {
createComponentTemplate,
@ -45,8 +45,8 @@ There can be different types of buttons: `primary`, and `secondary`.
<Canvas>
<Story name="Types">
<div className="flex space-x-2">
<Button type={ButtonType.primary}>Primary Button</Button>
<Button type={ButtonType.secondary}>Secondary Button</Button>
<Button type={ButtonEnums.type.primary}>Primary Button</Button>
<Button type={ButtonEnums.type.secondary}>Secondary Button</Button>
</div>
</Story>
</Canvas>
@ -59,8 +59,8 @@ to the button's height.
<Canvas>
<Story name="Colors">
<div className="flex items-center space-x-2">
<Button size={ButtonSize.small}>Small Button</Button>
<Button size={ButtonSize.medium}>Medium Button</Button>
<Button size={ButtonEnums.size.small}>Small Button</Button>
<Button size={ButtonEnums.size.medium}>Medium Button</Button>
</div>
</Story>
</Canvas>
@ -71,7 +71,7 @@ You can mix different props together to create a button.
<Canvas>
<Story name="Custom">
<Button type={ButtonType.secondary} size={ButtonSize.small}>
<Button type={ButtonEnums.type.secondary} size={ButtonEnums.size.small}>
Small, Secondary Button
</Button>
</Story>

View File

@ -0,0 +1,91 @@
import classNames from 'classnames';
import debounce from 'lodash.debounce';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Icon from '../Icon';
type InputFilterTextProps = {
className?: string;
value?: string;
placeholder: string;
onDebounceChange?: (val: string) => void;
onChange?: (val: string) => void;
debounceTime?: number;
};
/**
* A component to use as the input for text to filter by/on. A debounced callback is automatically provided
* so that the filtering in-turn will be debounced. There is also a straight onChange callback when the filter value is
* required immediately and NOT debounced. The debounce time is also configurable.
*/
const InputFilterText = ({
className,
value = '',
placeholder,
onDebounceChange,
onChange,
debounceTime = 200,
}: InputFilterTextProps): ReactElement => {
const [filterValue, setFilterValue] = useState<string>(value);
const searchInputRef = useRef(null);
const debouncedOnChange = useMemo(() => {
return debounce(onDebounceChange || (() => {}), debounceTime);
}, []);
// This allows for the filter value to be updated via the props.
useEffect(() => setFilterValue(value), [value]);
useEffect(() => {
return debouncedOnChange?.cancel();
}, []);
const handleFilterTextChanged = useCallback(value => {
setFilterValue(value);
if (onChange) {
onChange(value);
}
if (onDebounceChange) {
debouncedOnChange(value);
}
}, []);
return (
<label className={classNames('relative', className)}>
<span className="absolute inset-y-0 left-0 flex items-center pl-2">
<Icon name="icon-search"></Icon>
</span>
<input
ref={searchInputRef}
type="text"
className="block bg-black w-full shadow transition duration-300 appearance-none border border-inputfield-main focus:border-inputfield-focus focus:outline-none disabled:border-inputfield-disabled rounded-md w-full py-2 px-9 text-base leading-tight placeholder:text-inputfield-placeholder"
placeholder={placeholder}
onChange={event => handleFilterTextChanged(event.target.value)}
autoComplete="off"
value={filterValue}
></input>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
<Icon
name="icon-clear-field"
className={classNames('cursor-pointer', filterValue ? '' : 'hidden')}
onClick={() => {
searchInputRef.current.value = '';
handleFilterTextChanged('');
}}
></Icon>
</span>
</label>
);
};
export default InputFilterText;

View File

@ -0,0 +1,54 @@
import InputFilterText from '../InputFilterText';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: InputFilterText,
title: 'Components/InputFilterText',
};
<Meta title="Components/InputFilterText" component={InputFilterText} />
export const InputFilterTextTemplate = args => (
<div className="w-80">
<InputFilterText {...args} />
</div>
)
<Heading title="InputFilterText" componentRelativePath="InputFilterText/InputFilterText.tsx" />
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
InputFilterText is a component that is styled such that it can be used as a text input to
filter a list of textual items. It allows you to enter any text. There are two (optional)
callbacks that can be invoked as the characters of the text are entered: one
callback is invoked as each character is typed and another is debounced so that
any filtering can occur once the user has entered a significant amount of info and
pausing by a configurable amount of time in milliseconds. The component also
provides a button on the far right of the component that when clicked will clear the input
text. The button only appears when there is text in the component.
<Canvas>
<Story
name="Overview"
args={{
className: "w-full text-white",
placeholder: "Search for...",
onChange: () => console.log('input text changed'),
onDebounceChange: () => console.log('debounce text changed'),
}}
>
{InputFilterTextTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={InputFilterText} />
## Contribute
<Footer componentRelativePath="InputFilterText/__stories__/InputFilterText.stories.mdx" />

View File

@ -0,0 +1,2 @@
import InputFilterText from './InputFilterText';
export default InputFilterText;

View File

@ -15,6 +15,7 @@ const StudyListFilter = ({
isFiltering,
numOfStudies,
onUploadClick,
getDataSourceConfigurationComponent,
}) => {
const { t } = useTranslation('StudyList');
const { sortBy, sortDirection } = filterValues;
@ -30,13 +31,15 @@ const StudyListFilter = ({
return (
<React.Fragment>
<div>
<div className="bg-primary-dark">
<div className="container relative flex flex-col pt-5 m-auto">
<div className="flex flex-row justify-between px-12 mb-5">
<div className="flex flex-row">
<Typography variant="h4" className="mr-6 text-primary-light">
<div className="bg-black">
<div className="container relative flex flex-col pt-5 mx-auto">
<div className="flex flex-row justify-between mb-5">
<div className="flex flex-row items-center gap-6 shrink min-w-[1px]">
<Typography variant="h6" className="text-white">
{t('StudyList')}
</Typography>
{getDataSourceConfigurationComponent &&
getDataSourceConfigurationComponent()}
{onUploadClick && (
<div
className="flex items-center gap-2 cursor-pointer text-primary-active text-lg self-center font-semibold"
@ -63,7 +66,7 @@ const StudyListFilter = ({
</LegacyButton>
)}
<Typography
variant="h4"
variant="h6"
className="mr-2"
data-cy={'num-studies'}
>
@ -71,7 +74,7 @@ const StudyListFilter = ({
</Typography>
<Typography
variant="h6"
className="self-end pb-1 text-common-light"
className="self-end pb-1 text-primary-light"
>
{t('Studies')}
</Typography>
@ -80,8 +83,8 @@ const StudyListFilter = ({
</div>
</div>
</div>
<div className="sticky z-10 border-b-4 border-black -top-1">
<div className="pt-3 pb-3 bg-primary-dark ">
<div className="sticky z-10 border-b-4 border-black -top-1 mx-auto">
<div className="pt-3 pb-3 bg-primary-dark">
<InputGroup
inputMeta={filtersMeta}
values={filterValues}
@ -134,6 +137,7 @@ StudyListFilter.propTypes = {
clearFilters: PropTypes.func.isRequired,
isFiltering: PropTypes.bool.isRequired,
onUploadClick: PropTypes.func,
getDataSourceConfigurationComponent: PropTypes.func,
};
export default StudyListFilter;

View File

@ -13,6 +13,7 @@ import Icon from './Icon';
import IconButton from './IconButton';
import Input from './Input';
import InputDateRange from './InputDateRange';
import InputFilterText from './InputFilterText';
import InputGroup from './InputGroup';
import InputLabelWrapper from './InputLabelWrapper';
import InputMultiSelect from './InputMultiSelect';
@ -97,6 +98,7 @@ export {
IconButton,
Input,
InputDateRange,
InputFilterText,
InputGroup,
InputRange,
InputNumber,

View File

@ -58,6 +58,7 @@ export {
InputRange,
InputNumber,
InputDateRange,
InputFilterText,
InputGroup,
InputLabelWrapper,
InputMultiSelect,