diff --git a/extensions/default/src/Toolbar/ToolbarModeSelector.tsx b/extensions/default/src/Toolbar/ToolbarModeSelector.tsx index 53e90adc454..b1a73d2e339 100644 --- a/extensions/default/src/Toolbar/ToolbarModeSelector.tsx +++ b/extensions/default/src/Toolbar/ToolbarModeSelector.tsx @@ -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, 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'), @@ -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(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], @@ -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); @@ -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] ); @@ -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(() => { @@ -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; diff --git a/extensions/default/src/customizations/modeSelectorCustomization.ts b/extensions/default/src/customizations/modeSelectorCustomization.ts new file mode 100644 index 00000000000..7ddd93d372a --- /dev/null +++ b/extensions/default/src/customizations/modeSelectorCustomization.ts @@ -0,0 +1,5 @@ +import { defaultModeSelectorCustomization } from './modeSelectorCustomization.types'; + +export default { + 'ohif.modeSelector': defaultModeSelectorCustomization, +}; diff --git a/extensions/default/src/customizations/modeSelectorCustomization.types.ts b/extensions/default/src/customizations/modeSelectorCustomization.types.ts new file mode 100644 index 00000000000..4527dcb70f1 --- /dev/null +++ b/extensions/default/src/customizations/modeSelectorCustomization.types.ts @@ -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 = {}; diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index 6f8cebcc1ad..d7dc9355cd9 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -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 @@ -71,6 +72,7 @@ export default function getCustomizationModule({ servicesManager, extensionManag ...hotkeyBindingsCustomization, ...onboardingCustomization, ...instanceSortingCriteriaCustomization, + ...modeSelectorCustomization, }, }, ]; diff --git a/extensions/default/src/utils/modeSelectorUtils.test.ts b/extensions/default/src/utils/modeSelectorUtils.test.ts index 16fda8ea7a3..394ed9cba06 100644 --- a/extensions/default/src/utils/modeSelectorUtils.test.ts +++ b/extensions/default/src/utils/modeSelectorUtils.test.ts @@ -1,4 +1,6 @@ import { + buildModeSwitchHref, + buildModeSwitchSearch, hasUsableModalities, modalitiesStringFromSet, normalizeModalitiesString, @@ -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'); + }); + }); }); diff --git a/extensions/default/src/utils/modeSelectorUtils.ts b/extensions/default/src/utils/modeSelectorUtils.ts index 28b7d5ff1ea..09cc23b133e 100644 --- a/extensions/default/src/utils/modeSelectorUtils.ts +++ b/extensions/default/src/utils/modeSelectorUtils.ts @@ -1,6 +1,4 @@ -import { useMemo } from 'react'; - -import { type ExtensionManager, DicomMetadataStore, utils } from '@ohif/core'; +import { DicomMetadataStore, utils } from '@ohif/core'; import { preserveQueryParameters } from '@ohif/app'; const { formatPN } = utils; @@ -50,8 +48,6 @@ export type LoadedModeRouteHint = { routeName?: string | null | undefined; }; -type ExtensionManagerDataSources = Pick; - /** * Normalizes a modalities string for comparison (e.g. path-like separators are unified). */ @@ -207,8 +203,16 @@ function buildStudyEnvelopeFromMetadataStore(studyInstanceUID: string): StudyEnv */ export async function fetchStudyEnvelope( StudyInstanceUID: string, - dataSource: DataSourceWithStudySearch | null | undefined + dataSource: DataSourceWithStudySearch | null | undefined, + options?: { preferLoadedMetadata?: boolean } ): Promise { + if (options?.preferLoadedMetadata) { + const fromMetadataStore = buildStudyEnvelopeFromMetadataStore(StudyInstanceUID); + if (fromMetadataStore) { + return fromMetadataStore; + } + } + try { // Call as a method on query.studies so `this` inside search is bound (do not extract the function). if (dataSource?.query?.studies?.search) { @@ -298,41 +302,87 @@ export function evaluateModeValidity( } } +export type BuildModeSwitchSearchOptions = { + /** Query param keys to remove when switching modes. */ + stripQueryParams?: ReadonlyArray; + /** Sets the `datasources` query param (standard OHIF mode routes omit the data source path segment). */ + dataSourceName?: string; +}; + /** - * From the current URL path, returns the segment that names the data source (the piece after the mode route), if any. + * Builds the query string for mode-switch links. + * Ensures StudyInstanceUIDs are present when provided explicitly for navigation. */ -export function getDataSourcePathSegment( - locationPathname: string, - loadedModes: ReadonlyArray | undefined, - extensionManager: ExtensionManagerDataSources | null | undefined -): string | undefined { - const routeNames = new Set((loadedModes || []).filter(Boolean).map(m => m?.routeName).filter(Boolean)); - const segs = locationPathname.split('/').filter(Boolean); - - const modeSegmentIndex = segs.findIndex(seg => routeNames.has(seg)); - if (modeSegmentIndex === -1 || modeSegmentIndex + 1 >= segs.length) { - return undefined; +export function buildModeSwitchSearch( + locationSearch: string, + studyInstanceUIDs?: string | ReadonlyArray, + options: BuildModeSwitchSearchOptions = {} +): string { + const next = new URLSearchParams(locationSearch); + const current = new URLSearchParams(locationSearch); + + preserveQueryParameters(next); + + current.getAll('datasources').forEach(value => { + if (value && !next.getAll('datasources').includes(value)) { + next.append('datasources', value); + } + }); + + options.stripQueryParams?.forEach(key => { + next.delete(key); + }); + + if (options.dataSourceName) { + next.delete('datasources'); + next.set('datasources', options.dataSourceName); } - const candidate = segs[modeSegmentIndex + 1]; - if ( - candidate && - !routeNames.has(candidate) && - extensionManager?.getDataSources?.(candidate)?.length - ) { - return candidate; + const uidsFromArg = ( + studyInstanceUIDs ? + Array.isArray(studyInstanceUIDs) ? + studyInstanceUIDs + : [studyInstanceUIDs] + : [] + ).filter(Boolean); + + const existingUids = [...current.getAll('StudyInstanceUIDs'), ...current.getAll('studyInstanceUIDs')]; + + const uidsToSet = uidsFromArg.length > 0 ? uidsFromArg : existingUids; + + if (uidsToSet.length > 0) { + next.delete('StudyInstanceUIDs'); + next.delete('studyInstanceUIDs'); + uidsToSet.forEach(uid => next.append('StudyInstanceUIDs', uid)); } - return undefined; + + const s = next.toString(); + return s ? `?${s}` : ''; } +export type BuildModeSwitchHrefOptions = BuildModeSwitchSearchOptions; + /** - * React hook: builds the search string to append to mode-switch links while keeping viewer-wide query params. + * Full path + query for mode-switch navigation (string form for React Router `Link`). + * Uses the single-segment mode route (e.g. `/segmentation`) plus `datasources` in the query string. */ -export function usePreservedViewerSearch(locationSearch: string): string { - return useMemo(() => { - const next = new URLSearchParams(locationSearch); - preserveQueryParameters(next); - const s = next.toString(); - return s ? `?${s}` : ''; - }, [locationSearch]); +export function buildModeSwitchHref( + routeName: string, + locationSearch: string, + studyInstanceUIDs?: string | ReadonlyArray, + options: BuildModeSwitchHrefOptions = {} +): string { + const search = buildModeSwitchSearch(locationSearch, studyInstanceUIDs, options); + + return `/${routeName}${search}`; +} + +/** Compares the current viewer location to a mode-switch href string. */ +export function isCurrentModeHref( + pathname: string, + search: string, + modeHref: string +): boolean { + const current = `${pathname}${search || ''}`; + return current === modeHref || pathname === modeHref.split('?')[0]; }