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
@@ -1,6 +1,17 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icons, useModal } from '@ohif/ui-next';
|
||||
import {
|
||||
Icons,
|
||||
useModal,
|
||||
Button,
|
||||
HoverCard,
|
||||
HoverCardTrigger,
|
||||
HoverCardContent,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@ohif/ui-next';
|
||||
import { Types } from '@ohif/core';
|
||||
import DataSourceConfigurationModalComponent from './DataSourceConfigurationModalComponent';
|
||||
|
||||
@@ -18,6 +29,8 @@ function DataSourceConfigurationComponent({
|
||||
const [configuredItems, setConfiguredItems] =
|
||||
useState<Array<Types.BaseDataSourceConfigurationAPIItem>>();
|
||||
|
||||
const [itemLabels, setItemLabels] = useState<Array<string>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let shouldUpdate = true;
|
||||
|
||||
@@ -38,6 +51,7 @@ function DataSourceConfigurationComponent({
|
||||
|
||||
const configAPI = configurationAPIFactory(activeDataSourceDef.sourceName);
|
||||
setConfigurationAPI(configAPI);
|
||||
setItemLabels(configAPI.getItemLabels());
|
||||
|
||||
// New configuration API means that the existing configured items must be cleared.
|
||||
setConfiguredItems(null);
|
||||
@@ -87,31 +101,42 @@ function DataSourceConfigurationComponent({
|
||||
}, [configurationAPI, configuredItems, showConfigurationModal]);
|
||||
|
||||
return configuredItems ? (
|
||||
<div className="text-muted-foreground flex items-center overflow-hidden">
|
||||
<Icons.Settings
|
||||
className="mr-2.5 h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||
onClick={showConfigurationModal}
|
||||
/>
|
||||
{configuredItems.map((item, itemIndex) => {
|
||||
return (
|
||||
<div
|
||||
key={itemIndex}
|
||||
className="flex overflow-hidden"
|
||||
>
|
||||
<div
|
||||
key={itemIndex}
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
{itemIndex !== configuredItems.length - 1 && <div className="px-2.5">|</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1 text-sm"
|
||||
onClick={showConfigurationModal}
|
||||
>
|
||||
<Icons.CloudSettings className="h-5 w-5" />
|
||||
Source
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="center"
|
||||
className="w-72 p-0"
|
||||
>
|
||||
<Card className="border-0 shadow-none">
|
||||
<CardHeader className="p-3 pb-1">
|
||||
<CardDescription className="text-sm">
|
||||
<span className="text-foreground font-semibold">{t('Data Source')}:</span>{' '}
|
||||
{t('Configure the server connection and storage settings')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 p-3 pt-0 text-sm">
|
||||
<div className="bg-input col-span-2 my-2 h-px" />
|
||||
{itemLabels.map((label, index) => (
|
||||
<React.Fragment key={label}>
|
||||
<span className="text-muted-foreground">{t(label)}</span>
|
||||
<span>{configuredItems[index]?.name ?? '—'}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default DataSourceConfigurationComponent;
|
||||
@@ -16,7 +16,8 @@ import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStu
|
||||
import StaticWadoClient from './utils/StaticWadoClient';
|
||||
import getDirectURL from '../utils/getDirectURL';
|
||||
import { fixBulkDataURI } from './utils/fixBulkDataURI';
|
||||
import {HeadersInterface} from '@ohif/core/src/types/RequestHeaders';
|
||||
import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders';
|
||||
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
|
||||
|
||||
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
|
||||
|
||||
@@ -47,6 +48,15 @@ export type DicomWebConfig = {
|
||||
thumbnail - render using the thumbnail endpoint on wadors using bulkDataURI, passing authentication params to the url.
|
||||
rendered - should use the rendered endpoint instead of the thumbnail endpoint
|
||||
*/
|
||||
thumbnailRequestStrategy?: 'bulkDataRetrieve' | 'fetch';
|
||||
/**
|
||||
* Thumbnail data request strategy when `thumbnailRendering` is `thumbnail`/`rendered`; ignored for `wadors`/`thumbnailDirect`.
|
||||
*
|
||||
* - `bulkDataRetrieve` (default): Uses the DICOMweb client's bulk data retrieve API (`retrieveBulkData`)
|
||||
* - `fetch`: `GET` the WADO-RS thumbnail or rendered resource URL with auth headers and use the
|
||||
* response body as a JPEG blob URL. For series-level context, if that `GET` fails, a single
|
||||
* QIDO instances query (`limit=1`) is used to obtain `SOPInstanceUID` and the fetch is retried once.
|
||||
*/
|
||||
/** Whether the server supports reject calls (i.e. DCM4CHEE) */
|
||||
supportsReject?: boolean;
|
||||
/** indicates if the retrieves can fetch singlepart. Options are bulkdata, video, image, or true */
|
||||
@@ -98,6 +108,7 @@ export type BulkDataURIConfig = {
|
||||
relativeResolution?: 'studies' | 'series';
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* The header options are the options passed into the generateWadoHeader
|
||||
* command. This takes an extensible set of attributes to allow future enhancements.
|
||||
@@ -158,7 +169,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
||||
*/
|
||||
generateWadoHeader = (options: HeaderOptions): HeadersInterface => {
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
if (options?.includeTransferSyntax!==false) {
|
||||
if (options?.includeTransferSyntax !== false) {
|
||||
//Generate accept header depending on config params
|
||||
const formattedAcceptHeader = utils.generateAcceptHeader(
|
||||
dicomWebConfig.acceptHeader,
|
||||
@@ -175,7 +186,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
||||
// which the server expects Accept: application/dicom+json will still include that in the
|
||||
// header.
|
||||
return {
|
||||
...authorizationHeader
|
||||
...authorizationHeader,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -261,70 +272,16 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
||||
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI
|
||||
*/
|
||||
|
||||
getGetThumbnailSrc: function (instance, imageId) {
|
||||
if (dicomWebConfig.thumbnailRendering === 'wadors') {
|
||||
return function getThumbnailSrc(options) {
|
||||
if (!imageId) {
|
||||
return null;
|
||||
}
|
||||
if (!options?.getImageSrc) {
|
||||
return null;
|
||||
}
|
||||
return options.getImageSrc(imageId);
|
||||
};
|
||||
}
|
||||
if (dicomWebConfig.thumbnailRendering === 'thumbnailDirect') {
|
||||
return function getThumbnailSrc() {
|
||||
return this.directURL({
|
||||
instance: instance,
|
||||
defaultPath: '/thumbnail',
|
||||
defaultType: 'image/jpeg',
|
||||
singlepart: true,
|
||||
tag: 'Absent',
|
||||
});
|
||||
}.bind(this);
|
||||
}
|
||||
|
||||
if (dicomWebConfig.thumbnailRendering === 'thumbnail') {
|
||||
return async function getThumbnailSrc() {
|
||||
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
|
||||
const bulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/thumbnail?accept=image/jpeg`;
|
||||
return URL.createObjectURL(
|
||||
new Blob(
|
||||
[
|
||||
await this.bulkDataURI({
|
||||
BulkDataURI: bulkDataURI.replace('wadors:', ''),
|
||||
defaultType: 'image/jpeg',
|
||||
mediaTypes: ['image/jpeg'],
|
||||
thumbnail: true,
|
||||
}),
|
||||
],
|
||||
{ type: 'image/jpeg' }
|
||||
)
|
||||
);
|
||||
}.bind(this);
|
||||
}
|
||||
if (dicomWebConfig.thumbnailRendering === 'rendered') {
|
||||
return async function getThumbnailSrc() {
|
||||
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
|
||||
const bulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered?accept=image/jpeg`;
|
||||
return URL.createObjectURL(
|
||||
new Blob(
|
||||
[
|
||||
await this.bulkDataURI({
|
||||
BulkDataURI: bulkDataURI.replace('wadors:', ''),
|
||||
defaultType: 'image/jpeg',
|
||||
mediaTypes: ['image/jpeg'],
|
||||
thumbnail: true,
|
||||
}),
|
||||
],
|
||||
{ type: 'image/jpeg' }
|
||||
)
|
||||
);
|
||||
}.bind(this);
|
||||
}
|
||||
getGetThumbnailSrc: function (thumbnailContext: ThumbnailContext, imageId) {
|
||||
return getGetThumbnailSrc({
|
||||
thumbnailContext,
|
||||
imageId,
|
||||
config: dicomWebConfig,
|
||||
getAuthorizationHeader,
|
||||
qidoDicomWebClient,
|
||||
retrieve: this,
|
||||
});
|
||||
},
|
||||
|
||||
directURL: params => {
|
||||
return getDirectURL(
|
||||
{
|
||||
|
||||
@@ -55,6 +55,7 @@ function processResults(qidoStudies) {
|
||||
instances: Number(getString(qidoStudy['00201208'])) || 0, // number
|
||||
description: getString(qidoStudy['00081030']) || '',
|
||||
modalities: getString(getModalities(qidoStudy['00080060'], qidoStudy['00080061'])) || '',
|
||||
referringPhysicianName: utils.formatPN(getName(qidoStudy['00080090'])) || '', // Referring Physician's Name
|
||||
})
|
||||
);
|
||||
|
||||
@@ -151,6 +152,7 @@ function mapParams(params, options = {}) {
|
||||
const commaSeparatedFields = [
|
||||
'00081030', // Study Description
|
||||
'00080060', // Modality
|
||||
'00080090', // Referring Physician's Name
|
||||
// Add more fields here if you want them in the result
|
||||
].join(',');
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders';
|
||||
|
||||
export type ThumbnailContext = {
|
||||
StudyInstanceUID?: string;
|
||||
SeriesInstanceUID?: string;
|
||||
SOPInstanceUID?: string;
|
||||
};
|
||||
|
||||
type ThumbnailFetchRequestResult = {
|
||||
url: string;
|
||||
endpointPath: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
type GetThumbnailSrcOptions = {
|
||||
signal?: AbortSignal;
|
||||
getImageSrc?: (imageId: unknown) => unknown;
|
||||
};
|
||||
|
||||
type QidoClient = {
|
||||
headers: HeadersInterface;
|
||||
searchForInstances: (opts: unknown) => Promise<unknown[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The subset of the data source's `retrieve` object the thumbnail strategies
|
||||
* call back into for the directURL and bulkData-backed renderings.
|
||||
*/
|
||||
type RetrieveApi = {
|
||||
directURL: (params: unknown) => unknown;
|
||||
bulkDataURI: (params: unknown) => Promise<BlobPart>;
|
||||
};
|
||||
|
||||
type ThumbnailConfig = {
|
||||
thumbnailRendering?: string;
|
||||
thumbnailRequestStrategy?: 'bulkDataRetrieve' | 'fetch';
|
||||
wadoRoot?: string;
|
||||
};
|
||||
|
||||
type GetGetThumbnailSrcDeps = {
|
||||
thumbnailContext: ThumbnailContext;
|
||||
imageId: unknown;
|
||||
config: ThumbnailConfig;
|
||||
getAuthorizationHeader: () => HeadersInterface;
|
||||
qidoDicomWebClient: QidoClient;
|
||||
retrieve: RetrieveApi;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the `getThumbnailSrc` function for a given thumbnail context,
|
||||
* selecting the strategy from `config.thumbnailRendering` /
|
||||
* `config.thumbnailRequestStrategy`.
|
||||
*
|
||||
* Extracted from the DICOMweb data source so the thumbnail strategies live
|
||||
* apart from the data source wiring. The `retrieve` dependency provides the
|
||||
* `directURL` and `bulkDataURI` callbacks the data source used to reach via
|
||||
* `this`.
|
||||
*/
|
||||
export function getGetThumbnailSrc({
|
||||
thumbnailContext,
|
||||
imageId,
|
||||
config,
|
||||
getAuthorizationHeader,
|
||||
qidoDicomWebClient,
|
||||
retrieve,
|
||||
}: GetGetThumbnailSrcDeps) {
|
||||
const thumbnailRendering = config.thumbnailRendering;
|
||||
if (!thumbnailRendering) {
|
||||
return function getThumbnailSrc() {
|
||||
console.warn('thumbnailRendering is not configured; returning null thumbnail src.');
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
if (thumbnailRendering === 'wadors') {
|
||||
return function getThumbnailSrc(options?: GetThumbnailSrcOptions) {
|
||||
if (!imageId) {
|
||||
return null;
|
||||
}
|
||||
if (!options?.getImageSrc) {
|
||||
return null;
|
||||
}
|
||||
// Note: options.signal (Cornerstone-backed loadImageToCanvas via getImageSrc) does
|
||||
// not currently expose an AbortSignal hook, so abort is not propagated to the
|
||||
// underlying image load. We short-circuit only if already aborted at call time.
|
||||
if (options?.signal?.aborted) {
|
||||
return null;
|
||||
}
|
||||
return options.getImageSrc(imageId);
|
||||
};
|
||||
}
|
||||
|
||||
// thumbnailDirect is for plain <img src> URLs without auth headers; never use fetch here.
|
||||
// No network call happens at this layer (the <img> element loads the URL later), so
|
||||
// options.signal is not applicable here.
|
||||
if (thumbnailRendering === 'thumbnailDirect') {
|
||||
return function getThumbnailSrc() {
|
||||
return retrieve.directURL({
|
||||
instance: thumbnailContext,
|
||||
defaultPath: '/thumbnail',
|
||||
defaultType: 'image/jpeg',
|
||||
singlepart: true,
|
||||
tag: 'Absent',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const thumbnailRequestStrategy = config.thumbnailRequestStrategy || 'bulkDataRetrieve';
|
||||
if (thumbnailRequestStrategy === 'fetch') {
|
||||
return async function getThumbnailSrc(options?: { signal?: AbortSignal }) {
|
||||
return fetchThumbnailWithQidoFallbackForSeries(
|
||||
thumbnailContext,
|
||||
thumbnailRendering,
|
||||
config.wadoRoot,
|
||||
getAuthorizationHeader,
|
||||
qidoDicomWebClient,
|
||||
options?.signal
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
if (thumbnailRendering === 'thumbnail') {
|
||||
return async function getThumbnailSrc(options?: { signal?: AbortSignal }) {
|
||||
// Note: this path goes through bulkDataURI -> dicomweb-client retrieveBulkData,
|
||||
// which is XHR-based and does NOT honor AbortSignal. The underlying request will
|
||||
// run to completion server-side even if signal aborts; we only short-circuit
|
||||
// before kicking it off if already aborted.
|
||||
if (options?.signal?.aborted) {
|
||||
return null;
|
||||
}
|
||||
const endpoint = buildThumbnailEndpointPath(
|
||||
thumbnailContext,
|
||||
thumbnailRendering,
|
||||
new URLSearchParams({ accept: 'image/jpeg' })
|
||||
);
|
||||
const bulkDataURI = `${config.wadoRoot}${endpoint}`;
|
||||
return URL.createObjectURL(
|
||||
new Blob(
|
||||
[
|
||||
await retrieve.bulkDataURI({
|
||||
BulkDataURI: bulkDataURI.replace('wadors:', ''),
|
||||
defaultType: 'image/jpeg',
|
||||
mediaTypes: ['image/jpeg'],
|
||||
thumbnail: true,
|
||||
}),
|
||||
],
|
||||
{ type: 'image/jpeg' }
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
if (thumbnailRendering === 'rendered') {
|
||||
return async function getThumbnailSrc(options?: { signal?: AbortSignal }) {
|
||||
// Note: this path goes through bulkDataURI -> dicomweb-client retrieveBulkData,
|
||||
// which is XHR-based and does NOT honor AbortSignal. The underlying request will
|
||||
// run to completion server-side even if signal aborts; we only short-circuit
|
||||
// before kicking it off if already aborted.
|
||||
if (options?.signal?.aborted) {
|
||||
return null;
|
||||
}
|
||||
const endpoint = buildThumbnailEndpointPath(
|
||||
thumbnailContext,
|
||||
thumbnailRendering,
|
||||
new URLSearchParams({ accept: 'image/jpeg' })
|
||||
);
|
||||
const bulkDataURI = `${config.wadoRoot}${endpoint}`;
|
||||
return URL.createObjectURL(
|
||||
new Blob(
|
||||
[
|
||||
await retrieve.bulkDataURI({
|
||||
BulkDataURI: bulkDataURI.replace('wadors:', ''),
|
||||
defaultType: 'image/jpeg',
|
||||
mediaTypes: ['image/jpeg'],
|
||||
thumbnail: true,
|
||||
}),
|
||||
],
|
||||
{ type: 'image/jpeg' }
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return function getThumbnailSrc() {
|
||||
console.warn(
|
||||
`Unsupported thumbnailRendering "${thumbnailRendering}"; returning null thumbnail src.`
|
||||
);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
function buildThumbnailEndpointPath(
|
||||
thumbnailContext: ThumbnailContext,
|
||||
thumbnailRendering: string,
|
||||
queryParams?: URLSearchParams
|
||||
): string {
|
||||
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = thumbnailContext;
|
||||
|
||||
const basePath =
|
||||
SeriesInstanceUID && SOPInstanceUID
|
||||
? `/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/${thumbnailRendering}`
|
||||
: SeriesInstanceUID
|
||||
? `/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/${thumbnailRendering}`
|
||||
: `/studies/${StudyInstanceUID}/${thumbnailRendering}`;
|
||||
|
||||
if (!queryParams) {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
return queryString ? `${basePath}?${queryString}` : basePath;
|
||||
}
|
||||
|
||||
function getThumbnailFetchRequest(
|
||||
thumbnailContext: ThumbnailContext,
|
||||
thumbnailRendering: string,
|
||||
wadoRoot: string | undefined,
|
||||
getAuthorizationHeader: () => HeadersInterface
|
||||
): ThumbnailFetchRequestResult {
|
||||
const endpointPath = buildThumbnailEndpointPath(
|
||||
thumbnailContext,
|
||||
thumbnailRendering,
|
||||
// Thumbnails for some data source (e.g. dcm4chee) are pixelated by default, so we need to set the viewport to 256,256 to get a better thumbnail.
|
||||
new URLSearchParams({ viewport: '256,256' })
|
||||
);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(getAuthorizationHeader() as Record<string, string>),
|
||||
Accept: 'image/jpeg',
|
||||
};
|
||||
|
||||
return {
|
||||
url: `${wadoRoot}${endpointPath}`,
|
||||
endpointPath,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchThumbnailObjectURL(
|
||||
thumbnailContext: ThumbnailContext,
|
||||
thumbnailRendering: string,
|
||||
wadoRoot: string | undefined,
|
||||
getAuthorizationHeader: () => HeadersInterface,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
const fetchRequest = getThumbnailFetchRequest(
|
||||
thumbnailContext,
|
||||
thumbnailRendering,
|
||||
wadoRoot,
|
||||
getAuthorizationHeader
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(fetchRequest.url, {
|
||||
method: 'GET',
|
||||
headers: fetchRequest.headers,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`thumbnail fetch failed with status ${response.status} for ${fetchRequest.endpointPath}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
if ((error as { name?: string })?.name === 'AbortError') {
|
||||
return null;
|
||||
}
|
||||
console.warn('thumbnail fetch failed', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When thumbnailRequestStrategy is fetch: try WADO GET for the given context; if it fails and the
|
||||
* context is series-level (no SOPInstanceUID), QIDO one instance and retry fetch once.
|
||||
*/
|
||||
async function fetchThumbnailWithQidoFallbackForSeries(
|
||||
thumbnailContext: ThumbnailContext,
|
||||
thumbnailRendering: string,
|
||||
wadoRoot: string | undefined,
|
||||
getAuthorizationHeader: () => HeadersInterface,
|
||||
qidoClient: QidoClient,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
const sopInstanceUidTag = '00080018';
|
||||
|
||||
const initialThumbnailUrl = await fetchThumbnailObjectURL(
|
||||
thumbnailContext,
|
||||
thumbnailRendering,
|
||||
wadoRoot,
|
||||
getAuthorizationHeader,
|
||||
signal
|
||||
);
|
||||
if (initialThumbnailUrl) {
|
||||
return initialThumbnailUrl;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return null;
|
||||
}
|
||||
if (thumbnailContext.SOPInstanceUID) {
|
||||
return null;
|
||||
}
|
||||
if (!thumbnailContext.StudyInstanceUID || !thumbnailContext.SeriesInstanceUID) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
qidoClient.headers = getAuthorizationHeader();
|
||||
// Note: qidoClient.searchForInstances is XHR-based (dicomweb-client) and does not honor
|
||||
// AbortSignal. If signal aborts mid-request the network call still completes; we just
|
||||
// short-circuit before issuing a follow-up fetch below.
|
||||
const instances = await qidoClient.searchForInstances({
|
||||
studyInstanceUID: thumbnailContext.StudyInstanceUID,
|
||||
seriesInstanceUID: thumbnailContext.SeriesInstanceUID,
|
||||
queryParams: {
|
||||
limit: 1,
|
||||
includefield: sopInstanceUidTag,
|
||||
},
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
return null;
|
||||
}
|
||||
const firstInstance = instances?.[0] as Record<string, unknown> | undefined;
|
||||
const sopAttr = firstInstance?.[sopInstanceUidTag] as { Value?: string[] } | undefined;
|
||||
const sopValues = sopAttr?.Value;
|
||||
const SOPInstanceUID =
|
||||
Array.isArray(sopValues) && sopValues.length ? String(sopValues[0]) : undefined;
|
||||
if (!SOPInstanceUID) {
|
||||
return null;
|
||||
}
|
||||
return fetchThumbnailObjectURL(
|
||||
{ ...thumbnailContext, SOPInstanceUID },
|
||||
thumbnailRendering,
|
||||
wadoRoot,
|
||||
getAuthorizationHeader,
|
||||
signal
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('thumbnail fetch QIDO fallback failed', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ function createDicomWebProxyApi(dicomWebProxyConfig, servicesManager: AppTypes.S
|
||||
},
|
||||
},
|
||||
retrieve: {
|
||||
getGetThumbnailSrc: (...args) => dicomWebDelegate.retrieve.getGetThumbnailSrc(...args),
|
||||
directURL: (...args) => dicomWebDelegate.retrieve.directURL(...args),
|
||||
series: {
|
||||
metadata: async (...args) => dicomWebDelegate.retrieve.series.metadata(...args),
|
||||
|
||||
@@ -219,6 +219,13 @@ function createMergeDataSourceApi(
|
||||
},
|
||||
},
|
||||
retrieve: {
|
||||
getGetThumbnailSrc: (...args: unknown[]) =>
|
||||
callForDefaultDataSource({
|
||||
path: 'retrieve.getGetThumbnailSrc',
|
||||
args,
|
||||
defaultDataSourceName,
|
||||
extensionManager,
|
||||
}),
|
||||
bulkDataURI: (...args: unknown[]) =>
|
||||
callForAllDataSourcesAsync({
|
||||
mergeMap,
|
||||
|
||||
@@ -8,11 +8,10 @@ import { defaultActionIcons } from './constants';
|
||||
import MoreDropdownMenu from '../../Components/MoreDropdownMenu';
|
||||
import { CallbackCustomization } from 'platform/core/src/types';
|
||||
import { type TabsProps } from '@ohif/core/src/utils/createStudyBrowserTabs';
|
||||
import { thumbnailNoImageModalities } from '@ohif/core/src/utils/thumbnailNoImageModalities';
|
||||
|
||||
const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils;
|
||||
|
||||
const thumbnailNoImageModalities = ['SR', 'SEG', 'RTSTRUCT', 'RTPLAN', 'RTDOSE', 'DOC', 'PMAP'];
|
||||
|
||||
/**
|
||||
* Study Browser component that displays and manages studies and their display sets
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { StudyList } from '@ohif/ui-next';
|
||||
|
||||
/**
|
||||
* Default customization values for the WorkList study-list route.
|
||||
*
|
||||
* - `workList.variant`: `'default' | 'legacy'` (default: `'default'`)
|
||||
* Selects which study-list route is mounted at `/`.
|
||||
* - `'default'`: the new ui-next WorkList.
|
||||
* - `'legacy'`: the pre-3.13 WorkList (now `LegacyWorkList`). Useful as an
|
||||
* opt-out while integrators migrate to the new study list.
|
||||
*
|
||||
* - `workList.previewSeriesView`: `'all' | 'thumbnails' | 'list'` (default: `'all'`)
|
||||
* Controls which series views are available in the preview panel.
|
||||
* - `'all'`: thumbnails/list toggle is visible; defaults to thumbnails.
|
||||
* - `'thumbnails'`: toggle hidden; locked to thumbnails view.
|
||||
* - `'list'`: toggle hidden; locked to list view.
|
||||
* Note: the preview is forced to `'list'` when the active data source either:
|
||||
* - declares `thumbnailRendering` as `'wadors'` or `'thumbnailDirect'`, or
|
||||
* - declares `thumbnailRequestStrategy` as `'bulkDataRetrieve'` (default value).
|
||||
* Currently only applies when `workList.variant` is `'default'`.
|
||||
*
|
||||
* - `workList.columns`: `ColumnDef[]` (default: `StudyList.defaultColumns`)
|
||||
* The column set for the WorkList table, as a value (not a function). Because
|
||||
* it is a plain array, override it with immutability-helper commands:
|
||||
* - reorder / insert / remove: `$splice`
|
||||
* - relabel / resize / reprioritize (all plain data in `meta`): `$set` / `$merge`
|
||||
* - replace a renderer: `$set` a new `cell` / `header` function
|
||||
* - `$apply: (cols) => ColumnDef[]`: receive the current columns and return the
|
||||
* new array. Use it for anything the other commands don't express cleanly —
|
||||
* moves, conditional inserts, or any edit keyed off a column's `id` rather
|
||||
* than its position (e.g. `cols.find(c => c.id === 'modalities')`).
|
||||
* Use `StudyList.textColumn(id, label, meta?)` to build a simple display-only
|
||||
* column without writing the accessor/header/cell wiring.
|
||||
*
|
||||
* Gotchas / limitations:
|
||||
* - A `ColumnDef`'s `accessorFn` / `cell` / `header` / `filterFn` / `sortingFn`
|
||||
* are functions: `$set`/`$push` accept them, but they are not serializable,
|
||||
* so columns that render anything beyond plain text still need code.
|
||||
* - The trailing `actions` column should stay last for correct layout (its
|
||||
* hover menu is right-aligned to sit at the row end) — this is cosmetic,
|
||||
* not a hard requirement. Insert new columns *before* it with `$splice`
|
||||
* (a `$push` lands after it, leaving the actions menu mid-row).
|
||||
* - Index-based commands (e.g. `{ 2: { meta: { label: { $set: '…' } } } }`)
|
||||
* are position-fragile; prefer `$apply` for id-based edits.
|
||||
* If the merged value is not an array, WorkList falls back to the defaults.
|
||||
* Currently only applies when `workList.variant` is `'default'`.
|
||||
*
|
||||
* - `workList.renderPreviewContent`: `(React, props) => ReactNode` (default: undefined)
|
||||
* Render function for the preview panel content. Receives the host React and
|
||||
* `{ study, series, seriesView, onThumbnailImageError }`:
|
||||
* - `study`: the selected `StudyRow` (`null` when nothing is selected).
|
||||
* - `series`: the study's series; each item has the raw data-source fields
|
||||
* (`seriesInstanceUid`, `modality`, `description`, `seriesDate`,
|
||||
* `seriesNumber`, `numSeriesInstances`, etc.) plus `thumbnailStatus` added
|
||||
* by the shell, which is one of `{ status: 'loading' }`,
|
||||
* `{ status: 'ready', src }`, `{ status: 'notAvailable' }`, or
|
||||
* `{ status: 'notApplicable' }`. The `src` in the `'ready'` form is the
|
||||
* URL to render in an `<img>`.
|
||||
* - `seriesView`: `'all' | 'thumbnails' | 'list'`, resolved from
|
||||
* `workList.previewSeriesView` with `'list'` forced for data sources that
|
||||
* can't produce thumbnails. Honor it if your layout has both views.
|
||||
* - `onThumbnailImageError(seriesUID)`: call when an `<img>` you render fails
|
||||
* to load. The shell marks that series as `notAvailable` and revokes its
|
||||
* blob URL if needed.
|
||||
* Use this to change the preview layout while keeping the fetch/abort/thumbnail
|
||||
* logic intact. When unset (or not a function), the built-in
|
||||
* `<StudyList.PreviewContainer>` layout is used.
|
||||
* Currently only applies when `workList.variant` is `'default'`.
|
||||
*
|
||||
* - `workList.settingsMenuItems`: `(defaults) => SettingsMenuItem[]` (default: identity)
|
||||
* Builds the items in the WorkList settings popover. Receives the default
|
||||
* items (`about`, `userPreferences`, and `logout` when OIDC is configured)
|
||||
* and must return a
|
||||
* `SettingsMenuItem[]`. Each item is `{ id, label, onClick }`. Use this to
|
||||
* reorder, remove, or insert items without rebuilding the popover shell. If
|
||||
* the returned value is not an array, WorkList falls back to the defaults.
|
||||
* Currently only applies when `workList.variant` is `'default'`.
|
||||
*/
|
||||
export default function getWorkListCustomization() {
|
||||
return {
|
||||
'workList.variant': 'default',
|
||||
'workList.previewSeriesView': 'all',
|
||||
'workList.columns': StudyList.defaultColumns,
|
||||
'workList.renderPreviewContent': undefined,
|
||||
'workList.settingsMenuItems': (defaults: unknown) => defaults,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import reportDialogCustomization from './customizations/reportDialogCustomizatio
|
||||
import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization';
|
||||
import onboardingCustomization from './customizations/onboardingCustomization';
|
||||
import instanceSortingCriteriaCustomization from './customizations/instanceSortingCriteriaCustomization';
|
||||
import getWorkListCustomization from './customizations/workListCustomization';
|
||||
/**
|
||||
*
|
||||
* Note: this is an example of how the customization module can be used
|
||||
@@ -71,6 +72,7 @@ export default function getCustomizationModule({ servicesManager, extensionManag
|
||||
...hotkeyBindingsCustomization,
|
||||
...onboardingCustomization,
|
||||
...instanceSortingCriteriaCustomization,
|
||||
...getWorkListCustomization(),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+1
-2
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSystem } from '@ohif/core';
|
||||
import { thumbnailNoImageModalities } from '@ohif/core/src/utils/thumbnailNoImageModalities';
|
||||
import PanelStudyBrowser from '@ohif/extension-default/src/Panels/StudyBrowser/PanelStudyBrowser';
|
||||
import { UntrackSeriesModal } from './untrackSeriesModal';
|
||||
import { useTrackedMeasurements } from '../../getContextModule';
|
||||
|
||||
const thumbnailNoImageModalities = ['SR', 'SEG', 'RTSTRUCT', 'RTPLAN', 'RTDOSE', 'PMAP'];
|
||||
|
||||
/**
|
||||
* Panel component for the Study Browser with tracking capabilities
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user