feat(customization): new customization service api (#4688)

This commit is contained in:
Alireza authored and GitHub committed 2025-01-23 14:24:13 -05:00
1 parent fab52808cb
commit 55ad8efbab
124 files changed
+4261 -2517

No files matched your search

+1
View File
@@ -48,6 +48,7 @@
"dicomweb-client": "^0.10.4",
"gl-matrix": "^3.4.3",
"isomorphic-base64": "^1.0.2",
"immutability-helper": "^3.1.1",
"lodash.clonedeep": "^4.5.0",
"lodash.merge": "^4.6.2",
"lodash.mergewith": "^4.6.2",
+26 -5
View File
@@ -94,16 +94,29 @@ export class CommandsManager {
* @param {CommandDefinition} definition - {@link CommandDefinition}
*/
registerCommand(contextName, commandName, definition) {
if (typeof definition !== 'object') {
if (typeof definition !== 'object' && typeof definition !== 'function') {
return;
}
// Validate and restrict keys to prevent prototype pollution
const isSafeKey = key => {
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
};
if (!isSafeKey(contextName) || !isSafeKey(commandName)) {
throw new Error('Invalid key name to prevent prototype pollution');
}
const context = this.getContext(contextName);
if (!context) {
return;
}
context[commandName] = definition;
if (typeof definition === 'function') {
context[commandName] = { commandFn: definition, options: {} };
} else {
context[commandName] = definition;
}
}
/**
@@ -138,6 +151,11 @@ export class CommandsManager {
* @param {String} [contextName]
*/
public runCommand(commandName: string, options = {}, contextName?: string | string[]) {
if (typeof commandName === 'function') {
// If commandName is a function, run it directly
return commandName(options);
}
const definition = this.getCommand(commandName, contextName);
if (!definition) {
log.warn(`Command "${commandName}" not found in current context`);
@@ -147,7 +165,7 @@ export class CommandsManager {
const { commandFn } = definition;
const commandParams = Object.assign(
{},
definition.options, // "Command configuration"
definition.options || {}, // "Command configuration"
options // "Time of call" info
);
@@ -159,13 +177,16 @@ export class CommandsManager {
}
}
public static convertCommands(toRun: Command | Commands | Command[] | string) {
public static convertCommands(toRun: Command | Commands | Command[] | string | Function) {
if (typeof toRun === 'string') {
return [{ commandName: toRun }];
}
if ('commandName' in toRun) {
return [toRun as ComplexCommand];
}
if (typeof toRun === 'function') {
return [{ commandName: toRun }];
}
if ('commands' in toRun) {
const commandsInput = (toRun as Commands).commands;
return this.convertCommands(commandsInput);
@@ -203,7 +224,7 @@ export class CommandsManager {
*
* Example commands to run are:
* * 'updateMeasurement'
* * `{commandName: 'displayWhatever'}`
* * `{ commandName: 'displayWhatever'}`
* * `['updateMeasurement', {commandName: 'displayWhatever'}]`
* * `{ commands: 'updateMeasurement' }`
* * `{ commands: ['updateMeasurement', {commandName: 'displayWhatever'}]}`
@@ -1,313 +1,497 @@
import CustomizationService, { CustomizationType, MergeEnum } from './CustomizationService';
import log from '../../log';
jest.mock('../../log.js', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
// File: CustomizationService.registrationAndOperations.test.js
import CustomizationService, { CustomizationScope } from './CustomizationService';
const commandsManager = {};
const extensionManager = {
registeredExtensionIds: [],
moduleEntries: {},
getRegisteredExtensionIds: () => extensionManager.registeredExtensionIds,
getModuleEntry: function (id) {
return this.moduleEntries[id];
},
};
const commandsManager = {};
const noop = () => {};
const ohifOverlayItem = {
id: 'ohif.overlayItem',
content: function (props) {
return {
label: this.label,
value: props[this.attribute],
ver: 'default',
};
},
};
// A helper default customization module that mimics the structure returned by the module.
function getDefaultCustomizationModule() {
return {
// Simple types
showAddSegment: true,
somethingFalse: false,
onAddSegment: () => 'default add',
// Array of primitives
NumbersList: [1, 2, 3, 4],
// Object
SeriesInfo: {
label: 'Series Date',
sortFunction: noop,
views: ['sagittal', 'coronal', 'axial'],
advanced: {
subKey: 'original',
anotherKey: 42,
},
},
// Array of objects
studyBrowser: [
{
id: 'seriesDate',
label: 'Series Date',
sortFunction: noop,
},
],
advanced: {
firstLabel: 'hello',
functions: [
{
id: 'seriesDate',
label: 'Series Date',
sortFunction: () => {},
viewFunctions: [
{ id: 'sagittal', label: 'Sagittal', sortFunction: () => {} },
{ id: 'coronal', label: 'Coronal', sortFunction: () => {} },
{ id: 'axial', label: 'Axial', sortFunction: () => {} },
],
},
],
},
};
}
const testItem = {
id: 'testItem',
customizationType: 'ohif.overlayItem',
attribute: 'testAttribute',
label: 'testItemLabel',
};
describe('CustomizationService.ts', () => {
describe('CustomizationService - Registration + API Operations', () => {
let customizationService;
let configuration;
beforeEach(() => {
log.warn.mockClear();
jest.clearAllMocks();
configuration = {};
customizationService = new CustomizationService({
configuration,
commandsManager,
});
extensionManager.registeredExtensionIds = [];
extensionManager.moduleEntries = {};
customizationService = new CustomizationService({ commandsManager, configuration: {} });
// Simulate default registrations.
customizationService.addReferences(getDefaultCustomizationModule(), CustomizationScope.Default);
});
describe('init', () => {
it('init succeeds', () => {
customizationService.init(extensionManager);
afterEach(() => {
customizationService.onModeExit();
});
// Check that defaults are registered
it('has registered default customizations', () => {
const defaultShowAddSegment = customizationService.getCustomization('showAddSegment');
expect(defaultShowAddSegment).toBe(true);
const defaultNumbersList = customizationService.getCustomization('NumbersList');
expect(defaultNumbersList).toEqual([1, 2, 3, 4]);
const defaultSeriesInfo = customizationService.getCustomization('SeriesInfo');
expect(defaultSeriesInfo.label).toBe('Series Date');
expect(defaultSeriesInfo.advanced.subKey).toBe('original');
const defaultStudyBrowser = customizationService.getCustomization('studyBrowser');
expect(Array.isArray(defaultStudyBrowser)).toBe(true);
expect(defaultStudyBrowser.length).toBe(1);
//
const advanced = customizationService.getCustomization('advanced');
expect(advanced.firstLabel).toBe('hello');
expect(advanced.functions.length).toBe(1);
expect(advanced.functions[0].id).toBe('seriesDate');
expect(advanced.functions[0].viewFunctions.length).toBe(3);
expect(advanced.functions[0].viewFunctions[0].id).toBe('sagittal');
expect(advanced.functions[0].viewFunctions[1].id).toBe('coronal');
expect(advanced.functions[0].viewFunctions[2].id).toBe('axial');
});
// 1. Simple Data Types
describe('Simple Data Types', () => {
it('replaces boolean value using $set over the default', () => {
// Update the default value with a new one using $set.
customizationService.setCustomizations({
showAddSegment: { $set: false },
});
const result = customizationService.getCustomization('showAddSegment');
// Mode/global should override the default.
expect(result).toBe(false);
});
it('configurationRegistered', () => {
configuration.testItem = testItem;
customizationService.init(extensionManager);
expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem);
it('replaces boolean value using $set over the default false', () => {
// Update the default value with a new one using $set.
customizationService.setCustomizations({
somethingFalse: { $set: true },
});
const result = customizationService.getCustomization('somethingFalse');
// Mode/global should override the default.
expect(result).toBe(true);
});
it('defaultRegistered', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [testItem],
};
customizationService.init(extensionManager);
expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem);
it('replaces function value using $set over the default', () => {
// Original default returns "default add"
const original = customizationService.getCustomization('onAddSegment');
expect(original()).toBe('default add');
// Now update the function
customizationService.setCustomizations({
onAddSegment: { $set: () => 999 },
});
const updated = customizationService.getCustomization('onAddSegment');
expect(updated()).toBe(999);
});
it('replaces two properties at once', () => {
// Original default returns "default add"
const original = customizationService.getCustomization('onAddSegment');
expect(original()).toBe('default add');
// Now update the function
customizationService.setCustomizations({
onAddSegment: { $set: () => 998 },
showAddSegment: { $set: false },
});
expect(customizationService.getCustomization('onAddSegment')).toBeDefined();
expect(customizationService.getCustomization('showAddSegment')).toBe(false);
expect(customizationService.getCustomization('onAddSegment')()).toBe(998);
});
});
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');
// 2. Arrays of Primitives
describe('Arrays of Primitives', () => {
it('replaces entire array with $set over default', () => {
customizationService.setCustomizations({
NumbersList: { $set: [5, 6, 7, 8, 9] },
});
const result = customizationService.getCustomization('NumbersList');
expect(result).toEqual([5, 6, 7, 8, 9]);
});
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.getCustomization('testItem2', {
id: 'testItem2',
customizationType: 'ohif.overlayItem',
label: 'otherLabel',
attribute: 'otherAttr',
it('applies $push, $unshift, and $splice to default array', () => {
// Update array using merge commands
customizationService.setCustomizations({
NumbersList: {
$push: [5, 6],
},
});
const result = customizationService.getCustomization('NumbersList');
expect(result).toEqual([1, 2, 3, 4, 5, 6]);
});
// 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');
it('applies $push, $unshift, and $splice to default array', () => {
// Update array using merge commands
customizationService.setCustomizations({
NumbersList: {
$unshift: [0],
},
});
const result = customizationService.getCustomization('NumbersList');
expect(result).toEqual([0, 1, 2, 3, 4]);
});
it('applies $push, $unshift, and $splice to default array', () => {
// Update array using merge commands
customizationService.setCustomizations({
NumbersList: {
$splice: [
[2, 1, 99], // At index 2, remove
],
},
});
const result = customizationService.getCustomization('NumbersList');
expect(result).toEqual([1, 2, 99, 4]);
});
it('applies $push, $unshift, and $splice to default array', () => {
// Update array using merge commands
customizationService.setCustomizations({
NumbersList: {
$push: [5, 6],
$unshift: [0],
},
});
const result = customizationService.getCustomization('NumbersList');
expect(result).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
});
describe('mode customization', () => {
it('onModeEnter can add extensions', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [ohifOverlayItem],
};
customizationService.init(extensionManager);
// 3. Objects
describe('Objects', () => {
it('replaces entire object with $set', () => {
customizationService.setCustomizations({
SeriesInfo: {
$set: {
label: 'Series Number',
sortFunction: (a, b) => a?.SeriesNumber - b?.SeriesNumber,
views: ['3D'],
},
},
});
const result = customizationService.getCustomization('SeriesInfo');
expect(customizationService.getModeCustomization('testItem')).toBeUndefined();
customizationService.addModeCustomizations([testItem]);
expect(customizationService.getGlobalCustomization('testItem')).toBeUndefined();
const item = customizationService.getModeCustomization('testItem');
expect(item).not.toBeUndefined();
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');
expect(result.label).toBe('Series Number');
expect(result.sortFunction).not.toEqual(noop);
expect(result.views).toEqual(['3D']);
});
it('global customizations override modes', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.global'] = {
name: 'default',
value: [ohifOverlayItem],
};
configuration.testItem = testItem;
customizationService.init(extensionManager);
it('merges object fields with $merge over default', () => {
// Merge basic fields (in mode should override defaults)
customizationService.setCustomizations({
SeriesInfo: {
$merge: {
label: 'New Label',
extraField: true,
},
},
});
let result = customizationService.getCustomization('SeriesInfo');
// Add a mode customization that would otherwise fail below
customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
expect(result.label).toBe('New Label');
expect(result.extraField).toBe(true);
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);
// Merge deeper nested fields on the "advanced" property.
customizationService.setCustomizations({
SeriesInfo: {
advanced: {
$merge: {
subKey: 'updatedSubValue',
newSubKey: 123,
},
},
},
});
result = customizationService.getCustomization('SeriesInfo');
expect(result.advanced.subKey).toBe('updatedSubValue');
expect(result.advanced.newSubKey).toBe(123);
expect(result.advanced.anotherKey).toBe(42);
});
it('mode customizations override default', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [ohifOverlayItem, testItem],
};
customizationService.init(extensionManager);
it('applies a function to modify a property with $apply', () => {
customizationService.setCustomizations({
SeriesInfo: {
$apply: oldValue => ({
...oldValue,
label: 'Series Number (via $apply)',
}),
},
});
const result = customizationService.getCustomization('SeriesInfo');
// Add a mode customization that would otherwise fail below
customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
const item = customizationService.getCustomization('testItem');
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe('other');
expect(result.value).toBe(props.testAttribute);
expect(result.label).toBe('Series Number (via $apply)');
});
});
describe('merge', () => {
it('appends to global configuration', () => {
customizationService.init(extensionManager);
customizationService.setGlobalCustomization('appendSet', {
values: [{ id: 'one' }, { id: 'two' }],
});
const appendSet = customizationService.getCustomization('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setGlobalCustomization(
'appendSet',
{
values: [{ id: 'three' }],
// 4. Arrays of Objects
describe('Arrays of Objects', () => {
it('replaces entire array of objects using $set', () => {
customizationService.setCustomizations({
studyBrowser: {
$set: [
{
id: 'seriesNumber',
label: 'Series Number',
sortFunction: (a, b) => a?.SeriesNumber - b?.SeriesNumber,
},
{
id: 'seriesDate',
label: 'Series Date',
sortFunction: (a, b) => new Date(b?.SeriesDate) - new Date(a?.SeriesDate),
},
],
},
MergeEnum.Append
);
const appendSet2 = customizationService.getCustomization('appendSet');
expect(appendSet2.values.length).toBe(3);
});
const result = customizationService.getCustomization('studyBrowser');
expect(result.length).toBe(2);
expect(result[0].label).toBe('Series Number');
expect(result[1].label).toBe('Series Date');
});
it('appends mode to default without touching default', () => {
customizationService.init(extensionManager);
customizationService.setDefaultCustomization('appendSet', {
values: [{ id: 'one' }, { id: 'two' }],
});
const appendSet = customizationService.get('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ id: 'three' }],
it('updates array of objects with $push and $splice', () => {
// Append a new item using $push
customizationService.setCustomizations({
studyBrowser: {
$push: [
{
id: 'seriesNumber',
label: 'Series Number',
sortFunction: (a, b) => a?.SeriesNumber - b?.SeriesNumber,
},
],
},
MergeEnum.Append
);
});
expect(appendSet.values.length).toBe(2);
const appendSet2 = customizationService.getModeCustomization('appendSet');
expect(appendSet2.values.length).toBe(3);
let result = customizationService.getCustomization('studyBrowser');
expect(result.length).toBe(2);
expect(result[0].label).toBe('Series Date');
expect(result[1].label).toBe('Series Number');
// Insert at index 1 with $splice
customizationService.setCustomizations({
studyBrowser: {
$splice: [
[
1,
0,
{
id: 'anotherItem',
label: 'Another Item',
sortFunction: noop,
},
],
],
},
});
result = customizationService.getCustomization('studyBrowser');
expect(result.length).toBe(3);
expect(result[0].label).toBe('Series Date');
expect(result[1].label).toBe('Another Item');
});
});
// 5. Advanced Nested Structures
describe('Advanced Nested Structures', () => {
it('updates first level properties in advanced object', () => {
customizationService.setCustomizations({
advanced: {
firstLabel: {
$set: 'newLabel',
},
},
});
const result = customizationService.getCustomization('advanced');
expect(result.firstLabel).toBe('newLabel');
expect(result.functions).toBeDefined();
});
it('merges values by name/position', () => {
customizationService.init(extensionManager);
customizationService.setDefaultCustomization('appendSet', {
values: [{ id: 'one', obj: { v: '5' }, list: [1, 2, 3] }, { id: 'two' }],
});
const appendSet = customizationService.get('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ id: 'three', obj: { v: 2 }, list: [3, 2, 1, 4] }],
it('updates nested objects within functions array using $filter and $merge', () => {
customizationService.setCustomizations({
advanced: {
// filter an object that is inside the advanced object
// and then merge the object
$filter: {
match: { id: 'seriesDate' },
$merge: {
label: 'Series Data (via $filter)',
},
},
},
MergeEnum.Merge,
);
});
const appendSet2 = customizationService.get('appendSet');
const [value0] = appendSet2.values;
expect(value0.id).toBe('three');
expect(value0.list).toEqual([3, 2, 1, 4]);
const result = customizationService.getCustomization('advanced');
expect(result.functions.length).toBe(1);
expect(result.functions[0].label).toBe('Series Data (via $filter)');
});
it('merges functions', () => {
customizationService.init(extensionManager);
customizationService.setDefaultCustomization('appendSet', {
values: [{ f: () => 0, id: '0' }, { f: () => 5, id: '5' }],
it('updates deeply nested view functions using $filter', () => {
customizationService.setCustomizations({
advanced: {
$filter: {
match: { id: 'axial' },
$merge: {
label: 'Axial (via $filter)',
},
},
},
});
const appendSet = customizationService.get('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ f: () => 2, id: '2' }]
},
MergeEnum.Merge,
);
const result = customizationService.getCustomization('advanced');
const appendSet2 = customizationService.get('appendSet');
const [value0, value1] = appendSet2.values;
expect(value0.f()).toBe(2);
expect(value1.f()).toBe(5);
expect(result.functions.length).toBe(1);
expect(result.functions[0].viewFunctions[2].label).toBe('Axial (via $filter)');
});
});
it('merges list with object', () => {
customizationService.init(extensionManager);
// 6. Multiple Default Registrations
describe('Multiple Default Registrations', () => {
it('allows subsequent default registrations to enhance previous ones', () => {
customizationService = new CustomizationService({ commandsManager, configuration: {} });
const destination = [
1,
{ id: 'two', value: 2, list: [5, 6], },
{ id: 'three', value: 3 }
];
const source = {
two: { value: 'updated2', list: { 0: 8 } },
1: { extraValue: 2, list: [7], },
1.0001: { id: 'inserted', value: 1.0001 },
'-1': {
value: -3
},
// First extension registers its defaults
const firstExtensionDefaults = {
simpleList: [1, 2, 3],
};
customizationService.addReferences(firstExtensionDefaults, CustomizationScope.Default);
customizationService.setDefaultCustomization('appendSet', {
values: destination,
// Second extension enhances the first one's defaults
const secondExtensionDefaults = {
simpleList: { $push: [4, 5] },
};
customizationService.addReferences(secondExtensionDefaults, CustomizationScope.Default);
// Verify the final state combines both extensions' contributions
const result = customizationService.getCustomization('simpleList');
expect(result).toEqual([1, 2, 3, 4, 5]);
});
});
describe('CustomizationService - Inheritance (`inheritsFrom`)', () => {
it('inherits properties from the parent customization', () => {
// Register a parent customization
customizationService.setCustomizations(
{
'test.overlayItem': {
label: 'Default Label',
color: 'blue',
},
},
CustomizationScope.Default
);
// Register a child customization with `inheritsFrom`
customizationService.setCustomizations({
'viewportOverlay.topLeft.StudyDate': {
$set: {
inheritsFrom: 'test.overlayItem',
label: 'Study Date',
title: ' date',
},
},
});
customizationService.setModeCustomization('appendSet', {
values: source,
}, MergeEnum.Append);
const { values } = customizationService.getCustomization('appendSet');
const [zero, one, two, three] = values;
expect(zero).toBe(1);
expect(one.value).toBe('updated2');
expect(one.extraValue).toBe(2);
expect(one.list).toEqual([8, 6, 7]);
expect(two.id).toBe('inserted');
expect(three.value).toBe(-3);
const customization = customizationService.getCustomization(
'viewportOverlay.topLeft.StudyDate'
);
// Check that the inherited and overridden properties exist
expect(customization.label).toBe('Study Date'); // Overridden
expect(customization.color).toBe('blue'); // Inherited
});
it('executes transform methods from the parent customization', () => {
// Register a parent customization
customizationService.setCustomizations(
{
'test.overlayItem': {
$set: {
$transform: function () {
return {
label: this.label,
additionalKey: 'transformedValue',
};
},
},
},
},
CustomizationScope.Default
);
// Register a child customization with `inheritsFrom`
customizationService.setCustomizations({
'viewportOverlay.bottomRight.InstanceNumber': {
$set: {
inheritsFrom: 'test.overlayItem',
label: 'Instance Number',
title: 'Instance Title',
},
},
});
const customization = customizationService.getCustomization(
'viewportOverlay.bottomRight.InstanceNumber'
);
// Verify that the transform function from the parent is executed
expect(customization.additionalKey).toBe('transformedValue');
expect(customization.label).toBe('Instance Number');
expect(customization.title).toBe(undefined);
});
});
});
@@ -1,54 +1,36 @@
import { mergeWith, cloneDeepWith } from 'lodash';
import update, { extend } from 'immutability-helper';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { Customization, NestedStrings } from './types';
import type { Customization } from './types';
import type { CommandsManager } from '../../classes';
import type { ExtensionManager } from '../../extensions';
const EVENTS = {
MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified',
DEFAULT_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:defaultModified',
};
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;
};
/**
* Enum representing the different scopes of customizations available in the system.
*/
export enum CustomizationScope {
/**
* Global customizations that override both mode and default customizations.
* These are applied universally across the application.
*/
Global = 'global',
export enum MergeEnum {
/**
* Append values in the nested arrays
* Mode-specific customizations that are only active during a particular mode.
* These are cleared and reset when switching between modes.
*/
Append = 'Append',
/**
* Merge values, replacing arrays
*/
Merge = 'Merge',
/**
* Replace the given value - this is the default
*/
Replace = 'Replace',
}
Mode = 'mode',
export enum CustomizationType {
Global = 'Global',
Mode = 'Mode',
Default = 'Default',
/**
* Default customizations that serve as fallbacks when no global or mode-specific
* customizations are defined. These can only be defined once.
*/
Default = 'default',
}
/**
@@ -78,9 +60,12 @@ export enum CustomizationType {
* every module for the given id and to load it/add it to the extensions.
*/
export default class CustomizationService extends PubSubService {
public static EVENTS = EVENTS;
public Scope = CustomizationScope;
public static REGISTRATION = {
name: 'customizationService',
create: ({ configuration = {}, commandsManager }) => {
create: ({ configuration, commandsManager }) => {
return new CustomizationService({ configuration, commandsManager });
},
};
@@ -89,22 +74,25 @@ export default class CustomizationService extends PubSubService {
extensionManager: ExtensionManager;
/**
* mode customizations are changes to the default behaviour which are reset
* every time a new mode is entered. This allows the mode to define custom
* behaviour, and not interfere with other modes.
*/
private modeCustomizations = new Map<string, Customization>();
/**
* global customizations, are customizations which are set as a global default
* This allows changes across the board to be applied, essentially as a priority
* setting.
* A collection of global customizations that act as a priority layer.
* These customizations are applied universally, overriding both mode-specific
* and default customizations. Ideal for system-wide changes.
*/
private globalCustomizations = new Map<string, Customization>();
/**
* Default customizations allow applying default values. The intent is that
* there is only one customization of that type, and it is registered at setup
* time.
* A collection of mode-specific customizations. These allow modes to define
* their own behavior without impacting other modes. These customizations
* are cleared and redefined whenever a mode changes, ensuring isolation
* between modes. Read more about modes in the modes documentation.
*/
private modeCustomizations = new Map<string, Customization>();
/**
* A collection of default customizations used as fallbacks. These serve as
* the base configuration and are registered at setup. Default customizations
* provide baseline values that can be overridden by mode or global customizations.
* Use these for cases where default values are necessary for predictable behavior.
*/
private defaultCustomizations = new Map<string, Customization>();
@@ -113,13 +101,12 @@ export default class CustomizationService extends PubSubService {
* transform every time a customization is requested.
*/
private transformedCustomizations = new Map<string, Customization>();
configuration: any;
private configuration: AppTypes.Config;
constructor({ configuration, commandsManager }) {
super(EVENTS);
this.configuration = configuration;
this.commandsManager = commandsManager;
this.configuration = configuration || {};
}
public init(extensionManager: ExtensionManager): void {
@@ -128,33 +115,36 @@ export default class CustomizationService extends PubSubService {
this.defaultCustomizations.clear();
// Clear modes because those are defined in onModeEnter functions.
this.modeCustomizations.clear();
this.initDefaults();
this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => {
const keyDefault = `${extensionId}.customizationModule.default`;
const defaultCustomizations = this._findExtensionValue(keyDefault);
if (defaultCustomizations) {
const { value } = defaultCustomizations;
this._addReference(value, CustomizationScope.Default);
}
const keyGlobal = `${extensionId}.customizationModule.global`;
const globalCustomizations = this._findExtensionValue(keyGlobal);
if (globalCustomizations) {
const { value } = globalCustomizations;
this._addReference(value, CustomizationScope.Global);
}
});
this.addReferences(this.configuration);
}
initDefaults(): void {
this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => {
const keyDefault = `${extensionId}.customizationModule.default`;
const defaultCustomizations = this.findExtensionValue(keyDefault);
if (defaultCustomizations) {
const { value } = defaultCustomizations;
this.addReference(value, CustomizationType.Default);
}
const keyGlobal = `${extensionId}.customizationModule.global`;
const globalCustomizations = this.findExtensionValue(keyGlobal);
if (globalCustomizations) {
const { value } = globalCustomizations;
this.addReference(value, CustomizationType.Global);
}
});
}
findExtensionValue(value: string) {
const entry = this.extensionManager.getModuleEntry(value);
return entry as { value: Customization };
}
public onModeEnter(): void {
this.clearTransformedCustomizations();
this.init(this.extensionManager);
}
public onModeExit(): void {
this.clearTransformedCustomizations();
}
private clearTransformedCustomizations(): void {
super.reset();
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
@@ -165,65 +155,19 @@ export default class CustomizationService extends PubSubService {
this.modeCustomizations.clear();
}
public onModeExit(): void {
this.onModeEnter();
}
public getModeCustomizations(): Map<string, Customization> {
return this.modeCustomizations;
}
public setModeCustomization(
customizationId: string,
customization: Customization,
merge = MergeEnum.Merge
): void {
const defaultCustomization = this.defaultCustomizations.get(customizationId);
const modeCustomization = this.modeCustomizations.get(customizationId);
const globCustomization = this.globalCustomizations.get(customizationId);
const sourceCustomization =
modeCustomization ||
(globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
defaultCustomization ||
{};
// use the source merge type if not provided then fallback to merge
this.modeCustomizations.set(
customizationId,
this.mergeValue(sourceCustomization, customization, sourceCustomization.merge ?? merge)
);
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, {
buttons: this.modeCustomizations,
button: this.modeCustomizations.get(customizationId),
});
}
/**
* This is the preferred getter for all customizations,
* getting global (priority) customizations first,
* then mode customizations, and finally the default customization.
* Unified getter for customizations.
*
* @param customizationId - the customization id to look for
* @param defaultValue - is the default value to return.
* This value will be assigned as the default customization if there isn't
* currently a default customization, and thus, the first default provided
* will be used as the default - you cannot update this after or have it depend
* on changing values.
* Also, the value returned by the get customization has merges/updates applied,
* and is thus may be modified from the value provided, and may not be the original
* default provided. This allows applying the defaults for things like inheritance.
* @return A customization to use if one is found, or the default customization,
* both enhanced with any customizationType inheritance (see transform)
* @param customizationId - The ID of the customization to retrieve.
* @param scope - (Optional) The scope to retrieve from: 'global', 'mode', or 'default'.
* If not specified, it retrieves based on priority: global > mode > default.
* @returns The requested customization.
*/
public getCustomization(customizationId: string, defaultValue?: Customization): Customization {
public getCustomization(customizationId: string): Customization {
const transformed = this.transformedCustomizations.get(customizationId);
if (transformed) {
return transformed;
}
if (defaultValue && !this.defaultCustomizations.has(customizationId)) {
this.setDefaultCustomization(customizationId, defaultValue);
}
const customization =
this.globalCustomizations.get(customizationId) ??
this.modeCustomizations.get(customizationId) ??
@@ -235,197 +179,254 @@ export default class CustomizationService extends PubSubService {
return newTransformed;
}
/** Mode customizations are changes to the behavior of the extensions
* when running in a given mode. Reset clears mode customizations.
/**
* Takes an object with multiple properties, each property containing
* immutability-helper commands, and applies them one by one.
*
* Note that global customizations over-ride mode customizations
* Example:
* customizationService.setCustomizations({
* showAddSegment: { $set: false },
* NumbersList: { $push: [99] },
* }, CustomizationScope.Mode)
*
* @param defaultValue to return if no customization specified.
* Or you can simply apply a list of strings that are customization module items in the
* extension.
*
* Example:
* customizationService.setCustomizations(['@ohif/extension-cornerstone-dicom-seg.customizationModule.dicom-seg-sorts'], CustomizationScope.Mode)
*/
public getModeCustomization = this.getCustomization;
public setCustomizations(
customizations: string[] | Record<string, Customization>,
scope: CustomizationScope = CustomizationScope.Mode
): void {
if (Array.isArray(customizations)) {
customizations.forEach(customization => {
this._addReference(customization, scope);
});
} else {
Object.entries(customizations).forEach(([key, value]) => {
this._setCustomization(key, value, scope);
});
}
}
/**
* @deprecated Use setCustomizations instead
*/
public setCustomization(
customizationId: string,
customization: Customization | string,
scope: CustomizationScope = CustomizationScope.Mode
): void {
console.warn(
'setCustomization is deprecated. Please use setCustomizations with an object instead.'
);
this._setCustomization(customizationId, customization, scope);
}
/**
* Internal method to set a single customization
*/
private _setCustomization(
customizationId: string,
customization: Customization,
scope: CustomizationScope = CustomizationScope.Mode
): void {
// if (typeof customization === 'string') {
// const extensionValue = this._findExtensionValue(customization);
// customization = extensionValue.value;
// }
switch (scope) {
case CustomizationScope.Global:
this.setGlobalCustomization(customizationId, customization);
break;
case CustomizationScope.Mode:
this.setModeCustomization(customizationId, customization);
break;
case CustomizationScope.Default:
this.setDefaultCustomization(customizationId, customization);
break;
default:
throw new Error(`Invalid customization scope: ${scope}`);
}
}
/**
* Gets all customizations for a given scope.
*
* @param scope - The scope to retrieve customizations from: 'global', 'mode', or 'default'
* @returns A Map containing all customizations for the specified scope
*/
public getCustomizations(scope: CustomizationScope): Map<string, Customization> {
if (scope === CustomizationScope.Global) {
return this.globalCustomizations;
}
if (scope === CustomizationScope.Mode) {
return this.modeCustomizations;
}
return this.defaultCustomizations;
}
/**
* Returns true if there is a mode customization. Doesn't include defaults, but
* does return global overrides.
*/
public hasModeCustomization(customizationId: string) {
public hasCustomization(customizationId: string) {
return (
this.globalCustomizations.has(customizationId) || this.modeCustomizations.has(customizationId)
);
}
/**
* get is an alias for getModeCustomization, as it is the generic getter
* which will return both mode and global customizations, and should be
* used generally.
* Note that the second parameter, defaultValue, will be expanded to include
* any customizationType values defined in it, so it is not the same as doing:
* `customizationService.get('key') || defaultValue`
* unless the defaultValue does not contain any customizationType definitions.
*/
public get = this.getModeCustomization;
/**
* Applies any inheritance due to UI Type customization.
* This will look for customizationType in the customization object
* This will look for inheritsFrom in the customization object
* and if that is found, will assign all iterable values from that
* type into the new type, allowing default behaviour to be configured.
* type into the new type, allowing default behavior to be configured.
*/
public transform(customization: Customization): Customization {
if (!customization) {
return customization;
}
const { customizationType } = customization;
if (!customizationType) {
const { inheritsFrom } = customization;
if (!inheritsFrom) {
return customization;
}
const parent = this.getCustomization(customizationType);
const parent = this.getCustomization(inheritsFrom);
const result = parent ? Object.assign({}, parent, customization) : customization;
// Execute an nested type information
return result.transform?.(this) || result;
return result.$transform?.(this) || result;
}
/**
* Helper method to easily add and retrieve customizations
* @param id The unique identifier for the customization
* @param defaultComponent The default component to use if no customization is set
* @param customComponent Optional custom component to set
* @returns The custom component if set, otherwise the default component
*
* Sets a mode-specific customization.
*
* This method allows you to define or update a customization that applies only to the current mode.
* Mode customizations are temporary and isolated, reset whenever a mode changes.
*
* @param customizationId - The unique identifier for the customization.
* @param customization - The customization object containing the desired settings.
*/
public getCustomComponent(
id: string,
defaultComponent: React.ComponentType<any>,
customComponent?: React.ComponentType<any>
) {
const customization = this.getCustomization(id, {
id: `default-${id}`,
content: defaultComponent,
});
private setModeCustomization(customizationId: string, customization: Customization): void {
const defaultCustomization = this.defaultCustomizations.get(customizationId);
const modeCustomization = this.modeCustomizations.get(customizationId);
const globCustomization = this.globalCustomizations.get(customizationId);
if (customComponent) {
this.setModeCustomization(id, { content: customComponent });
}
const sourceCustomization =
modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
return customization.content;
}
const result = this._update(sourceCustomization, customization);
this.modeCustomizations.set(customizationId, result);
public addModeCustomizations(modeCustomizations): void {
if (!modeCustomizations) {
return;
}
this.addReferences(modeCustomizations, CustomizationType.Mode);
this._broadcastModeCustomizationModified();
}
_broadcastModeCustomizationModified(): void {
this._broadcastEvent(EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
modeCustomizations: this.modeCustomizations,
globalCustomizations: this.globalCustomizations,
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, {
buttons: this.modeCustomizations,
button: this.modeCustomizations.get(customizationId),
});
}
/** 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.transform(
this.globalCustomizations.get(id) ?? this.defaultCustomizations.get(id) ?? defaultValue
);
private setGlobalCustomization(id: string, value: Customization): void {
const defaultCustomization = this.defaultCustomizations.get(id);
const globCustomization = this.globalCustomizations.get(id);
const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization;
this.globalCustomizations.set(id, this._update(sourceCustomization, value));
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, {
buttons: this.defaultCustomizations,
button: this.defaultCustomizations.get(id),
});
}
private setDefaultCustomization(id: string, value: Customization): void {
if (this.defaultCustomizations.has(id)) {
console.warn(`Trying to update existing default for customization ${id}`);
}
this.transformedCustomizations.clear();
const sourceCustomization = this.defaultCustomizations.get(id);
this.defaultCustomizations.set(id, this._update(sourceCustomization, value));
this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, {
buttons: this.defaultCustomizations,
button: this.defaultCustomizations.get(id),
});
}
private _findExtensionValue(value: string) {
const entry = this.extensionManager.getModuleEntry(value);
return entry as { value: Customization };
}
/**
* Performs a merge, creating a new instance value - that is, not referencing
* the old one. This only works if you run once for the merge, so in general,
* the source value should be global, while the appends should be mode based.
* However, you can append to a global value too, as long as you ensure it
* only gets merged once.
* Registers a custom command to be used in customization updates.
* @param commandName - The name of the command (without the $ prefix)
* it will be prefixed with $
* @param handler - Function that handles the command it receives the value and the original value
*/
private mergeValue(oldValue, newValue, mergeType = MergeEnum.Replace) {
if (mergeType === MergeEnum.Replace) {
public registerCustomUpdateCommand(
commandName: string,
handler: (value: Customization, original: Customization) => Customization
): void {
if (!commandName.startsWith('$')) {
commandName = '$' + commandName;
}
extend(commandName, handler);
}
/**
* Uses immutability-helper to apply the user's commands (e.g. $set, $push, $apply, etc.)
* Takes into account the 'mergeType' if it's explicitly 'Replace'; otherwise does a normal update.
*/
private _update(oldValue: Customization | undefined, newValue: Customization): Customization {
if (!oldValue) {
oldValue = undefined;
}
// Use immutability-helper to apply the commands
// if $ is not part of the value in the json string, then we just return the newValue
if (!hasDollarKey(newValue)) {
return newValue;
}
const returnValue = mergeWith(
{},
oldValue,
newValue,
mergeType === MergeEnum.Append ? appendCustomizer : mergeCustomizer
);
return returnValue;
const result = update(oldValue, newValue);
return result;
}
public setGlobalCustomization(id: string, value: Customization, merge = MergeEnum.Replace): void {
const defaultCustomization = this.defaultCustomizations.get(id);
const globCustomization = this.globalCustomizations.get(id);
const sourceCustomization =
(globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
defaultCustomization ||
{};
this.globalCustomizations.set(
id,
this.mergeValue(sourceCustomization, value, value.merge ?? merge)
);
this.transformedCustomizations.clear();
this._broadcastGlobalCustomizationModified();
}
public setDefaultCustomization(
id: string,
value: Customization,
merge = MergeEnum.Replace
): void {
if (this.defaultCustomizations.has(id)) {
throw new Error(`Trying to update existing default for customization ${id}`);
private _cloneIfNeeded(value: any) {
// If it's null/undefined or not an object, return as is
if (!value || typeof value !== 'object') {
return value;
}
this.transformedCustomizations.clear();
this.defaultCustomizations.set(
id,
this.mergeValue(this.defaultCustomizations.get(id), value, merge)
);
// If it's an array, create a shallow copy
if (Array.isArray(value)) {
return [...value];
}
// Otherwise create a shallow copy of the object
return { ...value };
}
protected setConfigGlobalCustomization(configuration: AppConfigCustomization): void {
this.globalCustomizations.clear();
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 string to be loaded from a module,
* or a customization itself.
*/
addReference(value?, type = CustomizationType.Global, id?: string, merge?: MergeEnum): void {
_addReference(value?: any, type = CustomizationScope.Global): void {
if (!value) {
return;
}
if (typeof value === 'string') {
const extensionValue = this.findExtensionValue(value);
// The child of a reference is only a set of references when an array,
// so call the addReference direct. It could be a secondary reference perhaps
this.addReference(extensionValue.value, type, extensionValue.name, extensionValue.merge);
} else if (Array.isArray(value)) {
this.addReferences(value, type);
} else {
const useId = value.id || id;
const setName =
(type === CustomizationType.Global && 'setGlobalCustomization') ||
(type === CustomizationType.Default && 'setDefaultCustomization') ||
'setModeCustomization';
this[setName](useId as string, value, merge);
const extensionValue = this._findExtensionValue(value);
value = extensionValue.value;
}
Object.entries(value).forEach(([id, customization]) => {
const setName =
(type === CustomizationScope.Global && 'setGlobalCustomization') ||
(type === CustomizationScope.Default && 'setDefaultCustomization') ||
'setModeCustomization';
this[setName](id as string, customization as Customization);
});
}
/**
@@ -433,94 +434,106 @@ export default class CustomizationService extends PubSubService {
* or as an object whose key is the reference id, and the value is the string
* or customization.
*/
addReferences(references?, type = CustomizationType.Global): void {
addReferences(references?: any, type = CustomizationScope.Global): void {
if (!references) {
return;
}
if (Array.isArray(references)) {
references.forEach(item => {
this.addReference(item, type);
this._addReference(item, type);
});
} else {
for (const key of Object.keys(references)) {
const value = references[key];
this.addReference(value, type, key);
}
this._addReference(references, type);
}
}
}
/**
* Custom merging function, to handle merging arrays and copying functions
*/
function appendCustomizer(obj, src) {
if (Array.isArray(obj)) {
const srcArray = Array.isArray(src);
if (srcArray) {
return obj.concat(...src);
}
if (typeof src === 'object') {
const newList = obj.map(value => cloneDeepWith(value, cloneCustomizer));
for (const [key, value] of Object.entries(src)) {
const { position, isMerge } = findPosition(key, value, newList);
if (isMerge) {
if (typeof obj[position] === 'object') {
newList[position] = mergeWith(
Array.isArray(newList[position]) ? [] : {},
newList[position],
value,
appendCustomizer
);
} else {
newList[position] = value;
/** Add custom $filter command */
extend('$filter', (query, original) => {
// This helper checks if an object matches all key/value pairs in `match`
function objectMatches(item, matchObj) {
return (
item && typeof item === 'object' && Object.entries(matchObj).every(([k, v]) => item[k] === v)
);
}
// Recursively walk objects/arrays. Whenever we hit an array, we either filter
// or update items that match, depending on what was passed in via `query`.
function deepFilter(value, filterQuery) {
// If it's an array, apply the filtering/updating logic to each item
if (Array.isArray(value)) {
let result = value;
// 1) If it's a function, filter array items
if (typeof filterQuery === 'function') {
return value.filter(filterQuery);
}
// 2) If it's a string, remove items whose .id matches that string
if (typeof filterQuery === 'string') {
return value.filter(item => item.id !== filterQuery);
}
// 3) If it's an object with .match and .merge, apply the merge to matched items
if (typeof filterQuery === 'object' && filterQuery.match && filterQuery.$merge) {
// First recurse into sub-objects/arrays so we handle deeply nested arrays
result = value.map(item => deepFilter(item, filterQuery));
// Then update items that match
return result.map(item => {
if (objectMatches(item, filterQuery.match)) {
return { ...item, ...filterQuery.$merge };
}
} else {
newList.splice(position, 0, value);
}
return item;
});
}
return newList;
// 4) If it's an object with .id and .$merge, for backwards-compat
if (typeof filterQuery === 'object' && filterQuery.id && filterQuery.$merge) {
result = value.map(item => deepFilter(item, filterQuery));
return result.map(item => {
if (item.id === filterQuery.id) {
return { ...item, ...filterQuery.$merge };
}
return item;
});
}
// Otherwise, just recurse into sub-objects without filtering
return value.map(item => deepFilter(item, filterQuery));
}
return obj.concat(src);
}
return cloneCustomizer(src);
}
function mergeCustomizer(obj, src) {
return cloneCustomizer(src);
}
function findPosition(key, value, newList) {
const numVal = Number(key);
const isNumeric = !isNaN(numVal);
const { length: len } = newList;
if (isNumeric) {
if (newList[numVal < 0 ? numVal + len : numVal]) {
return { isMerge: true, position: (numVal + len) % len };
// If it's a plain object, recurse into its properties
if (value && typeof value === 'object') {
const newObj = { ...value };
for (const [key, val] of Object.entries(newObj)) {
newObj[key] = deepFilter(val, filterQuery);
}
return newObj;
}
const absPosition = Math.ceil(numVal < 0 ? len + numVal : numVal);
return { isMerge: false, position: Math.min(len, Math.max(absPosition, 0)) };
}
const findIndex = newList.findIndex(it => it.id === key);
if (findIndex !== -1) {
return { isMerge: true, position: findIndex };
}
const { _priority: priority } = value;
if (priority !== undefined) {
if (newList[(priority + len) % len]) {
return { isMerge: true, position: (priority + len) % len };
}
const absPosition = Math.ceil(priority < 0 ? len + priority : priority);
return { isMerge: false, position: Math.min(len, Math.max(absPosition, 0)) };
}
return { isMerge: false, position: len };
}
/**
* Custom cloning function to just copy function reference
*/
function cloneCustomizer(value) {
if (typeof value === 'function') {
// If it's neither array nor object, just return it
return value;
}
return deepFilter(original, query);
});
function hasDollarKey(value) {
if (Array.isArray(value)) {
for (const item of value) {
if (hasDollarKey(item)) {
return true;
}
}
} else if (value && typeof value === 'object') {
for (const key of Object.keys(value)) {
if (key.startsWith('$') && key !== '$transform') {
return true;
}
if (hasDollarKey(value[key])) {
return true;
}
}
}
return false;
}
@@ -4,8 +4,8 @@ import { ComponentType } from 'react';
export type Obj = Record<string, unknown>;
export interface BaseCustomization extends Obj {
id: string;
customizationType?: string;
id?: string;
inheritsFrom?: string;
description?: string;
label?: string;
commands?: Command[];
@@ -434,7 +434,9 @@ export default class HangingProtocolService extends PubSubService {
this._setProtocol(matchedProtocol);
}
this._commandsManager.run(this.protocol?.callbacks?.onProtocolEnter);
if (this.protocol?.callbacks?.onProtocolEnter) {
this._commandsManager.run(this.protocol?.callbacks?.onProtocolEnter);
}
}
/**
@@ -1028,7 +1030,9 @@ export default class HangingProtocolService extends PubSubService {
// before reassigning the protocol, we need to check if there is a callback
// on the old protocol that needs to be called
// Send the notification about updating the state
this._commandsManager.run(this.protocol?.callbacks?.onProtocolExit);
if (this.protocol?.callbacks?.onProtocolExit) {
this._commandsManager.run(this.protocol.callbacks.onProtocolExit);
}
this.protocol = protocol;