Study list resource actions: series archive download and study/series removal
## 1. Overview Two capabilities, both gated on the Sonador ACL system, both surfaced from the study list: 1. **Series archive download** — export a single series as a `.zip`, available when the user has the `view` permission on the study or the series. 2. **Study and series removal** — permanently delete a study or a series from Orthanc, available when the user has the `remove` permission on the study or the series. They ship together because they share one new surface: a **series Actions menu** in the expanded study drawer, which is where both series-scoped operations live. Three things change from a user's point of view: * Selecting a series in the drawer reveals an **Actions** menu (vertical-dots trigger) at the far right of the Metadata panel header, offering **Download Series** and **Remove Series**, each shown only when the user's permissions allow it. * The study-list per-row Actions menu gains **Remove Study**, and the bulk-actions toolbar gains a **Remove** button for the current selection. * Every removal is confirmed through a blocking overlay that names the resource and states what will be destroyed, because removal is irreversible. Scoping headline: **the series download half is a UI-only change** — the service layer, the endpoint, the job model, the progress reporting, and the notifications all already exist and are unused (§2.1). **The removal half is blocked** on oak-tree/medical-imaging/orthanc-sonador#57, which adds the DICOMweb removal endpoints; no removal API exists today (§2.4). The two halves are separable and download can be built and merged first. --- ## 2. Background and current state ### 2.1 Series archive download is already built everywhere except the UI oak-tree/medical-imaging/ohif-viewers#52 delivered `ArchiveDownloadService` (`platform/core/src/services/ArchiveDownloadService/`) as a module singleton with a bounded job queue, streaming progress, cancellation, de-duplication, and a notification layer. **Series jobs are a first-class case throughout it:** * `enqueueSeries({ server, StudyInstanceUID, SeriesInstanceUID, descriptor })` exists and requests `GET {server.wadoRoot}/series/{uid}/archive`. * The job model carries `kind: 'series'` plus `SeriesNumber`, `SeriesDescription`, and `Modality`. * `DownloadsMenu.js` renders a series row differently via `renderSeriesLine` — `"Series {SeriesNumber}: {SeriesDescription|Modality}"`. * `archiveNotifications.ts` has `describeSeries` and sets `seriesInstanceUID` on the notices. * De-duplication keys series jobs on `SeriesInstanceUID`, so a series export and a study export of its parent can run concurrently. `platform/viewer/src/api/ext.js` already exposes the adapter: ```js export const fetchDownloadSeries = (server, seriesId, descriptor) => { const duplicate = !!ArchiveDownloadService.getActiveJobForResource(seriesId); const job = ArchiveDownloadService.enqueueSeries({ server, StudyInstanceUID: descriptor?.StudyInstanceUID, SeriesInstanceUID: seriesId, descriptor: descriptor || {}, }); notifyArchivesQueued(duplicate ? { alreadyQueued: 1 } : { queued: [job] }); return job; } ``` **It has no caller.** The only archive call sites in the app are study-level: the per-row `download` menu item in `SelectAndSettingsAndExpandCell.js` and `handleDownloadSelectedStudies` in `StudiesTableActions.js`. Adding the menu item is the whole of the download work. The server endpoint `GET {dicomweb_root}/series/{SeriesInstanceUID}/archive` (`SeriesDICOMDownloadView`, `sonador_orthanc/web/download.py`) exists, works, and is covered by functional tests in `sonador-client` `ftests/tests_download.py`. ### 2.2 The expanded drawer, and where the Actions menu goes The drawer is `platform/viewer/src/components/studyList/StudyItemExpandedNG/StudyItemExpandedNG.js`, rendered by `StudiesTable.js` as an extra `<tr>` under an expanded row: ```jsx {isExpanded && ( <tr> <td colSpan={row.getVisibleCells().length} className={styles.expandedContainer}> <StudyItemExpandedNG studyId={studyId} study={row.original} /> </td> </tr> )} ``` with `const studyId = isWorkList ? row.original.StudyInstanceUID.value : row.id;` **A correction to the originating note, which matters for placement:** the "Metadata" panel is **not a table of series rows**. It is a key/value attribute panel (`.../StudyItemExpandedNG/components/Metadata/Metadata.js`) showing the attributes of the *currently selected* series and its study. Series selection happens in the horizontal thumbnail rail above it. So "at the far right of the Metadata table when a series is selected" resolves to: **the far right of the Metadata panel header**, visible only while a series (not the STUDY tile) is selected. That is the placement specified in FR-1. The header today: ```jsx <div className={styles.contentMetadata}> <div className={styles.contentMetadataHeader} ref={ref}> <FiltersIcon className={styles.metadataFilterIcon} onClick={() => setIsMetadataDropdownOpen((prevState) => !prevState)} /> <p className={styles.contentMetadataTitle}>Metadata</p> {isMetadataDropdownOpen && ( ...settings dropdown... )} </div> {metadataSettings.map(({ title, options }) => ( ...groups... ))} </div> ``` `.contentMetadataHeader` is `display: flex; align-items: center; column-gap: 8px; position: relative;` with **no `justify-content: space-between`** — a right-aligned trigger needs `margin-left: auto`. Selection state lives in the parent, not in `Metadata`: ```js const [selectedThumbnail, setSelectedThumbnail] = useState(null); const [selectedStudy, setSelectedStudy] = useState(studyId); ``` `Metadata` receives it as `selectedSeries`: ```jsx <Metadata study={study} seriesCount={data?.[0]?.thumbnails?.length ?? 0} selectedSeries={selectedThumbnail} /> ``` Two existing defects in this area to be aware of, neither of which this work must fix but both of which it must not trip over: * `Metadata.propTypes.selectedSeries` is malformed (`PropTypes.oneOfType(null, PropTypes.string)` — wrong arity, wrong type; the prop is an object). * `TabletMobileTabs.js` renders `<Metadata study={study} />` with **no `selectedSeries`**, so on the tablet/mobile path a series is never "selected" from `Metadata`'s point of view. The Actions menu will therefore not appear there. See FR-4 and §8. The thumbnail object shape comes from `processThumbnail` in `platform/viewer/src/hooks/useSeriesMetadata.js`: ```js { imageId, altImageText, displaySetInstanceUID, SeriesInstanceUID, SeriesDescription, numImageFrames, SeriesNumber } ``` Note it carries **neither `Modality` nor `StudyInstanceUID`** — both are needed for the download descriptor and must be sourced elsewhere (§5.2). ### 2.3 The dropdown precedent to copy The "Open in Viewer" split button and its chevron dropdown, in `StudyItemExpandedNG.js`, is the styling reference the originating note points at. It uses **Radix** (`import { DropdownMenu } from 'radix-ui';`), with shared classes from `platform/viewer/src/styles/radixUi.module.scss` and local classes from `StudyItemExpandedNG.module.scss`: ```jsx <DropdownMenu.Root> <DropdownMenu.Trigger asChild> <button className={classNames(radixStyles.IconButton, styles.moreViewersIconButton)} aria-label="Open In Viewer Links"> <ChevronDownIcon height={25} width={25} /> </button> </DropdownMenu.Trigger> <DropdownMenu.Portal> <DropdownMenu.Content className={classNames(radixStyles.Content, styles.moreViewersContentContainer)} sideOffset={5}> <DropdownMenu.Item className={radixStyles.DropdownItem} onClick={...}> <EyeIcon className={classNames(radixStyles.icon15x, radixStyles.DropDownSvgIcon)} /> <span>View in Sonador / OHIF</span> </DropdownMenu.Item> ... </DropdownMenu.Content> </DropdownMenu.Portal> </DropdownMenu.Root> ``` `radix-ui@^1.4.2` and `@radix-ui/react-icons@^1.3.2` are hoisted from `platform/ui-next/package.json`; `DotsVerticalIcon` is available from the same icon package. The vertical-dots asset also exists as `platform/ui/src/elements/Svg/svgs/dots.svg` (`viewBox="0 0 4 16"`, three circles, `fill="white"`), already imported in `SelectAndSettingsAndExpandCell.js` as the per-row kebab trigger. Styling caveat worth knowing before copying: `.moreViewersContentContainer { background-color: white; }` combined with `radixStyles.DropdownItem { color: var(--violet-11); }` renders light-on-light against the drawer's dark chrome. Copying it verbatim inherits that. See AR-4. ### 2.4 There is no removal API today, and no `remove` anywhere in the frontend * `platform/viewer/src/api/ext.js` has no study or series delete helper. Its only `DELETE` is `removeSeriesTag`. The only other `DELETE`s in `platform/viewer/src/api/` are `deleteAclUserPermission` / `deleteAclGroupPermission` in `share.js`, which delete ACL policy rows, not imaging data. * The gateway's only delete route is keyed by **Orthanc public ID** (`DELETE /studies/{orthanc-id}`, `DELETE /series/{orthanc-id}`), which the frontend does not hold. * No frontend code reads `perms.Remove` or `activeServer.perms.remove`. oak-tree/medical-imaging/orthanc-sonador#57 adds the endpoints this feature calls: ``` DELETE {wadoRoot}/studies/{StudyInstanceUID}/manage DELETE {wadoRoot}/series/{SeriesInstanceUID}/manage ``` Both answer **307** with a `Location` pointing at the Orthanc-native resource endpoint; the client follows the redirect, which performs the delete and answers 200 with Orthanc's JSON. **The removal half of this issue cannot be integration-tested until that lands.** ### 2.5 Permission plumbing as it exists Two permission surfaces, with **different casing** — a frequent source of bugs here: | Surface | Source | Casing | Keys observed | |---|---|---|---| | Server / global | `activeServer.perms`, passed through verbatim from `GET /visionaire/api/pacs?output-type=ohif` | lowercase | `query`, `upload`, `worklist`, `tag`, `tag_modify`, `devices_list`, `devices_list_modify`, `view`, `modify`, `remove`, `comment_edit`, `comment_view`, `acl`, plus `is_superuser`, `is_staff` | | Per-resource | `GET {wadoRoot}/studies/{uid}/resource-acl` via `fetchStudyAclPermissions` | PascalCase | `View`, `Modify`, `Remove`, `CommentEdit`, `CommentView`, `ACL`, alongside `Level`, `ID`, `StudyInstanceUID` | The established gating pattern, from `SelectAndSettingsAndExpandCell.js` — optimistic from the server perm, refined by a lazy per-resource fetch on menu open, cached both in row state and on `DicomMetadataStore`: ```js const [aclDownload, setAclDownload] = useState(activeServer?.perms?.view || studyMeta?.perms?.View || false); ... const onDropdownClick = async (e) => { e.stopPropagation(); if (activeServer && StudyInstanceUID && !resourceAclLoaded && (!aclDownload || !aclShare)) { const resourcePerms = await fetchStudyAclPermissions(activeServer, StudyInstanceUID); DicomMetadataStore.updateStudyMetadata(_.omit(resourcePerms, 'Level')); if (!aclDownload && resourcePerms?.perms?.View) { setAclDownload(resourcePerms.perms.View); } if (!aclShare && resourcePerms?.perms?.ACL) { setAclShare(resourcePerms.perms.ACL); } setResourceAclLoaded(true); } } ``` **Two facts that shape FR-8 and FR-9:** * **`activeServer.perms.remove` is wildcard-only.** `PacsImagingServer.server_perms()` sets a resource permission `True` only for a superuser or for a group policy that has `remove=True` **and** `resource == '*'`. A user holding a per-study or per-series `remove` grant reads `activeServer.perms.remove === false`. Gating a per-resource control solely on the server flag would hide it from exactly the users the ACL system exists to serve. * **There is no series-level ACL fetch in the frontend today.** Every ACL call is study-scoped (`/studies/{uid}/resource-acl`, `/studies/{uid}/acl/user|group`). Series comments inherit the *study's* `aclComments`. But the gateway **does** expose `GET {wadoRoot}/series/{SeriesInstanceUID}/resource-acl` (registered in `init_auth_endpoints`, returning `Level: "Series"` and a `SeriesInstanceUID` key), and the ACL models carry series-granular grants (`UserSeriesAuth` / `GroupSeriesAuth`). Since the originating note requires series permissions to count, a series ACL fetch must be added (§5.1). ### 2.6 Destructive-action precedent There is no shared confirmation component and no `UIDialogService`-based confirm helper. The strongest and most recent precedent is the **blocking in-modal overlay** used for "Clear Storage" in `DownloadManagerModal.js`: ```jsx {/* Blocking confirmation for Clear Storage: covers the whole dialog (including its close control) so nothing else is clickable until the user confirms or cancels. */} {confirmingClear && ( <div className={styles.confirmOverlay}> <div className={styles.confirmCard}> <p className={styles.confirmPrompt}>{t('Remove all offline studies?')} ({totalCachedCount})</p> <div className={styles.confirmActions}> <button className={styles.clearAllConfirm} onClick={handleClearAll}> <Icon name="trash" /> {t('Clear All')}</button> <button className={styles.clearAllCancel} onClick={() => setConfirmingClear(false)}> <Icon name="times" /> {t('Cancel')}</button> </div> </div> </div> )} ``` Counter-examples that must **not** be followed: ACL row deletion in `StudiesTableShareModal.js`, per-row "Remove Offline Copy" in `DownloadManagerModal.js`, and the `remove-offline` menu item all delete with no confirmation at all. Those are reversible local operations; this is not. ### 2.7 Notifications `uiNotificationService.show({...})` (`platform/core/src/services/UINotificationService/index.ts`) accepts `title`, `message`, `type` (`success | error | info | warning | loading`), `duration`, `position`, `autoClose`, `action`, `id`, `promise`/`promiseMessages`, `log`, `source`, `studyInstanceUID`, `seriesInstanceUID`, `details`, `error`. `autoClose: false` is sticky. `error` and `warning` write through to the unified log automatically; `info` and `success` only with `log: true`. A message-only call promotes `message` to `title`, so pass both. **`seriesInstanceUID` is already a first-class field** — series-scoped notifications need no new plumbing. --- ## 3. Functional requirements ### Series Actions menu **FR-1 — A series Actions menu appears at the far right of the Metadata panel header.** Trigger is a vertical-dots icon button, right-aligned within `.contentMetadataHeader`. It renders **only when a series is selected** — that is, when `Metadata` receives a non-null `selectedSeries`. Selecting the STUDY tile hides it. **FR-2 — The menu renders only when it has at least one item.** If the user has neither `view` nor `remove` on the resource, the trigger itself is absent — not a disabled button, not an empty menu. **FR-3 — Download Series.** Available when the user has `view` on the study **or** on the series. Selecting it enqueues a series archive job through the existing `fetchDownloadSeries` adapter, which raises the queued notification and surfaces the job in the Downloads menu with progress, cancel, and completion handling — all existing behavior, inherited by calling the adapter. **FR-4 — Remove Series.** Available when the user has `remove` on the study **or** on the series. Selecting it opens the removal confirmation (FR-10). On confirmation it issues `DELETE {wadoRoot}/series/{SeriesInstanceUID}/manage`. **FR-5 — Actions menu styling matches the Open in Viewer dropdown.** Radix `DropdownMenu`, `radixStyles.Content` / `radixStyles.DropdownItem`, icons via `radixStyles.DropDownSvgIcon`, per §2.3 and AR-4. ### Study removal **FR-6 — Remove Study in the per-row Actions menu.** `SelectAndSettingsAndExpandCell.js` gains a `remove-study` item, rendered last, after `share` and `create-worklist`, with the `trash-bin.svg` icon. Gated on `aclRemove` (FR-8). Selecting it opens the removal confirmation. **FR-7 — Remove in the bulk actions toolbar.** `StudiesTableActions.js` gains a **Remove** button beside Download and Save Offline Copy, removing every selected study. It is disabled while no rows are selected, and its confirmation names the count and enumerates the studies (FR-11). On completion it clears the selection, matching the commit-clears-selection convention of the other bulk actions. ### Permissions **FR-8 — Study-level `remove` gating follows the established lazy-ACL pattern.** `aclRemove` initialises to `activeServer?.perms?.remove || studyMeta?.perms?.Remove || false` and is refined by the existing `fetchStudyAclPermissions` call on menu open, reading `resourcePerms.perms.Remove`. This is a small extension of the block already present in `onDropdownClick` — the fetch is shared, not duplicated, and its `resourceAclLoaded` guard must be widened to account for the new signal. **FR-9 — Series-level permissions are fetched from the series resource-acl endpoint.** A new `fetchSeriesAclPermissions(server, seriesId)` calls `GET {server.wadoRoot}/series/{SeriesInstanceUID}/resource-acl` and returns the same shape as the study variant (`Level: "Series"`, `ID`, `SeriesInstanceUID`, `perms: { View, Modify, Remove, CommentEdit, CommentView, ACL }`). The series menu's effective permission is `study-or-server grant OR series grant` — a series grant can authorise where the study grant does not, and the reverse holds by inheritance. The fetch is lazy (on menu open), cached per series for the lifetime of the drawer, and re-issued when the selected series changes. **FR-10 — The bulk Remove button gates on the server permission.** It renders when `activeServer?.perms?.remove` is true, matching how bulk Download gates on `perms.view`. Per-study refinement is impractical for a selection of arbitrary size. **Consequence, stated so it is a decision and not a surprise:** a user holding only scoped `remove` grants will not see the bulk button and must remove studies one at a time from the per-row menu, which does refine per resource. The server is the authority either way — a 403 on any individual study is surfaced per FR-13. ### Confirmation and outcome **FR-11 — Removal is confirmed by a blocking overlay that names the resource.** The overlay covers its container including any close control, so nothing else is clickable until the user confirms or cancels. It states: * for a study — patient name and ID, study description, accession number, study date, and the number of series and instances that will be destroyed; * for a series — the same study identification plus `Series {SeriesNumber}: {SeriesDescription}`, the modality, and the instance count; * for a bulk removal — the count, and a scrollable list of the studies by patient name and description. It states plainly that the data is permanently deleted from the imaging server and cannot be recovered. Actions are **Remove** (destructive styling) and **Cancel**; Cancel is the default focus. Follow the `DownloadManagerModal` "Clear Storage" treatment (§2.6). **FR-12 — A successful removal raises a success notification and refreshes the affected views.** The notification names the removed resource. The study-list query cache is invalidated so the row disappears; an open drawer for a removed study closes; a removed series disappears from the thumbnail rail and selection falls back to the STUDY tile; any row selection including the removed study is cleared. **FR-13 — A failed removal is visible and diagnosable.** `type: 'error'`, `autoClose: false`, carrying `studyInstanceUID` (and `seriesInstanceUID` where applicable) and `details: { url, status, body }`. Errors write through to the unified log automatically. In a bulk removal, each failure raises its own notification and the successful removals still commit — a partial failure is not rolled back, and the summary notification reports `{n} of {m} studies removed`. **FR-14 — A removed study's offline copy is removed too.** If `LocalCacheService` holds a cached copy of a study that has just been removed from the server, that copy is dropped as part of the removal. Rationale: the cache exists to mirror server-side data for offline review; retaining a local copy of data an authorised user has deliberately destroyed is both surprising and a data-retention hazard. If no cached copy exists this is a no-op. Cache removal failure does not fail the removal; it raises a warning. **FR-15 — Removal is idempotent from the user's point of view.** A second removal attempt for an already-removed resource (double-click, stale row) produces the same terminal state without an error notification for the 404. The confirm button is disabled while a removal is in flight. --- ## 4. Architectural requirements **AR-1 — Removal goes through new `ext.js` helpers, and only through them.** Add `removeStudy(server, StudyInstanceUID)` and `removeSeries(server, SeriesInstanceUID)` to `platform/viewer/src/api/ext.js`, alongside the archive helpers, issuing `DELETE` against the `/manage` routes with `Authorization: Bearer ${getAuthToken()}`. Every removal path calls these; no component issues its own `fetch`. This mirrors the convention `ext.js` already documents for archive export ("the ONLY entry points to archive export in the viewer"). **AR-2 — Follow the redirect; do not disable it.** The gateway answers **307**, which preserves the method, and `fetch`'s default `redirect: 'follow'` re-issues the `DELETE` at the `Location`. Do not set `redirect: 'manual'`, and do not attempt to read the `Location` header and issue the second request by hand — a manual redirect response is opaque and its headers are unreadable. Do not set an explicit `redirect` option at all; reproduce the request shape the archive helpers already use against the same gateway, since that shape is proven in production. **AR-3 — Reuse `ArchiveDownloadService` unchanged for series download.** Call `fetchDownloadSeries` from the menu item. Do not add a series branch to `ArchiveDownloadService`, do not add a new command, and do not build a parallel path — the service, the queue, the Downloads menu rendering, and the notifications already handle `kind: 'series'` end to end. The only new code is the call site and its descriptor. **AR-4 — The series Actions menu uses Radix, matching the drawer; the per-row study menu keeps the legacy `Dropdown`.** These two menus are built on different libraries today: `StudyItemExpandedNG.js` uses Radix `DropdownMenu`, while `SelectAndSettingsAndExpandCell.js` uses `@ohif/ui`'s `Dropdown` with an `options` array of `{ id, Label, onClick }`. **Do not unify them as part of this work** — each new item follows the convention of the menu it joins. Radix in the drawer (per the originating note's styling instruction), the `options` array in the row kebab. See §6. **AR-5 — Correct the copied dropdown's colour treatment rather than inheriting it.** `.moreViewersContentContainer` sets `background-color: white` while `radixStyles.DropdownItem` sets `color: var(--violet-11)`, which is light-on-light against the drawer's dark chrome. The new menu's content class must set its own surface and text colours from the existing drawer tokens. Do not introduce new colour literals, and do not modify `.moreViewersContentContainer` or `radixUi.module.scss` — the Open in Viewer dropdown's appearance must not change. **AR-6 — One confirmation component, used by all three removal entry points.** Build a single `RemoveResourceConfirm` component under `StudyListNG/components/` that takes the resource descriptor(s) and `onConfirm` / `onCancel`, and render it from the drawer, the per-row menu, and the bulk toolbar. Three hand-rolled overlays would drift. Model it on the `DownloadManagerModal` Clear Storage overlay (§2.6), not on `UIDialogService`, which has no confirmation content component suited to this and is a viewer-side pattern. **AR-7 — Removal results flow through the existing react-query cache, not a bespoke refresh.** Study rows are served by `useStudies.js` (`@tanstack/react-query`, in-memory, no persister). Invalidate the relevant query keys on success and let the table re-render. Do not reach into table state to splice a row out, and do not force a page reload. **AR-8 — Series permission state lives in the drawer, not in `Metadata`.** `StudyItemExpandedNG.js` already owns `selectedThumbnail` and the study-level `aclView` / `aclComments` signals and its `DicomMetadataStore.STUDY_UPDATED` subscription. Resolve the series ACL there and pass the two booleans down to `Metadata` as props. `Metadata` stays a presentation component and gains no data fetching. **AR-9 — Identifier hygiene.** Four UI identifiers in this codebase already read as "download" or "remove" and none may be reused or shadowed: the study-list Action-menu `id: 'download'` (archive export), the viewer More-menu `id: 'Download'` (canvas screenshot export, unrelated), the offline-cache commands `goOffline` / `removeOffline` / `cancelStudyDownload`, and the Action-menu `id: 'remove-offline'` (offline cache eviction). New identifiers are scoped to resource removal — `remove-study`, `remove-series` — and every new string must make clear whether it means "delete from the server" or "evict from this browser". `Remove Study` versus `Remove Offline Copy` sitting in the same menu is the exact place this can go wrong. **AR-10 — Add i18n to the drawer components this work touches.** `StudyItemExpandedNG.js`, `Metadata.js`, `Comments.js`, and `TabletMobileTabs.js` hard-code English today. New user-facing strings introduced here go through `useTranslation('StudyList')` with the English string as the key, matching the study-list convention, and are added to `platform/i18n/src/locales/en-US/StudyList.json`. Do not retrofit the drawer's existing strings — that is unrelated churn. --- ## 5. Implementation specification ### 5.1 API layer — `platform/viewer/src/api/ext.js` Three additions, following the file's existing helper conventions (`urlUtil.urlJoin`, bearer token from `getAuthToken()`): | Helper | Request | Returns | |---|---|---| | `fetchSeriesAclPermissions(server, seriesId)` | `GET {server.wadoRoot}/series/{seriesId}/resource-acl` | parsed JSON — `{ Level: 'Series', ID, SeriesInstanceUID, perms: { View, Modify, Remove, CommentEdit, CommentView, ACL } }` | | `removeStudy(server, studyId)` | `DELETE {server.wadoRoot}/studies/{studyId}/manage` | resolves on 2xx; rejects with `{ url, status, body }` otherwise | | `removeSeries(server, seriesId)` | `DELETE {server.wadoRoot}/series/{seriesId}/manage` | same | `fetchSeriesAclPermissions` is a direct analogue of the existing `fetchStudyAclPermissions`. The removal helpers must capture the response body on failure so FR-13 can populate `details`, and must treat **404 as success** for FR-15 (the resource is already gone). ### 5.2 Series descriptor resolution `enqueueSeries` reads `SeriesNumber`, `SeriesDescription`, `Modality`, and `StudyInstanceUID` from the descriptor, but the thumbnail object (§2.2) carries only `SeriesInstanceUID`, `SeriesDescription`, `SeriesNumber`, `displaySetInstanceUID`, `numImageFrames`, `imageId`, `altImageText`. Assemble the descriptor in `StudyItemExpandedNG.js` from three sources: | Field | Source | |---|---| | `SeriesInstanceUID`, `SeriesNumber`, `SeriesDescription` | the selected thumbnail | | `Modality` | `DisplaySetService.getDisplaySetByUID(selectedThumbnail.displaySetInstanceUID)` — the same lookup `Metadata.js` already uses for attribute fallback | | `StudyInstanceUID` | the drawer's `studyId` prop | | `PatientName`, `PatientID`, `StudyDescription`, `StudyDate`, `AccessionNumber`, `ServiceEpisodeID` | the `study` prop, unwrapped the way `_getStudyDescriptor` in `SelectAndSettingsAndExpandCell.js` does (react-table cells arrive as `{ value, label, type }`) | Export and reuse `_getStudyDescriptor` rather than reimplementing the unwrapping — it already lives at the bottom of `SelectAndSettingsAndExpandCell.js` alongside `_getStudyInstanceUID`. ### 5.3 File-by-file **`platform/viewer/src/api/ext.js`** Add the three helpers from §5.1. Document `removeStudy`/`removeSeries` as the only removal entry points (AR-1) and note the 307-follow behavior (AR-2). **`.../StudyItemExpandedNG/StudyItemExpandedNG.js`** * Resolve series permissions (AR-8): on Actions-menu open, if not already resolved for the selected series, call `fetchSeriesAclPermissions` and combine with the existing study-level signals into `seriesAclView` and `seriesAclRemove`. Reset the cache when `selectedThumbnail.SeriesInstanceUID` changes. * Add `aclRemove` for the study, initialised from `activeServer?.perms?.remove || studyMeta?.perms?.Remove`, refreshed by the existing `DicomMetadataStore.STUDY_UPDATED` subscription. * Build the series descriptor per §5.2. * Pass `{ seriesAclView, seriesAclRemove, onDownloadSeries, onRemoveSeries, onActionsOpen }` down to `Metadata`. * Render `RemoveResourceConfirm` for a pending series removal. **`.../StudyItemExpandedNG/components/Metadata/Metadata.js`** and **`Metadata.module.scss`** * Render the Actions `DropdownMenu` in `.contentMetadataHeader`, right-aligned via `margin-left: auto` (§2.2), only when `selectedSeries` is non-null and at least one action is permitted (FR-1, FR-2). * Trigger: `DotsVerticalIcon` from `@radix-ui/react-icons`, matching the sibling `ChevronDownIcon` import in the drawer. Items: **Download Series** (`cloud-download.svg`) and **Remove Series** (`trash-bin.svg`), both already present in `platform/ui/src/elements/Svg/svgs/`. * Add `useTranslation('StudyList')` (AR-10). Fix the malformed `selectedSeries` propType while adding the new ones. * New content class per AR-5. Do not touch `radixUi.module.scss`. **`.../StudyListNG/components/SelectAndSettingsAndExpandCell/SelectAndSettingsAndExpandCell.js`** * Add `aclRemove` state and extend `onDropdownClick` to read `resourcePerms?.perms?.Remove` from the **existing** fetch. Widen the `resourceAclLoaded` short-circuit condition to include `!aclRemove` so the fetch still fires when only the remove signal is missing. * Add the `remove-study` option (last in the array) and its `filteredOptions` case gating on `aclRemove`. * Render `RemoveResourceConfirm` for a pending study removal; on confirm call `removeStudy`, then the FR-12 refresh and FR-14 cache eviction. **`.../StudyListNG/components/StudiesTableActions/StudiesTableActions.js`** * Add `handleRemoveSelectedStudies`: resolve each selected row's `StudyInstanceUID` via the shared `_getStudyInstanceUID` helper (which handles both the `row.id` path and the worklist `DicomMetadataStore.findStudy` path), collect descriptors, open the confirmation, and on confirm remove them with bounded concurrency, then `clearSelection()`. * Gate the button on `activeServer?.perms?.remove` (FR-10), disabled when the selection is empty. Mirror the structure of the adjacent `handleDownloadSelectedStudies`, and comment the distinction between removing from the server and removing an offline copy (AR-9). **`.../StudyListNG/components/RemoveResourceConfirm/RemoveResourceConfirm.js`** and **`.module.scss`** (new) Single confirmation component per AR-6 and FR-11. Props: `{ kind: 'study' | 'series' | 'studies', descriptor | descriptors, onConfirm, onCancel, isRemoving }`. Overlay geometry, card, and button treatment follow `DownloadManagerModal.module.scss`'s `.confirmOverlay` / `.confirmCard` / `.confirmActions` / `.clearAllConfirm` / `.clearAllCancel`. Strings via the `StudyList` namespace. **`platform/i18n/src/locales/en-US/StudyList.json`** New keys: `Actions`, `Download Series`, `Remove Series`, `Remove Study`, `Remove`, and the confirmation copy. `Download`, `Series`, `Series #`, and `Remove Offline Copy` already exist and must not be repurposed. ### 5.4 Tests Follow the existing unit-test setup (`UINotificationService.test.js`, `ArchiveDownloadService.test.js`). Cover: * `removeStudy` / `removeSeries` issue `DELETE` to the `/manage` routes with a bearer token, resolve on 200, treat 404 as success, and reject with `{ url, status, body }` on 500. * `fetchSeriesAclPermissions` requests the series `resource-acl` route and returns the parsed payload. * The effective series permission is the OR of the study/server grant and the series grant, in both directions. * `RemoveResourceConfirm` renders the resource descriptor, disables Remove while `isRemoving`, and calls back correctly for each of the three kinds. * Bulk removal with a mid-list failure commits the successes, raises one error notification per failure, and reports `{n} of {m}`. ### 5.5 Verification tasks for the implementer * **V-1** — Confirm `GET {wadoRoot}/series/{SeriesInstanceUID}/resource-acl` returns `Remove` in its `perms` object against a running server. The route is registered in `init_auth_endpoints` and the mapping table includes `remove -> "Remove"`, but only `View`, `ACL`, `CommentView`, and `CommentEdit` are read by any existing frontend code, so `Remove` has never been exercised from the browser. Record the answer here. * **V-2** — Confirm a cross-origin `DELETE` preflight succeeds from the OHIF origin. This is tracked as V-2 on oak-tree/medical-imaging/orthanc-sonador#57; the frontend cannot proceed past a failing preflight and the two verifications should be done together. * **V-3** — Confirm what the study list does with a study whose removal succeeded but whose react-query cache entry is refreshed from a stale gateway index. The Sonador cache is pruned asynchronously by the change-callback pipeline, so a refetch immediately after a delete may still return the row. If it does, FR-12 needs a brief optimistic exclusion; record the observed timing. --- ## 6. Refactor analysis This work does not migrate an established pattern, but it does land on top of a genuine pattern conflict, and the disposition needs to be explicit so the implementer does not "tidy" it. **Two dropdown implementations coexist in the study-list feature area:** | Component | Library | Item shape | Disposition | |---|---|---|---| | `SelectAndSettingsAndExpandCell.js` per-row kebab | `@ohif/ui` `Dropdown` | `options: [{ id, Label: () => JSX, onClick }]`, filtered by `filteredOptions` | **In scope.** Add `remove-study` following the existing array shape. Do not port to Radix. | | `StudyItemExpandedNG.js` Open in Viewer chevron | Radix `DropdownMenu` | `DropdownMenu.Item` children | **Reference implementation** for the new series Actions menu. Unchanged. | | New series Actions menu | Radix `DropdownMenu` | as above | **New.** Follows the drawer's convention, per the originating note. | | `DownloadsMenu.js` panel rows | Radix `DropdownMenu` with **plain `<button>` bodies** | not menu items, so the panel stays open on click | Unchanged. Different problem (rows must not dismiss); not a precedent for an action menu. | Normative convention going forward: **a menu follows the convention of the surface it lives on.** The drawer is Radix; the study-list row kebab is the legacy `Dropdown`. Converging them is worthwhile and is explicitly *not* this issue's job — doing it here would put a UI-library migration in the same merge request as an irreversible-delete feature. **Permission fetching** gains a second endpoint but not a second pattern: `fetchSeriesAclPermissions` is a direct analogue of `fetchStudyAclPermissions`, and the lazy-fetch-on-menu-open, cache-in-state discipline is unchanged. The one adjustment is that `SelectAndSettingsAndExpandCell.js`'s `resourceAclLoaded` guard must widen to include the new `aclRemove` signal — a one-line change to an existing condition, not a restructure. **`ext.js`** gains three helpers and keeps its role as the single API boundary. No existing helper's signature changes. --- ## 7. Acceptance criteria **Series download** - [x] Selecting a series in the drawer reveals an Actions menu at the far right of the Metadata panel header; selecting the STUDY tile hides it. - [x] **Download Series** enqueues a job that appears in the Downloads menu with the series line `Series {SeriesNumber}: {SeriesDescription}`, streams to completion, and saves a `.zip`. - [x] The queued and completed notifications name the patient, study, and series. - [x] Cancelling a series export mid-stream stops the transfer and saves no file. - [x] Requesting the same series export twice returns the existing job; no second row appears. - [x] A series export and an export of its parent study run concurrently and independently to completion. - [x] A user with `view` granted only on the series (not the study, not the server) sees Download Series and the export succeeds. **Removal** - [x] **Remove Series** removes the series; it disappears from the thumbnail rail, selection falls back to the STUDY tile, and the parent study remains when other series exist. - [x] Removing the last series of a study also removes the study from the list, matching Orthanc's cascade. - [x] **Remove Study** from the per-row Actions menu removes the study and its row disappears from the table. - [x] The bulk **Remove** button removes every selected study, clears the selection, and reports `{n} of {m} studies removed`. - [x] Every removal path shows the blocking confirmation naming the resource; nothing else in the surface is clickable until confirm or cancel; Cancel aborts with no request issued. - [x] The confirmation states the series and instance counts that will be destroyed, and that the deletion is permanent. - [x] Double-clicking Remove issues exactly one request; the confirm button is disabled while a removal is in flight. - [x] Removing an already-removed resource (404) reaches the same terminal state with no error notification. - [x] Removing a study that has an offline copy also drops the offline copy; the study-list row loses its cached styling and the Offline Storage dialog no longer lists it. - [x] A 403 produces a sticky error notification and an Issues-list entry carrying the request URL, HTTP status, and response body; the resource remains. - [x] A bulk removal where one study 403s commits the rest and raises one error notification for the failure. - [x] The `DELETE` follows the gateway's 307 and completes; a regression to a non-following request fails a test. **Permissions** - [x] A user with `view` but not `remove` sees Download Series and does not see Remove Series or Remove Study anywhere. - [x] A user with neither `view` nor `remove` on a series sees no Actions menu trigger at all — not a disabled button. - [x] A user holding `remove` only at series granularity sees Remove Series for that series and does not see Remove Study. - [x] A user holding `remove` only on a single study sees Remove Study in that row's menu; the bulk Remove button is absent (FR-10's documented consequence). - [x] Revoking `remove` and reopening the menu hides the item without a page reload. **Non-regression** - [x] Worklist rows, which resolve `StudyInstanceUID` via `DicomMetadataStore.findStudy` rather than `row.id`, show correct actions and remove correctly. - [x] The Open in Viewer button and its dropdown are visually and behaviourally unchanged. - [x] Existing Action-menu items (Download, Save Offline Copy, Remove Offline Copy, Share, Request review) are unchanged in behavior and ordering. - [x] "Remove Offline Copy" and "Remove Study" are unambiguous when both appear in the same menu. - [x] Opening and closing the drawer repeatedly does not leak ACL fetches, event subscriptions, or timers. - [x] `yarn build` completes clean and the new unit tests pass with no regression against the existing suite baseline. --- ## 8. Out of scope * **The removal endpoints themselves.** oak-tree/medical-imaging/orthanc-sonador#57. * **Audit logging of removals.** Nothing records deletes today, in any layer. Decided (2026-08-06) not to block this work on it; the gap is tracked under oak-tree/medical-imaging/imaging-development-env#78 and the insertion points are recorded on oak-tree/medical-imaging/orthanc-sonador#57. * **A removal control in the OHIF viewer's More menu.** Considered and excluded — removal is a study-list operation. * **A removal control in the drawer header beside Open in Viewer.** Study removal lives in the per-row menu and the bulk toolbar; the drawer's Actions menu is series-scoped. * **Instance-level download or removal.** Explicitly excluded by the maintainer: the need is met by series archives, and series metadata is inspectable from the viewer. * **A link to load the image/instance list.** Removed from scope 2026-08-06. * **Undo, soft delete, or a recycle bin.** Removal is Orthanc's hard delete. * **The series Actions menu on the tablet/mobile path.** `TabletMobileTabs.js` renders `<Metadata study={study} />` with no `selectedSeries`, so the menu will not appear there. Fixing that prop threading is a separate defect (§2.2) and should be filed on its own rather than folded in here. * **Unifying the two dropdown implementations** in the study-list feature area. See §6. * **Retrofitting i18n** to the drawer's existing hard-coded strings. Only newly-introduced strings are translated. * **Bulk series removal**, and mixed study/series selection. One series at a time, from its own drawer. --- ## 9. References * Origin: the series-actions note and scope changes on oak-tree/medical-imaging/imaging-development-env#64 (2026-08-06) * Tracking: oak-tree/medical-imaging/ohif-viewers#64 * Blocking API work: oak-tree/medical-imaging/orthanc-sonador#57 * Archive export service and Downloads menu (reused wholesale): #52 * Original viewer archive-download controls: #36 * Offline study cache (the adjacent feature whose vocabulary must not be collided with): #125 * Unified notifications and logging: #84, #85 * Study drawer origin: #43 * HIPAA audit logging (open, unimplemented): oak-tree/medical-imaging/imaging-development-env#78 * Reference files: * `platform/core/src/services/ArchiveDownloadService/{ArchiveDownloadService.ts,archiveNotifications.ts}` — job model, `enqueueSeries`, notification layer * `platform/viewer/src/api/ext.js` — `fetchDownloadSeries`, `fetchStudyAclPermissions`, helper conventions * `platform/viewer/src/components/studyList/StudyItemExpandedNG/StudyItemExpandedNG.js` — drawer, series selection, Open in Viewer dropdown, ACL signals * `.../StudyItemExpandedNG/components/Metadata/Metadata.js` + `.module.scss` — the panel header the Actions menu joins * `.../StudyItemExpandedNG/components/TabletMobileTabs/TabletMobileTabs.js` — the path that omits `selectedSeries` * `.../StudyListNG/components/SelectAndSettingsAndExpandCell/SelectAndSettingsAndExpandCell.js` — per-row menu, lazy ACL pattern, `_getStudyInstanceUID`, `_getStudyDescriptor` * `.../StudyListNG/components/StudiesTableActions/StudiesTableActions.js` — bulk toolbar, `handleDownloadSelectedStudies`, `clearSelection` * `.../StudyListNG/components/DownloadManagerModal/DownloadManagerModal.js` + `.module.scss` — the blocking-confirmation precedent * `.../StudyListNG/components/DownloadsMenu/DownloadsMenu.js` — series row rendering, progress treatment * `platform/viewer/src/hooks/useSeriesMetadata.js` — `processThumbnail`, the thumbnail object shape * `platform/viewer/src/hooks/useStudies.js` — the study-list query cache * `platform/viewer/src/styles/radixUi.module.scss` — shared Radix classes * `platform/ui/src/elements/Svg/svgs/{dots,cloud-download,trash-bin}.svg` — icon assets * `platform/core/src/services/UINotificationService/index.ts` — notification contract * `platform/core/src/services/LocalCacheService/LocalCacheService.ts` — offline copy removal (FR-14) * Server-side reference: * `sonador_orthanc/web/download.py` — the series archive endpoint * `sonador_orthanc/auth/web.py` — `AuthDICOMResourcePermissionLookupView`, the `resource-acl` payload * `sonador_orthanc/db/auth.py` — `UserSeriesAuth` / `GroupSeriesAuth`, series-granular grants * `apps/visionaire/models/servers.py` in oak-tree/medical-imaging/sonador — `PacsImagingServer.server_perms`, the wildcard-only semantics of `activeServer.perms.remove`
issue