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/__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/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)
+ }
+ />
+ ))}
+
-
- )}
+ );
+ }}
/>
);
}
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;