Skip to content
Merged
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
4 changes: 3 additions & 1 deletion fileglancer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,9 @@ async def get_neuroglancer_short_links(request: Request,
created_at=entry.created_at,
updated_at=entry.updated_at,
state_url=state_url,
neuroglancer_url=neuroglancer_url
neuroglancer_url=neuroglancer_url,
state=entry.state,
url_base=entry.url_base
))

return NeuroglancerShortLinkResponse(links=links)
Expand Down
6 changes: 6 additions & 0 deletions fileglancer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,12 @@ class NeuroglancerShortLink(BaseModel):
neuroglancer_url: str = Field(
description="Neuroglancer URL that references the stored state"
)
state: Dict = Field(
description="The stored Neuroglancer JSON state object"
)
url_base: str = Field(
description="The Neuroglancer base URL"
)


class NeuroglancerShortLinkResponse(BaseModel):
Expand Down
128 changes: 128 additions & 0 deletions frontend/src/__tests__/componentTests/NGLinkDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from 'vitest';
import type { ReactElement } from 'react';
import { waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '@/__tests__/mocks/node';
import NGLinkDialog from '@/components/ui/Dialogs/NGLinkDialog';
import type { NGLink } from '@/queries/ngLinkQueries';
import { constructNeuroglancerUrl } from '@/utils';

// Mock the nglinks list endpoint so NGLinkProvider's query doesn't fail
server.use(
http.get('/api/neuroglancer/nglinks', () => {
return HttpResponse.json({ links: [] });
})
);

function renderWithQueryClient(ui: ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
);
}

const testState = { layers: [{ name: 'test-layer' }], position: [100, 200] };
const testBaseUrl = 'https://neuroglancer-demo.appspot.com/';

const editItem: NGLink = {
short_key: 'abc123',
short_name: 'test-link',
title: 'Test Title',
created_at: '2025-08-01T00:00:00',
updated_at: '2025-08-01T00:00:00',
state_url: 'http://localhost:7878/api/neuroglancer/state/abc123/test-link',
neuroglancer_url: `${testBaseUrl}#!http://localhost:7878/api/neuroglancer/state/abc123/test-link`,
state: testState,
url_base: testBaseUrl
};

describe('NGLinkDialog', () => {
describe('edit mode - URL mode', () => {
it('prepopulates the URL field with the full Neuroglancer URL', async () => {
renderWithQueryClient(
<NGLinkDialog
editItem={editItem}
onClose={vi.fn()}
onUpdate={vi.fn()}
open={true}
pending={false}
/>
);

const expectedUrl = constructNeuroglancerUrl(testState, testBaseUrl);
await waitFor(() => {
expect(screen.getByLabelText('Neuroglancer URL')).toHaveValue(
expectedUrl
);
});
});

it('prepopulates the title field', async () => {
renderWithQueryClient(
<NGLinkDialog
editItem={editItem}
onClose={vi.fn()}
onUpdate={vi.fn()}
open={true}
pending={false}
/>
);

await waitFor(() => {
expect(
screen.getByLabelText('Title (optional, appears in tab name)')
).toHaveValue('Test Title');
});
});
});

describe('edit mode - State mode', () => {
it('prepopulates JSON state and base URL when switching to state mode', async () => {
renderWithQueryClient(
<NGLinkDialog
editItem={editItem}
onClose={vi.fn()}
onUpdate={vi.fn()}
open={true}
pending={false}
/>
);

const user = userEvent.setup();
await user.click(screen.getByLabelText('State Mode'));

expect(screen.getByLabelText('JSON State')).toHaveValue(
JSON.stringify(testState, null, 2)
);
expect(screen.getByLabelText('Neuroglancer Base URL')).toHaveValue(
testBaseUrl
);
});
});

describe('create mode', () => {
it('starts with empty fields', () => {
renderWithQueryClient(
<NGLinkDialog
onClose={vi.fn()}
onCreate={vi.fn()}
open={true}
pending={false}
/>
);

expect(screen.getByLabelText('Neuroglancer URL')).toHaveValue('');
expect(
screen.getByLabelText('Title (optional, appears in tab name)')
).toHaveValue('');
expect(
screen.getByLabelText('Name (optional, used in shortened link)')
).toHaveValue('');
});
});
});
30 changes: 23 additions & 7 deletions frontend/src/components/ui/Dialogs/NGLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import type { ChangeEvent } from 'react';
import { Button, Typography } from '@material-tailwind/react';

Expand Down Expand Up @@ -34,6 +34,8 @@ export default function NGLinkDialog({
editItem
}: NGLinkDialogProps) {
const isEditMode = !!editItem;
const urlInputRef = useRef<HTMLInputElement>(null);
const shouldAutoSelectUrl = useRef(false);

const [inputMode, setInputMode] = useState<'url' | 'state'>('url');
const [neuroglancerUrl, setNeuroglancerUrl] = useState('');
Expand All @@ -54,13 +56,16 @@ export default function NGLinkDialog({
useEffect(() => {
if (editItem) {
setInputMode('url');
setNeuroglancerUrl('');
setNeuroglancerUrl(
constructNeuroglancerUrl(editItem.state, editItem.url_base)
);
setShortName(editItem.short_name || '');
setTitle(editItem.title || '');
setStateJson('');
setBaseUrl(DEFAULT_BASE_URL);
setStateJson(JSON.stringify(editItem.state, null, 2));
setBaseUrl(editItem.url_base);
setUrlValidationError(null);
setStateValidationError(null);
shouldAutoSelectUrl.current = true;
} else {
setInputMode('url');
setNeuroglancerUrl('');
Expand All @@ -73,6 +78,14 @@ export default function NGLinkDialog({
}
}, [editItem]);

// Auto-select the URL text once after it's populated in edit mode
useEffect(() => {
if (shouldAutoSelectUrl.current && urlInputRef.current) {
urlInputRef.current.select();
shouldAutoSelectUrl.current = false;
}
}, [neuroglancerUrl]);

const validateUrlInput = (value: string): string | null => {
if (!value.trim()) {
return 'Neuroglancer URL is required';
Expand Down Expand Up @@ -109,9 +122,11 @@ export default function NGLinkDialog({

const handleModeChange = (mode: 'url' | 'state') => {
setInputMode(mode);
setNeuroglancerUrl('');
setStateJson('');
setBaseUrl(DEFAULT_BASE_URL);
if (!isEditMode) {
setNeuroglancerUrl('');
setStateJson('');
setBaseUrl(DEFAULT_BASE_URL);
}
setUrlValidationError(null);
setStateValidationError(null);
setError(null);
Expand Down Expand Up @@ -305,6 +320,7 @@ export default function NGLinkDialog({
id="neuroglancer-url"
onChange={handleUrlChange}
placeholder="https://neuroglancer-demo.appspot.com/#!{...}"
ref={urlInputRef}
type="text"
value={neuroglancerUrl}
/>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/queries/ngLinkQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type NGLink = {
updated_at: string;
state_url: string;
neuroglancer_url: string;
state: Record<string, unknown>;
url_base: string;
};

/**
Expand Down