From 1188fcc2add3d052b8612cd4455dcfabf1145970 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 16:46:37 -0400 Subject: [PATCH 01/16] docs: add ngviews-05a (cart pipeline) implementation plan --- .../2026-08-07-ngviews-05a-cart-pipeline.md | 817 ++++++++++++++++++ 1 file changed, 817 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-07-ngviews-05a-cart-pipeline.md diff --git a/docs/superpowers/plans/2026-08-07-ngviews-05a-cart-pipeline.md b/docs/superpowers/plans/2026-08-07-ngviews-05a-cart-pipeline.md new file mode 100644 index 00000000..9114545c --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-ngviews-05a-cart-pipeline.md @@ -0,0 +1,817 @@ +# Neuroglancer Views — PR 5a (`ngviews-05a-cart-pipeline`) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver the file-browser → **Layer Cart** → **saved View** pipeline: select datasets in the browser, add them to a server-persisted cart (or make a View straight from a multi-row selection), and check the cart out into a saved Neuroglancer View — creating the backing Data Links (consent-gated) and a generated `ng_state` along the way. + +**Architecture:** PR 4 already shipped `CartContext`, `ViewsContext`/`viewQueries` (incl. `useCreateViewMutation`), and the `/ngviews` page with a *placeholder* Layer Cart tab. 5a builds the **checkout engine** and the **entry points** that feed it: +1. Hoist `CartProvider` so the file browser (not just `/ngviews`) can use it. +2. A pure `buildViewState()` that turns N resolved datasets into one Neuroglancer `ng_state` object + a `ViewLayerInput[]`, by reusing `omezarr-helper`'s existing per-dataset generators and merging their `layers`. +3. `useCartCheckout()` — resolves each dataset to a Data Link (reuse existing, else create), builds the state, and calls `createViewMutation`. +4. A reusable `CreateViewButton` that wraps checkout with the Data-Link **consent gate** (reusing the `areDataLinksAutomatic` preference). +5. Entry points: a row `⋯` **"Add to Neuroglancer cart"** item, a **floating selection bar** (first consumer of PR 3's `checkedFiles`), and the **full Layer Cart tab** (channel selection + Create View). + +**This is PR 5a of a split PR 5.** PR 5b (a separate plan) adds the Browse-side right-edge **Properties↔Cart rail/drawer**, the **Data Link delete 409 dependent-Views dialog**, and the Properties **"Appears in N Views"** section. Branch `ngviews-05a-cart-pipeline` off `ngviews-04-views-page`; 5b stacks on 5a; the embedded-viewer PR (`ngviews-06-embedded-readonly`) stacks on 5b. + +**Tech Stack:** React 18, TypeScript, TanStack Query v5, Material Tailwind v3, `zarrita`/`ome-zarr.js` (via `omezarr-helper.ts`), Vitest + RTL + MSW. All commands run through **pixi**. + +## Global Constraints + +- **Always use pixi.** Frontend tests `pixi run test-frontend`; type-check `pixi run node-check`; lint `pixi run node-eslint-check` (autofix `pixi run node-eslint-write`). +- **Branch:** all commits land on `ngviews-05a-cart-pipeline`, branched off **`ngviews-04-views-page`**. Create it first: `git checkout ngviews-04-views-page && git checkout -b ngviews-05a-cart-pipeline`. Every implementer must `git branch --show-current` == `ngviews-05a-cart-pipeline` **before committing** (a parallel session previously cross-contaminated branches). Never run repo-wide prettier/eslint autofix on untouched files; `git add` only the task's own files and confirm with `git show --stat HEAD`. +- **Pre-push (whoever pushes):** the Lefthook pre-push hook does NOT fire from `/opt/fileglancer`, so run `pixi run node-prettier-check` + `pixi run node-eslint-check` manually before pushing or CI will fail on formatting. +- **Reuse, do not rebuild** (exact identifiers verified in the codebase): + - `useCartContext()` (`@/contexts/CartContext`) → `{ cart, cartCount, addToCart(items), removeFromCart(path, channel?), clearCart() }`; `CartItem = { fsp_name: string; path: string; channel?: string; label: string }` (defined/exported from `@/queries/preferencesQueries`, re-exported from CartContext). + - `useViewsContext()` (`@/contexts/ViewsContext`) → `{ allViewsQuery, createViewMutation, updateViewMutation, deleteViewMutation }`. `createViewMutation.mutateAsync(req: ViewCreateRequest)`. `ViewCreateRequest = { name: string; ng_state: Record; sharing_mode?: 'private'|'read'; layers: ViewLayerInput[] }`; `ViewLayerInput = { sharing_key: string|null; layer_index: number; channel: string|null; opts: Record|null }` (`@/queries/viewQueries`). + - Proxied paths: `useAllProxiedPathsQuery()` (list), `useCreateProxiedPathMutation()` → `mutateAsync({ fsp_name, path, url_prefix? }): Promise`; `ProxiedPath` (`@/contexts/ProxiedPathContext`) has `{ sharing_key, url, fsp_name, path, ... }` (`@/queries/proxiedPathQueries`). + - NG state: `getOmeZarrMetadata(dataUrl): Promise` and `generateNeuroglancerStateForOmeZarr(dataUrl, zarrVersion, layerType, multiscale, arr, labels, omero?, useLegacy?): string | null` and `generateNeuroglancerStateForDataURL(dataUrl, zarrVersion): string` — all exported from `@/omezarr-helper`. `Metadata` exposes `.multiscales?: MultiscaleMetadata[]`, `.arr`, `.labels?`, `.omero?`, `.zarrVersion: 2|3`. These generators return a **`encodeURIComponent(JSON.stringify(state))` string**, not an object. + - Preferences/consent: `usePreferencesContext()` → `areDataLinksAutomatic: boolean`, `dataLinkSubpathMode`, `toggleAutomaticDataLinks(): Promise>`. + - UI atoms: `FgButton`, `FgDialog` (`@/components/ui/Dialogs/FgDialog`), `FgBadge`, `FgSwitch`, `FgCheckbox`; Material Tailwind `Collapse` for the two-level cart tree (mirror `Sidebar/Zone.tsx`'s `openZones` controlled-open-map pattern — there is no generic TreeView). + - Context menu: file-browser row items are `ContextMenuItem = { name; action: () => boolean|void|Promise<...>; shouldShow?; color? }` (`@/components/ui/Menus/ContextMenu.tsx`), defined in the items array inside `FileBrowser.tsx`. +- **`checkedFiles`/`checkedPaths`** come from `useFileBrowserContext().fileBrowserState` (PR 3). `checkedFiles: FileOrFolder[]` is the current directory's checked rows; `clearChecked()` empties the set; it auto-clears on navigation. The current FSP name is `fileQuery.data?.currentFileSharePath?.name`. +- **Read-only scope:** every created View uses `sharing_mode: 'read'` (the default). No edit link. +- **Frontend conventions** (`frontend/CLAUDE.md`): separate value/type imports; no `console.log` (use `src/logger.ts`); named React imports; define prop interfaces; do not annotate component return types. + +**Interfaces this PR produces (consumed within 5a and by 5b/6):** +- `@/utils/viewCheckout`: `buildViewState(datasets: ResolvedCheckoutDataset[]): Promise<{ ng_state: Record; layers: ViewLayerInput[] }>`; type `ResolvedCheckoutDataset`. +- `@/omezarr-helper`: `getOmeZarrChannels(dataUrl: string): Promise`. +- `@/hooks/useCartCheckout`: `useCartCheckout()` → `{ checkout(datasets: CartItem[], name: string): Promise }`. +- `@/components/ui/Views/CreateViewButton`: ` void} … />`. + +--- + +### Task 1: Hoist `CartProvider` app-wide + +**Files:** +- Modify: `frontend/src/layouts/MainLayout.tsx` (mount `CartProvider` inside `PreferencesProvider`) +- Modify: `frontend/src/App.tsx` (remove the route-scoped `CartProvider` around `NGViews`; keep `ViewsProvider`) + +**Interfaces:** +- Consumes: existing `CartProvider` (`@/contexts/CartContext`), which requires a `PreferencesProvider` ancestor (it calls `usePreferencesContext`). +- Produces: `useCartContext()` is now resolvable on **every** route under `MainLayout` (Browse included), not just `/ngviews`. + +- [ ] **Step 1: Add `CartProvider` to `MainLayout`** + +In `frontend/src/layouts/MainLayout.tsx`, import `CartProvider` and wrap it directly inside `PreferencesProvider` (so it has the preferences ancestor and covers both `` and ``): + +```tsx +import { CartProvider } from '@/contexts/CartContext'; +``` +```tsx + + + + {/* …existing providers… */} + + {/* …/existing providers… */} + + + +``` +(Insert `CartProvider` as the child of `PreferencesProvider` and parent of the current `ExternalBucketProvider` subtree — do not reorder the others.) + +- [ ] **Step 2: Remove the route-scoped `CartProvider`** + +In `frontend/src/App.tsx`, the `/ngviews` route currently wraps `` around ``. Remove that wrapper (keep `ViewsProvider`), and remove the now-unused `CartProvider` import from `App.tsx`: + +```tsx + path="ngviews" + element={ + + + + + + } +``` + +- [ ] **Step 3: Type-check, lint, full suite** + +Run: `pixi run node-check`, `pixi run node-eslint-check`, `pixi run test-frontend` +Expected: green. `useCartCount` (nav badge) and the `/ngviews` cart tab keep working (both now resolve through the app-wide provider or the direct-preference read). The existing `NGViews.test.tsx` mocks `useCartContext`, so it is unaffected. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/layouts/MainLayout.tsx frontend/src/App.tsx +git commit -m "feat(views): mount CartProvider app-wide so the file browser can use the cart" +``` + +--- + +### Task 2: `getOmeZarrChannels` — list a dataset's channels + +**Files:** +- Modify: `frontend/src/omezarr-helper.ts` (add + export `getOmeZarrChannels`) +- Test: `frontend/src/__tests__/unitTests/getOmeZarrChannels.test.ts` (new) + +**Interfaces:** +- Consumes: existing `getOmeZarrMetadata(dataUrl)` and the axes helper `getAxesMap` (already in the file), `OmeroMetadata`, `MultiscaleMetadata`. +- Produces: `getOmeZarrChannels(dataUrl: string): Promise` — the channel labels for an OME-Zarr dataset (from `omero.channels[].label`, else synthesized `Channel 0..n-1` from the `c` axis length, else `[]` when there is no channel axis). + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/unitTests/getOmeZarrChannels.test.ts`. Mock `getOmeZarrMetadata` (spy on the module) and assert channel extraction for three shapes: omero channels present; no omero but a `c` axis of length 3; no channel axis. + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/omezarr-helper', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, getOmeZarrMetadata: vi.fn() }; +}); + +import { getOmeZarrChannels, getOmeZarrMetadata } from '@/omezarr-helper'; + +const asMock = getOmeZarrMetadata as unknown as ReturnType; + +// Minimal Metadata-like fixtures. axes/omero/arr shapes only need the fields the helper reads. +const withOmero = { + zarrVersion: 2, + multiscales: [{ axes: [{ name: 'c' }, { name: 'y' }, { name: 'x' }] }], + omero: { channels: [{ label: 'DAPI' }, { label: 'GFP' }] }, + arr: { shape: [2, 10, 10] } +}; +const cAxisNoOmero = { + zarrVersion: 2, + multiscales: [{ axes: [{ name: 'c' }, { name: 'y' }, { name: 'x' }] }], + omero: undefined, + arr: { shape: [3, 10, 10] } +}; +const noChannelAxis = { + zarrVersion: 2, + multiscales: [{ axes: [{ name: 'y' }, { name: 'x' }] }], + omero: undefined, + arr: { shape: [10, 10] } +}; + +describe('getOmeZarrChannels', () => { + beforeEach(() => asMock.mockReset()); + + it('reads omero channel labels', async () => { + asMock.mockResolvedValue(withOmero); + expect(await getOmeZarrChannels('u')).toEqual(['DAPI', 'GFP']); + }); + it('synthesizes channel names from the c-axis length when omero is absent', async () => { + asMock.mockResolvedValue(cAxisNoOmero); + expect(await getOmeZarrChannels('u')).toEqual([ + 'Channel 0', + 'Channel 1', + 'Channel 2' + ]); + }); + it('returns [] when there is no channel axis', async () => { + asMock.mockResolvedValue(noChannelAxis); + expect(await getOmeZarrChannels('u')).toEqual([]); + }); +}); +``` + +> Read `omezarr-helper.ts` first: confirm the `Metadata` field names (`multiscales`, `omero`, `arr`, `zarrVersion`) and how `getAxesMap(multiscale)` returns `{ [axisName]: { index } }`. Match the fixtures to the real accessors your implementation uses. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pixi run test-frontend -- getOmeZarrChannels` +Expected: FAIL — `getOmeZarrChannels` not exported. + +- [ ] **Step 3: Implement `getOmeZarrChannels`** + +In `frontend/src/omezarr-helper.ts`, add the function and export it (append to the `export { … }` block). Reuse `getOmeZarrMetadata` and `getAxesMap`; mirror the channel logic already inside `generateFullNeuroglancerStateForOmeZarr` (omero channels first, else the `c`-axis count): + +```ts +async function getOmeZarrChannels(dataUrl: string): Promise { + const metadata = await getOmeZarrMetadata(dataUrl); + const multiscale = metadata.multiscales?.[0]; + if (!multiscale) { + return []; + } + if (metadata.omero?.channels?.length) { + return metadata.omero.channels.map( + (ch, i) => ch.label || `Channel ${i}` + ); + } + const axesMap = getAxesMap(multiscale); + const cAxis = axesMap['c']; + if (!cAxis) { + return []; + } + const count = metadata.arr.shape[cAxis.index]; + return Array.from({ length: count }, (_, i) => `Channel ${i}`); +} +``` + +Add `getOmeZarrChannels` to the exports. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pixi run test-frontend -- getOmeZarrChannels` → PASS. + +- [ ] **Step 5: Type-check, lint, commit** + +Run `pixi run node-check`, `pixi run node-eslint-check`. Then: +```bash +git add frontend/src/omezarr-helper.ts frontend/src/__tests__/unitTests/getOmeZarrChannels.test.ts +git commit -m "feat(views): add getOmeZarrChannels helper for cart channel selection" +``` + +--- + +### Task 3: `buildViewState` — multi-dataset `ng_state` object + layers + +**Files:** +- Create: `frontend/src/utils/viewCheckout.ts` +- Test: `frontend/src/__tests__/unitTests/viewCheckout.test.ts` (new) + +**Interfaces:** +- Consumes: `getOmeZarrMetadata`, `generateNeuroglancerStateForOmeZarr`, `generateNeuroglancerStateForDataURL` (`@/omezarr-helper`); `ViewLayerInput` (`@/queries/viewQueries`). +- Produces: + - `type ResolvedCheckoutDataset = { url: string; sharing_key: string; fsp_name: string; path: string; channel?: string; label: string }` + - `buildViewState(datasets: ResolvedCheckoutDataset[]): Promise<{ ng_state: Record; layers: ViewLayerInput[] }>` + +**Approach (lazy + grounded):** reuse the existing single-dataset generators (which are well-tested), then **merge**. For each dataset: fetch metadata, generate its state *string*, `JSON.parse(decodeURIComponent(...))` to an object, take its `layers`, (optionally) filter to the requested channel, and concatenate. Keep `dimensions`/`layout`/`selectedLayer` from the first dataset that produced them. +`// ponytail: merge per-dataset generated states instead of refactoring the generator to emit objects. Ceiling: cross-dataset coordinate spaces aren't reconciled (first dataset's dimensions win) and layer_index>4 is archived per NG default — fine for curated read-only carts; revisit if mixed-resolution overlays misalign.` + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/unitTests/viewCheckout.test.ts`. Mock `@/omezarr-helper` so `getOmeZarrMetadata` returns a minimal OME metadata and `generateNeuroglancerStateForOmeZarr` returns an encoded state with named layers; assert the merged `ng_state.layers` concatenates across datasets, `layer_index` is sequential, and each `ViewLayerInput` carries the right `sharing_key`/`channel`. + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const encoded = (state: unknown) => encodeURIComponent(JSON.stringify(state)); + +vi.mock('@/omezarr-helper', () => ({ + getOmeZarrMetadata: vi.fn(), + generateNeuroglancerStateForOmeZarr: vi.fn(), + generateNeuroglancerStateForDataURL: vi.fn() +})); + +import { + getOmeZarrMetadata, + generateNeuroglancerStateForOmeZarr +} from '@/omezarr-helper'; +import { buildViewState } from '@/utils/viewCheckout'; + +const md = { multiscales: [{}], arr: {}, zarrVersion: 2 }; + +beforeEach(() => { + (getOmeZarrMetadata as any).mockReset().mockResolvedValue(md); + (generateNeuroglancerStateForOmeZarr as any) + .mockReset() + .mockImplementation((url: string) => + encoded({ + dimensions: { x: [1, 'm'] }, + layout: '4panel-alt', + layers: [{ name: `${url}-L0`, type: 'image' }] + }) + ); +}); + +describe('buildViewState', () => { + it('concatenates layers across datasets and maps ViewLayerInput', async () => { + const { ng_state, layers } = await buildViewState([ + { url: 'a', sharing_key: 'ka', fsp_name: 'f', path: '/a', label: 'A' }, + { + url: 'b', + sharing_key: 'kb', + fsp_name: 'f', + path: '/b', + channel: 'GFP', + label: 'B' + } + ]); + const stateLayers = (ng_state as any).layers; + expect(stateLayers).toHaveLength(2); + expect((ng_state as any).dimensions).toEqual({ x: [1, 'm'] }); // first dataset's dims kept + expect(layers).toEqual([ + { sharing_key: 'ka', layer_index: 0, channel: null, opts: null }, + { sharing_key: 'kb', layer_index: 1, channel: 'GFP', opts: null } + ]); + }); + + it('falls back to the data-URL generator when there are no multiscales', async () => { + (getOmeZarrMetadata as any).mockResolvedValue({ + multiscales: undefined, + arr: {}, + zarrVersion: 2 + }); + ( + (await import('@/omezarr-helper')).generateNeuroglancerStateForDataURL as any + ).mockReturnValue(encoded({ layers: [{ name: 'fallback' }] })); + const { ng_state } = await buildViewState([ + { url: 'a', sharing_key: 'ka', fsp_name: 'f', path: '/a', label: 'A' } + ]); + expect((ng_state as any).layers).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pixi run test-frontend -- viewCheckout` → FAIL (module missing). + +- [ ] **Step 3: Implement `viewCheckout.ts`** + +```ts +import { + getOmeZarrMetadata, + generateNeuroglancerStateForOmeZarr, + generateNeuroglancerStateForDataURL +} from '@/omezarr-helper'; +import type { ViewLayerInput } from '@/queries/viewQueries'; +import log from '@/logger'; + +export type ResolvedCheckoutDataset = { + url: string; + sharing_key: string; + fsp_name: string; + path: string; + channel?: string; + label: string; +}; + +type NgLayer = Record & { name?: string }; +type NgState = Record & { layers?: NgLayer[] }; + +function decodeState(encoded: string | null): NgState | null { + if (!encoded) { + return null; + } + try { + return JSON.parse(decodeURIComponent(encoded)) as NgState; + } catch (error) { + log.error('Failed to decode generated Neuroglancer state', error); + return null; + } +} + +async function generateStateForDataset( + ds: ResolvedCheckoutDataset +): Promise { + const metadata = await getOmeZarrMetadata(ds.url); + const multiscale = metadata.multiscales?.[0]; + // ponytail: default layerType 'image' — the thumbnail-edge heuristic used + // for the single-dir preview needs a rendered thumbnail we don't have here. + const encoded = multiscale + ? generateNeuroglancerStateForOmeZarr( + ds.url, + metadata.zarrVersion, + 'image', + multiscale, + metadata.arr, + metadata.labels, + metadata.omero + ) + : generateNeuroglancerStateForDataURL(ds.url, metadata.zarrVersion); + return decodeState(encoded); +} + +// If the cart entry names a channel, keep only layers whose name matches it; +// otherwise keep every layer the dataset produced. +function selectLayers(state: NgState, channel?: string): NgLayer[] { + const layers = state.layers ?? []; + if (!channel) { + return layers; + } + const matched = layers.filter(l => + (l.name ?? '').toString().toLowerCase().includes(channel.toLowerCase()) + ); + return matched.length ? matched : layers; +} + +export async function buildViewState( + datasets: ResolvedCheckoutDataset[] +): Promise<{ ng_state: Record; layers: ViewLayerInput[] }> { + const combinedLayers: NgLayer[] = []; + const viewLayers: ViewLayerInput[] = []; + let base: NgState | null = null; + + for (const ds of datasets) { + const state = await generateStateForDataset(ds); + if (!state) { + continue; + } + if (!base) { + base = state; + } + for (const layer of selectLayers(state, ds.channel)) { + const layer_index = combinedLayers.length; + combinedLayers.push({ ...layer, archived: layer_index >= 4 }); + viewLayers.push({ + sharing_key: ds.sharing_key, + layer_index, + channel: ds.channel ?? null, + opts: null + }); + } + } + + const first = combinedLayers[0]; + const ng_state: Record = { + ...(base ?? {}), + layers: combinedLayers, + selectedLayer: first ? { visible: true, layer: first.name } : undefined, + layout: (base?.layout as string) ?? '4panel-alt' + }; + return { ng_state, layers: viewLayers }; +} +``` + +> Confirm `@/logger`'s default export name matches your import (the repo uses `src/logger.ts`). If `Metadata.zarrVersion` is typed as `2 | 3`, the generator calls type-check directly; if the field name differs, adjust to the real one. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pixi run test-frontend -- viewCheckout` → PASS (both cases). + +- [ ] **Step 5: Type-check, lint, commit** + +```bash +git add frontend/src/utils/viewCheckout.ts frontend/src/__tests__/unitTests/viewCheckout.test.ts +git commit -m "feat(views): add buildViewState to assemble ng_state from multiple datasets" +``` + +--- + +### Task 4: `useCartCheckout` — resolve Data Links + build state + create View + +**Files:** +- Create: `frontend/src/hooks/useCartCheckout.ts` +- Test: `frontend/src/__tests__/componentTests/useCartCheckout.test.tsx` (new) + +**Interfaces:** +- Consumes: `useAllProxiedPathsQuery`, `useCreateProxiedPathMutation` (`@/queries/proxiedPathQueries`); `useViewsContext().createViewMutation`; `buildViewState` (Task 3); `CartItem` (`@/queries/preferencesQueries`); `normalizeFspRootPath` if present in `@/utils` (FSP-root `.` → `''`, matching `useDataToolLinks.handleCreateDataLink`). +- Produces: `useCartCheckout() → { checkout(datasets: CartItem[], name: string): Promise }`. `checkout` (a) resolves a Data Link per unique `(fsp_name, path)` — reusing an existing proxied path from the list, else creating one — (b) maps each cart entry to a `ResolvedCheckoutDataset` (carrying its channel), (c) `buildViewState`, (d) `createViewMutation.mutateAsync({ name, ng_state, layers })`, (e) returns the `View`. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/componentTests/useCartCheckout.test.tsx`. Mock the proxied-path hooks, `useViewsContext`, and `buildViewState`; render the hook with `renderHook` inside a `QueryClientProvider`; assert that checkout creates links only for datasets without an existing proxied path, and calls `createViewMutation.mutateAsync` with the built state. + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +const createProxied = vi.fn(); +const createViewAsync = vi.fn(); + +vi.mock('@/queries/proxiedPathQueries', () => ({ + useAllProxiedPathsQuery: () => ({ + data: [{ fsp_name: 'f', path: '/a', sharing_key: 'ka', url: 'http://a' }] + }), + useCreateProxiedPathMutation: () => ({ mutateAsync: createProxied }) +})); +vi.mock('@/contexts/ViewsContext', () => ({ + useViewsContext: () => ({ + createViewMutation: { mutateAsync: createViewAsync } + }) +})); +vi.mock('@/utils/viewCheckout', () => ({ + buildViewState: vi.fn().mockResolvedValue({ + ng_state: { layers: [] }, + layers: [{ sharing_key: 'ka', layer_index: 0, channel: null, opts: null }] + }) +})); + +import { useCartCheckout } from '@/hooks/useCartCheckout'; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return {children}; +} + +beforeEach(() => { + createProxied.mockReset().mockResolvedValue({ + fsp_name: 'f', + path: '/b', + sharing_key: 'kb', + url: 'http://b' + }); + createViewAsync.mockReset().mockResolvedValue({ short_key: 'v1', name: 'N' }); +}); + +describe('useCartCheckout', () => { + it('reuses existing links, creates missing ones, then creates the View', async () => { + const { result } = renderHook(() => useCartCheckout(), { wrapper }); + await result.current.checkout( + [ + { fsp_name: 'f', path: '/a', label: 'A' }, // existing link → no create + { fsp_name: 'f', path: '/b', label: 'B' } // missing → create + ], + 'My View' + ); + expect(createProxied).toHaveBeenCalledTimes(1); + expect(createProxied).toHaveBeenCalledWith( + expect.objectContaining({ fsp_name: 'f', path: '/b' }) + ); + expect(createViewAsync).toHaveBeenCalledWith( + expect.objectContaining({ name: 'My View', ng_state: { layers: [] } }) + ); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pixi run test-frontend -- useCartCheckout` → FAIL. + +- [ ] **Step 3: Implement `useCartCheckout.ts`** + +```tsx +import { useCallback } from 'react'; + +import { + useAllProxiedPathsQuery, + useCreateProxiedPathMutation +} from '@/queries/proxiedPathQueries'; +import { useViewsContext } from '@/contexts/ViewsContext'; +import { buildViewState } from '@/utils/viewCheckout'; +import type { ResolvedCheckoutDataset } from '@/utils/viewCheckout'; +import type { CartItem } from '@/queries/preferencesQueries'; +import type { View } from '@/queries/viewQueries'; +import type { ProxiedPath } from '@/contexts/ProxiedPathContext'; + +const datasetKey = (fsp_name: string, path: string) => `${fsp_name}::${path}`; + +export function useCartCheckout() { + const allProxiedPathsQuery = useAllProxiedPathsQuery(); + const createProxiedPath = useCreateProxiedPathMutation(); + const { createViewMutation } = useViewsContext(); + + const checkout = useCallback( + async (datasets: CartItem[], name: string): Promise => { + const existing = new Map( + (allProxiedPathsQuery.data ?? []).map(p => [ + datasetKey(p.fsp_name, p.path), + p + ]) + ); + + // Resolve one Data Link per unique (fsp_name, path); create if missing. + const linkByKey = new Map(); + for (const ds of datasets) { + const key = datasetKey(ds.fsp_name, ds.path); + if (linkByKey.has(key)) { + continue; + } + const link = + existing.get(key) ?? + (await createProxiedPath.mutateAsync({ + fsp_name: ds.fsp_name, + path: ds.path + })); + linkByKey.set(key, link); + } + + const resolved: ResolvedCheckoutDataset[] = datasets.map(ds => { + const link = linkByKey.get(datasetKey(ds.fsp_name, ds.path))!; + return { + url: link.url, + sharing_key: link.sharing_key, + fsp_name: ds.fsp_name, + path: ds.path, + channel: ds.channel, + label: ds.label + }; + }); + + const { ng_state, layers } = await buildViewState(resolved); + return createViewMutation.mutateAsync({ name, ng_state, layers }); + }, + [allProxiedPathsQuery.data, createProxiedPath, createViewMutation] + ); + + return { checkout }; +} +``` + +> Verify `ProxiedPath.path` is stored the same way you look it up (the create path uses `normalizeFspRootPath`; if the list stores normalized paths, normalize `ds.path` the same way before `datasetKey`, or matches will miss and you'll create duplicate links). Read `proxiedPathQueries.ts` + `useDataToolLinks.handleCreateDataLink` and mirror its `path` normalization. + +- [ ] **Step 4: Run the test to verify it passes → Step 5: type-check, lint, commit** + +Run `pixi run test-frontend -- useCartCheckout` (PASS), `pixi run node-check`, `pixi run node-eslint-check`. +```bash +git add frontend/src/hooks/useCartCheckout.ts frontend/src/__tests__/componentTests/useCartCheckout.test.tsx +git commit -m "feat(views): add useCartCheckout (resolve links, build state, create View)" +``` + +--- + +### Task 5: `CreateViewButton` — consent-gated checkout, reused everywhere + +**Files:** +- Create: `frontend/src/components/ui/Views/CreateViewButton.tsx` +- Test: `frontend/src/__tests__/componentTests/CreateViewButton.test.tsx` (new) + +**Interfaces:** +- Consumes: `useCartCheckout` (Task 4); `usePreferencesContext()` (`areDataLinksAutomatic`, `dataLinkSubpathMode`, `toggleAutomaticDataLinks`); `useAllProxiedPathsQuery` (to count links that would be newly created); `FgButton`, `FgDialog`, `FgSwitch`; `toast`; `useNavigate` (`react-router`); `CartItem`, `View`. +- Produces: ` void} />`. On click: if `datasets` empty → `toast.error('Nothing to add')`; else compute `newLinkCount` (datasets whose `(fsp_name,path)` has no existing proxied path); if `areDataLinksAutomatic` (and subpath mode not `'custom'`) **or** `newLinkCount === 0` → run checkout directly; else open a consent `FgDialog`. Consent dialog mirrors `DataLink.tsx`'s copy: a warning ("This will create N data links and 1 View"), a **"Don't ask me this again"** `FgSwitch` wired to `toggleAutomaticDataLinks()`, and Create/Cancel. On confirm → checkout. On success → `toast.success`, `onCreated?.(view)` (default: `navigate('/ngviews')`). + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/componentTests/CreateViewButton.test.tsx`. Mock `useCartCheckout` (spy `checkout`), `usePreferencesContext`, `useAllProxiedPathsQuery`, `react-router`'s `useNavigate`. Two cases: (a) `areDataLinksAutomatic=true` → clicking "Create View" calls `checkout` immediately (no dialog); (b) `areDataLinksAutomatic=false` with a dataset needing a new link → clicking opens the consent dialog, and confirming calls `checkout`. + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const checkout = vi.fn().mockResolvedValue({ short_key: 'v1', name: 'N' }); +let automatic = true; + +vi.mock('@/hooks/useCartCheckout', () => ({ + useCartCheckout: () => ({ checkout }) +})); +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferencesContext: () => ({ + areDataLinksAutomatic: automatic, + dataLinkSubpathMode: 'full_path', + toggleAutomaticDataLinks: vi.fn() + }) +})); +vi.mock('@/queries/proxiedPathQueries', () => ({ + useAllProxiedPathsQuery: () => ({ data: [] }) // nothing exists → 1 new link +})); +vi.mock('react-router', () => ({ useNavigate: () => vi.fn() })); + +import CreateViewButton from '@/components/ui/Views/CreateViewButton'; + +const datasets = [{ fsp_name: 'f', path: '/a', label: 'A' }]; + +beforeEach(() => { + checkout.mockClear(); + automatic = true; +}); + +describe('CreateViewButton', () => { + it('checks out directly when data links are automatic', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /create view/i })); + expect(checkout).toHaveBeenCalledWith(datasets, 'V'); + }); + + it('shows a consent dialog when not automatic, then checks out on confirm', async () => { + automatic = false; + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /create view/i })); + expect(checkout).not.toHaveBeenCalled(); + // consent dialog visible → confirm + await user.click( + await screen.findByRole('button', { name: /create.*view|confirm|continue/i }) + ); + expect(checkout).toHaveBeenCalledWith(datasets, 'V'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails → Step 3: implement `CreateViewButton.tsx`** + +Implement per the Produces contract. Use `FgDialog` for the consent modal (mirror the delete-dialog pattern in `NGViews.tsx`), `FgSwitch` for "Don't ask again" (call `toggleAutomaticDataLinks()`), and a `useState` pending flag disabling the button during `checkout`. Compute `newLinkCount` by comparing `datasets` against `useAllProxiedPathsQuery().data` on `(fsp_name, path)`. On success `toast.success(\`Created View "\${name}"\`)` and `onCreated?.(view) ?? navigate('/ngviews')`; on error `toast.error(...)`. + +> Read `DataLink.tsx` (the "NOT automatic" branch, ~lines 277-346) to match the consent copy and the `DataLinkOptions`/`FgSwitch` "don't ask again" wiring, so this dialog is consistent with the existing one. Do NOT import `DataLink.tsx` directly — it's a single-link discriminated-union dialog; this is the batch equivalent. + +- [ ] **Step 4: test PASS → Step 5: type-check, lint, commit** + +```bash +git add frontend/src/components/ui/Views/CreateViewButton.tsx frontend/src/__tests__/componentTests/CreateViewButton.test.tsx +git commit -m "feat(views): add consent-gated CreateViewButton (batch checkout)" +``` + +--- + +### Task 6: Row `⋯` "Add to Neuroglancer cart" + +**Files:** +- Modify: `frontend/src/components/ui/BrowsePage/FileBrowser.tsx` (add a context-menu item) +- Test: extend/add `frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx` (new) + +**Interfaces:** +- Consumes: `useCartContext().addToCart` (now app-wide, Task 1); the context-menu items array in `FileBrowser.tsx`; the current FSP name from `fileQuery.data?.currentFileSharePath?.name`; `ContextMenuItem` shape. +- Produces: a new row/right-click menu item **"Add to Neuroglancer cart"** that calls `addToCart([{ fsp_name, path: file.path, label: file.name }])` and `toast.success`. `shouldShow`: only for folders (datasets), not broken symlinks — mirror the guard the existing NG/data-link items use. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx`. Render the Browse page through the shared `render` harness (mirror `Browse.test.tsx`'s MSW file-listing handler so a directory with a folder loads), open a row's context menu, and assert an "Add to Neuroglancer cart" item exists and clicking it calls the cart. If wiring the whole Browse tree is brittle, fall back to a focused test that mocks `useCartContext` and asserts the item's `action` builds the right `CartItem` — assert the behavior (a folder row yields `addToCart([{fsp_name, path, label}])`), not the DOM mechanics. + +- [ ] **Step 2: fail → Step 3: add the menu item** + +In `FileBrowser.tsx`, find the context-menu items array (the `ContextMenuItem[]` built for a right-clicked/`⋯` file) and add: + +```tsx +{ + name: 'Add to Neuroglancer cart', + shouldShow: file.is_dir === true && !file.is_broken_symlink, // match the real FileOrFolder flags + action: () => { + addToCart([ + { + fsp_name: fileQuery.data?.currentFileSharePath?.name ?? '', + path: file.path, + label: file.name + } + ]); + toast.success(`Added "${file.name}" to the Neuroglancer cart`); + } +} +``` + +Pull `addToCart` from `useCartContext()` and `toast` from `react-hot-toast` (verify the real `FileOrFolder` field names — `is_dir`/`is_broken_symlink` may differ; read the type and mirror the guard the existing Neuroglancer/data-link menu items use). + +- [ ] **Step 4: PASS → Step 5: type-check, lint, commit** + +```bash +git add frontend/src/components/ui/BrowsePage/FileBrowser.tsx frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx +git commit -m "feat(browse): add \"Add to Neuroglancer cart\" row action" +``` + +--- + +### Task 7: Floating selection bar (first consumer of `checkedFiles`) + +**Files:** +- Create: `frontend/src/components/ui/BrowsePage/SelectionBar.tsx` +- Modify: `frontend/src/components/ui/BrowsePage/FileBrowser.tsx` (render `` when rows are checked) +- Test: `frontend/src/__tests__/componentTests/SelectionBar.test.tsx` (new) + +**Interfaces:** +- Consumes: `useFileBrowserContext().fileBrowserState.checkedFiles` + `clearChecked`; `fileQuery.data?.currentFileSharePath?.name`; `useCartContext().addToCart`; `CreateViewButton` (Task 5); `FgButton`. +- Produces: a fixed, bottom-center bar rendered only when `checkedFiles.length > 0`, showing the count and three actions: **Add N to cart** (`addToCart(checkedFiles.map(f => ({ fsp_name, path: f.path, label: f.name })))` + toast), **New View from selection** (``), **Clear** (`clearChecked()`). + `// ponytail: bar carries the two net-new cart actions + clear. Multi-select Share/Download/"More" (design §6) generalize existing single-file actions — deferred; add when a concrete need lands.` + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/componentTests/SelectionBar.test.tsx`. Mock `useFileBrowserContext` (return two `checkedFiles`, a `clearChecked` spy, and `fileQuery.data.currentFileSharePath.name`), `useCartContext` (spy `addToCart`), and `CreateViewButton` (a stub rendering a button). Assert: the bar shows "2 selected", "Add 2 to cart" calls `addToCart` with two mapped items, and "Clear" calls `clearChecked`. + +- [ ] **Step 2: fail → Step 3: implement `SelectionBar.tsx`** per the contract; a fixed-position (`fixed bottom-6 left-1/2 -translate-x-1/2 z-…`) rounded bar of `FgButton`s. Render nothing when `checkedFiles.length === 0`. + +- [ ] **Step 4: render it** in `FileBrowser.tsx` (once, near the table). It self-hides when nothing is checked, so an unconditional mount is fine. + +- [ ] **Step 5: PASS, type-check, lint, full suite, commit** + +Run `pixi run test-frontend -- SelectionBar`, then `pixi run node-check`, `pixi run node-eslint-check`, `pixi run test-frontend` (full — the FileBrowser edit is on a shared surface). +```bash +git add frontend/src/components/ui/BrowsePage/SelectionBar.tsx frontend/src/components/ui/BrowsePage/FileBrowser.tsx frontend/src/__tests__/componentTests/SelectionBar.test.tsx +git commit -m "feat(browse): floating selection bar (add to cart / new View from selection)" +``` + +--- + +### Task 8: Full Layer Cart tab (channel selection + Create View) + +**Files:** +- Modify: `frontend/src/components/NGViews.tsx` (replace the placeholder cart shell) +- Create: `frontend/src/components/ui/Views/CartDatasetRow.tsx` (one expandable dataset row) +- Test: `frontend/src/__tests__/componentTests/CartTab.test.tsx` (new) + +**Interfaces:** +- Consumes: `useCartContext()` (`cart`, `addToCart`, `removeFromCart`, `clearCart`); `getOmeZarrChannels` (Task 2) for lazy channel discovery; `useCreateProxiedPathMutation`/`useAllProxiedPathsQuery` **only** indirectly (channel discovery needs the dataset's data-link URL — reuse the existing proxied path if present, else fall back to disabling channel expansion with a hint); `CreateViewButton` (Task 5); Material Tailwind `Collapse` + a chevron (mirror `Sidebar/Zone.tsx`). +- Produces: the Layer Cart tab renders the cart grouped by dataset `(fsp_name, path)`. Each dataset row: label, a remove button, and an expander that **lazy-loads channels** (`getOmeZarrChannels(url)` on first expand) and shows a checkbox per channel; checking a channel adds a channel-specific `CartItem` (`addToCart([{ …, channel }])`), unchecking removes it (`removeFromCart(path, channel)`). Below the list: `` and a **Clear cart** button. + `// ponytail: two-level dataset→channel tree via MT Collapse (no generic TreeView exists). Channel URL comes from an existing Data Link; if a dataset has no link yet, disable expansion with "channels load after the View is created" rather than creating a link just to browse channels. Non-Zarr/N5 disabling (design §6) deferred to the row's channel-load erroring out gracefully.` + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/componentTests/CartTab.test.tsx`. Mock `useCartContext` (a two-item cart + spies), `useViewsContext`, `getOmeZarrChannels` (resolve `['DAPI','GFP']`), and `CreateViewButton` (stub). Render `NGViews`, switch to the Layer Cart tab, and assert: both datasets are listed; expanding one calls `getOmeZarrChannels` and shows the channels; a "Create View" control is present. (Mirror the existing `NGViews.test.tsx` mocking setup.) + +- [ ] **Step 2: fail → Step 3: implement `CartDatasetRow.tsx` + rewire the cart tab** in `NGViews.tsx`, replacing the placeholder `else` block (the one marked `{/* Create View checkout + Fiji-style tree land in PR 5. */}`). Group `cart` by `(fsp_name, path)`; render a `CartDatasetRow` per dataset (its own `Collapse` open-state + a lazily-fetched `channels` state); footer with `CreateViewButton` + Clear cart. Keep the Saved Views tab untouched. + +- [ ] **Step 4: PASS, type-check, lint, full suite, commit** + +Run `pixi run test-frontend -- CartTab`, then `pixi run node-check`, `pixi run node-eslint-check`, `pixi run test-frontend` (full). +```bash +git add frontend/src/components/NGViews.tsx frontend/src/components/ui/Views/CartDatasetRow.tsx frontend/src/__tests__/componentTests/CartTab.test.tsx +git commit -m "feat(views): full Layer Cart tab with channel selection + Create View" +``` + +--- + +## Self-Review + +**Spec coverage (design §6/§8 PR 5, cart-pipeline portion):** +- Layer Cart server-side per-user + checkout → creates a View ✓ (Tasks 3–4, 8; cart persistence shipped in PR 4). +- Multi-select selection bar (add to cart · New View from selection) ✓ (Task 7) — first consumer of PR 3's `checkedFiles`. +- Row `⋯` "Add to Neuroglancer cart" ✓ (Task 6). Row `⋯` "View in Neuroglancer" (scratch View) → **deferred to PR 6** (it needs the embedded viewer; noted in Out-of-scope). +- Data-link consent reuse for View-creating actions ✓ (Task 5, `areDataLinksAutomatic` gate + "don't ask again" via `toggleAutomaticDataLinks`). +- Full Layer Cart tab: two-level dataset+channel tree, lazy channel load, Create View ✓ (Task 8); non-Zarr/N5 hard-disable simplified to graceful channel-load failure (ponytail-noted). +- Cart count badges already ship (PR 4 nav badge + cart-tab badge). + +**Deferred to PR 5b (separate plan):** right-edge Properties↔Cart rail + Browse-side cart drawer; Data Link delete 409 dependent-Views dialog; Properties "Appears in N Views". Deferred to PR 6: embedded viewer + scratch "View in Neuroglancer" row item + repointing "Open". + +**Placeholder scan:** none — every code step has real code or a named reference file to verify against (the `omezarr-helper` field names, `ProxiedPath.path` normalization, `FileOrFolder` flags, `@/logger` export). These are verification points, not logic gaps. + +**Type consistency:** `CartItem` (fsp_name/path/channel?/label) flows unchanged from cart → `useCartCheckout.checkout(datasets, name)` → `ResolvedCheckoutDataset` (adds url/sharing_key) → `buildViewState` → `ViewLayerInput[]` → `ViewCreateRequest`. `CreateViewButton` takes `CartItem[]` and is fed by both the selection bar (`checkedFiles` mapped to `CartItem`) and the cart tab (`cart`). `checkout` returns `View`; `onCreated?(view: View)`. + +**Ambiguity check:** the checkout engine is shared by "Create View" (cart tab) and "New View from selection" (selection bar) via one `CreateViewButton`/`useCartCheckout` — no duplicated checkout logic. Data Links are resolved once per unique `(fsp_name, path)` even when multiple channels of the same dataset are in the cart. The consent gate fires only when a checkout would create ≥1 new link and `areDataLinksAutomatic` is off. + +## Risks / call-outs for the executor + +- **`buildViewState` merge is a heuristic** (first dataset's `dimensions`/`layout` win; layers concatenated; channel filter by name match). This is the intended read-only-first behavior; a mixed-resolution overlay may not co-register. Flagged with a `ponytail:` ceiling comment — do not gold-plate it in 5a. +- **`ProxiedPath.path` normalization** is the one correctness trap: if the cart stores `path` differently from how the proxied-path list stores it, `useCartCheckout` will create duplicate links. Task 4 step 3 calls this out explicitly — verify against `useDataToolLinks.handleCreateDataLink`. +- **Channel discovery needs a data-link URL.** In the cart tab, a dataset with no existing Data Link can't be browsed for channels without creating one. Task 8 disables expansion with a hint rather than creating links on browse — confirm that's acceptable UX, or (5b) create the links at add-to-cart time. + +## Out of scope for this PR (next plans) + +- **PR 5b `ngviews-05b-browser-chrome`:** right-edge Properties↔Cart rail + Browse cart drawer; Data Link delete 409 dependent-Views dialog (the backend already returns `409 {"detail": {message, dependent_views}}` + accepts `confirm`); Properties "Appears in N Views" (`GET /api/proxied-path/{sharing_key}/views`). +- **PR 6 `ngviews-06-embedded-readonly`:** the embedded `/ngview/:key` viewer; scratch "View in Neuroglancer" row item; repointing the Saved Views "Open" action from external NG to the embedded route. From 20316ced5168724f704e03ac4c73557eb30f5921 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 16:49:09 -0400 Subject: [PATCH 02/16] feat(views): mount CartProvider app-wide so the file browser can use the cart --- frontend/src/App.tsx | 5 +---- frontend/src/layouts/MainLayout.tsx | 25 ++++++++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b907ac0..f018dd39 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,7 +33,6 @@ import SSHKeys from '@/components/SSHKeys'; import ErrorFallback from '@/components/ErrorFallback'; import NGViews from '@/components/NGViews'; import { ViewsProvider } from '@/contexts/ViewsContext'; -import { CartProvider } from '@/contexts/CartContext'; function RequireAuth({ children }: { readonly children: ReactNode }) { const { loading, authStatus } = useAuthContext(); @@ -124,9 +123,7 @@ const AppComponent = () => { element={ - - - + } diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 47dd6a22..64d36265 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -11,6 +11,7 @@ import '@/components/tours/shepherd-overrides.css'; import { ZonesAndFspMapContextProvider } from '@/contexts/ZonesAndFspMapContext'; import { FileBrowserContextProvider } from '@/contexts/FileBrowserContext'; import { PreferencesProvider } from '@/contexts/PreferencesContext'; +import { CartProvider } from '@/contexts/CartContext'; import { OpenFavoritesProvider } from '@/contexts/OpenFavoritesContext'; import { TicketProvider } from '@/contexts/TicketsContext'; import { ProxiedPathProvider } from '@/contexts/ProxiedPathContext'; @@ -74,17 +75,19 @@ export const MainLayout = () => { - - - - - - - - - - - + + + + + + + + + + + + + From 850edb3ea5fb2b601a3fcbe776b2beebf13719e9 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 16:55:32 -0400 Subject: [PATCH 03/16] feat(views): add getOmeZarrChannels helper for cart channel selection --- .../unitTests/getOmeZarrChannels.test.ts | 66 +++++++++++++++++++ frontend/src/omezarr-helper.ts | 24 +++++++ 2 files changed, 90 insertions(+) create mode 100644 frontend/src/__tests__/unitTests/getOmeZarrChannels.test.ts diff --git a/frontend/src/__tests__/unitTests/getOmeZarrChannels.test.ts b/frontend/src/__tests__/unitTests/getOmeZarrChannels.test.ts new file mode 100644 index 00000000..aa9eeeb1 --- /dev/null +++ b/frontend/src/__tests__/unitTests/getOmeZarrChannels.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// `getOmeZarrChannels` calls `getOmeZarrMetadata`, which is defined in the +// same module and can't be intercepted by mocking '@/omezarr-helper' itself +// (same-module function calls resolve to local bindings, not the mocked +// export). Instead, mock the external `ome-zarr.js` dependency that +// `getOmeZarrMetadata` uses to fetch metadata, so the real +// getOmeZarrMetadata -> getOmeZarrChannels pipeline runs end to end. +vi.mock('ome-zarr.js', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, getMultiscaleWithArray: vi.fn() }; +}); + +import { getMultiscaleWithArray } from 'ome-zarr.js'; + +import { getOmeZarrChannels } from '@/omezarr-helper'; + +const asMock = getMultiscaleWithArray as unknown as ReturnType; + +// Minimal fixtures matching what `omezarr.getMultiscaleWithArray` resolves to: +// { arr, shapes, multiscale, omero, scales, zarr_version }. +const withOmero = { + arr: { shape: [2, 10, 10] }, + shapes: [[2, 10, 10]], + multiscale: { axes: [{ name: 'c' }, { name: 'y' }, { name: 'x' }] }, + omero: { channels: [{ label: 'DAPI' }, { label: 'GFP' }] }, + scales: [[1, 1, 1]], + zarr_version: 2 +}; +const cAxisNoOmero = { + arr: { shape: [3, 10, 10] }, + shapes: [[3, 10, 10]], + multiscale: { axes: [{ name: 'c' }, { name: 'y' }, { name: 'x' }] }, + omero: undefined, + scales: [[1, 1, 1]], + zarr_version: 2 +}; +const noChannelAxis = { + arr: { shape: [10, 10] }, + shapes: [[10, 10]], + multiscale: { axes: [{ name: 'y' }, { name: 'x' }] }, + omero: undefined, + scales: [[1, 1]], + zarr_version: 2 +}; + +describe('getOmeZarrChannels', () => { + beforeEach(() => asMock.mockReset()); + + it('reads omero channel labels', async () => { + asMock.mockResolvedValue(withOmero); + expect(await getOmeZarrChannels('u')).toEqual(['DAPI', 'GFP']); + }); + it('synthesizes channel names from the c-axis length when omero is absent', async () => { + asMock.mockResolvedValue(cAxisNoOmero); + expect(await getOmeZarrChannels('u')).toEqual([ + 'Channel 0', + 'Channel 1', + 'Channel 2' + ]); + }); + it('returns [] when there is no channel axis', async () => { + asMock.mockResolvedValue(noChannelAxis); + expect(await getOmeZarrChannels('u')).toEqual([]); + }); +}); diff --git a/frontend/src/omezarr-helper.ts b/frontend/src/omezarr-helper.ts index c34369b2..8651f5c5 100644 --- a/frontend/src/omezarr-helper.ts +++ b/frontend/src/omezarr-helper.ts @@ -634,6 +634,29 @@ async function getOmeZarrMetadata(dataUrl: string): Promise { return metadata; } +/** + * Get the channel labels for an OME-Zarr dataset: from `omero.channels[].label` + * when present, else synthesized `Channel 0..n-1` from the `c` axis length, + * else `[]` when there is no channel axis. + */ +async function getOmeZarrChannels(dataUrl: string): Promise { + const metadata = await getOmeZarrMetadata(dataUrl); + const multiscale = metadata.multiscales?.[0]; + if (!multiscale) { + return []; + } + if (metadata.omero?.channels?.length) { + return metadata.omero.channels.map((ch, i) => ch.label || `Channel ${i}`); + } + const axesMap = getAxesMap(multiscale); + const cAxis = axesMap['c']; + if (!cAxis) { + return []; + } + const count = metadata.arr.shape[cAxis.index]; + return Array.from({ length: count }, (_, i) => `Channel ${i}`); +} + type ThumbnailResult = [thumbnail: string | null, errorMessage: string | null]; async function getOmeZarrThumbnail( @@ -776,6 +799,7 @@ export { getNeuroglancerSource, getZarrArray, getOmeZarrMetadata, + getOmeZarrChannels, getOmeZarrThumbnail, generateNeuroglancerStateForDataURL, generateNeuroglancerStateForZarrArray, From 49deda72341cc3a033a4f5cfaff9ffb7118bbe10 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 17:02:45 -0400 Subject: [PATCH 04/16] feat(views): add buildViewState to assemble ng_state from multiple datasets --- .../__tests__/unitTests/viewCheckout.test.ts | 68 +++++++++++ frontend/src/utils/viewCheckout.ts | 107 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 frontend/src/__tests__/unitTests/viewCheckout.test.ts create mode 100644 frontend/src/utils/viewCheckout.ts diff --git a/frontend/src/__tests__/unitTests/viewCheckout.test.ts b/frontend/src/__tests__/unitTests/viewCheckout.test.ts new file mode 100644 index 00000000..abda6df1 --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewCheckout.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const encoded = (state: unknown) => encodeURIComponent(JSON.stringify(state)); + +vi.mock('@/omezarr-helper', () => ({ + getOmeZarrMetadata: vi.fn(), + generateNeuroglancerStateForOmeZarr: vi.fn(), + generateNeuroglancerStateForDataURL: vi.fn() +})); + +import { + getOmeZarrMetadata, + generateNeuroglancerStateForOmeZarr +} from '@/omezarr-helper'; +import { buildViewState } from '@/utils/viewCheckout'; + +const md = { multiscales: [{}], arr: {}, zarrVersion: 2 }; + +beforeEach(() => { + (getOmeZarrMetadata as any).mockReset().mockResolvedValue(md); + (generateNeuroglancerStateForOmeZarr as any) + .mockReset() + .mockImplementation((url: string) => + encoded({ + dimensions: { x: [1, 'm'] }, + layout: '4panel-alt', + layers: [{ name: `${url}-L0`, type: 'image' }] + }) + ); +}); + +describe('buildViewState', () => { + it('concatenates layers across datasets and maps ViewLayerInput', async () => { + const { ng_state, layers } = await buildViewState([ + { url: 'a', sharing_key: 'ka', fsp_name: 'f', path: '/a', label: 'A' }, + { + url: 'b', + sharing_key: 'kb', + fsp_name: 'f', + path: '/b', + channel: 'GFP', + label: 'B' + } + ]); + const stateLayers = (ng_state as any).layers; + expect(stateLayers).toHaveLength(2); + expect((ng_state as any).dimensions).toEqual({ x: [1, 'm'] }); // first dataset's dims kept + expect(layers).toEqual([ + { sharing_key: 'ka', layer_index: 0, channel: null, opts: null }, + { sharing_key: 'kb', layer_index: 1, channel: 'GFP', opts: null } + ]); + }); + + it('falls back to the data-URL generator when there are no multiscales', async () => { + (getOmeZarrMetadata as any).mockResolvedValue({ + multiscales: undefined, + arr: {}, + zarrVersion: 2 + }); + ( + (await import('@/omezarr-helper')).generateNeuroglancerStateForDataURL as any + ).mockReturnValue(encoded({ layers: [{ name: 'fallback' }] })); + const { ng_state } = await buildViewState([ + { url: 'a', sharing_key: 'ka', fsp_name: 'f', path: '/a', label: 'A' } + ]); + expect((ng_state as any).layers).toHaveLength(1); + }); +}); diff --git a/frontend/src/utils/viewCheckout.ts b/frontend/src/utils/viewCheckout.ts new file mode 100644 index 00000000..400646b9 --- /dev/null +++ b/frontend/src/utils/viewCheckout.ts @@ -0,0 +1,107 @@ +import { + getOmeZarrMetadata, + generateNeuroglancerStateForOmeZarr, + generateNeuroglancerStateForDataURL +} from '@/omezarr-helper'; +import type { ViewLayerInput } from '@/queries/viewQueries'; +import { default as log } from '@/logger'; + +export type ResolvedCheckoutDataset = { + url: string; + sharing_key: string; + fsp_name: string; + path: string; + channel?: string; + label: string; +}; + +type NgLayer = Record & { name?: string }; +type NgState = Record & { layers?: NgLayer[] }; + +function decodeState(encoded: string | null): NgState | null { + if (!encoded) { + return null; + } + try { + return JSON.parse(decodeURIComponent(encoded)) as NgState; + } catch (error) { + log.error('Failed to decode generated Neuroglancer state', error); + return null; + } +} + +async function generateStateForDataset( + ds: ResolvedCheckoutDataset +): Promise { + const metadata = await getOmeZarrMetadata(ds.url); + const multiscale = metadata.multiscales?.[0]; + // ponytail: default layerType 'image' — the thumbnail-edge heuristic used + // for the single-dir preview needs a rendered thumbnail we don't have here. + const encoded = multiscale + ? generateNeuroglancerStateForOmeZarr( + ds.url, + metadata.zarrVersion, + 'image', + multiscale, + metadata.arr, + metadata.labels, + metadata.omero + ) + : generateNeuroglancerStateForDataURL(ds.url, metadata.zarrVersion); + return decodeState(encoded); +} + +// If the cart entry names a channel, keep only layers whose name matches it; +// otherwise keep every layer the dataset produced. +function selectLayers(state: NgState, channel?: string): NgLayer[] { + const layers = state.layers ?? []; + if (!channel) { + return layers; + } + const matched = layers.filter(l => + (l.name ?? '').toString().toLowerCase().includes(channel.toLowerCase()) + ); + return matched.length ? matched : layers; +} + +// ponytail: merge per-dataset generated states instead of refactoring the +// generator to emit objects. Ceiling: cross-dataset coordinate spaces aren't +// reconciled (first dataset's dimensions win) and layer_index>4 is archived +// per NG default — fine for curated read-only carts; revisit if +// mixed-resolution overlays misalign. +export async function buildViewState( + datasets: ResolvedCheckoutDataset[] +): Promise<{ ng_state: Record; layers: ViewLayerInput[] }> { + const combinedLayers: NgLayer[] = []; + const viewLayers: ViewLayerInput[] = []; + let base: NgState | null = null; + + for (const ds of datasets) { + const state = await generateStateForDataset(ds); + if (!state) { + continue; + } + if (!base) { + base = state; + } + for (const layer of selectLayers(state, ds.channel)) { + const layer_index = combinedLayers.length; + combinedLayers.push({ ...layer, archived: layer_index >= 4 }); + viewLayers.push({ + sharing_key: ds.sharing_key, + layer_index, + channel: ds.channel ?? null, + opts: null + }); + } + } + + const first = combinedLayers[0]; + const ng_state: Record = { + ...(base ?? {}), + layers: combinedLayers, + selectedLayer: first ? { visible: true, layer: first.name } : undefined, + layout: (base?.layout as string) ?? '4panel-alt' + }; + return { ng_state, layers: viewLayers }; +} From da524f46cee3aaf6762eb251e89eb6b27309b878 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 17:09:26 -0400 Subject: [PATCH 05/16] fix(views): don't let one broken dataset abort the whole checkout build --- .../__tests__/unitTests/viewCheckout.test.ts | 36 +++++++++++++++++ frontend/src/utils/viewCheckout.ts | 39 +++++++++++-------- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewCheckout.test.ts b/frontend/src/__tests__/unitTests/viewCheckout.test.ts index abda6df1..500c7df6 100644 --- a/frontend/src/__tests__/unitTests/viewCheckout.test.ts +++ b/frontend/src/__tests__/unitTests/viewCheckout.test.ts @@ -65,4 +65,40 @@ describe('buildViewState', () => { ]); expect((ng_state as any).layers).toHaveLength(1); }); + + it('skips a dataset whose metadata fetch throws and keeps the rest', async () => { + (getOmeZarrMetadata as any).mockRejectedValueOnce(new Error('gone')); + const { ng_state, layers } = await buildViewState([ + { url: 'a', sharing_key: 'ka', fsp_name: 'f', path: '/a', label: 'A' }, + { url: 'b', sharing_key: 'kb', fsp_name: 'f', path: '/b', label: 'B' } + ]); + expect((ng_state as any).layers).toHaveLength(1); + expect(layers).toEqual([ + { sharing_key: 'kb', layer_index: 0, channel: null, opts: null } + ]); + }); + + it('narrows layers to those matching the requested channel', async () => { + (generateNeuroglancerStateForOmeZarr as any).mockReturnValue( + encoded({ + layers: [ + { name: 'DAPI', type: 'image' }, + { name: 'GFP', type: 'image' } + ] + }) + ); + const { ng_state } = await buildViewState([ + { + url: 'a', + sharing_key: 'ka', + fsp_name: 'f', + path: '/a', + channel: 'GFP', + label: 'A' + } + ]); + expect((ng_state as any).layers).toEqual([ + { name: 'GFP', type: 'image', archived: false } + ]); + }); }); diff --git a/frontend/src/utils/viewCheckout.ts b/frontend/src/utils/viewCheckout.ts index 400646b9..7944d7fb 100644 --- a/frontend/src/utils/viewCheckout.ts +++ b/frontend/src/utils/viewCheckout.ts @@ -33,22 +33,29 @@ function decodeState(encoded: string | null): NgState | null { async function generateStateForDataset( ds: ResolvedCheckoutDataset ): Promise { - const metadata = await getOmeZarrMetadata(ds.url); - const multiscale = metadata.multiscales?.[0]; - // ponytail: default layerType 'image' — the thumbnail-edge heuristic used - // for the single-dir preview needs a rendered thumbnail we don't have here. - const encoded = multiscale - ? generateNeuroglancerStateForOmeZarr( - ds.url, - metadata.zarrVersion, - 'image', - multiscale, - metadata.arr, - metadata.labels, - metadata.omero - ) - : generateNeuroglancerStateForDataURL(ds.url, metadata.zarrVersion); - return decodeState(encoded); + try { + const metadata = await getOmeZarrMetadata(ds.url); + const multiscale = metadata.multiscales?.[0]; + // ponytail: default layerType 'image' — the thumbnail-edge heuristic used + // for the single-dir preview needs a rendered thumbnail we don't have here. + const encoded = multiscale + ? generateNeuroglancerStateForOmeZarr( + ds.url, + metadata.zarrVersion, + 'image', + multiscale, + metadata.arr, + metadata.labels, + metadata.omero + ) + : generateNeuroglancerStateForDataURL(ds.url, metadata.zarrVersion); + return decodeState(encoded); + } catch (error) { + // One broken cart entry (moved/deleted file, missing multiscale) must + // not abort the whole checkout — skip it, keep the rest. + log.error(`Failed to generate Neuroglancer state for ${ds.url}`, error); + return null; + } } // If the cart entry names a channel, keep only layers whose name matches it; From 9603bb367d54fd5307f7427cc2cdea7ac1b7f3e8 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 17:14:35 -0400 Subject: [PATCH 06/16] feat(views): add useCartCheckout (resolve links, build state, create View) --- .../componentTests/useCartCheckout.test.tsx | 64 ++++++++++++++++ frontend/src/hooks/useCartCheckout.ts | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/useCartCheckout.test.tsx create mode 100644 frontend/src/hooks/useCartCheckout.ts diff --git a/frontend/src/__tests__/componentTests/useCartCheckout.test.tsx b/frontend/src/__tests__/componentTests/useCartCheckout.test.tsx new file mode 100644 index 00000000..7e2a9fc0 --- /dev/null +++ b/frontend/src/__tests__/componentTests/useCartCheckout.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +const createProxied = vi.fn(); +const createViewAsync = vi.fn(); + +vi.mock('@/queries/proxiedPathQueries', () => ({ + useAllProxiedPathsQuery: () => ({ + data: [{ fsp_name: 'f', path: '/a', sharing_key: 'ka', url: 'http://a' }] + }), + useCreateProxiedPathMutation: () => ({ mutateAsync: createProxied }) +})); +vi.mock('@/contexts/ViewsContext', () => ({ + useViewsContext: () => ({ + createViewMutation: { mutateAsync: createViewAsync } + }) +})); +vi.mock('@/utils/viewCheckout', () => ({ + buildViewState: vi.fn().mockResolvedValue({ + ng_state: { layers: [] }, + layers: [{ sharing_key: 'ka', layer_index: 0, channel: null, opts: null }] + }) +})); + +import { useCartCheckout } from '@/hooks/useCartCheckout'; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return {children}; +} + +beforeEach(() => { + createProxied.mockReset().mockResolvedValue({ + fsp_name: 'f', + path: '/b', + sharing_key: 'kb', + url: 'http://b' + }); + createViewAsync.mockReset().mockResolvedValue({ short_key: 'v1', name: 'N' }); +}); + +describe('useCartCheckout', () => { + it('reuses existing links, creates missing ones, then creates the View', async () => { + const { result } = renderHook(() => useCartCheckout(), { wrapper }); + await result.current.checkout( + [ + { fsp_name: 'f', path: '/a', label: 'A' }, // existing link → no create + { fsp_name: 'f', path: '/b', label: 'B' } // missing → create + ], + 'My View' + ); + expect(createProxied).toHaveBeenCalledTimes(1); + expect(createProxied).toHaveBeenCalledWith( + expect.objectContaining({ fsp_name: 'f', path: '/b' }) + ); + expect(createViewAsync).toHaveBeenCalledWith( + expect.objectContaining({ name: 'My View', ng_state: { layers: [] } }) + ); + }); +}); diff --git a/frontend/src/hooks/useCartCheckout.ts b/frontend/src/hooks/useCartCheckout.ts new file mode 100644 index 00000000..92342128 --- /dev/null +++ b/frontend/src/hooks/useCartCheckout.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react'; + +import { + useAllProxiedPathsQuery, + useCreateProxiedPathMutation +} from '@/queries/proxiedPathQueries'; +import { useViewsContext } from '@/contexts/ViewsContext'; +import { buildViewState } from '@/utils/viewCheckout'; +import type { ResolvedCheckoutDataset } from '@/utils/viewCheckout'; +import { normalizeFspRootPath } from '@/utils/pathHandling'; +import type { CartItem } from '@/queries/preferencesQueries'; +import type { View } from '@/queries/viewQueries'; +import type { ProxiedPath } from '@/contexts/ProxiedPathContext'; + +// Same normalization handleCreateDataLink applies before creating a Data +// Link (FSP root "." -> ""), so lookups against the list match what was +// actually created/stored instead of missing and creating duplicates. +const datasetKey = (fsp_name: string, path: string) => + `${fsp_name}::${normalizeFspRootPath(path)}`; + +export function useCartCheckout() { + const allProxiedPathsQuery = useAllProxiedPathsQuery(); + const createProxiedPath = useCreateProxiedPathMutation(); + const { createViewMutation } = useViewsContext(); + + const checkout = useCallback( + async (datasets: CartItem[], name: string): Promise => { + const existing = new Map( + (allProxiedPathsQuery.data ?? []).map(p => [ + datasetKey(p.fsp_name, p.path), + p + ]) + ); + + // Resolve one Data Link per unique (fsp_name, path); create if missing. + const linkByKey = new Map(); + for (const ds of datasets) { + const key = datasetKey(ds.fsp_name, ds.path); + if (linkByKey.has(key)) { + continue; + } + const link = + existing.get(key) ?? + (await createProxiedPath.mutateAsync({ + fsp_name: ds.fsp_name, + path: normalizeFspRootPath(ds.path) + })); + linkByKey.set(key, link); + } + + const resolved: ResolvedCheckoutDataset[] = datasets.map(ds => { + const link = linkByKey.get(datasetKey(ds.fsp_name, ds.path)); + if (!link) { + throw new Error( + `No resolved Data Link for ${ds.fsp_name}:${ds.path}` + ); + } + return { + url: link.url, + sharing_key: link.sharing_key, + fsp_name: ds.fsp_name, + path: ds.path, + channel: ds.channel, + label: ds.label + }; + }); + + const { ng_state, layers } = await buildViewState(resolved); + return createViewMutation.mutateAsync({ name, ng_state, layers }); + }, + [allProxiedPathsQuery.data, createProxiedPath, createViewMutation] + ); + + return { checkout }; +} From 7508ebaf48fdae211f28e87cb6a7455face99ceb Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 17:22:54 -0400 Subject: [PATCH 07/16] feat(views): add consent-gated CreateViewButton (batch checkout) --- .../componentTests/CreateViewButton.test.tsx | 54 +++++++ .../components/ui/Views/CreateViewButton.tsx | 152 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/CreateViewButton.test.tsx create mode 100644 frontend/src/components/ui/Views/CreateViewButton.tsx diff --git a/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx b/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx new file mode 100644 index 00000000..0b1ed280 --- /dev/null +++ b/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const checkout = vi.fn().mockResolvedValue({ short_key: 'v1', name: 'N' }); +let automatic = true; + +vi.mock('@/hooks/useCartCheckout', () => ({ + useCartCheckout: () => ({ checkout }) +})); +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferencesContext: () => ({ + areDataLinksAutomatic: automatic, + dataLinkSubpathMode: 'full_path', + toggleAutomaticDataLinks: vi.fn() + }) +})); +vi.mock('@/queries/proxiedPathQueries', () => ({ + useAllProxiedPathsQuery: () => ({ data: [] }) // nothing exists → 1 new link +})); +vi.mock('react-router', () => ({ useNavigate: () => vi.fn() })); + +import CreateViewButton from '@/components/ui/Views/CreateViewButton'; + +const datasets = [{ fsp_name: 'f', path: '/a', label: 'A' }]; + +beforeEach(() => { + checkout.mockClear(); + automatic = true; +}); + +describe('CreateViewButton', () => { + it('checks out directly when data links are automatic', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /create view/i })); + expect(checkout).toHaveBeenCalledWith(datasets, 'V'); + }); + + it('shows a consent dialog when not automatic, then checks out on confirm', async () => { + automatic = false; + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /create view/i })); + expect(checkout).not.toHaveBeenCalled(); + // consent dialog visible → confirm + await user.click( + await screen.findByRole('button', { + name: /create.*view|confirm|continue/i + }) + ); + expect(checkout).toHaveBeenCalledWith(datasets, 'V'); + }); +}); diff --git a/frontend/src/components/ui/Views/CreateViewButton.tsx b/frontend/src/components/ui/Views/CreateViewButton.tsx new file mode 100644 index 00000000..00c6e36a --- /dev/null +++ b/frontend/src/components/ui/Views/CreateViewButton.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { Typography } from '@material-tailwind/react'; +import toast from 'react-hot-toast'; +import { useNavigate } from 'react-router'; + +import { useCartCheckout } from '@/hooks/useCartCheckout'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useAllProxiedPathsQuery } from '@/queries/proxiedPathQueries'; +import { normalizeFspRootPath } from '@/utils/pathHandling'; +import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgSwitch from '@/components/designSystem/atoms/formElements/FgSwitch'; +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import type { CartItem } from '@/queries/preferencesQueries'; +import type { View } from '@/queries/viewQueries'; + +interface CreateViewButtonProps { + readonly datasets: CartItem[]; + readonly defaultName: string; + readonly label?: string; + readonly disabled?: boolean; + readonly onCreated?: (view: View) => void; +} + +// Same normalization useCartCheckout applies before comparing/creating Data +// Links, so this count matches what checkout will actually create. +const datasetKey = (fsp_name: string, path: string) => + `${fsp_name}::${normalizeFspRootPath(path)}`; + +export default function CreateViewButton({ + datasets, + defaultName, + label = 'Create View', + disabled = false, + onCreated +}: CreateViewButtonProps) { + const { checkout } = useCartCheckout(); + const { + areDataLinksAutomatic, + dataLinkSubpathMode, + toggleAutomaticDataLinks + } = usePreferencesContext(); + const allProxiedPathsQuery = useAllProxiedPathsQuery(); + const navigate = useNavigate(); + + const [showConsent, setShowConsent] = useState(false); + const [pending, setPending] = useState(false); + const [newLinkCount, setNewLinkCount] = useState(0); + + const runCheckout = async () => { + setPending(true); + try { + const view = await checkout(datasets, defaultName); + toast.success(`Created View "${defaultName}"`); + if (onCreated) { + onCreated(view); + } else { + navigate('/ngviews'); + } + setShowConsent(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Checkout failed'); + } finally { + setPending(false); + } + }; + + const handleClick = () => { + if (datasets.length === 0) { + toast.error('Nothing to add'); + return; + } + + const existingKeys = new Set( + (allProxiedPathsQuery.data ?? []).map(p => datasetKey(p.fsp_name, p.path)) + ); + const computedNewLinkCount = new Set( + datasets + .map(ds => datasetKey(ds.fsp_name, ds.path)) + .filter(key => !existingKeys.has(key)) + ).size; + + const autoLinksCoverThis = + areDataLinksAutomatic && dataLinkSubpathMode !== 'custom'; + + if (autoLinksCoverThis || computedNewLinkCount === 0) { + void runCheckout(); + return; + } + + setNewLinkCount(computedNewLinkCount); + setShowConsent(true); + }; + + return ( + <> + {!showConsent ? ( + + {label} + + ) : null} + + {showConsent ? ( + setShowConsent(false)} open={showConsent}> +
+ + Are you sure you want to create a data link? + + + This will create {newLinkCount} data link + {newLinkCount === 1 ? '' : 's'} and 1 View. If you share the data + link(s) with internal collaborators, they will be able to view + these data. + +
+ + Don't ask me this again: + + { + void toggleAutomaticDataLinks(); + }} + /> +
+
+ + Continue + + setShowConsent(false)} + variant="ghost" + > + Cancel + +
+
+
+ ) : null} + + ); +} From 808426224a1e2cd74ba1f00f2ceffc7e9caca1e2 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 17:35:12 -0400 Subject: [PATCH 08/16] feat(browse): add "Add to Neuroglancer cart" row action Wire useCartContext into FileBrowser and add a context-menu item that adds the right-clicked folder to the Neuroglancer cart, guarded by the same is_dir && !is_symlink condition the existing folder-only menu items (Set favorite, Convert to OME-Zarr) already use. Also add CartProvider to the shared test harness (test-utils.tsx), matching its real nesting in MainLayout -- FileBrowser is a shared surface and now depends on CartContext, which the harness didn't provide, breaking FileTableSelectColumn.test.tsx. --- .../FileBrowserCartItem.test.tsx | 150 ++++++++++++++++++ frontend/src/__tests__/test-utils.tsx | 21 +-- .../components/ui/BrowsePage/FileBrowser.tsx | 22 +++ 3 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx diff --git a/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx b/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx new file mode 100644 index 00000000..ac96fa5d --- /dev/null +++ b/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import toast from 'react-hot-toast'; + +import { render } from '../test-utils'; +import { server } from '@/__tests__/mocks/node'; +import FileBrowser from '@/components/ui/BrowsePage/FileBrowser'; +import type { FileOrFolder } from '@/shared.types'; + +const addToCart = vi.fn(); + +vi.mock('@/contexts/CartContext', async importOriginal => { + const actual = + await importOriginal(); + return { + ...actual, + useCartContext: () => ({ + cart: [], + cartCount: 0, + addToCart, + removeFromCart: vi.fn(), + clearCart: vi.fn() + }) + }; +}); + +// FileTable virtualizes rows in a way that doesn't render meaningfully in +// jsdom. Stub it with plain buttons that invoke the same +// handleContextMenuClick callback FileBrowser wires up, so the test can +// drive FileBrowser's real context-menu-item logic (name/shouldShow/action) +// without depending on virtualized-row DOM mechanics. +vi.mock('@/components/ui/BrowsePage/FileTable', () => ({ + default: ({ + data, + handleContextMenuClick + }: { + data: FileOrFolder[]; + handleContextMenuClick: (e: unknown, file: FileOrFolder) => void; + }) => ( +
+ {data.map(file => ( + + ))} +
+ ) +})); + +const noop = vi.fn(); + +function renderFileBrowser() { + return render(, { initialEntries: ['/browse/test_fsp/my_folder'] }); +} + +describe('FileBrowser row context menu - Add to Neuroglancer cart', () => { + beforeEach(() => { + addToCart.mockClear(); + vi.mocked(toast.success).mockClear(); + + server.use( + http.get('/api/files/:fspName', ({ params, request }) => { + const { fspName } = params; + if (fspName !== 'test_fsp') { + return HttpResponse.json({ error: 'Not found' }, { status: 404 }); + } + const url = new URL(request.url); + const subpath = url.searchParams.get('subpath') ?? 'my_folder'; + return HttpResponse.json({ + info: { + name: subpath.split('/').pop(), + path: subpath, + size: 0, + is_dir: true, + permissions: 'drwxr-xr-x', + owner: 'testuser', + group: 'testgroup', + last_modified: 1647855213 + }, + files: [ + { name: 'subfolder', is_dir: true, path: `${subpath}/subfolder` }, + { name: 'file1.txt', is_dir: false, path: `${subpath}/file1.txt` }, + { + name: 'linked_folder', + is_dir: true, + is_symlink: true, + symlink_target_fsp: null, + path: `${subpath}/linked_folder` + } + ] + }); + }) + ); + }); + + it('adds a folder to the cart and toasts success', async () => { + const user = userEvent.setup(); + renderFileBrowser(); + + const menuButton = await screen.findByText('menu-subfolder'); + await user.click(menuButton); + + const cartItem = await screen.findByText('Add to Neuroglancer cart'); + await user.click(cartItem); + + expect(addToCart).toHaveBeenCalledWith([ + { fsp_name: 'test_fsp', path: 'my_folder/subfolder', label: 'subfolder' } + ]); + expect(toast.success).toHaveBeenCalledWith( + 'Added "subfolder" to the Neuroglancer cart' + ); + }); + + it('does not show the item for a plain file', async () => { + const user = userEvent.setup(); + renderFileBrowser(); + + const menuButton = await screen.findByText('menu-file1.txt'); + await user.click(menuButton); + + await waitFor(() => { + expect(screen.getByText('Download')).toBeInTheDocument(); + }); + expect( + screen.queryByText('Add to Neuroglancer cart') + ).not.toBeInTheDocument(); + expect(addToCart).not.toHaveBeenCalled(); + }); + + it('does not show the item for a symlinked folder', async () => { + const user = userEvent.setup(); + renderFileBrowser(); + + const menuButton = await screen.findByText('menu-linked_folder'); + await user.click(menuButton); + + await waitFor(() => { + expect(screen.getByText('Rename')).toBeInTheDocument(); + }); + expect( + screen.queryByText('Add to Neuroglancer cart') + ).not.toBeInTheDocument(); + expect(addToCart).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/test-utils.tsx b/frontend/src/__tests__/test-utils.tsx index f3bcce0c..fbac5c52 100644 --- a/frontend/src/__tests__/test-utils.tsx +++ b/frontend/src/__tests__/test-utils.tsx @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ZonesAndFspMapContextProvider } from '@/contexts/ZonesAndFspMapContext'; import { FileBrowserContextProvider } from '@/contexts/FileBrowserContext'; import { PreferencesProvider } from '@/contexts/PreferencesContext'; +import { CartProvider } from '@/contexts/CartContext'; import { ProxiedPathProvider } from '@/contexts/ProxiedPathContext'; import { OpenFavoritesProvider } from '@/contexts/OpenFavoritesContext'; import { TicketProvider } from '@/contexts/TicketsContext'; @@ -41,15 +42,17 @@ const Browse = ({ children }: { children: ReactNode }) => { - - - - - {children} - - - - + + + + + + {children} + + + + + diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index 149071e0..927b6ad0 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -18,6 +18,7 @@ import useZarrMetadata from '@/hooks/useZarrMetadata'; import useN5Metadata from '@/hooks/useN5Metadata'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useCartContext } from '@/contexts/CartContext'; import useHideDotFiles from '@/hooks/useHideDotFiles'; import { useHandleDownload } from '@/hooks/useHandleDownload'; import { useHandleView } from '@/hooks/useHandleView'; @@ -61,6 +62,7 @@ export default function FileBrowser({ } = useFileBrowserContext(); const { folderPreferenceMap, handleContextMenuFavorite } = usePreferencesContext(); + const { addToCart } = useCartContext(); const { displayFiles } = useHideDotFiles(); const { handleDownload } = useHandleDownload(); const { handleView } = useHandleView(); @@ -188,6 +190,26 @@ export default function FileBrowser({ fileBrowserState.selectedFiles[0]?.is_dir && !fileBrowserState.selectedFiles[0]?.is_symlink }, + { + name: 'Add to Neuroglancer cart', + action: () => { + const file = fileBrowserState.selectedFiles[0]; + if (!file) { + return; + } + addToCart([ + { + fsp_name: fileQuery.data?.currentFileSharePath?.name ?? '', + path: file.path, + label: file.name + } + ]); + toast.success(`Added "${file.name}" to the Neuroglancer cart`); + }, + shouldShow: + fileBrowserState.selectedFiles[0]?.is_dir && + !fileBrowserState.selectedFiles[0]?.is_symlink + }, { name: 'Rename', action: () => { From 284d8cb5e75bccb42557ac9895cd8797460460f5 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 17:40:06 -0400 Subject: [PATCH 09/16] fix(browse): await addToCart and toast.error on cart-add failure The "Add to Neuroglancer cart" menu item called addToCart without awaiting it and toasted success unconditionally, so a rejected preference mutation left an unhandled rejection and a false-positive success toast. Await it in a try/catch and mirror the sibling Set favorite item's error-toast pattern. --- .../FileBrowserCartItem.test.tsx | 21 +++++++++++++++ .../components/ui/BrowsePage/FileBrowser.tsx | 26 ++++++++++++------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx b/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx index ac96fa5d..1ac582e1 100644 --- a/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx +++ b/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx @@ -61,7 +61,9 @@ function renderFileBrowser() { describe('FileBrowser row context menu - Add to Neuroglancer cart', () => { beforeEach(() => { addToCart.mockClear(); + addToCart.mockResolvedValue(undefined); vi.mocked(toast.success).mockClear(); + vi.mocked(toast.error).mockClear(); server.use( http.get('/api/files/:fspName', ({ params, request }) => { @@ -116,6 +118,25 @@ describe('FileBrowser row context menu - Add to Neuroglancer cart', () => { ); }); + it('toasts an error and does not toast success when addToCart rejects', async () => { + addToCart.mockRejectedValueOnce(new Error('preference update failed')); + const user = userEvent.setup(); + renderFileBrowser(); + + const menuButton = await screen.findByText('menu-subfolder'); + await user.click(menuButton); + + const cartItem = await screen.findByText('Add to Neuroglancer cart'); + await user.click(cartItem); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + 'Error adding "subfolder" to the Neuroglancer cart: preference update failed' + ); + }); + expect(toast.success).not.toHaveBeenCalled(); + }); + it('does not show the item for a plain file', async () => { const user = userEvent.setup(); renderFileBrowser(); diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index 927b6ad0..5413b982 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -192,19 +192,27 @@ export default function FileBrowser({ }, { name: 'Add to Neuroglancer cart', - action: () => { + action: async () => { const file = fileBrowserState.selectedFiles[0]; if (!file) { return; } - addToCart([ - { - fsp_name: fileQuery.data?.currentFileSharePath?.name ?? '', - path: file.path, - label: file.name - } - ]); - toast.success(`Added "${file.name}" to the Neuroglancer cart`); + try { + await addToCart([ + { + fsp_name: fileQuery.data?.currentFileSharePath?.name ?? '', + path: file.path, + label: file.name + } + ]); + toast.success(`Added "${file.name}" to the Neuroglancer cart`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error( + `Error adding "${file.name}" to the Neuroglancer cart: ${errorMessage}` + ); + } }, shouldShow: fileBrowserState.selectedFiles[0]?.is_dir && From 451a3b9be4a818288b95727fb144349b8ec46498 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 17:45:27 -0400 Subject: [PATCH 10/16] feat(browse): floating selection bar (add to cart / new View from selection) --- .../componentTests/SelectionBar.test.tsx | 87 +++++++++++++++++++ .../components/ui/BrowsePage/FileBrowser.tsx | 2 + .../components/ui/BrowsePage/SelectionBar.tsx | 62 +++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/SelectionBar.test.tsx create mode 100644 frontend/src/components/ui/BrowsePage/SelectionBar.tsx diff --git a/frontend/src/__tests__/componentTests/SelectionBar.test.tsx b/frontend/src/__tests__/componentTests/SelectionBar.test.tsx new file mode 100644 index 00000000..2807a57e --- /dev/null +++ b/frontend/src/__tests__/componentTests/SelectionBar.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import toast from 'react-hot-toast'; + +const clearChecked = vi.fn(); +const addToCart = vi.fn().mockResolvedValue(undefined); + +const twoCheckedFiles = [ + { name: 'a.txt', path: '/dir/a.txt' }, + { name: 'b.txt', path: '/dir/b.txt' } +]; +let checkedFiles: typeof twoCheckedFiles = twoCheckedFiles; + +vi.mock('@/contexts/FileBrowserContext', () => ({ + useFileBrowserContext: () => ({ + fileBrowserState: { checkedFiles }, + clearChecked, + fileQuery: { data: { currentFileSharePath: { name: 'myFsp' } } } + }) +})); + +vi.mock('@/contexts/CartContext', () => ({ + useCartContext: () => ({ addToCart }) +})); + +vi.mock('@/components/ui/Views/CreateViewButton', () => ({ + default: () => +})); + +import SelectionBar from '@/components/ui/BrowsePage/SelectionBar'; + +beforeEach(() => { + clearChecked.mockClear(); + addToCart.mockClear(); + vi.mocked(toast.success).mockClear(); + vi.mocked(toast.error).mockClear(); + checkedFiles = twoCheckedFiles; +}); + +describe('SelectionBar', () => { + it('shows the count and adds mapped items to cart', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText('2 selected')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /add 2 to cart/i })); + expect(addToCart).toHaveBeenCalledWith([ + { fsp_name: 'myFsp', path: '/dir/a.txt', label: 'a.txt' }, + { fsp_name: 'myFsp', path: '/dir/b.txt', label: 'b.txt' } + ]); + expect(toast.success).toHaveBeenCalled(); + }); + + it('reports an error toast when addToCart rejects', async () => { + addToCart.mockRejectedValueOnce(new Error('boom')); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /add 2 to cart/i })); + expect(toast.error).toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('renders the create-View action wired to the selection', () => { + render(); + expect( + screen.getByRole('button', { name: /new view from selection/i }) + ).toBeInTheDocument(); + }); + + it('clears the selection when Clear is clicked', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /clear/i })); + expect(clearChecked).toHaveBeenCalled(); + }); +}); + +describe('SelectionBar when nothing is checked', () => { + it('renders nothing', () => { + checkedFiles = []; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index 5413b982..86a771ea 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -9,6 +9,7 @@ import ZarrPreview from './ZarrPreview'; import N5Preview from './N5Preview'; import Table from './FileTable'; import FileViewer from './FileViewer'; +import SelectionBar from './SelectionBar'; import ContextMenu, { type ContextMenuItem } from '@/components/ui/Menus/ContextMenu'; @@ -366,6 +367,7 @@ export default function FileBrowser({ y={contextMenuCoords.y} /> ) : null} + ); } diff --git a/frontend/src/components/ui/BrowsePage/SelectionBar.tsx b/frontend/src/components/ui/BrowsePage/SelectionBar.tsx new file mode 100644 index 00000000..b0f561e3 --- /dev/null +++ b/frontend/src/components/ui/BrowsePage/SelectionBar.tsx @@ -0,0 +1,62 @@ +import toast from 'react-hot-toast'; + +import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { useCartContext } from '@/contexts/CartContext'; +import CreateViewButton from '@/components/ui/Views/CreateViewButton'; +import FgButton from '@/components/designSystem/atoms/FgButton'; +import type { CartItem } from '@/contexts/CartContext'; + +// ponytail: bar carries the two net-new cart actions + clear. Multi-select +// Share/Download/"More" (design §6) generalize existing single-file actions +// — deferred; add when a concrete need lands. +export default function SelectionBar() { + const { fileBrowserState, clearChecked, fileQuery } = useFileBrowserContext(); + const { addToCart } = useCartContext(); + + const { checkedFiles } = fileBrowserState; + + if (checkedFiles.length === 0) { + return null; + } + + const fspName = fileQuery.data?.currentFileSharePath?.name ?? ''; + const selectionDatasets: CartItem[] = checkedFiles.map(file => ({ + fsp_name: fspName, + path: file.path, + label: file.name + })); + + const handleAddToCart = async () => { + try { + await addToCart(selectionDatasets); + toast.success( + `Added ${checkedFiles.length} items to the Neuroglancer cart` + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error( + `Error adding items to the Neuroglancer cart: ${errorMessage}` + ); + } + }; + + return ( +
+ + {checkedFiles.length} selected + + void handleAddToCart()}> + Add {checkedFiles.length} to cart + + + + Clear + +
+ ); +} From 805718ce2599a027a298ba7028171cfe869f54fe Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 17:56:20 -0400 Subject: [PATCH 11/16] feat(views): full Layer Cart tab with channel selection + Create View --- .../__tests__/componentTests/CartTab.test.tsx | 148 ++++++++++++++++++ .../__tests__/componentTests/NGViews.test.tsx | 6 + frontend/src/components/NGViews.tsx | 102 +++++++++--- .../components/ui/Views/CartDatasetRow.tsx | 135 ++++++++++++++++ 4 files changed, 371 insertions(+), 20 deletions(-) create mode 100644 frontend/src/__tests__/componentTests/CartTab.test.tsx create mode 100644 frontend/src/components/ui/Views/CartDatasetRow.tsx diff --git a/frontend/src/__tests__/componentTests/CartTab.test.tsx b/frontend/src/__tests__/componentTests/CartTab.test.tsx new file mode 100644 index 00000000..3e8823e1 --- /dev/null +++ b/frontend/src/__tests__/componentTests/CartTab.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; + +import type { View } from '@/queries/viewQueries'; +import type { CartItem } from '@/contexts/CartContext'; + +const view: View = { + short_key: 'k1', + read_key: 'r1', + name: 'Seeded View', + ng_state: {}, + sharing_mode: 'read', + owner: 'me', + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-02T00:00:00Z', + layers: [] +}; + +// Dataset A has an existing Data Link (channel expansion enabled). +// Dataset B has no Data Link (channel expansion disabled + hint). +const cartA: CartItem = { fsp_name: 'fsp1', path: '/a', label: 'Dataset A' }; +const cartB: CartItem = { fsp_name: 'fsp2', path: '/b', label: 'Dataset B' }; + +const { addToCart, removeFromCart, clearCart, getOmeZarrChannels } = vi.hoisted( + () => ({ + addToCart: vi.fn().mockResolvedValue(undefined), + removeFromCart: vi.fn().mockResolvedValue(undefined), + clearCart: vi.fn().mockResolvedValue(undefined), + getOmeZarrChannels: vi.fn().mockResolvedValue(['DAPI', 'GFP']) + }) +); + +vi.mock('@/contexts/ViewsContext', () => ({ + useViewsContext: () => ({ + allViewsQuery: { data: [view], error: null, isPending: false }, + createViewMutation: { mutateAsync: vi.fn(), isPending: false }, + updateViewMutation: { mutateAsync: vi.fn(), isPending: false }, + deleteViewMutation: { mutateAsync: vi.fn(), isPending: false } + }) +})); +vi.mock('@/contexts/CartContext', () => ({ + useCartContext: () => ({ + cart: [cartA, cartB], + cartCount: 2, + addToCart, + removeFromCart, + clearCart + }) +})); +vi.mock('@/hooks/useDefaultNeuroglancerBaseUrl', () => ({ + useDefaultNeuroglancerBaseUrl: () => 'https://ng.example/' +})); +vi.mock('@/omezarr-helper', () => ({ + getOmeZarrChannels +})); +vi.mock('@/queries/proxiedPathQueries', () => ({ + useAllProxiedPathsQuery: () => ({ + data: [ + { + fsp_name: 'fsp1', + path: '/a', + url: 'https://data.example/a', + sharing_key: 'k1' + } + ], + error: null, + isPending: false + }) +})); +vi.mock('@/components/ui/Views/CreateViewButton', () => ({ + default: ({ label }: { label?: string }) => ( + + ) +})); + +import NGViews from '@/components/NGViews'; + +beforeEach(() => { + addToCart.mockClear(); + removeFromCart.mockClear(); + clearCart.mockClear(); + getOmeZarrChannels.mockClear(); +}); + +async function renderCartTab() { + const user = userEvent.setup(); + render( + + + + ); + await user.click(screen.getByRole('button', { name: /layer cart/i })); + return user; +} + +describe('Layer Cart tab', () => { + it('lists both cart datasets grouped by (fsp_name, path)', async () => { + await renderCartTab(); + expect(screen.getByText('Dataset A')).toBeInTheDocument(); + expect(screen.getByText('Dataset B')).toBeInTheDocument(); + }); + + it('lazy-loads and shows channels when expanding a dataset with a Data Link', async () => { + const user = await renderCartTab(); + await user.click(screen.getByRole('button', { name: 'Dataset A' })); + + await waitFor(() => { + expect(getOmeZarrChannels).toHaveBeenCalledWith('https://data.example/a'); + }); + expect(await screen.findByText('DAPI')).toBeInTheDocument(); + expect(screen.getByText('GFP')).toBeInTheDocument(); + }); + + it('disables expansion and shows a hint for a dataset with no Data Link', async () => { + await renderCartTab(); + const expandButton = screen.getByRole('button', { name: 'Dataset B' }); + expect(expandButton).toBeDisabled(); + expect( + screen.getByText(/channels load after the view is created/i) + ).toBeInTheDocument(); + expect(getOmeZarrChannels).not.toHaveBeenCalled(); + }); + + it('toggling a channel checkbox adds a channel-specific CartItem', async () => { + const user = await renderCartTab(); + await user.click(screen.getByRole('button', { name: 'Dataset A' })); + const dapiCheckbox = await screen.findByLabelText('DAPI'); + await user.click(dapiCheckbox); + + await waitFor(() => { + expect(addToCart).toHaveBeenCalledWith([ + { fsp_name: 'fsp1', path: '/a', channel: 'DAPI', label: 'DAPI' } + ]); + }); + }); + + it('shows the Create View control and a Clear cart button', async () => { + await renderCartTab(); + expect( + screen.getByRole('button', { name: /create view/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /clear cart/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/componentTests/NGViews.test.tsx b/frontend/src/__tests__/componentTests/NGViews.test.tsx index ca3af60c..7a105288 100644 --- a/frontend/src/__tests__/componentTests/NGViews.test.tsx +++ b/frontend/src/__tests__/componentTests/NGViews.test.tsx @@ -37,6 +37,12 @@ vi.mock('@/contexts/CartContext', () => ({ vi.mock('@/hooks/useDefaultNeuroglancerBaseUrl', () => ({ useDefaultNeuroglancerBaseUrl: () => 'https://ng.example/' })); +vi.mock('@/queries/proxiedPathQueries', () => ({ + useAllProxiedPathsQuery: () => ({ data: [], error: null, isPending: false }) +})); +vi.mock('@/components/ui/Views/CreateViewButton', () => ({ + default: () => +})); import NGViews from '@/components/NGViews'; diff --git a/frontend/src/components/NGViews.tsx b/frontend/src/components/NGViews.tsx index 64b13c26..6b00e61b 100644 --- a/frontend/src/components/NGViews.tsx +++ b/frontend/src/components/NGViews.tsx @@ -1,26 +1,85 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; import { TableCard } from '@/components/ui/Table/TableCard'; import { useNGViewsColumns } from '@/components/ui/Table/ngViewsColumns'; import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import CartDatasetRow from '@/components/ui/Views/CartDatasetRow'; +import CreateViewButton from '@/components/ui/Views/CreateViewButton'; import FgButton from '@/components/designSystem/atoms/FgButton'; import FgBadge from '@/components/designSystem/atoms/FgBadge'; import FgInput from '@/components/designSystem/atoms/formElements/FgInput'; import { useViewsContext } from '@/contexts/ViewsContext'; import { useCartContext } from '@/contexts/CartContext'; import { useDefaultNeuroglancerBaseUrl } from '@/hooks/useDefaultNeuroglancerBaseUrl'; +import { useAllProxiedPathsQuery } from '@/queries/proxiedPathQueries'; +import { normalizeFspRootPath } from '@/utils/pathHandling'; import type { View } from '@/queries/viewQueries'; +import type { CartItem } from '@/contexts/CartContext'; type ViewsTab = 'views' | 'cart'; +// Same normalization useCartCheckout/CreateViewButton apply before +// comparing against the proxied-path list, so this lookup matches what +// checkout will actually resolve/create for the same dataset. +const datasetKey = (fsp_name: string, path: string) => + `${fsp_name}::${normalizeFspRootPath(path)}`; + +type CartGroup = { + fsp_name: string; + path: string; + label: string; + items: CartItem[]; +}; + +function groupCartByDataset(cart: CartItem[]): CartGroup[] { + const groups = new Map(); + for (const item of cart) { + const key = datasetKey(item.fsp_name, item.path); + const existing = groups.get(key); + if (existing) { + existing.items.push(item); + // Prefer the base (no-channel) entry's label for the dataset row. + if (!item.channel) { + existing.label = item.label; + } + } else { + groups.set(key, { + fsp_name: item.fsp_name, + path: item.path, + label: item.label, + items: [item] + }); + } + } + return Array.from(groups.values()); +} + export default function NGViews() { const { allViewsQuery, updateViewMutation, deleteViewMutation } = useViewsContext(); - const { cart, cartCount, removeFromCart, clearCart } = useCartContext(); + const { cart, cartCount, clearCart } = useCartContext(); + const allProxiedPathsQuery = useAllProxiedPathsQuery(); const baseUrl = useDefaultNeuroglancerBaseUrl(); + const cartGroups = useMemo(() => groupCartByDataset(cart), [cart]); + const dataLinkUrlByDataset = useMemo(() => { + const map = new Map(); + for (const p of allProxiedPathsQuery.data ?? []) { + map.set(datasetKey(p.fsp_name, p.path), p.url); + } + return map; + }, [allProxiedPathsQuery.data]); + + const handleClearCart = async () => { + try { + await clearCart(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Clear failed'); + } + }; + const [tab, setTab] = useState('views'); const [renameItem, setRenameItem] = useState(undefined); const [renameValue, setRenameValue] = useState(''); @@ -121,28 +180,31 @@ export default function NGViews() { ) : ( <> - {cart.map(entry => ( -
- - {entry.label} - - removeFromCart(entry.path, entry.channel)} - variant="ghost" - > - Remove - -
+ {cartGroups.map(group => ( + ))} -
- clearCart()} variant="ghost"> +
+ + void handleClearCart()} + variant="ghost" + > Clear cart
- {/* Create View checkout + Fiji-style tree land in PR 5. */} )}
diff --git a/frontend/src/components/ui/Views/CartDatasetRow.tsx b/frontend/src/components/ui/Views/CartDatasetRow.tsx new file mode 100644 index 00000000..4cc70252 --- /dev/null +++ b/frontend/src/components/ui/Views/CartDatasetRow.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import { Collapse, Typography } from '@material-tailwind/react'; +import { HiChevronRight } from 'react-icons/hi'; +import toast from 'react-hot-toast'; + +import FgIcon from '@/components/designSystem/atoms/FgIcon'; +import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgCheckbox from '@/components/designSystem/atoms/formElements/FgCheckbox'; +import { useCartContext } from '@/contexts/CartContext'; +import { getOmeZarrChannels } from '@/omezarr-helper'; +import type { CartItem } from '@/contexts/CartContext'; + +interface CartDatasetRowProps { + readonly fsp_name: string; + readonly path: string; + readonly label: string; + readonly items: CartItem[]; + readonly dataLinkUrl: string | undefined; +} + +// ponytail: two-level dataset->channel tree via MT Collapse (no generic +// TreeView exists). Channel URL comes from an existing Data Link; if a +// dataset has no link yet, expansion is disabled with a hint rather than +// creating a link just to browse channels. Non-Zarr/N5 datasets simply fail +// getOmeZarrChannels gracefully (toast) instead of a hard pre-check. +export default function CartDatasetRow({ + fsp_name, + path, + label, + items, + dataLinkUrl +}: CartDatasetRowProps) { + const { addToCart, removeFromCart } = useCartContext(); + const [isOpen, setIsOpen] = useState(false); + const [channels, setChannels] = useState(undefined); + const [loadingChannels, setLoadingChannels] = useState(false); + + const checkedChannels = new Set( + items.map(item => item.channel).filter((c): c is string => Boolean(c)) + ); + + const handleToggleOpen = async () => { + const nextOpen = !isOpen; + setIsOpen(nextOpen); + if (nextOpen && channels === undefined && !loadingChannels && dataLinkUrl) { + setLoadingChannels(true); + try { + setChannels(await getOmeZarrChannels(dataLinkUrl)); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to load channels' + ); + setChannels([]); + } finally { + setLoadingChannels(false); + } + } + }; + + const handleRemoveDataset = async () => { + try { + for (const item of items) { + await removeFromCart(item.path, item.channel); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to remove dataset' + ); + } + }; + + const handleToggleChannel = async (channel: string, checked: boolean) => { + try { + if (checked) { + await addToCart([{ fsp_name, path, channel, label: channel }]); + } else { + await removeFromCart(path, channel); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to update channel' + ); + } + }; + + return ( +
+
+ + void handleRemoveDataset()} variant="ghost"> + Remove + +
+ + {dataLinkUrl ? ( + +
+ {loadingChannels ? ( + + Loading channels... + + ) : ( + (channels ?? []).map(channel => ( + + void handleToggleChannel(channel, e.target.checked) + } + /> + )) + )} +
+
+ ) : ( + + Channels load after the View is created. + + )} +
+ ); +} From 8aee2103c08b2cd1f2f9333f970635f00465f28a Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 18:07:28 -0400 Subject: [PATCH 12/16] fix(cart): batch dataset removal to fix stale-closure bug losing entries removeFromCart in a loop closed over the same pre-loop cart snapshot on every awaited iteration, so removing a multi-entry dataset (base + checked channels) only persisted the last removal - earlier entries got reintroduced. Add removeManyFromCart (single filter, single persist) and use it from CartDatasetRow's remove-all. Also stop a channel-only entry's label from leaking into the dataset row header when no base entry exists. --- .../componentTests/CartContext.test.tsx | 37 +++++++++++- .../__tests__/componentTests/CartTab.test.tsx | 60 +++++++++++++++---- .../__tests__/componentTests/NGViews.test.tsx | 1 + frontend/src/components/NGViews.tsx | 15 ++++- .../components/ui/Views/CartDatasetRow.tsx | 8 +-- frontend/src/contexts/CartContext.tsx | 17 ++++++ 6 files changed, 117 insertions(+), 21 deletions(-) diff --git a/frontend/src/__tests__/componentTests/CartContext.test.tsx b/frontend/src/__tests__/componentTests/CartContext.test.tsx index 54a51bea..58c56a8d 100644 --- a/frontend/src/__tests__/componentTests/CartContext.test.tsx +++ b/frontend/src/__tests__/componentTests/CartContext.test.tsx @@ -18,8 +18,14 @@ vi.mock('@/queries/preferencesQueries', () => ({ import { CartProvider, useCartContext } from '@/contexts/CartContext'; function Probe() { - const { cart, cartCount, addToCart, removeFromCart, clearCart } = - useCartContext(); + const { + cart, + cartCount, + addToCart, + removeFromCart, + removeManyFromCart, + clearCart + } = useCartContext(); return (
{cartCount} @@ -35,6 +41,13 @@ function Probe() { add +
); @@ -71,6 +84,26 @@ describe('CartContext', () => { }); }); + it('removeManyFromCart filters all removals in a single persist call', async () => { + cartData = [ + { fsp_name: 'fsp', path: '/a', label: 'a' }, + { fsp_name: 'fsp', path: '/b', channel: 'DAPI', label: 'DAPI' }, + { fsp_name: 'fsp', path: '/c', label: 'c' } + ]; + const user = userEvent.setup(); + render( + + + + ); + await user.click(screen.getByText('removeMany')); + expect(mutateAsync).toHaveBeenCalledTimes(1); + expect(mutateAsync).toHaveBeenCalledWith({ + key: 'neuroglancerCart', + value: [{ fsp_name: 'fsp', path: '/c', label: 'c' }] + }); + }); + it('clearCart persists an empty array', async () => { const user = userEvent.setup(); render( diff --git a/frontend/src/__tests__/componentTests/CartTab.test.tsx b/frontend/src/__tests__/componentTests/CartTab.test.tsx index 3e8823e1..057cd03c 100644 --- a/frontend/src/__tests__/componentTests/CartTab.test.tsx +++ b/frontend/src/__tests__/componentTests/CartTab.test.tsx @@ -18,19 +18,36 @@ const view: View = { layers: [] }; -// Dataset A has an existing Data Link (channel expansion enabled). +// Dataset A has an existing Data Link (channel expansion enabled) and TWO +// cart entries (a base entry + an already-checked "GFP" channel entry), to +// exercise the multi-entry "Remove" batch path. // Dataset B has no Data Link (channel expansion disabled + hint). -const cartA: CartItem = { fsp_name: 'fsp1', path: '/a', label: 'Dataset A' }; +const cartABase: CartItem = { + fsp_name: 'fsp1', + path: '/a', + label: 'Dataset A' +}; +const cartAGfp: CartItem = { + fsp_name: 'fsp1', + path: '/a', + channel: 'GFP', + label: 'GFP' +}; const cartB: CartItem = { fsp_name: 'fsp2', path: '/b', label: 'Dataset B' }; -const { addToCart, removeFromCart, clearCart, getOmeZarrChannels } = vi.hoisted( - () => ({ - addToCart: vi.fn().mockResolvedValue(undefined), - removeFromCart: vi.fn().mockResolvedValue(undefined), - clearCart: vi.fn().mockResolvedValue(undefined), - getOmeZarrChannels: vi.fn().mockResolvedValue(['DAPI', 'GFP']) - }) -); +const { + addToCart, + removeFromCart, + removeManyFromCart, + clearCart, + getOmeZarrChannels +} = vi.hoisted(() => ({ + addToCart: vi.fn().mockResolvedValue(undefined), + removeFromCart: vi.fn().mockResolvedValue(undefined), + removeManyFromCart: vi.fn().mockResolvedValue(undefined), + clearCart: vi.fn().mockResolvedValue(undefined), + getOmeZarrChannels: vi.fn().mockResolvedValue(['DAPI', 'GFP']) +})); vi.mock('@/contexts/ViewsContext', () => ({ useViewsContext: () => ({ @@ -42,10 +59,11 @@ vi.mock('@/contexts/ViewsContext', () => ({ })); vi.mock('@/contexts/CartContext', () => ({ useCartContext: () => ({ - cart: [cartA, cartB], - cartCount: 2, + cart: [cartABase, cartAGfp, cartB], + cartCount: 3, addToCart, removeFromCart, + removeManyFromCart, clearCart }) })); @@ -80,6 +98,7 @@ import NGViews from '@/components/NGViews'; beforeEach(() => { addToCart.mockClear(); removeFromCart.mockClear(); + removeManyFromCart.mockClear(); clearCart.mockClear(); getOmeZarrChannels.mockClear(); }); @@ -136,6 +155,23 @@ describe('Layer Cart tab', () => { }); }); + it('removing a multi-entry dataset clears every entry in one batch call, not a loop', async () => { + const user = await renderCartTab(); + const removeButtons = screen.getAllByRole('button', { name: /^remove$/i }); + // Dataset A (base + GFP channel entries) is the first row. + await user.click(removeButtons[0]); + + await waitFor(() => { + expect(removeManyFromCart).toHaveBeenCalledTimes(1); + }); + expect(removeManyFromCart).toHaveBeenCalledWith([ + { path: '/a', channel: undefined }, + { path: '/a', channel: 'GFP' } + ]); + // The bug being regression-tested: no per-item loop calling single-remove. + expect(removeFromCart).not.toHaveBeenCalled(); + }); + it('shows the Create View control and a Clear cart button', async () => { await renderCartTab(); expect( diff --git a/frontend/src/__tests__/componentTests/NGViews.test.tsx b/frontend/src/__tests__/componentTests/NGViews.test.tsx index 7a105288..1d6dd42f 100644 --- a/frontend/src/__tests__/componentTests/NGViews.test.tsx +++ b/frontend/src/__tests__/componentTests/NGViews.test.tsx @@ -31,6 +31,7 @@ vi.mock('@/contexts/CartContext', () => ({ cartCount: 1, addToCart: vi.fn(), removeFromCart: vi.fn(), + removeManyFromCart: vi.fn(), clearCart: vi.fn() }) })); diff --git a/frontend/src/components/NGViews.tsx b/frontend/src/components/NGViews.tsx index 6b00e61b..5201173e 100644 --- a/frontend/src/components/NGViews.tsx +++ b/frontend/src/components/NGViews.tsx @@ -33,8 +33,10 @@ type CartGroup = { items: CartItem[]; }; +type CartGroupBuilder = CartGroup & { hasBaseLabel: boolean }; + function groupCartByDataset(cart: CartItem[]): CartGroup[] { - const groups = new Map(); + const groups = new Map(); for (const item of cart) { const key = datasetKey(item.fsp_name, item.path); const existing = groups.get(key); @@ -43,17 +45,24 @@ function groupCartByDataset(cart: CartItem[]): CartGroup[] { // Prefer the base (no-channel) entry's label for the dataset row. if (!item.channel) { existing.label = item.label; + existing.hasBaseLabel = true; } } else { + // A channel-only entry's label is the channel name (e.g. "DAPI"), not + // the dataset name - fall back to the path until/unless a base entry + // shows up, rather than letting a channel string become the header. groups.set(key, { fsp_name: item.fsp_name, path: item.path, - label: item.label, + label: item.channel ? item.path : item.label, + hasBaseLabel: !item.channel, items: [item] }); } } - return Array.from(groups.values()); + return Array.from(groups.values()).map( + ({ hasBaseLabel: _hasBaseLabel, ...group }) => group + ); } export default function NGViews() { diff --git a/frontend/src/components/ui/Views/CartDatasetRow.tsx b/frontend/src/components/ui/Views/CartDatasetRow.tsx index 4cc70252..ab4d8a09 100644 --- a/frontend/src/components/ui/Views/CartDatasetRow.tsx +++ b/frontend/src/components/ui/Views/CartDatasetRow.tsx @@ -30,7 +30,7 @@ export default function CartDatasetRow({ items, dataLinkUrl }: CartDatasetRowProps) { - const { addToCart, removeFromCart } = useCartContext(); + const { addToCart, removeFromCart, removeManyFromCart } = useCartContext(); const [isOpen, setIsOpen] = useState(false); const [channels, setChannels] = useState(undefined); const [loadingChannels, setLoadingChannels] = useState(false); @@ -59,9 +59,9 @@ export default function CartDatasetRow({ const handleRemoveDataset = async () => { try { - for (const item of items) { - await removeFromCart(item.path, item.channel); - } + await removeManyFromCart( + items.map(item => ({ path: item.path, channel: item.channel })) + ); } catch (error) { toast.error( error instanceof Error ? error.message : 'Failed to remove dataset' diff --git a/frontend/src/contexts/CartContext.tsx b/frontend/src/contexts/CartContext.tsx index 8141c1e2..cc30da2b 100644 --- a/frontend/src/contexts/CartContext.tsx +++ b/frontend/src/contexts/CartContext.tsx @@ -7,11 +7,14 @@ import type { CartItem } from '@/queries/preferencesQueries'; export type { CartItem }; +type CartRemoval = { path: string; channel?: string }; + type CartContextType = { cart: CartItem[]; cartCount: number; addToCart: (items: CartItem[]) => Promise; removeFromCart: (path: string, channel?: string) => Promise; + removeManyFromCart: (removals: CartRemoval[]) => Promise; clearCart: () => Promise; }; @@ -79,6 +82,19 @@ export const CartProvider = ({ [cart, persist] ); + // Batch removal: filters once against a single `cart` snapshot, unlike + // calling removeFromCart in a loop (each awaited call would close over the + // same pre-loop `cart`/`persist` and overwrite the previous removal). + const removeManyFromCart = useCallback( + async (removals: CartRemoval[]) => { + const keys = new Set(removals.map(r => `${r.path}::${r.channel ?? ''}`)); + await persist( + cart.filter(i => !keys.has(`${i.path}::${i.channel ?? ''}`)) + ); + }, + [cart, persist] + ); + const clearCart = useCallback(() => persist([]), [persist]); const value: CartContextType = { @@ -86,6 +102,7 @@ export const CartProvider = ({ cartCount: cart.length, addToCart, removeFromCart, + removeManyFromCart, clearCart }; From 5c90946abaeead2201bdff520aadc1f295ebcf4f Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 18:19:16 -0400 Subject: [PATCH 13/16] fix(views): drop base entry when channels selected; dedupe datasetKey; drop dead field --- .../componentTests/useCartCheckout.test.tsx | 28 ++++++++++++++--- frontend/src/components/NGViews.tsx | 18 ++--------- .../components/ui/Views/CreateViewButton.tsx | 7 +---- frontend/src/hooks/useCartCheckout.ts | 31 ++++++++++++++----- frontend/src/utils/pathHandling.ts | 13 ++++++++ 5 files changed, 65 insertions(+), 32 deletions(-) diff --git a/frontend/src/__tests__/componentTests/useCartCheckout.test.tsx b/frontend/src/__tests__/componentTests/useCartCheckout.test.tsx index 7e2a9fc0..df19c99f 100644 --- a/frontend/src/__tests__/componentTests/useCartCheckout.test.tsx +++ b/frontend/src/__tests__/componentTests/useCartCheckout.test.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from 'react'; const createProxied = vi.fn(); const createViewAsync = vi.fn(); +const buildViewState = vi.hoisted(() => vi.fn()); vi.mock('@/queries/proxiedPathQueries', () => ({ useAllProxiedPathsQuery: () => ({ @@ -18,10 +19,7 @@ vi.mock('@/contexts/ViewsContext', () => ({ }) })); vi.mock('@/utils/viewCheckout', () => ({ - buildViewState: vi.fn().mockResolvedValue({ - ng_state: { layers: [] }, - layers: [{ sharing_key: 'ka', layer_index: 0, channel: null, opts: null }] - }) + buildViewState })); import { useCartCheckout } from '@/hooks/useCartCheckout'; @@ -41,6 +39,10 @@ beforeEach(() => { url: 'http://b' }); createViewAsync.mockReset().mockResolvedValue({ short_key: 'v1', name: 'N' }); + buildViewState.mockReset().mockResolvedValue({ + ng_state: { layers: [] }, + layers: [{ sharing_key: 'ka', layer_index: 0, channel: null, opts: null }] + }); }); describe('useCartCheckout', () => { @@ -61,4 +63,22 @@ describe('useCartCheckout', () => { expect.objectContaining({ name: 'My View', ng_state: { layers: [] } }) ); }); + + it('drops the base entry when a channel entry exists for the same dataset', async () => { + const { result } = renderHook(() => useCartCheckout(), { wrapper }); + await result.current.checkout( + [ + { fsp_name: 'f', path: '/a', label: 'A' }, // base entry, no channel + { fsp_name: 'f', path: '/a', label: 'DAPI', channel: 'DAPI' } // channel entry, same dataset + ], + 'My View' + ); + + expect(buildViewState).toHaveBeenCalledTimes(1); + const resolvedDatasets = buildViewState.mock.calls[0][0]; + expect(resolvedDatasets).toHaveLength(1); + expect(resolvedDatasets).toEqual([ + expect.objectContaining({ fsp_name: 'f', path: '/a', channel: 'DAPI' }) + ]); + }); }); diff --git a/frontend/src/components/NGViews.tsx b/frontend/src/components/NGViews.tsx index 5201173e..e66f3966 100644 --- a/frontend/src/components/NGViews.tsx +++ b/frontend/src/components/NGViews.tsx @@ -14,18 +14,12 @@ import { useViewsContext } from '@/contexts/ViewsContext'; import { useCartContext } from '@/contexts/CartContext'; import { useDefaultNeuroglancerBaseUrl } from '@/hooks/useDefaultNeuroglancerBaseUrl'; import { useAllProxiedPathsQuery } from '@/queries/proxiedPathQueries'; -import { normalizeFspRootPath } from '@/utils/pathHandling'; +import { datasetKey } from '@/utils/pathHandling'; import type { View } from '@/queries/viewQueries'; import type { CartItem } from '@/contexts/CartContext'; type ViewsTab = 'views' | 'cart'; -// Same normalization useCartCheckout/CreateViewButton apply before -// comparing against the proxied-path list, so this lookup matches what -// checkout will actually resolve/create for the same dataset. -const datasetKey = (fsp_name: string, path: string) => - `${fsp_name}::${normalizeFspRootPath(path)}`; - type CartGroup = { fsp_name: string; path: string; @@ -33,10 +27,8 @@ type CartGroup = { items: CartItem[]; }; -type CartGroupBuilder = CartGroup & { hasBaseLabel: boolean }; - function groupCartByDataset(cart: CartItem[]): CartGroup[] { - const groups = new Map(); + const groups = new Map(); for (const item of cart) { const key = datasetKey(item.fsp_name, item.path); const existing = groups.get(key); @@ -45,7 +37,6 @@ function groupCartByDataset(cart: CartItem[]): CartGroup[] { // Prefer the base (no-channel) entry's label for the dataset row. if (!item.channel) { existing.label = item.label; - existing.hasBaseLabel = true; } } else { // A channel-only entry's label is the channel name (e.g. "DAPI"), not @@ -55,14 +46,11 @@ function groupCartByDataset(cart: CartItem[]): CartGroup[] { fsp_name: item.fsp_name, path: item.path, label: item.channel ? item.path : item.label, - hasBaseLabel: !item.channel, items: [item] }); } } - return Array.from(groups.values()).map( - ({ hasBaseLabel: _hasBaseLabel, ...group }) => group - ); + return Array.from(groups.values()); } export default function NGViews() { diff --git a/frontend/src/components/ui/Views/CreateViewButton.tsx b/frontend/src/components/ui/Views/CreateViewButton.tsx index 00c6e36a..b70e9b74 100644 --- a/frontend/src/components/ui/Views/CreateViewButton.tsx +++ b/frontend/src/components/ui/Views/CreateViewButton.tsx @@ -6,7 +6,7 @@ import { useNavigate } from 'react-router'; import { useCartCheckout } from '@/hooks/useCartCheckout'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useAllProxiedPathsQuery } from '@/queries/proxiedPathQueries'; -import { normalizeFspRootPath } from '@/utils/pathHandling'; +import { datasetKey } from '@/utils/pathHandling'; import FgButton from '@/components/designSystem/atoms/FgButton'; import FgSwitch from '@/components/designSystem/atoms/formElements/FgSwitch'; import FgDialog from '@/components/ui/Dialogs/FgDialog'; @@ -21,11 +21,6 @@ interface CreateViewButtonProps { readonly onCreated?: (view: View) => void; } -// Same normalization useCartCheckout applies before comparing/creating Data -// Links, so this count matches what checkout will actually create. -const datasetKey = (fsp_name: string, path: string) => - `${fsp_name}::${normalizeFspRootPath(path)}`; - export default function CreateViewButton({ datasets, defaultName, diff --git a/frontend/src/hooks/useCartCheckout.ts b/frontend/src/hooks/useCartCheckout.ts index 92342128..2f01fa92 100644 --- a/frontend/src/hooks/useCartCheckout.ts +++ b/frontend/src/hooks/useCartCheckout.ts @@ -7,16 +7,32 @@ import { import { useViewsContext } from '@/contexts/ViewsContext'; import { buildViewState } from '@/utils/viewCheckout'; import type { ResolvedCheckoutDataset } from '@/utils/viewCheckout'; -import { normalizeFspRootPath } from '@/utils/pathHandling'; +import { datasetKey, normalizeFspRootPath } from '@/utils/pathHandling'; import type { CartItem } from '@/queries/preferencesQueries'; import type { View } from '@/queries/viewQueries'; import type { ProxiedPath } from '@/contexts/ProxiedPathContext'; -// Same normalization handleCreateDataLink applies before creating a Data -// Link (FSP root "." -> ""), so lookups against the list match what was -// actually created/stored instead of missing and creating duplicates. -const datasetKey = (fsp_name: string, path: string) => - `${fsp_name}::${normalizeFspRootPath(path)}`; +// A dataset can hold both a base (channel-less) CartItem and channel-specific +// CartItems at once. Drop the base entry when channel entries exist for the +// same dataset, so buildViewState doesn't run the dataset twice (once for +// the base entry's full layer set, once more for the duplicate channel). +function dropShadowedBaseEntries(datasets: CartItem[]): CartItem[] { + const hasChannelByKey = new Map(); + for (const ds of datasets) { + const key = datasetKey(ds.fsp_name, ds.path); + if (ds.channel) { + hasChannelByKey.set(key, true); + } else if (!hasChannelByKey.has(key)) { + hasChannelByKey.set(key, false); + } + } + return datasets.filter(ds => { + if (ds.channel) { + return true; + } + return !hasChannelByKey.get(datasetKey(ds.fsp_name, ds.path)); + }); +} export function useCartCheckout() { const allProxiedPathsQuery = useAllProxiedPathsQuery(); @@ -24,7 +40,8 @@ export function useCartCheckout() { const { createViewMutation } = useViewsContext(); const checkout = useCallback( - async (datasets: CartItem[], name: string): Promise => { + async (rawDatasets: CartItem[], name: string): Promise => { + const datasets = dropShadowedBaseEntries(rawDatasets); const existing = new Map( (allProxiedPathsQuery.data ?? []).map(p => [ datasetKey(p.fsp_name, p.path), diff --git a/frontend/src/utils/pathHandling.ts b/frontend/src/utils/pathHandling.ts index f5683dd5..aee65dec 100644 --- a/frontend/src/utils/pathHandling.ts +++ b/frontend/src/utils/pathHandling.ts @@ -85,6 +85,18 @@ function normalizeFspRootPath(filePath: string | null | undefined): string { return !filePath || filePath === '.' ? '' : filePath; } +/** + * Builds the key used to identify a unique dataset (fsp_name + normalized + * path) across the cart/checkout/views pipeline. Uses normalizeFspRootPath + * so lookups against Data Links match what was actually created/stored. + * Example: + * datasetKey('myFSP', '.'); // Returns 'myFSP::' + * datasetKey('myFSP', 'my_folder/my_zarr'); // Returns 'myFSP::my_folder/my_zarr' + */ +function datasetKey(fsp_name: string, path: string): string { + return `${fsp_name}::${normalizeFspRootPath(path)}`; +} + /** * Constructs a sharable URL to access file contents from the browser with the Fileglancer API. * If no filePath is provided, it returns the endpoint URL with the FSP path appended - this is the base URL. @@ -274,6 +286,7 @@ function resolvePathToFsp( export { convertBackToForwardSlash, + datasetKey, escapePathForUrl, getFileURL, getLastSegmentFromPath, From 242530e96f6e7e2d1cea7cd7fdcbfb08c6bc15da Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 18:23:02 -0400 Subject: [PATCH 14/16] style: prettier-format ngviews-05a test files --- .../FileBrowserCartItem.test.tsx | 18 +++++++++++++----- .../__tests__/unitTests/viewCheckout.test.ts | 3 ++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx b/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx index 1ac582e1..a24abc64 100644 --- a/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx +++ b/frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx @@ -41,10 +41,7 @@ vi.mock('@/components/ui/BrowsePage/FileTable', () => ({ }) => (
{data.map(file => ( - ))} @@ -55,7 +52,18 @@ vi.mock('@/components/ui/BrowsePage/FileTable', () => ({ const noop = vi.fn(); function renderFileBrowser() { - return render(, { initialEntries: ['/browse/test_fsp/my_folder'] }); + return render( + , + { initialEntries: ['/browse/test_fsp/my_folder'] } + ); } describe('FileBrowser row context menu - Add to Neuroglancer cart', () => { diff --git a/frontend/src/__tests__/unitTests/viewCheckout.test.ts b/frontend/src/__tests__/unitTests/viewCheckout.test.ts index 500c7df6..48805149 100644 --- a/frontend/src/__tests__/unitTests/viewCheckout.test.ts +++ b/frontend/src/__tests__/unitTests/viewCheckout.test.ts @@ -58,7 +58,8 @@ describe('buildViewState', () => { zarrVersion: 2 }); ( - (await import('@/omezarr-helper')).generateNeuroglancerStateForDataURL as any + (await import('@/omezarr-helper')) + .generateNeuroglancerStateForDataURL as any ).mockReturnValue(encoded({ layers: [{ name: 'fallback' }] })); const { ng_state } = await buildViewState([ { url: 'a', sharing_key: 'ka', fsp_name: 'f', path: '/a', label: 'A' } From 2dab769c630f836e8e61a718f8554874c32f82a3 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 12 Aug 2026 15:01:34 -0400 Subject: [PATCH 15/16] feat(views): show per-dataset dims in the Layer Cart Lazily fetch OME-Zarr metadata on first row expand (same trigger as the existing channel fetch) and render a compact "axis:size" summary next to the dataset label, so a shape mismatch between cart rows (e.g. different Z extents) is visible before checkout instead of only after a misaligned View. Exports getAxesMap from omezarr-helper so CartDatasetRow can map axis name to its arr.shape index. --- .../__tests__/componentTests/CartTab.test.tsx | 52 +++++++++++++++++-- .../components/ui/Views/CartDatasetRow.tsx | 48 ++++++++++++++++- frontend/src/omezarr-helper.ts | 1 + 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/componentTests/CartTab.test.tsx b/frontend/src/__tests__/componentTests/CartTab.test.tsx index 057cd03c..52d39fc8 100644 --- a/frontend/src/__tests__/componentTests/CartTab.test.tsx +++ b/frontend/src/__tests__/componentTests/CartTab.test.tsx @@ -40,13 +40,28 @@ const { removeFromCart, removeManyFromCart, clearCart, - getOmeZarrChannels + getOmeZarrChannels, + getOmeZarrMetadata, + getAxesMap } = vi.hoisted(() => ({ addToCart: vi.fn().mockResolvedValue(undefined), removeFromCart: vi.fn().mockResolvedValue(undefined), removeManyFromCart: vi.fn().mockResolvedValue(undefined), clearCart: vi.fn().mockResolvedValue(undefined), - getOmeZarrChannels: vi.fn().mockResolvedValue(['DAPI', 'GFP']) + getOmeZarrChannels: vi.fn().mockResolvedValue(['DAPI', 'GFP']), + getOmeZarrMetadata: vi.fn().mockResolvedValue({ + arr: { shape: [3, 2048, 2048] }, + multiscales: [{ axes: [{ name: 'c' }, { name: 'y' }, { name: 'x' }] }] + }), + // Real implementation (not a stub): CartDatasetRow's dims formatting + // depends on this actually mapping axis name -> shape index. + getAxesMap: vi.fn((multiscale: { axes?: { name: string }[] }) => { + const map: Record = {}; + (multiscale.axes ?? []).forEach((axis, i) => { + map[axis.name] = { ...axis, index: i }; + }); + return map; + }) })); vi.mock('@/contexts/ViewsContext', () => ({ @@ -71,7 +86,9 @@ vi.mock('@/hooks/useDefaultNeuroglancerBaseUrl', () => ({ useDefaultNeuroglancerBaseUrl: () => 'https://ng.example/' })); vi.mock('@/omezarr-helper', () => ({ - getOmeZarrChannels + getOmeZarrChannels, + getOmeZarrMetadata, + getAxesMap })); vi.mock('@/queries/proxiedPathQueries', () => ({ useAllProxiedPathsQuery: () => ({ @@ -101,6 +118,7 @@ beforeEach(() => { removeManyFromCart.mockClear(); clearCart.mockClear(); getOmeZarrChannels.mockClear(); + getOmeZarrMetadata.mockClear(); }); async function renderCartTab() { @@ -132,6 +150,34 @@ describe('Layer Cart tab', () => { expect(screen.getByText('GFP')).toBeInTheDocument(); }); + it('lazy-loads and shows dims when expanding a dataset with a Data Link', async () => { + const user = await renderCartTab(); + await user.click(screen.getByRole('button', { name: /Dataset A/ })); + + await waitFor(() => { + expect(getOmeZarrMetadata).toHaveBeenCalledWith('https://data.example/a'); + }); + expect( + await screen.findByText('c:3 × y:2048 × x:2048') + ).toBeInTheDocument(); + }); + + it('shows no dims text (and does not crash) when metadata has no axes', async () => { + getOmeZarrMetadata.mockResolvedValueOnce({ + arr: { shape: [] }, + multiscales: undefined + }); + const user = await renderCartTab(); + await user.click(screen.getByRole('button', { name: /Dataset A/ })); + + await waitFor(() => { + expect(getOmeZarrMetadata).toHaveBeenCalled(); + }); + // Channels still render fine; no dims string is shown for this dataset. + expect(await screen.findByText('DAPI')).toBeInTheDocument(); + expect(screen.queryByText(/×/)).not.toBeInTheDocument(); + }); + it('disables expansion and shows a hint for a dataset with no Data Link', async () => { await renderCartTab(); const expandButton = screen.getByRole('button', { name: 'Dataset B' }); diff --git a/frontend/src/components/ui/Views/CartDatasetRow.tsx b/frontend/src/components/ui/Views/CartDatasetRow.tsx index ab4d8a09..8cebcf82 100644 --- a/frontend/src/components/ui/Views/CartDatasetRow.tsx +++ b/frontend/src/components/ui/Views/CartDatasetRow.tsx @@ -7,7 +7,12 @@ import FgIcon from '@/components/designSystem/atoms/FgIcon'; import FgButton from '@/components/designSystem/atoms/FgButton'; import FgCheckbox from '@/components/designSystem/atoms/formElements/FgCheckbox'; import { useCartContext } from '@/contexts/CartContext'; -import { getOmeZarrChannels } from '@/omezarr-helper'; +import { + getOmeZarrChannels, + getOmeZarrMetadata, + getAxesMap +} from '@/omezarr-helper'; +import type { Metadata } from '@/omezarr-helper'; import type { CartItem } from '@/contexts/CartContext'; interface CartDatasetRowProps { @@ -18,6 +23,20 @@ interface CartDatasetRowProps { readonly dataLinkUrl: string | undefined; } +// Compact "x:512 × y:512 × z:10" summary so shape mismatches between cart +// rows are visible at a glance. Returns undefined when there's no axes +// metadata to summarize (e.g. non-OME-Zarr data). +function formatDims(metadata: Metadata): string | undefined { + const multiscale = metadata.multiscales?.[0]; + if (!multiscale?.axes?.length) { + return undefined; + } + const axesMap = getAxesMap(multiscale); + return multiscale.axes + .map(axis => `${axis.name}:${metadata.arr.shape[axesMap[axis.name].index]}`) + .join(' × '); +} + // ponytail: two-level dataset->channel tree via MT Collapse (no generic // TreeView exists). Channel URL comes from an existing Data Link; if a // dataset has no link yet, expansion is disabled with a hint rather than @@ -34,6 +53,9 @@ export default function CartDatasetRow({ const [isOpen, setIsOpen] = useState(false); const [channels, setChannels] = useState(undefined); const [loadingChannels, setLoadingChannels] = useState(false); + // '' means "fetched, nothing to show" (distinct from undefined = not yet fetched). + const [dims, setDims] = useState(undefined); + const [loadingDims, setLoadingDims] = useState(false); const checkedChannels = new Set( items.map(item => item.channel).filter((c): c is string => Boolean(c)) @@ -55,6 +77,19 @@ export default function CartDatasetRow({ setLoadingChannels(false); } } + if (nextOpen && dims === undefined && !loadingDims && dataLinkUrl) { + setLoadingDims(true); + try { + const metadata = await getOmeZarrMetadata(dataLinkUrl); + setDims(formatDims(metadata) ?? ''); + } catch { + // Dims are a nice-to-have; fetch failures shouldn't toast on top of + // the channels error above, so just fall back to showing nothing. + setDims(''); + } finally { + setLoadingDims(false); + } + } }; const handleRemoveDataset = async () => { @@ -97,7 +132,16 @@ export default function CartDatasetRow({ icon={HiChevronRight} size="sm" /> - {label} +
+ + {label} + + {dims ? ( + + {dims} + + ) : null} +
void handleRemoveDataset()} variant="ghost"> Remove diff --git a/frontend/src/omezarr-helper.ts b/frontend/src/omezarr-helper.ts index 8651f5c5..56c6689f 100644 --- a/frontend/src/omezarr-helper.ts +++ b/frontend/src/omezarr-helper.ts @@ -796,6 +796,7 @@ async function determineLayerType( export { getScaleTransform, getResolvedScales, + getAxesMap, getNeuroglancerSource, getZarrArray, getOmeZarrMetadata, From 3b704c153b02eebce94c8ce66031b57b832483e4 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 12 Aug 2026 15:11:14 -0400 Subject: [PATCH 16/16] feat(views): let the user name a View before checkout fires CreateViewButton now always opens a dialog with a name FgInput pre-filled from defaultName before calling checkout, instead of firing checkout silently when no data-link consent was needed. The data-link consent copy/switch renders inside the same dialog only when new links require consent. The create/continue button is disabled when the trimmed name is empty, and the typed name (not defaultName) is used for both checkout and the success toast. --- .../componentTests/CreateViewButton.test.tsx | 57 ++++++++++--- .../components/ui/Views/CreateViewButton.tsx | 85 +++++++++++-------- 2 files changed, 97 insertions(+), 45 deletions(-) diff --git a/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx b/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx index 0b1ed280..f43f0006 100644 --- a/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx +++ b/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx @@ -30,25 +30,62 @@ beforeEach(() => { }); describe('CreateViewButton', () => { - it('checks out directly when data links are automatic', async () => { + it('pre-fills the name input with defaultName and checks out with it when data links are automatic', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /create view/i })); + + const input = await screen.findByRole('textbox', { name: /view name/i }); + expect(input).toHaveValue('V'); + // no consent copy needed since data links are automatic + expect( + screen.queryByText(/are you sure you want to create a data link/i) + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /^create$/i })); expect(checkout).toHaveBeenCalledWith(datasets, 'V'); }); - it('shows a consent dialog when not automatic, then checks out on confirm', async () => { - automatic = false; + it('checks out with the edited name, not defaultName', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /create view/i })); + + const input = await screen.findByRole('textbox', { name: /view name/i }); + await user.clear(input); + await user.type(input, 'My Renamed View'); + + await user.click(screen.getByRole('button', { name: /^create$/i })); + expect(checkout).toHaveBeenCalledWith(datasets, 'My Renamed View'); + }); + + it('disables the create button when the name is empty', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /create view/i })); + + const input = await screen.findByRole('textbox', { name: /view name/i }); + await user.clear(input); + + expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled(); expect(checkout).not.toHaveBeenCalled(); - // consent dialog visible → confirm - await user.click( - await screen.findByRole('button', { - name: /create.*view|confirm|continue/i - }) - ); - expect(checkout).toHaveBeenCalledWith(datasets, 'V'); + }); + + it('shows data-link consent copy when not automatic, then checks out on confirm with the edited name', async () => { + automatic = false; + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /create view/i })); + + expect( + await screen.findByText(/are you sure you want to create a data link/i) + ).toBeInTheDocument(); + + const input = screen.getByRole('textbox', { name: /view name/i }); + await user.clear(input); + await user.type(input, 'Linked View'); + + await user.click(screen.getByRole('button', { name: /continue/i })); + expect(checkout).toHaveBeenCalledWith(datasets, 'Linked View'); }); }); diff --git a/frontend/src/components/ui/Views/CreateViewButton.tsx b/frontend/src/components/ui/Views/CreateViewButton.tsx index b70e9b74..1cb8fee8 100644 --- a/frontend/src/components/ui/Views/CreateViewButton.tsx +++ b/frontend/src/components/ui/Views/CreateViewButton.tsx @@ -8,6 +8,7 @@ import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useAllProxiedPathsQuery } from '@/queries/proxiedPathQueries'; import { datasetKey } from '@/utils/pathHandling'; import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgInput from '@/components/designSystem/atoms/formElements/FgInput'; import FgSwitch from '@/components/designSystem/atoms/formElements/FgSwitch'; import FgDialog from '@/components/ui/Dialogs/FgDialog'; import type { CartItem } from '@/queries/preferencesQueries'; @@ -37,21 +38,23 @@ export default function CreateViewButton({ const allProxiedPathsQuery = useAllProxiedPathsQuery(); const navigate = useNavigate(); - const [showConsent, setShowConsent] = useState(false); + const [showDialog, setShowDialog] = useState(false); + const [name, setName] = useState(defaultName); const [pending, setPending] = useState(false); const [newLinkCount, setNewLinkCount] = useState(0); + const [needsLinkConsent, setNeedsLinkConsent] = useState(false); const runCheckout = async () => { setPending(true); try { - const view = await checkout(datasets, defaultName); - toast.success(`Created View "${defaultName}"`); + const view = await checkout(datasets, name); + toast.success(`Created View "${name}"`); if (onCreated) { onCreated(view); } else { navigate('/ngviews'); } - setShowConsent(false); + setShowDialog(false); } catch (error) { toast.error(error instanceof Error ? error.message : 'Checkout failed'); } finally { @@ -77,18 +80,15 @@ export default function CreateViewButton({ const autoLinksCoverThis = areDataLinksAutomatic && dataLinkSubpathMode !== 'custom'; - if (autoLinksCoverThis || computedNewLinkCount === 0) { - void runCheckout(); - return; - } - + setName(defaultName); setNewLinkCount(computedNewLinkCount); - setShowConsent(true); + setNeedsLinkConsent(!autoLinksCoverThis && computedNewLinkCount > 0); + setShowDialog(true); }; return ( <> - {!showConsent ? ( + {!showDialog ? ( ) : null} - {showConsent ? ( - setShowConsent(false)} open={showConsent}> + {showDialog ? ( + setShowDialog(false)} open={showDialog}>
- Are you sure you want to create a data link? - - - This will create {newLinkCount} data link - {newLinkCount === 1 ? '' : 's'} and 1 View. If you share the data - link(s) with internal collaborators, they will be able to view - these data. + Create View -
- - Don't ask me this again: - - { - void toggleAutomaticDataLinks(); - }} - /> -
+ setName(e.target.value)} + value={name} + /> + + {needsLinkConsent ? ( + <> + + Are you sure you want to create a data link? + + + This will create {newLinkCount} data link + {newLinkCount === 1 ? '' : 's'} and 1 View. If you share the + data link(s) with internal collaborators, they will be able to + view these data. + +
+ + Don't ask me this again: + + { + void toggleAutomaticDataLinks(); + }} + /> +
+ + ) : null} +
- Continue + {needsLinkConsent ? 'Continue' : 'Create'} setShowConsent(false)} + onClick={() => setShowDialog(false)} variant="ghost" > Cancel