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
This commit is contained in:
Bill Wallace authored and GitHub committed 2022-11-24 16:28:10 -05:00
1 parent 32bb7e4c1e
commit daa56a8449
33 files changed
+1031 -67

No files matched your search

@@ -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);
});
});
});
@@ -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<string, string>
): Record<string, string> => {
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<string, unknown>;
extensionManager: Record<string, unknown>;
modeCustomizations: Record<string, Customization> = {};
globalCustomizations: Record<string, Customization> = {};
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<string, unknown>
): 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<string, Customization> {
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);
}
}
}
}
@@ -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 };
@@ -0,0 +1,39 @@
import Command from '../../types/Command';
import { ComponentType } from 'react';
export type Obj = Record<string, unknown>;
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[];