Skip to content
Open
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
c8ee579
fix(views): cart rows expand without a Data Link
allison-truhlar Aug 19, 2026
08c8100
feat(views): compact embedded viewer header into a single row
allison-truhlar Aug 19, 2026
fae0108
feat(views): NG Views table gets a resizable Sources column and horiz…
allison-truhlar Aug 19, 2026
56f8c90
fix(views): cart list scrolls internally instead of overflowing the s…
allison-truhlar Aug 20, 2026
d2b1d35
feat(views): add pure dimension-signature comparison for cart layers
allison-truhlar Aug 20, 2026
6da033f
feat(views): fetch and compare cart layer dimensions via useQueries
allison-truhlar Aug 20, 2026
6f1cebe
feat(views): warn on cart rows whose dimensions differ from the first…
allison-truhlar Aug 20, 2026
adad154
feat(views): gate Create View on acknowledging mismatched dimensions
allison-truhlar Aug 20, 2026
5a3e748
feat(views): make the view name open the embedded viewer
allison-truhlar Aug 20, 2026
836b888
refactor(views): rename user-facing "NG View(s)"/"Neuroglancer View(s…
allison-truhlar Aug 20, 2026
6c7b047
test(views): mock useCartDimensionCheck in cart/create-view tests to …
allison-truhlar Aug 20, 2026
c061dcf
feat(views): add a Create view tile to the zarr metadata preview
allison-truhlar Aug 20, 2026
9da188e
fix(views): drop stray 'Neuroglancer view' tooltip copy; fail open on…
allison-truhlar Aug 20, 2026
18f04cb
feat(views): show full source paths in the Views table
allison-truhlar Aug 21, 2026
3561d51
feat(views): explain the empty expansion for non-OME datasets in the …
allison-truhlar Aug 21, 2026
7aada80
fix(views): fit the Views table columns without horizontal scroll
allison-truhlar Aug 21, 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
6 changes: 3 additions & 3 deletions frontend/src/__tests__/componentTests/CartList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ vi.mock('@/contexts/CartContext', () => ({
clearCart
})
}));
vi.mock('@/queries/proxiedPathQueries', () => ({
useAllProxiedPathsQuery: () => ({ data: [] })
}));
vi.mock('@/components/ui/Views/CartDatasetRow', () => ({
default: ({ label }: { label: string }) => (
<div data-testid="row">{label}</div>
Expand All @@ -49,6 +46,9 @@ vi.mock('@/components/ui/Views/CreateViewButton', () => ({
</button>
)
}));
vi.mock('@/hooks/useCartDimensionCheck', () => ({
useCartDimensionCheck: () => ({ mismatchedKeys: new Set(), hasMismatch: false })
}));

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

Expand Down
69 changes: 41 additions & 28 deletions frontend/src/__tests__/componentTests/CartTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ const view: View = {
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).
// Dataset A has TWO cart entries (a base entry + an already-checked "GFP"
// channel entry), to exercise the multi-entry "Remove" batch path.
// Dataset B is a plain single-entry dataset - both expand identically now
// that metadata is fetched from the internal /api/content URL rather than
// a Data Link.
const cartABase: CartItem = {
fsp_name: 'fsp1',
path: '/a',
Expand Down Expand Up @@ -92,25 +93,17 @@ vi.mock('@/omezarr-helper', () => ({
getResolvedScales: () => [1, 0.65, 0.65],
translateUnitToNeuroglancer: (unit?: string) => unit ?? ''
}));
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>
)
}));
vi.mock('@/hooks/useCartDimensionCheck', () => ({
useCartDimensionCheck: () => ({
mismatchedKeys: new Set(),
hasMismatch: false
})
}));

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

Expand Down Expand Up @@ -140,18 +133,20 @@ describe('Layer Cart tab', () => {
expect(screen.getByText('Dataset B')).toBeInTheDocument();
});

it('lazy-loads and shows channels when expanding a dataset with a Data Link', async () => {
it('lazy-loads and shows channels when expanding a dataset', async () => {
const user = await renderCartTab();
await user.click(screen.getByRole('button', { name: 'Dataset A' }));

await waitFor(() => {
expect(getOmeZarrChannels).toHaveBeenCalledWith('https://data.example/a');
expect(getOmeZarrChannels).toHaveBeenCalledWith(
expect.stringContaining('/api/content/fsp1/a')
);
});
expect(await screen.findByText('DAPI')).toBeInTheDocument();
expect(screen.getByText('GFP')).toBeInTheDocument();
});

it('lazy-loads and shows the axis table when expanding a dataset with a Data Link', async () => {
it('lazy-loads and shows the axis table when expanding a dataset', async () => {
getOmeZarrMetadata.mockResolvedValueOnce({
shapes: [[3, 2048, 2048]],
arr: { chunks: [1, 512, 512] },
Expand All @@ -172,7 +167,9 @@ describe('Layer Cart tab', () => {
await user.click(screen.getByRole('button', { name: /Dataset A/ }));

await waitFor(() => {
expect(getOmeZarrMetadata).toHaveBeenCalledWith('https://data.example/a');
expect(getOmeZarrMetadata).toHaveBeenCalledWith(
expect.stringContaining('/api/content/fsp1/a')
);
});
expect(await screen.findByText('Chunk Size')).toBeInTheDocument();
});
Expand All @@ -193,14 +190,30 @@ describe('Layer Cart tab', () => {
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();
it('shows a "no OME-Zarr metadata" message for a plain (non-OME) array', async () => {
// Plain Zarr array: no channels and getOmeZarrMetadata throws (no
// multiscale group), so the expanded body has nothing to show.
getOmeZarrChannels.mockResolvedValueOnce([]);
getOmeZarrMetadata.mockRejectedValueOnce(new Error('not ome-zarr'));
const user = await renderCartTab();
await user.click(screen.getByRole('button', { name: 'Dataset B' }));

expect(
screen.getByText(/channels load after the view is created/i)
await screen.findByText('No OME-Zarr metadata to display.')
).toBeInTheDocument();
expect(getOmeZarrChannels).not.toHaveBeenCalled();
});

it('expands a dataset that has no Data Link (metadata fetched via /api/content)', async () => {
const user = await renderCartTab();
const expandButton = screen.getByRole('button', { name: 'Dataset B' });
expect(expandButton).not.toBeDisabled();
await user.click(expandButton);

await waitFor(() => {
expect(getOmeZarrChannels).toHaveBeenCalledWith(
expect.stringContaining('/api/content/fsp2/b')
);
});
});

it('toggling a channel checkbox adds a channel-specific CartItem', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ vi.mock('react-router', () => ({ useNavigate: () => vi.fn() }));
vi.mock('@/contexts/CartContext', () => ({
useCartContext: () => ({ clearCart: vi.fn().mockResolvedValue(undefined) })
}));
vi.mock('@/hooks/useCartDimensionCheck', () => ({
useCartDimensionCheck: () => ({ mismatchedKeys: new Set(), hasMismatch: false })
}));

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

Expand Down
8 changes: 7 additions & 1 deletion frontend/src/__tests__/componentTests/NGViews.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ vi.mock('@/queries/proxiedPathQueries', () => ({
vi.mock('@/components/ui/Views/CreateViewButton', () => ({
default: () => <button type="button">Create View</button>
}));
vi.mock('@/contexts/PreferencesContext', () => ({
usePreferencesContext: () => ({ pathPreference: ['linux_path'] })
}));
vi.mock('@/contexts/ZonesAndFspMapContext', () => ({
useZoneAndFspMapContext: () => ({ zonesAndFspQuery: { data: {} } })
}));

import NGViews from '@/components/NGViews';

Expand All @@ -53,7 +59,7 @@ describe('NGViews page', () => {
<NGViews />
</MemoryRouter>
);
expect(screen.getByText('Neuroglancer Views')).toBeInTheDocument();
expect(screen.getByText('Views')).toBeInTheDocument();
expect(screen.getByText('Seeded View')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,14 @@ describe('NeuroglancerView', () => {
).toBeInTheDocument();
});

it('shows a breadcrumb linking back to the NG Views list', () => {
it('shows a breadcrumb linking back to the Views list', () => {
useViewStateByReadKey.mockReturnValue({
data: { title: 'My View', layers: [{ name: 'L0' }] },
isPending: false,
isError: false
});
render(<NeuroglancerView />);
const crumbLink = screen.getByRole('link', { name: /ng views/i });
const crumbLink = screen.getByRole('link', { name: /^views$/i });
expect(crumbLink).toHaveAttribute('href', '/ngviews');
});

Expand Down
35 changes: 32 additions & 3 deletions frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ vi.mock('@/queries/proxiedPathQueries', () => ({
]
})
}));
vi.mock('@/contexts/PreferencesContext', () => ({
usePreferencesContext: () => ({ pathPreference: ['linux_path'] })
}));
vi.mock('@/contexts/ZonesAndFspMapContext', () => ({
useZoneAndFspMapContext: () => ({
zonesAndFspQuery: {
// key format is `fsp_<name>` (see makeMapKey)
data: {
fsp_nrs: {
zone: 'z',
name: 'nrs',
group: '',
storage: '',
mount_path: '/nrs',
linux_path: '/nrs',
mac_path: null,
windows_path: null
}
}
}
})
}));

const view: View = {
short_key: 'k1',
Expand Down Expand Up @@ -66,7 +88,13 @@ function TableProbe({
}) {
// ponytail: TableProbe is already a component, so call the hook directly
// rather than nesting renderHook inside a component under render().
const columns = useNGViewsColumns(onRename, onDelete, 'https://ng.example/');
const columns = useNGViewsColumns(
onRename,
onDelete,
'https://ng.example/',
320,
() => {}
);
const table = useReactTable({
data: [view],
columns,
Expand Down Expand Up @@ -110,10 +138,11 @@ describe('useNGViewsColumns', () => {
<TableProbe onDelete={vi.fn()} onRename={vi.fn()} />
</MemoryRouter>
);
const link = screen.getByText('dudman/reg.zarr/g1_r0');
// Sources show the full path (file share path + subpath), not just the subpath.
const link = screen.getByText('/nrs/dudman/reg.zarr/g1_r0');
expect(link).toBeInTheDocument();
expect(link.closest('a')).toHaveAttribute('href');
expect(screen.getByText('dudman/reg.zarr/g1_r1')).toBeInTheDocument();
expect(screen.getByText('/nrs/dudman/reg.zarr/g1_r1')).toBeInTheDocument();
});

it('fires onRename and onDelete from the actions menu', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ vi.mock('@/queries/proxiedPathQueries', () => ({
useAllProxiedPathsQuery: () => ({ data: [] })
}));
vi.mock('react-router', () => ({ useNavigate: () => vi.fn() }));
vi.mock('@/hooks/useCartDimensionCheck', () => ({
useCartDimensionCheck: () => ({ mismatchedKeys: new Set(), hasMismatch: false })
}));

import { useCreateViewFlow } from '@/hooks/useCreateViewFlow';

Expand Down
49 changes: 49 additions & 0 deletions frontend/src/__tests__/dimensionSignature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { signaturesMatch } from '@/utils/dimensionSignature';
import type { DimensionSignature } from '@/utils/dimensionSignature';

const sig = (
axes: [string, string][],
scales: number[]
): DimensionSignature => ({
axes: axes.map(([name, unit]) => ({ name, unit })),
scales
});

describe('signaturesMatch', () => {
it('matches identical axes and scales', () => {
const a = sig([['x', 'micrometer'], ['y', 'micrometer']], [0.1, 0.1]);
const b = sig([['x', 'micrometer'], ['y', 'micrometer']], [0.1, 0.1]);
expect(signaturesMatch(a, b)).toBe(true);
});

it('mismatches when axis names/order differ', () => {
const a = sig([['x', 'um'], ['y', 'um'], ['z', 'um']], [1, 1, 1]);
const b = sig([['x', 'um'], ['y', 'um']], [1, 1]);
expect(signaturesMatch(a, b)).toBe(false);
});

it('mismatches when a unit differs', () => {
const a = sig([['x', 'micrometer']], [1]);
const b = sig([['x', 'nanometer']], [1]);
expect(signaturesMatch(a, b)).toBe(false);
});

it('mismatches when voxel scale differs beyond relative epsilon', () => {
const a = sig([['x', 'um']], [0.1]);
const b = sig([['x', 'um']], [0.2]);
expect(signaturesMatch(a, b)).toBe(false);
});

it('matches when scales differ within relative epsilon', () => {
const a = sig([['x', 'um']], [0.1]);
const b = sig([['x', 'um']], [0.10005]); // 0.05% off
expect(signaturesMatch(a, b)).toBe(true);
});

it('matches tiny nanometer-scale values that are effectively equal', () => {
const a = sig([['x', 'nm']], [4]);
const b = sig([['x', 'nm']], [4.001]);
expect(signaturesMatch(a, b)).toBe(true);
});
});
35 changes: 29 additions & 6 deletions frontend/src/components/NGViews.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { Typography } from '@material-tailwind/react';
import toast from 'react-hot-toast';

Expand All @@ -19,6 +19,18 @@ export default function NGViews() {
const [renameItem, setRenameItem] = useState<View | undefined>(undefined);
const [renameValue, setRenameValue] = useState('');
const [deleteItem, setDeleteItem] = useState<View | undefined>(undefined);
// Sources column is user-resizable via a drag handle in its header. Width
// lives here (not in the column def) so a re-render on drag actually
// re-flows the CSS grid template.
const [sourcesColWidth, setSourcesColWidth] = useState(260);
const clampSourcesWidth = useCallback(
(w: number) => Math.max(120, Math.min(900, w)),
[]
);
const handleSourcesResize = useCallback(
(next: number) => setSourcesColWidth(clampSourcesWidth(next)),
[clampSourcesWidth]
);

const handleOpenRename = (item: View) => {
setRenameItem(item);
Expand Down Expand Up @@ -55,24 +67,35 @@ export default function NGViews() {
}
};

const columns = useNGViewsColumns(handleOpenRename, setDeleteItem, baseUrl);
const columns = useNGViewsColumns(
handleOpenRename,
setDeleteItem,
baseUrl,
sourcesColWidth,
handleSourcesResize
);

// Fixed pixel tracks for every column except Sources (user-resizable).
// Fixed (not fr) so the row has a deterministic width — that's what lets
// the outer overflow-x-auto scroll when Sources grows past the viewport.
const gridColsStyle = `160px 80px ${sourcesColWidth}px 160px 160px 56px`;

return (
<>
<div className="w-full">
<Typography className="mb-2 text-foreground font-bold" type="h5">
Neuroglancer Views
Views
</Typography>
<Typography className="mb-4 text-foreground">
Your saved Neuroglancer Views.
Your saved Views.
</Typography>

<TableCard
columns={columns}
data={allViewsQuery.data || []}
dataType="NG views"
dataType="views"
errorState={allViewsQuery.error}
gridColsClass="grid-cols-[2fr_0.6fr_1fr_1fr_0.6fr]"
gridColsStyle={gridColsStyle}
loadingState={allViewsQuery.isPending}
/>
</div>
Expand Down
Loading
Loading