From 276d3e2f086a2206a20af3264c7427c304c8ab0c Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 21 Oct 2026 14:45:58 -0400 Subject: [PATCH 1/2] perf(views): generate each dataset's state once at checkout Picking every channel of a dataset created one cart entry per channel, and each entry re-read the dataset's metadata (72 reads for g1_b1). --- .../__tests__/unitTests/viewCheckout.test.ts | 29 +++++++++++++++++++ frontend/src/utils/viewCheckout.ts | 12 +++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/unitTests/viewCheckout.test.ts b/frontend/src/__tests__/unitTests/viewCheckout.test.ts index 5b2e0cb9..bd12b0d8 100644 --- a/frontend/src/__tests__/unitTests/viewCheckout.test.ts +++ b/frontend/src/__tests__/unitTests/viewCheckout.test.ts @@ -227,6 +227,35 @@ describe('buildViewState', () => { ]); }); + it('generates state once for several channel entries of one dataset', async () => { + (generateNeuroglancerStateForOmeZarr as any).mockReturnValue( + encoded({ + layers: [ + { name: 'DAPI', type: 'image' }, + { name: 'GFP', type: 'image' } + ] + }) + ); + const entry = { + url: 'a', + sharing_key: 'ka', + url_prefix: 'a', + fsp_name: 'f', + path: '/a', + label: 'A' + }; + const { ng_state } = await buildViewState([ + { ...entry, channel: 'DAPI', channelIndex: 0 }, + { ...entry, channel: 'GFP', channelIndex: 1 } + ]); + expect(getOmeZarrMetadata).toHaveBeenCalledTimes(1); + expect(generateNeuroglancerStateForOmeZarr).toHaveBeenCalledTimes(1); + expect((ng_state as any).layers.map((l: any) => l.name)).toEqual([ + 'DAPI', + 'GFP' + ]); + }); + it('selects one per-channel layer per channel entry by index', async () => { // Checkout forces the per-channel layer path when a channel is selected // (8th generator arg = true), yielding layers in c-axis index order. diff --git a/frontend/src/utils/viewCheckout.ts b/frontend/src/utils/viewCheckout.ts index 97cc30db..157ae62e 100644 --- a/frontend/src/utils/viewCheckout.ts +++ b/frontend/src/utils/viewCheckout.ts @@ -126,8 +126,18 @@ export async function buildViewState( let base: NgState | null = null; const taken = new Set(); + // Every channel entry of a dataset generates the same state; generate it + // once per dataset and layer path, then slice it per entry. + const stateCache = new Map>(); + for (const ds of datasets) { - const state = await generateStateForDataset(ds); + const cacheKey = `${ds.url}::${ds.channel !== undefined}`; + let pending = stateCache.get(cacheKey); + if (!pending) { + pending = generateStateForDataset(ds); + stateCache.set(cacheKey, pending); + } + const state = await pending; if (!state) { unsupported.push(ds); continue; From 857299c8bafaa9948597104466afd9f96c68fc0a Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 21 Oct 2026 14:45:59 -0400 Subject: [PATCH 2/2] feat(views): add an "All channels" box to the cart --- .../componentTests/CartDatasetRow.test.tsx | 89 +++++++++++++++++++ .../components/ui/Views/CartDatasetRow.tsx | 79 ++++++++++++---- 2 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 frontend/src/__tests__/componentTests/CartDatasetRow.test.tsx diff --git a/frontend/src/__tests__/componentTests/CartDatasetRow.test.tsx b/frontend/src/__tests__/componentTests/CartDatasetRow.test.tsx new file mode 100644 index 00000000..be9d387d --- /dev/null +++ b/frontend/src/__tests__/componentTests/CartDatasetRow.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; + +import type { CartItem } from '@/contexts/CartContext'; + +const addToCart = vi.fn(); +const removeManyFromCart = vi.fn(); +vi.mock('@/contexts/CartContext', () => ({ + useCartContext: () => ({ + addToCart, + removeFromCart: vi.fn(), + removeManyFromCart + }) +})); +vi.mock('@/components/ui/Views/DatasetRow', () => ({ + default: ({ + renderChannels + }: { + renderChannels: (channels: string[]) => ReactNode; + }) =>
{renderChannels(['DAPI', 'GFP', 'DAPI'])}
+})); + +import CartDatasetRow from '@/components/ui/Views/CartDatasetRow'; + +const base: CartItem = { fsp_name: 'f', path: '/a', label: 'A' }; + +function renderRow(items: CartItem[]) { + render( + + ); + return screen.getByRole('checkbox', { name: 'All channels' }); +} + +beforeEach(() => { + addToCart.mockReset().mockResolvedValue(undefined); + removeManyFromCart.mockReset().mockResolvedValue(undefined); +}); + +describe('CartDatasetRow "All channels"', () => { + it('adds every channel by index, repeated names included', async () => { + await userEvent.click(renderRow([base])); + expect(addToCart).toHaveBeenCalledWith([ + { + fsp_name: 'f', + path: '/a', + channel: 'DAPI', + label: 'DAPI', + channelIndex: 0 + }, + { + fsp_name: 'f', + path: '/a', + channel: 'GFP', + label: 'GFP', + channelIndex: 1 + }, + { + fsp_name: 'f', + path: '/a', + channel: 'DAPI', + label: 'DAPI', + channelIndex: 2 + } + ]); + }); + + it('is indeterminate when only some channels are checked', () => { + const box = renderRow([ + base, + { ...base, channel: 'GFP', label: 'GFP', channelIndex: 1 } + ]) as HTMLInputElement; + expect(box.checked).toBe(false); + expect(box.indeterminate).toBe(true); + }); + + it('unchecking removes the channel entries but keeps the dataset', async () => { + const channels = [0, 1, 2].map(i => ({ ...base, channelIndex: i })); + const box = renderRow([base, ...channels]) as HTMLInputElement; + expect(box.checked).toBe(true); + await userEvent.click(box); + expect(removeManyFromCart).toHaveBeenCalledWith([ + { path: '/a', channelIndex: 0 }, + { path: '/a', channelIndex: 1 }, + { path: '/a', channelIndex: 2 } + ]); + }); +}); diff --git a/frontend/src/components/ui/Views/CartDatasetRow.tsx b/frontend/src/components/ui/Views/CartDatasetRow.tsx index a1350428..eb7b74bc 100644 --- a/frontend/src/components/ui/Views/CartDatasetRow.tsx +++ b/frontend/src/components/ui/Views/CartDatasetRow.tsx @@ -48,6 +48,33 @@ export default function CartDatasetRow({ } }; + const handleToggleAll = async (channels: string[], checked: boolean) => { + try { + if (checked) { + await addToCart( + channels.map((channel, index) => ({ + fsp_name, + path, + channel, + label: channel, + channelIndex: index + })) + ); + } else { + // Keep the channel-less entry, so the dataset stays in the cart. + await removeManyFromCart( + items + .filter(item => item.channelIndex !== undefined) + .map(item => ({ path: item.path, channelIndex: item.channelIndex })) + ); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to update channels' + ); + } + }; + const handleToggleChannel = async ( channel: string, index: number, @@ -78,26 +105,44 @@ export default function CartDatasetRow({ onRemove={() => void handleRemoveDataset()} open={open} path={path} - renderChannels={channels => ( -
- - Optional: select channels to create per-channel layers - - {/* Column-major, so channels still read in c-axis order. */} -
- {channels.map((channel, index) => ( + renderChannels={channels => { + const checkedCount = channels.filter((_, index) => + checkedIndices.has(index) + ).length; + const allChecked = checkedCount === channels.length; + return ( +
+ + Optional: select channels to create per-channel layers + + {channels.length > 1 ? ( - void handleToggleChannel(channel, index, e.target.checked) - } + checked={allChecked} + label="All channels" + onChange={e => void handleToggleAll(channels, e.target.checked)} + ref={el => { + if (el) { + el.indeterminate = checkedCount > 0 && !allChecked; + } + }} /> - ))} + ) : null} + {/* Column-major, so channels still read in c-axis order. */} +
+ {channels.map((channel, index) => ( + + void handleToggleChannel(channel, index, e.target.checked) + } + /> + ))} +
-
- )} + ); + }} /> ); }