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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bill Wallace 2026-07-10 08:16:16 -04:00 committed by GitHub
parent dbb8b1526e
commit 6b6761088b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 230 additions and 27 deletions

View File

@ -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 = { const definitions = {
@ -871,6 +898,10 @@ const commandsModule = ({
addDisplaySetAsLayer: actions.addDisplaySetAsLayer, addDisplaySetAsLayer: actions.addDisplaySetAsLayer,
removeDisplaySetLayer: actions.removeDisplaySetLayer, removeDisplaySetLayer: actions.removeDisplaySetLayer,
createStoreFunction: actions.createStoreFunction, createStoreFunction: actions.createStoreFunction,
launchDefaultMode: {
commandFn: actions.launchDefaultMode,
context: 'WORKLIST',
},
}; };
return { return {

View File

@ -67,6 +67,29 @@ import { StudyList } from '@ohif/ui-next';
* `<StudyList.PreviewContainer>` layout is used. * `<StudyList.PreviewContainer>` layout is used.
* Currently only applies when `workList.variant` is `'default'`. * 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) * - `workList.settingsMenuItems`: `(defaults) => SettingsMenuItem[]` (default: identity)
* Builds the items in the WorkList settings popover. Receives the default * Builds the items in the WorkList settings popover. Receives the default
* items (`about`, `userPreferences`, and `logout` when OIDC is configured) * items (`about`, `userPreferences`, and `logout` when OIDC is configured)
@ -82,6 +105,7 @@ export default function getWorkListCustomization() {
'workList.previewSeriesView': 'all', 'workList.previewSeriesView': 'all',
'workList.columns': StudyList.defaultColumns, 'workList.columns': StudyList.defaultColumns,
'workList.renderPreviewContent': undefined, 'workList.renderPreviewContent': undefined,
'workList.onStudyDoubleClick': { commandName: 'launchDefaultMode' },
'workList.settingsMenuItems': (defaults: unknown) => defaults, 'workList.settingsMenuItems': (defaults: unknown) => defaults,
}; };
} }

View File

@ -130,19 +130,39 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
} }
const { id } = mode; 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)) { if (modesById.has(id)) {
continue; 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 // Prevent duplication
modesById.add(id); modesById.add(id);
if (!mode || typeof mode !== 'object') { if (!mode || typeof mode !== 'object') {

View File

@ -1,10 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAppConfig } from '@state'; import { useAppConfig } from '@state';
import type { RunInput } from '@ohif/core/src/classes/CommandsManager';
import { preserveQueryParameters } from '../../utils/preserveQueryParameters'; import { preserveQueryParameters } from '../../utils/preserveQueryParameters';
import { useStudyListStateSync, useWorkListToolbarActions } from '../../hooks'; 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 { StudyListSettingsPopover } from './StudyListSettingsPopover';
import { SidePanelPreview } from './SidePanelPreview'; import { SidePanelPreview } from './SidePanelPreview';
@ -26,6 +33,7 @@ export default function WorkList({
onRefresh, onRefresh,
servicesManager, servicesManager,
extensionManager, extensionManager,
commandsManager,
}: Props) { }: Props) {
const [appConfig] = useAppConfig(); const [appConfig] = useAppConfig();
const { customizationService } = servicesManager.services; const { customizationService } = servicesManager.services;
@ -47,6 +55,22 @@ export default function WorkList({
const [selected, setSelected] = useState<StudyRow | null>(null); const [selected, setSelected] = useState<StudyRow | null>(null);
const [isPreviewOpen, setPreviewOpen] = useState(true); 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<OnStudyDoubleClick>(
(study, { defaultWorkflow, workflows }) => {
commandsManager.run(studyDoubleClickCommand, { study, defaultWorkflow, workflows });
},
[commandsManager, studyDoubleClickCommand]
);
const columns = useMemo(() => { const columns = useMemo(() => {
// `workList.columns` is registered as a value (StudyList.defaultColumns) and // `workList.columns` is registered as a value (StudyList.defaultColumns) and
// merged via customization commands, so we read the result directly. // merged via customization commands, so we read the result directly.
@ -122,6 +146,7 @@ export default function WorkList({
) )
} }
title={'Study List'} title={'Study List'}
onStudyDoubleClick={studyDoubleClickCommand ? onStudyDoubleClick : undefined}
onSelectionChange={sel => setSelected((sel as StudyRow[])[0] ?? null)} onSelectionChange={sel => setSelected((sel as StudyRow[])[0] ?? null)}
toolbarLeftComponent={logoComponent} toolbarLeftComponent={logoComponent}
toolbarRightActionsComponent={toolbarActions} toolbarRightActionsComponent={toolbarActions}

View File

@ -131,7 +131,7 @@ const createRoutes = ({
path: '/', path: '/',
children: DataSourceWrapper, children: DataSourceWrapper,
private: true, private: true,
props: { children: WorkListComponent, servicesManager, extensionManager }, props: { children: WorkListComponent, servicesManager, extensionManager, commandsManager },
}; };
const customRoutes = customizationService.getCustomization('routes.customRoutes'); const customRoutes = customizationService.getCustomization('routes.customRoutes');

View File

@ -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 * @private

View File

@ -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 <code>commandsManager.run</code> does: a command name string,{' '}
<code>{"{ commandName, commandOptions, context }"}</code>, 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):
<ul>
<li>
<code>study</code>: the double-clicked <code>StudyRow</code>.
</li>
<li>
<code>workflows</code>: the workflows applicable to the study, in the same order as
the row's Launch Workflow menu. Each has <code>id</code>, <code>displayName</code>,{' '}
<code>isDefault</code>, and <code>launchWithStudy(study)</code>.
</li>
<li>
<code>defaultWorkflow</code>: the user's default workflow when it applies to the
study, else <code>undefined</code>.
</li>
</ul>
The default <code>launchDefaultMode</code> command launches the default workflow, falling
back to the first applicable one. Modes can contribute their own commands via a{' '}
<code>getCommandsModule</code> export on the mode definition these are registered at
app init in the <code>WORKLIST</code> context, before any mode route is entered.
Currently only applies when <code>workList.variant</code> is <code>'default'</code>.
</>
),
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' },
},
},
},
],
}; };
`, `,
}, },

View File

@ -77,9 +77,19 @@ function InputMultiSelectRoot({ options, value, onChange, children }: InputMulti
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return normalized; if (!q) return normalized;
return normalized.filter( // Prefix matches rank above substring matches (typing "PT" lists PT before CTPT).
opt => opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q) 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]); }, [normalized, query]);
React.useEffect(() => { React.useEffect(() => {

View File

@ -135,6 +135,7 @@ function Table({
toolbarRightActionsComponent, toolbarRightActionsComponent,
isLoading, isLoading,
loadingComponent, loadingComponent,
onStudyDoubleClick,
children, children,
}: TableProps) { }: TableProps) {
const { defaultPreviewSizePercent } = useLayout(); const { defaultPreviewSizePercent } = useLayout();
@ -168,6 +169,7 @@ function Table({
filters={filters} filters={filters}
isLoading={isLoading} isLoading={isLoading}
loadingComponent={loadingComponent} loadingComponent={loadingComponent}
onStudyDoubleClick={onStudyDoubleClick}
/> />
</div> </div>
</div> </div>

View File

@ -7,9 +7,20 @@ import { DatePickerWithRange } from '../../DateRange';
import { InputMultiSelect } from '../../InputMultiSelect'; import { InputMultiSelect } from '../../InputMultiSelect';
import type { StudyDateRangeFilter, StudyRow } from '../types/types'; import type { StudyDateRangeFilter, StudyRow } from '../types/types';
import { tokenizeModalities } from '../utils/tokenizeModalities'; import { tokenizeModalities } from '../utils/tokenizeModalities';
import { useWorkflows } from './WorkflowsProvider'; import { useWorkflows, type Workflow } from './WorkflowsProvider';
import { COLUMN_IDS } from '../columns/defaultColumns'; 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<DataTableProps<StudyRow>, 'children' | 'getRowId'> & { export type TableProps = Omit<DataTableProps<StudyRow>, 'children' | 'getRowId'> & {
title?: ReactNode; title?: ReactNode;
showColumnVisibility?: boolean; showColumnVisibility?: boolean;
@ -19,6 +30,7 @@ export type TableProps = Omit<DataTableProps<StudyRow>, 'children' | 'getRowId'>
toolbarRightComponent?: ReactNode; toolbarRightComponent?: ReactNode;
isLoading?: boolean; isLoading?: boolean;
loadingComponent?: ReactNode; loadingComponent?: ReactNode;
onStudyDoubleClick?: OnStudyDoubleClick;
}; };
export function Table({ export function Table({
@ -42,6 +54,7 @@ export function Table({
isLoading, isLoading,
loadingComponent, loadingComponent,
manualFiltering, manualFiltering,
onStudyDoubleClick,
}: TableProps) { }: TableProps) {
return ( return (
<DataTable<StudyRow> <DataTable<StudyRow>
@ -68,6 +81,7 @@ export function Table({
toolbarRightComponent={toolbarRightComponent} toolbarRightComponent={toolbarRightComponent}
isLoading={isLoading} isLoading={isLoading}
loadingComponent={loadingComponent} loadingComponent={loadingComponent}
onStudyDoubleClick={onStudyDoubleClick}
/> />
</DataTable> </DataTable>
); );
@ -82,6 +96,7 @@ function TableContent({
toolbarRightComponent, toolbarRightComponent,
isLoading, isLoading,
loadingComponent, loadingComponent,
onStudyDoubleClick,
}: { }: {
title?: ReactNode; title?: ReactNode;
showColumnVisibility?: boolean; showColumnVisibility?: boolean;
@ -91,6 +106,7 @@ function TableContent({
toolbarRightComponent?: ReactNode; toolbarRightComponent?: ReactNode;
isLoading?: boolean; isLoading?: boolean;
loadingComponent?: ReactNode; loadingComponent?: ReactNode;
onStudyDoubleClick?: TableProps['onStudyDoubleClick'];
}) { }) {
const { t } = useTranslation('StudyList'); const { t } = useTranslation('StudyList');
const { table } = useDataTable<StudyRow>(); const { table } = useDataTable<StudyRow>();
@ -102,7 +118,7 @@ function TableContent({
return Array.from(new Set(tokens)).sort(); return Array.from(new Set(tokens)).sort();
}, [table.options?.data]); }, [table.options?.data]);
// Access workflow provider for default workflow + launch // Access workflow provider for default workflow + launch
const { getDefaultWorkflowForStudy } = useWorkflows(); const { getDefaultWorkflowForStudy, getWorkflowsForStudy } = useWorkflows();
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@ -198,10 +214,12 @@ function TableContent({
className: 'group cursor-pointer', className: 'group cursor-pointer',
onClick: row => { onClick: row => {
const original = row.original as StudyRow; const original = row.original as StudyRow;
const defaultWorkflow = getDefaultWorkflowForStudy(original); const canDoubleClickLaunch =
// When a default workflow is set, do not allow a second click to unselect. Boolean(onStudyDoubleClick) ||
// Always select on click; otherwise toggle selection. getWorkflowsForStudy(original).length > 0;
if (defaultWorkflow) { // 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()) { if (!row.getIsSelected()) {
row.toggleSelected(true); row.toggleSelected(true);
} }
@ -211,15 +229,20 @@ function TableContent({
}, },
onDoubleClick: row => { onDoubleClick: row => {
const original = row.original as StudyRow; const original = row.original as StudyRow;
const workflows = getWorkflowsForStudy(original);
const defaultWorkflow = getDefaultWorkflowForStudy(original); const defaultWorkflow = getDefaultWorkflowForStudy(original);
if (!defaultWorkflow) { // Ensure the row is selected before launching
return;
}
// Ensure the row is selected, then launch with the default workflow
if (!row.getIsSelected()) { if (!row.getIsSelected()) {
row.toggleSelected(true); 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);
}, },
}} }}
/> />

View File

@ -16,6 +16,8 @@ import { WorkflowsProvider } from './components/WorkflowsProvider';
// Types // Types
export * from './types/types'; export * from './types/types';
export type { Workflow } from './components/WorkflowsProvider';
export type { OnStudyDoubleClick } from './components/Table';
// Column ID constants // Column ID constants
export { COLUMN_IDS, FILTERABLE_COLUMN_IDS, TEXT_FILTER_COLUMN_IDS }; export { COLUMN_IDS, FILTERABLE_COLUMN_IDS, TEXT_FILTER_COLUMN_IDS };