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:
121 files changed
+8071
-1468
No files matched your search
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,7 +23,9 @@ window.config = {
|
||||
qidoSupportsIncludeField: true,
|
||||
imageRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
thumbnailRendering: 'wadors',
|
||||
thumbnailRendering: 'thumbnail',
|
||||
thumbnailRequestStrategy: 'fetch',
|
||||
supportsWildcard: true,
|
||||
requestOptions: {
|
||||
auth: 'admin:admin',
|
||||
},
|
||||
|
||||
@@ -25,7 +25,8 @@ window.config = {
|
||||
supportsReject: true,
|
||||
dicomUploadEnabled: true,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
thumbnailRendering: 'rendered',
|
||||
thumbnailRequestStrategy: 'fetch',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: true,
|
||||
supportsWildcard: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
File renamed without changes.
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user