Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions frontend/src/__tests__/componentTests/CartDatasetRow.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => <div>{renderChannels(['DAPI', 'GFP', 'DAPI'])}</div>
}));

import CartDatasetRow from '@/components/ui/Views/CartDatasetRow';

const base: CartItem = { fsp_name: 'f', path: '/a', label: 'A' };

function renderRow(items: CartItem[]) {
render(
<CartDatasetRow fsp_name="f" items={items} kind="ome" label="A" path="/a" />
);
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 }
]);
});
});
29 changes: 29 additions & 0 deletions frontend/src/__tests__/unitTests/viewCheckout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
79 changes: 62 additions & 17 deletions frontend/src/components/ui/Views/CartDatasetRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -78,26 +105,44 @@ export default function CartDatasetRow({
onRemove={() => void handleRemoveDataset()}
open={open}
path={path}
renderChannels={channels => (
<div className="flex flex-col gap-1">
<Typography className="text-foreground/70 text-xs font-semibold">
Optional: select channels to create per-channel layers
</Typography>
{/* Column-major, so channels still read in c-axis order. */}
<div className="columns-[8rem] gap-x-4 [&>*]:mb-1 [&>*]:break-inside-avoid">
{channels.map((channel, index) => (
renderChannels={channels => {
const checkedCount = channels.filter((_, index) =>
checkedIndices.has(index)
).length;
const allChecked = checkedCount === channels.length;
return (
<div className="flex flex-col gap-1">
<Typography className="text-foreground/70 text-xs font-semibold">
Optional: select channels to create per-channel layers
</Typography>
{channels.length > 1 ? (
<FgCheckbox
checked={checkedIndices.has(index)}
key={index}
label={channel}
onChange={e =>
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. */}
<div className="columns-[8rem] gap-x-4 [&>*]:mb-1 [&>*]:break-inside-avoid">
{channels.map((channel, index) => (
<FgCheckbox
checked={checkedIndices.has(index)}
key={index}
label={channel}
onChange={e =>
void handleToggleChannel(channel, index, e.target.checked)
}
/>
))}
</div>
</div>
</div>
)}
);
}}
/>
);
}
12 changes: 11 additions & 1 deletion frontend/src/utils/viewCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,18 @@ export async function buildViewState(
let base: NgState | null = null;
const taken = new Set<string>();

// 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<string, Promise<NgState | null>>();

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;
Expand Down
Loading