diff --git a/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx b/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx index fee3d619a..a78a6db1b 100644 --- a/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx +++ b/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx @@ -18,7 +18,9 @@ export default function _getStatusComponent({ isHydrated, onStatusClick }) { ); break; case false: - StatusIcon = () => ; + StatusIcon = () => ( + + ); ToolTipMessage = () =>
Click LOAD to load RTSTRUCT.
; } diff --git a/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx b/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx index d491faa6e..a814a3922 100644 --- a/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx +++ b/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx @@ -18,7 +18,9 @@ export default function _getStatusComponent({ isHydrated, onStatusClick }) { ); break; case false: - StatusIcon = () => ; + StatusIcon = () => ( + + ); ToolTipMessage = () =>
Click LOAD to load segmentation.
; } diff --git a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx index da60f2d83..b95af260f 100644 --- a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx @@ -511,7 +511,9 @@ function _getStatusComponent({ ); break; case 3: - StatusIcon = () => ; + StatusIcon = () => ( + + ); ToolTipMessage = () => (
{`Click ${loadStr} to restore measurements.`}
diff --git a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx new file mode 100644 index 000000000..768f55696 --- /dev/null +++ b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx @@ -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 + >(); + + 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 ? ( +
+ + show({ + content: DataSourceConfigurationModalComponent, + title: t('Configure Data Source'), + contentProps: { + configurationAPI, + configuredItems, + onHide: hide, + }, + }) + } + > + {configuredItems.map((item, itemIndex) => { + return ( +
+
+ {item.name} +
+ {itemIndex !== configuredItems.length - 1 && ( +
|
+ )} +
+ ); + })} +
+ ) : ( + <> + ); +} + +export default DataSourceConfigurationComponent; diff --git a/extensions/default/src/Components/DataSourceConfigurationModalComponent.tsx b/extensions/default/src/Components/DataSourceConfigurationModalComponent.tsx new file mode 100644 index 000000000..74145d3b0 --- /dev/null +++ b/extensions/default/src/Components/DataSourceConfigurationModalComponent.tsx @@ -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; + onHide: () => void; +}; + +function DataSourceConfigurationModalComponent({ + configurationAPI, + configuredItems, + onHide, +}: DataSourceConfigurationModalComponentProps) { + const { t } = useTranslation('DataSourceConfiguration'); + + const [itemList, setItemList] = useState< + Array + >(); + + 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(); + + 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 ( +
+
+ {t(`Error fetching ${itemLabels[selectedItems.length]} list`)} +
+
{errorMessage}
+
+ ); + }; + + const getSelectedItemsComponent = (): ReactElement => { + return ( +
+ {itemLabels.map((itemLabel, itemLabelIndex) => { + return ( +
{ + setShowFullConfig(false); + setSelectedItems(theList => + theList.slice(0, itemLabelIndex) + ); + } + : undefined + } + > +
+ {itemLabelIndex < selectedItems.length ? ( + + ) : ( + + )} +
+ {t(itemLabel)} +
+
+ {itemLabelIndex < selectedItems.length ? ( +
+ {selectedItems[itemLabelIndex].name} +
+ ) : ( +

+ )} +
+ ); + })} +
+ ); + }; + + return ( +
+ {getSelectedItemsComponent()} +
+ {errorMessage ? ( + getErrorComponent() + ) : ( + { + setShowFullConfig(false); + setSelectedItems(theList => [ + ...theList.slice(0, currentSelectedItemIndex + 1), + item, + ]); + }} + > + )} +
+ ); +} + +export default DataSourceConfigurationModalComponent; diff --git a/extensions/default/src/Components/ItemListComponent.tsx b/extensions/default/src/Components/ItemListComponent.tsx new file mode 100644 index 000000000..b2d8525a3 --- /dev/null +++ b/extensions/default/src/Components/ItemListComponent.tsx @@ -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; + onItemClicked: (item: Types.BaseDataSourceConfigurationAPIItem) => void; +}; + +function ItemListComponent({ + itemLabel, + itemList, + onItemClicked, +}: ItemListComponentProps): ReactElement { + const { t } = useTranslation('DataSourceConfiguration'); + const [filterValue, setFilterValue] = useState(''); + + useEffect(() => { + setFilterValue(''); + }, [itemList]); + + return ( +
+
+
+ {t(`Select ${itemLabel}`)} +
+ +
+
+ {itemList == null ? ( + + ) : itemList.length === 0 ? ( +
+ + {t(`No ${itemLabel} available`)} +
+ ) : ( + <> +
+ {t(itemLabel)} +
+
+ {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 ( +
+
{item.name}
+ +
+ ); + })} +
+ + )} +
+
+ ); +} + +export default ItemListComponent; diff --git a/extensions/default/src/DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI.ts b/extensions/default/src/DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI.ts new file mode 100644 index 000000000..d95cbd8ea --- /dev/null +++ b/extensions/default/src/DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI.ts @@ -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 { + const url = `${initialUrl}/projects`; + + const projects = (await GoogleCloudDataSourceConfigurationAPI._doFetch( + url, + ItemType.projects, + this._fetchOptions + )) as Array; + + 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 { + 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 + > { + 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 = {} + ): Promise | Array> { + 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 }; diff --git a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx index 57cf04fa8..c616fe022 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx @@ -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 }) => {
- {/* TODO - refactor the following into its own reusable component */} - +
diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index 6d03e2786..60c7d07f3 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -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 + ), + }, ], }, ]; diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx index 00e2c974b..66d9e2aa9 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx @@ -384,7 +384,7 @@ function _getStatusComponent(isTracked) { } > - + ); diff --git a/platform/app/public/config/google.js b/platform/app/public/config/google.js index a7c5c269c..bf3cfe7d9 100644 --- a/platform/app/public/config/google.js +++ b/platform/app/public/config/google.js @@ -55,6 +55,7 @@ window.config = { supportsWildcard: false, dicomUploadEnabled: true, omitQuotationForMultipartRequest: true, + configurationAPI: 'ohif.dataSourceConfigurationAPI.google', }, }, { diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index 64616d9a8..d410a85b6 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -485,6 +485,9 @@ function WorkList({ } : undefined; + const { component: dataSourceConfigurationComponent } = + customizationService.get('ohif.dataSourceConfigurationComponent') ?? {}; + return (
setFilterValues(defaultFilterValues)} isFiltering={isFiltering(filterValues, defaultFilterValues)} onUploadClick={uploadProps ? () => show(uploadProps) : undefined} + getDataSourceConfigurationComponent={ + dataSourceConfigurationComponent + ? () => dataSourceConfigurationComponent() + : undefined + } /> {hasStudies ? (
diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index b000bb07f..a41b0e8b9 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -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 diff --git a/platform/core/src/types/DataSourceConfigurationAPI.ts b/platform/core/src/types/DataSourceConfigurationAPI.ts new file mode 100644 index 000000000..146f4a285 --- /dev/null +++ b/platform/core/src/types/DataSourceConfigurationAPI.ts @@ -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; + + /** + * 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>; + + /** + * 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>; + + /** + * 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>; +} diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index cdbce68e7..d2a39b83b 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -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, +}; diff --git a/platform/docs/docs/assets/img/data-source-configuration-ui.png b/platform/docs/docs/assets/img/data-source-configuration-ui.png new file mode 100644 index 000000000..f04956e07 Binary files /dev/null and b/platform/docs/docs/assets/img/data-source-configuration-ui.png differ diff --git a/platform/docs/docs/configuration/dataSources/configuration-ui.md b/platform/docs/docs/configuration/dataSources/configuration-ui.md new file mode 100644 index 000000000..15f3b0bcb --- /dev/null +++ b/platform/docs/docs/configuration/dataSources/configuration-ui.md @@ -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 + ), + }, + ], + }, + ]; +} + +``` diff --git a/platform/docs/docs/configuration/dataSources/static-files.md b/platform/docs/docs/configuration/dataSources/static-files.md index 296a87996..820e20a1a 100644 --- a/platform/docs/docs/configuration/dataSources/static-files.md +++ b/platform/docs/docs/configuration/dataSources/static-files.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 sidebar_label: Static Files --- diff --git a/platform/i18n/src/locales/en-US/DataSourceConfiguration.json b/platform/i18n/src/locales/en-US/DataSourceConfiguration.json new file mode 100644 index 000000000..07af42104 --- /dev/null +++ b/platform/i18n/src/locales/en-US/DataSourceConfiguration.json @@ -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" +} diff --git a/platform/i18n/src/locales/en-US/index.js b/platform/i18n/src/locales/en-US/index.js index 037f807cd..d8b19e6f8 100644 --- a/platform/i18n/src/locales/en-US/index.js +++ b/platform/i18n/src/locales/en-US/index.js @@ -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, diff --git a/platform/ui/src/assets/icons/status-untracked.svg b/platform/ui/src/assets/icons/status-untracked.svg index cf347223f..a4186231a 100644 --- a/platform/ui/src/assets/icons/status-untracked.svg +++ b/platform/ui/src/assets/icons/status-untracked.svg @@ -1,6 +1,6 @@ - + diff --git a/platform/ui/src/components/Button/__stories__/button.stories.mdx b/platform/ui/src/components/Button/__stories__/button.stories.mdx index 5e6fa6236..ca77c4903 100644 --- a/platform/ui/src/components/Button/__stories__/button.stories.mdx +++ b/platform/ui/src/components/Button/__stories__/button.stories.mdx @@ -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`.
- - + +
@@ -59,8 +59,8 @@ to the button's height.
- - + +
@@ -71,7 +71,7 @@ You can mix different props together to create a button. - diff --git a/platform/ui/src/components/InputFilterText/InputFilterText.tsx b/platform/ui/src/components/InputFilterText/InputFilterText.tsx new file mode 100644 index 000000000..c7ceec7ad --- /dev/null +++ b/platform/ui/src/components/InputFilterText/InputFilterText.tsx @@ -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(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 ( + + ); +}; + +export default InputFilterText; diff --git a/platform/ui/src/components/InputFilterText/_stories_/inputFilterText.stories.mdx b/platform/ui/src/components/InputFilterText/_stories_/inputFilterText.stories.mdx new file mode 100644 index 000000000..aa030fe8a --- /dev/null +++ b/platform/ui/src/components/InputFilterText/_stories_/inputFilterText.stories.mdx @@ -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', +}; + + + +export const InputFilterTextTemplate = args => ( +
+ +
+) + + + +- [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. + + + console.log('input text changed'), + onDebounceChange: () => console.log('debounce text changed'), + }} + > + {InputFilterTextTemplate.bind({})} + + + +## Props + + + +## Contribute + +