From 6b6761088b2badd0b0817e13858e54b631021c2f Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 10 Jul 2026 08:16:16 -0400 Subject: [PATCH] fix: Several new worklist issues (#6130) * fix: Several new worklist issues * refactor: Export a single OnStudyDoubleClick type from the StudyList barrel Addresses PR review feedback: the double-click handler signature was written out in both TableProps and the WorkList customization cast, so the two could drift apart. Co-Authored-By: Claude Fable 5 * feat: Run worklist study double-click as a command, registrable by modes - Modes can now export getCommandsModule on their definition; appInit registers it (via the new ExtensionManager.registerCommandsModule) before the mode is instantiated, in a new 'WORKLIST' commands context, so the commands are available on the worklist before any mode route is entered. - The workList.onStudyDoubleClick customization is now a command run input (name/options) instead of a bare function, defaulting to the new launchDefaultMode command, which launches the default workflow falling back to the first applicable one. commandOptions.workflowId overrides it to a specific mode. - Duplicate mode ids are now skipped before running their modeFactory rather than after. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- extensions/default/src/commandsModule.ts | 31 ++++++++++++ .../customizations/workListCustomization.ts | 24 ++++++++++ platform/app/src/appInit.js | 40 ++++++++++++---- platform/app/src/routes/WorkList/WorkList.tsx | 29 ++++++++++- platform/app/src/routes/index.tsx | 2 +- .../core/src/extensions/ExtensionManager.ts | 18 +++++++ .../sampleCustomizations.tsx | 48 +++++++++++++++++++ .../InputMultiSelect/InputMultiSelect.tsx | 16 +++++-- .../StudyList/components/Layout.tsx | 2 + .../components/StudyList/components/Table.tsx | 45 ++++++++++++----- .../ui-next/src/components/StudyList/index.ts | 2 + 11 files changed, 230 insertions(+), 27 deletions(-) diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 40ef854d9..573830c42 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -842,6 +842,33 @@ const commandsModule = ({ } }; }, + + /** + * Launches a workflow (mode) for a study from the worklist. This is the + * default `workList.onStudyDoubleClick` command. + * + * @param study - the StudyRow the action applies to + * @param workflows - the workflows applicable to the study, in menu order; + * each has `id`, `displayName`, `isDefault` and `launchWithStudy(study)` + * @param defaultWorkflow - the user's default workflow when it applies to + * the study + * @param workflowId - command option to force a specific workflow (mode id) + * instead of the default/first applicable one + */ + launchDefaultMode: ({ study, workflows = [], defaultWorkflow, workflowId }) => { + const workflow = workflowId + ? workflows.find(w => w.id === workflowId) + : (defaultWorkflow ?? workflows[0]); + if (!workflow) { + console.warn( + workflowId + ? `launchDefaultMode: workflow '${workflowId}' is not applicable to the study` + : 'launchDefaultMode: no workflow is applicable to the study' + ); + return; + } + workflow.launchWithStudy(study); + }, }; const definitions = { @@ -871,6 +898,10 @@ const commandsModule = ({ addDisplaySetAsLayer: actions.addDisplaySetAsLayer, removeDisplaySetLayer: actions.removeDisplaySetLayer, createStoreFunction: actions.createStoreFunction, + launchDefaultMode: { + commandFn: actions.launchDefaultMode, + context: 'WORKLIST', + }, }; return { diff --git a/extensions/default/src/customizations/workListCustomization.ts b/extensions/default/src/customizations/workListCustomization.ts index 817f20d11..c4e85eb63 100644 --- a/extensions/default/src/customizations/workListCustomization.ts +++ b/extensions/default/src/customizations/workListCustomization.ts @@ -67,6 +67,29 @@ import { StudyList } from '@ohif/ui-next'; * `` layout is used. * Currently only applies when `workList.variant` is `'default'`. * + * - `workList.onStudyDoubleClick`: command run input (default: + * `{ commandName: 'launchDefaultMode' }`) + * The command(s) run when a study row is double-clicked (the row is selected + * first). Accepts anything `commandsManager.run` does: a command name string, + * `{ commandName, commandOptions, context }`, an array of those, or a plain + * function. At call time these are merged into the command options (a plain + * function receives them as its single argument): + * - `study`: the double-clicked `StudyRow`. + * - `workflows`: the workflows applicable to the study, in the same order as + * the row's Launch Workflow menu. Each has `id`, `displayName`, + * `isDefault`, and `launchWithStudy(study)`. + * - `defaultWorkflow`: the user's default workflow when it applies to the + * study, else `undefined`. + * The default `launchDefaultMode` command launches `defaultWorkflow`, + * falling back to the first applicable workflow. Override the options to + * always launch a specific mode: + * `{ commandName: 'launchDefaultMode', commandOptions: { workflowId: 'segmentation' } }`. + * Modes can contribute their own commands for this via a `getCommandsModule` + * export on the mode definition, registered at app init in the 'WORKLIST' + * context — before any mode route is entered. When set to a falsy value the + * built-in StudyList.Table double-click behavior applies. + * Currently only applies when `workList.variant` is `'default'`. + * * - `workList.settingsMenuItems`: `(defaults) => SettingsMenuItem[]` (default: identity) * Builds the items in the WorkList settings popover. Receives the default * items (`about`, `userPreferences`, and `logout` when OIDC is configured) @@ -82,6 +105,7 @@ export default function getWorkListCustomization() { 'workList.previewSeriesView': 'all', 'workList.columns': StudyList.defaultColumns, 'workList.renderPreviewContent': undefined, + 'workList.onStudyDoubleClick': { commandName: 'launchDefaultMode' }, 'workList.settingsMenuItems': (defaults: unknown) => defaults, }; } diff --git a/platform/app/src/appInit.js b/platform/app/src/appInit.js index 20a83c427..9059db6fa 100644 --- a/platform/app/src/appInit.js +++ b/platform/app/src/appInit.js @@ -130,19 +130,39 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { } const { id } = mode; - if (mode.modeFactory) { - // If the appConfig contains configuration for this mode, use it. - const modeConfiguration = - appConfig.modesConfiguration && appConfig.modesConfiguration[id] - ? appConfig.modesConfiguration[id] - : {}; - - mode = await mode.modeFactory({ modeConfiguration, loadModules }); - } - if (modesById.has(id)) { continue; } + + // If the appConfig contains configuration for this mode, use it. + const modeConfiguration = + appConfig.modesConfiguration && appConfig.modesConfiguration[id] + ? appConfig.modesConfiguration[id] + : {}; + + // Mirrors the extension commands path for modes, but registers before the + // mode is instantiated so the commands are usable on the worklist, ahead + // of any mode route being entered. Definitions land in the 'WORKLIST' + // context unless the module (or an individual command) declares its own. + if (typeof mode.getCommandsModule === 'function') { + extensionManager.registerCommandsModule( + mode.getCommandsModule({ + appConfig, + commandsManager, + servicesManager, + serviceProvidersManager, + hotkeysManager, + extensionManager, + modeConfiguration, + }), + 'WORKLIST' + ); + } + + if (mode.modeFactory) { + mode = await mode.modeFactory({ modeConfiguration, loadModules }); + } + // Prevent duplication modesById.add(id); if (!mode || typeof mode !== 'object') { diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index d15d2f27c..7288c5f00 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -1,10 +1,17 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useAppConfig } from '@state'; +import type { RunInput } from '@ohif/core/src/classes/CommandsManager'; import { preserveQueryParameters } from '../../utils/preserveQueryParameters'; import { useStudyListStateSync, useWorkListToolbarActions } from '../../hooks'; -import { StudyList, Icons, InvestigationalUseDialog, type StudyRow } from '@ohif/ui-next'; +import { + StudyList, + Icons, + InvestigationalUseDialog, + type StudyRow, + type OnStudyDoubleClick, +} from '@ohif/ui-next'; import { StudyListSettingsPopover } from './StudyListSettingsPopover'; import { SidePanelPreview } from './SidePanelPreview'; @@ -26,6 +33,7 @@ export default function WorkList({ onRefresh, servicesManager, extensionManager, + commandsManager, }: Props) { const [appConfig] = useAppConfig(); const { customizationService } = servicesManager.services; @@ -47,6 +55,22 @@ export default function WorkList({ const [selected, setSelected] = useState(null); const [isPreviewOpen, setPreviewOpen] = useState(true); + // `workList.onStudyDoubleClick` is the command (or command list) run when a + // study row is double-clicked — by default `launchDefaultMode`, which + // launches the default workflow, falling back to the first applicable one. + // The study and its applicable workflows are merged into the command options + // at call time, so an override only needs to name a command and any static + // options (e.g. a specific `workflowId`). + const studyDoubleClickCommand = customizationService.getCustomization( + 'workList.onStudyDoubleClick' + ) as RunInput; + const onStudyDoubleClick = useCallback( + (study, { defaultWorkflow, workflows }) => { + commandsManager.run(studyDoubleClickCommand, { study, defaultWorkflow, workflows }); + }, + [commandsManager, studyDoubleClickCommand] + ); + const columns = useMemo(() => { // `workList.columns` is registered as a value (StudyList.defaultColumns) and // merged via customization commands, so we read the result directly. @@ -122,6 +146,7 @@ export default function WorkList({ ) } title={'Study List'} + onStudyDoubleClick={studyDoubleClickCommand ? onStudyDoubleClick : undefined} onSelectionChange={sel => setSelected((sel as StudyRow[])[0] ?? null)} toolbarLeftComponent={logoComponent} toolbarRightActionsComponent={toolbarActions} diff --git a/platform/app/src/routes/index.tsx b/platform/app/src/routes/index.tsx index efd4e9a11..d1354f97d 100644 --- a/platform/app/src/routes/index.tsx +++ b/platform/app/src/routes/index.tsx @@ -131,7 +131,7 @@ const createRoutes = ({ path: '/', children: DataSourceWrapper, private: true, - props: { children: WorkListComponent, servicesManager, extensionManager }, + props: { children: WorkListComponent, servicesManager, extensionManager, commandsManager }, }; const customRoutes = customizationService.getCustomization('routes.customRoutes'); diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index 677f8fe68..ae5bde7af 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -614,6 +614,24 @@ export default class ExtensionManager extends PubSubService { }); } + /** + * Registers a commands module produced outside the extension registration + * path — e.g. a mode's `getCommandsModule`, registered at app init so its + * commands are available before any mode route is entered (worklist time). + * + * @param commandsModule - as returned by a `getCommandsModule` function + * @param defaultContext - context used when the module does not declare one + */ + public registerCommandsModule( + commandsModule: CommandsModule, + defaultContext: string = 'VIEWER' + ): void { + this._initCommandsModule({ + ...commandsModule, + defaultContext: commandsModule.defaultContext || defaultContext, + }); + } + /** * * @private diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index 5618791db..c42c486ae 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -521,6 +521,54 @@ window.config = { }, }, ], +}; + `, + }, + { + id: 'workList.onStudyDoubleClick', + description: ( + <> + The command run when a study row is double-clicked (the row is selected first). Accepts + anything commandsManager.run does: a command name string,{' '} + {"{ commandName, commandOptions, context }"}, an array of those, or a plain + function. At call time the following are merged into the command options (a plain + function receives them as its single argument): +
    +
  • + study: the double-clicked StudyRow. +
  • +
  • + workflows: the workflows applicable to the study, in the same order as + the row's Launch Workflow menu. Each has id, displayName,{' '} + isDefault, and launchWithStudy(study). +
  • +
  • + defaultWorkflow: the user's default workflow when it applies to the + study, else undefined. +
  • +
+ The default launchDefaultMode command launches the default workflow, falling + back to the first applicable one. Modes can contribute their own commands via a{' '} + getCommandsModule export on the mode definition — these are registered at + app init in the WORKLIST context, before any mode route is entered. + Currently only applies when workList.variant is 'default'. + + ), + default: "{ commandName: 'launchDefaultMode' }", + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'workList.onStudyDoubleClick': { + // Always launch a specific mode on double click, regardless of the default. + $set: { + commandName: 'launchDefaultMode', + commandOptions: { workflowId: '@ohif/mode-longitudinal' }, + }, + }, + }, + ], }; `, }, diff --git a/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx b/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx index 8196869c0..e7daff1e8 100644 --- a/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx +++ b/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx @@ -77,9 +77,19 @@ function InputMultiSelectRoot({ options, value, onChange, children }: InputMulti const filtered = React.useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return normalized; - return normalized.filter( - opt => opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q) - ); + // Prefix matches rank above substring matches (typing "PT" lists PT before CTPT). + const prefixMatches: NormalizedOption[] = []; + const substringMatches: NormalizedOption[] = []; + for (const opt of normalized) { + const label = opt.label.toLowerCase(); + const value = opt.value.toLowerCase(); + if (label.startsWith(q) || value.startsWith(q)) { + prefixMatches.push(opt); + } else if (label.includes(q) || value.includes(q)) { + substringMatches.push(opt); + } + } + return [...prefixMatches, ...substringMatches]; }, [normalized, query]); React.useEffect(() => { diff --git a/platform/ui-next/src/components/StudyList/components/Layout.tsx b/platform/ui-next/src/components/StudyList/components/Layout.tsx index a058d6c68..82080a641 100644 --- a/platform/ui-next/src/components/StudyList/components/Layout.tsx +++ b/platform/ui-next/src/components/StudyList/components/Layout.tsx @@ -135,6 +135,7 @@ function Table({ toolbarRightActionsComponent, isLoading, loadingComponent, + onStudyDoubleClick, children, }: TableProps) { const { defaultPreviewSizePercent } = useLayout(); @@ -168,6 +169,7 @@ function Table({ filters={filters} isLoading={isLoading} loadingComponent={loadingComponent} + onStudyDoubleClick={onStudyDoubleClick} /> diff --git a/platform/ui-next/src/components/StudyList/components/Table.tsx b/platform/ui-next/src/components/StudyList/components/Table.tsx index 068d2b850..f84752bf8 100644 --- a/platform/ui-next/src/components/StudyList/components/Table.tsx +++ b/platform/ui-next/src/components/StudyList/components/Table.tsx @@ -7,9 +7,20 @@ import { DatePickerWithRange } from '../../DateRange'; import { InputMultiSelect } from '../../InputMultiSelect'; import type { StudyDateRangeFilter, StudyRow } from '../types/types'; import { tokenizeModalities } from '../utils/tokenizeModalities'; -import { useWorkflows } from './WorkflowsProvider'; +import { useWorkflows, type Workflow } from './WorkflowsProvider'; import { COLUMN_IDS } from '../columns/defaultColumns'; +/** + * Replaces the built-in double-click action (launch the default workflow, + * falling back to the first applicable one). The row is selected before this + * is called. `workflows` are the workflows applicable to the study, in menu + * order; `defaultWorkflow` is the user's default when it applies to the study. + */ +export type OnStudyDoubleClick = ( + study: StudyRow, + context: { defaultWorkflow?: Workflow; workflows: Workflow[] } +) => void; + export type TableProps = Omit, 'children' | 'getRowId'> & { title?: ReactNode; showColumnVisibility?: boolean; @@ -19,6 +30,7 @@ export type TableProps = Omit, 'children' | 'getRowId'> toolbarRightComponent?: ReactNode; isLoading?: boolean; loadingComponent?: ReactNode; + onStudyDoubleClick?: OnStudyDoubleClick; }; export function Table({ @@ -42,6 +54,7 @@ export function Table({ isLoading, loadingComponent, manualFiltering, + onStudyDoubleClick, }: TableProps) { return ( @@ -68,6 +81,7 @@ export function Table({ toolbarRightComponent={toolbarRightComponent} isLoading={isLoading} loadingComponent={loadingComponent} + onStudyDoubleClick={onStudyDoubleClick} /> ); @@ -82,6 +96,7 @@ function TableContent({ toolbarRightComponent, isLoading, loadingComponent, + onStudyDoubleClick, }: { title?: ReactNode; showColumnVisibility?: boolean; @@ -91,6 +106,7 @@ function TableContent({ toolbarRightComponent?: ReactNode; isLoading?: boolean; loadingComponent?: ReactNode; + onStudyDoubleClick?: TableProps['onStudyDoubleClick']; }) { const { t } = useTranslation('StudyList'); const { table } = useDataTable(); @@ -102,7 +118,7 @@ function TableContent({ return Array.from(new Set(tokens)).sort(); }, [table.options?.data]); // Access workflow provider for default workflow + launch - const { getDefaultWorkflowForStudy } = useWorkflows(); + const { getDefaultWorkflowForStudy, getWorkflowsForStudy } = useWorkflows(); return (
@@ -198,10 +214,12 @@ function TableContent({ className: 'group cursor-pointer', onClick: row => { const original = row.original as StudyRow; - const defaultWorkflow = getDefaultWorkflowForStudy(original); - // When a default workflow is set, do not allow a second click to unselect. - // Always select on click; otherwise toggle selection. - if (defaultWorkflow) { + const canDoubleClickLaunch = + Boolean(onStudyDoubleClick) || + getWorkflowsForStudy(original).length > 0; + // When a double click can launch, the second click must not read + // as an unselect — clicking only ever selects. Otherwise toggle. + if (canDoubleClickLaunch) { if (!row.getIsSelected()) { row.toggleSelected(true); } @@ -211,15 +229,20 @@ function TableContent({ }, onDoubleClick: row => { const original = row.original as StudyRow; + const workflows = getWorkflowsForStudy(original); const defaultWorkflow = getDefaultWorkflowForStudy(original); - if (!defaultWorkflow) { - return; - } - // Ensure the row is selected, then launch with the default workflow + // Ensure the row is selected before launching if (!row.getIsSelected()) { row.toggleSelected(true); } - defaultWorkflow.launchWithStudy(original); + if (onStudyDoubleClick) { + onStudyDoubleClick(original, { defaultWorkflow, workflows }); + return; + } + // Launch the default workflow, or fall back to the first + // applicable one (the top entry of the row's workflow menu). + const workflow = defaultWorkflow ?? workflows[0]; + workflow?.launchWithStudy(original); }, }} /> diff --git a/platform/ui-next/src/components/StudyList/index.ts b/platform/ui-next/src/components/StudyList/index.ts index 20575b68a..1c6b0e7a3 100644 --- a/platform/ui-next/src/components/StudyList/index.ts +++ b/platform/ui-next/src/components/StudyList/index.ts @@ -16,6 +16,8 @@ import { WorkflowsProvider } from './components/WorkflowsProvider'; // Types export * from './types/types'; +export type { Workflow } from './components/WorkflowsProvider'; +export type { OnStudyDoubleClick } from './components/Table'; // Column ID constants export { COLUMN_IDS, FILTERABLE_COLUMN_IDS, TEXT_FILTER_COLUMN_IDS };