Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f360abb
Add customization URL parameter
wayfarer3130 May 4, 2026
38ddc9c
fix: Preserve should be customizeable
wayfarer3130 May 7, 2026
cee9d2a
Update customizations docs
wayfarer3130 May 7, 2026
ca6381b
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 May 7, 2026
a7ecb45
fix: Overlay items on patient name
wayfarer3130 May 7, 2026
74c5986
Add customization test
wayfarer3130 May 7, 2026
7068511
Fix resolve to absolute path
wayfarer3130 May 7, 2026
60efc1c
fix: Warn on no data in load
wayfarer3130 May 7, 2026
b80b5ca
Remove unused customization stuff
wayfarer3130 May 7, 2026
8cc46ad
Merge branch 'master' into feat/customization-url-parameter
wayfarer3130 May 11, 2026
393fd48
fix: PR comments
wayfarer3130 May 13, 2026
091e7e5
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 May 20, 2026
b9dcce7
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 May 25, 2026
44a04ed
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 Jun 17, 2026
d65f8aa
Update stored parameters to only use an array for mulitples
wayfarer3130 Jun 18, 2026
839d232
Remove requires ohif.* special call out
wayfarer3130 Jun 18, 2026
7ea9998
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 Jun 19, 2026
4066f6a
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 Jun 19, 2026
e42e383
Remove strict mode
wayfarer3130 Jun 19, 2026
e3e4ac4
PR comments
wayfarer3130 Jun 19, 2026
bf3cd63
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 Jun 19, 2026
877e3de
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 Jun 19, 2026
b7952d9
Document segmentation examples
wayfarer3130 Jun 19, 2026
a22d530
Add three examples as requested
wayfarer3130 Jun 19, 2026
bb1fcd7
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 Jun 22, 2026
0fa21ec
PR comments
wayfarer3130 Jun 22, 2026
6cb031b
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 Jun 23, 2026
88ad5be
lock
wayfarer3130 Jun 23, 2026
2b79531
Remove old customizatoin export
wayfarer3130 Jun 24, 2026
cc80c19
fix: Ordering issues on customization loads
wayfarer3130 Jun 24, 2026
271be36
Merge branch 'master' into feat/customization-url-parameter
wayfarer3130 Jun 24, 2026
13e3a31
fix: Use correct default for dev builds app config
wayfarer3130 Jun 26, 2026
949bbcd
Merge remote-tracking branch 'origin/master' into feat/customization-…
wayfarer3130 Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
Comment thread
wayfarer3130 marked this conversation as resolved.
Outdated
Comment thread
wayfarer3130 marked this conversation as resolved.
Outdated

// 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
4 changes: 3 additions & 1 deletion platform/app/src/hooks/useStudyListStateSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ function parseFiltersFromURL(params: URLSearchParams): ColumnFiltersState {
* Build URL query string from study list state preserving key query parameters.
*/
function buildQueryFromState(state: StudyListState): string {
const query: Record<string, string> = {};
// preserveQueryStrings may write repeated values as arrays, so the value type
// must allow string[] in addition to string.
const query: Record<string, string | string[]> = {};

// Sorting
if (state.sorting.length > 0) {
Expand Down
102 changes: 102 additions & 0 deletions platform/app/src/utils/preserveQueryParameters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// query-string is the stringifier used by the worklist navigation callers
// (useStudyListStateSync, LegacyWorkList); test against it, not the `qs` library.
import qs from 'query-string';

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 single values as strings and repeated values 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).toBe('foo.js');
expect(out.customization).toEqual(['a', 'b']);
});

it('keeps a single customization value as a string', () => {
const current = new URLSearchParams();
current.append('customization', 'only');
const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, undefined, current);
expect(out.customization).toBe('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).toBe('c');
expect(customizationService.getValue).toHaveBeenCalled();
});

it('serializes single preserved values as plain query keys with default options', () => {
const current = new URLSearchParams();
current.append('configUrl', 'foo.js');
const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, undefined, current);
// Default options only — no arrayFormat. Single values are plain strings, so
// they round-trip unchanged regardless of arrayFormat.
const search = qs.stringify(out, { skipNull: true, skipEmptyString: true });
expect(search).toBe('configUrl=foo.js');
expect(search).not.toMatch(/configUrl\[/);
});

it('serializes repeated preserved values as duplicated keys with default options', () => {
const current = new URLSearchParams();
current.append('customization', 'a');
current.append('customization', 'b');
const out: Record<string, string | string[]> = {};
preserveQueryStrings(out, undefined, current);
// query-string's default arrayFormat already produces duplicated keys, so callers
// need no arrayFormat option for repeated preserved keys to round-trip.
const search = qs.stringify(out, { skipNull: true, skipEmptyString: true });
expect(search).toBe('customization=a&customization=b');
});
});
67 changes: 52 additions & 15 deletions platform/app/src/utils/preserveQueryParameters.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,63 @@
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.
* Each key may repeat in the URL; a single occurrence is preserved as a string and
* multiple occurrences as an array (see preserveQueryStrings).
*/
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.
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) {
continue;
}
// A single value is stored as a plain string and repeated values as an array.
// The worklist stringifier (query-string) serializes both correctly with its
// default arrayFormat: a string becomes `key=value` and an array becomes
// duplicated `key=a&key=b` keys — no arrayFormat option required by callers.
// Keeping single values as strings also keeps them safe under stricter
// serializers (e.g. the `qs` library, whose default would index arrays).
query[key] = values.length === 1 ? values[0] : values;
}
}
Loading
Loading