From 632bd75a09ce71e9436d07647ce05f34c23b438a Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 12:47:22 -0400 Subject: [PATCH 01/20] feat(views): add useViewStateByReadKey for the embedded viewer --- .../unitTests/viewStateByReadKey.test.tsx | 61 +++++++++++++++++++ frontend/src/queries/viewQueries.ts | 39 +++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/unitTests/viewStateByReadKey.test.tsx diff --git a/frontend/src/__tests__/unitTests/viewStateByReadKey.test.tsx b/frontend/src/__tests__/unitTests/viewStateByReadKey.test.tsx new file mode 100644 index 00000000..c803c1b4 --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewStateByReadKey.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +const { sendFetchRequest } = vi.hoisted(() => ({ sendFetchRequest: vi.fn() })); +vi.mock('@/utils', () => ({ + sendFetchRequest, + buildUrl: (base: string, seg: string | null) => `${base}${seg ?? ''}` +})); + +import { useViewStateByReadKey } from '@/queries/viewQueries'; + +const fakeResponse = (status: number, body: unknown) => + ({ + ok: status < 300, + status, + statusText: String(status), + json: async () => body + }) as unknown as Response; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return {children}; +} + +beforeEach(() => sendFetchRequest.mockReset()); + +describe('useViewStateByReadKey', () => { + it('returns the raw ng_state on success', async () => { + sendFetchRequest.mockResolvedValue( + fakeResponse(200, { layers: [{ name: 'L0' }] }) + ); + const { result } = renderHook(() => useViewStateByReadKey('rk1'), { + wrapper + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ layers: [{ name: 'L0' }] }); + }); + + it('returns null on 404', async () => { + sendFetchRequest.mockResolvedValue( + fakeResponse(404, { detail: 'View not found' }) + ); + const { result } = renderHook(() => useViewStateByReadKey('rk1'), { + wrapper + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeNull(); + }); + + it('is disabled without a read key', () => { + const { result } = renderHook(() => useViewStateByReadKey(undefined), { + wrapper + }); + expect(result.current.fetchStatus).toBe('idle'); + expect(sendFetchRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/queries/viewQueries.ts b/frontend/src/queries/viewQueries.ts index 72ca593f..9e572ad9 100644 --- a/frontend/src/queries/viewQueries.ts +++ b/frontend/src/queries/viewQueries.ts @@ -64,7 +64,8 @@ export const viewQueryKeys = { all: ['views'] as const, list: () => ['views', 'list'] as const, forDataLink: (sharingKey: string) => - ['views', 'forDataLink', sharingKey] as const + ['views', 'forDataLink', sharingKey] as const, + state: (readKey: string) => ['views', 'state', readKey] as const }; /** @@ -129,6 +130,28 @@ const fetchViewsForDataLink = async ( throwResponseNotOkError(response, data); }; +/** + * Fetches a View's raw Neuroglancer state by its public read key. + * Mirrors fetchViews' manual status branch: 404 (unknown/removed key) → null, + * not an error, so the embedded viewer can render a "View not found" state. + * The endpoint (GET /ngview/{key}) returns the bare ng_state object, unwrapped. + */ +const fetchViewState = async ( + readKey: string, + signal?: AbortSignal +): Promise | null> => { + const url = buildUrl('/ngview/', readKey, null); + const response = await sendFetchRequest(url, 'GET', undefined, { signal }); + if (response.status === 404) { + return null; + } + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data as Record; +}; + /** * Query hook for fetching all Views belonging to the current user * @@ -155,6 +178,20 @@ export function useViewsForDataLinkQuery( }); } +/** + * Query hook for a View's Neuroglancer state by read key. Disabled until a + * read key is provided. Data is `null` when the key resolves to no View. + */ +export function useViewStateByReadKey( + readKey?: string +): UseQueryResult | null, Error> { + return useQuery | null, Error>({ + queryKey: viewQueryKeys.state(readKey ?? ''), + queryFn: ({ signal }) => fetchViewState(readKey!, signal), + enabled: !!readKey + }); +} + /** * Mutation hook for creating a View * From 98c85ed4965da978e17c36724522edee3a46adb1 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Mon, 10 Aug 2026 12:52:40 -0400 Subject: [PATCH 02/20] feat(views): embedded NeuroglancerView page (iframe + exports) --- .../componentTests/NeuroglancerView.test.tsx | 61 ++++++++++ frontend/src/components/NeuroglancerView.tsx | 109 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx create mode 100644 frontend/src/components/NeuroglancerView.tsx diff --git a/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx b/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx new file mode 100644 index 00000000..88c67496 --- /dev/null +++ b/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +const { useViewStateByReadKey } = vi.hoisted(() => ({ + useViewStateByReadKey: vi.fn() +})); +vi.mock('@/queries/viewQueries', () => ({ useViewStateByReadKey })); +vi.mock('@/hooks/useDefaultNeuroglancerBaseUrl', () => ({ + useDefaultNeuroglancerBaseUrl: () => 'https://ng.example/' +})); +vi.mock('react-router', () => ({ useParams: () => ({ readKey: 'rk1' }) })); + +import NeuroglancerView from '@/components/NeuroglancerView'; + +describe('NeuroglancerView', () => { + it('shows a loading state while pending', () => { + useViewStateByReadKey.mockReturnValue({ + data: undefined, + isPending: true, + isError: false + }); + render(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it('shows "not found" when the key resolves to null', () => { + useViewStateByReadKey.mockReturnValue({ + data: null, + isPending: false, + isError: false + }); + render(); + expect(screen.getByText(/view not found/i)).toBeInTheDocument(); + }); + + it('iframes Neuroglancer with the inline state and shows the export actions', () => { + useViewStateByReadKey.mockReturnValue({ + data: { title: 'My View', layers: [{ name: 'L0' }] }, + isPending: false, + isError: false + }); + render(); + const iframe = screen.getByTitle(/neuroglancer/i) as HTMLIFrameElement; + expect(iframe.src).toContain('https://ng.example/#!'); + expect(iframe.src).toContain( + encodeURIComponent( + JSON.stringify({ title: 'My View', layers: [{ name: 'L0' }] }) + ) + ); + expect(screen.getByText('My View')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /copy link/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /download json/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /open external/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/NeuroglancerView.tsx b/frontend/src/components/NeuroglancerView.tsx new file mode 100644 index 00000000..1e7ed89b --- /dev/null +++ b/frontend/src/components/NeuroglancerView.tsx @@ -0,0 +1,109 @@ +import { useRef } from 'react'; +import { useParams } from 'react-router'; +import { Typography } from '@material-tailwind/react'; +import toast from 'react-hot-toast'; +import { + HiOutlineDuplicate, + HiOutlineDownload, + HiOutlineExternalLink, + HiOutlineArrowsExpand +} from 'react-icons/hi'; + +import { useViewStateByReadKey } from '@/queries/viewQueries'; +import { useDefaultNeuroglancerBaseUrl } from '@/hooks/useDefaultNeuroglancerBaseUrl'; +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'; + +export default function NeuroglancerView() { + const { readKey } = useParams(); + const stateQuery = useViewStateByReadKey(readKey); + const baseUrl = useDefaultNeuroglancerBaseUrl(); + const containerRef = useRef(null); + + if (stateQuery.isPending) { + return ( +
+ Loading View… +
+ ); + } + + const ngState = stateQuery.data; + if (stateQuery.isError || !ngState) { + return ( +
+ View not found +
+ ); + } + + const title = (ngState.title as string) || 'Neuroglancer View'; + const externalUrl = constructNeuroglancerUrl(ngState, baseUrl); + + const handleCopy = async () => { + const result = await copyToClipboard(externalUrl); + if (result.success) { + toast.success('Neuroglancer link copied'); + } else { + toast.error(`Failed to copy: ${result.error}`); + } + }; + + const handleFullscreen = () => { + // ponytail: native Fullscreen API on the container — the /view route is + // already chrome-less, so this just drops the top bar into the OS + // fullscreen; no custom fullscreen state machine. + void containerRef.current?.requestFullscreen?.(); + }; + + return ( +
+
+ + {title} + +
+ void handleCopy()} variant="ghost"> + Copy link + + + downloadTextFile( + JSON.stringify(ngState, null, 2), + `${title}.json` + ) + } + variant="ghost" + > + Download JSON + + + window.open(externalUrl, '_blank', 'noopener,noreferrer') + } + variant="ghost" + > + Open external + + + Fullscreen + +
+
+