feat(WorkList): New Study List (WorkList based on ui-next); old study list renamed to LegacyWorklist (#6005)

---------

Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-05-28 12:26:30 -04:00
1 parent 9e7a3586ce
commit daae4c144e
121 files changed
+8071 -1468

No files matched your search

+3 -1
View File
@@ -44,7 +44,9 @@ const setHeaders = (res, path) => {
} else if (path.indexOf('.br') !== -1) {
res.setHeader('Content-Encoding', 'br');
}
if (path.indexOf('.pdf') !== -1) {
if (path.indexOf('thumbnail') !== -1) {
res.setHeader('Content-Type', 'image/jpeg');
} else if (path.indexOf('.pdf') !== -1) {
res.setHeader('Content-Type', 'application/pdf');
} else if (path.indexOf('mp4') !== -1) {
res.setHeader('Content-Type', 'video/mp4');
@@ -1,184 +0,0 @@
//We are keeping the hardcoded results values for the study list tests
//this is intended to be running in a controlled docker environment with test data.
describe('OHIF Study List', function () {
context('Desktop resolution', function () {
beforeEach(function () {
Cypress.on('uncaught:exception', () => false);
cy.window().then(win => win.sessionStorage.clear());
cy.openStudyList();
cy.viewport(1750, 720);
cy.initStudyListAliasesOnDesktop();
//Clear all text fields
cy.get('@PatientName').clear();
cy.get('@MRN').clear();
cy.get('@AccessionNumber').clear();
cy.get('@StudyDescription').clear();
});
afterEach(function () {
cy.window().then(win => win.sessionStorage.clear());
});
it('Displays several studies initially', function () {
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.greaterThan(1);
expect($list).to.contain('Juno');
expect($list).to.contain('832040');
});
});
it('searches Patient Name with exact string', function () {
cy.get('@PatientName').type('Juno');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('Juno');
});
});
it('maintains Patient Name filter upon return from viewer', function () {
cy.get('@PatientName').type('Juno');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('Juno');
});
});
it('searches MRN with exact string', function () {
cy.get('@MRN').type('0000003');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('0000003');
});
});
it('maintains MRN filter upon return from viewer', function () {
cy.get('@MRN').type('0000003');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('0000003');
});
});
it('searches Accession with exact string', function () {
cy.get('@AccessionNumber').type('321');
//Wait result list to be displayed
cy.waitStudyList();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('321');
});
});
it('maintains Accession filter upon return from viewer', function () {
cy.get('@AccessionNumber').type('0000155811');
//Wait result list to be displayed
cy.waitStudyList();
cy.wait(2000);
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('0000155811');
});
});
it('searches Description with exact string', function () {
cy.get('@StudyDescription').type('PETCT');
//Wait result list to be displayed
cy.waitStudyList();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('PETCT');
});
});
it('maintains Description filter upon return from viewer', function () {
cy.get('@StudyDescription').type('PETCT');
//Wait result list to be displayed
cy.waitStudyList();
cy.wait(2000);
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('PETCT');
});
});
/* Todo: fix react select
it('searches Modality with camel case', function() {
cy.get('@modalities').type('Ct');
// Wait result list to be displayed
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.greaterThan(1);
expect($list).to.contain('CT');
});
});
it('changes Rows per page and checks the study count', function() {
//Show Rows per page options
const pageRows = [25, 50, 100];
//Check all options of Rows
pageRows.forEach(numRows => {
cy.get('select').select(numRows.toString()); //Select Rows per page option
//Wait result list to be displayed
cy.waitStudyList().then(() => {
//Compare the search result with the Study Count on the table header
cy.get('@numStudies')
.should(numStudies => {
expect(parseInt(numStudies.text())).to.be.at.most(numRows); //less than or equals to
})
.then(numStudies => {
//Compare to the number of Rows in the search result
cy.get('@searchResult2').then($searchResult => {
let countResults = $searchResult.length;
expect(numStudies.text()).to.be.eq(countResults.toString());
});
});
});
});
});
*/
});
});
+4 -3
View File
@@ -20,7 +20,7 @@ window.config = {
allowMultiSelectExport: false,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
thumbnail: 5,
// Prefetch number is dependent on the http protocol. For http 2 or
// above, the number of requests can be go a lot higher.
prefetch: 25,
@@ -111,10 +111,11 @@ window.config = {
wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
supportsWildcard: false,
supportsWildcard: true,
staticWado: true,
singlepart: 'bulkdata,video',
// whether the data source should use retrieveBulkData to grab metadata,
+1 -1
View File
@@ -19,7 +19,7 @@ window.config = {
useNorm16Texture: true,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
thumbnail: 5,
// Prefetch number is dependent on the http protocol. For http 2 or
// above, the number of requests can be go a lot higher.
prefetch: 25,
@@ -13,7 +13,7 @@ window.config = {
groupEnabledModesFirst: true,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
thumbnail: 5,
prefetch: 25,
},
defaultDataSourceName: 'dicomweb',
+2 -1
View File
@@ -113,7 +113,8 @@ window.config = {
wadoRoot: '/viewer-testdata',
qidoSupportsIncludeField: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,
+2 -1
View File
@@ -45,7 +45,8 @@ window.config = {
'https://healthcare.googleapis.com/v1/projects/ohif-cloud-healthcare/locations/us-east4/datasets/ohif-qa-dataset/dicomStores/ohif-qa-2/dicomWeb',
qidoSupportsIncludeField: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'rendered',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
supportsWildcard: false,
+1 -1
View File
@@ -18,7 +18,7 @@ window.config = {
groupEnabledModesFirst: true,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
thumbnail: 5,
// Prefetch number is dependent on the http protocol. For http 2 or
// above, the number of requests can be go a lot higher.
prefetch: 25,
+3 -1
View File
@@ -23,7 +23,9 @@ window.config = {
qidoSupportsIncludeField: true,
imageRendering: 'wadors',
enableStudyLazyLoad: true,
thumbnailRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
supportsWildcard: true,
requestOptions: {
auth: 'admin:admin',
},
+2 -1
View File
@@ -25,7 +25,8 @@ window.config = {
supportsReject: true,
dicomUploadEnabled: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'rendered',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
supportsWildcard: true,
+2 -1
View File
@@ -26,7 +26,8 @@ window.config = {
wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,
+12 -1
View File
@@ -1,4 +1,15 @@
import useDebounce from './useDebounce';
import useSearchParams from './useSearchParams';
import { useStudyListStateSync } from './useStudyListStateSync';
import { useSeriesFetch } from './useSeriesFetch';
import { useWorkListToolbarActions } from './useWorkListToolbarActions';
import { useStudyListQuery } from './useStudyListQuery';
export { useDebounce, useSearchParams };
export {
useDebounce,
useSearchParams,
useStudyListStateSync,
useSeriesFetch,
useWorkListToolbarActions,
useStudyListQuery,
};
+12 -1
View File
@@ -1,4 +1,15 @@
import useDebounce from './useDebounce';
import useSearchParams from './useSearchParams';
import { useStudyListStateSync } from './useStudyListStateSync';
import { useSeriesFetch } from './useSeriesFetch';
import { useWorkListToolbarActions } from './useWorkListToolbarActions';
import { useStudyListQuery } from './useStudyListQuery';
export { useDebounce, useSearchParams };
export {
useDebounce,
useSearchParams,
useStudyListStateSync,
useSeriesFetch,
useWorkListToolbarActions,
useStudyListQuery,
};
+204
View File
@@ -0,0 +1,204 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAppConfig } from '@state';
import { utils } from '@ohif/core';
import { thumbnailNoImageModalities } from '@ohif/core/src/utils/thumbnailNoImageModalities';
import {
PreviewThumbnailStatusState,
type PreviewThumbnailStatus,
type StudyRow,
} from '@ohif/ui-next';
// A series row carries arbitrary DICOM fields plus the thumbnail status this
// panel tracks. Only the latter is typed; the rest stays open.
type PreviewSeries = Record<string, any> & { thumbnailStatus: PreviewThumbnailStatus };
// Series rows may carry the UID under either casing depending on the data
// source; read it through one place so callers can't forget a variant.
function getSeriesUID(row: Record<string, any>): string | undefined {
return row.seriesInstanceUid || row.SeriesInstanceUID;
}
/**
* Runs `worker` over `items` with at most `maxParallel` in flight at once,
* stopping early if `signal` aborts. A shared cursor hands each worker the
* next item, so a slow fetch doesn't hold up the rest.
*/
async function runThumbnailPool<T>(
items: T[],
maxParallel: number,
signal: AbortSignal,
worker: (item: T) => Promise<void>
): Promise<void> {
let nextIndex = 0;
const runWorker = async () => {
while (!signal.aborted) {
const idx = nextIndex++;
if (idx >= items.length) {
return;
}
await worker(items[idx]);
}
};
await Promise.all(Array.from({ length: Math.min(maxParallel, items.length) }, runWorker));
}
/**
* Fetches the series for the selected study and, where applicable, their
* thumbnails, exposing the resulting list and an image-error handler.
*
* Owns the blob-URL lifecycle for thumbnails produced by the `fetch` strategy:
* every created `blob:` URL is tracked and revoked on study change / unmount,
* and `onThumbnailImageError` revokes a single failed thumbnail's URL.
*/
export function useSeriesFetch({
dataSource,
selected,
}: {
dataSource: any;
selected: StudyRow | null;
}): {
series: PreviewSeries[];
onThumbnailImageError: (seriesUID: string) => void;
} {
const [series, setSeries] = useState<PreviewSeries[]>([]);
// Blob URLs created by this panel (via the `fetch` thumbnail strategy).
// Tracked so we can URL.revokeObjectURL them on study change / unmount —
// otherwise every fetched series leaks one blob worth of memory.
const ownedBlobUrlsRef = useRef<string[]>([]);
const [appConfig] = useAppConfig();
const { sortBySeriesDate } = utils as any;
useEffect(() => {
// Drives cancellation when the selection changes or the panel unmounts: stops the
// worker pool from scheduling new fetches and aborts in-flight requests that honor
// AbortSignal (the `fetch` thumbnail strategy; the bulkDataURI XHR path cannot abort).
const abortController = new AbortController();
const { signal } = abortController;
const run = async () => {
const studyInstanceUID = (selected as any)?.studyInstanceUid;
if (!studyInstanceUID) {
setSeries([]);
return;
}
try {
const seriesList = await dataSource.query.series.search(studyInstanceUID);
if (signal.aborted) {
return;
}
const sortedSeriesList = sortBySeriesDate?.(seriesList) ?? [];
const normalizedSeriesList = sortedSeriesList.map(row => {
const modality = String(row.modality || row.Modality || '').toUpperCase();
const thumbnailStatus: PreviewThumbnailStatus = thumbnailNoImageModalities.includes(
modality
)
? { status: PreviewThumbnailStatusState.NotApplicable }
: { status: PreviewThumbnailStatusState.Loading };
return {
...row,
thumbnailStatus,
};
});
setSeries(normalizedSeriesList);
const fetchTargets = normalizedSeriesList.filter((row: PreviewSeries) => {
if (!getSeriesUID(row)) {
return false;
}
return row.thumbnailStatus?.status !== PreviewThumbnailStatusState.NotApplicable;
});
// Bound parallel thumbnail fetches so studies with many series don't
// saturate the connection and stall later viewer navigation. Mirrors
// CS3D's imageLoadPoolManager.maxNumRequests.thumbnail.
const maxParallelRequests = Math.max(1, appConfig?.maxNumRequests?.thumbnail ?? 5);
const fetchThumbnail = async (row: (typeof fetchTargets)[number]) => {
const seriesUID = getSeriesUID(row);
let src: string | null = null;
try {
const getThumbnailSrc = dataSource?.retrieve?.getGetThumbnailSrc?.(
{ StudyInstanceUID: studyInstanceUID, SeriesInstanceUID: seriesUID },
undefined
);
src = (await getThumbnailSrc?.({ signal })) ?? null;
} catch {
src = null;
}
// Track ownership of blob URLs before the abort check so URLs that
// arrive just after abort are still revoked on cleanup.
if (src?.startsWith('blob:')) {
ownedBlobUrlsRef.current.push(src);
}
if (signal.aborted) {
return;
}
setSeries(prev =>
prev.map(seriesItem => {
if (getSeriesUID(seriesItem) !== seriesUID) {
return seriesItem;
}
return {
...seriesItem,
thumbnailStatus: src
? { status: PreviewThumbnailStatusState.Ready, src }
: { status: PreviewThumbnailStatusState.NotAvailable },
};
})
);
};
await runThumbnailPool(fetchTargets, maxParallelRequests, signal, fetchThumbnail);
} catch (e) {
if (!signal.aborted) {
console.warn('Failed to load preview series/thumbnails for selected study.', e);
setSeries([]);
}
}
};
void run();
return () => {
abortController.abort();
// Revoke blob URLs this run created. Safe even though the old series
// may still be in the DOM briefly: revokeObjectURL only invalidates
// future loads, the already-rendered <img> keeps its pixels.
const urls = ownedBlobUrlsRef.current;
ownedBlobUrlsRef.current = [];
urls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch {}
});
};
}, [dataSource, selected, appConfig?.maxNumRequests?.thumbnail]);
const onThumbnailImageError = useCallback((seriesUID: string) => {
setSeries(prevSeriesList =>
prevSeriesList.map(seriesItem => {
if (getSeriesUID(seriesItem) !== seriesUID) {
return seriesItem;
}
const thumbnailStatus = seriesItem.thumbnailStatus as PreviewThumbnailStatus | undefined;
if (
thumbnailStatus?.status === PreviewThumbnailStatusState.Ready &&
thumbnailStatus.src?.startsWith('blob:')
) {
try {
URL.revokeObjectURL(thumbnailStatus.src);
} catch {}
}
return {
...seriesItem,
thumbnailStatus: { status: PreviewThumbnailStatusState.NotAvailable },
};
})
);
}, []);
return { series, onThumbnailImageError };
}
@@ -0,0 +1,182 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router';
import { Enums, log } from '@ohif/core';
import { Button } from '@ohif/ui-next';
import { shallowEqualIgnoringArrayOrder } from '../utils/shallowEqualIgnoringArrayOrder';
import { URL_KEYS, getUrlParam } from '../utils/studyListFilterContract';
const DEFAULT_DATA = {
studies: [],
queryFilterValues: null,
};
/**
* Queries the data source for the study list and manages the result lifecycle:
* refetch-on-filter-change, loading / first-fetch flags, and surfacing
* connection errors (a modal with a Retry action). Pagination changes do not
* trigger refetches — the study list paginates client-side.
*
* `refresh` invalidates the cached result and re-arms the first-fetch gate,
* causing the next render to re-query. It also clears the loading flag, so the
* caller can use it as the single reset when the active data source changes.
*/
export function useStudyListQuery({
dataSource,
isDataSourceInitialized,
servicesManager,
}: {
dataSource: any;
isDataSourceInitialized: boolean;
servicesManager: AppTypes.ServicesManager;
}): {
studies: any[];
isLoading: boolean;
hasFetchedOnce: boolean;
refresh: () => void;
} {
const location = useLocation();
const params = useParams();
const [data, setData] = useState(DEFAULT_DATA);
const [isLoading, setIsLoading] = useState(false);
const [hasFetchedOnce, setHasFetchedOnce] = useState(false);
const refresh = useCallback(() => {
setIsLoading(false);
setHasFetchedOnce(false);
setData(DEFAULT_DATA);
}, []);
useEffect(() => {
if (!isDataSourceInitialized) {
return;
}
// Per-data-source result cap, passed to servers that honor the `limit`
// query parameter. Defaults to 101 when the data source doesn't set it.
const studiesLimit = dataSource.getConfig?.()?.queryLimit ?? 101;
const queryFilterValues = _getQueryFilterValues(location.search, studiesLimit);
// 204: no content
async function getData() {
setIsLoading(true);
log.time(Enums.TimingEnum.SEARCH_TO_LIST);
try {
const studies = await dataSource.query.studies.search(queryFilterValues);
setData({
studies: studies || [],
queryFilterValues,
});
log.timeEnd(Enums.TimingEnum.SCRIPT_TO_VIEW);
log.timeEnd(Enums.TimingEnum.SEARCH_TO_LIST);
} catch (e) {
console.error(e);
// Record that we attempted these filter values even though the fetch
// failed. Without this, the effect's `filtersChanged` check would
// remain true on the next render and immediately retry the same
// failing query in a tight loop.
setData(prev => ({ ...prev, queryFilterValues }));
// If there is a data source configuration API, the Worklist will pop
// up its own dialog to attempt to configure it. Otherwise surface the
// failure via a modal with a Retry action.
const { configurationAPI, friendlyName } = dataSource.getConfig();
if (!configurationAPI) {
const { uiModalService } = servicesManager.services;
uiModalService.show({
title: 'Data Source Connection Error',
content: () => (
<div className="text-foreground">
<p className="text-red-600">Error: {(e as Error).message}</p>
<p>
Please ensure the following data source is configured correctly or is running:
</p>
<div className="mt-2 font-bold">{friendlyName}</div>
<div className="mt-4 flex justify-end">
<Button
onClick={() => {
uiModalService.hide();
refresh();
}}
>
Retry
</Button>
</div>
</div>
),
});
}
} finally {
setIsLoading(false);
setHasFetchedOnce(true);
}
}
// Refetch when the filter set has actually changed. Filters can include
// array-valued fields like `modalitiesInStudy` whose element order
// doesn't matter, so we compare with an unordered-array shallow equal
// rather than reference equality — otherwise a re-render that
// re-creates the array with the same contents would force a refetch.
// Pagination changes alone don't invalidate the data (we paginate
// client-side).
const filtersChanged = !shallowEqualIgnoringArrayOrder(
data.queryFilterValues,
queryFilterValues
);
const isDataInvalid = !isLoading && filtersChanged;
if (isDataInvalid) {
getData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, location, params, isLoading, dataSource, isDataSourceInitialized]);
return {
studies: data.studies,
isLoading,
hasFetchedOnce,
refresh,
};
}
/**
* Translates the URL query string into the filter shape expected by the
* data source (`patientId`, `patientName`, `modalitiesInStudy`, …).
*
* URL keys come from the centralized contract in `studyListFilterContract.ts`,
* which is also what WorkList's URL serializer writes — so the read/write
* sides can't drift.
*
* @param {*} query - URL search string or `URLSearchParams`
*/
function _getQueryFilterValues(query, queryLimit) {
const params = new URLSearchParams(query);
const modalities = getUrlParam(params, URL_KEYS.modalities);
const queryFilterValues = {
// DCM
patientId: getUrlParam(params, URL_KEYS.mrn),
patientName: getUrlParam(params, URL_KEYS.patientName),
studyDescription: getUrlParam(params, URL_KEYS.description),
modalitiesInStudy: modalities ? modalities.split(',') : null,
accessionNumber: getUrlParam(params, URL_KEYS.accession),
//
startDate: getUrlParam(params, URL_KEYS.startDate),
endDate: getUrlParam(params, URL_KEYS.endDate),
// Rarely supported server-side
sortBy: getUrlParam(params, URL_KEYS.sortBy),
sortDirection: getUrlParam(params, URL_KEYS.sortDirection),
// So many different servers out there that we can't rely on them to support offset/limit.
// So we just query for everything up to the queryLimit for those that support it.
// For those that don't we will just assume we get everything back.
offset: 0,
limit: queryLimit,
};
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
}
@@ -0,0 +1,219 @@
import * as React from 'react';
import { useMemo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import type { SortingState, PaginationState, ColumnFiltersState } from '@tanstack/react-table';
import qs from 'query-string';
import useSearchParams from './useSearchParams';
import useDebounce from './useDebounce';
import {
useSessionStorage,
COLUMN_IDS,
TEXT_FILTER_COLUMN_IDS,
type StudyDateRangeFilter,
} from '@ohif/ui-next';
import { preserveQueryStrings } from '../utils/preserveQueryParameters';
import {
URL_KEYS,
getUrlParam,
urlKeyForTextFilter,
} from '../utils/studyListFilterContract';
export type StudyListState = {
sorting: SortingState;
pagination: PaginationState;
filters: ColumnFiltersState;
dataSources?: string;
};
/**
* Hook that syncs study list table state (sorting, pagination, filters) between:
* - URL query parameters (source of truth, takes precedence)
* - Session storage (fallback/persistence)
* - Component state (for reactivity)
*/
export function useStudyListStateSync() {
const navigate = useNavigate();
const location = useLocation();
const searchParams = useSearchParams({ lowerCaseKeys: true });
const [sessionState, updateSessionState] = useSessionStorage({
key: 'studyList.tableState',
defaultValue: {},
clearOnUnload: true,
});
const [pagination, setPagination] = useState<PaginationState>(
sessionState.pagination || parsePaginationFromURL(searchParams)
);
const [filters, setFilters] = useState<ColumnFiltersState>(
sessionState.filters || parseFiltersFromURL(searchParams)
);
const [sorting, setSorting] = useState<SortingState>(
sessionState.sorting || parseSortingFromURL(searchParams)
);
const dataSources = sessionState.dataSources || getUrlParam(searchParams, URL_KEYS.dataSources);
const state = useMemo(
() => ({ sorting, pagination, filters, dataSources }),
[sorting, pagination, filters, dataSources]
);
// Debounce state for URL updates
const debouncedState = useDebounce(state, 200);
// Sync to sessionStorage on state change
React.useEffect(() => {
updateSessionState(state);
}, [state, updateSessionState]);
// Sync to URL on debounced state change
React.useEffect(() => {
const query = buildQueryFromState(debouncedState);
const newSearch = query ? `?${query}` : '';
// Only navigate if the search string actually changed
if (newSearch !== location.search) {
navigate(
{
pathname: location.pathname,
search: newSearch,
},
{ replace: true }
);
}
}, [debouncedState, navigate, location.pathname, location.search]);
return {
sorting,
pagination,
filters,
setSorting,
setPagination,
setFilters,
};
}
/**
* Parse sorting state from URL query parameters
*/
function parseSortingFromURL(params: URLSearchParams): SortingState {
const sortBy = getUrlParam(params, URL_KEYS.sortBy);
const sortDirection = getUrlParam(params, URL_KEYS.sortDirection);
if (!sortBy) {
return [];
}
return [
{
id: sortBy,
desc: sortDirection === 'desc' || sortDirection === 'descending',
},
];
}
/**
* Parse pagination state from URL query parameters
*/
function parsePaginationFromURL(params: URLSearchParams): PaginationState {
const page = getUrlParam(params, URL_KEYS.pageNumber);
const perPage = getUrlParam(params, URL_KEYS.resultsPerPage);
return {
pageIndex: page ? parseInt(page, 10) - 1 : 0,
pageSize: perPage ? parseInt(perPage, 10) : 50,
};
}
/**
* Parse filters from URL query parameters
* Note: This is a simplified version. You may need to extend this based on your filter structure.
*/
function parseFiltersFromURL(params: URLSearchParams): ColumnFiltersState {
const filters: ColumnFiltersState = [];
const modalities = getUrlParam(params, URL_KEYS.modalities);
if (modalities) {
const modalityList = modalities.split(',').filter(Boolean);
if (modalityList.length > 0) {
filters.push({
id: COLUMN_IDS.MODALITIES,
value: modalityList,
});
}
}
const startDate = getUrlParam(params, URL_KEYS.startDate);
const endDate = getUrlParam(params, URL_KEYS.endDate);
if (startDate || endDate) {
filters.push({
id: COLUMN_IDS.STUDY_DATE_TIME,
value: {
...(startDate ? { startDate } : {}),
...(endDate ? { endDate } : {}),
},
});
}
// Text filters (patient name, MRN, accession, description). URL keys come
// from the centralized contract — see studyListFilterContract.ts.
TEXT_FILTER_COLUMN_IDS.forEach(id => {
const value = getUrlParam(params, urlKeyForTextFilter(id));
if (value) {
filters.push({
id,
value,
});
}
});
return filters;
}
/**
* Build URL query string from study list state preserving key query parameters.
*/
function buildQueryFromState(state: StudyListState): string {
const query: Record<string, string> = {};
// Sorting
if (state.sorting.length > 0) {
const sort = state.sorting[0];
query[URL_KEYS.sortBy] = sort.id;
query[URL_KEYS.sortDirection] = sort.desc ? 'desc' : 'asc';
}
// Pagination
if (state.pagination.pageIndex > 0) {
query[URL_KEYS.pageNumber] = String(state.pagination.pageIndex + 1);
}
if (state.pagination.pageSize !== 50) {
query[URL_KEYS.resultsPerPage] = String(state.pagination.pageSize);
}
// Filters
state.filters.forEach(filter => {
if (filter.id === COLUMN_IDS.MODALITIES && Array.isArray(filter.value)) {
query[URL_KEYS.modalities] = filter.value.join(',');
} else if (filter.id === COLUMN_IDS.STUDY_DATE_TIME) {
const dateRange = filter.value as StudyDateRangeFilter | undefined;
if (dateRange?.startDate) {
query[URL_KEYS.startDate] = dateRange.startDate;
}
if (dateRange?.endDate) {
query[URL_KEYS.endDate] = dateRange.endDate;
}
} else if (typeof filter.value === 'string' && filter.value) {
query[urlKeyForTextFilter(filter.id)] = filter.value;
}
});
if (state.dataSources) {
query[URL_KEYS.dataSources] = state.dataSources;
}
preserveQueryStrings(query);
return qs.stringify(query, { skipNull: true, skipEmptyString: true });
}
@@ -0,0 +1,66 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Icons, useModal } from '@ohif/ui-next';
import { ServicesManager } from '@ohif/core';
export function useWorkListToolbarActions(
servicesManager: ServicesManager,
dataSource: any,
onRefresh: () => void
): React.ReactNode {
const { t } = useTranslation();
const { show, hide } = useModal();
const { customizationService } = servicesManager.services;
const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent') as any;
const dataSourceConfigurationComponent = customizationService.getCustomization(
'ohif.dataSourceConfigurationComponent'
) as any;
const uploadEnabled = DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled;
const dataSourceConfigElement = dataSourceConfigurationComponent?.();
if (!uploadEnabled && !dataSourceConfigElement) {
return undefined;
}
const uploadProps = uploadEnabled
? {
title: 'Upload files',
containerClassName: DicomUploadComponent?.containerClassName,
closeButton: true,
shouldCloseOnEsc: false,
shouldCloseOnOverlayClick: false,
content: () => (
<DicomUploadComponent
dataSource={dataSource}
onComplete={() => {
hide();
onRefresh();
}}
onStarted={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
show({ ...uploadProps, closeButton: false } as any);
}}
/>
),
}
: undefined;
return (
<div className="flex items-center gap-1">
{uploadProps && (
<Button
variant="ghost"
size="sm"
className="gap-1"
onClick={() => show(uploadProps as any)}
>
<Icons.Upload className="h-4 w-4" />
{t('Upload')}
</Button>
)}
{dataSourceConfigElement}
</div>
);
}
+19 -175
View File
@@ -1,23 +1,13 @@
/* eslint-disable react/jsx-props-no-spreading */
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Enums, ExtensionManager, MODULE_TYPES, log } from '@ohif/core';
import { ExtensionManager, MODULE_TYPES } from '@ohif/core';
//
import { extensionManager } from '../App';
import { useParams, useLocation } from 'react-router';
import { useNavigate } from 'react-router-dom';
import { useParams } from 'react-router';
import useSearchParams from '../hooks/useSearchParams';
/**
* Determines if two React Router location objects are the same.
*/
const areLocationsTheSame = (location0, location1) => {
return (
location0.pathname === location1.pathname &&
location0.search === location1.search &&
location0.hash === location1.hash
);
};
import { useAppConfig } from '@state';
import { useStudyListQuery } from '../hooks';
/**
* Uses route properties to determine the data source that should be passed
@@ -29,32 +19,24 @@ const areLocationsTheSame = (location0, location1) => {
*/
function DataSourceWrapper(props: withAppTypes) {
const { servicesManager } = props;
const navigate = useNavigate();
const { children: LayoutTemplate, ...rest } = props;
const params = useParams();
const location = useLocation();
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
const query = useSearchParams();
const [appConfig] = useAppConfig();
// Route props --> studies.mapParams
// mapParams --> studies.search
// studies.search --> studies.processResults
// studies.processResults --> <LayoutTemplate studies={} />
// But only for LayoutTemplate type of 'list'?
// Or no data fetching here, and just hand down my source
const STUDIES_LIMIT = 101;
const DEFAULT_DATA = {
studies: [],
total: 0,
resultsPerPage: 25,
pageNumber: 1,
location: 'Not a valid location, causes first load to occur',
};
const getInitialDataSourceName = useCallback(() => {
// TODO - get the variable from the props all the time...
let dataSourceName = lowerCaseSearchParams.get('datasources');
if (!dataSourceName && window.config.defaultDataSourceName) {
if (!dataSourceName && appConfig.defaultDataSourceName) {
return '';
}
@@ -103,8 +85,11 @@ function DataSourceWrapper(props: withAppTypes) {
return dataSource;
});
const [data, setData] = useState(DEFAULT_DATA);
const [isLoading, setIsLoading] = useState(false);
const { studies, isLoading, hasFetchedOnce, refresh } = useStudyListQuery({
dataSource,
isDataSourceInitialized,
servicesManager,
});
/**
* The effect to initialize the data source whenever it changes. Similar to
@@ -125,12 +110,12 @@ function DataSourceWrapper(props: withAppTypes) {
useEffect(() => {
const dataSourceChangedCallback = () => {
setIsLoading(false);
setIsDataSourceInitialized(false);
setDataSourcePath('');
setDataSource(extensionManager.getActiveDataSource()[0]);
// Setting data to DEFAULT_DATA triggers a new query just like it does for the initial load.
setData(DEFAULT_DATA);
// Resets the cached data, the loading flag, and the first-fetch gate,
// then triggers a new query just like the initial load.
refresh();
};
const sub = extensionManager.subscribe(
@@ -140,97 +125,17 @@ function DataSourceWrapper(props: withAppTypes) {
return () => sub.unsubscribe();
}, []);
useEffect(() => {
if (!isDataSourceInitialized) {
return;
}
const queryFilterValues = _getQueryFilterValues(location.search, STUDIES_LIMIT);
// 204: no content
async function getData() {
setIsLoading(true);
log.time(Enums.TimingEnum.SEARCH_TO_LIST);
const studies = await dataSource.query.studies.search(queryFilterValues);
setData({
studies: studies || [],
total: studies.length,
resultsPerPage: queryFilterValues.resultsPerPage,
pageNumber: queryFilterValues.pageNumber,
location,
});
log.timeEnd(Enums.TimingEnum.SCRIPT_TO_VIEW);
log.timeEnd(Enums.TimingEnum.SEARCH_TO_LIST);
setIsLoading(false);
}
try {
// Cache invalidation :thinking:
// - Anytime change is not just next/previous page
// - And we didn't cross a result offset range
const isSamePage = data.pageNumber === queryFilterValues.pageNumber;
const previousOffset =
Math.floor((data.pageNumber * data.resultsPerPage) / STUDIES_LIMIT) * (STUDIES_LIMIT - 1);
const newOffset =
Math.floor(
(queryFilterValues.pageNumber * queryFilterValues.resultsPerPage) / STUDIES_LIMIT
) *
(STUDIES_LIMIT - 1);
// Simply checking data.location !== location is not sufficient because even though the location href (i.e. entire URL)
// has not changed, the React Router still provides a new location reference and would result in two study queries
// on initial load. Alternatively, window.location.href could be used.
const isLocationUpdated =
typeof data.location === 'string' || !areLocationsTheSame(data.location, location);
const isDataInvalid =
!isSamePage || (!isLoading && (newOffset !== previousOffset || isLocationUpdated));
if (isDataInvalid) {
getData().catch(e => {
console.error(e);
const { configurationAPI, friendlyName } = dataSource.getConfig();
// If there is a data source configuration API, then the Worklist will popup the dialog to attempt to configure it
// and attempt to resolve this issue.
if (configurationAPI) {
return;
}
servicesManager.services.uiModalService.show({
title: 'Data Source Connection Error',
content: () => {
return (
<div className="text-foreground">
<p className="text-red-600">Error: {e.message}</p>
<p>
Please ensure the following data source is configured correctly or is running:
</p>
<div className="mt-2 font-bold">{friendlyName}</div>
</div>
);
},
});
});
}
} catch (ex) {
console.warn(ex);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, location, params, isLoading, setIsLoading, dataSource, isDataSourceInitialized]);
// queryFilterValues
// TODO: Better way to pass DataSource?
return (
<LayoutTemplate
{...rest}
data={data.studies}
data={studies}
dataTotal={studies.length}
dataPath={dataSourcePath}
dataTotal={data.total}
dataSource={dataSource}
isLoadingData={isLoading}
// To refresh the data, simply reset it to DEFAULT_DATA which invalidates it and triggers a new query to fetch the data.
onRefresh={() => setData(DEFAULT_DATA)}
hasFetchedOnce={hasFetchedOnce}
onRefresh={refresh}
/>
);
}
@@ -241,64 +146,3 @@ DataSourceWrapper.propTypes = {
};
export default DataSourceWrapper;
/**
* Duplicated in `workList`
* Need generic that can be shared? Isn't this what qs is for?
* @param {*} query
*/
function _getQueryFilterValues(query, queryLimit) {
query = new URLSearchParams(query);
const newParams = new URLSearchParams();
for (const [key, value] of query) {
newParams.set(key.toLowerCase(), value);
}
query = newParams;
const pageNumber = _tryParseInt(query.get('pagenumber'), 1);
const resultsPerPage = _tryParseInt(query.get('resultsperpage'), 25);
const queryFilterValues = {
// DCM
patientId: query.get('mrn'),
patientName: query.get('patientname'),
studyDescription: query.get('description'),
modalitiesInStudy: query.get('modalities') && query.get('modalities').split(','),
accessionNumber: query.get('accession'),
//
startDate: query.get('startdate'),
endDate: query.get('enddate'),
page: _tryParseInt(query.get('page'), undefined),
pageNumber,
resultsPerPage,
// Rarely supported server-side
sortBy: query.get('sortby'),
sortDirection: query.get('sortdirection'),
// Offset...
offset: Math.floor((pageNumber * resultsPerPage) / queryLimit) * (queryLimit - 1),
config: query.get('configurl'),
};
// patientName: good
// studyDescription: good
// accessionNumber: good
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str !== null) {
if (str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
}
return retValue;
}
}
@@ -0,0 +1,685 @@
import React, { useState, useEffect, useMemo } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Link, useNavigate } from 'react-router-dom';
import qs from 'query-string';
import isEqual from 'lodash.isequal';
import { useTranslation } from 'react-i18next';
//
import filtersMeta from './filtersMeta.js';
import { useAppConfig } from '@state';
import { useDebounce, useSearchParams } from '../../hooks';
import { utils, Types as coreTypes } from '@ohif/core';
import {
StudyListExpandedRow,
EmptyStudies,
StudyListTable,
StudyListPagination,
StudyListFilter,
Button,
ButtonEnums,
} from '@ohif/ui';
import {
Header,
Icons,
Tooltip,
TooltipTrigger,
TooltipContent,
Clipboard,
useModal,
useSessionStorage,
Onboarding,
ScrollArea,
InvestigationalUseDialog,
formatDICOMDate,
formatDICOMTime,
parseStudyDateTimestamp,
} from '@ohif/ui-next';
import { Types } from '@ohif/ui';
import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters';
const PatientInfoVisibility = Types.PatientInfoVisibility;
const { sortBySeriesDate } = utils;
const seriesInStudiesMap = new Map();
/**
* TODO:
* - debounce `setFilterValues` (150ms?)
*/
function LegacyWorkList({
data: studies,
dataTotal: studiesTotal,
isLoadingData,
dataSource,
hotkeysManager,
dataPath,
onRefresh,
servicesManager,
}: withAppTypes) {
const { show, hide } = useModal();
const { t } = useTranslation();
// ~ Modes
const [appConfig] = useAppConfig();
// ~ Filters
const searchParams = useSearchParams();
const navigate = useNavigate();
const STUDIES_LIMIT = 101;
const queryFilterValues = _getQueryFilterValues(searchParams);
const [sessionQueryFilterValues, updateSessionQueryFilterValues] = useSessionStorage({
key: 'queryFilterValues',
defaultValue: queryFilterValues,
// ToDo: useSessionStorage currently uses an unload listener to clear the filters from session storage
// so on systems that do not support unload events a user will NOT be able to alter any existing filter
// in the URL, load the page and have it apply.
clearOnUnload: true,
});
const [filterValues, _setFilterValues] = useState({
...defaultFilterValues,
...sessionQueryFilterValues,
});
const debouncedFilterValues = useDebounce(filterValues, 200);
const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues;
/*
* The default sort value keep the filters synchronized with runtime conditional sorting
* Only applied if no other sorting is specified and there are less than 101 studies
*/
const canSort = studiesTotal < STUDIES_LIMIT;
const shouldUseDefaultSort = sortBy === '' || !sortBy;
const sortModifier = sortDirection === 'descending' ? 1 : -1;
const defaultSortValues =
shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {};
const { customizationService } = servicesManager.services;
const sortedStudies = useMemo(() => {
if (!canSort) {
return studies;
}
return [...studies].sort((s1, s2) => {
if (shouldUseDefaultSort) {
const ascendingSortModifier = -1;
return (
(parseStudyDateTimestamp(s1.date, s1.time) - parseStudyDateTimestamp(s2.date, s2.time)) *
ascendingSortModifier
);
}
const s1Prop = s1[sortBy];
const s2Prop = s2[sortBy];
if (typeof s1Prop === 'string' && typeof s2Prop === 'string') {
return s1Prop.localeCompare(s2Prop) * sortModifier;
} else if (typeof s1Prop === 'number' && typeof s2Prop === 'number') {
return (s1Prop > s2Prop ? 1 : -1) * sortModifier;
} else if (!s1Prop && s2Prop) {
return -1 * sortModifier;
} else if (!s2Prop && s1Prop) {
return 1 * sortModifier;
} else if (sortBy === 'studyDate') {
return (
(parseStudyDateTimestamp(s1.date, s1.time) - parseStudyDateTimestamp(s2.date, s2.time)) *
sortModifier
);
}
return 0;
});
}, [canSort, studies, shouldUseDefaultSort, sortBy, sortModifier]);
// ~ Rows & Studies
const [expandedRows, setExpandedRows] = useState([]);
const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]);
const numOfStudies = studiesTotal;
const querying = useMemo(() => {
return isLoadingData || expandedRows.length > 0;
}, [isLoadingData, expandedRows]);
const setFilterValues = val => {
if (filterValues.pageNumber === val.pageNumber) {
val.pageNumber = 1;
}
_setFilterValues(val);
updateSessionQueryFilterValues(val);
setExpandedRows([]);
};
const onPageNumberChange = newPageNumber => {
const oldPageNumber = filterValues.pageNumber;
const rollingPageNumberMod = Math.floor(101 / filterValues.resultsPerPage);
const rollingPageNumber = oldPageNumber % rollingPageNumberMod;
const isNextPage = newPageNumber > oldPageNumber;
const hasNextPage = Math.max(rollingPageNumber, 1) * resultsPerPage < numOfStudies;
if (isNextPage && !hasNextPage) {
return;
}
setFilterValues({ ...filterValues, pageNumber: newPageNumber });
};
const onResultsPerPageChange = newResultsPerPage => {
setFilterValues({
...filterValues,
pageNumber: 1,
resultsPerPage: Number(newResultsPerPage),
});
};
// Set body style
useEffect(() => {
document.body.classList.add('bg-black');
return () => {
document.body.classList.remove('bg-black');
};
}, []);
// Sync URL query parameters with filters
useEffect(() => {
if (!debouncedFilterValues) {
return;
}
const queryString = {};
Object.keys(defaultFilterValues).forEach(key => {
const defaultValue = defaultFilterValues[key];
const currValue = debouncedFilterValues[key];
// TODO: nesting/recursion?
if (key === 'studyDate') {
if (currValue.startDate && defaultValue.startDate !== currValue.startDate) {
queryString.startDate = currValue.startDate;
}
if (currValue.endDate && defaultValue.endDate !== currValue.endDate) {
queryString.endDate = currValue.endDate;
}
} else if (key === 'modalities' && currValue.length) {
queryString.modalities = currValue.join(',');
} else if (currValue !== defaultValue) {
queryString[key] = currValue;
}
});
preserveQueryStrings(queryString);
const search = qs.stringify(queryString, {
skipNull: true,
skipEmptyString: true,
});
navigate({
pathname: '/',
search: search ? `?${search}` : undefined,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedFilterValues]);
// Query for series information
useEffect(() => {
const fetchSeries = async studyInstanceUid => {
try {
const series = await dataSource.query.series.search(studyInstanceUid);
seriesInStudiesMap.set(studyInstanceUid, sortBySeriesDate(series));
setStudiesWithSeriesData([...studiesWithSeriesData, studyInstanceUid]);
} catch (ex) {
// TODO: UI Notification Service
console.warn(ex);
}
};
// TODO: WHY WOULD YOU USE AN INDEX OF 1?!
// Note: expanded rows index begins at 1
for (let z = 0; z < expandedRows.length; z++) {
const expandedRowIndex = expandedRows[z] - 1;
const studyInstanceUid = sortedStudies[expandedRowIndex].studyInstanceUid;
if (studiesWithSeriesData.includes(studyInstanceUid)) {
continue;
}
fetchSeries(studyInstanceUid);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandedRows, studies]);
const isFiltering = (filterValues, defaultFilterValues) => {
return !isEqual(filterValues, defaultFilterValues);
};
const rollingPageNumberMod = Math.floor(101 / resultsPerPage);
const rollingPageNumber = (pageNumber - 1) % rollingPageNumberMod;
const offset = resultsPerPage * rollingPageNumber;
const offsetAndTake = offset + resultsPerPage;
const tableDataSource = sortedStudies.map((study, key) => {
const rowKey = key + 1;
const isExpanded = expandedRows.some(k => k === rowKey);
const {
studyInstanceUid,
accession,
modalities,
instances,
description,
mrn,
patientName,
date,
time,
} = study;
const studyDate = formatDICOMDate(date, { fallbackFormat: 'MMM-DD-YYYY', invalidFallback: '' });
const studyTime = formatDICOMTime(time, { invalidFallback: '' });
const makeCopyTooltipCell = textValue => {
if (!textValue) {
return '';
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-pointer truncate">{textValue}</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex items-center justify-between gap-2">
{textValue}
<Clipboard>{textValue}</Clipboard>
</div>
</TooltipContent>
</Tooltip>
);
};
return {
dataCY: `studyRow-${studyInstanceUid}`,
clickableCY: studyInstanceUid,
row: [
{
key: 'patientName',
content: patientName ? makeCopyTooltipCell(patientName) : null,
gridCol: 4,
},
{
key: 'mrn',
content: makeCopyTooltipCell(mrn),
gridCol: 3,
},
{
key: 'studyDate',
content: (
<>
{studyDate && <span className="mr-4">{studyDate}</span>}
{studyTime && <span>{studyTime}</span>}
</>
),
title: `${studyDate || ''} ${studyTime || ''}`,
gridCol: 5,
},
{
key: 'description',
content: makeCopyTooltipCell(description),
gridCol: 4,
},
{
key: 'modality',
content: modalities,
title: modalities,
gridCol: 3,
},
{
key: 'accession',
content: makeCopyTooltipCell(accession),
gridCol: 3,
},
{
key: 'instances',
content: (
<>
<Icons.GroupLayers
className={classnames('mr-2 inline-flex w-4', {
'text-primary': isExpanded,
'text-secondary-light': !isExpanded,
})}
/>
{instances}
</>
),
title: (instances || 0).toString(),
gridCol: 2,
},
],
// Todo: This is actually running for all rows, even if they are
// not clicked on.
expandedContent: (
<StudyListExpandedRow
seriesTableColumns={{
description: t('StudyList:Description'),
seriesNumber: t('StudyList:Series'),
modality: t('StudyList:Modality'),
instances: t('StudyList:Instances'),
}}
seriesTableDataSource={
seriesInStudiesMap.has(studyInstanceUid)
? seriesInStudiesMap.get(studyInstanceUid).map(s => {
return {
description: s.description || '(empty)',
seriesNumber: s.seriesNumber ?? '',
modality: s.modality || '',
instances: s.numSeriesInstances || '',
};
})
: []
}
>
<div className="flex flex-row gap-2">
{(appConfig.groupEnabledModesFirst
? appConfig.loadedModes.sort((a, b) => {
const isValidA = a.isValidMode({
modalities: modalities.replaceAll('/', '\\'),
study,
}).valid;
const isValidB = b.isValidMode({
modalities: modalities.replaceAll('/', '\\'),
study,
}).valid;
return isValidB - isValidA;
})
: appConfig.loadedModes
).map((mode, i) => {
if (mode.hide) {
// Hide this mode from display
return null;
}
const modalitiesToCheck = modalities.replaceAll('/', '\\');
const { valid: isValidMode, description: invalidModeDescription } = mode.isValidMode({
modalities: modalitiesToCheck,
study,
});
if (isValidMode === null) {
// Hide this as a computed result.
return null;
}
// TODO: Modes need a default/target route? We mostly support a single one for now.
// We should also be using the route path, but currently are not
// mode.routeName
// mode.routes[x].path
// Don't specify default data source, and it should just be picked up... (this may not currently be the case)
// How do we know which params to pass? Today, it's just StudyInstanceUIDs and configUrl if exists
const query = new URLSearchParams();
if (filterValues.configUrl) {
query.append('configUrl', filterValues.configUrl);
}
query.append('StudyInstanceUIDs', studyInstanceUid);
preserveQueryParameters(query);
return (
mode.displayName && (
<Link
className={isValidMode ? '' : 'cursor-not-allowed'}
key={i}
to={`${mode.routeName}${dataPath || ''}?${query.toString()}`}
onClick={event => {
// In case any event bubbles up for an invalid mode, prevent the navigation.
// For example, the event bubbles up when the icon embedded in the disabled button is clicked.
if (!isValidMode) {
event.preventDefault();
}
}}
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
>
{/* TODO revisit the completely rounded style of buttons used for launching a mode from the worklist later */}
<Button
type={ButtonEnums.type.primary}
size={ButtonEnums.size.smallTall}
disabled={!isValidMode}
startIconTooltip={
!isValidMode ? (
<div className="font-inter flex w-[206px] whitespace-normal text-left text-xs font-normal text-white">
{invalidModeDescription}
</div>
) : null
}
startIcon={
isValidMode ? (
<Icons.LaunchArrow className="!h-[20px] !w-[20px] text-black" />
) : (
<Icons.LaunchInfo className="!h-[20px] !w-[20px] text-black" />
)
}
onClick={() => {}}
dataCY={`mode-${mode.routeName}-${studyInstanceUid}`}
className={!isValidMode && 'bg-[#222d44]'}
>
{mode.displayName}
</Button>
</Link>
)
);
})}
</div>
</StudyListExpandedRow>
),
onClickRow: () =>
setExpandedRows(s => (isExpanded ? s.filter(n => rowKey !== n) : [...s, rowKey])),
isExpanded,
};
});
const hasStudies = numOfStudies > 0;
const AboutModal = customizationService.getCustomization(
'ohif.aboutModal'
) as coreTypes.MenuComponentCustomization;
const UserPreferencesModal = customizationService.getCustomization(
'ohif.userPreferencesModal'
) as coreTypes.MenuComponentCustomization;
const menuOptions = [
{
title: AboutModal?.menuTitle ?? t('Header:About'),
icon: 'info',
onClick: () =>
show({
content: AboutModal,
title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'),
containerClassName: AboutModal?.containerClassName ?? 'max-w-md',
}),
},
{
title: UserPreferencesModal.menuTitle ?? t('Header:Preferences'),
icon: 'settings',
onClick: () =>
show({
content: UserPreferencesModal as React.ComponentType,
title: UserPreferencesModal.title ?? t('UserPreferencesModal:User preferences'),
containerClassName:
UserPreferencesModal?.containerClassName ?? 'flex max-w-4xl p-6 flex-col',
}),
},
];
if (appConfig.oidc) {
menuOptions.push({
icon: 'power-off',
title: t('Header:Logout'),
onClick: () => {
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
},
});
}
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
);
const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent');
const uploadProps =
DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled
? {
title: 'Upload files',
containerClassName: DicomUploadComponent?.containerClassName,
closeButton: true,
shouldCloseOnEsc: false,
shouldCloseOnOverlayClick: false,
content: () => (
<DicomUploadComponent
dataSource={dataSource}
onComplete={() => {
hide();
onRefresh();
}}
onStarted={() => {
show({
...uploadProps,
// when upload starts, hide the default close button as closing the dialogue must be handled by the upload dialogue itself
closeButton: false,
});
}}
/>
),
}
: undefined;
const dataSourceConfigurationComponent = customizationService.getCustomization(
'ohif.dataSourceConfigurationComponent'
);
return (
<div className="flex h-screen flex-col bg-black">
<Header
isSticky
menuOptions={menuOptions}
isReturnEnabled={false}
WhiteLabeling={appConfig.whiteLabeling}
showPatientInfo={PatientInfoVisibility.DISABLED}
/>
<Onboarding />
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
<div className="flex h-full flex-col overflow-y-auto">
<ScrollArea>
<div className="flex grow flex-col">
<StudyListFilter
numOfStudies={pageNumber * resultsPerPage > 100 ? 101 : numOfStudies}
filtersMeta={filtersMeta}
filterValues={{ ...filterValues, ...defaultSortValues }}
onChange={setFilterValues}
clearFilters={() => setFilterValues(defaultFilterValues)}
isFiltering={isFiltering(filterValues, defaultFilterValues)}
onUploadClick={uploadProps ? () => show(uploadProps) : undefined}
getDataSourceConfigurationComponent={
dataSourceConfigurationComponent
? () => dataSourceConfigurationComponent()
: undefined
}
/>
</div>
{hasStudies ? (
<div className="flex grow flex-col">
<StudyListTable
tableDataSource={tableDataSource.slice(offset, offsetAndTake)}
numOfStudies={numOfStudies}
querying={querying}
filtersMeta={filtersMeta}
/>
<div className="grow">
<StudyListPagination
onChangePage={onPageNumberChange}
onChangePerPage={onResultsPerPageChange}
currentPage={pageNumber}
perPage={resultsPerPage}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-48">
{appConfig.showLoadingIndicator && isLoadingData ? (
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
) : (
<EmptyStudies />
)}
</div>
)}
</ScrollArea>
</div>
</div>
);
}
LegacyWorkList.propTypes = {
data: PropTypes.array.isRequired,
dataSource: PropTypes.shape({
query: PropTypes.object.isRequired,
getConfig: PropTypes.func,
}).isRequired,
isLoadingData: PropTypes.bool.isRequired,
servicesManager: PropTypes.object.isRequired,
};
const defaultFilterValues = {
patientName: '',
mrn: '',
studyDate: {
startDate: null,
endDate: null,
},
description: '',
modalities: [],
accession: '',
sortBy: '',
sortDirection: 'none',
pageNumber: 1,
resultsPerPage: 25,
datasources: '',
};
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str && str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
return retValue;
}
function _getQueryFilterValues(params) {
const newParams = new URLSearchParams();
for (const [key, value] of params) {
newParams.set(key.toLowerCase(), value);
}
params = newParams;
const queryFilterValues = {
patientName: params.get('patientname'),
mrn: params.get('mrn'),
studyDate: {
startDate: params.get('startdate') || null,
endDate: params.get('enddate') || null,
},
description: params.get('description'),
modalities: params.get('modalities') ? params.get('modalities').split(',') : [],
accession: params.get('accession'),
sortBy: params.get('sortby'),
sortDirection: params.get('sortdirection'),
pageNumber: _tryParseInt(params.get('pagenumber'), undefined),
resultsPerPage: _tryParseInt(params.get('resultsperpage'), undefined),
datasources: params.get('datasources'),
configUrl: params.get('configurl'),
};
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
}
export default LegacyWorkList;
@@ -0,0 +1 @@
export { default } from './LegacyWorkList';
@@ -0,0 +1,90 @@
import React from 'react';
import { StudyList, type StudyRow } from '@ohif/ui-next';
import { useSeriesFetch } from '../../hooks';
import { StudyListSettingsPopover } from './StudyListSettingsPopover';
type PreviewSeriesView = 'all' | 'thumbnails' | 'list';
const ALLOWED_PREVIEW_SERIES_VIEWS: ReadonlyArray<PreviewSeriesView> = [
'all',
'thumbnails',
'list',
];
export function SidePanelPreview({
dataSource,
selected,
servicesManager,
}: {
dataSource: any;
selected: StudyRow | null;
servicesManager: AppTypes.ServicesManager;
}) {
const { series, onThumbnailImageError } = useSeriesFetch({ dataSource, selected });
const { customizationService } = servicesManager.services;
const thumbnailRendering = dataSource?.getConfig?.()?.thumbnailRendering;
const thumbnailRequestStrategy =
dataSource?.getConfig?.()?.thumbnailRequestStrategy || 'bulkDataRetrieve';
const forceListView =
thumbnailRendering === 'wadors' ||
thumbnailRendering === 'thumbnailDirect' ||
thumbnailRequestStrategy === 'bulkDataRetrieve';
const customizationSeriesView = customizationService.getCustomization(
'workList.previewSeriesView'
);
const configuredSeriesView: PreviewSeriesView = ALLOWED_PREVIEW_SERIES_VIEWS.includes(
customizationSeriesView as PreviewSeriesView
)
? (customizationSeriesView as PreviewSeriesView)
: 'all';
const seriesView: PreviewSeriesView = forceListView ? 'list' : configuredSeriesView;
const previewProps: PreviewContentProps = {
study: selected as StudyRow | null,
series,
seriesView,
onThumbnailImageError,
};
const renderPreviewContent = customizationService.getCustomization('workList.renderPreviewContent');
if (typeof renderPreviewContent === 'function') {
return <>{(renderPreviewContent as RenderPreviewContent)(React, previewProps)}</>;
}
return <DefaultPreviewContent {...previewProps} />;
}
export type PreviewContentProps = {
study: StudyRow | null;
series: any[];
seriesView: PreviewSeriesView;
onThumbnailImageError: (seriesUID: string) => void;
};
export type RenderPreviewContent = (
React: typeof import('react'),
props: PreviewContentProps
) => React.ReactNode;
function DefaultPreviewContent({
study,
series,
seriesView,
onThumbnailImageError,
}: PreviewContentProps) {
return (
<StudyList.PreviewContainer>
<StudyList.PreviewHeader>
<StudyListSettingsPopover />
<StudyList.ClosePreviewButton />
</StudyList.PreviewHeader>
<StudyList.PreviewContent
study={study}
series={series}
seriesView={seriesView}
onThumbnailImageError={onThumbnailImageError}
/>
</StudyList.PreviewContainer>
);
}
@@ -0,0 +1,128 @@
import React from 'react';
import { useNavigate, type NavigateFunction } from 'react-router-dom';
import { useTranslation, type TFunction } from 'react-i18next';
import { useAppConfig } from '@state';
import { useSystem } from '@ohif/core';
import { StudyList, Icons, Button, useModal } from '@ohif/ui-next';
export type SettingsMenuItem = {
id: string;
label: React.ReactNode;
onClick: () => void;
};
type DefaultItemsContext = {
t: TFunction;
navigate: NavigateFunction;
customizationService: any;
show: ReturnType<typeof useModal>['show'];
appConfig: ReturnType<typeof useAppConfig>[0];
};
export function defaultSettingsMenuItems({
t,
navigate,
customizationService,
show,
appConfig,
}: DefaultItemsContext): SettingsMenuItem[] {
const items: SettingsMenuItem[] = [
{
id: 'about',
label: 'About OHIF Viewer',
onClick: () => {
const AboutModal = customizationService.getCustomization('ohif.aboutModal');
show({
content: AboutModal,
title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'),
containerClassName: AboutModal?.containerClassName ?? 'max-w-md',
});
},
},
{
id: 'userPreferences',
label: 'User Preferences',
onClick: () => {
const UserPreferencesModal = customizationService.getCustomization(
'ohif.userPreferencesModal'
);
show({
content: UserPreferencesModal,
title: UserPreferencesModal?.title ?? t('UserPreferencesModal:User preferences'),
containerClassName:
UserPreferencesModal?.containerClassName ?? 'flex max-w-4xl p-6 flex-col',
});
},
},
];
if (appConfig.oidc) {
items.push({
id: 'logout',
label: t('Header:Logout'),
onClick: () => {
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
},
});
}
return items;
}
export function StudyListSettingsPopover() {
// SettingsPopover.Workflow now uses useStudyListWorkflows internally
const { t } = useTranslation();
const [appConfig] = useAppConfig();
const navigate = useNavigate();
const { servicesManager } = useSystem();
const { customizationService } = servicesManager.services as any;
const { show } = useModal();
const defaults = defaultSettingsMenuItems({
t,
navigate,
customizationService,
show,
appConfig,
});
const buildItems = customizationService.getCustomization('workList.settingsMenuItems');
const items: SettingsMenuItem[] =
typeof buildItems === 'function'
? (() => {
const result = (
buildItems as (defaults: SettingsMenuItem[]) => SettingsMenuItem[]
)(defaults);
return Array.isArray(result) ? result : defaults;
})()
: defaults;
return (
<StudyList.SettingsPopover>
<StudyList.SettingsPopover.Trigger>
<Button
variant="ghost"
size="icon"
aria-label="Open settings"
>
<Icons.SettingsStudyList
aria-hidden="true"
className="h-4 w-4"
/>
</Button>
</StudyList.SettingsPopover.Trigger>
<StudyList.SettingsPopover.Content>
<StudyList.SettingsPopover.Workflow />
<StudyList.SettingsPopover.Divider />
{items.map(item => (
<StudyList.SettingsPopover.Item
key={item.id}
onClick={item.onClick}
>
{item.label}
</StudyList.SettingsPopover.Item>
))}
</StudyList.SettingsPopover.Content>
</StudyList.SettingsPopover>
);
}
+114 -672
View File
@@ -1,699 +1,141 @@
import React, { useState, useEffect, useMemo } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Link, useNavigate } from 'react-router-dom';
import moment from 'moment';
import qs from 'query-string';
import isEqual from 'lodash.isequal';
import { useTranslation } from 'react-i18next';
//
import filtersMeta from './filtersMeta.js';
import React, { useEffect, useMemo, useState } from 'react';
import { useAppConfig } from '@state';
import { useDebounce, useSearchParams } from '../../hooks';
import { utils, Types as coreTypes } from '@ohif/core';
import { preserveQueryParameters } from '../../utils/preserveQueryParameters';
import { useStudyListStateSync, useWorkListToolbarActions } from '../../hooks';
import {
StudyListExpandedRow,
EmptyStudies,
StudyListTable,
StudyListPagination,
StudyListFilter,
Button,
ButtonEnums,
} from '@ohif/ui';
import { StudyList, Icons, InvestigationalUseDialog, type StudyRow } from '@ohif/ui-next';
import { StudyListSettingsPopover } from './StudyListSettingsPopover';
import { SidePanelPreview } from './SidePanelPreview';
import {
Header,
Icons,
Tooltip,
TooltipTrigger,
TooltipContent,
Clipboard,
useModal,
useSessionStorage,
Onboarding,
ScrollArea,
InvestigationalUseDialog,
} from '@ohif/ui-next';
type Props = withAppTypes & {
data: any[];
dataSource: any;
isLoadingData: boolean;
hasFetchedOnce?: boolean;
dataPath?: string;
onRefresh: () => void;
};
import { Types } from '@ohif/ui';
import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters';
const PatientInfoVisibility = Types.PatientInfoVisibility;
const { sortBySeriesDate } = utils;
const seriesInStudiesMap = new Map();
/**
* TODO:
* - debounce `setFilterValues` (150ms?)
*/
function WorkList({
data: studies,
dataTotal: studiesTotal,
isLoadingData,
export default function WorkList({
data,
dataSource,
hotkeysManager,
isLoadingData,
hasFetchedOnce = false,
dataPath,
onRefresh,
servicesManager,
}: withAppTypes) {
const { show, hide } = useModal();
const { t } = useTranslation();
// ~ Modes
extensionManager,
}: Props) {
const [appConfig] = useAppConfig();
// ~ Filters
const searchParams = useSearchParams();
const navigate = useNavigate();
const STUDIES_LIMIT = 101;
const queryFilterValues = _getQueryFilterValues(searchParams);
const [sessionQueryFilterValues, updateSessionQueryFilterValues] = useSessionStorage({
key: 'queryFilterValues',
defaultValue: queryFilterValues,
// ToDo: useSessionStorage currently uses an unload listener to clear the filters from session storage
// so on systems that do not support unload events a user will NOT be able to alter any existing filter
// in the URL, load the page and have it apply.
clearOnUnload: true,
});
const [filterValues, _setFilterValues] = useState({
...defaultFilterValues,
...sessionQueryFilterValues,
});
const debouncedFilterValues = useDebounce(filterValues, 200);
const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues;
/*
* The default sort value keep the filters synchronized with runtime conditional sorting
* Only applied if no other sorting is specified and there are less than 101 studies
*/
const canSort = studiesTotal < STUDIES_LIMIT;
const shouldUseDefaultSort = sortBy === '' || !sortBy;
const sortModifier = sortDirection === 'descending' ? 1 : -1;
const defaultSortValues =
shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {};
const { customizationService } = servicesManager.services;
const sortedStudies = useMemo(() => {
if (!canSort) {
return studies;
}
return [...studies].sort((s1, s2) => {
if (shouldUseDefaultSort) {
const ascendingSortModifier = -1;
return _sortStringDates(s1, s2, ascendingSortModifier);
}
const s1Prop = s1[sortBy];
const s2Prop = s2[sortBy];
if (typeof s1Prop === 'string' && typeof s2Prop === 'string') {
return s1Prop.localeCompare(s2Prop) * sortModifier;
} else if (typeof s1Prop === 'number' && typeof s2Prop === 'number') {
return (s1Prop > s2Prop ? 1 : -1) * sortModifier;
} else if (!s1Prop && s2Prop) {
return -1 * sortModifier;
} else if (!s2Prop && s1Prop) {
return 1 * sortModifier;
} else if (sortBy === 'studyDate') {
return _sortStringDates(s1, s2, sortModifier);
}
return 0;
});
}, [canSort, studies, shouldUseDefaultSort, sortBy, sortModifier]);
// ~ Rows & Studies
const [expandedRows, setExpandedRows] = useState([]);
const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]);
const numOfStudies = studiesTotal;
const querying = useMemo(() => {
return isLoadingData || expandedRows.length > 0;
}, [isLoadingData, expandedRows]);
const setFilterValues = val => {
if (filterValues.pageNumber === val.pageNumber) {
val.pageNumber = 1;
}
_setFilterValues(val);
updateSessionQueryFilterValues(val);
setExpandedRows([]);
};
const onPageNumberChange = newPageNumber => {
const oldPageNumber = filterValues.pageNumber;
const rollingPageNumberMod = Math.floor(101 / filterValues.resultsPerPage);
const rollingPageNumber = oldPageNumber % rollingPageNumberMod;
const isNextPage = newPageNumber > oldPageNumber;
const hasNextPage = Math.max(rollingPageNumber, 1) * resultsPerPage < numOfStudies;
if (isNextPage && !hasNextPage) {
return;
}
setFilterValues({ ...filterValues, pageNumber: newPageNumber });
};
const onResultsPerPageChange = newResultsPerPage => {
setFilterValues({
...filterValues,
pageNumber: 1,
resultsPerPage: Number(newResultsPerPage),
});
};
// Set body style
useEffect(() => {
document.body.classList.add('bg-black');
return () => {
document.body.classList.remove('bg-black');
};
}, []);
// Sync URL query parameters with filters
useEffect(() => {
if (!debouncedFilterValues) {
return;
}
const queryString = {};
Object.keys(defaultFilterValues).forEach(key => {
const defaultValue = defaultFilterValues[key];
const currValue = debouncedFilterValues[key];
// TODO: nesting/recursion?
if (key === 'studyDate') {
if (currValue.startDate && defaultValue.startDate !== currValue.startDate) {
queryString.startDate = currValue.startDate;
}
if (currValue.endDate && defaultValue.endDate !== currValue.endDate) {
queryString.endDate = currValue.endDate;
}
} else if (key === 'modalities' && currValue.length) {
queryString.modalities = currValue.join(',');
} else if (currValue !== defaultValue) {
queryString[key] = currValue;
}
});
preserveQueryStrings(queryString);
const search = qs.stringify(queryString, {
skipNull: true,
skipEmptyString: true,
});
navigate({
pathname: '/',
search: search ? `?${search}` : undefined,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedFilterValues]);
// Query for series information
useEffect(() => {
const fetchSeries = async studyInstanceUid => {
try {
const series = await dataSource.query.series.search(studyInstanceUid);
seriesInStudiesMap.set(studyInstanceUid, sortBySeriesDate(series));
setStudiesWithSeriesData([...studiesWithSeriesData, studyInstanceUid]);
} catch (ex) {
// TODO: UI Notification Service
console.warn(ex);
}
};
// TODO: WHY WOULD YOU USE AN INDEX OF 1?!
// Note: expanded rows index begins at 1
for (let z = 0; z < expandedRows.length; z++) {
const expandedRowIndex = expandedRows[z] - 1;
const studyInstanceUid = sortedStudies[expandedRowIndex].studyInstanceUid;
if (studiesWithSeriesData.includes(studyInstanceUid)) {
continue;
}
fetchSeries(studyInstanceUid);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandedRows, studies]);
const isFiltering = (filterValues, defaultFilterValues) => {
return !isEqual(filterValues, defaultFilterValues);
};
const rollingPageNumberMod = Math.floor(101 / resultsPerPage);
const rollingPageNumber = (pageNumber - 1) % rollingPageNumberMod;
const offset = resultsPerPage * rollingPageNumber;
const offsetAndTake = offset + resultsPerPage;
const tableDataSource = sortedStudies.map((study, key) => {
const rowKey = key + 1;
const isExpanded = expandedRows.some(k => k === rowKey);
const {
studyInstanceUid,
accession,
modalities,
instances,
description,
mrn,
patientName,
date,
time,
} = study;
const studyDate =
date &&
moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true).isValid() &&
moment(date, ['YYYYMMDD', 'YYYY.MM.DD']).format(t('Common:localDateFormat', 'MMM-DD-YYYY'));
const studyTime =
time &&
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).isValid() &&
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).format(
t('Common:localTimeFormat', 'hh:mm A')
);
const makeCopyTooltipCell = textValue => {
if (!textValue) {
return '';
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-pointer truncate">{textValue}</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex items-center justify-between gap-2">
{textValue}
<Clipboard>{textValue}</Clipboard>
</div>
</TooltipContent>
</Tooltip>
);
};
return {
dataCY: `studyRow-${studyInstanceUid}`,
clickableCY: studyInstanceUid,
row: [
{
key: 'patientName',
content: patientName ? makeCopyTooltipCell(patientName) : null,
gridCol: 4,
},
{
key: 'mrn',
content: makeCopyTooltipCell(mrn),
gridCol: 3,
},
{
key: 'studyDate',
content: (
<>
{studyDate && <span className="mr-4">{studyDate}</span>}
{studyTime && <span>{studyTime}</span>}
</>
),
title: `${studyDate || ''} ${studyTime || ''}`,
gridCol: 5,
},
{
key: 'description',
content: makeCopyTooltipCell(description),
gridCol: 4,
},
{
key: 'modality',
content: modalities,
title: modalities,
gridCol: 3,
},
{
key: 'accession',
content: makeCopyTooltipCell(accession),
gridCol: 3,
},
{
key: 'instances',
content: (
<>
<Icons.GroupLayers
className={classnames('mr-2 inline-flex w-4', {
'text-primary': isExpanded,
'text-secondary-light': !isExpanded,
})}
/>
{instances}
</>
),
title: (instances || 0).toString(),
gridCol: 2,
},
],
// Todo: This is actually running for all rows, even if they are
// not clicked on.
expandedContent: (
<StudyListExpandedRow
seriesTableColumns={{
description: t('StudyList:Description'),
seriesNumber: t('StudyList:Series'),
modality: t('StudyList:Modality'),
instances: t('StudyList:Instances'),
}}
seriesTableDataSource={
seriesInStudiesMap.has(studyInstanceUid)
? seriesInStudiesMap.get(studyInstanceUid).map(s => {
return {
description: s.description || '(empty)',
seriesNumber: s.seriesNumber ?? '',
modality: s.modality || '',
instances: s.numSeriesInstances || '',
};
})
: []
}
>
<div className="flex flex-row gap-2">
{(appConfig.groupEnabledModesFirst
? appConfig.loadedModes.sort((a, b) => {
const isValidA = a.isValidMode({
modalities: modalities.replaceAll('/', '\\'),
study,
}).valid;
const isValidB = b.isValidMode({
modalities: modalities.replaceAll('/', '\\'),
study,
}).valid;
return isValidB - isValidA;
})
: appConfig.loadedModes
).map((mode, i) => {
if (mode.hide) {
// Hide this mode from display
return null;
}
const modalitiesToCheck = modalities.replaceAll('/', '\\');
const { valid: isValidMode, description: invalidModeDescription } = mode.isValidMode({
modalities: modalitiesToCheck,
study,
});
if (isValidMode === null) {
// Hide this as a computed result.
return null;
}
// TODO: Modes need a default/target route? We mostly support a single one for now.
// We should also be using the route path, but currently are not
// mode.routeName
// mode.routes[x].path
// Don't specify default data source, and it should just be picked up... (this may not currently be the case)
// How do we know which params to pass? Today, it's just StudyInstanceUIDs and configUrl if exists
const query = new URLSearchParams();
if (filterValues.configUrl) {
query.append('configUrl', filterValues.configUrl);
}
query.append('StudyInstanceUIDs', studyInstanceUid);
preserveQueryParameters(query);
return (
mode.displayName && (
<Link
className={isValidMode ? '' : 'cursor-not-allowed'}
key={i}
to={`${mode.routeName}${dataPath || ''}?${query.toString()}`}
onClick={event => {
// In case any event bubbles up for an invalid mode, prevent the navigation.
// For example, the event bubbles up when the icon embedded in the disabled button is clicked.
if (!isValidMode) {
event.preventDefault();
}
}}
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
>
{/* TODO revisit the completely rounded style of buttons used for launching a mode from the worklist later */}
<Button
type={ButtonEnums.type.primary}
size={ButtonEnums.size.smallTall}
disabled={!isValidMode}
startIconTooltip={
!isValidMode ? (
<div className="font-inter flex w-[206px] whitespace-normal text-left text-xs font-normal text-white">
{invalidModeDescription}
</div>
) : null
}
startIcon={
isValidMode ? (
<Icons.LaunchArrow className="!h-[20px] !w-[20px] text-black" />
) : (
<Icons.LaunchInfo className="!h-[20px] !w-[20px] text-black" />
)
}
onClick={() => {}}
dataCY={`mode-${mode.routeName}-${studyInstanceUid}`}
className={!isValidMode && 'bg-[#222d44]'}
>
{mode.displayName}
</Button>
</Link>
)
);
})}
</div>
</StudyListExpandedRow>
),
onClickRow: () =>
setExpandedRows(s => (isExpanded ? s.filter(n => rowKey !== n) : [...s, rowKey])),
isExpanded,
};
});
const hasStudies = numOfStudies > 0;
const AboutModal = customizationService.getCustomization(
'ohif.aboutModal'
) as coreTypes.MenuComponentCustomization;
const UserPreferencesModal = customizationService.getCustomization(
'ohif.userPreferencesModal'
) as coreTypes.MenuComponentCustomization;
const menuOptions = [
{
title: AboutModal?.menuTitle ?? t('Header:About'),
icon: 'info',
onClick: () =>
show({
content: AboutModal,
title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'),
containerClassName: AboutModal?.containerClassName ?? 'max-w-md',
}),
},
{
title: UserPreferencesModal.menuTitle ?? t('Header:Preferences'),
icon: 'settings',
onClick: () =>
show({
content: UserPreferencesModal as React.ComponentType,
title: UserPreferencesModal.title ?? t('UserPreferencesModal:User preferences'),
containerClassName:
UserPreferencesModal?.containerClassName ?? 'flex max-w-4xl p-6 flex-col',
}),
},
];
if (appConfig.oidc) {
menuOptions.push({
icon: 'power-off',
title: t('Header:Logout'),
onClick: () => {
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
},
});
}
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
) as React.ComponentType<{ className?: string }> | undefined;
const [isFilterPending, setIsFilterPending] = useState(false);
const showStudyListLoading = Boolean(
(appConfig.showLoadingIndicator && isLoadingData) || !hasFetchedOnce || isFilterPending
);
const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent');
const uploadProps =
DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled
? {
title: 'Upload files',
containerClassName: DicomUploadComponent?.containerClassName,
closeButton: true,
shouldCloseOnEsc: false,
shouldCloseOnOverlayClick: false,
content: () => (
<DicomUploadComponent
dataSource={dataSource}
onComplete={() => {
hide();
onRefresh();
}}
onStarted={() => {
show({
...uploadProps,
// when upload starts, hide the default close button as closing the dialogue must be handled by the upload dialogue itself
closeButton: false,
});
}}
/>
),
}
: undefined;
// Sync table state (sorting, pagination, filters) with URL and sessionStorage
const { sorting, pagination, filters, setSorting, setPagination, setFilters } =
useStudyListStateSync();
const dataSourceConfigurationComponent = customizationService.getCustomization(
'ohif.dataSourceConfigurationComponent'
// Default sorting if no URL state exists
const defaultSorting = useMemo(() => [{ id: 'studyDateTime', desc: true }], []);
const [selected, setSelected] = useState<StudyRow | null>(null);
const [isPreviewOpen, setPreviewOpen] = useState(true);
const columns = useMemo(() => {
// `workList.columns` is registered as a value (StudyList.defaultColumns) and
// merged via customization commands, so we read the result directly.
const customized = customizationService.getCustomization('workList.columns');
return Array.isArray(customized) ? customized : StudyList.defaultColumns;
}, [customizationService]);
const logoComponent = appConfig?.whiteLabeling?.createLogoComponentFn?.(React) ?? (
<Icons.OHIFLogoHorizontal
aria-label="OHIF logo"
className="h-[22px] w-[232px]"
/>
);
const toolbarActions = useWorkListToolbarActions(servicesManager, dataSource, onRefresh);
const previewDefaultSize = useMemo(() => {
if (typeof window !== 'undefined' && window.innerWidth > 0) {
const percent = (325 / window.innerWidth) * 100;
return Math.min(Math.max(percent, 15), 50);
}
return 30;
}, []);
useEffect(() => {
if (isLoadingData) {
return;
}
setIsFilterPending(false);
}, [isLoadingData, data]);
return (
<div className="flex h-screen flex-col bg-black">
<Header
isSticky
menuOptions={menuOptions}
isReturnEnabled={false}
WhiteLabeling={appConfig.whiteLabeling}
showPatientInfo={PatientInfoVisibility.DISABLED}
/>
<Onboarding />
<div className="flex h-screen min-h-0 flex-col overflow-hidden bg-black">
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
<div className="flex h-full flex-col overflow-y-auto">
<ScrollArea>
<div className="flex grow flex-col">
<StudyListFilter
numOfStudies={pageNumber * resultsPerPage > 100 ? 101 : numOfStudies}
filtersMeta={filtersMeta}
filterValues={{ ...filterValues, ...defaultSortValues }}
onChange={setFilterValues}
clearFilters={() => setFilterValues(defaultFilterValues)}
isFiltering={isFiltering(filterValues, defaultFilterValues)}
onUploadClick={uploadProps ? () => show(uploadProps) : undefined}
getDataSourceConfigurationComponent={
dataSourceConfigurationComponent
? () => dataSourceConfigurationComponent()
: undefined
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<StudyList
loadedModes={appConfig?.loadedModes ?? []}
preserveQueryParameters={preserveQueryParameters}
dataPath={dataPath}
isPreviewOpen={isPreviewOpen}
onIsPreviewOpenChange={setPreviewOpen}
defaultPreviewSizePercent={previewDefaultSize}
className="h-full w-full"
>
<StudyList.Table
columns={columns}
data={data as StudyRow[]}
sorting={sorting.length > 0 ? sorting : defaultSorting}
pagination={pagination}
filters={filters}
onSortingChange={setSorting}
onPaginationChange={setPagination}
onFiltersChange={updater => {
setIsFilterPending(true);
setFilters(updater);
}}
isLoading={showStudyListLoading}
loadingComponent={
LoadingIndicatorProgress ? (
<LoadingIndicatorProgress className="!relative bg-black" />
) : (
<div className="h-8 w-8" />
)
}
title={'Study List'}
onSelectionChange={sel => setSelected((sel as StudyRow[])[0] ?? null)}
toolbarLeftComponent={logoComponent}
toolbarRightActionsComponent={toolbarActions}
toolbarRightComponent={
!isPreviewOpen ? (
<div className="relative -top-px mt-1 ml-2 flex items-center gap-1">
<StudyListSettingsPopover />
<StudyList.OpenPreviewButton />
</div>
) : undefined
}
/>
</div>
{hasStudies ? (
<div className="flex grow flex-col">
<StudyListTable
tableDataSource={tableDataSource.slice(offset, offsetAndTake)}
numOfStudies={numOfStudies}
querying={querying}
filtersMeta={filtersMeta}
<StudyList.Preview>
<SidePanelPreview
dataSource={dataSource}
selected={selected}
servicesManager={servicesManager}
/>
<div className="grow">
<StudyListPagination
onChangePage={onPageNumberChange}
onChangePerPage={onResultsPerPageChange}
currentPage={pageNumber}
perPage={resultsPerPage}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-48">
{appConfig.showLoadingIndicator && isLoadingData ? (
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
) : (
<EmptyStudies />
)}
</div>
)}
</ScrollArea>
</StudyList.Preview>
</StudyList>
</div>
</div>
</div>
);
}
WorkList.propTypes = {
data: PropTypes.array.isRequired,
dataSource: PropTypes.shape({
query: PropTypes.object.isRequired,
getConfig: PropTypes.func,
}).isRequired,
isLoadingData: PropTypes.bool.isRequired,
servicesManager: PropTypes.object.isRequired,
};
const defaultFilterValues = {
patientName: '',
mrn: '',
studyDate: {
startDate: null,
endDate: null,
},
description: '',
modalities: [],
accession: '',
sortBy: '',
sortDirection: 'none',
pageNumber: 1,
resultsPerPage: 25,
datasources: '',
};
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str && str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
return retValue;
}
function _getQueryFilterValues(params) {
const newParams = new URLSearchParams();
for (const [key, value] of params) {
newParams.set(key.toLowerCase(), value);
}
params = newParams;
const queryFilterValues = {
patientName: params.get('patientname'),
mrn: params.get('mrn'),
studyDate: {
startDate: params.get('startdate') || null,
endDate: params.get('enddate') || null,
},
description: params.get('description'),
modalities: params.get('modalities') ? params.get('modalities').split(',') : [],
accession: params.get('accession'),
sortBy: params.get('sortby'),
sortDirection: params.get('sortdirection'),
pageNumber: _tryParseInt(params.get('pagenumber'), undefined),
resultsPerPage: _tryParseInt(params.get('resultsperpage'), undefined),
datasources: params.get('datasources'),
configUrl: params.get('configurl'),
};
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
}
function _sortStringDates(s1, s2, sortModifier) {
// TODO: Delimiters are non-standard. Should we support them?
const s1Date = moment(s1.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
const s2Date = moment(s2.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
if (s1Date.isValid() && s2Date.isValid()) {
return (s1Date.toISOString() > s2Date.toISOString() ? 1 : -1) * sortModifier;
} else if (s1Date.isValid()) {
return sortModifier;
} else if (s2Date.isValid()) {
return -1 * sortModifier;
}
}
export default WorkList;
@@ -1 +0,0 @@
export { default } from './WorkList';
+9 -2
View File
@@ -3,8 +3,12 @@ import { Routes, Route, Link, useNavigate } from 'react-router-dom';
import { ErrorBoundary } from '@ohif/ui-next';
// Route Components
// Study list variants are selected by the `workList.variant` customization:
// - `'legacy'` → LegacyWorkList (the pre-3.13 study list)
// - anything else (including `'default'`) → WorkList (ui-next study list)
import WorkList from './WorkList/WorkList';
import LegacyWorkList from './LegacyWorkList/LegacyWorkList';
import DataSourceWrapper from './DataSourceWrapper';
import WorkList from './WorkList';
import Local from './Local';
import Debug from './Debug';
import NotFound from './NotFound';
@@ -120,11 +124,14 @@ const createRoutes = ({
console.log('Registering worklist route', routerBasename, path);
const workListVariant = customizationService.getCustomization('workList.variant');
const WorkListComponent = workListVariant === 'legacy' ? LegacyWorkList : WorkList;
const WorkListRoute = {
path: '/',
children: DataSourceWrapper,
private: true,
props: { children: WorkList, servicesManager, extensionManager },
props: { children: WorkListComponent, servicesManager, extensionManager },
};
const customRoutes = customizationService.getCustomization('routes.customRoutes');
@@ -0,0 +1,85 @@
import { shallowEqualIgnoringArrayOrder } from './shallowEqualIgnoringArrayOrder';
describe('shallowEqualIgnoringArrayOrder', () => {
describe('null / undefined handling', () => {
it('treats two nullish values with strict equality', () => {
expect(shallowEqualIgnoringArrayOrder(null, null)).toBe(true);
expect(shallowEqualIgnoringArrayOrder(undefined, undefined)).toBe(true);
// null !== undefined
expect(shallowEqualIgnoringArrayOrder(null, undefined)).toBe(false);
});
it('returns false when only one side is nullish', () => {
expect(shallowEqualIgnoringArrayOrder(null, {})).toBe(false);
expect(shallowEqualIgnoringArrayOrder({}, null)).toBe(false);
expect(shallowEqualIgnoringArrayOrder({ a: 1 }, undefined)).toBe(false);
});
});
describe('scalar values', () => {
it('returns true for equal flat records', () => {
expect(shallowEqualIgnoringArrayOrder({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true);
});
it('returns true for two empty objects', () => {
expect(shallowEqualIgnoringArrayOrder({}, {})).toBe(true);
});
it('returns false when a scalar value differs', () => {
expect(shallowEqualIgnoringArrayOrder({ a: 1 }, { a: 2 })).toBe(false);
});
it('compares scalars with strict equality (no coercion)', () => {
expect(shallowEqualIgnoringArrayOrder({ a: 1 }, { a: '1' })).toBe(false);
});
});
describe('keys present on only one side', () => {
it('returns false when one object has an extra defined key', () => {
expect(shallowEqualIgnoringArrayOrder({ a: 1 }, { a: 1, b: 2 })).toBe(false);
expect(shallowEqualIgnoringArrayOrder({ a: 1, b: 2 }, { a: 1 })).toBe(false);
});
it('treats a missing key as equal to an explicit undefined value', () => {
// b.y is absent, a.y is undefined — both read as undefined, so equal.
expect(shallowEqualIgnoringArrayOrder({ x: 1, y: undefined }, { x: 1 })).toBe(true);
});
});
describe('array values (order-insensitive)', () => {
it('treats arrays as equal regardless of element order', () => {
expect(shallowEqualIgnoringArrayOrder({ m: [1, 2, 3] }, { m: [3, 1, 2] })).toBe(true);
expect(shallowEqualIgnoringArrayOrder({ m: ['CT', 'MR'] }, { m: ['MR', 'CT'] })).toBe(true);
});
it('returns false when arrays have different lengths', () => {
expect(shallowEqualIgnoringArrayOrder({ m: [1, 2] }, { m: [1, 2, 3] })).toBe(false);
});
it('returns false when arrays of equal length have different elements', () => {
expect(shallowEqualIgnoringArrayOrder({ m: [1, 2] }, { m: [1, 3] })).toBe(false);
});
it('returns false when one value is an array and the other is not', () => {
expect(shallowEqualIgnoringArrayOrder({ m: [1] }, { m: 1 })).toBe(false);
expect(shallowEqualIgnoringArrayOrder({ m: 'CT' }, { m: ['CT'] })).toBe(false);
});
});
describe('shallow-only semantics (documented limitations)', () => {
it('compares nested objects by reference, not structurally', () => {
const shared = { nested: true };
expect(shallowEqualIgnoringArrayOrder({ o: shared }, { o: shared })).toBe(true);
// Equal-looking but distinct references are NOT considered equal.
expect(shallowEqualIgnoringArrayOrder({ o: { nested: true } }, { o: { nested: true } })).toBe(
false
);
});
it('compares arrays as sets, so differing duplicate counts can still be "equal"', () => {
// Same length and same distinct elements, but different multisets.
// The Set-based comparison cannot tell these apart.
expect(shallowEqualIgnoringArrayOrder({ m: [1, 1, 2] }, { m: [1, 2, 2] })).toBe(true);
});
});
});
@@ -0,0 +1,45 @@
/**
* Shallow equality for two flat record-shaped objects, with one twist:
* array values are compared as unordered sets (so `[1, 2]` is equal to
* `[2, 1]`). Scalar values are compared with strict `===`.
*
* Limitation: this is a *shallow* comparison. Nested objects are compared
* by reference; this function does not recurse. It is intended for flat
* records whose values are primitives or arrays of primitives.
*
* @param {object} a - First object
* @param {object} b - Second object
* @returns {boolean} True if the two are equal under the rules above.
*/
export function shallowEqualIgnoringArrayOrder(a, b): boolean {
if (!a || !b) {
return a === b;
}
const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of allKeys) {
const val1 = a[key];
const val2 = b[key];
if (Array.isArray(val1) && Array.isArray(val2)) {
if (val1.length !== val2.length) {
return false;
}
const s1 = new Set(val1);
const s2 = new Set(val2);
if (s1.size !== s2.size) {
return false;
}
for (const v of s2.values()) {
if (!s1.has(v)) {
return false;
}
}
} else if (val1 !== val2) {
return false;
}
}
return true;
}
@@ -0,0 +1,68 @@
import { COLUMN_IDS } from '@ohif/ui-next';
/**
* Canonical URL query keys for study-list filters, sorting, and pagination.
*
* This is the single source of truth for the URL contract documented in
* `platform/docs/docs/configuration/url.md`. WorkList's URL serializer and
* URL parser use these constants, as does
* `DataSourceWrapper._getQueryFilterValues`, so the writer and reader stay
* in lockstep.
*
* Values use the documented camelCase form (`patientName` rather than
* `patientname`), so URLs produced by the serializer render as
* `?patientName=…` and match what's documented. URL parsing is
* case-insensitive in this codebase (the readers lowercase before lookup),
* so bookmarks using any casing still work. To read a URL parameter by its
* canonical key, use `getUrlParam` below.
*/
export const URL_KEYS = {
// Filter values
patientName: 'patientName',
mrn: 'mrn',
description: 'description',
accession: 'accession',
modalities: 'modalities',
startDate: 'startDate',
endDate: 'endDate',
// Sorting + pagination
sortBy: 'sortBy',
sortDirection: 'sortDirection',
pageNumber: 'pageNumber',
resultsPerPage: 'resultsPerPage',
// Misc
dataSources: 'dataSources',
} as const;
/**
* Read a URL parameter by its canonical key, case-insensitively. Pass a
* value from `URL_KEYS`. Matches regardless of how the params were
* constructed — callers don't need to pre-lowercase keys.
*/
export function getUrlParam(params: URLSearchParams, key: string): string | null {
const target = key.toLowerCase();
for (const [k, v] of params) {
if (k.toLowerCase() === target) {
return v;
}
}
return null;
}
/**
* Column ID → canonical URL key for text-filter columns. Listed explicitly
* so adding a new text-filter column requires registering its URL key here
* — that's the whole point of centralizing the contract.
*/
const TEXT_FILTER_URL_KEYS: Record<string, string> = {
[COLUMN_IDS.PATIENT]: URL_KEYS.patientName,
[COLUMN_IDS.MRN]: URL_KEYS.mrn,
[COLUMN_IDS.DESCRIPTION]: URL_KEYS.description,
[COLUMN_IDS.ACCESSION]: URL_KEYS.accession,
};
export function urlKeyForTextFilter(columnId: string): string {
return TEXT_FILTER_URL_KEYS[columnId] ?? columnId.toLowerCase();
}