Skip to content
Draft
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
1,478 changes: 1,478 additions & 0 deletions docs/superpowers/plans/2026-08-07-ngviews-04-views-page.md

Large diffs are not rendered by default.

26 changes: 20 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router';
import {
BrowserRouter,
Navigate,
Route,
Routes,
useNavigate
} from 'react-router';
import { ErrorBoundary } from 'react-error-boundary';

import { AuthContextProvider, useAuthContext } from '@/contexts/AuthContext';
Expand All @@ -22,11 +28,12 @@ import Help from '@/components/Help';
import Jobs from '@/components/Jobs';
import Preferences from '@/components/Preferences';
import Links from '@/components/Links';
import NGLinks from '@/components/NGLinks';
import Notifications from '@/components/Notifications';
import SSHKeys from '@/components/SSHKeys';
import ErrorFallback from '@/components/ErrorFallback';
import { NGLinkProvider } from '@/contexts/NGLinkContext';
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 @@ -116,11 +123,18 @@ const AppComponent = () => {
<Route
element={
<RequireAuth>
<NGLinkProvider>
<NGLinks />
</NGLinkProvider>
<ViewsProvider>
<CartProvider>
<NGViews />
</CartProvider>
</ViewsProvider>
</RequireAuth>
}
path="ngviews"
/>
{/* ponytail: legacy /nglinks path redirects; the short-link serving routes (/ng/{key}) are untouched. */}
<Route
element={<Navigate replace to="/ngviews" />}
path="nglinks"
/>
{tasksEnabled ? (
Expand Down
87 changes: 87 additions & 0 deletions frontend/src/__tests__/componentTests/CartContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '@testing-library/react';

const mutateAsync = vi.fn().mockResolvedValue(undefined);
let cartData: unknown[] = [];

vi.mock('@/contexts/PreferencesContext', () => ({
usePreferencesContext: () => ({
preferenceQuery: { data: { neuroglancerCart: cartData } }
})
}));
vi.mock('@/queries/preferencesQueries', () => ({
useUpdatePreferenceMutation: () => ({ mutateAsync })
}));

import { CartProvider, useCartContext } from '@/contexts/CartContext';

function Probe() {
const { cart, cartCount, addToCart, removeFromCart, clearCart } =
useCartContext();
return (
<div>
<span data-testid="count">{cartCount}</span>
<span data-testid="len">{cart.length}</span>
<button
onClick={() =>
addToCart([
{ fsp_name: 'fsp', path: '/a', label: 'a' },
{ fsp_name: 'fsp', path: '/a', label: 'a' } // dup, must not double
])
}
>
add
</button>
<button onClick={() => removeFromCart('/a')}>remove</button>
<button onClick={() => clearCart()}>clear</button>
</div>
);
}

describe('CartContext', () => {
beforeEach(() => {
mutateAsync.mockClear();
cartData = [];
});

it('reflects the preference and exposes a count', () => {
cartData = [{ fsp_name: 'fsp', path: '/x', label: 'x' }];
render(
<CartProvider>
<Probe />
</CartProvider>
);
expect(screen.getByTestId('count').textContent).toBe('1');
expect(screen.getByTestId('len').textContent).toBe('1');
});

it('addToCart dedupes and persists via the preference mutation', async () => {
const user = userEvent.setup();
render(
<CartProvider>
<Probe />
</CartProvider>
);
await user.click(screen.getByText('add'));
expect(mutateAsync).toHaveBeenCalledWith({
key: 'neuroglancerCart',
value: [{ fsp_name: 'fsp', path: '/a', label: 'a' }]
});
});

it('clearCart persists an empty array', async () => {
const user = userEvent.setup();
render(
<CartProvider>
<Probe />
</CartProvider>
);
await user.click(screen.getByText('clear'));
expect(mutateAsync).toHaveBeenCalledWith({
key: 'neuroglancerCart',
value: []
});
});
});
69 changes: 69 additions & 0 deletions frontend/src/__tests__/componentTests/NGViews.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';

import type { View } from '@/queries/viewQueries';

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: []
};

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: [{ fsp_name: 'fsp', path: '/a', label: 'a' }],
cartCount: 1,
addToCart: vi.fn(),
removeFromCart: vi.fn(),
clearCart: vi.fn()
})
}));
vi.mock('@/hooks/useDefaultNeuroglancerBaseUrl', () => ({
useDefaultNeuroglancerBaseUrl: () => 'https://ng.example/'
}));

import NGViews from '@/components/NGViews';

describe('NGViews page', () => {
it('shows Saved Views and Layer Cart tabs, with the seeded view listed', () => {
render(
<MemoryRouter>
<NGViews />
</MemoryRouter>
);
expect(
screen.getByRole('button', { name: /saved views/i })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /layer cart/i })
).toBeInTheDocument();
expect(screen.getByText('Seeded View')).toBeInTheDocument();
});

it('switches to the Layer Cart tab and shows the cart item', async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<NGViews />
</MemoryRouter>
);
await user.click(screen.getByRole('button', { name: /layer cart/i }));
expect(screen.getByText('a')).toBeInTheDocument(); // cart item label
});
});
4 changes: 4 additions & 0 deletions frontend/src/__tests__/componentTests/NavbarBadge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ vi.mock('@/hooks/useActiveJobCount', () => ({
useActiveJobCount: vi.fn(() => 0)
}));

vi.mock('@/hooks/useCartCount', () => ({
useCartCount: () => 0
}));

vi.mock('@/hooks/useTheme', () => ({
default: vi.fn(() => ({
toggleTheme: vi.fn(),
Expand Down
99 changes: 99 additions & 0 deletions frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
useReactTable,
getCoreRowModel,
flexRender
} from '@tanstack/react-table';

import { useNGViewsColumns } from '@/components/ui/Table/ngViewsColumns';
import type { View } from '@/queries/viewQueries';
import { formatDateString } from '@/utils';

const view: View = {
short_key: 'k1',
read_key: 'r1',
name: 'My View',
ng_state: { foo: 'bar' },
sharing_mode: 'read',
owner: 'me',
created_at: '2026-08-01T00:00:00Z',
updated_at: '2026-08-02T00:00:00Z',
layers: [
{
layer_index: 0,
data_link_id: 1,
channel: null,
opts: null,
broken: false
},
{
layer_index: 1,
data_link_id: 2,
channel: 'ch0',
opts: null,
broken: false
}
]
};

function TableProbe({
onRename,
onDelete
}: {
onRename: (v: View) => void;
onDelete: (v: View) => void;
}) {
// 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 table = useReactTable({
data: [view],
columns,
getCoreRowModel: getCoreRowModel()
});
return (
<table>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

describe('useNGViewsColumns', () => {
it('renders name, layer count, sharing label and updated date', () => {
render(<TableProbe onDelete={vi.fn()} onRename={vi.fn()} />);
expect(screen.getByText('My View')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // layer count
expect(screen.getByText(/shared/i)).toBeInTheDocument(); // sharing label
expect(
screen.getByText(formatDateString(view.updated_at))
).toBeInTheDocument(); // updated date
});

it('fires onRename and onDelete from the actions menu', async () => {
const user = userEvent.setup();
const onRename = vi.fn();
const onDelete = vi.fn();
render(<TableProbe onDelete={onDelete} onRename={onRename} />);
const trigger = screen.getByRole('button'); // the CardActionsMenu trigger

await user.click(trigger);
await user.click(await screen.findByText('Rename'));
expect(onRename).toHaveBeenCalledWith(view);

await user.click(trigger);
await user.click(await screen.findByText('Delete'));
expect(onDelete).toHaveBeenCalledWith(view);
});
});
64 changes: 64 additions & 0 deletions frontend/src/__tests__/unitTests/viewQueries.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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';

// Stub the low-level fetch/url utils so the queries never hit the network.
const sendFetchRequest = vi.fn();
const buildUrl = vi.fn(
(base: string, seg: string | null) => `${base}${seg ?? ''}`
);
vi.mock('@/utils', () => ({
sendFetchRequest: (...args: unknown[]) => sendFetchRequest(...args),
buildUrl: (...args: Parameters<typeof buildUrl>) => buildUrl(...args)
}));

import { useViewsQuery, viewQueryKeys } from '@/queries/viewQueries';

function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

describe('viewQueries', () => {
beforeEach(() => {
sendFetchRequest.mockReset();
});

it('has a stable query-key factory', () => {
expect(viewQueryKeys.list()).toEqual(['views', 'list']);
});

it('unwraps the { views } envelope from the list endpoint', async () => {
sendFetchRequest.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
views: [{ short_key: 'k1', name: 'A', layers: [] }]
})
});
const { result } = renderHook(() => useViewsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0].short_key).toBe('k1');
expect(sendFetchRequest).toHaveBeenCalledWith(
'/api/neuroglancer/views',
'GET',
undefined,
expect.objectContaining({ signal: expect.anything() })
);
});

it('treats a 404 list as an empty array (no error)', async () => {
sendFetchRequest.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({})
});
const { result } = renderHook(() => useViewsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});
Loading
Loading