Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
91 changes: 75 additions & 16 deletions extensions/default/src/Toolbar/ToolbarModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,36 @@ import { CommandsManager } from '@ohif/core';
import { useAppConfig } from '@state';
import { useTranslation } from 'react-i18next';

import type { ModeSelectorCustomization } from '../customizations/modeSelectorCustomization.types';
import { defaultModeSelectorCustomization } from '../customizations/modeSelectorCustomization.types';
import {
buildModeSwitchHref,
evaluateModeValidity,
fetchStudyEnvelope,
getDataSourcePathSegment,
isCurrentModeHref,
modeIsValidForOrdering,
type StudyEnvelope,
usePreservedViewerSearch,
type LoadedModeRouteHint,
} from '../utils/modeSelectorUtils';

const HIDDEN_MODE_IDS = new Set(['ohif-gcp-mode']);

type ToolbarMenuRow = {
mode: LoadedModeRouteHint & { displayName?: string; hide?: boolean; isValidMode?: unknown };
validity: Exclude<ReturnType<typeof evaluateModeValidity>, undefined>;
modeHref: { pathname: string; search: string };
modeHref: string;
isCurrentRoute: boolean;
isDisabled: boolean;
label: string;
};

function ToolbarModeSelector({ commandsManager: _commandsManager, servicesManager }) {
const extensionManager = servicesManager.services.customizationService.extensionManager;
const { customizationService } = servicesManager.services;
const extensionManager = customizationService.extensionManager;
const { t } = useTranslation('ToolbarModeSelector');

const modeSelectorCustomization = (customizationService.getCustomization(
'ohif.modeSelector'
) ?? defaultModeSelectorCustomization) as ModeSelectorCustomization;

const labels = useMemo(
() => ({
browseModes: t('Browse modes'),
Expand All @@ -56,20 +61,55 @@ function ToolbarModeSelector({ commandsManager: _commandsManager, servicesManage
);

const location = useLocation();
const preservedSearch = usePreservedViewerSearch(location.search);

const [appConfig] = useAppConfig();
const loadedModes = appConfig?.loadedModes || [];
const groupEnabledModesFirst = appConfig?.groupEnabledModesFirst === true;
const defaultDataSourceName = appConfig?.defaultDataSourceName as string | undefined;

const imageViewer = useImageViewer();
const StudyInstanceUIDs = imageViewer?.StudyInstanceUIDs;
const primaryUid = Array.isArray(StudyInstanceUIDs) ? StudyInstanceUIDs[0] : undefined;

const studyUidsForNavigation = useMemo(() => {
if (modeSelectorCustomization.resolveStudyUidsForNavigation) {
return modeSelectorCustomization.resolveStudyUidsForNavigation({
studyInstanceUIDsFromViewer:
Array.isArray(StudyInstanceUIDs) && StudyInstanceUIDs.length > 0 ?
StudyInstanceUIDs
: undefined,
pathname: location.pathname,
search: location.search,
});
}

return Array.isArray(StudyInstanceUIDs) && StudyInstanceUIDs.length > 0 ?
StudyInstanceUIDs
: undefined;
}, [
StudyInstanceUIDs,
location.pathname,
location.search,
modeSelectorCustomization.resolveStudyUidsForNavigation,
]);

const [studyEnvelope, setStudyEnvelope] = useState<StudyEnvelope | null>(null);
const [metadataLoadFinished, setMetadataLoadFinished] = useState(false);
const [open, setOpen] = useState(false);

const fetchStudyEnvelopeOptions = useMemo(
() =>
modeSelectorCustomization.fetchStudyEnvelopeOptions?.({
pathname: location.pathname,
search: location.search,
}) ?? {},
[
location.pathname,
location.search,
modeSelectorCustomization.fetchStudyEnvelopeOptions,
]
);

const dataSource = useMemo(
() =>
extensionManager.getActiveDataSourceOrNull?.() ?? extensionManager.getActiveDataSource?.()?.[0],
Expand All @@ -88,7 +128,7 @@ function ToolbarModeSelector({ commandsManager: _commandsManager, servicesManage
return;
}

const env = await fetchStudyEnvelope(primaryUid, dataSource);
const env = await fetchStudyEnvelope(primaryUid, dataSource, fetchStudyEnvelopeOptions);

if (!cancelled) {
setStudyEnvelope(env);
Expand All @@ -101,10 +141,10 @@ function ToolbarModeSelector({ commandsManager: _commandsManager, servicesManage
return () => {
cancelled = true;
};
}, [primaryUid, dataSource]);
}, [fetchStudyEnvelopeOptions, primaryUid, dataSource]);

const modesForToolbar = useMemo(
() => loadedModes.filter(m => !m.hide && !HIDDEN_MODE_IDS.has(m.id)),
() => loadedModes.filter(m => !m.hide),
[loadedModes]
);

Expand All @@ -128,11 +168,23 @@ function ToolbarModeSelector({ commandsManager: _commandsManager, servicesManage

const buildHrefForMode = useCallback(
(routeName: string) => {
const dsSuffix = getDataSourcePathSegment(location.pathname, loadedModes, extensionManager);
const pathname = `/${routeName}${dsSuffix ? `/${dsSuffix}` : ''}`;
return { pathname, search: preservedSearch };
const switchOptions =
modeSelectorCustomization.augmentBuildModeSwitchOptions?.({
targetRouteName: routeName,
pathname: location.pathname,
search: location.search,
defaultDataSourceName,
}) ?? {};

return buildModeSwitchHref(routeName, location.search, studyUidsForNavigation, switchOptions);
},
[extensionManager, loadedModes, location.pathname, preservedSearch]
[
defaultDataSourceName,
location.pathname,
location.search,
modeSelectorCustomization.augmentBuildModeSwitchOptions,
studyUidsForNavigation,
]
);

const closePopover = useCallback(() => {
Expand All @@ -154,13 +206,20 @@ function ToolbarModeSelector({ commandsManager: _commandsManager, servicesManage
continue;
}
const modeHref = buildHrefForMode(routeName);
const isCurrentRoute = location.pathname === modeHref.pathname;
const isCurrentRoute = isCurrentModeHref(location.pathname, location.search, modeHref);
const isDisabled = validity.valid !== true || isCurrentRoute;
const label = mode.displayName || routeName;
rows.push({ mode, validity, modeHref, isCurrentRoute, isDisabled, label });
}
return rows;
}, [buildHrefForMode, comparableModesList, labels.unableToEvaluate, location.pathname, studyEnvelope]);
}, [
buildHrefForMode,
comparableModesList,
labels.unableToEvaluate,
location.pathname,
location.search,
studyEnvelope,
]);

if (modesForToolbar.length <= 1) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defaultModeSelectorCustomization } from './modeSelectorCustomization.types';

export default {
'ohif.modeSelector': defaultModeSelectorCustomization,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { BuildModeSwitchSearchOptions } from '../utils/modeSelectorUtils';

export type ModeSelectorStudyUidsContext = {
studyInstanceUIDsFromViewer?: string[];
pathname: string;
search: string;
};

export type ModeSelectorSwitchOptionsContext = {
targetRouteName: string;
pathname: string;
search: string;
defaultDataSourceName?: string;
};

export type ModeSelectorFetchEnvelopeContext = {
pathname: string;
search: string;
};

/** Optional hooks for deployments that use non-standard mode URLs (e.g. GCP Healthcare paths). */
export type ModeSelectorCustomization = {
resolveStudyUidsForNavigation?: (
context: ModeSelectorStudyUidsContext
) => string[] | undefined;
augmentBuildModeSwitchOptions?: (
context: ModeSelectorSwitchOptionsContext
) => BuildModeSwitchSearchOptions;
fetchStudyEnvelopeOptions?: (
context: ModeSelectorFetchEnvelopeContext
) => { preferLoadedMetadata?: boolean };
};

export const defaultModeSelectorCustomization: ModeSelectorCustomization = {};
2 changes: 2 additions & 0 deletions extensions/default/src/getCustomizationModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import reportDialogCustomization from './customizations/reportDialogCustomizatio
import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization';
import onboardingCustomization from './customizations/onboardingCustomization';
import instanceSortingCriteriaCustomization from './customizations/instanceSortingCriteriaCustomization';
import modeSelectorCustomization from './customizations/modeSelectorCustomization';
/**
*
* Note: this is an example of how the customization module can be used
Expand Down Expand Up @@ -71,6 +72,7 @@ export default function getCustomizationModule({ servicesManager, extensionManag
...hotkeyBindingsCustomization,
...onboardingCustomization,
...instanceSortingCriteriaCustomization,
...modeSelectorCustomization,
},
},
];
Expand Down
41 changes: 41 additions & 0 deletions extensions/default/src/utils/modeSelectorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
buildModeSwitchHref,
buildModeSwitchSearch,
hasUsableModalities,
modalitiesStringFromSet,
normalizeModalitiesString,
Expand Down Expand Up @@ -102,4 +104,43 @@ describe('modeSelectorUtils', () => {
expect(normalizeModalitiesString('CT/PT')).toBe('CT\\PT');
});
});

describe('buildModeSwitchSearch', () => {
it('adds StudyInstanceUIDs when only present in the path', () => {
const search = buildModeSwitchSearch('', '1.2.3');

expect(search).toBe('?StudyInstanceUIDs=1.2.3');
});

it('keeps existing query params and StudyInstanceUIDs', () => {
const search = buildModeSwitchSearch('?configUrl=test.json', ['1.2.3', '4.5.6']);

expect(search).toContain('StudyInstanceUIDs=1.2.3');
expect(search).toContain('StudyInstanceUIDs=4.5.6');
expect(search).toContain('configUrl=test.json');
});

it('removes listed query params when stripQueryParams is set', () => {
const search = buildModeSwitchSearch(
'?foo=bar&baz=1&StudyInstanceUIDs=1.2.3',
'1.2.3',
{ stripQueryParams: ['foo', 'baz'] }
);

expect(search).not.toContain('foo=');
expect(search).not.toContain('baz=');
expect(search).toContain('StudyInstanceUIDs=1.2.3');
});
});

describe('buildModeSwitchHref', () => {
it('builds a standard mode URL with datasources query param and study UID', () => {
const href = buildModeSwitchHref('segmentation', '?legacy=foo', '1.2.3', {
stripQueryParams: ['legacy'],
dataSourceName: 'idc-dicomweb',
});

expect(href).toBe('/segmentation?datasources=idc-dicomweb&StudyInstanceUIDs=1.2.3');
});
});
});
Loading
Loading