Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import './CustomizableViewportOverlay.css';
import { useViewportRendering } from '../../hooks';

const EPSILON = 1e-4;
const { formatPN } = utils;
const { formatPN, formatValue } = utils;

type ViewportData = StackViewportData | VolumeViewportData;

Expand Down Expand Up @@ -184,7 +184,12 @@ function CustomizableViewportOverlay({
} else {
const renderItem = customizationService.transform(item);

if (typeof renderItem.contentF === 'function') {
if (
renderItem &&
typeof renderItem === 'object' &&
'contentF' in renderItem &&
typeof renderItem.contentF === 'function'
) {
return renderItem.contentF(overlayItemProps);
}
}
Expand Down Expand Up @@ -357,7 +362,8 @@ function OverlayItem(props) {
const { instance, customization = {} } = props;
const { color, attribute, title, label, background } = customization;
const value = customization.contentF?.(props, customization) ?? instance?.[attribute];
if (value === undefined || value === null) {
const displayValue = formatValue(value);
if (displayValue === null || displayValue === '') {
return null;
}
return (
Expand All @@ -367,7 +373,7 @@ function OverlayItem(props) {
title={title}
>
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
<span className="ml-0 shrink-0">{value}</span>
<span className="ml-0 shrink-0">{displayValue}</span>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion extensions/default/src/ViewerLayout/ViewerHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }
if (dataSourceIdx !== -1 && existingDataSource) {
searchQuery.append('datasources', pathname.substring(dataSourceIdx + 1));
}
preserveQueryParameters(searchQuery);
preserveQueryParameters(searchQuery, customizationService);

navigate({
pathname: '/',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { utils } from '@ohif/core';

export default {
'ohif.overlayItem': function (props) {
Expand All @@ -13,7 +14,8 @@ export default {
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
if (!value) {
const displayValue = utils.formatValue(value);
if (!displayValue) {
return null;
}

Expand All @@ -24,7 +26,7 @@ export default {
title={this.title || ''}
>
{this.label && <span className="mr-1 shrink-0">{this.label}</span>}
<span className="font-light">{value}</span>
<span className="font-light">{displayValue}</span>
</span>
);
},
Expand Down
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',
},
],
},
},
};
5 changes: 2 additions & 3 deletions platform/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ function App({
cineService,
userAuthenticationService,
uiNotificationService,
customizationService,
} = servicesManager.services;

const providers = [
Expand Down Expand Up @@ -142,8 +141,8 @@ function App({

let authRoutes = null;

// Should there be a generic call to init on the extension manager?
customizationService.init(extensionManager);
// customizationService.init(extensionManager) runs in appInit after extensions register;
// do not call init again here — repeated init would duplicate-merge unless guarded (see CustomizationService.init).

// Use config to create routes
const appRoutes = createRoutes({
Expand Down
7 changes: 6 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,11 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);

const { customizationService } = servicesManager.services;
// Merge extension default/global modules first; then URL ?customization= globals layer on top.
customizationService.init(extensionManager);
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
6 changes: 4 additions & 2 deletions platform/app/src/routes/WorkList/WorkList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,13 @@ function WorkList({
}
});

preserveQueryStrings(queryString);
preserveQueryStrings(queryString, customizationService);

const search = qs.stringify(queryString, {
skipNull: true,
skipEmptyString: true,
// preserveQueryStrings stores preserved keys as arrays; default indices format breaks plain keys like configUrl
arrayFormat: 'repeat',
});
navigate({
pathname: '/',
Expand Down Expand Up @@ -417,7 +419,7 @@ function WorkList({
query.append('configUrl', filterValues.configUrl);
}
query.append('StudyInstanceUIDs', studyInstanceUid);
preserveQueryParameters(query);
preserveQueryParameters(query, customizationService);

return (
mode.displayName && (
Expand Down
104 changes: 104 additions & 0 deletions platform/app/src/utils/preserveQueryParameters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import qs from 'qs';

import { preserveQueryParameters, preserveQueryStrings } from './preserveQueryParameters';

describe('preserveQueryParameters', () => {
it('preserves base keys as query arrays', () => {
const current = new URLSearchParams();
current.append('configUrl', 'foo.js');
const out = new URLSearchParams();
preserveQueryParameters(out, undefined, current);
expect(out.getAll('configUrl')).toEqual(['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, undefined, 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, undefined, current);
expect(out.get('foo')).toBeNull();
});

it('uses customization service values for multi-key preservation', () => {
const customizationService = {
getValue: jest.fn().mockReturnValue(['customization', 'customizationAlt']),
};
const current = new URLSearchParams();
current.append('customizationAlt', 'c');
const out = new URLSearchParams();
preserveQueryParameters(out, customizationService, current);
expect(out.getAll('customizationAlt')).toEqual(['c']);
expect(customizationService.getValue).toHaveBeenCalled();
});
});

describe('preserveQueryStrings', () => {
it('keeps all preserved 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, undefined, current);
expect(out.configUrl).toEqual(['foo.js']);
expect(out.customization).toEqual(['a', 'b']);
});

it('keeps a single customization value as an array', () => {
const current = new URLSearchParams();
current.append('customization', 'only');
const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, undefined, current);
expect(out.customization).toEqual(['only']);
});

it('uses customization service values for query string preservation', () => {
const customizationService = {
getValue: jest.fn().mockReturnValue(['customization', 'customizationAlt']),
};
const current = new URLSearchParams();
current.append('customizationAlt', 'c');
const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, customizationService, current);
expect(out.customizationAlt).toEqual(['c']);
expect(customizationService.getValue).toHaveBeenCalled();
});

it('serializes single preserved values as plain query keys with arrayFormat repeat', () => {
const current = new URLSearchParams();
current.append('configUrl', 'foo.js');
const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, undefined, current);
const search = qs.stringify(out, {
skipNull: true,
skipEmptyString: true,
arrayFormat: 'repeat',
});
expect(search).toBe('configUrl=foo.js');
expect(search).not.toMatch(/configUrl\[/);
});

it('serializes repeated preserved values as repeated keys', () => {
const current = new URLSearchParams();
current.append('customization', 'a');
current.append('customization', 'b');
const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, undefined, current);
const search = qs.stringify(out, {
skipNull: true,
skipEmptyString: true,
arrayFormat: 'repeat',
});
expect(search).toBe('customization=a&customization=b');
});
});
59 changes: 44 additions & 15 deletions platform/app/src/utils/preserveQueryParameters.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,55 @@
function preserve(query, current, key) {
const value = current.get(key);
if (value) {
query.append(key, value);
import type CustomizationService from '@ohif/core/src/services/CustomizationService';

/**
* Keys preserved when navigating between worklist and viewer modes.
* All preserved keys are handled as multi-valued query parameters.
*/
export const PRESERVE_CUSTOMIZATION_KEYS_KEY = 'ohif.preserveCustomizationKeys';
export const preserveKeys = [
'configUrl',
'multimonitor',
'screenNumber',
'hangingProtocolId',
'customization',
];

function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) {
const values = current.getAll(key);
for (const value of values) {
if (value) {
query.append(key, value);
}
}
}

export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber', 'hangingProtocolId'];
function getPreserveKeys(customizationService?: CustomizationService): string[] {
const customKeys = customizationService?.getValue?.(PRESERVE_CUSTOMIZATION_KEYS_KEY, []) || [];
if (!customKeys?.length) {
return preserveKeys;
}

return [...preserveKeys, ...customKeys];
}
Comment thread
wayfarer3130 marked this conversation as resolved.

export function preserveQueryParameters(
query,
current = new URLSearchParams(window.location.search)
) {
for (const key of preserveKeys) {
preserve(query, current, key);
query: URLSearchParams,
customizationService?: CustomizationService,
current: URLSearchParams = new URLSearchParams(window.location.search)
): void {
for (const key of getPreserveKeys(customizationService)) {
preserveKey(query, current, key);
}
}

export function preserveQueryStrings(query, current = new URLSearchParams(window.location.search)) {
for (const key of preserveKeys) {
const value = current.get(key);
if (value) {
query[key] = value;
export function preserveQueryStrings(
query: Record<string, string | string[]>,
customizationService?: CustomizationService,
current: URLSearchParams = new URLSearchParams(window.location.search)
): void {
for (const key of getPreserveKeys(customizationService)) {
const values = current.getAll(key).filter(Boolean);
if (values.length) {
query[key] = values;
}
}
}
Loading
Loading