From f4cd0b0969bf7fd7a561dd244c6e79fe55f169e9 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 18 Sep 2025 11:20:53 -0400 Subject: [PATCH 001/172] Moving prototype location --- .../ui-next/.webpack/webpack.playground.js | 36 +++++++++++++++++++ platform/ui-next/playground/index.tsx | 18 ++++++++++ process.md | 26 ++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 platform/ui-next/.webpack/webpack.playground.js create mode 100644 platform/ui-next/playground/index.tsx create mode 100644 process.md diff --git a/platform/ui-next/.webpack/webpack.playground.js b/platform/ui-next/.webpack/webpack.playground.js new file mode 100644 index 00000000000..ee179403ef2 --- /dev/null +++ b/platform/ui-next/.webpack/webpack.playground.js @@ -0,0 +1,36 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); + +const PLAYGROUND_DIR = path.join(__dirname, '../playground'); +const DIST_DIR = path.join(__dirname, '../dist/playground'); +const ENTRY = { + app: path.join(PLAYGROUND_DIR, 'index.tsx'), +}; + +module.exports = (env, argv) => { + const config = webpackCommon(env, argv, { + SRC_DIR: PLAYGROUND_DIR, + DIST_DIR, + ENTRY, + }); + + config.output.path = DIST_DIR; + + config.plugins = [ + ...(config.plugins || []), + new HtmlWebpackPlugin({ + template: path.join(__dirname, 'template.html'), + title: 'UI Next Prototype', + }), + ]; + + config.devServer = { + hot: true, + port: 3100, + static: DIST_DIR, + historyApiFallback: true, + }; + + return config; +}; diff --git a/platform/ui-next/playground/index.tsx b/platform/ui-next/playground/index.tsx new file mode 100644 index 00000000000..d7a8704710d --- /dev/null +++ b/platform/ui-next/playground/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { ThemeWrapper } from '../src/components/ThemeWrapper'; + +const App = () => ( + +
+ +); + +const container = document.getElementById('root'); + +if (!container) { + throw new Error('Root element not found'); +} + +const root = createRoot(container); +root.render(); diff --git a/process.md b/process.md new file mode 100644 index 00000000000..db9d872618d --- /dev/null +++ b/process.md @@ -0,0 +1,26 @@ +# UI-Next Prototype Playground Setup + +## Goal +Enable design prototypes that render UI-Next components exactly as they appear in the viewer without Docusaurus interference. + +## Approach +Revive the existing `yarn dev` pathway inside `platform/ui-next` by reinstating the webpack playground it expected. The playground serves a minimal React shell that wraps prototypes with `ThemeWrapper`, guaranteeing the Tailwind token set and shared CSS match the production viewer. + +## Work Completed +- Added `platform/ui-next/playground/index.tsx`, a blank page that mounts `ThemeWrapper` and renders a full-height black background. This file is the entry point for future layout experiments. +- Restored `platform/ui-next/.webpack/webpack.playground.js`. The script already referenced this path; recreating it re-enabled webpack dev tooling with the shared base config, HTML template, and a dev server on port 3100. +- Confirmed the repo still uses the shared `.webpack/webpack.base.js`, ensuring Tailwind, aliases, and asset handling behave just like the viewer bundle. + +## Launch Instructions +```bash +cd platform/ui-next +yarn dev +# open http://localhost:3100 +``` + +## Important Notes +- The docs site (`platform/docs`) layers Docusaurus styles and routing on top of prototypes, so we are moving experiments to the playground to avoid those conflicts. +- The `dev` script always targeted `.webpack/webpack.playground.js`; the config file had been removed earlier, leaving the script broken. Re-adding it keeps historical tooling intact, so developers do not need to adopt new commands. +- Prototype pages should import from `../src/components/...` (or published package paths once available) and stay within the `playground` folder. Keep context providers and data stubs local to prototypes to avoid touching app code. +- The playground output directory is `platform/ui-next/dist/playground`; it is disposable and should not be checked in. +- When adding new layouts, continue wrapping content with `ThemeWrapper` (or additional providers if required) so tokens remain aligned with the viewer. From 80174882bc5cc1af1ba55219eb3bbf071f9f6c8b Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 18 Sep 2025 17:10:31 -0400 Subject: [PATCH 002/172] Panel prototypes --- platform/ui-next/playground/index.tsx | 19 +-------- platform/ui-next/playground/studylist.tsx | 50 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 platform/ui-next/playground/studylist.tsx diff --git a/platform/ui-next/playground/index.tsx b/platform/ui-next/playground/index.tsx index d7a8704710d..5a4c727e706 100644 --- a/platform/ui-next/playground/index.tsx +++ b/platform/ui-next/playground/index.tsx @@ -1,18 +1,3 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { ThemeWrapper } from '../src/components/ThemeWrapper'; +// Thin entry to satisfy webpack's playground ENTRY. It simply runs studylist. +import './studylist'; -const App = () => ( - -
- -); - -const container = document.getElementById('root'); - -if (!container) { - throw new Error('Root element not found'); -} - -const root = createRoot(container); -root.render(); diff --git a/platform/ui-next/playground/studylist.tsx b/platform/ui-next/playground/studylist.tsx new file mode 100644 index 00000000000..5bde2c07995 --- /dev/null +++ b/platform/ui-next/playground/studylist.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { ThemeWrapper } from '../src/components/ThemeWrapper'; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../src/components/Resizable'; +import { ScrollArea } from '../src/components/ScrollArea'; + +const App = () => ( + +
+ + {/* Main Area */} + +
+ Main Content +
+
+ + {/* Drag Handle */} + + + {/* Right Resizable Panel */} + +
+
+ Right Panel +
+ +
Placeholder content
+
+
+
+
+
+
+); + +const container = document.getElementById('root'); + +if (!container) { + throw new Error('Root element not found'); +} + +const root = createRoot(container); +root.render(); From 15572353e80e88268bc65e85f36a709d73440d7e Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Sun, 28 Sep 2025 20:16:42 -0400 Subject: [PATCH 003/172] Add Table component and prototype --- platform/ui-next/playground/index.tsx | 5 +- .../playground/patient-table-prototype.tsx | 104 +++++++++++++++++ ...studylist.tsx => studylist-panel-test.tsx} | 0 .../ui-next/src/components/Table/Table.tsx | 110 ++++++++++++++++++ .../ui-next/src/components/Table/index.ts | 10 ++ platform/ui-next/src/components/index.ts | 18 +++ 6 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 platform/ui-next/playground/patient-table-prototype.tsx rename platform/ui-next/playground/{studylist.tsx => studylist-panel-test.tsx} (100%) create mode 100644 platform/ui-next/src/components/Table/Table.tsx create mode 100644 platform/ui-next/src/components/Table/index.ts diff --git a/platform/ui-next/playground/index.tsx b/platform/ui-next/playground/index.tsx index 5a4c727e706..4a16442c744 100644 --- a/platform/ui-next/playground/index.tsx +++ b/platform/ui-next/playground/index.tsx @@ -1,3 +1,2 @@ -// Thin entry to satisfy webpack's playground ENTRY. It simply runs studylist. -import './studylist'; - +// Thin entry to satisfy webpack's playground ENTRY. It simply runs the selected prototype. +import './patient-table-prototype'; diff --git a/platform/ui-next/playground/patient-table-prototype.tsx b/platform/ui-next/playground/patient-table-prototype.tsx new file mode 100644 index 00000000000..5ea6cf67c4f --- /dev/null +++ b/platform/ui-next/playground/patient-table-prototype.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +// Import styles directly; no ThemeWrapper +import '../src/tailwind.css'; +import '../src/assets/styles.css'; +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, + TableCaption, +} from '../src/components/Table'; + +type PatientStudy = { + patient: string; + mrn: string; + studyDateTime: string; + modalities: string; + description: string; + accession: string; + instances: number; +}; + +const data: PatientStudy[] = [ + { + patient: 'John Doe', + mrn: 'MRN001234', + studyDateTime: '2025-06-14 09:32', + modalities: 'CT', + description: 'Chest CT w/ Contrast', + accession: 'ACC-102938', + instances: 324, + }, + { + patient: 'Jane Smith', + mrn: 'MRN007891', + studyDateTime: '2025-06-13 14:05', + modalities: 'MR', + description: 'Brain MRI', + accession: 'ACC-564738', + instances: 210, + }, + { + patient: 'Carlos Ruiz', + mrn: 'MRN003456', + studyDateTime: '2025-06-12 11:48', + modalities: 'US', + description: 'Abdominal Ultrasound', + accession: 'ACC-223344', + instances: 58, + }, + { + patient: 'Amina Khan', + mrn: 'MRN005432', + studyDateTime: '2025-06-11 08:21', + modalities: 'PET/CT', + description: 'Whole Body PET/CT', + accession: 'ACC-998877', + instances: 512, + }, +]; + +const App = () => ( +
+
+

Study List

+
+ + + + Patient + MRN + Study Date and Time + Modalities + Description + Accession Number + Instances + + + + {data.map((row, idx) => ( + + {row.patient} + {row.mrn} + {row.studyDateTime} + {row.modalities} + {row.description} + {row.accession} + {row.instances} + + ))} + +
+
+
+
+); + +const container = document.getElementById('root'); +if (!container) throw new Error('Root element not found'); +const root = createRoot(container); +root.render(); diff --git a/platform/ui-next/playground/studylist.tsx b/platform/ui-next/playground/studylist-panel-test.tsx similarity index 100% rename from platform/ui-next/playground/studylist.tsx rename to platform/ui-next/playground/studylist-panel-test.tsx 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..e2a1b361028 --- /dev/null +++ b/platform/ui-next/src/components/Table/Table.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +const Table = React.forwardRef>( + ({ className, ...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) => ( + + ) +); +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..e7441d4cd61 --- /dev/null +++ b/platform/ui-next/src/components/Table/index.ts @@ -0,0 +1,10 @@ +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} from './Table'; diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 699d2a14a87..71a98f19e7f 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -107,6 +107,16 @@ import { StudySummary } from './StudySummary'; import { ErrorBoundary } from './Errorboundary'; import { Header } from './Header'; import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card'; +import { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} from './Table'; import { ViewportActionButton, PatientInfo, @@ -194,6 +204,14 @@ export { ToggleGroup, ToggleGroupItem, ScrollBar, + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, Accordion, AccordionContent, AccordionItem, From f401c25dc3a24e5e27310212f5018e3dea2456b9 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Sun, 28 Sep 2025 20:39:46 -0400 Subject: [PATCH 004/172] Component theme updates --- .../ui-next/playground/patient-table-prototype.tsx | 2 +- platform/ui-next/src/components/Table/Table.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/platform/ui-next/playground/patient-table-prototype.tsx b/platform/ui-next/playground/patient-table-prototype.tsx index 5ea6cf67c4f..2d3f2350a8d 100644 --- a/platform/ui-next/playground/patient-table-prototype.tsx +++ b/platform/ui-next/playground/patient-table-prototype.tsx @@ -66,7 +66,7 @@ const App = () => (

Study List

-
+
diff --git a/platform/ui-next/src/components/Table/Table.tsx b/platform/ui-next/src/components/Table/Table.tsx index e2a1b361028..a39cfdf22cd 100644 --- a/platform/ui-next/src/components/Table/Table.tsx +++ b/platform/ui-next/src/components/Table/Table.tsx @@ -7,7 +7,7 @@ const Table = React.forwardRef
@@ -21,7 +21,7 @@ const TableHeader = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -45,7 +45,7 @@ const TableFooter = React.forwardRef< >(({ className, ...props }, ref) => ( tr]:last:border-b-0', className)} + className={cn('bg-muted border-input border-t [&>tr]:last:border-b-0', className)} {...props} /> )); @@ -56,7 +56,7 @@ const TableRow = React.forwardRef(({ className, ...props }, ref) => (
)); From 48ec1b751a15e15a46b8e5b48d8df2e28ca647de Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 04:11:36 -0400 Subject: [PATCH 005/172] More visual design edits --- platform/ui-next/playground/patient-table-prototype.tsx | 4 ++-- platform/ui-next/src/components/Table/Table.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/platform/ui-next/playground/patient-table-prototype.tsx b/platform/ui-next/playground/patient-table-prototype.tsx index 2d3f2350a8d..d71fd6d7cde 100644 --- a/platform/ui-next/playground/patient-table-prototype.tsx +++ b/platform/ui-next/playground/patient-table-prototype.tsx @@ -65,8 +65,8 @@ const data: PatientStudy[] = [ const App = () => (
-

Study List

-
+

Study List

+
diff --git a/platform/ui-next/src/components/Table/Table.tsx b/platform/ui-next/src/components/Table/Table.tsx index a39cfdf22cd..5a9b79a10ea 100644 --- a/platform/ui-next/src/components/Table/Table.tsx +++ b/platform/ui-next/src/components/Table/Table.tsx @@ -7,7 +7,7 @@ const Table = React.forwardRef
@@ -21,7 +21,7 @@ const TableHeader = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -45,7 +45,7 @@ const TableFooter = React.forwardRef< >(({ className, ...props }, ref) => ( tr]:last:border-b-0', className)} + className={cn('bg-muted border-input/50 border-t [&>tr]:last:border-b-0', className)} {...props} /> )); @@ -56,7 +56,7 @@ const TableRow = React.forwardReftd]:text-highlight hover:[&>th]:text-highlight data-[state=selected]:bg-muted border-input/50 border-b transition-colors', className )} {...props} From 64bac48d478714b9ae5fb356c9ae71ace0e58ea2 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 04:54:00 -0400 Subject: [PATCH 006/172] Updated Panel prototype with table --- platform/ui-next/playground/index.tsx | 27 ++++- .../playground/studylist-panel-test.tsx | 100 ++++++++++++++++-- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/platform/ui-next/playground/index.tsx b/platform/ui-next/playground/index.tsx index 4a16442c744..e39a1feb9e5 100644 --- a/platform/ui-next/playground/index.tsx +++ b/platform/ui-next/playground/index.tsx @@ -1,2 +1,25 @@ -// Thin entry to satisfy webpack's playground ENTRY. It simply runs the selected prototype. -import './patient-table-prototype'; +// Playground entry: dynamically load a prototype based on URL path. +// Usage: +// - Place a file in this folder, e.g. `playground/my-demo.tsx` +// - Navigate to `http://localhost:3100/my-demo` +// - Root `/` falls back to `patient-table-prototype.tsx` + +const trimSlashes = (s: string) => s.replace(/^\/+|\/+$/g, ''); +const route = trimSlashes(window.location.pathname); +const slug = route || 'patient-table-prototype'; + +// Attempt to import `.tsx` from this directory. +// Webpack will create a context for `./*.tsx` due to the dynamic import below. +// If not found, fall back to the default prototype. +(async () => { + try { + await import( + /* webpackMode: "lazy-once" */ + /* webpackInclude: /\.tsx$/ */ + `./${slug}.tsx` + ); + } catch (err) { + console.warn(`Prototype "${slug}" not found. Falling back to default.`, err); + await import('./patient-table-prototype'); + } +})(); diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 5bde2c07995..24c14a5443e 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -3,18 +3,101 @@ import { createRoot } from 'react-dom/client'; import { ThemeWrapper } from '../src/components/ThemeWrapper'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../src/components/Resizable'; import { ScrollArea } from '../src/components/ScrollArea'; +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, + TableCaption, +} from '../src/components/Table'; + +type PatientStudy = { + patient: string; + mrn: string; + studyDateTime: string; + modalities: string; + description: string; + accession: string; + instances: number; +}; + +const data: PatientStudy[] = [ + { + patient: 'John Doe', + mrn: 'MRN001234', + studyDateTime: '2025-06-14 09:32', + modalities: 'CT', + description: 'Chest CT w/ Contrast', + accession: 'ACC-102938', + instances: 324, + }, + { + patient: 'Jane Smith', + mrn: 'MRN007891', + studyDateTime: '2025-06-13 14:05', + modalities: 'MR', + description: 'Brain MRI', + accession: 'ACC-564738', + instances: 210, + }, + { + patient: 'Carlos Ruiz', + mrn: 'MRN003456', + studyDateTime: '2025-06-12 11:48', + modalities: 'US', + description: 'Abdominal Ultrasound', + accession: 'ACC-223344', + instances: 58, + }, + { + patient: 'Amina Khan', + mrn: 'MRN005432', + studyDateTime: '2025-06-11 08:21', + modalities: 'PET/CT', + description: 'Whole Body PET/CT', + accession: 'ACC-998877', + instances: 512, + }, +]; const App = () => (
- + {/* Main Area */} -
- Main Content +
+

Study List

+
+
+ + + Patient + MRN + Study Date and Time + Modalities + Description + Accession Number + Instances + + + + {data.map((row, idx) => ( + + {row.patient} + {row.mrn} + {row.studyDateTime} + {row.modalities} + {row.description} + {row.accession} + {row.instances} + + ))} + +
+
@@ -22,10 +105,7 @@ const App = () => ( {/* Right Resizable Panel */} - +
Right Panel From 74ca5bc25bf05253b9e16940b6445e149933f244 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 05:04:54 -0400 Subject: [PATCH 007/172] Separated patient list data into a separate file --- .../ui-next/playground/patient-studies.json | 38 +++++++++++ .../playground/patient-table-prototype.tsx | 49 +------------- .../playground/studylist-panel-test.tsx | 64 ++++--------------- 3 files changed, 50 insertions(+), 101 deletions(-) create mode 100644 platform/ui-next/playground/patient-studies.json diff --git a/platform/ui-next/playground/patient-studies.json b/platform/ui-next/playground/patient-studies.json new file mode 100644 index 00000000000..94eea1ecd2b --- /dev/null +++ b/platform/ui-next/playground/patient-studies.json @@ -0,0 +1,38 @@ +[ + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + } +] diff --git a/platform/ui-next/playground/patient-table-prototype.tsx b/platform/ui-next/playground/patient-table-prototype.tsx index d71fd6d7cde..071e455657e 100644 --- a/platform/ui-next/playground/patient-table-prototype.tsx +++ b/platform/ui-next/playground/patient-table-prototype.tsx @@ -13,54 +13,7 @@ import { TableCaption, } from '../src/components/Table'; -type PatientStudy = { - patient: string; - mrn: string; - studyDateTime: string; - modalities: string; - description: string; - accession: string; - instances: number; -}; - -const data: PatientStudy[] = [ - { - patient: 'John Doe', - mrn: 'MRN001234', - studyDateTime: '2025-06-14 09:32', - modalities: 'CT', - description: 'Chest CT w/ Contrast', - accession: 'ACC-102938', - instances: 324, - }, - { - patient: 'Jane Smith', - mrn: 'MRN007891', - studyDateTime: '2025-06-13 14:05', - modalities: 'MR', - description: 'Brain MRI', - accession: 'ACC-564738', - instances: 210, - }, - { - patient: 'Carlos Ruiz', - mrn: 'MRN003456', - studyDateTime: '2025-06-12 11:48', - modalities: 'US', - description: 'Abdominal Ultrasound', - accession: 'ACC-223344', - instances: 58, - }, - { - patient: 'Amina Khan', - mrn: 'MRN005432', - studyDateTime: '2025-06-11 08:21', - modalities: 'PET/CT', - description: 'Whole Body PET/CT', - accession: 'ACC-998877', - instances: 512, - }, -]; +import data from './patient-studies.json'; const App = () => (
diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 24c14a5443e..0449a4520c1 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -12,60 +12,15 @@ import { TableCell, TableCaption, } from '../src/components/Table'; - -type PatientStudy = { - patient: string; - mrn: string; - studyDateTime: string; - modalities: string; - description: string; - accession: string; - instances: number; -}; - -const data: PatientStudy[] = [ - { - patient: 'John Doe', - mrn: 'MRN001234', - studyDateTime: '2025-06-14 09:32', - modalities: 'CT', - description: 'Chest CT w/ Contrast', - accession: 'ACC-102938', - instances: 324, - }, - { - patient: 'Jane Smith', - mrn: 'MRN007891', - studyDateTime: '2025-06-13 14:05', - modalities: 'MR', - description: 'Brain MRI', - accession: 'ACC-564738', - instances: 210, - }, - { - patient: 'Carlos Ruiz', - mrn: 'MRN003456', - studyDateTime: '2025-06-12 11:48', - modalities: 'US', - description: 'Abdominal Ultrasound', - accession: 'ACC-223344', - instances: 58, - }, - { - patient: 'Amina Khan', - mrn: 'MRN005432', - studyDateTime: '2025-06-11 08:21', - modalities: 'PET/CT', - description: 'Whole Body PET/CT', - accession: 'ACC-998877', - instances: 512, - }, -]; +import data from './patient-studies.json'; const App = () => (
- + {/* Main Area */}
@@ -105,9 +60,12 @@ const App = () => ( {/* Right Resizable Panel */} - -
-
+ +
+
Right Panel
From eed1b3d4cc2d103ded5f9e33fdc17fb3fdd746a5 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 05:39:28 -0400 Subject: [PATCH 008/172] Panel orientation switch and scrolling --- .../ui-next/playground/patient-studies.json | 144 ++++++++++++++++ .../playground/studylist-panel-test.tsx | 160 +++++++++++------- 2 files changed, 245 insertions(+), 59 deletions(-) diff --git a/platform/ui-next/playground/patient-studies.json b/platform/ui-next/playground/patient-studies.json index 94eea1ecd2b..ee2bc21a36c 100644 --- a/platform/ui-next/playground/patient-studies.json +++ b/platform/ui-next/playground/patient-studies.json @@ -1,4 +1,148 @@ [ + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + }, + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + }, + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + }, + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + }, { "patient": "John Doe", "mrn": "MRN001234", diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 0449a4520c1..2d6880e0bc7 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { ThemeWrapper } from '../src/components/ThemeWrapper'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../src/components/Resizable'; import { ScrollArea } from '../src/components/ScrollArea'; +import { Button } from '../src/components/Button'; import { Table, TableHeader, @@ -14,69 +15,110 @@ import { } from '../src/components/Table'; import data from './patient-studies.json'; -const App = () => ( - -
- - {/* Main Area */} - -
-

Study List

-
- - - - Patient - MRN - Study Date and Time - Modalities - Description - Accession Number - Instances - - - - {data.map((row, idx) => ( - - {row.patient} - {row.mrn} - {row.studyDateTime} - {row.modalities} - {row.description} - {row.accession} - {row.instances} - - ))} - -
+const App = () => { + const [layout, setLayout] = React.useState<'right' | 'bottom'>('right'); + + return ( + +
+ + {/* Main Area */} + +
+

Study List

+ {layout === 'bottom' ? ( + +
+ + + + Patient + MRN + Study Date and Time + Modalities + Description + Accession Number + Instances + + + + {data.map((row, idx) => ( + + {row.patient} + {row.mrn} + {row.studyDateTime} + {row.modalities} + {row.description} + {row.accession} + {row.instances} + + ))} + +
+
+
+ ) : ( +
+ + + + Patient + MRN + Study Date and Time + Modalities + Description + Accession Number + Instances + + + + {data.map((row, idx) => ( + + {row.patient} + {row.mrn} + {row.studyDateTime} + {row.modalities} + {row.description} + {row.accession} + {row.instances} + + ))} + +
+
+ )}
-
- + - {/* Drag Handle */} - + {/* Drag Handle */} + - {/* Right Resizable Panel */} - -
-
- Right Panel + {/* Secondary Panel (Right or Bottom) */} + +
+
+ {layout === 'right' ? 'Right Panel' : 'Bottom Panel'} + +
+ +
Placeholder content
+
- -
Placeholder content
-
-
- - -
-
-); + + +
+ + ); +}; const container = document.getElementById('root'); From 027b40ec309cab4ae5bc23b17bd32e2b2bdffbe3 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 05:49:05 -0400 Subject: [PATCH 009/172] Table scrolling fixes --- .../playground/studylist-panel-test.tsx | 89 ++++++------------- 1 file changed, 29 insertions(+), 60 deletions(-) diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 2d6880e0bc7..89a93762649 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -29,67 +29,36 @@ const App = () => {

Study List

- {layout === 'bottom' ? ( - -
- - - - Patient - MRN - Study Date and Time - Modalities - Description - Accession Number - Instances - - - - {data.map((row, idx) => ( - - {row.patient} - {row.mrn} - {row.studyDateTime} - {row.modalities} - {row.description} - {row.accession} - {row.instances} - - ))} - -
-
-
- ) : ( -
- - - - Patient - MRN - Study Date and Time - Modalities - Description - Accession Number - Instances + +
+
+ + + Patient + MRN + Study Date and Time + Modalities + Description + Accession Number + Instances + + + + {data.map((row, idx) => ( + + {row.patient} + {row.mrn} + {row.studyDateTime} + {row.modalities} + {row.description} + {row.accession} + {row.instances} - - - {data.map((row, idx) => ( - - {row.patient} - {row.mrn} - {row.studyDateTime} - {row.modalities} - {row.description} - {row.accession} - {row.instances} - - ))} - -
-
- )} + ))} + +
+
+
From 196d6811d4f39e0aba2d6464b7190a6122900780 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 09:36:06 -0400 Subject: [PATCH 010/172] DataTable prototype --- platform/ui-next/package.json | 1 + .../playground/patient-studies-dupe.json | 182 ++++++++++++++++++ .../ui-next/playground/patient-studies.json | 144 -------------- .../playground/studylist-panel-test.tsx | 108 +++++++---- .../src/components/Table/DataTable.tsx | 143 ++++++++++++++ .../Table/DataTableColumnHeader.tsx | 36 ++++ .../ui-next/src/components/Table/index.ts | 3 + yarn.lock | 12 ++ 8 files changed, 450 insertions(+), 179 deletions(-) create mode 100644 platform/ui-next/playground/patient-studies-dupe.json create mode 100644 platform/ui-next/src/components/Table/DataTable.tsx create mode 100644 platform/ui-next/src/components/Table/DataTableColumnHeader.tsx diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index 25897e446fb..8e0e4b816b6 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -29,6 +29,7 @@ ".": "./src/index.ts" }, "dependencies": { + "@tanstack/react-table": "8.20.0", "@radix-ui/react-accordion": "1.2.11", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-context-menu": "2.2.15", diff --git a/platform/ui-next/playground/patient-studies-dupe.json b/platform/ui-next/playground/patient-studies-dupe.json new file mode 100644 index 00000000000..ee2bc21a36c --- /dev/null +++ b/platform/ui-next/playground/patient-studies-dupe.json @@ -0,0 +1,182 @@ +[ + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + }, + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + }, + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + }, + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + }, + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + } +] diff --git a/platform/ui-next/playground/patient-studies.json b/platform/ui-next/playground/patient-studies.json index ee2bc21a36c..94eea1ecd2b 100644 --- a/platform/ui-next/playground/patient-studies.json +++ b/platform/ui-next/playground/patient-studies.json @@ -1,148 +1,4 @@ [ - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - }, - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - }, - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - }, - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - }, { "patient": "John Doe", "mrn": "MRN001234", diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 89a93762649..6a9b4249728 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -4,17 +4,12 @@ import { ThemeWrapper } from '../src/components/ThemeWrapper'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../src/components/Resizable'; import { ScrollArea } from '../src/components/ScrollArea'; import { Button } from '../src/components/Button'; -import { - Table, - TableHeader, - TableBody, - TableHead, - TableRow, - TableCell, - TableCaption, -} from '../src/components/Table'; +import type { ColumnDef } from '@tanstack/react-table'; +import { DataTable, DataTableColumnHeader } from '../src/components/Table'; import data from './patient-studies.json'; +// (moved columns/type to the top of the file) + const App = () => { const [layout, setLayout] = React.useState<'right' | 'bottom'>('right'); @@ -31,32 +26,15 @@ const App = () => {

Study List

- - - - Patient - MRN - Study Date and Time - Modalities - Description - Accession Number - Instances - - - - {data.map((row, idx) => ( - - {row.patient} - {row.mrn} - {row.studyDateTime} - {row.modalities} - {row.description} - {row.accession} - {row.instances} - - ))} - -
+ {/* Data Table */} + + columns={columns} + data={data as StudyRow[]} + getRowId={row => row.accession} + singleRowSelection={true} + showColumnVisibilityControls={true} + tableClassName="min-w-[1000px]" + />
@@ -97,3 +75,63 @@ if (!container) { const root = createRoot(container); root.render(); + +// Types and column definitions for the study list +type StudyRow = { + patient: string; + mrn: string; + studyDateTime: string; + modalities: string; + description: string; + accession: string; + instances: number; +}; + +const columns: ColumnDef[] = [ + { + accessorKey: 'patient', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('patient')}
, + }, + { + accessorKey: 'mrn', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('mrn')}
, + }, + { + accessorKey: 'studyDateTime', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('studyDateTime')}
, + sortingFn: (a, b, colId) => + new Date(a.getValue(colId) as string).getTime() - + new Date(b.getValue(colId) as string).getTime(), + }, + { + accessorKey: 'modalities', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('modalities')}
, + }, + { + accessorKey: 'description', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('description')}
, + }, + { + accessorKey: 'accession', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('accession')}
, + }, + { + accessorKey: 'instances', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue('instances')}
, + sortingFn: (a, b, colId) => + (a.getValue(colId) as number) - (b.getValue(colId) as number), + }, +]; diff --git a/platform/ui-next/src/components/Table/DataTable.tsx b/platform/ui-next/src/components/Table/DataTable.tsx new file mode 100644 index 00000000000..1371b876b6a --- /dev/null +++ b/platform/ui-next/src/components/Table/DataTable.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import type { + ColumnDef, + SortingState, + VisibilityState, + RowSelectionState, +} from '@tanstack/react-table'; +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; + +import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from './Table'; +import { Button } from '../Button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../DropdownMenu'; + +export interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + getRowId?: (originalRow: TData, index: number) => string; + initialSorting?: SortingState; + initialColumnVisibility?: VisibilityState; + singleRowSelection?: boolean; // default: true + showColumnVisibilityControls?: boolean; // default: true + tableClassName?: string; +} + +export function DataTable({ + columns, + data, + getRowId, + initialSorting = [], + initialColumnVisibility = {}, + singleRowSelection = true, + showColumnVisibilityControls = true, + tableClassName, +}: DataTableProps) { + const [sorting, setSorting] = React.useState(initialSorting); + const [columnVisibility, setColumnVisibility] = + React.useState(initialColumnVisibility); + const [rowSelection, setRowSelection] = React.useState({}); + + const table = useReactTable({ + data, + columns, + state: { sorting, columnVisibility, rowSelection }, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableRowSelection: true, + enableMultiRowSelection: singleRowSelection ? false : true, + getRowId, + }); + + return ( +
+ {showColumnVisibilityControls && ( +
+
+ + + + + + {table + .getAllColumns() + .filter(col => col.getCanHide()) + .map(column => ( + column.toggleVisibility(!!v)} + className="capitalize" + > + {column.id} + + ))} + + +
+
+ )} + + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map(row => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="cursor-pointer" + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/platform/ui-next/src/components/Table/DataTableColumnHeader.tsx b/platform/ui-next/src/components/Table/DataTableColumnHeader.tsx new file mode 100644 index 00000000000..508afe7dd32 --- /dev/null +++ b/platform/ui-next/src/components/Table/DataTableColumnHeader.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import type { Column } from '@tanstack/react-table'; +import { Button } from '../Button'; + +export interface DataTableColumnHeaderProps { + column: Column; + title: string; + align?: 'left' | 'center' | 'right'; +} + +export function DataTableColumnHeader({ + column, + title, + align = 'left', +}: DataTableColumnHeaderProps) { + const sorted = column.getIsSorted() as false | 'asc' | 'desc'; + const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕'; + + const justifyClass = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; + + return ( +
+ {title} + +
+ ); +} + diff --git a/platform/ui-next/src/components/Table/index.ts b/platform/ui-next/src/components/Table/index.ts index e7441d4cd61..a4d2d04d6d4 100644 --- a/platform/ui-next/src/components/Table/index.ts +++ b/platform/ui-next/src/components/Table/index.ts @@ -8,3 +8,6 @@ export { TableCell, TableCaption, } from './Table'; + +export { DataTable } from './DataTable'; +export { DataTableColumnHeader } from './DataTableColumnHeader'; diff --git a/yarn.lock b/yarn.lock index 352bf615c3d..443f3567be8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4737,6 +4737,18 @@ dependencies: defer-to-connect "^2.0.1" +"@tanstack/react-table@^8.20.0": + 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" From 3f034e391134581dc03bf23d062b293f5dc91e50 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 12:13:05 -0400 Subject: [PATCH 011/172] Added pinned table header --- .../playground/patient-studies-small.json | 38 ++ .../ui-next/playground/patient-studies.json | 458 +++++++++++++++++- .../playground/studylist-panel-test.tsx | 24 +- .../src/components/Table/DataTable.tsx | 87 ++-- .../ui-next/src/components/Table/Table.tsx | 10 +- 5 files changed, 555 insertions(+), 62 deletions(-) create mode 100644 platform/ui-next/playground/patient-studies-small.json diff --git a/platform/ui-next/playground/patient-studies-small.json b/platform/ui-next/playground/patient-studies-small.json new file mode 100644 index 00000000000..94eea1ecd2b --- /dev/null +++ b/platform/ui-next/playground/patient-studies-small.json @@ -0,0 +1,38 @@ +[ + { + "patient": "John Doe", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324 + }, + { + "patient": "Jane Smith", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210 + }, + { + "patient": "Carlos Ruiz", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58 + }, + { + "patient": "Amina Khan", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512 + } +] diff --git a/platform/ui-next/playground/patient-studies.json b/platform/ui-next/playground/patient-studies.json index 94eea1ecd2b..e88dae3b759 100644 --- a/platform/ui-next/playground/patient-studies.json +++ b/platform/ui-next/playground/patient-studies.json @@ -1,6 +1,6 @@ [ { - "patient": "John Doe", + "patient": "Doe, John", "mrn": "MRN001234", "studyDateTime": "2025-06-14 09:32", "modalities": "CT", @@ -9,7 +9,7 @@ "instances": 324 }, { - "patient": "Jane Smith", + "patient": "Smith, Jane", "mrn": "MRN007891", "studyDateTime": "2025-06-13 14:05", "modalities": "MR", @@ -18,7 +18,7 @@ "instances": 210 }, { - "patient": "Carlos Ruiz", + "patient": "Ruiz, Carlos", "mrn": "MRN003456", "studyDateTime": "2025-06-12 11:48", "modalities": "US", @@ -27,12 +27,462 @@ "instances": 58 }, { - "patient": "Amina Khan", + "patient": "Khan, Amina", "mrn": "MRN005432", "studyDateTime": "2025-06-11 08:21", "modalities": "PET/CT", "description": "Whole Body PET/CT", "accession": "ACC-998877", "instances": 512 + }, + { + "patient": "Johnson, Michael", + "mrn": "MRN010001", + "studyDateTime": "2025-06-15 10:12", + "modalities": "CT", + "description": "CT Abdomen/Pelvis w/ Contrast", + "accession": "ACC-300001", + "instances": 512 + }, + { + "patient": "Patel, Priya", + "mrn": "MRN010002", + "studyDateTime": "2025-06-15 12:45", + "modalities": "MR", + "description": "MR Brain w/ and w/o Contrast", + "accession": "ACC-300002", + "instances": 240 + }, + { + "patient": "Lee, David", + "mrn": "MRN010003", + "studyDateTime": "2025-06-15 08:05", + "modalities": "US", + "description": "US Carotid Duplex", + "accession": "ACC-300003", + "instances": 76 + }, + { + "patient": "Ahmed, Fatima", + "mrn": "MRN010004", + "studyDateTime": "2025-06-14 16:20", + "modalities": "XR", + "description": "XR Chest PA & Lateral", + "accession": "ACC-300004", + "instances": 4 + }, + { + "patient": "Thompson, Robert", + "mrn": "MRN010005", + "studyDateTime": "2025-06-14 21:50", + "modalities": "CT", + "description": "CT Head w/o Contrast", + "accession": "ACC-300005", + "instances": 220 + }, + { + "patient": "Chen, Emily", + "mrn": "MRN010006", + "studyDateTime": "2025-06-14 09:10", + "modalities": "MG", + "description": "MG Screening Tomosynthesis", + "accession": "ACC-300006", + "instances": 132 + }, + { + "patient": "Zhang, Wei", + "mrn": "MRN010007", + "studyDateTime": "2025-06-13 15:35", + "modalities": "DEXA", + "description": "DEXA Bone Density Axial", + "accession": "ACC-300007", + "instances": 28 + }, + { + "patient": "Rossi, Sofia", + "mrn": "MRN010008", + "studyDateTime": "2025-06-13 11:22", + "modalities": "MR", + "description": "MR Knee Left", + "accession": "ACC-300008", + "instances": 180 + }, + { + "patient": "O'Connor, Liam", + "mrn": "MRN010009", + "studyDateTime": "2025-06-13 13:58", + "modalities": "US", + "description": "US Venous Doppler Lower Extremity Right", + "accession": "ACC-300009", + "instances": 64 + }, + { + "patient": "Garcia, Maria", + "mrn": "MRN010010", + "studyDateTime": "2025-06-13 18:42", + "modalities": "CT", + "description": "CT Pulmonary Angiography", + "accession": "ACC-300010", + "instances": 650 + }, + { + "patient": "Al-Sayed, Ahmed", + "mrn": "MRN010011", + "studyDateTime": "2025-06-12 09:30", + "modalities": "US", + "description": "US Abdomen RUQ", + "accession": "ACC-300011", + "instances": 54 + }, + { + "patient": "Kim, Hannah", + "mrn": "MRN010012", + "studyDateTime": "2025-06-12 14:17", + "modalities": "MR", + "description": "MR Lumbar Spine w/o Contrast", + "accession": "ACC-300012", + "instances": 156 + }, + { + "patient": "Brown, Ethan", + "mrn": "MRN010013", + "studyDateTime": "2025-06-12 16:05", + "modalities": "XR", + "description": "XR Ankle Right", + "accession": "ACC-300013", + "instances": 3 + }, + { + "patient": "Martinez, Olivia", + "mrn": "MRN010014", + "studyDateTime": "2025-06-12 08:02", + "modalities": "CT", + "description": "CT Chest Low-Dose Screening", + "accession": "ACC-300014", + "instances": 420 + }, + { + "patient": "Wilson, Noah", + "mrn": "MRN010015", + "studyDateTime": "2025-06-11 15:26", + "modalities": "MR", + "description": "MR Shoulder Right", + "accession": "ACC-300015", + "instances": 168 + }, + { + "patient": "Nguyen, Chloe", + "mrn": "MRN010016", + "studyDateTime": "2025-06-11 10:40", + "modalities": "XR", + "description": "XR Abdomen (KUB)", + "accession": "ACC-300016", + "instances": 2 + }, + { + "patient": "Silva, Lucas", + "mrn": "MRN010017", + "studyDateTime": "2025-06-11 12:55", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300017", + "instances": 48 + }, + { + "patient": "Costa, Isabella", + "mrn": "MRN010018", + "studyDateTime": "2025-06-11 13:20", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Right", + "accession": "ACC-300018", + "instances": 96 + }, + { + "patient": "Alvarez, Mateo", + "mrn": "MRN010019", + "studyDateTime": "2025-06-11 17:45", + "modalities": "PET/CT", + "description": "PET/CT FDG Skull Base to Mid-Thigh", + "accession": "ACC-300019", + "instances": 582 + }, + { + "patient": "Tanaka, Yuki", + "mrn": "MRN010020", + "studyDateTime": "2025-06-10 09:12", + "modalities": "MR", + "description": "MR Angiography Brain w/ and w/o Contrast", + "accession": "ACC-300020", + "instances": 212 + }, + { + "patient": "Cohen, Sara", + "mrn": "MRN010021", + "studyDateTime": "2025-06-10 11:31", + "modalities": "CT", + "description": "CT Sinuses w/o Contrast", + "accession": "ACC-300021", + "instances": 190 + }, + { + "patient": "Ivanov, Pavel", + "mrn": "MRN010022", + "studyDateTime": "2025-06-10 13:03", + "modalities": "US", + "description": "US Thyroid", + "accession": "ACC-300022", + "instances": 44 + }, + { + "patient": "El-Sayed, Amira", + "mrn": "MRN010023", + "studyDateTime": "2025-06-10 15:18", + "modalities": "XR", + "description": "XR Shoulder Left", + "accession": "ACC-300023", + "instances": 4 + }, + { + "patient": "Papadopoulos, George", + "mrn": "MRN010024", + "studyDateTime": "2025-06-10 19:05", + "modalities": "CT", + "description": "CT Urogram", + "accession": "ACC-300024", + "instances": 780 + }, + { + "patient": "Bergstrom, Hanna", + "mrn": "MRN010025", + "studyDateTime": "2025-06-09 10:15", + "modalities": "DEXA", + "description": "DEXA Bone Density Axial", + "accession": "ACC-300025", + "instances": 30 + }, + { + "patient": "Johansson, Marcus", + "mrn": "MRN010026", + "studyDateTime": "2025-06-09 12:47", + "modalities": "MR", + "description": "MR Brain w/o Contrast", + "accession": "ACC-300026", + "instances": 132 + }, + { + "patient": "Bello, Aisha", + "mrn": "MRN010027", + "studyDateTime": "2025-06-09 08:55", + "modalities": "US", + "description": "US Pelvis TA/TV", + "accession": "ACC-300027", + "instances": 66 + }, + { + "patient": "Adeyemi, Seun", + "mrn": "MRN010028", + "studyDateTime": "2025-06-09 14:33", + "modalities": "XR", + "description": "XR Hand Left", + "accession": "ACC-300028", + "instances": 2 + }, + { + "patient": "Dubois, Lea", + "mrn": "MRN010029", + "studyDateTime": "2025-06-09 16:02", + "modalities": "MR", + "description": "MR Abdomen w/ and w/o Contrast", + "accession": "ACC-300029", + "instances": 228 + }, + { + "patient": "Novak, Tomas", + "mrn": "MRN010030", + "studyDateTime": "2025-06-08 09:42", + "modalities": "CT", + "description": "CT Cervical Spine w/o Contrast", + "accession": "ACC-300030", + "instances": 360 + }, + { + "patient": "Ortiz, Daniel", + "mrn": "MRN010031", + "studyDateTime": "2025-06-08 07:20", + "modalities": "XR", + "description": "XR Chest Portable AP", + "accession": "ACC-300031", + "instances": 1 + }, + { + "patient": "Petrova, Nadia", + "mrn": "MRN010032", + "studyDateTime": "2025-06-08 13:11", + "modalities": "NM", + "description": "NM Bone Scan Whole Body", + "accession": "ACC-300032", + "instances": 240 + }, + { + "patient": "Mehta, Arjun", + "mrn": "MRN010033", + "studyDateTime": "2025-06-08 15:37", + "modalities": "MR", + "description": "MR Prostate w/ and w/o Contrast", + "accession": "ACC-300033", + "instances": 200 + }, + { + "patient": "Lin, Mei", + "mrn": "MRN010034", + "studyDateTime": "2025-06-08 10:58", + "modalities": "US", + "description": "US Obstetric 2nd Trimester", + "accession": "ACC-300034", + "instances": 92 + }, + { + "patient": "O'Brien, Kevin", + "mrn": "MRN010035", + "studyDateTime": "2025-06-07 18:25", + "modalities": "CT", + "description": "CT Angiography Head/Neck", + "accession": "ACC-300035", + "instances": 700 + }, + { + "patient": "Hussain, Zahra", + "mrn": "MRN010036", + "studyDateTime": "2025-06-07 11:06", + "modalities": "MR", + "description": "MR Enterography", + "accession": "ACC-300036", + "instances": 216 + }, + { + "patient": "Nasser, Omar", + "mrn": "MRN010037", + "studyDateTime": "2025-06-07 09:30", + "modalities": "US", + "description": "US Aorta Screening", + "accession": "ACC-300037", + "instances": 42 + }, + { + "patient": "Walker, Jade", + "mrn": "MRN010038", + "studyDateTime": "2025-06-07 14:48", + "modalities": "XR", + "description": "XR Lumbar Spine 2-3 Views", + "accession": "ACC-300038", + "instances": 5 + }, + { + "patient": "Muller, Peter", + "mrn": "MRN010039", + "studyDateTime": "2025-06-07 08:12", + "modalities": "CT", + "description": "CT Coronary Calcium Score", + "accession": "ACC-300039", + "instances": 180 + }, + { + "patient": "Mendes, Carla", + "mrn": "MRN010040", + "studyDateTime": "2025-06-06 10:20", + "modalities": "MG", + "description": "MG Screening Tomosynthesis", + "accession": "ACC-300040", + "instances": 140 + }, + { + "patient": "Pereira, Joao", + "mrn": "MRN010041", + "studyDateTime": "2025-06-06 17:40", + "modalities": "PET/CT", + "description": "PET/CT FDG Whole Body", + "accession": "ACC-300041", + "instances": 620 + }, + { + "patient": "Krishnan, Rajiv", + "mrn": "MRN010042", + "studyDateTime": "2025-06-06 12:18", + "modalities": "MR", + "description": "MR Shoulder Left", + "accession": "ACC-300042", + "instances": 160 + }, + { + "patient": "Bianchi, Giulia", + "mrn": "MRN010043", + "studyDateTime": "2025-06-06 15:29", + "modalities": "CT", + "description": "CT Abdomen/Pelvis w/o Contrast", + "accession": "ACC-300043", + "instances": 480 + }, + { + "patient": "Svensson, Nora", + "mrn": "MRN010044", + "studyDateTime": "2025-06-05 08:44", + "modalities": "US", + "description": "US Gallbladder RUQ", + "accession": "ACC-300044", + "instances": 50 + }, + { + "patient": "Svensson, Nora", + "mrn": "MRN010045", + "studyDateTime": "2025-06-05 13:27", + "modalities": "XR", + "description": "XR Hip Left", + "accession": "ACC-300045", + "instances": 3 + }, + { + "patient": "Wei, Jing", + "mrn": "MRN010046", + "studyDateTime": "2025-06-05 11:05", + "modalities": "US", + "description": "US Doppler Carotid Bilateral", + "accession": "ACC-300046", + "instances": 70 + }, + { + "patient": "Mensah, Kofi", + "mrn": "MRN010047", + "studyDateTime": "2025-06-04 09:55", + "modalities": "NM", + "description": "NM HIDA w/ Ejection Fraction", + "accession": "ACC-300047", + "instances": 180 + }, + { + "patient": "Shah, Lila", + "mrn": "MRN010048", + "studyDateTime": "2025-06-04 14:12", + "modalities": "MR", + "description": "MR Cervical Spine w/o Contrast", + "accession": "ACC-300048", + "instances": 150 + }, + { + "patient": "Laurent, Antoine", + "mrn": "MRN010049", + "studyDateTime": "2025-06-04 16:50", + "modalities": "CT", + "description": "CT Head w/ and w/o Contrast", + "accession": "ACC-300049", + "instances": 540 + }, + { + "patient": "Park, Grace", + "mrn": "MRN010050", + "studyDateTime": "2025-06-04 12:40", + "modalities": "XR", + "description": "XR Foot Left", + "accession": "ACC-300050", + "instances": 2 } ] diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 6a9b4249728..012abb968d1 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -24,19 +24,19 @@ const App = () => {

Study List

- -
- {/* Data Table */} - - columns={columns} - data={data as StudyRow[]} - getRowId={row => row.accession} - singleRowSelection={true} - showColumnVisibilityControls={true} - tableClassName="min-w-[1000px]" - /> +
+
+ {/* Data Table */} + + columns={columns} + data={data as StudyRow[]} + getRowId={row => row.accession} + singleRowSelection={true} + showColumnVisibilityControls={true} + tableClassName="min-w-[1000px]" + /> +
-
diff --git a/platform/ui-next/src/components/Table/DataTable.tsx b/platform/ui-next/src/components/Table/DataTable.tsx index 1371b876b6a..038dd24d6fc 100644 --- a/platform/ui-next/src/components/Table/DataTable.tsx +++ b/platform/ui-next/src/components/Table/DataTable.tsx @@ -62,7 +62,7 @@ export function DataTable({ }); return ( -
+
{showColumnVisibilityControls && (
@@ -94,50 +94,51 @@ export function DataTable({
)} - - - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map(row => ( - row.toggleSelected()} - aria-selected={row.getIsSelected()} - className="cursor-pointer" - > - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - +
+
+ + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + ))} - )) - ) : ( - - - No results. - - - )} - -
+ ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map(row => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="cursor-pointer" + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
); } diff --git a/platform/ui-next/src/components/Table/Table.tsx b/platform/ui-next/src/components/Table/Table.tsx index 5a9b79a10ea..38532e7ef9b 100644 --- a/platform/ui-next/src/components/Table/Table.tsx +++ b/platform/ui-next/src/components/Table/Table.tsx @@ -2,9 +2,13 @@ import * as React from 'react'; import { cn } from '../../lib/utils'; -const Table = React.forwardRef>( - ({ className, ...props }, ref) => ( -
+type TableProps = React.HTMLAttributes & { + containerClassName?: string; +}; + +const Table = React.forwardRef( + ({ className, containerClassName, ...props }, ref) => ( +
Date: Mon, 29 Sep 2025 12:39:48 -0400 Subject: [PATCH 012/172] Split panel states into separate files --- platform/ui-next/playground/panel-content.tsx | 23 ++++ platform/ui-next/playground/panel-default.tsx | 7 + .../playground/studylist-panel-test.tsx | 126 +++++++++++++----- .../src/components/Table/DataTable.tsx | 22 ++- 4 files changed, 138 insertions(+), 40 deletions(-) create mode 100644 platform/ui-next/playground/panel-content.tsx create mode 100644 platform/ui-next/playground/panel-default.tsx diff --git a/platform/ui-next/playground/panel-content.tsx b/platform/ui-next/playground/panel-content.tsx new file mode 100644 index 00000000000..2b4a63c8afe --- /dev/null +++ b/platform/ui-next/playground/panel-content.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export type StudyRow = { + patient: string; + mrn: string; + studyDateTime: string; + modalities: string; + description: string; + accession: string; + instances: number; +}; + +export function PanelContent({ + study, + layout, +}: { + study: StudyRow; + layout: 'right' | 'bottom'; +}) { + // Full layout can diverge per layout; for now text only + return
Study Data Preview here
; +} + diff --git a/platform/ui-next/playground/panel-default.tsx b/platform/ui-next/playground/panel-default.tsx new file mode 100644 index 00000000000..782ae1022df --- /dev/null +++ b/platform/ui-next/playground/panel-default.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export function PanelDefault({ layout }: { layout: 'right' | 'bottom' }) { + // Layout-aware empty state can diverge later; for now text only + return
Select a study
; +} + diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 012abb968d1..354cd27d8af 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -7,11 +7,14 @@ import { Button } from '../src/components/Button'; import type { ColumnDef } from '@tanstack/react-table'; import { DataTable, DataTableColumnHeader } from '../src/components/Table'; import data from './patient-studies.json'; +import { PanelDefault } from './panel-default'; +import { PanelContent, type StudyRow } from './panel-content'; // (moved columns/type to the top of the file) const App = () => { const [layout, setLayout] = React.useState<'right' | 'bottom'>('right'); + const [selected, setSelected] = React.useState(null); return ( @@ -24,8 +27,8 @@ const App = () => {

Study List

-
-
+
+
{/* Data Table */} columns={columns} @@ -34,6 +37,7 @@ const App = () => { singleRowSelection={true} showColumnVisibilityControls={true} tableClassName="min-w-[1000px]" + onRowSelectionChange={rows => setSelected(rows[0] ?? null)} />
@@ -44,22 +48,15 @@ const App = () => { {/* Secondary Panel (Right or Bottom) */} - -
-
- {layout === 'right' ? 'Right Panel' : 'Bottom Panel'} - -
- -
Placeholder content
-
-
+ + setLayout(layout === 'right' ? 'bottom' : 'right')} + />
@@ -76,32 +73,73 @@ if (!container) { const root = createRoot(container); root.render(); +// Panel scaffolding to support distinct layouts for empty vs. selected states +function SidePanel({ + layout, + selected, + onToggleLayout, +}: { + layout: 'right' | 'bottom'; + selected: StudyRow | null; + onToggleLayout: () => void; +}) { + const isRight = layout === 'right'; + const headerTitle = isRight ? 'Right Panel' : 'Bottom Panel'; + + return ( +
+
+ {headerTitle} + +
+ +
+ {selected ? ( + + ) : ( + + )} +
+
+
+ ); +} // Types and column definitions for the study list -type StudyRow = { - patient: string; - mrn: string; - studyDateTime: string; - modalities: string; - description: string; - accession: string; - instances: number; -}; const columns: ColumnDef[] = [ { accessorKey: 'patient', - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) =>
{row.getValue('patient')}
, }, { accessorKey: 'mrn', - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) =>
{row.getValue('mrn')}
, }, { accessorKey: 'studyDateTime', header: ({ column }) => ( - + ), cell: ({ row }) =>
{row.getValue('studyDateTime')}
, sortingFn: (a, b, colId) => @@ -110,28 +148,44 @@ const columns: ColumnDef[] = [ }, { accessorKey: 'modalities', - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) =>
{row.getValue('modalities')}
, }, { accessorKey: 'description', - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) =>
{row.getValue('description')}
, }, { accessorKey: 'accession', header: ({ column }) => ( - + ), cell: ({ row }) =>
{row.getValue('accession')}
, }, { accessorKey: 'instances', header: ({ column }) => ( - + ), cell: ({ row }) =>
{row.getValue('instances')}
, - sortingFn: (a, b, colId) => - (a.getValue(colId) as number) - (b.getValue(colId) as number), + sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), }, ]; diff --git a/platform/ui-next/src/components/Table/DataTable.tsx b/platform/ui-next/src/components/Table/DataTable.tsx index 038dd24d6fc..dbf5aaa3ac0 100644 --- a/platform/ui-next/src/components/Table/DataTable.tsx +++ b/platform/ui-next/src/components/Table/DataTable.tsx @@ -30,6 +30,7 @@ export interface DataTableProps { singleRowSelection?: boolean; // default: true showColumnVisibilityControls?: boolean; // default: true tableClassName?: string; + onRowSelectionChange?: (selectedRows: TData[], rowSelection: RowSelectionState) => void; } export function DataTable({ @@ -41,6 +42,7 @@ export function DataTable({ singleRowSelection = true, showColumnVisibilityControls = true, tableClassName, + onRowSelectionChange, }: DataTableProps) { const [sorting, setSorting] = React.useState(initialSorting); const [columnVisibility, setColumnVisibility] = @@ -61,8 +63,14 @@ export function DataTable({ getRowId, }); + React.useEffect(() => { + if (!onRowSelectionChange) return; + const selected = table.getSelectedRowModel().rows.map(r => r.original as TData); + onRowSelectionChange(selected, rowSelection); + }, [rowSelection, table, onRowSelectionChange]); + return ( -
+
{showColumnVisibilityControls && (
@@ -94,13 +102,19 @@ export function DataTable({
)} -
-
+
+
{table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => ( - + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} From 0cab69187091bf5b7bb1ed25b4a4c164d2705f56 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 13:16:27 -0400 Subject: [PATCH 013/172] Added thumbnail panel content --- platform/ui-next/playground/panel-content.tsx | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/platform/ui-next/playground/panel-content.tsx b/platform/ui-next/playground/panel-content.tsx index 2b4a63c8afe..70dd7f201b4 100644 --- a/platform/ui-next/playground/panel-content.tsx +++ b/platform/ui-next/playground/panel-content.tsx @@ -1,4 +1,8 @@ import React from 'react'; +import { Thumbnail } from '../src/components/Thumbnail'; +import { TooltipProvider } from '../src/components/Tooltip'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; export type StudyRow = { patient: string; @@ -17,7 +21,37 @@ export function PanelContent({ study: StudyRow; layout: 'right' | 'bottom'; }) { - // Full layout can diverge per layout; for now text only - return
Study Data Preview here
; -} + // Prototype eight series thumbnails; no image data provided on purpose. + const thumbnails = Array.from({ length: 8 }, (_, i) => ({ + id: `preview-${study.accession}-${i}`, + description: `Series ${i + 1}`, + seriesNumber: i + 1, + numInstances: 1, + })); + return ( + + +
+
Study Series
+
+ {thumbnails.map(item => ( + {}} + onDoubleClick={() => {}} + viewPreset="thumbnails" + /> + ))} +
+
+
+
+ ); +} From 1ec30f2f89de981195371996a641c3965e73b7df Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 13:23:38 -0400 Subject: [PATCH 014/172] Random number of thumbnails Between 3 and 9 --- platform/ui-next/playground/panel-content.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platform/ui-next/playground/panel-content.tsx b/platform/ui-next/playground/panel-content.tsx index 70dd7f201b4..e62fcb418df 100644 --- a/platform/ui-next/playground/panel-content.tsx +++ b/platform/ui-next/playground/panel-content.tsx @@ -22,7 +22,8 @@ export function PanelContent({ layout: 'right' | 'bottom'; }) { // Prototype eight series thumbnails; no image data provided on purpose. - const thumbnails = Array.from({ length: 8 }, (_, i) => ({ + const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); // 3–9 + const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ id: `preview-${study.accession}-${i}`, description: `Series ${i + 1}`, seriesNumber: i + 1, From 770b6f0ec1e0de078d3ba09e09f090ff2acc5fc2 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 16:41:56 -0400 Subject: [PATCH 015/172] Updated header layout --- .../playground/studylist-panel-test.tsx | 2 +- .../src/components/Table/DataTable.tsx | 113 ++++++++++-------- .../ui-next/src/components/Table/Table.tsx | 11 +- 3 files changed, 69 insertions(+), 57 deletions(-) diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 354cd27d8af..4c21295f2d2 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -26,7 +26,6 @@ const App = () => { {/* Main Area */}
-

Study List

{/* Data Table */} @@ -36,6 +35,7 @@ const App = () => { getRowId={row => row.accession} singleRowSelection={true} showColumnVisibilityControls={true} + title="Study List" tableClassName="min-w-[1000px]" onRowSelectionChange={rows => setSelected(rows[0] ?? null)} /> diff --git a/platform/ui-next/src/components/Table/DataTable.tsx b/platform/ui-next/src/components/Table/DataTable.tsx index dbf5aaa3ac0..9c653dcd6d5 100644 --- a/platform/ui-next/src/components/Table/DataTable.tsx +++ b/platform/ui-next/src/components/Table/DataTable.tsx @@ -14,6 +14,7 @@ import { import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from './Table'; import { Button } from '../Button'; +import { ScrollArea } from '../ScrollArea'; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -31,6 +32,7 @@ export interface DataTableProps { showColumnVisibilityControls?: boolean; // default: true tableClassName?: string; onRowSelectionChange?: (selectedRows: TData[], rowSelection: RowSelectionState) => void; + title?: React.ReactNode; } export function DataTable({ @@ -43,6 +45,7 @@ export function DataTable({ showColumnVisibilityControls = true, tableClassName, onRowSelectionChange, + title, }: DataTableProps) { const [sorting, setSorting] = React.useState(initialSorting); const [columnVisibility, setColumnVisibility] = @@ -70,10 +73,11 @@ export function DataTable({ }, [rowSelection, table, onRowSelectionChange]); return ( -
- {showColumnVisibilityControls && ( -
-
+
+ {(showColumnVisibilityControls || title) && ( +
+
{title}
+ {showColumnVisibilityControls && (
+ )}
)} -
-
- - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map(row => ( - row.toggleSelected()} - aria-selected={row.getIsSelected()} - className="cursor-pointer" - > - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - +
+ +
+ + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + ))} - )) - ) : ( - - - No results. - - - )} - -
+ ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map(row => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="cursor-pointer" + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + + +
); diff --git a/platform/ui-next/src/components/Table/Table.tsx b/platform/ui-next/src/components/Table/Table.tsx index 38532e7ef9b..3986c410adb 100644 --- a/platform/ui-next/src/components/Table/Table.tsx +++ b/platform/ui-next/src/components/Table/Table.tsx @@ -4,11 +4,12 @@ import { cn } from '../../lib/utils'; type TableProps = React.HTMLAttributes & { containerClassName?: string; + noScroll?: boolean; }; const Table = React.forwardRef( - ({ className, containerClassName, ...props }, ref) => ( -
+ ({ className, containerClassName, noScroll = false, ...props }, ref) => ( +
td]:text-highlight hover:[&>th]:text-highlight data-[state=selected]:bg-muted border-input/50 border-b transition-colors', + 'hover:bg-muted text-muted-foreground hover:text-highlight hover:[&>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} From 06a3aa31af2b1357679cbde195eb3827a29e7417 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 17:41:30 -0400 Subject: [PATCH 016/172] Removed DataTable and added directly to Study List --- .../playground/studylist-panel-test.tsx | 169 +++++++++++++++++- .../src/components/Table/DataTable.tsx | 165 ----------------- .../Table/DataTableColumnHeader.tsx | 36 ---- .../ui-next/src/components/Table/index.ts | 2 - 4 files changed, 167 insertions(+), 205 deletions(-) delete mode 100644 platform/ui-next/src/components/Table/DataTable.tsx delete mode 100644 platform/ui-next/src/components/Table/DataTableColumnHeader.tsx diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 4c21295f2d2..6a284ad8214 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -4,14 +4,179 @@ import { ThemeWrapper } from '../src/components/ThemeWrapper'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../src/components/Resizable'; import { ScrollArea } from '../src/components/ScrollArea'; import { Button } from '../src/components/Button'; -import type { ColumnDef } from '@tanstack/react-table'; -import { DataTable, DataTableColumnHeader } from '../src/components/Table'; +import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '../src/components/Table'; +import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '../src/components/DropdownMenu'; +import type { ColumnDef, SortingState, VisibilityState, RowSelectionState, Column } from '@tanstack/react-table'; +import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'; import data from './patient-studies.json'; import { PanelDefault } from './panel-default'; import { PanelContent, type StudyRow } from './panel-content'; // (moved columns/type to the top of the file) +// Inline versions of DataTable and DataTableColumnHeader for this prototype + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + getRowId?: (originalRow: TData, index: number) => string; + initialSorting?: SortingState; + initialColumnVisibility?: VisibilityState; + singleRowSelection?: boolean; // default: true + showColumnVisibilityControls?: boolean; // default: true + tableClassName?: string; + onRowSelectionChange?: (selectedRows: TData[], rowSelection: RowSelectionState) => void; + title?: React.ReactNode; +} + +function DataTable({ + columns, + data, + getRowId, + initialSorting = [], + initialColumnVisibility = {}, + singleRowSelection = true, + showColumnVisibilityControls = true, + tableClassName, + onRowSelectionChange, + title, +}: DataTableProps) { + const [sorting, setSorting] = React.useState(initialSorting); + const [columnVisibility, setColumnVisibility] = React.useState(initialColumnVisibility); + const [rowSelection, setRowSelection] = React.useState({}); + + const table = useReactTable({ + data, + columns, + state: { sorting, columnVisibility, rowSelection }, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableRowSelection: true, + enableMultiRowSelection: singleRowSelection ? false : true, + getRowId, + }); + + React.useEffect(() => { + if (!onRowSelectionChange) return; + const selected = table.getSelectedRowModel().rows.map(r => r.original as TData); + onRowSelectionChange(selected, rowSelection); + }, [rowSelection, table, onRowSelectionChange]); + + return ( +
+ {(showColumnVisibilityControls || title) && ( +
+
{title}
+ {showColumnVisibilityControls && ( + + + + + + {table + .getAllColumns() + .filter(col => col.getCanHide()) + .map(column => ( + column.toggleVisibility(!!v)} + className="capitalize" + > + {column.id} + + ))} + + + )} +
+ )} +
+ +
+ + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map(row => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="cursor-pointer" + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+ +
+
+ ); +} + +interface DataTableColumnHeaderProps { + column: Column; + title: string; + align?: 'left' | 'center' | 'right'; +} + +function DataTableColumnHeader({ + column, + title, + align = 'left', +}: DataTableColumnHeaderProps) { + const sorted = column.getIsSorted() as false | 'asc' | 'desc'; + const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕'; + + const justifyClass = + align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; + + return ( +
+ {title} + +
+ ); +} + const App = () => { const [layout, setLayout] = React.useState<'right' | 'bottom'>('right'); const [selected, setSelected] = React.useState(null); diff --git a/platform/ui-next/src/components/Table/DataTable.tsx b/platform/ui-next/src/components/Table/DataTable.tsx deleted file mode 100644 index 9c653dcd6d5..00000000000 --- a/platform/ui-next/src/components/Table/DataTable.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import * as React from 'react'; -import type { - ColumnDef, - SortingState, - VisibilityState, - RowSelectionState, -} from '@tanstack/react-table'; -import { - flexRender, - getCoreRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table'; - -import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from './Table'; -import { Button } from '../Button'; -import { ScrollArea } from '../ScrollArea'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from '../DropdownMenu'; - -export interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - getRowId?: (originalRow: TData, index: number) => string; - initialSorting?: SortingState; - initialColumnVisibility?: VisibilityState; - singleRowSelection?: boolean; // default: true - showColumnVisibilityControls?: boolean; // default: true - tableClassName?: string; - onRowSelectionChange?: (selectedRows: TData[], rowSelection: RowSelectionState) => void; - title?: React.ReactNode; -} - -export function DataTable({ - columns, - data, - getRowId, - initialSorting = [], - initialColumnVisibility = {}, - singleRowSelection = true, - showColumnVisibilityControls = true, - tableClassName, - onRowSelectionChange, - title, -}: DataTableProps) { - const [sorting, setSorting] = React.useState(initialSorting); - const [columnVisibility, setColumnVisibility] = - React.useState(initialColumnVisibility); - const [rowSelection, setRowSelection] = React.useState({}); - - const table = useReactTable({ - data, - columns, - state: { sorting, columnVisibility, rowSelection }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setRowSelection, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableRowSelection: true, - enableMultiRowSelection: singleRowSelection ? false : true, - getRowId, - }); - - React.useEffect(() => { - if (!onRowSelectionChange) return; - const selected = table.getSelectedRowModel().rows.map(r => r.original as TData); - onRowSelectionChange(selected, rowSelection); - }, [rowSelection, table, onRowSelectionChange]); - - return ( -
- {(showColumnVisibilityControls || title) && ( -
-
{title}
- {showColumnVisibilityControls && ( - - - - - - {table - .getAllColumns() - .filter(col => col.getCanHide()) - .map(column => ( - column.toggleVisibility(!!v)} - className="capitalize" - > - {column.id} - - ))} - - - )} -
- )} -
- - - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map(row => ( - row.toggleSelected()} - aria-selected={row.getIsSelected()} - className="cursor-pointer" - > - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
-
-
- ); -} diff --git a/platform/ui-next/src/components/Table/DataTableColumnHeader.tsx b/platform/ui-next/src/components/Table/DataTableColumnHeader.tsx deleted file mode 100644 index 508afe7dd32..00000000000 --- a/platform/ui-next/src/components/Table/DataTableColumnHeader.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react'; -import type { Column } from '@tanstack/react-table'; -import { Button } from '../Button'; - -export interface DataTableColumnHeaderProps { - column: Column; - title: string; - align?: 'left' | 'center' | 'right'; -} - -export function DataTableColumnHeader({ - column, - title, - align = 'left', -}: DataTableColumnHeaderProps) { - const sorted = column.getIsSorted() as false | 'asc' | 'desc'; - const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕'; - - const justifyClass = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; - - return ( -
- {title} - -
- ); -} - diff --git a/platform/ui-next/src/components/Table/index.ts b/platform/ui-next/src/components/Table/index.ts index a4d2d04d6d4..3e7b53b7f65 100644 --- a/platform/ui-next/src/components/Table/index.ts +++ b/platform/ui-next/src/components/Table/index.ts @@ -9,5 +9,3 @@ export { TableCaption, } from './Table'; -export { DataTable } from './DataTable'; -export { DataTableColumnHeader } from './DataTableColumnHeader'; From 027f95c4cb828a07f9799c33f5180b84c1bb1aa1 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 29 Sep 2025 18:30:49 -0400 Subject: [PATCH 017/172] Filters prototype working --- .../playground/studylist-panel-test.tsx | 116 ++++++++++++++---- 1 file changed, 90 insertions(+), 26 deletions(-) diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 6a284ad8214..9935db54221 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -4,26 +4,48 @@ import { ThemeWrapper } from '../src/components/ThemeWrapper'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../src/components/Resizable'; import { ScrollArea } from '../src/components/ScrollArea'; import { Button } from '../src/components/Button'; -import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '../src/components/Table'; -import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '../src/components/DropdownMenu'; -import type { ColumnDef, SortingState, VisibilityState, RowSelectionState, Column } from '@tanstack/react-table'; -import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'; +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from '../src/components/Table'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../src/components/DropdownMenu'; +import { Input } from '../src/components/Input'; +import type { + ColumnDef, + SortingState, + VisibilityState, + RowSelectionState, + Column, + ColumnFiltersState, +} from '@tanstack/react-table'; +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + useReactTable, +} from '@tanstack/react-table'; import data from './patient-studies.json'; import { PanelDefault } from './panel-default'; import { PanelContent, type StudyRow } from './panel-content'; -// (moved columns/type to the top of the file) - -// Inline versions of DataTable and DataTableColumnHeader for this prototype - interface DataTableProps { columns: ColumnDef[]; data: TData[]; getRowId?: (originalRow: TData, index: number) => string; initialSorting?: SortingState; initialColumnVisibility?: VisibilityState; - singleRowSelection?: boolean; // default: true - showColumnVisibilityControls?: boolean; // default: true + singleRowSelection?: boolean; + showColumnVisibilityControls?: boolean; tableClassName?: string; onRowSelectionChange?: (selectedRows: TData[], rowSelection: RowSelectionState) => void; title?: React.ReactNode; @@ -42,18 +64,22 @@ function DataTable({ title, }: DataTableProps) { const [sorting, setSorting] = React.useState(initialSorting); - const [columnVisibility, setColumnVisibility] = React.useState(initialColumnVisibility); + const [columnVisibility, setColumnVisibility] = + React.useState(initialColumnVisibility); const [rowSelection, setRowSelection] = React.useState({}); + const [columnFilters, setColumnFilters] = React.useState([]); const table = useReactTable({ data, columns, - state: { sorting, columnVisibility, rowSelection }, + state: { sorting, columnVisibility, rowSelection, columnFilters }, onSortingChange: setSorting, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, + onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), enableRowSelection: true, enableMultiRowSelection: singleRowSelection ? false : true, getRowId, @@ -69,11 +95,14 @@ function DataTable({
{(showColumnVisibilityControls || title) && (
-
{title}
+
{title}
{showColumnVisibilityControls && ( - @@ -96,14 +125,20 @@ function DataTable({ )}
)} -
+
- +
{table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => ( - + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} @@ -113,6 +148,34 @@ function DataTable({ ))} + + {table.getVisibleLeafColumns().map(col => ( + + {col.id === 'instances' ? ( + + ) : ( + table.getColumn(col.id)?.setFilterValue(e.target.value)} + className="h-7 w-full" + /> + )} + + ))} + {table.getRowModel().rows.length ? ( table.getRowModel().rows.map(row => ( ({ )) ) : ( - + No results. @@ -162,7 +228,7 @@ function DataTableColumnHeader({ align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; return ( -
+
{title}
- - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - - {table.getVisibleLeafColumns().map(col => ( - - {col.id === 'instances' ? ( -
+ + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + - Reset - - ) : ( - table.getColumn(col.id)?.setFilterValue(e.target.value)} - className="h-7 w-full" - /> - )} - - ))} - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map(row => ( - row.toggleSelected()} - aria-selected={row.getIsSelected()} - className="cursor-pointer" - > - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + ))} - )) - ) : ( - - - No results. - + ))} + + + + {table.getVisibleLeafColumns().map(col => ( + + {col.id === 'instances' ? ( + + ) : ( + table.getColumn(col.id)?.setFilterValue(e.target.value)} + className="h-7 w-full" + /> + )} + + ))} - )} - -
+ {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map(row => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="cursor-pointer" + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + + +
@@ -325,7 +328,7 @@ function SidePanel({
-
+
{selected ? ( [] = [ header: ({ column }) => ( ), cell: ({ row }) =>
{row.getValue('studyDateTime')}
, @@ -400,7 +403,7 @@ const columns: ColumnDef[] = [ header: ({ column }) => ( ), cell: ({ row }) =>
{row.getValue('accession')}
, From 985c8e514e87a6f34418cb896d68618fbcd30592 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Wed, 1 Oct 2025 11:53:58 -0400 Subject: [PATCH 019/172] Updated panel --- platform/ui-next/playground/panel-content.tsx | 23 +++++++++++- platform/ui-next/playground/panel-default.tsx | 37 +++++++++++++++++-- .../playground/studylist-panel-test.tsx | 26 +++++-------- 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/platform/ui-next/playground/panel-content.tsx b/platform/ui-next/playground/panel-content.tsx index e62fcb418df..477d0979092 100644 --- a/platform/ui-next/playground/panel-content.tsx +++ b/platform/ui-next/playground/panel-content.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Thumbnail } from '../src/components/Thumbnail'; import { TooltipProvider } from '../src/components/Tooltip'; +import { Table, TableHeader, TableRow, TableHead } from '../src/components/Table'; +import { Button } from '../src/components/Button'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; @@ -17,9 +19,11 @@ export type StudyRow = { export function PanelContent({ study, layout, + onToggleLayout, }: { study: StudyRow; layout: 'right' | 'bottom'; + onToggleLayout: () => void; }) { // Prototype eight series thumbnails; no image data provided on purpose. const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); // 3–9 @@ -34,7 +38,24 @@ export function PanelContent({
-
Study Series
+ + + + +
+ Studies + +
+
+
+
+
{thumbnails.map(item => ( Select a study
; -} +export function PanelDefault({ + layout, + onToggleLayout, +}: { + layout: 'right' | 'bottom'; + onToggleLayout: () => void; +}) { + return ( +
+ + + + +
+ Studies + +
+
+
+
+
+
Select a study
+
+ ); +} diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index da114d14525..0cc3e2da641 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -247,7 +247,7 @@ function DataTableColumnHeader({ } const App = () => { - const [layout, setLayout] = React.useState<'right' | 'bottom'>('right'); + const [layout, setLayout] = React.useState<'right' | 'bottom'>('bottom'); const [selected, setSelected] = React.useState(null); return ( @@ -312,31 +312,25 @@ function SidePanel({ selected: StudyRow | null; onToggleLayout: () => void; }) { - const isRight = layout === 'right'; - const headerTitle = isRight ? 'Right Panel' : 'Bottom Panel'; - return (
-
- {headerTitle} - -
-
+
{selected ? ( ) : ( - + )}
From 6bc294dd968ebe37941881ee965dc536d1d2d8c5 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Wed, 1 Oct 2025 12:44:13 -0400 Subject: [PATCH 020/172] Added action button to rows --- .../playground/studylist-panel-test.tsx | 138 ++++++++++++++---- 1 file changed, 107 insertions(+), 31 deletions(-) diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index 0cc3e2da641..c8747b031c4 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuTrigger, } from '../src/components/DropdownMenu'; import { Input } from '../src/components/Input'; @@ -94,34 +95,35 @@ function DataTable({ return (
{(showColumnVisibilityControls || title) && ( -
-
{title}
+
+
{title}
{showColumnVisibilityControls && ( - - - - - - {table - .getAllColumns() - .filter(col => col.getCanHide()) - .map(column => ( - column.toggleVisibility(!!v)} - className="capitalize" - > - {column.id} - - ))} - - +
+ + + + + + .getAllColumns() + .filter(col => col.getCanHide()) + .map(column => ( + column.toggleVisibility(!!v)} + className="capitalize" + > + {column.id} + + ))} + + +
)}
)} @@ -185,7 +187,7 @@ function DataTable({ data-state={row.getIsSelected() ? 'selected' : undefined} onClick={() => row.toggleSelected()} aria-selected={row.getIsSelected()} - className="cursor-pointer" + className="group cursor-pointer" > {row.getVisibleCells().map(cell => ( @@ -258,9 +260,9 @@ const App = () => { className="h-full w-full" > -
+
-
+
columns={columns} data={data as StudyRow[]} @@ -411,7 +413,81 @@ const columns: ColumnDef[] = [ align="right" /> ), - cell: ({ row }) =>
{row.getValue('instances')}
, + cell: ({ row }) => { + const value = row.getValue('instances') as number; + const isActive = row.getIsSelected(); + return ( +
+
+ {value} +
+
e.stopPropagation()} + onMouseDown={e => { + e.stopPropagation(); + if (!row.getIsSelected()) { + row.toggleSelected(true); + } + }} + onPointerDown={e => { + e.stopPropagation(); + if (!row.getIsSelected()) { + row.toggleSelected(true); + } + }} + > + + + + + e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + onPointerDown={e => e.stopPropagation()} + > + e.preventDefault()}>Basic Viewer + e.preventDefault()}>Segmentation + US Pleura B-line Annotations + Total Metabolic Tumor Volume + Microscopy + Preclinical 4D + + +
+
+ ); + }, sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), }, ]; From 960f32984d048febac340c7b4b4a3c78ad5eaeca Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Wed, 1 Oct 2025 12:45:16 -0400 Subject: [PATCH 021/172] Missing text fix --- platform/ui-next/playground/studylist-panel-test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index c8747b031c4..a5bb48be8f5 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -109,6 +109,7 @@ function DataTable({ + {table .getAllColumns() .filter(col => col.getCanHide()) .map(column => ( From 6c0f35a225e6d674438bc4366758860ea537367c Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Wed, 1 Oct 2025 17:37:52 -0400 Subject: [PATCH 022/172] Rebuilt prototype --- platform/ui-next/playground/panel-content.tsx | 11 +- .../playground/patient-table-prototype.tsx | 6 +- .../playground/studylist-panel-test.tsx | 421 +----------------- .../studylist/cells/launch-menu-cell.tsx | 73 +++ .../playground/studylist/column-header.tsx | 34 ++ .../ui-next/playground/studylist/columns.tsx | 51 +++ .../ui-next/playground/studylist/index.ts | 5 + .../playground/studylist/study-list-table.tsx | 181 ++++++++ .../ui-next/playground/studylist/types.ts | 10 + 9 files changed, 376 insertions(+), 416 deletions(-) create mode 100644 platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx create mode 100644 platform/ui-next/playground/studylist/column-header.tsx create mode 100644 platform/ui-next/playground/studylist/columns.tsx create mode 100644 platform/ui-next/playground/studylist/index.ts create mode 100644 platform/ui-next/playground/studylist/study-list-table.tsx create mode 100644 platform/ui-next/playground/studylist/types.ts diff --git a/platform/ui-next/playground/panel-content.tsx b/platform/ui-next/playground/panel-content.tsx index 477d0979092..5c9519aedd3 100644 --- a/platform/ui-next/playground/panel-content.tsx +++ b/platform/ui-next/playground/panel-content.tsx @@ -5,16 +5,7 @@ import { Table, TableHeader, TableRow, TableHead } from '../src/components/Table import { Button } from '../src/components/Button'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; - -export type StudyRow = { - patient: string; - mrn: string; - studyDateTime: string; - modalities: string; - description: string; - accession: string; - instances: number; -}; +import type { StudyRow } from './studylist/types'; export function PanelContent({ study, diff --git a/platform/ui-next/playground/patient-table-prototype.tsx b/platform/ui-next/playground/patient-table-prototype.tsx index 071e455657e..c0de8b186a5 100644 --- a/platform/ui-next/playground/patient-table-prototype.tsx +++ b/platform/ui-next/playground/patient-table-prototype.tsx @@ -20,7 +20,8 @@ const App = () => (

Study List

- +
+
Patient @@ -45,7 +46,8 @@ const App = () => ( ))} -
+ +
diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index a5bb48be8f5..e46901b2c72 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -3,251 +3,12 @@ import { createRoot } from 'react-dom/client'; import { ThemeWrapper } from '../src/components/ThemeWrapper'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../src/components/Resizable'; import { ScrollArea } from '../src/components/ScrollArea'; -import { Button } from '../src/components/Button'; -import { - Table, - TableHeader, - TableBody, - TableHead, - TableRow, - TableCell, -} from '../src/components/Table'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../src/components/DropdownMenu'; -import { Input } from '../src/components/Input'; -import type { - ColumnDef, - SortingState, - VisibilityState, - RowSelectionState, - Column, - ColumnFiltersState, -} from '@tanstack/react-table'; -import { - flexRender, - getCoreRowModel, - getSortedRowModel, - getFilteredRowModel, - useReactTable, -} from '@tanstack/react-table'; import data from './patient-studies.json'; import { PanelDefault } from './panel-default'; -import { PanelContent, type StudyRow } from './panel-content'; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - getRowId?: (originalRow: TData, index: number) => string; - initialSorting?: SortingState; - initialColumnVisibility?: VisibilityState; - singleRowSelection?: boolean; - showColumnVisibilityControls?: boolean; - tableClassName?: string; - onRowSelectionChange?: (selectedRows: TData[], rowSelection: RowSelectionState) => void; - title?: React.ReactNode; -} - -function DataTable({ - columns, - data, - getRowId, - initialSorting = [], - initialColumnVisibility = {}, - singleRowSelection = true, - showColumnVisibilityControls = true, - tableClassName, - onRowSelectionChange, - title, -}: DataTableProps) { - const [sorting, setSorting] = React.useState(initialSorting); - const [columnVisibility, setColumnVisibility] = - React.useState(initialColumnVisibility); - const [rowSelection, setRowSelection] = React.useState({}); - const [columnFilters, setColumnFilters] = React.useState([]); - - const table = useReactTable({ - data, - columns, - state: { sorting, columnVisibility, rowSelection, columnFilters }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setRowSelection, - onColumnFiltersChange: setColumnFilters, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - enableRowSelection: true, - enableMultiRowSelection: singleRowSelection ? false : true, - getRowId, - }); - - React.useEffect(() => { - if (!onRowSelectionChange) return; - const selected = table.getSelectedRowModel().rows.map(r => r.original as TData); - onRowSelectionChange(selected, rowSelection); - }, [rowSelection, table, onRowSelectionChange]); - - return ( -
- {(showColumnVisibilityControls || title) && ( -
-
{title}
- {showColumnVisibilityControls && ( -
- - - - - - {table - .getAllColumns() - .filter(col => col.getCanHide()) - .map(column => ( - column.toggleVisibility(!!v)} - className="capitalize" - > - {column.id} - - ))} - - -
- )} -
- )} -
- -
- - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - - {table.getVisibleLeafColumns().map(col => ( - - {col.id === 'instances' ? ( - - ) : ( - table.getColumn(col.id)?.setFilterValue(e.target.value)} - className="h-7 w-full" - /> - )} - - ))} - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map(row => ( - row.toggleSelected()} - aria-selected={row.getIsSelected()} - className="group cursor-pointer" - > - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
-
-
-
- ); -} - -interface DataTableColumnHeaderProps { - column: Column; - title: string; - align?: 'left' | 'center' | 'right'; -} - -function DataTableColumnHeader({ - column, - title, - align = 'left', -}: DataTableColumnHeaderProps) { - const sorted = column.getIsSorted() as false | 'asc' | 'desc'; - const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕'; - - const justifyClass = - align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; - - return ( -
- {title} - -
- ); -} +import { PanelContent } from './panel-content'; +import { StudyListTable } from './studylist/study-list-table'; +import { studyListColumns } from './studylist/columns'; +import type { StudyRow } from './studylist/types'; const App = () => { const [layout, setLayout] = React.useState<'right' | 'bottom'>('bottom'); @@ -264,16 +25,18 @@ const App = () => {
- - columns={columns} - data={data as StudyRow[]} - getRowId={row => row.accession} - singleRowSelection={true} - showColumnVisibilityControls={true} - title="Study List" - tableClassName="min-w-[1000px]" - onRowSelectionChange={rows => setSelected(rows[0] ?? null)} - /> + + row.accession} + enforceSingleSelection={true} + showColumnVisibility={true} + title="Study List" + tableClassName="min-w-[1000px]" + onSelectionChange={(rows) => setSelected(rows[0] ?? null)} + /> +
@@ -341,154 +104,4 @@ function SidePanel({ ); } -const columns: ColumnDef[] = [ - { - accessorKey: 'patient', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('patient')}
, - }, - { - accessorKey: 'mrn', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('mrn')}
, - }, - { - accessorKey: 'studyDateTime', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('studyDateTime')}
, - sortingFn: (a, b, colId) => - new Date(a.getValue(colId) as string).getTime() - - new Date(b.getValue(colId) as string).getTime(), - }, - { - accessorKey: 'modalities', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('modalities')}
, - }, - { - accessorKey: 'description', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('description')}
, - }, - { - accessorKey: 'accession', - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue('accession')}
, - }, - { - accessorKey: 'instances', - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const value = row.getValue('instances') as number; - const isActive = row.getIsSelected(); - return ( -
-
- {value} -
-
e.stopPropagation()} - onMouseDown={e => { - e.stopPropagation(); - if (!row.getIsSelected()) { - row.toggleSelected(true); - } - }} - onPointerDown={e => { - e.stopPropagation(); - if (!row.getIsSelected()) { - row.toggleSelected(true); - } - }} - > - - - - - e.stopPropagation()} - onMouseDown={e => e.stopPropagation()} - onPointerDown={e => e.stopPropagation()} - > - e.preventDefault()}>Basic Viewer - e.preventDefault()}>Segmentation - US Pleura B-line Annotations - Total Metabolic Tumor Volume - Microscopy - Preclinical 4D - - -
-
- ); - }, - sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), - }, -]; +// Columns are now defined in ./studylist/columns diff --git a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx new file mode 100644 index 00000000000..6966892948c --- /dev/null +++ b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx @@ -0,0 +1,73 @@ +import * as React from 'react' +import type { Row } from '@tanstack/react-table' +import { Button } from '../../../src/components/Button' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '../../../src/components/DropdownMenu' + +export function LaunchMenuCell({ row, value }: { row: Row; value: number }) { + const isActive = row.getIsSelected() + return ( +
+
+ {value} +
+
e.stopPropagation()} + onMouseDown={(e) => { + e.stopPropagation() + if (!row.getIsSelected()) row.toggleSelected(true) + }} + onPointerDown={(e) => { + e.stopPropagation() + if (!row.getIsSelected()) row.toggleSelected(true) + }} + > + + + + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + e.preventDefault()}>Basic Viewer + e.preventDefault()}>Segmentation + US Pleura B-line Annotations + Total Metabolic Tumor Volume + Microscopy + Preclinical 4D + + +
+
+ ) +} + diff --git a/platform/ui-next/playground/studylist/column-header.tsx b/platform/ui-next/playground/studylist/column-header.tsx new file mode 100644 index 00000000000..f72949ee80b --- /dev/null +++ b/platform/ui-next/playground/studylist/column-header.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import { Button } from '../../src/components/Button' +import type { Column } from '@tanstack/react-table' + +export function ColumnHeader({ + column, + title, + align = 'left', +}: { + column: Column + title: string + align?: 'left' | 'center' | 'right' +}) { + const sorted = column.getIsSorted() as false | 'asc' | 'desc' + const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕' + const justifyClass = + align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start' + + return ( +
+ {title} + +
+ ) +} + diff --git a/platform/ui-next/playground/studylist/columns.tsx b/platform/ui-next/playground/studylist/columns.tsx new file mode 100644 index 00000000000..e604e2f07ca --- /dev/null +++ b/platform/ui-next/playground/studylist/columns.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import { ColumnHeader } from './column-header' +import { LaunchMenuCell } from './cells/launch-menu-cell' +import type { StudyRow } from './types' + +export const studyListColumns: ColumnDef[] = [ + { + accessorKey: 'patient', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('patient')}
, + }, + { + accessorKey: 'mrn', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('mrn')}
, + }, + { + accessorKey: 'studyDateTime', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('studyDateTime')}
, + sortingFn: (a, b, colId) => + new Date(a.getValue(colId) as string).getTime() - + new Date(b.getValue(colId) as string).getTime(), + }, + { + accessorKey: 'modalities', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('modalities')}
, + }, + { + accessorKey: 'description', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('description')}
, + }, + { + accessorKey: 'accession', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('accession')}
, + }, + { + accessorKey: 'instances', + header: ({ column }) => , + cell: ({ row }) => { + const value = row.getValue('instances') as number + return + }, + sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), + }, +] + diff --git a/platform/ui-next/playground/studylist/index.ts b/platform/ui-next/playground/studylist/index.ts new file mode 100644 index 00000000000..3019b55c5df --- /dev/null +++ b/platform/ui-next/playground/studylist/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './columns' +export * from './column-header' +export * from './study-list-table' + diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx new file mode 100644 index 00000000000..cf0f18a306b --- /dev/null +++ b/platform/ui-next/playground/studylist/study-list-table.tsx @@ -0,0 +1,181 @@ +import * as React from 'react' +import type { + ColumnDef, + SortingState, + VisibilityState, + RowSelectionState, + ColumnFiltersState, +} from '@tanstack/react-table' +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + useReactTable, +} from '@tanstack/react-table' +import { Button } from '../../src/components/Button' +import { Input } from '../../src/components/Input' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, +} from '../../src/components/DropdownMenu' +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from '../../src/components/Table' +import type { StudyRow } from './types' + +type Props = { + columns: ColumnDef[] + data: StudyRow[] + title?: React.ReactNode + getRowId?: (row: StudyRow, index: number) => string + initialSorting?: SortingState + initialVisibility?: VisibilityState + enforceSingleSelection?: boolean + showColumnVisibility?: boolean + tableClassName?: string + onSelectionChange?: (rows: StudyRow[]) => void +} + +export function StudyListTable({ + columns, + data, + title, + getRowId, + initialSorting = [], + initialVisibility = {}, + enforceSingleSelection = true, + showColumnVisibility = true, + tableClassName, + onSelectionChange, +}: Props) { + const [sorting, setSorting] = React.useState(initialSorting) + const [columnVisibility, setColumnVisibility] = React.useState(initialVisibility) + const [rowSelection, setRowSelection] = React.useState({}) + const [columnFilters, setColumnFilters] = React.useState([]) + + const table = useReactTable({ + data, + columns, + state: { sorting, columnVisibility, rowSelection, columnFilters }, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + enableRowSelection: true, + enableMultiRowSelection: !enforceSingleSelection, + getRowId, + }) + + React.useEffect(() => { + if (!onSelectionChange) return + const selected = table.getSelectedRowModel().rows.map((r) => r.original as StudyRow) + onSelectionChange(selected) + }, [rowSelection, onSelectionChange, table]) + + return ( +
+ {(showColumnVisibility || title) && ( +
+ {title ?
{title}
: null} + {showColumnVisibility && ( +
+ + + + + + {table + .getAllColumns() + .filter((c) => c.getCanHide()) + .map((column) => ( + column.toggleVisibility(!!v)} + className="capitalize" + > + {column.id} + + ))} + + +
+ )} +
+ )} +
+
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + + {table.getVisibleLeafColumns().map((col) => ( + + {col.id === 'instances' ? ( + + ) : ( + table.getColumn(col.id)?.setFilterValue(e.target.value)} + className="h-7 w-full" + /> + )} + + ))} + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="group cursor-pointer" + > + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ ) +} + diff --git a/platform/ui-next/playground/studylist/types.ts b/platform/ui-next/playground/studylist/types.ts new file mode 100644 index 00000000000..f026973ac79 --- /dev/null +++ b/platform/ui-next/playground/studylist/types.ts @@ -0,0 +1,10 @@ +export type StudyRow = { + patient: string + mrn: string + studyDateTime: string + modalities: string + description: string + accession: string + instances: number +} + From 7ef09d4334c54ab18fd0f007671947fd6f374f22 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Wed, 1 Oct 2025 17:53:38 -0400 Subject: [PATCH 023/172] Fixed ScrollArea in new prototype --- .../playground/studylist-panel-test.tsx | 22 +++++++++---------- .../playground/studylist/study-list-table.tsx | 8 +++---- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist-panel-test.tsx index e46901b2c72..73da9f3c9d8 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist-panel-test.tsx @@ -25,18 +25,16 @@ const App = () => {
- - row.accession} - enforceSingleSelection={true} - showColumnVisibility={true} - title="Study List" - tableClassName="min-w-[1000px]" - onSelectionChange={(rows) => setSelected(rows[0] ?? null)} - /> - + row.accession} + enforceSingleSelection={true} + showColumnVisibility={true} + title="Study List" + tableClassName="min-w-[1000px]" + onSelectionChange={(rows) => setSelected(rows[0] ?? null)} + />
diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx index cf0f18a306b..ed71087aab8 100644 --- a/platform/ui-next/playground/studylist/study-list-table.tsx +++ b/platform/ui-next/playground/studylist/study-list-table.tsx @@ -29,6 +29,7 @@ import { TableRow, TableCell, } from '../../src/components/Table' +import { ScrollArea } from '../../src/components/ScrollArea' import type { StudyRow } from './types' type Props = { @@ -116,8 +117,8 @@ export function StudyListTable({ )}
)} -
-
+
+ {table.getHeaderGroups().map((hg) => ( @@ -173,9 +174,8 @@ export function StudyListTable({ )}
-
+
) } - From 84b9441928b7f21b3666ea39cdb7ea7f27b178f2 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 2 Oct 2025 09:54:04 -0400 Subject: [PATCH 024/172] Created a more reusable DataTable component --- .../studylist/cells/launch-menu-cell.tsx | 61 ++++------------ .../ui-next/playground/studylist/columns.tsx | 17 +++-- .../ui-next/playground/studylist/index.ts | 2 - .../playground/studylist/study-list-table.tsx | 70 ++++++------------- .../ui-next/playground/studylist/types.ts | 11 +-- .../DataTable/ActionOverlayCell.tsx | 46 ++++++++++++ .../components/DataTable/ColumnHeader.tsx} | 9 ++- .../src/components/DataTable/FilterRow.tsx | 59 ++++++++++++++++ .../src/components/DataTable/ViewOptions.tsx | 47 +++++++++++++ .../ui-next/src/components/DataTable/index.ts | 5 ++ platform/ui-next/src/types/worklist.ts | 10 +++ 11 files changed, 216 insertions(+), 121 deletions(-) create mode 100644 platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx rename platform/ui-next/{playground/studylist/column-header.tsx => src/components/DataTable/ColumnHeader.tsx} (69%) create mode 100644 platform/ui-next/src/components/DataTable/FilterRow.tsx create mode 100644 platform/ui-next/src/components/DataTable/ViewOptions.tsx create mode 100644 platform/ui-next/src/components/DataTable/index.ts create mode 100644 platform/ui-next/src/types/worklist.ts diff --git a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx index 6966892948c..fbbe7b67251 100644 --- a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx +++ b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import type { Row } from '@tanstack/react-table' import { Button } from '../../../src/components/Button' +import { DataTableActionOverlayCell } from '../../../src/components/DataTable' import { DropdownMenu, DropdownMenuTrigger, @@ -9,55 +10,22 @@ import { } from '../../../src/components/DropdownMenu' export function LaunchMenuCell({ row, value }: { row: Row; value: number }) { - const isActive = row.getIsSelected() + const [open, setOpen] = React.useState(false) return ( -
-
- {value} -
-
e.stopPropagation()} - onMouseDown={(e) => { - e.stopPropagation() - if (!row.getIsSelected()) row.toggleSelected(true) - }} - onPointerDown={(e) => { - e.stopPropagation() - if (!row.getIsSelected()) row.toggleSelected(true) - }} - > - + {value}
} + onActivate={() => { + if (!row.getIsSelected()) row.toggleSelected(true) + }} + overlay={ + - - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - > + e.stopPropagation()}> e.preventDefault()}>Basic Viewer e.preventDefault()}>Segmentation US Pleura B-line Annotations @@ -66,8 +34,7 @@ export function LaunchMenuCell({ row, value }: { row: Row; value: Preclinical 4D -
-
+ } + /> ) } - diff --git a/platform/ui-next/playground/studylist/columns.tsx b/platform/ui-next/playground/studylist/columns.tsx index e604e2f07ca..3314fc09ad8 100644 --- a/platform/ui-next/playground/studylist/columns.tsx +++ b/platform/ui-next/playground/studylist/columns.tsx @@ -1,23 +1,23 @@ import * as React from 'react' import type { ColumnDef } from '@tanstack/react-table' -import { ColumnHeader } from './column-header' +import { DataTableColumnHeader } from '../../src/components/DataTable' import { LaunchMenuCell } from './cells/launch-menu-cell' import type { StudyRow } from './types' export const studyListColumns: ColumnDef[] = [ { accessorKey: 'patient', - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('patient')}
, }, { accessorKey: 'mrn', - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('mrn')}
, }, { accessorKey: 'studyDateTime', - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('studyDateTime')}
, sortingFn: (a, b, colId) => new Date(a.getValue(colId) as string).getTime() - @@ -25,22 +25,22 @@ export const studyListColumns: ColumnDef[] = [ }, { accessorKey: 'modalities', - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('modalities')}
, }, { accessorKey: 'description', - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('description')}
, }, { accessorKey: 'accession', - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('accession')}
, }, { accessorKey: 'instances', - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const value = row.getValue('instances') as number return @@ -48,4 +48,3 @@ export const studyListColumns: ColumnDef[] = [ sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), }, ] - diff --git a/platform/ui-next/playground/studylist/index.ts b/platform/ui-next/playground/studylist/index.ts index 3019b55c5df..eda8a3363f2 100644 --- a/platform/ui-next/playground/studylist/index.ts +++ b/platform/ui-next/playground/studylist/index.ts @@ -1,5 +1,3 @@ export * from './types' export * from './columns' -export * from './column-header' export * from './study-list-table' - diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx index ed71087aab8..c723b49dc9c 100644 --- a/platform/ui-next/playground/studylist/study-list-table.tsx +++ b/platform/ui-next/playground/studylist/study-list-table.tsx @@ -13,14 +13,7 @@ import { getFilteredRowModel, useReactTable, } from '@tanstack/react-table' -import { Button } from '../../src/components/Button' -import { Input } from '../../src/components/Input' -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuCheckboxItem, -} from '../../src/components/DropdownMenu' +import { DataTableFilterRow, DataTableViewOptions } from '../../src/components/DataTable' import { Table, TableHeader, @@ -91,28 +84,13 @@ export function StudyListTable({ {title ?
{title}
: null} {showColumnVisibility && (
- - - - - - {table - .getAllColumns() - .filter((c) => c.getCanHide()) - .map((column) => ( - column.toggleVisibility(!!v)} - className="capitalize" - > - {column.id} - - ))} - - + { + const label = (table.getColumn(id)?.columnDef.meta as { label?: string } | undefined)?.label + return label ?? id + }} + />
)}
@@ -124,7 +102,14 @@ export function StudyListTable({ {table.getHeaderGroups().map((hg) => ( {hg.headers.map((header) => ( - + { + const s = header.column.getIsSorted() as false | 'asc' | 'desc' + return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none' + })()} + > {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} @@ -134,23 +119,12 @@ export function StudyListTable({ ))} - - {table.getVisibleLeafColumns().map((col) => ( - - {col.id === 'instances' ? ( - - ) : ( - table.getColumn(col.id)?.setFilterValue(e.target.value)} - className="h-7 w-full" - /> - )} - - ))} - + setColumnFilters([])} + excludeColumnIds={[]} + /> {table.getRowModel().rows.length ? ( table.getRowModel().rows.map((row) => ( void + alignRight?: boolean +} + +export function DataTableActionOverlayCell({ + isActive, + value, + overlay, + onActivate, + alignRight = true, +}: Props) { + return ( +
+
+ {value} +
+
e.stopPropagation()} + onMouseDown={(e) => { + e.stopPropagation() + onActivate?.() + }} + onPointerDown={(e) => { + e.stopPropagation() + onActivate?.() + }} + > + {overlay} +
+
+ ) +} + diff --git a/platform/ui-next/playground/studylist/column-header.tsx b/platform/ui-next/src/components/DataTable/ColumnHeader.tsx similarity index 69% rename from platform/ui-next/playground/studylist/column-header.tsx rename to platform/ui-next/src/components/DataTable/ColumnHeader.tsx index f72949ee80b..4faab849d9e 100644 --- a/platform/ui-next/playground/studylist/column-header.tsx +++ b/platform/ui-next/src/components/DataTable/ColumnHeader.tsx @@ -1,8 +1,8 @@ import * as React from 'react' -import { Button } from '../../src/components/Button' import type { Column } from '@tanstack/react-table' +import { Button } from '../Button' -export function ColumnHeader({ +export function DataTableColumnHeader({ column, title, align = 'left', @@ -13,11 +13,10 @@ export function ColumnHeader({ }) { const sorted = column.getIsSorted() as false | 'asc' | 'desc' const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕' - const justifyClass = - align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start' + const justify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start' return ( -
+
{title} + + ) + } + + if (excludeColumnIds.includes(id)) { + return + } + + return ( + + {renderCell ? ( + renderCell({ columnId: id, value, setValue }) + ) : ( + setValue(e.target.value)} className={inputClassName} /> + )} + + ) + })} + + ) +} + 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..b1f83fd5ccf --- /dev/null +++ b/platform/ui-next/src/components/DataTable/ViewOptions.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import type { Table } from '@tanstack/react-table' +import { Button } from '../Button' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, +} from '../DropdownMenu' + +type Props = { + table: Table + getLabel?: (columnId: string) => string + canHide?: (columnId: string) => boolean + buttonText?: string +} + +export function DataTableViewOptions({ + table, + getLabel = (id) => id, + canHide = () => true, + buttonText = 'Columns', +}: Props) { + const columns = table.getAllColumns().filter((c) => c.getCanHide() && canHide(c.id)) + return ( + + + + + + {columns.map((column) => ( + column.toggleVisibility(!!v)} + className="capitalize" + > + {getLabel(column.id)} + + ))} + + + ) +} + 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..35cba417696 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/index.ts @@ -0,0 +1,5 @@ +export { DataTableColumnHeader } from './ColumnHeader' +export { DataTableViewOptions } from './ViewOptions' +export { DataTableFilterRow } from './FilterRow' +export { DataTableActionOverlayCell } from './ActionOverlayCell' + diff --git a/platform/ui-next/src/types/worklist.ts b/platform/ui-next/src/types/worklist.ts new file mode 100644 index 00000000000..f026973ac79 --- /dev/null +++ b/platform/ui-next/src/types/worklist.ts @@ -0,0 +1,10 @@ +export type StudyRow = { + patient: string + mrn: string + studyDateTime: string + modalities: string + description: string + accession: string + instances: number +} + From 8bfe788414a08f1c078fafaffe0465cd11196662 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 2 Oct 2025 10:24:59 -0400 Subject: [PATCH 025/172] Moving all related prototype files --- .../playground/patient-table-prototype.tsx | 2 +- platform/ui-next/playground/studylist.tsx | 2 + .../app.tsx} | 74 ++++++------------- .../ui-next/playground/studylist/entry.tsx | 8 ++ .../{ => studylist/panels}/panel-content.tsx | 38 +++++----- .../{ => studylist/panels}/panel-default.tsx | 19 ++--- .../{ => studylist}/patient-studies.json | 0 .../ui-next/playground/studylist/types.ts | 10 ++- platform/ui-next/src/types/worklist.ts | 10 --- 9 files changed, 68 insertions(+), 95 deletions(-) create mode 100644 platform/ui-next/playground/studylist.tsx rename platform/ui-next/playground/{studylist-panel-test.tsx => studylist/app.tsx} (51%) create mode 100644 platform/ui-next/playground/studylist/entry.tsx rename platform/ui-next/playground/{ => studylist/panels}/panel-content.tsx (66%) rename platform/ui-next/playground/{ => studylist/panels}/panel-default.tsx (64%) rename platform/ui-next/playground/{ => studylist}/patient-studies.json (100%) delete mode 100644 platform/ui-next/src/types/worklist.ts diff --git a/platform/ui-next/playground/patient-table-prototype.tsx b/platform/ui-next/playground/patient-table-prototype.tsx index c0de8b186a5..cb746d80566 100644 --- a/platform/ui-next/playground/patient-table-prototype.tsx +++ b/platform/ui-next/playground/patient-table-prototype.tsx @@ -13,7 +13,7 @@ import { TableCaption, } from '../src/components/Table'; -import data from './patient-studies.json'; +import data from './studylist/patient-studies.json'; const App = () => (
diff --git a/platform/ui-next/playground/studylist.tsx b/platform/ui-next/playground/studylist.tsx new file mode 100644 index 00000000000..5b385e95212 --- /dev/null +++ b/platform/ui-next/playground/studylist.tsx @@ -0,0 +1,2 @@ +import './studylist/entry' + diff --git a/platform/ui-next/playground/studylist-panel-test.tsx b/platform/ui-next/playground/studylist/app.tsx similarity index 51% rename from platform/ui-next/playground/studylist-panel-test.tsx rename to platform/ui-next/playground/studylist/app.tsx index 73da9f3c9d8..ff5bd482d36 100644 --- a/platform/ui-next/playground/studylist-panel-test.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -1,26 +1,22 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { ThemeWrapper } from '../src/components/ThemeWrapper'; -import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../src/components/Resizable'; -import { ScrollArea } from '../src/components/ScrollArea'; -import data from './patient-studies.json'; -import { PanelDefault } from './panel-default'; -import { PanelContent } from './panel-content'; -import { StudyListTable } from './studylist/study-list-table'; -import { studyListColumns } from './studylist/columns'; -import type { StudyRow } from './studylist/types'; +import React from 'react' +import { ThemeWrapper } from '../../src/components/ThemeWrapper' +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../../src/components/Resizable' +import { ScrollArea } from '../../src/components/ScrollArea' +import data from './patient-studies.json' +import { StudyListTable } from './study-list-table' +import { studyListColumns } from './columns' +import type { StudyRow } from './types' +import { PanelDefault } from './panels/panel-default' +import { PanelContent } from './panels/panel-content' -const App = () => { - const [layout, setLayout] = React.useState<'right' | 'bottom'>('bottom'); - const [selected, setSelected] = React.useState(null); +export function App() { + const [layout, setLayout] = React.useState<'right' | 'bottom'>('bottom') + const [selected, setSelected] = React.useState(null) return (
- +
@@ -42,10 +38,7 @@ const App = () => { - + {
- ); -}; - -const container = document.getElementById('root'); - -if (!container) { - throw new Error('Root element not found'); + ) } -const root = createRoot(container); -root.render(); - function SidePanel({ layout, selected, onToggleLayout, }: { - layout: 'right' | 'bottom'; - selected: StudyRow | null; - onToggleLayout: () => void; + layout: 'right' | 'bottom' + selected: StudyRow | null + onToggleLayout: () => void }) { return (
-
+
{selected ? ( - + ) : ( - + )}
- ); + ) } -// Columns are now defined in ./studylist/columns diff --git a/platform/ui-next/playground/studylist/entry.tsx b/platform/ui-next/playground/studylist/entry.tsx new file mode 100644 index 00000000000..b431721e242 --- /dev/null +++ b/platform/ui-next/playground/studylist/entry.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './app' + +const container = document.getElementById('root') +if (!container) throw new Error('Root element not found') +createRoot(container).render() + diff --git a/platform/ui-next/playground/panel-content.tsx b/platform/ui-next/playground/studylist/panels/panel-content.tsx similarity index 66% rename from platform/ui-next/playground/panel-content.tsx rename to platform/ui-next/playground/studylist/panels/panel-content.tsx index 5c9519aedd3..2cd545b191c 100644 --- a/platform/ui-next/playground/panel-content.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-content.tsx @@ -1,29 +1,28 @@ -import React from 'react'; -import { Thumbnail } from '../src/components/Thumbnail'; -import { TooltipProvider } from '../src/components/Tooltip'; -import { Table, TableHeader, TableRow, TableHead } from '../src/components/Table'; -import { Button } from '../src/components/Button'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import type { StudyRow } from './studylist/types'; +import React from 'react' +import { Thumbnail } from '../../../src/components/Thumbnail' +import { TooltipProvider } from '../../../src/components/Tooltip' +import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table' +import { Button } from '../../../src/components/Button' +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' +import type { StudyRow } from '../types' export function PanelContent({ study, layout, onToggleLayout, }: { - study: StudyRow; - layout: 'right' | 'bottom'; - onToggleLayout: () => void; + study: StudyRow + layout: 'right' | 'bottom' + onToggleLayout: () => void }) { - // Prototype eight series thumbnails; no image data provided on purpose. - const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); // 3–9 + const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []) const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ id: `preview-${study.accession}-${i}`, description: `Series ${i + 1}`, seriesNumber: i + 1, numInstances: 1, - })); + })) return ( @@ -35,11 +34,7 @@ export function PanelContent({
Studies -
@@ -48,7 +43,7 @@ export function PanelContent({
- {thumbnails.map(item => ( + {thumbnails.map((item) => ( - ); + ) } + diff --git a/platform/ui-next/playground/panel-default.tsx b/platform/ui-next/playground/studylist/panels/panel-default.tsx similarity index 64% rename from platform/ui-next/playground/panel-default.tsx rename to platform/ui-next/playground/studylist/panels/panel-default.tsx index 1a68b233166..182aea97994 100644 --- a/platform/ui-next/playground/panel-default.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-default.tsx @@ -1,13 +1,13 @@ -import React from 'react'; -import { Table, TableHeader, TableRow, TableHead } from '../src/components/Table'; -import { Button } from '../src/components/Button'; +import React from 'react' +import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table' +import { Button } from '../../../src/components/Button' export function PanelDefault({ layout, onToggleLayout, }: { - layout: 'right' | 'bottom'; - onToggleLayout: () => void; + layout: 'right' | 'bottom' + onToggleLayout: () => void }) { return (
@@ -17,11 +17,7 @@ export function PanelDefault({
Studies -
@@ -32,5 +28,6 @@ export function PanelDefault({
Select a study
- ); + ) } + diff --git a/platform/ui-next/playground/patient-studies.json b/platform/ui-next/playground/studylist/patient-studies.json similarity index 100% rename from platform/ui-next/playground/patient-studies.json rename to platform/ui-next/playground/studylist/patient-studies.json diff --git a/platform/ui-next/playground/studylist/types.ts b/platform/ui-next/playground/studylist/types.ts index f3d54f8fdcd..667eb0786f6 100644 --- a/platform/ui-next/playground/studylist/types.ts +++ b/platform/ui-next/playground/studylist/types.ts @@ -1 +1,9 @@ -export type { StudyRow } from '../../src/types/worklist' +export type StudyRow = { + patient: string + mrn: string + studyDateTime: string + modalities: string + description: string + accession: string + instances: number +} diff --git a/platform/ui-next/src/types/worklist.ts b/platform/ui-next/src/types/worklist.ts deleted file mode 100644 index f026973ac79..00000000000 --- a/platform/ui-next/src/types/worklist.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type StudyRow = { - patient: string - mrn: string - studyDateTime: string - modalities: string - description: string - accession: string - instances: number -} - From 965bdd0e477a6cdd0ecb1ffe2760630ad5daeecb Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 2 Oct 2025 10:45:48 -0400 Subject: [PATCH 026/172] DataTable component updates --- platform/ui-next/playground/index.tsx | 2 +- .../ui-next/playground/studylist/README.md | 40 +++++++++ .../ui-next/playground/studylist/columns.tsx | 14 ++- .../playground/studylist/study-list-table.tsx | 87 ++++++++----------- .../src/components/DataTable/ActionCell.tsx | 2 + .../src/components/DataTable/ColumnHeader.tsx | 16 +++- .../src/components/DataTable/DataTable.tsx | 85 ++++++++++++++++++ .../src/components/DataTable/FilterRow.tsx | 7 +- .../src/components/DataTable/Title.tsx | 6 ++ .../src/components/DataTable/Toolbar.tsx | 6 ++ .../src/components/DataTable/ViewOptions.tsx | 7 +- .../src/components/DataTable/context.tsx | 32 +++++++ .../ui-next/src/components/DataTable/index.ts | 6 +- 13 files changed, 243 insertions(+), 67 deletions(-) create mode 100644 platform/ui-next/playground/studylist/README.md create mode 100644 platform/ui-next/src/components/DataTable/ActionCell.tsx create mode 100644 platform/ui-next/src/components/DataTable/DataTable.tsx create mode 100644 platform/ui-next/src/components/DataTable/Title.tsx create mode 100644 platform/ui-next/src/components/DataTable/Toolbar.tsx create mode 100644 platform/ui-next/src/components/DataTable/context.tsx diff --git a/platform/ui-next/playground/index.tsx b/platform/ui-next/playground/index.tsx index e39a1feb9e5..121bb58ff77 100644 --- a/platform/ui-next/playground/index.tsx +++ b/platform/ui-next/playground/index.tsx @@ -6,7 +6,7 @@ const trimSlashes = (s: string) => s.replace(/^\/+|\/+$/g, ''); const route = trimSlashes(window.location.pathname); -const slug = route || 'patient-table-prototype'; +const slug = route || 'studylist'; // Attempt to import `.tsx` from this directory. // Webpack will create a context for `./*.tsx` due to the dynamic import below. diff --git a/platform/ui-next/playground/studylist/README.md b/platform/ui-next/playground/studylist/README.md new file mode 100644 index 00000000000..cb26367a9d5 --- /dev/null +++ b/platform/ui-next/playground/studylist/README.md @@ -0,0 +1,40 @@ +# Studylist Prototype + +This folder contains a self-contained prototype of the Study List UX, using the design system’s composable DataTable primitives and a compound components pattern. + +- Route: visit `/studylist` via the playground loader (default route). +- Purpose: demonstrate a domain-specific table (StudyListTable) that composes reusable building blocks from `src/components`. + +## What’s Reusable (design system) +- `src/components/DataTable/*`: + - `DataTable` (provider): owns TanStack table state via context. + - `DataTableToolbar`, `DataTableTitle`. + - `DataTableColumnHeader`. + - `DataTableViewOptions` (columns menu). + - `DataTableFilterRow`. + - `DataTableActionOverlayCell` (and `DataTableActionCell` alias). +- Other primitives (Table, Button, DropdownMenu, Input, etc.). + +## What’s Domain-Specific (prototype) +- `studylist/study-list-table.tsx`: composes the reusable pieces for the Study List. +- `studylist/columns.tsx`: column defs (sorting, labels, cells) for the Study domain. +- `studylist/cells/launch-menu-cell.tsx`: instance action menu using the overlay cell pattern. +- `studylist/panels/*`: side panel content and default view. +- `studylist/patient-studies.json`: mock data. +- `studylist/types.ts`: domain type `StudyRow`. + +## Compound Components Rationale +- The `DataTable` provider centralizes TanStack state (sorting, filters, visibility, selection) so sibling components don’t receive a `table` prop. +- Consumers use: + - Toolbar & Title for layout. + - ViewOptions to toggle column visibility. + - FilterRow for column filters and a reset slot. + - ColumnHeader to control sorting per column. +- The playground’s `StudyListTable` shows how to compose these while keeping domain logic local. + +## Run +- Start the UI Next dev server as usual, then open `/studylist`. + +## Notes +- Column labels are added via `meta.label` in `columns.tsx`, used by `DataTableViewOptions` for friendly names. +- The provider approach intentionally mirrors shadcn’s composable primitives style: no monolithic DataTable abstraction. diff --git a/platform/ui-next/playground/studylist/columns.tsx b/platform/ui-next/playground/studylist/columns.tsx index 3314fc09ad8..9d275333670 100644 --- a/platform/ui-next/playground/studylist/columns.tsx +++ b/platform/ui-next/playground/studylist/columns.tsx @@ -9,34 +9,41 @@ export const studyListColumns: ColumnDef[] = [ accessorKey: 'patient', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('patient')}
, + meta: { label: 'Patient' }, }, { accessorKey: 'mrn', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('mrn')}
, + meta: { label: 'MRN' }, }, { accessorKey: 'studyDateTime', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('studyDateTime')}
, - sortingFn: (a, b, colId) => - new Date(a.getValue(colId) as string).getTime() - - new Date(b.getValue(colId) as string).getTime(), + sortingFn: (a, b, colId) => { + const norm = (s: string) => new Date(s.replace(' ', 'T')).getTime() || 0 + return norm(a.getValue(colId) as string) - norm(b.getValue(colId) as string) + }, + meta: { label: 'Study Date' }, }, { accessorKey: 'modalities', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('modalities')}
, + meta: { label: 'Modalities' }, }, { accessorKey: 'description', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('description')}
, + meta: { label: 'Description' }, }, { accessorKey: 'accession', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('accession')}
, + meta: { label: 'Accession' }, }, { accessorKey: 'instances', @@ -46,5 +53,6 @@ export const studyListColumns: ColumnDef[] = [ return }, sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), + meta: { label: 'Instances' }, }, ] diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx index c723b49dc9c..eac52f41fd8 100644 --- a/platform/ui-next/playground/studylist/study-list-table.tsx +++ b/platform/ui-next/playground/studylist/study-list-table.tsx @@ -1,19 +1,14 @@ import * as React from 'react' -import type { - ColumnDef, - SortingState, - VisibilityState, - RowSelectionState, - ColumnFiltersState, -} from '@tanstack/react-table' +import type { ColumnDef, SortingState, VisibilityState } from '@tanstack/react-table' +import { flexRender } from '@tanstack/react-table' import { - flexRender, - getCoreRowModel, - getSortedRowModel, - getFilteredRowModel, - useReactTable, -} from '@tanstack/react-table' -import { DataTableFilterRow, DataTableViewOptions } from '../../src/components/DataTable' + DataTable, + DataTableToolbar, + DataTableTitle, + DataTableFilterRow, + DataTableViewOptions, + useDataTable, +} from '../../src/components/DataTable' import { Table, TableHeader, @@ -50,42 +45,39 @@ export function StudyListTable({ tableClassName, onSelectionChange, }: Props) { - const [sorting, setSorting] = React.useState(initialSorting) - const [columnVisibility, setColumnVisibility] = React.useState(initialVisibility) - const [rowSelection, setRowSelection] = React.useState({}) - const [columnFilters, setColumnFilters] = React.useState([]) - - const table = useReactTable({ - data, - columns, - state: { sorting, columnVisibility, rowSelection, columnFilters }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setRowSelection, - onColumnFiltersChange: setColumnFilters, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - enableRowSelection: true, - enableMultiRowSelection: !enforceSingleSelection, - getRowId, - }) - - React.useEffect(() => { - if (!onSelectionChange) return - const selected = table.getSelectedRowModel().rows.map((r) => r.original as StudyRow) - onSelectionChange(selected) - }, [rowSelection, onSelectionChange, table]) + return ( + + data={data} + columns={columns} + getRowId={getRowId} + initialSorting={initialSorting} + initialVisibility={initialVisibility} + enforceSingleSelection={enforceSingleSelection} + onSelectionChange={onSelectionChange} + > + + + ) +} +function Content({ + title, + showColumnVisibility, + tableClassName, +}: { + title?: React.ReactNode + showColumnVisibility?: boolean + tableClassName?: string +}) { + const { table, setColumnFilters } = useDataTable() return (
{(showColumnVisibility || title) && ( -
- {title ?
{title}
: null} + + {title ? {title} : null} {showColumnVisibility && (
{ const label = (table.getColumn(id)?.columnDef.meta as { label?: string } | undefined)?.label return label ?? id @@ -93,7 +85,7 @@ export function StudyListTable({ />
)} -
+ )}
@@ -119,12 +111,7 @@ export function StudyListTable({ ))} - setColumnFilters([])} - excludeColumnIds={[]} - /> + setColumnFilters([])} excludeColumnIds={[]} /> {table.getRowModel().rows.length ? ( table.getRowModel().rows.map((row) => ( ({ column, + columnId, title, align = 'left', }: { - column: Column + column?: Column + columnId?: string title: string align?: 'left' | 'center' | 'right' }) { - const sorted = column.getIsSorted() as false | 'asc' | 'desc' + const ctx = ReactNS.useContext(DataTableContext) + const resolvedColumn = column ?? (columnId && ctx ? (ctx.table.getColumn(columnId) as Column) : undefined) + if (!resolvedColumn) { + return {title} + } + const sorted = resolvedColumn.getIsSorted() as false | 'asc' | 'desc' const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕' const justify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start' @@ -21,7 +30,7 @@ export function DataTableColumnHeader({
) } - 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..7d4207b244e --- /dev/null +++ b/platform/ui-next/src/components/DataTable/DataTable.tsx @@ -0,0 +1,85 @@ +import * as React from 'react' +import type { + ColumnDef, + ColumnFiltersState, + RowSelectionState, + SortingState, + VisibilityState, +} from '@tanstack/react-table' +import { + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { DataTableContext } from './context' + +type Props = { + data: TData[] + columns: ColumnDef[] + getRowId?: (row: TData, index: number) => string + initialSorting?: SortingState + initialVisibility?: VisibilityState + initialFilters?: ColumnFiltersState + enforceSingleSelection?: boolean + onSelectionChange?: (rows: TData[]) => void + children: React.ReactNode +} + +export function DataTable({ + data, + columns, + getRowId, + initialSorting = [], + initialVisibility = {}, + initialFilters = [], + enforceSingleSelection = true, + onSelectionChange, + children, +}: Props) { + const [sorting, setSorting] = React.useState(initialSorting) + const [columnVisibility, setColumnVisibility] = React.useState(initialVisibility) + const [rowSelection, setRowSelection] = React.useState({}) + const [columnFilters, setColumnFilters] = React.useState(initialFilters) + + const table = useReactTable({ + data, + columns, + state: { sorting, columnVisibility, rowSelection, columnFilters }, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + enableRowSelection: true, + enableMultiRowSelection: !enforceSingleSelection, + getRowId, + }) + + React.useEffect(() => { + if (!onSelectionChange) return + const selected = table.getSelectedRowModel().rows.map((r) => r.original as TData) + onSelectionChange(selected) + }, [rowSelection, onSelectionChange, table]) + + const value = React.useMemo( + () => ({ + table, + sorting, + setSorting, + columnVisibility, + setColumnVisibility, + rowSelection, + setRowSelection, + columnFilters, + setColumnFilters, + resetFilters: () => setColumnFilters([]), + }), + [table, sorting, columnVisibility, rowSelection, columnFilters] + ) + + return {children} +} + diff --git a/platform/ui-next/src/components/DataTable/FilterRow.tsx b/platform/ui-next/src/components/DataTable/FilterRow.tsx index 48d66c88f31..54b5f03c5cd 100644 --- a/platform/ui-next/src/components/DataTable/FilterRow.tsx +++ b/platform/ui-next/src/components/DataTable/FilterRow.tsx @@ -1,11 +1,11 @@ import * as React from 'react' -import type { Table } from '@tanstack/react-table' import { TableRow, TableCell } from '../Table' import { Input } from '../Input' import { Button } from '../Button' +import { useDataTable } from './context' + type Props = { - table: Table excludeColumnIds?: string[] resetCellId?: string onReset?: () => void @@ -14,13 +14,13 @@ type Props = { } export function DataTableFilterRow({ - table, excludeColumnIds = [], resetCellId, onReset, renderCell, inputClassName = 'h-7 w-full', }: Props) { + const { table } = useDataTable() const cols = table.getVisibleLeafColumns() return ( @@ -56,4 +56,3 @@ export function DataTableFilterRow({ ) } - 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..b4c84b7a6c3 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/Title.tsx @@ -0,0 +1,6 @@ +import * as React from 'react' + +export function DataTableTitle({ 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..85cced8b91c --- /dev/null +++ b/platform/ui-next/src/components/DataTable/Toolbar.tsx @@ -0,0 +1,6 @@ +import * as React from 'react' + +export function DataTableToolbar({ 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 index b1f83fd5ccf..68865ab0759 100644 --- a/platform/ui-next/src/components/DataTable/ViewOptions.tsx +++ b/platform/ui-next/src/components/DataTable/ViewOptions.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import type { Table } from '@tanstack/react-table' import { Button } from '../Button' import { DropdownMenu, @@ -8,19 +7,20 @@ import { DropdownMenuCheckboxItem, } from '../DropdownMenu' +import { useDataTable } from './context' + type Props = { - table: Table getLabel?: (columnId: string) => string canHide?: (columnId: string) => boolean buttonText?: string } export function DataTableViewOptions({ - table, getLabel = (id) => id, canHide = () => true, buttonText = 'Columns', }: Props) { + const { table } = useDataTable() const columns = table.getAllColumns().filter((c) => c.getCanHide() && canHide(c.id)) return ( @@ -44,4 +44,3 @@ export function DataTableViewOptions({ ) } - 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..1e3089797aa --- /dev/null +++ b/platform/ui-next/src/components/DataTable/context.tsx @@ -0,0 +1,32 @@ +import * as React from 'react' +import type { + ColumnFiltersState, + RowSelectionState, + SortingState, + Table as RTable, + VisibilityState, +} from '@tanstack/react-table' + +export type DataTableContextValue = { + table: RTable + sorting: SortingState + setSorting: React.Dispatch> + columnVisibility: VisibilityState + setColumnVisibility: React.Dispatch> + rowSelection: RowSelectionState + setRowSelection: React.Dispatch> + columnFilters: ColumnFiltersState + setColumnFilters: React.Dispatch> + resetFilters: () => void +} + +const DataTableContext = React.createContext | null>(null) + +export function useDataTable() { + const ctx = React.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 index 35cba417696..8a1172efbb8 100644 --- a/platform/ui-next/src/components/DataTable/index.ts +++ b/platform/ui-next/src/components/DataTable/index.ts @@ -2,4 +2,8 @@ export { DataTableColumnHeader } from './ColumnHeader' export { DataTableViewOptions } from './ViewOptions' export { DataTableFilterRow } from './FilterRow' export { DataTableActionOverlayCell } from './ActionOverlayCell' - +export { DataTableActionCell } from './ActionCell' +export { DataTable } from './DataTable' +export { useDataTable } from './context' +export { DataTableToolbar } from './Toolbar' +export { DataTableTitle } from './Title' From d76f52e4b050eb0e2eb266280ab2a06ae4ec8684 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 2 Oct 2025 10:47:49 -0400 Subject: [PATCH 027/172] Updated prototype documentation --- .../ui-next/playground/studylist/README.md | 188 +++++++++++++++--- 1 file changed, 156 insertions(+), 32 deletions(-) diff --git a/platform/ui-next/playground/studylist/README.md b/platform/ui-next/playground/studylist/README.md index cb26367a9d5..bbe0f950b14 100644 --- a/platform/ui-next/playground/studylist/README.md +++ b/platform/ui-next/playground/studylist/README.md @@ -1,40 +1,164 @@ # Studylist Prototype -This folder contains a self-contained prototype of the Study List UX, using the design system’s composable DataTable primitives and a compound components pattern. +This folder contains a self-contained prototype of the Study List UX, built on top of composable DataTable primitives implemented as compound components in the design system. - Route: visit `/studylist` via the playground loader (default route). -- Purpose: demonstrate a domain-specific table (StudyListTable) that composes reusable building blocks from `src/components`. - -## What’s Reusable (design system) -- `src/components/DataTable/*`: - - `DataTable` (provider): owns TanStack table state via context. - - `DataTableToolbar`, `DataTableTitle`. - - `DataTableColumnHeader`. - - `DataTableViewOptions` (columns menu). - - `DataTableFilterRow`. - - `DataTableActionOverlayCell` (and `DataTableActionCell` alias). -- Other primitives (Table, Button, DropdownMenu, Input, etc.). - -## What’s Domain-Specific (prototype) -- `studylist/study-list-table.tsx`: composes the reusable pieces for the Study List. -- `studylist/columns.tsx`: column defs (sorting, labels, cells) for the Study domain. -- `studylist/cells/launch-menu-cell.tsx`: instance action menu using the overlay cell pattern. -- `studylist/panels/*`: side panel content and default view. -- `studylist/patient-studies.json`: mock data. -- `studylist/types.ts`: domain type `StudyRow`. - -## Compound Components Rationale -- The `DataTable` provider centralizes TanStack state (sorting, filters, visibility, selection) so sibling components don’t receive a `table` prop. -- Consumers use: - - Toolbar & Title for layout. - - ViewOptions to toggle column visibility. - - FilterRow for column filters and a reset slot. - - ColumnHeader to control sorting per column. -- The playground’s `StudyListTable` shows how to compose these while keeping domain logic local. +- Goal: demonstrate a domain-specific table (StudyListTable) that composes reusable building blocks from `src/components`. + +## DataTable – Files, Exports, and Responsibilities + +All reusable pieces live under `platform/ui-next/src/components/DataTable/` and are exported from `index.ts`. + +- Provider and hook + - `DataTable.tsx`: Context provider that owns TanStack state (sorting, filters, visibility, selection) and wires `useReactTable`. + - `context.tsx`: Defines `DataTableContext` and `useDataTable()` hook. +- Compound layout helpers + - `Toolbar.tsx`: Simple header container for title and controls. + - `Title.tsx`: Styled title, typically used inside the toolbar. +- Column header and menus + - `ColumnHeader.tsx`: Sortable header control. Accepts `column` from TanStack or a `columnId` when used within the provider. + - `ViewOptions.tsx`: “Columns” menu to toggle column visibility. Requires the provider; supports `getLabel`/`canHide`/`buttonText` props. +- Filters + - `FilterRow.tsx`: Renders a filter input cell for each visible column. Requires the provider. + - Props: `excludeColumnIds?: string[]`, `resetCellId?: string`, `onReset?: () => void`, `renderCell?`, `inputClassName?`. +- Overlay/action cell + - `ActionOverlayCell.tsx`: Encapsulates the “value with hover/selection overlay” pattern. + - `ActionCell.tsx`: Re-exports ActionOverlayCell as `DataTableActionCell` for naming alignment. +- Barrel exports + - `index.ts`: Exposes + - `DataTable`, `useDataTable`, `DataTableToolbar`, `DataTableTitle` + - `DataTableColumnHeader`, `DataTableViewOptions`, `DataTableFilterRow` + - `DataTableActionOverlayCell`, `DataTableActionCell` + +### Core APIs + +- `DataTable` provider + - Props: `data`, `columns`, `getRowId?`, `initialSorting?`, `initialVisibility?`, `enforceSingleSelection?`, `onSelectionChange?`. + - Context: `{ table, sorting, setSorting, columnVisibility, setColumnVisibility, rowSelection, setRowSelection, columnFilters, setColumnFilters, resetFilters }`. + +- `DataTableColumnHeader` + - Props: `title`, `align?`, plus either `column` (TanStack Column) or `columnId` when used inside the provider. + - Behavior: toggles sorting and shows ▲/▼/↕ indicator. + +- `DataTableViewOptions` + - Props: `getLabel? (id) => string`, `canHide? (id) => boolean`, `buttonText?`. + - Behavior: lists hideable columns and toggles visibility. + +- `DataTableFilterRow` + - Props: `excludeColumnIds?`, `resetCellId?`, `onReset?`, `renderCell?`, `inputClassName?`. + - Behavior: renders an `Input` for each visible column unless excluded; if a column id matches `resetCellId`, a Reset button is shown instead. + +- `DataTableActionOverlayCell` + - Props: `isActive`, `value`, `overlay`, `onActivate?`, `alignRight?`. + - Behavior: hides the value on hover/selection and reveals the overlay control; stops propagation and supports pre-activating selection. + +## Usage Patterns + +1) Wrap table area with the provider + +```tsx +import { DataTable, DataTableToolbar, DataTableTitle, DataTableFilterRow, DataTableViewOptions, useDataTable } from '../../src/components/DataTable' +import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '../../src/components/Table' + +export function MyDomainTable({ data, columns }) { + return ( + + + + ) +} + +function Content() { + const { table, setColumnFilters } = useDataTable() + return ( +
+ + My Table +
+ (table.getColumn(id)?.columnDef.meta as { label?: string } | undefined)?.label ?? id} /> +
+
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header) => ( + { + const s = header.column.getIsSorted() as false | 'asc' | 'desc' + return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none' + })()}> + {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} + + ))} + + ))} + + + setColumnFilters([])} /> + {table.getRowModel().rows.map((row) => ( + row.toggleSelected()} aria-selected={row.getIsSelected()} className="group cursor-pointer"> + {row.getVisibleCells().map((cell) => ( + {cell.column.columnDef.cell?.(cell.getContext())} + ))} + + ))} + +
+
+ ) +} +``` + +2) Define columns with `DataTableColumnHeader` + +```tsx +import type { ColumnDef } from '@tanstack/react-table' +import { DataTableColumnHeader } from '../../src/components/DataTable' + +export const columns: ColumnDef[] = [ + { + accessorKey: 'patient', + header: ({ column }) => , + meta: { label: 'Patient' }, + }, +] +``` + +3) Use the overlay action cell pattern for inline actions + +```tsx +import { DataTableActionOverlayCell } from '../../src/components/DataTable' + +function LaunchMenuCell({ row, value }: { row: any; value: number }) { + return ( + {value}
} + onActivate={() => { if (!row.getIsSelected()) row.toggleSelected(true) }} + overlay={} + /> + ) +} +``` + +## Reusable vs. Domain Split + +- Reusable (design system) + - DataTable provider + primitives in `src/components/DataTable/*`. + - UI primitives: `Table`, `Button`, `DropdownMenu`, `Input`, etc. +- Domain (prototype) + - `study-list-table.tsx`, `columns.tsx`, `cells/launch-menu-cell.tsx`, `panels/*`, `patient-studies.json`, `types.ts`. + +## Accessibility and Labels + +- Headers should set `aria-sort` based on `column.getIsSorted()`. +- Add `meta.label` to each column; `DataTableViewOptions` uses it to show friendly names. ## Run -- Start the UI Next dev server as usual, then open `/studylist`. + +- Start the UI Next dev server and navigate to `/studylist`. ## Notes -- Column labels are added via `meta.label` in `columns.tsx`, used by `DataTableViewOptions` for friendly names. -- The provider approach intentionally mirrors shadcn’s composable primitives style: no monolithic DataTable abstraction. + +- Components are context-only where appropriate (no `table` prop for `FilterRow` and `ViewOptions`). +- This follows shadcn’s composable primitives guidance — no monolithic DataTable abstraction. From d3b91242bbc2762250c3617ff97924fea613f06f Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 2 Oct 2025 11:21:57 -0400 Subject: [PATCH 028/172] Added temporary logo for prototype --- platform/ui-next/playground/studylist/app.tsx | 1 - .../playground/studylist/assets/ohif-logo.svg | 33 +++++++++++++++++++ .../playground/studylist/study-list-table.tsx | 4 +++ .../src/components/DataTable/ViewOptions.tsx | 2 +- 4 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 platform/ui-next/playground/studylist/assets/ohif-logo.svg diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index ff5bd482d36..7cd69c6b112 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -74,4 +74,3 @@ function SidePanel({
) } - diff --git a/platform/ui-next/playground/studylist/assets/ohif-logo.svg b/platform/ui-next/playground/studylist/assets/ohif-logo.svg new file mode 100644 index 00000000000..f55d4260671 --- /dev/null +++ b/platform/ui-next/playground/studylist/assets/ohif-logo.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx index eac52f41fd8..7f97d33f893 100644 --- a/platform/ui-next/playground/studylist/study-list-table.tsx +++ b/platform/ui-next/playground/studylist/study-list-table.tsx @@ -19,6 +19,7 @@ import { } from '../../src/components/Table' import { ScrollArea } from '../../src/components/ScrollArea' import type { StudyRow } from './types' +import ohifLogo from './assets/ohif-logo.svg' type Props = { columns: ColumnDef[] @@ -74,6 +75,9 @@ function Content({
{(showColumnVisibility || title) && ( +
+ OHIF Logo +
{title ? {title} : null} {showColumnVisibility && (
diff --git a/platform/ui-next/src/components/DataTable/ViewOptions.tsx b/platform/ui-next/src/components/DataTable/ViewOptions.tsx index 68865ab0759..7bc49a06ff2 100644 --- a/platform/ui-next/src/components/DataTable/ViewOptions.tsx +++ b/platform/ui-next/src/components/DataTable/ViewOptions.tsx @@ -18,7 +18,7 @@ type Props = { export function DataTableViewOptions({ getLabel = (id) => id, canHide = () => true, - buttonText = 'Columns', + buttonText = 'View', }: Props) { const { table } = useDataTable() const columns = table.getAllColumns().filter((c) => c.getCanHide() && canHide(c.id)) From d1b94805fd591286ef87ba0973c47cca1774fc5a Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 2 Oct 2025 11:43:51 -0400 Subject: [PATCH 029/172] Spacing fixes in panels --- .../studylist/panels/panel-content.tsx | 45 ++++++++++--------- .../studylist/panels/panel-default.tsx | 29 ++++++------ 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/platform/ui-next/playground/studylist/panels/panel-content.tsx b/platform/ui-next/playground/studylist/panels/panel-content.tsx index 2cd545b191c..1f5c17a4afb 100644 --- a/platform/ui-next/playground/studylist/panels/panel-content.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-content.tsx @@ -1,40 +1,44 @@ -import React from 'react' -import { Thumbnail } from '../../../src/components/Thumbnail' -import { TooltipProvider } from '../../../src/components/Tooltip' -import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table' -import { Button } from '../../../src/components/Button' -import { DndProvider } from 'react-dnd' -import { HTML5Backend } from 'react-dnd-html5-backend' -import type { StudyRow } from '../types' +import React from 'react'; +import { Thumbnail } from '../../../src/components/Thumbnail'; +import { TooltipProvider } from '../../../src/components/Tooltip'; +import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table'; +import { Button } from '../../../src/components/Button'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import type { StudyRow } from '../types'; export function PanelContent({ study, layout, onToggleLayout, }: { - study: StudyRow - layout: 'right' | 'bottom' - onToggleLayout: () => void + study: StudyRow; + layout: 'right' | 'bottom'; + onToggleLayout: () => void; }) { - const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []) + const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ id: `preview-${study.accession}-${i}`, description: `Series ${i + 1}`, seriesNumber: i + 1, numInstances: 1, - })) + })); return ( -
+
- - + +
- Studies -
@@ -43,7 +47,7 @@ export function PanelContent({
- {thumbnails.map((item) => ( + {thumbnails.map(item => ( - ) + ); } - diff --git a/platform/ui-next/playground/studylist/panels/panel-default.tsx b/platform/ui-next/playground/studylist/panels/panel-default.tsx index 182aea97994..e7f0ab1d8b7 100644 --- a/platform/ui-next/playground/studylist/panels/panel-default.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-default.tsx @@ -1,23 +1,27 @@ -import React from 'react' -import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table' -import { Button } from '../../../src/components/Button' +import React from 'react'; +import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table'; +import { Button } from '../../../src/components/Button'; export function PanelDefault({ layout, onToggleLayout, }: { - layout: 'right' | 'bottom' - onToggleLayout: () => void + layout: 'right' | 'bottom'; + onToggleLayout: () => void; }) { return ( -
+
- - + +
- Studies -
@@ -26,8 +30,7 @@ export function PanelDefault({
-
Select a study
+
Select a study to preview
- ) + ); } - From 4eceb1846916761210a798b160b61df75a3eb2ae Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 2 Oct 2025 13:07:10 -0400 Subject: [PATCH 030/172] Spacing fixes --- platform/ui-next/playground/studylist/panels/panel-default.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/ui-next/playground/studylist/panels/panel-default.tsx b/platform/ui-next/playground/studylist/panels/panel-default.tsx index e7f0ab1d8b7..d85926edaf3 100644 --- a/platform/ui-next/playground/studylist/panels/panel-default.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-default.tsx @@ -30,7 +30,7 @@ export function PanelDefault({ -
Select a study to preview
+
Select a study to preview
); } From e2c2c845dfcfb0d4c9970b329863e4460804238f Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Wed, 22 Oct 2025 17:00:24 -0400 Subject: [PATCH 031/172] Removed bottom panel --- platform/ui-next/playground/studylist/app.tsx | 32 ++++++++----------- .../studylist/panels/panel-content.tsx | 20 ++---------- .../studylist/panels/panel-default.tsx | 18 ++--------- 3 files changed, 18 insertions(+), 52 deletions(-) diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index 7cd69c6b112..c7db2de3e5e 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -10,14 +10,20 @@ import { PanelDefault } from './panels/panel-default' import { PanelContent } from './panels/panel-content' export function App() { - const [layout, setLayout] = React.useState<'right' | 'bottom'>('bottom') const [selected, setSelected] = React.useState(null) + const previewDefaultSize = React.useMemo(() => { + if (typeof window !== 'undefined' && window.innerWidth > 0) { + const percent = (300 / window.innerWidth) * 100 + return Math.min(Math.max(percent, 15), 50) + } + return 30 + }, []) return (
- - + +
@@ -38,11 +44,9 @@ export function App() { - + setLayout(layout === 'right' ? 'bottom' : 'right')} /> @@ -51,23 +55,15 @@ export function App() { ) } -function SidePanel({ - layout, - selected, - onToggleLayout, -}: { - layout: 'right' | 'bottom' - selected: StudyRow | null - onToggleLayout: () => void -}) { +function SidePanel({ selected }: { selected: StudyRow | null }) { return (
-
+
{selected ? ( - + ) : ( - + )}
diff --git a/platform/ui-next/playground/studylist/panels/panel-content.tsx b/platform/ui-next/playground/studylist/panels/panel-content.tsx index 1f5c17a4afb..a7204ae34cb 100644 --- a/platform/ui-next/playground/studylist/panels/panel-content.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-content.tsx @@ -2,20 +2,11 @@ import React from 'react'; import { Thumbnail } from '../../../src/components/Thumbnail'; import { TooltipProvider } from '../../../src/components/Tooltip'; import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table'; -import { Button } from '../../../src/components/Button'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import type { StudyRow } from '../types'; -export function PanelContent({ - study, - layout, - onToggleLayout, -}: { - study: StudyRow; - layout: 'right' | 'bottom'; - onToggleLayout: () => void; -}) { +export function PanelContent({ study }: { study: StudyRow }) { const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ id: `preview-${study.accession}-${i}`, @@ -32,15 +23,8 @@ export function PanelContent({ -
+
Studies -
diff --git a/platform/ui-next/playground/studylist/panels/panel-default.tsx b/platform/ui-next/playground/studylist/panels/panel-default.tsx index d85926edaf3..29b8cb9f4bd 100644 --- a/platform/ui-next/playground/studylist/panels/panel-default.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-default.tsx @@ -1,29 +1,15 @@ import React from 'react'; import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table'; -import { Button } from '../../../src/components/Button'; -export function PanelDefault({ - layout, - onToggleLayout, -}: { - layout: 'right' | 'bottom'; - onToggleLayout: () => void; -}) { +export function PanelDefault() { return (
-
+
Studies -
From c67e1a6c6489e18dd36db85933b97162a91cacf6 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 23 Oct 2025 10:00:22 -0400 Subject: [PATCH 032/172] Update package.json after rebase --- platform/ui-next/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index 8e0e4b816b6..985db6d4069 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -29,7 +29,7 @@ ".": "./src/index.ts" }, "dependencies": { - "@tanstack/react-table": "8.20.0", + "@tanstack/react-table": "^8.20.0", "@radix-ui/react-accordion": "1.2.11", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-context-menu": "2.2.15", From a156c8997b720aafedb7f81408cce3bf678ec7b2 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 24 Oct 2025 11:21:28 -0400 Subject: [PATCH 033/172] Netlify deployable --- .../ui-next/.webpack/webpack.playground.js | 21 +++++++++++++++++++ platform/ui-next/package.json | 4 +++- platform/ui-next/playground/public/_redirects | 1 + .../ui-next/playground/studylist/README.md | 7 +++++++ platform/ui-next/playground/studylist/app.tsx | 2 +- 5 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 platform/ui-next/playground/public/_redirects diff --git a/platform/ui-next/.webpack/webpack.playground.js b/platform/ui-next/.webpack/webpack.playground.js index ee179403ef2..a8d45489481 100644 --- a/platform/ui-next/.webpack/webpack.playground.js +++ b/platform/ui-next/.webpack/webpack.playground.js @@ -1,5 +1,7 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const webpackCommon = require('./../../../.webpack/webpack.base.js'); const PLAYGROUND_DIR = path.join(__dirname, '../playground'); @@ -17,12 +19,31 @@ module.exports = (env, argv) => { config.output.path = DIST_DIR; + const isProdBuild = process.env.NODE_ENV === 'production'; + config.plugins = [ ...(config.plugins || []), new HtmlWebpackPlugin({ template: path.join(__dirname, 'template.html'), title: 'UI Next Prototype', }), + new CopyWebpackPlugin({ + patterns: [ + { + from: path.join(PLAYGROUND_DIR, 'public'), + to: DIST_DIR, + noErrorOnMissing: true, + }, + ], + }), + ...(isProdBuild + ? [ + new MiniCssExtractPlugin({ + filename: 'app.[contenthash].css', + chunkFilename: '[name].[contenthash].css', + }), + ] + : []), ]; config.devServer = { diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index 985db6d4069..ac493097cd1 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -16,6 +16,7 @@ "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", + "build:playground": "cross-env NODE_ENV=production webpack --config .webpack/webpack.playground.js", "test": "echo \"Error: no test specified\" && exit 1", "test:unit": "jest --watchAll", "test:unit:ci": "jest --ci --runInBand --collectCoverage", @@ -67,7 +68,8 @@ "tailwindcss-animate": "1.0.7" }, "devDependencies": { - "@babel/plugin-transform-private-property-in-object": "7.27.1" + "@babel/plugin-transform-private-property-in-object": "7.27.1", + "copy-webpack-plugin": "10.2.4" }, "keywords": [], "author": "OHIF", diff --git a/platform/ui-next/playground/public/_redirects b/platform/ui-next/playground/public/_redirects new file mode 100644 index 00000000000..7797f7c6a73 --- /dev/null +++ b/platform/ui-next/playground/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/platform/ui-next/playground/studylist/README.md b/platform/ui-next/playground/studylist/README.md index bbe0f950b14..298a184e366 100644 --- a/platform/ui-next/playground/studylist/README.md +++ b/platform/ui-next/playground/studylist/README.md @@ -158,6 +158,13 @@ function LaunchMenuCell({ row, value }: { row: any; value: number }) { - Start the UI Next dev server and navigate to `/studylist`. +## Deploying to Netlify + +- Install workspace deps at repo root (`yarn install`) if you have not already. +- Build the static playground bundle with `yarn --cwd platform/ui-next build:playground`. This runs webpack in production mode against `.webpack/webpack.playground.js`, extracts CSS, and copies any files under `playground/public/` (including `_redirects`) into the output. +- Upload the contents of `platform/ui-next/dist/playground/` to Netlify (or point a Netlify site’s publish directory at that folder). The bundle contains `index.html`, hashed JS/CSS assets, and a `_redirects` file so routes such as `/studylist` stay functional on refresh. +- Optional preflight: run `npx serve platform/ui-next/dist/playground` locally to confirm the build before uploading. + ## Notes - Components are context-only where appropriate (no `table` prop for `FilterRow` and `ViewOptions`). diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index c7db2de3e5e..958bacec0b0 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -13,7 +13,7 @@ export function App() { const [selected, setSelected] = React.useState(null) const previewDefaultSize = React.useMemo(() => { if (typeof window !== 'undefined' && window.innerWidth > 0) { - const percent = (300 / window.innerWidth) * 100 + const percent = (315 / window.innerWidth) * 100 return Math.min(Math.max(percent, 15), 50) } return 30 From 000ec0902e564701bf373a267fc282b0c64ec9b9 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 24 Oct 2025 12:52:13 -0400 Subject: [PATCH 034/172] Initial Summary component --- .../studylist/assets/PatientStudyList.svg | 14 ++ .../ui-next/playground/studylist/index.ts | 1 + .../studylist/panels/panel-content.tsx | 23 ++-- .../studylist/panels/panel-default.tsx | 21 +-- .../studylist/panels/panel-summary.tsx | 123 ++++++++++++++++++ 5 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 platform/ui-next/playground/studylist/assets/PatientStudyList.svg create mode 100644 platform/ui-next/playground/studylist/panels/panel-summary.tsx diff --git a/platform/ui-next/playground/studylist/assets/PatientStudyList.svg b/platform/ui-next/playground/studylist/assets/PatientStudyList.svg new file mode 100644 index 00000000000..533cbb9e722 --- /dev/null +++ b/platform/ui-next/playground/studylist/assets/PatientStudyList.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/platform/ui-next/playground/studylist/index.ts b/platform/ui-next/playground/studylist/index.ts index eda8a3363f2..6c3f0890ad3 100644 --- a/platform/ui-next/playground/studylist/index.ts +++ b/platform/ui-next/playground/studylist/index.ts @@ -1,3 +1,4 @@ export * from './types' export * from './columns' export * from './study-list-table' +export { PanelSummary, Summary } from './panels/panel-summary' diff --git a/platform/ui-next/playground/studylist/panels/panel-content.tsx b/platform/ui-next/playground/studylist/panels/panel-content.tsx index a7204ae34cb..2d10a839247 100644 --- a/platform/ui-next/playground/studylist/panels/panel-content.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-content.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { Thumbnail } from '../../../src/components/Thumbnail'; -import { TooltipProvider } from '../../../src/components/Tooltip'; -import { Table, TableHeader, TableRow, TableHead } from '../../../src/components/Table'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; +import { Thumbnail } from '../../../src/components/Thumbnail'; +import { TooltipProvider } from '../../../src/components/Tooltip'; import type { StudyRow } from '../types'; +import { Summary } from './panel-summary'; export function PanelContent({ study }: { study: StudyRow }) { const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); @@ -18,18 +18,11 @@ export function PanelContent({ study }: { study: StudyRow }) { return ( -
-
- - - -
- Studies -
-
-
-
-
+
+ + + +
{thumbnails.map(item => ( - - - - -
- Studies -
-
-
-
-
- -
Select a study to preview
-
+ + + + ); } diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx new file mode 100644 index 00000000000..8cee0a18950 --- /dev/null +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import type { StudyRow } from '../types'; +import { cn } from '../../../src/lib/utils'; +import patientSummaryIcon from '../assets/PatientStudyList.svg'; +import { Icons } from '../../../src/components/Icons/Icons'; + +type SummaryContextValue = { + study: StudyRow | null; +}; + +const SummaryContext = React.createContext(null); + +function useSummaryContext() { + const context = React.useContext(SummaryContext); + if (!context) { + throw new Error('Summary.* components must be used within '); + } + return context; +} + +type SummaryProps = { + study?: StudyRow | null; + children: React.ReactNode; + className?: string; +}; + +function SummaryRoot({ study = null, children, className }: SummaryProps) { + return ( + +
{children}
+
+ ); +} + +type SummaryPatientProps = { + placeholder?: string; + className?: string; +}; + +function SummaryPatient({ placeholder = 'Select a study', className }: SummaryPatientProps) { + const { study } = useSummaryContext(); + const hasSelection = Boolean(study); + + return ( +
+ +
+ {hasSelection && study ? ( + <> + + {study.patient} + + {study.mrn} + + ) : ( + + {placeholder} + + )} +
+
+ ); +} + +type SummaryWorkflowsProps = { + label?: React.ReactNode; + onClick?: () => void; + className?: string; + disabled?: boolean; +}; + +function SummaryWorkflows({ + label = 'Launch workflow', + onClick, + className, + disabled, +}: SummaryWorkflowsProps) { + const { study } = useSummaryContext(); + const hasSelection = Boolean(study); + const isDisabled = disabled ?? !hasSelection; + + return ( + + ); +} + +export const Summary = Object.assign(SummaryRoot, { + Patient: SummaryPatient, + Workflows: SummaryWorkflows, +}); + +export const PanelSummary = Summary; + From 9cc1c742bf2c6b4b260a6f8678c99f6d0d5e90a3 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 24 Oct 2025 13:10:47 -0400 Subject: [PATCH 035/172] Updated component --- .../ui-next/playground/studylist/README.md | 7 + .../studylist/panels/panel-content.tsx | 4 +- .../studylist/panels/panel-default.tsx | 4 +- .../studylist/panels/panel-summary.tsx | 361 +++++++++++++++--- 4 files changed, 313 insertions(+), 63 deletions(-) diff --git a/platform/ui-next/playground/studylist/README.md b/platform/ui-next/playground/studylist/README.md index 298a184e366..ce15e1d8d3f 100644 --- a/platform/ui-next/playground/studylist/README.md +++ b/platform/ui-next/playground/studylist/README.md @@ -149,6 +149,13 @@ function LaunchMenuCell({ row, value }: { row: any; value: number }) { - Domain (prototype) - `study-list-table.tsx`, `columns.tsx`, `cells/launch-menu-cell.tsx`, `panels/*`, `patient-studies.json`, `types.ts`. +### Panel Summary Compound API + +- `panels/panel-summary.tsx` exports a flexible `Summary` namespace for the right-hand preview header. + - Compose it as `` or mix slot primitives like ``, ``, ``, ``, ``, and ``. + - Provide `get` adapters on the root to map custom data shapes (`name`/`mrn` formatters), omit slots to hide fields, and pass custom `icon`, `label`, or `onClick` handlers for launch actions. + - Legacy helpers `Summary.Patient` and `Summary.Workflows` remain available as thin wrappers around the new slots for quick defaults. + ## Accessibility and Labels - Headers should set `aria-sort` based on `column.getIsSorted()`. diff --git a/platform/ui-next/playground/studylist/panels/panel-content.tsx b/platform/ui-next/playground/studylist/panels/panel-content.tsx index 2d10a839247..d22a5ead37b 100644 --- a/platform/ui-next/playground/studylist/panels/panel-content.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-content.tsx @@ -19,10 +19,10 @@ export function PanelContent({ study }: { study: StudyRow }) {
- + - +
{thumbnails.map(item => ( + -
+ ); } diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index 8cee0a18950..6a54945d7b1 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -4,120 +4,363 @@ import { cn } from '../../../src/lib/utils'; import patientSummaryIcon from '../assets/PatientStudyList.svg'; import { Icons } from '../../../src/components/Icons/Icons'; -type SummaryContextValue = { - study: StudyRow | null; +export type SummaryGetters = { + name?: (data: T) => React.ReactNode; + mrn?: (data: T) => React.ReactNode; }; -const SummaryContext = React.createContext(null); +type SummaryResolvedGetters = { + name: (data: T) => React.ReactNode; + mrn: (data: T) => React.ReactNode; +}; + +type SummaryContextValue = { + data: T | null; + get: SummaryResolvedGetters; +}; + +const SummaryContext = React.createContext | null>(null); -function useSummaryContext() { +function useSummaryContext() { const context = React.useContext(SummaryContext); if (!context) { - throw new Error('Summary.* components must be used within '); + throw new Error('Summary.* components must be used within '); } - return context; + return context as SummaryContextValue; } -type SummaryProps = { - study?: StudyRow | null; - children: React.ReactNode; +type SummaryRootProps = { + data?: T | null; + /** @deprecated use `data` instead */ + study?: T | null; + get?: SummaryGetters; className?: string; + children: React.ReactNode; }; -function SummaryRoot({ study = null, children, className }: SummaryProps) { +function SummaryRoot({ + data: dataProp, + study, + get, + className, + children, +}: SummaryRootProps) { + const data = dataProp ?? study ?? null; + + const resolvedGetters = React.useMemo>( + () => ({ + name: + get?.name ?? + ((item: T) => ((item as unknown as StudyRow | undefined)?.patient ?? '') as React.ReactNode), + mrn: + get?.mrn ?? + ((item: T) => ((item as unknown as StudyRow | undefined)?.mrn ?? '') as React.ReactNode), + }), + [get] + ); + return ( - +
{children}
); } -type SummaryPatientProps = { - placeholder?: string; +type SummarySectionProps = { + variant?: 'card' | 'row'; className?: string; + children?: React.ReactNode; }; -function SummaryPatient({ placeholder = 'Select a study', className }: SummaryPatientProps) { - const { study } = useSummaryContext(); - const hasSelection = Boolean(study); +function SummarySection({ variant = 'card', className, children }: SummarySectionProps) { + const base = + variant === 'card' + ? 'border-border/50 bg-muted/40 rounded-lg border px-4 py-3' + : 'border-border/50 rounded-lg border px-4 py-3'; + return
{children}
; +} - return ( -
- -
- {hasSelection && study ? ( - <> - - {study.patient} - - {study.mrn} - - ) : ( - - {placeholder} - - )} + style={{ width: size, height: size }} + > + {children}
-
+ ); + } + + if (!src) { + return null; + } + + return ( + {alt} ); } -type SummaryWorkflowsProps = { - label?: React.ReactNode; - onClick?: () => void; +type SummaryNameProps = { + placeholder?: React.ReactNode; + className?: string; + children?: (value: React.ReactNode, data: T | null) => React.ReactNode; +}; + +function SummaryName({ + placeholder = 'Select a study', + className, + children, +}: SummaryNameProps) { + const { data, get } = useSummaryContext(); + const value = data ? get.name(data) : null; + const content = value ?? placeholder; + + return ( + + {typeof children === 'function' ? children(content, data) : content} + + ); +} + +type SummaryMRNProps = { + hideWhenEmpty?: boolean; + prefix?: React.ReactNode; className?: string; + children?: (value: React.ReactNode, data: T | null) => React.ReactNode; +}; + +function SummaryMRN({ + hideWhenEmpty = true, + prefix, + className, + children, +}: SummaryMRNProps) { + const { data, get } = useSummaryContext(); + const value = data ? get.mrn(data) : null; + + if ((value === null || value === undefined || value === '') && hideWhenEmpty) { + return null; + } + + const baseContent = ( + <> + {prefix} + {value} + + ); + + return ( + + {typeof children === 'function' ? children(value, data) : baseContent} + + ); +} + +function SummaryMeta({ className, children }: { className?: string; children?: React.ReactNode }) { + if (!children) { + return null; + } + return ( + + {children} + + ); +} + +function SummaryActions({ className, children }: { className?: string; children?: React.ReactNode }) { + return
{children}
; +} + +type SummaryActionProps = { + label?: React.ReactNode; + icon?: React.ReactNode; + onClick?: (data: T | null) => void; disabled?: boolean; + disabledReason?: string; + className?: string; + children?: React.ReactNode; }; -function SummaryWorkflows({ - label = 'Launch workflow', +function SummaryAction({ + label, + icon, onClick, - className, disabled, -}: SummaryWorkflowsProps) { - const { study } = useSummaryContext(); - const hasSelection = Boolean(study); - const isDisabled = disabled ?? !hasSelection; + disabledReason, + className, + children, +}: SummaryActionProps) { + const { data } = useSummaryContext(); + const isDisabled = disabled ?? !data; + const titleAttr = isDisabled && disabledReason ? String(disabledReason) : undefined; return ( ); } +type SummaryWorkflowButtonProps = { + label?: React.ReactNode; + onClick?: (data: T) => void; + disabled?: boolean; + disabledReason?: string; + className?: string; + icon?: React.ReactNode; +}; + +function SummaryWorkflowButton({ + label = 'Launch workflow', + onClick, + disabled, + disabledReason, + className, + icon = , +}: SummaryWorkflowButtonProps) { + const { data } = useSummaryContext(); + const computedDisabled = disabled ?? !data; + + return ( + + label={label} + icon={icon} + className={className} + disabled={computedDisabled} + disabledReason={disabledReason ?? 'Select a study to launch'} + onClick={(item) => { + if (!computedDisabled && item) { + onClick?.(item); + } + }} + /> + ); +} + +type SummaryPatientProps = { + placeholder?: React.ReactNode; + className?: string; + hideIcon?: boolean; + hideName?: boolean; + hideMrn?: boolean; + iconAlt?: string; + iconSrc?: string; +}; + +function SummaryPatient({ + placeholder = 'Select a study', + className, + hideIcon, + hideName, + hideMrn, + iconAlt = '', + iconSrc = patientSummaryIcon, +}: SummaryPatientProps) { + return ( + + {!hideIcon && } +
+ {!hideName && } + {!hideMrn && } +
+
+ ); +} + +type SummaryWorkflowsProps = { + label?: React.ReactNode; + onClick?: (data: T) => void; + className?: string; + disabled?: boolean; + disabledReason?: string; + icon?: React.ReactNode; +}; + +function SummaryWorkflows({ + label = 'Launch workflow', + onClick, + className, + disabled, + disabledReason, + icon, +}: SummaryWorkflowsProps) { + return ( + + label={label} + onClick={onClick} + className={className} + disabled={disabled} + disabledReason={disabledReason} + icon={icon} + /> + ); +} + export const Summary = Object.assign(SummaryRoot, { + Root: SummaryRoot, + Section: SummarySection, + Icon: SummaryIcon, + Name: SummaryName, + MRN: SummaryMRN, + Meta: SummaryMeta, + Actions: SummaryActions, + Action: SummaryAction, + WorkflowButton: SummaryWorkflowButton, Patient: SummaryPatient, Workflows: SummaryWorkflows, }); export const PanelSummary = Summary; - From cf6eab86539b120dee4832a0f79940a434c4c61f Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 24 Oct 2025 15:26:29 -0400 Subject: [PATCH 036/172] Updated Summary component --- .../ui-next/playground/studylist/README.md | 44 +- .../studylist/panels/panel-summary.tsx | 471 +++++++++++++++--- 2 files changed, 433 insertions(+), 82 deletions(-) diff --git a/platform/ui-next/playground/studylist/README.md b/platform/ui-next/playground/studylist/README.md index ce15e1d8d3f..112491a7094 100644 --- a/platform/ui-next/playground/studylist/README.md +++ b/platform/ui-next/playground/studylist/README.md @@ -151,10 +151,46 @@ function LaunchMenuCell({ row, value }: { row: any; value: number }) { ### Panel Summary Compound API -- `panels/panel-summary.tsx` exports a flexible `Summary` namespace for the right-hand preview header. - - Compose it as `` or mix slot primitives like ``, ``, ``, ``, ``, and ``. - - Provide `get` adapters on the root to map custom data shapes (`name`/`mrn` formatters), omit slots to hide fields, and pass custom `icon`, `label`, or `onClick` handlers for launch actions. - - Legacy helpers `Summary.Patient` and `Summary.Workflows` remain available as thin wrappers around the new slots for quick defaults. +- `panels/panel-summary.tsx` exports a `Summary` namespace that can render the preview header however you like. The most direct usage is: + ```tsx + + + + + ``` +- Slot breakdown (each slot can be omitted or reordered): + - `Summary.Root({ data, get, className, children })` – supplies context; `get.name`/`get.mrn` let you adapt any data shape. + - `Summary.Section({ variant = 'card' | 'row' | 'ghost', align = 'center', gap, ...divProps })` – layout wrapper with configurable alignment, spacing, and full div props/ref forwarding. + - `Summary.Icon({ src, alt, size, hideWhenEmpty })`, `Summary.Name({ showTitleOnTruncate })`, `Summary.MRN({ prefix, showTitleOnTruncate })`, `Summary.Meta`, `Summary.Field({ of, muted, showTitleOnTruncate })` – compose the identity block you need. + - `Summary.Actions({ direction, justify, wrap, gap })`, `Summary.Action({ as, iconPosition = 'end', disabledReason, ...htmlProps })`, `Summary.WorkflowButton({ iconPosition = 'end', ... })` – build CTAs; actions pass the current `data` to handlers and can render as links or arbitrary elements via `as`. + - `Summary.Empty({ icon, iconSrc, iconAlt, iconSize, section })` – optional placeholder that only renders when `data` is null. + - Legacy helpers `Summary.Patient` (thin wrapper over Section/Name/MRN) and `Summary.Workflows` (**deprecated**, prefer `Summary.WorkflowButton`) remain for quick defaults. +- Example showing reordering, custom getters, and additional metadata: + ```tsx + import patientSummaryIcon from './assets/PatientStudyList.svg' + + s.patient, + mrn: (s) => (s.mrn ? <>MRN: {s.mrn} : ''), + }} + > + Select a study to preview + + launchDefault(s)} /> + + + + +
+ + + s.accession} muted /> +
+
+
+ ``` ## Accessibility and Labels diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index 6a54945d7b1..d9359a65fbf 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { ElementType } from 'react'; import type { StudyRow } from '../types'; import { cn } from '../../../src/lib/utils'; import patientSummaryIcon from '../assets/PatientStudyList.svg'; @@ -51,10 +52,10 @@ function SummaryRoot( () => ({ name: get?.name ?? - ((item: T) => ((item as unknown as StudyRow | undefined)?.patient ?? '') as React.ReactNode), + ((item: T) => ((item as any)?.patient ?? '') as React.ReactNode), mrn: get?.mrn ?? - ((item: T) => ((item as unknown as StudyRow | undefined)?.mrn ?? '') as React.ReactNode), + ((item: T) => ((item as any)?.mrn ?? '') as React.ReactNode), }), [get] ); @@ -66,19 +67,54 @@ function SummaryRoot( ); } -type SummarySectionProps = { - variant?: 'card' | 'row'; - className?: string; - children?: React.ReactNode; +type SummarySectionProps = React.HTMLAttributes & { + variant?: 'card' | 'row' | 'ghost'; + align?: 'start' | 'center' | 'end' | 'stretch'; + gap?: number; }; -function SummarySection({ variant = 'card', className, children }: SummarySectionProps) { - const base = - variant === 'card' - ? 'border-border/50 bg-muted/40 rounded-lg border px-4 py-3' - : 'border-border/50 rounded-lg border px-4 py-3'; - return
{children}
; -} +const SummarySection = React.forwardRef( + ( + { + variant = 'card', + align = 'center', + gap = 3, + className, + style, + children, + ...rest + }, + ref + ) => { + const baseClassMap = { + card: 'border-border/50 bg-muted/40 rounded-lg border px-4 py-3', + row: 'border-border/50 rounded-lg border px-4 py-3', + ghost: 'px-0 py-0', + } as const; + const baseClass = baseClassMap[variant] ?? baseClassMap.card; + + const alignClassMap = { + start: 'items-start', + end: 'items-end', + stretch: 'items-stretch', + center: 'items-center', + } as const; + const alignmentClass = alignClassMap[align] ?? alignClassMap.center; + + return ( +
+ {children} +
+ ); + } +); + +SummarySection.displayName = 'SummarySection'; type SummaryIconProps = { src?: string; @@ -121,8 +157,6 @@ function SummaryIcon({ {alt} @@ -133,19 +167,28 @@ type SummaryNameProps = { placeholder?: React.ReactNode; className?: string; children?: (value: React.ReactNode, data: T | null) => React.ReactNode; + showTitleOnTruncate?: boolean; }; function SummaryName({ placeholder = 'Select a study', className, children, + showTitleOnTruncate = true, }: SummaryNameProps) { const { data, get } = useSummaryContext(); const value = data ? get.name(data) : null; const content = value ?? placeholder; + const title = + showTitleOnTruncate && (typeof value === 'string' || typeof value === 'number') + ? String(value) + : undefined; return ( - + {typeof children === 'function' ? children(content, data) : content} ); @@ -156,6 +199,7 @@ type SummaryMRNProps = { prefix?: React.ReactNode; className?: string; children?: (value: React.ReactNode, data: T | null) => React.ReactNode; + showTitleOnTruncate?: boolean; }; function SummaryMRN({ @@ -163,6 +207,7 @@ function SummaryMRN({ prefix, className, children, + showTitleOnTruncate = true, }: SummaryMRNProps) { const { data, get } = useSummaryContext(); const value = data ? get.mrn(data) : null; @@ -179,14 +224,21 @@ function SummaryMRN({ ); return ( - + {typeof children === 'function' ? children(value, data) : baseContent} ); } function SummaryMeta({ className, children }: { className?: string; children?: React.ReactNode }) { - if (!children) { + if (children == null) { return null; } return ( @@ -196,11 +248,53 @@ function SummaryMeta({ className, children }: { className?: string; children?: R ); } -function SummaryActions({ className, children }: { className?: string; children?: React.ReactNode }) { - return
{children}
; -} +type SummaryActionsProps = React.HTMLAttributes & { + direction?: 'column' | 'row'; + gap?: number; + wrap?: boolean; + justify?: 'start' | 'end' | 'between' | 'center'; +}; + +const SummaryActions = React.forwardRef( + ( + { + direction = 'column', + gap = 2, + wrap = false, + justify = 'start', + className, + style, + children, + ...rest + }, + ref + ) => { + const directionClass = direction === 'row' ? 'flex-row' : 'flex-col'; + + const justifyClassMap = { + start: 'justify-start', + end: 'justify-end', + between: 'justify-between', + center: 'justify-center', + } as const; + const justifyClass = justifyClassMap[justify] ?? justifyClassMap.start; + + return ( +
+ {children} +
+ ); + } +); -type SummaryActionProps = { +SummaryActions.displayName = 'SummaryActions'; + +type SummaryActionOwnProps = { label?: React.ReactNode; icon?: React.ReactNode; onClick?: (data: T | null) => void; @@ -208,51 +302,150 @@ type SummaryActionProps = { disabledReason?: string; className?: string; children?: React.ReactNode; + as?: ElementType; + href?: string; + iconPosition?: 'start' | 'end'; + iconSize?: number; }; -function SummaryAction({ - label, - icon, - onClick, - disabled, - disabledReason, - className, - children, -}: SummaryActionProps) { +type SummaryActionProps = SummaryActionOwnProps & + Omit, keyof SummaryActionOwnProps | 'onClick'>; + +const SummaryActionInner = ( + { + label, + icon, + onClick, + disabled, + disabledReason, + className, + children, + as: Component = 'button', + href, + iconPosition = 'end', + iconSize = 24, + style, + ...rest + }: SummaryActionProps, + ref: React.ForwardedRef +) => { const { data } = useSummaryContext(); const isDisabled = disabled ?? !data; const titleAttr = isDisabled && disabledReason ? String(disabledReason) : undefined; - - return ( - + ); + + const trailingContent = iconNode && iconPosition === 'end' ? iconNode : null; + + const srOnly = + isDisabled && disabledReason ? ( + + {disabledReason} + + ) : null; + + const commonClassName = cn( + 'border-border/50 flex w-full items-center justify-between rounded-lg border px-4 py-3 text-left transition', + isDisabled + ? 'cursor-not-allowed opacity-50' + : 'hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2', + className ); -} + + const commonProps = { + className: commonClassName, + style, + 'aria-disabled': isDisabled || undefined, + title: titleAttr, + 'aria-describedby': isDisabled && disabledReason ? reasonId : undefined, + }; + + const handleActivate = (event: React.MouseEvent) => { + if (isDisabled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onClick?.(data ?? null); + }; + + if (Component === 'button') { + return ( + + ); + } + + if (Component === 'a') { + return ( + } + href={isDisabled ? undefined : href} + onClick={handleActivate} + {...(rest as React.AnchorHTMLAttributes)} + {...commonProps} + > + {srOnly} + {leadingContent} + {trailingContent} + + ); + } + + const Comp = Component as ElementType; + + return ( + )} + {...commonProps} + > + {srOnly} + {leadingContent} + {trailingContent} + + ); +}; + +type SummaryActionComponentType = ( + props: SummaryActionProps & { ref?: React.Ref } +) => React.ReactElement | null; + +const SummaryAction = React.forwardRef(SummaryActionInner) as SummaryActionComponentType; +SummaryAction.displayName = 'SummaryAction'; type SummaryWorkflowButtonProps = { label?: React.ReactNode; @@ -261,34 +454,58 @@ type SummaryWorkflowButtonProps = { disabledReason?: string; className?: string; icon?: React.ReactNode; -}; - -function SummaryWorkflowButton({ - label = 'Launch workflow', - onClick, - disabled, - disabledReason, - className, - icon = , -}: SummaryWorkflowButtonProps) { + iconPosition?: 'start' | 'end'; + iconSize?: number; + as?: ElementType; +} & Omit, 'onClick'>; + +const SummaryWorkflowButtonInner = ( + { + label = 'Launch workflow', + onClick, + disabled, + disabledReason, + className, + icon = , + iconPosition, + iconSize, + as, + style, + ...rest + }: SummaryWorkflowButtonProps, + ref: React.ForwardedRef +) => { const { data } = useSummaryContext(); const computedDisabled = disabled ?? !data; return ( + ref={ref} label={label} icon={icon} className={className} + style={style} disabled={computedDisabled} disabledReason={disabledReason ?? 'Select a study to launch'} + iconPosition={iconPosition} + iconSize={iconSize} + as={as} onClick={(item) => { if (!computedDisabled && item) { onClick?.(item); } }} + {...rest} /> ); -} +}; + +type SummaryWorkflowButtonComponent = ( + props: SummaryWorkflowButtonProps & { ref?: React.Ref } +) => React.ReactElement | null; + +const SummaryWorkflowButton = React.forwardRef(SummaryWorkflowButtonInner) as SummaryWorkflowButtonComponent; +SummaryWorkflowButton.displayName = 'SummaryWorkflowButton'; type SummaryPatientProps = { placeholder?: React.ReactNode; @@ -298,6 +515,9 @@ type SummaryPatientProps = { hideMrn?: boolean; iconAlt?: string; iconSrc?: string; + align?: SummarySectionProps['align']; + gap?: number; + variant?: SummarySectionProps['variant']; }; function SummaryPatient({ @@ -308,9 +528,12 @@ function SummaryPatient({ hideMrn, iconAlt = '', iconSrc = patientSummaryIcon, + align, + gap, + variant, }: SummaryPatientProps) { return ( - + {!hideIcon && }
{!hideName && } @@ -320,15 +543,9 @@ function SummaryPatient({ ); } -type SummaryWorkflowsProps = { - label?: React.ReactNode; - onClick?: (data: T) => void; - className?: string; - disabled?: boolean; - disabledReason?: string; - icon?: React.ReactNode; -}; +type SummaryWorkflowsProps = SummaryWorkflowButtonProps; +/** @deprecated Prefer for new usage. */ function SummaryWorkflows({ label = 'Launch workflow', onClick, @@ -336,6 +553,9 @@ function SummaryWorkflows({ disabled, disabledReason, icon, + iconPosition, + iconSize, + ...rest }: SummaryWorkflowsProps) { return ( @@ -345,11 +565,104 @@ function SummaryWorkflows({ disabled={disabled} disabledReason={disabledReason} icon={icon} + iconPosition={iconPosition} + iconSize={iconSize} + {...rest} /> ); } -export const Summary = Object.assign(SummaryRoot, { +type SummaryEmptyProps = { + children?: React.ReactNode; + icon?: React.ReactNode; + iconSrc?: string; + iconAlt?: string; + iconSize?: number; + section?: SummarySectionProps; +}; + +function SummaryEmpty({ + children, + icon, + iconSrc = patientSummaryIcon, + iconAlt = '', + iconSize = 33, + section, +}: SummaryEmptyProps) { + const { data } = useSummaryContext(); + + if (data) { + return null; + } + + return ( + + {icon ?? } + + {children ?? 'Select a study'} + + + ); +} + +type SummaryFieldProps = { + of: (data: T) => React.ReactNode; + hideWhenEmpty?: boolean; + muted?: boolean; + className?: string; + showTitleOnTruncate?: boolean; +}; + +function SummaryField({ + of, + hideWhenEmpty = true, + muted, + className, + showTitleOnTruncate = true, +}: SummaryFieldProps) { + const { data } = useSummaryContext(); + const value = data ? of(data) : null; + const isEmpty = value === null || value === undefined || value === ''; + + if (hideWhenEmpty && isEmpty) { + return null; + } + + return ( + + {value} + + ); +} + +type SummaryNamespace = typeof SummaryRoot & { + Root: typeof SummaryRoot; + Section: typeof SummarySection; + Icon: typeof SummaryIcon; + Name: typeof SummaryName; + MRN: typeof SummaryMRN; + Meta: typeof SummaryMeta; + Actions: typeof SummaryActions; + Action: SummaryActionComponentType; + WorkflowButton: SummaryWorkflowButtonComponent; + Patient: typeof SummaryPatient; + Workflows: typeof SummaryWorkflows; + Empty: typeof SummaryEmpty; + Field: typeof SummaryField; +}; + +export const Summary: SummaryNamespace = Object.assign(SummaryRoot, { Root: SummaryRoot, Section: SummarySection, Icon: SummaryIcon, @@ -361,6 +674,8 @@ export const Summary = Object.assign(SummaryRoot, { WorkflowButton: SummaryWorkflowButton, Patient: SummaryPatient, Workflows: SummaryWorkflows, + Empty: SummaryEmpty, + Field: SummaryField, }); export const PanelSummary = Summary; From 3061c6271512ab8ab7d537269695c1dcc7d79f2d Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 06:58:01 -0400 Subject: [PATCH 037/172] Color updates and info icon --- .../playground/studylist/assets/info.svg | 3 + .../studylist/panels/panel-summary.tsx | 83 +++++++++++-------- 2 files changed, 53 insertions(+), 33 deletions(-) create mode 100644 platform/ui-next/playground/studylist/assets/info.svg diff --git a/platform/ui-next/playground/studylist/assets/info.svg b/platform/ui-next/playground/studylist/assets/info.svg new file mode 100644 index 00000000000..298386820c7 --- /dev/null +++ b/platform/ui-next/playground/studylist/assets/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index d9359a65fbf..3b4539461c3 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -3,6 +3,7 @@ import type { ElementType } from 'react'; import type { StudyRow } from '../types'; import { cn } from '../../../src/lib/utils'; import patientSummaryIcon from '../assets/PatientStudyList.svg'; +import infoIcon from '../assets/info.svg'; import { Icons } from '../../../src/components/Icons/Icons'; export type SummaryGetters = { @@ -50,12 +51,8 @@ function SummaryRoot( const resolvedGetters = React.useMemo>( () => ({ - name: - get?.name ?? - ((item: T) => ((item as any)?.patient ?? '') as React.ReactNode), - mrn: - get?.mrn ?? - ((item: T) => ((item as any)?.mrn ?? '') as React.ReactNode), + name: get?.name ?? ((item: T) => ((item as any)?.patient ?? '') as React.ReactNode), + mrn: get?.mrn ?? ((item: T) => ((item as any)?.mrn ?? '') as React.ReactNode), }), [get] ); @@ -74,21 +71,10 @@ type SummarySectionProps = React.HTMLAttributes & { }; const SummarySection = React.forwardRef( - ( - { - variant = 'card', - align = 'center', - gap = 3, - className, - style, - children, - ...rest - }, - ref - ) => { + ({ variant = 'card', align = 'center', gap = 3, className, style, children, ...rest }, ref) => { const baseClassMap = { - card: 'border-border/50 bg-muted/40 rounded-lg border px-4 py-3', - row: 'border-border/50 rounded-lg border px-4 py-3', + card: 'bg-muted rounded-lg px-4 py-3', + row: 'rounded-lg px-4 py-3', ghost: 'px-0 py-0', } as const; const baseClass = baseClassMap[variant] ?? baseClassMap.card; @@ -311,7 +297,7 @@ type SummaryActionOwnProps = { type SummaryActionProps = SummaryActionOwnProps & Omit, keyof SummaryActionOwnProps | 'onClick'>; -const SummaryActionInner = ( +const SummaryActionInner = ( { label, icon, @@ -347,7 +333,7 @@ const SummaryActionInner = ( const leadingContent = iconNode && iconPosition === 'start' ? ( - + {iconNode} {children ?? label} @@ -361,13 +347,16 @@ const SummaryActionInner = ( const srOnly = isDisabled && disabledReason ? ( - + {disabledReason} ) : null; const commonClassName = cn( - 'border-border/50 flex w-full items-center justify-between rounded-lg border px-4 py-3 text-left transition', + 'border-border/50 flex w-full items-center justify-between rounded-lg bg-muted px-4 py-3 text-left transition', isDisabled ? 'cursor-not-allowed opacity-50' : 'hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2', @@ -459,16 +448,22 @@ type SummaryWorkflowButtonProps = { as?: ElementType; } & Omit, 'onClick'>; -const SummaryWorkflowButtonInner = ( +const SummaryWorkflowButtonInner = ( { label = 'Launch workflow', onClick, disabled, disabledReason, className, - icon = , + icon = ( + + ), iconPosition, - iconSize, + iconSize = 18, as, style, ...rest @@ -490,7 +485,7 @@ const SummaryWorkflowButtonInner = ( iconPosition={iconPosition} iconSize={iconSize} as={as} - onClick={(item) => { + onClick={item => { if (!computedDisabled && item) { onClick?.(item); } @@ -504,7 +499,9 @@ type SummaryWorkflowButtonComponent = ( props: SummaryWorkflowButtonProps & { ref?: React.Ref } ) => React.ReactElement | null; -const SummaryWorkflowButton = React.forwardRef(SummaryWorkflowButtonInner) as SummaryWorkflowButtonComponent; +const SummaryWorkflowButton = React.forwardRef( + SummaryWorkflowButtonInner +) as SummaryWorkflowButtonComponent; SummaryWorkflowButton.displayName = 'SummaryWorkflowButton'; type SummaryPatientProps = { @@ -533,8 +530,19 @@ function SummaryPatient({ variant, }: SummaryPatientProps) { return ( - - {!hideIcon && } + + {!hideIcon && ( + + )}
{!hideName && } {!hideMrn && } @@ -596,8 +604,17 @@ function SummaryEmpty({ } return ( - - {icon ?? } + + {icon ?? ( + + )} {children ?? 'Select a study'} From c1ac9545a7c8cf296443e3ec6c69c0600ffa393d Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 08:51:36 -0400 Subject: [PATCH 038/172] Added workflow buttons to preview --- .../studylist/panels/panel-summary.tsx | 95 +++++++++++++++---- 1 file changed, 78 insertions(+), 17 deletions(-) diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index 3b4539461c3..b8c142cbd24 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -5,6 +5,7 @@ import { cn } from '../../../src/lib/utils'; import patientSummaryIcon from '../assets/PatientStudyList.svg'; import infoIcon from '../assets/info.svg'; import { Icons } from '../../../src/components/Icons/Icons'; +import { Button } from '../../../src/components/Button'; export type SummaryGetters = { name?: (data: T) => React.ReactNode; @@ -446,6 +447,8 @@ type SummaryWorkflowButtonProps = { iconPosition?: 'start' | 'end'; iconSize?: number; as?: ElementType; + onLaunchBasic?: (data: T) => void; + onLaunchSegmentation?: (data: T) => void; } & Omit, 'onClick'>; const SummaryWorkflowButtonInner = ( @@ -462,9 +465,11 @@ const SummaryWorkflowButtonInner = ( className="h-4.5 w-4.5" /> ), - iconPosition, + iconPosition = 'end', iconSize = 18, as, + onLaunchBasic, + onLaunchSegmentation, style, ...rest }: SummaryWorkflowButtonProps, @@ -472,26 +477,82 @@ const SummaryWorkflowButtonInner = ( ) => { const { data } = useSummaryContext(); const computedDisabled = disabled ?? !data; + const id = React.useId(); + const reasonId = `${id}-reason`; + + const handleBasic = () => { + if (computedDisabled || !data) return; + // Prefer explicit basic callback if provided; fall back to legacy onClick. + onLaunchBasic?.(data); + if (!onLaunchBasic) onClick?.(data); + }; + + const handleSegmentation = () => { + if (computedDisabled || !data) return; + onLaunchSegmentation?.(data); + if (!onLaunchSegmentation) onClick?.(data); + }; + + const iconNode = icon ? ( + + {icon} + + ) : null; + + const srOnly = + computedDisabled && disabledReason ? ( + + {disabledReason} + + ) : null; return ( - - ref={ref} - label={label} - icon={icon} - className={className} +
} + className={cn( + 'border-border/50 w-full rounded-lg bg-muted px-4 py-3 text-left transition', + className + )} style={style} - disabled={computedDisabled} - disabledReason={disabledReason ?? 'Select a study to launch'} - iconPosition={iconPosition} - iconSize={iconSize} - as={as} - onClick={item => { - if (!computedDisabled && item) { - onClick?.(item); - } - }} + aria-disabled={computedDisabled || undefined} + aria-describedby={computedDisabled && disabledReason ? reasonId : undefined} {...rest} - /> + > + {srOnly} +
+ {iconNode && iconPosition === 'start' ? ( + + {iconNode} + {label} + + ) : ( + {label} + )} + {iconNode && iconPosition === 'end' ? iconNode : null} +
+ {data ? ( +
+ + +
+ ) : null} +
); }; From 0a33f4d5e333ffaa02d3833c9a7014114fd75084 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 09:14:13 -0400 Subject: [PATCH 039/172] Responsive fixes for buttons --- .../studylist/assets/icon-left-base.svg | 4 ++++ .../studylist/panels/panel-content.tsx | 1 + .../studylist/panels/panel-summary.tsx | 17 ++++++++++++----- 3 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 platform/ui-next/playground/studylist/assets/icon-left-base.svg diff --git a/platform/ui-next/playground/studylist/assets/icon-left-base.svg b/platform/ui-next/playground/studylist/assets/icon-left-base.svg new file mode 100644 index 00000000000..028b596cfdf --- /dev/null +++ b/platform/ui-next/playground/studylist/assets/icon-left-base.svg @@ -0,0 +1,4 @@ + + + + diff --git a/platform/ui-next/playground/studylist/panels/panel-content.tsx b/platform/ui-next/playground/studylist/panels/panel-content.tsx index d22a5ead37b..0070362dadf 100644 --- a/platform/ui-next/playground/studylist/panels/panel-content.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-content.tsx @@ -23,6 +23,7 @@ export function PanelContent({ study }: { study: StudyRow }) { +
1 Study
{thumbnails.map(item => ( ( return ( -
{children}
+
{children}
); } @@ -494,14 +494,21 @@ const SummaryWorkflowButtonInner = ( }; const iconNode = icon ? ( - + {icon} ) : null; const srOnly = computedDisabled && disabledReason ? ( - + {disabledReason} ) : null; @@ -510,7 +517,7 @@ const SummaryWorkflowButtonInner = (
} className={cn( - 'border-border/50 w-full rounded-lg bg-muted px-4 py-3 text-left transition', + 'border-border/50 bg-muted w-full rounded-lg px-4 py-3 text-left transition', className )} style={style} @@ -531,7 +538,7 @@ const SummaryWorkflowButtonInner = ( {iconNode && iconPosition === 'end' ? iconNode : null}
{data ? ( -
+
- ) + ); } -function SidePanel({ selected }: { selected: StudyRow | null }) { +function SidePanel({ selected, onClose }: { selected: StudyRow | null; onClose: () => void }) { return ( -
+
+
+ +
-
+
{selected ? ( - + ) : ( )}
- ) + ); } diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx index 7f97d33f893..0d8bb885c34 100644 --- a/platform/ui-next/playground/studylist/study-list-table.tsx +++ b/platform/ui-next/playground/studylist/study-list-table.tsx @@ -1,6 +1,6 @@ -import * as React from 'react' -import type { ColumnDef, SortingState, VisibilityState } from '@tanstack/react-table' -import { flexRender } from '@tanstack/react-table' +import * as React from 'react'; +import type { ColumnDef, SortingState, VisibilityState } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; import { DataTable, DataTableToolbar, @@ -8,7 +8,7 @@ import { DataTableFilterRow, DataTableViewOptions, useDataTable, -} from '../../src/components/DataTable' +} from '../../src/components/DataTable'; import { Table, TableHeader, @@ -16,23 +16,28 @@ import { TableHead, TableRow, TableCell, -} from '../../src/components/Table' -import { ScrollArea } from '../../src/components/ScrollArea' -import type { StudyRow } from './types' -import ohifLogo from './assets/ohif-logo.svg' +} from '../../src/components/Table'; +import { ScrollArea } from '../../src/components/ScrollArea'; +import type { StudyRow } from './types'; +import ohifLogo from './assets/ohif-logo.svg'; +import { Button } from '../../src/components/Button'; +import iconLeftBase from './assets/icon-left-base.svg'; type Props = { columns: ColumnDef[] - data: StudyRow[] - title?: React.ReactNode - getRowId?: (row: StudyRow, index: number) => string - initialSorting?: SortingState - initialVisibility?: VisibilityState - enforceSingleSelection?: boolean - showColumnVisibility?: boolean - tableClassName?: string - onSelectionChange?: (rows: StudyRow[]) => void -} + columns: ColumnDef[]; + data: StudyRow[]; + title?: React.ReactNode; + getRowId?: (row: StudyRow, index: number) => string; + initialSorting?: SortingState; + initialVisibility?: VisibilityState; + enforceSingleSelection?: boolean; + showColumnVisibility?: boolean; + tableClassName?: string; + onSelectionChange?: (rows: StudyRow[]) => void; + isPanelOpen?: boolean; + onOpenPanel?: () => void; +}; export function StudyListTable({ columns, @@ -45,6 +50,8 @@ export function StudyListTable({ showColumnVisibility = true, tableClassName, onSelectionChange, + isPanelOpen, + onOpenPanel, }: Props) { return ( @@ -56,54 +63,93 @@ export function StudyListTable({ enforceSingleSelection={enforceSingleSelection} onSelectionChange={onSelectionChange} > - + - ) + ); } function Content({ title, showColumnVisibility, tableClassName, + isPanelOpen, + onOpenPanel, }: { - title?: React.ReactNode - showColumnVisibility?: boolean - tableClassName?: string + title?: React.ReactNode; + showColumnVisibility?: boolean; + tableClassName?: string; + isPanelOpen?: boolean; + onOpenPanel?: () => void; }) { - const { table, setColumnFilters } = useDataTable() + const { table, setColumnFilters } = useDataTable(); return (
{(showColumnVisibility || title) && (
- OHIF Logo + OHIF Logo
{title ? {title} : null} {showColumnVisibility && ( -
+
{ - const label = (table.getColumn(id)?.columnDef.meta as { label?: string } | undefined)?.label - return label ?? id + getLabel={id => { + const label = ( + table.getColumn(id)?.columnDef.meta as { label?: string } | undefined + )?.label; + return label ?? id; }} /> + {/* Open preview panel button appears when panel is closed; add right padding only when visible */} + {typeof onOpenPanel === 'function' && isPanelOpen === false ? ( +
+ +
+ ) : null}
)} )}
- +
- {table.getHeaderGroups().map((hg) => ( + {table.getHeaderGroups().map(hg => ( - {hg.headers.map((header) => ( + {hg.headers.map(header => ( { - const s = header.column.getIsSorted() as false | 'asc' | 'desc' - return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none' + const s = header.column.getIsSorted() as false | 'asc' | 'desc'; + return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; })()} > {header.isPlaceholder @@ -115,9 +161,13 @@ function Content({ ))} - setColumnFilters([])} excludeColumnIds={[]} /> + setColumnFilters([])} + excludeColumnIds={[]} + /> {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( + table.getRowModel().rows.map(row => ( - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} )) ) : ( - + No results. @@ -142,5 +197,5 @@ function Content({ - ) + ); } From 886359103daed9c24605eebc55b0108f30ac0285 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 11:00:03 -0400 Subject: [PATCH 041/172] Table responsive updates --- platform/ui-next/playground/studylist/app.tsx | 1 - .../ui-next/playground/studylist/columns.tsx | 49 +++++++++++++++---- .../playground/studylist/study-list-table.tsx | 23 ++++++++- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index 819c5742f50..ad97d591a07 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -44,7 +44,6 @@ export function App() { enforceSingleSelection={true} showColumnVisibility={true} title="Study List" - tableClassName="min-w-[1000px]" isPanelOpen={isPanelOpen} onOpenPanel={() => setIsPanelOpen(true)} onSelectionChange={rows => setSelected(rows[0] ?? null)} diff --git a/platform/ui-next/playground/studylist/columns.tsx b/platform/ui-next/playground/studylist/columns.tsx index 9d275333670..53268ee7348 100644 --- a/platform/ui-next/playground/studylist/columns.tsx +++ b/platform/ui-next/playground/studylist/columns.tsx @@ -14,36 +14,60 @@ export const studyListColumns: ColumnDef[] = [ { accessorKey: 'mrn', header: ({ column }) => , - cell: ({ row }) =>
{row.getValue('mrn')}
, - meta: { label: 'MRN' }, + cell: ({ row }) =>
{row.getValue('mrn')}
, + meta: { + label: 'MRN', + headerClassName: 'w-[120px] min-w-[120px] max-w-[120px]', + cellClassName: 'w-[120px] min-w-[120px] max-w-[120px]', + fixedWidth: 120, + }, }, { accessorKey: 'studyDateTime', header: ({ column }) => , - cell: ({ row }) =>
{row.getValue('studyDateTime')}
, + cell: ({ row }) =>
{row.getValue('studyDateTime')}
, sortingFn: (a, b, colId) => { const norm = (s: string) => new Date(s.replace(' ', 'T')).getTime() || 0 return norm(a.getValue(colId) as string) - norm(b.getValue(colId) as string) }, - meta: { label: 'Study Date' }, + meta: { + label: 'Study Date', + headerClassName: 'w-[150px] min-w-[150px] max-w-[150px]', + cellClassName: 'w-[150px] min-w-[150px] max-w-[150px]', + fixedWidth: 150, + }, }, { accessorKey: 'modalities', header: ({ column }) => , - cell: ({ row }) =>
{row.getValue('modalities')}
, - meta: { label: 'Modalities' }, + cell: ({ row }) =>
{row.getValue('modalities')}
, + meta: { + label: 'Modalities', + headerClassName: 'w-[85px] min-w-[85px] max-w-[85px]', + cellClassName: 'w-[85px] min-w-[85px] max-w-[85px]', + fixedWidth: 85, + }, }, { accessorKey: 'description', header: ({ column }) => , cell: ({ row }) =>
{row.getValue('description')}
, - meta: { label: 'Description' }, + meta: { + label: 'Description', + headerClassName: 'min-w-[290px]', + cellClassName: 'min-w-[290px]', + }, }, { accessorKey: 'accession', header: ({ column }) => , - cell: ({ row }) =>
{row.getValue('accession')}
, - meta: { label: 'Accession' }, + cell: ({ row }) =>
{row.getValue('accession')}
, + meta: { + label: 'Accession', + headerClassName: 'w-[140px] min-w-[140px] max-w-[140px]', + cellClassName: 'w-[140px] min-w-[140px] max-w-[140px]', + fixedWidth: 140, + }, }, { accessorKey: 'instances', @@ -53,6 +77,11 @@ export const studyListColumns: ColumnDef[] = [ return }, sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), - meta: { label: 'Instances' }, + meta: { + label: 'Instances', + headerClassName: 'w-[90px] min-w-[90px] max-w-[90px]', + cellClassName: 'w-[90px] min-w-[90px] max-w-[90px] overflow-hidden', + fixedWidth: 90, + }, }, ] diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx index 0d8bb885c34..dcbf32e620d 100644 --- a/platform/ui-next/playground/studylist/study-list-table.tsx +++ b/platform/ui-next/playground/studylist/study-list-table.tsx @@ -140,13 +140,27 @@ function Content({ containerClassName="h-full" noScroll > + {/* Column widths */} +
+ {table.getVisibleLeafColumns().map(col => { + const meta = col.columnDef.meta as unknown as { fixedWidth?: number | string } | undefined; + const width = meta?.fixedWidth; + return width ? ( + + ) : ( + + ); + })} + {table.getHeaderGroups().map(hg => ( {hg.headers.map(header => ( { const s = header.column.getIsSorted() as false | 'asc' | 'desc'; return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; @@ -176,7 +190,12 @@ function Content({ className="group cursor-pointer" > {row.getVisibleCells().map(cell => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} From 816e06bf4b037e123fc1e6e62307e4ab7c608848 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 11:05:45 -0400 Subject: [PATCH 042/172] Table patient column sizing --- platform/ui-next/playground/studylist/columns.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/platform/ui-next/playground/studylist/columns.tsx b/platform/ui-next/playground/studylist/columns.tsx index 53268ee7348..881cab9804c 100644 --- a/platform/ui-next/playground/studylist/columns.tsx +++ b/platform/ui-next/playground/studylist/columns.tsx @@ -8,8 +8,13 @@ export const studyListColumns: ColumnDef[] = [ { accessorKey: 'patient', header: ({ column }) => , - cell: ({ row }) =>
{row.getValue('patient')}
, - meta: { label: 'Patient' }, + cell: ({ row }) =>
{row.getValue('patient')}
, + meta: { + label: 'Patient', + headerClassName: 'w-[165px] min-w-[165px] max-w-[165px]', + cellClassName: 'w-[165px] min-w-[165px] max-w-[165px]', + fixedWidth: 165, + }, }, { accessorKey: 'mrn', From d61fa456e40bbc3b209ae641b66cbce05c981403 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 11:31:40 -0400 Subject: [PATCH 043/172] Workflow buttons match Modality selection --- .../studylist/cells/launch-menu-cell.tsx | 26 ++- .../studylist/panels/panel-summary.tsx | 64 ++++--- .../playground/studylist/patient-studies.json | 162 ++++++++++++------ .../ui-next/playground/studylist/types.ts | 2 + 4 files changed, 167 insertions(+), 87 deletions(-) diff --git a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx index fbbe7b67251..f678844c70d 100644 --- a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx +++ b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx @@ -9,8 +9,23 @@ import { DropdownMenuItem, } from '../../../src/components/DropdownMenu' +function getWorkflowsFromRow(row: Row): string[] { + const defaults = ['Basic Viewer', 'Segmentation'] + const original: any = row.original ?? {} + if (Array.isArray(original.workflows) && original.workflows.length > 0) { + return Array.from(new Set(original.workflows)) + } + const mod = String(original.modalities ?? '').toUpperCase() + const flows = [...defaults] + if (mod.includes('US')) flows.push('US Workflow') + if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) flows.push('TMTV Workflow') + return Array.from(new Set(flows)) +} + export function LaunchMenuCell({ row, value }: { row: Row; value: number }) { const [open, setOpen] = React.useState(false) + const workflows = getWorkflowsFromRow(row) + return ( ({ row, value }: { row: Row; value: e.stopPropagation()}> - e.preventDefault()}>Basic Viewer - e.preventDefault()}>Segmentation - US Pleura B-line Annotations - Total Metabolic Tumor Volume - Microscopy - Preclinical 4D + {workflows.map((wf) => ( + e.preventDefault()}> + {wf} + + ))} } diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index 4c7f3a77d0d..7a300d2daec 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -480,17 +480,33 @@ const SummaryWorkflowButtonInner = ( const id = React.useId(); const reasonId = `${id}-reason`; - const handleBasic = () => { - if (computedDisabled || !data) return; - // Prefer explicit basic callback if provided; fall back to legacy onClick. - onLaunchBasic?.(data); - if (!onLaunchBasic) onClick?.(data); - }; + const getInferredWorkflows = React.useCallback((d: any): string[] => { + const defaults = ['Basic Viewer', 'Segmentation']; + if (!d) return defaults; + if (Array.isArray(d.workflows) && d.workflows.length > 0) { + return Array.from(new Set(d.workflows)); + } + const mod = String(d.modalities ?? '').toUpperCase(); + const flows = [...defaults]; + if (mod.includes('US')) flows.push('US Workflow'); + if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) flows.push('TMTV Workflow'); + return Array.from(new Set(flows)); + }, []); + + const workflowButtons = React.useMemo(() => getInferredWorkflows(data), [data, getInferredWorkflows]); - const handleSegmentation = () => { + const handleLaunch = (wfLabel: string) => { if (computedDisabled || !data) return; - onLaunchSegmentation?.(data); - if (!onLaunchSegmentation) onClick?.(data); + // Back-compat explicit callbacks: + if (wfLabel === 'Basic Viewer') onLaunchBasic?.(data); + if (wfLabel === 'Segmentation') onLaunchSegmentation?.(data); + // Generic handler fallback: + onClick?.(data); + // For prototype visibility: + try { + // eslint-disable-next-line no-console + console.log('Launch workflow:', wfLabel, { study: data }); + } catch {} }; const iconNode = icon ? ( @@ -539,24 +555,18 @@ const SummaryWorkflowButtonInner = ( {data ? (
- - + {workflowButtons.map((wf) => ( + + ))}
) : null} diff --git a/platform/ui-next/playground/studylist/patient-studies.json b/platform/ui-next/playground/studylist/patient-studies.json index e88dae3b759..2828399cdf7 100644 --- a/platform/ui-next/playground/studylist/patient-studies.json +++ b/platform/ui-next/playground/studylist/patient-studies.json @@ -6,7 +6,8 @@ "modalities": "CT", "description": "Chest CT w/ Contrast", "accession": "ACC-102938", - "instances": 324 + "instances": 324, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Smith, Jane", @@ -15,7 +16,8 @@ "modalities": "MR", "description": "Brain MRI", "accession": "ACC-564738", - "instances": 210 + "instances": 210, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Ruiz, Carlos", @@ -24,7 +26,8 @@ "modalities": "US", "description": "Abdominal Ultrasound", "accession": "ACC-223344", - "instances": 58 + "instances": 58, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Khan, Amina", @@ -33,7 +36,8 @@ "modalities": "PET/CT", "description": "Whole Body PET/CT", "accession": "ACC-998877", - "instances": 512 + "instances": 512, + "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] }, { "patient": "Johnson, Michael", @@ -42,7 +46,8 @@ "modalities": "CT", "description": "CT Abdomen/Pelvis w/ Contrast", "accession": "ACC-300001", - "instances": 512 + "instances": 512, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Patel, Priya", @@ -51,7 +56,8 @@ "modalities": "MR", "description": "MR Brain w/ and w/o Contrast", "accession": "ACC-300002", - "instances": 240 + "instances": 240, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Lee, David", @@ -60,7 +66,8 @@ "modalities": "US", "description": "US Carotid Duplex", "accession": "ACC-300003", - "instances": 76 + "instances": 76, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Ahmed, Fatima", @@ -69,7 +76,8 @@ "modalities": "XR", "description": "XR Chest PA & Lateral", "accession": "ACC-300004", - "instances": 4 + "instances": 4, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Thompson, Robert", @@ -78,7 +86,8 @@ "modalities": "CT", "description": "CT Head w/o Contrast", "accession": "ACC-300005", - "instances": 220 + "instances": 220, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Chen, Emily", @@ -87,7 +96,8 @@ "modalities": "MG", "description": "MG Screening Tomosynthesis", "accession": "ACC-300006", - "instances": 132 + "instances": 132, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Zhang, Wei", @@ -96,7 +106,8 @@ "modalities": "DEXA", "description": "DEXA Bone Density Axial", "accession": "ACC-300007", - "instances": 28 + "instances": 28, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Rossi, Sofia", @@ -105,7 +116,8 @@ "modalities": "MR", "description": "MR Knee Left", "accession": "ACC-300008", - "instances": 180 + "instances": 180, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "O'Connor, Liam", @@ -114,7 +126,8 @@ "modalities": "US", "description": "US Venous Doppler Lower Extremity Right", "accession": "ACC-300009", - "instances": 64 + "instances": 64, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Garcia, Maria", @@ -123,7 +136,8 @@ "modalities": "CT", "description": "CT Pulmonary Angiography", "accession": "ACC-300010", - "instances": 650 + "instances": 650, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Al-Sayed, Ahmed", @@ -132,7 +146,8 @@ "modalities": "US", "description": "US Abdomen RUQ", "accession": "ACC-300011", - "instances": 54 + "instances": 54, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Kim, Hannah", @@ -141,7 +156,8 @@ "modalities": "MR", "description": "MR Lumbar Spine w/o Contrast", "accession": "ACC-300012", - "instances": 156 + "instances": 156, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Brown, Ethan", @@ -150,7 +166,8 @@ "modalities": "XR", "description": "XR Ankle Right", "accession": "ACC-300013", - "instances": 3 + "instances": 3, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Martinez, Olivia", @@ -159,7 +176,8 @@ "modalities": "CT", "description": "CT Chest Low-Dose Screening", "accession": "ACC-300014", - "instances": 420 + "instances": 420, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Wilson, Noah", @@ -168,7 +186,8 @@ "modalities": "MR", "description": "MR Shoulder Right", "accession": "ACC-300015", - "instances": 168 + "instances": 168, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Nguyen, Chloe", @@ -177,7 +196,8 @@ "modalities": "XR", "description": "XR Abdomen (KUB)", "accession": "ACC-300016", - "instances": 2 + "instances": 2, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Silva, Lucas", @@ -186,7 +206,8 @@ "modalities": "US", "description": "US Renal", "accession": "ACC-300017", - "instances": 48 + "instances": 48, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Costa, Isabella", @@ -195,7 +216,8 @@ "modalities": "MG", "description": "MG Diagnostic Unilateral Right", "accession": "ACC-300018", - "instances": 96 + "instances": 96, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Alvarez, Mateo", @@ -204,7 +226,8 @@ "modalities": "PET/CT", "description": "PET/CT FDG Skull Base to Mid-Thigh", "accession": "ACC-300019", - "instances": 582 + "instances": 582, + "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] }, { "patient": "Tanaka, Yuki", @@ -213,7 +236,8 @@ "modalities": "MR", "description": "MR Angiography Brain w/ and w/o Contrast", "accession": "ACC-300020", - "instances": 212 + "instances": 212, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Cohen, Sara", @@ -222,7 +246,8 @@ "modalities": "CT", "description": "CT Sinuses w/o Contrast", "accession": "ACC-300021", - "instances": 190 + "instances": 190, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Ivanov, Pavel", @@ -231,7 +256,8 @@ "modalities": "US", "description": "US Thyroid", "accession": "ACC-300022", - "instances": 44 + "instances": 44, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "El-Sayed, Amira", @@ -240,7 +266,8 @@ "modalities": "XR", "description": "XR Shoulder Left", "accession": "ACC-300023", - "instances": 4 + "instances": 4, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Papadopoulos, George", @@ -249,7 +276,8 @@ "modalities": "CT", "description": "CT Urogram", "accession": "ACC-300024", - "instances": 780 + "instances": 780, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Bergstrom, Hanna", @@ -258,7 +286,8 @@ "modalities": "DEXA", "description": "DEXA Bone Density Axial", "accession": "ACC-300025", - "instances": 30 + "instances": 30, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Johansson, Marcus", @@ -267,7 +296,8 @@ "modalities": "MR", "description": "MR Brain w/o Contrast", "accession": "ACC-300026", - "instances": 132 + "instances": 132, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Bello, Aisha", @@ -276,7 +306,8 @@ "modalities": "US", "description": "US Pelvis TA/TV", "accession": "ACC-300027", - "instances": 66 + "instances": 66, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Adeyemi, Seun", @@ -285,7 +316,8 @@ "modalities": "XR", "description": "XR Hand Left", "accession": "ACC-300028", - "instances": 2 + "instances": 2, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Dubois, Lea", @@ -294,7 +326,8 @@ "modalities": "MR", "description": "MR Abdomen w/ and w/o Contrast", "accession": "ACC-300029", - "instances": 228 + "instances": 228, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Novak, Tomas", @@ -303,7 +336,8 @@ "modalities": "CT", "description": "CT Cervical Spine w/o Contrast", "accession": "ACC-300030", - "instances": 360 + "instances": 360, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Ortiz, Daniel", @@ -312,7 +346,8 @@ "modalities": "XR", "description": "XR Chest Portable AP", "accession": "ACC-300031", - "instances": 1 + "instances": 1, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Petrova, Nadia", @@ -321,7 +356,8 @@ "modalities": "NM", "description": "NM Bone Scan Whole Body", "accession": "ACC-300032", - "instances": 240 + "instances": 240, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Mehta, Arjun", @@ -330,7 +366,8 @@ "modalities": "MR", "description": "MR Prostate w/ and w/o Contrast", "accession": "ACC-300033", - "instances": 200 + "instances": 200, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Lin, Mei", @@ -339,7 +376,8 @@ "modalities": "US", "description": "US Obstetric 2nd Trimester", "accession": "ACC-300034", - "instances": 92 + "instances": 92, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "O'Brien, Kevin", @@ -348,7 +386,8 @@ "modalities": "CT", "description": "CT Angiography Head/Neck", "accession": "ACC-300035", - "instances": 700 + "instances": 700, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Hussain, Zahra", @@ -357,7 +396,8 @@ "modalities": "MR", "description": "MR Enterography", "accession": "ACC-300036", - "instances": 216 + "instances": 216, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Nasser, Omar", @@ -366,7 +406,8 @@ "modalities": "US", "description": "US Aorta Screening", "accession": "ACC-300037", - "instances": 42 + "instances": 42, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Walker, Jade", @@ -375,7 +416,8 @@ "modalities": "XR", "description": "XR Lumbar Spine 2-3 Views", "accession": "ACC-300038", - "instances": 5 + "instances": 5, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Muller, Peter", @@ -384,7 +426,8 @@ "modalities": "CT", "description": "CT Coronary Calcium Score", "accession": "ACC-300039", - "instances": 180 + "instances": 180, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Mendes, Carla", @@ -393,7 +436,8 @@ "modalities": "MG", "description": "MG Screening Tomosynthesis", "accession": "ACC-300040", - "instances": 140 + "instances": 140, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Pereira, Joao", @@ -402,7 +446,8 @@ "modalities": "PET/CT", "description": "PET/CT FDG Whole Body", "accession": "ACC-300041", - "instances": 620 + "instances": 620, + "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] }, { "patient": "Krishnan, Rajiv", @@ -411,7 +456,8 @@ "modalities": "MR", "description": "MR Shoulder Left", "accession": "ACC-300042", - "instances": 160 + "instances": 160, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Bianchi, Giulia", @@ -420,7 +466,8 @@ "modalities": "CT", "description": "CT Abdomen/Pelvis w/o Contrast", "accession": "ACC-300043", - "instances": 480 + "instances": 480, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Svensson, Nora", @@ -429,7 +476,8 @@ "modalities": "US", "description": "US Gallbladder RUQ", "accession": "ACC-300044", - "instances": 50 + "instances": 50, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Svensson, Nora", @@ -438,7 +486,8 @@ "modalities": "XR", "description": "XR Hip Left", "accession": "ACC-300045", - "instances": 3 + "instances": 3, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Wei, Jing", @@ -447,7 +496,8 @@ "modalities": "US", "description": "US Doppler Carotid Bilateral", "accession": "ACC-300046", - "instances": 70 + "instances": 70, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] }, { "patient": "Mensah, Kofi", @@ -456,7 +506,8 @@ "modalities": "NM", "description": "NM HIDA w/ Ejection Fraction", "accession": "ACC-300047", - "instances": 180 + "instances": 180, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Shah, Lila", @@ -465,7 +516,8 @@ "modalities": "MR", "description": "MR Cervical Spine w/o Contrast", "accession": "ACC-300048", - "instances": 150 + "instances": 150, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Laurent, Antoine", @@ -474,7 +526,8 @@ "modalities": "CT", "description": "CT Head w/ and w/o Contrast", "accession": "ACC-300049", - "instances": 540 + "instances": 540, + "workflows": ["Basic Viewer", "Segmentation"] }, { "patient": "Park, Grace", @@ -483,6 +536,7 @@ "modalities": "XR", "description": "XR Foot Left", "accession": "ACC-300050", - "instances": 2 + "instances": 2, + "workflows": ["Basic Viewer", "Segmentation"] } ] diff --git a/platform/ui-next/playground/studylist/types.ts b/platform/ui-next/playground/studylist/types.ts index 667eb0786f6..befa2e22f7f 100644 --- a/platform/ui-next/playground/studylist/types.ts +++ b/platform/ui-next/playground/studylist/types.ts @@ -6,4 +6,6 @@ export type StudyRow = { description: string accession: string instances: number + /** Optional, data-driven list of available workflows for this study */ + workflows?: string[] } From 3cedf08b93d8b66c913d2f91a2c0885e2578d991 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 11:58:24 -0400 Subject: [PATCH 044/172] Added default mode selection --- platform/ui-next/playground/studylist/app.tsx | 22 ++++- .../studylist/panels/panel-content.tsx | 12 ++- .../studylist/panels/panel-default.tsx | 10 +- .../studylist/panels/panel-summary.tsx | 91 +++++++++++++++++-- 4 files changed, 122 insertions(+), 13 deletions(-) diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index ad97d591a07..319b57b7cc5 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -18,6 +18,7 @@ import iconLeftBase from './assets/icon-left-base.svg'; export function App() { const [selected, setSelected] = React.useState(null); const [isPanelOpen, setIsPanelOpen] = React.useState(true); + const [defaultMode, setDefaultMode] = React.useState(null); const previewDefaultSize = React.useMemo(() => { if (typeof window !== 'undefined' && window.innerWidth > 0) { const percent = (315 / window.innerWidth) * 100; @@ -63,6 +64,8 @@ export function App() { setIsPanelOpen(false)} + defaultMode={defaultMode} + onDefaultModeChange={setDefaultMode} /> @@ -73,7 +76,17 @@ export function App() { ); } -function SidePanel({ selected, onClose }: { selected: StudyRow | null; onClose: () => void }) { +function SidePanel({ + selected, + onClose, + defaultMode, + onDefaultModeChange, +}: { + selected: StudyRow | null + onClose: () => void + defaultMode: string | null + onDefaultModeChange: (v: string | null) => void +}) { return (
@@ -99,9 +112,14 @@ function SidePanel({ selected, onClose }: { selected: StudyRow | null; onClose: ) : ( - + )}
diff --git a/platform/ui-next/playground/studylist/panels/panel-content.tsx b/platform/ui-next/playground/studylist/panels/panel-content.tsx index 0070362dadf..621c96d0bcf 100644 --- a/platform/ui-next/playground/studylist/panels/panel-content.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-content.tsx @@ -6,7 +6,15 @@ import { TooltipProvider } from '../../../src/components/Tooltip'; import type { StudyRow } from '../types'; import { Summary } from './panel-summary'; -export function PanelContent({ study }: { study: StudyRow }) { +export function PanelContent({ + study, + defaultMode, + onDefaultModeChange, +}: { + study: StudyRow + defaultMode: string | null + onDefaultModeChange: (v: string | null) => void +}) { const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ id: `preview-${study.accession}-${i}`, @@ -21,7 +29,7 @@ export function PanelContent({ study }: { study: StudyRow }) {
- +
1 Study
diff --git a/platform/ui-next/playground/studylist/panels/panel-default.tsx b/platform/ui-next/playground/studylist/panels/panel-default.tsx index e149ed5f4f3..aedde068dcb 100644 --- a/platform/ui-next/playground/studylist/panels/panel-default.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-default.tsx @@ -1,11 +1,17 @@ import React from 'react'; import { Summary } from './panel-summary'; -export function PanelDefault() { +export function PanelDefault({ + defaultMode, + onDefaultModeChange, +}: { + defaultMode: string | null + onDefaultModeChange: (v: string | null) => void +}) { return ( - + ); } diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index 7a300d2daec..61720a04cc0 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -6,6 +6,12 @@ import patientSummaryIcon from '../assets/PatientStudyList.svg'; import infoIcon from '../assets/info.svg'; import { Icons } from '../../../src/components/Icons/Icons'; import { Button } from '../../../src/components/Button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '../../../src/components/DropdownMenu'; export type SummaryGetters = { name?: (data: T) => React.ReactNode; @@ -449,6 +455,10 @@ type SummaryWorkflowButtonProps = { as?: ElementType; onLaunchBasic?: (data: T) => void; onLaunchSegmentation?: (data: T) => void; + /** Selected default mode label; when set, replaces per-study workflow buttons with a badge */ + defaultMode?: string | null; + /** Updates the default mode label (set or clear) */ + onDefaultModeChange?: (value: string | null) => void; } & Omit, 'onClick'>; const SummaryWorkflowButtonInner = ( @@ -470,13 +480,15 @@ const SummaryWorkflowButtonInner = ( as, onLaunchBasic, onLaunchSegmentation, + defaultMode, + onDefaultModeChange, style, ...rest }: SummaryWorkflowButtonProps, ref: React.ForwardedRef ) => { const { data } = useSummaryContext(); - const computedDisabled = disabled ?? !data; + const computedDisabled = disabled ?? false; // allow default-mode picking even when no data const id = React.useId(); const reasonId = `${id}-reason`; @@ -489,11 +501,16 @@ const SummaryWorkflowButtonInner = ( const mod = String(d.modalities ?? '').toUpperCase(); const flows = [...defaults]; if (mod.includes('US')) flows.push('US Workflow'); - if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) flows.push('TMTV Workflow'); + if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) + flows.push('TMTV Workflow'); return Array.from(new Set(flows)); }, []); - const workflowButtons = React.useMemo(() => getInferredWorkflows(data), [data, getInferredWorkflows]); + const workflowButtons = React.useMemo( + () => getInferredWorkflows(data), + [data, getInferredWorkflows] + ); + const hasDefault = !!(defaultMode && String(defaultMode).trim().length > 0); const handleLaunch = (wfLabel: string) => { if (computedDisabled || !data) return; @@ -502,7 +519,6 @@ const SummaryWorkflowButtonInner = ( if (wfLabel === 'Segmentation') onLaunchSegmentation?.(data); // Generic handler fallback: onClick?.(data); - // For prototype visibility: try { // eslint-disable-next-line no-console console.log('Launch workflow:', wfLabel, { study: data }); @@ -529,6 +545,58 @@ const SummaryWorkflowButtonInner = ( ) : null; + const renderBadge = (labelValue: string) => ( +
+ + {labelValue} + + +
+ ); + + const renderDefaultPicker = () => ( +
+ + + + + e.stopPropagation()} + > + {['Option 1', 'Option 2', 'Option 3'].map(opt => ( + { + e.preventDefault(); + onDefaultModeChange?.(opt); + }} + > + {opt} + + ))} + + +
+ ); + return (
} @@ -553,9 +621,18 @@ const SummaryWorkflowButtonInner = ( )} {iconNode && iconPosition === 'end' ? iconNode : null}
- {data ? ( + + {/* Content area: + - If default mode is chosen => show badge (even when a study is selected) + - Else if no study selected => show "Set Default Mode" picker + - Else (study selected and no default) => show dynamic workflow buttons */} + {hasDefault ? ( + renderBadge(String(defaultMode)) + ) : !data ? ( + renderDefaultPicker() + ) : (
- {workflowButtons.map((wf) => ( + {workflowButtons.map(wf => ( ))}
- ) : null} + )}
); }; From ea968ac7ec170ca42c97eebfcecc57ae9d4923e3 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 12:47:12 -0400 Subject: [PATCH 045/172] Content fixes --- platform/ui-next/playground/studylist/app.tsx | 24 +++++++++++++++- .../playground/studylist/assets/settings.svg | 4 +++ .../studylist/panels/panel-summary.tsx | 28 ++++++++++--------- 3 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 platform/ui-next/playground/studylist/assets/settings.svg diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index 319b57b7cc5..372209cdd6a 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -14,6 +14,8 @@ import { PanelDefault } from './panels/panel-default'; import { PanelContent } from './panels/panel-content'; import { Button } from '../../src/components/Button'; import iconLeftBase from './assets/icon-left-base.svg'; +import settingsIcon from './assets/settings.svg'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../src/components/Dialog'; export function App() { const [selected, setSelected] = React.useState(null); @@ -87,9 +89,22 @@ function SidePanel({ defaultMode: string | null onDefaultModeChange: (v: string | null) => void }) { + const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); return (
-
+
+
+ + + + Settings + + +
+ + + diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index 61720a04cc0..d24a6bf9fd1 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -548,7 +548,7 @@ const SummaryWorkflowButtonInner = ( const renderBadge = (labelValue: string) => (
@@ -581,17 +581,19 @@ const SummaryWorkflowButtonInner = ( align="start" onClick={e => e.stopPropagation()} > - {['Option 1', 'Option 2', 'Option 3'].map(opt => ( - { - e.preventDefault(); - onDefaultModeChange?.(opt); - }} - > - {opt} - - ))} + {['Basic Viewer', 'Segmentation', 'TMTV Workflow', 'US Workflow', 'Preclinical 4D'].map( + opt => ( + { + e.preventDefault(); + onDefaultModeChange?.(opt); + }} + > + {opt} + + ) + )}
@@ -637,7 +639,7 @@ const SummaryWorkflowButtonInner = ( key={String(wf)} variant="ghost" size="sm" - className="h-6 w-32" + className="bg-primary/20 h-6 w-32" disabled={computedDisabled} onClick={() => handleLaunch(String(wf))} > From 1b5c103cf3ebdcff3691f525e481c06bc7811292 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 13:05:47 -0400 Subject: [PATCH 046/172] Add a settings dialog with workflow select --- platform/ui-next/playground/studylist/app.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index 372209cdd6a..ad283613b19 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -16,6 +16,8 @@ import { Button } from '../../src/components/Button'; import iconLeftBase from './assets/icon-left-base.svg'; import settingsIcon from './assets/settings.svg'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../src/components/Dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../src/components/Select'; +import { Label } from '../../src/components/Label'; export function App() { const [selected, setSelected] = React.useState(null); @@ -90,6 +92,7 @@ function SidePanel({ onDefaultModeChange: (v: string | null) => void }) { const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); + const selectId = React.useId(); return (
@@ -119,10 +122,35 @@ function SidePanel({
- + Settings +
+ +
+ +
+
From fc8ab4ae6e107f5f5b2b4810560f40254b5f6c8c Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 27 Oct 2025 13:13:13 -0400 Subject: [PATCH 047/172] Fix workflow button padding --- platform/ui-next/playground/studylist/app.tsx | 58 +++++++++++++------ .../studylist/panels/panel-summary.tsx | 4 +- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index ad283613b19..60321d64678 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -16,7 +16,13 @@ import { Button } from '../../src/components/Button'; import iconLeftBase from './assets/icon-left-base.svg'; import settingsIcon from './assets/settings.svg'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../src/components/Dialog'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../src/components/Select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../../src/components/Select'; import { Label } from '../../src/components/Label'; export function App() { @@ -25,7 +31,7 @@ export function App() { const [defaultMode, setDefaultMode] = React.useState(null); const previewDefaultSize = React.useMemo(() => { if (typeof window !== 'undefined' && window.innerWidth > 0) { - const percent = (315 / window.innerWidth) * 100; + const percent = (325 / window.innerWidth) * 100; return Math.min(Math.max(percent, 15), 50); } return 30; @@ -86,10 +92,10 @@ function SidePanel({ defaultMode, onDefaultModeChange, }: { - selected: StudyRow | null - onClose: () => void - defaultMode: string | null - onDefaultModeChange: (v: string | null) => void + selected: StudyRow | null; + onClose: () => void; + defaultMode: string | null; + onDefaultModeChange: (v: string | null) => void; }) { const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); const selectId = React.useId(); @@ -121,7 +127,10 @@ function SidePanel({ />
- + Settings
- -
+ +
diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index d24a6bf9fd1..7ce34b2c37c 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -633,13 +633,13 @@ const SummaryWorkflowButtonInner = ( ) : !data ? ( renderDefaultPicker() ) : ( -
+
{workflowButtons.map(wf => ( Date: Thu, 30 Oct 2025 10:22:02 -0400 Subject: [PATCH 049/172] StudyList compound component --- platform/ui-next/playground/studylist/app.tsx | 157 ++++-------- .../studylist/cells/launch-menu-cell.tsx | 64 ++--- .../studylist/components/studylist-layout.tsx | 106 ++++++++ .../components/studylist-settings.tsx | 102 ++++++++ .../components/studylist-table-context.tsx | 29 +++ .../ui-next/playground/studylist/index.ts | 6 + .../playground/studylist/study-list-table.tsx | 241 ++++++++++-------- .../studylist/workflows/WorkflowsMenu.tsx | 59 +++++ .../workflows/getAvailableWorkflows.ts | 13 + 9 files changed, 514 insertions(+), 263 deletions(-) create mode 100644 platform/ui-next/playground/studylist/components/studylist-layout.tsx create mode 100644 platform/ui-next/playground/studylist/components/studylist-settings.tsx create mode 100644 platform/ui-next/playground/studylist/components/studylist-table-context.tsx create mode 100644 platform/ui-next/playground/studylist/workflows/WorkflowsMenu.tsx create mode 100644 platform/ui-next/playground/studylist/workflows/getAvailableWorkflows.ts diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index 60321d64678..38a1159fc79 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -1,10 +1,5 @@ import React from 'react'; import { ThemeWrapper } from '../../src/components/ThemeWrapper'; -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from '../../src/components/Resizable'; import { ScrollArea } from '../../src/components/ScrollArea'; import data from './patient-studies.json'; import { StudyListTable } from './study-list-table'; @@ -15,20 +10,16 @@ import { PanelContent } from './panels/panel-content'; import { Button } from '../../src/components/Button'; import iconLeftBase from './assets/icon-left-base.svg'; import settingsIcon from './assets/settings.svg'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../src/components/Dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '../../src/components/Select'; -import { Label } from '../../src/components/Label'; +import { StudylistLayout } from './components/studylist-layout'; +import { StudylistSettingsDialog, useDefaultWorkflow } from './components/studylist-settings'; export function App() { const [selected, setSelected] = React.useState(null); const [isPanelOpen, setIsPanelOpen] = React.useState(true); - const [defaultMode, setDefaultMode] = React.useState(null); + + // Default Workflow with persistence + const [defaultMode, setDefaultMode] = useDefaultWorkflow(); + const previewDefaultSize = React.useMemo(() => { if (typeof window !== 'undefined' && window.innerWidth > 0) { const percent = (325 / window.innerWidth) * 100; @@ -37,50 +28,54 @@ export function App() { return 30; }, []); + const launchWorkflow = React.useCallback((study: StudyRow, workflow: string) => { + // Prototype: log the intent. Replace with navigation as needed. + try { + // eslint-disable-next-line no-console + console.log('Launch workflow:', workflow, { study }); + } catch {} + }, []); + return (
- - +
row.accession} + getRowId={(row) => row.accession} enforceSingleSelection={true} showColumnVisibility={true} title="Study List" isPanelOpen={isPanelOpen} onOpenPanel={() => setIsPanelOpen(true)} - onSelectionChange={rows => setSelected(rows[0] ?? null)} + onSelectionChange={(rows) => setSelected(rows[0] ?? null)} + defaultMode={defaultMode} + onLaunch={launchWorkflow} />
-
+ - {isPanelOpen && ( - <> - - - setIsPanelOpen(false)} - defaultMode={defaultMode} - onDefaultModeChange={setDefaultMode} - /> - - - )} -
+ + setIsPanelOpen(false)} + defaultMode={defaultMode} + onDefaultModeChange={setDefaultMode} + /> + +
); @@ -98,9 +93,10 @@ function SidePanel({ onDefaultModeChange: (v: string | null) => void; }) { const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); - const selectId = React.useId(); + return (
+ {/* Header utility buttons */}
-
- - - - Settings - -
- -
- -
-
-
-
+ defaultMode={defaultMode} + onDefaultModeChange={onDefaultModeChange} + /> + -
+
{selected ? ( ) : ( - + )}
diff --git a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx index f678844c70d..848556ff167 100644 --- a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx +++ b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx @@ -1,54 +1,36 @@ -import * as React from 'react' -import type { Row } from '@tanstack/react-table' -import { Button } from '../../../src/components/Button' -import { DataTableActionOverlayCell } from '../../../src/components/DataTable' -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, -} from '../../../src/components/DropdownMenu' - -function getWorkflowsFromRow(row: Row): string[] { - const defaults = ['Basic Viewer', 'Segmentation'] - const original: any = row.original ?? {} - if (Array.isArray(original.workflows) && original.workflows.length > 0) { - return Array.from(new Set(original.workflows)) - } - const mod = String(original.modalities ?? '').toUpperCase() - const flows = [...defaults] - if (mod.includes('US')) flows.push('US Workflow') - if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) flows.push('TMTV Workflow') - return Array.from(new Set(flows)) -} +import * as React from 'react'; +import type { Row } from '@tanstack/react-table'; +import { DataTableActionOverlayCell } from '../../../src/components/DataTable'; +import { StudylistWorkflowsMenu } from '../workflows/WorkflowsMenu'; +import { useStudylistTableContext } from '../components/studylist-table-context'; export function LaunchMenuCell({ row, value }: { row: Row; value: number }) { - const [open, setOpen] = React.useState(false) - const workflows = getWorkflowsFromRow(row) + const [open, setOpen] = React.useState(false); + const { defaultMode, onLaunch } = useStudylistTableContext(); + + const original: any = row.original ?? {}; + const handleLaunch = (wf: string) => { + onLaunch?.(original, wf); + setOpen(false); + }; return ( {value}
} onActivate={() => { - if (!row.getIsSelected()) row.toggleSelected(true) + if (!row.getIsSelected()) row.toggleSelected(true); }} overlay={ - - - - - e.stopPropagation()}> - {workflows.map((wf) => ( - e.preventDefault()}> - {wf} - - ))} - - +
e.stopPropagation()}> + +
} /> - ) + ); } diff --git a/platform/ui-next/playground/studylist/components/studylist-layout.tsx b/platform/ui-next/playground/studylist/components/studylist-layout.tsx new file mode 100644 index 00000000000..040351e3a11 --- /dev/null +++ b/platform/ui-next/playground/studylist/components/studylist-layout.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from '../../../src/components/Resizable'; +import { Button } from '../../../src/components/Button'; +import iconLeftBase from '../assets/icon-left-base.svg'; + +type LayoutContextValue = { + isPanelOpen: boolean; + openPanel: () => void; + closePanel: () => void; +}; + +const LayoutContext = React.createContext(undefined); + +export function useStudylistLayout() { + const ctx = React.useContext(LayoutContext); + if (!ctx) { + throw new Error('useStudylistLayout must be used within '); + } + return ctx; +} + +type RootProps = { + isPanelOpen: boolean; + onIsPanelOpenChange: (open: boolean) => void; + defaultPreviewSizePercent: number; + minPreviewSizePercent?: number; + children: React.ReactNode; + className?: string; +}; + +function Root({ + isPanelOpen, + onIsPanelOpenChange, + defaultPreviewSizePercent, + minPreviewSizePercent = 15, + children, + className, +}: RootProps) { + const openPanel = React.useCallback(() => onIsPanelOpenChange(true), [onIsPanelOpenChange]); + const closePanel = React.useCallback(() => onIsPanelOpenChange(false), [onIsPanelOpenChange]); + + const value = React.useMemo( + () => ({ isPanelOpen, openPanel, closePanel }), + [isPanelOpen, openPanel, closePanel] + ); + + const kids = React.Children.toArray(children) as React.ReactElement[]; + const tableChild = kids.find(c => (c as any)?.type === TableArea) as React.ReactElement | undefined; + const previewChild = kids.find(c => (c as any)?.type === PreviewArea) as React.ReactElement | undefined; + + return ( + + + + {tableChild ? tableChild.props.children : null} + + {isPanelOpen ? ( + <> + + + {previewChild ? previewChild.props.children : null} + + + ) : null} + + + ); +} + +function TableArea({ children }: { children?: React.ReactNode }) { + return <>{children}; +} + +function PreviewArea({ children }: { children?: React.ReactNode }) { + return <>{children}; +} + +function OpenPreviewButton({ + className, + 'aria-label': ariaLabel = 'Open preview panel', +}: React.HTMLAttributes & { 'aria-label'?: string }) { + const { isPanelOpen, openPanel } = useStudylistLayout(); + if (isPanelOpen) return null; + return ( + + ); +} + +export const StudylistLayout = Object.assign(Root, { + Root, + TableArea, + PreviewArea, + OpenPreviewButton, +}); \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/components/studylist-settings.tsx b/platform/ui-next/playground/studylist/components/studylist-settings.tsx new file mode 100644 index 00000000000..179c504b291 --- /dev/null +++ b/platform/ui-next/playground/studylist/components/studylist-settings.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../src/components/Dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../../../src/components/Select'; +import { Label } from '../../../src/components/Label'; + +export const WORKFLOW_OPTIONS = [ + 'Basic Viewer', + 'Segmentation', + 'TMTV Workflow', + 'US Workflow', + 'Preclinical 4D', +] as const; + +export type DefaultWorkflow = typeof WORKFLOW_OPTIONS[number]; + +export function useDefaultWorkflow(storageKey = 'studylist.defaultWorkflow') { + const [value, setValue] = React.useState(null); + + React.useEffect(() => { + try { + if (typeof window !== 'undefined') { + const raw = window.localStorage.getItem(storageKey); + if (raw) setValue(raw); + } + } catch {} + }, [storageKey]); + + const setAndPersist = React.useCallback( + (next: string | null) => { + setValue(next); + try { + if (typeof window !== 'undefined') { + if (next == null) { + window.localStorage.removeItem(storageKey); + } else { + window.localStorage.setItem(storageKey, next); + } + } + } catch {} + }, + [storageKey] + ); + + return [value, setAndPersist] as const; +} + +type SettingsDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultMode: string | null; + onDefaultModeChange: (value: string | null) => void; +}; + +export function StudylistSettingsDialog({ + open, + onOpenChange, + defaultMode, + onDefaultModeChange, +}: SettingsDialogProps) { + const selectId = React.useId(); + + return ( + + + + Settings + +
+ +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/components/studylist-table-context.tsx b/platform/ui-next/playground/studylist/components/studylist-table-context.tsx new file mode 100644 index 00000000000..e2a142230c0 --- /dev/null +++ b/platform/ui-next/playground/studylist/components/studylist-table-context.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import type { StudyRow } from '../types'; + +type Ctx = { + defaultMode: string | null; + onLaunch?: (study: StudyRow, workflow: string) => void; +}; + +const StudylistTableContext = React.createContext(undefined); + +export function StudylistTableProvider({ + value, + children, +}: { + value: Ctx; + children: React.ReactNode; +}) { + return ( + {children} + ); +} + +export function useStudylistTableContext() { + const ctx = React.useContext(StudylistTableContext); + if (!ctx) { + throw new Error('useStudylistTableContext must be used within StudylistTableProvider'); + } + return ctx; +} \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/index.ts b/platform/ui-next/playground/studylist/index.ts index 6c3f0890ad3..4c7ad9e09e2 100644 --- a/platform/ui-next/playground/studylist/index.ts +++ b/platform/ui-next/playground/studylist/index.ts @@ -2,3 +2,9 @@ export * from './types' export * from './columns' export * from './study-list-table' export { PanelSummary, Summary } from './panels/panel-summary' + +/** Prototype compounds (to be promoted to design system later) */ +export { StudylistLayout } from './components/studylist-layout' +export { useDefaultWorkflow, StudylistSettingsDialog, WORKFLOW_OPTIONS } from './components/studylist-settings' +export { getAvailableWorkflows } from './workflows/getAvailableWorkflows' +export { StudylistWorkflowsMenu } from './workflows/WorkflowsMenu' diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx index dcbf32e620d..57e73706fbb 100644 --- a/platform/ui-next/playground/studylist/study-list-table.tsx +++ b/platform/ui-next/playground/studylist/study-list-table.tsx @@ -22,9 +22,9 @@ import type { StudyRow } from './types'; import ohifLogo from './assets/ohif-logo.svg'; import { Button } from '../../src/components/Button'; import iconLeftBase from './assets/icon-left-base.svg'; +import { StudylistTableProvider } from './components/studylist-table-context'; type Props = { - columns: ColumnDef[] columns: ColumnDef[]; data: StudyRow[]; title?: React.ReactNode; @@ -37,6 +37,11 @@ type Props = { onSelectionChange?: (rows: StudyRow[]) => void; isPanelOpen?: boolean; onOpenPanel?: () => void; + + /** Prototype-only: default workflow label for highlighting */ + defaultMode?: string | null; + /** Prototype-only: centralized launcher for workflow actions */ + onLaunch?: (study: StudyRow, workflow: string) => void; }; export function StudyListTable({ @@ -52,6 +57,8 @@ export function StudyListTable({ onSelectionChange, isPanelOpen, onOpenPanel, + defaultMode = null, + onLaunch, }: Props) { return ( @@ -69,6 +76,8 @@ export function StudyListTable({ tableClassName={tableClassName} isPanelOpen={isPanelOpen} onOpenPanel={onOpenPanel} + defaultMode={defaultMode} + onLaunch={onLaunch} /> ); @@ -80,39 +89,45 @@ function Content({ tableClassName, isPanelOpen, onOpenPanel, + defaultMode = null, + onLaunch, }: { title?: React.ReactNode; showColumnVisibility?: boolean; tableClassName?: string; isPanelOpen?: boolean; onOpenPanel?: () => void; + defaultMode?: string | null; + onLaunch?: (study: StudyRow, workflow: string) => void; }) { const { table, setColumnFilters } = useDataTable(); + return ( -
- {(showColumnVisibility || title) && ( - -
- OHIF Logo -
- {title ? {title} : null} - {showColumnVisibility && ( -
- { - const label = ( - table.getColumn(id)?.columnDef.meta as { label?: string } | undefined - )?.label; - return label ?? id; - }} + +
+ {(showColumnVisibility || title) && ( + +
+ OHIF Logo - {/* Open preview panel button appears when panel is closed; add right padding only when visible */} +
+ {title ? {title} : null} +
+ {showColumnVisibility && ( + { + const label = ( + table.getColumn(id)?.columnDef.meta as { label?: string } | undefined + )?.label; + return label ?? id; + }} + /> + )} {typeof onOpenPanel === 'function' && isPanelOpen === false ? (
) : null}
- )} -
- )} -
- -
- {/* Column widths */} - - {table.getVisibleLeafColumns().map(col => { - const meta = col.columnDef.meta as unknown as { fixedWidth?: number | string } | undefined; - const width = meta?.fixedWidth; - return width ? ( - - ) : ( - - ); - })} - - - {table.getHeaderGroups().map(hg => ( - - {hg.headers.map(header => ( - { - const s = header.column.getIsSorted() as false | 'asc' | 'desc'; - return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; - })()} - > - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - setColumnFilters([])} - excludeColumnIds={[]} - /> - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map(row => ( - row.toggleSelected()} - aria-selected={row.getIsSelected()} - className="group cursor-pointer" - > - {row.getVisibleCells().map(cell => ( - + )} +
+ +
+ + {table.getVisibleLeafColumns().map((col) => { + const meta = + (col.columnDef.meta as unknown as { fixedWidth?: number | string } | undefined) ?? + undefined; + const width = meta?.fixedWidth; + return width ? ( + + ) : ( + + ); + })} + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header) => ( + { + const s = header.column.getIsSorted() as false | 'asc' | 'desc'; + return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; + })()} > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + ))} - )) - ) : ( - - - No results. - - - )} - -
-
+ ))} + + + setColumnFilters([])} + excludeColumnIds={[]} + /> + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="group cursor-pointer" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + row.toggleSelected(); + } + }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + + + +
-
+ ); } diff --git a/platform/ui-next/playground/studylist/workflows/WorkflowsMenu.tsx b/platform/ui-next/playground/studylist/workflows/WorkflowsMenu.tsx new file mode 100644 index 00000000000..ceee8bcd97f --- /dev/null +++ b/platform/ui-next/playground/studylist/workflows/WorkflowsMenu.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { Button } from '../../../src/components/Button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '../../../src/components/DropdownMenu'; +import { getAvailableWorkflows } from './getAvailableWorkflows'; + +type Props = { + /** Optional explicit workflows; if omitted, `modalities` is used to infer. */ + workflows?: string[]; + modalities?: string; + defaultMode?: string | null; + onLaunch?: (workflow: string) => void; + align?: 'start' | 'end' | 'center'; +}; + +export function StudylistWorkflowsMenu({ + workflows, + modalities, + defaultMode, + onLaunch, + align = 'end', +}: Props) { + const [open, setOpen] = React.useState(false); + const items = React.useMemo( + () => (workflows && workflows.length ? workflows : getAvailableWorkflows({ workflows, modalities })), + [workflows, modalities] + ); + + return ( + + + + + e.stopPropagation()}> + {items.map((wf) => { + const isDefault = defaultMode != null && String(defaultMode) === String(wf); + return ( + { + e.preventDefault(); + onLaunch?.(String(wf)); + }} + className={isDefault ? 'font-semibold' : undefined} + aria-current={isDefault ? 'true' : undefined} + > + {isDefault ? '✓ ' : null} + {wf} + + ); + })} + + + ); +} \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/workflows/getAvailableWorkflows.ts b/platform/ui-next/playground/studylist/workflows/getAvailableWorkflows.ts new file mode 100644 index 00000000000..76154f4e491 --- /dev/null +++ b/platform/ui-next/playground/studylist/workflows/getAvailableWorkflows.ts @@ -0,0 +1,13 @@ +export function getAvailableWorkflows(input: { workflows?: string[]; modalities?: string } | null) { + const defaults = ['Basic Viewer', 'Segmentation']; + if (!input) return defaults; + const { workflows, modalities } = input; + if (Array.isArray(workflows) && workflows.length > 0) { + return Array.from(new Set(workflows)); + } + const mod = String(modalities ?? '').toUpperCase(); + const flows = [...defaults]; + if (mod.includes('US')) flows.push('US Workflow'); + if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) flows.push('TMTV Workflow'); + return Array.from(new Set(flows)); +} \ No newline at end of file From 3491ef2a97f08ae2b49257ab860403f562cdacca Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 30 Oct 2025 12:18:01 -0400 Subject: [PATCH 050/172] Added StudyList component --- platform/ui-next/StudyList/EmptyPanel.tsx | 22 ++ platform/ui-next/StudyList/PreviewPanel.tsx | 62 +++++ platform/ui-next/StudyList/SettingsDialog.tsx | 56 ++++ platform/ui-next/StudyList/StudyList.tsx | 171 +++++++++++++ .../ui-next/StudyList/StudyListColumns.tsx | 92 +++++++ .../StudyList/StudyListInstancesCell.tsx | 37 +++ platform/ui-next/StudyList/StudyListTable.tsx | 239 ++++++++++++++++++ platform/ui-next/StudyList/StudyListTypes.ts | 13 + platform/ui-next/StudyList/TableContext.tsx | 28 ++ platform/ui-next/StudyList/WorkflowsInfer.ts | 45 ++++ platform/ui-next/StudyList/WorkflowsMenu.tsx | 60 +++++ platform/ui-next/StudyList/index.ts | 12 + .../ui-next/StudyList/useDefaultWorkflow.ts | 51 ++++ 13 files changed, 888 insertions(+) create mode 100644 platform/ui-next/StudyList/EmptyPanel.tsx create mode 100644 platform/ui-next/StudyList/PreviewPanel.tsx create mode 100644 platform/ui-next/StudyList/SettingsDialog.tsx create mode 100644 platform/ui-next/StudyList/StudyList.tsx create mode 100644 platform/ui-next/StudyList/StudyListColumns.tsx create mode 100644 platform/ui-next/StudyList/StudyListInstancesCell.tsx create mode 100644 platform/ui-next/StudyList/StudyListTable.tsx create mode 100644 platform/ui-next/StudyList/StudyListTypes.ts create mode 100644 platform/ui-next/StudyList/TableContext.tsx create mode 100644 platform/ui-next/StudyList/WorkflowsInfer.ts create mode 100644 platform/ui-next/StudyList/WorkflowsMenu.tsx create mode 100644 platform/ui-next/StudyList/index.ts create mode 100644 platform/ui-next/StudyList/useDefaultWorkflow.ts diff --git a/platform/ui-next/StudyList/EmptyPanel.tsx b/platform/ui-next/StudyList/EmptyPanel.tsx new file mode 100644 index 00000000000..94c8140fa06 --- /dev/null +++ b/platform/ui-next/StudyList/EmptyPanel.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Summary } from '../playground/studylist/panels/panel-summary'; +import type { WorkflowId } from './WorkflowsInfer'; + +export function EmptyPanel({ + defaultMode, + onDefaultModeChange, +}: { + defaultMode: WorkflowId | null; + onDefaultModeChange: (v: WorkflowId | null) => void; +}) { + return ( + + + {/* Casting to any since panel-summary is prototype-only and untyped */} + + + ); +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/PreviewPanel.tsx b/platform/ui-next/StudyList/PreviewPanel.tsx new file mode 100644 index 00000000000..6ea4c516a04 --- /dev/null +++ b/platform/ui-next/StudyList/PreviewPanel.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { Thumbnail } from '../src/components/Thumbnail'; +import { TooltipProvider } from '../src/components/Tooltip'; +import type { StudyRow } from './StudyListTypes'; +import type { WorkflowId } from './WorkflowsInfer'; +import { Summary } from '../playground/studylist/panels/panel-summary'; + +export function PreviewPanel({ + study, + defaultMode, + onDefaultModeChange, +}: { + study: StudyRow; + defaultMode: WorkflowId | null; + onDefaultModeChange: (v: WorkflowId | null) => void; +}) { + const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); + const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ + id: `preview-${study.accession}-${i}`, + description: `Series ${i + 1}`, + seriesNumber: i + 1, + numInstances: 1, + })); + + return ( + + +
+ + + {/* Casting to any since panel-summary is prototype-only and untyped */} + + +
+ 1 Study +
+
+ {thumbnails.map((item) => ( + {}} + onDoubleClick={() => {}} + viewPreset="thumbnails" + /> + ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/SettingsDialog.tsx b/platform/ui-next/StudyList/SettingsDialog.tsx new file mode 100644 index 00000000000..2867eb22786 --- /dev/null +++ b/platform/ui-next/StudyList/SettingsDialog.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../src/components/Dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../src/components/Select'; +import { Label } from '../src/components/Label'; +import { ALL_WORKFLOW_OPTIONS, type WorkflowId } from './WorkflowsInfer'; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultMode: WorkflowId | null; + onDefaultModeChange: (value: WorkflowId | null) => void; +}; + +export function SettingsDialog({ open, onOpenChange, defaultMode, onDefaultModeChange }: Props) { + const selectId = React.useId(); + return ( + + + + Settings + +
+ +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/StudyList.tsx b/platform/ui-next/StudyList/StudyList.tsx new file mode 100644 index 00000000000..96ffe496b50 --- /dev/null +++ b/platform/ui-next/StudyList/StudyList.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { ScrollArea } from '../src/components/ScrollArea'; +import { Button } from '../src/components/Button'; +import settingsIcon from '../playground/studylist/assets/settings.svg'; +import iconLeftBase from '../playground/studylist/assets/icon-left-base.svg'; +import ohifLogo from '../playground/studylist/assets/ohif-logo.svg'; + +import type { StudyRow } from './StudyListTypes'; +import { StudyListColumns } from './StudyListColumns'; +import { StudyListTable } from './StudyListTable'; +import { PreviewPanel } from './PreviewPanel'; +import { EmptyPanel } from './EmptyPanel'; +import { SettingsDialog } from './SettingsDialog'; +import { useDefaultWorkflow } from './useDefaultWorkflow'; +import { StudylistLayout } from '../playground/studylist/components/studylist-layout'; +import { ALL_WORKFLOW_OPTIONS, type WorkflowId } from './WorkflowsInfer'; +import { StudyListTableProvider } from './TableContext'; + +type Props = { + data: StudyRow[]; + columns?: ColumnDef[]; + title?: React.ReactNode; + getRowId?: (row: StudyRow, index: number) => string; + enforceSingleSelection?: boolean; + showColumnVisibility?: boolean; + tableClassName?: string; + onLaunch?: (study: StudyRow, workflow: WorkflowId) => void; +}; + +export function StudyList({ + data, + columns = StudyListColumns, + title = 'Study List', + getRowId = (row) => row.accession, + enforceSingleSelection = true, + showColumnVisibility = true, + tableClassName, + onLaunch, +}: Props) { + const [selected, setSelected] = React.useState(null); + const [isPanelOpen, setIsPanelOpen] = React.useState(true); + const [defaultMode, setDefaultMode] = useDefaultWorkflow( + 'studylist.defaultWorkflow', + ALL_WORKFLOW_OPTIONS + ); + + const previewDefaultSize = React.useMemo(() => { + if (typeof window !== 'undefined' && window.innerWidth > 0) { + const percent = (325 / window.innerWidth) * 100; + return Math.min(Math.max(percent, 15), 50); + } + return 30; + }, []); + + const launchWorkflow = React.useCallback( + (study: StudyRow, workflow: WorkflowId) => { + onLaunch?.(study, workflow); + try { + // eslint-disable-next-line no-console + console.log('Launch workflow:', workflow, { study }); + } catch {} + }, + [onLaunch] + ); + + const toolbarLeft = ( + OHIF Logo + ); + + return ( + + +
+
+
+ + setIsPanelOpen(true)} + onSelectionChange={(rows) => setSelected(rows[0] ?? null)} + tableClassName={tableClassName} + toolbarLeft={toolbarLeft} + renderOpenPanelButton={({ onOpenPanel }) => ( + + )} + /> + +
+
+
+
+ + + setIsPanelOpen(false)} + defaultMode={defaultMode} + onDefaultModeChange={setDefaultMode} + /> + +
+ ); +} + +function SidePanel({ + selected, + onClose, + defaultMode, + onDefaultModeChange, +}: { + selected: StudyRow | null; + onClose: () => void; + defaultMode: WorkflowId | null; + onDefaultModeChange: (v: WorkflowId | null) => void; +}) { + const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); + + return ( +
+
+ + +
+ + + + +
+ {selected ? ( + + ) : ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/StudyListColumns.tsx b/platform/ui-next/StudyList/StudyListColumns.tsx new file mode 100644 index 00000000000..11161256002 --- /dev/null +++ b/platform/ui-next/StudyList/StudyListColumns.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { DataTableColumnHeader } from '../src/components/DataTable'; +import type { StudyRow } from './StudyListTypes'; +import { StudyListInstancesCell } from './StudyListInstancesCell'; + +export const StudyListColumns: ColumnDef[] = [ + { + accessorKey: 'patient', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('patient')}
, + meta: { + label: 'Patient', + headerClassName: 'w-[165px] min-w-[165px] max-w-[165px]', + cellClassName: 'w-[165px] min-w-[165px] max-w-[165px]', + fixedWidth: 165, + }, + }, + { + accessorKey: 'mrn', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('mrn')}
, + meta: { + label: 'MRN', + headerClassName: 'w-[120px] min-w-[120px] max-w-[120px]', + cellClassName: 'w-[120px] min-w-[120px] max-w-[120px]', + fixedWidth: 120, + }, + }, + { + accessorKey: 'studyDateTime', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('studyDateTime')}
, + sortingFn: (a, b, colId) => { + const norm = (s: string) => new Date(s.replace(' ', 'T')).getTime() || 0; + return norm(a.getValue(colId) as string) - norm(b.getValue(colId) as string); + }, + meta: { + label: 'Study Date', + headerClassName: 'w-[150px] min-w-[150px] max-w-[150px]', + cellClassName: 'w-[150px] min-w-[150px] max-w-[150px]', + fixedWidth: 150, + }, + }, + { + accessorKey: 'modalities', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('modalities')}
, + meta: { + label: 'Modalities', + headerClassName: 'w-[85px] min-w-[85px] max-w-[85px]', + cellClassName: 'w-[85px] min-w-[85px] max-w-[85px]', + fixedWidth: 85, + }, + }, + { + accessorKey: 'description', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('description')}
, + meta: { + label: 'Description', + headerClassName: 'min-w-[290px]', + cellClassName: 'min-w-[290px]', + }, + }, + { + accessorKey: 'accession', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('accession')}
, + meta: { + label: 'Accession', + headerClassName: 'w-[140px] min-w-[140px] max-w-[140px]', + cellClassName: 'w-[140px] min-w-[140px] max-w-[140px]', + fixedWidth: 140, + }, + }, + { + accessorKey: 'instances', + header: ({ column }) => , + cell: ({ row }) => { + const value = row.getValue('instances') as number; + return ; + }, + sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), + meta: { + label: 'Instances', + headerClassName: 'w-[90px] min-w-[90px] max-w-[90px]', + cellClassName: 'w-[90px] min-w-[90px] max-w-[90px] overflow-hidden', + fixedWidth: 90, + }, + }, +]; \ No newline at end of file diff --git a/platform/ui-next/StudyList/StudyListInstancesCell.tsx b/platform/ui-next/StudyList/StudyListInstancesCell.tsx new file mode 100644 index 00000000000..45574024881 --- /dev/null +++ b/platform/ui-next/StudyList/StudyListInstancesCell.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import type { Row } from '@tanstack/react-table'; +import { DataTableActionOverlayCell } from '../src/components/DataTable'; +import { WorkflowsMenu } from './WorkflowsMenu'; +import { useStudyListTableContext } from './TableContext'; +import type { WorkflowId } from './WorkflowsInfer'; + +export function StudyListInstancesCell({ row, value }: { row: Row; value: number }) { + const [open, setOpen] = React.useState(false); + const { defaultMode, onLaunch } = useStudyListTableContext(); + const original: any = row.original ?? {}; + + const handleLaunch = (wf: WorkflowId) => { + onLaunch?.(original, wf); + setOpen(false); + }; + + return ( + {value}
} + onActivate={() => { + if (!row.getIsSelected()) row.toggleSelected(true); + }} + overlay={ +
e.stopPropagation()}> + +
+ } + /> + ); +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/StudyListTable.tsx b/platform/ui-next/StudyList/StudyListTable.tsx new file mode 100644 index 00000000000..1ff42142092 --- /dev/null +++ b/platform/ui-next/StudyList/StudyListTable.tsx @@ -0,0 +1,239 @@ +import * as React from 'react'; +import type { ColumnDef, SortingState, VisibilityState } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; +import { + DataTable, + DataTableToolbar, + DataTableTitle, + DataTableFilterRow, + DataTableViewOptions, + useDataTable, +} from '../src/components/DataTable'; +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from '../src/components/Table'; +import { ScrollArea } from '../src/components/ScrollArea'; +import { Button } from '../src/components/Button'; +import type { StudyRow } from './StudyListTypes'; + +type Props = { + columns: ColumnDef[]; + data: StudyRow[]; + title?: React.ReactNode; + getRowId?: (row: StudyRow, index: number) => string; + initialSorting?: SortingState; + initialVisibility?: VisibilityState; + enforceSingleSelection?: boolean; + showColumnVisibility?: boolean; + tableClassName?: string; + onSelectionChange?: (rows: StudyRow[]) => void; + isPanelOpen?: boolean; + onOpenPanel?: () => void; + + /** Slots to decouple visuals from the table for DS migration */ + toolbarLeft?: React.ReactNode; + toolbarRightExtras?: React.ReactNode; + /** Custom "open panel" button renderer (receives onOpenPanel) */ + renderOpenPanelButton?: (args: { onOpenPanel: () => void }) => React.ReactNode; +}; + +export function StudyListTable({ + columns, + data, + title, + getRowId, + initialSorting = [], + initialVisibility = {}, + enforceSingleSelection = true, + showColumnVisibility = true, + tableClassName, + onSelectionChange, + isPanelOpen, + onOpenPanel, + toolbarLeft, + toolbarRightExtras, + renderOpenPanelButton, +}: Props) { + return ( + + data={data} + columns={columns} + getRowId={getRowId} + initialSorting={initialSorting} + initialVisibility={initialVisibility} + enforceSingleSelection={enforceSingleSelection} + onSelectionChange={onSelectionChange} + > + + + ); +} + +function ChevronLeftIcon(props: React.SVGProps) { + return ( + + ); +} + +function Content({ + title, + showColumnVisibility, + tableClassName, + isPanelOpen, + onOpenPanel, + toolbarLeft, + toolbarRightExtras, + renderOpenPanelButton, +}: { + title?: React.ReactNode; + showColumnVisibility?: boolean; + tableClassName?: string; + isPanelOpen?: boolean; + onOpenPanel?: () => void; + toolbarLeft?: React.ReactNode; + toolbarRightExtras?: React.ReactNode; + renderOpenPanelButton?: (args: { onOpenPanel: () => void }) => React.ReactNode; +}) { + const { table, setColumnFilters } = useDataTable(); + + return ( +
+ {(showColumnVisibility || title) && ( + +
{toolbarLeft}
+ {title ? {title} : null} +
+ {showColumnVisibility && ( + { + const label = ( + table.getColumn(id)?.columnDef.meta as { label?: string } | undefined + )?.label; + return label ?? id; + }} + /> + )} + {toolbarRightExtras} + {typeof onOpenPanel === 'function' && isPanelOpen === false ? ( +
+ {renderOpenPanelButton ? ( + renderOpenPanelButton({ onOpenPanel }) + ) : ( + + )} +
+ ) : null} +
+
+ )} +
+ + + + {table.getVisibleLeafColumns().map((col) => { + const meta = + (col.columnDef.meta as unknown as { fixedWidth?: number | string } | undefined) ?? + undefined; + const width = meta?.fixedWidth; + return width ? ( + + ) : ( + + ); + })} + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header) => ( + { + const s = header.column.getIsSorted() as false | 'asc' | 'desc'; + return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; + })()} + > + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + setColumnFilters([])} + excludeColumnIds={[]} + /> + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="group cursor-pointer" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + row.toggleSelected(); + } + }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/StudyListTypes.ts b/platform/ui-next/StudyList/StudyListTypes.ts new file mode 100644 index 00000000000..f8142bc5721 --- /dev/null +++ b/platform/ui-next/StudyList/StudyListTypes.ts @@ -0,0 +1,13 @@ +import type { WorkflowId } from './WorkflowsInfer'; + +export type StudyRow = { + patient: string; + mrn: string; + studyDateTime: string; + modalities: string; + description: string; + accession: string; + instances: number; + /** Optional, data-driven list of available workflows for this study */ + workflows?: WorkflowId[]; +}; \ No newline at end of file diff --git a/platform/ui-next/StudyList/TableContext.tsx b/platform/ui-next/StudyList/TableContext.tsx new file mode 100644 index 00000000000..7c8191f5615 --- /dev/null +++ b/platform/ui-next/StudyList/TableContext.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import type { StudyRow } from './StudyListTypes'; +import type { WorkflowId } from './WorkflowsInfer'; + +type Ctx = { + defaultMode: WorkflowId | null; + onLaunch?: (study: StudyRow, workflow: WorkflowId) => void; +}; + +const StudyListTableContext = React.createContext(undefined); + +export function StudyListTableProvider({ + value, + children, +}: { + value: Ctx; + children: React.ReactNode; +}) { + return {children}; +} + +export function useStudyListTableContext() { + const ctx = React.useContext(StudyListTableContext); + if (!ctx) { + throw new Error('useStudyListTableContext must be used within StudyListTableProvider'); + } + return ctx; +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/WorkflowsInfer.ts b/platform/ui-next/StudyList/WorkflowsInfer.ts new file mode 100644 index 00000000000..414134b66b6 --- /dev/null +++ b/platform/ui-next/StudyList/WorkflowsInfer.ts @@ -0,0 +1,45 @@ +export const DEFAULT_WORKFLOW_OPTIONS = ['Basic Viewer', 'Segmentation'] as const; +export const EXTENDED_WORKFLOW_OPTIONS = ['TMTV Workflow', 'US Workflow', 'Preclinical 4D'] as const; + +/** All workflow options that the UI supports. */ +export const ALL_WORKFLOW_OPTIONS = [ + ...DEFAULT_WORKFLOW_OPTIONS, + ...EXTENDED_WORKFLOW_OPTIONS, +] as const; + +/** Union type of valid workflow identifiers. */ +export type WorkflowId = (typeof ALL_WORKFLOW_OPTIONS)[number]; + +/** + * Infers available workflows from row data (explicit list or modality heuristics). + * Only returns values that are part of ALL_WORKFLOW_OPTIONS to avoid drift. + */ +export function getAvailableWorkflows( + input: { workflows?: readonly (string | WorkflowId)[]; modalities?: string } | null +): WorkflowId[] { + const all = new Set(ALL_WORKFLOW_OPTIONS as readonly string[]); + + if (!input) { + return [...DEFAULT_WORKFLOW_OPTIONS] as WorkflowId[]; + } + + const { workflows, modalities } = input; + + // If row specifies workflows, filter them to the known set + if (Array.isArray(workflows) && workflows.length > 0) { + const filtered = workflows.map(String).filter(w => all.has(w)); + return Array.from(new Set(filtered)) as WorkflowId[]; + } + + // Otherwise, infer from modalities + const mod = String(modalities ?? '').toUpperCase(); + const flows: string[] = [...DEFAULT_WORKFLOW_OPTIONS]; + + if (mod.includes('US')) flows.push('US Workflow'); + if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) { + flows.push('TMTV Workflow'); + } + + const filtered = flows.filter(w => all.has(w)); + return Array.from(new Set(filtered)) as WorkflowId[]; +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/WorkflowsMenu.tsx b/platform/ui-next/StudyList/WorkflowsMenu.tsx new file mode 100644 index 00000000000..89a093bfb9b --- /dev/null +++ b/platform/ui-next/StudyList/WorkflowsMenu.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { Button } from '../src/components/Button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '../src/components/DropdownMenu'; +import { getAvailableWorkflows, type WorkflowId } from './WorkflowsInfer'; + +type Props = { + workflows?: readonly (WorkflowId | string)[]; + modalities?: string; + defaultMode?: WorkflowId | null; + onLaunch?: (workflow: WorkflowId) => void; + align?: 'start' | 'end' | 'center'; +}; + +export function WorkflowsMenu({ + workflows, + modalities, + defaultMode, + onLaunch, + align = 'end', +}: Props) { + const [open, setOpen] = React.useState(false); + const items = React.useMemo( + () => getAvailableWorkflows({ workflows, modalities }), + [workflows, modalities] + ); + + return ( + + + + + e.stopPropagation()}> + {items.map((wf) => { + const isDefault = defaultMode != null && String(defaultMode) === String(wf); + return ( + { + e.preventDefault(); + onLaunch?.(wf); + }} + className={isDefault ? 'font-semibold' : undefined} + aria-current={isDefault ? 'true' : undefined} + > + {isDefault ? '✓ ' : null} + {wf} + + ); + })} + + + ); +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/index.ts b/platform/ui-next/StudyList/index.ts new file mode 100644 index 00000000000..af2e5248259 --- /dev/null +++ b/platform/ui-next/StudyList/index.ts @@ -0,0 +1,12 @@ +export * from './StudyListTypes'; +export * from './StudyListColumns'; +export * from './StudyListTable'; +export * from './StudyListInstancesCell'; +export * from './TableContext'; +export * from './WorkflowsInfer'; +export * from './WorkflowsMenu'; +export * from './SettingsDialog'; +export * from './useDefaultWorkflow'; +export * from './PreviewPanel'; +export * from './EmptyPanel'; +export * from './StudyList'; \ No newline at end of file diff --git a/platform/ui-next/StudyList/useDefaultWorkflow.ts b/platform/ui-next/StudyList/useDefaultWorkflow.ts new file mode 100644 index 00000000000..643a5bc1ec0 --- /dev/null +++ b/platform/ui-next/StudyList/useDefaultWorkflow.ts @@ -0,0 +1,51 @@ +import * as React from 'react'; + +/** + * Persist and retrieve a "default workflow" (or any string union) from localStorage. + * If `allowed` is provided, the returned value is guaranteed to be from the allowed list (or null). + */ +export function useDefaultWorkflow( + storageKey: string = 'studylist.defaultWorkflow', + allowed?: readonly T[] +) { + 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 as readonly string[]).includes(raw)) { + setValue(raw as T); + } else { + setValue(null); + } + } + } + } catch { + // no-op + } + }, [storageKey, allowed]); + + const setAndPersist = React.useCallback( + (next: T | 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 + } + }, + [storageKey, allowed] + ); + + return [value, setAndPersist] as const; +} \ No newline at end of file From 638982f0078d3307f4526b8f578db0ae1f28bb00 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 30 Oct 2025 15:15:58 -0400 Subject: [PATCH 051/172] Prototype updates --- .../StudyList/StudyListInstancesCell.tsx | 2 - platform/ui-next/StudyList/StudyListTypes.ts | 4 +- .../ui-next/playground/studylist/README.md | 453 ++++++++++-------- platform/ui-next/playground/studylist/app.tsx | 16 +- .../studylist/cells/launch-menu-cell.tsx | 5 +- .../components/studylist-settings.tsx | 56 +-- .../components/studylist-table-context.tsx | 5 +- .../playground/studylist/study-list-table.tsx | 13 +- .../ui-next/playground/studylist/types.ts | 6 +- .../studylist/workflows/WorkflowsMenu.tsx | 11 +- .../workflows/getAvailableWorkflows.ts | 14 +- 11 files changed, 314 insertions(+), 271 deletions(-) diff --git a/platform/ui-next/StudyList/StudyListInstancesCell.tsx b/platform/ui-next/StudyList/StudyListInstancesCell.tsx index 45574024881..99bc74f8479 100644 --- a/platform/ui-next/StudyList/StudyListInstancesCell.tsx +++ b/platform/ui-next/StudyList/StudyListInstancesCell.tsx @@ -6,13 +6,11 @@ import { useStudyListTableContext } from './TableContext'; import type { WorkflowId } from './WorkflowsInfer'; export function StudyListInstancesCell({ row, value }: { row: Row; value: number }) { - const [open, setOpen] = React.useState(false); const { defaultMode, onLaunch } = useStudyListTableContext(); const original: any = row.original ?? {}; const handleLaunch = (wf: WorkflowId) => { onLaunch?.(original, wf); - setOpen(false); }; return ( diff --git a/platform/ui-next/StudyList/StudyListTypes.ts b/platform/ui-next/StudyList/StudyListTypes.ts index f8142bc5721..197e57b1986 100644 --- a/platform/ui-next/StudyList/StudyListTypes.ts +++ b/platform/ui-next/StudyList/StudyListTypes.ts @@ -8,6 +8,6 @@ export type StudyRow = { description: string; accession: string; instances: number; - /** Optional, data-driven list of available workflows for this study */ - workflows?: WorkflowId[]; + /** Optional, data-driven list of available workflows for this study (immutable) */ + workflows?: readonly WorkflowId[]; }; \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/README.md b/platform/ui-next/playground/studylist/README.md index 112491a7094..2649b9a5a28 100644 --- a/platform/ui-next/playground/studylist/README.md +++ b/platform/ui-next/playground/studylist/README.md @@ -1,214 +1,289 @@ -# Studylist Prototype +# Study List — Prototype (Playground) + +This directory contains a **self‑contained prototype** of the Study List UX that composes reusable primitives from the **ui‑next design system (DS)**. It also includes a few **prototype‑only compounds** (layout, settings, panels) that exist here to keep the playground isolated and easy to iterate on. + +> Route: open `/studylist` via the playground loader (default route). + +--- + +## Architecture at a glance + +- **Design System (DS)** — stable, reusable primitives (under `platform/ui-next/src/components/*`) and a typed domain package (`platform/ui-next/StudyList/*`) used by products. +- **Prototype (this folder)** — domain assembly and demo: + - Composes DS primitives into a **domain table** (`study-list-table.tsx`) + **layout** + **preview panels**. + - Keeps **prototype‑only** compounds (`Summary` panel API, demo workflows menu) local until promotion. + +### Key DS primitives used here + +- **DataTable**: provider + headless state, column header, filter row, column visibility menu, and overlay action cell. +- **Table**: semantic table elements. +- **UI primitives**: Button, Dialog, Select, Label, ScrollArea, Resizable, Tooltip, Thumbnail, Icons. + +> DS now ships a typed workflow model (`WorkflowId`) and in‑row action cell (canonical: `StudyListInstancesCell`). The prototype keeps its own menu (`StudylistWorkflowsMenu`) to avoid cross‑coupling while we iterate. + +--- + +## File map & responsibilities + + +playground/studylist/ +├─ app.tsx # End-to-end composition (theme, layout, table, preview panel) +├─ entry.tsx # Mounts +├─ index.ts # Prototype exports for local reuse +├─ types.ts # StudyRow shape for the playground +├─ columns.tsx # Column definitions (cells, labels, widths, sort) +├─ study-list-table.tsx # Domain wrapper around DS + toolbar + filter row +├─ patient-studies.json # Demo dataset +│ +├─ components/ +│ ├─ studylist-layout.tsx # Resizable split: table area + preview area +│ ├─ studylist-settings.tsx # Settings dialog + useDefaultWorkflow (prototype version) +│ └─ studylist-table-context.tsx # Context for default workflow + centralized onLaunch +│ +├─ panels/ +│ ├─ panel-summary.tsx # Prototype-only "Summary" compound (namespaced subcomponents) +│ ├─ panel-content.tsx # Preview content w/ thumbnails + Summary +│ └─ panel-default.tsx # Empty/placeholder panel using Summary +│ +├─ workflows/ +│ ├─ getAvailableWorkflows.ts # Heuristics to infer workflows for a study +│ └─ WorkflowsMenu.tsx # "Open in…" dropdown (prototype version) +│ +└─ assets/ # Icons and images used by the playground + +### What lives where? + +- **Prototype (keep here for now)** + - `components/studylist-layout.tsx`: Resizable split + open/close affordances. + - `components/studylist-settings.tsx`: Dialog + `useDefaultWorkflow` *prototype variant*. + - `components/studylist-table-context.tsx`: Context for `defaultMode` and centralized `onLaunch`. + - `panels/*`: `Summary` compound and preview content. + - `workflows/*`: Demo heuristics + prototype menu. + - `study-list-table.tsx`: Domain wrapper around DS `DataTable` (toolbar slots, filter row). + +- **Design System (already provided & used)** + - `src/components/DataTable/*`, `src/components/Table/*`, `src/components/*` (Button, Dialog, etc.). + - Domain package under `platform/ui-next/StudyList/*` (typed workflow model, instances cell, etc.). + +--- + +## Data model + +```ts +// playground/studylist/types.ts +export type StudyRow = { + patient: string + mrn: string + studyDateTime: string + modalities: string + description: string + accession: string + instances: number + workflows?: string[] // prototype uses strings; DS uses a typed union WorkflowId +} +```` -This folder contains a self-contained prototype of the Study List UX, built on top of composable DataTable primitives implemented as compound components in the design system. +> **Note:** The DS defines `WorkflowId` (a string union) and `getAvailableWorkflows` that only returns valid values. The prototype keeps `string[]` for flexibility but the menus/heuristics align with DS labels. -- Route: visit `/studylist` via the playground loader (default route). -- Goal: demonstrate a domain-specific table (StudyListTable) that composes reusable building blocks from `src/components`. +--- -## DataTable – Files, Exports, and Responsibilities +## Usage — compose the table + layout + panel -All reusable pieces live under `platform/ui-next/src/components/DataTable/` and are exported from `index.ts`. +Minimal composition (see `app.tsx` for a full example): -- Provider and hook - - `DataTable.tsx`: Context provider that owns TanStack state (sorting, filters, visibility, selection) and wires `useReactTable`. - - `context.tsx`: Defines `DataTableContext` and `useDataTable()` hook. -- Compound layout helpers - - `Toolbar.tsx`: Simple header container for title and controls. - - `Title.tsx`: Styled title, typically used inside the toolbar. -- Column header and menus - - `ColumnHeader.tsx`: Sortable header control. Accepts `column` from TanStack or a `columnId` when used within the provider. - - `ViewOptions.tsx`: “Columns” menu to toggle column visibility. Requires the provider; supports `getLabel`/`canHide`/`buttonText` props. -- Filters - - `FilterRow.tsx`: Renders a filter input cell for each visible column. Requires the provider. - - Props: `excludeColumnIds?: string[]`, `resetCellId?: string`, `onReset?: () => void`, `renderCell?`, `inputClassName?`. -- Overlay/action cell - - `ActionOverlayCell.tsx`: Encapsulates the “value with hover/selection overlay” pattern. - - `ActionCell.tsx`: Re-exports ActionOverlayCell as `DataTableActionCell` for naming alignment. -- Barrel exports - - `index.ts`: Exposes - - `DataTable`, `useDataTable`, `DataTableToolbar`, `DataTableTitle` - - `DataTableColumnHeader`, `DataTableViewOptions`, `DataTableFilterRow` - - `DataTableActionOverlayCell`, `DataTableActionCell` +```tsx +import { StudylistLayout } from './components/studylist-layout' +import { StudyListTable } from './study-list-table' +import { studyListColumns } from './columns' +import type { StudyRow } from './types' +import data from './patient-studies.json' -### Core APIs +export function App() { + const [selected, setSelected] = React.useState(null) + const [isPanelOpen, setIsPanelOpen] = React.useState(true) + const [defaultMode, setDefaultMode] = useDefaultWorkflow() -- `DataTable` provider - - Props: `data`, `columns`, `getRowId?`, `initialSorting?`, `initialVisibility?`, `enforceSingleSelection?`, `onSelectionChange?`. - - Context: `{ table, sorting, setSorting, columnVisibility, setColumnVisibility, rowSelection, setRowSelection, columnFilters, setColumnFilters, resetFilters }`. + return ( + + + setIsPanelOpen(true)} + onSelectionChange={rows => setSelected(rows[0] ?? null)} + defaultMode={defaultMode} + onLaunch={(study, wf) => console.log('Launch', wf, { study })} + /> + + + + {/* Show Summary-only default or the rich preview */} + + + ) +} +``` -- `DataTableColumnHeader` - - Props: `title`, `align?`, plus either `column` (TanStack Column) or `columnId` when used inside the provider. - - Behavior: toggles sorting and shows ▲/▼/↕ indicator. +--- -- `DataTableViewOptions` - - Props: `getLabel? (id) => string`, `canHide? (id) => boolean`, `buttonText?`. - - Behavior: lists hideable columns and toggles visibility. +## Columns & overlay action pattern -- `DataTableFilterRow` - - Props: `excludeColumnIds?`, `resetCellId?`, `onReset?`, `renderCell?`, `inputClassName?`. - - Behavior: renders an `Input` for each visible column unless excluded; if a column id matches `resetCellId`, a Reset button is shown instead. +- Column headers use `DataTableColumnHeader` for consistent sorting affordances. +- Each column can specify `meta.label` (used by the column visibility menu) and width classes. +- The **instances** column uses the overlay action cell pattern so the “Open in…” menu appears on row focus/selection. -- `DataTableActionOverlayCell` - - Props: `isActive`, `value`, `overlay`, `onActivate?`, `alignRight?`. - - Behavior: hides the value on hover/selection and reveals the overlay control; stops propagation and supports pre-activating selection. -## Usage Patterns +```tsx +// playground/studylist/columns.tsx (excerpt) +{ + accessorKey: 'instances', + header: ({ column }) => , + cell: ({ row }) => , + meta: { label: 'Instances', fixedWidth: 90 } +} +``` -1) Wrap table area with the provider +Overlay cell: ```tsx -import { DataTable, DataTableToolbar, DataTableTitle, DataTableFilterRow, DataTableViewOptions, useDataTable } from '../../src/components/DataTable' -import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '../../src/components/Table' +// playground/studylist/cells/launch-menu-cell.tsx (simplified) +{value}
} + onActivate={() => { if (!row.getIsSelected()) row.toggleSelected(true) }} + overlay={} +/> +``` -export function MyDomainTable({ data, columns }) { - return ( - - - - ) -} +--- -function Content() { - const { table, setColumnFilters } = useDataTable() - return ( -
- - My Table -
- (table.getColumn(id)?.columnDef.meta as { label?: string } | undefined)?.label ?? id} /> -
-
- - - {table.getHeaderGroups().map((hg) => ( - - {hg.headers.map((header) => ( - { - const s = header.column.getIsSorted() as false | 'asc' | 'desc' - return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none' - })()}> - {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} - - ))} - - ))} - - - setColumnFilters([])} /> - {table.getRowModel().rows.map((row) => ( - row.toggleSelected()} aria-selected={row.getIsSelected()} className="group cursor-pointer"> - {row.getVisibleCells().map((cell) => ( - {cell.column.columnDef.cell?.(cell.getContext())} - ))} - - ))} - -
-
- ) -} -``` +## Workflows (prototype) + +- `workflows/getAvailableWorkflows.ts`: If a row provides `workflows`, we use them; otherwise, we infer from `modalities`: + - Always: “Basic Viewer”, “Segmentation”. + - Add “US Workflow” if modalities include **US**. + - Add “TMTV Workflow” if modalities include **PET/CT** (or both **PET** and **CT**). +- `workflows/WorkflowsMenu.tsx`: Dropdown to launch a workflow; highlights the persisted default. + + +> DS ships the same labels with a typed union `WorkflowId`; the prototype menu is intentionally local for iteration, but aligned to DS labels. + +--- + +## Panels & Summary (prototype‑only, planned for promotion) -2) Define columns with `DataTableColumnHeader` +The `panels/panel-summary.tsx` file exports a **`Summary` namespace** with subcomponents to assemble a patient header and action block: ```tsx -import type { ColumnDef } from '@tanstack/react-table' -import { DataTableColumnHeader } from '../../src/components/DataTable' - -export const columns: ColumnDef[] = [ - { - accessorKey: 'patient', - header: ({ column }) => , - meta: { label: 'Patient' }, - }, -] + + + + ``` -3) Use the overlay action cell pattern for inline actions +Slots include `Section`, `Icon`, `Name`, `MRN`, `Meta`, `Field`, `Actions`, `Action`, and `WorkflowButton`. `PanelContent` and `PanelDefault` show how to use `Summary` for both selected and empty states. -```tsx -import { DataTableActionOverlayCell } from '../../src/components/DataTable' +--- -function LaunchMenuCell({ row, value }: { row: any; value: number }) { - return ( - {value}
} - onActivate={() => { if (!row.getIsSelected()) row.toggleSelected(true) }} - overlay={} - /> - ) -} -``` +## Accessibility notes + +- Header cells set `aria-sort` via TanStack’s sort state. +- Overlay menus stop event propagation to preserve row selection semantics. +- Summary actions support `aria-disabled` + hidden screen reader reasons for disabled states. + + +--- + +## Run & Build + +- Dev: start the UI Next dev server and open `/studylist`. +- Production playground bundle: + `yarn --cwd platform/ui-next build:playground` + Output: `platform/ui-next/dist/playground/` +- Optional: `npx serve platform/ui-next/dist/playground` to verify locally before uploading to Netlify. + + +--- + +## Migration status (Steps 1–6) + +**Completed** + +1. **Componentization (Step 1)** + Flattened `StudyList` domain files (no subfolders), clear names, column meta labels, and overlay pattern. + +2. **Design System integration (Step 2)** + Centralized default workflow state + settings dialog pattern; unified table provider usage; context for `onLaunch`. + +3. **Slots & decoupling (Step 4)** + `StudyListTable` now exposes **toolbar slots** (`toolbarLeft`, `toolbarRightExtras`) and a **custom open‑panel button renderer**, making visuals replaceable without forking the table. + +4. **Prototype consolidation (Steps 5–6)** + - Prototype kept `Summary` and the workflows menu locally. + - DS promoted instances action via `StudyListInstancesCell`. + - DS action cell shim removed (legacy `ActionCell.tsx` deleted; alias provided via DS barrel export). + +5. **Final pass (updates)** + - **Tiny cleanup**: minor import and export consistency. + - **Type hardening in DS**: `WorkflowId` union; `getAvailableWorkflows` returns only valid values. + - **Prototype consolidation**: stable local `StudylistWorkflowsMenu` and `Summary` to avoid DS churn. + - **Promotion prep**: DS table accepts toolbar slots; prototype APIs match DS names/labels. + + +--- + +## What can be deleted now? + +- **Already done in DS**: legacy `ActionCell.tsx` was removed; DS barrel re‑exports `ActionCell` as an alias to `StudyListInstancesCell` for backward compatibility. +- **Prototype**: keep all current files; they’re either referenced or intended for promotion. No additional deletions recommended here. + + +--- + +## What will move to the DS next? + +- `panels/panel-summary.tsx` → **Design‑system “Summary” compound** (namespaced subcomponents). +- `workflows/WorkflowsMenu.tsx` and `getAvailableWorkflows.ts` → **replace** with DS `WorkflowsMenu` + `WorkflowsInfer` and adopt `WorkflowId` in `playground/studylist/types.ts`. + + +--- + +## Next steps (promotion checklist) + +1. **Adopt DS types in prototype** + Change `types.ts` to use `WorkflowId[]` for `StudyRow.workflows`. + Validate all call sites (columns, menu, panel) compile cleanly. +2. **Swap to DS workflows menu** + Replace `StudylistWorkflowsMenu` with DS `WorkflowsMenu`; remove prototype `workflows/*`. +3. **Promote Summary** + Move `panels/panel-summary.tsx` into DS as `src/components/Summary/*`. + Add minimal stories/MDX and usage docs. +4. **Remove bridging exports** + After consuming DS equivalents, delete prototype exports in `index.ts` and any now‑unused files. +5. **Docs + stories** + Add Storybook stories for `StudyListTable` with toolbar slots, and for `Summary` in DS. + + +--- + +## Import quick reference + +- DS primitives: `../../src/components/*` +- Domain table wrapper (prototype): `./study-list-table` +- Columns (prototype): `./columns` +- Layout (prototype): `./components/studylist-layout` +- Settings (prototype): `./components/studylist-settings` +- Workflows menu (prototype): `./workflows/WorkflowsMenu` +- Summary (prototype): `./panels/panel-summary` + +--- -## Reusable vs. Domain Split - -- Reusable (design system) - - DataTable provider + primitives in `src/components/DataTable/*`. - - UI primitives: `Table`, `Button`, `DropdownMenu`, `Input`, etc. -- Domain (prototype) - - `study-list-table.tsx`, `columns.tsx`, `cells/launch-menu-cell.tsx`, `panels/*`, `patient-studies.json`, `types.ts`. - -### Panel Summary Compound API - -- `panels/panel-summary.tsx` exports a `Summary` namespace that can render the preview header however you like. The most direct usage is: - ```tsx - - - - - ``` -- Slot breakdown (each slot can be omitted or reordered): - - `Summary.Root({ data, get, className, children })` – supplies context; `get.name`/`get.mrn` let you adapt any data shape. - - `Summary.Section({ variant = 'card' | 'row' | 'ghost', align = 'center', gap, ...divProps })` – layout wrapper with configurable alignment, spacing, and full div props/ref forwarding. - - `Summary.Icon({ src, alt, size, hideWhenEmpty })`, `Summary.Name({ showTitleOnTruncate })`, `Summary.MRN({ prefix, showTitleOnTruncate })`, `Summary.Meta`, `Summary.Field({ of, muted, showTitleOnTruncate })` – compose the identity block you need. - - `Summary.Actions({ direction, justify, wrap, gap })`, `Summary.Action({ as, iconPosition = 'end', disabledReason, ...htmlProps })`, `Summary.WorkflowButton({ iconPosition = 'end', ... })` – build CTAs; actions pass the current `data` to handlers and can render as links or arbitrary elements via `as`. - - `Summary.Empty({ icon, iconSrc, iconAlt, iconSize, section })` – optional placeholder that only renders when `data` is null. - - Legacy helpers `Summary.Patient` (thin wrapper over Section/Name/MRN) and `Summary.Workflows` (**deprecated**, prefer `Summary.WorkflowButton`) remain for quick defaults. -- Example showing reordering, custom getters, and additional metadata: - ```tsx - import patientSummaryIcon from './assets/PatientStudyList.svg' - - s.patient, - mrn: (s) => (s.mrn ? <>MRN: {s.mrn} : ''), - }} - > - Select a study to preview - - launchDefault(s)} /> - - - - -
- - - s.accession} muted /> -
-
-
- ``` - -## Accessibility and Labels - -- Headers should set `aria-sort` based on `column.getIsSorted()`. -- Add `meta.label` to each column; `DataTableViewOptions` uses it to show friendly names. - -## Run - -- Start the UI Next dev server and navigate to `/studylist`. - -## Deploying to Netlify - -- Install workspace deps at repo root (`yarn install`) if you have not already. -- Build the static playground bundle with `yarn --cwd platform/ui-next build:playground`. This runs webpack in production mode against `.webpack/webpack.playground.js`, extracts CSS, and copies any files under `playground/public/` (including `_redirects`) into the output. -- Upload the contents of `platform/ui-next/dist/playground/` to Netlify (or point a Netlify site’s publish directory at that folder). The bundle contains `index.html`, hashed JS/CSS assets, and a `_redirects` file so routes such as `/studylist` stay functional on refresh. -- Optional preflight: run `npx serve platform/ui-next/dist/playground` locally to confirm the build before uploading. - -## Notes - -- Components are context-only where appropriate (no `table` prop for `FilterRow` and `ViewOptions`). -- This follows shadcn’s composable primitives guidance — no monolithic DataTable abstraction. +# **Status:** Prototype is feature‑complete for the demo route. +**Scope to promote:** Summary compound + workflows menu/heuristics, and eventual adoption of `WorkflowId` in prototype types. diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index 38a1159fc79..d2981e33b02 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -12,12 +12,13 @@ import iconLeftBase from './assets/icon-left-base.svg'; import settingsIcon from './assets/settings.svg'; import { StudylistLayout } from './components/studylist-layout'; import { StudylistSettingsDialog, useDefaultWorkflow } from './components/studylist-settings'; +import type { WorkflowId } from '../../StudyList/WorkflowsInfer'; export function App() { const [selected, setSelected] = React.useState(null); const [isPanelOpen, setIsPanelOpen] = React.useState(true); - // Default Workflow with persistence + // Default Workflow with persistence (DS-typed) const [defaultMode, setDefaultMode] = useDefaultWorkflow(); const previewDefaultSize = React.useMemo(() => { @@ -28,7 +29,7 @@ export function App() { return 30; }, []); - const launchWorkflow = React.useCallback((study: StudyRow, workflow: string) => { + const launchWorkflow = React.useCallback((study: StudyRow, workflow: WorkflowId) => { // Prototype: log the intent. Replace with navigation as needed. try { // eslint-disable-next-line no-console @@ -89,8 +90,8 @@ function SidePanel({ }: { selected: StudyRow | null; onClose: () => void; - defaultMode: string | null; - onDefaultModeChange: (v: string | null) => void; + defaultMode: WorkflowId | null; + onDefaultModeChange: (v: WorkflowId | null) => void; }) { const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); @@ -125,10 +126,13 @@ function SidePanel({ key={selected.accession} study={selected} defaultMode={defaultMode} - onDefaultModeChange={onDefaultModeChange} + onDefaultModeChange={(v) => onDefaultModeChange(v as WorkflowId | null)} /> ) : ( - + onDefaultModeChange(v as WorkflowId | null)} + /> )}
diff --git a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx index 848556ff167..6486a90b73b 100644 --- a/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx +++ b/platform/ui-next/playground/studylist/cells/launch-menu-cell.tsx @@ -3,15 +3,14 @@ import type { Row } from '@tanstack/react-table'; import { DataTableActionOverlayCell } from '../../../src/components/DataTable'; import { StudylistWorkflowsMenu } from '../workflows/WorkflowsMenu'; import { useStudylistTableContext } from '../components/studylist-table-context'; +import type { WorkflowId } from '../../../StudyList/WorkflowsInfer'; export function LaunchMenuCell({ row, value }: { row: Row; value: number }) { - const [open, setOpen] = React.useState(false); const { defaultMode, onLaunch } = useStudylistTableContext(); const original: any = row.original ?? {}; - const handleLaunch = (wf: string) => { + const handleLaunch = (wf: WorkflowId) => { onLaunch?.(original, wf); - setOpen(false); }; return ( diff --git a/platform/ui-next/playground/studylist/components/studylist-settings.tsx b/platform/ui-next/playground/studylist/components/studylist-settings.tsx index 179c504b291..9d9642a2ef7 100644 --- a/platform/ui-next/playground/studylist/components/studylist-settings.tsx +++ b/platform/ui-next/playground/studylist/components/studylist-settings.tsx @@ -8,53 +8,27 @@ import { SelectValue, } from '../../../src/components/Select'; import { Label } from '../../../src/components/Label'; +import { ALL_WORKFLOW_OPTIONS, type WorkflowId } from '../../../StudyList/WorkflowsInfer'; +import { useDefaultWorkflow as useDefaultWorkflowDS } from '../../../StudyList/useDefaultWorkflow'; -export const WORKFLOW_OPTIONS = [ - 'Basic Viewer', - 'Segmentation', - 'TMTV Workflow', - 'US Workflow', - 'Preclinical 4D', -] as const; - -export type DefaultWorkflow = typeof WORKFLOW_OPTIONS[number]; +/** Keep the existing export name so playground imports remain unchanged */ +export const WORKFLOW_OPTIONS = ALL_WORKFLOW_OPTIONS; +/** Alias to DS union to avoid churn in playground callers */ +export type DefaultWorkflow = WorkflowId; +/** + * Prototype wrapper around the DS hook to enforce the WorkflowId union and allowed list. + * Keeps the same signature used by the playground. + */ export function useDefaultWorkflow(storageKey = 'studylist.defaultWorkflow') { - const [value, setValue] = React.useState(null); - - React.useEffect(() => { - try { - if (typeof window !== 'undefined') { - const raw = window.localStorage.getItem(storageKey); - if (raw) setValue(raw); - } - } catch {} - }, [storageKey]); - - const setAndPersist = React.useCallback( - (next: string | null) => { - setValue(next); - try { - if (typeof window !== 'undefined') { - if (next == null) { - window.localStorage.removeItem(storageKey); - } else { - window.localStorage.setItem(storageKey, next); - } - } - } catch {} - }, - [storageKey] - ); - - return [value, setAndPersist] as const; + return useDefaultWorkflowDS(storageKey, ALL_WORKFLOW_OPTIONS); } type SettingsDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; - defaultMode: string | null; - onDefaultModeChange: (value: string | null) => void; + defaultMode: WorkflowId | null; + onDefaultModeChange: (value: WorkflowId | null) => void; }; export function StudylistSettingsDialog({ @@ -81,13 +55,13 @@ export function StudylistSettingsDialog({
onDefaultModeChange(value as WorkflowId)} - > - - - - e.stopPropagation()}> - {ALL_WORKFLOW_OPTIONS.map((opt) => ( - - {opt} - - ))} - - -
-
- - - ); -} \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/components/studylist-table-context.tsx b/platform/ui-next/playground/studylist/components/studylist-table-context.tsx deleted file mode 100644 index 01746110cd1..00000000000 --- a/platform/ui-next/playground/studylist/components/studylist-table-context.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; -import type { StudyRow } from '../types'; -import type { WorkflowId } from '../../../StudyList/WorkflowsInfer'; - -type Ctx = { - defaultMode: WorkflowId | null; - onLaunch?: (study: StudyRow, workflow: WorkflowId) => void; -}; - -const StudylistTableContext = React.createContext(undefined); - -export function StudylistTableProvider({ - value, - children, -}: { - value: Ctx; - children: React.ReactNode; -}) { - return ( - {children} - ); -} - -export function useStudylistTableContext() { - const ctx = React.useContext(StudylistTableContext); - if (!ctx) { - throw new Error('useStudylistTableContext must be used within StudylistTableProvider'); - } - return ctx; -} \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/index.ts b/platform/ui-next/playground/studylist/index.ts index 4c7ad9e09e2..d3813d14a9a 100644 --- a/platform/ui-next/playground/studylist/index.ts +++ b/platform/ui-next/playground/studylist/index.ts @@ -1,10 +1,2 @@ -export * from './types' -export * from './columns' -export * from './study-list-table' -export { PanelSummary, Summary } from './panels/panel-summary' - -/** Prototype compounds (to be promoted to design system later) */ -export { StudylistLayout } from './components/studylist-layout' -export { useDefaultWorkflow, StudylistSettingsDialog, WORKFLOW_OPTIONS } from './components/studylist-settings' -export { getAvailableWorkflows } from './workflows/getAvailableWorkflows' -export { StudylistWorkflowsMenu } from './workflows/WorkflowsMenu' +export { PanelSummary, Summary } from './panels/panel-summary'; +export { StudylistLayout } from './components/studylist-layout'; diff --git a/platform/ui-next/playground/studylist/panels/panel-content.tsx b/platform/ui-next/playground/studylist/panels/panel-content.tsx deleted file mode 100644 index 621c96d0bcf..00000000000 --- a/platform/ui-next/playground/studylist/panels/panel-content.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import { Thumbnail } from '../../../src/components/Thumbnail'; -import { TooltipProvider } from '../../../src/components/Tooltip'; -import type { StudyRow } from '../types'; -import { Summary } from './panel-summary'; - -export function PanelContent({ - study, - defaultMode, - onDefaultModeChange, -}: { - study: StudyRow - defaultMode: string | null - onDefaultModeChange: (v: string | null) => void -}) { - const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); - const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ - id: `preview-${study.accession}-${i}`, - description: `Series ${i + 1}`, - seriesNumber: i + 1, - numInstances: 1, - })); - - return ( - - -
- - - - -
1 Study
-
- {thumbnails.map(item => ( - {}} - onDoubleClick={() => {}} - viewPreset="thumbnails" - /> - ))} -
-
-
-
- ); -} diff --git a/platform/ui-next/playground/studylist/panels/panel-default.tsx b/platform/ui-next/playground/studylist/panels/panel-default.tsx deleted file mode 100644 index aedde068dcb..00000000000 --- a/platform/ui-next/playground/studylist/panels/panel-default.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { Summary } from './panel-summary'; - -export function PanelDefault({ - defaultMode, - onDefaultModeChange, -}: { - defaultMode: string | null - onDefaultModeChange: (v: string | null) => void -}) { - return ( - - - - - ); -} diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index 44731a1c906..45b8b4b2fce 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { ElementType } from 'react'; -import type { StudyRow } from '../types'; +import type { StudyRow } from '../../../StudyList/StudyListTypes'; import { cn } from '../../../src/lib/utils'; import patientSummaryIcon from '../assets/PatientStudyList.svg'; import infoIcon from '../assets/info.svg'; diff --git a/platform/ui-next/playground/studylist/study-list-table.tsx b/platform/ui-next/playground/studylist/study-list-table.tsx deleted file mode 100644 index 92f8509dc30..00000000000 --- a/platform/ui-next/playground/studylist/study-list-table.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import * as React from 'react'; -import type { ColumnDef, SortingState, VisibilityState } from '@tanstack/react-table'; -import { flexRender } from '@tanstack/react-table'; -import { - DataTable, - DataTableToolbar, - DataTableTitle, - DataTableFilterRow, - DataTableViewOptions, - useDataTable, -} from '../../src/components/DataTable'; -import { - Table, - TableHeader, - TableBody, - TableHead, - TableRow, - TableCell, -} from '../../src/components/Table'; -import { ScrollArea } from '../../src/components/ScrollArea'; -import type { StudyRow } from './types'; -import ohifLogo from './assets/ohif-logo.svg'; -import { Button } from '../../src/components/Button'; -import iconLeftBase from './assets/icon-left-base.svg'; -import { StudylistTableProvider } from './components/studylist-table-context'; -import type { WorkflowId } from '../../StudyList/WorkflowsInfer'; - -type Props = { - columns: ColumnDef[]; - data: StudyRow[]; - title?: React.ReactNode; - getRowId?: (row: StudyRow, index: number) => string; - initialSorting?: SortingState; - initialVisibility?: VisibilityState; - enforceSingleSelection?: boolean; - showColumnVisibility?: boolean; - tableClassName?: string; - onSelectionChange?: (rows: StudyRow[]) => void; - isPanelOpen?: boolean; - onOpenPanel?: () => void; - - /** Prototype-only: default workflow label for highlighting (DS-typed) */ - defaultMode?: WorkflowId | null; - /** Prototype-only: centralized launcher for workflow actions (DS-typed) */ - onLaunch?: (study: StudyRow, workflow: WorkflowId) => void; -}; - -export function StudyListTable({ - columns, - data, - title, - getRowId, - initialSorting = [], - initialVisibility = {}, - enforceSingleSelection = true, - showColumnVisibility = true, - tableClassName, - onSelectionChange, - isPanelOpen, - onOpenPanel, - defaultMode = null, - onLaunch, -}: Props) { - return ( - - data={data} - columns={columns} - getRowId={getRowId} - initialSorting={initialSorting} - initialVisibility={initialVisibility} - enforceSingleSelection={enforceSingleSelection} - onSelectionChange={onSelectionChange} - > - - - ); -} - -function Content({ - title, - showColumnVisibility, - tableClassName, - isPanelOpen, - onOpenPanel, - defaultMode = null, - onLaunch, -}: { - title?: React.ReactNode; - showColumnVisibility?: boolean; - tableClassName?: string; - isPanelOpen?: boolean; - onOpenPanel?: () => void; - defaultMode?: WorkflowId | null; - onLaunch?: (study: StudyRow, workflow: WorkflowId) => void; -}) { - const { table, setColumnFilters } = useDataTable(); - - return ( - -
- {(showColumnVisibility || title) && ( - -
- OHIF Logo -
- {title ? {title} : null} -
- {showColumnVisibility && ( - { - const label = ( - table.getColumn(id)?.columnDef.meta as { label?: string } | undefined - )?.label; - return label ?? id; - }} - /> - )} - {typeof onOpenPanel === 'function' && isPanelOpen === false ? ( -
- -
- ) : null} -
-
- )} -
- - - - {table.getVisibleLeafColumns().map((col) => { - const meta = - (col.columnDef.meta as unknown as { fixedWidth?: number | string } | undefined) ?? - undefined; - const width = meta?.fixedWidth; - return width ? ( - - ) : ( - - ); - })} - - - {table.getHeaderGroups().map((hg) => ( - - {hg.headers.map((header) => ( - { - const s = header.column.getIsSorted() as false | 'asc' | 'desc'; - return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; - })()} - > - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - setColumnFilters([])} - excludeColumnIds={[]} - /> - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - row.toggleSelected()} - aria-selected={row.getIsSelected()} - className="group cursor-pointer" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - row.toggleSelected(); - } - }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
-
-
-
- ); -} diff --git a/platform/ui-next/playground/studylist/types.ts b/platform/ui-next/playground/studylist/types.ts deleted file mode 100644 index fa71203ae49..00000000000 --- a/platform/ui-next/playground/studylist/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { WorkflowId } from '../../StudyList/WorkflowsInfer'; - -export type StudyRow = { - patient: string - mrn: string - studyDateTime: string - modalities: string - description: string - accession: string - instances: number - /** Optional, data-driven list of available workflows for this study (immutable) */ - workflows?: readonly WorkflowId[] -} diff --git a/platform/ui-next/playground/studylist/workflows/WorkflowsMenu.tsx b/platform/ui-next/playground/studylist/workflows/WorkflowsMenu.tsx deleted file mode 100644 index 8bde40ccc71..00000000000 --- a/platform/ui-next/playground/studylist/workflows/WorkflowsMenu.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react'; -import { Button } from '../../../src/components/Button'; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, -} from '../../../src/components/DropdownMenu'; -import { getAvailableWorkflows } from './getAvailableWorkflows'; -import type { WorkflowId } from '../../../StudyList/WorkflowsInfer'; - -type Props = { - /** Optional explicit workflows; if omitted, `modalities` is used to infer. */ - workflows?: readonly (WorkflowId | string)[]; - modalities?: string; - defaultMode?: WorkflowId | null; - onLaunch?: (workflow: WorkflowId) => void; - align?: 'start' | 'end' | 'center'; -}; - -export function StudylistWorkflowsMenu({ - workflows, - modalities, - defaultMode, - onLaunch, - align = 'end', -}: Props) { - const [open, setOpen] = React.useState(false); - const items = React.useMemo( - () => getAvailableWorkflows({ workflows, modalities }), - [workflows, modalities] - ); - - return ( - - - - - e.stopPropagation()}> - {items.map((wf) => { - const isDefault = defaultMode != null && String(defaultMode) === String(wf); - return ( - { - e.preventDefault(); - onLaunch?.(wf); - }} - className={isDefault ? 'font-semibold' : undefined} - aria-current={isDefault ? 'true' : undefined} - > - {isDefault ? '✓ ' : null} - {wf} - - ); - })} - - - ); -} \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/workflows/getAvailableWorkflows.ts b/platform/ui-next/playground/studylist/workflows/getAvailableWorkflows.ts deleted file mode 100644 index 9ad32dd9261..00000000000 --- a/platform/ui-next/playground/studylist/workflows/getAvailableWorkflows.ts +++ /dev/null @@ -1 +0,0 @@ -export { getAvailableWorkflows } from '../../../StudyList/WorkflowsInfer'; \ No newline at end of file From 3b544e82dc2274fc3305406e9c3381c6b182fb24 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 30 Oct 2025 16:00:49 -0400 Subject: [PATCH 053/172] Remove legacy files --- .../playground/patient-studies-dupe.json | 182 ------------------ .../playground/patient-studies-small.json | 38 ---- platform/ui-next/playground/studylist/app.tsx | 8 - .../ui-next/playground/studylist/index.ts | 2 - 4 files changed, 230 deletions(-) delete mode 100644 platform/ui-next/playground/patient-studies-dupe.json delete mode 100644 platform/ui-next/playground/patient-studies-small.json delete mode 100644 platform/ui-next/playground/studylist/index.ts diff --git a/platform/ui-next/playground/patient-studies-dupe.json b/platform/ui-next/playground/patient-studies-dupe.json deleted file mode 100644 index ee2bc21a36c..00000000000 --- a/platform/ui-next/playground/patient-studies-dupe.json +++ /dev/null @@ -1,182 +0,0 @@ -[ - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - }, - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - }, - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - }, - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - }, - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - } -] diff --git a/platform/ui-next/playground/patient-studies-small.json b/platform/ui-next/playground/patient-studies-small.json deleted file mode 100644 index 94eea1ecd2b..00000000000 --- a/platform/ui-next/playground/patient-studies-small.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "patient": "John Doe", - "mrn": "MRN001234", - "studyDateTime": "2025-06-14 09:32", - "modalities": "CT", - "description": "Chest CT w/ Contrast", - "accession": "ACC-102938", - "instances": 324 - }, - { - "patient": "Jane Smith", - "mrn": "MRN007891", - "studyDateTime": "2025-06-13 14:05", - "modalities": "MR", - "description": "Brain MRI", - "accession": "ACC-564738", - "instances": 210 - }, - { - "patient": "Carlos Ruiz", - "mrn": "MRN003456", - "studyDateTime": "2025-06-12 11:48", - "modalities": "US", - "description": "Abdominal Ultrasound", - "accession": "ACC-223344", - "instances": 58 - }, - { - "patient": "Amina Khan", - "mrn": "MRN005432", - "studyDateTime": "2025-06-11 08:21", - "modalities": "PET/CT", - "description": "Whole Body PET/CT", - "accession": "ACC-998877", - "instances": 512 - } -] diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index 4925ac17cdc..c677211620e 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { createRoot } from 'react-dom/client'; import { ThemeWrapper } from '../../src/components/ThemeWrapper'; import data from './patient-studies.json'; import { StudyList, type StudyRow, type WorkflowId } from '../../StudyList'; @@ -20,10 +19,3 @@ export function App() { ); } - -// In case this file is mounted directly (dev convenience) -const container = document.getElementById('root'); -if (container) { - const root = createRoot(container); - root.render(); -} diff --git a/platform/ui-next/playground/studylist/index.ts b/platform/ui-next/playground/studylist/index.ts deleted file mode 100644 index d3813d14a9a..00000000000 --- a/platform/ui-next/playground/studylist/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PanelSummary, Summary } from './panels/panel-summary'; -export { StudylistLayout } from './components/studylist-layout'; From 9b7e46b86ded5c0cec2e1a17b53fe97306c4c0d5 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 30 Oct 2025 16:32:36 -0400 Subject: [PATCH 054/172] Icons from ui-next, removed tmp assets --- .../studylist/assets/PatientStudyList.svg | 14 ----- .../studylist/panels/panel-summary.tsx | 46 ++++++-------- .../ui-next/src/components/Icons/Icons.tsx | 2 + .../Icons/Sources/PatientStudyList.tsx | 62 +++++++++++++++++++ 4 files changed, 84 insertions(+), 40 deletions(-) delete mode 100644 platform/ui-next/playground/studylist/assets/PatientStudyList.svg create mode 100644 platform/ui-next/src/components/Icons/Sources/PatientStudyList.tsx diff --git a/platform/ui-next/playground/studylist/assets/PatientStudyList.svg b/platform/ui-next/playground/studylist/assets/PatientStudyList.svg deleted file mode 100644 index 533cbb9e722..00000000000 --- a/platform/ui-next/playground/studylist/assets/PatientStudyList.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/playground/studylist/panels/panel-summary.tsx index 45b8b4b2fce..60d8618efb0 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/playground/studylist/panels/panel-summary.tsx @@ -2,8 +2,6 @@ import React from 'react'; import type { ElementType } from 'react'; import type { StudyRow } from '../../../StudyList/StudyListTypes'; import { cn } from '../../../src/lib/utils'; -import patientSummaryIcon from '../assets/PatientStudyList.svg'; -import infoIcon from '../assets/info.svg'; import { Icons } from '../../../src/components/Icons/Icons'; import { Button } from '../../../src/components/Button'; import { @@ -468,13 +466,7 @@ const SummaryWorkflowButtonInner = ( disabled, disabledReason, className, - icon = ( - - ), + icon = , iconPosition = 'end', iconSize = 18, as, @@ -667,8 +659,7 @@ type SummaryPatientProps = { hideIcon?: boolean; hideName?: boolean; hideMrn?: boolean; - iconAlt?: string; - iconSrc?: string; + icon?: React.ReactNode; align?: SummarySectionProps['align']; gap?: number; variant?: SummarySectionProps['variant']; @@ -680,8 +671,7 @@ function SummaryPatient({ hideIcon, hideName, hideMrn, - iconAlt = '', - iconSrc = patientSummaryIcon, + icon, align, gap, variant, @@ -695,10 +685,16 @@ function SummaryPatient({ > {!hideIcon && ( + className="text-primary" + > + {icon ?? ( + + )} + )}
{!hideName && } @@ -740,18 +736,12 @@ function SummaryWorkflows({ type SummaryEmptyProps = { children?: React.ReactNode; icon?: React.ReactNode; - iconSrc?: string; - iconAlt?: string; - iconSize?: number; section?: SummarySectionProps; }; function SummaryEmpty({ children, icon, - iconSrc = patientSummaryIcon, - iconAlt = '', - iconSize = 33, section, }: SummaryEmptyProps) { const { data } = useSummaryContext(); @@ -767,10 +757,14 @@ function SummaryEmpty({ > {icon ?? ( + size={33} + className="text-primary" + > + + )} {children ?? 'Select a study'} diff --git a/platform/ui-next/src/components/Icons/Icons.tsx b/platform/ui-next/src/components/Icons/Icons.tsx index 5229655bf92..20af8a5df69 100644 --- a/platform/ui-next/src/components/Icons/Icons.tsx +++ b/platform/ui-next/src/components/Icons/Icons.tsx @@ -35,6 +35,7 @@ import MultiplePatients from './Sources/MultiplePatients'; import NavigationPanelReveal from './Sources/NavigationPanelReveal'; import OHIFLogo from './Sources/OHIFLogo'; 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'; @@ -509,6 +510,7 @@ export const Icons = { NavigationPanelReveal, OHIFLogo, Patient, + PatientStudyList, Pin, PinFill, Plus, 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; From 487b6e82ef088ad8ce90befec82f79cc4b2c3dce Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 30 Oct 2025 17:48:05 -0400 Subject: [PATCH 055/172] Migrated PatientSummary to ui-next --- platform/ui-next/StudyList/EmptyPanel.tsx | 15 +- platform/ui-next/StudyList/PreviewPanel.tsx | 15 +- .../PatientSummary/PatientSummary.tsx} | 313 +++++++----------- .../src/components/PatientSummary/index.ts | 1 + 4 files changed, 133 insertions(+), 211 deletions(-) rename platform/ui-next/{playground/studylist/panels/panel-summary.tsx => src/components/PatientSummary/PatientSummary.tsx} (71%) create mode 100644 platform/ui-next/src/components/PatientSummary/index.ts diff --git a/platform/ui-next/StudyList/EmptyPanel.tsx b/platform/ui-next/StudyList/EmptyPanel.tsx index 94c8140fa06..ce0a356cc06 100644 --- a/platform/ui-next/StudyList/EmptyPanel.tsx +++ b/platform/ui-next/StudyList/EmptyPanel.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Summary } from '../playground/studylist/panels/panel-summary'; +import { PatientSummary } from '../src/components/PatientSummary'; import type { WorkflowId } from './WorkflowsInfer'; export function EmptyPanel({ @@ -10,13 +10,12 @@ export function EmptyPanel({ onDefaultModeChange: (v: WorkflowId | null) => void; }) { return ( - - - {/* Casting to any since panel-summary is prototype-only and untyped */} - + + + defaultMode={defaultMode} + onDefaultModeChange={onDefaultModeChange} /> - + ); } \ No newline at end of file diff --git a/platform/ui-next/StudyList/PreviewPanel.tsx b/platform/ui-next/StudyList/PreviewPanel.tsx index 6ea4c516a04..5c248da67a6 100644 --- a/platform/ui-next/StudyList/PreviewPanel.tsx +++ b/platform/ui-next/StudyList/PreviewPanel.tsx @@ -5,7 +5,7 @@ import { Thumbnail } from '../src/components/Thumbnail'; import { TooltipProvider } from '../src/components/Tooltip'; import type { StudyRow } from './StudyListTypes'; import type { WorkflowId } from './WorkflowsInfer'; -import { Summary } from '../playground/studylist/panels/panel-summary'; +import { PatientSummary } from '../src/components/PatientSummary'; export function PreviewPanel({ study, @@ -28,14 +28,13 @@ export function PreviewPanel({
- - - {/* Casting to any since panel-summary is prototype-only and untyped */} - + + + defaultMode={defaultMode} + onDefaultModeChange={onDefaultModeChange} /> - +
1 Study
diff --git a/platform/ui-next/playground/studylist/panels/panel-summary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx similarity index 71% rename from platform/ui-next/playground/studylist/panels/panel-summary.tsx rename to platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index 60d8618efb0..84bde7fef5f 100644 --- a/platform/ui-next/playground/studylist/panels/panel-summary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -1,29 +1,29 @@ -import React from 'react'; +import * as React from 'react'; import type { ElementType } from 'react'; -import type { StudyRow } from '../../../StudyList/StudyListTypes'; -import { cn } from '../../../src/lib/utils'; -import { Icons } from '../../../src/components/Icons/Icons'; -import { Button } from '../../../src/components/Button'; +import { cn } from '../../lib/utils'; +import { Icons } from '../Icons/Icons'; +import { Button } from '../Button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, -} from '../../../src/components/DropdownMenu'; +} from '../DropdownMenu'; -export type SummaryGetters = { +/** Public getters to adapt arbitrary data shapes to the PatientSummary defaults */ +export type PatientSummaryGetters = { name?: (data: T) => React.ReactNode; mrn?: (data: T) => React.ReactNode; }; -type SummaryResolvedGetters = { +type ResolvedGetters = { name: (data: T) => React.ReactNode; mrn: (data: T) => React.ReactNode; }; type SummaryContextValue = { data: T | null; - get: SummaryResolvedGetters; + get: ResolvedGetters; }; const SummaryContext = React.createContext | null>(null); @@ -31,30 +31,30 @@ const SummaryContext = React.createContext | null>( function useSummaryContext() { const context = React.useContext(SummaryContext); if (!context) { - throw new Error('Summary.* components must be used within '); + throw new Error('PatientSummary.* components must be used within '); } return context as SummaryContextValue; } -type SummaryRootProps = { +type RootProps = { data?: T | null; /** @deprecated use `data` instead */ study?: T | null; - get?: SummaryGetters; + get?: PatientSummaryGetters; className?: string; - children: React.ReactNode; + children?: React.ReactNode; }; -function SummaryRoot({ +function Root({ data: dataProp, study, get, className, children, -}: SummaryRootProps) { +}: RootProps) { const data = dataProp ?? study ?? null; - const resolvedGetters = React.useMemo>( + const resolvedGetters = React.useMemo>( () => ({ name: get?.name ?? ((item: T) => ((item as any)?.patient ?? '') as React.ReactNode), mrn: get?.mrn ?? ((item: T) => ((item as any)?.mrn ?? '') as React.ReactNode), @@ -69,13 +69,13 @@ function SummaryRoot( ); } -type SummarySectionProps = React.HTMLAttributes & { +type SectionProps = React.HTMLAttributes & { variant?: 'card' | 'row' | 'ghost'; align?: 'start' | 'center' | 'end' | 'stretch'; gap?: number; }; -const SummarySection = React.forwardRef( +const Section = React.forwardRef( ({ variant = 'card', align = 'center', gap = 3, className, style, children, ...rest }, ref) => { const baseClassMap = { card: 'bg-muted rounded-lg px-4 py-3', @@ -104,10 +104,9 @@ const SummarySection = React.forwardRef( ); } ); +Section.displayName = 'PatientSummarySection'; -SummarySection.displayName = 'SummarySection'; - -type SummaryIconProps = { +type IconProps = { src?: string; alt?: string; size?: number; @@ -116,14 +115,14 @@ type SummaryIconProps = { children?: React.ReactNode; }; -function SummaryIcon({ +function Icon({ src, alt = '', size = 33, className, hideWhenEmpty, children, -}: SummaryIconProps) { +}: IconProps) { if (hideWhenEmpty && !src && !children) { return null; } @@ -154,19 +153,19 @@ function SummaryIcon({ ); } -type SummaryNameProps = { +type NameProps = { placeholder?: React.ReactNode; className?: string; children?: (value: React.ReactNode, data: T | null) => React.ReactNode; showTitleOnTruncate?: boolean; }; -function SummaryName({ +function Name({ placeholder = 'Select a study', className, children, showTitleOnTruncate = true, -}: SummaryNameProps) { +}: NameProps) { const { data, get } = useSummaryContext(); const value = data ? get.name(data) : null; const content = value ?? placeholder; @@ -185,7 +184,7 @@ function SummaryName({ ); } -type SummaryMRNProps = { +type MRNProps = { hideWhenEmpty?: boolean; prefix?: React.ReactNode; className?: string; @@ -193,13 +192,13 @@ type SummaryMRNProps = { showTitleOnTruncate?: boolean; }; -function SummaryMRN({ +function MRN({ hideWhenEmpty = true, prefix, className, children, showTitleOnTruncate = true, -}: SummaryMRNProps) { +}: MRNProps) { const { data, get } = useSummaryContext(); const value = data ? get.mrn(data) : null; @@ -228,7 +227,7 @@ function SummaryMRN({ ); } -function SummaryMeta({ className, children }: { className?: string; children?: React.ReactNode }) { +function Meta({ className, children }: { className?: string; children?: React.ReactNode }) { if (children == null) { return null; } @@ -239,14 +238,14 @@ function SummaryMeta({ className, children }: { className?: string; children?: R ); } -type SummaryActionsProps = React.HTMLAttributes & { +type ActionsProps = React.HTMLAttributes & { direction?: 'column' | 'row'; gap?: number; wrap?: boolean; justify?: 'start' | 'end' | 'between' | 'center'; }; -const SummaryActions = React.forwardRef( +const Actions = React.forwardRef( ( { direction = 'column', @@ -282,10 +281,9 @@ const SummaryActions = React.forwardRef( ); } ); +Actions.displayName = 'PatientSummaryActions'; -SummaryActions.displayName = 'SummaryActions'; - -type SummaryActionOwnProps = { +type ActionOwnProps = { label?: React.ReactNode; icon?: React.ReactNode; onClick?: (data: T | null) => void; @@ -299,10 +297,10 @@ type SummaryActionOwnProps = { iconSize?: number; }; -type SummaryActionProps = SummaryActionOwnProps & - Omit, keyof SummaryActionOwnProps | 'onClick'>; +type ActionProps = ActionOwnProps & + Omit, keyof ActionOwnProps | 'onClick'>; -const SummaryActionInner = ( +const ActionInner = ( { label, icon, @@ -317,7 +315,7 @@ const SummaryActionInner = ( iconSize = 24, style, ...rest - }: SummaryActionProps, + }: ActionProps, ref: React.ForwardedRef ) => { const { data } = useSummaryContext(); @@ -352,10 +350,7 @@ const SummaryActionInner = ( const srOnly = isDisabled && disabledReason ? ( - + {disabledReason} ) : null; @@ -434,14 +429,14 @@ const SummaryActionInner = ( ); }; -type SummaryActionComponentType = ( - props: SummaryActionProps & { ref?: React.Ref } +type ActionComponentType = ( + props: ActionProps & { ref?: React.Ref } ) => React.ReactElement | null; -const SummaryAction = React.forwardRef(SummaryActionInner) as SummaryActionComponentType; -SummaryAction.displayName = 'SummaryAction'; +const Action = React.forwardRef(ActionInner) as ActionComponentType; +Action.displayName = 'PatientSummaryAction'; -type SummaryWorkflowButtonProps = { +type WorkflowButtonProps = { label?: React.ReactNode; onClick?: (data: T) => void; disabled?: boolean; @@ -454,12 +449,12 @@ type SummaryWorkflowButtonProps = { onLaunchBasic?: (data: T) => void; onLaunchSegmentation?: (data: T) => void; /** Selected default mode label; when set, replaces per-study workflow buttons with a badge */ - defaultMode?: string | null; + defaultMode?: M | null; /** Updates the default mode label (set or clear) */ - onDefaultModeChange?: (value: string | null) => void; + onDefaultModeChange?: (value: M | null) => void; } & Omit, 'onClick'>; -const SummaryWorkflowButtonInner = ( +const WorkflowButtonInner = ( { label = 'Launch workflow', onClick, @@ -476,7 +471,7 @@ const SummaryWorkflowButtonInner = ( onDefaultModeChange, style, ...rest - }: SummaryWorkflowButtonProps, + }: WorkflowButtonProps, ref: React.ForwardedRef ) => { const { data } = useSummaryContext(); @@ -488,7 +483,7 @@ const SummaryWorkflowButtonInner = ( const defaults = ['Basic Viewer', 'Segmentation']; if (!d) return defaults; if (Array.isArray(d.workflows) && d.workflows.length > 0) { - return Array.from(new Set(d.workflows)); + return Array.from(new Set(d.workflows.map(String))); } const mod = String(d.modalities ?? '').toUpperCase(); const flows = [...defaults]; @@ -529,10 +524,7 @@ const SummaryWorkflowButtonInner = ( const srOnly = computedDisabled && disabledReason ? ( - + {disabledReason} ) : null; @@ -561,25 +553,18 @@ const SummaryWorkflowButtonInner = (
- - e.stopPropagation()} - > + e.stopPropagation()}> {['Basic Viewer', 'Segmentation', 'TMTV Workflow', 'US Workflow', 'Preclinical 4D'].map( - opt => ( + (opt) => ( { + onSelect={(e) => { e.preventDefault(); - onDefaultModeChange?.(opt); + onDefaultModeChange?.(opt as M); }} > {opt} @@ -594,10 +579,7 @@ const SummaryWorkflowButtonInner = ( return (
} - className={cn( - 'border-border/50 bg-muted w-full rounded-lg px-4 py-3 text-left transition', - className - )} + className={cn('border-border/50 bg-muted w-full rounded-lg px-4 py-3 text-left transition', className)} style={style} aria-disabled={computedDisabled || undefined} aria-describedby={computedDisabled && disabledReason ? reasonId : undefined} @@ -616,17 +598,14 @@ const SummaryWorkflowButtonInner = ( {iconNode && iconPosition === 'end' ? iconNode : null}
- {/* Content area: - - If default mode is chosen => show badge (even when a study is selected) - - Else if no study selected => show "Set Default Mode" picker - - Else (study selected and no default) => show dynamic workflow buttons */} + {/* Content selection logic */} {hasDefault ? ( renderBadge(String(defaultMode)) ) : !data ? ( renderDefaultPicker() ) : (
- {workflowButtons.map(wf => ( + {workflowButtons.map((wf) => ( )} /> @@ -138,10 +142,16 @@ function SidePanel({
@@ -168,4 +178,4 @@ function SidePanel({
); -} \ No newline at end of file +} diff --git a/platform/ui-next/playground/studylist/assets/icon-left-base.svg b/platform/ui-next/playground/studylist/assets/icon-left-base.svg deleted file mode 100644 index 028b596cfdf..00000000000 --- a/platform/ui-next/playground/studylist/assets/icon-left-base.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/platform/ui-next/playground/studylist/assets/info.svg b/platform/ui-next/playground/studylist/assets/info.svg deleted file mode 100644 index 298386820c7..00000000000 --- a/platform/ui-next/playground/studylist/assets/info.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/platform/ui-next/playground/studylist/components/studylist-layout.tsx b/platform/ui-next/playground/studylist/components/studylist-layout.tsx index 040351e3a11..8e2b925b856 100644 --- a/platform/ui-next/playground/studylist/components/studylist-layout.tsx +++ b/platform/ui-next/playground/studylist/components/studylist-layout.tsx @@ -5,7 +5,7 @@ import { ResizableHandle, } from '../../../src/components/Resizable'; import { Button } from '../../../src/components/Button'; -import iconLeftBase from '../assets/icon-left-base.svg'; +import { Icons } from '../../../src/components/Icons'; type LayoutContextValue = { isPanelOpen: boolean; @@ -93,7 +93,10 @@ function OpenPreviewButton({ onClick={openPanel} className={className} > - +
diff --git a/platform/ui-next/StudyList/TableContext.tsx b/platform/ui-next/StudyList/TableContext.tsx deleted file mode 100644 index 7c8191f5615..00000000000 --- a/platform/ui-next/StudyList/TableContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import type { StudyRow } from './StudyListTypes'; -import type { WorkflowId } from './WorkflowsInfer'; - -type Ctx = { - defaultMode: WorkflowId | null; - onLaunch?: (study: StudyRow, workflow: WorkflowId) => void; -}; - -const StudyListTableContext = React.createContext(undefined); - -export function StudyListTableProvider({ - value, - children, -}: { - value: Ctx; - children: React.ReactNode; -}) { - return {children}; -} - -export function useStudyListTableContext() { - const ctx = React.useContext(StudyListTableContext); - if (!ctx) { - throw new Error('useStudyListTableContext must be used within StudyListTableProvider'); - } - return ctx; -} \ No newline at end of file diff --git a/platform/ui-next/StudyList/WorkflowsInfer.ts b/platform/ui-next/StudyList/WorkflowsInfer.ts index 414134b66b6..e449a907726 100644 --- a/platform/ui-next/StudyList/WorkflowsInfer.ts +++ b/platform/ui-next/StudyList/WorkflowsInfer.ts @@ -1,45 +1,7 @@ -export const DEFAULT_WORKFLOW_OPTIONS = ['Basic Viewer', 'Segmentation'] as const; -export const EXTENDED_WORKFLOW_OPTIONS = ['TMTV Workflow', 'US Workflow', 'Preclinical 4D'] as const; - -/** All workflow options that the UI supports. */ -export const ALL_WORKFLOW_OPTIONS = [ - ...DEFAULT_WORKFLOW_OPTIONS, - ...EXTENDED_WORKFLOW_OPTIONS, -] as const; - -/** Union type of valid workflow identifiers. */ -export type WorkflowId = (typeof ALL_WORKFLOW_OPTIONS)[number]; - -/** - * Infers available workflows from row data (explicit list or modality heuristics). - * Only returns values that are part of ALL_WORKFLOW_OPTIONS to avoid drift. - */ -export function getAvailableWorkflows( - input: { workflows?: readonly (string | WorkflowId)[]; modalities?: string } | null -): WorkflowId[] { - const all = new Set(ALL_WORKFLOW_OPTIONS as readonly string[]); - - if (!input) { - return [...DEFAULT_WORKFLOW_OPTIONS] as WorkflowId[]; - } - - const { workflows, modalities } = input; - - // If row specifies workflows, filter them to the known set - if (Array.isArray(workflows) && workflows.length > 0) { - const filtered = workflows.map(String).filter(w => all.has(w)); - return Array.from(new Set(filtered)) as WorkflowId[]; - } - - // Otherwise, infer from modalities - const mod = String(modalities ?? '').toUpperCase(); - const flows: string[] = [...DEFAULT_WORKFLOW_OPTIONS]; - - if (mod.includes('US')) flows.push('US Workflow'); - if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) { - flows.push('TMTV Workflow'); - } - - const filtered = flows.filter(w => all.has(w)); - return Array.from(new Set(filtered)) as WorkflowId[]; -} \ No newline at end of file +export { + DEFAULT_WORKFLOW_OPTIONS, + EXTENDED_WORKFLOW_OPTIONS, + ALL_WORKFLOW_OPTIONS, + type WorkflowId, + getAvailableWorkflows, +} from './headless/workflows-registry'; \ No newline at end of file diff --git a/platform/ui-next/StudyList/columns/defaultColumns.tsx b/platform/ui-next/StudyList/columns/defaultColumns.tsx new file mode 100644 index 00000000000..ac9d5095be1 --- /dev/null +++ b/platform/ui-next/StudyList/columns/defaultColumns.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { DataTableColumnHeader } from '../../src/components/DataTable'; +import type { StudyRow } from '../StudyListTypes'; +import { StudyListInstancesCell } from '../StudyListInstancesCell'; + +export function defaultColumns(): ColumnDef[] { + return [ + { + accessorKey: 'patient', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('patient')}
, + meta: { + label: 'Patient', + headerClassName: 'w-[165px] min-w-[165px] max-w-[165px]', + cellClassName: 'w-[165px] min-w-[165px] max-w-[165px]', + fixedWidth: 165, + }, + }, + { + accessorKey: 'mrn', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('mrn')}
, + meta: { + label: 'MRN', + headerClassName: 'w-[120px] min-w-[120px] max-w-[120px]', + cellClassName: 'w-[120px] min-w-[120px] max-w-[120px]', + fixedWidth: 120, + }, + }, + { + accessorKey: 'studyDateTime', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('studyDateTime')}
, + sortingFn: (a, b, colId) => { + const norm = (s: string) => new Date(s.replace(' ', 'T')).getTime() || 0; + return norm(a.getValue(colId) as string) - norm(b.getValue(colId) as string); + }, + meta: { + label: 'Study Date', + headerClassName: 'w-[150px] min-w-[150px] max-w-[150px]', + cellClassName: 'w-[150px] min-w-[150px] max-w-[150px]', + fixedWidth: 150, + }, + }, + { + accessorKey: 'modalities', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('modalities')}
, + meta: { + label: 'Modalities', + headerClassName: 'w-[85px] min-w-[85px] max-w-[85px]', + cellClassName: 'w-[85px] min-w-[85px] max-w-[85px]', + fixedWidth: 85, + }, + }, + { + accessorKey: 'description', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('description')}
, + meta: { + label: 'Description', + headerClassName: 'min-w-[290px]', + cellClassName: 'min-w-[290px]', + }, + }, + { + accessorKey: 'accession', + header: ({ column }) => , + cell: ({ row }) =>
{row.getValue('accession')}
, + meta: { + label: 'Accession', + headerClassName: 'w-[140px] min-w-[140px] max-w-[140px]', + cellClassName: 'w-[140px] min-w-[140px] max-w-[140px]', + fixedWidth: 140, + }, + }, + { + accessorKey: 'instances', + header: ({ column }) => , + cell: ({ row }) => { + const value = row.getValue('instances') as number; + return ; + }, + sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), + meta: { + label: 'Instances', + headerClassName: 'w-[90px] min-w-[90px] max-w-[90px]', + cellClassName: 'w-[90px] min-w-[90px] max-w-[90px] overflow-hidden', + fixedWidth: 90, + }, + }, + ]; +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/headless/StudyListProvider.tsx b/platform/ui-next/StudyList/headless/StudyListProvider.tsx new file mode 100644 index 00000000000..61c42c59f61 --- /dev/null +++ b/platform/ui-next/StudyList/headless/StudyListProvider.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +/** + * Headless Study List context & hook. + * Provides selection, panel state, default workflow, available workflows and launch action. + * Generic over row type T and workflow type W (string union). + */ + +export type StudyListContextValue = { + rows: T[]; + selected: T | null; + setSelected: (r: T | null) => void; + + isPanelOpen: boolean; + setPanelOpen: (open: boolean) => void; + + defaultWorkflow: W | null; + setDefaultWorkflow: (wf: W | null) => void; + + availableWorkflowsFor: (row: Partial | null | undefined) => readonly W[]; + + launch: (row: T, wf: W) => void; +}; + +export const StudyListContext = React.createContext(undefined); + +export function StudyListProvider({ + value, + children, +}: { + value: StudyListContextValue; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useStudyList() { + const ctx = React.useContext(StudyListContext); + if (!ctx) { + throw new Error('useStudyList must be used within '); + } + return ctx as StudyListContextValue; +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/headless/useStudyList.ts b/platform/ui-next/StudyList/headless/useStudyList.ts new file mode 100644 index 00000000000..ce2970d018c --- /dev/null +++ b/platform/ui-next/StudyList/headless/useStudyList.ts @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { useDefaultWorkflow } from '../useDefaultWorkflow'; +import { + ALL_WORKFLOW_OPTIONS, + getAvailableWorkflows, + type WorkflowId, +} from './workflows-registry'; + +/** + * Builds the headless state for the Study List. + * Keeps selection, panel open state, default workflow, and a launch handler. + */ +export function useStudyListState( + rows: T[], + { + defaultWorkflowKey = 'studylist.defaultWorkflow', + onLaunch, + }: { + defaultWorkflowKey?: string; + onLaunch?: (row: T, wf: W) => void; + } = {} +) { + const [selected, setSelected] = React.useState(null); + const [isPanelOpen, setPanelOpen] = React.useState(true); + const [defaultWorkflow, setDefaultWorkflow] = useDefaultWorkflow( + defaultWorkflowKey, + ALL_WORKFLOW_OPTIONS as unknown as ReadonlyArray + ); + + const launch = React.useCallback( + (row: T, wf: W) => { + onLaunch?.(row, wf); + try { + // eslint-disable-next-line no-console + console.log('Launch workflow:', wf, { study: row }); + } catch {} + }, + [onLaunch] + ); + + return { + rows, + selected, + setSelected, + isPanelOpen, + setPanelOpen, + defaultWorkflow, + setDefaultWorkflow, + availableWorkflowsFor: (r: Partial | null | undefined) => + getAvailableWorkflows((r ?? {}) as any) as readonly W[], + launch, + } as const; +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/headless/workflows-registry.ts b/platform/ui-next/StudyList/headless/workflows-registry.ts new file mode 100644 index 00000000000..006ca1682b8 --- /dev/null +++ b/platform/ui-next/StudyList/headless/workflows-registry.ts @@ -0,0 +1,46 @@ +/** Core + extended workflow sets */ +export const DEFAULT_WORKFLOW_OPTIONS = ['Basic Viewer', 'Segmentation'] as const; +export const EXTENDED_WORKFLOW_OPTIONS = ['TMTV Workflow', 'US Workflow', 'Preclinical 4D'] as const; + +/** All workflow options that the UI supports. */ +export const ALL_WORKFLOW_OPTIONS = [ + ...DEFAULT_WORKFLOW_OPTIONS, + ...EXTENDED_WORKFLOW_OPTIONS, +] as const; + +/** Union type of valid workflow identifiers. */ +export type WorkflowId = (typeof ALL_WORKFLOW_OPTIONS)[number]; + +/** + * Infers available workflows from row data (explicit list or modality heuristics). + * Only returns values that are part of ALL_WORKFLOW_OPTIONS to avoid drift. + */ +export function getAvailableWorkflows( + input: { workflows?: readonly (string | WorkflowId)[]; modalities?: string } | null +): WorkflowId[] { + const all = new Set(ALL_WORKFLOW_OPTIONS as readonly string[]); + + if (!input) { + return [...DEFAULT_WORKFLOW_OPTIONS] as WorkflowId[]; + } + + const { workflows, modalities } = input; + + // If row specifies workflows, filter them to the known set + if (Array.isArray(workflows) && workflows.length > 0) { + const filtered = workflows.map(String).filter(w => all.has(w)); + return Array.from(new Set(filtered)) as WorkflowId[]; + } + + // Otherwise, infer from modalities + const mod = String(modalities ?? '').toUpperCase(); + const flows: string[] = [...DEFAULT_WORKFLOW_OPTIONS]; + + if (mod.includes('US')) flows.push('US Workflow'); + if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) { + flows.push('TMTV Workflow'); + } + + const filtered = flows.filter(w => all.has(w)); + return Array.from(new Set(filtered)) as WorkflowId[]; +} \ No newline at end of file diff --git a/platform/ui-next/StudyList/index.ts b/platform/ui-next/StudyList/index.ts index af2e5248259..221a6696887 100644 --- a/platform/ui-next/StudyList/index.ts +++ b/platform/ui-next/StudyList/index.ts @@ -1,12 +1,35 @@ export * from './StudyListTypes'; -export * from './StudyListColumns'; + +// Columns +export * from './columns/defaultColumns'; + +// Table and cells export * from './StudyListTable'; export * from './StudyListInstancesCell'; -export * from './TableContext'; + +// Workflows export * from './WorkflowsInfer'; export * from './WorkflowsMenu'; + +// Dialogs and panels export * from './SettingsDialog'; -export * from './useDefaultWorkflow'; export * from './PreviewPanel'; export * from './EmptyPanel'; -export * from './StudyList'; \ No newline at end of file + +// Hooks +export * from './useDefaultWorkflow'; + +// Public StudyList (facade -> recipe) +export * from './StudyList'; + +// Headless +export * from './headless/StudyListProvider'; +export * from './headless/useStudyList'; +export * from './headless/workflows-registry'; + +// Primitives +export * from './primitives/StudylistLayout'; +export * from './primitives/PreviewShell'; + +// Recipes +export * from './recipes/DefaultStudyList'; \ No newline at end of file diff --git a/platform/ui-next/StudyList/primitives/PreviewShell.tsx b/platform/ui-next/StudyList/primitives/PreviewShell.tsx new file mode 100644 index 00000000000..e772ff8eb90 --- /dev/null +++ b/platform/ui-next/StudyList/primitives/PreviewShell.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { ScrollArea } from '../../src/components/ScrollArea'; + +export function PreviewShell({ + header, + children, +}: { + header?: React.ReactNode; + children?: React.ReactNode; +}) { + return ( +
+ {header} + +
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/platform/ui-next/playground/studylist/components/studylist-layout.tsx b/platform/ui-next/StudyList/primitives/StudylistLayout.tsx similarity index 94% rename from platform/ui-next/playground/studylist/components/studylist-layout.tsx rename to platform/ui-next/StudyList/primitives/StudylistLayout.tsx index 8e2b925b856..6d532adaf43 100644 --- a/platform/ui-next/playground/studylist/components/studylist-layout.tsx +++ b/platform/ui-next/StudyList/primitives/StudylistLayout.tsx @@ -3,9 +3,9 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle, -} from '../../../src/components/Resizable'; -import { Button } from '../../../src/components/Button'; -import { Icons } from '../../../src/components/Icons'; +} from '../../src/components/Resizable'; +import { Button } from '../../src/components/Button'; +import { Icons } from '../../src/components/Icons'; type LayoutContextValue = { isPanelOpen: boolean; @@ -106,4 +106,4 @@ export const StudylistLayout = Object.assign(Root, { TableArea, PreviewArea, OpenPreviewButton, -}); +}); \ No newline at end of file diff --git a/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx b/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx new file mode 100644 index 00000000000..cc50e6613db --- /dev/null +++ b/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { Icons } from '../../src/components/Icons'; +import { Button } from '../../src/components/Button'; +import type { StudyRow } from '../StudyListTypes'; +import type { WorkflowId } from '../WorkflowsInfer'; +import { StudyListTable } from '../StudyListTable'; +import { SettingsDialog } from '../SettingsDialog'; +import { PreviewPanel } from '../PreviewPanel'; +import { EmptyPanel } from '../EmptyPanel'; +import { StudylistLayout } from '../primitives/StudylistLayout'; +import { StudyListProvider, useStudyList } from '../headless/StudyListProvider'; +import { useStudyListState } from '../headless/useStudyList'; +import { defaultColumns } from '../columns/defaultColumns'; + +type Props = { + data: StudyRow[]; + columns?: ColumnDef[]; + title?: React.ReactNode; + getRowId?: (row: StudyRow, index: number) => string; + enforceSingleSelection?: boolean; + showColumnVisibility?: boolean; + tableClassName?: string; + onLaunch?: (study: StudyRow, workflow: WorkflowId) => void; +}; + +export function DefaultStudyList({ + data, + columns = defaultColumns(), + title = 'Study List', + getRowId = (row) => row.accession, + enforceSingleSelection = true, + showColumnVisibility = true, + tableClassName, + onLaunch, +}: Props) { + const state = useStudyListState(data, { onLaunch }); + + const previewDefaultSize = React.useMemo(() => { + if (typeof window !== 'undefined' && window.innerWidth > 0) { + const percent = (325 / window.innerWidth) * 100; + return Math.min(Math.max(percent, 15), 50); + } + return 30; + }, []); + + const toolbarLeft = ( + + ); + + return ( + + + +
+
+
+ state.setPanelOpen(true)} + onSelectionChange={(rows) => state.setSelected(rows[0] ?? null)} + tableClassName={tableClassName} + toolbarLeft={toolbarLeft} + renderOpenPanelButton={({ onOpenPanel }) => ( + + )} + /> +
+
+
+
+ + + + +
+
+ ); +} + +function SidePanel() { + const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow } = useStudyList(); + const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); + + return ( +
+
+ + +
+ + + + {/* Reuse the exact preview content to keep visuals identical */} +
+
+ {selected ? ( + + ) : ( + + )} +
+
+
+ ); +} \ No newline at end of file From 5850b705bb0474abf8c80bf838a500d4de57fe4b Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 4 Nov 2025 14:43:58 -0500 Subject: [PATCH 058/172] Simplified row action button --- platform/ui-next/StudyList/README.md | 2 +- .../StudyList/StudyListInstancesCell.tsx | 3 ++- platform/ui-next/StudyList/WorkflowsMenu.tsx | 17 +++++++++--- .../DataTable/ActionOverlayCell.tsx | 27 ++++++++++++++----- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/platform/ui-next/StudyList/README.md b/platform/ui-next/StudyList/README.md index 1d136d6922b..bacf6ef3599 100644 --- a/platform/ui-next/StudyList/README.md +++ b/platform/ui-next/StudyList/README.md @@ -52,7 +52,7 @@ StudyList/ │ ├─ StudyList.tsx # Public façade -> recipe (keeps external API stable) ├─ StudyListTable.tsx # Thin table primitive built on DataTable -├─ StudyListInstancesCell.tsx # "Open in..." overlay cell (workflow launcher) +├─ StudyListInstancesCell.tsx # Workflow launcher menu trigger ├─ WorkflowsMenu.tsx # Dropdown to pick a workflow ├─ WorkflowsInfer.ts # Re-exports from headless registry ├─ SettingsDialog.tsx # Settings (default workflow) diff --git a/platform/ui-next/StudyList/StudyListInstancesCell.tsx b/platform/ui-next/StudyList/StudyListInstancesCell.tsx index 9706e0e2673..434cd23b84e 100644 --- a/platform/ui-next/StudyList/StudyListInstancesCell.tsx +++ b/platform/ui-next/StudyList/StudyListInstancesCell.tsx @@ -20,6 +20,7 @@ export function StudyListInstancesCell({ row, value }: { row: Row; {value}
} + overlayAlign="end" onActivate={() => { if (!row.getIsSelected()) row.toggleSelected(true); }} @@ -35,4 +36,4 @@ export function StudyListInstancesCell({ row, value }: { row: Row; } /> ); -} \ No newline at end of file +} diff --git a/platform/ui-next/StudyList/WorkflowsMenu.tsx b/platform/ui-next/StudyList/WorkflowsMenu.tsx index 89a093bfb9b..25553631574 100644 --- a/platform/ui-next/StudyList/WorkflowsMenu.tsx +++ b/platform/ui-next/StudyList/WorkflowsMenu.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Button } from '../src/components/Button'; +import { Icons } from '../src/components/Icons'; import { DropdownMenu, DropdownMenuTrigger, @@ -32,11 +33,21 @@ export function WorkflowsMenu({ return ( - e.stopPropagation()}> +
+ Launch Workflow: +
{items.map((wf) => { const isDefault = defaultMode != null && String(defaultMode) === String(wf); return ( @@ -57,4 +68,4 @@ export function WorkflowsMenu({
); -} \ No newline at end of file +} diff --git a/platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx b/platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx index 2ef499f0ec4..94ef1f5ca1c 100644 --- a/platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx +++ b/platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx @@ -6,6 +6,7 @@ type Props = { overlay: React.ReactNode onActivate?: () => void alignRight?: boolean + overlayAlign?: 'start' | 'center' | 'end' } export function DataTableActionOverlayCell({ @@ -14,20 +15,33 @@ export function DataTableActionOverlayCell({ overlay, onActivate, alignRight = true, + overlayAlign, }: Props) { + const computedAlign = overlayAlign ?? (alignRight ? 'end' : 'start') + const valueAlignmentClass = + computedAlign === 'end' ? 'text-right' : computedAlign === 'center' ? 'text-center' : '' + const overlayPositionClass = + computedAlign === 'center' + ? 'inset-y-0 inset-x-0 justify-center px-2' + : computedAlign === 'start' + ? 'inset-y-0 left-0 px-2' + : 'inset-y-0 right-0 px-2' + const overlayVisibilityClass = isActive + ? 'bg-popover opacity-100' + : 'opacity-0 group-hover:bg-muted group-hover:opacity-100' + const valueVisibilityClass = isActive + ? 'invisible opacity-0' + : 'group-hover:invisible group-hover:opacity-0 group-hover:text-transparent' + return (
{value}
e.stopPropagation()} onMouseDown={(e) => { e.stopPropagation() @@ -43,4 +57,3 @@ export function DataTableActionOverlayCell({
) } - From 0ae2bc73e46424251d2a807e0c2ccda3250b9bf4 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 6 Nov 2025 16:17:31 -0500 Subject: [PATCH 059/172] Pagination added to table --- platform/ui-next/StudyList/StudyListTable.tsx | 3 + .../studylist/patient-studies-backup.json | 542 ++++ .../playground/studylist/patient-studies.json | 2488 ++++++++++++++++- .../src/components/DataTable/DataTable.tsx | 12 +- .../src/components/DataTable/Pagination.tsx | 61 + .../ui-next/src/components/DataTable/index.ts | 1 + 6 files changed, 3051 insertions(+), 56 deletions(-) create mode 100644 platform/ui-next/playground/studylist/patient-studies-backup.json create mode 100644 platform/ui-next/src/components/DataTable/Pagination.tsx diff --git a/platform/ui-next/StudyList/StudyListTable.tsx b/platform/ui-next/StudyList/StudyListTable.tsx index 1ff42142092..cbed1e01453 100644 --- a/platform/ui-next/StudyList/StudyListTable.tsx +++ b/platform/ui-next/StudyList/StudyListTable.tsx @@ -7,6 +7,7 @@ import { DataTableTitle, DataTableFilterRow, DataTableViewOptions, + DataTablePagination, useDataTable, } from '../src/components/DataTable'; import { @@ -119,6 +120,8 @@ function Content({
{toolbarLeft}
{title ? {title} : null}
+ {/* Pagination appears to the left of the "View" button */} + {showColumnVisibility && ( { diff --git a/platform/ui-next/playground/studylist/patient-studies-backup.json b/platform/ui-next/playground/studylist/patient-studies-backup.json new file mode 100644 index 00000000000..2828399cdf7 --- /dev/null +++ b/platform/ui-next/playground/studylist/patient-studies-backup.json @@ -0,0 +1,542 @@ +[ + { + "patient": "Doe, John", + "mrn": "MRN001234", + "studyDateTime": "2025-06-14 09:32", + "modalities": "CT", + "description": "Chest CT w/ Contrast", + "accession": "ACC-102938", + "instances": 324, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Smith, Jane", + "mrn": "MRN007891", + "studyDateTime": "2025-06-13 14:05", + "modalities": "MR", + "description": "Brain MRI", + "accession": "ACC-564738", + "instances": 210, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Ruiz, Carlos", + "mrn": "MRN003456", + "studyDateTime": "2025-06-12 11:48", + "modalities": "US", + "description": "Abdominal Ultrasound", + "accession": "ACC-223344", + "instances": 58, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Khan, Amina", + "mrn": "MRN005432", + "studyDateTime": "2025-06-11 08:21", + "modalities": "PET/CT", + "description": "Whole Body PET/CT", + "accession": "ACC-998877", + "instances": 512, + "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] + }, + { + "patient": "Johnson, Michael", + "mrn": "MRN010001", + "studyDateTime": "2025-06-15 10:12", + "modalities": "CT", + "description": "CT Abdomen/Pelvis w/ Contrast", + "accession": "ACC-300001", + "instances": 512, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Patel, Priya", + "mrn": "MRN010002", + "studyDateTime": "2025-06-15 12:45", + "modalities": "MR", + "description": "MR Brain w/ and w/o Contrast", + "accession": "ACC-300002", + "instances": 240, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Lee, David", + "mrn": "MRN010003", + "studyDateTime": "2025-06-15 08:05", + "modalities": "US", + "description": "US Carotid Duplex", + "accession": "ACC-300003", + "instances": 76, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Ahmed, Fatima", + "mrn": "MRN010004", + "studyDateTime": "2025-06-14 16:20", + "modalities": "XR", + "description": "XR Chest PA & Lateral", + "accession": "ACC-300004", + "instances": 4, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Thompson, Robert", + "mrn": "MRN010005", + "studyDateTime": "2025-06-14 21:50", + "modalities": "CT", + "description": "CT Head w/o Contrast", + "accession": "ACC-300005", + "instances": 220, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Chen, Emily", + "mrn": "MRN010006", + "studyDateTime": "2025-06-14 09:10", + "modalities": "MG", + "description": "MG Screening Tomosynthesis", + "accession": "ACC-300006", + "instances": 132, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Zhang, Wei", + "mrn": "MRN010007", + "studyDateTime": "2025-06-13 15:35", + "modalities": "DEXA", + "description": "DEXA Bone Density Axial", + "accession": "ACC-300007", + "instances": 28, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Rossi, Sofia", + "mrn": "MRN010008", + "studyDateTime": "2025-06-13 11:22", + "modalities": "MR", + "description": "MR Knee Left", + "accession": "ACC-300008", + "instances": 180, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "O'Connor, Liam", + "mrn": "MRN010009", + "studyDateTime": "2025-06-13 13:58", + "modalities": "US", + "description": "US Venous Doppler Lower Extremity Right", + "accession": "ACC-300009", + "instances": 64, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Garcia, Maria", + "mrn": "MRN010010", + "studyDateTime": "2025-06-13 18:42", + "modalities": "CT", + "description": "CT Pulmonary Angiography", + "accession": "ACC-300010", + "instances": 650, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Al-Sayed, Ahmed", + "mrn": "MRN010011", + "studyDateTime": "2025-06-12 09:30", + "modalities": "US", + "description": "US Abdomen RUQ", + "accession": "ACC-300011", + "instances": 54, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Kim, Hannah", + "mrn": "MRN010012", + "studyDateTime": "2025-06-12 14:17", + "modalities": "MR", + "description": "MR Lumbar Spine w/o Contrast", + "accession": "ACC-300012", + "instances": 156, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Brown, Ethan", + "mrn": "MRN010013", + "studyDateTime": "2025-06-12 16:05", + "modalities": "XR", + "description": "XR Ankle Right", + "accession": "ACC-300013", + "instances": 3, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Martinez, Olivia", + "mrn": "MRN010014", + "studyDateTime": "2025-06-12 08:02", + "modalities": "CT", + "description": "CT Chest Low-Dose Screening", + "accession": "ACC-300014", + "instances": 420, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Wilson, Noah", + "mrn": "MRN010015", + "studyDateTime": "2025-06-11 15:26", + "modalities": "MR", + "description": "MR Shoulder Right", + "accession": "ACC-300015", + "instances": 168, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Nguyen, Chloe", + "mrn": "MRN010016", + "studyDateTime": "2025-06-11 10:40", + "modalities": "XR", + "description": "XR Abdomen (KUB)", + "accession": "ACC-300016", + "instances": 2, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Silva, Lucas", + "mrn": "MRN010017", + "studyDateTime": "2025-06-11 12:55", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300017", + "instances": 48, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Costa, Isabella", + "mrn": "MRN010018", + "studyDateTime": "2025-06-11 13:20", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Right", + "accession": "ACC-300018", + "instances": 96, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Alvarez, Mateo", + "mrn": "MRN010019", + "studyDateTime": "2025-06-11 17:45", + "modalities": "PET/CT", + "description": "PET/CT FDG Skull Base to Mid-Thigh", + "accession": "ACC-300019", + "instances": 582, + "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] + }, + { + "patient": "Tanaka, Yuki", + "mrn": "MRN010020", + "studyDateTime": "2025-06-10 09:12", + "modalities": "MR", + "description": "MR Angiography Brain w/ and w/o Contrast", + "accession": "ACC-300020", + "instances": 212, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Cohen, Sara", + "mrn": "MRN010021", + "studyDateTime": "2025-06-10 11:31", + "modalities": "CT", + "description": "CT Sinuses w/o Contrast", + "accession": "ACC-300021", + "instances": 190, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Ivanov, Pavel", + "mrn": "MRN010022", + "studyDateTime": "2025-06-10 13:03", + "modalities": "US", + "description": "US Thyroid", + "accession": "ACC-300022", + "instances": 44, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "El-Sayed, Amira", + "mrn": "MRN010023", + "studyDateTime": "2025-06-10 15:18", + "modalities": "XR", + "description": "XR Shoulder Left", + "accession": "ACC-300023", + "instances": 4, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Papadopoulos, George", + "mrn": "MRN010024", + "studyDateTime": "2025-06-10 19:05", + "modalities": "CT", + "description": "CT Urogram", + "accession": "ACC-300024", + "instances": 780, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Bergstrom, Hanna", + "mrn": "MRN010025", + "studyDateTime": "2025-06-09 10:15", + "modalities": "DEXA", + "description": "DEXA Bone Density Axial", + "accession": "ACC-300025", + "instances": 30, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Johansson, Marcus", + "mrn": "MRN010026", + "studyDateTime": "2025-06-09 12:47", + "modalities": "MR", + "description": "MR Brain w/o Contrast", + "accession": "ACC-300026", + "instances": 132, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Bello, Aisha", + "mrn": "MRN010027", + "studyDateTime": "2025-06-09 08:55", + "modalities": "US", + "description": "US Pelvis TA/TV", + "accession": "ACC-300027", + "instances": 66, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Adeyemi, Seun", + "mrn": "MRN010028", + "studyDateTime": "2025-06-09 14:33", + "modalities": "XR", + "description": "XR Hand Left", + "accession": "ACC-300028", + "instances": 2, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Dubois, Lea", + "mrn": "MRN010029", + "studyDateTime": "2025-06-09 16:02", + "modalities": "MR", + "description": "MR Abdomen w/ and w/o Contrast", + "accession": "ACC-300029", + "instances": 228, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Novak, Tomas", + "mrn": "MRN010030", + "studyDateTime": "2025-06-08 09:42", + "modalities": "CT", + "description": "CT Cervical Spine w/o Contrast", + "accession": "ACC-300030", + "instances": 360, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Ortiz, Daniel", + "mrn": "MRN010031", + "studyDateTime": "2025-06-08 07:20", + "modalities": "XR", + "description": "XR Chest Portable AP", + "accession": "ACC-300031", + "instances": 1, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Petrova, Nadia", + "mrn": "MRN010032", + "studyDateTime": "2025-06-08 13:11", + "modalities": "NM", + "description": "NM Bone Scan Whole Body", + "accession": "ACC-300032", + "instances": 240, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Mehta, Arjun", + "mrn": "MRN010033", + "studyDateTime": "2025-06-08 15:37", + "modalities": "MR", + "description": "MR Prostate w/ and w/o Contrast", + "accession": "ACC-300033", + "instances": 200, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Lin, Mei", + "mrn": "MRN010034", + "studyDateTime": "2025-06-08 10:58", + "modalities": "US", + "description": "US Obstetric 2nd Trimester", + "accession": "ACC-300034", + "instances": 92, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "O'Brien, Kevin", + "mrn": "MRN010035", + "studyDateTime": "2025-06-07 18:25", + "modalities": "CT", + "description": "CT Angiography Head/Neck", + "accession": "ACC-300035", + "instances": 700, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Hussain, Zahra", + "mrn": "MRN010036", + "studyDateTime": "2025-06-07 11:06", + "modalities": "MR", + "description": "MR Enterography", + "accession": "ACC-300036", + "instances": 216, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Nasser, Omar", + "mrn": "MRN010037", + "studyDateTime": "2025-06-07 09:30", + "modalities": "US", + "description": "US Aorta Screening", + "accession": "ACC-300037", + "instances": 42, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Walker, Jade", + "mrn": "MRN010038", + "studyDateTime": "2025-06-07 14:48", + "modalities": "XR", + "description": "XR Lumbar Spine 2-3 Views", + "accession": "ACC-300038", + "instances": 5, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Muller, Peter", + "mrn": "MRN010039", + "studyDateTime": "2025-06-07 08:12", + "modalities": "CT", + "description": "CT Coronary Calcium Score", + "accession": "ACC-300039", + "instances": 180, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Mendes, Carla", + "mrn": "MRN010040", + "studyDateTime": "2025-06-06 10:20", + "modalities": "MG", + "description": "MG Screening Tomosynthesis", + "accession": "ACC-300040", + "instances": 140, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Pereira, Joao", + "mrn": "MRN010041", + "studyDateTime": "2025-06-06 17:40", + "modalities": "PET/CT", + "description": "PET/CT FDG Whole Body", + "accession": "ACC-300041", + "instances": 620, + "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] + }, + { + "patient": "Krishnan, Rajiv", + "mrn": "MRN010042", + "studyDateTime": "2025-06-06 12:18", + "modalities": "MR", + "description": "MR Shoulder Left", + "accession": "ACC-300042", + "instances": 160, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Bianchi, Giulia", + "mrn": "MRN010043", + "studyDateTime": "2025-06-06 15:29", + "modalities": "CT", + "description": "CT Abdomen/Pelvis w/o Contrast", + "accession": "ACC-300043", + "instances": 480, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Svensson, Nora", + "mrn": "MRN010044", + "studyDateTime": "2025-06-05 08:44", + "modalities": "US", + "description": "US Gallbladder RUQ", + "accession": "ACC-300044", + "instances": 50, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Svensson, Nora", + "mrn": "MRN010045", + "studyDateTime": "2025-06-05 13:27", + "modalities": "XR", + "description": "XR Hip Left", + "accession": "ACC-300045", + "instances": 3, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Wei, Jing", + "mrn": "MRN010046", + "studyDateTime": "2025-06-05 11:05", + "modalities": "US", + "description": "US Doppler Carotid Bilateral", + "accession": "ACC-300046", + "instances": 70, + "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + }, + { + "patient": "Mensah, Kofi", + "mrn": "MRN010047", + "studyDateTime": "2025-06-04 09:55", + "modalities": "NM", + "description": "NM HIDA w/ Ejection Fraction", + "accession": "ACC-300047", + "instances": 180, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Shah, Lila", + "mrn": "MRN010048", + "studyDateTime": "2025-06-04 14:12", + "modalities": "MR", + "description": "MR Cervical Spine w/o Contrast", + "accession": "ACC-300048", + "instances": 150, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Laurent, Antoine", + "mrn": "MRN010049", + "studyDateTime": "2025-06-04 16:50", + "modalities": "CT", + "description": "CT Head w/ and w/o Contrast", + "accession": "ACC-300049", + "instances": 540, + "workflows": ["Basic Viewer", "Segmentation"] + }, + { + "patient": "Park, Grace", + "mrn": "MRN010050", + "studyDateTime": "2025-06-04 12:40", + "modalities": "XR", + "description": "XR Foot Left", + "accession": "ACC-300050", + "instances": 2, + "workflows": ["Basic Viewer", "Segmentation"] + } +] diff --git a/platform/ui-next/playground/studylist/patient-studies.json b/platform/ui-next/playground/studylist/patient-studies.json index 2828399cdf7..1605cdda6fd 100644 --- a/platform/ui-next/playground/studylist/patient-studies.json +++ b/platform/ui-next/playground/studylist/patient-studies.json @@ -7,7 +7,10 @@ "description": "Chest CT w/ Contrast", "accession": "ACC-102938", "instances": 324, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Smith, Jane", @@ -17,7 +20,10 @@ "description": "Brain MRI", "accession": "ACC-564738", "instances": 210, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Ruiz, Carlos", @@ -27,7 +33,11 @@ "description": "Abdominal Ultrasound", "accession": "ACC-223344", "instances": 58, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Khan, Amina", @@ -37,7 +47,11 @@ "description": "Whole Body PET/CT", "accession": "ACC-998877", "instances": 512, - "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] }, { "patient": "Johnson, Michael", @@ -47,7 +61,10 @@ "description": "CT Abdomen/Pelvis w/ Contrast", "accession": "ACC-300001", "instances": 512, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Patel, Priya", @@ -57,7 +74,10 @@ "description": "MR Brain w/ and w/o Contrast", "accession": "ACC-300002", "instances": 240, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Lee, David", @@ -67,7 +87,11 @@ "description": "US Carotid Duplex", "accession": "ACC-300003", "instances": 76, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Ahmed, Fatima", @@ -77,7 +101,10 @@ "description": "XR Chest PA & Lateral", "accession": "ACC-300004", "instances": 4, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Thompson, Robert", @@ -87,7 +114,10 @@ "description": "CT Head w/o Contrast", "accession": "ACC-300005", "instances": 220, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Chen, Emily", @@ -97,7 +127,10 @@ "description": "MG Screening Tomosynthesis", "accession": "ACC-300006", "instances": 132, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Zhang, Wei", @@ -107,7 +140,10 @@ "description": "DEXA Bone Density Axial", "accession": "ACC-300007", "instances": 28, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Rossi, Sofia", @@ -117,7 +153,10 @@ "description": "MR Knee Left", "accession": "ACC-300008", "instances": 180, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "O'Connor, Liam", @@ -127,7 +166,11 @@ "description": "US Venous Doppler Lower Extremity Right", "accession": "ACC-300009", "instances": 64, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Garcia, Maria", @@ -137,7 +180,10 @@ "description": "CT Pulmonary Angiography", "accession": "ACC-300010", "instances": 650, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Al-Sayed, Ahmed", @@ -147,7 +193,11 @@ "description": "US Abdomen RUQ", "accession": "ACC-300011", "instances": 54, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Kim, Hannah", @@ -157,7 +207,10 @@ "description": "MR Lumbar Spine w/o Contrast", "accession": "ACC-300012", "instances": 156, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Brown, Ethan", @@ -167,7 +220,10 @@ "description": "XR Ankle Right", "accession": "ACC-300013", "instances": 3, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Martinez, Olivia", @@ -177,7 +233,10 @@ "description": "CT Chest Low-Dose Screening", "accession": "ACC-300014", "instances": 420, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Wilson, Noah", @@ -187,7 +246,10 @@ "description": "MR Shoulder Right", "accession": "ACC-300015", "instances": 168, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Nguyen, Chloe", @@ -197,7 +259,10 @@ "description": "XR Abdomen (KUB)", "accession": "ACC-300016", "instances": 2, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Silva, Lucas", @@ -207,7 +272,11 @@ "description": "US Renal", "accession": "ACC-300017", "instances": 48, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Costa, Isabella", @@ -217,7 +286,10 @@ "description": "MG Diagnostic Unilateral Right", "accession": "ACC-300018", "instances": 96, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Alvarez, Mateo", @@ -227,7 +299,11 @@ "description": "PET/CT FDG Skull Base to Mid-Thigh", "accession": "ACC-300019", "instances": 582, - "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] }, { "patient": "Tanaka, Yuki", @@ -237,7 +313,10 @@ "description": "MR Angiography Brain w/ and w/o Contrast", "accession": "ACC-300020", "instances": 212, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Cohen, Sara", @@ -247,7 +326,10 @@ "description": "CT Sinuses w/o Contrast", "accession": "ACC-300021", "instances": 190, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Ivanov, Pavel", @@ -257,7 +339,11 @@ "description": "US Thyroid", "accession": "ACC-300022", "instances": 44, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "El-Sayed, Amira", @@ -267,7 +353,10 @@ "description": "XR Shoulder Left", "accession": "ACC-300023", "instances": 4, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Papadopoulos, George", @@ -277,7 +366,10 @@ "description": "CT Urogram", "accession": "ACC-300024", "instances": 780, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Bergstrom, Hanna", @@ -287,7 +379,10 @@ "description": "DEXA Bone Density Axial", "accession": "ACC-300025", "instances": 30, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Johansson, Marcus", @@ -297,7 +392,10 @@ "description": "MR Brain w/o Contrast", "accession": "ACC-300026", "instances": 132, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Bello, Aisha", @@ -307,7 +405,11 @@ "description": "US Pelvis TA/TV", "accession": "ACC-300027", "instances": 66, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Adeyemi, Seun", @@ -317,7 +419,10 @@ "description": "XR Hand Left", "accession": "ACC-300028", "instances": 2, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Dubois, Lea", @@ -327,7 +432,10 @@ "description": "MR Abdomen w/ and w/o Contrast", "accession": "ACC-300029", "instances": 228, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Novak, Tomas", @@ -337,7 +445,10 @@ "description": "CT Cervical Spine w/o Contrast", "accession": "ACC-300030", "instances": 360, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Ortiz, Daniel", @@ -347,7 +458,10 @@ "description": "XR Chest Portable AP", "accession": "ACC-300031", "instances": 1, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Petrova, Nadia", @@ -357,7 +471,10 @@ "description": "NM Bone Scan Whole Body", "accession": "ACC-300032", "instances": 240, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Mehta, Arjun", @@ -367,7 +484,10 @@ "description": "MR Prostate w/ and w/o Contrast", "accession": "ACC-300033", "instances": 200, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Lin, Mei", @@ -377,7 +497,11 @@ "description": "US Obstetric 2nd Trimester", "accession": "ACC-300034", "instances": 92, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "O'Brien, Kevin", @@ -387,7 +511,10 @@ "description": "CT Angiography Head/Neck", "accession": "ACC-300035", "instances": 700, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Hussain, Zahra", @@ -397,7 +524,10 @@ "description": "MR Enterography", "accession": "ACC-300036", "instances": 216, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Nasser, Omar", @@ -407,7 +537,11 @@ "description": "US Aorta Screening", "accession": "ACC-300037", "instances": 42, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Walker, Jade", @@ -417,7 +551,10 @@ "description": "XR Lumbar Spine 2-3 Views", "accession": "ACC-300038", "instances": 5, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Muller, Peter", @@ -427,7 +564,10 @@ "description": "CT Coronary Calcium Score", "accession": "ACC-300039", "instances": 180, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Mendes, Carla", @@ -437,7 +577,10 @@ "description": "MG Screening Tomosynthesis", "accession": "ACC-300040", "instances": 140, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Pereira, Joao", @@ -447,7 +590,11 @@ "description": "PET/CT FDG Whole Body", "accession": "ACC-300041", "instances": 620, - "workflows": ["Basic Viewer", "Segmentation", "TMTV Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] }, { "patient": "Krishnan, Rajiv", @@ -457,7 +604,10 @@ "description": "MR Shoulder Left", "accession": "ACC-300042", "instances": 160, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Bianchi, Giulia", @@ -467,7 +617,10 @@ "description": "CT Abdomen/Pelvis w/o Contrast", "accession": "ACC-300043", "instances": 480, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Svensson, Nora", @@ -477,7 +630,11 @@ "description": "US Gallbladder RUQ", "accession": "ACC-300044", "instances": 50, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Svensson, Nora", @@ -487,7 +644,10 @@ "description": "XR Hip Left", "accession": "ACC-300045", "instances": 3, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Wei, Jing", @@ -497,7 +657,11 @@ "description": "US Doppler Carotid Bilateral", "accession": "ACC-300046", "instances": 70, - "workflows": ["Basic Viewer", "Segmentation", "US Workflow"] + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] }, { "patient": "Mensah, Kofi", @@ -507,7 +671,10 @@ "description": "NM HIDA w/ Ejection Fraction", "accession": "ACC-300047", "instances": 180, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Shah, Lila", @@ -517,7 +684,10 @@ "description": "MR Cervical Spine w/o Contrast", "accession": "ACC-300048", "instances": 150, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Laurent, Antoine", @@ -527,7 +697,10 @@ "description": "CT Head w/ and w/o Contrast", "accession": "ACC-300049", "instances": 540, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] }, { "patient": "Park, Grace", @@ -537,6 +710,2211 @@ "description": "XR Foot Left", "accession": "ACC-300050", "instances": 2, - "workflows": ["Basic Viewer", "Segmentation"] + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Nagy, Mila", + "mrn": "MRN010051", + "studyDateTime": "2025-06-16 09:00", + "modalities": "NM", + "description": "NM Bone Scan Whole Body", + "accession": "ACC-300051", + "instances": 180, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Nagy, Mason", + "mrn": "MRN010052", + "studyDateTime": "2025-06-16 08:13", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300052", + "instances": 50, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Nguyen, Amina", + "mrn": "MRN010053", + "studyDateTime": "2025-06-16 07:26", + "modalities": "MR", + "description": "MR Brain w/o Contrast", + "accession": "ACC-300053", + "instances": 160, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Clark, Daniel", + "mrn": "MRN010054", + "studyDateTime": "2025-06-16 06:39", + "modalities": "US", + "description": "US Thyroid", + "accession": "ACC-300054", + "instances": 64, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Rossi, Isabella", + "mrn": "MRN010055", + "studyDateTime": "2025-06-16 05:52", + "modalities": "US", + "description": "US Doppler Carotid Bilateral", + "accession": "ACC-300055", + "instances": 58, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Lefevre, Rowan", + "mrn": "MRN010056", + "studyDateTime": "2025-06-16 05:05", + "modalities": "NM", + "description": "NM Bone Scan Whole Body", + "accession": "ACC-300056", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Reyes, Sofia", + "mrn": "MRN010057", + "studyDateTime": "2025-06-16 04:18", + "modalities": "US", + "description": "US Pelvis TA/TV", + "accession": "ACC-300057", + "instances": 60, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Martinez, Elijah", + "mrn": "MRN010058", + "studyDateTime": "2025-06-16 03:31", + "modalities": "US", + "description": "US Pelvis TA/TV", + "accession": "ACC-300058", + "instances": 44, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Chen, Harper", + "mrn": "MRN010059", + "studyDateTime": "2025-06-16 02:44", + "modalities": "CT", + "description": "CT Abdomen/Pelvis w/ Contrast", + "accession": "ACC-300059", + "instances": 740, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Souza, William", + "mrn": "MRN010060", + "studyDateTime": "2025-06-16 01:57", + "modalities": "MR", + "description": "MR Lumbar Spine w/o Contrast", + "accession": "ACC-300060", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Thomas, Layla", + "mrn": "MRN010061", + "studyDateTime": "2025-06-16 01:10", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300061", + "instances": 58, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Johansson, Giulia", + "mrn": "MRN010062", + "studyDateTime": "2025-06-16 00:23", + "modalities": "MR", + "description": "MR Knee Left", + "accession": "ACC-300062", + "instances": 168, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Suzuki, Yusuf", + "mrn": "MRN010063", + "studyDateTime": "2025-06-15 23:36", + "modalities": "XR", + "description": "XR Shoulder Left", + "accession": "ACC-300063", + "instances": 6, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kobayashi, Kevin", + "mrn": "MRN010064", + "studyDateTime": "2025-06-15 22:49", + "modalities": "CT", + "description": "CT Head w/o Contrast", + "accession": "ACC-300064", + "instances": 320, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Anderson, Zahra", + "mrn": "MRN010065", + "studyDateTime": "2025-06-15 22:02", + "modalities": "CT", + "description": "CT Abdomen/Pelvis w/o Contrast", + "accession": "ACC-300065", + "instances": 380, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Young, Hannah", + "mrn": "MRN010066", + "studyDateTime": "2025-06-15 21:15", + "modalities": "US", + "description": "US Abdomen RUQ", + "accession": "ACC-300066", + "instances": 70, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Volkov, Mei", + "mrn": "MRN010067", + "studyDateTime": "2025-06-15 20:28", + "modalities": "MR", + "description": "MR Shoulder Left", + "accession": "ACC-300067", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Laurent, Yusuf", + "mrn": "MRN010068", + "studyDateTime": "2025-06-15 19:41", + "modalities": "CT", + "description": "CT Head w/o Contrast", + "accession": "ACC-300068", + "instances": 780, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Ali, Chloe", + "mrn": "MRN010069", + "studyDateTime": "2025-06-15 18:54", + "modalities": "NM", + "description": "NM HIDA w/ Ejection Fraction", + "accession": "ACC-300069", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "O'Neill, Amelia", + "mrn": "MRN010070", + "studyDateTime": "2025-06-15 18:07", + "modalities": "NM", + "description": "NM Bone Scan Whole Body", + "accession": "ACC-300070", + "instances": 240, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Laurent, Quinn", + "mrn": "MRN010071", + "studyDateTime": "2025-06-15 17:20", + "modalities": "DEXA", + "description": "DEXA Bone Density Axial", + "accession": "ACC-300071", + "instances": 28, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Fraser, Seun", + "mrn": "MRN010072", + "studyDateTime": "2025-06-15 16:33", + "modalities": "US", + "description": "US Aorta Screening", + "accession": "ACC-300072", + "instances": 54, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Dubois, Mei", + "mrn": "MRN010073", + "studyDateTime": "2025-06-15 15:46", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Left", + "accession": "ACC-300073", + "instances": 140, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Bello, Hanna", + "mrn": "MRN010074", + "studyDateTime": "2025-06-15 14:59", + "modalities": "MR", + "description": "MR Shoulder Left", + "accession": "ACC-300074", + "instances": 168, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Lopez, Omar", + "mrn": "MRN010075", + "studyDateTime": "2025-06-15 14:12", + "modalities": "NM", + "description": "NM HIDA w/ Ejection Fraction", + "accession": "ACC-300075", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kobayashi, Tomas", + "mrn": "MRN010076", + "studyDateTime": "2025-06-15 13:25", + "modalities": "CT", + "description": "CT Chest w/ Contrast", + "accession": "ACC-300076", + "instances": 400, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "MacLeod, Gianna", + "mrn": "MRN010077", + "studyDateTime": "2025-06-15 12:38", + "modalities": "PET/CT", + "description": "PET/CT FDG Skull Base to Mid-Thigh", + "accession": "ACC-300077", + "instances": 660, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Sato, Logan", + "mrn": "MRN010078", + "studyDateTime": "2025-06-15 11:51", + "modalities": "CT", + "description": "CT Urogram", + "accession": "ACC-300078", + "instances": 540, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Johansson, Aisha", + "mrn": "MRN010079", + "studyDateTime": "2025-06-15 11:04", + "modalities": "NM", + "description": "NM Myocardial Perfusion Rest/Stress", + "accession": "ACC-300079", + "instances": 260, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Adeyemi, Joao", + "mrn": "MRN010080", + "studyDateTime": "2025-06-15 10:17", + "modalities": "DEXA", + "description": "DEXA Bone Density Axial", + "accession": "ACC-300080", + "instances": 24, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Svensson, Fatima", + "mrn": "MRN010081", + "studyDateTime": "2025-06-15 09:30", + "modalities": "CT", + "description": "CT Abdomen/Pelvis w/o Contrast", + "accession": "ACC-300081", + "instances": 580, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Greco, Isabella", + "mrn": "MRN010082", + "studyDateTime": "2025-06-15 08:43", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300082", + "instances": 740, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Singh, Grace", + "mrn": "MRN010083", + "studyDateTime": "2025-06-15 07:56", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Left", + "accession": "ACC-300083", + "instances": 132, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Nagy, Arjun", + "mrn": "MRN010084", + "studyDateTime": "2025-06-15 07:09", + "modalities": "XR", + "description": "XR Hip Right", + "accession": "ACC-300084", + "instances": 6, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Krishnan, Mei", + "mrn": "MRN010085", + "studyDateTime": "2025-06-15 06:22", + "modalities": "CT", + "description": "CT Chest w/ Contrast", + "accession": "ACC-300085", + "instances": 360, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Ionescu, George", + "mrn": "MRN010086", + "studyDateTime": "2025-06-15 05:35", + "modalities": "XR", + "description": "XR Hip Right", + "accession": "ACC-300086", + "instances": 6, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Horváth, John", + "mrn": "MRN010087", + "studyDateTime": "2025-06-15 04:48", + "modalities": "MR", + "description": "MR Brain w/o Contrast", + "accession": "ACC-300087", + "instances": 160, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Parker, Maria", + "mrn": "MRN010088", + "studyDateTime": "2025-06-15 04:01", + "modalities": "CT", + "description": "CT Abdomen/Pelvis w/o Contrast", + "accession": "ACC-300088", + "instances": 480, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Johansson, Eleanor", + "mrn": "MRN010089", + "studyDateTime": "2025-06-15 03:14", + "modalities": "CT", + "description": "CT Chest w/ Contrast", + "accession": "ACC-300089", + "instances": 650, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "García, Ava", + "mrn": "MRN010090", + "studyDateTime": "2025-06-15 02:27", + "modalities": "XR", + "description": "XR Lumbar Spine 2-3 Views", + "accession": "ACC-300090", + "instances": 1, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Walker, Morgan", + "mrn": "MRN010091", + "studyDateTime": "2025-06-15 01:40", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Left", + "accession": "ACC-300091", + "instances": 160, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Silva, Mei", + "mrn": "MRN010092", + "studyDateTime": "2025-06-15 00:53", + "modalities": "CT", + "description": "CT Head w/o Contrast", + "accession": "ACC-300092", + "instances": 480, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Petrov, Yuki", + "mrn": "MRN010093", + "studyDateTime": "2025-06-15 00:06", + "modalities": "MR", + "description": "MR Lumbar Spine w/o Contrast", + "accession": "ACC-300093", + "instances": 180, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "O'Connor, Benjamin", + "mrn": "MRN010094", + "studyDateTime": "2025-06-14 23:19", + "modalities": "XR", + "description": "XR Hand Right", + "accession": "ACC-300094", + "instances": 5, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Mehta, Logan", + "mrn": "MRN010095", + "studyDateTime": "2025-06-14 22:32", + "modalities": "XR", + "description": "XR Lumbar Spine 2-3 Views", + "accession": "ACC-300095", + "instances": 2, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Campbell, Ibrahim", + "mrn": "MRN010096", + "studyDateTime": "2025-06-14 21:45", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Right", + "accession": "ACC-300096", + "instances": 90, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Smith, Taylor", + "mrn": "MRN010097", + "studyDateTime": "2025-06-14 20:58", + "modalities": "XR", + "description": "XR Shoulder Left", + "accession": "ACC-300097", + "instances": 5, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Parker, Harper", + "mrn": "MRN010098", + "studyDateTime": "2025-06-14 20:11", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300098", + "instances": 360, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Wilson, Mei", + "mrn": "MRN010099", + "studyDateTime": "2025-06-14 19:24", + "modalities": "XR", + "description": "XR Ankle Right", + "accession": "ACC-300099", + "instances": 6, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Lopez, Carlos", + "mrn": "MRN010100", + "studyDateTime": "2025-06-14 18:37", + "modalities": "XR", + "description": "XR Hip Left", + "accession": "ACC-300100", + "instances": 3, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Rossi, Oliver", + "mrn": "MRN010101", + "studyDateTime": "2025-06-14 17:50", + "modalities": "XR", + "description": "XR Chest PA & Lateral", + "accession": "ACC-300101", + "instances": 6, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Torres, Zahra", + "mrn": "MRN010102", + "studyDateTime": "2025-06-14 17:03", + "modalities": "CT", + "description": "CT Urogram", + "accession": "ACC-300102", + "instances": 480, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Parker, Ibrahim", + "mrn": "MRN010103", + "studyDateTime": "2025-06-14 16:16", + "modalities": "US", + "description": "US Gallbladder RUQ", + "accession": "ACC-300103", + "instances": 48, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Walker, Luna", + "mrn": "MRN010104", + "studyDateTime": "2025-06-14 15:29", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Right", + "accession": "ACC-300104", + "instances": 96, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Garcia, Jane", + "mrn": "MRN010105", + "studyDateTime": "2025-06-14 14:42", + "modalities": "US", + "description": "US Abdomen RUQ", + "accession": "ACC-300105", + "instances": 48, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Garcia, Casey", + "mrn": "MRN010106", + "studyDateTime": "2025-06-14 13:55", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300106", + "instances": 58, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Young, Henry", + "mrn": "MRN010107", + "studyDateTime": "2025-06-14 13:08", + "modalities": "XR", + "description": "XR Hip Right", + "accession": "ACC-300107", + "instances": 2, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kaczmarek, Ella", + "mrn": "MRN010108", + "studyDateTime": "2025-06-14 12:21", + "modalities": "MR", + "description": "MR Abdomen w/ and w/o Contrast", + "accession": "ACC-300108", + "instances": 132, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Dubois, Eleanor", + "mrn": "MRN010109", + "studyDateTime": "2025-06-14 11:34", + "modalities": "XR", + "description": "XR Hip Left", + "accession": "ACC-300109", + "instances": 3, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Fischer, Marcus", + "mrn": "MRN010110", + "studyDateTime": "2025-06-14 10:47", + "modalities": "US", + "description": "US Gallbladder RUQ", + "accession": "ACC-300110", + "instances": 64, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Kováč, Casey", + "mrn": "MRN010111", + "studyDateTime": "2025-06-14 10:00", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300111", + "instances": 580, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Johnson, Henry", + "mrn": "MRN010112", + "studyDateTime": "2025-06-14 09:13", + "modalities": "US", + "description": "US Obstetric 2nd Trimester", + "accession": "ACC-300112", + "instances": 50, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Novak, William", + "mrn": "MRN010113", + "studyDateTime": "2025-06-14 08:26", + "modalities": "MG", + "description": "MG Diagnostic Bilateral", + "accession": "ACC-300113", + "instances": 90, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Svensson, Joao", + "mrn": "MRN010114", + "studyDateTime": "2025-06-14 07:39", + "modalities": "US", + "description": "US Aorta Screening", + "accession": "ACC-300114", + "instances": 64, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Nagy, Sara", + "mrn": "MRN010115", + "studyDateTime": "2025-06-14 06:52", + "modalities": "DEXA", + "description": "DEXA Bone Density Axial", + "accession": "ACC-300115", + "instances": 34, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kaczmarek, Rowan", + "mrn": "MRN010116", + "studyDateTime": "2025-06-14 06:05", + "modalities": "PET/CT", + "description": "PET/CT FDG Whole Body", + "accession": "ACC-300116", + "instances": 680, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Schneider, Grace", + "mrn": "MRN010117", + "studyDateTime": "2025-06-14 05:18", + "modalities": "MG", + "description": "MG Diagnostic Bilateral", + "accession": "ACC-300117", + "instances": 120, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Souza, Kofi", + "mrn": "MRN010118", + "studyDateTime": "2025-06-14 04:31", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300118", + "instances": 320, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Suzuki, Marcus", + "mrn": "MRN010119", + "studyDateTime": "2025-06-14 03:44", + "modalities": "PET/CT", + "description": "PET/CT FDG Skull Base to Mid-Thigh", + "accession": "ACC-300119", + "instances": 700, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Moreau, Nora", + "mrn": "MRN010120", + "studyDateTime": "2025-06-14 02:57", + "modalities": "XR", + "description": "XR Chest Portable AP", + "accession": "ACC-300120", + "instances": 5, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Svensson, Nadia", + "mrn": "MRN010121", + "studyDateTime": "2025-06-14 02:10", + "modalities": "MR", + "description": "MR Prostate w/ and w/o Contrast", + "accession": "ACC-300121", + "instances": 150, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Brown, Tomas", + "mrn": "MRN010122", + "studyDateTime": "2025-06-14 01:23", + "modalities": "MR", + "description": "MR Brain w/o Contrast", + "accession": "ACC-300122", + "instances": 240, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Ionescu, Emily", + "mrn": "MRN010123", + "studyDateTime": "2025-06-14 00:36", + "modalities": "MG", + "description": "MG Screening Tomosynthesis", + "accession": "ACC-300123", + "instances": 96, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "O'Brien, Emily", + "mrn": "MRN010124", + "studyDateTime": "2025-06-13 23:49", + "modalities": "CT", + "description": "CT Urogram", + "accession": "ACC-300124", + "instances": 360, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Hughes, Sofia", + "mrn": "MRN010125", + "studyDateTime": "2025-06-13 23:02", + "modalities": "MR", + "description": "MR Knee Right", + "accession": "ACC-300125", + "instances": 180, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Volkov, Eleanor", + "mrn": "MRN010126", + "studyDateTime": "2025-06-13 22:15", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Left", + "accession": "ACC-300126", + "instances": 118, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Flores, Seun", + "mrn": "MRN010127", + "studyDateTime": "2025-06-13 21:28", + "modalities": "US", + "description": "US Venous Doppler Lower Extremity Right", + "accession": "ACC-300127", + "instances": 84, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Bennett, Maya", + "mrn": "MRN010128", + "studyDateTime": "2025-06-13 20:41", + "modalities": "US", + "description": "US Doppler Carotid Bilateral", + "accession": "ACC-300128", + "instances": 92, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Mensah, Benjamin", + "mrn": "MRN010129", + "studyDateTime": "2025-06-13 19:54", + "modalities": "MR", + "description": "MR Shoulder Left", + "accession": "ACC-300129", + "instances": 240, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Patel, Aaliyah", + "mrn": "MRN010130", + "studyDateTime": "2025-06-13 19:07", + "modalities": "MR", + "description": "MR Knee Right", + "accession": "ACC-300130", + "instances": 150, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Walker, Omar", + "mrn": "MRN010131", + "studyDateTime": "2025-06-13 18:20", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Right", + "accession": "ACC-300131", + "instances": 118, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Hassan, Michael", + "mrn": "MRN010132", + "studyDateTime": "2025-06-13 17:33", + "modalities": "XR", + "description": "XR Ankle Left", + "accession": "ACC-300132", + "instances": 2, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Svensson, Logan", + "mrn": "MRN010133", + "studyDateTime": "2025-06-13 16:46", + "modalities": "PET/CT", + "description": "PET/CT FDG Whole Body", + "accession": "ACC-300133", + "instances": 720, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Fraser, Yuki", + "mrn": "MRN010134", + "studyDateTime": "2025-06-13 15:59", + "modalities": "CT", + "description": "CT Chest w/ Contrast", + "accession": "ACC-300134", + "instances": 380, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kobayashi, Priya", + "mrn": "MRN010135", + "studyDateTime": "2025-06-13 15:12", + "modalities": "MR", + "description": "MR Shoulder Left", + "accession": "ACC-300135", + "instances": 150, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Adeyemi, Lila", + "mrn": "MRN010136", + "studyDateTime": "2025-06-13 14:25", + "modalities": "MR", + "description": "MR Lumbar Spine w/o Contrast", + "accession": "ACC-300136", + "instances": 236, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Brown, Emma", + "mrn": "MRN010137", + "studyDateTime": "2025-06-13 13:38", + "modalities": "PET/CT", + "description": "PET/CT FDG Skull Base to Mid-Thigh", + "accession": "ACC-300137", + "instances": 660, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "García, Rajiv", + "mrn": "MRN010138", + "studyDateTime": "2025-06-13 12:51", + "modalities": "MR", + "description": "MR Shoulder Right", + "accession": "ACC-300138", + "instances": 200, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Hassan, Mei", + "mrn": "MRN010139", + "studyDateTime": "2025-06-13 12:04", + "modalities": "XR", + "description": "XR Chest PA & Lateral", + "accession": "ACC-300139", + "instances": 5, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Khan, Kofi", + "mrn": "MRN010140", + "studyDateTime": "2025-06-13 11:17", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300140", + "instances": 64, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Brown, Sophia", + "mrn": "MRN010141", + "studyDateTime": "2025-06-13 10:30", + "modalities": "US", + "description": "US Doppler Carotid Bilateral", + "accession": "ACC-300141", + "instances": 50, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Papadopoulos, Mason", + "mrn": "MRN010142", + "studyDateTime": "2025-06-13 09:43", + "modalities": "US", + "description": "US Doppler Carotid Bilateral", + "accession": "ACC-300142", + "instances": 70, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Volkov, Maya", + "mrn": "MRN010143", + "studyDateTime": "2025-06-13 08:56", + "modalities": "PET/CT", + "description": "PET/CT FDG Whole Body", + "accession": "ACC-300143", + "instances": 660, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Weber, Marcus", + "mrn": "MRN010144", + "studyDateTime": "2025-06-13 08:09", + "modalities": "CT", + "description": "CT Chest w/ Contrast", + "accession": "ACC-300144", + "instances": 780, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Brown, Chloe", + "mrn": "MRN010145", + "studyDateTime": "2025-06-13 07:22", + "modalities": "MR", + "description": "MR Brain w/o Contrast", + "accession": "ACC-300145", + "instances": 140, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Walker, Emily", + "mrn": "MRN010146", + "studyDateTime": "2025-06-13 06:35", + "modalities": "CT", + "description": "CT Cervical Spine w/o Contrast", + "accession": "ACC-300146", + "instances": 400, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kim, Jade", + "mrn": "MRN010147", + "studyDateTime": "2025-06-13 05:48", + "modalities": "MG", + "description": "MG Diagnostic Bilateral", + "accession": "ACC-300147", + "instances": 120, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Ruiz, George", + "mrn": "MRN010148", + "studyDateTime": "2025-06-13 05:01", + "modalities": "CT", + "description": "CT Cervical Spine w/o Contrast", + "accession": "ACC-300148", + "instances": 340, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kováč, Omar", + "mrn": "MRN010149", + "studyDateTime": "2025-06-13 04:14", + "modalities": "US", + "description": "US Carotid Duplex", + "accession": "ACC-300149", + "instances": 92, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Kowalski, Olivia", + "mrn": "MRN010150", + "studyDateTime": "2025-06-13 03:27", + "modalities": "PET/CT", + "description": "PET/CT FDG Skull Base to Mid-Thigh", + "accession": "ACC-300150", + "instances": 640, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Davis, William", + "mrn": "MRN010151", + "studyDateTime": "2025-06-13 02:40", + "modalities": "CT", + "description": "CT Angiography Head/Neck", + "accession": "ACC-300151", + "instances": 420, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kobayashi, Abigail", + "mrn": "MRN010152", + "studyDateTime": "2025-06-13 01:53", + "modalities": "MR", + "description": "MR Abdomen w/ and w/o Contrast", + "accession": "ACC-300152", + "instances": 160, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Taylor, Aiden", + "mrn": "MRN010153", + "studyDateTime": "2025-06-13 01:06", + "modalities": "CT", + "description": "CT Pulmonary Angiography", + "accession": "ACC-300153", + "instances": 780, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Tanaka, Lea", + "mrn": "MRN010154", + "studyDateTime": "2025-06-13 00:19", + "modalities": "MR", + "description": "MR Prostate w/ and w/o Contrast", + "accession": "ACC-300154", + "instances": 236, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "O'Connor, Sofia", + "mrn": "MRN010155", + "studyDateTime": "2025-06-12 23:32", + "modalities": "DEXA", + "description": "DEXA Bone Density Axial", + "accession": "ACC-300155", + "instances": 28, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Chen, Ella", + "mrn": "MRN010156", + "studyDateTime": "2025-06-12 22:45", + "modalities": "CT", + "description": "CT Pulmonary Angiography", + "accession": "ACC-300156", + "instances": 380, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Silva, Ibrahim", + "mrn": "MRN010157", + "studyDateTime": "2025-06-12 21:58", + "modalities": "CT", + "description": "CT Angiography Head/Neck", + "accession": "ACC-300157", + "instances": 540, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Johnson, Lucas", + "mrn": "MRN010158", + "studyDateTime": "2025-06-12 21:11", + "modalities": "MR", + "description": "MR Brain w/ and w/o Contrast", + "accession": "ACC-300158", + "instances": 212, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Nowak, Grace", + "mrn": "MRN010159", + "studyDateTime": "2025-06-12 20:24", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300159", + "instances": 400, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Esposito, Logan", + "mrn": "MRN010160", + "studyDateTime": "2025-06-12 19:37", + "modalities": "CT", + "description": "CT Urogram", + "accession": "ACC-300160", + "instances": 380, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Pereira, Mila", + "mrn": "MRN010161", + "studyDateTime": "2025-06-12 18:50", + "modalities": "XR", + "description": "XR Shoulder Left", + "accession": "ACC-300161", + "instances": 4, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Santos, Aaliyah", + "mrn": "MRN010162", + "studyDateTime": "2025-06-12 18:03", + "modalities": "MR", + "description": "MR Knee Left", + "accession": "ACC-300162", + "instances": 200, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Souza, Grace", + "mrn": "MRN010163", + "studyDateTime": "2025-06-12 17:16", + "modalities": "US", + "description": "US Gallbladder RUQ", + "accession": "ACC-300163", + "instances": 50, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Volkov, Aria", + "mrn": "MRN010164", + "studyDateTime": "2025-06-12 16:29", + "modalities": "MR", + "description": "MR Lumbar Spine w/o Contrast", + "accession": "ACC-300164", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Lopez, Amira", + "mrn": "MRN010165", + "studyDateTime": "2025-06-12 15:42", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Left", + "accession": "ACC-300165", + "instances": 160, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Garcia, Harper", + "mrn": "MRN010166", + "studyDateTime": "2025-06-12 14:55", + "modalities": "MR", + "description": "MR Brain w/ and w/o Contrast", + "accession": "ACC-300166", + "instances": 120, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kobayashi, Mila", + "mrn": "MRN010167", + "studyDateTime": "2025-06-12 14:08", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300167", + "instances": 400, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Souza, Isabella", + "mrn": "MRN010168", + "studyDateTime": "2025-06-12 13:21", + "modalities": "MR", + "description": "MR Prostate w/ and w/o Contrast", + "accession": "ACC-300168", + "instances": 192, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Abdullah, Evelyn", + "mrn": "MRN010169", + "studyDateTime": "2025-06-12 12:34", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300169", + "instances": 100, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "O'Brien, Luna", + "mrn": "MRN010170", + "studyDateTime": "2025-06-12 11:47", + "modalities": "PET/CT", + "description": "PET/CT FDG Skull Base to Mid-Thigh", + "accession": "ACC-300170", + "instances": 620, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Johansson, Maya", + "mrn": "MRN010171", + "studyDateTime": "2025-06-12 11:00", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300171", + "instances": 48, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Reyes, Rajiv", + "mrn": "MRN010172", + "studyDateTime": "2025-06-12 10:13", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300172", + "instances": 400, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Müller, Sofia", + "mrn": "MRN010173", + "studyDateTime": "2025-06-12 09:26", + "modalities": "MR", + "description": "MR Lumbar Spine w/o Contrast", + "accession": "ACC-300173", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Mendes, Sara", + "mrn": "MRN010174", + "studyDateTime": "2025-06-12 08:39", + "modalities": "DEXA", + "description": "DEXA Bone Density Peripheral", + "accession": "ACC-300174", + "instances": 32, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Volkov, Mia", + "mrn": "MRN010175", + "studyDateTime": "2025-06-12 07:52", + "modalities": "CT", + "description": "CT Cervical Spine w/o Contrast", + "accession": "ACC-300175", + "instances": 600, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Petrov, Fatima", + "mrn": "MRN010176", + "studyDateTime": "2025-06-12 07:05", + "modalities": "CT", + "description": "CT Cervical Spine w/o Contrast", + "accession": "ACC-300176", + "instances": 650, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "O'Neill, Lila", + "mrn": "MRN010177", + "studyDateTime": "2025-06-12 06:18", + "modalities": "MR", + "description": "MR Brain w/ and w/o Contrast", + "accession": "ACC-300177", + "instances": 180, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Ionescu, Luna", + "mrn": "MRN010178", + "studyDateTime": "2025-06-12 05:31", + "modalities": "XR", + "description": "XR Hand Left", + "accession": "ACC-300178", + "instances": 2, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Kaczmarek, Maria", + "mrn": "MRN010179", + "studyDateTime": "2025-06-12 04:44", + "modalities": "MR", + "description": "MR Lumbar Spine w/o Contrast", + "accession": "ACC-300179", + "instances": 240, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "García, Ethan", + "mrn": "MRN010180", + "studyDateTime": "2025-06-12 03:57", + "modalities": "MR", + "description": "MR Brain w/ and w/o Contrast", + "accession": "ACC-300180", + "instances": 200, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Ruiz, Gianna", + "mrn": "MRN010181", + "studyDateTime": "2025-06-12 03:10", + "modalities": "US", + "description": "US Venous Doppler Lower Extremity Right", + "accession": "ACC-300181", + "instances": 58, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Chen, Aria", + "mrn": "MRN010182", + "studyDateTime": "2025-06-12 02:23", + "modalities": "MR", + "description": "MR Abdomen w/ and w/o Contrast", + "accession": "ACC-300182", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Rossi, Omar", + "mrn": "MRN010183", + "studyDateTime": "2025-06-12 01:36", + "modalities": "US", + "description": "US Gallbladder RUQ", + "accession": "ACC-300183", + "instances": 110, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Walker, Scarlett", + "mrn": "MRN010184", + "studyDateTime": "2025-06-12 00:49", + "modalities": "CT", + "description": "CT Cervical Spine w/o Contrast", + "accession": "ACC-300184", + "instances": 600, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Tanaka, Sophia", + "mrn": "MRN010185", + "studyDateTime": "2025-06-12 00:02", + "modalities": "US", + "description": "US Pelvis TA/TV", + "accession": "ACC-300185", + "instances": 54, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Fraser, Avery", + "mrn": "MRN010186", + "studyDateTime": "2025-06-11 23:15", + "modalities": "US", + "description": "US Pelvis TA/TV", + "accession": "ACC-300186", + "instances": 84, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Singh, Kofi", + "mrn": "MRN010187", + "studyDateTime": "2025-06-11 22:28", + "modalities": "CT", + "description": "CT Pulmonary Angiography", + "accession": "ACC-300187", + "instances": 580, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "O'Connor, Jade", + "mrn": "MRN010188", + "studyDateTime": "2025-06-11 21:41", + "modalities": "US", + "description": "US Renal", + "accession": "ACC-300188", + "instances": 50, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Santos, Noah", + "mrn": "MRN010189", + "studyDateTime": "2025-06-11 20:54", + "modalities": "XR", + "description": "XR Hand Right", + "accession": "ACC-300189", + "instances": 4, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Sato, Kofi", + "mrn": "MRN010190", + "studyDateTime": "2025-06-11 20:07", + "modalities": "CT", + "description": "CT Head w/o Contrast", + "accession": "ACC-300190", + "instances": 780, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Walker, Carlos", + "mrn": "MRN010191", + "studyDateTime": "2025-06-11 19:20", + "modalities": "CT", + "description": "CT Chest w/ Contrast", + "accession": "ACC-300191", + "instances": 580, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Müller, Hannah", + "mrn": "MRN010192", + "studyDateTime": "2025-06-11 18:33", + "modalities": "XR", + "description": "XR Chest Portable AP", + "accession": "ACC-300192", + "instances": 3, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Walker, Chloe", + "mrn": "MRN010193", + "studyDateTime": "2025-06-11 17:46", + "modalities": "US", + "description": "US Abdomen RUQ", + "accession": "ACC-300193", + "instances": 100, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Clark, Taylor", + "mrn": "MRN010194", + "studyDateTime": "2025-06-11 16:59", + "modalities": "MR", + "description": "MR Brain w/ and w/o Contrast", + "accession": "ACC-300194", + "instances": 240, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Nagy, Daniel", + "mrn": "MRN010195", + "studyDateTime": "2025-06-11 16:12", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Right", + "accession": "ACC-300195", + "instances": 150, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Ionescu, Evelyn", + "mrn": "MRN010196", + "studyDateTime": "2025-06-11 15:25", + "modalities": "US", + "description": "US Pelvis TA/TV", + "accession": "ACC-300196", + "instances": 58, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Anderson, Aaliyah", + "mrn": "MRN010197", + "studyDateTime": "2025-06-11 14:38", + "modalities": "NM", + "description": "NM Myocardial Perfusion Rest/Stress", + "accession": "ACC-300197", + "instances": 220, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Moreau, Noah", + "mrn": "MRN010198", + "studyDateTime": "2025-06-11 13:51", + "modalities": "CT", + "description": "CT Head w/o Contrast", + "accession": "ACC-300198", + "instances": 740, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Taylor, Avery", + "mrn": "MRN010199", + "studyDateTime": "2025-06-11 13:04", + "modalities": "CT", + "description": "CT Angiography Head/Neck", + "accession": "ACC-300199", + "instances": 400, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Ionescu, Benjamin", + "mrn": "MRN010200", + "studyDateTime": "2025-06-11 12:17", + "modalities": "PET/CT", + "description": "PET/CT FDG Whole Body", + "accession": "ACC-300200", + "instances": 700, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Ibrahim, Liam", + "mrn": "MRN010201", + "studyDateTime": "2025-06-11 11:30", + "modalities": "US", + "description": "US Venous Doppler Lower Extremity Right", + "accession": "ACC-300201", + "instances": 84, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Johansson, Ibrahim", + "mrn": "MRN010202", + "studyDateTime": "2025-06-11 10:43", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300202", + "instances": 360, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Mehta, George", + "mrn": "MRN010203", + "studyDateTime": "2025-06-11 09:56", + "modalities": "MG", + "description": "MG Diagnostic Unilateral Left", + "accession": "ACC-300203", + "instances": 118, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Mendes, Camila", + "mrn": "MRN010204", + "studyDateTime": "2025-06-11 09:09", + "modalities": "CT", + "description": "CT Chest w/o Contrast", + "accession": "ACC-300204", + "instances": 680, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Nguyen, Mila", + "mrn": "MRN010205", + "studyDateTime": "2025-06-11 08:22", + "modalities": "MG", + "description": "MG Diagnostic Bilateral", + "accession": "ACC-300205", + "instances": 118, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Bianchi, Mei", + "mrn": "MRN010206", + "studyDateTime": "2025-06-11 07:35", + "modalities": "PET/CT", + "description": "PET/CT FDG Skull Base to Mid-Thigh", + "accession": "ACC-300206", + "instances": 600, + "workflows": [ + "Basic Viewer", + "Segmentation", + "TMTV Workflow" + ] + }, + { + "patient": "Abdullah, Ava", + "mrn": "MRN010207", + "studyDateTime": "2025-06-11 06:48", + "modalities": "XR", + "description": "XR Hand Right", + "accession": "ACC-300207", + "instances": 1, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Müller, Nadia", + "mrn": "MRN010208", + "studyDateTime": "2025-06-11 06:01", + "modalities": "US", + "description": "US Venous Doppler Lower Extremity Right", + "accession": "ACC-300208", + "instances": 64, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Kaczmarek, Scarlett", + "mrn": "MRN010209", + "studyDateTime": "2025-06-11 05:14", + "modalities": "US", + "description": "US Gallbladder RUQ", + "accession": "ACC-300209", + "instances": 84, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Brown, Daniel", + "mrn": "MRN010210", + "studyDateTime": "2025-06-11 04:27", + "modalities": "DEXA", + "description": "DEXA Bone Density Peripheral", + "accession": "ACC-300210", + "instances": 30, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Smith, Seun", + "mrn": "MRN010211", + "studyDateTime": "2025-06-11 03:40", + "modalities": "MR", + "description": "MR Knee Left", + "accession": "ACC-300211", + "instances": 180, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Horváth, Casey", + "mrn": "MRN010212", + "studyDateTime": "2025-06-11 02:53", + "modalities": "CT", + "description": "CT Cervical Spine w/o Contrast", + "accession": "ACC-300212", + "instances": 340, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Campbell, Emma", + "mrn": "MRN010213", + "studyDateTime": "2025-06-11 02:06", + "modalities": "US", + "description": "US Doppler Carotid Bilateral", + "accession": "ACC-300213", + "instances": 54, + "workflows": [ + "Basic Viewer", + "Segmentation", + "US Workflow" + ] + }, + { + "patient": "Brown, Jane", + "mrn": "MRN010214", + "studyDateTime": "2025-06-11 01:19", + "modalities": "CT", + "description": "CT Chest w/ Contrast", + "accession": "ACC-300214", + "instances": 580, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Moreau, Emma", + "mrn": "MRN010215", + "studyDateTime": "2025-06-11 00:32", + "modalities": "XR", + "description": "XR Ankle Right", + "accession": "ACC-300215", + "instances": 3, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] + }, + { + "patient": "Campbell, Aria", + "mrn": "MRN010216", + "studyDateTime": "2025-06-10 23:45", + "modalities": "XR", + "description": "XR Shoulder Left", + "accession": "ACC-300216", + "instances": 6, + "workflows": [ + "Basic Viewer", + "Segmentation" + ] } -] +] \ No newline at end of file diff --git a/platform/ui-next/src/components/DataTable/DataTable.tsx b/platform/ui-next/src/components/DataTable/DataTable.tsx index 7d4207b244e..640f495a70d 100644 --- a/platform/ui-next/src/components/DataTable/DataTable.tsx +++ b/platform/ui-next/src/components/DataTable/DataTable.tsx @@ -5,11 +5,13 @@ import type { RowSelectionState, SortingState, VisibilityState, + PaginationState, } from '@tanstack/react-table' import { getCoreRowModel, getFilteredRowModel, getSortedRowModel, + getPaginationRowModel, useReactTable, } from '@tanstack/react-table' import { DataTableContext } from './context' @@ -41,23 +43,31 @@ export function DataTable({ const [columnVisibility, setColumnVisibility] = React.useState(initialVisibility) const [rowSelection, setRowSelection] = React.useState({}) const [columnFilters, setColumnFilters] = React.useState(initialFilters) + const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 50 }) const table = useReactTable({ data, columns, - state: { sorting, columnVisibility, rowSelection, columnFilters }, + state: { sorting, columnVisibility, rowSelection, columnFilters, pagination }, onSortingChange: setSorting, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), enableRowSelection: true, enableMultiRowSelection: !enforceSingleSelection, getRowId, }) + // When filters (or incoming data) change, go back to the first page + React.useEffect(() => { + setPagination(p => ({ ...p, pageIndex: 0 })) + }, [columnFilters, data]) + React.useEffect(() => { if (!onSelectionChange) return const selected = table.getSelectedRowModel().rows.map((r) => r.original as TData) 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..ceb68b3f658 --- /dev/null +++ b/platform/ui-next/src/components/DataTable/Pagination.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { Button } from '../Button'; +import { useDataTable } from './context'; + +function ChevronLeftIcon(props: React.SVGProps) { + return ( + + ); +} + +function ChevronRightIcon(props: React.SVGProps) { + return ( + + ); +} + +/** + * DataTablePagination + * Renders "start-end of total" and ghost chevron buttons for prev/next. + * Uses the TanStack table instance from DataTable context. + */ +export function DataTablePagination() { + 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 ( +
+ {`${start}-${end} of ${total}`} + + +
+ ); +} \ No newline at end of file diff --git a/platform/ui-next/src/components/DataTable/index.ts b/platform/ui-next/src/components/DataTable/index.ts index 8a1172efbb8..bf517fa73ee 100644 --- a/platform/ui-next/src/components/DataTable/index.ts +++ b/platform/ui-next/src/components/DataTable/index.ts @@ -7,3 +7,4 @@ export { DataTable } from './DataTable' export { useDataTable } from './context' export { DataTableToolbar } from './Toolbar' export { DataTableTitle } from './Title' +export { DataTablePagination } from './Pagination' From a970301f1660ddead90a8e8792c0d2231f24b9d4 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 6 Nov 2025 16:41:06 -0500 Subject: [PATCH 060/172] Pagination fixes --- platform/ui-next/StudyList/StudyListTable.tsx | 4 ++-- platform/ui-next/src/components/DataTable/DataTable.tsx | 8 +++++--- platform/ui-next/src/components/DataTable/context.tsx | 3 +++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/platform/ui-next/StudyList/StudyListTable.tsx b/platform/ui-next/StudyList/StudyListTable.tsx index cbed1e01453..94a871ccbfb 100644 --- a/platform/ui-next/StudyList/StudyListTable.tsx +++ b/platform/ui-next/StudyList/StudyListTable.tsx @@ -195,8 +195,8 @@ function Content({ onReset={() => setColumnFilters([])} excludeColumnIds={[]} /> - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( + {table.getPaginationRowModel().rows.length ? ( + table.getPaginationRowModel().rows.map((row) => ( ({ getRowId, }) - // When filters (or incoming data) change, go back to the first page + // When filters, sorting, or incoming data change, go back to the first page React.useEffect(() => { setPagination(p => ({ ...p, pageIndex: 0 })) - }, [columnFilters, data]) + }, [columnFilters, sorting, data]) React.useEffect(() => { if (!onSelectionChange) return @@ -85,9 +85,11 @@ export function DataTable({ setRowSelection, columnFilters, setColumnFilters, + pagination, + setPagination, resetFilters: () => setColumnFilters([]), }), - [table, sorting, columnVisibility, rowSelection, columnFilters] + [table, sorting, columnVisibility, rowSelection, columnFilters, pagination] ) return {children} diff --git a/platform/ui-next/src/components/DataTable/context.tsx b/platform/ui-next/src/components/DataTable/context.tsx index 1e3089797aa..34dfd472d8c 100644 --- a/platform/ui-next/src/components/DataTable/context.tsx +++ b/platform/ui-next/src/components/DataTable/context.tsx @@ -5,6 +5,7 @@ import type { SortingState, Table as RTable, VisibilityState, + PaginationState, } from '@tanstack/react-table' export type DataTableContextValue = { @@ -17,6 +18,8 @@ export type DataTableContextValue = { setRowSelection: React.Dispatch> columnFilters: ColumnFiltersState setColumnFilters: React.Dispatch> + pagination: PaginationState + setPagination: React.Dispatch> resetFilters: () => void } From d30fddd5c61b04e55d8197bead4cf38a2ffe9cb0 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 7 Nov 2025 12:16:25 -0500 Subject: [PATCH 061/172] Added Popover for settings instead of Dialog --- platform/ui-next/StudyList/SettingsDialog.tsx | 77 +++++++++++-------- .../StudyList/recipes/DefaultStudyList.tsx | 45 ++++++----- 2 files changed, 70 insertions(+), 52 deletions(-) diff --git a/platform/ui-next/StudyList/SettingsDialog.tsx b/platform/ui-next/StudyList/SettingsDialog.tsx index 2867eb22786..07f866f2c2e 100644 --- a/platform/ui-next/StudyList/SettingsDialog.tsx +++ b/platform/ui-next/StudyList/SettingsDialog.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../src/components/Dialog'; +import { PopoverContent } from '../src/components/Popover/Popover'; import { Select, SelectContent, @@ -11,46 +11,59 @@ import { Label } from '../src/components/Label'; import { ALL_WORKFLOW_OPTIONS, type WorkflowId } from './WorkflowsInfer'; type Props = { + /** Controlled open state from parent Popover */ open: boolean; + /** onOpenChange from parent Popover */ onOpenChange: (open: boolean) => void; + /** Selected default workflow */ defaultMode: WorkflowId | null; + /** Handler when default workflow changes */ onDefaultModeChange: (value: WorkflowId | null) => void; }; +/** + * SettingsDialog (Popover version) + * This component now renders PopoverContent with the settings form. + * It is intended to be used as a child of a Popover Root with a PopoverTrigger. + */ export function SettingsDialog({ open, onOpenChange, defaultMode, onDefaultModeChange }: Props) { const selectId = React.useId(); + return ( - - - - Settings - -
- -
- -
+ e.preventDefault()} + > +
+ +
+
- -
+
+ ); } \ No newline at end of file diff --git a/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx b/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx index cc50e6613db..f74c2c09929 100644 --- a/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx +++ b/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import type { ColumnDef } from '@tanstack/react-table'; import { Icons } from '../../src/components/Icons'; import { Button } from '../../src/components/Button'; +import { Popover, PopoverTrigger } from '../../src/components/Popover/Popover'; import type { StudyRow } from '../StudyListTypes'; import type { WorkflowId } from '../WorkflowsInfer'; import { StudyListTable } from '../StudyListTable'; @@ -108,27 +109,31 @@ function SidePanel() { return (
-
- - -
+ +
+ + + + +
- + +
{/* Reuse the exact preview content to keep visuals identical */}
From 44d08b791c6280cb846f51539294fa7fcde2d0cf Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 7 Nov 2025 12:30:41 -0500 Subject: [PATCH 062/172] Remove default workflow button in Patient summary Now lives in settings. Removing this based on user feedback. --- .../PatientSummary/PatientSummary.tsx | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index 84bde7fef5f..1aa5dbd3254 100644 --- a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -549,32 +549,6 @@ const WorkflowButtonInner = (
); - const renderDefaultPicker = () => ( -
- - - - - e.stopPropagation()}> - {['Basic Viewer', 'Segmentation', 'TMTV Workflow', 'US Workflow', 'Preclinical 4D'].map( - (opt) => ( - { - e.preventDefault(); - onDefaultModeChange?.(opt as M); - }} - > - {opt} - - ) - )} - - -
- ); return (
( {/* Content selection logic */} {hasDefault ? ( renderBadge(String(defaultMode)) - ) : !data ? ( - renderDefaultPicker() - ) : ( + ) : data ? (
{workflowButtons.map((wf) => ( ))}
- )} + ) : null}
); }; From 49d8243d0e7139ffe7bbc4c51b38721ffb70d711 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 7 Nov 2025 12:52:10 -0500 Subject: [PATCH 063/172] Other modes are selectable with a default active --- .../PatientSummary/PatientSummary.tsx | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index 1aa5dbd3254..732e21ca0dc 100644 --- a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -573,24 +573,25 @@ const WorkflowButtonInner = (
{/* Content selection logic */} - {hasDefault ? ( - renderBadge(String(defaultMode)) - ) : data ? ( + {hasDefault && renderBadge(String(defaultMode))} + {data && (
- {workflowButtons.map((wf) => ( - - ))} + {workflowButtons + .filter((wf) => !hasDefault || String(wf) !== String(defaultMode)) + .map((wf) => ( + + ))}
- ) : null} + )}
); }; From 5871531370e6c782410381a37d8f8dd28e2dcaa1 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 7 Nov 2025 13:24:48 -0500 Subject: [PATCH 064/172] Updated default workflow design and feedback --- .../PatientSummary/PatientSummary.tsx | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index 732e21ca0dc..9fac0bddf12 100644 --- a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import type { ElementType } from 'react'; +import { Cross2Icon } from '@radix-ui/react-icons'; import { cn } from '../../lib/utils'; import { Icons } from '../Icons/Icons'; import { Button } from '../Button'; @@ -529,23 +530,28 @@ const WorkflowButtonInner = (
) : null; - const renderBadge = (labelValue: string) => ( -
- ( +
+ - + +
); @@ -573,7 +579,7 @@ const WorkflowButtonInner = (
{/* Content selection logic */} - {hasDefault && renderBadge(String(defaultMode))} + {hasDefault && renderDefaultWorkflow(String(defaultMode))} {data && (
{workflowButtons @@ -742,4 +748,4 @@ export const PatientSummary: PatientSummaryNamespace = Object.assign(Root, { Workflows, Empty, Field, -}); \ No newline at end of file +}); From 6479b25c14b32006e45cde74072b33ce8eac3034 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Fri, 7 Nov 2025 15:17:49 -0500 Subject: [PATCH 065/172] Filters always visible when scrolling --- platform/ui-next/StudyList/StudyListTable.tsx | 189 ++++++++++-------- 1 file changed, 103 insertions(+), 86 deletions(-) diff --git a/platform/ui-next/StudyList/StudyListTable.tsx b/platform/ui-next/StudyList/StudyListTable.tsx index 94a871ccbfb..e685ec13fc8 100644 --- a/platform/ui-next/StudyList/StudyListTable.tsx +++ b/platform/ui-next/StudyList/StudyListTable.tsx @@ -112,6 +112,24 @@ function Content({ renderOpenPanelButton?: (args: { onOpenPanel: () => void }) => React.ReactNode; }) { const { table, setColumnFilters } = useDataTable(); + const renderColGroup = React.useCallback( + () => ( + + {table.getVisibleLeafColumns().map((col) => { + const meta = + (col.columnDef.meta as unknown as { fixedWidth?: number | string } | undefined) ?? + undefined; + const width = meta?.fixedWidth; + return width ? ( + + ) : ( + + ); + })} + + ), + [table] + ); return (
@@ -148,95 +166,94 @@ function Content({ )}
- - - - {table.getVisibleLeafColumns().map((col) => { - const meta = - (col.columnDef.meta as unknown as { fixedWidth?: number | string } | undefined) ?? - undefined; - const width = meta?.fixedWidth; - return width ? ( - - ) : ( - - ); - })} - - - {table.getHeaderGroups().map((hg) => ( - - {hg.headers.map((header) => ( - { - const s = header.column.getIsSorted() as false | 'asc' | 'desc'; - return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; - })()} - > - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - setColumnFilters([])} - excludeColumnIds={[]} - /> - {table.getPaginationRowModel().rows.length ? ( - table.getPaginationRowModel().rows.map((row) => ( - row.toggleSelected()} - aria-selected={row.getIsSelected()} - className="group cursor-pointer" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - row.toggleSelected(); - } - }} - > - {row.getVisibleCells().map((cell) => ( - +
+
+ {renderColGroup()} + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header) => ( + { + const s = header.column.getIsSorted() as false | 'asc' | 'desc'; + return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; + })()} > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + ))} - )) - ) : ( - - - No results. - - - )} - -
-
+ ))} + + + setColumnFilters([])} + excludeColumnIds={[]} + /> + + +
+
+ + + {renderColGroup()} + + {table.getPaginationRowModel().rows.length ? ( + table.getPaginationRowModel().rows.map((row) => ( + row.toggleSelected()} + aria-selected={row.getIsSelected()} + className="group cursor-pointer" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + row.toggleSelected(); + } + }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
); -} \ No newline at end of file +} From a22d32431fbcf00827b359f9ef57120b9a2876ff Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 11 Nov 2025 08:57:10 -0500 Subject: [PATCH 066/172] Rename for SettingsPopover --- platform/ui-next/StudyList/README.md | 16 ++++++++-------- .../{SettingsDialog.tsx => SettingsPopover.tsx} | 10 +++++----- platform/ui-next/StudyList/index.ts | 4 ++-- .../StudyList/recipes/DefaultStudyList.tsx | 6 +++--- 4 files changed, 18 insertions(+), 18 deletions(-) rename platform/ui-next/StudyList/{SettingsDialog.tsx => SettingsPopover.tsx} (87%) diff --git a/platform/ui-next/StudyList/README.md b/platform/ui-next/StudyList/README.md index bacf6ef3599..6a73e952a37 100644 --- a/platform/ui-next/StudyList/README.md +++ b/platform/ui-next/StudyList/README.md @@ -55,7 +55,7 @@ StudyList/ ├─ StudyListInstancesCell.tsx # Workflow launcher menu trigger ├─ WorkflowsMenu.tsx # Dropdown to pick a workflow ├─ WorkflowsInfer.ts # Re-exports from headless registry -├─ SettingsDialog.tsx # Settings (default workflow) +├─ SettingsPopover.tsx # Settings (default workflow) ├─ PreviewPanel.tsx # Default preview content (PatientSummary + thumbnails) ├─ EmptyPanel.tsx # Default empty state (PatientSummary) ├─ useDefaultWorkflow.ts # localStorage persistence hook @@ -71,7 +71,7 @@ StudyList/ * `StudyListTable` (selection → updates `selected`) * `StudylistLayout` (resizable split, toggling preview visiblity) * `StudyListInstancesCell` + `WorkflowsMenu` (launch workflows per row) - * `SettingsDialog` (persist default workflow via `useDefaultWorkflow`) + * `SettingsPopover` (persist default workflow via `useDefaultWorkflow`) --- @@ -147,7 +147,7 @@ StudyList/ * `useStudyListState` → `StudyListProvider` * `StudylistLayout` for split panes * `StudyListTable` with `defaultColumns()` - * `SettingsDialog`, `PreviewPanel`, `EmptyPanel` + * `SettingsPopover`, `PreviewPanel`, `EmptyPanel` * **Why**: A working example you can (a) use as-is or (b) copy and tweak. ### `StudyList.tsx` (Façade) @@ -207,9 +207,9 @@ StudyList/ * **What**: PatientSummary-based empty state when nothing is selected. -### `SettingsDialog.tsx` +### `SettingsPopover.tsx` -* **What**: Dialog with the **Default Workflow** selector. +* **What**: Popover content with the **Default Workflow** selector. * **Persistence**: Updates the headless `defaultWorkflow` which is persisted by `useDefaultWorkflow` in localStorage. --- @@ -261,10 +261,10 @@ StudyList builds on a small set of DS primitives. Key modules: * `Button` (`src/components/Button`) * `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuItem` (`src/components/DropdownMenu`) -* **Inputs & Dialog** +* **Inputs & Popover** * `Input` (`src/components/Input`) *(used by filter row)* - * `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle` (`src/components/Dialog`) + * `Popover`, `PopoverTrigger`, `PopoverContent` (`src/components/Popover/Popover`) * `Select`, `SelectTrigger`, `SelectContent`, `SelectItem`, `SelectValue` (`src/components/Select`) * `Label` (`src/components/Label`) * **Layout & Scroll** @@ -371,7 +371,7 @@ You can place `PatientSummary` in the preview, above the table, or inside a cell * **Selection**: `StudyListTable` toggles selection on row click and `Enter`/`Space`. * **Panel**: `StudylistLayout` controls whether the preview area is visible. The toolbar exposes a button to reopen when closed. -* **Default Workflow**: stored in `localStorage` under the `studylist.defaultWorkflow` key. The `SettingsDialog` writes to the same headless state. +* **Default Workflow**: stored in `localStorage` under the `studylist.defaultWorkflow` key. The `SettingsPopover` writes to the same headless state. * **Launch**: The action flows through `useStudyListState(..., { onLaunch })` → `launch(study, workflow)`. The default recipe calls `console.log` for demo; apps should pass a real handler. --- diff --git a/platform/ui-next/StudyList/SettingsDialog.tsx b/platform/ui-next/StudyList/SettingsPopover.tsx similarity index 87% rename from platform/ui-next/StudyList/SettingsDialog.tsx rename to platform/ui-next/StudyList/SettingsPopover.tsx index 07f866f2c2e..2089e5241f4 100644 --- a/platform/ui-next/StudyList/SettingsDialog.tsx +++ b/platform/ui-next/StudyList/SettingsPopover.tsx @@ -22,11 +22,11 @@ type Props = { }; /** - * SettingsDialog (Popover version) - * This component now renders PopoverContent with the settings form. - * It is intended to be used as a child of a Popover Root with a PopoverTrigger. + * SettingsPopover + * Renders PopoverContent with the settings form. + * Intended to be used inside a Popover with a PopoverTrigger. */ -export function SettingsDialog({ open, onOpenChange, defaultMode, onDefaultModeChange }: Props) { +export function SettingsPopover({ open, onOpenChange, defaultMode, onDefaultModeChange }: Props) { const selectId = React.useId(); return ( @@ -66,4 +66,4 @@ export function SettingsDialog({ open, onOpenChange, defaultMode, onDefaultModeC
); -} \ No newline at end of file +} diff --git a/platform/ui-next/StudyList/index.ts b/platform/ui-next/StudyList/index.ts index 221a6696887..1b2c153e48a 100644 --- a/platform/ui-next/StudyList/index.ts +++ b/platform/ui-next/StudyList/index.ts @@ -12,7 +12,7 @@ export * from './WorkflowsInfer'; export * from './WorkflowsMenu'; // Dialogs and panels -export * from './SettingsDialog'; +export * from './SettingsPopover'; export * from './PreviewPanel'; export * from './EmptyPanel'; @@ -32,4 +32,4 @@ export * from './primitives/StudylistLayout'; export * from './primitives/PreviewShell'; // Recipes -export * from './recipes/DefaultStudyList'; \ No newline at end of file +export * from './recipes/DefaultStudyList'; diff --git a/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx b/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx index f74c2c09929..12a3daa9516 100644 --- a/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx +++ b/platform/ui-next/StudyList/recipes/DefaultStudyList.tsx @@ -6,7 +6,7 @@ import { Popover, PopoverTrigger } from '../../src/components/Popover/Popover'; import type { StudyRow } from '../StudyListTypes'; import type { WorkflowId } from '../WorkflowsInfer'; import { StudyListTable } from '../StudyListTable'; -import { SettingsDialog } from '../SettingsDialog'; +import { SettingsPopover } from '../SettingsPopover'; import { PreviewPanel } from '../PreviewPanel'; import { EmptyPanel } from '../EmptyPanel'; import { StudylistLayout } from '../primitives/StudylistLayout'; @@ -127,7 +127,7 @@ function SidePanel() {
-
); -} \ No newline at end of file +} From e8cff7698f48fb3df7597ea441cff2e0ef7c9e4e Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 11 Nov 2025 09:11:05 -0500 Subject: [PATCH 067/172] Added hint text for other workflows --- platform/ui-next/StudyList/SettingsPopover.tsx | 2 +- .../ui-next/src/components/PatientSummary/PatientSummary.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/platform/ui-next/StudyList/SettingsPopover.tsx b/platform/ui-next/StudyList/SettingsPopover.tsx index 2089e5241f4..b32c2222ede 100644 --- a/platform/ui-next/StudyList/SettingsPopover.tsx +++ b/platform/ui-next/StudyList/SettingsPopover.tsx @@ -51,7 +51,7 @@ export function SettingsPopover({ open, onOpenChange, defaultMode, onDefaultMode }} > - + {/* Keep stopPropagation so the Select's portal doesn't trigger outside interactions on the Popover */} e.stopPropagation()}> diff --git a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index 9fac0bddf12..3afe0879111 100644 --- a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -580,6 +580,11 @@ const WorkflowButtonInner = ( {/* Content selection logic */} {hasDefault && renderDefaultWorkflow(String(defaultMode))} + {hasDefault && data && ( +
+ Other Available Workflows +
+ )} {data && (
{workflowButtons From d3c3833a1e6c190019fb604c585d53714d337e05 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 11 Nov 2025 10:41:00 -0500 Subject: [PATCH 068/172] Default mode double click launches viewer --- platform/ui-next/StudyList/EmptyPanel.tsx | 7 +- platform/ui-next/StudyList/PreviewPanel.tsx | 5 +- platform/ui-next/StudyList/README.md | 13 ++ platform/ui-next/StudyList/StudyListTable.tsx | 29 ++++- platform/ui-next/playground/studylist/app.tsx | 4 +- .../ui-next/playground/studylist/launch.tsx | 43 +++++++ .../PatientSummary/PatientSummary.tsx | 113 ++++++++++++------ 7 files changed, 174 insertions(+), 40 deletions(-) create mode 100644 platform/ui-next/playground/studylist/launch.tsx diff --git a/platform/ui-next/StudyList/EmptyPanel.tsx b/platform/ui-next/StudyList/EmptyPanel.tsx index ce0a356cc06..388c1a053a6 100644 --- a/platform/ui-next/StudyList/EmptyPanel.tsx +++ b/platform/ui-next/StudyList/EmptyPanel.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { PatientSummary } from '../src/components/PatientSummary'; import type { WorkflowId } from './WorkflowsInfer'; +import { useStudyList } from './headless/StudyListProvider'; export function EmptyPanel({ defaultMode, @@ -9,13 +10,17 @@ export function EmptyPanel({ defaultMode: WorkflowId | null; onDefaultModeChange: (v: WorkflowId | null) => void; }) { + const { launch } = useStudyList(); return ( defaultMode={defaultMode} onDefaultModeChange={onDefaultModeChange} + onLaunchWorkflow={(data, wf) => { + if (data) launch(data, wf); + }} /> ); -} \ No newline at end of file +} diff --git a/platform/ui-next/StudyList/PreviewPanel.tsx b/platform/ui-next/StudyList/PreviewPanel.tsx index 5c248da67a6..cf8caf5d9e3 100644 --- a/platform/ui-next/StudyList/PreviewPanel.tsx +++ b/platform/ui-next/StudyList/PreviewPanel.tsx @@ -6,6 +6,7 @@ import { TooltipProvider } from '../src/components/Tooltip'; import type { StudyRow } from './StudyListTypes'; import type { WorkflowId } from './WorkflowsInfer'; import { PatientSummary } from '../src/components/PatientSummary'; +import { useStudyList } from './headless/StudyListProvider'; export function PreviewPanel({ study, @@ -16,6 +17,7 @@ export function PreviewPanel({ defaultMode: WorkflowId | null; onDefaultModeChange: (v: WorkflowId | null) => void; }) { + const { launch } = useStudyList(); const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ id: `preview-${study.accession}-${i}`, @@ -33,6 +35,7 @@ export function PreviewPanel({ defaultMode={defaultMode} onDefaultModeChange={onDefaultModeChange} + onLaunchWorkflow={(data, wf) => launch((data as StudyRow) ?? study, wf)} />
@@ -58,4 +61,4 @@ export function PreviewPanel({ ); -} \ No newline at end of file +} diff --git a/platform/ui-next/StudyList/README.md b/platform/ui-next/StudyList/README.md index 6a73e952a37..27a46e1a429 100644 --- a/platform/ui-next/StudyList/README.md +++ b/platform/ui-next/StudyList/README.md @@ -370,8 +370,21 @@ You can place `PatientSummary` in the preview, above the table, or inside a cell ## Event & State Semantics * **Selection**: `StudyListTable` toggles selection on row click and `Enter`/`Space`. +* **Prototype: Default-mode double‑click launch**: + - When a default workflow is set, a row click always selects (a second click does not unselect). + - Double‑clicking a row launches the selected study using the default workflow. + - Keyboard Enter/Space mirror the single‑click behavior above. + - Implementation lives in `StudyListTable.tsx` row handlers: + - `platform/ui-next/StudyList/StudyListTable.tsx:219` + - `platform/ui-next/StudyList/StudyListTable.tsx:228` + - `platform/ui-next/StudyList/StudyListTable.tsx:238` * **Panel**: `StudylistLayout` controls whether the preview area is visible. The toolbar exposes a button to reopen when closed. * **Default Workflow**: stored in `localStorage` under the `studylist.defaultWorkflow` key. The `SettingsPopover` writes to the same headless state. +* **Launch behavior (prototype)**: + - The playground route `/studylist/launch?wf=...` is used to preview launches. + - The playground’s `onLaunch` navigates in-page to this route: `platform/ui-next/playground/studylist/app.tsx:6`. + - The page rendering the workflow name and a back button lives at `platform/ui-next/playground/studylist/launch.tsx:1`. + - Real apps should replace this with router navigation or a command handler via the `onLaunch(study, workflow)` prop. * **Launch**: The action flows through `useStudyListState(..., { onLaunch })` → `launch(study, workflow)`. The default recipe calls `console.log` for demo; apps should pass a real handler. --- diff --git a/platform/ui-next/StudyList/StudyListTable.tsx b/platform/ui-next/StudyList/StudyListTable.tsx index e685ec13fc8..86f3378aa79 100644 --- a/platform/ui-next/StudyList/StudyListTable.tsx +++ b/platform/ui-next/StudyList/StudyListTable.tsx @@ -21,6 +21,8 @@ import { import { ScrollArea } from '../src/components/ScrollArea'; import { Button } from '../src/components/Button'; import type { StudyRow } from './StudyListTypes'; +import { useStudyList } from './headless/StudyListProvider'; +import type { WorkflowId } from './WorkflowsInfer'; type Props = { columns: ColumnDef[]; @@ -112,6 +114,8 @@ function Content({ renderOpenPanelButton?: (args: { onOpenPanel: () => void }) => React.ReactNode; }) { const { table, setColumnFilters } = useDataTable(); + // Access headless state for default workflow + launch + const { defaultWorkflow, launch } = useStudyList(); const renderColGroup = React.useCallback( () => ( @@ -212,14 +216,35 @@ function Content({ row.toggleSelected()} + onClick={(e) => { + // 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={(e) => { + if (!defaultWorkflow) return; + // Ensure the row is selected, then launch with the default workflow + if (!row.getIsSelected()) row.toggleSelected(true); + const original: any = row.original ?? {}; + launch(original as StudyRow, defaultWorkflow as WorkflowId); + }} aria-selected={row.getIsSelected()} className="group cursor-pointer" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - row.toggleSelected(); + // Keyboard behavior mirrors click: when default workflow is set, + // Enter/Space should select but not toggle to unselect. + if (defaultWorkflow) { + if (!row.getIsSelected()) row.toggleSelected(true); + } else { + row.toggleSelected(); + } } }} > diff --git a/platform/ui-next/playground/studylist/app.tsx b/platform/ui-next/playground/studylist/app.tsx index c677211620e..74d18807be6 100644 --- a/platform/ui-next/playground/studylist/app.tsx +++ b/platform/ui-next/playground/studylist/app.tsx @@ -6,8 +6,8 @@ import { StudyList, type StudyRow, type WorkflowId } from '../../StudyList'; export function App() { const handleLaunch = React.useCallback((study: StudyRow, workflow: WorkflowId) => { try { - // eslint-disable-next-line no-console - console.log('Launch workflow:', workflow, { study }); + const target = `/studylist/launch?wf=${encodeURIComponent(String(workflow))}`; + window.location.assign(target); } catch {} }, []); diff --git a/platform/ui-next/playground/studylist/launch.tsx b/platform/ui-next/playground/studylist/launch.tsx new file mode 100644 index 00000000000..ff0fb63684e --- /dev/null +++ b/platform/ui-next/playground/studylist/launch.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { ThemeWrapper } from '../../src/components/ThemeWrapper'; +import { Button } from '../../src/components/Button'; + +function useWorkflowName() { + const params = new URLSearchParams(window.location.search); + const wf = params.get('wf') || ''; + return wf; +} + +function LaunchPage() { + const wf = useWorkflowName(); + const label = (wf || 'unknown').toString(); + const display = label.toLowerCase(); + + return ( + +
+
+
+

{display}

+ +
+
This is a placeholder page for the Viewer.
+
+
+
+ ); +} + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Root element not found'); +} +createRoot(container).render(); diff --git a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index 3afe0879111..9eaf91bf336 100644 --- a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -116,14 +116,7 @@ type IconProps = { children?: React.ReactNode; }; -function Icon({ - src, - alt = '', - size = 33, - className, - hideWhenEmpty, - children, -}: IconProps) { +function Icon({ src, alt = '', size = 33, className, hideWhenEmpty, children }: IconProps) { if (hideWhenEmpty && !src && !children) { return null; } @@ -351,7 +344,10 @@ const ActionInner = ( const srOnly = isDisabled && disabledReason ? ( - + {disabledReason} ) : null; @@ -440,6 +436,8 @@ Action.displayName = 'PatientSummaryAction'; type WorkflowButtonProps = { label?: React.ReactNode; onClick?: (data: T) => void; + /** Preferred: invoked with both data and workflow label */ + onLaunchWorkflow?: (data: T, workflow: M) => void; disabled?: boolean; disabledReason?: string; className?: string; @@ -459,6 +457,7 @@ const WorkflowButtonInner = ( { label = 'Launch workflow', onClick, + onLaunchWorkflow, disabled, disabledReason, className, @@ -482,15 +481,20 @@ const WorkflowButtonInner = ( const getInferredWorkflows = React.useCallback((d: any): string[] => { const defaults = ['Basic Viewer', 'Segmentation']; - if (!d) return defaults; + if (!d) { + return defaults; + } if (Array.isArray(d.workflows) && d.workflows.length > 0) { return Array.from(new Set(d.workflows.map(String))); } const mod = String(d.modalities ?? '').toUpperCase(); const flows = [...defaults]; - if (mod.includes('US')) flows.push('US Workflow'); - if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) + if (mod.includes('US')) { + flows.push('US Workflow'); + } + if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) { flows.push('TMTV Workflow'); + } return Array.from(new Set(flows)); }, []); @@ -501,11 +505,18 @@ const WorkflowButtonInner = ( const hasDefault = !!(defaultMode && String(defaultMode).trim().length > 0); const handleLaunch = (wfLabel: string) => { - if (computedDisabled || !data) return; + if (computedDisabled || !data) { + return; + } // Back-compat explicit callbacks: - if (wfLabel === 'Basic Viewer') onLaunchBasic?.(data); - if (wfLabel === 'Segmentation') onLaunchSegmentation?.(data); + if (wfLabel === 'Basic Viewer') { + onLaunchBasic?.(data); + } + if (wfLabel === 'Segmentation') { + onLaunchSegmentation?.(data); + } // Generic handler fallback: + onLaunchWorkflow?.(data, wfLabel as unknown as M); onClick?.(data); try { // eslint-disable-next-line no-console @@ -525,17 +536,24 @@ const WorkflowButtonInner = ( const srOnly = computedDisabled && disabledReason ? ( - + {disabledReason} ) : null; const renderDefaultWorkflow = (labelValue: string) => ( -
+
); - return (
} - className={cn('border-border/50 bg-muted w-full rounded-lg px-4 py-3 text-left transition', className)} + className={cn( + 'border-border/50 bg-muted w-full rounded-lg px-4 py-3 text-left transition', + className + )} style={style} aria-disabled={computedDisabled || undefined} aria-describedby={computedDisabled && disabledReason ? reasonId : undefined} @@ -581,15 +604,13 @@ const WorkflowButtonInner = ( {/* Content selection logic */} {hasDefault && renderDefaultWorkflow(String(defaultMode))} {hasDefault && data && ( -
- Other Available Workflows -
+
Other Available Workflows
)} {data && (
{workflowButtons - .filter((wf) => !hasDefault || String(wf) !== String(defaultMode)) - .map((wf) => ( + .filter(wf => !hasDefault || String(wf) !== String(defaultMode)) + .map(wf => ( + + - - -
- - + + + } + > + {selected ? ( + - - - {/* Reuse the exact preview content to keep visuals identical */} -
-
- {selected ? ( - - ) : ( - - )} -
-
-
+ ) : ( + + )} + ); } diff --git a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index 9eaf91bf336..3e692b5f302 100644 --- a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -1,15 +1,41 @@ +/** + * PatientSummary — compound component for patient header + workflow actions + * + * What it is + * - A small set of primitives composed under a root provider: Patient, Workflows, Name, MRN, Meta, Actions, Action, Field, Section, Icon. + * - All subcomponents read `data` from the nearest via context. + * + * Minimal usage + * + * + * launch(data, wf)} + * /> + * + * + * Adapting data shapes + * - Use `get` on the root to map custom fields: + * r.displayName, mrn: r => r.patientId }}> + * + * Default workflow behavior + * - Pass `defaultMode` and `onDefaultModeChange` to show a default badge and a clear control. + * - Workflows render buttons for the rest of the available workflows. + * + * Helpful references + * - platform/ui-next/StudyList/PreviewPanel.tsx (in-context example) + * - platform/ui-next/StudyList/EmptyPanel.tsx (empty state example) + * - platform/ui-next/StudyList/headless/useStudyList.ts (state + availableWorkflowsFor) + * - platform/ui-next/StudyList/headless/workflows-registry.ts (workflow ids/utilities) + */ import * as React from 'react'; import type { ElementType } from 'react'; import { Cross2Icon } from '@radix-ui/react-icons'; import { cn } from '../../lib/utils'; import { Icons } from '../Icons/Icons'; import { Button } from '../Button'; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, -} from '../DropdownMenu'; /** Public getters to adapt arbitrary data shapes to the PatientSummary defaults */ export type PatientSummaryGetters = { @@ -32,28 +58,30 @@ const SummaryContext = React.createContext | null>( function useSummaryContext() { const context = React.useContext(SummaryContext); if (!context) { - throw new Error('PatientSummary.* components must be used within '); + throw new Error('PatientSummary.* components must be used within '); } return context as SummaryContextValue; } -type RootProps = { +type RootProps = { data?: T | null; - /** @deprecated use `data` instead */ - study?: T | null; get?: PatientSummaryGetters; className?: string; children?: React.ReactNode; }; -function Root({ +/** + * Root context provider for PatientSummary compound components. + * + * Use `get` to adapt arbitrary data shapes (e.g., map `displayName` → name, `patientId` → mrn). + */ +function Root({ data: dataProp, - study, get, className, children, }: RootProps) { - const data = dataProp ?? study ?? null; + const data = dataProp ?? null; const resolvedGetters = React.useMemo>( () => ({ @@ -436,7 +464,7 @@ Action.displayName = 'PatientSummaryAction'; type WorkflowButtonProps = { label?: React.ReactNode; onClick?: (data: T) => void; - /** Preferred: invoked with both data and workflow label */ + /** Called with (data, workflow) when a workflow is launched */ onLaunchWorkflow?: (data: T, workflow: M) => void; disabled?: boolean; disabledReason?: string; @@ -444,13 +472,12 @@ type WorkflowButtonProps = { icon?: React.ReactNode; iconPosition?: 'start' | 'end'; iconSize?: number; - as?: ElementType; - onLaunchBasic?: (data: T) => void; - onLaunchSegmentation?: (data: T) => void; - /** Selected default mode label; when set, replaces per-study workflow buttons with a badge */ + /** Selected default workflow label (managed via SettingsPopover) */ defaultMode?: M | null; - /** Updates the default mode label (set or clear) */ + /** Update the default workflow label (managed via SettingsPopover) */ onDefaultModeChange?: (value: M | null) => void; + /** Explicit list of workflows to render */ + workflows?: readonly (M | string)[]; } & Omit, 'onClick'>; const WorkflowButtonInner = ( @@ -464,64 +491,34 @@ const WorkflowButtonInner = ( icon = , iconPosition = 'end', iconSize = 18, - as, - onLaunchBasic, - onLaunchSegmentation, defaultMode, onDefaultModeChange, + workflows, style, ...rest }: WorkflowButtonProps, ref: React.ForwardedRef ) => { const { data } = useSummaryContext(); - const computedDisabled = disabled ?? false; // allow default-mode picking even when no data + const computedDisabled = disabled ?? false; const id = React.useId(); const reasonId = `${id}-reason`; - - const getInferredWorkflows = React.useCallback((d: any): string[] => { - const defaults = ['Basic Viewer', 'Segmentation']; - if (!d) { - return defaults; - } - if (Array.isArray(d.workflows) && d.workflows.length > 0) { - return Array.from(new Set(d.workflows.map(String))); - } - const mod = String(d.modalities ?? '').toUpperCase(); - const flows = [...defaults]; - if (mod.includes('US')) { - flows.push('US Workflow'); - } - if (mod.includes('PET/CT') || (mod.includes('PET') && mod.includes('CT'))) { - flows.push('TMTV Workflow'); - } - return Array.from(new Set(flows)); - }, []); - - const workflowButtons = React.useMemo( - () => getInferredWorkflows(data), - [data, getInferredWorkflows] - ); + const workflowButtons = React.useMemo(() => { + return Array.isArray(workflows) ? Array.from(new Set(workflows.map(String))) : []; + }, [workflows]); const hasDefault = !!(defaultMode && String(defaultMode).trim().length > 0); + const filteredWorkflows = React.useMemo(() => { + if (!hasDefault) return workflowButtons; + return workflowButtons.filter(wf => String(wf) !== String(defaultMode)); + }, [workflowButtons, hasDefault, defaultMode]); const handleLaunch = (wfLabel: string) => { if (computedDisabled || !data) { return; } - // Back-compat explicit callbacks: - if (wfLabel === 'Basic Viewer') { - onLaunchBasic?.(data); - } - if (wfLabel === 'Segmentation') { - onLaunchSegmentation?.(data); - } // Generic handler fallback: onLaunchWorkflow?.(data, wfLabel as unknown as M); onClick?.(data); - try { - // eslint-disable-next-line no-console - console.log('Launch workflow:', wfLabel, { study: data }); - } catch {} }; const iconNode = icon ? ( @@ -601,16 +598,14 @@ const WorkflowButtonInner = ( {iconNode && iconPosition === 'end' ? iconNode : null}
- {/* Content selection logic */} + {/* Default workflow badge + other workflows */} {hasDefault && renderDefaultWorkflow(String(defaultMode))} - {hasDefault && data && ( + {hasDefault && data && filteredWorkflows.length > 0 && (
Other Available Workflows
)} - {data && ( + {data && filteredWorkflows.length > 0 && (
- {workflowButtons - .filter(wf => !hasDefault || String(wf) !== String(defaultMode)) - .map(wf => ( + {filteredWorkflows.map(wf => ( - + + + + + + + + Action Menu + + e.stopPropagation()}>
Launch Workflow: From f399d27a4b258ae518c67c828e34db25e66689ef Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 17 Nov 2025 13:16:05 -0500 Subject: [PATCH 082/172] Fix: table header and filters resize correctly --- .../src/components/StudyList/components/StudyListTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx b/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx index fb0c65e33f4..be30b34fb10 100644 --- a/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx +++ b/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx @@ -172,7 +172,7 @@ function Content({
- +
{renderColGroup()} {table.getHeaderGroups().map((hg) => ( From e4b1ec12d1ef43e18d8f3c810204d9c0aaa9a0a1 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 17 Nov 2025 15:07:54 -0500 Subject: [PATCH 083/172] Added rows per page in Pagination, Assets Default 50 results --- .../src/components/DataTable/Pagination.tsx | 59 ++++++++++++------- .../src/components/Icons/Sources/More.tsx | 1 - 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/platform/ui-next/src/components/DataTable/Pagination.tsx b/platform/ui-next/src/components/DataTable/Pagination.tsx index ceb68b3f658..e2b13f7dd85 100644 --- a/platform/ui-next/src/components/DataTable/Pagination.tsx +++ b/platform/ui-next/src/components/DataTable/Pagination.tsx @@ -1,23 +1,14 @@ import * as React from 'react'; import { Button } from '../Button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '../DropdownMenu'; +import { Icons } from '../Icons'; import { useDataTable } from './context'; -function ChevronLeftIcon(props: React.SVGProps) { - return ( - - ); -} - -function ChevronRightIcon(props: React.SVGProps) { - return ( - - ); -} - /** * DataTablePagination * Renders "start-end of total" and ghost chevron buttons for prev/next. @@ -35,8 +26,34 @@ export function DataTablePagination() { const canNext = table.getCanNextPage(); return ( -
- {`${start}-${end} of ${total}`} +
+ + + + + + {[25, 50, 100].map(size => ( + { + e.preventDefault(); + table.setPageSize(size); + }} + className="flex items-center gap-[2px]" + > + + {size} per page + + ))} + +
); -} \ No newline at end of file +} 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 Date: Mon, 17 Nov 2025 16:02:10 -0500 Subject: [PATCH 084/172] Settings added to panel close state --- .../routes/StudyListNext2/StudyListNext2.tsx | 26 +++++++++++++++++- .../StudyList/columns/defaultColumns.tsx | 4 +-- .../layouts/StudyListLargeLayout.tsx | 27 +++++++++++++++++-- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx index 545c0dad4d9..dd7271d8a29 100644 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx @@ -292,7 +292,7 @@ export default function StudyListNext2({ onOpenPanel={() => state.setPanelOpen(true)} onSelectionChange={sel => state.setSelected((sel as UISLRow[])[0] ?? null)} toolbarLeft={} - renderOpenPanelButton={() => } + renderOpenPanelButton={() => } />
@@ -307,6 +307,30 @@ export default function StudyListNext2({ ); } +function ClosedPanelControls() { + const { defaultWorkflow, setDefaultWorkflow } = useStudyList(); + const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); + + return ( + +
+ + + + +
+ +
+ ); +} + function SidePanelPreview({ dataSource, extensionManager }: { dataSource: any; extensionManager: any }) { const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow, launch } = useStudyList(); const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); diff --git a/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx index b02cae023b2..f8786fb1fcf 100644 --- a/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx +++ b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx @@ -114,8 +114,8 @@ export function defaultColumns(): ColumnDef[] { const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕'; return (
-
@@ -88,6 +88,30 @@ export function StudyListLargeLayout({ ); } +function ClosedPanelControls() { + const { defaultWorkflow, setDefaultWorkflow } = useStudyList(); + const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); + + return ( + +
+ + + + +
+ +
+ ); +} + function SidePanel() { const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow } = useStudyList(); const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); @@ -133,4 +157,3 @@ function SidePanel() { ); } - From 4d83eb690cfa177b822213739f312479195c119b Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 17 Nov 2025 16:38:58 -0500 Subject: [PATCH 085/172] Added List View to Preview Panel --- .../routes/StudyListNext2/StudyListNext2.tsx | 75 ++++++++++++------- .../components/PreviewPanelContent.tsx | 72 ++++++++++++------ .../StudyList/components/SeriesListView.tsx | 72 ++++++++++++++++++ .../StudyList/headless/useStudyList.ts | 5 ++ .../ui-next/src/components/StudyList/index.ts | 1 + 5 files changed, 178 insertions(+), 47 deletions(-) create mode 100644 platform/ui-next/src/components/StudyList/components/SeriesListView.tsx diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx index dd7271d8a29..ce0f09ef3e2 100644 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx @@ -23,6 +23,7 @@ import { Popover, PopoverTrigger, SettingsPopover, + SeriesListView, } from '@ohif/ui-next'; import { Types as coreTypes, utils, DicomMetadataStore } from '@ohif/core'; @@ -332,7 +333,7 @@ function ClosedPanelControls() { } function SidePanelPreview({ dataSource, extensionManager }: { dataSource: any; extensionManager: any }) { - const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow, launch } = useStudyList(); + const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow, launch, seriesViewMode, setSeriesViewMode } = useStudyList(); const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); const [series, setSeries] = React.useState([]); const [thumbs, setThumbs] = React.useState>({}); @@ -497,31 +498,55 @@ function SidePanelPreview({ dataSource, extensionManager }: { dataSource: any; e onLaunchWorkflow={(data, wf) => launch((data as UISLRow) ?? (selected as UISLRow), wf)} /> -
- {series?.length ? '1 Study' : 'No Series'} -
-
- {series?.map((s: any, i: number) => { - const seriesUID = s.seriesInstanceUid || s.SeriesInstanceUID || String(i); - const imageSrc = thumbs[seriesUID] || undefined; - return ( - {}} - onDoubleClick={() => {}} - viewPreset="thumbnails" - /> - ); - })} +
+ {series?.length ? '1 Study' : 'No Series'} +
+ + +
+ {seriesViewMode === 'thumbnails' ? ( +
+ {series?.map((s: any, i: number) => { + const seriesUID = s.seriesInstanceUid || s.SeriesInstanceUID || String(i); + const imageSrc = thumbs[seriesUID] || undefined; + return ( + {}} + onDoubleClick={() => {}} + viewPreset="thumbnails" + /> + ); + })} +
+ ) : ( + + )}
diff --git a/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx b/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx index d7735837c45..5c4181a9ced 100644 --- a/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx +++ b/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx @@ -7,6 +7,9 @@ import type { StudyRow } from '../StudyListTypes'; import type { WorkflowId } from '../WorkflowsInfer'; import { PatientSummary } from '../../PatientSummary'; import { useStudyList } from '../headless/StudyListProvider'; +import { SeriesListView } from './SeriesListView'; +import { Button } from '../../Button'; +import { Icons } from '../../Icons'; export function PreviewPanelContent({ study, @@ -17,13 +20,14 @@ export function PreviewPanelContent({ defaultMode: WorkflowId | null; onDefaultModeChange: (v: WorkflowId | null) => void; }) { - const { launch, availableWorkflowsFor } = useStudyList(); + const { launch, availableWorkflowsFor, seriesViewMode, setSeriesViewMode } = useStudyList(); const seriesCount = React.useMemo(() => Math.floor(Math.random() * 7) + 3, []); - const thumbnails = Array.from({ length: seriesCount }, (_, i) => ({ - id: `preview-${study.accession}-${i}`, + const seriesData = Array.from({ length: seriesCount }, (_, i) => ({ + seriesInstanceUid: `preview-${study.accession}-${i}`, description: `Series ${i + 1}`, seriesNumber: i + 1, - numInstances: 1, + numInstances: Math.floor(Math.random() * 150) + 1, + modality: study.modalities, })); return ( @@ -39,25 +43,49 @@ export function PreviewPanelContent({ onLaunchWorkflow={(data, wf) => launch((data as StudyRow) ?? study, wf)} /> -
- 1 Study -
-
- {thumbnails.map((item) => ( - {}} - onDoubleClick={() => {}} - viewPreset="thumbnails" - /> - ))} +
+ 1 Study +
+ + +
+ {seriesViewMode === 'thumbnails' ? ( +
+ {seriesData.map((item) => ( + {}} + onDoubleClick={() => {}} + viewPreset="thumbnails" + /> + ))} +
+ ) : ( + + )}
diff --git a/platform/ui-next/src/components/StudyList/components/SeriesListView.tsx b/platform/ui-next/src/components/StudyList/components/SeriesListView.tsx new file mode 100644 index 00000000000..aa4a48dbb7b --- /dev/null +++ b/platform/ui-next/src/components/StudyList/components/SeriesListView.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '../../Table'; +import { Icons } from '../../Icons'; + +type SeriesData = { + seriesInstanceUid?: string; + SeriesInstanceUID?: string; + modality?: string; + Modality?: string; + description?: string; + SeriesDescription?: string; + seriesNumber?: number | string; + SeriesNumber?: number | string; + numSeriesInstances?: number; + numInstances?: number; +}; + +type Props = { + series: SeriesData[]; + onSeriesClick?: (series: SeriesData) => void; +}; + +export function SeriesListView({ series, onSeriesClick }: Props) { + return ( +
+
+ + + + 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/headless/useStudyList.ts b/platform/ui-next/src/components/StudyList/headless/useStudyList.ts index 7db0e5d7ae2..9dcaa995bf9 100644 --- a/platform/ui-next/src/components/StudyList/headless/useStudyList.ts +++ b/platform/ui-next/src/components/StudyList/headless/useStudyList.ts @@ -10,6 +10,8 @@ import { * Builds the headless state for the Study List. * Keeps selection, panel open state, default workflow, and a launch handler. */ +export type SeriesViewMode = 'thumbnails' | 'list'; + export function useStudyListState( rows: T[], { @@ -22,6 +24,7 @@ export function useStudyListState( ) { const [selected, setSelected] = React.useState(null); const [isPanelOpen, setPanelOpen] = React.useState(true); + const [seriesViewMode, setSeriesViewMode] = React.useState('thumbnails'); const [defaultWorkflow, setDefaultWorkflow] = useDefaultWorkflow( defaultWorkflowKey, ALL_WORKFLOW_OPTIONS as unknown as ReadonlyArray @@ -40,6 +43,8 @@ export function useStudyListState( setSelected, isPanelOpen, setPanelOpen, + seriesViewMode, + setSeriesViewMode, defaultWorkflow, setDefaultWorkflow, availableWorkflowsFor: (r: Partial | null | undefined) => diff --git a/platform/ui-next/src/components/StudyList/index.ts b/platform/ui-next/src/components/StudyList/index.ts index 4eb44699044..25ca8cf74f2 100644 --- a/platform/ui-next/src/components/StudyList/index.ts +++ b/platform/ui-next/src/components/StudyList/index.ts @@ -17,6 +17,7 @@ export * from './components/PreviewPanelShell'; export * from './components/StudyListLayout'; export * from './components/PreviewPanelContent'; export * from './components/PreviewPanelEmpty'; +export * from './components/SeriesListView'; // Hooks export * from './useDefaultWorkflow'; From 2f284f27fd10b3072bb37980c22ae01fbc92d930 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 17 Nov 2025 16:58:41 -0500 Subject: [PATCH 086/172] Use Viewer StudyBrowser controls --- .../routes/StudyListNext2/StudyListNext2.tsx | 34 ++++++++----------- .../components/PreviewPanelContent.tsx | 34 +++++++------------ .../StudyList/components/SeriesListView.tsx | 13 +++---- 3 files changed, 34 insertions(+), 47 deletions(-) diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx index ce0f09ef3e2..ead6c523b73 100644 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx @@ -24,6 +24,8 @@ import { PopoverTrigger, SettingsPopover, SeriesListView, + ToggleGroup, + ToggleGroupItem, } from '@ohif/ui-next'; import { Types as coreTypes, utils, DicomMetadataStore } from '@ohif/core'; @@ -500,26 +502,18 @@ function SidePanelPreview({ dataSource, extensionManager }: { dataSource: any; e
{series?.length ? '1 Study' : 'No Series'} -
- - -
+ value && setSeriesViewMode(value as 'thumbnails' | 'list')} + > + + + + + + +
{seriesViewMode === 'thumbnails' ? (
diff --git a/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx b/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx index 5c4181a9ced..8b082440525 100644 --- a/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx +++ b/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx @@ -8,7 +8,7 @@ import type { WorkflowId } from '../WorkflowsInfer'; import { PatientSummary } from '../../PatientSummary'; import { useStudyList } from '../headless/StudyListProvider'; import { SeriesListView } from './SeriesListView'; -import { Button } from '../../Button'; +import { ToggleGroup, ToggleGroupItem } from '../../ToggleGroup'; import { Icons } from '../../Icons'; export function PreviewPanelContent({ @@ -45,26 +45,18 @@ export function PreviewPanelContent({
1 Study -
- - -
+ value && setSeriesViewMode(value as 'thumbnails' | 'list')} + > + + + + + + +
{seriesViewMode === 'thumbnails' ? (
diff --git a/platform/ui-next/src/components/StudyList/components/SeriesListView.tsx b/platform/ui-next/src/components/StudyList/components/SeriesListView.tsx index aa4a48dbb7b..db053a83910 100644 --- a/platform/ui-next/src/components/StudyList/components/SeriesListView.tsx +++ b/platform/ui-next/src/components/StudyList/components/SeriesListView.tsx @@ -33,10 +33,11 @@ export function SeriesListView({ series, onSeriesClick }: Props) { - - Modality / Series + + Modality + / Series - + @@ -53,13 +54,13 @@ export function SeriesListView({ series, onSeriesClick }: Props) { key={seriesUID} className="hover:bg-transparent hover:text-muted-foreground cursor-default" > - +
- {modality} + {modality} {description}
- + {numInstances} From fe7c4477f0e3a1dd0989c8fdc3f6d0a0a28d53f6 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 17 Nov 2025 17:13:50 -0500 Subject: [PATCH 087/172] Added no description feedback in table --- .../src/components/StudyList/columns/defaultColumns.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx index f8786fb1fcf..6805009c672 100644 --- a/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx +++ b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx @@ -84,7 +84,10 @@ export function defaultColumns(): ColumnDef[] { title="Description" /> ), - cell: ({ row }) =>
{row.getValue('description')}
, + cell: ({ row }) => { + const description = row.getValue('description') as string; + return
{description || 'No Description'}
; + }, meta: { label: 'Description', headerClassName: 'min-w-[290px]', From d0c677da616fbd23f23b0e847b0bc5041f39bcc5 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 17 Nov 2025 17:19:18 -0500 Subject: [PATCH 088/172] Added "No Default" to default workflow menu --- .../StudyList/components/SettingsPopover.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx b/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx index f47671cccd4..480e7869bff 100644 --- a/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx +++ b/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx @@ -4,6 +4,7 @@ import { Select, SelectContent, SelectItem, + SelectSeparator, SelectTrigger, SelectValue, } from '../../Select'; @@ -28,6 +29,7 @@ type Props = { */ export function SettingsPopover({ open, onOpenChange, defaultMode, onDefaultModeChange }: Props) { const selectId = React.useId(); + const NO_DEFAULT_VALUE = '__NO_DEFAULT__'; return ( { - onDefaultModeChange(value as WorkflowId); + if (value === NO_DEFAULT_VALUE) { + onDefaultModeChange(null); + } else { + onDefaultModeChange(value as WorkflowId); + } // Close the popover after selection for a snappier UX. onOpenChange(false); }} @@ -60,6 +66,8 @@ export function SettingsPopover({ open, onOpenChange, defaultMode, onDefaultMode {opt} ))} + + No Default From c09836cfbb9db70ee1da84e8f63e65f6f09f5c90 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Mon, 17 Nov 2025 17:37:06 -0500 Subject: [PATCH 089/172] Removed additional backgrounds --- platform/app/src/routes/StudyListNext/StudyListNext.tsx | 2 +- platform/app/src/routes/StudyListNext2/StudyListNext2.tsx | 2 +- .../src/components/StudyList/layouts/StudyListLargeLayout.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/platform/app/src/routes/StudyListNext/StudyListNext.tsx b/platform/app/src/routes/StudyListNext/StudyListNext.tsx index ed2c8633a9e..0de31eec7a2 100644 --- a/platform/app/src/routes/StudyListNext/StudyListNext.tsx +++ b/platform/app/src/routes/StudyListNext/StudyListNext.tsx @@ -340,7 +340,7 @@ export default function StudyListNext({ table={
-
+
-
+
-
+
Date: Tue, 18 Nov 2025 06:39:00 -0500 Subject: [PATCH 090/172] SettingsPopover with subcomponents --- .../PatientSummary/PatientSummary.tsx | 2 +- .../StudyList/components/SettingsPopover.tsx | 286 +++++++++++++++--- .../layouts/StudyListLargeLayout.tsx | 73 ++--- 3 files changed, 274 insertions(+), 87 deletions(-) diff --git a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index 2f0c5b03e20..affaa1ac3a0 100644 --- a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -199,7 +199,7 @@ function Title({ return ( {typeof children === 'function' ? children(content, data) : content} diff --git a/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx b/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx index 480e7869bff..42ae12d0fee 100644 --- a/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx +++ b/platform/ui-next/src/components/StudyList/components/SettingsPopover.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { PopoverContent } from '../../Popover/Popover'; +import { Popover, PopoverTrigger, PopoverContent } from '../../Popover/Popover'; import { Select, SelectContent, @@ -9,69 +9,255 @@ import { SelectValue, } from '../../Select'; import { Label } from '../../Label'; +import { Button } from '../../Button'; +import { Icons } from '../../Icons'; import { ALL_WORKFLOW_OPTIONS, type WorkflowId } from '../WorkflowsInfer'; -type Props = { - /** Controlled open state from parent Popover */ - open: boolean; - /** onOpenChange from parent Popover */ - onOpenChange: (open: boolean) => void; +/** 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 RootProps = { + /** Controlled open state (optional). If omitted, component manages its own state. */ + open?: boolean; + /** onOpenChange for controlled usage (optional). */ + onOpenChange?: (open: boolean) => void; + /** PopoverContent alignment (defaults to "end"). */ + align?: React.ComponentProps['align']; + /** PopoverContent side offset (defaults to 8). */ + sideOffset?: number; + /** Optional className to extend PopoverContent. */ + contentClassName?: string; + /** + * Children must include exactly one plus any content for the popover body + * (e.g., , , , etc.). + */ + 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 SettingsPopoverComponent({ + open, + onOpenChange, + align = 'end', + sideOffset = 8, + contentClassName, + children, +}: RootProps) { + const isControlled = typeof open === 'boolean'; + const [internalOpen, setInternalOpen] = React.useState(false); + + const isOpen = isControlled ? (open as boolean) : internalOpen; + const setOpen = React.useCallback( + (next: boolean) => { + if (!isControlled) { + setInternalOpen(next); + } + onOpenChange?.(next); + }, + [isControlled, onOpenChange] + ); + + const close = React.useCallback(() => setOpen(false), [setOpen]); + + // Extract the Trigger node from children and collect the rest as popover content + const childrenArray = React.Children.toArray(children); + let triggerNode: React.ReactNode | null = null; + const contentChildren: React.ReactNode[] = []; + + 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 { + contentChildren.push(child); + } + } + + if (!triggerNode) { + throw new Error(' is required as a direct child of .'); + } + + return ( + + + {triggerNode} + + + e.preventDefault()} + > + + {contentChildren} + + + + ); +} + +type WorkflowProps = { /** Selected default workflow */ defaultMode: WorkflowId | null; /** Handler when default workflow changes */ onDefaultModeChange: (value: WorkflowId | null) => void; + /** Optional label text, defaults to "Default Workflow" */ + label?: string; }; /** - * SettingsPopover - * Renders PopoverContent with the settings form. - * Intended to be used inside a Popover with a PopoverTrigger. + * SettingsPopover.Workflow + * Renders the "Default Workflow" row with a Select. + * Closes the popover after selection. */ -export function SettingsPopover({ open, onOpenChange, defaultMode, onDefaultModeChange }: Props) { +function Workflow({ defaultMode, onDefaultModeChange, label = 'Default Workflow' }: WorkflowProps) { + const { close } = useSettingsPopoverContext(); const selectId = React.useId(); const NO_DEFAULT_VALUE = '__NO_DEFAULT__'; return ( - e.preventDefault()} - > -
- -
- -
+
+ +
+
- +
+ ); +} + +/** + * SettingsPopover.Divider + * A simple divider to separate sections inside the popover. + */ +function Divider() { + return
; +} + +type LinkProps = { + /** Link label */ + children: React.ReactNode; + /** Optional href for navigation */ + href?: string; + /** Optional click handler (runs before closing) */ + onClick?: () => void; + /** Target for anchor links (e.g., "_blank") */ + target?: string; + /** rel for anchor links */ + rel?: string; + /** data-cy for testing */ + dataCY?: string; +}; + +/** + * SettingsPopover.Link + * Generic link-style button that matches existing popover link styling. + * Supports href or onClick and closes the popover afterwards. + */ +function Link({ children, href, onClick, target, rel, dataCY }: LinkProps) { + const { close } = useSettingsPopoverContext(); + + const handleClick: React.MouseEventHandler = (e) => { + onClick?.(); + // Close popover after an action + close(); + }; + + // Render as anchor if href provided; otherwise as button + if (href) { + return ( + + ); + } + + return ( + ); } + +SettingsPopoverComponent.displayName = 'SettingsPopover'; + +export const SettingsPopover = Object.assign(SettingsPopoverComponent, { + Trigger: SettingsPopoverTrigger, + Workflow, + Divider, + Link, +}); diff --git a/platform/ui-next/src/components/StudyList/layouts/StudyListLargeLayout.tsx b/platform/ui-next/src/components/StudyList/layouts/StudyListLargeLayout.tsx index 9bd6e7359b1..e3e879934be 100644 --- a/platform/ui-next/src/components/StudyList/layouts/StudyListLargeLayout.tsx +++ b/platform/ui-next/src/components/StudyList/layouts/StudyListLargeLayout.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import type { ColumnDef } from '@tanstack/react-table'; import { Icons } from '../../Icons'; import { Button } from '../../Button'; -import { Popover, PopoverTrigger } from '../../Popover/Popover'; import type { StudyRow } from '../StudyListTypes'; import type { WorkflowId } from '../WorkflowsInfer'; import { StudyListTable } from '../components/StudyListTable'; @@ -90,58 +89,60 @@ export function StudyListLargeLayout({ function ClosedPanelControls() { const { defaultWorkflow, setDefaultWorkflow } = useStudyList(); - const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); return ( - -
- +
+ + - - -
- - + + + + About OHIF Viewer + User Preferences + + + +
); } function SidePanel() { const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow } = useStudyList(); - const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); return ( -
- +
+ + - - -
- - + + + + About OHIF Viewer + User Preferences + + + +
} > {selected ? ( From 61dbd127b0bbd86b286e6bf4f43bf18592c32413 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 18 Nov 2025 06:50:12 -0500 Subject: [PATCH 091/172] Added settings popover to prototype with working modals --- .../routes/StudyListNext2/StudyListNext2.tsx | 128 +++++++++++++----- 1 file changed, 91 insertions(+), 37 deletions(-) diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx index c3e87cb3afe..38b4fe49c25 100644 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx @@ -20,15 +20,14 @@ import { TooltipProvider, Icons, Button, - Popover, - PopoverTrigger, + useModal, SettingsPopover, SeriesListView, ToggleGroup, ToggleGroupItem, } from '@ohif/ui-next'; -import { Types as coreTypes, utils, DicomMetadataStore } from '@ohif/core'; +import { Types as coreTypes, utils, DicomMetadataStore, useSystem } from '@ohif/core'; import type { StudyRow as UISLRow } from '@ohif/ui-next'; import type { WorkflowId } from '@ohif/ui-next'; import { DndProvider } from 'react-dnd'; @@ -312,31 +311,62 @@ export default function StudyListNext2({ function ClosedPanelControls() { const { defaultWorkflow, setDefaultWorkflow } = useStudyList(); - const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); + const { t } = useTranslation(); + const { servicesManager } = useSystem(); + const { customizationService } = servicesManager.services as any; + const { show } = useModal(); return ( - -
- +
+ + - - -
- - + + + + { + const AboutModal = customizationService.getCustomization('ohif.aboutModal'); + show({ + content: AboutModal, + title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'), + containerClassName: AboutModal?.containerClassName ?? 'max-w-md', + }); + }} + > + About OHIF Viewer + + { + 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', + }); + }} + > + User Preferences + + + + +
); } function SidePanelPreview({ dataSource, extensionManager }: { dataSource: any; extensionManager: any }) { const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow, launch, seriesViewMode, setSeriesViewMode } = useStudyList(); - const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); + const { t } = useTranslation(); + const { servicesManager } = useSystem(); + const { customizationService } = servicesManager.services as any; + const { show } = useModal(); const [series, setSeries] = React.useState([]); const [thumbs, setThumbs] = React.useState>({}); const { sortBySeriesDate } = utils as any; @@ -462,29 +492,53 @@ function SidePanelPreview({ dataSource, extensionManager }: { dataSource: any; e return ( -
- +
+ + - - -
- - + User Preferences + + + +
} > {selected ? ( From e445a9cfa8d3a18d3dfb7588bb5261cf59e80053 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 18 Nov 2025 07:05:12 -0500 Subject: [PATCH 092/172] Updated docs --- platform/ui-next/src/components/StudyList/README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/platform/ui-next/src/components/StudyList/README.md b/platform/ui-next/src/components/StudyList/README.md index 1a60f1a5841..e310e68a0e3 100644 --- a/platform/ui-next/src/components/StudyList/README.md +++ b/platform/ui-next/src/components/StudyList/README.md @@ -126,8 +126,17 @@ ui-next/src/components/StudyList/ - Dropdown built with DS `DropdownMenu` listing workflows for a row. - Source of truth: `getAvailableWorkflows({ workflows, modalities })`. -### `components/SettingsPopover.tsx` -- Popover content for selecting the default workflow, persisted via `useDefaultWorkflow`. +### `components/SettingsPopover.tsx` (compound) +- Overview: a small, composable popover used in the Study List to surface quick settings and actions (e.g., choosing a default workflow, opening About/User Preferences). +- Structure: a root SettingsPopover with exactly one Trigger and any number of body items. +- Subcomponents: + - SettingsPopover.Trigger — wraps your trigger element (such as a button or icon). + - SettingsPopover.Workflow — renders the “Default Workflow” selector and closes the popover after selection. + - SettingsPopover.Divider — visual separator between sections. + - SettingsPopover.Link — link‑style action that can navigate or run a custom handler; the popover closes after activation. +- Notes: + - Include one Trigger as a direct child of SettingsPopover. + - Intended for use in the table toolbar and preview panel header. ### `components/PreviewPanelContent.tsx` and `components/PreviewPanelEmpty.tsx` - Default preview content using `PatientSummary`; the former renders thumbnails and workflows for the selected row, the latter renders an empty state. From 312f5f1090a127797d5aef404a6bf45965321d89 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 18 Nov 2025 08:01:32 -0500 Subject: [PATCH 093/172] SettingsPopover updates --- .../routes/StudyListNext2/StudyListNext2.tsx | 124 +++++++++--------- .../src/components/StudyList/README.md | 34 ++++- .../StudyList/components/SettingsPopover.tsx | 74 +++++++---- .../layouts/StudyListLargeLayout.tsx | 32 +++-- 4 files changed, 154 insertions(+), 110 deletions(-) diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx index 38b4fe49c25..cf778181b29 100644 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx @@ -324,36 +324,38 @@ function ClosedPanelControls() {
diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx index cf778181b29..3aa290c8e5d 100644 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx @@ -280,7 +280,8 @@ export default function StudyListNext2({ onIsPanelOpenChange={state.setPanelOpen} defaultPreviewSizePercent={previewDefaultSize} className="h-full w-full" - table={ + > +
@@ -299,9 +300,11 @@ export default function StudyListNext2({
- } - preview={} - /> +
+ + + +
diff --git a/platform/ui-next/src/components/StudyList/README.md b/platform/ui-next/src/components/StudyList/README.md index 86f96863aac..a5afaed88b5 100644 --- a/platform/ui-next/src/components/StudyList/README.md +++ b/platform/ui-next/src/components/StudyList/README.md @@ -103,8 +103,13 @@ ui-next/src/components/StudyList/ ### `components/StudyListLayout.tsx` - Resizable horizontal split for table and preview. -- Props: `isPanelOpen`, `onIsPanelOpenChange`, `defaultPreviewSizePercent`, `minPreviewSizePercent`, `table`, `preview`. -- Hook: `useStudyListLayout()`; `OpenPreviewButton` re‑opens the preview when closed. +- Compound API using slots: + - `StudyListLayout.Table` — left panel content (e.g., table). + - `StudyListLayout.Preview` — right panel content (renders only when open). + - `StudyListLayout.Handle` — optional explicit handle insert (Preview includes one automatically). + - `StudyListLayout.OpenPreviewButton` — button to re‑open the preview when closed. +- Props: `isPanelOpen`, `onIsPanelOpenChange`, `defaultPreviewSizePercent`, `minPreviewSizePercent?`, `className?`. +- Hook: `useStudyListLayout()` to access `isPanelOpen`, `openPanel`, `closePanel`. ### `components/PreviewPanelShell.tsx` - Light container for preview content (header slot + scroll area). @@ -264,7 +269,8 @@ Internal monorepo path (for local development): `platform/ui-next/src/components onIsPanelOpenChange={state.setPanelOpen} defaultPreviewSizePercent={30} className="h-full w-full" - table={ + > + state.setPanelOpen(true)} /> - } - preview={ + +
{/* Your preview content */}
- } - /> +
+ ); } diff --git a/platform/ui-next/src/components/StudyList/components/StudyListLayout.tsx b/platform/ui-next/src/components/StudyList/components/StudyListLayout.tsx index 9cdeb4af2d4..c0c6446e359 100644 --- a/platform/ui-next/src/components/StudyList/components/StudyListLayout.tsx +++ b/platform/ui-next/src/components/StudyList/components/StudyListLayout.tsx @@ -11,6 +11,8 @@ type LayoutContextValue = { isPanelOpen: boolean; openPanel: () => void; closePanel: () => void; + defaultPreviewSizePercent: number; + minPreviewSizePercent: number; }; const LayoutContext = React.createContext(undefined); @@ -28,9 +30,8 @@ type RootProps = { onIsPanelOpenChange: (open: boolean) => void; defaultPreviewSizePercent: number; minPreviewSizePercent?: number; - table?: React.ReactNode; - preview?: React.ReactNode; className?: string; + children?: React.ReactNode; }; function StudyListLayoutComponent({ @@ -38,37 +39,59 @@ function StudyListLayoutComponent({ onIsPanelOpenChange, defaultPreviewSizePercent, minPreviewSizePercent = 15, - table, - preview, className, + children, }: RootProps) { const openPanel = React.useCallback(() => onIsPanelOpenChange(true), [onIsPanelOpenChange]); const closePanel = React.useCallback(() => onIsPanelOpenChange(false), [onIsPanelOpenChange]); const value = React.useMemo( - () => ({ isPanelOpen, openPanel, closePanel }), - [isPanelOpen, openPanel, closePanel] + () => ({ isPanelOpen, openPanel, closePanel, defaultPreviewSizePercent, minPreviewSizePercent }), + [isPanelOpen, openPanel, closePanel, defaultPreviewSizePercent, minPreviewSizePercent] ); return ( - - {table ?? null} - - {isPanelOpen ? ( - <> - - - {preview ?? null} - - - ) : null} + {children} ); } +function Table({ children }: { children?: React.ReactNode }) { + const { defaultPreviewSizePercent } = useStudyListLayout(); + return {children}; +} + +function Handle() { + return ; +} + +function Preview({ + minSizePercent, + defaultSizePercent, + children, +}: { + minSizePercent?: number; + defaultSizePercent?: number; + children?: React.ReactNode; +}) { + const { isPanelOpen, defaultPreviewSizePercent, minPreviewSizePercent } = useStudyListLayout(); + if (!isPanelOpen) return null; + return ( + <> + + + {children} + + + ); +} + function OpenPreviewButton({ className, 'aria-label': ariaLabel = 'Open preview panel', @@ -92,4 +115,9 @@ function OpenPreviewButton({ } StudyListLayoutComponent.displayName = 'StudyListLayout'; -export const StudyListLayout = Object.assign(StudyListLayoutComponent, { OpenPreviewButton }); +export const StudyListLayout = Object.assign(StudyListLayoutComponent, { + Table, + Preview, + OpenPreviewButton, + Handle, +}); diff --git a/platform/ui-next/src/components/StudyList/layouts/StudyListLargeLayout.tsx b/platform/ui-next/src/components/StudyList/layouts/StudyListLargeLayout.tsx index 4e48046bf63..7323da1d368 100644 --- a/platform/ui-next/src/components/StudyList/layouts/StudyListLargeLayout.tsx +++ b/platform/ui-next/src/components/StudyList/layouts/StudyListLargeLayout.tsx @@ -59,7 +59,8 @@ export function StudyListLargeLayout({ onIsPanelOpenChange={state.setPanelOpen} defaultPreviewSizePercent={previewDefaultSize} className="h-full w-full" - table={ + > +
@@ -80,9 +81,11 @@ export function StudyListLargeLayout({
- } - preview={} - /> +
+ + + + ); } From 7332e7eaccdedda5a51d71b814110c1d48f9c83d Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 18 Nov 2025 08:46:49 -0500 Subject: [PATCH 095/172] Added real Icons for sorting --- .../src/components/DataTable/ColumnHeader.tsx | 10 ++++-- .../ui-next/src/components/Icons/Icons.tsx | 6 ++++ .../components/Icons/Sources/SortingNew.tsx | 20 +++++++++++ .../Icons/Sources/SortingNewAscending.tsx | 35 +++++++++++++++++++ .../Icons/Sources/SortingNewDescending.tsx | 35 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 platform/ui-next/src/components/Icons/Sources/SortingNew.tsx create mode 100644 platform/ui-next/src/components/Icons/Sources/SortingNewAscending.tsx create mode 100644 platform/ui-next/src/components/Icons/Sources/SortingNewDescending.tsx diff --git a/platform/ui-next/src/components/DataTable/ColumnHeader.tsx b/platform/ui-next/src/components/DataTable/ColumnHeader.tsx index 82b20dbdad8..21eea586aed 100644 --- a/platform/ui-next/src/components/DataTable/ColumnHeader.tsx +++ b/platform/ui-next/src/components/DataTable/ColumnHeader.tsx @@ -3,6 +3,7 @@ import type { Column } from '@tanstack/react-table' import { Button } from '../Button' import * as ReactNS from 'react' import { DataTableContext } from './context' +import { Icons } from '../Icons' export function DataTableColumnHeader({ column, @@ -21,9 +22,14 @@ export function DataTableColumnHeader({ return {title} } const sorted = resolvedColumn.getIsSorted() as false | 'asc' | 'desc' - const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕' const justify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start' + const SortIcon = sorted === 'asc' + ? Icons.SortingNewAscending + : sorted === 'desc' + ? Icons.SortingNewDescending + : Icons.SortingNew + return (
{title} @@ -34,7 +40,7 @@ export function DataTableColumnHeader({ aria-label={`Sort ${title}`} className="px-1" > - {indicator} +
) diff --git a/platform/ui-next/src/components/Icons/Icons.tsx b/platform/ui-next/src/components/Icons/Icons.tsx index d6165dc50fa..9f103d6e7b6 100644 --- a/platform/ui-next/src/components/Icons/Icons.tsx +++ b/platform/ui-next/src/components/Icons/Icons.tsx @@ -52,6 +52,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'; @@ -532,6 +535,9 @@ export const Icons = { SortingAscending, SortingDescending, Sorting, + SortingNew, + SortingNewAscending, + SortingNewDescending, StatusError, StatusSuccess, StatusTracking, 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; From f76161cd875503408b83fa19d2ddade1881fe3c8 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 18 Nov 2025 08:51:33 -0500 Subject: [PATCH 096/172] Default sorting in prototype and width updates --- .../src/routes/StudyListNext2/StudyListNext2.tsx | 1 + .../StudyList/columns/defaultColumns.tsx | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx index 3aa290c8e5d..9e4a06a537c 100644 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx @@ -288,6 +288,7 @@ export default function StudyListNext2({ [] { cell: ({ row }) =>
{row.getValue('modalities')}
, meta: { label: 'Modalities', - headerClassName: 'w-[85px] min-w-[85px] max-w-[85px]', - cellClassName: 'w-[85px] min-w-[85px] max-w-[85px]', - fixedWidth: 85, + headerClassName: 'w-[97px] min-w-[97px] max-w-[97px]', + cellClassName: 'w-[97px] min-w-[97px] max-w-[97px]', + fixedWidth: 97, }, }, { @@ -114,7 +114,11 @@ export function defaultColumns(): ColumnDef[] { accessorKey: 'instances', header: ({ column }) => { const sorted = column.getIsSorted() as false | 'asc' | 'desc'; - const indicator = sorted === 'asc' ? '▲' : sorted === 'desc' ? '▼' : '↕'; + const SortIcon = sorted === 'asc' + ? Icons.SortingNewAscending + : sorted === 'desc' + ? Icons.SortingNewDescending + : Icons.SortingNew; return (
[] { aria-label="Sort by instances" className="px-1" > - {indicator} +
); From 66b9b0da3a2dd2523d2cc61dc86f99a2305bce0f Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 18 Nov 2025 09:10:29 -0500 Subject: [PATCH 097/172] Visual fixes to preview panel --- platform/app/src/routes/StudyListNext2/StudyListNext2.tsx | 4 ++-- .../src/components/PatientSummary/PatientSummary.tsx | 8 ++++---- .../StudyList/components/PreviewPanelContent.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx index 9e4a06a537c..0da0ecba46f 100644 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx @@ -562,8 +562,8 @@ function SidePanelPreview({ dataSource, extensionManager }: { dataSource: any; e onLaunchWorkflow={(data, wf) => launch((data as UISLRow) ?? (selected as UISLRow), wf)} /> -
- {series?.length ? '1 Study' : 'No Series'} +
+ {series?.length ? ((selected as UISLRow)?.description || 'No Description') : 'No Series'} ({ return ( {typeof children === 'function' ? children(content, data) : content} @@ -242,7 +242,7 @@ function Subtitle({ ? String(value) : undefined } - className={cn('text-muted-foreground truncate text-sm leading-tight', className)} + className={cn('text-muted-foreground truncate text-lg leading-tight', className)} > {typeof children === 'function' ? children(value, data) : baseContent} @@ -523,7 +523,7 @@ const WorkflowButtonInner = ( const iconNode = icon ? ( @@ -673,7 +673,7 @@ function Patient({ )} )} -
+
{!hideTitle && } {!hideSubtitle && <Subtitle />} </div> diff --git a/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx b/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx index 8b082440525..f88a927be8a 100644 --- a/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx +++ b/platform/ui-next/src/components/StudyList/components/PreviewPanelContent.tsx @@ -43,17 +43,17 @@ export function PreviewPanelContent({ onLaunchWorkflow={(data, wf) => launch((data as StudyRow) ?? study, wf)} /> </PatientSummary> - <div className="h-7 w-full px-2 flex items-center justify-between text-foreground font-semibold text-base"> - <span>1 Study</span> + <div className="h-5 w-full px-2 flex items-center justify-between gap-1 text-muted-foreground text-base"> + <span className="leading-tight">{study?.description || 'No Description'}</span> <ToggleGroup type="single" value={seriesViewMode} onValueChange={(value) => value && setSeriesViewMode(value as 'thumbnails' | 'list')} > - <ToggleGroupItem value="thumbnails" aria-label="Thumbnail view" className="text-actions-primary"> + <ToggleGroupItem value="thumbnails" aria-label="Thumbnail view" className="text-primary"> <Icons.ThumbnailView /> </ToggleGroupItem> - <ToggleGroupItem value="list" aria-label="List view" className="text-actions-primary"> + <ToggleGroupItem value="list" aria-label="List view" className="text-primary"> <Icons.ListView /> </ToggleGroupItem> </ToggleGroup> From 5939d875a2be39ed2fea14802df9073abd76f982 Mon Sep 17 00:00:00 2001 From: Dan Rukas <dan.rukas@gmail.com> Date: Tue, 18 Nov 2025 09:45:22 -0500 Subject: [PATCH 098/172] visual fix spacing for patient summary --- .../src/components/PatientSummary/PatientSummary.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx index d57026b9394..280dce0e6f7 100644 --- a/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx +++ b/platform/ui-next/src/components/PatientSummary/PatientSummary.tsx @@ -673,7 +673,7 @@ function Patient({ )} </Icon> )} - <div className="flex min-w-0 flex-col gap-px"> + <div className="flex min-w-0 flex-col gap-px h-[38px] justify-center"> {!hideTitle && <Title placeholder={placeholder} />} {!hideSubtitle && <Subtitle />} </div> @@ -722,9 +722,11 @@ function Empty({ children, icon, section }: EmptyProps) { /> </Icon> )} - <span className="text-muted-foreground text-base font-medium leading-tight"> - {children ?? 'Select a study'} - </span> + <div className="flex items-center h-[38px]"> + <span className="text-muted-foreground text-base font-medium leading-tight"> + {children ?? 'Select a study'} + </span> + </div> </Section> ); } From bb38da1fc3aacc2ddcbad2dc990db2e56d5e2a44 Mon Sep 17 00:00:00 2001 From: Dan Rukas <dan.rukas@gmail.com> Date: Tue, 18 Nov 2025 09:51:29 -0500 Subject: [PATCH 099/172] Remove second real data prototype --- .../routes/StudyListNext/StudyListNext.tsx | 571 ---------------- .../routes/StudyListNext2/StudyListNext2.tsx | 617 ------------------ .../StudyListNext2/StudyListNext2Entry.tsx | 57 -- platform/app/src/routes/index.tsx | 20 +- .../src/components/Icons/Sources/IconMPR.tsx | 1 + 5 files changed, 4 insertions(+), 1262 deletions(-) delete mode 100644 platform/app/src/routes/StudyListNext/StudyListNext.tsx delete mode 100644 platform/app/src/routes/StudyListNext2/StudyListNext2.tsx delete mode 100644 platform/app/src/routes/StudyListNext2/StudyListNext2Entry.tsx diff --git a/platform/app/src/routes/StudyListNext/StudyListNext.tsx b/platform/app/src/routes/StudyListNext/StudyListNext.tsx deleted file mode 100644 index 31e2d46c479..00000000000 --- a/platform/app/src/routes/StudyListNext/StudyListNext.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import moment from 'moment'; - -import { useAppConfig } from '@state'; -import { preserveQueryParameters } from '../../utils/preserveQueryParameters'; - -import { Onboarding, InvestigationalUseDialog } from '@ohif/ui-next'; - -import { - StudyListTable, - StudyListLayout, - StudyListProvider, - useStudyList, - useStudyListState, - defaultColumns, -} from '@ohif/ui-next'; -import { Button, Icons, Popover, PopoverTrigger, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@ohif/ui-next'; -import { SettingsPopover } from '@ohif/ui-next'; -import { PreviewPanelShell } from '@ohif/ui-next'; -import { PatientSummary } from '@ohif/ui-next'; -import { PreviewPanelEmpty } from '@ohif/ui-next'; -import { Thumbnail } from '@ohif/ui-next'; -import { Types as coreTypes, utils, DicomMetadataStore } from '@ohif/core'; -import type { StudyRow as UISLRow, WorkflowId } from '@ohif/ui-next'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import { TooltipProvider } from '@ohif/ui-next'; - -type Props = withAppTypes & { - data: any[]; - dataTotal: number; - dataSource: any; - isLoadingData: boolean; - dataPath?: string; - onRefresh: () => void; -}; - -const ROUTE_TO_WORKFLOW: Record<string, WorkflowId> = { - viewer: 'Basic Viewer', - basic: 'Basic Viewer', - segmentation: 'Segmentation', - tmtv: 'TMTV Workflow', - usAnnotation: 'US Workflow', - 'dynamic-volume': 'Preclinical 4D', - microscopy: 'Microscopy', -}; - -const WORKFLOW_TO_ROUTE: Record<WorkflowId, string> = { - 'Basic Viewer': 'viewer', - Segmentation: 'segmentation', - 'TMTV Workflow': 'tmtv', - 'US Workflow': 'usAnnotation', - 'Preclinical 4D': 'dynamic-volume', - Microscopy: 'microscopy', -}; - -function normalizeStudyDateTime(date?: string, time?: string) { - 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 d = mDate.format('YYYY-MM-DD'); - const t = mTime && mTime.isValid() ? mTime.format('HH:mm') : '00:00'; - return `${d} ${t}`; - } - return ''; -} - -function toUIRow(study: any, workflows: WorkflowId[]): UISLRow & { - studyInstanceUid?: string; - _rawStudy?: any; -} { - const { - patientName, - mrn, - date, - time, - modalities, - description, - accession, - instances, - studyInstanceUid, - } = study; - - return { - patient: patientName ?? '', - mrn: mrn ?? '', - studyDateTime: normalizeStudyDateTime(date, time), - modalities: modalities ?? '', - description: description ?? '', - accession: accession ?? '', - instances: Number(instances ?? 0), - workflows, - studyInstanceUid, - _rawStudy: study, - } as any; -} - -function computeWorkflowsForStudy(loadedModes: any[], study: any): WorkflowId[] { - if (!Array.isArray(loadedModes)) return []; - const modes = loadedModes - .filter(m => !m.hide) - .filter(m => { - try { - const modalitiesToCheck = String(study?.modalities ?? '').replaceAll('/', '\\'); - const res = m.isValidMode?.({ modalities: modalitiesToCheck, study }); - return res?.valid === true; // include only valid - } catch { - return false; - } - }); - - const set = new Set<WorkflowId>(); - for (const m of modes) { - const wf = ROUTE_TO_WORKFLOW[m.routeName as string]; - if (wf) set.add(wf); - } - return Array.from(set); -} - -export default function StudyListNext({ - data, - dataTotal, - dataSource, - isLoadingData, - dataPath, - onRefresh, - servicesManager, - extensionManager, -}: Props) { - const navigate = useNavigate(); - const { t } = useTranslation(); - const [appConfig] = useAppConfig(); - const { customizationService } = servicesManager.services; - - const rows: (UISLRow & { studyInstanceUid?: string; _rawStudy?: any })[] = React.useMemo(() => { - const loadedModes = appConfig?.loadedModes ?? []; - return (Array.isArray(data) ? data : []).map(study => { - const workflows = computeWorkflowsForStudy(loadedModes, study); - return toUIRow(study, workflows); - }); - }, [data, appConfig?.loadedModes]); - - const handleLaunch = React.useCallback( - (row: UISLRow & { studyInstanceUid?: string; _rawStudy?: any }, wf: WorkflowId | string) => { - const loadedModes: any[] = appConfig?.loadedModes ?? []; - - // Helper: resolve a routeName from a workflow label (union or mode displayName) - const resolveRoute = (label: string): string | null => { - // Union mapping first - const mapped = WORKFLOW_TO_ROUTE[label as WorkflowId]; - if (mapped) return mapped; - // Try by matching mode displayName - const byName = loadedModes.find(m => String(m.displayName).toLowerCase() === String(label).toLowerCase()); - if (byName) return byName.routeName; - return null; - }; - - // Compute available workflows for this row based on current business logic - const available = computeWorkflowsForStudy(loadedModes, row?._rawStudy ?? row); - - // Determine target route - let targetRoute = resolveRoute(String(wf)); - if (!targetRoute) { - // Fallback to first available workflow for the row - const first = available[0]; - targetRoute = first ? resolveRoute(String(first)) : null; - } - if (!targetRoute) { - // Last resort: prefer viewer/basic - targetRoute = 'viewer'; - } - - // Handle viewer/basic alias - let mode = loadedModes.find(m => m.routeName === targetRoute); - if (!mode && targetRoute === 'viewer') { - mode = loadedModes.find(m => m.routeName === 'basic') ?? null; - } - if (!mode) return; - - // Validate mode again for this study - const modalitiesToCheck = String(row?.modalities ?? '').replaceAll('/', '\\'); - const validity = mode.isValidMode?.({ modalities: modalitiesToCheck, study: row?._rawStudy }); - if (validity?.valid === false || validity?.valid === null) { - // If default is invalid, try first available - const first = available.find(l => !!resolveRoute(String(l))); - if (first) { - const r = resolveRoute(String(first)); - mode = loadedModes.find(m => m.routeName === r) ?? mode; - } else { - return; - } - } - - const query = new URLSearchParams(); - if (row?.studyInstanceUid) { - query.append('StudyInstanceUIDs', row.studyInstanceUid); - } - preserveQueryParameters(query); - navigate(`${mode.routeName}${dataPath || ''}?${query.toString()}`); - }, - [appConfig?.loadedModes, dataPath, navigate] - ); - - const AboutModal = customizationService.getCustomization( - 'ohif.aboutModal' - ) as coreTypes.MenuComponentCustomization; - const UserPreferencesModal = customizationService.getCustomization( - 'ohif.userPreferencesModal' - ) as coreTypes.MenuComponentCustomization; - - const LoadingIndicatorProgress = customizationService.getCustomization( - 'ui.loadingIndicatorProgress' - ); - - const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent'); - const dataSourceConfigurationComponent = customizationService.getCustomization( - 'ohif.dataSourceConfigurationComponent' - ); - - const toolbarMenu = ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="icon" aria-label="Open settings" className="ml-2"> - <Icons.GearSettings /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onSelect={() => - servicesManager.services.uiModalService.show({ - content: AboutModal, - title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'), - containerClassName: AboutModal?.containerClassName ?? 'max-w-md', - }) - } - > - About - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => - servicesManager.services.uiModalService.show({ - content: UserPreferencesModal as unknown as React.ComponentType, - title: UserPreferencesModal?.title ?? t('UserPreferencesModal:User preferences'), - containerClassName: - UserPreferencesModal?.containerClassName ?? 'flex max-w-4xl p-6 flex-col', - }) - } - > - Preferences - </DropdownMenuItem> - {DicomUploadComponent && dataSource.getConfig?.()?.dicomUploadEnabled ? ( - <DropdownMenuItem - onSelect={() => - servicesManager.services.uiModalService.show({ - title: 'Upload files', - closeButton: true, - shouldCloseOnEsc: false, - shouldCloseOnOverlayClick: false, - content: () => ( - <DicomUploadComponent - dataSource={dataSource} - onComplete={() => { - servicesManager.services.uiModalService.hide(); - onRefresh(); - }} - onStarted={() => { - servicesManager.services.uiModalService.show({ - title: 'Upload files', - closeButton: false, - shouldCloseOnEsc: false, - shouldCloseOnOverlayClick: false, - content: () => ( - <DicomUploadComponent dataSource={dataSource} /> - ), - }); - }} - /> - ), - }) - } - > - Upload files - </DropdownMenuItem> - ) : null} - {dataSourceConfigurationComponent ? ( - <DropdownMenuItem - onSelect={() => - servicesManager.services.uiModalService.show({ - title: 'Configure Data Source', - content: dataSourceConfigurationComponent as unknown as React.ComponentType, - }) - } - > - Configure Data Source - </DropdownMenuItem> - ) : null} - {appConfig?.oidc ? ( - <DropdownMenuItem - onSelect={() => - navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`) - } - > - Logout - </DropdownMenuItem> - ) : null} - </DropdownMenuContent> - </DropdownMenu> - ); - - const state = useStudyListState<UISLRow, WorkflowId>(rows as UISLRow[], { onLaunch: handleLaunch }); - - const previewDefaultSize = React.useMemo(() => { - if (typeof window !== 'undefined' && window.innerWidth > 0) { - const percent = (325 / window.innerWidth) * 100; - return Math.min(Math.max(percent, 15), 50); - } - return 30; - }, []); - - return ( - <div className="flex h-screen min-h-0 flex-col bg-black overflow-hidden"> - <Onboarding /> - <InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} /> - <div className="flex h-full min-h-0 flex-col"> - <div className="flex flex-1 min-h-0 flex-col"> - - {isLoadingData ? ( - appConfig?.showLoadingIndicator && LoadingIndicatorProgress ? ( - <LoadingIndicatorProgress className={'h-full w-full bg-black'} /> - ) : null - ) : null} - <StudyListProvider value={state}> - <StudyListLayout - isPanelOpen={state.isPanelOpen} - onIsPanelOpenChange={state.setPanelOpen} - defaultPreviewSizePercent={previewDefaultSize} - className="h-full w-full" - > - <StudyListLayout.Table> - <div className="flex h-full min-h-0 w-full flex-col px-3 pb-3 pt-0"> - <div className="min-h-0 flex-1"> - <div className="h-full rounded-md px-2 pb-2 pt-0"> - <StudyListTable - columns={defaultColumns()} - data={rows as UISLRow[]} - enforceSingleSelection - showColumnVisibility - title={'Study List'} - isPanelOpen={state.isPanelOpen} - onOpenPanel={() => state.setPanelOpen(true)} - onSelectionChange={(sel) => state.setSelected((sel as UISLRow[])[0] ?? null)} - toolbarLeft={<Icons.OHIFLogoHorizontal aria-label="OHIF logo" className="h-[22px] w-[232px]" />} - toolbarRightExtras={toolbarMenu} - renderOpenPanelButton={() => <StudyListLayout.OpenPreviewButton />} - /> - </div> - </div> - </div> - </StudyListLayout.Table> - <StudyListLayout.Preview defaultSizePercent={previewDefaultSize}> - <SidePanelReal dataSource={dataSource} extensionManager={extensionManager as any} /> - </StudyListLayout.Preview> - </StudyListLayout> - </StudyListProvider> - </div> - </div> - </div> - ); -} - -function SidePanelReal({ dataSource, extensionManager }: { dataSource: any; extensionManager: any }) { - const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow, launch } = useStudyList<UISLRow, WorkflowId>(); - const [isSettingsOpen, setIsSettingsOpen] = React.useState(false); - const [series, setSeries] = React.useState<any[]>([]); - const [thumbs, setThumbs] = React.useState<Record<string, string | null>>({}); - const { sortBySeriesDate } = utils as any; - - React.useEffect(() => { - const run = async () => { - const sid = (selected as any)?.studyInstanceUid; - if (!sid) { - setSeries([]); - setThumbs({}); - return; - } - try { - const s = await dataSource.query.series.search(sid); - const sorted = typeof sortBySeriesDate === 'function' ? sortBySeriesDate(s) : s; - setSeries(sorted ?? []); - } catch (e) { - // eslint-disable-next-line no-console - console.warn(e); - setSeries([]); - setThumbs({}); - } - }; - run(); - }, [dataSource, selected]); - - React.useEffect(() => { - const sid = (selected as any)?.studyInstanceUid; - if (!sid || !series?.length) { - setThumbs({}); - return; - } - - let cancelled = false; - - const load = async () => { - try { - // Ensure series/instances metadata is available so instances include imageId - await dataSource.retrieve.series.metadata({ StudyInstanceUID: sid }); - - const nextThumbs: Record<string, string | null> = {}; - for (const s of series) { - const seriesUID = s.seriesInstanceUid || s.SeriesInstanceUID; - if (!seriesUID) continue; - // Find a representative instance; prefer mid-frame - const seriesMeta = DicomMetadataStore.getSeries?.(sid, seriesUID); - let instance = seriesMeta?.instances?.[Math.floor((seriesMeta?.instances?.length || 1) / 2)]; - if (!instance) { - nextThumbs[seriesUID] = null; - continue; - } - - // Compute imageId - let imageId: string | undefined; - if (instance?.imageId) { - imageId = instance.imageId; - } else if (instance) { - try { - const ids = dataSource.getImageIdsForInstance({ instance }); - imageId = Array.isArray(ids) ? ids[Math.floor(ids.length / 2)] : ids; - } catch {} - } - - // Use data source helper; choose strategy based on configured thumbnailRendering - let src: string | null = null; - try { - if (instance && imageId) { - const cfg = dataSource.getConfig?.(); - const rendering = cfg?.thumbnailRendering; - - // Cornerstone-powered getImageSrc only for 'wadors' - let opts: any = undefined; - if (rendering === 'wadors') { - try { - const utilitiesModule = extensionManager?.getModuleEntry?.( - '@ohif/extension-cornerstone.utilityModule.common' - ); - const { cornerstone } = utilitiesModule?.exports?.getCornerstoneLibraries?.() || {}; - if (cornerstone?.utilities?.loadImageToCanvas) { - const getImageSrc = (imageId: string) => - new Promise<string>((resolve, reject) => { - try { - const canvas = document.createElement('canvas'); - cornerstone.utilities - .loadImageToCanvas({ canvas, imageId, thumbnail: true }) - .then(() => resolve(canvas.toDataURL())) - .catch(reject); - } catch (e) { - reject(e); - } - }); - opts = { getImageSrc }; - } - } catch {} - } - - const getThumb = dataSource.retrieve.getGetThumbnailSrc(instance, imageId); - if (typeof getThumb === 'function') { - src = await getThumb(opts); - } - } - } catch {} - - nextThumbs[seriesUID] = src ?? null; - } - - if (!cancelled) setThumbs(nextThumbs); - } catch (e) { - if (!cancelled) setThumbs({}); - } - }; - - load(); - return () => { - cancelled = true; - }; - }, [dataSource, extensionManager, series, selected]); - - return ( - <PreviewPanelShell - header={ - <Popover open={isSettingsOpen} onOpenChange={setIsSettingsOpen}> - <div className="absolute right-2 top-4 z-10 mt-1 mr-3 flex items-center gap-1"> - <PopoverTrigger asChild> - <Button variant="ghost" size="icon" aria-label="Open settings"> - <Icons.SettingsStudyList aria-hidden="true" className="h-4 w-4" /> - </Button> - </PopoverTrigger> - <Button - variant="ghost" - size="icon" - aria-label="Close preview panel" - onClick={() => setPanelOpen(false)} - > - <Icons.PanelRight aria-hidden="true" className="h-4 w-4" /> - </Button> - </div> - <SettingsPopover - open={isSettingsOpen} - onOpenChange={setIsSettingsOpen} - defaultMode={defaultWorkflow} - onDefaultModeChange={setDefaultWorkflow} - /> - </Popover> - } - > - {selected ? ( - <DndProvider backend={HTML5Backend}> - <TooltipProvider delayDuration={200}> - <div className="flex flex-col gap-3"> - <PatientSummary data={selected}> - <PatientSummary.Patient /> - <PatientSummary.Workflows<WorkflowId> - defaultMode={defaultWorkflow} - onDefaultModeChange={setDefaultWorkflow} - workflows={(selected as any)?.workflows as WorkflowId[]} - onLaunchWorkflow={(data, wf) => launch((data as UISLRow) ?? (selected as UISLRow), wf)} - /> - </PatientSummary> - <div className="h-7 w-full px-2 flex items-center text-foreground font-semibold text-base"> - {series?.length ? '1 Study' : 'No Series'} - </div> - <div className="grid grid-cols-[repeat(auto-fit,_minmax(0,135px))] place-items-start gap-[4px] pr-2"> - {series?.map((s: any, i: number) => { - const seriesUID = s.seriesInstanceUid || s.SeriesInstanceUID || String(i); - const imageSrc = thumbs[seriesUID] || undefined; - return ( - <Thumbnail - key={`series-${seriesUID}`} - displaySetInstanceUID={`series-${seriesUID}`} - imageSrc={imageSrc as any} - imageAltText={s.description || s.SeriesDescription || ''} - description={s.description || s.SeriesDescription || '(empty)'} - seriesNumber={s.seriesNumber ?? s.SeriesNumber ?? ''} - numInstances={s.numSeriesInstances ?? s.numInstances ?? 0} - modality={s.modality || s.Modality || ''} - isActive={false} - onClick={() => {}} - onDoubleClick={() => {}} - viewPreset="thumbnails" - /> - ); - })} - </div> - </div> - </TooltipProvider> - </DndProvider> - ) : ( - <PreviewPanelEmpty - defaultMode={defaultWorkflow} - onDefaultModeChange={setDefaultWorkflow} - /> - )} - </PreviewPanelShell> - ); -} diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx deleted file mode 100644 index 0da0ecba46f..00000000000 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2.tsx +++ /dev/null @@ -1,617 +0,0 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import moment from 'moment'; - -import { useAppConfig } from '@state'; -import { preserveQueryParameters } from '../../utils/preserveQueryParameters'; - -import { - StudyListTable, - StudyListLayout, - StudyListProvider, - useStudyList, - useStudyListState, - defaultColumns, - PatientSummary, - PreviewPanelShell, - PreviewPanelEmpty, - Thumbnail, - TooltipProvider, - Icons, - Button, - useModal, - SettingsPopover, - SeriesListView, - ToggleGroup, - ToggleGroupItem, -} from '@ohif/ui-next'; - -import { Types as coreTypes, utils, DicomMetadataStore, useSystem } from '@ohif/core'; -import type { StudyRow as UISLRow } from '@ohif/ui-next'; -import type { WorkflowId } from '@ohif/ui-next'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; - -type Props = withAppTypes & { - data: any[]; - dataTotal: number; - dataSource: any; - isLoadingData: boolean; - dataPath?: string; - onRefresh: () => void; -}; - -// Modalities that should not attempt pixel-based thumbnail rendering -const NON_IMAGE_MODALITIES = new Set(['RTDOSE', 'RTPLAN', 'RTSTRUCT']); - -const ROUTE_TO_WORKFLOW: Record<string, WorkflowId> = { - viewer: 'Basic Viewer', - basic: 'Basic Viewer', - segmentation: 'Segmentation', - tmtv: 'TMTV Workflow', - usAnnotation: 'US Workflow', - 'dynamic-volume': 'Preclinical 4D', - microscopy: 'Microscopy', -}; - -const WORKFLOW_TO_ROUTE: Record<WorkflowId, string> = { - 'Basic Viewer': 'viewer', - Segmentation: 'segmentation', - 'TMTV Workflow': 'tmtv', - 'US Workflow': 'usAnnotation', - 'Preclinical 4D': 'dynamic-volume', - Microscopy: 'microscopy', -}; - -function formatStudyDateDisplay(date?: string, time?: string) { - 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 d = mDate.format('DD-MMM-YYYY'); - const t = mTime && mTime.isValid() ? mTime.format('HH:mm') : ''; - return t ? `${d} ${t}` : d; - } - return ''; -} - -function buildStudyDateSortKey(date?: string, time?: string) { - 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; -} - -function toUIRow(study: any, workflows: WorkflowId[]): UISLRow & { - studyInstanceUid?: string; - studyDateTimestamp?: number; - _rawStudy?: any; -} { - const { - patientName, - mrn, - date, - time, - modalities, - description, - accession, - instances, - studyInstanceUid, - } = study; - - return { - patient: patientName ?? '', - mrn: mrn ?? '', - studyDateTime: formatStudyDateDisplay(date, time), - studyDateTimestamp: buildStudyDateSortKey(date, time), - modalities: modalities ?? '', - description: description ?? '', - accession: accession ?? '', - instances: Number(instances ?? 0), - workflows, - studyInstanceUid, - _rawStudy: study, - } as any; -} - -function computeWorkflowsForStudy(loadedModes: any[], study: any): WorkflowId[] { - if (!Array.isArray(loadedModes)) return []; - const modes = loadedModes - .filter(m => !m.hide) - .filter(m => { - try { - const modalitiesToCheck = String(study?.modalities ?? '').replaceAll('/', '\\'); - const res = m.isValidMode?.({ modalities: modalitiesToCheck, study }); - return res?.valid === true; // include only valid - } catch { - return false; - } - }); - - const set = new Set<WorkflowId>(); - for (const m of modes) { - const wf = ROUTE_TO_WORKFLOW[m.routeName as string]; - if (wf) set.add(wf); - } - return Array.from(set); -} - -export default function StudyListNext2({ - data, - dataTotal, - dataSource, - isLoadingData, - dataPath, - onRefresh, - servicesManager, - extensionManager, -}: Props) { - const navigate = useNavigate(); - const { t } = useTranslation(); - const [appConfig] = useAppConfig(); - - const rows: (UISLRow & { studyInstanceUid?: string; studyDateTimestamp?: number; _rawStudy?: any })[] = React.useMemo(() => { - const loadedModes = appConfig?.loadedModes ?? []; - return (Array.isArray(data) ? data : []).map(study => { - const workflows = computeWorkflowsForStudy(loadedModes, study); - return toUIRow(study, workflows); - }); - }, [data, appConfig?.loadedModes]); - - // URL rehydration is handled by StudyListNext2Entry before DataSourceWrapper mounts - - const handleLaunch = React.useCallback( - (row: UISLRow & { studyInstanceUid?: string; _rawStudy?: any }, wf: WorkflowId | string) => { - const loadedModes: any[] = appConfig?.loadedModes ?? []; - const { uiNotificationService } = servicesManager.services as any; - - if (!row?.studyInstanceUid) { - uiNotificationService?.show?.({ - title: 'Cannot launch viewer', - message: 'Selected study has no StudyInstanceUID. Launch is unavailable.', - type: 'warning', - }); - return; - } - - const resolveRoute = (label: string): string | null => { - const mapped = WORKFLOW_TO_ROUTE[label as WorkflowId]; - if (mapped) return mapped; - const byName = loadedModes.find(m => String(m.displayName).toLowerCase() === String(label).toLowerCase()); - if (byName) return byName.routeName; - return null; - }; - - const available = computeWorkflowsForStudy(loadedModes, row?._rawStudy ?? row); - - let targetRoute = resolveRoute(String(wf)); - if (!targetRoute) { - const first = available[0]; - targetRoute = first ? resolveRoute(String(first)) : null; - } - if (!targetRoute) targetRoute = 'viewer'; - - let mode = loadedModes.find(m => m.routeName === targetRoute); - if (!mode && targetRoute === 'viewer') { - mode = loadedModes.find(m => m.routeName === 'basic') ?? null; - } - if (!mode) { - uiNotificationService?.show?.({ - title: 'Cannot launch viewer', - message: `No mode found for workflow "${String(wf)}".`, - type: 'warning', - }); - return; - } - - const modalitiesToCheck = String(row?.modalities ?? '').replaceAll('/', '\\'); - const validity = mode.isValidMode?.({ modalities: modalitiesToCheck, study: row?._rawStudy }); - if (validity?.valid === false || validity?.valid === null) { - const first = available.find(l => !!resolveRoute(String(l))); - if (first) { - const r = resolveRoute(String(first)); - mode = loadedModes.find(m => m.routeName === r) ?? mode; - } else { - return; - } - } - - const query = new URLSearchParams(); - query.append('StudyInstanceUIDs', row.studyInstanceUid); - preserveQueryParameters(query); - try { - navigate(`${mode.routeName}${dataPath || ''}?${query.toString()}`); - } catch (e: any) { - uiNotificationService?.show?.({ - title: 'Navigation error', - message: e?.message || 'Unexpected navigation error.', - type: 'error', - }); - } - }, - [appConfig?.loadedModes, dataPath, navigate, servicesManager?.services] - ); - - const state = useStudyListState<UISLRow, WorkflowId>(rows as UISLRow[], { onLaunch: handleLaunch }); - - const previewDefaultSize = React.useMemo(() => { - if (typeof window !== 'undefined' && window.innerWidth > 0) { - const percent = (325 / window.innerWidth) * 100; - return Math.min(Math.max(percent, 15), 50); - } - return 30; - }, []); - - const dateSortedColumns = React.useMemo(() => { - const cols = defaultColumns(); - return cols.map(col => - col.accessorKey === 'studyDateTime' - ? { - ...col, - // Display already handled by value; sort by our timestamp - sortingFn: (a: any, b: any) => { - const av = (a.original?.studyDateTimestamp as number) || 0; - const bv = (b.original?.studyDateTimestamp as number) || 0; - return av - bv; - }, - } - : col - ); - }, []); - - return ( - <div className="flex h-screen min-h-0 flex-col bg-black overflow-hidden"> - <div className="flex h-full min-h-0 flex-col"> - <div className="flex flex-1 min-h-0 flex-col"> - <StudyListProvider value={state}> - <StudyListLayout - isPanelOpen={state.isPanelOpen} - onIsPanelOpenChange={state.setPanelOpen} - defaultPreviewSizePercent={previewDefaultSize} - className="h-full w-full" - > - <StudyListLayout.Table> - <div className="flex h-full min-h-0 w-full flex-col px-3 pb-3 pt-0"> - <div className="min-h-0 flex-1"> - <div className="h-full rounded-md px-2 pb-2 pt-0"> - <StudyListTable - columns={dateSortedColumns as any} - data={rows as UISLRow[]} - initialSorting={[{ id: 'studyDateTime', desc: true }]} - enforceSingleSelection - showColumnVisibility - title={'Study List'} - isPanelOpen={state.isPanelOpen} - onOpenPanel={() => state.setPanelOpen(true)} - onSelectionChange={sel => state.setSelected((sel as UISLRow[])[0] ?? null)} - toolbarLeft={<Icons.OHIFLogoHorizontal aria-label="OHIF logo" className="h-[22px] w-[232px]" />} - renderOpenPanelButton={() => <ClosedPanelControls />} - /> - </div> - </div> - </div> - </StudyListLayout.Table> - <StudyListLayout.Preview defaultSizePercent={previewDefaultSize}> - <SidePanelPreview dataSource={dataSource} extensionManager={extensionManager as any} /> - </StudyListLayout.Preview> - </StudyListLayout> - </StudyListProvider> - </div> - </div> - </div> - ); -} - -function ClosedPanelControls() { - const { defaultWorkflow, setDefaultWorkflow } = useStudyList<UISLRow, WorkflowId>(); - const { t } = useTranslation(); - const { servicesManager } = useSystem(); - const { customizationService } = servicesManager.services as any; - const { show } = useModal(); - - return ( - <div className="relative -top-px flex items-center gap-1"> - <SettingsPopover> - <SettingsPopover.Trigger> - <Button variant="ghost" size="icon" aria-label="Open settings"> - <Icons.SettingsStudyList aria-hidden="true" className="h-4 w-4" /> - </Button> - </SettingsPopover.Trigger> - <SettingsPopover.Content> - <SettingsPopover.Workflow - defaultMode={defaultWorkflow} - onDefaultModeChange={setDefaultWorkflow} - /> - <SettingsPopover.Divider /> - <SettingsPopover.Link - onClick={() => { - const AboutModal = customizationService.getCustomization('ohif.aboutModal'); - show({ - content: AboutModal, - title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'), - containerClassName: AboutModal?.containerClassName ?? 'max-w-md', - }); - }} - > - About OHIF Viewer - </SettingsPopover.Link> - <SettingsPopover.Link - 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', - }); - }} - > - User Preferences - </SettingsPopover.Link> - </SettingsPopover.Content> - </SettingsPopover> - - <StudyListLayout.OpenPreviewButton /> - </div> - ); -} - -function SidePanelPreview({ dataSource, extensionManager }: { dataSource: any; extensionManager: any }) { - const { selected, setPanelOpen, defaultWorkflow, setDefaultWorkflow, launch, seriesViewMode, setSeriesViewMode } = useStudyList<UISLRow, WorkflowId>(); - const { t } = useTranslation(); - const { servicesManager } = useSystem(); - const { customizationService } = servicesManager.services as any; - const { show } = useModal(); - const [series, setSeries] = React.useState<any[]>([]); - const [thumbs, setThumbs] = React.useState<Record<string, string | null>>({}); - const { sortBySeriesDate } = utils as any; - - React.useEffect(() => { - const run = async () => { - const sid = (selected as any)?.studyInstanceUid; - if (!sid) { - setSeries([]); - setThumbs({}); - return; - } - try { - const s = await dataSource.query.series.search(sid); - const sorted = typeof sortBySeriesDate === 'function' ? sortBySeriesDate(s) : s; - setSeries(sorted ?? []); - } catch (e) { - console.warn(e); - setSeries([]); - setThumbs({}); - } - }; - run(); - }, [dataSource, selected]); - - React.useEffect(() => { - const sid = (selected as any)?.studyInstanceUid; - if (!sid || !series?.length) { - setThumbs({}); - return; - } - - let cancelled = false; - - const load = async () => { - try { - await dataSource.retrieve.series.metadata({ StudyInstanceUID: sid }); - - const nextThumbs: Record<string, string | null> = {}; - for (const s of series) { - const seriesUID = s.seriesInstanceUid || s.SeriesInstanceUID; - if (!seriesUID) continue; - // Skip rendering thumbnails for non-image modalities (e.g., RTDOSE/RTPLAN/RTSTRUCT) - const modality = String(s.modality || s.Modality || '').toUpperCase(); - if (NON_IMAGE_MODALITIES.has(modality)) { - nextThumbs[seriesUID] = null; - continue; - } - const seriesMeta = DicomMetadataStore.getSeries?.(sid, seriesUID); - let instance = seriesMeta?.instances?.[Math.floor((seriesMeta?.instances?.length || 1) / 2)]; - if (!instance) { - nextThumbs[seriesUID] = null; - continue; - } - - let imageId: string | undefined; - if (instance?.imageId) { - imageId = instance.imageId; - } else if (instance) { - try { - const ids = dataSource.getImageIdsForInstance({ instance }); - imageId = Array.isArray(ids) ? ids[Math.floor(ids.length / 2)] : ids; - } catch {} - } - - let src: string | null = null; - try { - if (instance && imageId) { - const cfg = dataSource.getConfig?.(); - const rendering = cfg?.thumbnailRendering; - - let opts: any = undefined; - if (rendering === 'wadors') { - try { - const utilitiesModule = extensionManager?.getModuleEntry?.( - '@ohif/extension-cornerstone.utilityModule.common' - ); - const { cornerstone } = utilitiesModule?.exports?.getCornerstoneLibraries?.() || {}; - if (cornerstone?.utilities?.loadImageToCanvas) { - const getImageSrc = (imageId: string) => - new Promise<string>((resolve, reject) => { - try { - const canvas = document.createElement('canvas'); - cornerstone.utilities - .loadImageToCanvas({ canvas, imageId, thumbnail: true }) - .then(() => resolve(canvas.toDataURL())) - .catch(reject); - } catch (e) { - reject(e); - } - }); - opts = { getImageSrc }; - } - } catch {} - } - - const getThumb = dataSource.retrieve.getGetThumbnailSrc(instance, imageId); - if (typeof getThumb === 'function') { - try { - src = await getThumb(opts); - } catch { - src = null; - } - } - } - } catch {} - - nextThumbs[seriesUID] = src ?? null; - } - - if (!cancelled) setThumbs(nextThumbs); - } catch (e) { - if (!cancelled) setThumbs({}); - } - }; - - load(); - return () => { - cancelled = true; - }; - }, [dataSource, extensionManager, series, selected]); - - return ( - <PreviewPanelShell - header={ - <div className="absolute right-2 top-4 z-10 mt-1 mr-3 flex items-center gap-1"> - <SettingsPopover> - <SettingsPopover.Trigger> - <Button variant="ghost" size="icon" aria-label="Open settings"> - <Icons.SettingsStudyList aria-hidden="true" className="h-4 w-4" /> - </Button> - </SettingsPopover.Trigger> - <SettingsPopover.Content> - <SettingsPopover.Workflow - defaultMode={defaultWorkflow} - onDefaultModeChange={setDefaultWorkflow} - /> - <SettingsPopover.Divider /> - <SettingsPopover.Link - onClick={() => { - const AboutModal = customizationService.getCustomization('ohif.aboutModal'); - show({ - content: AboutModal, - title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'), - containerClassName: AboutModal?.containerClassName ?? 'max-w-md', - }); - }} - > - About OHIF Viewer - </SettingsPopover.Link> - <SettingsPopover.Link - 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', - }); - }} - > - User Preferences - </SettingsPopover.Link> - </SettingsPopover.Content> - </SettingsPopover> - <Button - variant="ghost" - size="icon" - aria-label="Close preview panel" - onClick={() => setPanelOpen(false)} - > - <Icons.PanelRight aria-hidden="true" className="h-4 w-4" /> - </Button> - </div> - } - > - {selected ? ( - <DndProvider backend={HTML5Backend}> - <TooltipProvider delayDuration={200}> - <div className="flex flex-col gap-3"> - <PatientSummary data={selected}> - <PatientSummary.Patient /> - <PatientSummary.Workflows<WorkflowId> - defaultMode={defaultWorkflow} - onDefaultModeChange={setDefaultWorkflow} - workflows={(selected as any)?.workflows as WorkflowId[]} - onLaunchWorkflow={(data, wf) => launch((data as UISLRow) ?? (selected as UISLRow), wf)} - /> - </PatientSummary> - <div className="h-5 w-full px-2 flex items-center justify-between gap-1 text-muted-foreground text-base"> - <span className="leading-tight">{series?.length ? ((selected as UISLRow)?.description || 'No Description') : 'No Series'}</span> - <ToggleGroup - type="single" - value={seriesViewMode} - onValueChange={(value) => value && setSeriesViewMode(value as 'thumbnails' | 'list')} - > - <ToggleGroupItem value="thumbnails" aria-label="Thumbnail view" className="text-actions-primary"> - <Icons.ThumbnailView /> - </ToggleGroupItem> - <ToggleGroupItem value="list" aria-label="List view" className="text-actions-primary"> - <Icons.ListView /> - </ToggleGroupItem> - </ToggleGroup> - </div> - {seriesViewMode === 'thumbnails' ? ( - <div className="grid grid-cols-[repeat(auto-fit,_minmax(0,135px))] place-items-start gap-[4px] pr-2"> - {series?.map((s: any, i: number) => { - const seriesUID = s.seriesInstanceUid || s.SeriesInstanceUID || String(i); - const imageSrc = thumbs[seriesUID] || undefined; - return ( - <Thumbnail - key={`series-${seriesUID}`} - displaySetInstanceUID={`series-${seriesUID}`} - imageSrc={imageSrc as any} - imageAltText={s.description || s.SeriesDescription || ''} - description={s.description || s.SeriesDescription || '(empty)'} - seriesNumber={s.seriesNumber ?? s.SeriesNumber ?? ''} - numInstances={s.numSeriesInstances ?? s.numInstances ?? 0} - modality={s.modality || s.Modality || ''} - isActive={false} - onClick={() => {}} - onDoubleClick={() => {}} - viewPreset="thumbnails" - /> - ); - })} - </div> - ) : ( - <SeriesListView series={series} /> - )} - </div> - </TooltipProvider> - </DndProvider> - ) : ( - <PreviewPanelEmpty - defaultMode={defaultWorkflow} - onDefaultModeChange={setDefaultWorkflow} - /> - )} - </PreviewPanelShell> - ); -} diff --git a/platform/app/src/routes/StudyListNext2/StudyListNext2Entry.tsx b/platform/app/src/routes/StudyListNext2/StudyListNext2Entry.tsx deleted file mode 100644 index d9ebce700ca..00000000000 --- a/platform/app/src/routes/StudyListNext2/StudyListNext2Entry.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import DataSourceWrapper from '../DataSourceWrapper'; -import StudyListNext2 from './StudyListNext2'; - -/** - * Ensures preserved params (e.g., configUrl) are present in the URL - * BEFORE mounting DataSourceWrapper, mirroring legacy WorkList behavior. - * - * This allows us to integrate directly with the legacy DataSourceWrapper - * without modifying it. - */ -export default function StudyListNext2Entry(props: withAppTypes) { - const navigate = useNavigate(); - const location = useLocation(); - const [ready, setReady] = React.useState(false); - - React.useEffect(() => { - const search = new URLSearchParams(location.search); - const next = new URLSearchParams(location.search); - - // Keep track of last known configUrl when present - const currentCfg = search.get('configUrl'); - if (currentCfg) { - try { - localStorage.setItem('ohif.lastConfigUrl', currentCfg); - } catch {} - } - - // If missing, try to recover from sessionStorage (legacy WorkList) or localStorage (our fallback) - if (!currentCfg) { - let recovered: string | null = null; - try { - const raw = sessionStorage.getItem('queryFilterValues'); - if (raw) { - const saved = JSON.parse(raw); - if (saved?.configUrl) recovered = saved.configUrl; - } - } catch {} - if (!recovered) { - try { - recovered = localStorage.getItem('ohif.lastConfigUrl'); - } catch {} - } - if (recovered) { - next.set('configUrl', recovered); - navigate({ pathname: location.pathname, search: `?${next.toString()}` }, { replace: true }); - return; // wait for URL update; effect will run again - } - } - setReady(true); - }, [location.pathname, location.search, navigate]); - - if (!ready) return null; - - return <DataSourceWrapper {...props} children={StudyListNext2} />; -} diff --git a/platform/app/src/routes/index.tsx b/platform/app/src/routes/index.tsx index 9e2a29693af..7524960f26a 100644 --- a/platform/app/src/routes/index.tsx +++ b/platform/app/src/routes/index.tsx @@ -3,14 +3,8 @@ import { Routes, Route, Link, useNavigate } from 'react-router-dom'; import { ErrorBoundary } from '@ohif/ui-next'; // Route Components -// Study list variants: -// - Default: StudyListNext2Entry (ui-next, with pre-DS hydration) -// - Optional: StudyListNext (previous ui-next) -// - Optional: WorkList (legacy) -import StudyListNext from './StudyListNext/StudyListNext'; -import WorkList from './WorkList/WorkList'; -import StudyListNext2Entry from './StudyListNext2/StudyListNext2Entry'; import DataSourceWrapper from './DataSourceWrapper'; +import WorkList from './WorkList'; import Local from './Local'; import Debug from './Debug'; import NotFound from './NotFound'; @@ -126,19 +120,11 @@ const createRoutes = ({ console.log('Registering worklist route', routerBasename, path); - // Worklist Route: set `children` (and `props` if using DataSourceWrapper) - const WorkListRoute = { path: '/', - // Default: StudyListNext2Entry (pre-DS hydration) - children: StudyListNext2Entry, + children: DataSourceWrapper, private: true, - // To use StudyListNext instead: - // children: DataSourceWrapper, - // props: { children: StudyListNext, servicesManager, extensionManager }, - // To use legacy WorkList instead: - // children: DataSourceWrapper, - // props: { children: WorkList, servicesManager, extensionManager }, + props: { children: WorkList, servicesManager, extensionManager }, }; const customRoutes = customizationService.getCustomization('routes.customRoutes'); diff --git a/platform/ui-next/src/components/Icons/Sources/IconMPR.tsx b/platform/ui-next/src/components/Icons/Sources/IconMPR.tsx index dde4339ef50..28dcaa7b10e 100644 --- a/platform/ui-next/src/components/Icons/Sources/IconMPR.tsx +++ b/platform/ui-next/src/components/Icons/Sources/IconMPR.tsx @@ -10,6 +10,7 @@ export const IconMPR = (props: IconProps) => ( xmlns="http://www.w3.org/2000/svg" {...props} > + <title>info-mpr Date: Mon, 24 Nov 2025 09:24:27 -0500 Subject: [PATCH 100/172] Add InputMultiSelect and more to Study List Includes InputMultiSelect, Badge, filters.ts for modality tokenization, and changes to defaultColumns and StudyListTable --- platform/ui-next/package.json | 2 +- .../ui-next/src/components/Badge/Badge.tsx | 38 ++ .../ui-next/src/components/Badge/index.ts | 1 + .../InputMultiSelect/InputMultiSelect.tsx | 470 ++++++++++++++++++ .../src/components/InputMultiSelect/index.ts | 1 + .../StudyList/columns/defaultColumns.tsx | 47 +- .../StudyList/components/StudyListTable.tsx | 86 +++- platform/ui-next/src/components/index.ts | 5 + platform/ui-next/src/lib/filters.ts | 7 + yarn.lock | 2 +- 10 files changed, 632 insertions(+), 27 deletions(-) create mode 100644 platform/ui-next/src/components/Badge/Badge.tsx create mode 100644 platform/ui-next/src/components/Badge/index.ts create mode 100644 platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx create mode 100644 platform/ui-next/src/components/InputMultiSelect/index.ts create mode 100644 platform/ui-next/src/lib/filters.ts diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index ac493097cd1..36f83da98bd 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -30,7 +30,7 @@ ".": "./src/index.ts" }, "dependencies": { - "@tanstack/react-table": "^8.20.0", + "@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", 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/InputMultiSelect/InputMultiSelect.tsx b/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx new file mode 100644 index 00000000000..d5b568d3537 --- /dev/null +++ b/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx @@ -0,0 +1,470 @@ +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 }; + +function normalizeOption(opt: Option): { value: string; label: string } { + if (typeof opt === 'string') return { value: opt, label: opt }; + return { value: opt.value, label: opt.label ?? opt.value }; +} + +type IMSContext = { + // state + value: string[]; + onChange: (next: string[]) => void; + options: Option[]; + normalized: { value: string; label: string }[]; + selectedSet: Set; + disabled?: boolean; + closeOnSelect: boolean; + debounceMs: number; + + // inline query/open + query: string; + setQuery: (s: string) => void; + open: boolean; + setOpen: (b: boolean) => void; + + // layout/anchors + containerRef: React.MutableRefObject; + fieldRef: React.MutableRefObject; + overlayRef: React.MutableRefObject; + inputRef: React.MutableRefObject; + + // positioning + coords: { left: number; top: number; width: number; maxHeight: number } | null; + measure: () => void; + + // helpers + filtered: { value: string; label: string }[]; + commit: (next: string[]) => void; + toggle: (val: string) => void; + remove: (val: string) => void; + clear: () => void; +}; + +const InputMultiSelectContext = React.createContext(null); +const useInputMultiSelect = () => { + 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; + disabled?: boolean; + closeOnSelect?: boolean; + debounceMs?: number; + className?: string; + children?: React.ReactNode; +}; + +const InputMultiSelectRoot = ({ + options, + value, + onChange, + disabled, + closeOnSelect = false, + debounceMs = 0, + className, + 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 [pending, setPending] = React.useState(null); + + const selectedSet = React.useMemo(() => new Set((value ?? []).map(v => String(v))), [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]); + + // Outside click + React.useEffect(() => { + function handleDoc(event: MouseEvent) { + const target = event.target as Node | null; + const fieldEl = containerRef.current; + const dropEl = overlayRef.current; + if (!target) return; + if (fieldEl?.contains(target)) return; + if (dropEl?.contains(target)) return; + setOpen(false); + } + document.addEventListener('mousedown', handleDoc); + return () => document.removeEventListener('mousedown', handleDoc); + }, []); + + // Positioning (fixed) + const [coords, setCoords] = React.useState<{ left: number; top: number; width: number; maxHeight: number } | null>(null); + 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); + window.addEventListener('scroll', handler, true); + return () => { + window.removeEventListener('resize', handler); + window.removeEventListener('scroll', handler, true); + }; + }, [open, measure]); + + // Debounce changes + React.useEffect(() => { + if (!pending) return; + const id = setTimeout(() => { + onChange(pending); + setPending(null); + }, Math.max(0, debounceMs)); + return () => clearTimeout(id); + }, [pending, debounceMs, onChange]); + + const commit = React.useCallback((next: string[]) => { + if (!debounceMs) return onChange(next); + setPending(next); + }, [debounceMs, onChange]); + + const remove = React.useCallback((val: string) => { + const next = (value ?? []).filter(v => v !== val); + commit(next); + }, [value, commit]); + + const toggle = React.useCallback((val: string) => { + const exists = selectedSet.has(val); + const next = exists ? (value ?? []).filter(v => v !== val) : [...(value ?? []), val]; + commit(next); + setQuery(''); + if (closeOnSelect) { + setOpen(false); + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [selectedSet, value, commit, closeOnSelect]); + + const clear = React.useCallback(() => commit([]), [commit]); + + const ctx: IMSContext = { + value, + onChange, + options, + normalized, + selectedSet, + disabled, + closeOnSelect, + debounceMs, + query, + setQuery, + open, + setOpen, + containerRef, + fieldRef, + overlayRef, + inputRef, + coords, + measure, + filtered, + commit, + toggle, + remove, + clear, + }; + + return ( + + {/* Single Command root to unify input and list for keyboard navigation */} + +
{children}
+
+
+ ); +}; + +type FieldProps = React.HTMLAttributes & { disabled?: boolean }; +const Field = React.forwardRef(({ className, disabled: disabledProp, ...rest }, ref) => { + const { fieldRef, inputRef, disabled } = useInputMultiSelect(); + return ( +
{ + fieldRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }} + className={cn( + 'border-input text-foreground bg-background hover:bg-primary/10 focus-within:ring-ring flex h-7 w-full items-center gap-1 rounded border px-2 py-1 text-base shadow-sm transition-colors focus-within:outline-none focus-within:ring-1', + (disabledProp ?? disabled) ? 'opacity-50 pointer-events-none' : '', + className + )} + role="group" + onClick={() => inputRef.current?.focus()} + {...rest} + /> + ); +}); +Field.displayName = 'InputMultiSelect.Field'; + +type SummaryProps = React.HTMLAttributes & { + // Variant controls how selections are summarized: + // - 'single' (default): one badge; when multiple selected, shows the count (e.g., "3"). + // - 'multi': each selected item renders as its own badge with remove affordance. + variant?: 'single' | 'multi'; + // For 'single' variant, allow custom formatting (first label + extra count) + format?: (firstLabel: string, extra: number) => string; +}; +const Summary = ({ className, format, variant = 'multi', ...rest }: SummaryProps) => { + const { value, normalized, clear, remove } = useInputMultiSelect(); + if (!value || value.length === 0) return null; + + if (variant === 'multi') { + return ( +
+ {value.map((val) => { + const lab = normalized.find(o => o.value === val)?.label ?? val; + return ( + + {lab} + remove(val)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); remove(val); } }} + > + × + + + ); + })} +
+ ); + } + + // 'single' variant + const firstVal = value[0]; + const firstLabel = normalized.find(o => o.value === firstVal)?.label ?? firstVal; + const count = value.length; + const text = format ? format(firstLabel, Math.max(0, count - 1)) : (count > 1 ? String(count) : firstLabel); + return ( + + {text} + clear()} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); clear(); } }} + > + × + + + ); +}; +Summary.displayName = 'InputMultiSelect.Summary'; + +type InputPropsEx = Omit, 'value'> & { ariaLabel?: string }; +const IMSInput = React.forwardRef(({ className, placeholder, ariaLabel, onFocus, onKeyDown, onBlur, onValueChange, ...rest }, ref) => { + const { inputRef, value, query, setQuery, open, setOpen, remove } = useInputMultiSelect(); + return ( + { + inputRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }} + aria-label={ariaLabel} + placeholder={value.length === 0 ? placeholder : ''} + className={cn('h-5 min-w-0 flex-1 bg-transparent px-0 py-0 outline-none', className)} + value={query} + onValueChange={(v) => { + setQuery(v); + if (!open) setOpen(true); + onValueChange?.(v); + }} + onFocus={(e) => { + setOpen(true); + onFocus?.(e); + }} + onKeyDown={(e) => { + if (e.key === 'Escape') setOpen(false); + if (e.key === 'Backspace' && query === '' && value.length > 0) { + remove(value[value.length - 1]); + } + // Arrow key handling is provided by cmdk when input and list share the same Command root. + onKeyDown?.(e); + }} + onBlur={(e) => { + onBlur?.(e); + }} + {...rest} + /> + ); +}); +IMSInput.displayName = 'InputMultiSelect.Input'; + +type ContentProps = React.HTMLAttributes & { + children?: React.ReactNode; + fitToContent?: boolean; + maxWidth?: number; // only used when fitToContent is true +}; +const Content = ({ className, children, fitToContent = false, maxWidth, ...rest }: ContentProps) => { + const { open, disabled, coords, overlayRef, setOpen } = useInputMultiSelect(); + if (!(open && !disabled && coords)) return null; + const gutter = 8; + const viewportMaxWidth = Math.max(200, (typeof window !== 'undefined' ? window.innerWidth : 1200) - coords.left - gutter); + const computedMaxWidth = Math.min(maxWidth ?? 480, viewportMaxWidth); + return createPortal( +
{ if (e.key === 'Escape') setOpen(false); }} + {...rest} + > + + + {children} + + +
, + document.body + ); +}; +Content.displayName = 'InputMultiSelect.Content'; + +type ListProps = React.ComponentPropsWithoutRef & { multiselectable?: boolean }; +const List = React.forwardRef, ListProps>(({ className, multiselectable = true, children, ...props }, ref) => { + return ( + + {children} + + ); +}); +List.displayName = 'InputMultiSelect.List'; + +const Group = CommandGroup as unknown as typeof CommandGroup; +Group.displayName = 'InputMultiSelect.Group'; + +type ItemProps = React.ComponentPropsWithoutRef & { valueKey?: string }; +const Item = React.forwardRef, ItemProps>(({ children, value: itemValue, valueKey, className, ...props }, ref) => { + const { toggle, selectedSet } = useInputMultiSelect(); + const label = typeof itemValue === 'string' ? itemValue : String(itemValue ?? ''); + const val = (valueKey ?? label) as string; + const selected = selectedSet.has(val); + return ( + toggle(val)} + aria-selected={selected} + className={cn('min-w-0 leading-none', className)} + {...props} + > + {children ?? ( + {label} + )} + + ); +}); +Item.displayName = 'InputMultiSelect.Item'; + +const Empty = CommandEmpty as unknown as typeof CommandEmpty; +Empty.displayName = 'InputMultiSelect.Empty'; + +// Convenience: render items for current filtered options +// Selected items appear at the top, order otherwise preserved. +const Options = () => { + const { filtered, toggle, selectedSet } = useInputMultiSelect(); + 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'; + +// Root export with subcomponents +const InputMultiSelect = Object.assign(InputMultiSelectRoot, { + Field, + Summary, + Input: IMSInput, + Content, + List, + Group, + Item, + Empty, + 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/StudyList/columns/defaultColumns.tsx b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx index 9a918bc611b..643834a7c96 100644 --- a/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx +++ b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx @@ -5,6 +5,7 @@ import { Icons } from '../../Icons'; import { Button } from '../../Button'; import type { StudyRow } from '../StudyListTypes'; import { StudyListActionsCell } from '../components/StudyListActionsCell'; +import { tokenizeModalities } from '../../../lib/filters'; export function defaultColumns(): ColumnDef[] { return [ @@ -19,9 +20,9 @@ export function defaultColumns(): ColumnDef[] { cell: ({ row }) =>
{row.getValue('patient')}
, meta: { label: 'Patient', - headerClassName: 'w-[165px] min-w-[165px] max-w-[165px]', - cellClassName: 'w-[165px] min-w-[165px] max-w-[165px]', - fixedWidth: 165, + headerClassName: 'min-w-[165px]', + cellClassName: 'min-w-[165px]', + minWidth: 165, }, }, { @@ -35,9 +36,9 @@ export function defaultColumns(): ColumnDef[] { cell: ({ row }) =>
{row.getValue('mrn')}
, meta: { label: 'MRN', - headerClassName: 'w-[120px] min-w-[120px] max-w-[120px]', - cellClassName: 'w-[120px] min-w-[120px] max-w-[120px]', - fixedWidth: 120, + headerClassName: 'min-w-[120px]', + cellClassName: 'min-w-[120px]', + minWidth: 120, }, }, { @@ -55,9 +56,9 @@ export function defaultColumns(): ColumnDef[] { }, meta: { label: 'Study Date', - headerClassName: 'w-[150px] min-w-[150px] max-w-[150px]', - cellClassName: 'w-[150px] min-w-[150px] max-w-[150px]', - fixedWidth: 150, + headerClassName: 'min-w-[150px]', + cellClassName: 'min-w-[150px]', + minWidth: 150, }, }, { @@ -69,11 +70,18 @@ export function defaultColumns(): ColumnDef[] { /> ), cell: ({ row }) =>
{row.getValue('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: 'w-[97px] min-w-[97px] max-w-[97px]', - cellClassName: 'w-[97px] min-w-[97px] max-w-[97px]', - fixedWidth: 97, + headerClassName: 'min-w-[97px]', + cellClassName: 'min-w-[97px]', + minWidth: 97, }, }, { @@ -92,6 +100,7 @@ export function defaultColumns(): ColumnDef[] { label: 'Description', headerClassName: 'min-w-[290px]', cellClassName: 'min-w-[290px]', + minWidth: 290, }, }, { @@ -105,9 +114,9 @@ export function defaultColumns(): ColumnDef[] { cell: ({ row }) =>
{row.getValue('accession')}
, meta: { label: 'Accession', - headerClassName: 'w-[140px] min-w-[140px] max-w-[140px]', - cellClassName: 'w-[140px] min-w-[140px] max-w-[140px]', - fixedWidth: 140, + headerClassName: 'min-w-[140px]', + cellClassName: 'min-w-[140px]', + minWidth: 140, }, }, { @@ -144,9 +153,9 @@ export function defaultColumns(): ColumnDef[] { sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), meta: { label: 'Instances', - headerClassName: 'w-[45px] min-w-[45px] max-w-[45px]', - cellClassName: 'w-[45px] min-w-[45px] max-w-[45px] overflow-hidden', - fixedWidth: 45, + headerClassName: 'min-w-[45px]', + cellClassName: 'min-w-[45px] overflow-hidden', + minWidth: 45, }, }, // Non-hideable trailing actions column to keep the menu at row end @@ -160,7 +169,7 @@ export function defaultColumns(): ColumnDef[] { // No label so it never appears labeled in any UI; also non-hideable headerClassName: 'w-[56px] min-w-[56px] max-w-[56px]', cellClassName: 'w-[56px] min-w-[56px] max-w-[56px] overflow-visible', - fixedWidth: 56, + minWidth: 56, }, }, ]; diff --git a/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx b/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx index be30b34fb10..36c6d3a5a97 100644 --- a/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx +++ b/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { cn } from '../../../lib/utils'; import type { ColumnDef, SortingState, VisibilityState } from '@tanstack/react-table'; import { flexRender } from '@tanstack/react-table'; import { @@ -20,8 +21,11 @@ import { } from '../../Table'; import { ScrollArea } from '../../ScrollArea'; import { Button } from '../../Button'; +import { Input } from '../../Input'; +import { InputMultiSelect } from '../../InputMultiSelect'; import type { StudyRow } from '../StudyListTypes'; import { useStudyList } from '../headless/StudyListProvider'; +import { tokenizeModalities } from '../../../lib/filters'; import type { WorkflowId } from '../WorkflowsInfer'; type Props = { @@ -114,18 +118,61 @@ function Content({ renderOpenPanelButton?: (args: { onOpenPanel: () => void }) => React.ReactNode; }) { const { table, setColumnFilters } = useDataTable(); + const modalityOptions = React.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 headless state for default workflow + launch const { defaultWorkflow, launch } = useStudyList(); + + // Responsive column visibility based on viewport width + React.useEffect(() => { + const updateVisibility = () => { + const width = window.innerWidth; + const isMobile = width < 768; + const isTablet = width >= 768 && width < 1024; + + if (isMobile) { + // Mobile: Show only Patient, Description, Actions + table.getColumn('mrn')?.toggleVisibility(false); + table.getColumn('studyDateTime')?.toggleVisibility(false); + table.getColumn('modalities')?.toggleVisibility(false); + table.getColumn('accession')?.toggleVisibility(false); + table.getColumn('instances')?.toggleVisibility(false); + } else if (isTablet) { + // Tablet: Add Study Date, Modalities + table.getColumn('mrn')?.toggleVisibility(false); + table.getColumn('studyDateTime')?.toggleVisibility(true); + table.getColumn('modalities')?.toggleVisibility(true); + table.getColumn('accession')?.toggleVisibility(false); + table.getColumn('instances')?.toggleVisibility(false); + } else { + // Desktop: Show all + table.getColumn('mrn')?.toggleVisibility(true); + table.getColumn('studyDateTime')?.toggleVisibility(true); + table.getColumn('modalities')?.toggleVisibility(true); + table.getColumn('accession')?.toggleVisibility(true); + table.getColumn('instances')?.toggleVisibility(true); + } + }; + + updateVisibility(); + window.addEventListener('resize', updateVisibility); + return () => window.removeEventListener('resize', updateVisibility); + }, [table]); const renderColGroup = React.useCallback( () => (
{table.getVisibleLeafColumns().map((col) => { const meta = - (col.columnDef.meta as unknown as { fixedWidth?: number | string } | undefined) ?? + (col.columnDef.meta as unknown as { minWidth?: number | string } | undefined) ?? undefined; - const width = meta?.fixedWidth; - return width ? ( - + const minWidth = meta?.minWidth; + return minWidth ? ( + ) : ( ); @@ -172,7 +219,7 @@ function Content({
-
+
{renderColGroup()} {table.getHeaderGroups().map((hg) => ( @@ -202,13 +249,40 @@ function Content({ resetCellId="actions" onReset={() => setColumnFilters([])} excludeColumnIds={["instances"]} + renderCell={({ columnId, value, setValue }) => { + if (columnId === 'modalities') { + const selected = Array.isArray(value) ? (value as string[]) : []; + return ( + setValue(next)} + > + + + + + + + + + ); + } + return ( + setValue(e.target.value)} + className="h-7 w-full" + /> + ); + }} />
- +
{renderColGroup()} {table.getPaginationRowModel().rows.length ? ( diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 811352f2e04..064134bd2d0 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'; @@ -156,6 +158,8 @@ export { useSegmentationTableContext, useSegmentationExpanded, useSegmentStatist export { Numeric, ErrorBoundary, + Badge, + badgeVariants, Button, buttonVariants, ThemeWrapper, @@ -191,6 +195,7 @@ export { DatePickerWithRange, Input, InputNumber, + InputMultiSelect, Label, Tabs, TabsContent, diff --git a/platform/ui-next/src/lib/filters.ts b/platform/ui-next/src/lib/filters.ts new file mode 100644 index 00000000000..fc3bec6bebb --- /dev/null +++ b/platform/ui-next/src/lib/filters.ts @@ -0,0 +1,7 @@ +export function tokenizeModalities(value: string): string[] { + return String(value ?? '') + .toUpperCase() + .split(/[\s,\/\\]+/) + .filter(Boolean); +} + diff --git a/yarn.lock b/yarn.lock index 443f3567be8..918af20f9ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4737,7 +4737,7 @@ dependencies: defer-to-connect "^2.0.1" -"@tanstack/react-table@^8.20.0": +"@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== From 38592b930fbf71912a3ebd477ece3bffec62e537 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 25 Nov 2025 12:51:03 -0500 Subject: [PATCH 101/172] Updated DataTable compound component --- .../src/components/DataTable/ActionCell.tsx | 2 - .../src/components/DataTable/DataTable.tsx | 389 ++++++++++++++++-- .../ui-next/src/components/DataTable/index.ts | 24 +- .../StudyList/components/StudyListTable.tsx | 277 +++++-------- 4 files changed, 467 insertions(+), 225 deletions(-) delete mode 100644 platform/ui-next/src/components/DataTable/ActionCell.tsx diff --git a/platform/ui-next/src/components/DataTable/ActionCell.tsx b/platform/ui-next/src/components/DataTable/ActionCell.tsx deleted file mode 100644 index 4522a549ed8..00000000000 --- a/platform/ui-next/src/components/DataTable/ActionCell.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { DataTableActionOverlayCell as DataTableActionCell } from './ActionOverlayCell' - diff --git a/platform/ui-next/src/components/DataTable/DataTable.tsx b/platform/ui-next/src/components/DataTable/DataTable.tsx index 763c893f3aa..ddcf4f4f30f 100644 --- a/platform/ui-next/src/components/DataTable/DataTable.tsx +++ b/platform/ui-next/src/components/DataTable/DataTable.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import * as React from 'react'; import type { ColumnDef, ColumnFiltersState, @@ -6,29 +6,44 @@ import type { SortingState, VisibilityState, PaginationState, -} from '@tanstack/react-table' + Row, +} from '@tanstack/react-table'; import { getCoreRowModel, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, useReactTable, -} from '@tanstack/react-table' -import { DataTableContext } from './context' - -type Props = { - data: TData[] - columns: ColumnDef[] - getRowId?: (row: TData, index: number) => string - initialSorting?: SortingState - initialVisibility?: VisibilityState - initialFilters?: ColumnFiltersState - enforceSingleSelection?: boolean - onSelectionChange?: (rows: TData[]) => void - children: React.ReactNode -} + flexRender, +} from '@tanstack/react-table'; + +import { DataTableContext, useDataTable } from './context'; +import { DataTableToolbar } from './Toolbar'; +import { DataTableTitle } from './Title'; +import { DataTablePagination } from './Pagination'; +import { DataTableViewOptions as InternalViewOptions } from './ViewOptions'; +import { DataTableFilterRow } from './FilterRow'; +import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '../Table'; +import { ScrollArea } from '../ScrollArea'; +import { cn } from '../../lib/utils'; + +export type DataTableProps = { + data: TData[]; + columns: ColumnDef[]; + getRowId?: (row: TData, index: number) => string; + initialSorting?: SortingState; + initialVisibility?: VisibilityState; + initialFilters?: ColumnFiltersState; + enforceSingleSelection?: boolean; + onSelectionChange?: (rows: TData[]) => void; + children: React.ReactNode; +}; -export function DataTable({ +/** + * Root DataTable provider component. + * Creates the TanStack table instance, manages state, and exposes it via context. + */ +function DataTableRoot({ data, columns, getRowId, @@ -38,12 +53,17 @@ export function DataTable({ enforceSingleSelection = true, onSelectionChange, children, -}: Props) { - const [sorting, setSorting] = React.useState(initialSorting) - const [columnVisibility, setColumnVisibility] = React.useState(initialVisibility) - const [rowSelection, setRowSelection] = React.useState({}) - const [columnFilters, setColumnFilters] = React.useState(initialFilters) - const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 50 }) +}: DataTableProps) { + const [sorting, setSorting] = React.useState(initialSorting); + const [columnVisibility, setColumnVisibility] = + React.useState(initialVisibility); + const [rowSelection, setRowSelection] = React.useState({}); + const [columnFilters, setColumnFilters] = + React.useState(initialFilters); + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 50, + }); const table = useReactTable({ data, @@ -61,18 +81,19 @@ export function DataTable({ enableRowSelection: true, enableMultiRowSelection: !enforceSingleSelection, getRowId, - }) + }); - // When filters, sorting, or incoming data change, go back to the first page + // When filters, sorting, or incoming data change, go back to the first page. React.useEffect(() => { - setPagination(p => ({ ...p, pageIndex: 0 })) - }, [columnFilters, sorting, data]) + setPagination(p => ({ ...p, pageIndex: 0 })); + }, [columnFilters, sorting, data]); + // Surface selection changes to consumers. React.useEffect(() => { - if (!onSelectionChange) return - const selected = table.getSelectedRowModel().rows.map((r) => r.original as TData) - onSelectionChange(selected) - }, [rowSelection, onSelectionChange, table]) + if (!onSelectionChange) return; + const selected = table.getSelectedRowModel().rows.map(r => r.original as TData); + onSelectionChange(selected); + }, [rowSelection, onSelectionChange, table]); const value = React.useMemo( () => ({ @@ -90,8 +111,310 @@ export function DataTable({ resetFilters: () => setColumnFilters([]), }), [table, sorting, columnVisibility, rowSelection, columnFilters, pagination] - ) + ); + + return ( + + {children} + + ); +} + +/** + * Simple toolbar wrapper. Typically used as: + * + * ... + * + * + * + */ +function Toolbar({ children }: { children?: React.ReactNode }) { + return {children}; +} + +/** + * Title element rendered inside the toolbar. + */ +function Title({ children }: { children?: React.ReactNode }) { + return {children}; +} + +/** + * Pagination controls bound to the current table instance. + */ +function Pagination() { + return ; +} + +type ViewOptionsProps = { + getLabel?: (columnId: string) => string; + canHide?: (columnId: string) => boolean; + buttonText?: string; +}; + +/** + * View options (column visibility) menu. + */ +function ViewOptions({ + getLabel, + canHide, + buttonText, +}: ViewOptionsProps) { + return ( + + getLabel={getLabel} + canHide={canHide} + buttonText={buttonText} + /> + ); +} + +type DataTableTableProps = { + children?: React.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 DataTableTable({ children, className, tableClassName }: DataTableTableProps) { + const { table } = useDataTable(); + + const renderColGroup = React.useCallback( + () => ( + + {table.getVisibleLeafColumns().map(col => { + const meta = + (col.columnDef.meta as { minWidth?: number | string } | undefined) ?? undefined; + const minWidth = meta?.minWidth; + return minWidth ? ( + + ) : ( + + ); + })} + + ), + [table] + ); - return {children} + let headerChild: React.ReactElement | null = null; + let filterRowChild: React.ReactElement | null = null; + let bodyChild: React.ReactElement | null = null; + + React.Children.forEach(children, child => { + if (!React.isValidElement(child)) return; + if (child.type === DataTableHeader) headerChild = child; + if (child.type === DataTableFilterRowCompound) filterRowChild = child; + if (child.type === DataTableBody) bodyChild = child; + }); + + return ( +
+
+ {/* Header + filter row */} +
+
+ {renderColGroup()} + {headerChild} + + {filterRowChild} + +
+
+ + {/* Scrollable body */} +
+ + + {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 DataTableHeader() { + const { table } = useDataTable(); + + return ( + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + const meta = + (header.column.columnDef.meta as { headerClassName?: string } | undefined) ?? + undefined; + const headerClassName = meta?.headerClassName ?? ''; + const sorted = header.column.getIsSorted() as false | 'asc' | 'desc'; + + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + ); +} + +type FilterRowProps = { + excludeColumnIds?: string[]; + resetCellId?: string; + onReset?: () => void; + renderCell?: (opts: { + columnId: string; + value: unknown; + setValue: (v: unknown) => void; + }) => React.ReactNode; + inputClassName?: string; +}; + +/** + * Wraps the lower-level DataTableFilterRow to automatically wire reset behavior + * to the table's resetFilters helper when onReset is not provided. + */ +function DataTableFilterRowCompound({ + onReset, + ...rest +}: FilterRowProps) { + const { resetFilters } = useDataTable(); + + return ( + + onReset={onReset ?? resetFilters} + {...rest} + /> + ); +} + +type BodyProps = { + /** + * Which row model to render: + * - "pagination" (default): uses table.getPaginationRowModel() + * - "core": uses table.getRowModel() + */ + rowModel?: 'pagination' | 'core'; + /** + * Optional custom row renderer. If omitted, a default cell-only renderer is used. + */ + renderRow?: (row: Row) => React.ReactNode; + /** + * Message shown when there are no rows to render. + */ + emptyMessage?: string; +}; + +/** + * Core body renderer. Keeps awareness of selection state via data-state="selected". + * Consumers can either rely on the default row renderer or provide a custom one. + */ +function DataTableBody({ + rowModel = 'pagination', + renderRow, + emptyMessage = 'No results.', +}: BodyProps) { + const { table } = useDataTable(); + const rows = + rowModel === 'pagination' + ? table.getPaginationRowModel().rows + : table.getRowModel().rows; + + if (!rows.length) { + return ( + + + {emptyMessage} + + + ); + } + + return ( + <> + {rows.map(row => + renderRow ? ( + renderRow(row) + ) : ( + + {row.getVisibleCells().map(cell => { + const metaClass = + ((cell.column.columnDef.meta as { cellClassName?: string })?.cellClassName) ?? + ''; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ) + )} + + ); +} + +const DataTableCompound = Object.assign(DataTableRoot, { + Toolbar, + Title, + Pagination, + ViewOptions, + Table: DataTableTable, + Header: DataTableHeader, + FilterRow: DataTableFilterRowCompound, + Body: DataTableBody, +}); + +export { DataTableCompound as DataTable }; diff --git a/platform/ui-next/src/components/DataTable/index.ts b/platform/ui-next/src/components/DataTable/index.ts index bf517fa73ee..dec5f52d4dd 100644 --- a/platform/ui-next/src/components/DataTable/index.ts +++ b/platform/ui-next/src/components/DataTable/index.ts @@ -1,10 +1,14 @@ -export { DataTableColumnHeader } from './ColumnHeader' -export { DataTableViewOptions } from './ViewOptions' -export { DataTableFilterRow } from './FilterRow' -export { DataTableActionOverlayCell } from './ActionOverlayCell' -export { DataTableActionCell } from './ActionCell' -export { DataTable } from './DataTable' -export { useDataTable } from './context' -export { DataTableToolbar } from './Toolbar' -export { DataTableTitle } from './Title' -export { DataTablePagination } from './Pagination' +// Core compound table + hook +export { DataTable } from './DataTable'; +export { useDataTable } from './context'; + +// Shared building blocks for columns/cells +export { DataTableColumnHeader } from './ColumnHeader'; +export { DataTableActionOverlayCell } from './ActionOverlayCell'; + +// Export is no longer needed. Instead use DataTable compound component. +// export { DataTableFilterRow } from './FilterRow' +// export { DataTableViewOptions } from './ViewOptions' +// export { DataTableToolbar } from './Toolbar' +// export { DataTableTitle } from './Title' +// export { DataTablePagination } from './Pagination' diff --git a/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx b/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx index 36c6d3a5a97..e2e40268b54 100644 --- a/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx +++ b/platform/ui-next/src/components/StudyList/components/StudyListTable.tsx @@ -1,25 +1,8 @@ import * as React from 'react'; -import { cn } from '../../../lib/utils'; import type { ColumnDef, SortingState, VisibilityState } from '@tanstack/react-table'; import { flexRender } from '@tanstack/react-table'; -import { - DataTable, - DataTableToolbar, - DataTableTitle, - DataTableFilterRow, - DataTableViewOptions, - DataTablePagination, - useDataTable, -} from '../../DataTable'; -import { - Table, - TableHeader, - TableBody, - TableHead, - TableRow, - TableCell, -} from '../../Table'; -import { ScrollArea } from '../../ScrollArea'; +import { DataTable, useDataTable } from '../../DataTable'; +import { TableRow, TableCell } from '../../Table'; import { Button } from '../../Button'; import { Input } from '../../Input'; import { InputMultiSelect } from '../../InputMultiSelect'; @@ -117,7 +100,7 @@ function Content({ toolbarRightExtras?: React.ReactNode; renderOpenPanelButton?: (args: { onOpenPanel: () => void }) => React.ReactNode; }) { - const { table, setColumnFilters } = useDataTable(); + const { table } = useDataTable(); const modalityOptions = React.useMemo(() => { const rows = (table.options?.data as StudyRow[]) ?? []; // Build a flat list of modality tokens across all rows. @@ -163,36 +146,18 @@ function Content({ window.addEventListener('resize', updateVisibility); return () => window.removeEventListener('resize', updateVisibility); }, [table]); - const renderColGroup = React.useCallback( - () => ( - - {table.getVisibleLeafColumns().map((col) => { - const meta = - (col.columnDef.meta as unknown as { minWidth?: number | string } | undefined) ?? - undefined; - const minWidth = meta?.minWidth; - return minWidth ? ( - - ) : ( - - ); - })} - - ), - [table] - ); return (
{(showColumnVisibility || title) && ( - +
{toolbarLeft}
- {title ? {title} : null} + {title ? {title} : null}
{/* Pagination appears to the left of the "View" button */} - + {showColumnVisibility && ( - getLabel={(id) => { const label = ( table.getColumn(id)?.columnDef.meta as { label?: string } | undefined @@ -214,145 +179,97 @@ function Content({
) : null}
- + )} -
-
-
- - {renderColGroup()} - - {table.getHeaderGroups().map((hg) => ( - - {hg.headers.map((header) => ( - { - const s = header.column.getIsSorted() as false | 'asc' | 'desc'; - return s === 'asc' ? 'ascending' : s === 'desc' ? 'descending' : 'none'; - })()} - > - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - setColumnFilters([])} - excludeColumnIds={["instances"]} - renderCell={({ columnId, value, setValue }) => { - if (columnId === 'modalities') { - const selected = Array.isArray(value) ? (value as string[]) : []; - return ( - setValue(next)} - > - - - - - - - - - ); - } - return ( - setValue(e.target.value)} - className="h-7 w-full" - /> - ); - }} - /> - -
-
-
- - - {renderColGroup()} - - {table.getPaginationRowModel().rows.length ? ( - table.getPaginationRowModel().rows.map((row) => ( - { - // 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={(e) => { - if (!defaultWorkflow) return; - // Ensure the row is selected, then launch with the default workflow - if (!row.getIsSelected()) row.toggleSelected(true); - const original = row.original as StudyRow; - launch(original, defaultWorkflow as WorkflowId); - }} - aria-selected={row.getIsSelected()} - className="group cursor-pointer" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - // Keyboard behavior mirrors click: when default workflow is set, - // Enter/Space should select but not toggle to unselect. - if (defaultWorkflow) { - if (!row.getIsSelected()) row.toggleSelected(true); - } else { - row.toggleSelected(); - } - } - }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
-
-
-
+ + + + resetCellId="actions" + excludeColumnIds={['instances']} + renderCell={({ columnId, value, setValue }) => { + if (columnId === 'modalities') { + const selected = Array.isArray(value) ? (value as string[]) : []; + return ( + setValue(next)} + > + + + + + + + + + ); + } + return ( + setValue(e.target.value)} + className="h-7 w-full" + /> + ); + }} + /> + + rowModel="pagination" + emptyMessage="No results." + renderRow={(row) => ( + { + // 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={() => { + if (!defaultWorkflow) return; + // Ensure the row is selected, then launch with the default workflow + if (!row.getIsSelected()) row.toggleSelected(true); + const original = row.original as StudyRow; + launch(original, defaultWorkflow as WorkflowId); + }} + aria-selected={row.getIsSelected()} + className="group cursor-pointer" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + // Keyboard behavior mirrors click: when default workflow is set, + // Enter/Space should select but not toggle to unselect. + if (defaultWorkflow) { + if (!row.getIsSelected()) row.toggleSelected(true); + } else { + row.toggleSelected(); + } + } + }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )} + /> +
); } From 06dfa8fba9851cdc1e128f6f98dd186b590495d1 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Thu, 27 Nov 2025 09:07:20 -0500 Subject: [PATCH 102/172] More DataTable refactoring. Made DataTable and its components more flexible and easier to reuse. Removed both unused and duplicate code. Removed some unnecessary complexity. --- .../DataTable/ActionOverlayCell.tsx | 169 +++++++--- .../src/components/DataTable/ColumnHeader.tsx | 79 +++-- .../src/components/DataTable/DataTable.tsx | 290 +++++++----------- .../src/components/DataTable/FilterRow.tsx | 80 +++-- .../src/components/DataTable/Pagination.tsx | 4 +- .../src/components/DataTable/Title.tsx | 7 +- .../src/components/DataTable/Toolbar.tsx | 7 +- .../src/components/DataTable/ViewOptions.tsx | 59 ++-- .../src/components/DataTable/context.tsx | 39 +-- .../ui-next/src/components/DataTable/index.ts | 4 - .../ui-next/src/components/DataTable/types.ts | 20 ++ .../components/StudyList/StudyListTypes.ts | 2 +- .../StudyList/columns/defaultColumns.tsx | 91 ++---- .../components/StudyListActionsCell.tsx | 44 ++- .../components/StudyListInstancesCell.tsx | 29 +- .../StudyList/components/StudyListTable.tsx | 150 +++++---- 16 files changed, 510 insertions(+), 564 deletions(-) create mode 100644 platform/ui-next/src/components/DataTable/types.ts diff --git a/platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx b/platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx index 94ef1f5ca1c..c1e43cd55ff 100644 --- a/platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx +++ b/platform/ui-next/src/components/DataTable/ActionOverlayCell.tsx @@ -1,59 +1,138 @@ -import * as React from 'react' - -type Props = { - isActive: boolean - value: React.ReactNode - overlay: React.ReactNode - onActivate?: () => void - alignRight?: boolean - overlayAlign?: 'start' | 'center' | 'end' +import * as React from 'react'; +import type { Cell } from '@tanstack/react-table'; +import type { ColumnMeta } from './types'; + +// Context to share computed values with sub-components +type ActionOverlayCellContextValue = { + isActive: boolean; + computedAlign: 'start' | 'center' | 'end'; + cell: Cell; +}; + +const ActionOverlayCellContext = React.createContext(null); + +// Symbol to identify sub-components +const VALUE_TYPE = Symbol('ActionOverlayCell.Value'); +const OVERLAY_TYPE = Symbol('ActionOverlayCell.Overlay'); + +type ActionOverlayCellProps = { + cell: Cell; + children?: React.ReactNode; +}; + +export function ActionOverlayCell({ cell, children }: ActionOverlayCellProps) { + const isActive = cell.row.getIsSelected(); + const meta = (cell.column.columnDef.meta as ColumnMeta | undefined) ?? undefined; + const align = meta?.align ?? 'right'; + // Map 'left' | 'center' | 'right' to 'start' | 'center' | 'end' for overlay positioning + const computedAlign = align === 'left' ? 'start' : align === 'center' ? 'center' : 'end'; + + const contextValue: ActionOverlayCellContextValue = { + isActive, + computedAlign, + cell: cell as Cell, + }; + + // Extract Value and Overlay components from children + // Only Value and Overlay sub-components are recognized; other children are ignored + let valueElement: React.ReactElement | null = null; + let overlayElement: React.ReactElement | null = null; + + React.Children.forEach(children, child => { + if (React.isValidElement(child)) { + const childType = (child.type as { _type?: symbol })?._type; + if (childType === VALUE_TYPE) { + valueElement = child; + } else if (childType === OVERLAY_TYPE) { + overlayElement = child; + } + } + }); + + return ( + +
+ {valueElement} + {overlayElement} +
+
+ ); } -export function DataTableActionOverlayCell({ - isActive, - value, - overlay, - onActivate, - alignRight = true, - overlayAlign, -}: Props) { - const computedAlign = overlayAlign ?? (alignRight ? 'end' : 'start') +// Value sub-component +type ValueProps = { + children?: React.ReactNode; +}; + +function Value({ children }: ValueProps) { + const context = React.useContext(ActionOverlayCellContext); + if (!context) { + throw new Error('ActionOverlayCell.Value must be used within ActionOverlayCell'); + } + + const { isActive, computedAlign } = context; const valueAlignmentClass = - computedAlign === 'end' ? 'text-right' : computedAlign === 'center' ? 'text-center' : '' + computedAlign === 'end' ? 'text-right' : computedAlign === 'center' ? 'text-center' : ''; + const valueVisibilityClass = isActive + ? 'invisible opacity-0' + : 'group-hover:invisible group-hover:opacity-0 group-hover:text-transparent'; + + return ( +
+ {children} +
+ ); +} + +// Mark Value component with symbol for identification +(Value as { _type?: symbol })._type = VALUE_TYPE; + +// Overlay sub-component +type OverlayProps = { + children?: React.ReactNode; +}; + +function Overlay({ children }: OverlayProps) { + const context = React.useContext(ActionOverlayCellContext); + if (!context) { + throw new Error('ActionOverlayCell.Overlay must be used within ActionOverlayCell'); + } + + const { isActive, computedAlign, cell } = context; const overlayPositionClass = computedAlign === 'center' ? 'inset-y-0 inset-x-0 justify-center px-2' : computedAlign === 'start' ? 'inset-y-0 left-0 px-2' - : 'inset-y-0 right-0 px-2' + : 'inset-y-0 right-0 px-2'; const overlayVisibilityClass = isActive ? 'bg-popover opacity-100' - : 'opacity-0 group-hover:bg-muted group-hover:opacity-100' - const valueVisibilityClass = isActive - ? 'invisible opacity-0' - : 'group-hover:invisible group-hover:opacity-0 group-hover:text-transparent' + : 'opacity-0 group-hover:bg-muted group-hover:opacity-100'; return ( -
-
- {value} -
-
e.stopPropagation()} - onMouseDown={(e) => { - e.stopPropagation() - onActivate?.() - }} - onPointerDown={(e) => { - e.stopPropagation() - onActivate?.() - }} - > - {overlay} -
+
{ + e.stopPropagation(); + if (!cell.row.getIsSelected()) { + cell.row.toggleSelected(true); + } + }} + onPointerDown={e => { + e.stopPropagation(); + if (!cell.row.getIsSelected()) { + cell.row.toggleSelected(true); + } + }} + > + {children}
- ) + ); } + +// Mark Overlay component with symbol for identification +(Overlay as { _type?: symbol })._type = OVERLAY_TYPE; + +// Export as compound component +ActionOverlayCell.Value = Value; +ActionOverlayCell.Overlay = Overlay; diff --git a/platform/ui-next/src/components/DataTable/ColumnHeader.tsx b/platform/ui-next/src/components/DataTable/ColumnHeader.tsx index 21eea586aed..d81728cb79a 100644 --- a/platform/ui-next/src/components/DataTable/ColumnHeader.tsx +++ b/platform/ui-next/src/components/DataTable/ColumnHeader.tsx @@ -1,47 +1,46 @@ -import * as React from 'react' -import type { Column } from '@tanstack/react-table' -import { Button } from '../Button' -import * as ReactNS from 'react' -import { DataTableContext } from './context' -import { Icons } from '../Icons' +import * as React from 'react'; +import type { Column } from '@tanstack/react-table'; +import { Button } from '../Button'; +import { Icons } from '../Icons'; +import type { ColumnMeta } from './types'; -export function DataTableColumnHeader({ - column, - columnId, - title, - align = 'left', -}: { - column?: Column - columnId?: string - title: string - align?: 'left' | 'center' | 'right' -}) { - const ctx = ReactNS.useContext(DataTableContext) - const resolvedColumn = column ?? (columnId && ctx ? (ctx.table.getColumn(columnId) as Column) : undefined) - if (!resolvedColumn) { - return {title} - } - const sorted = resolvedColumn.getIsSorted() as false | 'asc' | 'desc' - const justify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start' +export function ColumnHeader({ column }: { column: Column }) { + const meta = (column.columnDef.meta as ColumnMeta | undefined) ?? undefined; - const SortIcon = sorted === 'asc' - ? Icons.SortingNewAscending - : sorted === 'desc' - ? Icons.SortingNewDescending - : Icons.SortingNew + // Use headerContent if provided, otherwise use label + const content = meta?.headerContent ?? meta?.label ?? column.id; + const align = meta?.align ?? 'left'; + const canSort = column.getCanSort(); + const sorted = column.getIsSorted() as false | 'asc' | 'desc'; + const justify = + align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; + + const SortIcon = + sorted === 'asc' + ? Icons.SortingNewAscending + : sorted === 'desc' + ? Icons.SortingNewDescending + : Icons.SortingNew; + + const ariaLabel = meta?.label ?? column.id; return (
- {title} - + {typeof content === 'string' ? {content} : content} + {canSort && ( + + )}
- ) + ); } diff --git a/platform/ui-next/src/components/DataTable/DataTable.tsx b/platform/ui-next/src/components/DataTable/DataTable.tsx index ddcf4f4f30f..d37cd0dfb28 100644 --- a/platform/ui-next/src/components/DataTable/DataTable.tsx +++ b/platform/ui-next/src/components/DataTable/DataTable.tsx @@ -17,13 +17,23 @@ import { flexRender, } from '@tanstack/react-table'; -import { DataTableContext, useDataTable } from './context'; -import { DataTableToolbar } from './Toolbar'; -import { DataTableTitle } from './Title'; -import { DataTablePagination } from './Pagination'; -import { DataTableViewOptions as InternalViewOptions } from './ViewOptions'; -import { DataTableFilterRow } from './FilterRow'; -import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '../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 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'; @@ -58,14 +68,13 @@ function DataTableRoot({ const [columnVisibility, setColumnVisibility] = React.useState(initialVisibility); const [rowSelection, setRowSelection] = React.useState({}); - const [columnFilters, setColumnFilters] = - React.useState(initialFilters); + const [columnFilters, setColumnFilters] = React.useState(initialFilters); const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 50, }); - const table = useReactTable({ + const table = useReactTable({ data, columns, state: { sorting, columnVisibility, rowSelection, columnFilters, pagination }, @@ -90,86 +99,21 @@ function DataTableRoot({ // Surface selection changes to consumers. React.useEffect(() => { - if (!onSelectionChange) return; + if (!onSelectionChange) { + return; + } const selected = table.getSelectedRowModel().rows.map(r => r.original as TData); onSelectionChange(selected); }, [rowSelection, onSelectionChange, table]); - const value = React.useMemo( - () => ({ - table, - sorting, - setSorting, - columnVisibility, - setColumnVisibility, - rowSelection, - setRowSelection, - columnFilters, - setColumnFilters, - pagination, - setPagination, - resetFilters: () => setColumnFilters([]), - }), - [table, sorting, columnVisibility, rowSelection, columnFilters, pagination] - ); - return ( - + }> {children} ); } -/** - * Simple toolbar wrapper. Typically used as: - * - * ... - * - * - * - */ -function Toolbar({ children }: { children?: React.ReactNode }) { - return {children}; -} - -/** - * Title element rendered inside the toolbar. - */ -function Title({ children }: { children?: React.ReactNode }) { - return {children}; -} - -/** - * Pagination controls bound to the current table instance. - */ -function Pagination() { - return ; -} - -type ViewOptionsProps = { - getLabel?: (columnId: string) => string; - canHide?: (columnId: string) => boolean; - buttonText?: string; -}; - -/** - * View options (column visibility) menu. - */ -function ViewOptions({ - getLabel, - canHide, - buttonText, -}: ViewOptionsProps) { - return ( - - getLabel={getLabel} - canHide={canHide} - buttonText={buttonText} - /> - ); -} - -type DataTableTableProps = { +type TableProps = { children?: React.ReactNode; /** * Optional className applied to the outer bordered container. @@ -189,15 +133,14 @@ type DataTableTableProps = { * Consumers pass , , and * as children; this component wires them into the correct structure. */ -function DataTableTable({ children, className, tableClassName }: DataTableTableProps) { +function Table({ children, className, tableClassName }: TableProps) { const { table } = useDataTable(); const renderColGroup = React.useCallback( () => ( {table.getVisibleLeafColumns().map(col => { - const meta = - (col.columnDef.meta as { minWidth?: number | string } | undefined) ?? undefined; + const meta = (col.columnDef.meta as ColumnMeta | undefined) ?? undefined; const minWidth = meta?.minWidth; return minWidth ? ( { - if (!React.isValidElement(child)) return; - if (child.type === DataTableHeader) headerChild = child; - if (child.type === DataTableFilterRowCompound) filterRowChild = child; - if (child.type === DataTableBody) bodyChild = child; + if (!React.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} - -
+ {filterRowChild} +
{/* Scrollable body */}
- {renderColGroup()} - {bodyChild} -
+ {bodyChild} +
@@ -266,85 +215,47 @@ function DataTableTable({ children, className, tableClassName }: DataTableTableP * Renders the table header row(s) based on the current table instance. * Applies meta.headerClassName and a muted background to match StudyList styling. */ -function DataTableHeader() { - const { table } = useDataTable(); +function Header() { + const { table } = useDataTable(); return ( - + {table.getHeaderGroups().map(headerGroup => ( - + {headerGroup.headers.map(header => { - const meta = - (header.column.columnDef.meta as { headerClassName?: string } | undefined) ?? - undefined; + const meta = (header.column.columnDef.meta as ColumnMeta | undefined) ?? undefined; const headerClassName = meta?.headerClassName ?? ''; - const sorted = header.column.getIsSorted() as false | 'asc' | 'desc'; + const sortState = header.column.getIsSorted() as false | 'asc' | 'desc'; return ( - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - + ); })} - + ))} - + ); } -type FilterRowProps = { - excludeColumnIds?: string[]; - resetCellId?: string; - onReset?: () => void; - renderCell?: (opts: { - columnId: string; - value: unknown; - setValue: (v: unknown) => void; - }) => React.ReactNode; - inputClassName?: string; +type RowProps = { + render?: (row: Row) => React.ReactNode; + onClick?: (row: Row) => void; + onDoubleClick?: (row: Row) => void; + className?: string | ((row: Row) => string); }; -/** - * Wraps the lower-level DataTableFilterRow to automatically wire reset behavior - * to the table's resetFilters helper when onReset is not provided. - */ -function DataTableFilterRowCompound({ - onReset, - ...rest -}: FilterRowProps) { - const { resetFilters } = useDataTable(); - - return ( - - onReset={onReset ?? resetFilters} - {...rest} - /> - ); -} - type BodyProps = { - /** - * Which row model to render: - * - "pagination" (default): uses table.getPaginationRowModel() - * - "core": uses table.getRowModel() - */ - rowModel?: 'pagination' | 'core'; - /** - * Optional custom row renderer. If omitted, a default cell-only renderer is used. - */ - renderRow?: (row: Row) => React.ReactNode; + rowProps?: RowProps; /** * Message shown when there are no rows to render. */ @@ -353,68 +264,89 @@ type BodyProps = { /** * 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 DataTableBody({ - rowModel = 'pagination', - renderRow, - emptyMessage = 'No results.', -}: BodyProps) { +function Body({ rowProps, emptyMessage = 'No results.' }: BodyProps) { const { table } = useDataTable(); + + // Automatically determine if pagination should be used + // Use pagination if getPaginationRowModel is defined (pagination is configured) const rows = - rowModel === 'pagination' + typeof table.getPaginationRowModel === 'function' ? table.getPaginationRowModel().rows : table.getRowModel().rows; if (!rows.length) { return ( - - + {emptyMessage} - - + + ); } return ( <> - {rows.map(row => - renderRow ? ( - renderRow(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 { cellClassName?: string })?.cellClassName) ?? - ''; + (cell.column.columnDef.meta as ColumnMeta | undefined)?.cellClassName ?? ''; return ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} - + ); })} - - ) - )} + + ); + })} ); } -const DataTableCompound = Object.assign(DataTableRoot, { +const DataTable = Object.assign(DataTableRoot, { Toolbar, Title, Pagination, ViewOptions, - Table: DataTableTable, - Header: DataTableHeader, - FilterRow: DataTableFilterRowCompound, - Body: DataTableBody, + Table, + Header, + FilterRow, + Body, + ColumnHeader, + ActionOverlayCell, }); -export { DataTableCompound as DataTable }; +export { DataTable }; diff --git a/platform/ui-next/src/components/DataTable/FilterRow.tsx b/platform/ui-next/src/components/DataTable/FilterRow.tsx index 54b5f03c5cd..fd4b29e5e36 100644 --- a/platform/ui-next/src/components/DataTable/FilterRow.tsx +++ b/platform/ui-next/src/components/DataTable/FilterRow.tsx @@ -1,58 +1,52 @@ -import * as React from 'react' -import { TableRow, TableCell } from '../Table' -import { Input } from '../Input' -import { Button } from '../Button' +import * as React from 'react'; +import { TableRow, TableCell } from '../Table'; +import { Input } from '../Input'; -import { useDataTable } from './context' +import { useDataTable } from './context'; -type Props = { - excludeColumnIds?: string[] - resetCellId?: string - onReset?: () => void - renderCell?: (opts: { columnId: string; value: unknown; setValue: (v: unknown) => void }) => React.ReactNode - inputClassName?: string -} +export type FilterRowProps = { + excludeColumnIds?: string[]; + renderFilterCell?: (opts: { + columnId: string; + value: unknown; + setValue: (v: unknown) => void; + }) => React.ReactNode; +}; -export function DataTableFilterRow({ - excludeColumnIds = [], - resetCellId, - onReset, - renderCell, - inputClassName = 'h-7 w-full', -}: Props) { - const { table } = useDataTable() - const cols = table.getVisibleLeafColumns() +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) + + {cols.map(col => { + const id = col.id; + const value = table.getColumn(id)?.getFilterValue(); + const setValue = (v: unknown) => table.getColumn(id)?.setFilterValue(v); - if (resetCellId && id === resetCellId) { - return ( - - - - ) + if (excludeColumnIds?.includes(id)) { + return ; } - if (excludeColumnIds.includes(id)) { - return + const customRender = renderFilterCell?.({ columnId: id, value, setValue }); + + if (customRender) { + return {customRender}; } + // Default cell rendering return ( - {renderCell ? ( - renderCell({ columnId: id, value, setValue }) - ) : ( - setValue(e.target.value)} className={inputClassName} /> - )} + 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 index e2b13f7dd85..6005de361f5 100644 --- a/platform/ui-next/src/components/DataTable/Pagination.tsx +++ b/platform/ui-next/src/components/DataTable/Pagination.tsx @@ -10,11 +10,11 @@ import { Icons } from '../Icons'; import { useDataTable } from './context'; /** - * DataTablePagination + * Pagination * Renders "start-end of total" and ghost chevron buttons for prev/next. * Uses the TanStack table instance from DataTable context. */ -export function DataTablePagination() { +export function Pagination() { const { table } = useDataTable(); const { pageIndex, pageSize } = table.getState().pagination ?? { pageIndex: 0, pageSize: 50 }; diff --git a/platform/ui-next/src/components/DataTable/Title.tsx b/platform/ui-next/src/components/DataTable/Title.tsx index b4c84b7a6c3..31107fcb01a 100644 --- a/platform/ui-next/src/components/DataTable/Title.tsx +++ b/platform/ui-next/src/components/DataTable/Title.tsx @@ -1,6 +1,5 @@ -import * as React from 'react' +import * as React from 'react'; -export function DataTableTitle({ children }: { children?: React.ReactNode }) { - return
{children}
+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 index 85cced8b91c..9c2bc3b5ba8 100644 --- a/platform/ui-next/src/components/DataTable/Toolbar.tsx +++ b/platform/ui-next/src/components/DataTable/Toolbar.tsx @@ -1,6 +1,5 @@ -import * as React from 'react' +import * as React from 'react'; -export function DataTableToolbar({ children }: { children?: React.ReactNode }) { - return
{children}
+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 index 7bc49a06ff2..fdd8d5a478f 100644 --- a/platform/ui-next/src/components/DataTable/ViewOptions.tsx +++ b/platform/ui-next/src/components/DataTable/ViewOptions.tsx @@ -1,46 +1,49 @@ -import * as React from 'react' -import { Button } from '../Button' +import * as React from 'react'; +import { Button } from '../Button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem, -} from '../DropdownMenu' +} from '../DropdownMenu'; -import { useDataTable } from './context' +import { useDataTable } from './context'; +import type { ColumnMeta } from './types'; -type Props = { - getLabel?: (columnId: string) => string - canHide?: (columnId: string) => boolean - buttonText?: string -} +type ViewOptionsProps = { + buttonText?: string; +}; + +export function ViewOptions({ buttonText = 'View' }: ViewOptionsProps) { + const { table } = useDataTable(); + const columns = table.getAllColumns().filter(c => c.getCanHide()); -export function DataTableViewOptions({ - getLabel = (id) => id, - canHide = () => true, - buttonText = 'View', -}: Props) { - const { table } = useDataTable() - const columns = table.getAllColumns().filter((c) => c.getCanHide() && canHide(c.id)) return ( - - {columns.map((column) => ( - column.toggleVisibility(!!v)} - className="capitalize" - > - {getLabel(column.id)} - - ))} + {columns.map(column => { + const meta = (column.columnDef.meta as ColumnMeta | undefined) ?? undefined; + const label = meta?.label ?? column.id; + return ( + column.toggleVisibility(!!v)} + className="capitalize" + > + {label} + + ); + })} - ) + ); } diff --git a/platform/ui-next/src/components/DataTable/context.tsx b/platform/ui-next/src/components/DataTable/context.tsx index 34dfd472d8c..3f964a248fe 100644 --- a/platform/ui-next/src/components/DataTable/context.tsx +++ b/platform/ui-next/src/components/DataTable/context.tsx @@ -1,35 +1,18 @@ -import * as React from 'react' -import type { - ColumnFiltersState, - RowSelectionState, - SortingState, - Table as RTable, - VisibilityState, - PaginationState, -} from '@tanstack/react-table' +import * as React from 'react'; +import type { Table } from '@tanstack/react-table'; export type DataTableContextValue = { - table: RTable - sorting: SortingState - setSorting: React.Dispatch> - columnVisibility: VisibilityState - setColumnVisibility: React.Dispatch> - rowSelection: RowSelectionState - setRowSelection: React.Dispatch> - columnFilters: ColumnFiltersState - setColumnFilters: React.Dispatch> - pagination: PaginationState - setPagination: React.Dispatch> - resetFilters: () => void -} + table: Table; +}; -const DataTableContext = React.createContext | null>(null) +const DataTableContext = React.createContext | null>(null); export function useDataTable() { - const ctx = React.useContext(DataTableContext) - if (!ctx) throw new Error('useDataTable must be used within a provider') - return ctx as DataTableContextValue + const ctx = React.useContext(DataTableContext); + if (!ctx) { + throw new Error('useDataTable must be used within a provider'); + } + return ctx as DataTableContextValue; } -export { DataTableContext } - +export { DataTableContext }; diff --git a/platform/ui-next/src/components/DataTable/index.ts b/platform/ui-next/src/components/DataTable/index.ts index dec5f52d4dd..2597c12b4e1 100644 --- a/platform/ui-next/src/components/DataTable/index.ts +++ b/platform/ui-next/src/components/DataTable/index.ts @@ -2,10 +2,6 @@ export { DataTable } from './DataTable'; export { useDataTable } from './context'; -// Shared building blocks for columns/cells -export { DataTableColumnHeader } from './ColumnHeader'; -export { DataTableActionOverlayCell } from './ActionOverlayCell'; - // Export is no longer needed. Instead use DataTable compound component. // export { DataTableFilterRow } from './FilterRow' // export { DataTableViewOptions } from './ViewOptions' 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..7dd64316b9d --- /dev/null +++ b/platform/ui-next/src/components/DataTable/types.ts @@ -0,0 +1,20 @@ +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; +}; diff --git a/platform/ui-next/src/components/StudyList/StudyListTypes.ts b/platform/ui-next/src/components/StudyList/StudyListTypes.ts index 20c355f030d..510dc2fef36 100644 --- a/platform/ui-next/src/components/StudyList/StudyListTypes.ts +++ b/platform/ui-next/src/components/StudyList/StudyListTypes.ts @@ -1,6 +1,7 @@ import type { WorkflowId } from './WorkflowsInfer'; export type StudyRow = { + studyInstanceUid: string; patient: string; mrn: string; studyDateTime: string; @@ -11,4 +12,3 @@ export type StudyRow = { /** Optional, data-driven list of available workflows for this study (immutable) */ workflows?: readonly WorkflowId[]; }; - diff --git a/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx index 643834a7c96..4fb8d57c88d 100644 --- a/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx +++ b/platform/ui-next/src/components/StudyList/columns/defaultColumns.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import type { ColumnDef } from '@tanstack/react-table'; -import { DataTableColumnHeader } from '../../DataTable'; +import { DataTable } from '../../DataTable'; import { Icons } from '../../Icons'; -import { Button } from '../../Button'; import type { StudyRow } from '../StudyListTypes'; import { StudyListActionsCell } from '../components/StudyListActionsCell'; import { tokenizeModalities } from '../../../lib/filters'; @@ -11,12 +10,7 @@ export function defaultColumns(): ColumnDef[] { return [ { accessorKey: 'patient', - header: ({ column }) => ( - - ), + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('patient')}
, meta: { label: 'Patient', @@ -27,12 +21,7 @@ export function defaultColumns(): ColumnDef[] { }, { accessorKey: 'mrn', - header: ({ column }) => ( - - ), + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('mrn')}
, meta: { label: 'MRN', @@ -43,12 +32,8 @@ export function defaultColumns(): ColumnDef[] { }, { accessorKey: 'studyDateTime', - header: ({ column }) => ( - - ), + enableSorting: false, + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('studyDateTime')}
, sortingFn: (a, b, colId) => { const norm = (s: string) => new Date(s.replace(' ', 'T')).getTime() || 0; @@ -63,16 +48,13 @@ export function defaultColumns(): ColumnDef[] { }, { accessorKey: 'modalities', - header: ({ column }) => ( - - ), + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('modalities')}
, filterFn: (row, colId, filter) => { const selected = Array.isArray(filter) ? (filter as string[]) : []; - if (!selected.length) return true; + 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())); @@ -86,15 +68,14 @@ export function defaultColumns(): ColumnDef[] { }, { accessorKey: 'description', - header: ({ column }) => ( - - ), + header: ({ column }) => , cell: ({ row }) => { const description = row.getValue('description') as string; - return
{description || 'No Description'}
; + return ( +
+ {description || 'No Description'} +
+ ); }, meta: { label: 'Description', @@ -105,12 +86,7 @@ export function defaultColumns(): ColumnDef[] { }, { accessorKey: 'accession', - header: ({ column }) => ( - - ), + header: ({ column }) => , cell: ({ row }) =>
{row.getValue('accession')}
, meta: { label: 'Accession', @@ -121,31 +97,7 @@ export function defaultColumns(): ColumnDef[] { }, { accessorKey: 'instances', - header: ({ column }) => { - const sorted = column.getIsSorted() as false | 'asc' | 'desc'; - const SortIcon = sorted === 'asc' - ? Icons.SortingNewAscending - : sorted === 'desc' - ? Icons.SortingNewDescending - : Icons.SortingNew; - return ( -
-
- ); - }, + header: ({ column }) => , cell: ({ row }) => { const value = row.getValue('instances') as number; return
{value}
; @@ -153,6 +105,13 @@ export function defaultColumns(): ColumnDef[] { sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), meta: { label: 'Instances', + headerContent: ( +