diff --git a/frontend/src/__tests__/componentTests/CartList.test.tsx b/frontend/src/__tests__/componentTests/CartList.test.tsx index f8ff6e6fc..f0a13abdf 100644 --- a/frontend/src/__tests__/componentTests/CartList.test.tsx +++ b/frontend/src/__tests__/componentTests/CartList.test.tsx @@ -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 }) => (
{label}
@@ -49,6 +46,12 @@ vi.mock('@/components/ui/Views/CreateViewButton', () => ({ ) })); +vi.mock('@/hooks/useCartDimensionCheck', () => ({ + useCartDimensionCheck: () => ({ + mismatchedKeys: new Set(), + hasMismatch: false + }) +})); import CartList from '@/components/ui/Views/CartList'; diff --git a/frontend/src/__tests__/componentTests/CartTab.test.tsx b/frontend/src/__tests__/componentTests/CartTab.test.tsx index 408f0fa11..19185561e 100644 --- a/frontend/src/__tests__/componentTests/CartTab.test.tsx +++ b/frontend/src/__tests__/componentTests/CartTab.test.tsx @@ -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', @@ -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 }) => ( ) })); +vi.mock('@/hooks/useCartDimensionCheck', () => ({ + useCartDimensionCheck: () => ({ + mismatchedKeys: new Set(), + hasMismatch: false + }) +})); import CartList from '@/components/ui/Views/CartList'; @@ -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] }, @@ -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(); }); @@ -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 () => { diff --git a/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx b/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx index 4818f8c13..1c228afef 100644 --- a/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx +++ b/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx @@ -22,6 +22,12 @@ 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'; diff --git a/frontend/src/__tests__/componentTests/MainLayout.test.tsx b/frontend/src/__tests__/componentTests/MainLayout.test.tsx index 0c4e0db09..3cd9fac5f 100644 --- a/frontend/src/__tests__/componentTests/MainLayout.test.tsx +++ b/frontend/src/__tests__/componentTests/MainLayout.test.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from 'react'; // MainLayout composes ~a dozen context providers unrelated to this test; // stub them all as passthroughs so we can assert on the one thing that -// changed: the navbar is no longer skipped for /view/:readKey. +// changed: the navbar is now skipped for /view/:readKey. // vi.mock factories are hoisted above imports, so the shared stub must be // created via vi.hoisted rather than a plain top-level const. const { passthrough } = vi.hoisted(() => ({ @@ -69,7 +69,7 @@ vi.mock('@/contexts/ViewersContext', () => ({ ViewersProvider: passthrough })); import { MainLayout } from '@/layouts/MainLayout'; describe('MainLayout', () => { - it('renders the navbar on the embedded viewer route (/view/:readKey)', () => { + it('suppresses the navbar on the embedded viewer route (/view/:readKey)', () => { render( @@ -79,7 +79,7 @@ describe('MainLayout', () => { ); - expect(screen.getByTestId('navbar')).toBeInTheDocument(); + expect(screen.queryByTestId('navbar')).not.toBeInTheDocument(); }); it('still renders the navbar on an ordinary route', () => { diff --git a/frontend/src/__tests__/componentTests/NGViews.test.tsx b/frontend/src/__tests__/componentTests/NGViews.test.tsx index dfa2ccc04..8f17acdb9 100644 --- a/frontend/src/__tests__/componentTests/NGViews.test.tsx +++ b/frontend/src/__tests__/componentTests/NGViews.test.tsx @@ -43,6 +43,12 @@ vi.mock('@/queries/proxiedPathQueries', () => ({ vi.mock('@/components/ui/Views/CreateViewButton', () => ({ default: () => })); +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferencesContext: () => ({ pathPreference: ['linux_path'] }) +})); +vi.mock('@/contexts/ZonesAndFspMapContext', () => ({ + useZoneAndFspMapContext: () => ({ zonesAndFspQuery: { data: {} } }) +})); import NGViews from '@/components/NGViews'; @@ -53,7 +59,7 @@ describe('NGViews page', () => { ); - expect(screen.getByText('Neuroglancer Views')).toBeInTheDocument(); + expect(screen.getByText('Views')).toBeInTheDocument(); expect(screen.getByText('Seeded View')).toBeInTheDocument(); }); }); diff --git a/frontend/src/__tests__/componentTests/NavbarBadge.test.tsx b/frontend/src/__tests__/componentTests/NavbarBadge.test.tsx index 757f4ae99..0928dcd12 100644 --- a/frontend/src/__tests__/componentTests/NavbarBadge.test.tsx +++ b/frontend/src/__tests__/componentTests/NavbarBadge.test.tsx @@ -14,14 +14,6 @@ vi.mock('@/hooks/useCartCount', () => ({ useCartCount: () => 0 })); -vi.mock('@/hooks/useTheme', () => ({ - default: vi.fn(() => ({ - toggleTheme: vi.fn(), - isLightTheme: true, - setIsLightTheme: vi.fn() - })) -})); - vi.mock('@/utils/fathom', () => ({ trackEvent: vi.fn() })); diff --git a/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx b/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx index ba9b154aa..9e039ebc8 100644 --- a/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx +++ b/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx @@ -22,6 +22,10 @@ const { copyToClipboard } = vi.hoisted(() => ({ })); vi.mock('@/utils/copyText', () => ({ copyToClipboard })); +vi.mock('@/components/ui/Navbar/ProfileMenu', () => ({ + default: () =>
+})); + import NeuroglancerView from '@/components/NeuroglancerView'; describe('NeuroglancerView', () => { @@ -73,18 +77,18 @@ describe('NeuroglancerView', () => { screen.getByRole('button', { name: /download json/i }) ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: /open external/i }) + screen.getByRole('button', { name: /open in neuroglancer/i }) ).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(); - const crumbLink = screen.getByRole('link', { name: /ng views/i }); + const crumbLink = screen.getByRole('link', { name: /^views$/i }); expect(crumbLink).toHaveAttribute('href', '/ngviews'); }); diff --git a/frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx b/frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx index c43c68349..2d59452c5 100644 --- a/frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx +++ b/frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx @@ -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_` (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', @@ -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, @@ -110,10 +138,11 @@ describe('useNGViewsColumns', () => { ); - 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 () => { diff --git a/frontend/src/__tests__/componentTests/useCreateViewFlow.test.tsx b/frontend/src/__tests__/componentTests/useCreateViewFlow.test.tsx index 85cca967a..d0dee71d7 100644 --- a/frontend/src/__tests__/componentTests/useCreateViewFlow.test.tsx +++ b/frontend/src/__tests__/componentTests/useCreateViewFlow.test.tsx @@ -19,6 +19,12 @@ 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'; diff --git a/frontend/src/__tests__/dimensionSignature.test.ts b/frontend/src/__tests__/dimensionSignature.test.ts new file mode 100644 index 000000000..a63940fe0 --- /dev/null +++ b/frontend/src/__tests__/dimensionSignature.test.ts @@ -0,0 +1,74 @@ +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); + }); +}); diff --git a/frontend/src/components/Help.tsx b/frontend/src/components/Help.tsx index f16c19f83..888fdc8bb 100644 --- a/frontend/src/components/Help.tsx +++ b/frontend/src/components/Help.tsx @@ -46,6 +46,13 @@ function getHelpLinks(version: string | undefined): HelpLink[] { ? `https://github.com/JaneliaSciComp/fileglancer/releases/tag/${version}` : 'https://github.com/JaneliaSciComp/fileglancer/releases' }, + { + icon: TbBrandGithub, + title: 'GitHub Repository', + description: + 'Browse the source code, report issues, and contribute on GitHub', + url: 'https://github.com/JaneliaSciComp/fileglancer' + }, { icon: SiClickup, title: 'Submit Tickets', diff --git a/frontend/src/components/NGViews.tsx b/frontend/src/components/NGViews.tsx index 76704dd7b..66ee5b295 100644 --- a/frontend/src/components/NGViews.tsx +++ b/frontend/src/components/NGViews.tsx @@ -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'; @@ -19,6 +19,18 @@ export default function NGViews() { const [renameItem, setRenameItem] = useState(undefined); const [renameValue, setRenameValue] = useState(''); const [deleteItem, setDeleteItem] = useState(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); @@ -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 ( <>
- Neuroglancer Views + Views - Your saved Neuroglancer Views. + Your saved Views.
diff --git a/frontend/src/components/NeuroglancerView.tsx b/frontend/src/components/NeuroglancerView.tsx index fe6b9fdaf..32ab33b47 100644 --- a/frontend/src/components/NeuroglancerView.tsx +++ b/frontend/src/components/NeuroglancerView.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; -import { useParams } from 'react-router'; -import { Typography } from '@material-tailwind/react'; +import type { IconType } from 'react-icons'; +import { Link, useParams } from 'react-router'; +import { IconButton, Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; import { HiOutlineDuplicate, @@ -14,9 +15,32 @@ import { useInternalNeuroglancerBaseUrl } from '@/hooks/useDefaultNeuroglancerBa import { constructNeuroglancerUrl } from '@/utils/neuroglancerUrl'; import { downloadTextFile } from '@/utils'; import { copyToClipboard } from '@/utils/copyText'; -import FgButton from '@/components/designSystem/atoms/FgButton'; import FgIcon from '@/components/designSystem/atoms/FgIcon'; import FgLink from '@/components/designSystem/atoms/FgLink'; +import LogoSvg from '@/components/ui/Navbar/LogoSvg'; +import ProfileMenu from '@/components/ui/Navbar/ProfileMenu'; +import FgTooltip from '@/components/ui/widgets/FgTooltip'; + +type ToolbarIconButtonProps = { + readonly label: string; + readonly icon: IconType; + readonly onClick: () => void; +}; + +function ToolbarIconButton({ label, icon, onClick }: ToolbarIconButtonProps) { + return ( + + + + + + ); +} export default function NeuroglancerView() { const { readKey } = useParams(); @@ -82,50 +106,52 @@ export default function NeuroglancerView() { className="flex h-full w-full flex-col bg-background" ref={containerRef} > -
-
- - NG Views - - / - - {title} - +
+
+ + + +
+ + Views + + / + {title} +
-
- - {title} - -
- void handleCopy()} variant="ghost"> - Copy link - - +
+ void handleCopy()} + /> + downloadTextFile( JSON.stringify(ngState, null, 2), `${title}.json` ) } - variant="ghost" - > - Download JSON - - + window.open(externalUrl, '_blank', 'noopener,noreferrer') } - variant="ghost" - > - Open external - - - Fullscreen - + /> +
+ + +