From daa56a844967de9794d69da2f5ba762d4a7d21bd Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 24 Nov 2022 16:28:10 -0500 Subject: [PATCH 1/8] feat(CustomizationService):Add a customization service (#2984) * feat(customization):Add a customization service Update UICustomizationService to v3-stable. Also, adds an example customization for tab colors * moved the customization service to ui category --- .../default/src/Panels/PanelStudyBrowser.tsx | 1 + .../default/src/getCustomizationModule.tsx | 29 ++ extensions/default/src/index.js | 3 + .../PanelStudyBrowserTracking.tsx | 1 + modes/longitudinal/src/index.js | 8 +- .../core/src/extensions/ExtensionManager.js | 21 +- .../src/extensions/ExtensionManager.test.js | 3 + platform/core/src/extensions/MODULE_TYPES.js | 1 + platform/core/src/index.test.js | 1 + platform/core/src/index.ts | 3 + .../CustomizationService.test.js | 169 +++++++++ .../CustomizationService.ts | 268 +++++++++++++++ .../services/CustomizationService/index.ts | 11 + .../services/CustomizationService/types.ts | 39 +++ .../{ToolBarService.js => ToolBarService.ts} | 25 +- .../ToolBarService/{index.js => index.ts} | 0 .../ViewportGridService.ts | 4 + .../_shared/pubSubServiceInterface.js | 21 +- platform/core/src/services/index.js | 11 +- platform/core/src/types/Command.ts | 7 + platform/core/src/types/index.ts | 10 +- .../docs/docs/platform/managers/service.md | 30 +- .../docs/docs/platform/services/data/index.md | 1 + platform/docs/docs/platform/services/index.md | 11 + .../services/ui/customization-service.md | 324 ++++++++++++++++++ .../components/StudyBrowser/StudyBrowser.tsx | 11 +- .../contextProviders/ViewportGridProvider.tsx | 1 + platform/viewer/public/config/default.js | 4 + platform/viewer/public/config/local_static.js | 25 +- platform/viewer/public/config/multiple.js | 6 + platform/viewer/src/App.tsx | 29 +- platform/viewer/src/appInit.js | 2 + platform/viewer/src/routes/index.tsx | 18 +- 33 files changed, 1031 insertions(+), 67 deletions(-) create mode 100644 extensions/default/src/getCustomizationModule.tsx create mode 100644 platform/core/src/services/CustomizationService/CustomizationService.test.js create mode 100644 platform/core/src/services/CustomizationService/CustomizationService.ts create mode 100644 platform/core/src/services/CustomizationService/index.ts create mode 100644 platform/core/src/services/CustomizationService/types.ts rename platform/core/src/services/ToolBarService/{ToolBarService.js => ToolBarService.ts} (91%) rename platform/core/src/services/ToolBarService/{index.js => index.ts} (100%) create mode 100644 platform/core/src/types/Command.ts create mode 100644 platform/docs/docs/platform/services/ui/customization-service.md diff --git a/extensions/default/src/Panels/PanelStudyBrowser.tsx b/extensions/default/src/Panels/PanelStudyBrowser.tsx index 3129bb26538..49f84f75163 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.tsx @@ -234,6 +234,7 @@ function PanelStudyBrowser({ return ( ( +

Hello Custom Route

+ ), + }, + ], + }, + }, + ]; +} diff --git a/extensions/default/src/index.js b/extensions/default/src/index.js index ecbce1582f2..bb76b0afc6e 100644 --- a/extensions/default/src/index.js +++ b/extensions/default/src/index.js @@ -6,6 +6,7 @@ import getToolbarModule from './getToolbarModule'; import commandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID'; +import getCustomizationModule from './getCustomizationModule'; import { id } from './id.js'; import init from './init'; @@ -36,6 +37,8 @@ const defaultExtension = { }, ]; }, + + getCustomizationModule, }; export default defaultExtension; diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index ea79c6be549..84bcfddc91d 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -349,6 +349,7 @@ function PanelStudyBrowserTracking({ return ( { - const { ToolBarService, ToolGroupService } = servicesManager.services; + const { + MeasurementService, + ToolBarService, + ToolGroupService, + } = servicesManager.services; + + MeasurementService.clearMeasurements(); // Init Default and SR ToolGroups initToolGroups(extensionManager, ToolGroupService, commandsManager); diff --git a/platform/core/src/extensions/ExtensionManager.js b/platform/core/src/extensions/ExtensionManager.js index d7c93fe3294..aca983790bb 100644 --- a/platform/core/src/extensions/ExtensionManager.js +++ b/platform/core/src/extensions/ExtensionManager.js @@ -40,13 +40,9 @@ export default class ExtensionManager { _extensionLifeCycleHooks, } = this; - const { - MeasurementService, - ViewportGridService, - } = _servicesManager.services; - - MeasurementService.clearMeasurements(); - ViewportGridService.reset(); + for (const service of Object.values(_servicesManager.services)) { + service?.onModeEnter?.(); + } registeredExtensionIds.forEach(extensionId => { const onModeEnter = _extensionLifeCycleHooks.onModeEnter[extensionId]; @@ -69,13 +65,9 @@ export default class ExtensionManager { _extensionLifeCycleHooks, } = this; - const { - MeasurementService, - ViewportGridService, - } = _servicesManager.services; - - MeasurementService.clearMeasurements(); - ViewportGridService.reset(); + for (const service of Object.values(_servicesManager.services)) { + service?.onModeExit?.(); + } registeredExtensionIds.forEach(extensionId => { const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId]; @@ -200,6 +192,7 @@ export default class ExtensionManager { case MODULE_TYPES.SOP_CLASS_HANDLER: case MODULE_TYPES.CONTEXT: case MODULE_TYPES.LAYOUT_TEMPLATE: + case MODULE_TYPES.CUSTOMIZATION: case MODULE_TYPES.UTILITY: // Default for most extension points, // Just adds each entry ready for consumption by mode. diff --git a/platform/core/src/extensions/ExtensionManager.test.js b/platform/core/src/extensions/ExtensionManager.test.js index 1ecf778ada2..47c737518bf 100644 --- a/platform/core/src/extensions/ExtensionManager.test.js +++ b/platform/core/src/extensions/ExtensionManager.test.js @@ -235,6 +235,9 @@ describe('ExtensionManager.js', () => { getUtilityModule: () => { return [{}]; }, + getCustomizationModule: () => { + return [{}]; + }, }; await extensionManager.registerExtension(extension); diff --git a/platform/core/src/extensions/MODULE_TYPES.js b/platform/core/src/extensions/MODULE_TYPES.js index a0627c9baa0..c96c3f1fbff 100644 --- a/platform/core/src/extensions/MODULE_TYPES.js +++ b/platform/core/src/extensions/MODULE_TYPES.js @@ -1,5 +1,6 @@ export default { COMMANDS: 'commandsModule', + CUSTOMIZATION: 'customizationModule', DATA_SOURCE: 'dataSourcesModule', PANEL: 'panelModule', SOP_CLASS_HANDLER: 'sopClassHandlerModule', diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index fdc3b60a564..cd07dfd60f0 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -24,6 +24,7 @@ describe('Top level exports', () => { 'OHIF', // 'CineService', + 'CustomizationServiceRegistration', 'UIDialogService', 'UIModalService', 'UINotificationService', diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index c5adb427eb1..9abc4e1e33c 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -27,6 +27,7 @@ import { HangingProtocolService, pubSubServiceInterface, UserAuthenticationService, + CustomizationServiceRegistration, } from './services'; import IWebApiDataSource from './DataSources/IWebApiDataSource'; @@ -57,6 +58,7 @@ const OHIF = { viewer: {}, // CineService, + CustomizationServiceRegistration, UIDialogService, UIModalService, UINotificationService, @@ -92,6 +94,7 @@ export { DICOMWeb, // CineService, + CustomizationServiceRegistration, UIDialogService, UIModalService, UINotificationService, diff --git a/platform/core/src/services/CustomizationService/CustomizationService.test.js b/platform/core/src/services/CustomizationService/CustomizationService.test.js new file mode 100644 index 00000000000..959816ea576 --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.test.js @@ -0,0 +1,169 @@ +import CustomizationService from './CustomizationService'; +import log from '../../log'; + +jest.mock('../../log.js', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +const extensionManager = { + registeredExtensionIds: [], + moduleEntries: {}, + + getModuleEntry: function (id) { + return this.moduleEntries[id]; + }, +}; + +const commandsManager = {}; + +const ohifOverlayItem = { + id: 'ohif.overlayItem', + content: function (props) { + return { + label: this.label, + value: props[this.attribute], + ver: 'default', + }; + }, +}; + +const testItem = { + id: 'testItem', + customizationType: 'ohif.overlayItem', + attribute: 'testAttribute', + label: 'testItemLabel', +}; + +describe('CustomizationService.ts', () => { + let customizationService; + + let configuration; + + beforeEach(() => { + log.warn.mockClear(); + jest.clearAllMocks(); + configuration = {}; + customizationService = new CustomizationService({ + configuration, + commandsManager, + }); + }); + + describe('init', () => { + it('init succeeds', () => { + customizationService.init(extensionManager); + }); + + it('configurationRegistered', () => { + configuration.testItem = testItem; + customizationService.init(extensionManager); + expect(customizationService.getGlobalCustomization('testItem')).toBe( + testItem + ); + }); + + it('defaultRegistered', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [testItem] }; + customizationService.init(extensionManager); + expect(customizationService.getGlobalCustomization('testItem')).toBe( + testItem + ); + }); + }); + + describe('customizationType', () => { + it('inherits type', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [ohifOverlayItem] }; + configuration.testItem = testItem; + customizationService.init(extensionManager); + + const item = customizationService.getGlobalCustomization('testItem'); + + const props = { testAttribute: 'testAttrValue' }; + const result = item.content(props); + expect(result.label).toBe(testItem.label); + expect(result.value).toBe(props.testAttribute); + expect(result.ver).toBe('default'); + }); + + it('inline default inherits type', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [ohifOverlayItem] }; + configuration.testItem = testItem; + customizationService.init(extensionManager); + + const item = customizationService.getGlobalCustomization('testItem2', { + id: 'testItem2', + customizationType: 'ohif.overlayItem', + label: 'otherLabel', + attribute: 'otherAttr', + }); + + // Customizes the default value, as this is testItem2 + const props = { otherAttr: 'other attribute value' }; + const result = item.content(props); + expect(result.label).toBe('otherLabel'); + expect(result.value).toBe(props.otherAttr); + expect(result.ver).toBe('default'); + }); + }); + + describe('mode customization', () => { + it('onModeEnter can add extensions', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [ohifOverlayItem] }; + customizationService.init(extensionManager); + + expect( + customizationService.getModeCustomization('testItem') + ).toBeUndefined(); + + customizationService.addModeCustomizations([testItem]); + + expect( + customizationService.getGlobalCustomization('testItem') + ).toBeUndefined(); + + const item = customizationService.getModeCustomization('testItem'); + + const props = { testAttribute: 'testAttrValue' }; + const result = item.content(props); + expect(result.label).toBe(testItem.label); + expect(result.value).toBe(props.testAttribute); + expect(result.ver).toBe('default'); + }); + + it('global customizations override modes', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [ohifOverlayItem] }; + configuration.testItem = testItem; + customizationService.init(extensionManager); + + // Add a mode customization that would otherwise fail below + customizationService.addModeCustomizations([ + { ...testItem, label: 'other' }, + ]); + + const item = customizationService.getModeCustomization('testItem'); + + const props = { testAttribute: 'testAttrValue' }; + const result = item.content(props); + expect(result.label).toBe(testItem.label); + expect(result.value).toBe(props.testAttribute); + }); + }); +}); diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts new file mode 100644 index 00000000000..376cab1b3b4 --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -0,0 +1,268 @@ +import merge from 'lodash.merge'; +import { PubSubService } from '../_shared/pubSubServiceInterface'; +import { Customization, NestedStrings, Obj } from './types'; + +const EVENTS = { + MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified', + GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified', +}; + +const flattenNestedStrings = ( + strs: NestedStrings | string, + ret?: Record +): Record => { + if (!ret) ret = {}; + if (!strs) return ret; + if (Array.isArray(strs)) { + for (const val of strs) { + flattenNestedStrings(val, ret); + } + } else { + ret[strs] = strs; + } + return ret; +}; + +/** + * The CustomizationService allows for retrieving of custom components + * and configuration for mode and global values. + * The intent of the items is to provide a react component. This can be + * done by straight out providing an entire react component or else can be + * done by configuring a react component, or configuring a part of a react + * component. These are intended to be fairly indistinguishable in use of + * it, although the internals of how that is implemented may need to know + * about the customization service. + * + * A customization value can be: + * 1. React function, taking (React, props) and returning a rendered component + * For example, createLogoComponentFn renders a component logo for display + * 2. Custom UI component configuration, as defined by the component which uses it. + * For example, context menus define a complex structure allowing site-determined + * context menus to be set. + * 3. A string name, being the extension id for retrieving one of the above. + * + * The default values for the extension come from the app_config value 'whiteLabeling', + * The whiteLabelling can have lists of extensions to load for the default global and + * mode extensions. These are: + * 'globalExtensions' which is a list of extension id's to load for global values + * 'modeExtensions' which is a list of extension id's to load for mode values + * They default to the list ['*'] if not otherwise provided, which means to check + * every module for the given id and to load it/add it to the extensions. + */ +export default class CustomizationService extends PubSubService { + commandsManager: Record; + extensionManager: Record; + + modeCustomizations: Record = {}; + globalCustomizations: Record = {}; + configuration: UICustomizationConfiguration; + + constructor({ configuration, commandsManager }) { + super(EVENTS); + this.commandsManager = commandsManager; + this.configuration = configuration || {}; + } + + public init(extensionManager: ExtensionManager): void { + this.extensionManager = extensionManager; + this.initDefaults(); + this.addReferences(this.configuration); + } + + initDefaults(): void { + this.extensionManager.registeredExtensionIds.forEach(extensionId => { + const key = `${extensionId}.customizationModule.default`; + const defaultCustomizations = this.findExtensionValue(key); + if (!defaultCustomizations) return; + const { value } = defaultCustomizations; + this.addReference(value, true); + }); + } + + findExtensionValue(value: string): Obj | void { + const entry = this.extensionManager.getModuleEntry(value); + return entry; + } + + public onModeEnter(): void { + super.reset(); + this.modeCustomizations = {}; + } + + /** + * + * @param {*} interaction - can be undefined to run nothing + * @param {*} extraOptions to include in the commands run + */ + recordInteraction( + interaction: Customization | void, + extraOptions?: Record + ): void { + if (!interaction) return; + const commandsManager = this.commandsManager; + const { commands = [] } = interaction; + + commands.forEach(({ commandName, commandOptions, context }) => { + if (commandName) { + console.log('Running command', commandName); + commandsManager.runCommand( + commandName, + { + interaction, + ...commandOptions, + ...extraOptions, + }, + context + ); + } else { + console.warn('No command name supplied in', interaction); + } + }); + } + + public getModeCustomizations(): Record { + return this.modeCustomizations; + } + + public setModeCustomization( + customizationId: string, + customization: Customization + ): void { + console.log('** Set mode customization', customizationId, customization); + this.modeCustomizations[customizationId] = merge( + this.modeCustomizations[customizationId] || {}, + customization + ); + this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, { + buttons: this.modeCustomizations, + button: this.modeCustomizations[customizationId], + }); + } + + /** Mode customizations are changes to the behaviour of the extensions + * when running in a given mode. Reset clears mode customizations. + * Note that global customizations over-ride mode customizations. + * @param defautlValue to return if no customization specified. + */ + public getModeCustomization( + customizationId: string, + defaultValue?: Customization + ): Customization | void { + const customization = + this.globalCustomizations[customizationId] ?? + this.modeCustomizations[customizationId] ?? + defaultValue; + return this.applyType(customization); + } + + /** Applies any inheritance due to UI Type customization */ + public applyType(customization: Customization): Customization { + if (!customization) return customization; + const { customizationType } = customization; + if (!customizationType) return customization; + const parent = this.getModeCustomization(customizationType); + return parent + ? Object.assign(Object.create(parent), customization) + : customization; + } + + public addModeCustomizations(modeCustomizations): void { + if (!modeCustomizations) { + return; + } + this.addReferences(modeCustomizations, false); + + this._broadcastModeCustomizationModified(); + } + + _broadcastModeCustomizationModified(): void { + this._broadcastEvent(EVENTS.MODE_CUSTOMIZATION_MODIFIED, { + modeCustomizations: this.modeCustomizations, + globalCustomizations: this.globalCustomizations, + }); + } + + /** Global customizations are those that affect parts of the GUI other than + * the modes. They include things like settings for the search screen. + * Reset does NOT clear global customizations. + */ + getGlobalCustomization( + id: string, + defaultValue?: Customization + ): Customization | void { + return this.applyType(this.globalCustomizations[id] ?? defaultValue); + } + + setGlobalCustomization(id: string, value: Customization): void { + console.log('*** Set global', id, value); + this.globalCustomizations[id] = value; + this._broadcastGlobalCustomizationModified(); + } + + protected setConfigGlobalCustomization( + configuration: AppConfigCustomization + ): void { + this.globalCustomizations = {}; + const keys = flattenNestedStrings(configuration.globalCustomizations); + this.readCustomizationTypes( + v => keys[v.name] && v.customization, + this.globalCustomizations + ); + + // TODO - iterate over customizations, loading them from the extension + // manager. + this._broadcastGlobalCustomizationModified(); + } + + _broadcastGlobalCustomizationModified(): void { + this._broadcastEvent(EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, { + modeCustomizations: this.modeCustomizations, + globalCustomizations: this.globalCustomizations, + }); + } + + /** + * A single reference is either an an array, or a single customization value, + * whose id is the id in the object, or the parent id. + * This allows for general use to register customizationModule entries. + */ + addReference( + value?: Obj | Obj[] | string, + isGlobal = true, + id?: string + ): void { + if (!value) return; + if (typeof value === 'string') { + const extensionValue = this.findExtensionValue(value); + console.log('Adding extension values', value, extensionValue); + this.addReferences(extensionValue); + } else if (Array.isArray(value)) { + this.addReferences(value, isGlobal); + } else { + const useId = value.id || id; + this[isGlobal ? 'setGlobalCustomization' : 'setModeCustomization']( + useId as string, + value + ); + } + } + + /** References are: + * list of customizations, added in order + * object containing a customization id and value + * This format allows for the original whitelist format. + */ + addReferences(references?: Obj | Obj[], isGlobal = true): void { + if (!references) return; + if (Array.isArray(references)) { + references.forEach(item => { + this.addReference(item, isGlobal); + }); + } else { + for (const key of Object.keys(references)) { + const value = references[key]; + this.addReference(value, isGlobal, key); + } + } + } +} diff --git a/platform/core/src/services/CustomizationService/index.ts b/platform/core/src/services/CustomizationService/index.ts new file mode 100644 index 00000000000..d2eec2824af --- /dev/null +++ b/platform/core/src/services/CustomizationService/index.ts @@ -0,0 +1,11 @@ +import CustomizationService from './CustomizationService'; + +const CustomizationServiceRegistration = { + name: 'customizationService', + create: ({ configuration = {}, commandsManager }) => { + return new CustomizationService({ configuration, commandsManager }); + }, +}; + +export default CustomizationServiceRegistration; +export { CustomizationService, CustomizationServiceRegistration }; diff --git a/platform/core/src/services/CustomizationService/types.ts b/platform/core/src/services/CustomizationService/types.ts new file mode 100644 index 00000000000..b9c3826196a --- /dev/null +++ b/platform/core/src/services/CustomizationService/types.ts @@ -0,0 +1,39 @@ +import Command from '../../types/Command'; +import { ComponentType } from 'react'; + +export type Obj = Record; + +export interface BaseCustomization extends Obj { + id: string; + customizationType?: string; + description?: string; + label?: string; + commands?: Command[]; +} + +export interface LabelCustomization extends BaseCustomization { + label: string; +} + +export interface CodeCustomization extends BaseCustomization { + code: string; +} + +export interface CommandCustomization extends BaseCustomization { + commands: Command[]; +} + +export type Customization = + | BaseCustomization + | LabelCustomization + | CommandCustomization + | CodeCustomization; + +export default Customization; + +export type ComponentReturn = { + component: ComponentType; + props?: Obj; +}; + +export type NestedStrings = string[] | NestedStrings[]; diff --git a/platform/core/src/services/ToolBarService/ToolBarService.js b/platform/core/src/services/ToolBarService/ToolBarService.ts similarity index 91% rename from platform/core/src/services/ToolBarService/ToolBarService.js rename to platform/core/src/services/ToolBarService/ToolBarService.ts index 29f2e9f2b7f..908899f21e1 100644 --- a/platform/core/src/services/ToolBarService/ToolBarService.js +++ b/platform/core/src/services/ToolBarService/ToolBarService.ts @@ -52,11 +52,19 @@ export default class ToolBarService { this.buttons = {}; } + onModeEnter() { + this.reset(); + } + /** * - * @param {*} interaction + * @param {*} interaction - can be undefined to run nothing + * @param {*} options is an optional set of extra commandOptions + * used for calling the specified interaction. That is, the command is + * called with {...commandOptions,...options} */ - recordInteraction(interaction) { + recordInteraction(interaction, options) { + if (!interaction) return; const commandsManager = this._commandsManager; const { groupId, itemId, interactionType, commands } = interaction; @@ -64,7 +72,14 @@ export default class ToolBarService { case 'action': { commands.forEach(({ commandName, commandOptions, context }) => { if (commandName) { - commandsManager.runCommand(commandName, commandOptions, context); + commandsManager.runCommand( + commandName, + { + ...commandOptions, + ...options, + }, + context + ); } }); break; @@ -171,6 +186,10 @@ export default class ToolBarService { } } + getButton(id) { + return this.buttons[id]; + } + setButtons(buttons) { this.buttons = buttons; this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { diff --git a/platform/core/src/services/ToolBarService/index.js b/platform/core/src/services/ToolBarService/index.ts similarity index 100% rename from platform/core/src/services/ToolBarService/index.js rename to platform/core/src/services/ToolBarService/index.ts diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index 2cc193847e6..bf8d0c0398c 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -24,6 +24,7 @@ class ViewportGridService { restoreCachedLayout: restoreCachedLayoutImplementation, setLayout: setLayoutImplementation, reset: resetImplementation, + onModeExit: onModeExitImplementation, set: setImplementation, }) { if (getStateImplementation) { @@ -50,6 +51,9 @@ class ViewportGridService { if (restoreCachedLayoutImplementation) { this.serviceImplementation._restoreCachedLayout = restoreCachedLayoutImplementation; } + if (onModeExitImplementation) { + this.serviceImplementation._onModeExit = onModeExitImplementation; + } if (setImplementation) { this.serviceImplementation._set = setImplementation; } diff --git a/platform/core/src/services/_shared/pubSubServiceInterface.js b/platform/core/src/services/_shared/pubSubServiceInterface.js index 9a22a3ccb32..855f0728084 100644 --- a/platform/core/src/services/_shared/pubSubServiceInterface.js +++ b/platform/core/src/services/_shared/pubSubServiceInterface.js @@ -1,4 +1,5 @@ import guid from '../../utils/guid'; +import * as Types from '../../Types'; /** * Consumer must implement: @@ -24,7 +25,7 @@ function subscribe(eventName, callback) { const listenerId = guid(); const subscription = { id: listenerId, callback }; - console.info(`Subscribing to '${eventName}'.`); + // console.info(`Subscribing to '${eventName}'.`); if (Array.isArray(this.listeners[eventName])) { this.listeners[eventName].push(subscription); } else { @@ -86,3 +87,21 @@ function _broadcastEvent(eventName, callbackProps) { }); } } + +/** Export a PubSubService class to be used instead of the individual items */ +export class PubSubService { + constructor(EVENTS) { + this.EVENTS = EVENTS; + this.subscribe = subscribe; + this._broadcastEvent = _broadcastEvent; + this._unsubscribe = _unsubscribe; + this._isValidEvent = _isValidEvent; + this.listeners = {}; + this.unsubscriptions = []; + } + + reset() { + this.unsubscriptions.forEach(unsub => unsub()); + this.unsubscriptions = []; + } +} diff --git a/platform/core/src/services/index.js b/platform/core/src/services/index.js index 87f714815c2..01605f407b1 100644 --- a/platform/core/src/services/index.js +++ b/platform/core/src/services/index.js @@ -10,12 +10,20 @@ import ToolBarService from './ToolBarService'; import ViewportGridService from './ViewportGridService'; import CineService from './CineService'; import HangingProtocolService from './HangingProtocolService'; -import pubSubServiceInterface from './_shared/pubSubServiceInterface'; +import pubSubServiceInterface, { + PubSubService, +} from './_shared/pubSubServiceInterface'; import UserAuthenticationService from './UserAuthenticationService'; +import { + CustomizationService, + CustomizationServiceRegistration, +} from './CustomizationService'; export { MeasurementService, ServicesManager, + CustomizationService, + CustomizationServiceRegistration, UIDialogService, UIModalService, UINotificationService, @@ -27,5 +35,6 @@ export { HangingProtocolService, CineService, pubSubServiceInterface, + PubSubService, UserAuthenticationService, }; diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts new file mode 100644 index 00000000000..42d75f63bb0 --- /dev/null +++ b/platform/core/src/types/Command.ts @@ -0,0 +1,7 @@ +export interface Command { + commandName: string; + commandOptions?: Record; + context?: string; +} + +export default Command; diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index 80351c95377..9e1f795577f 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -5,13 +5,21 @@ import { } from './StudyMetadata'; import Consumer from './Consumer'; - +import { ExtensionManager } from '../extensions'; +import { CustomizationService, PubSubService } from '../services'; import * as HangingProtocol from './HangingProtocol'; +import Command from './Command'; + +export * from '../services/CustomizationService/types'; export type { + ExtensionManager, HangingProtocol, StudyMetadata, SeriesMetadata, InstanceMetadata, Consumer, + PubSubService, + CustomizationService, + Command, }; diff --git a/platform/docs/docs/platform/managers/service.md b/platform/docs/docs/platform/managers/service.md index 2cacda412e8..849a79d40de 100644 --- a/platform/docs/docs/platform/managers/service.md +++ b/platform/docs/docs/platform/managers/service.md @@ -51,6 +51,7 @@ By default, `OHIF-v3` registers the following services in the `appInit`. ```js title="platform/viewer/src/appInit.js" servicesManager.registerServices([ + CustomizationService, UINotificationService, UIModalService, UIDialogService, @@ -86,9 +87,16 @@ export default { and the implementation of `ToolBarService` lies in the same folder at `./ToolbarSerivce.js`. -> Note, the create method is critical for any custom service that you write and +> Note: The create method is critical for any custom service that you write and > want to add to the list of services +> Note: For typescript definitions, the service type should be exported +> as part of the Types export on the module. This is recommended going forward +> and existing services will be migrated. As well, the capitalization of the +> name should be lower camel case, with the type being upper camel case. In +> the above example, the service instance should be `toolBarService` with the +> class being `ToolBarService`. + ## Accessing Services Throughout the app you can use `services` property of the service manager to @@ -135,13 +143,15 @@ export default { and the logic for your service shall be ```js title="extensions/customExtension/src/services/backEndService/index.js" -import backEndService from './backEndService'; +// Canonical name of upper camel case BackEndService for the class +import BackEndService from './BackEndService'; export default function WrappedBackEndService(serviceManager) { return { - name: 'myService', + // Note the canonical name of lower camel case backEndService + name: 'backEndService', create: ({ configuration = {} }) => { - return new backEndService(serviceManager); + return new BackEndService(serviceManager); }, }; } @@ -149,8 +159,8 @@ export default function WrappedBackEndService(serviceManager) { with implementation of -```js -export default class backEndService { +```ts +export default class BackEndService { constructor(serviceManager) { this.serviceManager = serviceManager; } @@ -160,3 +170,11 @@ export default class backEndService { } } ``` + +with a registration of + +```ts title="types/index.ts" +import BackEndService from "../services/BackEndService/BackEndService"; + +export { BackEndService }; +``` diff --git a/platform/docs/docs/platform/services/data/index.md b/platform/docs/docs/platform/services/data/index.md index fa9128ae4da..ae158927628 100644 --- a/platform/docs/docs/platform/services/data/index.md +++ b/platform/docs/docs/platform/services/data/index.md @@ -19,6 +19,7 @@ We maintain the following non-ui Services: - [Hanging Protocol Service](../data/HangingProtocolService.md) - [Toolbar Service](../data/ToolBarService.md) - [Measurement Service](../data/MeasurementService.md) +- [Customization Service](customization-service.md) ## Service Architecture diff --git a/platform/docs/docs/platform/services/index.md b/platform/docs/docs/platform/services/index.md index 6fb2f829521..c2190e9a927 100644 --- a/platform/docs/docs/platform/services/index.md +++ b/platform/docs/docs/platform/services/index.md @@ -125,6 +125,17 @@ The following services is available in the `OHIF-v3`. cine + + + + CustomizationService + + + UI Service + + customizationService (NEW) + + diff --git a/platform/docs/docs/platform/services/ui/customization-service.md b/platform/docs/docs/platform/services/ui/customization-service.md new file mode 100644 index 00000000000..7e83fad8f0a --- /dev/null +++ b/platform/docs/docs/platform/services/ui/customization-service.md @@ -0,0 +1,324 @@ +--- +sidebar_position: 7 +sidebar_label: Customization Service +--- +# Customization Service + +There are a lot of places where users may want to configure certain elements +differently between different modes or for different deployments. A mode +example might be the use of a custom overlay showing mode related DICOM header +information such as radiation dose or patient age. + +The use of this service enables these to be defined in a typed fashion by +providing an easy way to set default values for this, but to allow a +non-default value to be specified by the configuration or mode. + +This service is a UI service in that part of the registration allows for registering +UI components and types to deal with, but it does not directly provide an UI +displayable elements unless customized to do so. + +## Registering Customizations +There are several ways to register customizations. The +`APP_CONFIG.customizationService` +field is used as a per-configuration entry. This object can list single +configurations by id, or it can list sets of customizations by referring to +the `customizationModule` in an extension. For example, the fictitious +customization 'customIcons' might be defined as below in the APP_CONFIG: + +```js +window.config = { + ..., + customizationService: [ + { + id: 'customIcons', + backArrow: 'https://customIcons.org/backArrow.svg', + }, + ], + ... +} +``` + +As well, extensions can register default customizations by providing a 'default' +name key within the extension. These are simply customizations loaded when +the extension is loaded. For example, the previous customization could have +been added in an extension as: + +```js + getCustomizationModule: () => [ + { + name: 'default', + value: { + id: 'customIcons', + backArrow: 'https://customIcons.org/backArrow.svg', + }, + }, + ], +``` + +Note the name of this is default (thus loaded automatically instead of by +reference), and the value is a customization of customIcons. + +The type and parameters of a customization are defined by the user of the +customization, based on the customization id. For example, `cornerstoneOverlay` +is a customization that is a React component, so it requires a react content, +and optionally contentProps which are used to supply values to the content. + +The extension can also supply a default parent instance to inherit values from. +This allows the content or other parameters to be pre-filled, and only the +required values changed. The parent to use is specified by the `customizationType` field, +and is simply the id of another customization object. An example of this might +be a demographics overlay field, where the base version needs an actual component, +while the typed version just needs the attribute and label to use. + +```js + getCustomizationModule: () => [ + { + name: 'default', + value: [ + // This first value defines the base type + { + id: imageDemographicOverlay, + content: function({image}) { + return (

{image[this.attribute]}

); + } + }, + // The second one defines an instance. + // It may or may not use the previous type definition - it will use it + // if nothing replaces the previous definition, otherwise it will use the new one. + { + id: PatientIDOverlayItem, + customizationType: 'imageDemographicOverlay', + attribute: 'PatientID', + }, + ] + } + ] +``` + +Mode-specific customizations are no different from the global ones, +except that the mode customizations are cleared before the mode `onModeEnter` +is called, and they can have new values registered in the `onModeEnter` + +The following example shows first the registration of the default instances, +and then shows how they might be used. + +```js +// In the cornerstone extension getCustomizationModule: +const getCustomizationModule = () => ([ + { + name: 'default', + value: [ + { + id: 'ohif.cornerstoneOverlay', + content: CornerstoneOverlay, + // Requires items on instances + }, + { + id: 'ohif.overlayItem', + content: CornerstoneOverlayItem, + // Requires attribute and label on instances + }, + ], + }, +]); +``` + +Then, in the configuration file one might have a custom overlay definition: + +```js +// in the APP_CONFIG file set the top right area to show the patient name +// using PN: as a prefix when the study has a non-empty patient name. +customizationService: { + cornerstoneOverlayTopRight: { + id: 'cornerstoneOverlayTopRight', + customizationType: 'ohif.cornerstoneOverlay', + items: [ + { + id: 'PatientNameOverlay', + // Note the ohif.overlayItem is a prototype instance for this object + // The ohif.overlayItem is defined up above + customizationType: 'ohif.overlayItem', + attribute: 'PatientName', + label: 'PN:', + }, + ], + }, +}, +``` + +In the mode customization, the overlay is then further customized +with a bottom-right overlay, which extends the customizationService configuration. + +```js +// Import the type from the extension itself +import OverlayUICustomization from '@ohif/cornerstone-extension'; + + +// In the mode itself, customizations can be registered: +onModeEnter() { + ... + // Note how the object can be strongly typed + const bottomRight: OverlayUICustomization = { + id: 'cornerstoneOverlayBottomRight', + // Note the type is the previously registered ohif.cornerstoneOverlay + customizationType: 'ohif.cornerstoneOverlay', + // The cornerstoneOverlay definition requires an items list here. + items: [ + // Custom definitions for hte context menu here. + ], + }; + customizationService.addModeCustomizations(bottomRight); +``` + +## Mode Customizations +The mode customizations are retrieved via the `getModeCustomization` function, +providing an id, and optionally a default value. The retrieval will return, +in order: + +1. Global customization with the given id. +2. Mode customization with the id. +3. The default value specified. + +The return value then inherits the `customizationType` instance, so that the +value can be typed and have default values and functionality provided. The object +can then be used in a way defined by the extension provided that customization +point. + +```ts + cornerstoneOverlay = uiConfigurationService.getModeCustomization("cornerstoneOverlay", {customizationType: "ohif.cornerstoneOverlay", ...}); + const { component: overlayComponent, props} = uiConfigurationService.getComponent(cornerstoneOverlay); + return (); +``` + +This example shows fetching the default component to render this object. The +returned object would be a sub-type of ohif.cornerstoneOverlay if defined. This +object can be a React component or other object such as a commands list, for +example (this example comes from the context menu customizations as that one +uses commands lists): + +```ts + cornerstoneContextMenu = uiConfigurationService.getModeCustomization("cornerstoneContextMenu", defaultMenu); + uiConfigurationService.recordInteraction(cornerstoneContextMenu, extraProps); +``` + +## Global Customizations +Global customizations are retrieved in the same was as mode customizations, except +that the `getGlobalCustomization` is called instead of the mode call. + +## Types +Some types for the customization service are provided by the `@ohif/ui` types +export. Additionally, extensions can provide a Types export with custom +typing, allowing for better typing for the extension specific capabilities. +This allows for having strong typing when declaring customizations, for example: + +```ts +import { Types } from '@ohif/ui'; + +const customContextMenu: Types.UIContextMenu = + { + id: 'cornerstoneContextMenu', + customizationType: 'ohif.contextMenu', + // items will be type checked to be in accordance with UIContextMenu.items + items: [ ... ] + }, +``` + +## Inheritance +JavaScript property inheritance can be supplied by defining customizations +with id corresponding to the customizationType value. For example: + +```js +getCustomizationModule = () => ([ + { + name: 'default', + value: [ + { + id: 'ohif.overlayItem', + content: function (props) { + return (

{this.label} {props.instance[this.attribute]}

) + }, + }, + ], + } +]) +``` + +defines an overlay item which has a React content object as the render value. +This can then be used by specifying a customizationType of `ohif.overlayItem`, for example: + +```js +const overlayItem: Types.UIOverlayItem = { + id: 'anOverlayItem', + customizationType: 'ohif.overlayItem', + attribute: 'PatientName', + label: 'PN:', +}; +``` + +# Customizations +This section can be used to specify various customization capabilities. + + +## Text color for StudyBrowser tabs +This is the recommended pattern for deep customization of class attributes, +making it fine grained, and have it apply a set of attributes, mostly from +tailwind. In this case it is a double indirection, as the buttons class +uses it's own internal class names. + +* Name: 'class:StudyBrowser' +* Attributes: +** `true` for the is active true text color +** `false` fo rhte is active false text color. +** Values are button colors, from the Button class, eg default, white, black + +## customRoutes + +* Name: `customRoutes` global +* Attributes: +** `routes` of type List of route objects (see `route/index.tsx`) is a set of route objects to add. +** Should any element of routes match an existing baked in element, the baked in one will be replaced. +** `notFoundRoute` is the route to display when nothing is found (this has to be at the end of the overall list, so can't be added to routes) + +### Example + +```js +{ + id: 'customRoutes', + routes: [ + { + path: '/myroute', + children: MyRouteReactFunction, + } + ], +} +``` + +There is a usage of this example commented out in config/default.js that +looks like the code below. This example is provided by the default extension, +again with commented out code. Uncomment the getCustomizationModule customRoutes +code in the default module to activate this, and then go to: `http://localhost:3000/custom` +to see the custom route. + +Note the name of this is the customization module name, which usually won't match +the id, and in fact there can be multiple customization objects defined for a single +customization module, to allow for customizing sets of related values. + +```js +customizationService: [ + // Shows a custom route -access via http://localhost:3000/custom + '@ohif/extension-default.customizationModule.helloPage', +], +``` + +> 3rd Party implementers may be added to this table via pull requests. + + + + +[interface]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/services/UIModalService/index.js +[modal-provider]: https://github.com/OHIF/Viewers/blob/master/platform/ui/src/contextProviders/ModalProvider.js +[modal-consumer]: https://github.com/OHIF/Viewers/tree/master/platform/ui/src/components/ohifModal +[ux-article]: https://uxplanet.org/best-practices-for-modals-overlays-dialog-windows-c00c66cddd8c + diff --git a/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx b/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx index 209cd7ac8b9..37993c58e9f 100644 --- a/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx +++ b/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx @@ -26,8 +26,10 @@ const StudyBrowser = ({ onDoubleClickThumbnail, onClickUntrack, activeDisplaySetInstanceUIDs, + servicesManager, }) => { const { t } = useTranslation('StudyBrowser'); + const { customizationService } = servicesManager?.services || {}; const getTabContent = () => { const tabData = tabs.find(tab => tab.name === activeTabName); @@ -82,8 +84,13 @@ const StudyBrowser = ({ const isActive = activeTabName === name; const isDisabled = !studies.length; // Apply the contrasting color for brighter button color visibility - // const color = isActive ? 'black' : 'default'; - const color = 'default'; + const classStudyBrowser = customizationService?.getModeCustomization( + 'class:StudyBrowser' + ) || { + true: 'default', + false: 'default', + }; + const color = classStudyBrowser[`${isActive}`]; return (