Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
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();

// 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