Skip to content
7 changes: 7 additions & 0 deletions platform/app/public/customizations/veterinary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Example chaining module: ensures `veterinaryOverlay` is loaded and applied
* first when using `?customization=veterinary` alone.
*/
export default {
requires: ['veterinaryOverlay'],
};
58 changes: 58 additions & 0 deletions platform/app/public/customizations/veterinaryOverlay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Example URL-loaded customization module: veterinaryOverlay
*
* Demonstrates a runtime-loaded customization that overrides the default
* viewport overlay with a veterinary-style demographics layout. Loaded via
* `?customization=veterinaryOverlay` (see CustomizationService URL handling).
*
* Uses the same default-export shape as cornerstone overlay samples
* (`global` at top level) and `inheritsFrom: 'ohif.overlayItem'` on each
* row, matching `extensions/cornerstone/.../viewportOverlayCustomization.tsx`.
*/
export default {
global: {
'viewportOverlay.topLeft': {
$set: [
{
id: 'PatientName',
inheritsFrom: 'ohif.overlayItem',
attribute: 'PatientName',
label: 'Patient',
title: 'Patient name',
},
{
id: 'PatientID',
inheritsFrom: 'ohif.overlayItem',
attribute: 'PatientID',
label: 'ID',
title: 'Patient ID',
},
{
id: 'StudyDate',
inheritsFrom: 'ohif.overlayItem',
attribute: 'StudyDate',
label: 'Date',
title: 'Study date',
},
],
},
'viewportOverlay.topRight': {
$set: [
{
id: 'PatientSpecies',
inheritsFrom: 'ohif.overlayItem',
attribute: 'PatientSpecies',
label: 'Species',
title: 'Patient species',
},
{
id: 'PatientBreed',
inheritsFrom: 'ohif.overlayItem',
attribute: 'PatientBreed',
label: 'Breed',
title: 'Patient breed',
},
],
},
},
};
8 changes: 7 additions & 1 deletion platform/app/src/appInit.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import loadModules, { loadModule as peerImport } from './pluginImports';
import { publicUrl } from './utils/publicUrl';

/**
* @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration
* @param {object|function} appConfigOrFunc - application configuration, or a function that returns application configuration
* @param {object[]} defaultExtensions - array of extension objects
*/
async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
Expand Down Expand Up @@ -93,6 +93,12 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);

const { customizationService } = servicesManager.services;
// There might be more defaults loaded here.
customizationService.init(extensionManager);
// After the default extensions are loaded, the customizations parameter ones can be loaded.
await customizationService.applyWindowUrlCustomizations();

// TODO: We no longer use `utils.addServer`
// TODO: We no longer init webWorkers at app level
// TODO: We no longer init the user Manager
Expand Down
50 changes: 50 additions & 0 deletions platform/app/src/utils/preserveQueryParameters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { preserveQueryParameters, preserveQueryStrings } from './preserveQueryParameters';

describe('preserveQueryParameters', () => {
it('preserves single-valued keys like configUrl', () => {
const current = new URLSearchParams();
current.append('configUrl', 'foo.js');
const out = new URLSearchParams();
preserveQueryParameters(out, current);
expect(out.get('configUrl')).toBe('foo.js');
});

it('preserves all repeated values for the customization key', () => {
const current = new URLSearchParams();
current.append('customization', 'a');
current.append('customization', 'b');
const out = new URLSearchParams();
preserveQueryParameters(out, current);
expect(out.getAll('customization')).toEqual(['a', 'b']);
});

it('does not preserve unrelated keys', () => {
const current = new URLSearchParams();
current.append('foo', 'bar');
const out = new URLSearchParams();
preserveQueryParameters(out, current);
expect(out.get('foo')).toBeNull();
});
});

describe('preserveQueryStrings', () => {
it('keeps single-valued keys flat and multi-valued keys as arrays', () => {
const current = new URLSearchParams();
current.append('configUrl', 'foo.js');
current.append('customization', 'a');
current.append('customization', 'b');

const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, current);
expect(out.configUrl).toBe('foo.js');
expect(out.customization).toEqual(['a', 'b']);
});

it('uses a flat string when there is exactly one customization value', () => {
const current = new URLSearchParams();
current.append('customization', 'only');
const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, current);
expect(out.customization).toBe('only');
});
});
46 changes: 40 additions & 6 deletions platform/app/src/utils/preserveQueryParameters.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,60 @@
function preserve(query, current, key) {
/**
* Keys that are preserved as single-valued query parameters when navigating
* between worklist and viewer modes.
*/
export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber', 'hangingProtocolId'];

/**
* Keys that are preserved as multi-valued query parameters. Each occurrence
* (and comma-delimited values within an occurrence) is appended back to the
* outgoing query so repeated values survive navigation.
*/
export const preserveMultiKeys = ['customization'];

function preserve(query: URLSearchParams, current: URLSearchParams, key: string) {
const value = current.get(key);
if (value) {
query.append(key, value);
}
}

export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber', 'hangingProtocolId'];
function preserveMulti(query: URLSearchParams, current: URLSearchParams, key: string) {
const values = current.getAll(key);
for (const value of values) {
if (value) {
query.append(key, value);
}
}
}

export function preserveQueryParameters(
query,
current = new URLSearchParams(window.location.search)
) {
query: URLSearchParams,
current: URLSearchParams = new URLSearchParams(window.location.search)
): void {
for (const key of preserveKeys) {
preserve(query, current, key);
}
for (const key of preserveMultiKeys) {
preserveMulti(query, current, key);
}
}

export function preserveQueryStrings(query, current = new URLSearchParams(window.location.search)) {
export function preserveQueryStrings(
query: Record<string, string | string[]>,
current: URLSearchParams = new URLSearchParams(window.location.search)
): void {
for (const key of preserveKeys) {
const value = current.get(key);
if (value) {
query[key] = value;
}
}
for (const key of preserveMultiKeys) {
const values = current.getAll(key).filter(Boolean);
if (values.length === 1) {
query[key] = values[0];
} else if (values.length > 1) {
query[key] = values;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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) => ({
customizations: { 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.customizations).toBeDefined();
expect(importFn).toHaveBeenCalledTimes(1);
});

it('returns immediately when the same module is already loaded', async () => {
const importFn = jest.fn(async (url: string) => ({
customizations: { 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 the unified `customization` field', async () => {
const importFn = jest.fn(async (url: string) => {
if (url.endsWith('/A.js')) {
return {
customizations: {
global: {
'pkg.A': { customization: 'B' },
},
},
};
}
if (url.endsWith('/B.js')) {
return {
customizations: { 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.js')) {
return {
customizations: { global: { 'pkg.A': { customization: 'B' } } },
};
}
if (url.endsWith('/B.js')) {
return {
customizations: { global: { 'pkg.B': { customization: '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('skips dependency refs that are not URL-loadable customizations', async () => {
const importFn = jest.fn(async () => ({
customizations: {
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 () => ({
customizations: { global: { 'pkg.X': {} } },
}));
const loaded = await service.requires(['A', 'B', '/default/C'], { policy, importFn });
expect(loaded.map(l => l.request.name)).toEqual(['A', 'B', 'C']);
expect(importFn).toHaveBeenCalledTimes(3);
});

it('logs warnings for rejected entries but still loads valid ones when not strict', async () => {
const importFn = jest.fn(async () => ({
customizations: { global: {} },
}));
const warn = jest.fn();
const loaded = await service.requires(['A', '/missing/foo', '../escape'], {
policy,
importFn,
logger: { warn, error: jest.fn() },
});
expect(loaded).toHaveLength(1);
expect(loaded[0].request.name).toBe('A');
expect(warn).toHaveBeenCalled();
});

it('throws in strict mode when any path is rejected as invalid', async () => {
const importFn = jest.fn(async () => ({
customizations: { global: {} },
}));
const outcome = await service
.requires(['A', '/missing/foo'], {
policy: { ...policy, strict: true },
importFn,
logger: { warn: jest.fn(), error: jest.fn() },
})
.catch(e => e);
expect(outcome).toBeInstanceOf(Error);
expect((outcome as Error).message).toMatch(/strict mode/);
expect(importFn).not.toHaveBeenCalled();
});

it('throws in strict mode when import fails', async () => {
const importFn = jest.fn(async () => {
throw new Error('404');
});
const outcome = await service
.requires(['A'], {
policy: { ...policy, strict: true },
importFn,
logger: { warn: jest.fn(), error: jest.fn() },
})
.catch(e => e);
expect(outcome).toBeInstanceOf(Error);
expect((outcome as Error).message).toMatch(/failed to import/);
});

it('warns and skips when import fails if not strict', 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('throws in strict mode when the module has no customization payload', async () => {
const importFn = jest.fn(async () => ({}));
const outcome = await service
.requires(['A'], {
policy: { ...policy, strict: true },
importFn,
logger: { warn: jest.fn(), error: jest.fn() },
})
.catch(e => e);
expect(outcome).toBeInstanceOf(Error);
expect((outcome as Error).message).toMatch(/no customizations payload/);
});

it('applyCustomizationUrlSearchParams delegates to requires', async () => {
const importFn = jest.fn(async () => ({
customizations: { global: { 'pkg.X': {} } },
}));
const params = new URLSearchParams();
params.append('customization', 'A,B');
params.append('customization', '/default/C');
await service.applyCustomizationUrlSearchParams(params, { policy, importFn });
expect(importFn).toHaveBeenCalledTimes(3);
});
});
Loading
Loading