Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1188fcc
docs: add ngviews-05a (cart pipeline) implementation plan
allison-truhlar Aug 7, 2026
20316ce
feat(views): mount CartProvider app-wide so the file browser can use …
allison-truhlar Aug 7, 2026
850edb3
feat(views): add getOmeZarrChannels helper for cart channel selection
allison-truhlar Aug 7, 2026
49deda7
feat(views): add buildViewState to assemble ng_state from multiple da…
allison-truhlar Aug 7, 2026
da524f4
fix(views): don't let one broken dataset abort the whole checkout build
allison-truhlar Aug 7, 2026
9603bb3
feat(views): add useCartCheckout (resolve links, build state, create …
allison-truhlar Aug 7, 2026
7508eba
feat(views): add consent-gated CreateViewButton (batch checkout)
allison-truhlar Aug 7, 2026
8084262
feat(browse): add "Add to Neuroglancer cart" row action
allison-truhlar Aug 7, 2026
284d8cb
fix(browse): await addToCart and toast.error on cart-add failure
allison-truhlar Aug 7, 2026
451a3b9
feat(browse): floating selection bar (add to cart / new View from sel…
allison-truhlar Aug 7, 2026
805718c
feat(views): full Layer Cart tab with channel selection + Create View
allison-truhlar Aug 7, 2026
8aee210
fix(cart): batch dataset removal to fix stale-closure bug losing entries
allison-truhlar Aug 7, 2026
5c90946
fix(views): drop base entry when channels selected; dedupe datasetKey…
allison-truhlar Aug 7, 2026
242530e
style: prettier-format ngviews-05a test files
allison-truhlar Aug 7, 2026
2dab769
feat(views): show per-dataset dims in the Layer Cart
allison-truhlar Aug 12, 2026
3b704c1
feat(views): let the user name a View before checkout fires
allison-truhlar Aug 12, 2026
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
817 changes: 817 additions & 0 deletions docs/superpowers/plans/2026-08-07-ngviews-05a-cart-pipeline.md

Large diffs are not rendered by default.

5 changes: 1 addition & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -124,9 +123,7 @@ const AppComponent = () => {
element={
<RequireAuth>
<ViewsProvider>
<CartProvider>
<NGViews />
</CartProvider>
<NGViews />
</ViewsProvider>
</RequireAuth>
}
Expand Down
37 changes: 35 additions & 2 deletions frontend/src/__tests__/componentTests/CartContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<span data-testid="count">{cartCount}</span>
Expand All @@ -35,6 +41,13 @@ function Probe() {
add
</button>
<button onClick={() => removeFromCart('/a')}>remove</button>
<button
onClick={() =>
removeManyFromCart([{ path: '/a' }, { path: '/b', channel: 'DAPI' }])
}
>
removeMany
</button>
<button onClick={() => clearCart()}>clear</button>
</div>
);
Expand Down Expand Up @@ -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(
<CartProvider>
<Probe />
</CartProvider>
);
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(
Expand Down
230 changes: 230 additions & 0 deletions frontend/src/__tests__/componentTests/CartTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
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) 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 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,
removeManyFromCart,
clearCart,
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']),
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<string, { name: string; index: number }> = {};
(multiscale.axes ?? []).forEach((axis, i) => {
map[axis.name] = { ...axis, index: i };
});
return map;
})
}));

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: [cartABase, cartAGfp, cartB],
cartCount: 3,
addToCart,
removeFromCart,
removeManyFromCart,
clearCart
})
}));
vi.mock('@/hooks/useDefaultNeuroglancerBaseUrl', () => ({
useDefaultNeuroglancerBaseUrl: () => 'https://ng.example/'
}));
vi.mock('@/omezarr-helper', () => ({
getOmeZarrChannels,
getOmeZarrMetadata,
getAxesMap
}));
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 }) => (
<button type="button">{label ?? 'Create View'}</button>
)
}));

import NGViews from '@/components/NGViews';

beforeEach(() => {
addToCart.mockClear();
removeFromCart.mockClear();
removeManyFromCart.mockClear();
clearCart.mockClear();
getOmeZarrChannels.mockClear();
getOmeZarrMetadata.mockClear();
});

async function renderCartTab() {
const user = userEvent.setup();
render(
<MemoryRouter>
<NGViews />
</MemoryRouter>
);
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('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' });
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('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(
screen.getByRole('button', { name: /create view/i })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /clear cart/i })
).toBeInTheDocument();
});
});
91 changes: 91 additions & 0 deletions frontend/src/__tests__/componentTests/CreateViewButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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('pre-fills the name input with defaultName and checks out with it when data links are automatic', async () => {
const user = userEvent.setup();
render(<CreateViewButton datasets={datasets} defaultName="V" />);
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('checks out with the edited name, not defaultName', async () => {
const user = userEvent.setup();
render(<CreateViewButton datasets={datasets} defaultName="V" />);
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(<CreateViewButton datasets={datasets} defaultName="V" />);
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();
});

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(<CreateViewButton datasets={datasets} defaultName="V" />);
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');
});
});
Loading
Loading