feat: Add extensibility for tmtv and segmentation modes (#6128)
* feat: Add extensibility for tmtv and segmentation modes * Fixes for ordering issues on laod * Remove unnecessary reference lookup * Chane side panel timing to fix tests * PR comments - change how mode definitions get created * Improvements to mode customizations * Start organizing customizations * Misc fixes for a customization demo page * Security fixes * PR requested changes to naming
This commit is contained in:
1 parent
f79055f98e
commit
b266c0a86a
49 files changed
+2653
-1215
No files matched your search
+180
@@ -0,0 +1,180 @@
|
||||
import CustomizationService, { CustomizationScope } from './CustomizationService';
|
||||
|
||||
/**
|
||||
* Tests for the `$reference` read-time resolution marker: composing
|
||||
* customizations by name, flattening pack lists, and — crucially — replacing a
|
||||
* reference wholesale with a subsequent `$set` (another reference or a
|
||||
* hard-coded value).
|
||||
*/
|
||||
describe('CustomizationService $reference', () => {
|
||||
let service: CustomizationService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CustomizationService({ configuration: {}, commandsManager: {} } as any);
|
||||
// Capability packs registered at Default scope (as an extension would).
|
||||
service.setCustomizations(
|
||||
{
|
||||
'cornerstone.toolbarButtons': [{ id: 'Length' }, { id: 'Pan' }],
|
||||
'cornerstone.segTools': [{ id: 'Brush' }, { id: 'Eraser' }],
|
||||
'other.toolbarButtons': [{ id: 'Zoom' }],
|
||||
// Object-valued pack (a tool block), used to test references as an
|
||||
// array item that resolves to a non-array (kept, not flattened).
|
||||
'cornerstone.annotationBlock': { passive: [{ toolName: 'Length' }] },
|
||||
},
|
||||
CustomizationScope.Default
|
||||
);
|
||||
});
|
||||
|
||||
it('flattens a referenced array into the surrounding list', () => {
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
|
||||
});
|
||||
|
||||
it('composes multiple packs and mixes in literals', () => {
|
||||
service.setCustomizations(
|
||||
{
|
||||
toolbarButtons: [
|
||||
{ $reference: 'cornerstone.toolbarButtons' },
|
||||
{ id: 'Custom' },
|
||||
{ $reference: 'other.toolbarButtons' },
|
||||
],
|
||||
},
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([
|
||||
{ id: 'Length' },
|
||||
{ id: 'Pan' },
|
||||
{ id: 'Custom' },
|
||||
{ id: 'Zoom' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves references inside object property values (toolGroupAdditions map)', () => {
|
||||
service.setCustomizations(
|
||||
{
|
||||
toolGroupAdditions: {
|
||||
default: [{ $reference: 'cornerstone.annotationBlock' }],
|
||||
mpr: [],
|
||||
},
|
||||
},
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('toolGroupAdditions')).toEqual({
|
||||
default: [{ passive: [{ toolName: 'Length' }] }],
|
||||
mpr: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('$push adds another reference that resolves and flattens', () => {
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: { $push: [{ $reference: 'cornerstone.segTools' }] } },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([
|
||||
{ id: 'Length' },
|
||||
{ id: 'Pan' },
|
||||
{ id: 'Brush' },
|
||||
{ id: 'Eraser' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('$set replaces a reference with a DIFFERENT reference', () => {
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: { $set: [{ $reference: 'other.toolbarButtons' }] } },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Zoom' }]);
|
||||
});
|
||||
|
||||
it('$set replaces a reference with a HARD-CODED list', () => {
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: { $set: [{ id: 'OnlyThis' }] } },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'OnlyThis' }]);
|
||||
});
|
||||
|
||||
it('a global-scope $set overrides a mode reference by scope precedence', () => {
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: { $set: [{ $reference: 'other.toolbarButtons' }] } },
|
||||
CustomizationScope.Global
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Zoom' }]);
|
||||
});
|
||||
|
||||
it('picks up live edits to the referenced pack', () => {
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toHaveLength(2);
|
||||
// Extend the pack itself; the referencing value reflects it on next read.
|
||||
service.setCustomizations(
|
||||
{ 'cornerstone.toolbarButtons': { $push: [{ id: 'Added' }] } },
|
||||
CustomizationScope.Global
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([
|
||||
{ id: 'Length' },
|
||||
{ id: 'Pan' },
|
||||
{ id: 'Added' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a whole-value reference (alias) and chains references', () => {
|
||||
service.setCustomizations(
|
||||
{
|
||||
'alias.buttons': { $reference: 'cornerstone.toolbarButtons' },
|
||||
toolbarButtons: [{ $reference: 'alias.buttons' }],
|
||||
},
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('alias.buttons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
|
||||
});
|
||||
|
||||
it('breaks reference cycles instead of looping', () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
service.setCustomizations(
|
||||
{
|
||||
cycleA: [{ $reference: 'cycleB' }],
|
||||
cycleB: [{ $reference: 'cycleA' }],
|
||||
},
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
// Should return without throwing; the cyclic branch resolves to nothing.
|
||||
expect(() => service.getCustomization('cycleA')).not.toThrow();
|
||||
expect(service.getCustomization('cycleA')).toEqual([]);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('warns and drops a reference to an unregistered customization', () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
service.setCustomizations(
|
||||
{ toolbarButtons: [{ $reference: 'does.not.exist' }, { id: 'Kept' }] },
|
||||
CustomizationScope.Mode
|
||||
);
|
||||
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Kept' }]);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -484,13 +484,98 @@ export default class CustomizationService extends PubSubService {
|
||||
this.globalCustomizations.get(customizationId) ??
|
||||
this.modeCustomizations.get(customizationId) ??
|
||||
this.defaultCustomizations.get(customizationId);
|
||||
const newTransformed = this.transform(customization);
|
||||
// Apply `inheritsFrom` / `$transform`, then expand any `$reference`
|
||||
// markers (see `_resolveReferences`). `seen` starts with the id being read
|
||||
// so a value that references itself is caught as a cycle.
|
||||
const newTransformed = this._resolveReferences(
|
||||
this.transform(customization),
|
||||
new Set([customizationId])
|
||||
);
|
||||
if (newTransformed !== undefined) {
|
||||
this.transformedCustomizations.set(customizationId, newTransformed);
|
||||
}
|
||||
return newTransformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands `$reference` markers inside a resolved customization value.
|
||||
*
|
||||
* A `{ $reference: '<name>' }` object is replaced by the value of the
|
||||
* customization `<name>` (itself resolved recursively, so references can
|
||||
* chain). References may appear anywhere in a value:
|
||||
* - as the whole value — an alias for another customization;
|
||||
* - as an item in an **array** — if the referenced value is itself an
|
||||
* array it is spread (flattened) into the parent, so a list can compose
|
||||
* several capability packs by name (e.g. a mode's `toolbarButtons`);
|
||||
* - as a property value of a **plain object** (e.g. each list under a
|
||||
* `toolGroupAdditions` map).
|
||||
*
|
||||
* Because expansion happens at read time (not when customizations are
|
||||
* merged), a later `$set` replaces the reference wholesale — with a different
|
||||
* `{ $reference }` or a hard-coded value — and edits to the referenced target
|
||||
* are picked up live. Only plain arrays/objects are walked; class instances,
|
||||
* functions and React elements are returned untouched, and unchanged values
|
||||
* are returned by identity so non-referencing customizations are not cloned.
|
||||
* Cycles are broken and warned via `seen`.
|
||||
*/
|
||||
private _resolveReferences(value: any, seen: Set<string>): any {
|
||||
if (!value || typeof value !== 'object' || value.$$typeof) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value.$reference === 'string') {
|
||||
return this._resolveReferenceName(value.$reference, seen);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const result: any[] = [];
|
||||
for (const item of value) {
|
||||
if (item && typeof item === 'object' && !item.$$typeof && typeof item.$reference === 'string') {
|
||||
changed = true;
|
||||
const resolved = this._resolveReferenceName(item.$reference, seen);
|
||||
if (Array.isArray(resolved)) {
|
||||
result.push(...resolved);
|
||||
} else if (resolved !== undefined) {
|
||||
result.push(resolved);
|
||||
}
|
||||
} else {
|
||||
const resolved = this._resolveReferences(item, seen);
|
||||
changed ||= resolved !== item;
|
||||
result.push(resolved);
|
||||
}
|
||||
}
|
||||
return changed ? result : value;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
let changed = false;
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
const resolved = this._resolveReferences(val, seen);
|
||||
changed ||= resolved !== val;
|
||||
result[key] = resolved;
|
||||
}
|
||||
return changed ? result : value;
|
||||
}
|
||||
|
||||
/** Resolves a single `$reference` target name, guarding against cycles. */
|
||||
private _resolveReferenceName(name: string, seen: Set<string>): any {
|
||||
if (seen.has(name)) {
|
||||
console.warn(`CustomizationService: $reference cycle detected at "${name}"`);
|
||||
return undefined;
|
||||
}
|
||||
const raw =
|
||||
this.globalCustomizations.get(name) ??
|
||||
this.modeCustomizations.get(name) ??
|
||||
this.defaultCustomizations.get(name);
|
||||
if (raw === undefined) {
|
||||
console.warn(`CustomizationService: no customization registered for $reference "${name}"`);
|
||||
return undefined;
|
||||
}
|
||||
const nextSeen = new Set(seen).add(name);
|
||||
return this._resolveReferences(this.transform(raw), nextSeen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a customization value, or the provided fallback when unset.
|
||||
*/
|
||||
@@ -1112,6 +1197,15 @@ export function normalizeCustomizationConfig(configuration: unknown): {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** True for `{}`-literal / null-prototype objects (not arrays or class instances). */
|
||||
function isPlainObject(value: any): boolean {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
function hasDollarKey(value) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
@@ -1128,7 +1222,11 @@ function hasDollarKey(value) {
|
||||
return false;
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key.startsWith('$') && key !== '$transform') {
|
||||
// `$transform` and `$reference` are read-time markers resolved by the
|
||||
// service (in `transform` / `_resolveReferences`), not immutability-helper
|
||||
// merge commands — so a value carrying them is stored verbatim rather than
|
||||
// being run through `update()`.
|
||||
if (key.startsWith('$') && key !== '$transform' && key !== '$reference') {
|
||||
return true;
|
||||
}
|
||||
if (hasDollarKey(value[key])) {
|
||||
|
||||
Reference in new issue
Block a user