feat: Add customization URL parameter (#5992)
* Add customization URL parameter * fix: Preserve should be customizeable * Update customizations docs * fix: Overlay items on patient name * Add customization test * Fix resolve to absolute path * fix: Warn on no data in load * Remove unused customization stuff * fix: PR comments * Update stored parameters to only use an array for mulitples * Remove requires ohif.* special call out * Remove strict mode * PR comments * Document segmentation examples * Add three examples as requested * PR comments * lock * Remove old customizatoin export * fix: Ordering issues on customization loads * fix: Use correct default for dev builds app config * Fixes for conflicts * chore: restore pnpm-lock.yaml to match master The lockfile diff was incidental peer-descriptor churn and carried no functional dependency change. It tripped the CircleCI security-audit gate (which only runs when pnpm-lock.yaml is in the PR diff), surfacing a pre-existing critical `decompress` transitive vuln that also exists on master. Restoring master's lockfile removes the audit trigger. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): restore json5 lockfile entry; ignore unfixable decompress GHSA The previous commit restored pnpm-lock.yaml from master, which dropped the json5@2.2.3 entry that platform/core legitimately depends on (JSONC parsing for the customization feature). That broke `--frozen-lockfile` install (ERR_PNPM_OUTDATED_LOCKFILE). This restores the correct lockfile. Because the lockfile must change (json5), the CircleCI security-audit gate runs and previously failed on a critical `decompress` <=4.2.1 zip-slip advisory. This is a pre-existing transitive vuln (present on master too) with no published patch — decompress's latest release is 4.2.1, so no version bump/override can resolve it. It reaches the tree only via @itk-wasm/dam, a build/data-asset extraction tool under @cornerstonejs/labelmap-interpolation. Add GHSA-mp2f-45pm-3cg9 to the existing pnpm-workspace.yaml auditConfig ignoreGhsas accepted-risk list, matching how the repo already exempts other build-tooling advisories. `pnpm audit --audit-level high` now passes locally (1 critical ignored, 0 high). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): fix visitStudy URL encoding that broke mpr2 study load The visitStudy rewrite (added for the ?customization= option) built the URL with new URLSearchParams({ StudyInstanceUIDs: studyInstanceUID }), which percent-encodes the value. mpr2.spec.ts embeds an extra param in the UID string ('<uid>&hangingprotocolid=mpr'), so the & and = were encoded and the whole thing collapsed into one invalid StudyInstanceUIDs value -> the study could not be found ('studies are not available'), the viewer never rendered, and the side-panel-header-right click timed out. Restore master's raw concatenation for StudyInstanceUIDs (so embedded params survive as separate query params) while still appending the customization option separately. Only mpr2 embeds & in the UID, matching the single failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR comments --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3d9a17bc0c
commit
3dd5c70cb2
58 files changed
+3411
-586
No files matched your search
@@ -49,6 +49,7 @@
|
||||
"gl-matrix": "3.4.3",
|
||||
"immutability-helper": "3.1.1",
|
||||
"isomorphic-base64": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.isequal": "4.5.0",
|
||||
"moment": "2.30.1",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import CustomizationService from './CustomizationService';
|
||||
|
||||
const commandsManager = {};
|
||||
|
||||
describe('CustomizationService.init (extension default/global deduplication)', () => {
|
||||
it('does not re-merge the same extension customization modules on repeated init', () => {
|
||||
const getModuleEntry = jest.fn((id: string) => {
|
||||
if (id === 'ext1.customizationModule.default') {
|
||||
return { value: { fromDefault: { $set: 1 } } };
|
||||
}
|
||||
if (id === 'ext1.customizationModule.global') {
|
||||
return { value: { fromGlobal: { $set: 'g' } } };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const extensionManager = {
|
||||
registeredExtensionIds: ['ext1'],
|
||||
getRegisteredExtensionIds: () => ['ext1'],
|
||||
getModuleEntry,
|
||||
};
|
||||
|
||||
const service = new CustomizationService({ commandsManager, configuration: {} });
|
||||
service.init(extensionManager as any);
|
||||
service.init(extensionManager as any);
|
||||
|
||||
expect(getModuleEntry).toHaveBeenCalledTimes(2);
|
||||
expect(service.getCustomization('fromDefault')).toBe(1);
|
||||
expect(service.getCustomization('fromGlobal')).toBe('g');
|
||||
});
|
||||
|
||||
it('merges a module the first time it appears on a later init', () => {
|
||||
const moduleEntries: Record<string, { value: Record<string, unknown> }> = {};
|
||||
|
||||
const extensionManager = {
|
||||
registeredExtensionIds: ['ext2'],
|
||||
getRegisteredExtensionIds: () => ['ext2'],
|
||||
getModuleEntry: (id: string) => moduleEntries[id],
|
||||
};
|
||||
|
||||
const service = new CustomizationService({ commandsManager, configuration: {} });
|
||||
service.init(extensionManager as any);
|
||||
expect(service.getCustomization('late')).toBeUndefined();
|
||||
|
||||
moduleEntries['ext2.customizationModule.default'] = { value: { late: { $set: true } } };
|
||||
service.init(extensionManager as any);
|
||||
|
||||
expect(service.getCustomization('late')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import CustomizationService, { GENERAL_MODE_KEY } from './CustomizationService';
|
||||
|
||||
const commandsManager = {};
|
||||
|
||||
const policy = {
|
||||
prefixes: { default: './customizations/' },
|
||||
};
|
||||
|
||||
/** Minimal ExtensionManager stub exposing the appConfig the URL policy reads. */
|
||||
function makeExtensionManager(appConfig: Record<string, unknown> = {}) {
|
||||
return {
|
||||
appConfig: { customizationUrlPrefixes: policy.prefixes, ...appConfig },
|
||||
registeredExtensionIds: [],
|
||||
getRegisteredExtensionIds: () => [],
|
||||
getModuleEntry: () => undefined,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('CustomizationService phase-tagged loading', () => {
|
||||
it('applies bootstrap before extensions and global after, both Global scope', async () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: {
|
||||
bootstrap: { early: { $set: 'pre' } },
|
||||
global: { late: { $set: 'post' } },
|
||||
},
|
||||
});
|
||||
|
||||
const extensionManager = makeExtensionManager();
|
||||
await service.loadAndApplyBootstrapCustomizations(extensionManager);
|
||||
|
||||
// bootstrap is applied immediately; global is not yet.
|
||||
expect(service.getCustomization('early')).toBe('pre');
|
||||
expect(service.getCustomization('late')).toBeUndefined();
|
||||
|
||||
service.init(extensionManager);
|
||||
service.applyGlobalCustomizations();
|
||||
|
||||
expect(service.getCustomization('late')).toBe('post');
|
||||
expect(service.getCustomizations(service.Scope.Global).get('early')).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not treat a phase-tagged config as legacy Global references', () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: { global: { a: { $set: 1 } } },
|
||||
});
|
||||
service.init(makeExtensionManager());
|
||||
// `global` must NOT be added as a customization id during init; it is a phase.
|
||||
expect(service.getCustomization('global')).toBeUndefined();
|
||||
expect(service.getCustomization('a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies the general mode block first, then the mode-specific block', () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: {
|
||||
mode: {
|
||||
[GENERAL_MODE_KEY]: { greeting: { $set: 'general' }, shared: { $set: 'general' } },
|
||||
viewer: { greeting: { $set: 'viewer' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
service.init(makeExtensionManager());
|
||||
|
||||
service.onModeEnter();
|
||||
service.applyModeCustomizations(['viewer']);
|
||||
|
||||
// mode-specific overrides general; non-overridden general value remains.
|
||||
expect(service.getCustomization('greeting')).toBe('viewer');
|
||||
expect(service.getCustomization('shared')).toBe('general');
|
||||
});
|
||||
|
||||
it('matches a mode block by id OR routeName', () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: {
|
||||
mode: { viewer: { fromRouteName: { $set: true } } },
|
||||
},
|
||||
});
|
||||
service.init(makeExtensionManager());
|
||||
|
||||
service.onModeEnter();
|
||||
service.applyModeCustomizations(['@ohif/mode-longitudinal', 'viewer']);
|
||||
expect(service.getCustomization('fromRouteName')).toBe(true);
|
||||
});
|
||||
|
||||
it('clears mode-scope customizations on re-enter so a different mode does not leak', () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: {
|
||||
mode: {
|
||||
viewer: { onlyViewer: { $set: true } },
|
||||
segmentation: { onlySeg: { $set: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
service.init(makeExtensionManager());
|
||||
|
||||
service.onModeEnter();
|
||||
service.applyModeCustomizations(['viewer']);
|
||||
expect(service.getCustomization('onlyViewer')).toBe(true);
|
||||
|
||||
service.onModeEnter();
|
||||
service.applyModeCustomizations(['segmentation']);
|
||||
expect(service.getCustomization('onlyViewer')).toBeUndefined();
|
||||
expect(service.getCustomization('onlySeg')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies phase blocks from URL-loaded modules and merges $apply over extension defaults', async () => {
|
||||
const service = new CustomizationService({ commandsManager, configuration: {} });
|
||||
const extensionManager = makeExtensionManager();
|
||||
|
||||
// Seed an extension-style default so $apply has something to build on.
|
||||
service.init(extensionManager);
|
||||
service.setCustomizations({ 'list.columns': ['a', 'b'] }, service.Scope.Default);
|
||||
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: { 'list.columns': { $push: ['c'] } },
|
||||
mode: { '*': { banner: { $set: 'all' } }, viewer: { banner: { $set: 'viewer' } } },
|
||||
}));
|
||||
|
||||
await service.loadCustomizationModules(['A'], { policy, importFn });
|
||||
service.applyGlobalCustomizations();
|
||||
expect(service.getCustomization('list.columns')).toEqual(['a', 'b', 'c']);
|
||||
|
||||
service.applyModeCustomizations(['viewer']);
|
||||
expect(service.getCustomization('banner')).toBe('viewer');
|
||||
});
|
||||
});
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import CustomizationService from './CustomizationService';
|
||||
|
||||
const commandsManager = {};
|
||||
|
||||
const policy = {
|
||||
prefixes: { default: './customizations/' },
|
||||
};
|
||||
|
||||
describe('CustomizationService.requires (URL customization modules)', () => {
|
||||
let service: CustomizationService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CustomizationService({ commandsManager, configuration: {} });
|
||||
});
|
||||
|
||||
it('loads a single module', async () => {
|
||||
const importFn = jest.fn(async (url: string) => ({
|
||||
global: { entry: { value: url } },
|
||||
}));
|
||||
const loaded = await service.requires(['A'], { policy, importFn });
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].request.name).toBe('A');
|
||||
expect(loaded[0].module.global).toBeDefined();
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns immediately when the same module is already loaded', async () => {
|
||||
const importFn = jest.fn(async (url: string) => ({
|
||||
global: { entry: { value: url } },
|
||||
}));
|
||||
await service.requires(['A'], { policy, importFn });
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
const loadedAgain = await service.requires(['A'], { policy, importFn });
|
||||
expect(loadedAgain).toHaveLength(0);
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loads dependencies first via module requires', async () => {
|
||||
const importFn = jest.fn(async (url: string) => {
|
||||
if (url.endsWith('/A.jsonc')) {
|
||||
return {
|
||||
global: { 'pkg.A': { value: 'A' } },
|
||||
requires: ['B'],
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/B.jsonc')) {
|
||||
return {
|
||||
global: { 'pkg.B': { value: 'B' } },
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const loaded = await service.requires(['A'], { policy, importFn });
|
||||
|
||||
expect(loaded.map(l => l.request.name)).toEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('handles cycles per the spec: A requires B, B requires A => B then A', async () => {
|
||||
const importFn = jest.fn(async (url: string) => {
|
||||
if (url.endsWith('/A.jsonc')) {
|
||||
return {
|
||||
global: { 'pkg.A': { value: 'A' } },
|
||||
requires: ['B'],
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/B.jsonc')) {
|
||||
return {
|
||||
global: { 'pkg.B': { value: 'B' } },
|
||||
requires: ['A'],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const loaded = await service.requires(['A'], { policy, importFn });
|
||||
|
||||
expect(loaded.map(l => l.request.name)).toEqual(['B', 'A']);
|
||||
expect(importFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not treat customization field refs as URL dependencies', async () => {
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: {
|
||||
'viewportOverlay.topLeft.X': { customization: 'ohif.overlayItem' },
|
||||
},
|
||||
}));
|
||||
const loaded = await service.requires(['A'], { policy, importFn });
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('parses comma-separated names like the URL query integration', async () => {
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: { 'pkg.X': {} },
|
||||
}));
|
||||
const loaded = await service.requires(['A', 'B', 'C'], { policy, importFn });
|
||||
expect(loaded.map(l => l.request.name)).toEqual(['A', 'B', 'C']);
|
||||
expect(importFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('throws and loads nothing when any entry is rejected', async () => {
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: {},
|
||||
}));
|
||||
await expect(
|
||||
service.requires(['A', '/missing/foo', '../escape'], { policy, importFn })
|
||||
).rejects.toThrow(/refusing to load customization/);
|
||||
expect(importFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips when import fails', async () => {
|
||||
const importFn = jest.fn(async () => {
|
||||
throw new Error('404');
|
||||
});
|
||||
const warn = jest.fn();
|
||||
const loaded = await service.requires(['A'], {
|
||||
policy,
|
||||
importFn,
|
||||
logger: { warn, error: jest.fn() },
|
||||
});
|
||||
expect(loaded).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips when the module has no customization payload', async () => {
|
||||
const importFn = jest.fn(async () => ({}));
|
||||
const warn = jest.fn();
|
||||
const loaded = await service.requires(['A'], {
|
||||
policy,
|
||||
importFn,
|
||||
logger: { warn, error: jest.fn() },
|
||||
});
|
||||
expect(loaded).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applyCustomizationUrlSearchParams delegates to requires', async () => {
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: { 'pkg.X': {} },
|
||||
}));
|
||||
const params = new URLSearchParams();
|
||||
params.append('customization', 'A,B');
|
||||
params.append('customization', 'C');
|
||||
await service.applyCustomizationUrlSearchParams(params, { policy, importFn });
|
||||
expect(importFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,33 @@
|
||||
import update, { extend } from 'immutability-helper';
|
||||
import JSON5 from 'json5';
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import type { Customization } from './types';
|
||||
import type { CommandsManager } from '../../classes';
|
||||
import type { ExtensionManager } from '../../extensions';
|
||||
import type ExtensionManager from '../../extensions/ExtensionManager';
|
||||
import { getCustomizationUrlPolicy } from './customizationUrl';
|
||||
import { getUrlCustomizationModulePayload } from './getUrlCustomizationModulePayload';
|
||||
import { resolveCustomizationUrl } from './resolve';
|
||||
import {
|
||||
parseCustomizationParams,
|
||||
validateCustomizationRequests,
|
||||
} from './validate';
|
||||
import type { ValidatedCustomization } from './validate';
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import type {
|
||||
CustomizationModule,
|
||||
CustomizationPhaseInput,
|
||||
LoadedCustomization,
|
||||
LoadOptions,
|
||||
PhasedCustomizationConfig,
|
||||
} from './customizationUrlTypes';
|
||||
|
||||
/**
|
||||
* Reserved key in a `mode` phase block for the "general" customizations that
|
||||
* apply to every mode. The general block is applied FIRST on each mode enter;
|
||||
* a block keyed by the entered mode's id / routeName is applied after it so a
|
||||
* single mode can override the general values.
|
||||
*/
|
||||
export const GENERAL_MODE_KEY = '*';
|
||||
|
||||
const EVENTS = {
|
||||
MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
|
||||
@@ -28,7 +53,8 @@ export enum CustomizationScope {
|
||||
|
||||
/**
|
||||
* Default customizations that serve as fallbacks when no global or mode-specific
|
||||
* customizations are defined. These can only be defined once.
|
||||
* customizations are defined. These are not cleared when the service re-inits
|
||||
* for a mode change; only Mode scope is reset.
|
||||
*/
|
||||
Default = 'default',
|
||||
}
|
||||
@@ -89,10 +115,9 @@ export default class CustomizationService extends PubSubService {
|
||||
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.
|
||||
* A collection of default customizations used as fallbacks. Entries are merged
|
||||
* over time (including from `init()` re-reading extension default modules) and
|
||||
* are not cleared on mode change. Mode and Global scopes override these values.
|
||||
*/
|
||||
private defaultCustomizations = new Map<string, Customization>();
|
||||
|
||||
@@ -103,41 +128,334 @@ export default class CustomizationService extends PubSubService {
|
||||
private transformedCustomizations = new Map<string, Customization>();
|
||||
private configuration: AppTypes.Config;
|
||||
|
||||
/**
|
||||
* URL customization modules already imported and applied (key = normalized `/prefix/name`).
|
||||
* Entries are kept for the lifetime of the page: repeated loads skip imports, and the app
|
||||
* normally applies `?customization=` only at bootstrap (see {@link applyWindowUrlCustomizations}).
|
||||
*/
|
||||
private _urlCustomizationLoaded = new Map<string, LoadedCustomization>();
|
||||
|
||||
private _urlCustomizationPending = new Map<string, Promise<LoadedCustomization | null>>();
|
||||
|
||||
/**
|
||||
* Every URL customization module resolved this page session, in load order
|
||||
* (dependencies before dependents). The lifecycle phase appliers
|
||||
* ({@link applyBootstrapCustomizations}, {@link applyGlobalCustomizations},
|
||||
* {@link applyModeCustomizations}) iterate this list so a module's phase
|
||||
* blocks are applied at the right time regardless of when it was fetched.
|
||||
*/
|
||||
private _resolvedUrlModules: LoadedCustomization[] = [];
|
||||
|
||||
/**
|
||||
* Normalized form of `appConfig.customizationService`. Either a phase-tagged
|
||||
* config ({@link PhasedCustomizationConfig}) or, for the legacy array/object
|
||||
* form, the references to add (Global) during {@link init}.
|
||||
*/
|
||||
private _customizationConfig: {
|
||||
phased?: PhasedCustomizationConfig;
|
||||
legacyReferences?: unknown;
|
||||
} | null = null;
|
||||
|
||||
/** Id/aliases of the mode currently entered, used to re-apply mode phase blocks. */
|
||||
private _currentModeIds: string[] = [];
|
||||
|
||||
/**
|
||||
* Extension module entry ids (e.g. `${extensionId}.customizationModule.default`) whose
|
||||
* default/global payloads have already been merged via {@link init}. Matches the URL
|
||||
* loader pattern: repeated {@link init} skips work for the same slot so immutability-style
|
||||
* merges are not applied twice. A slot is recorded only after a module was present and applied;
|
||||
* if a module appears only on a later {@link init} (e.g. a newly registered extension), it is merged then.
|
||||
*/
|
||||
private _extensionCustomizationModuleApplied = new Set<string>();
|
||||
|
||||
constructor({ configuration, commandsManager }) {
|
||||
super(EVENTS);
|
||||
this.configuration = configuration;
|
||||
this.commandsManager = commandsManager;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Clears mode customizations and merges each extension's `customizationModule.default` /
|
||||
* `customizationModule.global` into the service. Safe to call multiple times (e.g. from
|
||||
* {@link onModeEnter}): each extension module slot is merged at most once per page session,
|
||||
* matching the deduplication pattern used for URL-loaded modules in {@link requires}.
|
||||
* Slots with no module yet are left unmarked so a later call can merge when the module appears.
|
||||
*/
|
||||
public init(extensionManager: ExtensionManager): void {
|
||||
this.extensionManager = extensionManager;
|
||||
// Clear defaults as those are defined by the customization modules
|
||||
this.defaultCustomizations.clear();
|
||||
// Clear modes because those are defined in onModeEnter functions.
|
||||
// Mode customizations are defined per mode in onModeEnter; reset them here.
|
||||
// Default customizations are not cleared — they are merged again from
|
||||
// extension modules below so definitions stay available across mode changes.
|
||||
this.modeCustomizations.clear();
|
||||
|
||||
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);
|
||||
if (!this._extensionCustomizationModuleApplied.has(keyDefault)) {
|
||||
const defaultCustomizations = this._findExtensionValue(keyDefault);
|
||||
if (defaultCustomizations) {
|
||||
const { value } = defaultCustomizations;
|
||||
this._addReference(value, CustomizationScope.Default);
|
||||
this._extensionCustomizationModuleApplied.add(keyDefault);
|
||||
}
|
||||
}
|
||||
const keyGlobal = `${extensionId}.customizationModule.global`;
|
||||
const globalCustomizations = this._findExtensionValue(keyGlobal);
|
||||
if (globalCustomizations) {
|
||||
const { value } = globalCustomizations;
|
||||
this._addReference(value, CustomizationScope.Global);
|
||||
if (!this._extensionCustomizationModuleApplied.has(keyGlobal)) {
|
||||
const globalCustomizations = this._findExtensionValue(keyGlobal);
|
||||
if (globalCustomizations) {
|
||||
const { value } = globalCustomizations;
|
||||
this._addReference(value, CustomizationScope.Global);
|
||||
this._extensionCustomizationModuleApplied.add(keyGlobal);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only add references for the configuration once.
|
||||
if (!this.configuration?._hasBeenAdded) {
|
||||
this.addReferences(this.configuration);
|
||||
// Only add references for the configuration once. The phase-tagged config
|
||||
// form (bootstrap/global/mode) is applied by the lifecycle appliers
|
||||
// instead, so only the legacy array/object form is added here as Global.
|
||||
const config = this._getCustomizationConfig();
|
||||
if (config.legacyReferences !== undefined && !this.configuration?._hasBeenAdded) {
|
||||
this.addReferences(config.legacyReferences);
|
||||
Object.defineProperty(this.configuration, '_hasBeenAdded', { value: true, writable: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized, normalized view of `appConfig.customizationService`. Detects the
|
||||
* phase-tagged form (any of `requires` / `bootstrap` / `global` / `mode`)
|
||||
* and otherwise treats the value as legacy Global references.
|
||||
*/
|
||||
private _getCustomizationConfig(): { phased?: PhasedCustomizationConfig; legacyReferences?: unknown } {
|
||||
if (!this._customizationConfig) {
|
||||
this._customizationConfig = normalizeCustomizationConfig(this.configuration);
|
||||
}
|
||||
return this._customizationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and applies `?customization=` modules from `window.location.search`.
|
||||
*
|
||||
* **Throws on disallowed values.** A `?customization=` value whose prefix is
|
||||
* not in `appConfig.customizationUrlPrefixes` (the default — the feature is off
|
||||
* until prefixes are configured) rejects this promise, which by design aborts
|
||||
* app bootstrap rather than silently ignoring the request.
|
||||
*
|
||||
* **Intended SPA behavior:** The shell typically calls this once during startup. It does not
|
||||
* run again on client-side route changes. The query key `customization` may still appear in
|
||||
* URLs (for example preserved by worklist navigation) without implying that modules are
|
||||
* re-evaluated on every navigation. Modules resolved here are also deduplicated by normalized
|
||||
* URL for the lifetime of the page in {@link requires}. To pick up a different `?customization=`
|
||||
* set, use a full page load or call {@link applyCustomizationUrlSearchParams} /
|
||||
* {@link requires} from your own integration code when appropriate.
|
||||
*
|
||||
* **What is loaded:** Each `?customization=` entry resolves to a JSONC file (JSON with
|
||||
* comments / trailing commas) under a configured prefix directory. The file is fetched and
|
||||
* parsed as **data** — it is never executed. Executable code (plugins, modes, extensions)
|
||||
* loads only through `pluginConfig.json`, never from the customization URL path. A module's
|
||||
* `global` payload is applied as global customizations and its `requires` are loaded first.
|
||||
*/
|
||||
public async applyWindowUrlCustomizations(overrides?: Partial<LoadOptions>): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
await this.applyCustomizationUrlSearchParams(
|
||||
new URLSearchParams(window.location.search),
|
||||
overrides
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `?customization=` values from the search string and delegates to
|
||||
* {@link requires}.
|
||||
*/
|
||||
public async applyCustomizationUrlSearchParams(
|
||||
params: URLSearchParams,
|
||||
overrides?: Partial<LoadOptions>
|
||||
): Promise<void> {
|
||||
const raws = parseCustomizationParams(params);
|
||||
if (!raws.length) {
|
||||
return;
|
||||
}
|
||||
await this.requires(raws, overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-first dynamic import of URL customization modules
|
||||
* `requires` edges and `customization` field
|
||||
* references are loaded before dependents. Already-loaded modules (same normalized key) are
|
||||
* skipped for the rest of the page session; they are not unloaded when the address bar changes.
|
||||
*
|
||||
* Invalid query entries, resolve failures, failed imports, and modules without a
|
||||
* customization payload are warned and skipped.
|
||||
*/
|
||||
public requires(
|
||||
names: string | string[],
|
||||
overrides?: Partial<LoadOptions>
|
||||
): Promise<LoadedCustomization[]> {
|
||||
return this.loadCustomizationModules(names, overrides).then(newlyLoaded => {
|
||||
// Back-compat: a direct `requires()` / `applyWindowUrlCustomizations()`
|
||||
// call applies the `global` slice immediately. The `bootstrap` and
|
||||
// `mode` phases are driven by the boot orchestration
|
||||
// ({@link loadAndApplyBootstrapCustomizations}) and {@link onModeEnter}.
|
||||
this._applyLoadedUrlCustomizationModules(newlyLoaded);
|
||||
return newlyLoaded;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves (fetches + parses) the given URL customization modules and their
|
||||
* `requires` dependencies depth-first, WITHOUT applying any phase block.
|
||||
* Newly resolved modules are appended to {@link _resolvedUrlModules} in load
|
||||
* order so the lifecycle appliers can apply each phase at the right time.
|
||||
*
|
||||
* Rejects (aborting the load) when any entry is disallowed by the policy.
|
||||
*/
|
||||
public loadCustomizationModules(
|
||||
names: string | string[],
|
||||
overrides?: Partial<LoadOptions>
|
||||
): Promise<LoadedCustomization[]> {
|
||||
const policy = overrides?.policy ?? getCustomizationUrlPolicy(this);
|
||||
const list = (Array.isArray(names) ? names : [names])
|
||||
.map(s => String(s).trim())
|
||||
.filter(Boolean);
|
||||
if (!list.length) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const { valid, rejected } = validateCustomizationRequests(list, policy);
|
||||
const logger = overrides?.logger || console;
|
||||
// A `?customization=` value that is not allowed by the configured prefixes is
|
||||
// a hard error: stop the load and let it propagate. The feature is off until
|
||||
// `appConfig.customizationUrlPrefixes` allows a prefix, so on a default build
|
||||
// any `?customization=` value throws here rather than being silently ignored.
|
||||
if (rejected.length) {
|
||||
const details = rejected.map(r => `"${r.raw}" (${r.reason})`).join('; ');
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`[customizationUrl] refusing to load customization(s): ${details}. ` +
|
||||
`Allowed prefixes are configured in appConfig.customizationUrlPrefixes.`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!valid.length) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const importFn = overrides?.importFn ?? this._urlDefaultImport.bind(this);
|
||||
const requestedSet = new Set<string>();
|
||||
const newlyLoaded: LoadedCustomization[] = [];
|
||||
|
||||
return valid
|
||||
.reduce(
|
||||
(prev, request) =>
|
||||
prev.then(() =>
|
||||
this._urlCustomizationLoadOne(
|
||||
request,
|
||||
policy,
|
||||
importFn,
|
||||
logger,
|
||||
requestedSet,
|
||||
newlyLoaded
|
||||
)
|
||||
),
|
||||
Promise.resolve() as Promise<void>
|
||||
)
|
||||
.then(() => {
|
||||
this._resolvedUrlModules.push(...newlyLoaded);
|
||||
return newlyLoaded;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time entry point that runs BEFORE extensions are registered. It:
|
||||
* 1. records the ExtensionManager (so the URL policy / appConfig is readable),
|
||||
* 2. resolves every customization module requested via the
|
||||
* `appConfig.customizationService.requires` list and the `?customization=`
|
||||
* URL parameter (their data is fetched once, up front — long before any
|
||||
* mode loads), and
|
||||
* 3. applies the `bootstrap` phase blocks.
|
||||
*
|
||||
* Rejects (aborting bootstrap) if a `?customization=` value is disallowed.
|
||||
*/
|
||||
public async loadAndApplyBootstrapCustomizations(
|
||||
extensionManager: ExtensionManager,
|
||||
overrides?: Partial<LoadOptions>
|
||||
): Promise<void> {
|
||||
this.extensionManager = extensionManager;
|
||||
const config = this._getCustomizationConfig();
|
||||
|
||||
const names: string[] = [];
|
||||
const requires = config.phased?.requires;
|
||||
if (typeof requires === 'string' && requires) {
|
||||
names.push(requires);
|
||||
} else if (Array.isArray(requires)) {
|
||||
names.push(...requires.filter(name => typeof name === 'string' && name));
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
names.push(...parseCustomizationParams(new URLSearchParams(window.location.search)));
|
||||
}
|
||||
|
||||
if (names.length) {
|
||||
await this.loadCustomizationModules(names, overrides);
|
||||
}
|
||||
|
||||
this.applyBootstrapCustomizations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the `bootstrap` phase (Global scope) of the structured app config
|
||||
* and every resolved URL module. App-config blocks apply first so URL modules
|
||||
* layer on top.
|
||||
*/
|
||||
public applyBootstrapCustomizations(): void {
|
||||
this._applyPhase('bootstrap');
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the `global` phase (Global scope) of the structured app config and
|
||||
* every resolved URL module. Call after {@link init} so extension default /
|
||||
* global customizations are already in place for `$apply`-style merges.
|
||||
*/
|
||||
public applyGlobalCustomizations(): void {
|
||||
this._applyPhase('global');
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the `mode` phase (Mode scope) for the entered mode. The general
|
||||
* block (`*`) is applied FIRST, then any block keyed by one of `modeIds`
|
||||
* (the mode's id / routeName), so a single mode can override the general
|
||||
* values. Call this AFTER `customizationService.onModeEnter()` has reset the
|
||||
* mode scope (i.e. after `extensionManager.onModeEnter()`).
|
||||
*/
|
||||
public applyModeCustomizations(modeIds: string | string[]): void {
|
||||
const ids = (Array.isArray(modeIds) ? modeIds : [modeIds]).filter(Boolean) as string[];
|
||||
this._currentModeIds = ids;
|
||||
|
||||
const blocks = this._collectPhaseBlocks('mode') as Array<{
|
||||
mode?: Record<string, CustomizationPhaseInput>;
|
||||
}>;
|
||||
|
||||
// General first, for every source.
|
||||
for (const block of blocks) {
|
||||
const general = block.mode?.[GENERAL_MODE_KEY];
|
||||
if (general) {
|
||||
this.setCustomizations(general, CustomizationScope.Mode);
|
||||
}
|
||||
}
|
||||
// Then mode-specific, for every source.
|
||||
for (const block of blocks) {
|
||||
for (const id of ids) {
|
||||
const specific = block.mode?.[id];
|
||||
if (specific) {
|
||||
this.setCustomizations(specific, CustomizationScope.Mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onModeEnter(): void {
|
||||
this.clearTransformedCustomizations();
|
||||
|
||||
@@ -148,17 +466,6 @@ export default class CustomizationService extends PubSubService {
|
||||
this.clearTransformedCustomizations();
|
||||
}
|
||||
|
||||
private clearTransformedCustomizations(): void {
|
||||
super.reset();
|
||||
|
||||
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
|
||||
for (const key of modeCustomizationKeys) {
|
||||
this.transformedCustomizations.delete(key);
|
||||
}
|
||||
|
||||
this.modeCustomizations.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified getter for customizations.
|
||||
*
|
||||
@@ -184,6 +491,14 @@ export default class CustomizationService extends PubSubService {
|
||||
return newTransformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a customization value, or the provided fallback when unset.
|
||||
*/
|
||||
public getValue<T = Customization>(customizationId: string, fallbackValue?: T): T | undefined {
|
||||
const value = this.getCustomization(customizationId);
|
||||
return (value === undefined ? fallbackValue : (value as T)) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an object with multiple properties, each property containing
|
||||
* immutability-helper commands, and applies them one by one.
|
||||
@@ -229,34 +544,6 @@ export default class CustomizationService extends PubSubService {
|
||||
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.
|
||||
*
|
||||
@@ -303,68 +590,6 @@ export default class CustomizationService extends PubSubService {
|
||||
return result.$transform?.(this) || result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
|
||||
const sourceCustomization =
|
||||
modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
|
||||
|
||||
const result = this._update(sourceCustomization, customization);
|
||||
this.modeCustomizations.set(customizationId, result);
|
||||
|
||||
this.transformedCustomizations.clear();
|
||||
this._broadcastEvent(this.EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.modeCustomizations,
|
||||
button: this.modeCustomizations.get(customizationId),
|
||||
});
|
||||
}
|
||||
|
||||
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.GLOBAL_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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom command to be used in customization updates.
|
||||
* @param commandName - The name of the command (without the $ prefix)
|
||||
@@ -381,40 +606,6 @@ export default class CustomizationService extends PubSubService {
|
||||
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 result = update(oldValue, newValue);
|
||||
return result;
|
||||
}
|
||||
|
||||
private _cloneIfNeeded(value: any) {
|
||||
// If it's null/undefined or not an object, return as is
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
_addReference(value?: any, type = CustomizationScope.Global): void {
|
||||
if (!value) {
|
||||
return;
|
||||
@@ -451,6 +642,362 @@ export default class CustomizationService extends PubSubService {
|
||||
this._addReference(references, type);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Private methods
|
||||
// ===========================================================================
|
||||
|
||||
private clearTransformedCustomizations(): void {
|
||||
super.reset();
|
||||
|
||||
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
|
||||
for (const key of modeCustomizationKeys) {
|
||||
this.transformedCustomizations.delete(key);
|
||||
}
|
||||
|
||||
this.modeCustomizations.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default loader for a `?customization=` module. Customization files are
|
||||
* **data**, not code: the file is fetched and parsed as JSONC (JSON with
|
||||
* comments / trailing commas, via JSON5 which is a superset). It is never
|
||||
* executed. Executable modules — plugins, modes and extensions — load only
|
||||
* through `pluginConfig.json`, never from the customization URL path.
|
||||
*/
|
||||
private async _urlDefaultImport(url: string): Promise<any> {
|
||||
if (typeof fetch !== 'function') {
|
||||
throw new Error(`No fetch implementation available to load customization ${url}`);
|
||||
}
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch customization ${url}: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
return JSON5.parse(text);
|
||||
}
|
||||
|
||||
private _normalizeImportedCustomizationModule(imported: any): CustomizationModule {
|
||||
return imported && typeof imported === 'object' && 'customizations' in imported
|
||||
? imported
|
||||
: imported && typeof imported.default === 'object'
|
||||
? imported.default
|
||||
: imported;
|
||||
}
|
||||
|
||||
private _collectUrlDependencyRefs(module: CustomizationModule): string[] {
|
||||
const refs = new Set<string>();
|
||||
const payload = getUrlCustomizationModulePayload(module);
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return Array.from(refs);
|
||||
}
|
||||
const moduleRequires = (payload as any).requires;
|
||||
if (typeof moduleRequires === 'string' && moduleRequires) {
|
||||
refs.add(moduleRequires);
|
||||
} else if (Array.isArray(moduleRequires)) {
|
||||
for (const id of moduleRequires) {
|
||||
if (typeof id === 'string' && id) {
|
||||
refs.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(refs);
|
||||
}
|
||||
|
||||
private _urlDependencyToRequest(
|
||||
name: string,
|
||||
policy: CustomizationUrlPolicy
|
||||
): ValidatedCustomization | null {
|
||||
// `requires` entries are module names to load; each is validated/resolved like any
|
||||
// other URL customization request. Entries that don't resolve to a valid module
|
||||
// (e.g. a bare customization-key reference) are rejected by validation and
|
||||
// skipped — there is no namespace carve-out.
|
||||
const result = validateCustomizationRequests([name], policy);
|
||||
if (result.valid.length) {
|
||||
return result.valid[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _urlCustomizationLoadOne(
|
||||
request: ValidatedCustomization,
|
||||
policy: CustomizationUrlPolicy,
|
||||
importFn: (url: string) => Promise<any>,
|
||||
logger: { warn: (...args: any[]) => void; error: (...args: any[]) => void },
|
||||
requestedSet: Set<string>,
|
||||
newlyLoaded: LoadedCustomization[]
|
||||
): Promise<LoadedCustomization | null> {
|
||||
const key = request.normalized;
|
||||
if (this._urlCustomizationLoaded.has(key)) {
|
||||
return Promise.resolve(this._urlCustomizationLoaded.get(key) || null);
|
||||
}
|
||||
if (this._urlCustomizationPending.has(key)) {
|
||||
return this._urlCustomizationPending.get(key)!;
|
||||
}
|
||||
|
||||
requestedSet.add(key);
|
||||
|
||||
const promise = this._urlCustomizationLoadOneBody(
|
||||
request,
|
||||
policy,
|
||||
importFn,
|
||||
logger,
|
||||
requestedSet,
|
||||
newlyLoaded
|
||||
);
|
||||
|
||||
this._urlCustomizationPending.set(key, promise);
|
||||
// Use then(success, failure) for cleanup — `finally` left rejections unhandled
|
||||
// with the current Promise polyfill in the Jest/Node test stack.
|
||||
promise.then(
|
||||
() => this._urlCustomizationPending.delete(key),
|
||||
() => this._urlCustomizationPending.delete(key)
|
||||
);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private _urlCustomizationLoadOneBody(
|
||||
request: ValidatedCustomization,
|
||||
policy: CustomizationUrlPolicy,
|
||||
importFn: (url: string) => Promise<any>,
|
||||
logger: { warn: (...args: any[]) => void; error: (...args: any[]) => void },
|
||||
requestedSet: Set<string>,
|
||||
newlyLoaded: LoadedCustomization[]
|
||||
): Promise<LoadedCustomization | null> {
|
||||
const key = request.normalized;
|
||||
const importFailedSentinel = Symbol('importFailed');
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = resolveCustomizationUrl(request, policy);
|
||||
} catch (err) {
|
||||
const msg = `[customizationUrl] failed to resolve "${request.raw}": ${(err as Error).message}`;
|
||||
logger.warn(msg);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return importFn(url)
|
||||
.catch(err => {
|
||||
logger.warn(
|
||||
`[customizationUrl] failed to import customization "${request.raw}" (${url})`,
|
||||
err
|
||||
);
|
||||
return importFailedSentinel;
|
||||
})
|
||||
.then(importedOrSentinel => {
|
||||
if (importedOrSentinel === importFailedSentinel) {
|
||||
return null;
|
||||
}
|
||||
const imported = importedOrSentinel;
|
||||
const module = this._normalizeImportedCustomizationModule(imported);
|
||||
if (!module || typeof module !== 'object') {
|
||||
const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): module is not an object`;
|
||||
logger.warn(msg);
|
||||
return null;
|
||||
}
|
||||
if (!getUrlCustomizationModulePayload(module)) {
|
||||
const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): no customizations payload`;
|
||||
logger.warn(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
const depRefs = this._collectUrlDependencyRefs(module);
|
||||
let depsChain: Promise<unknown> = Promise.resolve();
|
||||
for (const depRef of depRefs) {
|
||||
depsChain = depsChain.then(() => {
|
||||
const depRequest = this._urlDependencyToRequest(depRef, policy);
|
||||
if (!depRequest || requestedSet.has(depRequest.normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return this._urlCustomizationLoadOne(
|
||||
depRequest,
|
||||
policy,
|
||||
importFn,
|
||||
logger,
|
||||
requestedSet,
|
||||
newlyLoaded
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return depsChain.then(() => {
|
||||
const loaded: LoadedCustomization = { request, module, url };
|
||||
this._urlCustomizationLoaded.set(key, loaded);
|
||||
newlyLoaded.push(loaded);
|
||||
return loaded;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _applyLoadedUrlCustomizationModules(loaded: LoadedCustomization[]): void {
|
||||
if (!loaded?.length) {
|
||||
return;
|
||||
}
|
||||
for (const entry of loaded) {
|
||||
const payload = getUrlCustomizationModulePayload(entry.module);
|
||||
if (payload?.global) {
|
||||
this.setCustomizations(payload.global, CustomizationScope.Global);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the phase blocks for `phase` from every source, in apply order:
|
||||
* the structured app config first, then each resolved URL module in load
|
||||
* order. Used by {@link applyModeCustomizations}; the simpler `bootstrap` /
|
||||
* `global` phases go through {@link _applyPhase}.
|
||||
*/
|
||||
private _collectPhaseBlocks(phase: keyof PhasedCustomizationConfig): PhasedCustomizationConfig[] {
|
||||
const blocks: PhasedCustomizationConfig[] = [];
|
||||
const phased = this._getCustomizationConfig().phased;
|
||||
if (phased && phased[phase] !== undefined) {
|
||||
blocks.push(phased);
|
||||
}
|
||||
for (const entry of this._resolvedUrlModules) {
|
||||
const payload = getUrlCustomizationModulePayload(entry.module);
|
||||
if (payload && payload[phase] !== undefined) {
|
||||
blocks.push(payload);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Applies a Global-scoped phase (`bootstrap` / `global`) from all sources. */
|
||||
private _applyPhase(phase: 'bootstrap' | 'global'): void {
|
||||
for (const block of this._collectPhaseBlocks(phase)) {
|
||||
const value = block[phase] as CustomizationPhaseInput | undefined;
|
||||
if (value) {
|
||||
this.setCustomizations(value, CustomizationScope.Global);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
|
||||
const sourceCustomization =
|
||||
modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
|
||||
|
||||
const result = this._update(sourceCustomization, customization);
|
||||
this.modeCustomizations.set(customizationId, result);
|
||||
|
||||
this.transformedCustomizations.clear();
|
||||
this._broadcastEvent(this.EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.modeCustomizations,
|
||||
button: this.modeCustomizations.get(customizationId),
|
||||
});
|
||||
}
|
||||
|
||||
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.GLOBAL_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.globalCustomizations,
|
||||
button: this.globalCustomizations.get(id),
|
||||
});
|
||||
}
|
||||
|
||||
private setDefaultCustomization(id: string, value: Customization): void {
|
||||
// There are two inits now, without a clear between them, so we can't warn about existing defaults
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 result = update(oldValue, newValue);
|
||||
return result;
|
||||
}
|
||||
|
||||
private _cloneIfNeeded(value: any) {
|
||||
// If it's null/undefined or not an object, return as is
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/** Add custom $filter command */
|
||||
@@ -523,6 +1070,48 @@ extend('$filter', (query, original) => {
|
||||
return deepFilter(original, query);
|
||||
});
|
||||
|
||||
const PHASE_CONFIG_KEYS: Array<keyof PhasedCustomizationConfig> = [
|
||||
'requires',
|
||||
'bootstrap',
|
||||
'global',
|
||||
'mode',
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalizes `appConfig.customizationService` into either:
|
||||
* - `{ phased }` — the structured, phase-tagged config, detected by
|
||||
* the presence of any of `requires` / `bootstrap`
|
||||
* / `global` / `mode`; or
|
||||
* - `{ legacyReferences }` — the legacy array / object-map form, which is
|
||||
* added (Global scope) during `init()` exactly as
|
||||
* before.
|
||||
*/
|
||||
export function normalizeCustomizationConfig(configuration: unknown): {
|
||||
phased?: PhasedCustomizationConfig;
|
||||
legacyReferences?: unknown;
|
||||
} {
|
||||
if (!configuration) {
|
||||
return {};
|
||||
}
|
||||
if (Array.isArray(configuration)) {
|
||||
return { legacyReferences: configuration };
|
||||
}
|
||||
if (typeof configuration === 'object') {
|
||||
const isPhased = PHASE_CONFIG_KEYS.some(key => key in (configuration as object));
|
||||
if (isPhased) {
|
||||
const phased: PhasedCustomizationConfig = {};
|
||||
for (const key of PHASE_CONFIG_KEYS) {
|
||||
if (key in (configuration as object)) {
|
||||
(phased as Record<string, unknown>)[key] = (configuration as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
return { phased };
|
||||
}
|
||||
return { legacyReferences: configuration };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function hasDollarKey(value) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CUSTOMIZATION_URL_PREFIXES_KEY,
|
||||
customizationUrlDefaults,
|
||||
DEFAULT_PREFIX,
|
||||
} from './customizationUrlDefaults';
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import { getUrlCustomizationModulePayload } from './getUrlCustomizationModulePayload';
|
||||
import {
|
||||
parseCustomizationParams,
|
||||
validateCustomizationRequests,
|
||||
normalizeCustomizationValue,
|
||||
} from './validate';
|
||||
import type { ValidatedCustomization, ValidationResult } from './validate';
|
||||
import { resolveCustomizationUrl } from './resolve';
|
||||
|
||||
/**
|
||||
* Builds the `?customization=` policy from the **app config** property
|
||||
* `customizationUrlPrefixes` (read off `extensionManager.appConfig`). This is
|
||||
* intentionally not a customization: customizations can be loaded from the URL,
|
||||
* so letting one define prefixes would let it widen its own allowlist. When the
|
||||
* property is absent the policy has no prefixes and the feature is off.
|
||||
*/
|
||||
export function getCustomizationUrlPolicy(customizationService: any): CustomizationUrlPolicy {
|
||||
const prefixes =
|
||||
customizationService?.extensionManager?.appConfig?.[CUSTOMIZATION_URL_PREFIXES_KEY];
|
||||
if (prefixes && typeof prefixes === 'object') {
|
||||
return { prefixes };
|
||||
}
|
||||
return customizationUrlDefaults;
|
||||
}
|
||||
|
||||
export {
|
||||
CUSTOMIZATION_URL_PREFIXES_KEY,
|
||||
customizationUrlDefaults,
|
||||
DEFAULT_PREFIX,
|
||||
getUrlCustomizationModulePayload,
|
||||
parseCustomizationParams,
|
||||
validateCustomizationRequests,
|
||||
normalizeCustomizationValue,
|
||||
resolveCustomizationUrl,
|
||||
};
|
||||
|
||||
export type {
|
||||
CustomizationUrlPolicy,
|
||||
ValidatedCustomization,
|
||||
ValidationResult,
|
||||
};
|
||||
|
||||
export type { CustomizationModule, LoadedCustomization, LoadOptions } from './customizationUrlTypes';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Policy for the `?customization=` URL query parameter.
|
||||
*
|
||||
* The effective policy comes from the **app config** property
|
||||
* `customizationUrlPrefixes` (NOT a customization — a customization must never
|
||||
* be able to widen its own allowlist). When that property is absent the policy
|
||||
* has no prefixes, so the feature is **off by default**: any `?customization=`
|
||||
* value is rejected because its prefix is not configured.
|
||||
*
|
||||
* Shape:
|
||||
* - prefixes: map of prefix -> base URL used to resolve a customization value
|
||||
* to a fetched `.jsonc` data file. The `default` prefix (no slashes) is used
|
||||
* for values with no leading slash; every other prefix must start and end
|
||||
* with a slash (e.g. `/remote/`) and is matched against the leading
|
||||
* `/segment/` of the value.
|
||||
*
|
||||
* Example app config:
|
||||
* window.config = {
|
||||
* customizationUrlPrefixes: {
|
||||
* default: './customizations/',
|
||||
* '/remote/': 'https://cdn.example.com/ohif-customizations/',
|
||||
* },
|
||||
* };
|
||||
*/
|
||||
export interface CustomizationUrlPolicy {
|
||||
prefixes: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* App config property name holding the prefix allowlist. Read directly off
|
||||
* `appConfig` — deliberately not a customization key.
|
||||
*/
|
||||
export const CUSTOMIZATION_URL_PREFIXES_KEY = 'customizationUrlPrefixes';
|
||||
|
||||
export const DEFAULT_PREFIX = 'default';
|
||||
|
||||
/** Off by default: no prefixes are allowed until the app config configures them. */
|
||||
export const customizationUrlDefaults: CustomizationUrlPolicy = {
|
||||
prefixes: {},
|
||||
};
|
||||
|
||||
export default customizationUrlDefaults;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import type { ValidatedCustomization } from './validate';
|
||||
|
||||
/**
|
||||
* The value accepted by any single customization phase block. It is whatever
|
||||
* {@link CustomizationService.setCustomizations} accepts:
|
||||
* - an object map of `customizationId -> customization` (with optional
|
||||
* immutability-helper commands like `$set` / `$apply` / `$splice`), or
|
||||
* - an array that mixes string references (extension module ids resolved via
|
||||
* the ExtensionManager) and inline object maps.
|
||||
*/
|
||||
export type CustomizationPhaseInput = string[] | Record<string, any>;
|
||||
|
||||
/**
|
||||
* Mode-phase customizations, keyed by mode. The reserved `*` key (see
|
||||
* `GENERAL_MODE_KEY`) is the "general" block applied to every mode FIRST; any
|
||||
* other key is matched against the entered mode's `id` / `routeName` and applied
|
||||
* AFTER the general block, so a single mode can override the general values.
|
||||
*/
|
||||
export type ModePhaseCustomizations = Record<string, CustomizationPhaseInput>;
|
||||
|
||||
/**
|
||||
* Phase-tagged customization payload. The same shape is used by:
|
||||
* - URL-loaded customization modules (`?customization=` JSONC files), and
|
||||
* - the `appConfig.customizationService` structured config.
|
||||
*
|
||||
* Each block is applied at a distinct point in the app lifecycle so ordering is
|
||||
* deterministic regardless of when extensions / modes load:
|
||||
* - `requires` — other URL customization modules to resolve first.
|
||||
* - `bootstrap` — applied (Global scope) BEFORE extensions register.
|
||||
* - `global` — applied (Global scope) AFTER extensions register / init.
|
||||
* - `mode` — applied (Mode scope) on every mode enter; general first,
|
||||
* then the entered mode's specific block.
|
||||
*/
|
||||
export interface PhasedCustomizationConfig {
|
||||
requires?: string | string[];
|
||||
bootstrap?: CustomizationPhaseInput;
|
||||
global?: CustomizationPhaseInput;
|
||||
mode?: ModePhaseCustomizations;
|
||||
}
|
||||
|
||||
export interface CustomizationModule extends PhasedCustomizationConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface LoadedCustomization {
|
||||
request: ValidatedCustomization;
|
||||
module: CustomizationModule;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LoadOptions {
|
||||
policy?: CustomizationUrlPolicy;
|
||||
importFn?: (url: string) => Promise<any>;
|
||||
logger?: { warn: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CustomizationModule, PhasedCustomizationConfig } from './customizationUrlTypes';
|
||||
|
||||
/**
|
||||
* Extracts the phase-tagged payload from a loaded customization module.
|
||||
*
|
||||
* A module is considered to carry a payload when it declares any of the
|
||||
* lifecycle phase blocks (`bootstrap` / `global` / `mode`) or a `requires`
|
||||
* edge. Returns `null` when none are present so callers can warn/skip a module
|
||||
* that does nothing.
|
||||
*/
|
||||
export function getUrlCustomizationModulePayload(
|
||||
module: CustomizationModule | null | undefined
|
||||
): PhasedCustomizationConfig | null {
|
||||
if (!module || typeof module !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const hasBootstrap = isPhaseInput(module.bootstrap);
|
||||
const hasGlobal = isPhaseInput(module.global);
|
||||
const hasMode = module.mode && typeof module.mode === 'object' && !Array.isArray(module.mode);
|
||||
const hasRequires =
|
||||
typeof module.requires === 'string' ||
|
||||
(Array.isArray(module.requires) && module.requires.length > 0);
|
||||
|
||||
if (!hasBootstrap && !hasGlobal && !hasMode && !hasRequires) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(hasBootstrap ? { bootstrap: module.bootstrap } : {}),
|
||||
...(hasGlobal ? { global: module.global } : {}),
|
||||
...(hasMode ? { mode: module.mode } : {}),
|
||||
...(hasRequires ? { requires: module.requires } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A phase block is either an object map or an array of references. */
|
||||
function isPhaseInput(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return Boolean(value) && typeof value === 'object';
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
import CustomizationService from './CustomizationService';
|
||||
|
||||
export { GENERAL_MODE_KEY, normalizeCustomizationConfig } from './CustomizationService';
|
||||
export type {
|
||||
CustomizationModule,
|
||||
CustomizationPhaseInput,
|
||||
ModePhaseCustomizations,
|
||||
PhasedCustomizationConfig,
|
||||
} from './customizationUrlTypes';
|
||||
|
||||
export default CustomizationService;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { resolveCustomizationUrl } from './resolve';
|
||||
import type { ValidatedCustomization } from './validate';
|
||||
|
||||
const policy = {
|
||||
prefixes: {
|
||||
default: './customizations/',
|
||||
remote: 'https://customizations.example.com/ohifCustomizations',
|
||||
relative: '/customAssets/',
|
||||
},
|
||||
};
|
||||
|
||||
function req(prefix: string, name: string): ValidatedCustomization {
|
||||
return {
|
||||
raw: name,
|
||||
normalized: `/${prefix}/${name}`,
|
||||
prefix,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CustomizationService URL resolve', () => {
|
||||
let originalLocation: PropertyDescriptor | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
origin: 'https://viewer.example.com',
|
||||
search: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalLocation) {
|
||||
Object.defineProperty(window, 'location', originalLocation);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves /default/<name> against same-origin/base', () => {
|
||||
const url = resolveCustomizationUrl(req('default', 'veterinaryOverlay'), policy);
|
||||
expect(url.startsWith('https://viewer.example.com')).toBe(true);
|
||||
expect(url.endsWith('/customizations/veterinaryOverlay.jsonc')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves an absolute trusted prefix to its remote URL', () => {
|
||||
const url = resolveCustomizationUrl(req('remote', 'veterinaryOverlay'), policy);
|
||||
expect(url).toBe(
|
||||
'https://customizations.example.com/ohifCustomizations/veterinaryOverlay.jsonc'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a relative absolute-path prefix against same origin', () => {
|
||||
const url = resolveCustomizationUrl(req('relative', 'foo'), policy);
|
||||
expect(url).toBe('https://viewer.example.com/customAssets/foo.jsonc');
|
||||
});
|
||||
|
||||
it('throws on unknown prefix', () => {
|
||||
expect(() => resolveCustomizationUrl(req('missing', 'x'), policy)).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the name contains traversal', () => {
|
||||
expect(() =>
|
||||
resolveCustomizationUrl(req('default', '../escape'), policy)
|
||||
).toThrow(/traversal/);
|
||||
});
|
||||
|
||||
it('accepts names that already include .jsonc suffix', () => {
|
||||
const url = resolveCustomizationUrl(req('default', 'veterinary.jsonc'), policy);
|
||||
expect(url.endsWith('/customizations/veterinary.jsonc')).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when an encoded traversal would escape the base after URL normalization', () => {
|
||||
// `%2e%2e` survives the literal `..` check but the URL parser collapses it to
|
||||
// `..`, which would otherwise escape the prefix directory.
|
||||
expect(() => resolveCustomizationUrl(req('default', '%2e%2e/secret'), policy)).toThrow(
|
||||
/escapes its configured prefix/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when an encoded traversal escapes an absolute (remote) prefix', () => {
|
||||
expect(() =>
|
||||
resolveCustomizationUrl(req('remote', '%2e%2e/%2e%2e/evil'), policy)
|
||||
).toThrow(/escapes its configured prefix/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import type { ValidatedCustomization } from './validate';
|
||||
|
||||
const ABSOLUTE_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
|
||||
|
||||
// Used only to parse same-origin/relative paths into a comparable URL. Any
|
||||
// host works since we compare origin + pathname against the base built the
|
||||
// same way; it is never part of the returned value.
|
||||
const PARSE_BASE = 'https://ohif.invalid';
|
||||
|
||||
function getViewerPublicUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '/';
|
||||
}
|
||||
return (window as any).PUBLIC_URL || '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Defense-in-depth containment check on the *final* resolved string. We parse
|
||||
* both the resolved URL and its configured base directory with the same URL
|
||||
* parser that `import()` uses — which collapses `.`/`..`/`%2e%2e` path segments
|
||||
* — and require the result to stay within the base directory. Validation should
|
||||
* already have rejected traversal, but parsing the final string here guarantees
|
||||
* a module can never load outside its allowlisted prefix even if an earlier
|
||||
* check is bypassed.
|
||||
*/
|
||||
function assertWithinBase(
|
||||
finalUrl: string,
|
||||
baseDirUrl: string,
|
||||
request: ValidatedCustomization
|
||||
): void {
|
||||
let resolved: URL;
|
||||
let baseDir: URL;
|
||||
try {
|
||||
resolved = new URL(finalUrl, PARSE_BASE);
|
||||
baseDir = new URL(baseDirUrl, PARSE_BASE);
|
||||
} catch {
|
||||
throw new Error(`Customization URL could not be parsed for "${request.raw}"`);
|
||||
}
|
||||
const baseDirPath = baseDir.pathname.endsWith('/') ? baseDir.pathname : `${baseDir.pathname}/`;
|
||||
const within = resolved.origin === baseDir.origin && resolved.pathname.startsWith(baseDirPath);
|
||||
if (!within) {
|
||||
throw new Error(`Customization "${request.raw}" escapes its configured prefix directory`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCustomizationUrl(
|
||||
request: ValidatedCustomization,
|
||||
policy: CustomizationUrlPolicy
|
||||
): string {
|
||||
const prefixes = policy.prefixes || {};
|
||||
const base = prefixes[request.prefix];
|
||||
if (!base) {
|
||||
throw new Error(`Unknown customization prefix: ${request.prefix}`);
|
||||
}
|
||||
if (request.name.includes('..')) {
|
||||
throw new Error(`Customization name contains traversal: ${request.name}`);
|
||||
}
|
||||
|
||||
const fileName = request.name.endsWith('.jsonc') ? request.name : `${request.name}.jsonc`;
|
||||
const baseWithSlash = base.endsWith('/') ? base : `${base}/`;
|
||||
const joined = `${baseWithSlash}${fileName}`;
|
||||
|
||||
if (ABSOLUTE_URL_REGEX.test(base)) {
|
||||
assertWithinBase(joined, baseWithSlash, request);
|
||||
return joined;
|
||||
}
|
||||
|
||||
const origin =
|
||||
typeof window !== 'undefined' && window.location?.origin ? window.location.origin : '';
|
||||
const publicUrl = getViewerPublicUrl();
|
||||
const root = publicUrl?.startsWith('/') ? publicUrl : `/${publicUrl || ''}`;
|
||||
const strip = (value: string): string =>
|
||||
value.startsWith('./') ? value.slice(2) : value.startsWith('/') ? value.slice(1) : value;
|
||||
const relative = strip(joined);
|
||||
const relativeBase = strip(baseWithSlash);
|
||||
const rootWithSlash = root.endsWith('/') ? root : `${root}/`;
|
||||
const path = `${rootWithSlash}${relative}`;
|
||||
const baseDirPath = `${rootWithSlash}${relativeBase}`;
|
||||
|
||||
const finalUrl = origin ? `${origin}${path}` : path;
|
||||
assertWithinBase(finalUrl, origin ? `${origin}${baseDirPath}` : baseDirPath, request);
|
||||
return finalUrl;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
parseCustomizationParams,
|
||||
normalizeCustomizationValue,
|
||||
validateCustomizationRequests,
|
||||
} from './validate';
|
||||
import { customizationUrlDefaults } from './customizationUrlDefaults';
|
||||
|
||||
describe('CustomizationService URL validate', () => {
|
||||
describe('parseCustomizationParams', () => {
|
||||
it('returns repeated and comma-delimited values flattened', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('customization', 'a,b');
|
||||
params.append('customization', 'c');
|
||||
expect(parseCustomizationParams(params)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('matches the parameter key case-insensitively', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('Customization', 'foo');
|
||||
params.append('CUSTOMIZATION', 'bar');
|
||||
expect(parseCustomizationParams(params)).toEqual(['foo', 'bar']);
|
||||
});
|
||||
|
||||
it('skips empty pieces and trims whitespace', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('customization', ' a , , b ');
|
||||
expect(parseCustomizationParams(params)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCustomizationValue', () => {
|
||||
it('prepends /default/ for path-relative values', () => {
|
||||
expect(normalizeCustomizationValue('veterinaryOverlay')).toBe(
|
||||
'/default/veterinaryOverlay'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves explicit /prefix/name forms', () => {
|
||||
expect(normalizeCustomizationValue('/remote/foo')).toBe('/remote/foo');
|
||||
});
|
||||
|
||||
it('returns null when there is no name part', () => {
|
||||
expect(normalizeCustomizationValue('/onlyPrefix')).toBeNull();
|
||||
expect(normalizeCustomizationValue('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCustomizationRequests', () => {
|
||||
const policy = {
|
||||
...customizationUrlDefaults,
|
||||
prefixes: {
|
||||
default: './customizations/',
|
||||
'/remote/': 'https://customizations.example.com/ohifCustomizations',
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts default-prefixed names', () => {
|
||||
const result = validateCustomizationRequests(['veterinary'], policy);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.valid).toHaveLength(1);
|
||||
expect(result.valid[0].normalized).toBe('/default/veterinary');
|
||||
expect(result.valid[0].prefix).toBe('default');
|
||||
expect(result.valid[0].name).toBe('veterinary');
|
||||
});
|
||||
|
||||
it('accepts arbitrary logical names under a configured prefix', () => {
|
||||
const result = validateCustomizationRequests(['siteTheme2026'], policy);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.valid).toHaveLength(1);
|
||||
expect(result.valid[0].name).toBe('siteTheme2026');
|
||||
});
|
||||
|
||||
it('accepts remote-prefixed names', () => {
|
||||
const result = validateCustomizationRequests(['/remote/veterinaryOverlay'], policy);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.valid[0].prefix).toBe('/remote/');
|
||||
expect(result.valid[0].name).toBe('veterinaryOverlay');
|
||||
});
|
||||
|
||||
it('keeps multi-segment names under a slash prefix', () => {
|
||||
const result = validateCustomizationRequests(['/remote/siteA/theme'], policy);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.valid[0].prefix).toBe('/remote/');
|
||||
expect(result.valid[0].name).toBe('siteA/theme');
|
||||
});
|
||||
|
||||
it('rejects values with .. traversal', () => {
|
||||
const result = validateCustomizationRequests(['../etc/passwd'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/traversal/);
|
||||
});
|
||||
|
||||
it('rejects full URLs', () => {
|
||||
const result = validateCustomizationRequests(
|
||||
['http://evil.example.com/x', 'https://evil/x', '//evil/x'],
|
||||
policy
|
||||
);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected).toHaveLength(3);
|
||||
for (const r of result.rejected) {
|
||||
expect(r.reason).toMatch(/full URLs/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown prefixes', () => {
|
||||
const result = validateCustomizationRequests(['/missing/x'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/unknown prefix/);
|
||||
});
|
||||
|
||||
it('rejects an explicit /default/ prefix (the default prefix has no slashes)', () => {
|
||||
// The `default` prefix is only reachable by values without a leading slash.
|
||||
// A leading-slash value matches `/default/`, which is not a configured key.
|
||||
const result = validateCustomizationRequests(['/default/foo'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/unknown prefix/);
|
||||
});
|
||||
|
||||
it('rejects unsafe name segments', () => {
|
||||
const result = validateCustomizationRequests(['foo/./bar'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/unsafe/);
|
||||
});
|
||||
|
||||
it('rejects percent-encoded traversal that decodes to ".."', () => {
|
||||
// URLSearchParams decodes one layer, so a double-encoded `..` arrives here
|
||||
// as `%2e%2e`; the WHATWG URL parser would normalize that to `..`.
|
||||
const result = validateCustomizationRequests(
|
||||
['foo/%2e%2e/%2e%2e/app-config', '/default/%2E%2E/secret'],
|
||||
policy
|
||||
);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected).toHaveLength(2);
|
||||
for (const r of result.rejected) {
|
||||
expect(r.reason).toMatch(/traversal/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects percent-encoded slashes that would inject extra path segments', () => {
|
||||
const result = validateCustomizationRequests(['/default/foo%2f..%2fbar'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/traversal/);
|
||||
});
|
||||
|
||||
it('rejects encoded full URLs', () => {
|
||||
const result = validateCustomizationRequests(['https%3A%2F%2Fevil.example.com/x'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/full URLs/);
|
||||
});
|
||||
|
||||
it('rejects malformed percent-encoding', () => {
|
||||
const result = validateCustomizationRequests(['/default/foo%zz', '/default/bar%'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected).toHaveLength(2);
|
||||
for (const r of result.rejected) {
|
||||
expect(r.reason).toMatch(/percent-encoding/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import { DEFAULT_PREFIX } from './customizationUrlDefaults';
|
||||
|
||||
export interface ValidatedCustomization {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: ValidatedCustomization[];
|
||||
rejected: { raw: string; reason: string }[];
|
||||
}
|
||||
|
||||
const FULL_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
|
||||
|
||||
/**
|
||||
* Fully percent-decode a customization value so validation runs against the
|
||||
* string the URL parser (used by `import()`) ultimately resolves — not the
|
||||
* escaped form. This is what blocks encoded traversal such as `%2e%2e`
|
||||
* (decodes to `..`) or `%2f` (decodes to `/`): the WHATWG URL parser treats
|
||||
* `%2e%2e` as a `..` path segment and would otherwise let a value escape the
|
||||
* configured prefix directory.
|
||||
*
|
||||
* Returns the stable decoded string, or `null` when the value is malformed
|
||||
* (invalid escape), still contains a literal `%` after decoding, or nests
|
||||
* encoding too deeply to settle. Legitimate customization names are plain
|
||||
* identifiers/paths and never need percent-encoding, so rejecting these is safe.
|
||||
*/
|
||||
export function fullyDecodeValue(value: string): string | null {
|
||||
let current = value;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current.includes('%')) {
|
||||
return current;
|
||||
}
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(current);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded === current) {
|
||||
// Stable but still contains a literal `%`; treat as unsafe.
|
||||
return null;
|
||||
}
|
||||
current = decoded;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCustomizationParams(
|
||||
params: URLSearchParams,
|
||||
paramKey = 'customization'
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const keys = Array.from(new Set(params.keys()));
|
||||
for (const key of keys) {
|
||||
if (key.toLowerCase() !== paramKey.toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
for (const raw of params.getAll(key)) {
|
||||
if (!raw) continue;
|
||||
for (const piece of raw.split(',')) {
|
||||
const trimmed = piece.trim();
|
||||
if (trimmed) {
|
||||
out.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a customization value into its prefix and name.
|
||||
*
|
||||
* - A value with **no leading slash** uses the special `default` prefix and the
|
||||
* whole value is the name (e.g. `abc/def` -> prefix `default`, name `abc/def`).
|
||||
* - A value with a **leading slash** is matched against its first `/segment/`,
|
||||
* which becomes the prefix (slashes included), and the remainder is the name
|
||||
* (e.g. `/remote/def` -> prefix `/remote/`, name `def`). This is why every
|
||||
* prefix other than `default` must start and end with a slash.
|
||||
*
|
||||
* Returns `null` when there is no usable name part.
|
||||
*/
|
||||
export function splitPrefixAndName(
|
||||
value: string
|
||||
): { prefix: string; name: string } | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!value.startsWith('/')) {
|
||||
return { prefix: DEFAULT_PREFIX, name: value };
|
||||
}
|
||||
const rest = value.slice(1);
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx <= 0) {
|
||||
return null;
|
||||
}
|
||||
const segment = rest.slice(0, slashIdx);
|
||||
const name = rest.slice(slashIdx + 1);
|
||||
if (!segment || !name) {
|
||||
return null;
|
||||
}
|
||||
return { prefix: `/${segment}/`, name };
|
||||
}
|
||||
|
||||
function buildNormalized(prefix: string, name: string): string {
|
||||
return prefix === DEFAULT_PREFIX ? `/${DEFAULT_PREFIX}/${name}` : `${prefix}${name}`;
|
||||
}
|
||||
|
||||
export function normalizeCustomizationValue(value: string): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const split = splitPrefixAndName(value.trim());
|
||||
if (!split) {
|
||||
return null;
|
||||
}
|
||||
return buildNormalized(split.prefix, split.name);
|
||||
}
|
||||
|
||||
function hasUnsafeNameSegments(name: string): boolean {
|
||||
if (!name.trim()) {
|
||||
return true;
|
||||
}
|
||||
return name.split('/').some(seg => !seg || seg === '.' || seg === '..' || seg.includes('..'));
|
||||
}
|
||||
|
||||
export function validateCustomizationRequests(
|
||||
raws: string[],
|
||||
policy: CustomizationUrlPolicy
|
||||
): ValidationResult {
|
||||
const result: ValidationResult = { valid: [], rejected: [] };
|
||||
const prefixes = policy.prefixes || {};
|
||||
|
||||
for (const raw of raws) {
|
||||
// Validate against the fully decoded value so percent-encoded traversal
|
||||
// (e.g. `%2e%2e`, `%2f`) cannot slip past the checks below and escape the
|
||||
// configured prefix once the URL parser normalizes the import path.
|
||||
const decoded = fullyDecodeValue(raw);
|
||||
if (decoded === null) {
|
||||
result.rejected.push({ raw, reason: 'contains malformed or unsupported percent-encoding' });
|
||||
continue;
|
||||
}
|
||||
if (decoded.includes('..')) {
|
||||
result.rejected.push({ raw, reason: 'contains ".." traversal segment' });
|
||||
continue;
|
||||
}
|
||||
if (FULL_URL_REGEX.test(decoded)) {
|
||||
result.rejected.push({ raw, reason: 'full URLs are not permitted' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const split = splitPrefixAndName(decoded.trim());
|
||||
if (!split) {
|
||||
result.rejected.push({ raw, reason: 'could not be split into a prefix and name' });
|
||||
continue;
|
||||
}
|
||||
const { prefix, name } = split;
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(prefixes, prefix)) {
|
||||
result.rejected.push({ raw, reason: `unknown prefix "${prefix}"` });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasUnsafeNameSegments(name)) {
|
||||
result.rejected.push({ raw, reason: 'invalid or unsafe customization name' });
|
||||
continue;
|
||||
}
|
||||
|
||||
result.valid.push({ raw, normalized: buildNormalized(prefix, name), prefix, name });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -191,7 +191,7 @@ export class MultiMonitorService {
|
||||
* Try moving the screen to the correct location - this will only work with
|
||||
* screens opened with openWindow containing no more than 1 tab.
|
||||
*/
|
||||
public async onModeEnter() {
|
||||
public onModeEnter() {
|
||||
this.setBasePath();
|
||||
|
||||
if (
|
||||
|
||||
@@ -21,6 +21,8 @@ import { MultiMonitorService } from './MultiMonitorService';
|
||||
|
||||
import type Services from '../types/Services';
|
||||
|
||||
export * from './CustomizationService/customizationUrl';
|
||||
|
||||
export {
|
||||
Services,
|
||||
MeasurementService,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
import HangingProtocolServiceType from '../services/HangingProtocolService';
|
||||
import CustomizationServiceType from '../services/CustomizationService';
|
||||
import type { PhasedCustomizationConfig } from '../services/CustomizationService';
|
||||
import MeasurementServiceType from '../services/MeasurementService';
|
||||
import ViewportGridServiceType from '../services/ViewportGridService';
|
||||
import ToolbarServiceType from '../services/ToolBarService';
|
||||
@@ -85,7 +86,29 @@ declare global {
|
||||
export interface Config {
|
||||
studyBrowserMode?: 'all' | 'primary';
|
||||
routerBasename?: string;
|
||||
customizationService?: CustomizationServiceType;
|
||||
/**
|
||||
* Startup customizations. Two forms are accepted:
|
||||
*
|
||||
* - **Legacy**: an array of references / object map applied to the Global
|
||||
* scope during `init()` (e.g.
|
||||
* `['@ohif/extension-default.customizationModule.datasources']`).
|
||||
* - **Phase-tagged** ({@link PhasedCustomizationConfig}): an object with
|
||||
* any of `requires` / `bootstrap` / `global` / `mode`. `requires`
|
||||
* pulls in URL-style customization data files; `bootstrap` /
|
||||
* `global` apply (Global scope) before / after extensions register; and
|
||||
* `mode` applies (Mode scope) per mode on entry — the `*` block to all
|
||||
* modes first, then a block keyed by the mode id / routeName.
|
||||
*/
|
||||
customizationService?: PhasedCustomizationConfig | string[] | Record<string, unknown>;
|
||||
/**
|
||||
* Allowlist of prefixes for the `?customization=` URL parameter, mapping a
|
||||
* prefix to a base URL/path. The `default` prefix (no slashes) handles
|
||||
* values with no leading slash; every other prefix must start and end with
|
||||
* a slash (e.g. `/remote/`). Intentionally an app-config property and not a
|
||||
* customization so a URL-loaded customization cannot widen its own
|
||||
* allowlist. Absent (the default) means `?customization=` is disabled.
|
||||
*/
|
||||
customizationUrlPrefixes?: Record<string, string>;
|
||||
extensions?: string[];
|
||||
modes?: string[];
|
||||
experimentalStudyBrowserSort?: boolean;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Formats values for safe text display.
|
||||
*/
|
||||
export default function formatValue(value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && typeof value.Alphabetic === 'string') {
|
||||
return value.Alphabetic;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import formatValue from './formatValue';
|
||||
|
||||
describe('formatValue', () => {
|
||||
it('returns null for null or undefined', () => {
|
||||
expect(formatValue(null)).toBeNull();
|
||||
expect(formatValue(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns strings unchanged, including empty strings', () => {
|
||||
expect(formatValue('horse')).toBe('horse');
|
||||
expect(formatValue('')).toBe('');
|
||||
});
|
||||
|
||||
it('extracts the Alphabetic component of a PersonName object', () => {
|
||||
expect(formatValue({ Alphabetic: 'Doe^John' })).toBe('Doe^John');
|
||||
});
|
||||
|
||||
it('coerces numbers to strings, including zero', () => {
|
||||
expect(formatValue(0)).toBe('0');
|
||||
expect(formatValue(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('coerces booleans to strings', () => {
|
||||
expect(formatValue(true)).toBe('true');
|
||||
expect(formatValue(false)).toBe('false');
|
||||
});
|
||||
|
||||
it('returns null for plain objects so they never render as [object Object]', () => {
|
||||
expect(formatValue({})).toBeNull();
|
||||
expect(formatValue({ foo: 'bar' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for objects whose Alphabetic is not a string', () => {
|
||||
expect(formatValue({ Alphabetic: 123 })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for arrays', () => {
|
||||
expect(formatValue(['a', 'b'])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import isDicomUid from './isDicomUid';
|
||||
import formatDate from './formatDate';
|
||||
import formatTime from './formatTime';
|
||||
import formatPN from './formatPN';
|
||||
import formatValue from './formatValue';
|
||||
import generateAcceptHeader from './generateAcceptHeader';
|
||||
import resolveObjectPath from './resolveObjectPath';
|
||||
import hierarchicalListUtils from './hierarchicalListUtils';
|
||||
@@ -73,6 +74,7 @@ const utils = {
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatPN,
|
||||
formatValue,
|
||||
b64toBlob,
|
||||
urlUtil,
|
||||
imageIdToURI,
|
||||
@@ -117,6 +119,7 @@ export {
|
||||
absoluteUrl,
|
||||
sortBy,
|
||||
formatDate,
|
||||
formatValue,
|
||||
writeScript,
|
||||
b64toBlob,
|
||||
urlUtil,
|
||||
|
||||
Reference in new issue
Block a user