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:
Joe Boccanfuso 2026-05-28 12:26:30 -04:00 committed by GitHub
parent 9e7a3586ce
commit daae4c144e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
121 changed files with 8071 additions and 1468 deletions

107
bun.lock
View File

@ -50,7 +50,7 @@
},
"addOns/externals/devDependencies": {
"name": "@externals/devDependencies",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@kitware/vtk.js": "34.15.1",
@ -131,14 +131,14 @@
},
"addOns/externals/dicom-microscopy-viewer": {
"name": "@externals/dicom-microscopy-viewer",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"dicom-microscopy-viewer": "0.48.17",
},
},
"extensions/cornerstone": {
"name": "@ohif/extension-cornerstone",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "4.22.10",
@ -165,8 +165,8 @@
"@cornerstonejs/codec-openjpeg": "1.3.0",
"@cornerstonejs/codec-openjph": "2.4.7",
"@cornerstonejs/dicom-image-loader": "4.22.10",
"@ohif/core": "3.13.0-beta.83",
"@ohif/ui": "3.13.0-beta.83",
"@ohif/core": "3.13.0-beta.84",
"@ohif/ui": "3.13.0-beta.84",
"dcmjs": "0.49.4",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
@ -178,7 +178,7 @@
},
"extensions/cornerstone-dicom-pmap": {
"name": "@ohif/extension-cornerstone-dicom-pmap",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "4.22.10",
@ -200,7 +200,7 @@
},
"extensions/cornerstone-dicom-rt": {
"name": "@ohif/extension-cornerstone-dicom-rt",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
},
@ -219,7 +219,7 @@
},
"extensions/cornerstone-dicom-seg": {
"name": "@ohif/extension-cornerstone-dicom-seg",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "4.22.10",
@ -241,7 +241,7 @@
},
"extensions/cornerstone-dicom-sr": {
"name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "4.22.10",
@ -250,10 +250,10 @@
"classnames": "2.5.1",
},
"peerDependencies": {
"@ohif/core": "3.13.0-beta.83",
"@ohif/extension-cornerstone": "3.13.0-beta.83",
"@ohif/extension-measurement-tracking": "3.13.0-beta.83",
"@ohif/ui": "3.13.0-beta.83",
"@ohif/core": "3.13.0-beta.84",
"@ohif/extension-cornerstone": "3.13.0-beta.84",
"@ohif/extension-measurement-tracking": "3.13.0-beta.84",
"@ohif/ui": "3.13.0-beta.84",
"dcmjs": "0.49.4",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
@ -263,7 +263,7 @@
},
"extensions/cornerstone-dynamic-volume": {
"name": "@ohif/extension-cornerstone-dynamic-volume",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/core": "4.22.10",
@ -285,7 +285,7 @@
},
"extensions/default": {
"name": "@ohif/extension-default",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/calculate-suv": "1.1.0",
@ -294,8 +294,8 @@
"react-color": "2.19.3",
},
"peerDependencies": {
"@ohif/core": "3.13.0-beta.83",
"@ohif/i18n": "3.13.0-beta.83",
"@ohif/core": "3.13.0-beta.84",
"@ohif/i18n": "3.13.0-beta.84",
"dcmjs": "0.49.4",
"dicomweb-client": "0.10.4",
"prop-types": "15.8.1",
@ -309,7 +309,7 @@
},
"extensions/dicom-microscopy": {
"name": "@ohif/extension-dicom-microscopy",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/codec-charls": "1.2.3",
@ -334,7 +334,7 @@
},
"extensions/dicom-pdf": {
"name": "@ohif/extension-dicom-pdf",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"classnames": "2.5.1",
@ -351,7 +351,7 @@
},
"extensions/dicom-video": {
"name": "@ohif/extension-dicom-video",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"classnames": "2.5.1",
@ -368,20 +368,20 @@
},
"extensions/measurement-tracking": {
"name": "@ohif/extension-measurement-tracking",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@ohif/ui": "3.13.0-beta.83",
"@ohif/ui": "3.13.0-beta.84",
"@xstate/react": "3.2.2",
"xstate": "4.38.3",
},
"peerDependencies": {
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/tools": "4.22.10",
"@ohif/core": "3.13.0-beta.83",
"@ohif/extension-cornerstone-dicom-sr": "3.13.0-beta.83",
"@ohif/extension-default": "3.13.0-beta.83",
"@ohif/ui": "3.13.0-beta.83",
"@ohif/core": "3.13.0-beta.84",
"@ohif/extension-cornerstone-dicom-sr": "3.13.0-beta.84",
"@ohif/extension-default": "3.13.0-beta.84",
"@ohif/ui": "3.13.0-beta.84",
"classnames": "2.5.1",
"dcmjs": "0.49.4",
"lodash.debounce": "4.0.8",
@ -394,7 +394,7 @@
},
"extensions/test-extension": {
"name": "@ohif/extension-test",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"classnames": "2.5.1",
@ -411,7 +411,7 @@
},
"extensions/tmtv": {
"name": "@ohif/extension-tmtv",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"classnames": "2.5.1",
@ -428,7 +428,7 @@
},
"extensions/usAnnotation": {
"name": "@ohif/extension-ultrasound-pleura-bline",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/core": "4.22.10",
@ -476,7 +476,7 @@
},
"modes/basic": {
"name": "@ohif/mode-basic",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
},
@ -497,7 +497,7 @@
},
"modes/basic-dev-mode": {
"name": "@ohif/mode-basic-dev-mode",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"i18next": "17.3.1",
@ -517,7 +517,7 @@
},
"modes/basic-test-mode": {
"name": "@ohif/mode-test",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"i18next": "17.3.1",
@ -539,7 +539,7 @@
},
"modes/longitudinal": {
"name": "@ohif/mode-longitudinal",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"i18next": "17.3.1",
@ -563,7 +563,7 @@
},
"modes/microscopy": {
"name": "@ohif/mode-microscopy",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"i18next": "17.3.1",
@ -575,7 +575,7 @@
},
"modes/preclinical-4d": {
"name": "@ohif/mode-preclinical-4d",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
},
@ -594,7 +594,7 @@
},
"modes/segmentation": {
"name": "@ohif/mode-segmentation",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"i18next": "17.3.1",
@ -636,7 +636,7 @@
},
"modes/tmtv": {
"name": "@ohif/mode-tmtv",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"i18next": "17.3.1",
@ -657,7 +657,7 @@
},
"modes/usAnnotation": {
"name": "@ohif/mode-ultrasound-pleura-bline",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/core": "4.22.10",
@ -693,7 +693,7 @@
},
"platform/app": {
"name": "@ohif/app",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/codec-charls": "1.2.3",
@ -772,7 +772,7 @@
},
"platform/cli": {
"name": "@ohif/cli",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"bin": {
"ohif-cli": "src/index.js",
},
@ -796,7 +796,7 @@
},
"platform/core": {
"name": "@ohif/core",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"dcmjs": "0.49.4",
@ -823,14 +823,14 @@
"@cornerstonejs/codec-openjph": "2.4.7",
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/dicom-image-loader": "4.22.10",
"@ohif/ui": "3.13.0-beta.83",
"@ohif/ui": "3.13.0-beta.84",
"cornerstone-math": "0.1.10",
"dicom-parser": "1.8.21",
},
},
"platform/i18n": {
"name": "@ohif/i18n",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@babel/runtime": "7.28.2",
"i18next-locize-backend": "2.2.2",
@ -855,7 +855,7 @@
},
"platform/ui": {
"name": "@ohif/ui",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@testing-library/react": "13.4.0",
"browser-detect": "0.2.28",
@ -905,7 +905,7 @@
},
"platform/ui-next": {
"name": "@ohif/ui-next",
"version": "3.13.0-beta.83",
"version": "3.13.0-beta.84",
"dependencies": {
"@radix-ui/react-accordion": "1.2.11",
"@radix-ui/react-checkbox": "1.3.2",
@ -926,15 +926,16 @@
"@radix-ui/react-toggle": "1.1.9",
"@radix-ui/react-toggle-group": "1.1.10",
"@radix-ui/react-tooltip": "1.2.7",
"@tanstack/react-table": "8.21.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"date-fns": "3.6.0",
"date-fns": "4.1.0",
"framer-motion": "6.2.4",
"lucide-react": "0.379.0",
"next-themes": "0.3.0",
"react": "18.3.1",
"react-day-picker": "8.10.1",
"react-day-picker": "9.12.0",
"react-resizable-panels": "2.1.9",
"react-shepherd": "6.1.1",
"shepherd.js": "13.0.3",
@ -1306,6 +1307,8 @@
"@cypress/xvfb": ["@cypress/xvfb@1.2.4", "", { "dependencies": { "debug": "^3.1.0", "lodash.once": "^4.1.1" } }, "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q=="],
"@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="],
"@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
@ -1944,6 +1947,10 @@
"@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@testing-library/dom": ["@testing-library/dom@8.20.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.1.3", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g=="],
"@testing-library/react": ["@testing-library/react@13.4.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.5.0", "@types/react-dom": "^18.0.0" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw=="],
@ -2756,7 +2763,9 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"dateformat": ["dateformat@3.0.3", "", {}, "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q=="],
@ -4484,7 +4493,7 @@
"react-dates": ["react-dates@21.8.0", "", { "dependencies": { "airbnb-prop-types": "^2.15.0", "consolidated-events": "^1.1.1 || ^2.0.0", "enzyme-shallow-equal": "^1.0.0", "is-touch-device": "^1.0.1", "lodash": "^4.1.1", "object.assign": "^4.1.0", "object.values": "^1.1.0", "prop-types": "^15.7.2", "raf": "^3.4.1", "react-moment-proptypes": "^1.6.0", "react-outside-click-handler": "^1.2.4", "react-portal": "^4.2.0", "react-with-direction": "^1.3.1", "react-with-styles": "^4.1.0", "react-with-styles-interface-css": "^6.0.0" }, "peerDependencies": { "@babel/runtime": "^7.0.0", "moment": "^2.18.1", "react": "^0.14 || ^15.5.4 || ^16.1.1", "react-dom": "^0.14 || ^15.5.4 || ^16.1.1" } }, "sha512-PPriGqi30CtzZmoHiGdhlA++YPYPYGCZrhydYmXXQ6RAvAsaONcPtYgXRTLozIOrsQ5mSo40+DiA5eOFHnZ6xw=="],
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
"react-day-picker": ["react-day-picker@9.12.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-t8OvG/Zrciso5CQJu5b1A7yzEmebvST+S3pOVQJWxwjjVngyG/CA2htN/D15dLI4uTEuLLkbZyS4YYt480FAtA=="],
"react-dnd": ["react-dnd@14.0.2", "", { "dependencies": { "@react-dnd/invariant": "^2.0.0", "@react-dnd/shallowequal": "^2.0.0", "dnd-core": "14.0.0", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-JoEL78sBCg8SzjOKMlkR70GWaPORudhWuTNqJ56lb2P8Vq0eM2+er3ZrMGiSDhOmzaRPuA9SNBz46nHCrjn11A=="],

View File

@ -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;

View File

@ -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(
{

View File

@ -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(',');

View File

@ -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;
}
}

View File

@ -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),

View File

@ -219,6 +219,13 @@ function createMergeDataSourceApi(
},
},
retrieve: {
getGetThumbnailSrc: (...args: unknown[]) =>
callForDefaultDataSource({
path: 'retrieve.getGetThumbnailSrc',
args,
defaultDataSourceName,
extensionManager,
}),
bulkDataURI: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,

View File

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

View File

@ -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,
};
}

View File

@ -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(),
},
},
];

View File

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

View File

@ -44,7 +44,9 @@ const setHeaders = (res, path) => {
} else if (path.indexOf('.br') !== -1) {
res.setHeader('Content-Encoding', 'br');
}
if (path.indexOf('.pdf') !== -1) {
if (path.indexOf('thumbnail') !== -1) {
res.setHeader('Content-Type', 'image/jpeg');
} else if (path.indexOf('.pdf') !== -1) {
res.setHeader('Content-Type', 'application/pdf');
} else if (path.indexOf('mp4') !== -1) {
res.setHeader('Content-Type', 'video/mp4');

View File

@ -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());
});
});
});
});
});
*/
});
});

View File

@ -20,7 +20,7 @@ window.config = {
allowMultiSelectExport: false,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
thumbnail: 5,
// Prefetch number is dependent on the http protocol. For http 2 or
// above, the number of requests can be go a lot higher.
prefetch: 25,
@ -111,10 +111,11 @@ window.config = {
wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
supportsWildcard: false,
supportsWildcard: true,
staticWado: true,
singlepart: 'bulkdata,video',
// whether the data source should use retrieveBulkData to grab metadata,

View File

@ -19,7 +19,7 @@ window.config = {
useNorm16Texture: true,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
thumbnail: 5,
// Prefetch number is dependent on the http protocol. For http 2 or
// above, the number of requests can be go a lot higher.
prefetch: 25,

View File

@ -13,7 +13,7 @@ window.config = {
groupEnabledModesFirst: true,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
thumbnail: 5,
prefetch: 25,
},
defaultDataSourceName: 'dicomweb',

View File

@ -113,7 +113,8 @@ window.config = {
wadoRoot: '/viewer-testdata',
qidoSupportsIncludeField: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,

View File

@ -45,7 +45,8 @@ window.config = {
'https://healthcare.googleapis.com/v1/projects/ohif-cloud-healthcare/locations/us-east4/datasets/ohif-qa-dataset/dicomStores/ohif-qa-2/dicomWeb',
qidoSupportsIncludeField: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'rendered',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
supportsWildcard: false,

View File

@ -18,7 +18,7 @@ window.config = {
groupEnabledModesFirst: true,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
thumbnail: 5,
// Prefetch number is dependent on the http protocol. For http 2 or
// above, the number of requests can be go a lot higher.
prefetch: 25,

View File

@ -23,7 +23,9 @@ window.config = {
qidoSupportsIncludeField: true,
imageRendering: 'wadors',
enableStudyLazyLoad: true,
thumbnailRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
supportsWildcard: true,
requestOptions: {
auth: 'admin:admin',
},

View File

@ -25,7 +25,8 @@ window.config = {
supportsReject: true,
dicomUploadEnabled: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'rendered',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
supportsWildcard: true,

View File

@ -26,7 +26,8 @@ window.config = {
wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,

View File

@ -1,4 +1,15 @@
import useDebounce from './useDebounce';
import useSearchParams from './useSearchParams';
import { useStudyListStateSync } from './useStudyListStateSync';
import { useSeriesFetch } from './useSeriesFetch';
import { useWorkListToolbarActions } from './useWorkListToolbarActions';
import { useStudyListQuery } from './useStudyListQuery';
export { useDebounce, useSearchParams };
export {
useDebounce,
useSearchParams,
useStudyListStateSync,
useSeriesFetch,
useWorkListToolbarActions,
useStudyListQuery,
};

View File

@ -1,4 +1,15 @@
import useDebounce from './useDebounce';
import useSearchParams from './useSearchParams';
import { useStudyListStateSync } from './useStudyListStateSync';
import { useSeriesFetch } from './useSeriesFetch';
import { useWorkListToolbarActions } from './useWorkListToolbarActions';
import { useStudyListQuery } from './useStudyListQuery';
export { useDebounce, useSearchParams };
export {
useDebounce,
useSearchParams,
useStudyListStateSync,
useSeriesFetch,
useWorkListToolbarActions,
useStudyListQuery,
};

View File

@ -0,0 +1,204 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAppConfig } from '@state';
import { utils } from '@ohif/core';
import { thumbnailNoImageModalities } from '@ohif/core/src/utils/thumbnailNoImageModalities';
import {
PreviewThumbnailStatusState,
type PreviewThumbnailStatus,
type StudyRow,
} from '@ohif/ui-next';
// A series row carries arbitrary DICOM fields plus the thumbnail status this
// panel tracks. Only the latter is typed; the rest stays open.
type PreviewSeries = Record<string, any> & { thumbnailStatus: PreviewThumbnailStatus };
// Series rows may carry the UID under either casing depending on the data
// source; read it through one place so callers can't forget a variant.
function getSeriesUID(row: Record<string, any>): string | undefined {
return row.seriesInstanceUid || row.SeriesInstanceUID;
}
/**
* Runs `worker` over `items` with at most `maxParallel` in flight at once,
* stopping early if `signal` aborts. A shared cursor hands each worker the
* next item, so a slow fetch doesn't hold up the rest.
*/
async function runThumbnailPool<T>(
items: T[],
maxParallel: number,
signal: AbortSignal,
worker: (item: T) => Promise<void>
): Promise<void> {
let nextIndex = 0;
const runWorker = async () => {
while (!signal.aborted) {
const idx = nextIndex++;
if (idx >= items.length) {
return;
}
await worker(items[idx]);
}
};
await Promise.all(Array.from({ length: Math.min(maxParallel, items.length) }, runWorker));
}
/**
* Fetches the series for the selected study and, where applicable, their
* thumbnails, exposing the resulting list and an image-error handler.
*
* Owns the blob-URL lifecycle for thumbnails produced by the `fetch` strategy:
* every created `blob:` URL is tracked and revoked on study change / unmount,
* and `onThumbnailImageError` revokes a single failed thumbnail's URL.
*/
export function useSeriesFetch({
dataSource,
selected,
}: {
dataSource: any;
selected: StudyRow | null;
}): {
series: PreviewSeries[];
onThumbnailImageError: (seriesUID: string) => void;
} {
const [series, setSeries] = useState<PreviewSeries[]>([]);
// Blob URLs created by this panel (via the `fetch` thumbnail strategy).
// Tracked so we can URL.revokeObjectURL them on study change / unmount —
// otherwise every fetched series leaks one blob worth of memory.
const ownedBlobUrlsRef = useRef<string[]>([]);
const [appConfig] = useAppConfig();
const { sortBySeriesDate } = utils as any;
useEffect(() => {
// Drives cancellation when the selection changes or the panel unmounts: stops the
// worker pool from scheduling new fetches and aborts in-flight requests that honor
// AbortSignal (the `fetch` thumbnail strategy; the bulkDataURI XHR path cannot abort).
const abortController = new AbortController();
const { signal } = abortController;
const run = async () => {
const studyInstanceUID = (selected as any)?.studyInstanceUid;
if (!studyInstanceUID) {
setSeries([]);
return;
}
try {
const seriesList = await dataSource.query.series.search(studyInstanceUID);
if (signal.aborted) {
return;
}
const sortedSeriesList = sortBySeriesDate?.(seriesList) ?? [];
const normalizedSeriesList = sortedSeriesList.map(row => {
const modality = String(row.modality || row.Modality || '').toUpperCase();
const thumbnailStatus: PreviewThumbnailStatus = thumbnailNoImageModalities.includes(
modality
)
? { status: PreviewThumbnailStatusState.NotApplicable }
: { status: PreviewThumbnailStatusState.Loading };
return {
...row,
thumbnailStatus,
};
});
setSeries(normalizedSeriesList);
const fetchTargets = normalizedSeriesList.filter((row: PreviewSeries) => {
if (!getSeriesUID(row)) {
return false;
}
return row.thumbnailStatus?.status !== PreviewThumbnailStatusState.NotApplicable;
});
// Bound parallel thumbnail fetches so studies with many series don't
// saturate the connection and stall later viewer navigation. Mirrors
// CS3D's imageLoadPoolManager.maxNumRequests.thumbnail.
const maxParallelRequests = Math.max(1, appConfig?.maxNumRequests?.thumbnail ?? 5);
const fetchThumbnail = async (row: (typeof fetchTargets)[number]) => {
const seriesUID = getSeriesUID(row);
let src: string | null = null;
try {
const getThumbnailSrc = dataSource?.retrieve?.getGetThumbnailSrc?.(
{ StudyInstanceUID: studyInstanceUID, SeriesInstanceUID: seriesUID },
undefined
);
src = (await getThumbnailSrc?.({ signal })) ?? null;
} catch {
src = null;
}
// Track ownership of blob URLs before the abort check so URLs that
// arrive just after abort are still revoked on cleanup.
if (src?.startsWith('blob:')) {
ownedBlobUrlsRef.current.push(src);
}
if (signal.aborted) {
return;
}
setSeries(prev =>
prev.map(seriesItem => {
if (getSeriesUID(seriesItem) !== seriesUID) {
return seriesItem;
}
return {
...seriesItem,
thumbnailStatus: src
? { status: PreviewThumbnailStatusState.Ready, src }
: { status: PreviewThumbnailStatusState.NotAvailable },
};
})
);
};
await runThumbnailPool(fetchTargets, maxParallelRequests, signal, fetchThumbnail);
} catch (e) {
if (!signal.aborted) {
console.warn('Failed to load preview series/thumbnails for selected study.', e);
setSeries([]);
}
}
};
void run();
return () => {
abortController.abort();
// Revoke blob URLs this run created. Safe even though the old series
// may still be in the DOM briefly: revokeObjectURL only invalidates
// future loads, the already-rendered <img> keeps its pixels.
const urls = ownedBlobUrlsRef.current;
ownedBlobUrlsRef.current = [];
urls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch {}
});
};
}, [dataSource, selected, appConfig?.maxNumRequests?.thumbnail]);
const onThumbnailImageError = useCallback((seriesUID: string) => {
setSeries(prevSeriesList =>
prevSeriesList.map(seriesItem => {
if (getSeriesUID(seriesItem) !== seriesUID) {
return seriesItem;
}
const thumbnailStatus = seriesItem.thumbnailStatus as PreviewThumbnailStatus | undefined;
if (
thumbnailStatus?.status === PreviewThumbnailStatusState.Ready &&
thumbnailStatus.src?.startsWith('blob:')
) {
try {
URL.revokeObjectURL(thumbnailStatus.src);
} catch {}
}
return {
...seriesItem,
thumbnailStatus: { status: PreviewThumbnailStatusState.NotAvailable },
};
})
);
}, []);
return { series, onThumbnailImageError };
}

View File

@ -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;
}

View File

@ -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 });
}

View File

@ -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>
);
}

View File

@ -1,23 +1,13 @@
/* eslint-disable react/jsx-props-no-spreading */
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Enums, ExtensionManager, MODULE_TYPES, log } from '@ohif/core';
import { ExtensionManager, MODULE_TYPES } from '@ohif/core';
//
import { extensionManager } from '../App';
import { useParams, useLocation } from 'react-router';
import { useNavigate } from 'react-router-dom';
import { useParams } from 'react-router';
import useSearchParams from '../hooks/useSearchParams';
/**
* Determines if two React Router location objects are the same.
*/
const areLocationsTheSame = (location0, location1) => {
return (
location0.pathname === location1.pathname &&
location0.search === location1.search &&
location0.hash === location1.hash
);
};
import { useAppConfig } from '@state';
import { useStudyListQuery } from '../hooks';
/**
* Uses route properties to determine the data source that should be passed
@ -29,32 +19,24 @@ const areLocationsTheSame = (location0, location1) => {
*/
function DataSourceWrapper(props: withAppTypes) {
const { servicesManager } = props;
const navigate = useNavigate();
const { children: LayoutTemplate, ...rest } = props;
const params = useParams();
const location = useLocation();
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
const query = useSearchParams();
const [appConfig] = useAppConfig();
// Route props --> studies.mapParams
// mapParams --> studies.search
// studies.search --> studies.processResults
// studies.processResults --> <LayoutTemplate studies={} />
// But only for LayoutTemplate type of 'list'?
// Or no data fetching here, and just hand down my source
const STUDIES_LIMIT = 101;
const DEFAULT_DATA = {
studies: [],
total: 0,
resultsPerPage: 25,
pageNumber: 1,
location: 'Not a valid location, causes first load to occur',
};
const getInitialDataSourceName = useCallback(() => {
// TODO - get the variable from the props all the time...
let dataSourceName = lowerCaseSearchParams.get('datasources');
if (!dataSourceName && window.config.defaultDataSourceName) {
if (!dataSourceName && appConfig.defaultDataSourceName) {
return '';
}
@ -103,8 +85,11 @@ function DataSourceWrapper(props: withAppTypes) {
return dataSource;
});
const [data, setData] = useState(DEFAULT_DATA);
const [isLoading, setIsLoading] = useState(false);
const { studies, isLoading, hasFetchedOnce, refresh } = useStudyListQuery({
dataSource,
isDataSourceInitialized,
servicesManager,
});
/**
* The effect to initialize the data source whenever it changes. Similar to
@ -125,12 +110,12 @@ function DataSourceWrapper(props: withAppTypes) {
useEffect(() => {
const dataSourceChangedCallback = () => {
setIsLoading(false);
setIsDataSourceInitialized(false);
setDataSourcePath('');
setDataSource(extensionManager.getActiveDataSource()[0]);
// Setting data to DEFAULT_DATA triggers a new query just like it does for the initial load.
setData(DEFAULT_DATA);
// Resets the cached data, the loading flag, and the first-fetch gate,
// then triggers a new query just like the initial load.
refresh();
};
const sub = extensionManager.subscribe(
@ -140,97 +125,17 @@ function DataSourceWrapper(props: withAppTypes) {
return () => sub.unsubscribe();
}, []);
useEffect(() => {
if (!isDataSourceInitialized) {
return;
}
const queryFilterValues = _getQueryFilterValues(location.search, STUDIES_LIMIT);
// 204: no content
async function getData() {
setIsLoading(true);
log.time(Enums.TimingEnum.SEARCH_TO_LIST);
const studies = await dataSource.query.studies.search(queryFilterValues);
setData({
studies: studies || [],
total: studies.length,
resultsPerPage: queryFilterValues.resultsPerPage,
pageNumber: queryFilterValues.pageNumber,
location,
});
log.timeEnd(Enums.TimingEnum.SCRIPT_TO_VIEW);
log.timeEnd(Enums.TimingEnum.SEARCH_TO_LIST);
setIsLoading(false);
}
try {
// Cache invalidation :thinking:
// - Anytime change is not just next/previous page
// - And we didn't cross a result offset range
const isSamePage = data.pageNumber === queryFilterValues.pageNumber;
const previousOffset =
Math.floor((data.pageNumber * data.resultsPerPage) / STUDIES_LIMIT) * (STUDIES_LIMIT - 1);
const newOffset =
Math.floor(
(queryFilterValues.pageNumber * queryFilterValues.resultsPerPage) / STUDIES_LIMIT
) *
(STUDIES_LIMIT - 1);
// Simply checking data.location !== location is not sufficient because even though the location href (i.e. entire URL)
// has not changed, the React Router still provides a new location reference and would result in two study queries
// on initial load. Alternatively, window.location.href could be used.
const isLocationUpdated =
typeof data.location === 'string' || !areLocationsTheSame(data.location, location);
const isDataInvalid =
!isSamePage || (!isLoading && (newOffset !== previousOffset || isLocationUpdated));
if (isDataInvalid) {
getData().catch(e => {
console.error(e);
const { configurationAPI, friendlyName } = dataSource.getConfig();
// If there is a data source configuration API, then the Worklist will popup the dialog to attempt to configure it
// and attempt to resolve this issue.
if (configurationAPI) {
return;
}
servicesManager.services.uiModalService.show({
title: 'Data Source Connection Error',
content: () => {
return (
<div className="text-foreground">
<p className="text-red-600">Error: {e.message}</p>
<p>
Please ensure the following data source is configured correctly or is running:
</p>
<div className="mt-2 font-bold">{friendlyName}</div>
</div>
);
},
});
});
}
} catch (ex) {
console.warn(ex);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, location, params, isLoading, setIsLoading, dataSource, isDataSourceInitialized]);
// queryFilterValues
// TODO: Better way to pass DataSource?
return (
<LayoutTemplate
{...rest}
data={data.studies}
data={studies}
dataTotal={studies.length}
dataPath={dataSourcePath}
dataTotal={data.total}
dataSource={dataSource}
isLoadingData={isLoading}
// To refresh the data, simply reset it to DEFAULT_DATA which invalidates it and triggers a new query to fetch the data.
onRefresh={() => setData(DEFAULT_DATA)}
hasFetchedOnce={hasFetchedOnce}
onRefresh={refresh}
/>
);
}
@ -241,64 +146,3 @@ DataSourceWrapper.propTypes = {
};
export default DataSourceWrapper;
/**
* Duplicated in `workList`
* Need generic that can be shared? Isn't this what qs is for?
* @param {*} query
*/
function _getQueryFilterValues(query, queryLimit) {
query = new URLSearchParams(query);
const newParams = new URLSearchParams();
for (const [key, value] of query) {
newParams.set(key.toLowerCase(), value);
}
query = newParams;
const pageNumber = _tryParseInt(query.get('pagenumber'), 1);
const resultsPerPage = _tryParseInt(query.get('resultsperpage'), 25);
const queryFilterValues = {
// DCM
patientId: query.get('mrn'),
patientName: query.get('patientname'),
studyDescription: query.get('description'),
modalitiesInStudy: query.get('modalities') && query.get('modalities').split(','),
accessionNumber: query.get('accession'),
//
startDate: query.get('startdate'),
endDate: query.get('enddate'),
page: _tryParseInt(query.get('page'), undefined),
pageNumber,
resultsPerPage,
// Rarely supported server-side
sortBy: query.get('sortby'),
sortDirection: query.get('sortdirection'),
// Offset...
offset: Math.floor((pageNumber * resultsPerPage) / queryLimit) * (queryLimit - 1),
config: query.get('configurl'),
};
// patientName: good
// studyDescription: good
// accessionNumber: good
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str !== null) {
if (str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
}
return retValue;
}
}

View File

@ -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;

View File

@ -0,0 +1 @@
export { default } from './LegacyWorkList';

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,699 +1,141 @@
import React, { useState, useEffect, useMemo } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Link, useNavigate } from 'react-router-dom';
import moment from 'moment';
import qs from 'query-string';
import isEqual from 'lodash.isequal';
import { useTranslation } from 'react-i18next';
//
import filtersMeta from './filtersMeta.js';
import React, { useEffect, useMemo, useState } from 'react';
import { useAppConfig } from '@state';
import { useDebounce, useSearchParams } from '../../hooks';
import { utils, Types as coreTypes } from '@ohif/core';
import { preserveQueryParameters } from '../../utils/preserveQueryParameters';
import { useStudyListStateSync, useWorkListToolbarActions } from '../../hooks';
import {
StudyListExpandedRow,
EmptyStudies,
StudyListTable,
StudyListPagination,
StudyListFilter,
Button,
ButtonEnums,
} from '@ohif/ui';
import { StudyList, Icons, InvestigationalUseDialog, type StudyRow } from '@ohif/ui-next';
import { StudyListSettingsPopover } from './StudyListSettingsPopover';
import { SidePanelPreview } from './SidePanelPreview';
import {
Header,
Icons,
Tooltip,
TooltipTrigger,
TooltipContent,
Clipboard,
useModal,
useSessionStorage,
Onboarding,
ScrollArea,
InvestigationalUseDialog,
} from '@ohif/ui-next';
type Props = withAppTypes & {
data: any[];
dataSource: any;
isLoadingData: boolean;
hasFetchedOnce?: boolean;
dataPath?: string;
onRefresh: () => void;
};
import { Types } from '@ohif/ui';
import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters';
const PatientInfoVisibility = Types.PatientInfoVisibility;
const { sortBySeriesDate } = utils;
const seriesInStudiesMap = new Map();
/**
* TODO:
* - debounce `setFilterValues` (150ms?)
*/
function WorkList({
data: studies,
dataTotal: studiesTotal,
isLoadingData,
export default function WorkList({
data,
dataSource,
hotkeysManager,
isLoadingData,
hasFetchedOnce = false,
dataPath,
onRefresh,
servicesManager,
}: withAppTypes) {
const { show, hide } = useModal();
const { t } = useTranslation();
// ~ Modes
extensionManager,
}: Props) {
const [appConfig] = useAppConfig();
// ~ Filters
const searchParams = useSearchParams();
const navigate = useNavigate();
const STUDIES_LIMIT = 101;
const queryFilterValues = _getQueryFilterValues(searchParams);
const [sessionQueryFilterValues, updateSessionQueryFilterValues] = useSessionStorage({
key: 'queryFilterValues',
defaultValue: queryFilterValues,
// ToDo: useSessionStorage currently uses an unload listener to clear the filters from session storage
// so on systems that do not support unload events a user will NOT be able to alter any existing filter
// in the URL, load the page and have it apply.
clearOnUnload: true,
});
const [filterValues, _setFilterValues] = useState({
...defaultFilterValues,
...sessionQueryFilterValues,
});
const debouncedFilterValues = useDebounce(filterValues, 200);
const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues;
/*
* The default sort value keep the filters synchronized with runtime conditional sorting
* Only applied if no other sorting is specified and there are less than 101 studies
*/
const canSort = studiesTotal < STUDIES_LIMIT;
const shouldUseDefaultSort = sortBy === '' || !sortBy;
const sortModifier = sortDirection === 'descending' ? 1 : -1;
const defaultSortValues =
shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {};
const { customizationService } = servicesManager.services;
const sortedStudies = useMemo(() => {
if (!canSort) {
return studies;
}
return [...studies].sort((s1, s2) => {
if (shouldUseDefaultSort) {
const ascendingSortModifier = -1;
return _sortStringDates(s1, s2, ascendingSortModifier);
}
const s1Prop = s1[sortBy];
const s2Prop = s2[sortBy];
if (typeof s1Prop === 'string' && typeof s2Prop === 'string') {
return s1Prop.localeCompare(s2Prop) * sortModifier;
} else if (typeof s1Prop === 'number' && typeof s2Prop === 'number') {
return (s1Prop > s2Prop ? 1 : -1) * sortModifier;
} else if (!s1Prop && s2Prop) {
return -1 * sortModifier;
} else if (!s2Prop && s1Prop) {
return 1 * sortModifier;
} else if (sortBy === 'studyDate') {
return _sortStringDates(s1, s2, sortModifier);
}
return 0;
});
}, [canSort, studies, shouldUseDefaultSort, sortBy, sortModifier]);
// ~ Rows & Studies
const [expandedRows, setExpandedRows] = useState([]);
const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]);
const numOfStudies = studiesTotal;
const querying = useMemo(() => {
return isLoadingData || expandedRows.length > 0;
}, [isLoadingData, expandedRows]);
const setFilterValues = val => {
if (filterValues.pageNumber === val.pageNumber) {
val.pageNumber = 1;
}
_setFilterValues(val);
updateSessionQueryFilterValues(val);
setExpandedRows([]);
};
const onPageNumberChange = newPageNumber => {
const oldPageNumber = filterValues.pageNumber;
const rollingPageNumberMod = Math.floor(101 / filterValues.resultsPerPage);
const rollingPageNumber = oldPageNumber % rollingPageNumberMod;
const isNextPage = newPageNumber > oldPageNumber;
const hasNextPage = Math.max(rollingPageNumber, 1) * resultsPerPage < numOfStudies;
if (isNextPage && !hasNextPage) {
return;
}
setFilterValues({ ...filterValues, pageNumber: newPageNumber });
};
const onResultsPerPageChange = newResultsPerPage => {
setFilterValues({
...filterValues,
pageNumber: 1,
resultsPerPage: Number(newResultsPerPage),
});
};
// Set body style
useEffect(() => {
document.body.classList.add('bg-black');
return () => {
document.body.classList.remove('bg-black');
};
}, []);
// Sync URL query parameters with filters
useEffect(() => {
if (!debouncedFilterValues) {
return;
}
const queryString = {};
Object.keys(defaultFilterValues).forEach(key => {
const defaultValue = defaultFilterValues[key];
const currValue = debouncedFilterValues[key];
// TODO: nesting/recursion?
if (key === 'studyDate') {
if (currValue.startDate && defaultValue.startDate !== currValue.startDate) {
queryString.startDate = currValue.startDate;
}
if (currValue.endDate && defaultValue.endDate !== currValue.endDate) {
queryString.endDate = currValue.endDate;
}
} else if (key === 'modalities' && currValue.length) {
queryString.modalities = currValue.join(',');
} else if (currValue !== defaultValue) {
queryString[key] = currValue;
}
});
preserveQueryStrings(queryString);
const search = qs.stringify(queryString, {
skipNull: true,
skipEmptyString: true,
});
navigate({
pathname: '/',
search: search ? `?${search}` : undefined,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedFilterValues]);
// Query for series information
useEffect(() => {
const fetchSeries = async studyInstanceUid => {
try {
const series = await dataSource.query.series.search(studyInstanceUid);
seriesInStudiesMap.set(studyInstanceUid, sortBySeriesDate(series));
setStudiesWithSeriesData([...studiesWithSeriesData, studyInstanceUid]);
} catch (ex) {
// TODO: UI Notification Service
console.warn(ex);
}
};
// TODO: WHY WOULD YOU USE AN INDEX OF 1?!
// Note: expanded rows index begins at 1
for (let z = 0; z < expandedRows.length; z++) {
const expandedRowIndex = expandedRows[z] - 1;
const studyInstanceUid = sortedStudies[expandedRowIndex].studyInstanceUid;
if (studiesWithSeriesData.includes(studyInstanceUid)) {
continue;
}
fetchSeries(studyInstanceUid);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandedRows, studies]);
const isFiltering = (filterValues, defaultFilterValues) => {
return !isEqual(filterValues, defaultFilterValues);
};
const rollingPageNumberMod = Math.floor(101 / resultsPerPage);
const rollingPageNumber = (pageNumber - 1) % rollingPageNumberMod;
const offset = resultsPerPage * rollingPageNumber;
const offsetAndTake = offset + resultsPerPage;
const tableDataSource = sortedStudies.map((study, key) => {
const rowKey = key + 1;
const isExpanded = expandedRows.some(k => k === rowKey);
const {
studyInstanceUid,
accession,
modalities,
instances,
description,
mrn,
patientName,
date,
time,
} = study;
const studyDate =
date &&
moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true).isValid() &&
moment(date, ['YYYYMMDD', 'YYYY.MM.DD']).format(t('Common:localDateFormat', 'MMM-DD-YYYY'));
const studyTime =
time &&
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).isValid() &&
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).format(
t('Common:localTimeFormat', 'hh:mm A')
);
const makeCopyTooltipCell = textValue => {
if (!textValue) {
return '';
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-pointer truncate">{textValue}</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex items-center justify-between gap-2">
{textValue}
<Clipboard>{textValue}</Clipboard>
</div>
</TooltipContent>
</Tooltip>
);
};
return {
dataCY: `studyRow-${studyInstanceUid}`,
clickableCY: studyInstanceUid,
row: [
{
key: 'patientName',
content: patientName ? makeCopyTooltipCell(patientName) : null,
gridCol: 4,
},
{
key: 'mrn',
content: makeCopyTooltipCell(mrn),
gridCol: 3,
},
{
key: 'studyDate',
content: (
<>
{studyDate && <span className="mr-4">{studyDate}</span>}
{studyTime && <span>{studyTime}</span>}
</>
),
title: `${studyDate || ''} ${studyTime || ''}`,
gridCol: 5,
},
{
key: 'description',
content: makeCopyTooltipCell(description),
gridCol: 4,
},
{
key: 'modality',
content: modalities,
title: modalities,
gridCol: 3,
},
{
key: 'accession',
content: makeCopyTooltipCell(accession),
gridCol: 3,
},
{
key: 'instances',
content: (
<>
<Icons.GroupLayers
className={classnames('mr-2 inline-flex w-4', {
'text-primary': isExpanded,
'text-secondary-light': !isExpanded,
})}
/>
{instances}
</>
),
title: (instances || 0).toString(),
gridCol: 2,
},
],
// Todo: This is actually running for all rows, even if they are
// not clicked on.
expandedContent: (
<StudyListExpandedRow
seriesTableColumns={{
description: t('StudyList:Description'),
seriesNumber: t('StudyList:Series'),
modality: t('StudyList:Modality'),
instances: t('StudyList:Instances'),
}}
seriesTableDataSource={
seriesInStudiesMap.has(studyInstanceUid)
? seriesInStudiesMap.get(studyInstanceUid).map(s => {
return {
description: s.description || '(empty)',
seriesNumber: s.seriesNumber ?? '',
modality: s.modality || '',
instances: s.numSeriesInstances || '',
};
})
: []
}
>
<div className="flex flex-row gap-2">
{(appConfig.groupEnabledModesFirst
? appConfig.loadedModes.sort((a, b) => {
const isValidA = a.isValidMode({
modalities: modalities.replaceAll('/', '\\'),
study,
}).valid;
const isValidB = b.isValidMode({
modalities: modalities.replaceAll('/', '\\'),
study,
}).valid;
return isValidB - isValidA;
})
: appConfig.loadedModes
).map((mode, i) => {
if (mode.hide) {
// Hide this mode from display
return null;
}
const modalitiesToCheck = modalities.replaceAll('/', '\\');
const { valid: isValidMode, description: invalidModeDescription } = mode.isValidMode({
modalities: modalitiesToCheck,
study,
});
if (isValidMode === null) {
// Hide this as a computed result.
return null;
}
// TODO: Modes need a default/target route? We mostly support a single one for now.
// We should also be using the route path, but currently are not
// mode.routeName
// mode.routes[x].path
// Don't specify default data source, and it should just be picked up... (this may not currently be the case)
// How do we know which params to pass? Today, it's just StudyInstanceUIDs and configUrl if exists
const query = new URLSearchParams();
if (filterValues.configUrl) {
query.append('configUrl', filterValues.configUrl);
}
query.append('StudyInstanceUIDs', studyInstanceUid);
preserveQueryParameters(query);
return (
mode.displayName && (
<Link
className={isValidMode ? '' : 'cursor-not-allowed'}
key={i}
to={`${mode.routeName}${dataPath || ''}?${query.toString()}`}
onClick={event => {
// In case any event bubbles up for an invalid mode, prevent the navigation.
// For example, the event bubbles up when the icon embedded in the disabled button is clicked.
if (!isValidMode) {
event.preventDefault();
}
}}
// to={`${mode.routeName}/dicomweb?StudyInstanceUIDs=${studyInstanceUid}`}
>
{/* TODO revisit the completely rounded style of buttons used for launching a mode from the worklist later */}
<Button
type={ButtonEnums.type.primary}
size={ButtonEnums.size.smallTall}
disabled={!isValidMode}
startIconTooltip={
!isValidMode ? (
<div className="font-inter flex w-[206px] whitespace-normal text-left text-xs font-normal text-white">
{invalidModeDescription}
</div>
) : null
}
startIcon={
isValidMode ? (
<Icons.LaunchArrow className="!h-[20px] !w-[20px] text-black" />
) : (
<Icons.LaunchInfo className="!h-[20px] !w-[20px] text-black" />
)
}
onClick={() => {}}
dataCY={`mode-${mode.routeName}-${studyInstanceUid}`}
className={!isValidMode && 'bg-[#222d44]'}
>
{mode.displayName}
</Button>
</Link>
)
);
})}
</div>
</StudyListExpandedRow>
),
onClickRow: () =>
setExpandedRows(s => (isExpanded ? s.filter(n => rowKey !== n) : [...s, rowKey])),
isExpanded,
};
});
const hasStudies = numOfStudies > 0;
const AboutModal = customizationService.getCustomization(
'ohif.aboutModal'
) as coreTypes.MenuComponentCustomization;
const UserPreferencesModal = customizationService.getCustomization(
'ohif.userPreferencesModal'
) as coreTypes.MenuComponentCustomization;
const menuOptions = [
{
title: AboutModal?.menuTitle ?? t('Header:About'),
icon: 'info',
onClick: () =>
show({
content: AboutModal,
title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'),
containerClassName: AboutModal?.containerClassName ?? 'max-w-md',
}),
},
{
title: UserPreferencesModal.menuTitle ?? t('Header:Preferences'),
icon: 'settings',
onClick: () =>
show({
content: UserPreferencesModal as React.ComponentType,
title: UserPreferencesModal.title ?? t('UserPreferencesModal:User preferences'),
containerClassName:
UserPreferencesModal?.containerClassName ?? 'flex max-w-4xl p-6 flex-col',
}),
},
];
if (appConfig.oidc) {
menuOptions.push({
icon: 'power-off',
title: t('Header:Logout'),
onClick: () => {
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
},
});
}
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
) as React.ComponentType<{ className?: string }> | undefined;
const [isFilterPending, setIsFilterPending] = useState(false);
const showStudyListLoading = Boolean(
(appConfig.showLoadingIndicator && isLoadingData) || !hasFetchedOnce || isFilterPending
);
const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent');
const uploadProps =
DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled
? {
title: 'Upload files',
containerClassName: DicomUploadComponent?.containerClassName,
closeButton: true,
shouldCloseOnEsc: false,
shouldCloseOnOverlayClick: false,
content: () => (
<DicomUploadComponent
dataSource={dataSource}
onComplete={() => {
hide();
onRefresh();
}}
onStarted={() => {
show({
...uploadProps,
// when upload starts, hide the default close button as closing the dialogue must be handled by the upload dialogue itself
closeButton: false,
});
}}
/>
),
}
: undefined;
// Sync table state (sorting, pagination, filters) with URL and sessionStorage
const { sorting, pagination, filters, setSorting, setPagination, setFilters } =
useStudyListStateSync();
const dataSourceConfigurationComponent = customizationService.getCustomization(
'ohif.dataSourceConfigurationComponent'
// Default sorting if no URL state exists
const defaultSorting = useMemo(() => [{ id: 'studyDateTime', desc: true }], []);
const [selected, setSelected] = useState<StudyRow | null>(null);
const [isPreviewOpen, setPreviewOpen] = useState(true);
const columns = useMemo(() => {
// `workList.columns` is registered as a value (StudyList.defaultColumns) and
// merged via customization commands, so we read the result directly.
const customized = customizationService.getCustomization('workList.columns');
return Array.isArray(customized) ? customized : StudyList.defaultColumns;
}, [customizationService]);
const logoComponent = appConfig?.whiteLabeling?.createLogoComponentFn?.(React) ?? (
<Icons.OHIFLogoHorizontal
aria-label="OHIF logo"
className="h-[22px] w-[232px]"
/>
);
const toolbarActions = useWorkListToolbarActions(servicesManager, dataSource, onRefresh);
const previewDefaultSize = useMemo(() => {
if (typeof window !== 'undefined' && window.innerWidth > 0) {
const percent = (325 / window.innerWidth) * 100;
return Math.min(Math.max(percent, 15), 50);
}
return 30;
}, []);
useEffect(() => {
if (isLoadingData) {
return;
}
setIsFilterPending(false);
}, [isLoadingData, data]);
return (
<div className="flex h-screen flex-col bg-black">
<Header
isSticky
menuOptions={menuOptions}
isReturnEnabled={false}
WhiteLabeling={appConfig.whiteLabeling}
showPatientInfo={PatientInfoVisibility.DISABLED}
/>
<Onboarding />
<div className="flex h-screen min-h-0 flex-col overflow-hidden bg-black">
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
<div className="flex h-full flex-col overflow-y-auto">
<ScrollArea>
<div className="flex grow flex-col">
<StudyListFilter
numOfStudies={pageNumber * resultsPerPage > 100 ? 101 : numOfStudies}
filtersMeta={filtersMeta}
filterValues={{ ...filterValues, ...defaultSortValues }}
onChange={setFilterValues}
clearFilters={() => setFilterValues(defaultFilterValues)}
isFiltering={isFiltering(filterValues, defaultFilterValues)}
onUploadClick={uploadProps ? () => show(uploadProps) : undefined}
getDataSourceConfigurationComponent={
dataSourceConfigurationComponent
? () => dataSourceConfigurationComponent()
: undefined
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<StudyList
loadedModes={appConfig?.loadedModes ?? []}
preserveQueryParameters={preserveQueryParameters}
dataPath={dataPath}
isPreviewOpen={isPreviewOpen}
onIsPreviewOpenChange={setPreviewOpen}
defaultPreviewSizePercent={previewDefaultSize}
className="h-full w-full"
>
<StudyList.Table
columns={columns}
data={data as StudyRow[]}
sorting={sorting.length > 0 ? sorting : defaultSorting}
pagination={pagination}
filters={filters}
onSortingChange={setSorting}
onPaginationChange={setPagination}
onFiltersChange={updater => {
setIsFilterPending(true);
setFilters(updater);
}}
isLoading={showStudyListLoading}
loadingComponent={
LoadingIndicatorProgress ? (
<LoadingIndicatorProgress className="!relative bg-black" />
) : (
<div className="h-8 w-8" />
)
}
title={'Study List'}
onSelectionChange={sel => setSelected((sel as StudyRow[])[0] ?? null)}
toolbarLeftComponent={logoComponent}
toolbarRightActionsComponent={toolbarActions}
toolbarRightComponent={
!isPreviewOpen ? (
<div className="relative -top-px mt-1 ml-2 flex items-center gap-1">
<StudyListSettingsPopover />
<StudyList.OpenPreviewButton />
</div>
) : undefined
}
/>
</div>
{hasStudies ? (
<div className="flex grow flex-col">
<StudyListTable
tableDataSource={tableDataSource.slice(offset, offsetAndTake)}
numOfStudies={numOfStudies}
querying={querying}
filtersMeta={filtersMeta}
<StudyList.Preview>
<SidePanelPreview
dataSource={dataSource}
selected={selected}
servicesManager={servicesManager}
/>
<div className="grow">
<StudyListPagination
onChangePage={onPageNumberChange}
onChangePerPage={onResultsPerPageChange}
currentPage={pageNumber}
perPage={resultsPerPage}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-48">
{appConfig.showLoadingIndicator && isLoadingData ? (
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
) : (
<EmptyStudies />
)}
</div>
)}
</ScrollArea>
</StudyList.Preview>
</StudyList>
</div>
</div>
</div>
);
}
WorkList.propTypes = {
data: PropTypes.array.isRequired,
dataSource: PropTypes.shape({
query: PropTypes.object.isRequired,
getConfig: PropTypes.func,
}).isRequired,
isLoadingData: PropTypes.bool.isRequired,
servicesManager: PropTypes.object.isRequired,
};
const defaultFilterValues = {
patientName: '',
mrn: '',
studyDate: {
startDate: null,
endDate: null,
},
description: '',
modalities: [],
accession: '',
sortBy: '',
sortDirection: 'none',
pageNumber: 1,
resultsPerPage: 25,
datasources: '',
};
function _tryParseInt(str, defaultValue) {
let retValue = defaultValue;
if (str && str.length > 0) {
if (!isNaN(str)) {
retValue = parseInt(str);
}
}
return retValue;
}
function _getQueryFilterValues(params) {
const newParams = new URLSearchParams();
for (const [key, value] of params) {
newParams.set(key.toLowerCase(), value);
}
params = newParams;
const queryFilterValues = {
patientName: params.get('patientname'),
mrn: params.get('mrn'),
studyDate: {
startDate: params.get('startdate') || null,
endDate: params.get('enddate') || null,
},
description: params.get('description'),
modalities: params.get('modalities') ? params.get('modalities').split(',') : [],
accession: params.get('accession'),
sortBy: params.get('sortby'),
sortDirection: params.get('sortdirection'),
pageNumber: _tryParseInt(params.get('pagenumber'), undefined),
resultsPerPage: _tryParseInt(params.get('resultsperpage'), undefined),
datasources: params.get('datasources'),
configUrl: params.get('configurl'),
};
// Delete null/undefined keys
Object.keys(queryFilterValues).forEach(
key => queryFilterValues[key] == null && delete queryFilterValues[key]
);
return queryFilterValues;
}
function _sortStringDates(s1, s2, sortModifier) {
// TODO: Delimiters are non-standard. Should we support them?
const s1Date = moment(s1.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
const s2Date = moment(s2.date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
if (s1Date.isValid() && s2Date.isValid()) {
return (s1Date.toISOString() > s2Date.toISOString() ? 1 : -1) * sortModifier;
} else if (s1Date.isValid()) {
return sortModifier;
} else if (s2Date.isValid()) {
return -1 * sortModifier;
}
}
export default WorkList;

View File

@ -1 +0,0 @@
export { default } from './WorkList';

View File

@ -3,8 +3,12 @@ import { Routes, Route, Link, useNavigate } from 'react-router-dom';
import { ErrorBoundary } from '@ohif/ui-next';
// Route Components
// Study list variants are selected by the `workList.variant` customization:
// - `'legacy'` → LegacyWorkList (the pre-3.13 study list)
// - anything else (including `'default'`) → WorkList (ui-next study list)
import WorkList from './WorkList/WorkList';
import LegacyWorkList from './LegacyWorkList/LegacyWorkList';
import DataSourceWrapper from './DataSourceWrapper';
import WorkList from './WorkList';
import Local from './Local';
import Debug from './Debug';
import NotFound from './NotFound';
@ -120,11 +124,14 @@ const createRoutes = ({
console.log('Registering worklist route', routerBasename, path);
const workListVariant = customizationService.getCustomization('workList.variant');
const WorkListComponent = workListVariant === 'legacy' ? LegacyWorkList : WorkList;
const WorkListRoute = {
path: '/',
children: DataSourceWrapper,
private: true,
props: { children: WorkList, servicesManager, extensionManager },
props: { children: WorkListComponent, servicesManager, extensionManager },
};
const customRoutes = customizationService.getCustomization('routes.customRoutes');

View File

@ -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);
});
});
});

View File

@ -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;
}

View File

@ -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();
}

View File

@ -494,4 +494,54 @@ describe('CustomizationService - Registration + API Operations', () => {
expect(customization.title).toBe(undefined);
});
});
// Values can legitimately contain React elements (e.g. a column's
// `meta.headerContent`). React brands every element with `$$typeof`, which
// must NOT be mistaken for an immutability-helper command.
describe('React elements in values', () => {
const reactElement = {
$$typeof: Symbol.for('react.element'),
type: 'svg',
key: null,
ref: null,
props: {},
};
it('registers a default array containing a React element without throwing', () => {
const columns = [
{ id: 'a', meta: { label: 'A' } },
{ id: 'b', meta: { label: 'B', headerContent: reactElement } },
];
expect(() =>
customizationService.addReferences(
{ 'workList.columns': columns },
CustomizationScope.Default
)
).not.toThrow();
const result = customizationService.getCustomization('workList.columns');
expect(result).toEqual(columns);
expect(result[1].meta.headerContent).toBe(reactElement);
});
it('still applies $ commands over a default array containing a React element', () => {
const columns = [
{ id: 'a', meta: { label: 'A' } },
{ id: 'b', meta: { label: 'B', headerContent: reactElement } },
];
customizationService.addReferences(
{ 'workList.columns': columns },
CustomizationScope.Default
);
customizationService.setCustomizations({
'workList.columns': { $push: [{ id: 'c', meta: { label: 'C' } }] },
});
const result = customizationService.getCustomization('workList.columns');
expect(result.map(c => c.id)).toEqual(['a', 'b', 'c']);
expect(result[1].meta.headerContent).toBe(reactElement);
});
});
});

View File

@ -531,6 +531,13 @@ function hasDollarKey(value) {
}
}
} else if (value && typeof value === 'object') {
// React elements carry a `$$typeof` brand; they're values to render, not
// immutability-helper command specs, so don't scan into them (otherwise
// their `$$typeof` is misread as a command and `update()` runs on a value
// it shouldn't).
if (value.$$typeof) {
return false;
}
for (const key of Object.keys(value)) {
if (key.startsWith('$') && key !== '$transform') {
return true;

View File

@ -48,6 +48,7 @@ import calculateScanAxisNormal from './calculateScanAxisNormal';
import areAllImageOrientationsEqual from './areAllImageOrientationsEqual';
import { structuredCloneWithFunctions } from './structuredCloneWithFunctions';
import { buildButtonCommands } from './buildButtonCommands';
import { thumbnailNoImageModalities } from './thumbnailNoImageModalities';
import { downloadBlob, downloadUrl, downloadCsv, downloadDicom } from './downloadBlob';
@ -103,6 +104,7 @@ const utils = {
getClosestOrientationFromIOP,
calculateScanAxisNormal,
areAllImageOrientationsEqual,
thumbnailNoImageModalities,
downloadBlob,
downloadUrl,
downloadCsv,
@ -145,6 +147,7 @@ export {
MeasurementFilters,
getClosestOrientationFromIOP,
buildButtonCommands,
thumbnailNoImageModalities,
downloadBlob,
downloadUrl,
downloadCsv,

View File

@ -0,0 +1,10 @@
export const thumbnailNoImageModalities = [
'SR',
'SEG',
'RTSTRUCT',
'RTPLAN',
'RTDOSE',
'DOC',
'PMAP',
'RWV',
];

View File

@ -132,7 +132,7 @@ Here are a list of some options available:
- `requestTransferSyntaxUID` : Request a specific Transfer syntax from dicom web server ex: 1.2.840.10008.1.2.4.80 (applied only if acceptHeader is not set)
- `omitQuotationForMultipartRequest`: Some servers (e.g., .NET) require the `multipart/related` request to be sent without quotation marks. Defaults to `false`. If your server doesn't require this, then setting this flag to `true` might improve performance (by removing the need for preflight requests). Also note that
if auth headers are used, a preflight request is required.
- `maxNumRequests`: The maximum number of requests to allow in parallel. It is an object with keys of `interaction`, `thumbnail`, and `prefetch`. You can specify a specific number for each type.
- `maxNumRequests`: The maximum number of requests to allow in parallel. It is an object with keys of `interaction`, `thumbnail`, and `prefetch`. You can specify a specific number for each type. For `thumbnail`, a small pool (around `5`) is recommended: the study list preview panel fetches a thumbnail per series in parallel, and a larger pool yields little throughput benefit while risking server overload and contention with `interaction`/`prefetch` requests.
- `modesConfiguration`: Allows overriding modes configuration.
- Example config:
```js

View File

@ -187,6 +187,43 @@ For DICOM video and PDF it has been found that Orthanc delivers multipart, while
To learn more about how you can configure the OHIF Viewer, check out our
[Configuration Guide](../configurationFiles.md).
#### `thumbnailRendering`
Optional. Controls how thumbnail images are requested.
| Value | Behavior |
| ----- | -------- |
| `wadors` (default) | Uses WADO-RS retrieval for thumbnail image content |
| `thumbnailDirect` | Uses the thumbnail URL directly as the image source. Use this only when the archive serves that URL without auth headers |
| `thumbnail` | Uses the WADO-RS `.../thumbnail` endpoint |
| `rendered` | Uses the WADO-RS `.../rendered` endpoint |
Use `thumbnail` or `rendered` when the archive supports dedicated thumbnail/rendered responses. If either is selected, `thumbnailRequestStrategy` controls how the image data is fetched.
#### `thumbnailRequestStrategy`
Optional. Controls how the app retrieves the data for various thumbnails (e.g. side study panel study browser and study list preview panel) when
`thumbnailRendering` is `thumbnail` or `rendered`. It does not apply to `wadors` or `thumbnailDirect`.
| Value | Behavior |
| ----- | -------- |
| `bulkDataRetrieve` (default) | Uses the DICOMweb client's bulk data retrieve API, consistent with other bulk data reads |
| `fetch` | Performs an authenticated HTTP `GET` to the WADO-RS `.../thumbnail` or `.../rendered` URL and builds a blob URL from the JPEG response. Prefer this when the archive serves a plain image body and bulk data retrieve is incompatible or unreliable. |
Example (Orthanc-style plain fetch for thumbnails):
```js
thumbnailRendering: 'rendered',
thumbnailRequestStrategy: 'fetch',
```
#### `queryLimit`
Optional. The maximum number of studies requested from the server for the study list, passed as the `limit` query parameter to data sources that honor it. Paging is handled in the OHIF client, so this value caps how many studies a single search returns. When not specified, it defaults to `101`.
```js
queryLimit: 101,
```
### DICOM PDF
See the [`singlepart`](#singlepart) data source configuration option.

View File

@ -0,0 +1,45 @@
---
sidebar_position: 6
sidebar_label: Study List paging
title: Study list paging and the query limit
---
# Study list paging and the query limit
The study-list data fetch in `DataSourceWrapper` was simplified. This affects **both** the new `WorkList` and the `LegacyWorkList`, since both receive their studies from `DataSourceWrapper`.
## What changed
Previously, for data sources that support `offset`/`limit`, the wrapper derived a server-side `offset` from the current page and re-queried as you paged — a "rolling window" that let you page past the first result window.
Now the wrapper issues a **single query** with `offset: 0` and `limit: queryLimit`, and the study list paginates **client-side** over the returned studies. Changing pages no longer re-queries the server.
That cap was previously a **hard-coded `101`** in the application. It is now the per-data-source `queryLimit` configuration option, still defaulting to `101` — so it can be raised (or lowered) per data source instead of requiring a code change.
On data sources that honor `offset`, this is a deliberate behavior change: studies beyond `queryLimit` are no longer reachable from the study list, whereas previously you could page into them.
## Why the change
The previous, server-paged behavior was difficult to reason about and not consistently accurate:
- **Sorting applied only to the fetched window.** Sorting was performed client-side over the returned results (and only when the total was below the limit), so with large result sets the on-screen order did not represent a true sort across all matching studies — even though the sort controls implied that it did.
- **The study count was approximate.** The total handed to the pager was an estimate rather than the real number of matching studies.
- **Paging across the window boundary was fragile.** It relied on the page size evenly dividing the window and on the server honoring `offset`, so navigating near or beyond the result cap could surface duplicate or empty pages.
The new single-fetch, client-paginated model trades reach (a hard `queryLimit` cap) for predictable, consistent ordering and paging within that capped set.
## What you may need to do
The list can show at most `queryLimit` studies (default **101**). If a deployment needs to reach more studies than that, either:
- **narrow the search** (date range, MRN, accession, modality) so the matching studies fall under the cap, or
- **raise the cap** with the per-data-source `queryLimit` option:
```js
configuration: {
// ...
queryLimit: 500,
},
```
`queryLimit` is only honored by servers that support the `limit` query parameter; servers that ignore it return whatever they return. See the [DICOMweb data source options](../../configuration/dataSources/dicom-web.md).

View File

@ -0,0 +1,31 @@
---
sidebar_position: 5
sidebar_label: formatDICOMDate
title: formatDICOMDate options object
---
# formatDICOMDate options object
`formatDICOMDate` (exported from `@ohif/ui-next`) now takes its optional
arguments as a single options object instead of positional parameters.
**Before (3.12):**
```ts
formatDICOMDate(date, 'YYYY-MM-DD');
```
**After (3.13):**
```ts
formatDICOMDate(date, { strFormat: 'YYYY-MM-DD' });
```
The available options are:
- `strFormat` — explicit output format; overrides the locale's `Common:localDateFormat`.
- `fallbackFormat` — format used only when the active locale doesn't define `Common:localDateFormat` (defaults to `MMM D, YYYY`).
- `invalidFallback` — value returned for empty/unparseable input; when omitted, the prior lenient behavior is preserved.
Callers that passed only the `date` argument (including passing `formatDICOMDate`
directly as a `formatDate` formatter) are unaffected.

View File

@ -0,0 +1,49 @@
---
sidebar_position: 4
sidebar_label: WorkList
title: WorkList route rename
---
# WorkList route rename
3.13 ships a new study-list at `/`. The 3.12 study-list code has been preserved and renamed to `LegacyWorkList`; what is now mounted at `/` by default is the new `WorkList`.
If you imported the 3.12 `WorkList` directly from `platform/app`, update the import path:
**Before (3.12):**
```ts
import WorkList from 'path/to/routes/WorkList/WorkList';
```
**After (3.13):**
```ts
import LegacyWorkList from 'path/to/routes/LegacyWorkList/LegacyWorkList';
```
## Opting back into the legacy study list
If you need more time to migrate, set the new `workList.variant` customization to `'legacy'` to mount `LegacyWorkList` at `/`:
```js
window.config = {
customizationService: [
{
'workList.variant': {
$set: 'legacy',
},
},
],
};
```
See the [Work List customization docs](../../platform/services/customization-service/WorkList.md) for details.
## Thumbnail request concurrency
The new study list's preview panel fetches a thumbnail for each series in the selected study, in parallel. To keep that from saturating the connection and delaying viewer navigation, the parallel thumbnail pool is now bounded (mirroring Cornerstone3D's `imageLoadPoolManager` thumbnail limit), and the shipped example configs set `maxNumRequests.thumbnail` to `5`.
The shipped configs previously used `75`. In practice that was higher than this workload benefits from — beyond a handful of concurrent requests there is little throughput gain, while a large pool can crowd out interaction and prefetch requests and put unnecessary load on the server. If your configuration is based on a shipped config (or otherwise sets `maxNumRequests.thumbnail`), consider lowering it to around `5`.
When the value is not configured, the preview panel already defaults to `5`, so this only affects configurations that set it explicitly.

View File

@ -0,0 +1,175 @@
---
title: Work List Customization
summary: Documentation for configuring the OHIF WorkList study-list route — selecting between the new (default) and legacy variants, the preview panel's series view (thumbnails, list, or both), and the columns shown in the study-list table.
sidebar_position: 9
---
# Work List
The `workList.*` namespace customizes the WorkList study-list route used as the default landing page in OHIF.
With the exception of `workList.variant` itself, the customizations below only apply when `workList.variant` is `'default'`; they are ignored when the legacy study list is mounted.
## `workList.variant`
Selects which study-list route is mounted at `/`.
- `'default'` (default customization value): the new ui-next WorkList, introduced in 3.13.
- `'legacy'`: the pre-3.13 WorkList (internally `LegacyWorkList`). Use this as an opt-out while migrating to the new study list.
The customization is read once during route registration, so changing it requires a reload.
## `workList.previewSeriesView`
Controls which series views are available in the preview panel that opens to the right of the study list.
- `'all'` (default customization value): the thumbnails/list toggle is visible. The initial preview view is thumbnails.
- `'thumbnails'`: the toggle is hidden; the preview is locked to thumbnails.
- `'list'`: the toggle is hidden; the preview is locked to the series list.
Note: the preview is forced to `'list'` when the active data source either declares `thumbnailRendering` as `'wadors'` or `'thumbnailDirect'`, or declares `thumbnailRequestStrategy` as `'bulkDataRetrieve'` (the default value for `thumbnailRequestStrategy`). In those cases, the customization is ignored and the technical override wins.
:::tip
The customizations below often involve functions — `workList.renderPreviewContent` and `workList.settingsMenuItems` are functions, and `workList.columns` (a value) commonly carries cell/header renderers or an `$apply` transform. Those functions frequently need access to React hooks, the services manager, or the commands manager (e.g. to open modals, navigate, run commands, or build translated labels). They can be set from `window.config`, but they're generally easier to author in a custom extension's `getCustomizationModule`, where the services manager is in scope and components can use hooks normally. Plain config is best suited to simple tweaks like reordering, removing, or inserting items that only need static handlers.
:::
## `workList.columns`
The column set for the WorkList table, registered as a **value**`ColumnDef<StudyRow, unknown>[]` — rather than a function. The default is `StudyList.defaultColumns`, so out of the box the table shows `Patient`, `MRN`, `Study Date`, `Modalities`, `Description`, `Accession`, `Instances`, and a trailing actions column in that order.
Because it is a plain array, you customize it with [immutability-helper](https://github.com/kolodny/immutability-helper) commands:
| Command | Use |
| --- | --- |
| `$splice` | reorder, insert, or remove columns |
| `$set` / `$merge` | tweak a column's `meta` (label, `minWidth`, `priority`, `align`) |
| `$set` a `cell`/`header` | replace a column's renderer |
| `$apply: (cols) => cols` | run your own function over the current columns and return the new array |
The first three commands cover the common, declarative tweaks. `$apply` is the general-purpose option for anything they don't express cleanly: instead of describing the change with a command, you receive the current `ColumnDef[]` and return the array you want. Because it's a plain function you can use normal JavaScript — `find`, `filter`, `map`, `slice`, conditionals — which makes it the right choice for moves, conditional inserts, or any edit that should be driven by a column's `id` rather than its position. For example, reordering by id:
```ts
'workList.columns': {
$apply: columns => {
// Move "Modalities" to the front, leaving everything else in order.
const modalities = columns.find(c => c.id === 'modalities');
return modalities ? [modalities, ...columns.filter(c => c.id !== 'modalities')] : columns;
},
}
```
For a simple, display-only column, `StudyList.textColumn(id, label, meta?)` fills in the accessor/header/cell wiring for you:
```ts
window.config = {
customizationService: [
{
'workList.columns': {
// Insert before the trailing actions column so it stays at row end.
$apply: columns => {
const at = columns.findIndex(c => c.id === 'actions');
const referring = StudyList.textColumn('referringPhysicianName', 'Referring Physician');
return [...columns.slice(0, at), referring, ...columns.slice(at)];
},
},
},
],
};
```
A pure-data tweak (e.g. relabel) needs no function at all:
```ts
'workList.columns': { 2: { meta: { label: { $set: 'Study Date / Time' } } } }
```
### Surfacing an attribute the data source doesn't map yet
A column can only display data that's already on the study row. The default DICOMweb data source maps a fixed set of fields (`patientName`, `mrn`, `date`/`time`, `accession`, `description`, `modalities`, `instances`, `studyInstanceUid`, `referringPhysicianName`). To add a column for a DICOM attribute outside that set — say **Requesting Physician** `(0032,1032)` — extend the QIDO handling in `extensions/default/src/DicomWebDataSource/qido.js`:
1. **Request the tag** — add it to `includefield` in `mapParams`, so the server is asked to return it:
```js
const commaSeparatedFields = [
'00081030', // Study Description
'00080060', // Modality
'00080090', // Referring Physician's Name
'00321032', // Requesting Physician
].join(',');
```
2. **Map it onto the row** — in `processResults` (a `PN`-VR field, so it goes through `formatPN`/`getName` like `patientName`):
```js
requestingPhysician: utils.formatPN(getName(qidoStudy['00321032'])) || '',
```
Now any column can read `row.requestingPhysician` (e.g. `StudyList.textColumn('requestingPhysician', 'Requesting Physician')`). Note that **`StudyRow` does not need editing** — it carries an index signature, so data-source-mapped fields are readable without a type change.
Two caveats: the server must actually return the tag (it has to support `includefield` — see the data source's `qidoSupportsIncludeField` — and the studies must carry the attribute), and this is a data-source-wide change, not scoped to the worklist.
### Gotchas and limitations
- **Renderers aren't serializable.** A column's `accessorFn`, `cell`, `header`, `filterFn`, and `sortingFn` are functions. `$set`/`$push` accept them, but a column that renders anything beyond plain text still requires code — you can't express it as pure JSON config. `StudyList.textColumn` covers the simple text case.
- **The `actions` column should stay last (cosmetic).** Its hover menu is right-aligned to anchor the row end, so placing it mid-row just looks wrong — it's not a functional requirement. Insert new columns *before* it (e.g. `$splice` at its index, or the `$apply` pattern above); a bare `$push` lands *after* it, leaving the actions menu mid-row.
- **Index-based commands are position-fragile.** `{ 2: { … } }` targets whatever is at index 2, which shifts if earlier columns are added/removed. Prefer `$apply` with a `findIndex`/`id` lookup for edits that should survive reordering.
- If the merged value is not an array, WorkList falls back to `StudyList.defaultColumns`.
## `workList.renderPreviewContent`
Render function for the preview panel that opens to the right of the study list. The customization receives the host React and the same data the built-in renderer uses — series and thumbnails are fetched by the `SidePanelPreview` shell and passed in as props.
```ts
type PreviewContentProps = {
study: StudyRow | null;
series: any[];
seriesView: 'all' | 'thumbnails' | 'list';
onThumbnailImageError: (seriesUID: string) => void;
};
type RenderPreviewContent = (
React: typeof import('react'),
props: PreviewContentProps
) => React.ReactNode;
```
### Props
- **`study`** — the currently selected `StudyRow` (`null` when no study is selected). Useful fields include `studyInstanceUid`, `patientName`, `mrn`, `date`, `modalities`, `description`, `accession`, and `instances`.
- **`series`** — the series belonging to `study`, sorted by series date. Each item has the raw fields returned by the data source (`seriesInstanceUid`, `modality`, `description`, `seriesDate`, `seriesNumber`, `numSeriesInstances`, etc.) plus a `thumbnailStatus` added by the shell:
- `{ status: 'loading' }` — a thumbnail fetch is in flight.
- `{ status: 'ready', src }``src` is the URL (often a `blob:` URL) you can render in an `<img>`.
- `{ status: 'notAvailable' }` — the fetch failed or `onThumbnailImageError` was called for this series.
- `{ status: 'notApplicable' }` — the modality has no displayable thumbnail (e.g. SR, KO).
- **`seriesView`** — `'all' | 'thumbnails' | 'list'`. Resolved from `workList.previewSeriesView`, with `'list'` forced when the active data source uses `wadors`/`thumbnailDirect` rendering or `bulkDataRetrieve` retrieval. Honor it if you want to respect the user's toggle and the data-source constraints; ignore it if your custom layout doesn't have a thumbnails/list distinction.
- **`onThumbnailImageError`** — call with a series UID when an `<img>` you render fails to load. The shell marks that series as `notAvailable` and revokes its blob URL if needed. Wire it to your image element's `onError` to keep the state consistent.
Use this customization to change the preview layout (e.g. a different patient summary, a custom series grid) while keeping the fetch, abort-on-selection-change, and bounded thumbnail worker pool intact. When the customization is unset (the default) or not a function, WorkList uses the built-in `<StudyList.PreviewContainer>` layout.
## `workList.settingsMenuItems`
Builds the items in the WorkList settings popover (the gear menu in the top right). The customization is a function that receives the default items and must return a `SettingsMenuItem[]`.
```ts
type SettingsMenuItem = {
id: string;
label: React.ReactNode;
onClick: () => void;
};
type WorkListSettingsMenuItems = (defaults: SettingsMenuItem[]) => SettingsMenuItem[];
```
The default items are:
- `about` — opens the About modal (`ohif.aboutModal` customization).
- `userPreferences` — opens the User Preferences modal (`ohif.userPreferencesModal` customization).
- `logout` — only included when `appConfig.oidc` is configured; navigates to `/logout`.
Use it to reorder, remove, or insert items (e.g. a "Help" link, a "Send feedback" action) without rebuilding the popover shell. If the customization returns a non-array value, WorkList falls back to the defaults.
import { workListCustomizations, TableGenerator } from './sampleCustomizations';
{TableGenerator(workListCustomizations)}

View File

@ -356,6 +356,215 @@ window.config = {
},
];
export const workListCustomizations = [
{
id: 'workList.variant',
description: (
<>
Selects which study-list route is mounted at <code>/</code>. Use <code>'default'</code>{' '}
(default customization value) for the new ui-next WorkList introduced in 3.13. Use{' '}
<code>'legacy'</code> to mount the pre-3.13 WorkList (internally{' '}
<code>LegacyWorkList</code>) as an opt-out while migrating. The customization is read once
during route registration, so changing it requires a reload.
</>
),
default: 'default',
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'workList.variant': {
$set: 'legacy',
},
},
],
};
`,
},
{
id: 'workList.previewSeriesView',
description: (
<>
Controls which series views are available in the WorkList preview panel. Use{' '}
<code>'all'</code> (default customization value) to show the thumbnails/list toggle. The
initial preview view is thumbnails. Use <code>'thumbnails'</code> to lock the preview to
thumbnails, or <code>'list'</code> to lock it to the series list. The preview is forced to{' '}
<code>'list'</code> when the active data source declares <code>thumbnailRendering</code> as{' '}
<code>'wadors'</code> or <code>'thumbnailDirect'</code>, or declares{' '}
<code>thumbnailRequestStrategy</code> as <code>'bulkDataRetrieve'</code> (its default
value), regardless of this setting. Currently only applies when <code>workList.variant</code> is{' '}
<code>'default'</code>.
</>
),
default: 'all',
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'workList.previewSeriesView': {
$set: 'list',
},
},
],
};
`,
},
{
id: 'workList.columns',
description: (
<>
The column set for the WorkList table, as a <code>ColumnDef[]</code> value (default:{' '}
<code>StudyList.defaultColumns</code>). Because it is a plain array, override it with
immutability-helper commands <code>$splice</code> to reorder/insert/remove,{' '}
<code>$set</code>/<code>$merge</code> to tweak <code>meta</code> (label, width, priority),
or <code>$apply</code> a function that receives the current columns and returns the new
array for anything the other commands don't express cleanly (moves, conditional inserts,
or edits keyed off a column's <code>id</code> rather than its position). Use{' '}
<code>StudyList.textColumn(id, label, meta?)</code> for a simple display-only column.
Gotchas: a column's <code>cell</code>/<code>accessorFn</code>/etc. are functions (not
serializable, so non-text columns still need code); the trailing <code>actions</code>{' '}
column should stay last for correct layout (cosmetic, not required), so insert{' '}
<em>before</em> it with <code>$splice</code> rather than <code>$push</code>; and
index-based edits are position-fragile (prefer <code>$apply</code>{' '}
for id-based changes). If the merged value is not an array, WorkList falls back to the
defaults. Currently only applies when <code>workList.variant</code> is{' '}
<code>'default'</code>.
</>
),
default: 'StudyList.defaultColumns',
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'workList.columns': {
// Insert a Referring Physician column before the trailing actions column.
$apply: (columns) => {
const actionsIndex = columns.findIndex((c) => c.id === 'actions');
const at = actionsIndex === -1 ? columns.length : actionsIndex;
const referring = StudyList.textColumn('referringPhysicianName', 'Referring Physician');
return [...columns.slice(0, at), referring, ...columns.slice(at)];
},
},
},
],
};
`,
},
{
id: 'workList.renderPreviewContent',
description: (
<>
Render function for the preview panel content. Receives the host React and{' '}
<code>{'{ study, series, seriesView, onThumbnailImageError }'}</code> the same data the
built-in renderer uses, with series and thumbnails already fetched by the{' '}
<code>SidePanelPreview</code> shell.
<ul>
<li>
<code>study</code>: the selected <code>StudyRow</code>, or <code>null</code>.
</li>
<li>
<code>series</code>: the study's series with raw data-source fields (
<code>seriesInstanceUid</code>, <code>modality</code>, <code>description</code>,{' '}
<code>seriesDate</code>, <code>seriesNumber</code>, <code>numSeriesInstances</code>,
etc.) plus a <code>thumbnailStatus</code> added by the shell, which is one of{' '}
<code>{"{ status: 'loading' }"}</code>, <code>{"{ status: 'ready', src }"}</code>,{' '}
<code>{"{ status: 'notAvailable' }"}</code>, or{' '}
<code>{"{ status: 'notApplicable' }"}</code>. Use <code>src</code> from the{' '}
<code>'ready'</code> form as the <code>{'<img>'}</code> source.
</li>
<li>
<code>seriesView</code>: <code>'all' | 'thumbnails' | 'list'</code>, resolved from{' '}
<code>workList.previewSeriesView</code> with <code>'list'</code> forced for data
sources that can't produce thumbnails.
</li>
<li>
<code>onThumbnailImageError(seriesUID)</code>: call when an <code>{'<img>'}</code>{' '}
you render fails to load; the shell marks that series as <code>notAvailable</code>{' '}
and revokes its blob URL if needed.
</li>
</ul>
Use this to change the preview layout while keeping the fetch, abort, and thumbnail
worker-pool logic intact. When unset, the built-in{' '}
<code>{'<StudyList.PreviewContainer>'}</code> layout is used. Currently only applies when{' '}
<code>workList.variant</code> is <code>'default'</code>.
</>
),
default: 'undefined',
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'workList.renderPreviewContent': {
$set: function (React, { study, series, seriesView, onThumbnailImageError }) {
// Render whatever layout you like using the data the shell provides.
return React.createElement(
'div',
{ className: 'flex h-full flex-col bg-black p-4 text-white' },
React.createElement('h2', null, study?.patientName ?? 'No study selected'),
React.createElement(
'ul',
{ className: 'mt-2 flex flex-col gap-2' },
series.map((s) =>
React.createElement(
'li',
{ key: s.seriesInstanceUid },
s.description
)
)
)
);
},
},
},
],
};
`,
},
{
id: 'workList.settingsMenuItems',
description: (
<>
Builds the items in the WorkList settings popover (the gear menu in the top right). The
customization is a function that receives the default items and must return a{' '}
<code>SettingsMenuItem[]</code> (each <code>{'{ id, label, onClick }'}</code>). The
defaults are <code>about</code>, <code>userPreferences</code>, and (when{' '}
<code>appConfig.oidc</code> is configured) <code>logout</code>. Use it to reorder, remove,
or insert items without rebuilding the popover shell. If the customization returns a
non-array value, WorkList falls back to the defaults. Currently only applies when{' '}
<code>workList.variant</code> is <code>'default'</code>.
</>
),
default: '(defaults) => defaults',
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'workList.settingsMenuItems': {
// Remove "User Preferences" and add a custom "Help" item at the top.
$set: (defaults) => {
const filtered = defaults.filter((i) => i.id !== 'userPreferences');
return [
{
id: 'help',
label: 'Help',
onClick: () => window.open('https://docs.example.com', '_blank'),
},
...filtered,
];
},
},
},
],
};
`,
},
];
export const customizations = [
{
id: 'ohif.hotkeyBindings',
@ -1022,89 +1231,47 @@ window.config = {
},
{
id: 'ohif.aboutModal',
description: 'The About modal',
description: (
<>
Replaces the About modal. The customization value is a React component; see{' '}
<code>extensions/default/src/customizations/aboutModalCustomization.tsx</code> for the
default and the <code>AboutModal</code> compound API. The consumer also reads optional{' '}
<code>title</code> and <code>containerClassName</code> static properties off the
component; both fall back to sensible defaults when omitted.
</>
),
image: aboutModal,
default: 'Our own default component',
configurationIntro: (
<p style={{ margin: 0 }}>
Easiest to register from a custom extension's <code>getCustomizationModule</code>, where
JSX and the <code>@ohif/ui-next</code> imports work normally. See{' '}
<code>aboutModalCustomization.tsx</code> for the full default and the rest of the{' '}
<code>AboutModal</code> compound API.
</p>
),
configuration: `
window.config = {
// rest of window config
import { AboutModal } from '@ohif/ui-next';
// You can use the component from AboutModal
// to build your own custom component
customizationService: [
{
'ohif.aboutModal': {
$set: CustomizedComponent,
},
},
],
};
`,
},
{
id: 'viewportDownload.warningMessage',
description: 'Customizes the warning message for the viewport download form.',
image: viewportDownloadWarning,
default: {
enabled: true,
value: 'Not For Diagnostic Use',
},
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'viewportDownload.warningMessage': {
$set: {
enabled: true,
value: 'Careful! This is not for diagnostic use.',
},
},
},
],
};
`,
},
{
id: 'ohif.captureViewportModal',
description: 'The modal for capturing the viewport image.',
image: captureViewportModal,
default: 'Our own default component',
configuration: `
window.config = {
// rest of window config
function MyAboutModal() {
return (
<AboutModal className="w-[400px]">
<AboutModal.ProductName>My Custom Viewer</AboutModal.ProductName>
<AboutModal.ProductVersion>1.2.3</AboutModal.ProductVersion>
</AboutModal>
);
}
// You can use the component from ImageModal and FooterAction
// to build your own custom component
customizationService: [
{
'ohif.captureViewportModal': {
$set: CustomizedComponent,
},
},
],
};
`,
},
{
id: 'ohif.aboutModal',
description: 'The About modal',
image: aboutModal,
default: 'Our own default component',
configuration: `
window.config = {
// rest of window config
// Optional: override the modal title and container size.
MyAboutModal.title = 'About My Custom Viewer';
MyAboutModal.containerClassName = 'max-w-md';
// You can use the component from AboutModal
// to build your own custom component
customizationService: [
{
'ohif.aboutModal': {
$set: CustomizedComponent,
},
},
],
};
window.config = {
// rest of window config
customizationService: [
{ 'ohif.aboutModal': { $set: MyAboutModal } },
],
};
`,
},
{

View File

@ -185,67 +185,53 @@ export const ModalConsumer = ModalContext.Consumer;
```
Therefore, anywhere in the app that we have access to react context we can use
it by calling the `useModal` from `@ohif/ui`. As a matter of fact, we are
it by calling the `useModal` from `@ohif/ui-next`. As a matter of fact, we are
utilizing the modal for the preference window which shows the hotkeys after
clicking on the gear button on the right side of the header.
A `simplified` code for our worklist is:
A `simplified` code for our viewer header is:
```js title="platform/app/src/routes/WorkList/WorkList.jsx"
import { useModal, Header } from '@ohif/ui';
```tsx title="extensions/default/src/ViewerLayout/ViewerHeader.tsx"
import { Header, useModal } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
function WorkList({
history,
data: studies,
dataTotal: studiesTotal,
isLoadingData,
dataSource,
hotkeysManager,
}) {
const { show, hide } = useModal();
function ViewerHeader({ appConfig }) {
const { servicesManager } = useSystem();
const { customizationService } = servicesManager.services;
const { show } = useModal();
/** ... **/
const AboutModal = customizationService.getCustomization('ohif.aboutModal');
const UserPreferencesModal = customizationService.getCustomization(
'ohif.userPreferencesModal'
);
const menuOptions = [
{
title: t('Header:About'),
title: AboutModal?.menuTitle ?? t('Header:About'),
icon: 'info',
onClick: () => show({ content: AboutModal, title: 'About OHIF Viewer' }),
onClick: () =>
show({
content: AboutModal,
title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'),
containerClassName: AboutModal?.containerClassName ?? 'max-w-md',
}),
},
{
title: t('Header:Preferences'),
title: UserPreferencesModal.menuTitle ?? t('Header:Preferences'),
icon: 'settings',
onClick: () =>
show({
title: t('UserPreferencesModal:User Preferences'),
content: UserPreferences,
contentProps: {
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(
hotkeyDefaults
),
hotkeyDefinitions,
onCancel: hide,
currentLanguage: currentLanguage(),
availableLanguages,
defaultLanguage,
onSubmit: state => {
i18n.changeLanguage(state.language.value);
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
hide();
},
onReset: () => hotkeysManager.restoreDefaultBindings(),
},
content: UserPreferencesModal,
title: UserPreferencesModal.title ?? t('UserPreferencesModal:User preferences'),
containerClassName:
UserPreferencesModal?.containerClassName ?? 'flex max-w-4xl p-6 flex-col',
}),
},
];
/** ... **/
return (
<div>
/** ... **/
<Header isSticky menuOptions={menuOptions} isReturnEnabled={false} />
/** ... **/
</div>
);
return <Header menuOptions={menuOptions} /** ... **/ />;
}
```

View File

@ -13,6 +13,15 @@ server for the `OHIF Viewer`.
![user-study-list](../assets/img/user-study-list.png)
## Study Limit
To accommodate the various data sources that support various query parameters for
paging and limiting results it was decided to handle paging in the OHIF client.
This nicely handles those data sources that do not support paging and query limits.
For those that support the `limit` query parameter, there is a data source
configuration parameter `queryLimit` that will be passed for study list searches
so as to cap the result. When not specified, this value defaults to `101`.
## Sorting
When the Study List is opened, the application queries the PACS for 101 studies

View File

@ -0,0 +1,4 @@
{
"No results.": "No results.",
"Not enough room to display this column": "Not enough room to display this column"
}

View File

@ -3,6 +3,7 @@ import Buttons from './Buttons.json';
import CineDialog from './CineDialog.json';
import Common from './Common.json';
import DataSourceConfiguration from './DataSourceConfiguration.json';
import DataTable from './DataTable.json';
import DatePicker from './DatePicker.json';
import ErrorBoundary from './ErrorBoundary.json';
import Header from './Header.json';
@ -39,6 +40,7 @@ export default {
CineDialog,
Common,
DataSourceConfiguration,
DataTable,
DatePicker,
ErrorBoundary,
Header,

View File

@ -0,0 +1,4 @@
{
"No results.": "Test No results.",
"Not enough room to display this column": "Test Not enough room to display this column"
}

View File

@ -4,6 +4,7 @@ import CineDialog from './CineDialog.json';
import Common from './Common.json';
import Colormaps from './Colormaps.json';
import DataSourceConfiguration from './DataSourceConfiguration.json';
import DataTable from './DataTable.json';
import DatePicker from './DatePicker.json';
import ErrorBoundary from './ErrorBoundary.json';
import Header from './Header.json';
@ -43,6 +44,7 @@ export default {
Common,
Colormaps,
DataSourceConfiguration,
DataTable,
DatePicker,
ErrorBoundary,
Header,

View File

@ -15,7 +15,6 @@
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"start": "yarn run build --watch",
"dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.playground.js",
"test": "echo \"Error: no test specified\" && exit 1",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage",
@ -29,6 +28,7 @@
".": "./src/index.ts"
},
"dependencies": {
"@tanstack/react-table": "8.21.3",
"@radix-ui/react-accordion": "1.2.11",
"@radix-ui/react-checkbox": "1.3.2",
"@radix-ui/react-context-menu": "2.2.15",
@ -51,12 +51,12 @@
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"date-fns": "3.6.0",
"date-fns": "4.1.0",
"framer-motion": "6.2.4",
"lucide-react": "0.379.0",
"next-themes": "0.3.0",
"react": "18.3.1",
"react-day-picker": "8.10.1",
"react-day-picker": "9.12.0",
"react-resizable-panels": "2.1.9",
"react-shepherd": "6.1.1",
"shepherd.js": "13.0.3",

View File

@ -0,0 +1,38 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-xl border px-1 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary/25 text-primary shadow hover:bg-primary/35',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1 @@
export { Badge, badgeVariants, type BadgeProps } from './Badge';

View File

@ -1,10 +1,9 @@
import * as React from 'react';
import { useMemo } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
import type { Locale } from 'react-day-picker';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import type { Locale } from 'date-fns';
import {
ar as arLocale,
ca as caLocale,
@ -18,15 +17,12 @@ import {
tr as trLocale,
vi as viLocale,
zhCN as zhLocale,
} from 'date-fns/locale';
} from 'react-day-picker/locale';
import { cn } from '../../lib/utils';
import { Button, buttonVariants } from '../Button';
import { buttonVariants } from '../Button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
const DATE_FNS_LOCALE_MAP: Record<string, Locale> = {
const LOCALE_MAP: Record<string, Locale> = {
en: enUS,
'en-US': enUS,
fr: frLocale,
@ -49,65 +45,206 @@ const DATE_FNS_LOCALE_MAP: Record<string, Locale> = {
'test-LNG': enUS,
};
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
const { i18n, t } = useTranslation('DatePicker');
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
locale: localeProp,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const { i18n } = useTranslation('DatePicker');
const defaultClassNames = getDefaultClassNames();
const locale = useMemo(() => {
if (localeProp) {
return localeProp;
}
const lang = i18n.language || 'en';
return DATE_FNS_LOCALE_MAP[lang] ?? enUS;
}, [i18n.language]);
return LOCALE_MAP[lang] ?? enUS;
}, [i18n.language, localeProp]);
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
captionLayout="dropdown"
fromYear={1945}
toYear={new Date().getFullYear()}
labels={{
labelMonthDropdown: () => undefined,
labelYearDropdown: () => undefined,
}}
locale={locale}
className={cn(
'bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatCaption: month => format(month, 'LLLL yyyy', { locale }),
formatMonthDropdown: date => date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-between items-center px-2',
caption_dropdowns: 'flex space-x-2 text-black',
caption_label: 'hidden',
nav: 'space-x-1 flex items-center',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem] uppercase',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-base p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
root: cn('w-fit min-w-[255px]', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn(
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary/60 text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground focus:bg-primary/80 focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-[--cell-size] select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-[--cell-size] select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_next
),
month_caption: cn(
'flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]',
defaultClassNames.month_caption
),
dropdowns: cn(
'flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns
),
dropdown_root: cn(
'relative rounded-md border border-input',
defaultClassNames.dropdown_root
),
dropdown: cn('absolute inset-0 opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5',
defaultClassNames.caption_label
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground flex-1 rounded-md text-[0.8rem] font-normal',
defaultClassNames.weekday
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn('w-[--cell-size] select-none', defaultClassNames.week_number_header),
week_number: cn(
'text-muted-foreground select-none text-[0.8rem]',
defaultClassNames.week_number
),
day: cn(
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
defaultClassNames.day
),
range_start: cn('bg-accent rounded-l-md', defaultClassNames.range_start),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside
),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon
className={cn('size-4', className)}
{...props}
/>
);
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
);
}
return (
<ChevronDownIcon
className={cn('size-4', className)}
{...props}
/>
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="size-[--cell-size] flex items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) {
ref.current?.focus();
}
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@ -1,3 +1,3 @@
import { Calendar } from './Calendar';
import { Calendar, CalendarDayButton } from './Calendar';
export { Calendar};
export { Calendar, CalendarDayButton };

View File

@ -0,0 +1,138 @@
import * as React from 'react';
import type { Cell } from '@tanstack/react-table';
import type { ColumnMeta } from './types';
// Context to share computed values with sub-components
type ActionOverlayCellContextValue = {
isActive: boolean;
computedAlign: 'start' | 'center' | 'end';
cell: Cell<unknown, unknown>;
};
const ActionOverlayCellContext = React.createContext<ActionOverlayCellContextValue | null>(null);
// Symbol to identify sub-components
const VALUE_TYPE = Symbol('ActionOverlayCell.Value');
const OVERLAY_TYPE = Symbol('ActionOverlayCell.Overlay');
type ActionOverlayCellProps<TData> = {
cell: Cell<TData, unknown>;
children?: React.ReactNode;
};
export function ActionOverlayCell<TData>({ cell, children }: ActionOverlayCellProps<TData>) {
const isActive = cell.row.getIsSelected();
const meta = (cell.column.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const align = meta?.align ?? 'right';
// Map 'left' | 'center' | 'right' to 'start' | 'center' | 'end' for overlay positioning
const computedAlign = align === 'left' ? 'start' : align === 'center' ? 'center' : 'end';
const contextValue: ActionOverlayCellContextValue = {
isActive,
computedAlign,
cell: cell as Cell<unknown, unknown>,
};
// Extract Value and Overlay components from children
// Only Value and Overlay sub-components are recognized; other children are ignored
let valueElement: React.ReactElement | null = null;
let overlayElement: React.ReactElement | null = null;
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
const childType = (child.type as { _type?: symbol })?._type;
if (childType === VALUE_TYPE) {
valueElement = child;
} else if (childType === OVERLAY_TYPE) {
overlayElement = child;
}
}
});
return (
<ActionOverlayCellContext.Provider value={contextValue}>
<div className="relative">
{valueElement}
{overlayElement}
</div>
</ActionOverlayCellContext.Provider>
);
}
// Value sub-component
type ValueProps = {
children?: React.ReactNode;
};
function Value({ children }: ValueProps) {
const context = React.useContext(ActionOverlayCellContext);
if (!context) {
throw new Error('ActionOverlayCell.Value must be used within ActionOverlayCell');
}
const { isActive, computedAlign } = context;
const valueAlignmentClass =
computedAlign === 'end' ? 'text-right' : computedAlign === 'center' ? 'text-center' : '';
const valueVisibilityClass = isActive
? 'invisible opacity-0'
: 'group-hover:invisible group-hover:opacity-0 group-hover:text-transparent';
return (
<div className={`transition-opacity ${valueAlignmentClass} ${valueVisibilityClass}`}>
{children}
</div>
);
}
// Mark Value component with symbol for identification
(Value as { _type?: symbol })._type = VALUE_TYPE;
// Overlay sub-component
type OverlayProps = {
children?: React.ReactNode;
};
function Overlay({ children }: OverlayProps) {
const context = React.useContext(ActionOverlayCellContext);
if (!context) {
throw new Error('ActionOverlayCell.Overlay must be used within ActionOverlayCell');
}
const { isActive, computedAlign, cell } = context;
const overlayPositionClass =
computedAlign === 'center'
? 'inset-y-0 inset-x-0 justify-center px-2'
: computedAlign === 'start'
? 'inset-y-0 left-0 px-2'
: 'inset-y-0 right-0 px-2';
const overlayVisibilityClass = isActive
? 'bg-popover opacity-100'
: 'opacity-0 group-hover:bg-muted group-hover:opacity-100';
return (
<div
className={`absolute z-10 flex items-center ${overlayPositionClass} ${overlayVisibilityClass}`}
onMouseDown={e => {
e.stopPropagation();
if (!cell.row.getIsSelected()) {
cell.row.toggleSelected(true);
}
}}
onPointerDown={e => {
e.stopPropagation();
if (!cell.row.getIsSelected()) {
cell.row.toggleSelected(true);
}
}}
>
{children}
</div>
);
}
// Mark Overlay component with symbol for identification
(Overlay as { _type?: symbol })._type = OVERLAY_TYPE;
// Export as compound component
ActionOverlayCell.Value = Value;
ActionOverlayCell.Overlay = Overlay;

View File

@ -0,0 +1,46 @@
import * as React from 'react';
import type { Column } from '@tanstack/react-table';
import { Button } from '../Button';
import { Icons } from '../Icons';
import type { ColumnMeta } from './types';
export function ColumnHeader<TData, TValue>({ column }: { column: Column<TData, TValue> }) {
const meta = (column.columnDef.meta as ColumnMeta | undefined) ?? undefined;
// Use headerContent if provided, otherwise use label
const content = meta?.headerContent ?? meta?.label ?? column.id;
const align = meta?.align ?? 'left';
const canSort = column.getCanSort();
const sorted = column.getIsSorted() as false | 'asc' | 'desc';
const justify =
align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start';
const SortIcon =
sorted === 'asc'
? Icons.SortingNewAscending
: sorted === 'desc'
? Icons.SortingNewDescending
: Icons.SortingNew;
const ariaLabel = meta?.label ?? column.id;
return (
<div className={`flex w-full items-center gap-1 ${justify}`}>
{typeof content === 'string' ? <span>{content}</span> : content}
{canSort && (
<Button
variant="ghost"
size="sm"
onClick={() => column.toggleSorting(sorted === 'asc')}
aria-label={`Sort ${ariaLabel}`}
className="px-1"
>
<SortIcon
className="h-4 w-2.5"
aria-hidden="true"
/>
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,455 @@
import React, {
type ReactNode,
type ReactElement,
useState,
useCallback,
useEffect,
useRef,
Children,
isValidElement,
} from 'react';
import { useTranslation } from 'react-i18next';
import type {
ColumnDef,
ColumnFiltersState,
RowSelectionState,
SortingState,
VisibilityState,
PaginationState,
Row,
} from '@tanstack/react-table';
import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
useReactTable,
flexRender,
} from '@tanstack/react-table';
import { DataTableContext, DataTableContextValue, useDataTable } from './context';
import { Toolbar } from './Toolbar';
import { Title } from './Title';
import { Pagination } from './Pagination';
import { ViewOptions } from './ViewOptions';
import { ActionOverlayCell } from './ActionOverlayCell';
import { FilterRow } from './FilterRow';
import { ColumnHeader } from './ColumnHeader';
import { ResponsiveColumnsProvider, useResponsiveColumns } from './useResponsiveColumns';
import type { ColumnMeta } from './types';
import {
Table as BasicTable,
TableHeader as BasicTableHeader,
TableBody as BasicTableBody,
TableHead as BasicTableHead,
TableRow as BasicTableRow,
TableCell as BasicTableCell,
} from '../Table';
import { ScrollArea } from '../ScrollArea';
import { cn } from '../../lib/utils';
// Type for state update functions that accept either a value or an updater function
type Updater<T> = T | ((prev: T) => T);
type OnChangeFn<T> = (updater: Updater<T>) => void;
export type DataTableProps<TData> = {
data: TData[];
columns: ColumnDef<TData, unknown>[];
getRowId?: (row: TData, index: number) => string;
initialVisibility?: VisibilityState;
sorting?: SortingState;
pagination?: PaginationState;
filters?: ColumnFiltersState;
onSortingChange?: OnChangeFn<SortingState>;
onPaginationChange?: OnChangeFn<PaginationState>;
onFiltersChange?: OnChangeFn<ColumnFiltersState>;
manualFiltering?: boolean;
enforceSingleSelection?: boolean;
onSelectionChange?: (rows: TData[]) => void;
children: ReactNode;
};
/**
* Root DataTable provider component.
* Creates the TanStack table instance, manages state, and exposes it via context.
*/
function DataTableRoot<TData>({
data,
columns,
getRowId,
initialVisibility = {},
sorting = [],
pagination = { pageIndex: 0, pageSize: 50 },
filters = [],
onSortingChange,
onPaginationChange,
onFiltersChange,
manualFiltering = false,
enforceSingleSelection = true,
onSelectionChange,
children,
}: DataTableProps<TData>) {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(initialVisibility);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useReactTable<TData>({
data,
columns,
state: { sorting, columnVisibility, rowSelection, columnFilters: filters, pagination },
onSortingChange: onSortingChange,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onColumnFiltersChange: onFiltersChange,
onPaginationChange: onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualFiltering,
enableRowSelection: true,
enableMultiRowSelection: !enforceSingleSelection,
getRowId,
autoResetPageIndex: false,
});
// Reset pagination to page 0 when filters or sorting change, but only when
// pagination itself did not change in the same render. A simultaneous
// pagination change means the caller set the page intentionally (e.g. a
// coordinated state restore), so we leave it alone.
// The refs are initialized to the mount-time values so the first effect run
// always sees all three as unchanged — ensuring that
// any pagination applied with filtering or sorting during the initial render
// is not overridden.
const prevFiltersRef = useRef(filters);
const prevSortingRef = useRef(sorting);
const prevPaginationRef = useRef(pagination);
useEffect(() => {
const filtersChanged = filters !== prevFiltersRef.current;
const sortingChanged = sorting !== prevSortingRef.current;
const paginationChanged = pagination !== prevPaginationRef.current;
prevFiltersRef.current = filters;
prevSortingRef.current = sorting;
prevPaginationRef.current = pagination;
if ((filtersChanged || sortingChanged) && !paginationChanged && onPaginationChange) {
onPaginationChange(p => ({ ...p, pageIndex: 0 }));
}
}, [filters, sorting, pagination, onPaginationChange]);
// Deselect rows that are no longer on the current page (after filters +
// pagination). Without this, a selected row that gets filtered or paged out
// remains in rowSelection — keeping downstream consumers (e.g. a preview
// panel) pinned to a study the user can't see.
useEffect(() => {
const selectedIds = Object.keys(rowSelection);
if (selectedIds.length === 0) {
return;
}
const visibleIds = new Set(table.getPaginationRowModel().rows.map(r => r.id));
if (selectedIds.every(id => visibleIds.has(id))) {
return;
}
setRowSelection(prev => {
const next: RowSelectionState = {};
for (const id of Object.keys(prev)) {
if (visibleIds.has(id)) {
next[id] = prev[id];
}
}
return next;
});
}, [filters, sorting, pagination, data, table, rowSelection]);
// Surface selection changes to consumers.
useEffect(() => {
if (!onSelectionChange) {
return;
}
const selected = table.getSelectedRowModel().rows.map(r => r.original as TData);
onSelectionChange(selected);
}, [rowSelection, onSelectionChange, table]);
return (
<DataTableContext.Provider value={{ table } as DataTableContextValue<unknown>}>
<ResponsiveColumnsProvider>{children}</ResponsiveColumnsProvider>
</DataTableContext.Provider>
);
}
type TableProps = {
children?: ReactNode;
/**
* Optional className applied to the outer bordered container.
*/
className?: string;
/**
* Optional className applied to the underlying <table> in both header and body tables.
*/
tableClassName?: string;
};
/**
* Layout shell that renders:
* - A header table (column headers + optional filter row).
* - A scrollable body table.
*
* Consumers pass <DataTable.Header />, <DataTable.FilterRow />, and <DataTable.Body />
* as children; this component wires them into the correct structure.
*/
function Table<TData>({ children, className, tableClassName }: TableProps) {
const { table } = useDataTable<TData>();
const wrapperRef = useRef<HTMLDivElement>(null);
// Drive responsive column visibility from the wrapper's width. The hook
// is a no-op for tables whose columns don't declare meta.priority, and
// publishes the "unfit" set through ResponsiveColumnsProvider (rendered
// by DataTableRoot above us in the tree).
useResponsiveColumns(table, wrapperRef);
const rows =
typeof table.getPaginationRowModel === 'function'
? table.getPaginationRowModel().rows
: table.getRowModel().rows;
const isEmpty = rows.length === 0;
const renderColGroup = useCallback(
() => (
<colgroup>
{table.getVisibleLeafColumns().map(col => {
const meta = (col.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const minWidth = meta?.minWidth;
return minWidth ? (
<col
key={col.id}
style={{
width: typeof minWidth === 'number' ? `${minWidth}px` : minWidth,
}}
/>
) : (
<col key={col.id} />
);
})}
</colgroup>
),
[table]
);
let headerChild: ReactElement | null = null;
let filterRowChild: ReactElement | null = null;
let bodyChild: ReactElement | null = null;
Children.forEach(children, child => {
if (!isValidElement(child)) {
return;
}
if (child.type === Header) {
headerChild = child;
}
if (child.type === FilterRow) {
filterRowChild = child;
}
if (child.type === Body) {
bodyChild = child;
}
});
return (
<div
ref={wrapperRef}
className={cn('border-input/50 min-h-0 flex-1 rounded-md border', className)}
>
<div className="flex h-full flex-col">
{/* Header + filter row */}
<div className="border-input/50 shrink-0 border-b">
<BasicTable
className={cn('table-fixed', tableClassName)}
containerClassName="overflow-x-hidden"
noScroll
>
{renderColGroup()}
{headerChild}
<BasicTableBody>{filterRowChild}</BasicTableBody>
</BasicTable>
</div>
{/* Scrollable body */}
<div className="min-h-0 flex-1">
<ScrollArea className={cn('h-full', isEmpty && '[&>div>div]:!h-full')}>
<BasicTable
className={cn('h-full table-fixed', tableClassName)}
containerClassName="h-full"
noScroll
>
{renderColGroup()}
<BasicTableBody>{bodyChild}</BasicTableBody>
</BasicTable>
</ScrollArea>
</div>
</div>
</div>
);
}
/**
* Renders the table header row(s) based on the current table instance.
* Applies meta.headerClassName and a muted background to match StudyList styling.
*/
function Header<TData>() {
const { table } = useDataTable<TData>();
return (
<BasicTableHeader>
{table.getHeaderGroups().map(headerGroup => (
<BasicTableRow key={headerGroup.id}>
{headerGroup.headers.map(header => {
const meta = (header.column.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const headerClassName = meta?.headerClassName ?? '';
const sortState = header.column.getIsSorted() as false | 'asc' | 'desc';
return (
<BasicTableHead
key={header.id}
className={cn('bg-muted', headerClassName)}
aria-sort={
sortState === 'asc' ? 'ascending' : sortState === 'desc' ? 'descending' : 'none'
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</BasicTableHead>
);
})}
</BasicTableRow>
))}
</BasicTableHeader>
);
}
type RowProps<TData> = {
render?: (row: Row<TData>) => ReactNode;
onClick?: (row: Row<TData>) => void;
onDoubleClick?: (row: Row<TData>) => void;
className?: string | ((row: Row<TData>) => string);
};
type BodyProps<TData> = {
rowProps?: RowProps<TData>;
/**
* Message shown when there are no rows to render.
*/
emptyMessage?: string;
/**
* When true and there are no rows, show loadingComponent centered instead of emptyMessage.
*/
isLoading?: boolean;
/**
* Rendered in the empty body when isLoading is true (e.g. customization loading indicator).
*/
loadingComponent?: ReactNode;
};
/**
* Core body renderer. Keeps awareness of selection state via data-state="selected".
* Automatically uses pagination if getPaginationRowModel is configured on the table.
* Consumers can either rely on the default row renderer or provide a custom one.
*/
function Body<TData>({
rowProps,
emptyMessage,
isLoading,
loadingComponent,
}: BodyProps<TData>) {
const { t } = useTranslation('DataTable');
const resolvedEmptyMessage = emptyMessage ?? t('No results.');
const { table } = useDataTable<TData>();
// Automatically determine if pagination should be used
// Use pagination if getPaginationRowModel is defined (pagination is configured)
const rows =
typeof table.getPaginationRowModel === 'function'
? table.getPaginationRowModel().rows
: table.getRowModel().rows;
if (!rows.length) {
if (isLoading && loadingComponent) {
return (
<BasicTableRow className="hover:bg-transparent hover:text-inherit hover:[&>td]:text-inherit hover:[&>th]:text-inherit">
<BasicTableCell colSpan={table.getAllLeafColumns().length}>
<div className="flex h-full w-full items-center justify-center">{loadingComponent}</div>
</BasicTableCell>
</BasicTableRow>
);
}
return (
<BasicTableRow className="hover:bg-transparent hover:text-inherit hover:[&>td]:text-inherit hover:[&>th]:text-inherit">
<BasicTableCell
colSpan={table.getAllLeafColumns().length}
className="!pt-10 text-center align-text-top"
>
{resolvedEmptyMessage}
</BasicTableCell>
</BasicTableRow>
);
}
return (
<>
{rows.map(row => {
const customRender = rowProps?.render?.(row);
if (customRender) {
return customRender;
}
// Default row rendering
return (
<BasicTableRow
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
className={
rowProps?.className
? typeof rowProps.className === 'function'
? rowProps.className(row)
: rowProps.className
: ''
}
{...(rowProps?.onClick && { onClick: () => rowProps.onClick(row) })}
{...(rowProps?.onDoubleClick && {
onDoubleClick: () => rowProps.onDoubleClick(row),
})}
aria-selected={row.getIsSelected()}
>
{row.getVisibleCells().map(cell => {
const metaClass =
(cell.column.columnDef.meta as ColumnMeta | undefined)?.cellClassName ?? '';
return (
<BasicTableCell
key={cell.id}
className={metaClass}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</BasicTableCell>
);
})}
</BasicTableRow>
);
})}
</>
);
}
const DataTable = Object.assign(DataTableRoot, {
Toolbar,
Title,
Pagination,
ViewOptions,
Table,
Header,
FilterRow,
Body,
ColumnHeader,
ActionOverlayCell,
});
export { DataTable };

View File

@ -0,0 +1,52 @@
import * as React from 'react';
import { TableRow, TableCell } from '../Table';
import { Input } from '../Input';
import { useDataTable } from './context';
export type FilterRowProps = {
excludeColumnIds?: string[];
renderFilterCell?: (opts: {
columnId: string;
value: unknown;
setValue: (v: unknown) => void;
}) => React.ReactNode;
};
export function FilterRow<TData>({ excludeColumnIds = [], renderFilterCell }: FilterRowProps) {
const { table } = useDataTable<TData>();
const cols = table.getVisibleLeafColumns();
return (
<TableRow
data-filter-row
className="hover:bg-transparent"
>
{cols.map(col => {
const id = col.id;
const value = table.getColumn(id)?.getFilterValue();
const setValue = (v: unknown) => table.getColumn(id)?.setFilterValue(v);
if (excludeColumnIds?.includes(id)) {
return <TableCell key={id} />;
}
const customRender = renderFilterCell?.({ columnId: id, value, setValue });
if (customRender) {
return <TableCell key={id}>{customRender}</TableCell>;
}
// Default cell rendering
return (
<TableCell key={id}>
<Input
value={(value as string) ?? ''}
onChange={e => setValue(e.target.value)}
className="h-7 w-full"
/>
</TableCell>
);
})}
</TableRow>
);
}

View File

@ -0,0 +1,78 @@
import * as React from 'react';
import { Button } from '../Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '../DropdownMenu';
import { Icons } from '../Icons';
import { useDataTable } from './context';
/**
* Pagination
* Renders "start-end of total" and ghost chevron buttons for prev/next.
* Uses the TanStack table instance from DataTable context.
*/
export function Pagination<TData>() {
const { table } = useDataTable<TData>();
const { pageIndex, pageSize } = table.getState().pagination ?? { pageIndex: 0, pageSize: 50 };
const total = table.getFilteredRowModel().rows.length;
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min(total, (pageIndex + 1) * pageSize);
const canPrev = table.getCanPreviousPage();
const canNext = table.getCanNextPage();
return (
<div className="mr-2 flex items-center gap-0.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-primary/80 px-2 text-sm leading-tight"
aria-label="Rows per page"
>
{`${start}-${end} of ${total}`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{[25, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onSelect={e => {
e.preventDefault();
table.setPageSize(size);
}}
className="flex items-center gap-[2px]"
>
<Icons.Checked className={`h-6 w-6 ${pageSize === size ? '' : 'invisible'}`} />
{size} per page
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
aria-label="Previous page"
onClick={() => table.previousPage()}
disabled={!canPrev}
className="ml-1"
>
<Icons.ChevronLeft className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Next page"
onClick={() => table.nextPage()}
disabled={!canNext}
>
<Icons.ChevronRight className="h-3 w-3" />
</Button>
</div>
);
}

View File

@ -0,0 +1,5 @@
import * as React from 'react';
export function Title({ children }: { children?: React.ReactNode }) {
return <div className="text-primary text-[20px] font-medium">{children}</div>;
}

View File

@ -0,0 +1,5 @@
import * as React from 'react';
export function Toolbar({ children }: { children?: React.ReactNode }) {
return <div className="relative flex items-center justify-center py-4">{children}</div>;
}

View File

@ -0,0 +1,76 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
} from '../DropdownMenu';
import { Icons } from '../Icons';
import { Tooltip, TooltipContent, TooltipTrigger } from '../Tooltip';
import { useDataTable } from './context';
import { useUnfitColumnIds } from './useResponsiveColumns';
import type { ColumnMeta } from './types';
type ViewOptionsProps = {
buttonText?: string;
};
export function ViewOptions<TData>({ buttonText = 'View' }: ViewOptionsProps) {
const { t } = useTranslation('DataTable');
const { table } = useDataTable<TData>();
const unfitColumnIds = useUnfitColumnIds();
const columns = table.getAllColumns().filter(c => c.getCanHide());
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1 text-sm"
>
{buttonText}
<Icons.ChevronDown className="h-2 w-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{columns.map(column => {
const meta = (column.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const label = meta?.label ?? column.id;
const isUnfit = !column.getIsVisible() && unfitColumnIds.has(column.id);
const checkbox = (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
disabled={isUnfit}
onCheckedChange={v => column.toggleVisibility(!!v)}
className="capitalize"
>
{label}
</DropdownMenuCheckboxItem>
);
// Radix tooltips don't fire on a disabled descendant (no pointer
// events). Wrap in a span — matching the ToolButton pattern in this
// package — so the trigger element itself is enabled.
if (isUnfit) {
return (
<Tooltip key={column.id}>
<TooltipTrigger asChild>
<span>{checkbox}</span>
</TooltipTrigger>
<TooltipContent side="left">
{t('Not enough room to display this column')}
</TooltipContent>
</Tooltip>
);
}
return <React.Fragment key={column.id}>{checkbox}</React.Fragment>;
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { Table } from '@tanstack/react-table';
export type DataTableContextValue<TData> = {
table: Table<TData>;
};
// React Context cannot be generic, so we use 'unknown' as the base type
// The generic type is properly restored by useDataTable<TData>() via type assertion
const DataTableContext = createContext<DataTableContextValue<unknown> | null>(null);
export function useDataTable<TData>() {
const ctx = useContext(DataTableContext);
if (!ctx) {
throw new Error('useDataTable must be used within a <DataTable> provider');
}
return ctx as DataTableContextValue<TData>;
}
export { DataTableContext };

View File

@ -0,0 +1,4 @@
// Core compound table + hook
export { DataTable } from './DataTable';
export type { DataTableProps } from './DataTable';
export { useDataTable } from './context';

View File

@ -0,0 +1,27 @@
import * as React from 'react';
/**
* Metadata type for DataTable columns.
* This type defines the structure of the `meta` property in TanStack Table column definitions.
*/
export type ColumnMeta = {
/** Required label for the column (used by ViewOptions and as fallback for header) */
label: string;
/** Optional custom React node to render in the header instead of the label */
headerContent?: React.ReactNode;
/** Optional alignment for the column content */
align?: 'left' | 'center' | 'right';
/** Optional CSS class name for the header cell */
headerClassName?: string;
/** Optional CSS class name for the body cells in this column */
cellClassName?: string;
/** Optional minimum width for the column (can be a number or CSS string) */
minWidth?: number | string;
/**
* Drop priority for responsive column visibility. Setting this opts the
* column in to responsive dropping; columns without a priority are never
* auto-hidden. Among opted-in columns, higher values are kept visible
* longer when container width shrinks; lower values are dropped first.
*/
priority?: number;
};

View File

@ -0,0 +1,348 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import type { Table as TanStackTable, VisibilityState } from '@tanstack/react-table';
import type { ColumnMeta } from './types';
// Default extra pixels required before re-showing a previously-hidden column on
// grow. Prevents oscillation at the threshold.
const DEFAULT_REGROW_SLACK_PX = 12;
type ColumnSizing = {
id: string;
minWidth: number;
priority: number;
alwaysVisible: boolean;
};
type ResponsiveColumnsContextValue = {
unfitColumnIds: Set<string>;
setUnfitColumnIds: React.Dispatch<React.SetStateAction<Set<string>>>;
};
const ResponsiveColumnsContext = createContext<ResponsiveColumnsContextValue | null>(null);
function useResponsiveColumnsContext(): ResponsiveColumnsContextValue {
const ctx = useContext(ResponsiveColumnsContext);
if (!ctx) {
throw new Error(
'useResponsiveColumns/useUnfitColumnIds must be used within a <ResponsiveColumnsProvider>'
);
}
return ctx;
}
/**
* Holds the responsive-layout state for a single DataTable instance.
* Rendered by `DataTableRoot` so the writer (`useResponsiveColumns`) and
* reader (`useUnfitColumnIds`) can communicate.
*/
export function ResponsiveColumnsProvider({ children }: { children: ReactNode }) {
const [unfitColumnIds, setUnfitColumnIds] = useState<Set<string>>(() => new Set());
const value = useMemo(() => ({ unfitColumnIds, setUnfitColumnIds }), [unfitColumnIds]);
return (
<ResponsiveColumnsContext.Provider value={value}>{children}</ResponsiveColumnsContext.Provider>
);
}
/**
* Returns the set of column ids the responsive layout has determined don't
* fit at the current table width. Consumed by `ViewOptions` to disable
* toggles whose effect would be immediately reverted.
*/
export function useUnfitColumnIds(): Set<string> {
return useResponsiveColumnsContext().unfitColumnIds;
}
type ComputeColumnVisibilityResult = {
/** Column ids hidden in the applied output (algorithm-dropped or user-hidden). */
hiddenIds: Set<string>;
/**
* Column ids whose View-menu toggle would have no visible effect right
* now i.e. the algorithm would immediately re-hide them. A user-hidden
* column is unfit if lifting just *its* override wouldn't let it fit at
* its position in the walk (other user-hidden columns remain hidden). A
* non-user-hidden column is unfit if the algorithm has dropped it.
*/
unfitIds: Set<string>;
};
/**
* Walk droppable columns in priority desc (tiebreaking by minWidth asc) and
* compute both the applied visibility (after strict-priority drops and
* user-hidden overrides) and the "unfit" set used by the View menu.
*
* Strict-priority drop rule: the first column whose minWidth (plus regrow
* slack, if it was hidden on the previous run) doesn't fit in the remaining
* budget is dropped and so is every lower-priority column after it, even
* if some of them would have fit on their own.
*
* `isUserHidden` items are hidden in the applied output without consuming
* budget or starting the drop, so hiding a mid-priority column via the View
* menu doesn't force every lower-priority column down with it.
*
* `wasHidden` reports each id's hidden state on the previous run; this
* controls regrow hysteresis.
*/
function computeColumnVisibility(
droppableColumns: ColumnSizing[],
budget: number,
isUserHidden: (id: string) => boolean,
wasHidden: (id: string) => boolean
): ComputeColumnVisibilityResult {
const hiddenIds = new Set<string>();
const unfitIds = new Set<string>();
let cumulative = 0;
let cascading = false;
for (const sizing of droppableColumns) {
const regrowSlack = wasHidden(sizing.id) ? DEFAULT_REGROW_SLACK_PX : 0;
// Would this column fit at this exact position in the walk? Used to
// answer "could the user re-show it?" for user-hidden columns and to
// decide when to start dropping the rest.
const fits = !cascading && cumulative + sizing.minWidth + regrowSlack <= budget;
if (isUserHidden(sizing.id)) {
hiddenIds.add(sizing.id);
if (!fits) {
unfitIds.add(sizing.id);
}
continue;
}
if (!fits) {
// Either dropping already started, or this column starts it.
hiddenIds.add(sizing.id);
unfitIds.add(sizing.id);
cascading = true;
continue;
}
// Fits — visible, consumes budget.
cumulative += sizing.minWidth;
}
return { hiddenIds, unfitIds };
}
function idSetsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) {
return false;
}
for (const id of a) {
if (!b.has(id)) {
return false;
}
}
return true;
}
/**
* Drives responsive column visibility from a wrapper element's width.
*
* Setting `meta.priority` opts a column in to responsive dropping; columns
* without a priority (or marked `enableHiding: false`) are always visible
* and consume their `meta.minWidth` from the budget unconditionally. Among
* the opted-in columns, higher priority is dropped last.
*
* Strict-priority drop rule: walk droppable columns by priority desc,
* including each that fits the remaining budget. The first column that
* doesn't fit and every lower-priority column after it is dropped, even
* if some would have fit individually. This guarantees that on shrink
* columns can only disappear (never reappear) and on grow they return in
* the reverse of the order they were dropped.
*
* User overrides via the View menu are tracked separately: a column the
* user hides is added to a userHidden set and stays hidden through subsequent
* width changes. Re-showing a column via the View menu clears its entry; the
* algorithm may still drop it for space on the next shrink.
*
* Publishes the "unfit" set hidden column ids whose View-menu toggle would
* have no visible effect because the algorithm would immediately re-hide
* them through `ResponsiveColumnsProvider`. The check is per-column: a
* user-hidden column is unfit only if lifting *its* override (with other
* user overrides intact) wouldn't free enough budget for it to fit.
*
* This hook must be used inside a `<ResponsiveColumnsProvider>`.
*/
export function useResponsiveColumns<TData>(
table: TanStackTable<TData>,
wrapperRef: React.RefObject<HTMLElement | null>
): void {
const { setUnfitColumnIds } = useResponsiveColumnsContext();
// Snapshot of the visibility map the algorithm last applied. Used to detect
// user-driven toggles by diffing against the table's current state.
const lastColumnVisibilityRef = useRef<VisibilityState>({});
// Column ids the user has explicitly hidden via the View menu.
const userHiddenRef = useRef<Set<string>>(new Set());
// The last "unfit" set we published. Used to diff against the next run so
// we only call setUnfitColumnIds when the set actually changes.
const lastUnfitColumnIdsRef = useRef<Set<string>>(new Set());
// Snapshot column sizing data once per columns change. TanStack memoizes
// getAllLeafColumns() internally, so the array reference is stable across
// renders until the column definitions themselves change.
const leafColumns = table.getAllLeafColumns();
const columnSizings = useMemo<ColumnSizing[]>(() => {
return leafColumns.map(col => {
const meta = (col.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const minWidth = typeof meta?.minWidth === 'number' ? meta.minWidth : 0;
const enableHiding = col.columnDef.enableHiding !== false;
const hasPriority = typeof meta?.priority === 'number';
return {
id: col.id,
minWidth,
priority: hasPriority ? (meta!.priority as number) : 0,
// Setting meta.priority opts a column in to responsive hiding;
// enableHiding: false opts it out. Columns without a priority are
// treated as always-visible and consume their minWidth from the budget.
alwaysVisible: !enableHiding || !hasPriority,
};
});
}, [leafColumns]);
// Droppable columns sorted in strict priority order (highest first), with
// minWidth ascending as a tiebreaker. computeColumnVisibility walks this list.
const droppableColumns = useMemo<ColumnSizing[]>(
() =>
columnSizings
.filter(s => !s.alwaysVisible)
.sort((a, b) => b.priority - a.priority || a.minWidth - b.minWidth),
[columnSizings]
);
// Guards the first algorithm invocation so we don't mistake the table's
// initial visibility for a user override.
const isFirstRunRef = useRef(true);
const runAlgorithm = useCallback(
(containerWidth: number) => {
if (droppableColumns.length === 0) {
return;
}
if (isFirstRunRef.current) {
// Seed the "last applied" snapshot with the table's current state so
// any initialVisibility from the consumer is treated as the algorithm's
// baseline, not as a user override.
lastColumnVisibilityRef.current = { ...table.getState().columnVisibility };
isFirstRunRef.current = false;
} else {
// Diff the table's current visibility against what we last applied;
// any divergence is a user toggle (via DataTable.ViewOptions).
const current = table.getState().columnVisibility;
const last = lastColumnVisibilityRef.current;
for (const sizing of droppableColumns) {
// Default visibility when a key is absent from VisibilityState is true.
const currentVisible = current[sizing.id] !== false;
const lastVisible = last[sizing.id] !== false;
if (currentVisible === lastVisible) {
continue;
}
if (currentVisible) {
// User re-showed a column: clear stickiness so the algorithm
// can manage it again. May still be dropped on the next shrink.
userHiddenRef.current.delete(sizing.id);
} else {
// User hid a column: remember so we don't auto-restore on grow.
userHiddenRef.current.add(sizing.id);
}
}
}
// Always-visible columns consume budget unconditionally; the walk
// operates on what remains.
let budget = containerWidth;
for (const sizing of columnSizings) {
if (sizing.alwaysVisible) {
budget -= sizing.minWidth;
}
}
// Single walk produces both the applied visibility (honoring user-
// hidden columns) and the unfit set (columns whose View-menu toggle
// would have no effect because the algorithm would immediately re-hide
// them).
const lastVisibility = lastColumnVisibilityRef.current;
const { hiddenIds: appliedHidden, unfitIds: nextUnfit } = computeColumnVisibility(
droppableColumns,
budget,
(id: string) => userHiddenRef.current.has(id),
(id: string) => lastVisibility[id] === false
);
// Build and apply the visibility map.
const nextVisibility: VisibilityState = {};
for (const sizing of droppableColumns) {
nextVisibility[sizing.id] = !appliedHidden.has(sizing.id);
}
const currentVisibility = table.getState().columnVisibility;
let appliedChanged = false;
for (const key of Object.keys(nextVisibility)) {
if ((currentVisibility[key] !== false) !== (nextVisibility[key] !== false)) {
appliedChanged = true;
break;
}
}
lastColumnVisibilityRef.current = nextVisibility;
if (appliedChanged) {
table.setColumnVisibility(prev => ({ ...prev, ...nextVisibility }));
}
// Publish the unfit set if it changed.
const lastUnfit = lastUnfitColumnIdsRef.current;
lastUnfitColumnIdsRef.current = nextUnfit;
if (!idSetsEqual(lastUnfit, nextUnfit)) {
setUnfitColumnIds(nextUnfit);
}
},
[table, columnSizings, droppableColumns, setUnfitColumnIds]
);
// Track and react to the table's width.
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || droppableColumns.length === 0) {
return;
}
runAlgorithm(wrapper.clientWidth);
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
runAlgorithm(entry.contentRect.width);
}
});
observer.observe(wrapper);
return () => observer.disconnect();
}, [runAlgorithm, wrapperRef, droppableColumns]);
// Re-run when columnVisibility changes from outside (e.g. a user toggle via
// the View menu). Without this, a user-hide that frees budget for a lower-
// priority column wouldn't take effect until the next resize. The diffs
// inside runAlgorithm make this a no-op when the change was algorithm-
// driven, so there's no feedback loop.
//
// Skipped on the initial mount: the width-driven effect above has already
// run the algorithm by this point, but table.getState() still reflects the
// pre-commit state (TanStack updates only when the parent re-renders),
// which would make the diff misinterpret the algorithm's own output as a
// user override. We only want this effect to react to subsequent changes.
const hasMountedRef = useRef(false);
const columnVisibility = table.getState().columnVisibility;
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
}
const wrapper = wrapperRef.current;
if (!wrapper || droppableColumns.length === 0) {
return;
}
runAlgorithm(wrapper.clientWidth);
}, [columnVisibility, runAlgorithm, wrapperRef, droppableColumns]);
}

View File

@ -83,32 +83,36 @@ export function DatePickerWithRange({
<Popover.Popover>
<Popover.PopoverTrigger asChild>
<div className="relative w-full">
<CalendarIcon className="text-foreground absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform" />
{!start && (
<CalendarIcon className="text-primary absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform" />
)}
<input
id={`${id}-start`}
type="text"
placeholder={t('Start Date', 'Start date')}
placeholder={t('Start', 'Start')}
autoComplete="off"
value={start}
onChange={e => handleInputChange(e, 'start')}
className={cn(
'border-inputfield-main focus:border-inputfield-focus hover:text-foreground h-[32px] w-full justify-start rounded border bg-background py-[6.5px] pl-[6.5px] pr-[6.5px] text-left text-base font-normal hover:bg-background',
!start && 'text-muted-foreground'
'border-inputfield-main focus:border-inputfield-focus hover:text-foreground placeholder:text-muted-foreground h-7 w-full justify-start rounded border bg-background pl-1.5 pr-0.5 py-1 text-left text-base font-normal hover:bg-background'
)}
data-cy="input-date-range-start"
/>
</div>
</Popover.PopoverTrigger>
<Popover.PopoverContent
className="w-auto p-0"
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
initialFocus
autoFocus
mode="single"
captionLayout="dropdown"
defaultMonth={start ? parse(start, 'yyyy-MM-dd', new Date()) : new Date()}
selected={start ? parse(start, 'yyyy-MM-dd', new Date()) : undefined}
onSelect={handleStartSelect}
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 1, 11)}
numberOfMonths={1}
/>
</Popover.PopoverContent>
@ -120,32 +124,36 @@ export function DatePickerWithRange({
>
<Popover.PopoverTrigger asChild>
<div className="relative w-full">
<CalendarIcon className="text-foreground absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform" />
{!end && (
<CalendarIcon className="text-primary absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform" />
)}
<input
id={`${id}-end`}
type="text"
placeholder={t('End Date', 'End date')}
placeholder={t('End', 'End')}
autoComplete="off"
value={end}
onChange={e => handleInputChange(e, 'end')}
className={cn(
'border-inputfield-main focus:border-inputfield-focus hover:text-foreground h-full w-full justify-start rounded border bg-background py-[6.5px] pl-[6.5px] pr-[6.5px] text-left text-base font-normal hover:bg-background',
!end && 'text-muted-foreground'
'border-inputfield-main focus:border-inputfield-focus hover:text-foreground placeholder:text-muted-foreground h-7 w-full justify-start rounded border bg-background pl-1.5 pr-0.5 py-1 text-left text-base font-normal hover:bg-background'
)}
data-cy="input-date-range-end"
/>
</div>
</Popover.PopoverTrigger>
<Popover.PopoverContent
className="w-auto p-0"
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
initialFocus
autoFocus
mode="single"
captionLayout="dropdown"
defaultMonth={start ? parse(start, 'yyyy-MM-dd', new Date()) : new Date()}
selected={end ? parse(end, 'yyyy-MM-dd', new Date()) : undefined}
onSelect={handleEndSelect}
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 1, 11)}
numberOfMonths={1}
/>
</Popover.PopoverContent>

View File

@ -34,7 +34,9 @@ import More from './Sources/More';
import MultiplePatients from './Sources/MultiplePatients';
import NavigationPanelReveal from './Sources/NavigationPanelReveal';
import OHIFLogo from './Sources/OHIFLogo';
import OHIFLogoHorizontal from './Sources/OHIFLogoHorizontal';
import Patient from './Sources/Patient';
import PatientStudyList from './Sources/PatientStudyList';
import Pin from './Sources/Pin';
import PinFill from './Sources/PinFill';
import Plus from './Sources/Plus';
@ -43,6 +45,7 @@ import Redo from './Sources/Redo';
import Refresh from './Sources/Refresh';
import Rename from './Sources/Rename';
import Series from './Sources/Series';
import SeriesPlaceholder from './Sources/SeriesPlaceholder';
import Settings from './Sources/Settings';
import Show from './Sources/Show';
import SidePanelCloseLeft from './Sources/SidePanelCloseLeft';
@ -50,6 +53,9 @@ import SidePanelCloseRight from './Sources/SidePanelCloseRight';
import SortingAscending from './Sources/SortingAscending';
import SocialGithub from './Sources/SocialGithub';
import SortingDescending from './Sources/SortingDescending';
import SortingNew from './Sources/SortingNew';
import SortingNewAscending from './Sources/SortingNewAscending';
import SortingNewDescending from './Sources/SortingNewDescending';
import StatusError from './Sources/StatusError';
import StatusSuccess from './Sources/StatusSuccess';
import StatusTracking from './Sources/StatusTracking';
@ -157,9 +163,11 @@ import ContentPrev from './Sources/ContentPrev';
import ContentNext from './Sources/ContentNext';
import CheckBoxChecked from './Sources/CheckBoxChecked';
import CheckBoxUnchecked from './Sources/CheckBoxUnChecked';
import CloudSettings from './Sources/CloudSettings';
import Close from './Sources/Close';
import Pause from './Sources/Pause';
import Play from './Sources/Play';
import PanelRight from './Sources/PanelRight';
import ViewportWindowLevel from './Sources/ViewportWindowLevel';
import Search from './Sources/Search';
import Clear from './Sources/Clear';
@ -233,6 +241,7 @@ import StatusAlert from './Sources/StatusAlert';
import Undo from './Sources/Undo';
import TabContours from './Sources/TabContours';
import IllustrationNotFound from './Sources/IllustrationNotFound';
import SettingsStudyList from './Sources/SettingsStudyList';
//
//
type IconProps = React.HTMLAttributes<SVGElement>;
@ -422,6 +431,7 @@ export const Icons = {
OrientationSwitchR,
Checked,
Clipboard,
CloudSettings,
ActionNewDialog,
GroupLayers,
Database,
@ -508,7 +518,9 @@ export const Icons = {
MultiplePatients,
NavigationPanelReveal,
OHIFLogo,
OHIFLogoHorizontal,
Patient,
PatientStudyList,
Pin,
PinFill,
Plus,
@ -516,14 +528,20 @@ export const Icons = {
Refresh,
Rename,
Series,
SeriesPlaceholder,
Settings,
SettingsStudyList,
Show,
SidePanelCloseLeft,
SidePanelCloseRight,
PanelRight,
SocialGithub,
SortingAscending,
SortingDescending,
Sorting,
SortingNew,
SortingNewAscending,
SortingNewDescending,
StatusError,
StatusSuccess,
StatusTracking,
@ -650,6 +668,7 @@ export const Icons = {
magnifier: (props: IconProps) => Magnifier(props),
'status-alert-warning': (props: IconProps) => StatusWarning(props),
'logo-dark-background': (props: IconProps) => OHIFLogoColorDarkBackground(props),
'ohif-logo-horizontal': (props: IconProps) => OHIFLogoHorizontal(props),
'external-link': (props: IconProps) => ExternalLink(props),
'checkbox-checked': (props: IconProps) => CheckBoxChecked(props),
'checkbox-unchecked': (props: IconProps) => CheckBoxUnchecked(props),
@ -692,6 +711,7 @@ export const Icons = {
'power-off': (props: IconProps) => PowerOff(props),
'icon-multiple-patients': (props: IconProps) => MultiplePatients(props),
'icon-patient': (props: IconProps) => Patient(props),
'panel-right': (props: IconProps) => PanelRight(props),
'chevron-down': (props: IconProps) => ChevronOpen(props),
'tool-length': (props: IconProps) => ToolLength(props),
'tool-3d-rotate': (props: IconProps) => Tool3DRotate(props),
@ -760,6 +780,8 @@ export const Icons = {
'content-prev': (props: IconProps) => ContentPrev(props),
'content-next': (props: IconProps) => ContentNext(props),
'icon-settings': (props: IconProps) => Settings(props),
'settings-study-list': (props: IconProps) => SettingsStudyList(props),
'cloud-settings': (props: IconProps) => CloudSettings(props),
close: (props: IconProps) => Close(props),
pause: (props: IconProps) => Pause(props),
'icon-pause': (props: IconProps) => Pause(props),

View File

@ -0,0 +1,34 @@
import React from 'react';
import type { IconProps } from '../types';
export const CloudSettings = (props: IconProps) => (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M14.125 12.625H15.25C15.25 12.625 18.625 12.1855 18.625 8.875C18.6254 8.37127 18.5241 7.87265 18.3271 7.40901C18.1302 6.94536 17.8417 6.52623 17.479 6.17672C17.1163 5.82721 16.6867 5.55451 16.2161 5.37495C15.7454 5.19539 15.2434 5.11267 14.74 5.13175C14.2436 4.07779 13.4101 3.21927 12.3713 2.69184C11.3325 2.16441 10.1476 1.99814 9.00376 2.21928C7.85992 2.44043 6.82237 3.03639 6.05502 3.91302C5.28768 4.78964 4.8343 5.89695 4.7665 7.06C4.35698 6.97399 3.93343 6.98042 3.52671 7.07882C3.11999 7.17722 2.74035 7.36511 2.41544 7.6288C2.09052 7.8925 1.82853 8.22535 1.64854 8.60312C1.46855 8.98089 1.3751 9.39405 1.375 9.8125C1.3418 10.468 1.55602 11.1121 1.97524 11.6172C2.39445 12.1222 2.98807 12.4514 3.6385 12.5395"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.83074 14.2699C9.3602 14.2699 9.78941 13.8407 9.78941 13.3112C9.78941 12.7818 9.3602 12.3526 8.83074 12.3526C8.30128 12.3526 7.87207 12.7818 7.87207 13.3112C7.87207 13.8407 8.30128 14.2699 8.83074 14.2699Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.64474 9.60392L9.92741 10.5319C9.97374 10.6862 10.077 10.817 10.2163 10.8979C10.3556 10.9787 10.5205 11.0035 10.6774 10.9673L11.6181 10.7493C11.7964 10.7091 11.9828 10.727 12.1501 10.8005C12.3175 10.874 12.4568 10.9992 12.5479 11.1577C12.6389 11.3161 12.6767 11.4996 12.6559 11.6812C12.6351 11.8627 12.5566 12.0328 12.4321 12.1666L11.7734 12.8759C11.6647 12.9941 11.6044 13.1487 11.6044 13.3093C11.6044 13.4698 11.6647 13.6244 11.7734 13.7426L12.4314 14.4526C12.556 14.5863 12.6344 14.7564 12.6552 14.938C12.6761 15.1196 12.6382 15.303 12.5472 15.4615C12.4562 15.62 12.3168 15.7451 12.1495 15.8186C11.9822 15.8921 11.7957 15.9101 11.6174 15.8699L10.6767 15.6519C10.52 15.6169 10.3557 15.6427 10.2173 15.7242C10.0788 15.8056 9.97638 15.9366 9.93074 16.0906L9.64807 17.0186C9.59546 17.1936 9.48787 17.3469 9.34125 17.4559C9.19463 17.565 9.01678 17.6238 8.83407 17.6238C8.65136 17.6238 8.47352 17.565 8.3269 17.4559C8.18028 17.3469 8.07269 17.1936 8.02007 17.0186L7.73407 16.0906C7.68774 15.9363 7.58444 15.8055 7.44514 15.7246C7.30585 15.6438 7.141 15.619 6.98407 15.6553L6.04341 15.8733C5.86513 15.9134 5.67866 15.8955 5.51134 15.822C5.34402 15.7485 5.20464 15.6233 5.11362 15.4648C5.02261 15.3064 4.98474 15.1229 5.00558 14.9413C5.02642 14.7598 5.10486 14.5897 5.22941 14.4559L5.88741 13.7459C5.9961 13.6278 6.05643 13.4731 6.05643 13.3126C6.05643 13.1521 5.9961 12.9974 5.88741 12.8793L5.22941 12.1693C5.10486 12.0355 5.02642 11.8654 5.00558 11.6838C4.98474 11.5023 5.02261 11.3188 5.11362 11.1603C5.20464 11.0019 5.34402 10.8767 5.51134 10.8032C5.67866 10.7297 5.86513 10.7117 6.04341 10.7519L6.98407 10.9699C7.14099 11.006 7.30576 10.9812 7.44502 10.9003C7.58428 10.8195 7.6876 10.6887 7.73407 10.5346L8.01674 9.60658C8.06906 9.43153 8.1764 9.27799 8.32285 9.16873C8.46929 9.05947 8.64704 9.0003 8.82975 9C9.01246 8.9997 9.1904 9.05828 9.3372 9.16706C9.484 9.27584 9.59184 9.42903 9.64474 9.60392Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default CloudSettings;

View File

@ -10,7 +10,6 @@ export const More = (props: IconProps) => (
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>icon-more</title>
<g
id="icon-more"
stroke="none"

View File

@ -0,0 +1,139 @@
import React from 'react';
import type { IconProps } from '../types';
export const OHIFLogoHorizontal = (props: IconProps) => (
<svg
width="233"
height="23"
viewBox="0 0 233 23"
fill="none"
{...props}
>
<path
d="M227.792 11.7344V16H226.702V8.90909H227.755V10.017H227.847C228.013 9.65696 228.266 9.36766 228.604 9.14915C228.943 8.92756 229.38 8.81676 229.915 8.81676C230.395 8.81676 230.816 8.91525 231.176 9.11222C231.536 9.30611 231.816 9.60156 232.016 9.99858C232.216 10.3925 232.316 10.8911 232.316 11.4943V16H231.226V11.5682C231.226 11.0111 231.082 10.5772 230.792 10.2663C230.503 9.95241 230.106 9.79545 229.601 9.79545C229.254 9.79545 228.943 9.87086 228.669 10.0217C228.398 10.1725 228.184 10.3925 228.027 10.6818C227.87 10.9711 227.792 11.322 227.792 11.7344Z"
fill="white"
/>
<path
d="M221.826 16.1477C221.186 16.1477 220.624 15.9954 220.141 15.6907C219.661 15.386 219.285 14.9598 219.015 14.4119C218.747 13.8641 218.613 13.224 218.613 12.4915C218.613 11.7528 218.747 11.1081 219.015 10.5572C219.285 10.0063 219.661 9.57848 220.141 9.27379C220.624 8.9691 221.186 8.81676 221.826 8.81676C222.466 8.81676 223.026 8.9691 223.507 9.27379C223.99 9.57848 224.365 10.0063 224.633 10.5572C224.904 11.1081 225.039 11.7528 225.039 12.4915C225.039 13.224 224.904 13.8641 224.633 14.4119C224.365 14.9598 223.99 15.386 223.507 15.6907C223.026 15.9954 222.466 16.1477 221.826 16.1477ZM221.826 15.169C222.312 15.169 222.712 15.0444 223.026 14.7951C223.34 14.5458 223.573 14.218 223.723 13.8118C223.874 13.4055 223.95 12.9654 223.95 12.4915C223.95 12.0175 223.874 11.5759 223.723 11.1665C223.573 10.7572 223.34 10.4264 223.026 10.174C222.712 9.92164 222.312 9.79545 221.826 9.79545C221.34 9.79545 220.94 9.92164 220.626 10.174C220.312 10.4264 220.08 10.7572 219.929 11.1665C219.778 11.5759 219.703 12.0175 219.703 12.4915C219.703 12.9654 219.778 13.4055 219.929 13.8118C220.08 14.218 220.312 14.5458 220.626 14.7951C220.94 15.0444 221.34 15.169 221.826 15.169Z"
fill="white"
/>
<path
d="M215.861 16V8.90909H216.95V16H215.861ZM216.414 7.72727C216.202 7.72727 216.019 7.65495 215.865 7.5103C215.714 7.36565 215.639 7.19176 215.639 6.98864C215.639 6.78551 215.714 6.61162 215.865 6.46697C216.019 6.32232 216.202 6.25 216.414 6.25C216.627 6.25 216.808 6.32232 216.959 6.46697C217.113 6.61162 217.19 6.78551 217.19 6.98864C217.19 7.19176 217.113 7.36565 216.959 7.5103C216.808 7.65495 216.627 7.72727 216.414 7.72727Z"
fill="white"
/>
<path
d="M214.222 8.90909V9.83239H210.547V8.90909H214.222ZM211.618 7.21023H212.707V13.9688C212.707 14.2765 212.752 14.5073 212.841 14.6612C212.934 14.812 213.051 14.9136 213.192 14.9659C213.337 15.0152 213.489 15.0398 213.649 15.0398C213.769 15.0398 213.868 15.0336 213.945 15.0213C214.022 15.0059 214.083 14.9936 214.129 14.9844L214.351 15.9631C214.277 15.9908 214.174 16.0185 214.042 16.0462C213.909 16.0769 213.741 16.0923 213.538 16.0923C213.231 16.0923 212.929 16.0262 212.634 15.8938C212.341 15.7615 212.098 15.5599 211.904 15.2891C211.713 15.0182 211.618 14.6766 211.618 14.2642V7.21023Z"
fill="white"
/>
<path
d="M205.899 16.1662C205.45 16.1662 205.042 16.0816 204.676 15.9123C204.31 15.7399 204.019 15.4922 203.803 15.169C203.588 14.8428 203.48 14.4489 203.48 13.9872C203.48 13.581 203.56 13.2517 203.72 12.9993C203.88 12.7438 204.094 12.5438 204.362 12.3991C204.63 12.2545 204.925 12.1468 205.248 12.076C205.575 12.0021 205.902 11.9437 206.232 11.9006C206.663 11.8452 207.012 11.8036 207.28 11.7759C207.55 11.7451 207.747 11.6944 207.87 11.6236C207.997 11.5528 208.06 11.4297 208.06 11.2543V11.2173C208.06 10.7618 207.935 10.4079 207.686 10.1555C207.44 9.90317 207.066 9.77699 206.564 9.77699C206.044 9.77699 205.636 9.89086 205.341 10.1186C205.045 10.3464 204.837 10.5895 204.717 10.848L203.683 10.4787C203.868 10.0478 204.114 9.71236 204.422 9.4723C204.733 9.22917 205.071 9.0599 205.438 8.96449C205.807 8.866 206.17 8.81676 206.527 8.81676C206.755 8.81676 207.016 8.84446 207.312 8.89986C207.61 8.95218 207.898 9.06143 208.175 9.22763C208.455 9.39382 208.688 9.64465 208.872 9.98011C209.057 10.3156 209.149 10.7649 209.149 11.3281V16H208.06V15.0398H208.004C207.931 15.1937 207.807 15.3583 207.635 15.5337C207.463 15.7092 207.233 15.8584 206.947 15.9815C206.661 16.1046 206.312 16.1662 205.899 16.1662ZM206.065 15.1875C206.496 15.1875 206.859 15.1029 207.155 14.9336C207.453 14.7643 207.678 14.5458 207.829 14.2781C207.983 14.0103 208.06 13.7287 208.06 13.4332V12.4361C208.014 12.4915 207.912 12.5423 207.755 12.5884C207.601 12.6315 207.423 12.67 207.22 12.7038C207.02 12.7346 206.824 12.7623 206.633 12.7869C206.446 12.8085 206.293 12.8269 206.176 12.8423C205.893 12.8793 205.628 12.9393 205.382 13.0224C205.139 13.1024 204.942 13.224 204.791 13.3871C204.644 13.5471 204.57 13.7656 204.57 14.0426C204.57 14.4212 204.71 14.7074 204.99 14.9013C205.273 15.0921 205.631 15.1875 206.065 15.1875Z"
fill="white"
/>
<path
d="M198.416 16.1477C197.825 16.1477 197.303 15.9985 196.851 15.6999C196.399 15.3983 196.045 14.9736 195.789 14.4258C195.534 13.8749 195.406 13.224 195.406 12.473C195.406 11.7282 195.534 11.0819 195.789 10.5341C196.045 9.98627 196.4 9.56309 196.856 9.26456C197.311 8.96603 197.837 8.81676 198.434 8.81676C198.896 8.81676 199.261 8.8937 199.529 9.04759C199.799 9.19839 200.006 9.37074 200.147 9.56463C200.292 9.75544 200.404 9.9124 200.484 10.0355H200.576V6.54545H201.666V16H200.613V14.9105H200.484C200.404 15.0398 200.29 15.2029 200.143 15.3999C199.995 15.5937 199.784 15.7676 199.51 15.9215C199.236 16.0723 198.871 16.1477 198.416 16.1477ZM198.564 15.169C199.001 15.169 199.37 15.0552 199.672 14.8274C199.973 14.5966 200.203 14.2781 200.359 13.8718C200.516 13.4625 200.595 12.9901 200.595 12.4545C200.595 11.9252 200.518 11.462 200.364 11.065C200.21 10.6649 199.982 10.354 199.681 10.1325C199.379 9.90779 199.007 9.79545 198.564 9.79545C198.102 9.79545 197.717 9.91394 197.41 10.1509C197.105 10.3848 196.876 10.7034 196.722 11.1065C196.571 11.5066 196.495 11.956 196.495 12.4545C196.495 12.9593 196.572 13.4179 196.726 13.8303C196.883 14.2396 197.114 14.5658 197.419 14.8089C197.727 15.049 198.108 15.169 198.564 15.169Z"
fill="white"
/>
<path
d="M189.223 11.7344V16H188.134V8.90909H189.186V10.017H189.279C189.445 9.65696 189.697 9.36766 190.036 9.14915C190.374 8.92756 190.811 8.81676 191.347 8.81676C191.827 8.81676 192.247 8.91525 192.607 9.11222C192.967 9.30611 193.247 9.60156 193.447 9.99858C193.648 10.3925 193.748 10.8911 193.748 11.4943V16H192.658V11.5682C192.658 11.0111 192.513 10.5772 192.224 10.2663C191.935 9.95241 191.538 9.79545 191.033 9.79545C190.685 9.79545 190.374 9.87086 190.101 10.0217C189.83 10.1725 189.616 10.3925 189.459 10.6818C189.302 10.9711 189.223 11.322 189.223 11.7344Z"
fill="white"
/>
<path
d="M185.049 13.1009V8.90909H186.138V16H185.049V14.7997H184.975C184.809 15.1598 184.55 15.466 184.2 15.7184C183.849 15.9677 183.405 16.0923 182.87 16.0923C182.427 16.0923 182.033 15.9954 181.688 15.8015C181.343 15.6045 181.073 15.3091 180.876 14.9151C180.679 14.5181 180.58 14.018 180.58 13.4148V8.90909H181.67V13.3409C181.67 13.858 181.814 14.2704 182.104 14.5781C182.396 14.8859 182.768 15.0398 183.221 15.0398C183.492 15.0398 183.767 14.9705 184.047 14.832C184.33 14.6935 184.567 14.4812 184.758 14.195C184.952 13.9087 185.049 13.544 185.049 13.1009Z"
fill="white"
/>
<path
d="M175.704 16.1477C175.064 16.1477 174.502 15.9954 174.019 15.6907C173.539 15.386 173.163 14.9598 172.893 14.4119C172.625 13.8641 172.491 13.224 172.491 12.4915C172.491 11.7528 172.625 11.1081 172.893 10.5572C173.163 10.0063 173.539 9.57848 174.019 9.27379C174.502 8.9691 175.064 8.81676 175.704 8.81676C176.344 8.81676 176.904 8.9691 177.384 9.27379C177.868 9.57848 178.243 10.0063 178.511 10.5572C178.782 11.1081 178.917 11.7528 178.917 12.4915C178.917 13.224 178.782 13.8641 178.511 14.4119C178.243 14.9598 177.868 15.386 177.384 15.6907C176.904 15.9954 176.344 16.1477 175.704 16.1477ZM175.704 15.169C176.19 15.169 176.59 15.0444 176.904 14.7951C177.218 14.5458 177.451 14.218 177.601 13.8118C177.752 13.4055 177.828 12.9654 177.828 12.4915C177.828 12.0175 177.752 11.5759 177.601 11.1665C177.451 10.7572 177.218 10.4264 176.904 10.174C176.59 9.92164 176.19 9.79545 175.704 9.79545C175.218 9.79545 174.818 9.92164 174.504 10.174C174.19 10.4264 173.957 10.7572 173.807 11.1665C173.656 11.5759 173.58 12.0175 173.58 12.4915C173.58 12.9654 173.656 13.4055 173.807 13.8118C173.957 14.218 174.19 14.5458 174.504 14.7951C174.818 15.0444 175.218 15.169 175.704 15.169Z"
fill="white"
/>
<path
d="M165.862 16V6.54545H171.531V7.56108H167.007V10.7557H171.106V11.7713H167.007V16H165.862Z"
fill="white"
/>
<path
d="M156.998 18.8068C156.472 18.8068 156.019 18.7391 155.641 18.6037C155.262 18.4714 154.947 18.2959 154.694 18.0774C154.445 17.862 154.247 17.6312 154.099 17.3849L154.967 16.7756C155.065 16.9048 155.19 17.0526 155.341 17.2188C155.492 17.388 155.698 17.5342 155.959 17.6573C156.224 17.7835 156.57 17.8466 156.998 17.8466C157.571 17.8466 158.043 17.7081 158.415 17.4311C158.788 17.1541 158.974 16.7202 158.974 16.1293V14.6889H158.882C158.802 14.8182 158.688 14.9782 158.54 15.169C158.395 15.3568 158.186 15.5245 157.912 15.6722C157.641 15.8169 157.275 15.8892 156.813 15.8892C156.241 15.8892 155.727 15.7538 155.272 15.483C154.819 15.2121 154.461 14.8182 154.196 14.3011C153.934 13.7841 153.803 13.1562 153.803 12.4176C153.803 11.6913 153.931 11.0588 154.187 10.5202C154.442 9.97857 154.798 9.56001 155.253 9.26456C155.709 8.96603 156.235 8.81676 156.832 8.81676C157.294 8.81676 157.66 8.8937 157.931 9.04759C158.205 9.19839 158.414 9.37074 158.558 9.56463C158.706 9.75544 158.82 9.9124 158.9 10.0355H159.011V8.90909H160.063V16.2031C160.063 16.8125 159.925 17.308 159.648 17.6896C159.374 18.0743 159.005 18.3559 158.54 18.5344C158.078 18.716 157.564 18.8068 156.998 18.8068ZM156.961 14.9105C157.398 14.9105 157.767 14.8105 158.069 14.6104C158.371 14.4104 158.6 14.1226 158.757 13.7472C158.914 13.3717 158.992 12.9223 158.992 12.3991C158.992 11.8883 158.915 11.4374 158.762 11.0465C158.608 10.6557 158.38 10.3494 158.078 10.1278C157.777 9.90625 157.404 9.79545 156.961 9.79545C156.499 9.79545 156.115 9.9124 155.807 10.1463C155.502 10.3802 155.273 10.6941 155.119 11.0881C154.968 11.482 154.893 11.919 154.893 12.3991C154.893 12.8916 154.97 13.3271 155.124 13.7056C155.281 14.0811 155.512 14.3765 155.816 14.592C156.124 14.8043 156.506 14.9105 156.961 14.9105Z"
fill="white"
/>
<path
d="M147.621 11.7344V16H146.531V8.90909H147.584V10.017H147.676C147.842 9.65696 148.095 9.36766 148.433 9.14915C148.772 8.92756 149.209 8.81676 149.744 8.81676C150.225 8.81676 150.645 8.91525 151.005 9.11222C151.365 9.30611 151.645 9.60156 151.845 9.99858C152.045 10.3925 152.145 10.8911 152.145 11.4943V16H151.056V11.5682C151.056 11.0111 150.911 10.5772 150.622 10.2663C150.332 9.95241 149.935 9.79545 149.431 9.79545C149.083 9.79545 148.772 9.87086 148.498 10.0217C148.227 10.1725 148.013 10.3925 147.856 10.6818C147.699 10.9711 147.621 11.322 147.621 11.7344Z"
fill="white"
/>
<path
d="M143.446 16V8.90909H144.536V16H143.446ZM144 7.72727C143.788 7.72727 143.605 7.65495 143.451 7.5103C143.3 7.36565 143.225 7.19176 143.225 6.98864C143.225 6.78551 143.3 6.61162 143.451 6.46697C143.605 6.32232 143.788 6.25 144 6.25C144.213 6.25 144.394 6.32232 144.545 6.46697C144.699 6.61162 144.776 6.78551 144.776 6.98864C144.776 7.19176 144.699 7.36565 144.545 7.5103C144.394 7.65495 144.213 7.72727 144 7.72727Z"
fill="white"
/>
<path
d="M138.387 18.8068C137.86 18.8068 137.408 18.7391 137.029 18.6037C136.651 18.4714 136.335 18.2959 136.083 18.0774C135.834 17.862 135.635 17.6312 135.488 17.3849L136.355 16.7756C136.454 16.9048 136.579 17.0526 136.729 17.2188C136.88 17.388 137.086 17.5342 137.348 17.6573C137.613 17.7835 137.959 17.8466 138.387 17.8466C138.959 17.8466 139.432 17.7081 139.804 17.4311C140.176 17.1541 140.363 16.7202 140.363 16.1293V14.6889H140.27C140.19 14.8182 140.076 14.9782 139.929 15.169C139.784 15.3568 139.575 15.5245 139.301 15.6722C139.03 15.8169 138.664 15.8892 138.202 15.8892C137.63 15.8892 137.116 15.7538 136.66 15.483C136.208 15.2121 135.849 14.8182 135.585 14.3011C135.323 13.7841 135.192 13.1562 135.192 12.4176C135.192 11.6913 135.32 11.0588 135.575 10.5202C135.831 9.97857 136.186 9.56001 136.642 9.26456C137.097 8.96603 137.623 8.81676 138.221 8.81676C138.682 8.81676 139.048 8.8937 139.319 9.04759C139.593 9.19839 139.802 9.37074 139.947 9.56463C140.095 9.75544 140.209 9.9124 140.289 10.0355H140.4V8.90909H141.452V16.2031C141.452 16.8125 141.314 17.308 141.037 17.6896C140.763 18.0743 140.393 18.3559 139.929 18.5344C139.467 18.716 138.953 18.8068 138.387 18.8068ZM138.35 14.9105C138.787 14.9105 139.156 14.8105 139.458 14.6104C139.759 14.4104 139.989 14.1226 140.146 13.7472C140.303 13.3717 140.381 12.9223 140.381 12.3991C140.381 11.8883 140.304 11.4374 140.15 11.0465C139.996 10.6557 139.769 10.3494 139.467 10.1278C139.165 9.90625 138.793 9.79545 138.35 9.79545C137.888 9.79545 137.503 9.9124 137.196 10.1463C136.891 10.3802 136.662 10.6941 136.508 11.0881C136.357 11.482 136.282 11.919 136.282 12.3991C136.282 12.8916 136.359 13.3271 136.512 13.7056C136.669 14.0811 136.9 14.3765 137.205 14.592C137.513 14.8043 137.894 14.9105 138.35 14.9105Z"
fill="white"
/>
<path
d="M130.286 16.1662C129.837 16.1662 129.429 16.0816 129.063 15.9123C128.696 15.7399 128.406 15.4922 128.19 15.169C127.975 14.8428 127.867 14.4489 127.867 13.9872C127.867 13.581 127.947 13.2517 128.107 12.9993C128.267 12.7438 128.481 12.5438 128.749 12.3991C129.016 12.2545 129.312 12.1468 129.635 12.076C129.961 12.0021 130.289 11.9437 130.618 11.9006C131.049 11.8452 131.399 11.8036 131.666 11.7759C131.937 11.7451 132.134 11.6944 132.257 11.6236C132.383 11.5528 132.446 11.4297 132.446 11.2543V11.2173C132.446 10.7618 132.322 10.4079 132.073 10.1555C131.826 9.90317 131.452 9.77699 130.951 9.77699C130.431 9.77699 130.023 9.89086 129.727 10.1186C129.432 10.3464 129.224 10.5895 129.104 10.848L128.07 10.4787C128.255 10.0478 128.501 9.71236 128.809 9.4723C129.12 9.22917 129.458 9.0599 129.824 8.96449C130.194 8.866 130.557 8.81676 130.914 8.81676C131.142 8.81676 131.403 8.84446 131.699 8.89986C131.997 8.95218 132.285 9.06143 132.562 9.22763C132.842 9.39382 133.074 9.64465 133.259 9.98011C133.444 10.3156 133.536 10.7649 133.536 11.3281V16H132.446V15.0398H132.391C132.317 15.1937 132.194 15.3583 132.022 15.5337C131.849 15.7092 131.62 15.8584 131.334 15.9815C131.048 16.1046 130.698 16.1662 130.286 16.1662ZM130.452 15.1875C130.883 15.1875 131.246 15.1029 131.542 14.9336C131.84 14.7643 132.065 14.5458 132.216 14.2781C132.37 14.0103 132.446 13.7287 132.446 13.4332V12.4361C132.4 12.4915 132.299 12.5423 132.142 12.5884C131.988 12.6315 131.809 12.67 131.606 12.7038C131.406 12.7346 131.211 12.7623 131.02 12.7869C130.832 12.8085 130.68 12.8269 130.563 12.8423C130.28 12.8793 130.015 12.9393 129.769 13.0224C129.526 13.1024 129.329 13.224 129.178 13.3871C129.03 13.5471 128.956 13.7656 128.956 14.0426C128.956 14.4212 129.096 14.7074 129.377 14.9013C129.66 15.0921 130.018 15.1875 130.452 15.1875Z"
fill="white"
/>
<path
d="M116.901 16V8.90909H117.953V10.017H118.045C118.193 9.63849 118.432 9.34458 118.761 9.1353C119.09 8.92294 119.486 8.81676 119.947 8.81676C120.415 8.81676 120.805 8.92294 121.115 9.1353C121.429 9.34458 121.674 9.63849 121.849 10.017H121.923C122.105 9.6508 122.377 9.35997 122.74 9.14453C123.104 8.92602 123.539 8.81676 124.047 8.81676C124.681 8.81676 125.199 9.01527 125.603 9.41229C126.006 9.80623 126.207 10.4202 126.207 11.2543V16H125.118V11.2543C125.118 10.7311 124.975 10.3571 124.689 10.1325C124.402 9.90779 124.065 9.79545 123.677 9.79545C123.179 9.79545 122.793 9.94626 122.519 10.2479C122.245 10.5464 122.108 10.925 122.108 11.3835V16H121V11.1435C121 10.7403 120.869 10.4156 120.608 10.1694C120.346 9.9201 120.009 9.79545 119.597 9.79545C119.313 9.79545 119.049 9.87086 118.802 10.0217C118.559 10.1725 118.362 10.3817 118.212 10.6495C118.064 10.9142 117.99 11.2204 117.99 11.5682V16H116.901Z"
fill="white"
/>
<path
d="M114.753 6.54545V16H113.608V6.54545H114.753Z"
fill="white"
/>
<path
d="M103.213 11.7344V16H102.123V6.54545H103.213V10.017H103.305C103.471 9.6508 103.72 9.35997 104.053 9.14453C104.388 8.92602 104.835 8.81676 105.392 8.81676C105.875 8.81676 106.298 8.91371 106.661 9.1076C107.024 9.29841 107.306 9.59233 107.506 9.98935C107.709 10.3833 107.811 10.8849 107.811 11.4943V16H106.721V11.5682C106.721 11.005 106.575 10.5695 106.283 10.2617C105.993 9.95088 105.592 9.79545 105.078 9.79545C104.721 9.79545 104.401 9.87086 104.117 10.0217C103.837 10.1725 103.616 10.3925 103.453 10.6818C103.293 10.9711 103.213 11.322 103.213 11.7344Z"
fill="white"
/>
<path
d="M100.332 8.90909V9.83239H96.6572V8.90909H100.332ZM97.7282 7.21023H98.8177V13.9688C98.8177 14.2765 98.8624 14.5073 98.9516 14.6612C99.0439 14.812 99.1609 14.9136 99.3025 14.9659C99.4471 15.0152 99.5995 15.0398 99.7595 15.0398C99.8795 15.0398 99.978 15.0336 100.055 15.0213C100.132 15.0059 100.193 14.9936 100.24 14.9844L100.461 15.9631C100.387 15.9908 100.284 16.0185 100.152 16.0462C100.02 16.0769 99.8518 16.0923 99.6487 16.0923C99.3409 16.0923 99.0393 16.0262 98.7439 15.8938C98.4515 15.7615 98.2084 15.5599 98.0145 15.2891C97.8237 15.0182 97.7282 14.6766 97.7282 14.2642V7.21023Z"
fill="white"
/>
<path
d="M95.2527 6.54545V16H94.1632V6.54545H95.2527Z"
fill="white"
/>
<path
d="M88.9246 16.1662C88.4753 16.1662 88.0675 16.0816 87.7013 15.9123C87.335 15.7399 87.0442 15.4922 86.8288 15.169C86.6133 14.8428 86.5056 14.4489 86.5056 13.9872C86.5056 13.581 86.5856 13.2517 86.7457 12.9993C86.9057 12.7438 87.1196 12.5438 87.3874 12.3991C87.6551 12.2545 87.9506 12.1468 88.2737 12.076C88.6 12.0021 88.9277 11.9437 89.257 11.9006C89.6879 11.8452 90.0372 11.8036 90.305 11.7759C90.5758 11.7451 90.7728 11.6944 90.8959 11.6236C91.0221 11.5528 91.0852 11.4297 91.0852 11.2543V11.2173C91.0852 10.7618 90.9605 10.4079 90.7112 10.1555C90.465 9.90317 90.0911 9.77699 89.5894 9.77699C89.0693 9.77699 88.6615 9.89086 88.3661 10.1186C88.0706 10.3464 87.8629 10.5895 87.7428 10.848L86.7087 10.4787C86.8934 10.0478 87.1396 9.71236 87.4474 9.4723C87.7582 9.22917 88.0968 9.0599 88.463 8.96449C88.8323 8.866 89.1955 8.81676 89.5525 8.81676C89.7802 8.81676 90.0418 8.84446 90.3373 8.89986C90.6358 8.95218 90.9236 9.06143 91.2006 9.22763C91.4806 9.39382 91.713 9.64465 91.8977 9.98011C92.0823 10.3156 92.1747 10.7649 92.1747 11.3281V16H91.0852V15.0398H91.0298C90.9559 15.1937 90.8328 15.3583 90.6604 15.5337C90.4881 15.7092 90.2588 15.8584 89.9726 15.9815C89.6864 16.1046 89.3371 16.1662 88.9246 16.1662ZM89.0908 15.1875C89.5217 15.1875 89.8849 15.1029 90.1803 14.9336C90.4789 14.7643 90.7035 14.5458 90.8543 14.2781C91.0082 14.0103 91.0852 13.7287 91.0852 13.4332V12.4361C91.039 12.4915 90.9374 12.5423 90.7805 12.5884C90.6266 12.6315 90.4481 12.67 90.245 12.7038C90.0449 12.7346 89.8495 12.7623 89.6587 12.7869C89.4709 12.8085 89.3186 12.8269 89.2016 12.8423C88.9185 12.8793 88.6538 12.9393 88.4076 13.0224C88.1645 13.1024 87.9675 13.224 87.8167 13.3871C87.669 13.5471 87.5951 13.7656 87.5951 14.0426C87.5951 14.4212 87.7351 14.7074 88.0152 14.9013C88.2983 15.0921 88.6569 15.1875 89.0908 15.1875Z"
fill="white"
/>
<path
d="M82.2446 16.1477C81.5614 16.1477 80.972 15.9969 80.4765 15.6953C79.9841 15.3906 79.604 14.9659 79.3362 14.4212C79.0715 13.8733 78.9392 13.2363 78.9392 12.5099C78.9392 11.7836 79.0715 11.1435 79.3362 10.5895C79.604 10.0324 79.9764 9.59848 80.4534 9.28764C80.9335 8.97372 81.4937 8.81676 82.1338 8.81676C82.5031 8.81676 82.8678 8.87831 83.2279 9.00142C83.588 9.12453 83.9158 9.32457 84.2112 9.60156C84.5067 9.87547 84.7421 10.2386 84.9175 10.6911C85.093 11.1435 85.1807 11.7005 85.1807 12.3622V12.8239H79.7148V11.8821H84.0727C84.0727 11.482 83.9927 11.125 83.8327 10.8111C83.6757 10.4972 83.451 10.2494 83.1587 10.0678C82.8694 9.88624 82.5278 9.79545 82.1338 9.79545C81.6999 9.79545 81.3244 9.90317 81.0074 10.1186C80.6935 10.331 80.4519 10.608 80.2826 10.9496C80.1133 11.2912 80.0287 11.6574 80.0287 12.0483V12.6761C80.0287 13.2116 80.121 13.6656 80.3057 14.038C80.4934 14.4073 80.7535 14.6889 81.0859 14.8828C81.4183 15.0736 81.8045 15.169 82.2446 15.169C82.5308 15.169 82.7894 15.129 83.0202 15.049C83.2541 14.9659 83.4557 14.8428 83.6249 14.6797C83.7942 14.5135 83.925 14.3073 84.0173 14.0611L85.0699 14.3565C84.9591 14.7135 84.7729 15.0275 84.5113 15.2983C84.2497 15.5661 83.9265 15.7753 83.5418 15.9261C83.1571 16.0739 82.7247 16.1477 82.2446 16.1477Z"
fill="white"
/>
<path
d="M69.7963 16V6.54545H70.9412V10.7557H75.9823V6.54545H77.1272V16H75.9823V11.7713H70.9412V16H69.7963Z"
fill="white"
/>
<path
d="M59.4773 11.7344V16H58.3878V8.90909H59.4404V10.017H59.5327C59.6989 9.65696 59.9513 9.36766 60.2898 9.14915C60.6283 8.92756 61.0654 8.81676 61.6009 8.81676C62.081 8.81676 62.5011 8.91525 62.8612 9.11222C63.2213 9.30611 63.5013 9.60156 63.7014 9.99858C63.9014 10.3925 64.0015 10.8911 64.0015 11.4943V16H62.912V11.5682C62.912 11.0111 62.7673 10.5772 62.478 10.2663C62.1887 9.95241 61.7917 9.79545 61.287 9.79545C60.9392 9.79545 60.6283 9.87086 60.3544 10.0217C60.0836 10.1725 59.8697 10.3925 59.7127 10.6818C59.5558 10.9711 59.4773 11.322 59.4773 11.7344Z"
fill="white"
/>
<path
d="M53.7944 16.1477C53.1112 16.1477 52.5218 15.9969 52.0263 15.6953C51.5339 15.3906 51.1538 14.9659 50.886 14.4212C50.6214 13.8733 50.489 13.2363 50.489 12.5099C50.489 11.7836 50.6214 11.1435 50.886 10.5895C51.1538 10.0324 51.5262 9.59848 52.0032 9.28764C52.4833 8.97372 53.0435 8.81676 53.6836 8.81676C54.0529 8.81676 54.4176 8.87831 54.7777 9.00142C55.1378 9.12453 55.4656 9.32457 55.761 9.60156C56.0565 9.87547 56.2919 10.2386 56.4674 10.6911C56.6428 11.1435 56.7305 11.7005 56.7305 12.3622V12.8239H51.2646V11.8821H55.6225C55.6225 11.482 55.5425 11.125 55.3825 10.8111C55.2255 10.4972 55.0009 10.2494 54.7085 10.0678C54.4192 9.88624 54.0776 9.79545 53.6836 9.79545C53.2497 9.79545 52.8742 9.90317 52.5572 10.1186C52.2433 10.331 52.0017 10.608 51.8324 10.9496C51.6631 11.2912 51.5785 11.6574 51.5785 12.0483V12.6761C51.5785 13.2116 51.6708 13.6656 51.8555 14.038C52.0432 14.4073 52.3033 14.6889 52.6357 14.8828C52.9681 15.0736 53.3543 15.169 53.7944 15.169C54.0806 15.169 54.3392 15.129 54.57 15.049C54.8039 14.9659 55.0055 14.8428 55.1747 14.6797C55.344 14.5135 55.4748 14.3073 55.5671 14.0611L56.6197 14.3565C56.5089 14.7135 56.3227 15.0275 56.0611 15.2983C55.7995 15.5661 55.4763 15.7753 55.0916 15.9261C54.7069 16.0739 54.2745 16.1477 53.7944 16.1477Z"
fill="white"
/>
<path
d="M42.8995 18.6591V8.90909H43.9521V10.0355H44.0814C44.1614 9.9124 44.2722 9.75544 44.4137 9.56463C44.5584 9.37074 44.7646 9.19839 45.0323 9.04759C45.3032 8.8937 45.6694 8.81676 46.1311 8.81676C46.7281 8.81676 47.2544 8.96603 47.7099 9.26456C48.1654 9.56309 48.5209 9.98627 48.7763 10.5341C49.0318 11.0819 49.1595 11.7282 49.1595 12.473C49.1595 13.224 49.0318 13.8749 48.7763 14.4258C48.5209 14.9736 48.1669 15.3983 47.7145 15.6999C47.2621 15.9985 46.7404 16.1477 46.1495 16.1477C45.694 16.1477 45.3293 16.0723 45.0554 15.9215C44.7815 15.7676 44.5707 15.5937 44.423 15.3999C44.2752 15.2029 44.1614 15.0398 44.0814 14.9105H43.989V18.6591H42.8995ZM43.9706 12.4545C43.9706 12.9901 44.049 13.4625 44.206 13.8718C44.363 14.2781 44.5922 14.5966 44.8939 14.8274C45.1955 15.0552 45.5648 15.169 46.0018 15.169C46.4573 15.169 46.8374 15.049 47.1421 14.8089C47.4498 14.5658 47.6807 14.2396 47.8346 13.8303C47.9915 13.4178 48.07 12.9593 48.07 12.4545C48.07 11.956 47.9931 11.5066 47.8392 11.1065C47.6884 10.7034 47.4591 10.3848 47.1513 10.1509C46.8466 9.91394 46.4635 9.79545 46.0018 9.79545C45.5586 9.79545 45.1862 9.90779 44.8846 10.1325C44.583 10.354 44.3553 10.6649 44.2014 11.065C44.0475 11.462 43.9706 11.9252 43.9706 12.4545Z"
fill="white"
/>
<path
d="M41.1221 11.2727C41.1221 12.2699 40.9421 13.1316 40.582 13.858C40.2219 14.5843 39.7279 15.1444 39.1001 15.5384C38.4722 15.9323 37.7552 16.1293 36.9488 16.1293C36.1425 16.1293 35.4254 15.9323 34.7975 15.5384C34.1697 15.1444 33.6757 14.5843 33.3156 13.858C32.9556 13.1316 32.7755 12.2699 32.7755 11.2727C32.7755 10.2756 32.9556 9.41383 33.3156 8.6875C33.6757 7.96117 34.1697 7.40104 34.7975 7.0071C35.4254 6.61316 36.1425 6.41619 36.9488 6.41619C37.7552 6.41619 38.4722 6.61316 39.1001 7.0071C39.7279 7.40104 40.2219 7.96117 40.582 8.6875C40.9421 9.41383 41.1221 10.2756 41.1221 11.2727ZM40.0141 11.2727C40.0141 10.4541 39.8772 9.76314 39.6033 9.19993C39.3324 8.63672 38.9647 8.21046 38.4999 7.92116C38.0383 7.63187 37.5213 7.48722 36.9488 7.48722C36.3764 7.48722 35.8578 7.63187 35.3931 7.92116C34.9314 8.21046 34.5636 8.63672 34.2897 9.19993C34.0189 9.76314 33.8835 10.4541 33.8835 11.2727C33.8835 12.0914 34.0189 12.7823 34.2897 13.3455C34.5636 13.9087 34.9314 14.335 35.3931 14.6243C35.8578 14.9136 36.3764 15.0582 36.9488 15.0582C37.5213 15.0582 38.0383 14.9136 38.4999 14.6243C38.9647 14.335 39.3324 13.9087 39.6033 13.3455C39.8772 12.7823 40.0141 12.0914 40.0141 11.2727Z"
fill="white"
/>
<path
d="M21.5 3C21.5 2.17157 20.8284 1.5 20 1.5H15C14.1716 1.5 13.5 2.17157 13.5 3V8C13.5 8.82843 14.1716 9.5 15 9.5H20C20.8284 9.5 21.5 8.82843 21.5 8V3ZM22.5 8C22.5 9.38071 21.3807 10.5 20 10.5H15C13.6193 10.5 12.5 9.38071 12.5 8V3C12.5 1.61929 13.6193 0.5 15 0.5H20C21.3807 0.5 22.5 1.61929 22.5 3V8Z"
fill="#358CFD"
/>
<path
d="M9.5 3C9.5 2.17157 8.82843 1.5 8 1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V8C1.5 8.82843 2.17157 9.5 3 9.5H8C8.82843 9.5 9.5 8.82843 9.5 8V3ZM10.5 8C10.5 9.38071 9.38071 10.5 8 10.5H3C1.61929 10.5 0.5 9.38071 0.5 8V3C0.5 1.61929 1.61929 0.5 3 0.5H8C9.38071 0.5 10.5 1.61929 10.5 3V8Z"
fill="#358CFD"
/>
<path
d="M9.5 15C9.5 14.1716 8.82843 13.5 8 13.5H3C2.17157 13.5 1.5 14.1716 1.5 15V20C1.5 20.8284 2.17157 21.5 3 21.5H8C8.82843 21.5 9.5 20.8284 9.5 20V15ZM10.5 20C10.5 21.3807 9.38071 22.5 8 22.5H3C1.61929 22.5 0.5 21.3807 0.5 20V15C0.5 13.6193 1.61929 12.5 3 12.5H8C9.38071 12.5 10.5 13.6193 10.5 15V20Z"
fill="#358CFD"
/>
<path
d="M21.5 15C21.5 14.1716 20.8284 13.5 20 13.5H15C14.1716 13.5 13.5 14.1716 13.5 15V20C13.5 20.8284 14.1716 21.5 15 21.5H20C20.8284 21.5 21.5 20.8284 21.5 20V15ZM22.5 20C22.5 21.3807 21.3807 22.5 20 22.5H15C13.6193 22.5 12.5 21.3807 12.5 20V15C12.5 13.6193 13.6193 12.5 15 12.5H20C21.3807 12.5 22.5 13.6193 22.5 15V20Z"
fill="#358CFD"
/>
</svg>
);
export default OHIFLogoHorizontal;

View File

@ -0,0 +1,29 @@
import React from 'react';
import type { IconProps } from '../types';
export const PanelRight = (props: IconProps) => (
<svg
width="19"
height="16"
viewBox="0 0 19 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
x="-0.5"
y="0.5"
width="18"
height="15"
rx="1.5"
transform="matrix(-1 0 0 1 18 0)"
stroke="#348CFD"
/>
<path
d="M17 0.5H13.5V15.5H17C17.8284 15.5 18.5 14.8284 18.5 14V2C18.5 1.17157 17.8284 0.5 17 0.5Z"
stroke="#348CFD"
/>
</svg>
);
export default PanelRight;

View File

@ -0,0 +1,62 @@
import React from 'react';
import type { IconProps } from '../types';
const PatientStudyList = (props: IconProps) => (
<svg
width="33"
height="33"
viewBox="0 0 33 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#patient-study-list_svg__a)">
<path
d="M0.6875 8.9375V4.8125C0.6875 3.71848 1.1221 2.66927 1.89568 1.89568 2.66927 1.1221 3.71848 0.6875 4.8125 0.6875H8.9375"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M32.3125 8.9375V4.8125C32.3125 3.71848 31.8779 2.66927 31.1043 1.89568 30.3307 1.1221 29.2815 0.6875 28.1875 0.6875H24.0625"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M0.6875 24.0625V28.1875C0.6875 29.2815 1.1221 30.3307 1.89568 31.1043 2.66927 31.8779 3.71848 32.3125 4.8125 32.3125H8.9375"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M32.3125 24.0625V28.1875C32.3125 29.2815 31.8779 30.3307 31.1043 31.1043 30.3307 31.8779 29.2815 32.3125 28.1875 32.3125H24.0625"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5.4436 26.2446C7.79153 24.8832 10.2891 23.798 12.8865 23.0106 14.0374 22.5844 14.1625 19.9458 13.3375 19.0369 12.1467 17.7279 11.1375 16.192 11.1375 12.485 11.0681 11.7465 11.1563 11.0017 11.3962 10.2997 11.6361 9.59783 12.0223 8.95483 12.5291 8.41325 13.036 7.87167 13.6521 7.44384 14.3366 7.15806 15.0211 6.87227 15.7585 6.73502 16.5 6.7554 17.2415 6.73502 17.9789 6.87227 18.6634 7.15806 19.3479 7.44384 19.9639 7.87167 20.4708 8.41325 20.9777 8.95483 21.3639 9.59783 21.6038 10.2997 21.8437 11.0017 21.9318 11.7465 21.8625 12.485 21.8625 16.1975 20.8532 17.7279 19.6625 19.0369 18.8375 19.9458 18.9626 22.5844 20.1135 23.0106 22.7109 23.798 25.2084 24.8832 27.5564 26.2446"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="patient-study-list_svg__a">
<rect
width={33}
height={33}
fill="#fff"
/>
</clipPath>
</defs>
</svg>
);
export default PatientStudyList;

View File

@ -0,0 +1,40 @@
import React from 'react';
import type { IconProps } from '../types';
export const SeriesPlaceholder = (props: IconProps) => (
<svg
width="75px"
height="50px"
viewBox="0 0 75 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
opacity="0.3"
x="1"
y="1"
width="73"
height="48"
rx="11"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M30.7059 31H25.1176C24.8212 31 24.537 30.8822 24.3274 30.6726C24.1178 30.4631 24 30.1788 24 29.8824V13.1176C24 12.8212 24.1178 12.537 24.3274 12.3274C24.537 12.1178 24.8212 12 25.1176 12H41.8824C42.1788 12 42.4631 12.1178 42.6726 12.3274C42.8822 12.537 43 12.8212 43 13.1176V18.7059"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M47.9412 19H32.0588C31.4741 19 31 19.4741 31 20.0588V35.9412C31 36.5259 31.4741 37 32.0588 37H47.9412C48.5259 37 49 36.5259 49 35.9412V20.0588C49 19.4741 48.5259 19 47.9412 19Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SeriesPlaceholder;

View File

@ -0,0 +1,28 @@
import React from 'react';
import type { IconProps } from '../types';
export const SettingsStudyList = (props: IconProps) => (
<svg
width="17"
height="17"
viewBox="0 0 17 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M14.0113 5.16848C13.9329 5.33325 13.8899 5.51264 13.8853 5.69504C13.8806 5.87745 13.9142 6.0588 13.9841 6.22738C14.0539 6.39595 14.1583 6.54799 14.2906 6.67365C14.4229 6.79931 14.5801 6.8958 14.7521 6.95687L15.632 7.26989C15.8859 7.36002 16.1057 7.52657 16.2611 7.74666C16.4166 7.96676 16.5 8.22958 16.5 8.49902C16.5 8.76846 16.4166 9.03129 16.2611 9.25138C16.1057 9.47147 15.8859 9.63803 15.632 9.72815L14.7521 10.0412C14.5801 10.1022 14.4229 10.1987 14.2906 10.3244C14.1583 10.4501 14.0539 10.6021 13.9841 10.7707C13.9142 10.9392 13.8806 11.1206 13.8853 11.303C13.8899 11.4854 13.9329 11.6648 14.0113 11.8296L14.4133 12.6719C14.5295 12.9153 14.5675 13.1887 14.5219 13.4546C14.4763 13.7204 14.3494 13.9656 14.1586 14.1563C13.9679 14.347 13.7228 14.4739 13.4569 14.5195C13.1911 14.5651 12.9177 14.5272 12.6743 14.4109L11.8305 14.0103C11.6658 13.9319 11.4864 13.889 11.304 13.8843C11.1216 13.8796 10.9402 13.9133 10.7716 13.9831C10.6031 14.0529 10.451 14.1574 10.3254 14.2897C10.1997 14.4219 10.1032 14.5792 10.0421 14.7511L9.72913 15.631C9.63886 15.8847 9.47229 16.1042 9.25228 16.2595C9.03227 16.4147 8.76961 16.498 8.50035 16.498C8.23109 16.498 7.96842 16.4147 7.74841 16.2595C7.52841 16.1042 7.36183 15.8847 7.27157 15.631L6.95855 14.7511C6.8974 14.5791 6.80083 14.4219 6.67511 14.2896C6.54938 14.1573 6.39728 14.0529 6.22865 13.983C6.06002 13.9132 5.87862 13.8796 5.69617 13.8843C5.51371 13.8889 5.33428 13.9319 5.16946 14.0103L4.32709 14.4109C4.08371 14.5276 3.81011 14.5659 3.54406 14.5205C3.27801 14.475 3.03262 14.3481 2.84177 14.1573C2.65091 13.9664 2.52401 13.721 2.47857 13.455C2.43313 13.1889 2.47139 12.9153 2.58808 12.6719L2.98875 11.8282C3.06714 11.6634 3.11006 11.484 3.11475 11.3016C3.11943 11.1192 3.08577 10.9379 3.01594 10.7693C2.94611 10.6007 2.84167 10.4487 2.70937 10.323C2.57708 10.1973 2.41987 10.1009 2.24793 10.0398L1.368 9.72676C1.11408 9.63663 0.8943 9.47008 0.738874 9.24999C0.583448 9.0299 0.5 8.76707 0.5 8.49763C0.5 8.22819 0.583448 7.96536 0.738874 7.74527C0.8943 7.52518 1.11408 7.35863 1.368 7.2685L2.24793 6.95548C2.41969 6.89438 2.57673 6.79794 2.70889 6.67238C2.84106 6.54681 2.94542 6.39492 3.01524 6.22652C3.08505 6.05811 3.11876 5.87694 3.1142 5.69469C3.10964 5.51244 3.0669 5.33318 2.98875 5.16848L2.58808 4.32472C2.47139 4.08134 2.43313 3.80774 2.47857 3.54169C2.52401 3.27564 2.65091 3.03025 2.84177 2.8394C3.03262 2.64854 3.27801 2.52164 3.54406 2.4762C3.81011 2.43076 4.08371 2.46902 4.32709 2.58571L5.17085 2.98638C5.33551 3.06457 5.51474 3.10737 5.69696 3.11203C5.87919 3.11669 6.06037 3.0831 6.22881 3.01343C6.39725 2.94376 6.54922 2.83955 6.67491 2.70754C6.80061 2.57552 6.89723 2.41861 6.95855 2.24695L7.27157 1.36702C7.36183 1.11334 7.52841 0.893811 7.74841 0.738574C7.96842 0.583338 8.23109 0.5 8.50035 0.5C8.76961 0.5 9.03227 0.583338 9.25228 0.738574C9.47229 0.893811 9.63886 1.11334 9.72913 1.36702L10.0421 2.24695C10.1032 2.41889 10.1997 2.5761 10.3254 2.70839C10.451 2.84069 10.6031 2.94513 10.7716 3.01496C10.9402 3.08479 11.1216 3.11845 11.304 3.11377C11.4864 3.10908 11.6658 3.06616 11.8305 2.98777L12.6743 2.58571C12.9177 2.46948 13.1911 2.43155 13.4569 2.47716C13.7228 2.52277 13.9679 2.64967 14.1586 2.84038C14.3494 3.0311 14.4763 3.27625 14.5219 3.54208C14.5675 3.80791 14.5295 4.08134 14.4133 4.32472L14.0113 5.16848Z"
stroke="#358CFD"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.49691 11.6292C10.2257 11.6292 11.6271 10.2277 11.6271 8.49898C11.6271 6.77022 10.2257 5.36877 8.49691 5.36877C6.76814 5.36877 5.3667 6.77022 5.3667 8.49898C5.3667 10.2277 6.76814 11.6292 8.49691 11.6292Z"
stroke="#358CFD"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SettingsStudyList;

View File

@ -0,0 +1,20 @@
import React from 'react';
import type { IconProps } from '../types';
export const SortingNew = (props: IconProps) => (
<svg
width="10"
height="16"
viewBox="0 0 10 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5.0293 1.5C5.1141 1.50007 5.19938 1.51765 5.28418 1.55273C5.36895 1.58496 5.44053 1.63194 5.49902 1.69336L8.36035 4.55859C8.49202 4.69037 8.55762 4.84288 8.55762 5.01562C8.55762 5.19434 8.49902 5.34082 8.38184 5.45508C8.26758 5.56934 8.12402 5.62695 7.95117 5.62695C7.85742 5.62695 7.77246 5.60742 7.69629 5.56934C7.62022 5.53423 7.55464 5.48719 7.49902 5.42871L6.50586 4.44434L5.62305 3.4248L5.66211 4.37891V11.6963L5.62305 12.6484L6.50586 11.6299L7.49902 10.6455C7.55464 10.587 7.62022 10.54 7.69629 10.5049C7.77246 10.4668 7.85742 10.4482 7.95117 10.4482C8.12402 10.4482 8.26758 10.5049 8.38184 10.6191C8.49902 10.7334 8.55762 10.8799 8.55762 11.0586C8.55762 11.2313 8.49202 11.3838 8.36035 11.5156L5.49902 14.3809C5.44053 14.4423 5.36895 14.4893 5.28418 14.5215C5.19938 14.5566 5.1141 14.5742 5.0293 14.5742C4.94141 14.5742 4.85449 14.5566 4.76953 14.5215C4.6875 14.4893 4.61719 14.4424 4.55859 14.3809L1.69824 11.5156C1.56641 11.3838 1.5 11.2314 1.5 11.0586C1.5 10.8799 1.55762 10.7334 1.67188 10.6191C1.78902 10.505 1.93372 10.4482 2.10645 10.4482C2.2002 10.4482 2.28418 10.4668 2.35742 10.5049C2.43343 10.54 2.50108 10.587 2.55957 10.6455L3.54785 11.6299L4.42969 12.6484L4.3916 11.6963V4.37891L4.42969 3.4248L3.54785 4.44434L2.55957 5.42871C2.50108 5.4872 2.43343 5.5342 2.35742 5.56934C2.28418 5.60742 2.2002 5.62695 2.10645 5.62695C1.93372 5.62695 1.78902 5.56917 1.67188 5.45508C1.55762 5.34082 1.5 5.19434 1.5 5.01562C1.5 4.84277 1.56641 4.69043 1.69824 4.55859L4.55859 1.69336C4.61719 1.63184 4.6875 1.58496 4.76953 1.55273C4.85449 1.51758 4.94141 1.5 5.0293 1.5Z"
fill="currentColor"
/>
</svg>
);
export default SortingNew;

View File

@ -0,0 +1,35 @@
import React from 'react';
import type { IconProps } from '../types';
export const SortingNewAscending = (props: IconProps) => (
<svg
width="10"
height="16"
viewBox="0 0 10 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<line
x1="0.75"
y1="-0.75"
x2="4.90685"
y2="-0.75"
transform="matrix(0.707107 -0.707107 -0.707107 -0.707107 1 8)"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="7.93934"
y1="8"
x2="5"
y2="5.06066"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
export default SortingNewAscending;

View File

@ -0,0 +1,35 @@
import React from 'react';
import type { IconProps } from '../types';
export const SortingNewDescending = (props: IconProps) => (
<svg
width="10"
height="16"
viewBox="0 0 10 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<line
x1="2.06066"
y1="8"
x2="5"
y2="10.9393"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="0.75"
y1="-0.75"
x2="4.90685"
y2="-0.75"
transform="matrix(-0.707107 0.707107 0.707107 0.707107 9 8)"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
export default SortingNewDescending;

View File

@ -0,0 +1,364 @@
/**
* Compound multi-select chip-input built on cmdk.
*
* <InputMultiSelect options={...} value={...} onChange={...}>
* <InputMultiSelect.Field>
* <InputMultiSelect.Summary />
* <InputMultiSelect.Input ariaLabel="..." />
* </InputMultiSelect.Field>
* <InputMultiSelect.Content fitToContent maxWidth={185}>
* <InputMultiSelect.Options />
* </InputMultiSelect.Content>
* </InputMultiSelect>
*/
import * as React from 'react';
import { createPortal } from 'react-dom';
import { cn } from '../../lib/utils';
import { Command as CommandPrimitive } from 'cmdk';
import { CommandList, CommandGroup, CommandItem, CommandEmpty } from '../Command/Command';
import { Badge } from '../Badge';
import { Icons } from '../Icons';
import { ScrollArea } from '../ScrollArea';
type Option = string | { value: string; label?: string };
type NormalizedOption = { value: string; label: string };
type Coords = { left: number; top: number; width: number; maxHeight: number };
function normalizeOption(opt: Option): NormalizedOption {
if (typeof opt === 'string') return { value: opt, label: opt };
return { value: opt.value, label: opt.label ?? opt.value };
}
type IMSContext = {
value: string[];
normalized: NormalizedOption[];
selectedSet: Set<string>;
query: string;
setQuery: (s: string) => void;
open: boolean;
setOpen: (b: boolean) => void;
fieldRef: React.MutableRefObject<HTMLDivElement | null>;
overlayRef: React.MutableRefObject<HTMLDivElement | null>;
inputRef: React.MutableRefObject<HTMLInputElement | null>;
coords: Coords | null;
filtered: NormalizedOption[];
toggle: (val: string) => void;
remove: (val: string) => void;
clear: () => void;
};
const InputMultiSelectContext = React.createContext<IMSContext | null>(null);
function useInputMultiSelect(): IMSContext {
const ctx = React.useContext(InputMultiSelectContext);
if (!ctx) throw new Error('useInputMultiSelect must be used within <InputMultiSelect>');
return ctx;
}
export type InputMultiSelectRootProps = {
options: Option[];
value: string[];
onChange: (next: string[]) => void;
children?: React.ReactNode;
};
function InputMultiSelectRoot({ options, value, onChange, children }: InputMultiSelectRootProps) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const fieldRef = React.useRef<HTMLDivElement | null>(null);
const overlayRef = React.useRef<HTMLDivElement | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const selectedSet = React.useMemo(() => new Set(value), [value]);
const normalized = React.useMemo(() => options.map(normalizeOption), [options]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return normalized;
return normalized.filter(
opt => opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
);
}, [normalized, query]);
React.useEffect(() => {
function handleDoc(event: MouseEvent) {
const target = event.target as Node | null;
if (!target) return;
if (containerRef.current?.contains(target)) return;
if (overlayRef.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener('mousedown', handleDoc);
return () => document.removeEventListener('mousedown', handleDoc);
}, []);
const [coords, setCoords] = React.useState<Coords | null>(null);
// Prefer placing the overlay below the field; flip above if there's more space upward.
const measure = React.useCallback(() => {
const anchor = fieldRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const gutter = 8;
const bottomSpace = window.innerHeight - rect.bottom;
const topSpace = rect.top;
const preferBelow = bottomSpace >= topSpace;
const available = preferBelow ? bottomSpace : topSpace;
const maxHeight = Math.max(120, Math.min(300, available - gutter));
const top = preferBelow ? rect.bottom : Math.max(0, rect.top - maxHeight);
setCoords({ left: rect.left, top, width: rect.width, maxHeight });
}, []);
React.useLayoutEffect(() => {
if (open) measure();
}, [open, measure, query, value]);
React.useEffect(() => {
if (!open) return;
const handler = () => measure();
window.addEventListener('resize', handler);
// Capture phase catches scrolls in nested scrollable ancestors.
window.addEventListener('scroll', handler, true);
return () => {
window.removeEventListener('resize', handler);
window.removeEventListener('scroll', handler, true);
};
}, [open, measure]);
const remove = React.useCallback(
(val: string) => onChange(value.filter(v => v !== val)),
[value, onChange]
);
const toggle = React.useCallback(
(val: string) => {
const next = selectedSet.has(val) ? value.filter(v => v !== val) : [...value, val];
onChange(next);
setQuery('');
},
[selectedSet, value, onChange]
);
const clear = React.useCallback(() => onChange([]), [onChange]);
const ctx: IMSContext = {
value,
normalized,
selectedSet,
query,
setQuery,
open,
setOpen,
fieldRef,
overlayRef,
inputRef,
coords,
filtered,
toggle,
remove,
clear,
};
return (
<InputMultiSelectContext.Provider value={ctx}>
{/* shouldFilter={false} so we own filtering and can sort selected items to the top. */}
<CommandPrimitive
className="h-auto overflow-visible bg-transparent"
shouldFilter={false}
>
<div
ref={containerRef}
className="relative"
>
{children}
</div>
</CommandPrimitive>
</InputMultiSelectContext.Provider>
);
}
function Field({ children }: { children?: React.ReactNode }) {
const { fieldRef, inputRef } = useInputMultiSelect();
return (
<div
ref={fieldRef}
className="border-input text-foreground bg-background hover:bg-primary/10 focus-within:ring-ring flex h-7 w-full items-center gap-1 rounded border px-2 py-1 text-base shadow-sm transition-colors focus-within:outline-none focus-within:ring-1"
role="group"
onClick={() => inputRef.current?.focus()}
>
{children}
</div>
);
}
Field.displayName = 'InputMultiSelect.Field';
function Summary() {
const { value, normalized, clear } = useInputMultiSelect();
if (value.length === 0) return null;
const firstLabel = normalized.find(o => o.value === value[0])?.label ?? value[0];
const text = value.length > 1 ? String(value.length) : firstLabel;
return (
<Badge
variant="default"
className="inline-flex h-5 items-center gap-1 shrink-0 px-2"
>
<span
className="truncate max-w-[160px]"
title={firstLabel}
>
{text}
</span>
<span
role="button"
tabIndex={0}
aria-label="Clear all selections"
className="cursor-pointer select-none opacity-80 hover:opacity-100"
onClick={() => clear()}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clear();
}
}}
>
×
</span>
</Badge>
);
}
Summary.displayName = 'InputMultiSelect.Summary';
type IMSInputProps = {
ariaLabel?: string;
placeholder?: string;
};
function IMSInput({ ariaLabel, placeholder }: IMSInputProps) {
const { inputRef, value, query, setQuery, open, setOpen, remove } = useInputMultiSelect();
return (
<CommandPrimitive.Input
ref={node => {
inputRef.current = node;
}}
aria-label={ariaLabel}
placeholder={value.length === 0 ? placeholder : ''}
className="h-5 min-w-0 flex-1 bg-transparent px-0 py-0 outline-none"
value={query}
onValueChange={v => {
setQuery(v);
if (!open) setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape') setOpen(false);
// Backspace on empty query removes the most recently selected value.
if (e.key === 'Backspace' && query === '' && value.length > 0) {
remove(value[value.length - 1]);
}
}}
/>
);
}
IMSInput.displayName = 'InputMultiSelect.Input';
type ContentProps = {
children?: React.ReactNode;
fitToContent?: boolean;
maxWidth?: number;
};
function Content({ children, fitToContent = false, maxWidth }: ContentProps) {
const { open, coords, overlayRef, setOpen } = useInputMultiSelect();
if (!(open && coords)) return null;
const gutter = 8;
const viewportMaxWidth = Math.max(200, window.innerWidth - coords.left - gutter);
const computedMaxWidth = Math.min(maxWidth ?? 480, viewportMaxWidth);
return createPortal(
<div
ref={overlayRef}
className="z-[1000] mt-1 rounded-md border border-input bg-popover shadow-md"
style={{
position: 'fixed',
left: coords.left,
top: coords.top,
width: fitToContent ? 'auto' : coords.width,
minWidth: coords.width,
maxWidth: fitToContent ? computedMaxWidth : undefined,
maxHeight: coords.maxHeight,
overflow: 'hidden',
}}
onKeyDown={e => {
if (e.key === 'Escape') setOpen(false);
}}
>
<ScrollArea
className="h-auto min-h-0 max-h-[inherit]"
type="auto"
>
<CommandList
className="max-h-none overflow-visible"
role="listbox"
aria-multiselectable={true}
>
{children}
</CommandList>
</ScrollArea>
</div>,
document.body
);
}
Content.displayName = 'InputMultiSelect.Content';
function Options() {
const { filtered, toggle, selectedSet } = useInputMultiSelect();
// Selected items appear at the top so the user can quickly see and uncheck them.
const selected = filtered.filter(o => selectedSet.has(o.value));
const unselected = filtered.filter(o => !selectedSet.has(o.value));
const ordered = [...selected, ...unselected];
return (
<CommandGroup>
{filtered.length === 0 ? (
<CommandEmpty>No options found.</CommandEmpty>
) : (
ordered.map(opt => (
<CommandItem
key={opt.value}
onSelect={() => toggle(opt.value)}
aria-selected={selectedSet.has(opt.value)}
className="min-w-0 leading-none"
>
<span
className="truncate leading-none"
title={opt.label}
>
{opt.label}
</span>
<Icons.Checked
className={cn(
'ml-auto block h-6 w-6 shrink-0',
selectedSet.has(opt.value) ? 'opacity-70' : 'invisible'
)}
aria-hidden="true"
/>
</CommandItem>
))
)}
</CommandGroup>
);
}
Options.displayName = 'InputMultiSelect.Options';
const InputMultiSelect = Object.assign(InputMultiSelectRoot, {
Field,
Summary,
Input: IMSInput,
Content,
Options,
});
export { InputMultiSelect };
export type { Option };

View File

@ -0,0 +1 @@
export { InputMultiSelect } from './InputMultiSelect'

View File

@ -18,6 +18,7 @@ const PopoverContent = React.forwardRef<
ref={ref}
align={align}
sideOffset={sideOffset}
data-slot="popover-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-input z-50 w-72 rounded-md border p-4 shadow-md outline-none',
className

View File

@ -0,0 +1,257 @@
import * as React from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '../../DataTable';
import type { ColumnMeta } from '../../DataTable/types';
import { Icons } from '../../Icons';
import type { StudyDateRangeFilter, StudyRow } from '../types/types';
import { ActionCell } from '../components/ActionCell';
import { tokenizeModalities } from '../utils/tokenizeModalities';
import { formatDICOMDate } from '../../../utils/formatDICOMDate';
import { formatDICOMTime } from '../../../utils/formatDICOMTime';
import { parseStudyDateTimestamp } from '../../../utils/parseStudyDateTimestamp';
// Column ID constants - shared across the codebase
export const COLUMN_IDS = {
PATIENT: 'patient',
MRN: 'mrn',
STUDY_DATE_TIME: 'studyDateTime',
MODALITIES: 'modalities',
DESCRIPTION: 'description',
ACCESSION: 'accession',
INSTANCES: 'instances',
ACTIONS: 'actions',
} as const;
// Filterable column IDs (columns that support filtering)
export const FILTERABLE_COLUMN_IDS = [
COLUMN_IDS.PATIENT,
COLUMN_IDS.MRN,
COLUMN_IDS.MODALITIES,
COLUMN_IDS.DESCRIPTION,
COLUMN_IDS.ACCESSION,
] as const;
// Text filter column IDs (columns that use text-based filtering)
export const TEXT_FILTER_COLUMN_IDS = [
COLUMN_IDS.PATIENT,
COLUMN_IDS.MRN,
COLUMN_IDS.ACCESSION,
COLUMN_IDS.DESCRIPTION,
] as const;
/**
* Creates a basic, display-only text column. Use this to add simple columns
* declaratively (e.g. via the `workList.columns` customization) without
* writing the accessor/header/cell wiring by hand.
*/
export function textColumn(
id: string,
label: string,
meta?: Partial<ColumnMeta>
): ColumnDef<StudyRow, unknown> {
return {
id,
accessorFn: row => (row as Record<string, unknown>)[id] ?? '',
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue<string>(id)}</div>,
meta: { label, minWidth: 120, ...meta },
};
}
export const defaultColumns: ColumnDef<StudyRow, unknown>[] = [
{
id: COLUMN_IDS.PATIENT,
accessorFn: row => {
const r = row as StudyRow;
return r.patientName ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue(COLUMN_IDS.PATIENT)}</div>,
meta: {
label: 'Patient',
headerClassName: 'min-w-[165px]',
cellClassName: 'min-w-[165px]',
minWidth: 165,
priority: 100,
},
},
{
id: COLUMN_IDS.MRN,
accessorFn: row => {
const r = row as StudyRow;
return r.mrn ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue(COLUMN_IDS.MRN)}</div>,
meta: {
label: 'MRN',
headerClassName: 'min-w-[120px]',
cellClassName: 'min-w-[120px]',
minWidth: 120,
priority: 20,
},
},
{
id: COLUMN_IDS.STUDY_DATE_TIME,
accessorFn: row => {
const r = row as StudyRow;
// Date drives the cell: with no valid date we show nothing, even if a
// time is present.
const date = formatDICOMDate(r.date ?? '', {
fallbackFormat: 'MMM-DD-YYYY',
invalidFallback: '',
});
if (!date) {
return '';
}
const time = formatDICOMTime(r.time ?? '', { invalidFallback: '' });
return time ? `${date} ${time}` : date;
},
filterFn: (row, _colId, filter) => {
const range =
filter && typeof filter === 'object' ? (filter as StudyDateRangeFilter) : undefined;
const startDate = range?.startDate;
const endDate = range?.endDate;
if (!startDate && !endDate) {
return true;
}
const rowDate = String((row.original as StudyRow).date ?? '').replace(/\D/g, '');
if (!rowDate) {
return false;
}
if (startDate && rowDate < startDate) {
return false;
}
if (endDate && rowDate > endDate) {
return false;
}
return true;
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => {
return <div className="truncate">{row.getValue(COLUMN_IDS.STUDY_DATE_TIME)}</div>;
},
sortingFn: (a, b) => {
const aRow = a.original as StudyRow;
const bRow = b.original as StudyRow;
const aTimestamp = parseStudyDateTimestamp(aRow.date ?? '', aRow.time ?? '');
const bTimestamp = parseStudyDateTimestamp(bRow.date ?? '', bRow.time ?? '');
return aTimestamp - bTimestamp;
},
meta: {
label: 'Study Date',
headerClassName: 'min-w-[195px]',
cellClassName: 'min-w-[195px]',
minWidth: 195,
priority: 70,
},
},
{
id: COLUMN_IDS.MODALITIES,
accessorFn: row => {
const r = row as StudyRow;
return r.modalities ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue(COLUMN_IDS.MODALITIES)}</div>,
filterFn: (row, colId, filter) => {
const selected = Array.isArray(filter) ? (filter as string[]) : [];
if (!selected.length) {
return true;
}
const tokens = tokenizeModalities(String(row.getValue(colId) ?? ''));
const set = new Set(tokens);
return selected.some(v => set.has(String(v).toUpperCase()));
},
meta: {
label: 'Modalities',
headerClassName: 'min-w-[97px]',
cellClassName: 'min-w-[97px]',
minWidth: 97,
priority: 60,
},
},
{
id: COLUMN_IDS.DESCRIPTION,
accessorFn: row => {
const r = row as StudyRow;
return r.description ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => {
const description = row.getValue(COLUMN_IDS.DESCRIPTION) as string;
return (
<div className={!description ? 'text-muted-foreground/40' : ''}>
{description || 'No Description'}
</div>
);
},
meta: {
label: 'Description',
headerClassName: 'min-w-[290px]',
cellClassName: 'min-w-[290px]',
minWidth: 290,
priority: 90,
},
},
{
id: COLUMN_IDS.ACCESSION,
accessorFn: row => {
const r = row as StudyRow;
return r.accession ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue(COLUMN_IDS.ACCESSION)}</div>,
meta: {
label: 'Accession',
headerClassName: 'min-w-[140px]',
cellClassName: 'min-w-[140px]',
minWidth: 140,
priority: 30,
},
},
{
id: COLUMN_IDS.INSTANCES,
accessorFn: row => {
const r = row as StudyRow;
return Number(r.instances ?? 0);
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => {
const value = row.getValue(COLUMN_IDS.INSTANCES) as number;
return <div className="text-right">{value}</div>;
},
sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number),
meta: {
label: 'Instances',
headerContent: (
<Icons.Series
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
),
align: 'right',
headerClassName: 'min-w-[45px]',
cellClassName: 'min-w-[45px] overflow-hidden',
minWidth: 45,
priority: 50,
},
},
// Non-hideable trailing actions column to keep the menu at row end
{
id: COLUMN_IDS.ACTIONS,
header: () => null,
enableSorting: false,
enableHiding: false,
cell: ({ cell }) => <ActionCell cell={cell} />,
meta: {
// No label so it never appears labeled in any UI; also non-hideable
headerClassName: 'w-[56px] min-w-[56px] max-w-[56px]',
cellClassName: 'w-[56px] min-w-[56px] max-w-[56px] overflow-visible',
minWidth: 56,
},
},
];

View File

@ -0,0 +1,19 @@
import * as React from 'react';
import type { Cell } from '@tanstack/react-table';
import { DataTable } from '../../DataTable';
import { WorkflowMenu } from './WorkflowMenu';
import type { StudyRow } from '../types/types';
export function ActionCell({ cell }: { cell: Cell<StudyRow, unknown> }) {
const original = cell.row.original as StudyRow;
return (
<DataTable.ActionOverlayCell cell={cell}>
<DataTable.ActionOverlayCell.Overlay>
<div onClick={e => e.stopPropagation()}>
<WorkflowMenu studyRow={original} />
</div>
</DataTable.ActionOverlayCell.Overlay>
</DataTable.ActionOverlayCell>
);
}

View File

@ -0,0 +1,210 @@
import * as React from 'react';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../../Resizable';
import { Table as StudyListTable, type TableProps as StudyListTableProps } from './Table';
import { WorkflowsProvider, type Mode } from './WorkflowsProvider';
type LayoutContextValue = {
isPreviewOpen: boolean;
openPreview: () => void;
closePreview: () => void;
defaultPreviewSizePercent: number;
minPreviewSizePercent: number;
};
const LayoutContext = React.createContext<LayoutContextValue | undefined>(undefined);
export function useLayout() {
const ctx = React.useContext(LayoutContext);
if (!ctx) {
throw new Error('useLayout must be used within <StudyList>');
}
return ctx;
}
export type LayoutProps = {
isPreviewOpen: boolean;
onIsPreviewOpenChange: (open: boolean) => void;
defaultPreviewSizePercent: number;
minPreviewSizePercent?: number;
className?: string;
children?: React.ReactNode;
/** Array of loaded modes from appConfig */
loadedModes?: Mode[];
/** Optional data path prefix for routes (e.g., '/dicomweb') */
dataPath?: string;
/** Function to preserve query parameters when launching workflows */
preserveQueryParameters: (query: URLSearchParams) => void;
};
function LayoutRoot({
isPreviewOpen,
onIsPreviewOpenChange,
defaultPreviewSizePercent,
minPreviewSizePercent = 15,
className,
loadedModes = [],
preserveQueryParameters,
dataPath,
children,
}: LayoutProps) {
let tableChild: React.ReactNode = null;
let previewChild: React.ReactNode = null;
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
const childType = child.type;
if (childType === Table) {
if (tableChild) {
throw new Error('StudyList can only contain one Table component.');
}
tableChild = child;
} else if (childType === Preview) {
if (previewChild) {
throw new Error('StudyList can only contain one Preview component.');
}
previewChild = child;
} else {
throw new Error('StudyList can only contain Table and Preview components.');
}
}
});
if (!tableChild) {
throw new Error('StudyList must contain a Table component.');
}
const openPreview = React.useCallback(() => onIsPreviewOpenChange(true), [onIsPreviewOpenChange]);
const closePreview = React.useCallback(
() => onIsPreviewOpenChange(false),
[onIsPreviewOpenChange]
);
const value = React.useMemo<LayoutContextValue>(
() => ({
isPreviewOpen,
openPreview,
closePreview,
defaultPreviewSizePercent,
minPreviewSizePercent,
}),
[isPreviewOpen, openPreview, closePreview, defaultPreviewSizePercent, minPreviewSizePercent]
);
return (
<WorkflowsProvider
loadedModes={loadedModes}
preserveQueryParameters={preserveQueryParameters}
dataPath={dataPath}
>
<LayoutContext.Provider value={value}>
<ResizablePanelGroup
direction="horizontal"
className={className ?? 'h-full w-full'}
>
{tableChild}
{previewChild}
</ResizablePanelGroup>
</LayoutContext.Provider>
</WorkflowsProvider>
);
}
type TableProps = StudyListTableProps & {
children?: React.ReactNode; // If provided, overrides table rendering (for custom content)
};
function Table({
columns,
data,
title,
initialVisibility,
enforceSingleSelection,
showColumnVisibility,
tableClassName,
onSelectionChange,
onSortingChange,
onPaginationChange,
onFiltersChange,
manualFiltering,
sorting,
pagination,
filters,
toolbarLeftComponent,
toolbarRightComponent,
toolbarRightActionsComponent,
isLoading,
loadingComponent,
children,
}: TableProps) {
const { defaultPreviewSizePercent } = useLayout();
// If children are provided, use them (for custom content)
// Otherwise, render the StudyList.Table with the provided props
const content = children ? (
children
) : (
<div className="flex h-full min-h-0 w-full flex-col px-3 pb-3 pt-0">
<div className="min-h-0 flex-1">
<div className="h-full rounded-md px-2 pb-2 pt-0">
<StudyListTable
columns={columns}
data={data}
title={title}
initialVisibility={initialVisibility}
enforceSingleSelection={enforceSingleSelection}
showColumnVisibility={showColumnVisibility}
tableClassName={tableClassName}
onSelectionChange={onSelectionChange}
toolbarLeftComponent={toolbarLeftComponent}
toolbarRightComponent={toolbarRightComponent}
toolbarRightActionsComponent={toolbarRightActionsComponent}
onSortingChange={onSortingChange}
onPaginationChange={onPaginationChange}
onFiltersChange={onFiltersChange}
manualFiltering={manualFiltering}
sorting={sorting}
pagination={pagination}
filters={filters}
isLoading={isLoading}
loadingComponent={loadingComponent}
/>
</div>
</div>
</div>
);
return <ResizablePanel defaultSize={100 - defaultPreviewSizePercent}>{content}</ResizablePanel>;
}
function Preview({
minSizePercent,
children,
}: {
minSizePercent?: number;
children?: React.ReactNode;
}) {
const { isPreviewOpen, defaultPreviewSizePercent, minPreviewSizePercent } = useLayout();
if (!isPreviewOpen) {
return null;
}
return (
<>
<ResizableHandle />
<ResizablePanel
defaultSize={defaultPreviewSizePercent}
minSize={minSizePercent ?? minPreviewSizePercent}
>
{children}
</ResizablePanel>
</>
);
}
LayoutRoot.displayName = 'Layout';
// Export for backward compatibility (will be removed from StudyList namespace)
export const Layout = Object.assign(LayoutRoot, {
Table,
Preview,
});

View File

@ -0,0 +1,45 @@
import * as React from 'react';
import { ScrollArea } from '../../ScrollArea';
import { PreviewContent } from './PreviewContent';
import { PreviewHeader } from './PreviewHeader';
type PreviewContainerProps = {
children: React.ReactNode;
};
function PreviewContainerRoot({ children }: PreviewContainerProps) {
let header: React.ReactNode = null;
let content: React.ReactNode = null;
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
const childType = child.type;
if (childType === PreviewHeader) {
header = child;
} else if (childType === PreviewContent) {
if (content) {
throw new Error('PreviewContainer can only contain one PreviewContent.');
}
content = child;
} else {
throw new Error('PreviewContainer can only contain PreviewHeader and PreviewContent.');
}
}
});
if (!content) {
throw new Error('PreviewContainer must contain PreviewContent.');
}
return (
<div className="bg-background relative flex h-full w-full flex-col">
{header}
<div className="direction-y flex min-h-0 flex-1 pr-3 pb-3 pt-[15px]">{content}</div>
</div>
);
}
PreviewContainerRoot.displayName = 'PreviewContainer';
export const PreviewContainer = PreviewContainerRoot;

View File

@ -0,0 +1,186 @@
import * as React from 'react';
import { Thumbnail } from '../../Thumbnail';
import { TooltipProvider } from '../../Tooltip';
import {
PreviewThumbnailStatusState,
type PreviewThumbnailStatus,
type StudyRow,
} from '../types/types';
import { PreviewPatientSummary } from './PreviewPatientSummary';
import { PreviewSeriesList } from './PreviewSeriesList';
import { ToggleGroup, ToggleGroupItem } from '../../ToggleGroup';
import { Icons } from '../../Icons';
import { ScrollArea } from '../../ScrollArea';
type PreviewSeriesViewMode = 'thumbnails' | 'list';
type PreviewSeriesView = 'all' | PreviewSeriesViewMode;
function PreviewContent({
study,
series = [],
seriesView = 'all',
onThumbnailImageError,
}: {
study?: StudyRow | null;
series?: Array<{
seriesInstanceUid?: string;
SeriesInstanceUID?: string;
description?: string;
SeriesDescription?: string;
seriesNumber?: number;
SeriesNumber?: number;
numInstances?: number;
numSeriesInstances?: number;
modality?: string;
Modality?: string;
thumbnailStatus?: PreviewThumbnailStatus;
}>;
/**
* Controls which series views are available in the preview.
* - `'all'` (default): toggle visible; initially shows the thumbnails.
* - `'thumbnails'`: toggle hidden; locked to thumbnails view.
* - `'list'`: toggle hidden; locked to list view.
*/
seriesView?: PreviewSeriesView;
/**
* Called when the thumbnail src URL fails to decode in the browser (broken image).
*/
onThumbnailImageError?: (seriesInstanceUid: string) => void;
}) {
const isToggleVisible = seriesView === 'all';
const [seriesViewMode, setSeriesViewMode] = React.useState<PreviewSeriesViewMode>('thumbnails');
const effectiveSeriesViewMode: PreviewSeriesViewMode = isToggleVisible
? seriesViewMode
: seriesView;
const imagingSeries = series.filter(
s => s.thumbnailStatus?.status !== PreviewThumbnailStatusState.NotApplicable
);
const nonImagingSeries = series.filter(
s => s.thumbnailStatus?.status === PreviewThumbnailStatusState.NotApplicable
);
// Handle empty state when no study is provided
if (!study) {
return (
<PreviewPatientSummary>
<PreviewPatientSummary.Patient />
<PreviewPatientSummary.Workflows />
</PreviewPatientSummary>
);
}
return (
<TooltipProvider delayDuration={200}>
<div className="flex h-full min-h-0 w-full flex-col gap-3">
<PreviewPatientSummary data={study}>
<PreviewPatientSummary.Patient />
<PreviewPatientSummary.Workflows />
</PreviewPatientSummary>
<div className="text-muted-foreground flex h-5 w-full items-center justify-between gap-1 px-2 text-base">
<span className="leading-tight">
{series?.length ? study?.description || 'No Description' : 'No Series'}
</span>
{isToggleVisible && (
<ToggleGroup
type="single"
value={seriesViewMode}
onValueChange={value => value && setSeriesViewMode(value as 'thumbnails' | 'list')}
>
<ToggleGroupItem
value="thumbnails"
aria-label="Thumbnail view"
className="text-primary"
>
<Icons.ThumbnailView />
</ToggleGroupItem>
<ToggleGroupItem
value="list"
aria-label="List view"
className="text-primary"
>
<Icons.ListView />
</ToggleGroupItem>
</ToggleGroup>
)}
</div>
<ScrollArea className="h-full min-h-0 w-full flex-1">
{effectiveSeriesViewMode === 'thumbnails' ? (
<div className="flex flex-col">
{imagingSeries.length > 0 && (
<div className="grid grid-cols-[repeat(auto-fit,_minmax(0,135px))] place-items-start gap-[4px] pr-2">
{imagingSeries.map((seriesItem, index) => {
const seriesUID =
seriesItem.seriesInstanceUid || seriesItem.SeriesInstanceUID || String(index);
const thumbnailState = seriesItem.thumbnailStatus;
const imageSrc =
thumbnailState?.status === PreviewThumbnailStatusState.Ready
? thumbnailState.src
: undefined;
return (
<Thumbnail
key={`series-imaging-${seriesUID}`}
displaySetInstanceUID={`series-${seriesUID}`}
imageSrc={imageSrc as any}
onImageLoadError={() => onThumbnailImageError?.(seriesUID)}
imageAltText={seriesItem.description || seriesItem.SeriesDescription || ''}
description={
seriesItem.description || seriesItem.SeriesDescription || '(empty)'
}
seriesNumber={seriesItem.seriesNumber ?? seriesItem.SeriesNumber ?? ''}
numInstances={seriesItem.numSeriesInstances ?? seriesItem.numInstances ?? 0}
modality={seriesItem.modality || seriesItem.Modality || ''}
isActive={false}
onClick={() => {}}
onDoubleClick={() => {}}
isDraggable={false}
viewPreset="thumbnails"
thumbnailType="thumbnail"
>
{thumbnailState?.status === PreviewThumbnailStatusState.NotAvailable && (
<div className="bg-muted/50 flex h-full w-full items-center justify-center rounded">
<Icons.SeriesPlaceholder className="text-muted-foreground h-[40px] w-[60px] opacity-50" />
</div>
)}
</Thumbnail>
);
})}
</div>
)}
{nonImagingSeries.length > 0 && (
<div className="mt-1 grid grid-cols-[repeat(auto-fit,_minmax(0,275px))] place-items-start gap-[2px] pr-2">
{nonImagingSeries.map((seriesItem, index) => {
const seriesUID =
seriesItem.seriesInstanceUid || seriesItem.SeriesInstanceUID || String(index);
return (
<Thumbnail
key={`series-non-imaging-${seriesUID}`}
displaySetInstanceUID={`series-${seriesUID}`}
imageAltText={seriesItem.description || seriesItem.SeriesDescription || ''}
description={
seriesItem.description || seriesItem.SeriesDescription || '(empty)'
}
seriesNumber={seriesItem.seriesNumber ?? seriesItem.SeriesNumber ?? ''}
numInstances={seriesItem.numSeriesInstances ?? seriesItem.numInstances ?? 0}
modality={seriesItem.modality || seriesItem.Modality || ''}
isActive={false}
onClick={() => {}}
onDoubleClick={() => {}}
isDraggable={false}
viewPreset="list"
thumbnailType="thumbnailNoImage"
/>
);
})}
</div>
)}
</div>
) : (
<PreviewSeriesList series={series} />
)}
</ScrollArea>
</div>
</TooltipProvider>
);
}
export { PreviewContent };

View File

@ -0,0 +1,5 @@
import * as React from 'react';
export function PreviewHeader({ children }: { children?: React.ReactNode }) {
return <div className="z-10 mt-5 mr-5 flex items-center justify-end gap-1">{children}</div>;
}

View File

@ -0,0 +1,235 @@
/**
* PreviewPatientSummary compound component for patient header + workflow actions
*
* What it is
* - A compact summary API composed under a root provider: Patient and Workflows.
* - Subcomponents read the current study row from context.
*
* Minimal usage
* <PreviewPatientSummary data={row}>
* <PreviewPatientSummary.Patient />
* <PreviewPatientSummary.Workflows />
* </PreviewPatientSummary>
*
* Default workflow behavior
* - Workflows uses WorkflowsProvider context for available workflows and the default selection.
* - Shows the default workflow as a badge (with a clear-X) and the remaining applicable
* workflows as buttons below.
*
* Helpful references
* - platform/ui-next/src/components/StudyList/components/PreviewContent.tsx (in-context example, including empty state handling)
*/
import * as React from 'react';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cn } from '../../../lib/utils';
import { Icons } from '../../Icons/Icons';
import { Button } from '../../Button';
import type { StudyRow } from '../types/types';
import { useWorkflows } from './WorkflowsProvider';
type SummaryContextValue = {
data: StudyRow | null;
};
const SummaryContext = React.createContext<SummaryContextValue | null>(null);
function useSummaryContext(): SummaryContextValue {
const context = React.useContext(SummaryContext);
if (!context) {
throw new Error(
'PreviewPatientSummary.* components must be used within <PreviewPatientSummary>'
);
}
return context;
}
type RootProps = {
data?: StudyRow | null;
className?: string;
children?: React.ReactNode;
};
function Root({ data: dataProp, className, children }: RootProps) {
const data = dataProp ?? null;
const value = React.useMemo<SummaryContextValue>(() => ({ data }), [data]);
return (
<SummaryContext.Provider value={value}>
<div className={cn('flex w-full flex-col gap-1', className)}>{children}</div>
</SummaryContext.Provider>
);
}
function Patient({ className }: { className?: string } = {}) {
const { data } = useSummaryContext();
const patientName = data?.patientName;
const mrn = data?.mrn;
const nameContent = patientName ?? 'Select a study';
const nameTitle =
typeof patientName === 'string' || typeof patientName === 'number'
? String(patientName)
: undefined;
const showMrn = mrn !== null && mrn !== undefined && mrn !== '';
const mrnTitle =
typeof mrn === 'string' || typeof mrn === 'number' ? String(mrn) : undefined;
return (
<div className={cn('bg-muted flex items-center gap-3 rounded-lg px-4 py-3', className)}>
<div
className="text-primary shrink-0"
aria-hidden
style={{ width: 33, height: 33 }}
>
<Icons.PatientStudyList />
</div>
<div className="flex h-[38px] min-w-0 flex-col justify-center gap-px">
<span
className="text-foreground truncate text-lg font-medium leading-tight"
title={nameTitle}
>
{nameContent}
</span>
{showMrn && (
<span
className="text-muted-foreground truncate text-lg leading-tight"
title={mrnTitle}
>
{mrn}
</span>
)}
</div>
</div>
);
}
/**
* Workflows panel.
*
* - Must be used inside `<PreviewPatientSummary>` to read the current study from context.
* - Must be used inside `<WorkflowsProvider>` to access workflow context.
* - Shows the default workflow badge (with a clear-X) when one is set, and renders the
* remaining applicable workflows as buttons below.
*/
function Workflows({ className }: { className?: string } = {}) {
const { data: studyRow } = useSummaryContext();
const { workflows, getWorkflowsForStudy, getDefaultWorkflowForStudy, setDefaultWorkflowId } =
useWorkflows();
const availableWorkflows = React.useMemo(() => {
if (studyRow) {
return getWorkflowsForStudy(studyRow);
}
return workflows;
}, [studyRow, workflows, getWorkflowsForStudy]);
const defaultWorkflow = React.useMemo(() => {
if (studyRow) {
return getDefaultWorkflowForStudy(studyRow);
}
return workflows.find(w => w.isDefault);
}, [studyRow, workflows, getDefaultWorkflowForStudy]);
const otherWorkflows = React.useMemo(() => {
if (!defaultWorkflow) {
return availableWorkflows;
}
return availableWorkflows.filter(w => w.displayName !== defaultWorkflow.displayName);
}, [availableWorkflows, defaultWorkflow]);
const handleLaunch = (displayName: string) => {
if (!studyRow) {
return;
}
const wf = availableWorkflows.find(w => w.displayName === displayName);
wf?.launchWithStudy(studyRow);
};
const handleClearDefault = () => {
setDefaultWorkflowId();
};
return (
<div
className={cn(
'border-border/50 bg-muted w-full rounded-lg px-4 py-3 text-left transition',
className
)}
>
<div className="flex w-full items-center justify-between">
<span className="text-foreground text-base font-medium leading-tight">Launch workflow</span>
<span
className="text-primary shrink-0 -translate-y-0.5"
aria-hidden
style={{ width: 18, height: 18 }}
>
<Icons.Info />
</span>
</div>
{defaultWorkflow && (
<div
className="mt-2 flex flex-wrap items-center gap-0"
role="status"
aria-live="polite"
>
<Button
variant="ghost"
size="sm"
className="bg-primary/20 text-primary ring-primary ring-offset-background ml-1 mb-1 h-6 w-32 overflow-hidden ring-1 ring-offset-2"
onClick={() => handleLaunch(defaultWorkflow.displayName)}
title={defaultWorkflow.displayName}
>
<span className="truncate">{defaultWorkflow.displayName}</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear default mode"
className="text-primary focus-visible:ring-ring focus-visible:ring-offset-background ml-1.5 mb-1 opacity-70 transition hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2"
onClick={handleClearDefault}
>
<Cross2Icon
className="text-primary h-3.5 w-3.5"
aria-hidden
/>
<span className="sr-only">Clear default mode</span>
</Button>
</div>
)}
{defaultWorkflow && studyRow && otherWorkflows.length > 0 && (
<div className="text-muted-foreground mt-2 text-sm">Other Available Workflows</div>
)}
{studyRow && otherWorkflows.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-0">
{otherWorkflows.map(wf => (
<Button
key={wf.displayName}
variant="ghost"
size="sm"
className="bg-primary/20 ml-1 mb-1 h-6 w-32 overflow-hidden"
onClick={() => handleLaunch(wf.displayName)}
title={wf.displayName}
>
<span className="truncate">{wf.displayName}</span>
</Button>
))}
</div>
)}
</div>
);
}
type PreviewPatientSummaryNamespace = typeof Root & {
Patient: typeof Patient;
Workflows: typeof Workflows;
};
export const PreviewPatientSummary: PreviewPatientSummaryNamespace = Object.assign(Root, {
Patient,
Workflows,
});

View File

@ -0,0 +1,64 @@
import * as React from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../Table';
import { Icons } from '../../Icons';
type Series = {
seriesInstanceUid?: string;
SeriesInstanceUID?: string;
modality?: string;
Modality?: string;
description?: string;
SeriesDescription?: string;
seriesNumber?: number | string;
SeriesNumber?: number | string;
numSeriesInstances?: number;
numInstances?: number;
};
type PreviewSeriesListProps = {
series: Series[];
onSeriesClick?: (series: Series) => void;
};
export function PreviewSeriesList({ series, onSeriesClick }: PreviewSeriesListProps) {
return (
<div className="w-full px-2">
<Table noScroll>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="pl-0 text-base font-normal">
<span className="text-foreground">Modality</span>
<span className="text-muted-foreground"> / Series</span>
</TableHead>
<TableHead className="text-foreground w-8 pr-0 text-right text-base font-normal">
<Icons.Series className="ml-auto h-4 w-4" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{series.map((s, idx) => {
const seriesUID = s.seriesInstanceUid || s.SeriesInstanceUID || String(idx);
const modality = String(s.modality || s.Modality || '').toUpperCase();
const description = s.description || s.SeriesDescription || '';
const numInstances = s.numSeriesInstances ?? s.numInstances ?? 0;
return (
<TableRow
key={seriesUID}
className="hover:text-muted-foreground cursor-default hover:bg-transparent"
>
<TableCell className="pl-0 text-base">
<div className="flex items-center gap-2">
<span className="text-foreground font-normal">{modality}</span>
<span className="font-normal">{description}</span>
</div>
</TableCell>
<TableCell className="w-8 pr-0 text-right text-base">{numInstances}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,69 @@
import * as React from 'react';
import { Button } from '../../Button';
import { Icons } from '../../Icons';
import { useLayout } from './Layout';
type PreviewToggleButtonProps = React.HTMLAttributes<HTMLButtonElement> & {
'aria-label'?: string;
shouldShow: boolean;
onClick: () => void;
defaultAriaLabel: string;
};
function PreviewToggleButton({
className,
'aria-label': ariaLabel,
shouldShow,
onClick,
defaultAriaLabel,
}: PreviewToggleButtonProps) {
if (!shouldShow) {
return null;
}
return (
<Button
variant="ghost"
size="icon"
aria-label={ariaLabel ?? defaultAriaLabel}
onClick={onClick}
className={className}
>
<Icons.PanelRight
aria-hidden="true"
className="h-4 w-4"
/>
</Button>
);
}
export function OpenPreviewButton({
className,
'aria-label': ariaLabel = 'Open preview',
}: React.HTMLAttributes<HTMLButtonElement> & { 'aria-label'?: string }) {
const { isPreviewOpen, openPreview } = useLayout();
return (
<PreviewToggleButton
className={className}
aria-label={ariaLabel}
shouldShow={!isPreviewOpen}
onClick={openPreview}
defaultAriaLabel="Open preview"
/>
);
}
export function ClosePreviewButton({
className,
'aria-label': ariaLabel = 'Close preview',
}: React.HTMLAttributes<HTMLButtonElement> & { 'aria-label'?: string }) {
const { isPreviewOpen, closePreview } = useLayout();
return (
<PreviewToggleButton
className={className}
aria-label={ariaLabel}
shouldShow={isPreviewOpen}
onClick={closePreview}
defaultAriaLabel="Close preview"
/>
);
}

View File

@ -0,0 +1,214 @@
import * as React from 'react';
import { Popover, PopoverTrigger, PopoverContent } from '../../Popover/Popover';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '../../Select';
import { Label } from '../../Label';
import { Button } from '../../Button';
import { useWorkflows } from './WorkflowsProvider';
/** Context to allow subcomponents to close the popover */
type SettingsPopoverContextValue = {
close: () => void;
};
const SettingsPopoverContext = React.createContext<SettingsPopoverContextValue | null>(null);
function useSettingsPopoverContext() {
const ctx = React.useContext(SettingsPopoverContext);
if (!ctx) {
throw new Error('SettingsPopover subcomponents must be used within <SettingsPopover>');
}
return ctx;
}
type SettingsPopoverProps = {
children?: React.ReactNode;
};
/** Marker subcomponent: consumed by the root to render the PopoverTrigger */
type TriggerProps = {
children: React.ReactNode;
};
function SettingsPopoverTrigger(_props: TriggerProps) {
// This is a marker-only component. The root extracts its children and renders them inside PopoverTrigger.
return null;
}
SettingsPopoverTrigger.displayName = 'SettingsPopover.Trigger';
/**
* Root SettingsPopover component (compound API).
* Usage:
* <SettingsPopover>
* <SettingsPopover.Trigger><Button>...</Button></SettingsPopover.Trigger>
* <SettingsPopover.Content>
* <SettingsPopover.Workflow ... />
* <SettingsPopover.Divider />
* <SettingsPopover.Item onClick={...}>About</SettingsPopover.Item>
* </SettingsPopover.Content>
* </SettingsPopover>
*/
function SettingsPopoverRoot({ children }: SettingsPopoverProps) {
const [open, setOpen] = React.useState(false);
const close = React.useCallback(() => setOpen(false), []);
// Extract the Trigger and Content nodes from children
const childrenArray = React.Children.toArray(children);
let triggerNode: React.ReactNode | null = null;
let contentNode: React.ReactElement<ContentProps> | null = null;
for (const child of childrenArray) {
if (React.isValidElement(child) && child.type === SettingsPopoverTrigger) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
triggerNode = (child.props as TriggerProps).children;
} else if (React.isValidElement(child) && child.type === SettingsPopoverContent) {
contentNode = child as React.ReactElement<ContentProps>;
}
}
if (!triggerNode) {
throw new Error(
'<SettingsPopover.Trigger> is required as a direct child of <SettingsPopover>.'
);
}
if (!contentNode) {
throw new Error(
'<SettingsPopover.Content> is required as a direct child of <SettingsPopover>.'
);
}
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>{triggerNode}</PopoverTrigger>
<SettingsPopoverContext.Provider value={{ close }}>
{contentNode}
</SettingsPopoverContext.Provider>
</Popover>
);
}
type ContentProps = {
children?: React.ReactNode;
};
function SettingsPopoverContent({ children }: ContentProps) {
return (
<PopoverContent
align="end"
sideOffset={8}
className="w-[315px] p-4"
onOpenAutoFocus={e => e.preventDefault()}
>
{children}
</PopoverContent>
);
}
/**
* SettingsPopover.Workflow
* Renders the "Default Workflow" row with a Select.
* Closes the popover after selection.
*/
function Workflow() {
const { close } = useSettingsPopoverContext();
const { workflows, defaultWorkflowId, setDefaultWorkflowId } = useWorkflows();
const selectId = React.useId();
const NO_DEFAULT_VALUE = '__NO_DEFAULT__';
return (
<div className="mt-0 flex items-center gap-3">
<Label
htmlFor={selectId}
className="whitespace-nowrap"
>
Default Workflow
</Label>
<div className="min-w-0 flex-1">
<Select
value={defaultWorkflowId ?? undefined}
onValueChange={value => {
if (value === NO_DEFAULT_VALUE) {
setDefaultWorkflowId();
} else {
setDefaultWorkflowId(value);
}
close();
}}
>
<SelectTrigger
id={selectId}
className="w-full"
>
<SelectValue placeholder="Select Workflow" />
</SelectTrigger>
{/* Keep stopPropagation so the Select's portal doesn't trigger outside interactions on the Popover */}
<SelectContent onPointerDown={e => e.stopPropagation()}>
{workflows.map(workflow => (
<SelectItem
key={workflow.id}
value={workflow.id}
>
{workflow.displayName}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={NO_DEFAULT_VALUE}>No Default</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
/**
* SettingsPopover.Divider
* A simple divider to separate sections inside the popover.
*/
function Divider() {
return <div className="bg-muted -mx-2 my-3 h-px" />;
}
type ItemProps = {
children: React.ReactNode;
onClick?: () => void;
};
function Item({ children, onClick }: ItemProps) {
const { close } = useSettingsPopoverContext();
const handleClick = () => {
onClick?.();
close();
};
return (
<Button
variant="ghost"
size="default"
onClick={handleClick}
className="h-7 w-full justify-start"
>
{children}
</Button>
);
}
SettingsPopoverRoot.displayName = 'SettingsPopover';
export const SettingsPopover = Object.assign(SettingsPopoverRoot, {
Trigger: SettingsPopoverTrigger,
Content: SettingsPopoverContent,
Workflow,
Divider,
Item,
});

View File

@ -0,0 +1,229 @@
import React, { type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { DataTable, useDataTable } from '../../DataTable';
import type { DataTableProps } from '../../DataTable/DataTable';
import { Button } from '../../Button';
import { DatePickerWithRange } from '../../DateRange';
import { InputMultiSelect } from '../../InputMultiSelect';
import type { StudyDateRangeFilter, StudyRow } from '../types/types';
import { tokenizeModalities } from '../utils/tokenizeModalities';
import { useWorkflows } from './WorkflowsProvider';
import { COLUMN_IDS } from '../columns/defaultColumns';
export type TableProps = Omit<DataTableProps<StudyRow>, 'children' | 'getRowId'> & {
title?: ReactNode;
showColumnVisibility?: boolean;
tableClassName?: string;
toolbarLeftComponent?: ReactNode;
toolbarRightActionsComponent?: ReactNode;
toolbarRightComponent?: ReactNode;
isLoading?: boolean;
loadingComponent?: ReactNode;
};
export function Table({
columns,
data,
title,
initialVisibility = {},
sorting,
pagination,
filters,
onSortingChange,
onPaginationChange,
onFiltersChange,
enforceSingleSelection = true,
showColumnVisibility = true,
tableClassName,
onSelectionChange,
toolbarLeftComponent,
toolbarRightActionsComponent,
toolbarRightComponent,
isLoading,
loadingComponent,
manualFiltering,
}: TableProps) {
return (
<DataTable<StudyRow>
data={data}
columns={columns}
getRowId={row => row.studyInstanceUid}
initialVisibility={initialVisibility}
sorting={sorting}
pagination={pagination}
filters={filters}
onSortingChange={onSortingChange}
onPaginationChange={onPaginationChange}
onFiltersChange={onFiltersChange}
manualFiltering={manualFiltering}
enforceSingleSelection={enforceSingleSelection}
onSelectionChange={onSelectionChange}
>
<TableContent
title={title}
showColumnVisibility={showColumnVisibility}
tableClassName={tableClassName}
toolbarLeftComponent={toolbarLeftComponent}
toolbarRightActionsComponent={toolbarRightActionsComponent}
toolbarRightComponent={toolbarRightComponent}
isLoading={isLoading}
loadingComponent={loadingComponent}
/>
</DataTable>
);
}
function TableContent({
title,
showColumnVisibility,
tableClassName,
toolbarLeftComponent,
toolbarRightActionsComponent,
toolbarRightComponent,
isLoading,
loadingComponent,
}: {
title?: ReactNode;
showColumnVisibility?: boolean;
tableClassName?: string;
toolbarLeftComponent?: ReactNode;
toolbarRightActionsComponent?: ReactNode;
toolbarRightComponent?: ReactNode;
isLoading?: boolean;
loadingComponent?: ReactNode;
}) {
const { t } = useTranslation('StudyList');
const { table } = useDataTable<StudyRow>();
const modalityOptions = useMemo(() => {
const rows = (table.options?.data as StudyRow[]) ?? [];
// Build a flat list of modality tokens across all rows.
// tokenizeModalities uppercases and splits on whitespace/slash/comma to produce unique modality codes for filtering.
const tokens = rows.flatMap(r => tokenizeModalities(String(r.modalities ?? '')));
return Array.from(new Set(tokens)).sort();
}, [table.options?.data]);
// Access workflow provider for default workflow + launch
const { getDefaultWorkflowForStudy } = useWorkflows();
return (
<div className="flex h-full flex-col">
{(showColumnVisibility || title) && (
<DataTable.Toolbar>
<div className="absolute left-0">{toolbarLeftComponent}</div>
{title ? <DataTable.Title>{title}</DataTable.Title> : null}
<div className="absolute right-0 flex items-center">
{toolbarRightActionsComponent}
{toolbarRightActionsComponent && <div className="bg-input mx-2 h-4 w-px" />}
{/* Pagination appears to the left of the "View" button */}
<DataTable.Pagination<StudyRow> />
{showColumnVisibility && <DataTable.ViewOptions<StudyRow> />}
{toolbarRightComponent}
</div>
</DataTable.Toolbar>
)}
<DataTable.Table<StudyRow> tableClassName={tableClassName}>
<DataTable.Header<StudyRow> />
<DataTable.FilterRow<StudyRow>
excludeColumnIds={[COLUMN_IDS.INSTANCES]}
renderFilterCell={({ columnId, value, setValue }) => {
if (columnId === COLUMN_IDS.ACTIONS) {
return (
<div className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => table.setColumnFilters([])}
aria-label="Reset filters"
>
Reset
</Button>
</div>
);
}
if (columnId === COLUMN_IDS.STUDY_DATE_TIME) {
const dateRange =
value && typeof value === 'object'
? (value as StudyDateRangeFilter)
: {};
const startDate = dateRange.startDate ?? '';
const endDate = dateRange.endDate ?? '';
return (
<DatePickerWithRange
id={COLUMN_IDS.STUDY_DATE_TIME}
startDate={startDate}
endDate={endDate}
onChange={next => {
const normalized = {
...(next.startDate ? { startDate: next.startDate } : {}),
...(next.endDate ? { endDate: next.endDate } : {}),
};
setValue(Object.keys(normalized).length > 0 ? normalized : undefined);
}}
/>
);
}
if (columnId === COLUMN_IDS.MODALITIES) {
const selected = Array.isArray(value) ? (value as string[]) : [];
return (
<InputMultiSelect
options={modalityOptions}
value={selected}
onChange={next => setValue(next)}
>
<InputMultiSelect.Field>
<InputMultiSelect.Summary />
<InputMultiSelect.Input
ariaLabel="Filter Modalities"
placeholder=""
/>
</InputMultiSelect.Field>
<InputMultiSelect.Content
fitToContent
maxWidth={185}
>
<InputMultiSelect.Options />
</InputMultiSelect.Content>
</InputMultiSelect>
);
}
// Return null/undefined to use default rendering for other columns
return null;
}}
/>
<DataTable.Body<StudyRow>
emptyMessage={t('No studies available')}
isLoading={isLoading}
loadingComponent={loadingComponent}
rowProps={{
className: 'group cursor-pointer',
onClick: row => {
const original = row.original as StudyRow;
const defaultWorkflow = getDefaultWorkflowForStudy(original);
// When a default workflow is set, do not allow a second click to unselect.
// Always select on click; otherwise toggle selection.
if (defaultWorkflow) {
if (!row.getIsSelected()) {
row.toggleSelected(true);
}
} else {
row.toggleSelected();
}
},
onDoubleClick: row => {
const original = row.original as StudyRow;
const defaultWorkflow = getDefaultWorkflowForStudy(original);
if (!defaultWorkflow) {
return;
}
// Ensure the row is selected, then launch with the default workflow
if (!row.getIsSelected()) {
row.toggleSelected(true);
}
defaultWorkflow.launchWithStudy(original);
},
}}
/>
</DataTable.Table>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More