diff --git a/bun.lock b/bun.lock index e2ebde02da8..70abf2919cb 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx index f82cb50d737..7f26540a99c 100644 --- a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx +++ b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx @@ -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>(); + const [itemLabels, setItemLabels] = useState>([]); + 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 ? ( -
- - {configuredItems.map((item, itemIndex) => { - return ( -
-
- {item.name} -
- {itemIndex !== configuredItems.length - 1 &&
|
} -
- ); - })} -
- ) : ( - <> - ); + + + + + + + + + {t('Data Source')}:{' '} + {t('Configure the server connection and storage settings')} + + + +
+ {itemLabels.map((label, index) => ( + + {t(label)} + {configuredItems[index]?.name ?? '—'} + + ))} + + + + + ) : null; } export default DataSourceConfigurationComponent; diff --git a/extensions/default/src/DicomWebDataSource/index.ts b/extensions/default/src/DicomWebDataSource/index.ts index 5fb351767d8..209cd406ce0 100644 --- a/extensions/default/src/DicomWebDataSource/index.ts +++ b/extensions/default/src/DicomWebDataSource/index.ts @@ -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( { diff --git a/extensions/default/src/DicomWebDataSource/qido.js b/extensions/default/src/DicomWebDataSource/qido.js index ee13fa8c4b0..2a5d84b61de 100644 --- a/extensions/default/src/DicomWebDataSource/qido.js +++ b/extensions/default/src/DicomWebDataSource/qido.js @@ -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(','); diff --git a/extensions/default/src/DicomWebDataSource/retrieveThumbnail.ts b/extensions/default/src/DicomWebDataSource/retrieveThumbnail.ts new file mode 100644 index 00000000000..52e892ce6fd --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/retrieveThumbnail.ts @@ -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; +}; + +type GetThumbnailSrcOptions = { + signal?: AbortSignal; + getImageSrc?: (imageId: unknown) => unknown; +}; + +type QidoClient = { + headers: HeadersInterface; + searchForInstances: (opts: unknown) => Promise; +}; + +/** + * 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; +}; + +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 URLs without auth headers; never use fetch here. + // No network call happens at this layer (the 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 = { + ...(getAuthorizationHeader() as Record), + Accept: 'image/jpeg', + }; + + return { + url: `${wadoRoot}${endpointPath}`, + endpointPath, + headers, + }; +} + +async function fetchThumbnailObjectURL( + thumbnailContext: ThumbnailContext, + thumbnailRendering: string, + wadoRoot: string | undefined, + getAuthorizationHeader: () => HeadersInterface, + signal?: AbortSignal +): Promise { + 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 { + 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 | 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; + } +} diff --git a/extensions/default/src/DicomWebProxyDataSource/index.ts b/extensions/default/src/DicomWebProxyDataSource/index.ts index 28bb70d19ce..453afe49909 100644 --- a/extensions/default/src/DicomWebProxyDataSource/index.ts +++ b/extensions/default/src/DicomWebProxyDataSource/index.ts @@ -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), diff --git a/extensions/default/src/MergeDataSource/index.ts b/extensions/default/src/MergeDataSource/index.ts index 92f54151d9d..69b89c56597 100644 --- a/extensions/default/src/MergeDataSource/index.ts +++ b/extensions/default/src/MergeDataSource/index.ts @@ -219,6 +219,13 @@ function createMergeDataSourceApi( }, }, retrieve: { + getGetThumbnailSrc: (...args: unknown[]) => + callForDefaultDataSource({ + path: 'retrieve.getGetThumbnailSrc', + args, + defaultDataSourceName, + extensionManager, + }), bulkDataURI: (...args: unknown[]) => callForAllDataSourcesAsync({ mergeMap, diff --git a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx index c91e8240685..5a0d7a0e069 100644 --- a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx @@ -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 */ diff --git a/extensions/default/src/customizations/workListCustomization.ts b/extensions/default/src/customizations/workListCustomization.ts new file mode 100644 index 00000000000..817f20d11f2 --- /dev/null +++ b/extensions/default/src/customizations/workListCustomization.ts @@ -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 ``. + * - `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 `` 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 + * `` 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, + }; +} diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index 6f8cebcc1ad..120712fa4ad 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -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(), }, }, ]; diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index 575ed7c16a2..442e92b4cb4 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -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 */ diff --git a/platform/app/.webpack/webpack.pwa.js b/platform/app/.webpack/webpack.pwa.js index 9bb0383263d..981dcdb658d 100644 --- a/platform/app/.webpack/webpack.pwa.js +++ b/platform/app/.webpack/webpack.pwa.js @@ -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'); diff --git a/platform/app/cypress/integration/study-list/OHIFStudyList.spec.js b/platform/app/cypress/integration/study-list/OHIFStudyList.spec.js deleted file mode 100644 index c92216de2dd..00000000000 --- a/platform/app/cypress/integration/study-list/OHIFStudyList.spec.js +++ /dev/null @@ -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()); - }); - }); - }); - }); - }); - */ - }); -}); diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 955b60d86d3..afca474b1e1 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -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, diff --git a/platform/app/public/config/default_16bit.js b/platform/app/public/config/default_16bit.js index 1dfb9a829dd..07fdfe03c2a 100644 --- a/platform/app/public/config/default_16bit.js +++ b/platform/app/public/config/default_16bit.js @@ -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, diff --git a/platform/app/public/config/docker-nginx-orthanc-keycloak.js b/platform/app/public/config/docker-nginx-orthanc-keycloak.js index 3a8b60a2c45..1f1746665c0 100644 --- a/platform/app/public/config/docker-nginx-orthanc-keycloak.js +++ b/platform/app/public/config/docker-nginx-orthanc-keycloak.js @@ -13,7 +13,7 @@ window.config = { groupEnabledModesFirst: true, maxNumRequests: { interaction: 100, - thumbnail: 75, + thumbnail: 5, prefetch: 25, }, defaultDataSourceName: 'dicomweb', diff --git a/platform/app/public/config/e2e.js b/platform/app/public/config/e2e.js index 93acbbd0b1a..3e5d589ce2c 100644 --- a/platform/app/public/config/e2e.js +++ b/platform/app/public/config/e2e.js @@ -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, diff --git a/platform/app/public/config/google.js b/platform/app/public/config/google.js index ea2e7637f50..4cdb643fb5b 100644 --- a/platform/app/public/config/google.js +++ b/platform/app/public/config/google.js @@ -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, diff --git a/platform/app/public/config/kheops.js b/platform/app/public/config/kheops.js index 4a92cf51429..c83fa965bbe 100644 --- a/platform/app/public/config/kheops.js +++ b/platform/app/public/config/kheops.js @@ -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, diff --git a/platform/app/public/config/local_dcm4chee.js b/platform/app/public/config/local_dcm4chee.js index 054c12b73ee..0cacd390eca 100644 --- a/platform/app/public/config/local_dcm4chee.js +++ b/platform/app/public/config/local_dcm4chee.js @@ -23,7 +23,9 @@ window.config = { qidoSupportsIncludeField: true, imageRendering: 'wadors', enableStudyLazyLoad: true, - thumbnailRendering: 'wadors', + thumbnailRendering: 'thumbnail', + thumbnailRequestStrategy: 'fetch', + supportsWildcard: true, requestOptions: { auth: 'admin:admin', }, diff --git a/platform/app/public/config/local_orthanc.js b/platform/app/public/config/local_orthanc.js index 838ee8816cd..1d2559d6d3b 100644 --- a/platform/app/public/config/local_orthanc.js +++ b/platform/app/public/config/local_orthanc.js @@ -25,7 +25,8 @@ window.config = { supportsReject: true, dicomUploadEnabled: true, imageRendering: 'wadors', - thumbnailRendering: 'wadors', + thumbnailRendering: 'rendered', + thumbnailRequestStrategy: 'fetch', enableStudyLazyLoad: true, supportsFuzzyMatching: true, supportsWildcard: true, diff --git a/platform/app/public/config/netlify.js b/platform/app/public/config/netlify.js index a7eec568467..5dde6e35d1a 100644 --- a/platform/app/public/config/netlify.js +++ b/platform/app/public/config/netlify.js @@ -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, diff --git a/platform/app/src/hooks/index.js b/platform/app/src/hooks/index.js index 29c94cee1ba..aef461161f1 100644 --- a/platform/app/src/hooks/index.js +++ b/platform/app/src/hooks/index.js @@ -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, +}; diff --git a/platform/app/src/hooks/index.ts b/platform/app/src/hooks/index.ts index 29c94cee1ba..aef461161f1 100644 --- a/platform/app/src/hooks/index.ts +++ b/platform/app/src/hooks/index.ts @@ -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, +}; diff --git a/platform/app/src/hooks/useSeriesFetch.ts b/platform/app/src/hooks/useSeriesFetch.ts new file mode 100644 index 00000000000..dddea5ac5dc --- /dev/null +++ b/platform/app/src/hooks/useSeriesFetch.ts @@ -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 & { 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 | 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( + items: T[], + maxParallel: number, + signal: AbortSignal, + worker: (item: T) => Promise +): Promise { + 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([]); + // 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([]); + 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 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 }; +} diff --git a/platform/app/src/hooks/useStudyListQuery.tsx b/platform/app/src/hooks/useStudyListQuery.tsx new file mode 100644 index 00000000000..83c83901196 --- /dev/null +++ b/platform/app/src/hooks/useStudyListQuery.tsx @@ -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: () => ( +
+

Error: {(e as Error).message}

+

+ Please ensure the following data source is configured correctly or is running: +

+
{friendlyName}
+
+ +
+
+ ), + }); + } + } 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; +} diff --git a/platform/app/src/hooks/useStudyListStateSync.ts b/platform/app/src/hooks/useStudyListStateSync.ts new file mode 100644 index 00000000000..703b3bef4a1 --- /dev/null +++ b/platform/app/src/hooks/useStudyListStateSync.ts @@ -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( + sessionState.pagination || parsePaginationFromURL(searchParams) + ); + const [filters, setFilters] = useState( + sessionState.filters || parseFiltersFromURL(searchParams) + ); + const [sorting, setSorting] = useState( + 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 = {}; + + // 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 }); +} diff --git a/platform/app/src/hooks/useWorkListToolbarActions.tsx b/platform/app/src/hooks/useWorkListToolbarActions.tsx new file mode 100644 index 00000000000..0ec4f709feb --- /dev/null +++ b/platform/app/src/hooks/useWorkListToolbarActions.tsx @@ -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: () => ( + { + hide(); + onRefresh(); + }} + onStarted={() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + show({ ...uploadProps, closeButton: false } as any); + }} + /> + ), + } + : undefined; + + return ( +
+ {uploadProps && ( + + )} + {dataSourceConfigElement} +
+ ); +} diff --git a/platform/app/src/routes/DataSourceWrapper.tsx b/platform/app/src/routes/DataSourceWrapper.tsx index 3c9ee3a6cc5..3869884f74f 100644 --- a/platform/app/src/routes/DataSourceWrapper.tsx +++ b/platform/app/src/routes/DataSourceWrapper.tsx @@ -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 --> // 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 ( -
-

Error: {e.message}

-

- Please ensure the following data source is configured correctly or is running: -

-
{friendlyName}
-
- ); - }, - }); - }); - } - } 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 ( 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; - } -} diff --git a/platform/app/src/routes/LegacyWorkList/LegacyWorkList.tsx b/platform/app/src/routes/LegacyWorkList/LegacyWorkList.tsx new file mode 100644 index 00000000000..6d833cb45bf --- /dev/null +++ b/platform/app/src/routes/LegacyWorkList/LegacyWorkList.tsx @@ -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 ( + + + {textValue} + + +
+ {textValue} + {textValue} +
+
+
+ ); + }; + + 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 && {studyDate}} + {studyTime && {studyTime}} + + ), + 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: ( + <> + + {instances} + + ), + title: (instances || 0).toString(), + gridCol: 2, + }, + ], + // Todo: This is actually running for all rows, even if they are + // not clicked on. + expandedContent: ( + { + return { + description: s.description || '(empty)', + seriesNumber: s.seriesNumber ?? '', + modality: s.modality || '', + instances: s.numSeriesInstances || '', + }; + }) + : [] + } + > +
+ {(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 && ( + { + // 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 */} +
+ ) : null + } + startIcon={ + isValidMode ? ( + + ) : ( + + ) + } + onClick={() => {}} + dataCY={`mode-${mode.routeName}-${studyInstanceUid}`} + className={!isValidMode && 'bg-[#222d44]'} + > + {mode.displayName} + + + ) + ); + })} +
+ + ), + 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: () => ( + { + 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 ( +
+
+ + +
+ +
+ 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 + } + /> +
+ {hasStudies ? ( +
+ +
+ +
+
+ ) : ( +
+ {appConfig.showLoadingIndicator && isLoadingData ? ( + + ) : ( + + )} +
+ )} +
+
+
+ ); +} + +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; diff --git a/platform/app/src/routes/WorkList/filtersMeta.js b/platform/app/src/routes/LegacyWorkList/filtersMeta.js similarity index 100% rename from platform/app/src/routes/WorkList/filtersMeta.js rename to platform/app/src/routes/LegacyWorkList/filtersMeta.js diff --git a/platform/app/src/routes/LegacyWorkList/index.js b/platform/app/src/routes/LegacyWorkList/index.js new file mode 100644 index 00000000000..1b8763e11a7 --- /dev/null +++ b/platform/app/src/routes/LegacyWorkList/index.js @@ -0,0 +1 @@ +export { default } from './LegacyWorkList'; diff --git a/platform/app/src/routes/WorkList/SidePanelPreview.tsx b/platform/app/src/routes/WorkList/SidePanelPreview.tsx new file mode 100644 index 00000000000..0cc4b46d788 --- /dev/null +++ b/platform/app/src/routes/WorkList/SidePanelPreview.tsx @@ -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 = [ + '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 ; +} + +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 ( + + + + + + + + ); +} diff --git a/platform/app/src/routes/WorkList/StudyListSettingsPopover.tsx b/platform/app/src/routes/WorkList/StudyListSettingsPopover.tsx new file mode 100644 index 00000000000..0bc367aa546 --- /dev/null +++ b/platform/app/src/routes/WorkList/StudyListSettingsPopover.tsx @@ -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['show']; + appConfig: ReturnType[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 ( + + + + + + + + {items.map(item => ( + + {item.label} + + ))} + + + ); +} diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index 9c97426ea6f..88c6509f7eb 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -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 { 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, -} from '@ohif/ui-next'; - -import { Types } from '@ohif/ui'; - -import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters'; - -const PatientInfoVisibility = Types.PatientInfoVisibility; +import React, { useEffect, useMemo, useState } from 'react'; -const { sortBySeriesDate } = utils; - -const seriesInStudiesMap = new Map(); +import { useAppConfig } from '@state'; +import { preserveQueryParameters } from '../../utils/preserveQueryParameters'; +import { useStudyListStateSync, useWorkListToolbarActions } from '../../hooks'; + +import { StudyList, Icons, InvestigationalUseDialog, type StudyRow } from '@ohif/ui-next'; +import { StudyListSettingsPopover } from './StudyListSettingsPopover'; +import { SidePanelPreview } from './SidePanelPreview'; + +type Props = withAppTypes & { + data: any[]; + dataSource: any; + isLoadingData: boolean; + hasFetchedOnce?: boolean; + dataPath?: string; + onRefresh: () => void; +}; -/** - * 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 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 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]; + // Sync table state (sorting, pagination, filters) with URL and sessionStorage + const { sorting, pagination, filters, setSorting, setPagination, setFilters } = + useStudyListStateSync(); - 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); - } + // Default sorting if no URL state exists + const defaultSorting = useMemo(() => [{ id: 'studyDateTime', desc: true }], []); - return 0; - }); - }, [canSort, studies, shouldUseDefaultSort, sortBy, sortModifier]); + const [selected, setSelected] = useState(null); + const [isPreviewOpen, setPreviewOpen] = useState(true); - // ~ Rows & Studies - const [expandedRows, setExpandedRows] = useState([]); - const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]); - const numOfStudies = studiesTotal; - const querying = useMemo(() => { - return isLoadingData || expandedRows.length > 0; - }, [isLoadingData, expandedRows]); + 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 setFilterValues = val => { - if (filterValues.pageNumber === val.pageNumber) { - val.pageNumber = 1; - } - _setFilterValues(val); - updateSessionQueryFilterValues(val); - setExpandedRows([]); - }; + const logoComponent = appConfig?.whiteLabeling?.createLogoComponentFn?.(React) ?? ( + + ); - 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; + const toolbarActions = useWorkListToolbarActions(servicesManager, dataSource, onRefresh); - if (isNextPage && !hasNextPage) { - return; + const previewDefaultSize = useMemo(() => { + if (typeof window !== 'undefined' && window.innerWidth > 0) { + const percent = (325 / window.innerWidth) * 100; + return Math.min(Math.max(percent, 15), 50); } - - 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'); - }; + return 30; }, []); - // Sync URL query parameters with filters useEffect(() => { - if (!debouncedFilterValues) { + if (isLoadingData) { 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 ( - - - {textValue} - - -
- {textValue} - {textValue} -
-
-
- ); - }; - - 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 && {studyDate}} - {studyTime && {studyTime}} - - ), - 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: ( - <> - - {instances} - - ), - title: (instances || 0).toString(), - gridCol: 2, - }, - ], - // Todo: This is actually running for all rows, even if they are - // not clicked on. - expandedContent: ( - { - return { - description: s.description || '(empty)', - seriesNumber: s.seriesNumber ?? '', - modality: s.modality || '', - instances: s.numSeriesInstances || '', - }; - }) - : [] - } - > -
- {(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 && ( - { - // 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 */} -
- ) : null - } - startIcon={ - isValidMode ? ( - - ) : ( - - ) - } - onClick={() => {}} - dataCY={`mode-${mode.routeName}-${studyInstanceUid}`} - className={!isValidMode && 'bg-[#222d44]'} - > - {mode.displayName} - - - ) - ); - })} - -
- ), - 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: () => ( - { - 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' - ); + setIsFilterPending(false); + }, [isLoadingData, data]); return ( -
-
- +
-
- -
- 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 +
+
+ + 0 ? sorting : defaultSorting} + pagination={pagination} + filters={filters} + onSortingChange={setSorting} + onPaginationChange={setPagination} + onFiltersChange={updater => { + setIsFilterPending(true); + setFilters(updater); + }} + isLoading={showStudyListLoading} + loadingComponent={ + LoadingIndicatorProgress ? ( + + ) : ( +
+ ) + } + title={'Study List'} + onSelectionChange={sel => setSelected((sel as StudyRow[])[0] ?? null)} + toolbarLeftComponent={logoComponent} + toolbarRightActionsComponent={toolbarActions} + toolbarRightComponent={ + !isPreviewOpen ? ( +
+ + +
+ ) : undefined } /> -
- {hasStudies ? ( -
- + -
- -
-
- ) : ( -
- {appConfig.showLoadingIndicator && isLoadingData ? ( - - ) : ( - - )} -
- )} - + +
+
); } -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; diff --git a/platform/app/src/routes/WorkList/index.js b/platform/app/src/routes/WorkList/index.js deleted file mode 100644 index 83a6650d202..00000000000 --- a/platform/app/src/routes/WorkList/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './WorkList'; diff --git a/platform/app/src/routes/index.tsx b/platform/app/src/routes/index.tsx index 7524960f26a..efd4e9a11e1 100644 --- a/platform/app/src/routes/index.tsx +++ b/platform/app/src/routes/index.tsx @@ -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'); diff --git a/platform/app/src/utils/shallowEqualIgnoringArrayOrder.test.ts b/platform/app/src/utils/shallowEqualIgnoringArrayOrder.test.ts new file mode 100644 index 00000000000..a9a0aba8c10 --- /dev/null +++ b/platform/app/src/utils/shallowEqualIgnoringArrayOrder.test.ts @@ -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); + }); + }); +}); diff --git a/platform/app/src/utils/shallowEqualIgnoringArrayOrder.ts b/platform/app/src/utils/shallowEqualIgnoringArrayOrder.ts new file mode 100644 index 00000000000..3d94f9a3422 --- /dev/null +++ b/platform/app/src/utils/shallowEqualIgnoringArrayOrder.ts @@ -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; +} diff --git a/platform/app/src/utils/studyListFilterContract.ts b/platform/app/src/utils/studyListFilterContract.ts new file mode 100644 index 00000000000..bc005c833e3 --- /dev/null +++ b/platform/app/src/utils/studyListFilterContract.ts @@ -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 = { + [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(); +} diff --git a/platform/core/src/services/CustomizationService/CustomizationService.test.js b/platform/core/src/services/CustomizationService/CustomizationService.test.js index 84e049e07bb..7a8d232a285 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.test.js +++ b/platform/core/src/services/CustomizationService/CustomizationService.test.js @@ -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); + }); + }); }); diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts index 439810848a4..88041a64187 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -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; diff --git a/platform/core/src/utils/index.ts b/platform/core/src/utils/index.ts index 0ef35bc1bc2..026fd0ce695 100644 --- a/platform/core/src/utils/index.ts +++ b/platform/core/src/utils/index.ts @@ -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, diff --git a/platform/core/src/utils/thumbnailNoImageModalities.ts b/platform/core/src/utils/thumbnailNoImageModalities.ts new file mode 100644 index 00000000000..ef40536024f --- /dev/null +++ b/platform/core/src/utils/thumbnailNoImageModalities.ts @@ -0,0 +1,10 @@ +export const thumbnailNoImageModalities = [ + 'SR', + 'SEG', + 'RTSTRUCT', + 'RTPLAN', + 'RTDOSE', + 'DOC', + 'PMAP', + 'RWV', +]; diff --git a/platform/docs/docs/configuration/configurationFiles.md b/platform/docs/docs/configuration/configurationFiles.md index 8df9cb26cfc..75fb177e559 100644 --- a/platform/docs/docs/configuration/configurationFiles.md +++ b/platform/docs/docs/configuration/configurationFiles.md @@ -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 diff --git a/platform/docs/docs/configuration/dataSources/dicom-web.md b/platform/docs/docs/configuration/dataSources/dicom-web.md index 1f7fab230e8..bb58c91b6dc 100644 --- a/platform/docs/docs/configuration/dataSources/dicom-web.md +++ b/platform/docs/docs/configuration/dataSources/dicom-web.md @@ -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. diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/data-source-paging.md b/platform/docs/docs/migration-guide/3p12-to-3p13/data-source-paging.md new file mode 100644 index 00000000000..8b3802924d4 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/data-source-paging.md @@ -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). diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/format-dicom-date.md b/platform/docs/docs/migration-guide/3p12-to-3p13/format-dicom-date.md new file mode 100644 index 00000000000..e272cad048f --- /dev/null +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/format-dicom-date.md @@ -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. diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/work-list.md b/platform/docs/docs/migration-guide/3p12-to-3p13/work-list.md new file mode 100644 index 00000000000..fc8e54f0e6b --- /dev/null +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/work-list.md @@ -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. diff --git a/platform/docs/docs/platform/services/customization-service/WorkList.md b/platform/docs/docs/platform/services/customization-service/WorkList.md new file mode 100644 index 00000000000..d4d955b9e6b --- /dev/null +++ b/platform/docs/docs/platform/services/customization-service/WorkList.md @@ -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[]` — 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 ``. + - `{ 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 `` 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 `` 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)} diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index 95ec61f6f4e..5618791db74 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -356,6 +356,215 @@ window.config = { }, ]; +export const workListCustomizations = [ + { + id: 'workList.variant', + description: ( + <> + Selects which study-list route is mounted at /. Use 'default'{' '} + (default customization value) for the new ui-next WorkList introduced in 3.13. Use{' '} + 'legacy' to mount the pre-3.13 WorkList (internally{' '} + LegacyWorkList) 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{' '} + 'all' (default customization value) to show the thumbnails/list toggle. The + initial preview view is thumbnails. Use 'thumbnails' to lock the preview to + thumbnails, or 'list' to lock it to the series list. The preview is forced to{' '} + 'list' when the active data source declares thumbnailRendering as{' '} + 'wadors' or 'thumbnailDirect', or declares{' '} + thumbnailRequestStrategy as 'bulkDataRetrieve' (its default + value), regardless of this setting. Currently only applies when workList.variant is{' '} + 'default'. + + ), + 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 ColumnDef[] value (default:{' '} + StudyList.defaultColumns). Because it is a plain array, override it with + immutability-helper commands — $splice to reorder/insert/remove,{' '} + $set/$merge to tweak meta (label, width, priority), + or $apply — 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 id rather than its position). Use{' '} + StudyList.textColumn(id, label, meta?) for a simple display-only column. + Gotchas: a column's cell/accessorFn/etc. are functions (not + serializable, so non-text columns still need code); the trailing actions{' '} + column should stay last for correct layout (cosmetic, not required), so insert{' '} + before it with $splice rather than $push; and + index-based edits are position-fragile (prefer $apply{' '} + for id-based changes). If the merged value is not an array, WorkList falls back to the + defaults. Currently only applies when workList.variant is{' '} + 'default'. + + ), + 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{' '} + {'{ study, series, seriesView, onThumbnailImageError }'} — the same data the + built-in renderer uses, with series and thumbnails already fetched by the{' '} + SidePanelPreview shell. +
    +
  • + study: the selected StudyRow, or null. +
  • +
  • + series: the study's series with raw data-source fields ( + seriesInstanceUid, modality, description,{' '} + seriesDate, seriesNumber, numSeriesInstances, + etc.) plus a thumbnailStatus added by the shell, which is one of{' '} + {"{ status: 'loading' }"}, {"{ status: 'ready', src }"},{' '} + {"{ status: 'notAvailable' }"}, or{' '} + {"{ status: 'notApplicable' }"}. Use src from the{' '} + 'ready' form as the {''} source. +
  • +
  • + seriesView: 'all' | 'thumbnails' | 'list', resolved from{' '} + workList.previewSeriesView with 'list' forced for data + sources that can't produce thumbnails. +
  • +
  • + onThumbnailImageError(seriesUID): call when an {''}{' '} + 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, and thumbnail + worker-pool logic intact. When unset, the built-in{' '} + {''} layout is used. Currently only applies when{' '} + workList.variant is 'default'. + + ), + 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{' '} + SettingsMenuItem[] (each {'{ id, label, onClick }'}). The + defaults are about, userPreferences, and (when{' '} + appConfig.oidc is configured) logout. 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{' '} + workList.variant is 'default'. + + ), + 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{' '} + extensions/default/src/customizations/aboutModalCustomization.tsx for the + default and the AboutModal compound API. The consumer also reads optional{' '} + title and containerClassName static properties off the + component; both fall back to sensible defaults when omitted. + + ), image: aboutModal, default: 'Our own default component', + configurationIntro: ( +

+ Easiest to register from a custom extension's getCustomizationModule, where + JSX and the @ohif/ui-next imports work normally. See{' '} + aboutModalCustomization.tsx for the full default and the rest of the{' '} + AboutModal compound API. +

+ ), 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 ( + + My Custom Viewer + 1.2.3 + + ); +} - // 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 } }, + ], +}; `, }, { diff --git a/platform/docs/docs/platform/services/ui/index.md b/platform/docs/docs/platform/services/ui/index.md index ad872203376..f9762d22deb 100644 --- a/platform/docs/docs/platform/services/ui/index.md +++ b/platform/docs/docs/platform/services/ui/index.md @@ -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 ( -
- /** ... **/ -
- /** ... **/ -
- ); + + return
; } ``` diff --git a/platform/docs/docs/user-guide/index.md b/platform/docs/docs/user-guide/index.md index f40ef30df23..b6b29524a49 100644 --- a/platform/docs/docs/user-guide/index.md +++ b/platform/docs/docs/user-guide/index.md @@ -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 diff --git a/platform/i18n/src/locales/en-US/DataTable.json b/platform/i18n/src/locales/en-US/DataTable.json new file mode 100644 index 00000000000..fd263a23405 --- /dev/null +++ b/platform/i18n/src/locales/en-US/DataTable.json @@ -0,0 +1,4 @@ +{ + "No results.": "No results.", + "Not enough room to display this column": "Not enough room to display this column" +} diff --git a/platform/i18n/src/locales/en-US/index.js b/platform/i18n/src/locales/en-US/index.js index eab6880903f..ca5af6b1658 100644 --- a/platform/i18n/src/locales/en-US/index.js +++ b/platform/i18n/src/locales/en-US/index.js @@ -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, diff --git a/platform/i18n/src/locales/test-LNG/DataTable.json b/platform/i18n/src/locales/test-LNG/DataTable.json new file mode 100644 index 00000000000..7bc59cb2405 --- /dev/null +++ b/platform/i18n/src/locales/test-LNG/DataTable.json @@ -0,0 +1,4 @@ +{ + "No results.": "Test No results.", + "Not enough room to display this column": "Test Not enough room to display this column" +} diff --git a/platform/i18n/src/locales/test-LNG/index.js b/platform/i18n/src/locales/test-LNG/index.js index 64059c1bf11..7822a3f4ff3 100644 --- a/platform/i18n/src/locales/test-LNG/index.js +++ b/platform/i18n/src/locales/test-LNG/index.js @@ -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, diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index 771f93044a6..437bcf2e3b3 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -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", diff --git a/platform/ui-next/src/components/Badge/Badge.tsx b/platform/ui-next/src/components/Badge/Badge.tsx new file mode 100644 index 00000000000..8e692a3e80c --- /dev/null +++ b/platform/ui-next/src/components/Badge/Badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/platform/ui-next/src/components/Badge/index.ts b/platform/ui-next/src/components/Badge/index.ts new file mode 100644 index 00000000000..7385ca71684 --- /dev/null +++ b/platform/ui-next/src/components/Badge/index.ts @@ -0,0 +1 @@ +export { Badge, badgeVariants, type BadgeProps } from './Badge'; diff --git a/platform/ui-next/src/components/Calendar/Calendar.tsx b/platform/ui-next/src/components/Calendar/Calendar.tsx index 877f6c4d21f..69af38bacfd 100644 --- a/platform/ui-next/src/components/Calendar/Calendar.tsx +++ b/platform/ui-next/src/components/Calendar/Calendar.tsx @@ -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; - -const DATE_FNS_LOCALE_MAP: Record = { +const LOCALE_MAP: Record = { en: enUS, 'en-US': enUS, fr: frLocale, @@ -49,65 +45,206 @@ const DATE_FNS_LOCALE_MAP: Record = { '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 & { + buttonVariant?: React.ComponentProps['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 ( 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', + 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 + ), + 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( - buttonVariants({ variant: 'ghost' }), - 'h-9 w-9 p-0 font-normal aria-selected:opacity-100' + '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 ), - 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', + 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 }) => , - IconRight: ({ ...props }) => , + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === 'left') { + return ( + + ); + } + + if (orientation === 'right') { + return ( + + ); + } + + return ( + + ); + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components, }} {...props} /> ); } -Calendar.displayName = 'Calendar'; -export { Calendar }; +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) { + ref.current?.focus(); + } + }, [modifiers.focused]); + + return ( + + )} +
+ ); +} diff --git a/platform/ui-next/src/components/DataTable/DataTable.tsx b/platform/ui-next/src/components/DataTable/DataTable.tsx new file mode 100644 index 00000000000..7fb6d1cd0d8 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/DataTable.tsx @@ -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 | ((prev: T) => T); +type OnChangeFn = (updater: Updater) => void; + +export type DataTableProps = { + data: TData[]; + columns: ColumnDef[]; + getRowId?: (row: TData, index: number) => string; + initialVisibility?: VisibilityState; + sorting?: SortingState; + pagination?: PaginationState; + filters?: ColumnFiltersState; + onSortingChange?: OnChangeFn; + onPaginationChange?: OnChangeFn; + onFiltersChange?: OnChangeFn; + 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({ + data, + columns, + getRowId, + initialVisibility = {}, + sorting = [], + pagination = { pageIndex: 0, pageSize: 50 }, + filters = [], + onSortingChange, + onPaginationChange, + onFiltersChange, + manualFiltering = false, + enforceSingleSelection = true, + onSelectionChange, + children, +}: DataTableProps) { + const [columnVisibility, setColumnVisibility] = useState(initialVisibility); + const [rowSelection, setRowSelection] = useState({}); + + const table = useReactTable({ + 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 ( + }> + {children} + + ); +} + +type TableProps = { + children?: ReactNode; + /** + * Optional className applied to the outer bordered container. + */ + className?: string; + /** + * Optional className applied to the underlying 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 , , and + * as children; this component wires them into the correct structure. + */ +function Table({ children, className, tableClassName }: TableProps) { + const { table } = useDataTable(); + const wrapperRef = useRef(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( + () => ( + + {table.getVisibleLeafColumns().map(col => { + const meta = (col.columnDef.meta as ColumnMeta | undefined) ?? undefined; + const minWidth = meta?.minWidth; + return minWidth ? ( + + ) : ( + + ); + })} + + ), + [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 ( +
+
+ {/* Header + filter row */} +
+ + {renderColGroup()} + {headerChild} + {filterRowChild} + +
+ + {/* Scrollable body */} +
+ div>div]:!h-full')}> + + {renderColGroup()} + {bodyChild} + + +
+
+
+ ); +} + +/** + * 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() { + const { table } = useDataTable(); + + return ( + + {table.getHeaderGroups().map(headerGroup => ( + + {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 ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + ); +} + +type RowProps = { + render?: (row: Row) => ReactNode; + onClick?: (row: Row) => void; + onDoubleClick?: (row: Row) => void; + className?: string | ((row: Row) => string); +}; + +type BodyProps = { + rowProps?: RowProps; + /** + * 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({ + rowProps, + emptyMessage, + isLoading, + loadingComponent, +}: BodyProps) { + const { t } = useTranslation('DataTable'); + const resolvedEmptyMessage = emptyMessage ?? t('No results.'); + const { table } = useDataTable(); + + // 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 ( + + +
{loadingComponent}
+
+
+ ); + } + return ( + + + {resolvedEmptyMessage} + + + ); + } + + return ( + <> + {rows.map(row => { + const customRender = rowProps?.render?.(row); + + if (customRender) { + return customRender; + } + + // Default row rendering + return ( + 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 ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + })} + + ); +} + +const DataTable = Object.assign(DataTableRoot, { + Toolbar, + Title, + Pagination, + ViewOptions, + Table, + Header, + FilterRow, + Body, + ColumnHeader, + ActionOverlayCell, +}); + +export { DataTable }; diff --git a/platform/ui-next/src/components/DataTable/FilterRow.tsx b/platform/ui-next/src/components/DataTable/FilterRow.tsx new file mode 100644 index 00000000000..fd4b29e5e36 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/FilterRow.tsx @@ -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({ excludeColumnIds = [], renderFilterCell }: FilterRowProps) { + const { table } = useDataTable(); + const cols = table.getVisibleLeafColumns(); + return ( + + {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 ; + } + + const customRender = renderFilterCell?.({ columnId: id, value, setValue }); + + if (customRender) { + return {customRender}; + } + + // Default cell rendering + return ( + + setValue(e.target.value)} + className="h-7 w-full" + /> + + ); + })} + + ); +} diff --git a/platform/ui-next/src/components/DataTable/Pagination.tsx b/platform/ui-next/src/components/DataTable/Pagination.tsx new file mode 100644 index 00000000000..6e3bbc8096c --- /dev/null +++ b/platform/ui-next/src/components/DataTable/Pagination.tsx @@ -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() { + const { table } = useDataTable(); + 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 ( +
+ + + + + + {[25, 50, 100].map(size => ( + { + e.preventDefault(); + table.setPageSize(size); + }} + className="flex items-center gap-[2px]" + > + + {size} per page + + ))} + + + + +
+ ); +} diff --git a/platform/ui-next/src/components/DataTable/Title.tsx b/platform/ui-next/src/components/DataTable/Title.tsx new file mode 100644 index 00000000000..31107fcb01a --- /dev/null +++ b/platform/ui-next/src/components/DataTable/Title.tsx @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export function Title({ children }: { children?: React.ReactNode }) { + return
{children}
; +} diff --git a/platform/ui-next/src/components/DataTable/Toolbar.tsx b/platform/ui-next/src/components/DataTable/Toolbar.tsx new file mode 100644 index 00000000000..9c2bc3b5ba8 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/Toolbar.tsx @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export function Toolbar({ children }: { children?: React.ReactNode }) { + return
{children}
; +} diff --git a/platform/ui-next/src/components/DataTable/ViewOptions.tsx b/platform/ui-next/src/components/DataTable/ViewOptions.tsx new file mode 100644 index 00000000000..47634ba87fa --- /dev/null +++ b/platform/ui-next/src/components/DataTable/ViewOptions.tsx @@ -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({ buttonText = 'View' }: ViewOptionsProps) { + const { t } = useTranslation('DataTable'); + const { table } = useDataTable(); + const unfitColumnIds = useUnfitColumnIds(); + const columns = table.getAllColumns().filter(c => c.getCanHide()); + + return ( + + + + + + {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 = ( + column.toggleVisibility(!!v)} + className="capitalize" + > + {label} + + ); + + // 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 ( + + + {checkbox} + + + {t('Not enough room to display this column')} + + + ); + } + + return {checkbox}; + })} + + + ); +} diff --git a/platform/ui-next/src/components/DataTable/context.tsx b/platform/ui-next/src/components/DataTable/context.tsx new file mode 100644 index 00000000000..5410a88c9c8 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/context.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; +import type { Table } from '@tanstack/react-table'; + +export type DataTableContextValue = { + table: Table; +}; + +// React Context cannot be generic, so we use 'unknown' as the base type +// The generic type is properly restored by useDataTable() via type assertion +const DataTableContext = createContext | null>(null); + +export function useDataTable() { + const ctx = useContext(DataTableContext); + if (!ctx) { + throw new Error('useDataTable must be used within a provider'); + } + return ctx as DataTableContextValue; +} + +export { DataTableContext }; diff --git a/platform/ui-next/src/components/DataTable/index.ts b/platform/ui-next/src/components/DataTable/index.ts new file mode 100644 index 00000000000..595036fd99b --- /dev/null +++ b/platform/ui-next/src/components/DataTable/index.ts @@ -0,0 +1,4 @@ +// Core compound table + hook +export { DataTable } from './DataTable'; +export type { DataTableProps } from './DataTable'; +export { useDataTable } from './context'; diff --git a/platform/ui-next/src/components/DataTable/types.ts b/platform/ui-next/src/components/DataTable/types.ts new file mode 100644 index 00000000000..c92f6055238 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/types.ts @@ -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; +}; diff --git a/platform/ui-next/src/components/DataTable/useResponsiveColumns.tsx b/platform/ui-next/src/components/DataTable/useResponsiveColumns.tsx new file mode 100644 index 00000000000..c9152ee6e61 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/useResponsiveColumns.tsx @@ -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; + setUnfitColumnIds: React.Dispatch>>; +}; + +const ResponsiveColumnsContext = createContext(null); + +function useResponsiveColumnsContext(): ResponsiveColumnsContextValue { + const ctx = useContext(ResponsiveColumnsContext); + if (!ctx) { + throw new Error( + 'useResponsiveColumns/useUnfitColumnIds must be used within a ' + ); + } + 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>(() => new Set()); + const value = useMemo(() => ({ unfitColumnIds, setUnfitColumnIds }), [unfitColumnIds]); + return ( + {children} + ); +} + +/** + * 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 { + return useResponsiveColumnsContext().unfitColumnIds; +} + +type ComputeColumnVisibilityResult = { + /** Column ids hidden in the applied output (algorithm-dropped or user-hidden). */ + hiddenIds: Set; + /** + * 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; +}; + +/** + * 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(); + const unfitIds = new Set(); + 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, b: Set): 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 ``. + */ +export function useResponsiveColumns( + table: TanStackTable, + wrapperRef: React.RefObject +): 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({}); + // Column ids the user has explicitly hidden via the View menu. + const userHiddenRef = useRef>(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>(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(() => { + 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( + () => + 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]); +} diff --git a/platform/ui-next/src/components/DateRange/DateRange.tsx b/platform/ui-next/src/components/DateRange/DateRange.tsx index 22797fedfb3..90ba7034120 100644 --- a/platform/ui-next/src/components/DateRange/DateRange.tsx +++ b/platform/ui-next/src/components/DateRange/DateRange.tsx @@ -83,32 +83,36 @@ export function DatePickerWithRange({
- + {!start && ( + + )} 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" />
@@ -120,32 +124,36 @@ export function DatePickerWithRange({ >
- + {!end && ( + + )} 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" />
diff --git a/platform/ui-next/src/components/Icons/Icons.tsx b/platform/ui-next/src/components/Icons/Icons.tsx index 5229655bf92..eccbf221696 100644 --- a/platform/ui-next/src/components/Icons/Icons.tsx +++ b/platform/ui-next/src/components/Icons/Icons.tsx @@ -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; @@ -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), diff --git a/platform/ui-next/src/components/Icons/Sources/CloudSettings.tsx b/platform/ui-next/src/components/Icons/Sources/CloudSettings.tsx new file mode 100644 index 00000000000..872e3840ee4 --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/CloudSettings.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const CloudSettings = (props: IconProps) => ( + + + + + +); + +export default CloudSettings; diff --git a/platform/ui-next/src/components/Icons/Sources/More.tsx b/platform/ui-next/src/components/Icons/Sources/More.tsx index 02988a006ea..2ea988b7f87 100644 --- a/platform/ui-next/src/components/Icons/Sources/More.tsx +++ b/platform/ui-next/src/components/Icons/Sources/More.tsx @@ -10,7 +10,6 @@ export const More = (props: IconProps) => ( xmlns="http://www.w3.org/2000/svg" {...props} > - icon-more ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default OHIFLogoHorizontal; diff --git a/platform/ui-next/src/components/Icons/Sources/PanelRight.tsx b/platform/ui-next/src/components/Icons/Sources/PanelRight.tsx new file mode 100644 index 00000000000..ba154aba070 --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/PanelRight.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const PanelRight = (props: IconProps) => ( + + + + +); + +export default PanelRight; diff --git a/platform/ui-next/src/components/Icons/Sources/PatientStudyList.tsx b/platform/ui-next/src/components/Icons/Sources/PatientStudyList.tsx new file mode 100644 index 00000000000..1d9197aa32a --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/PatientStudyList.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +const PatientStudyList = (props: IconProps) => ( + + + + + + + + + + + + + + +); + +export default PatientStudyList; diff --git a/platform/ui-next/src/components/Icons/Sources/SeriesPlaceholder.tsx b/platform/ui-next/src/components/Icons/Sources/SeriesPlaceholder.tsx new file mode 100644 index 00000000000..017900b01fd --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/SeriesPlaceholder.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const SeriesPlaceholder = (props: IconProps) => ( + + + + + +); + +export default SeriesPlaceholder; diff --git a/platform/ui-next/src/components/Icons/Sources/SettingsStudyList.tsx b/platform/ui-next/src/components/Icons/Sources/SettingsStudyList.tsx new file mode 100644 index 00000000000..19bbf2a0ded --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/SettingsStudyList.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const SettingsStudyList = (props: IconProps) => ( + + + + +); + +export default SettingsStudyList; diff --git a/platform/ui-next/src/components/Icons/Sources/SortingNew.tsx b/platform/ui-next/src/components/Icons/Sources/SortingNew.tsx new file mode 100644 index 00000000000..06aefdfc006 --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/SortingNew.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const SortingNew = (props: IconProps) => ( + + + +); + +export default SortingNew; diff --git a/platform/ui-next/src/components/Icons/Sources/SortingNewAscending.tsx b/platform/ui-next/src/components/Icons/Sources/SortingNewAscending.tsx new file mode 100644 index 00000000000..ce0f2e260c1 --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/SortingNewAscending.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const SortingNewAscending = (props: IconProps) => ( + + + + +); + +export default SortingNewAscending; diff --git a/platform/ui-next/src/components/Icons/Sources/SortingNewDescending.tsx b/platform/ui-next/src/components/Icons/Sources/SortingNewDescending.tsx new file mode 100644 index 00000000000..497483852b0 --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/SortingNewDescending.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const SortingNewDescending = (props: IconProps) => ( + + + + +); + +export default SortingNewDescending; diff --git a/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx b/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx new file mode 100644 index 00000000000..8196869c06f --- /dev/null +++ b/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx @@ -0,0 +1,364 @@ +/** + * Compound multi-select chip-input built on cmdk. + * + * + * + * + * + * + * + * + * + * + */ +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; + query: string; + setQuery: (s: string) => void; + open: boolean; + setOpen: (b: boolean) => void; + fieldRef: React.MutableRefObject; + overlayRef: React.MutableRefObject; + inputRef: React.MutableRefObject; + coords: Coords | null; + filtered: NormalizedOption[]; + toggle: (val: string) => void; + remove: (val: string) => void; + clear: () => void; +}; + +const InputMultiSelectContext = React.createContext(null); + +function useInputMultiSelect(): IMSContext { + const ctx = React.useContext(InputMultiSelectContext); + if (!ctx) throw new Error('useInputMultiSelect must be used within '); + 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(null); + const fieldRef = React.useRef(null); + const overlayRef = React.useRef(null); + const inputRef = React.useRef(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(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 ( + + {/* shouldFilter={false} so we own filtering and can sort selected items to the top. */} + +
+ {children} +
+
+
+ ); +} + +function Field({ children }: { children?: React.ReactNode }) { + const { fieldRef, inputRef } = useInputMultiSelect(); + return ( +
inputRef.current?.focus()} + > + {children} +
+ ); +} +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 ( + + + {text} + + clear()} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + clear(); + } + }} + > + × + + + ); +} +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 ( + { + 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( +
{ + if (e.key === 'Escape') setOpen(false); + }} + > + + + {children} + + +
, + 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 ( + + {filtered.length === 0 ? ( + No options found. + ) : ( + ordered.map(opt => ( + toggle(opt.value)} + aria-selected={selectedSet.has(opt.value)} + className="min-w-0 leading-none" + > + + {opt.label} + + + )) + )} + + ); +} +Options.displayName = 'InputMultiSelect.Options'; + +const InputMultiSelect = Object.assign(InputMultiSelectRoot, { + Field, + Summary, + Input: IMSInput, + Content, + Options, +}); + +export { InputMultiSelect }; +export type { Option }; diff --git a/platform/ui-next/src/components/InputMultiSelect/index.ts b/platform/ui-next/src/components/InputMultiSelect/index.ts new file mode 100644 index 00000000000..843d566ec99 --- /dev/null +++ b/platform/ui-next/src/components/InputMultiSelect/index.ts @@ -0,0 +1 @@ +export { InputMultiSelect } from './InputMultiSelect' diff --git a/platform/ui-next/src/components/Popover/Popover.tsx b/platform/ui-next/src/components/Popover/Popover.tsx index bd5ffcb8d47..0a1833968bc 100644 --- a/platform/ui-next/src/components/Popover/Popover.tsx +++ b/platform/ui-next/src/components/Popover/Popover.tsx @@ -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 diff --git a/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx new file mode 100644 index 00000000000..92822c15aff --- /dev/null +++ b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx @@ -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 +): ColumnDef { + return { + id, + accessorFn: row => (row as Record)[id] ?? '', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue(id)}
, + meta: { label, minWidth: 120, ...meta }, + }; +} + +export const defaultColumns: ColumnDef[] = [ + { + id: COLUMN_IDS.PATIENT, + accessorFn: row => { + const r = row as StudyRow; + return r.patientName ?? ''; + }, + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue(COLUMN_IDS.PATIENT)}
, + 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 }) => , + cell: ({ row }) =>
{row.getValue(COLUMN_IDS.MRN)}
, + 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 }) => , + cell: ({ row }) => { + return
{row.getValue(COLUMN_IDS.STUDY_DATE_TIME)}
; + }, + 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 }) => , + cell: ({ row }) =>
{row.getValue(COLUMN_IDS.MODALITIES)}
, + 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 }) => , + cell: ({ row }) => { + const description = row.getValue(COLUMN_IDS.DESCRIPTION) as string; + return ( +
+ {description || 'No Description'} +
+ ); + }, + 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 }) => , + cell: ({ row }) =>
{row.getValue(COLUMN_IDS.ACCESSION)}
, + 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 }) => , + cell: ({ row }) => { + const value = row.getValue(COLUMN_IDS.INSTANCES) as number; + return
{value}
; + }, + sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), + meta: { + label: 'Instances', + headerContent: ( +
+ + + + Modality + / Series + + + + + + + + {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 ( + + +
+ {modality} + {description} +
+
+ {numInstances} +
+ ); + })} +
+
+
+ ); +} diff --git a/platform/ui-next/src/components/StudyList/components/PreviewToggleButton.tsx b/platform/ui-next/src/components/StudyList/components/PreviewToggleButton.tsx new file mode 100644 index 00000000000..709688f3475 --- /dev/null +++ b/platform/ui-next/src/components/StudyList/components/PreviewToggleButton.tsx @@ -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 & { + 'aria-label'?: string; + shouldShow: boolean; + onClick: () => void; + defaultAriaLabel: string; +}; + +function PreviewToggleButton({ + className, + 'aria-label': ariaLabel, + shouldShow, + onClick, + defaultAriaLabel, +}: PreviewToggleButtonProps) { + if (!shouldShow) { + return null; + } + return ( + + ); +} + +export function OpenPreviewButton({ + className, + 'aria-label': ariaLabel = 'Open preview', +}: React.HTMLAttributes & { 'aria-label'?: string }) { + const { isPreviewOpen, openPreview } = useLayout(); + return ( + + ); +} + +export function ClosePreviewButton({ + className, + 'aria-label': ariaLabel = 'Close preview', +}: React.HTMLAttributes & { 'aria-label'?: string }) { + const { isPreviewOpen, closePreview } = useLayout(); + return ( + + ); +} diff --git a/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx b/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx new file mode 100644 index 00000000000..a7b93ac5f56 --- /dev/null +++ b/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx @@ -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(null); + +function useSettingsPopoverContext() { + const ctx = React.useContext(SettingsPopoverContext); + if (!ctx) { + throw new Error('SettingsPopover subcomponents must be used within '); + } + 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: + * + * + * + * + * + * About + * + * + */ +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 | 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; + } + } + + if (!triggerNode) { + throw new Error( + ' is required as a direct child of .' + ); + } + if (!contentNode) { + throw new Error( + ' is required as a direct child of .' + ); + } + + return ( + + {triggerNode} + + + {contentNode} + + + ); +} + +type ContentProps = { + children?: React.ReactNode; +}; + +function SettingsPopoverContent({ children }: ContentProps) { + return ( + e.preventDefault()} + > + {children} + + ); +} + +/** + * 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 ( +
+ +
+ +
+
+ ); +} + +/** + * SettingsPopover.Divider + * A simple divider to separate sections inside the popover. + */ +function Divider() { + return
; +} + +type ItemProps = { + children: React.ReactNode; + onClick?: () => void; +}; + +function Item({ children, onClick }: ItemProps) { + const { close } = useSettingsPopoverContext(); + + const handleClick = () => { + onClick?.(); + close(); + }; + + return ( + + ); +} + +SettingsPopoverRoot.displayName = 'SettingsPopover'; + +export const SettingsPopover = Object.assign(SettingsPopoverRoot, { + Trigger: SettingsPopoverTrigger, + Content: SettingsPopoverContent, + Workflow, + Divider, + Item, +}); diff --git a/platform/ui-next/src/components/StudyList/components/Table.tsx b/platform/ui-next/src/components/StudyList/components/Table.tsx new file mode 100644 index 00000000000..068d2b8509e --- /dev/null +++ b/platform/ui-next/src/components/StudyList/components/Table.tsx @@ -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, '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 ( + + 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} + > + + + ); +} + +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(); + 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 ( +
+ {(showColumnVisibility || title) && ( + +
{toolbarLeftComponent}
+ {title ? {title} : null} +
+ {toolbarRightActionsComponent} + {toolbarRightActionsComponent &&
} + {/* Pagination appears to the left of the "View" button */} + /> + {showColumnVisibility && />} + {toolbarRightComponent} +
+ + )} + tableClassName={tableClassName}> + /> + + excludeColumnIds={[COLUMN_IDS.INSTANCES]} + renderFilterCell={({ columnId, value, setValue }) => { + if (columnId === COLUMN_IDS.ACTIONS) { + return ( +
+ +
+ ); + } + 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 ( + { + 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 ( + setValue(next)} + > + + + + + + + + + ); + } + // Return null/undefined to use default rendering for other columns + return null; + }} + /> + + 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); + }, + }} + /> + +
+ ); +} diff --git a/platform/ui-next/src/components/StudyList/components/WorkflowMenu.tsx b/platform/ui-next/src/components/StudyList/components/WorkflowMenu.tsx new file mode 100644 index 00000000000..a250e708093 --- /dev/null +++ b/platform/ui-next/src/components/StudyList/components/WorkflowMenu.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { Button } from '../../Button'; +import { Icons } from '../../Icons'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '../../DropdownMenu'; +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../../Tooltip'; +import { useWorkflows } from './WorkflowsProvider'; +import type { StudyRow } from '../types/types'; + +type Props = { + studyRow: StudyRow; + align?: 'start' | 'end' | 'center'; +}; + +export function WorkflowMenu({ studyRow, align = 'end' }: Props) { + const [open, setOpen] = React.useState(false); + const { getWorkflowsForStudy } = useWorkflows(); + const workflows = getWorkflowsForStudy(studyRow); + + return ( + + + + + + + + + Action Menu + + + e.stopPropagation()} + > +
+ Launch Workflow: +
+ {workflows.map(workflow => ( + { + e.preventDefault(); + workflow.launchWithStudy(studyRow); + }} + className={workflow.isDefault ? 'font-semibold' : undefined} + aria-current={workflow.isDefault ? 'true' : undefined} + > + {workflow.isDefault ? '✓ ' : null} + {workflow.displayName} + + ))} +
+
+ ); +} diff --git a/platform/ui-next/src/components/StudyList/components/WorkflowsProvider.tsx b/platform/ui-next/src/components/StudyList/components/WorkflowsProvider.tsx new file mode 100644 index 00000000000..959efcd4af7 --- /dev/null +++ b/platform/ui-next/src/components/StudyList/components/WorkflowsProvider.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { StudyRow } from '../types/types'; +import { useDefaultWorkflow } from '../hooks/useDefaultWorkflow'; + +/** + * Represents a workflow that can be launched from the study list. + * Each workflow corresponds to a mode from appConfig.loadedModes. + */ +export interface Workflow { + /** Unique identifier for the workflow (from mode.id) */ + readonly id: string; + /** Display name of the workflow (from mode.displayName) */ + readonly displayName: string; + /** Launches the study using this workflow's route */ + launchWithStudy: (studyRow: StudyRow) => void; + /** Determines if this workflow is applicable to the given study */ + isApplicableToStudy: (studyRow: StudyRow) => boolean; + /** Whether this workflow is the default workflow */ + readonly isDefault: boolean; +} + +export type WorkflowsContextValue = { + /** All available workflows */ + workflows: readonly Workflow[]; + /** Get workflows that are applicable to a specific study */ + getWorkflowsForStudy: (studyRow: StudyRow) => Workflow[]; + /** Get the default workflow for a study (only if it's applicable) */ + getDefaultWorkflowForStudy: (studyRow: StudyRow) => Workflow | undefined; + /** The current default workflow mode ID */ + defaultWorkflowId: string | undefined; + /** Set the default workflow ID */ + setDefaultWorkflowId: (workflowId?: string) => void; +}; + +const WorkflowsContext = React.createContext(undefined); + +export type Mode = { + id: string; + routeName: string; + displayName: string; + hide?: boolean; + isValidMode?: (args: { modalities: string; study: unknown }) => { + valid: boolean | null; + description?: string; + }; +}; + +type WorkflowsProviderProps = { + /** 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; + children: React.ReactNode; +}; + +/** + * Provider that creates workflows from loaded modes and provides them via context. + * Each workflow can launch studies and determine applicability based on mode validation. + */ +export function WorkflowsProvider({ + loadedModes, + dataPath, + preserveQueryParameters, + children, +}: WorkflowsProviderProps) { + const navigate = useNavigate(); + + // Get valid workflow IDs from loaded modes (for validation) + const validWorkflowIds = React.useMemo(() => { + return loadedModes.filter(m => !m.hide && m.displayName).map(m => m.id); + }, [loadedModes]); + + // Use localStorage-backed hook for persistence + const [storedDefaultWorkflowId, setStoredDefaultWorkflowId] = + useDefaultWorkflow(validWorkflowIds); + + // Convert null to undefined for consistency with context type + const defaultWorkflowId = storedDefaultWorkflowId ?? undefined; + + // Wrapper that persists to localStorage + const setDefaultWorkflowId = React.useCallback( + (workflowId?: string) => { + setStoredDefaultWorkflowId(workflowId ?? null); + }, + [setStoredDefaultWorkflowId] + ); + + const workflows = React.useMemo(() => { + const workflowList: Workflow[] = []; + + for (const mode of loadedModes) { + // Filter out hidden modes + if (mode.hide) { + continue; + } + + // Skip modes without displayName + if (!mode.displayName) { + continue; + } + + const isDefault = mode.id === defaultWorkflowId; + + const workflow: Workflow = { + get id() { + return mode.id; + }, + get displayName() { + return mode.displayName; + }, + launchWithStudy: (studyRow: StudyRow) => { + if (!studyRow.studyInstanceUid) { + console.warn('Cannot launch workflow: study has no studyInstanceUid'); + return; + } + + const query = new URLSearchParams(); + query.append('StudyInstanceUIDs', studyRow.studyInstanceUid); + preserveQueryParameters(query); + + const route = `${mode.routeName}${dataPath || ''}?${query.toString()}`; + navigate(route); + }, + isApplicableToStudy: (studyRow: StudyRow) => { + if (!mode.isValidMode) { + // If no validation function, assume applicable + return true; + } + + const modalitiesToCheck = String(studyRow.modalities ?? '').replaceAll('/', '\\'); + const result = mode.isValidMode({ + modalities: modalitiesToCheck, + study: studyRow, + }); + + // Return true only if valid is explicitly true + // null means hide (not applicable), false means disabled but visible + return result.valid === true; + }, + get isDefault() { + return isDefault; + }, + }; + + workflowList.push(workflow); + } + + return workflowList; + }, [loadedModes, defaultWorkflowId, dataPath, navigate, preserveQueryParameters]); + + const getWorkflowsForStudy = React.useCallback( + (studyRow: StudyRow): Workflow[] => { + return workflows.filter(workflow => workflow.isApplicableToStudy(studyRow)); + }, + [workflows] + ); + + const getDefaultWorkflowForStudy = React.useCallback( + (studyRow: StudyRow): Workflow | undefined => { + const applicableWorkflows = getWorkflowsForStudy(studyRow); + return applicableWorkflows.find(workflow => workflow.isDefault); + }, + [getWorkflowsForStudy] + ); + + const value: WorkflowsContextValue = React.useMemo( + () => ({ + workflows, + getWorkflowsForStudy, + getDefaultWorkflowForStudy, + defaultWorkflowId, + setDefaultWorkflowId, + }), + [ + workflows, + getWorkflowsForStudy, + getDefaultWorkflowForStudy, + defaultWorkflowId, + setDefaultWorkflowId, + ] + ); + + return {children}; +} + +/** + * Hook to access the study list workflow context. + * Must be used within a WorkflowsProvider. + */ +export function useWorkflows(): WorkflowsContextValue { + const ctx = React.useContext(WorkflowsContext); + if (!ctx) { + throw new Error('useWorkflows must be used within '); + } + return ctx; +} diff --git a/platform/ui-next/src/components/StudyList/hooks/useDefaultWorkflow.ts b/platform/ui-next/src/components/StudyList/hooks/useDefaultWorkflow.ts new file mode 100644 index 00000000000..dc0b4056f61 --- /dev/null +++ b/platform/ui-next/src/components/StudyList/hooks/useDefaultWorkflow.ts @@ -0,0 +1,51 @@ +import * as React from 'react'; + +/** + * Persist and retrieve a default workflow string from localStorage. + * If `allowed` is provided, the returned value is guaranteed to be from the allowed list (or null). + */ +export function useDefaultWorkflow( + allowed?: readonly string[] +): [string | null, (next: string | null) => void] { + const storageKey = 'studyList.defaultWorkflow'; + const [value, setValue] = React.useState(null); + + React.useEffect(() => { + try { + if (typeof window !== 'undefined') { + const raw = window.localStorage.getItem(storageKey); + if (raw != null) { + if (!allowed || allowed.includes(raw)) { + setValue(raw); + } else { + setValue(null); + } + } + } + } catch { + // no-op + } + }, [allowed]); + + const setAndPersist = React.useCallback( + (next: string | null) => { + setValue(next); + try { + if (typeof window !== 'undefined') { + if (next == null) { + window.localStorage.removeItem(storageKey); + } else { + if (!allowed || allowed.includes(next)) { + window.localStorage.setItem(storageKey, next); + } + } + } + } catch { + // no-op + } + }, + [allowed] + ); + + return [value, setAndPersist] as const; +} diff --git a/platform/ui-next/src/components/StudyList/index.ts b/platform/ui-next/src/components/StudyList/index.ts new file mode 100644 index 00000000000..20575b68aff --- /dev/null +++ b/platform/ui-next/src/components/StudyList/index.ts @@ -0,0 +1,50 @@ +// Import components for StudyList namespace +import { + defaultColumns, + textColumn, + COLUMN_IDS, + FILTERABLE_COLUMN_IDS, + TEXT_FILTER_COLUMN_IDS, +} from './columns/defaultColumns'; +import { SettingsPopover } from './components/SettingsPopover'; +import { PreviewContainer } from './components/PreviewContainer'; +import { PreviewHeader } from './components/PreviewHeader'; +import { Layout } from './components/Layout'; +import { OpenPreviewButton, ClosePreviewButton } from './components/PreviewToggleButton'; +import { PreviewContent } from './components/PreviewContent'; +import { WorkflowsProvider } from './components/WorkflowsProvider'; + +// Types +export * from './types/types'; + +// Column ID constants +export { COLUMN_IDS, FILTERABLE_COLUMN_IDS, TEXT_FILTER_COLUMN_IDS }; + +// StudyList compound component namespace +type StudyListNamespace = typeof Layout & { + Table: typeof Layout.Table; + Preview: typeof Layout.Preview; + SettingsPopover: typeof SettingsPopover; + PreviewContainer: typeof PreviewContainer; + PreviewHeader: typeof PreviewHeader; + PreviewContent: typeof PreviewContent; + WorkflowsProvider: typeof WorkflowsProvider; + OpenPreviewButton: typeof OpenPreviewButton; + ClosePreviewButton: typeof ClosePreviewButton; + defaultColumns: typeof defaultColumns; + textColumn: typeof textColumn; +}; + +export const StudyList: StudyListNamespace = Object.assign(Layout, { + Table: Layout.Table, + Preview: Layout.Preview, + SettingsPopover, + PreviewContainer, + PreviewHeader, + PreviewContent, + WorkflowsProvider, + OpenPreviewButton, + ClosePreviewButton, + defaultColumns, + textColumn, +}); diff --git a/platform/ui-next/src/components/StudyList/types/types.ts b/platform/ui-next/src/components/StudyList/types/types.ts new file mode 100644 index 00000000000..3189d158edc --- /dev/null +++ b/platform/ui-next/src/components/StudyList/types/types.ts @@ -0,0 +1,42 @@ +export type StudyRow = { + studyInstanceUid: string; + patientName: string; + mrn: string; + /** Raw date string (YYYYMMDD or YYYY.MM.DD format) */ + date: string; + /** Raw time string (HH, HHmm, HHmmss, or HHmmss.SSS format) */ + time: string; + modalities: string; + description: string; + accession: string; + instances: number; + // A data source may map additional fields (e.g. extra DICOM attributes pulled + // in via `includefield`). Allowing arbitrary keys means a custom column can + // read one without StudyRow needing an edit per field. The declared fields + // above keep their precise types. + [key: string]: unknown; +}; + +export type StudyDateRangeFilter = { + startDate?: string; + endDate?: string; +}; + +export const PreviewThumbnailStatusState = { + Loading: 'loading', + Ready: 'ready', + NotAvailable: 'notAvailable', + NotApplicable: 'notApplicable', +} as const; + +export type PreviewThumbnailStatusState = + (typeof PreviewThumbnailStatusState)[keyof typeof PreviewThumbnailStatusState]; + +type NonReadyPreviewThumbnailStatusState = Exclude< + PreviewThumbnailStatusState, + typeof PreviewThumbnailStatusState.Ready +>; + +export type PreviewThumbnailStatus = + | { status: NonReadyPreviewThumbnailStatusState } + | { status: typeof PreviewThumbnailStatusState.Ready; src: string }; diff --git a/platform/ui-next/src/components/StudyList/utils/tokenizeModalities.ts b/platform/ui-next/src/components/StudyList/utils/tokenizeModalities.ts new file mode 100644 index 00000000000..fe60431bb4e --- /dev/null +++ b/platform/ui-next/src/components/StudyList/utils/tokenizeModalities.ts @@ -0,0 +1,6 @@ +export function tokenizeModalities(value: string): string[] { + return String(value ?? '') + .toUpperCase() + .split(/[\s,\/\\]+/) + .filter(Boolean); +} diff --git a/platform/ui-next/src/components/Table/Table.tsx b/platform/ui-next/src/components/Table/Table.tsx new file mode 100644 index 00000000000..3986c410adb --- /dev/null +++ b/platform/ui-next/src/components/Table/Table.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +type TableProps = React.HTMLAttributes & { + containerClassName?: string; + noScroll?: boolean; +}; + +const Table = React.forwardRef( + ({ className, containerClassName, noScroll = false, ...props }, ref) => ( +
+ + + ) +); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', className)} + {...props} + /> +)); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + td]:text-highlight hover:[&>th]:text-highlight', + 'data-[state=selected]:bg-popover data-[state=selected]:hover:bg-popover', + 'data-[state=selected]:text-foreground data-[state=selected]:[&>td]:text-foreground data-[state=selected]:[&>th]:text-foreground', + 'data-[state=selected]:hover:text-foreground data-[state=selected]:hover:[&>td]:text-foreground data-[state=selected]:hover:[&>th]:text-foreground', + 'border-input/50 border-b transition-colors', + className + )} + {...props} + /> + ) +); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> +)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> +)); +TableCell.displayName = 'TableCell'; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = 'TableCaption'; + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/platform/ui-next/src/components/Table/index.ts b/platform/ui-next/src/components/Table/index.ts new file mode 100644 index 00000000000..3e7b53b7f65 --- /dev/null +++ b/platform/ui-next/src/components/Table/index.ts @@ -0,0 +1,11 @@ +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} from './Table'; + diff --git a/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx b/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx index d113b360c5d..ad76f0150a2 100644 --- a/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx +++ b/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { useDrag } from 'react-dnd'; @@ -6,12 +6,36 @@ import { Icons } from '../Icons'; import { DisplaySetMessageListTooltip } from '../DisplaySetMessageListTooltip'; import { TooltipTrigger, TooltipContent, Tooltip } from '../Tooltip'; +const DraggableThumbnailContent = ({ dragData, children }: any) => { + const [, drag] = useDrag({ + type: 'displayset', + item: { ...dragData }, + canDrag: function () { + return Object.keys(dragData).length !== 0; + }, + }); + + return ( +
+ {children} +
+ ); +}; + +const StaticThumbnailContent = ({ children }: any) => { + return
{children}
; +}; + /** * Display a thumbnail for a display set. */ const Thumbnail = ({ displaySetInstanceUID, className, + children, imageSrc, imageAltText, description, @@ -30,22 +54,20 @@ const Thumbnail = ({ isTracked = false, canReject = false, dragData = {}, + isDraggable = true, onReject = () => {}, onClickUntrack = () => {}, ThumbnailMenuItems = () => {}, + onImageLoadError = () => {}, }: withAppTypes): React.ReactNode => { - // TODO: We should wrap our thumbnail to create a "DraggableThumbnail", as - // this will still allow for "drag", even if there is no drop target for the - // specified item. - const [collectedProps, drag, dragPreview] = useDrag({ - type: 'displayset', - item: { ...dragData }, - canDrag: function (monitor) { - return Object.keys(dragData).length !== 0; - }, - }); - const [lastTap, setLastTap] = useState(0); + const [imageLoadFailed, setImageLoadFailed] = useState(false); + + useEffect(() => { + setImageLoadFailed(false); + }, [imageSrc]); + + const shouldRenderThumbnailImage = Boolean(imageSrc && !imageLoadFailed); const handleTouchEnd = e => { const currentTime = new Date().getTime(); @@ -68,15 +90,19 @@ const Thumbnail = ({ >
- {imageSrc ? ( + {shouldRenderThumbnailImage ? ( {imageAltText} { + setImageLoadFailed(true); + onImageLoadError(); + }} /> ) : ( -
+
{children}
)} {/* bottom left */} @@ -269,7 +295,9 @@ const Thumbnail = ({
-
- {viewPreset === 'thumbnails' && renderThumbnailPreset()} - {viewPreset === 'list' && renderListPreset()} -
+ {isDraggable ? ( + + {viewPreset === 'thumbnails' && renderThumbnailPreset()} + {viewPreset === 'list' && renderListPreset()} + + ) : ( + + {viewPreset === 'thumbnails' && renderThumbnailPreset()} + {viewPreset === 'list' && renderListPreset()} + + )}
); }; @@ -299,6 +331,7 @@ const Thumbnail = ({ Thumbnail.propTypes = { displaySetInstanceUID: PropTypes.string.isRequired, className: PropTypes.string, + children: PropTypes.node, imageSrc: PropTypes.string, /** * Data the thumbnail should expose to a receiving drop target. Use a matching @@ -326,7 +359,9 @@ Thumbnail.propTypes = { isTracked: PropTypes.bool, onClickUntrack: PropTypes.func, countIcon: PropTypes.string, + isDraggable: PropTypes.bool, thumbnailType: PropTypes.oneOf(['thumbnail', 'thumbnailTracked', 'thumbnailNoImage']), + onImageLoadError: PropTypes.func, }; export { Thumbnail }; diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 699d2a14a87..83b7c1da1ca 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -1,3 +1,4 @@ +import { Badge, badgeVariants } from './Badge'; import { Button, buttonVariants } from './Button'; import { SmartScrollbar, @@ -47,6 +48,7 @@ import { Toggle, toggleVariants } from './Toggle'; import { ToggleGroup, ToggleGroupItem } from './ToggleGroup'; import { Input } from './Input'; import { InputNumber } from './InputNumber'; +import { InputMultiSelect } from './InputMultiSelect'; import { Label } from './Label'; import { Switch } from './Switch'; import { Checkbox } from './Checkbox'; @@ -106,7 +108,18 @@ import { Toaster, toast } from './Sonner'; import { StudySummary } from './StudySummary'; import { ErrorBoundary } from './Errorboundary'; import { Header } from './Header'; +import { HoverCard, HoverCardTrigger, HoverCardContent } from './HoverCard'; import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card'; +import { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} from './Table'; import { ViewportActionButton, PatientInfo, @@ -129,6 +142,7 @@ import { } from './ToolButton'; import { LayoutSelector } from './LayoutSelector'; import { ToolSettings } from './OHIFToolSettings'; +export * from './StudyList'; export { DataRow } from './DataRow'; export { MeasurementTable } from './MeasurementTable'; export * from './ColorCircle'; @@ -144,6 +158,8 @@ export { useSegmentationTableContext, useSegmentationExpanded, useSegmentStatist export { Numeric, ErrorBoundary, + Badge, + badgeVariants, Button, buttonVariants, ThemeWrapper, @@ -179,6 +195,7 @@ export { DatePickerWithRange, Input, InputNumber, + InputMultiSelect, Label, Tabs, TabsContent, @@ -194,6 +211,14 @@ export { ToggleGroup, ToggleGroupItem, ScrollBar, + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, Accordion, AccordionContent, AccordionItem, @@ -239,6 +264,9 @@ export { SegmentationTable, StudySummary, Header, + HoverCard, + HoverCardTrigger, + HoverCardContent, Card, CardHeader, CardFooter, diff --git a/platform/ui-next/src/index.ts b/platform/ui-next/src/index.ts index 592b99fab79..8c89b3f73be 100644 --- a/platform/ui-next/src/index.ts +++ b/platform/ui-next/src/index.ts @@ -1,7 +1,7 @@ import * as utils from './utils'; -import { cn, formatDICOMDate } from './utils'; +import { cn, formatDICOMDate, formatDICOMTime, parseStudyDateTimestamp } from './utils'; export * from './components'; export * from './contextProviders'; export * as Types from './types'; -export { utils, cn, formatDICOMDate }; +export { utils, cn, formatDICOMDate, formatDICOMTime, parseStudyDateTimestamp }; export { useSessionStorage, useDynamicMaxHeight } from './hooks'; diff --git a/platform/ui-next/src/utils/formatDICOMDate.test.ts b/platform/ui-next/src/utils/formatDICOMDate.test.ts new file mode 100644 index 00000000000..9ced826904d --- /dev/null +++ b/platform/ui-next/src/utils/formatDICOMDate.test.ts @@ -0,0 +1,61 @@ +// Mock i18next so `t(key, fallback)` deterministically returns the fallback +// (the test env doesn't initialize i18n with resources). +jest.mock('i18next', () => ({ + __esModule: true, + default: { + t: (_key: string, fallback?: string) => fallback, + language: 'en', + }, +})); + +import { formatDICOMDate } from './formatDICOMDate'; + +describe('formatDICOMDate', () => { + describe('valid DICOM dates', () => { + it('formats YYYYMMDD with the default fallback (MMM D, YYYY)', () => { + expect(formatDICOMDate('20180916')).toBe('Sep 16, 2018'); + }); + + it('formats the YYYY.MM.DD variant', () => { + expect(formatDICOMDate('2018.09.16')).toBe('Sep 16, 2018'); + }); + + it('honors fallbackFormat', () => { + expect(formatDICOMDate('20180916', { fallbackFormat: 'MMM-DD-YYYY' })).toBe('Sep-16-2018'); + }); + + it('honors strFormat (overriding the locale key)', () => { + expect(formatDICOMDate('20180916', { strFormat: 'YYYY-MM-DD' })).toBe('2018-09-16'); + }); + }); + + describe('empty input', () => { + it('returns empty string by default', () => { + expect(formatDICOMDate('')).toBe(''); + expect(formatDICOMDate(undefined as unknown as string)).toBe(''); + }); + + it('returns invalidFallback when provided', () => { + expect(formatDICOMDate('', { invalidFallback: 'N/A' })).toBe('N/A'); + }); + }); + + describe('invalid input', () => { + it('preserves the prior lenient behavior when no invalidFallback is given', () => { + // A non-DICOM but moment-parseable string still formats via the lenient parse. + expect(formatDICOMDate('2018-09-16')).toBe('Sep 16, 2018'); + // Genuinely unparseable input yields moment's "Invalid date". + expect(formatDICOMDate('garbage')).toBe('Invalid date'); + }); + + it('returns invalidFallback for unparseable input when provided', () => { + expect(formatDICOMDate('garbage', { invalidFallback: 'N/A' })).toBe('N/A'); + }); + + it('invalidFallback short-circuits the lenient parse', () => { + // Even though "2018-09-16" is loosely parseable, an explicit invalidFallback + // wins because strict DICOM parsing failed. + expect(formatDICOMDate('2018-09-16', { invalidFallback: 'N/A' })).toBe('N/A'); + }); + }); +}); diff --git a/platform/ui-next/src/utils/formatDICOMDate.ts b/platform/ui-next/src/utils/formatDICOMDate.ts index 6a4c053fbbb..50e866717e3 100644 --- a/platform/ui-next/src/utils/formatDICOMDate.ts +++ b/platform/ui-next/src/utils/formatDICOMDate.ts @@ -1,24 +1,64 @@ import moment from 'moment'; import i18n from 'i18next'; +export interface FormatDICOMDateOptions { + /** + * Explicit output format. When provided it overrides the locale's + * `Common:localDateFormat`; when omitted the locale key is used. + */ + strFormat?: string; + /** + * Output format used only when the active locale does not define + * `Common:localDateFormat` (8 of the shipped locales don't). Defaults to + * `MMM D, YYYY`. + */ + fallbackFormat?: string; + /** + * Returned when `date` is empty or cannot be parsed as a date at all. When + * omitted, the prior behavior is preserved: a lenient `moment(date).format(...)` + * is returned (which is `"Invalid date"` for unparseable input). + */ + invalidFallback?: string; +} + /** - * Formats DICOM date. + * Formats a DICOM date. + * + * @param date - Raw date string (e.g. `YYYYMMDD` or `YYYY.MM.DD`). + * @param options - See {@link FormatDICOMDateOptions}. + * @returns The formatted date, or the resolved invalid fallback. * - * @param {string} date - * @param {string} strFormat - * @returns {string} formatted date. + * @remarks + * Migration: this previously took a positional format argument + * (`formatDICOMDate(date, strFormat?)`). That format — and the additional + * `fallbackFormat` / `invalidFallback` options — are now passed as a single + * options object. + * + * @example + * // Before (positional): + * formatDICOMDate(date, 'YYYY-MM-DD'); + * // After (options object): + * formatDICOMDate(date, { strFormat: 'YYYY-MM-DD' }); + * + * @example + * formatDICOMDate(date, { fallbackFormat: 'DD-MMM-YYYY', invalidFallback: '' }); */ -export function formatDICOMDate(date: string, strFormat?: string): string { +export function formatDICOMDate(date: string, options: FormatDICOMDateOptions = {}): string { + const { strFormat, fallbackFormat = 'MMM D, YYYY', invalidFallback } = options; + if (!date) { - return ''; + return invalidFallback ?? ''; } - const format = strFormat ?? i18n.t('Common:localDateFormat', 'MMM D, YYYY'); + const format = strFormat ?? i18n.t('Common:localDateFormat', fallbackFormat); const locale = i18n.language || 'en'; const parsed = moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true); if (!parsed.isValid()) { - return moment(date).locale(locale).format(format); + // Honor an explicit fallback, otherwise preserve prior behavior (a lenient + // parse, which formats valid non-DICOM strings and yields "Invalid date" + // for unparseable input). + return invalidFallback ?? moment(date).locale(locale).format(format); } return parsed.locale(locale).format(format); diff --git a/platform/ui-next/src/utils/formatDICOMTime.test.ts b/platform/ui-next/src/utils/formatDICOMTime.test.ts new file mode 100644 index 00000000000..550390d38fc --- /dev/null +++ b/platform/ui-next/src/utils/formatDICOMTime.test.ts @@ -0,0 +1,62 @@ +// Mock i18next so `t(key, fallback)` deterministically returns the fallback +// (the test env doesn't initialize i18n with resources). +jest.mock('i18next', () => ({ + __esModule: true, + default: { + t: (_key: string, fallback?: string) => fallback, + language: 'en', + }, +})); + +import { formatDICOMTime } from './formatDICOMTime'; + +describe('formatDICOMTime', () => { + describe('valid DICOM times (default hh:mm A fallback)', () => { + it('formats HHmmss', () => { + expect(formatDICOMTime('143052')).toBe('02:30 PM'); + }); + + it('formats HHmm', () => { + expect(formatDICOMTime('0905')).toBe('09:05 AM'); + }); + + it('formats HH', () => { + expect(formatDICOMTime('14')).toBe('02:00 PM'); + }); + + it('does not misread a 4-digit HHmm time as a year', () => { + // The whole reason there is no lenient reparse: moment("1430") with no + // format would yield the year 1430, not 14:30. + expect(formatDICOMTime('1430')).toBe('02:30 PM'); + }); + }); + + describe('format overrides', () => { + it('uses strFormat when provided', () => { + expect(formatDICOMTime('143052', { strFormat: 'HH:mm:ss' })).toBe('14:30:52'); + expect(formatDICOMTime('143052', { strFormat: 'HH:mm' })).toBe('14:30'); + }); + + it('uses fallbackFormat for the locale-key-missing path', () => { + expect(formatDICOMTime('143052', { fallbackFormat: 'HH:mm' })).toBe('14:30'); + }); + }); + + describe('empty / invalid input', () => { + it('returns empty string by default for empty input', () => { + expect(formatDICOMTime('')).toBe(''); + expect(formatDICOMTime(undefined as unknown as string)).toBe(''); + }); + + it('returns empty string by default for unparseable input', () => { + expect(formatDICOMTime('notatime')).toBe(''); + // Out-of-range hour is invalid under strict parsing. + expect(formatDICOMTime('2530')).toBe(''); + }); + + it('returns invalidFallback when provided', () => { + expect(formatDICOMTime('', { invalidFallback: 'N/A' })).toBe('N/A'); + expect(formatDICOMTime('notatime', { invalidFallback: '--' })).toBe('--'); + }); + }); +}); diff --git a/platform/ui-next/src/utils/formatDICOMTime.ts b/platform/ui-next/src/utils/formatDICOMTime.ts new file mode 100644 index 00000000000..12c2d5cb02f --- /dev/null +++ b/platform/ui-next/src/utils/formatDICOMTime.ts @@ -0,0 +1,49 @@ +import moment from 'moment'; +import i18n from 'i18next'; + +export interface FormatDICOMTimeOptions { + /** + * Explicit output format. When provided it overrides the locale's + * `Common:localTimeFormat`; when omitted the locale key is used. + */ + strFormat?: string; + /** + * Output format used when the active locale does not define + * `Common:localTimeFormat` (none of the shipped locales do today, so in + * practice this is what renders). Defaults to `hh:mm A`. + */ + fallbackFormat?: string; + /** Returned when `time` is empty or cannot be parsed. Defaults to `''`. */ + invalidFallback?: string; +} + +/** + * Formats a DICOM time. + * + * @param time - Raw time string (e.g. `HH`, `HHmm`, `HHmmss`, `HHmmss.SSS`). + * @param options - See {@link FormatDICOMTimeOptions}. + * @returns The formatted time, or the resolved invalid fallback. + * + * @example + * formatDICOMTime(time, { invalidFallback: '' }); + */ +export function formatDICOMTime(time: string, options: FormatDICOMTimeOptions = {}): string { + const { strFormat, fallbackFormat = 'hh:mm A', invalidFallback } = options; + + if (!time) { + return invalidFallback ?? ''; + } + + const format = strFormat ?? i18n.t('Common:localTimeFormat', fallbackFormat); + const locale = i18n.language || 'en'; + const parsed = moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS'], true); + + // Unlike formatDICOMDate, there is no lenient reparse: a no-format + // moment(time) treats the string as a date (e.g. "1430" -> year 1430), so an + // unparseable time falls straight back rather than fabricating a wrong value. + if (!parsed.isValid()) { + return invalidFallback ?? ''; + } + + return parsed.locale(locale).format(format); +} diff --git a/platform/ui-next/src/utils/index.ts b/platform/ui-next/src/utils/index.ts index 245c9a39550..91616c027ad 100644 --- a/platform/ui-next/src/utils/index.ts +++ b/platform/ui-next/src/utils/index.ts @@ -2,5 +2,14 @@ import { getToggledClassName } from './getToggledClassName'; import roundNumber from './roundNumber'; import { cn } from '../lib/utils'; import { formatDICOMDate } from './formatDICOMDate'; +import { formatDICOMTime } from './formatDICOMTime'; +import { parseStudyDateTimestamp } from './parseStudyDateTimestamp'; -export { getToggledClassName, roundNumber, cn, formatDICOMDate }; +export { + getToggledClassName, + roundNumber, + cn, + formatDICOMDate, + formatDICOMTime, + parseStudyDateTimestamp, +}; diff --git a/platform/ui-next/src/utils/parseStudyDateTimestamp.test.ts b/platform/ui-next/src/utils/parseStudyDateTimestamp.test.ts new file mode 100644 index 00000000000..f6929bb7e7e --- /dev/null +++ b/platform/ui-next/src/utils/parseStudyDateTimestamp.test.ts @@ -0,0 +1,47 @@ +import { parseStudyDateTimestamp } from './parseStudyDateTimestamp'; + +describe('parseStudyDateTimestamp', () => { + it('parses date + time into the matching local timestamp', () => { + // Month is 0-indexed in the Date constructor; both use local time, so this + // is timezone-independent. + const expected = new Date(2018, 8, 16, 14, 30, 52).getTime(); + expect(parseStudyDateTimestamp('20180916', '143052')).toBe(expected); + }); + + it('defaults to midnight when no time is given', () => { + const expected = new Date(2018, 8, 16, 0, 0, 0, 0).getTime(); + expect(parseStudyDateTimestamp('20180916')).toBe(expected); + }); + + it('ignores an invalid time (treats as midnight)', () => { + expect(parseStudyDateTimestamp('20180916', 'notatime')).toBe( + parseStudyDateTimestamp('20180916') + ); + }); + + it('returns 0 for missing or invalid dates', () => { + expect(parseStudyDateTimestamp(undefined, '143052')).toBe(0); + expect(parseStudyDateTimestamp('')).toBe(0); + expect(parseStudyDateTimestamp('garbage')).toBe(0); + }); + + describe('ordering (the sort use case)', () => { + it('orders by date', () => { + expect(parseStudyDateTimestamp('20180916')).toBeGreaterThan( + parseStudyDateTimestamp('20180915') + ); + }); + + it('uses time as a tiebreaker within the same date', () => { + expect(parseStudyDateTimestamp('20180916', '130000')).toBeGreaterThan( + parseStudyDateTimestamp('20180916', '120000') + ); + }); + + it('treats equal date + time as a true tie (equal keys)', () => { + expect(parseStudyDateTimestamp('20180916', '120000')).toBe( + parseStudyDateTimestamp('20180916', '120000') + ); + }); + }); +}); diff --git a/platform/ui-next/src/utils/parseStudyDateTimestamp.ts b/platform/ui-next/src/utils/parseStudyDateTimestamp.ts new file mode 100644 index 00000000000..9407f930664 --- /dev/null +++ b/platform/ui-next/src/utils/parseStudyDateTimestamp.ts @@ -0,0 +1,27 @@ +import moment from 'moment'; + +/** + * Parses a DICOM study date and time into a timestamp for sorting. + * + * @param date - Raw date string (YYYYMMDD or YYYY.MM.DD format) + * @param time - Raw time string (HH, HHmm, HHmmss, or HHmmss.SSS format) + * @returns Timestamp in milliseconds, or 0 if the date is missing/invalid + */ +export function parseStudyDateTimestamp(date?: string, time?: string): number { + const mDate = date && moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true); + const mTime = time && moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS'], true); + + if (mDate && mDate.isValid()) { + const md = mDate.clone(); + if (mTime && mTime.isValid()) { + md.set({ + hour: mTime.hour(), + minute: mTime.minute(), + second: mTime.second(), + millisecond: mTime.millisecond(), + }); + } + return md.toDate().getTime(); + } + return 0; +} diff --git a/platform/ui/src/components/InputDateRange/InputDateRange.tsx b/platform/ui/src/components/InputDateRange/InputDateRange.tsx index 88638b496d7..ff5d7fa93d0 100644 --- a/platform/ui/src/components/InputDateRange/InputDateRange.tsx +++ b/platform/ui/src/components/InputDateRange/InputDateRange.tsx @@ -30,7 +30,7 @@ const InputDateRange = ({ >
{ - await page.goto(`/?datasources=ohif`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); -}); - -test('should render scroll bars with the correct look-and-feel', async ({ page }) => { - const studyRowHeader = await page.getByTestId( - '1.3.6.1.4.1.14519.5.2.1.5099.8010.217836670708542506360829799868' - ); - - await studyRowHeader.scrollIntoViewIfNeeded(); - await page.waitForTimeout(3000); - - await studyRowHeader.click(); - - const expandedStudyRow = await page.getByTestId( - 'studyRow-1.3.6.1.4.1.14519.5.2.1.5099.8010.217836670708542506360829799868' - ); - - // Wait for the expanded row to be visible and rendered - await expandedStudyRow.waitFor({ state: 'visible', timeout: 10000 }); - await expandedStudyRow.scrollIntoViewIfNeeded(); - - // Wait for content to load and stabilize, including any lazy-loaded items - await page.waitForTimeout(2000); - - // Wait for network to be idle to ensure all content is loaded - await page.waitForLoadState('networkidle'); - - // Additional wait to ensure scrollbar rendering is stable - await page.waitForTimeout(1000); - - await checkForScreenshot({ - page, - locator: expandedStudyRow, - normalizedClip: { x: 0.97, y: 0.35, width: 0.03, height: 0.65 }, - screenshotPath: screenShotPaths.workList.scrollBarRenderedProperly, - }); -}); diff --git a/yarn.lock b/yarn.lock index 56bfa46fdf1..3ce591d3a04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2151,6 +2151,11 @@ debug "^3.1.0" lodash.once "^4.1.1" +"@date-fns/tz@^1.4.1": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.5.0.tgz#e9e79b7583f0b1322c53db884a0112551095e3f3" + integrity sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg== + "@discoveryjs/json-ext@0.5.7", "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" @@ -4703,6 +4708,18 @@ dependencies: defer-to-connect "^2.0.1" +"@tanstack/react-table@8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b" + integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww== + dependencies: + "@tanstack/table-core" "8.21.3" + +"@tanstack/table-core@8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c" + integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg== + "@testing-library/dom@^8.5.0": version "8.20.1" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f" @@ -8315,16 +8332,26 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" -date-fns@3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf" - integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== +date-fns-jalali@^4.1.0-0: + version "4.1.0-0" + resolved "https://registry.yarnpkg.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz#9c7fb286004fab267a300d3e9f1ada9f10b4b6b0" + integrity sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg== + +date-fns@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== date-fns@^1.27.2: version "1.30.1" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== +date-fns@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.3.0.tgz#95663b78f2be5ce664eb6606d94582acc39c77ba" + integrity sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ== + dateformat@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" @@ -16660,10 +16687,14 @@ react-dates@21.8.0: react-with-styles "^4.1.0" react-with-styles-interface-css "^6.0.0" -react-day-picker@8.10.1: - version "8.10.1" - resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80" - integrity sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA== +react-day-picker@9.12.0: + version "9.12.0" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-9.12.0.tgz#f1251c80aa2f932069b1854eeb1fc0ae4dae5ac6" + integrity sha512-t8OvG/Zrciso5CQJu5b1A7yzEmebvST+S3pOVQJWxwjjVngyG/CA2htN/D15dLI4uTEuLLkbZyS4YYt480FAtA== + dependencies: + "@date-fns/tz" "^1.4.1" + date-fns "^4.1.0" + date-fns-jalali "^4.1.0-0" react-dnd-html5-backend@14.0.0: version "14.0.0"