From dae9e25f7020a566979b2b93fa1b11bcaef23a11 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 16:47:51 +0300 Subject: [PATCH] web: sectioned /settings with saved-searches CRUD Add four TanStack Query hooks (useSavedSearches, useCreateSavedSearch, useUpdateSavedSearch, useDeleteSavedSearch) backed by IF3 contract. Introduce apiFetchVoid in client.ts for the 204 No Content DELETE path. Replace the placeholder /settings route with a two-column sectioned shell (SectionRail + SavedSearchesSection); Display section is disabled pending #8. --- internal/server/web/dist/index.html | 4 +- web/src/api/client.ts | 31 +++ .../settings/SavedSearchesSection.tsx | 227 ++++++++++++++++++ web/src/features/settings/SectionRail.tsx | 57 +++++ .../settings/useSavedSearches.test.ts | 94 ++++++++ web/src/features/settings/useSavedSearches.ts | 79 ++++++ web/src/routes/settings.tsx | 23 +- web/src/styles/settings.css | 225 +++++++++++++++++ 8 files changed, 734 insertions(+), 6 deletions(-) create mode 100644 web/src/features/settings/SavedSearchesSection.tsx create mode 100644 web/src/features/settings/SectionRail.tsx create mode 100644 web/src/features/settings/useSavedSearches.test.ts create mode 100644 web/src/features/settings/useSavedSearches.ts create mode 100644 web/src/styles/settings.css diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html index 576934c70b8ea69dd6fd555de1f6491b9ae7aead..e69236073ff22fda736c72267e83466d77661280 100644 --- a/internal/server/web/dist/index.html +++ b/internal/server/web/dist/index.html @@ -13,8 +13,8 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" /> - - + +
diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 878d7adb581860a4a3b5bb30c43aef327510c6b8..1e5cc474260d0276ad4903f0d1bd92f9ad9ec1bf 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -43,3 +43,34 @@ export async function apiFetch(path: string, init?: RequestInit): Promise return resp.json() as Promise } + +/** + * apiFetchVoid is a variant of apiFetch for endpoints that return no body + * (e.g. DELETE → 204 No Content). It shares the same auth and error handling + * as apiFetch but does not attempt to parse the response body. + */ +export async function apiFetchVoid(path: string, init?: RequestInit): Promise { + const resp = await fetch(path, { + ...init, + headers: { + Accept: 'application/json', + ...init?.headers, + }, + }) + + if (resp.status === 401) { + throw new AuthError('not authenticated') + } + + if (!resp.ok) { + const ct = resp.headers.get('Content-Type') ?? '' + if (ct.includes('application/problem+json')) { + const body = await resp.json() as { detail?: string; title?: string; status?: number; code?: string } + throw new APIError(body.detail ?? body.title ?? 'error', body.status ?? resp.status, body.code ?? '') + } + if (resp.status >= 500) { + throw new APIError('server error', resp.status, '') + } + throw new APIError(`request failed: ${resp.status}`, resp.status, '') + } +} diff --git a/web/src/features/settings/SavedSearchesSection.tsx b/web/src/features/settings/SavedSearchesSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f30e2bd62eac4b99b6d70c55f07b82902e237b84 --- /dev/null +++ b/web/src/features/settings/SavedSearchesSection.tsx @@ -0,0 +1,227 @@ +import React, { useState } from 'react' +import { EmptyState, Sub } from '../../primitives' +import { AuthError, APIError } from '../../api/client' +import type { SavedSearch } from '../../api/adapters' +import { + useSavedSearches, + useCreateSavedSearch, + useUpdateSavedSearch, + useDeleteSavedSearch, +} from './useSavedSearches' + +// ── Inline "Add new" form ───────────────────────────────────────────────────── + +function AddForm(): React.JSX.Element { + const [name, setName] = useState('') + const [query, setQuery] = useState('') + const createMutation = useCreateSavedSearch() + + function handleSave(e: React.FormEvent) { + e.preventDefault() + if (!name.trim() || !query.trim()) return + createMutation.mutate( + { name: name.trim(), query: query.trim() }, + { + onSuccess: () => { + setName('') + setQuery('') + }, + }, + ) + } + + return ( +
+ setName(e.target.value)} + autoComplete="off" + /> + setQuery(e.target.value)} + autoComplete="off" + /> + + {createMutation.isError && ( + + {createMutation.error instanceof APIError + ? createMutation.error.message + : String(createMutation.error)} + + )} +
+ ) +} + +// ── Per-row inline edit form ────────────────────────────────────────────────── + +interface RowProps { + row: SavedSearch +} + +function SavedSearchRow({ row }: RowProps): React.JSX.Element { + const [editing, setEditing] = useState(false) + const [name, setName] = useState(row.name) + const [query, setQuery] = useState(row.query) + const updateMutation = useUpdateSavedSearch() + const deleteMutation = useDeleteSavedSearch() + + function handleSave(e: React.FormEvent) { + e.preventDefault() + updateMutation.mutate( + { oldName: row.name, name: name.trim() || undefined, query: query.trim() || undefined }, + { onSuccess: () => setEditing(false) }, + ) + } + + function handleDelete() { + deleteMutation.mutate({ name: row.name }) + } + + if (editing) { + return ( +
+
+ setName(e.target.value)} + autoComplete="off" + /> + setQuery(e.target.value)} + autoComplete="off" + /> + + +
+ {updateMutation.isError && ( + + {updateMutation.error instanceof APIError + ? updateMutation.error.message + : String(updateMutation.error)} + + )} +
+ ) + } + + return ( +
+ {row.name} + {row.query} + + {new Date(row.updatedAt).toLocaleDateString()} + + + + + + {deleteMutation.isError && ( + + {deleteMutation.error instanceof APIError + ? deleteMutation.error.message + : String(deleteMutation.error)} + + )} +
+ ) +} + +// ── Main section ────────────────────────────────────────────────────────────── + +export function SavedSearchesSection(): React.JSX.Element { + const { data, isLoading, error } = useSavedSearches() + + if (isLoading) { + return loading… + } + + if (error != null) { + if (error instanceof AuthError) { + return ( +
+
+
not authenticated
+
Sign in to manage your saved searches.
+
+
+ ) + } + const detail = error instanceof APIError ? error.message : String(error) + return ( +
+
+
error
+
{detail}
+
+
+ ) + } + + return ( +
+
Saved searches
+ + {data != null && data.length === 0 ? ( + + ) : ( +
+
+ Name + Query + Updated + +
+ {data?.map((row) => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/src/features/settings/SectionRail.tsx b/web/src/features/settings/SectionRail.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0456ccda22960ec5c00aa533fb3b6209772e36b7 --- /dev/null +++ b/web/src/features/settings/SectionRail.tsx @@ -0,0 +1,57 @@ +import React from 'react' + +interface Section { + key: K + label: string + disabled?: boolean + tag?: string +} + +interface SectionRailProps { + sections: Section[] + active: K + onSelect: (k: K) => void +} + +export function SectionRail({ + sections, + active, + onSelect, +}: SectionRailProps): React.JSX.Element { + return ( +
+ {sections.map((s) => { + const isActive = s.key === active + const cls = [ + 'section-row', + isActive ? 'active' : '', + s.disabled ? 'disabled' : '', + ] + .filter(Boolean) + .join(' ') + + return ( +
onSelect(s.key)} + role={s.disabled ? undefined : 'button'} + tabIndex={s.disabled ? undefined : 0} + onKeyDown={ + s.disabled + ? undefined + : (e) => { + if (e.key === 'Enter' || e.key === ' ') onSelect(s.key) + } + } + > + {s.label} + {s.tag != null && ( + {s.tag} + )} +
+ ) + })} +
+ ) +} diff --git a/web/src/features/settings/useSavedSearches.test.ts b/web/src/features/settings/useSavedSearches.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb87f77e20886bdd3f6b08270a40b930b400c207 --- /dev/null +++ b/web/src/features/settings/useSavedSearches.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useSavedSearches, useCreateSavedSearch } from './useSavedSearches' +import type { SavedSearchDTO } from '../../api/adapters' + +// ── Mock apiFetch ───────────────────────────────────────────────────────────── + +const mockApiFetch = vi.fn() + +vi.mock('../../api/client', () => ({ + apiFetch: (...args: unknown[]) => mockApiFetch(...args), + apiFetchVoid: vi.fn().mockResolvedValue(undefined), + AuthError: class AuthError extends Error { + name = 'AuthError' + constructor(message: string) { super(message) } + }, + APIError: class APIError extends Error { + name = 'APIError' + status: number + code: string + constructor(message: string, status: number, code: string) { + super(message) + this.status = status + this.code = code + } + }, +})) + +// ── Test wrapper ────────────────────────────────────────────────────────────── + +function makeWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('useSavedSearches + useCreateSavedSearch — cache invalidation', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + mockApiFetch.mockReset() + }) + + it('invalidates saved-searches after createSavedSearch succeeds', async () => { + const emptyResponse = { saved_searches: [] as SavedSearchDTO[] } + const filledResponse = { + saved_searches: [ + { name: 'a', query: 'q', created_at: 0, updated_at: 0 } satisfies SavedSearchDTO, + ], + } + // POST returns the created row + const createdRow: SavedSearchDTO = { name: 'a', query: 'q', created_at: 0, updated_at: 0 } + + // First call → initial list fetch (empty) + // Second call → re-fetch after invalidation (one row) + // Third call may come from the mutation POST + mockApiFetch + .mockResolvedValueOnce(emptyResponse) // initial GET + .mockResolvedValueOnce(createdRow) // POST create + .mockResolvedValueOnce(filledResponse) // re-fetch after invalidation + + const wrapper = makeWrapper(queryClient) + + const { result: listResult } = renderHook(() => useSavedSearches(), { wrapper }) + const { result: mutResult } = renderHook(() => useCreateSavedSearch(), { wrapper }) + + // Wait for initial fetch to settle + await waitFor(() => expect(listResult.current.isSuccess).toBe(true)) + expect(listResult.current.data).toEqual([]) + + // Call mutate + act(() => { + mutResult.current.mutate({ name: 'a', query: 'q' }) + }) + + // Wait for mutation success + re-fetch + await waitFor(() => expect(mutResult.current.isSuccess).toBe(true)) + await waitFor(() => { + expect(listResult.current.data?.length).toBe(1) + }) + + expect(listResult.current.data?.[0].name).toBe('a') + }) +}) diff --git a/web/src/features/settings/useSavedSearches.ts b/web/src/features/settings/useSavedSearches.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1cc965bfb752861f5c349245f59c497c17e195a --- /dev/null +++ b/web/src/features/settings/useSavedSearches.ts @@ -0,0 +1,79 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query' +import { apiFetch, apiFetchVoid } from '../../api/client' +import { adaptSavedSearch } from '../../api/adapters' +import type { SavedSearch, SavedSearchDTO } from '../../api/adapters' + +interface SavedSearchesResponse { + saved_searches: SavedSearchDTO[] +} + +export function useSavedSearches(): UseQueryResult { + return useQuery({ + queryKey: ['saved-searches'], + queryFn: async () => { + const data = await apiFetch('/api/v1/saved-searches') + return data.saved_searches.map(adaptSavedSearch) + }, + staleTime: 30_000, + }) +} + +export function useCreateSavedSearch(): UseMutationResult { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ name, query }) => { + const dto = await apiFetch('/api/v1/saved-searches', { + method: 'POST', + body: JSON.stringify({ name, query }), + headers: { 'Content-Type': 'application/json' }, + }) + return adaptSavedSearch(dto) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['saved-searches'] }) + }, + }) +} + +export function useUpdateSavedSearch(): UseMutationResult< + SavedSearch, + Error, + { oldName: string; name?: string; query?: string } +> { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ oldName, name, query }) => { + const body: Record = {} + if (name !== undefined) body['name'] = name + if (query !== undefined) body['query'] = query + + const dto = await apiFetch( + `/api/v1/saved-searches/${encodeURIComponent(oldName)}`, + { + method: 'PUT', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }, + ) + return adaptSavedSearch(dto) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['saved-searches'] }) + }, + }) +} + +export function useDeleteSavedSearch(): UseMutationResult { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ name }) => { + await apiFetchVoid(`/api/v1/saved-searches/${encodeURIComponent(name)}`, { + method: 'DELETE', + }) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['saved-searches'] }) + }, + }) +} diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index 2e4632c2e288973d7ac847a342c380a8ffff34a3..4836631fbc8bead8ff20380cc51f3b856a66da11 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -1,20 +1,35 @@ import { createFileRoute } from '@tanstack/react-router' -import React from 'react' -import { EmptyState, Tag } from '../primitives' +import React, { useState } from 'react' +import { Tag } from '../primitives' import { SubBar } from '../shell/SubBar' +import { SectionRail } from '../features/settings/SectionRail' +import { SavedSearchesSection } from '../features/settings/SavedSearchesSection' +import '../styles/settings.css' + +type SectionKey = 'saved-searches' | 'display' + +const SECTIONS: { key: SectionKey; label: string; disabled?: boolean; tag?: string }[] = [ + { key: 'saved-searches', label: 'Saved searches' }, + { key: 'display', label: 'Display', disabled: true, tag: 'in #8' }, +] export const Route = createFileRoute('/settings')({ component: SettingsRoute, }) function SettingsRoute(): React.JSX.Element { + const [active, setActive] = useState('saved-searches') + return ( <> settings -
- +
+ +
+ {active === 'saved-searches' && } +
) diff --git a/web/src/styles/settings.css b/web/src/styles/settings.css new file mode 100644 index 0000000000000000000000000000000000000000..b89c17fb0f186060f7b15f0b5d8677b94bda717a --- /dev/null +++ b/web/src/styles/settings.css @@ -0,0 +1,225 @@ +/* Settings route */ + +/* Two-column shell: narrow sidebar + main panel */ +.settings-shell { + display: grid; + grid-template-columns: 220px 1fr; + gap: 0; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.settings-panel { + padding: 16px 20px; + overflow: auto; + min-height: 0; +} + +/* ── Section rail (vertical nav) ────────────────────────────────────────────── */ + +.section-rail { + display: flex; + flex-direction: column; + border-right: 1px solid var(--rule-2); + padding: 8px 0; + overflow: auto; +} + +.section-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + font-size: 12px; + color: var(--ink-2); + cursor: pointer; + transition: background 80ms; + border-left: 2px solid transparent; + user-select: none; +} + +.section-row:hover:not(.disabled) { + background: var(--paper-2); + color: var(--ink); +} + +.section-row.active { + background: var(--accent-soft); + border-left: 2px solid var(--accent); + color: var(--ink); + font-weight: 500; +} + +.section-row.disabled { + opacity: 0.5; + cursor: default; +} + +.section-row-tag { + font-size: 10px; + font-family: var(--mono); + color: var(--ink-3); + margin-left: auto; +} + +/* ── Saved searches section ──────────────────────────────────────────────────── */ + +.saved-searches-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.saved-searches-heading { + font-size: 13px; + font-weight: 600; + color: var(--ink); + padding-bottom: 4px; + border-bottom: 1px solid var(--rule-2); +} + +/* Inline add/edit form: inputs + button on one line */ +.saved-search-form { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.saved-search-input { + padding: 4px 8px; + font-size: 12px; + font-family: var(--sans); + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper-4); + color: var(--ink); + outline: none; + min-width: 120px; +} + +.saved-search-input:focus { + border-color: var(--accent); +} + +.saved-search-input-mono { + font-family: var(--mono); + min-width: 200px; +} + +.saved-search-btn { + padding: 3px 10px; + font-size: 11px; + font-family: var(--mono); + border: 1px solid var(--rule); + border-radius: 4px; + background: transparent; + color: var(--ink-2); + cursor: pointer; + transition: background 80ms; + white-space: nowrap; +} + +.saved-search-btn:hover:not(:disabled) { + background: var(--paper-2); + color: var(--ink); +} + +.saved-search-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.saved-search-btn-danger { + border-color: var(--err); + color: var(--err); +} + +.saved-search-btn-danger:hover:not(:disabled) { + background: var(--err-bg); +} + +.saved-search-error { + font-size: 11px; + color: var(--err); + font-family: var(--mono); +} + +/* Table */ +.saved-searches-table { + display: flex; + flex-direction: column; + border: 1px solid var(--rule-2); + border-radius: 4px; + overflow: hidden; +} + +.saved-searches-thead { + display: grid; + grid-template-columns: 180px 1fr 90px 120px; + padding: 5px 10px; + background: var(--paper-3); + font-size: 10px; + color: var(--ink-3); + text-transform: uppercase; + letter-spacing: 0.05em; + font-family: var(--mono); + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--rule-2); +} + +.saved-searches-row { + display: grid; + grid-template-columns: 180px 1fr 90px 120px; + padding: 5px 10px; + border-bottom: 1px solid var(--rule-2); + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--ink); +} + +.saved-searches-row:last-child { + border-bottom: none; +} + +.saved-searches-row:hover { + background: var(--paper-2); +} + +.saved-searches-row-editing { + display: block; + padding: 6px 10px; +} + +.saved-searches-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.saved-searches-query { + font-family: var(--mono); + font-size: 11px; +} + +.saved-searches-updated { + font-size: 11px; + color: var(--ink-3); +} + +.saved-searches-actions { + display: flex; + gap: 4px; + overflow: visible; +} + +.mono { + font-family: var(--mono); +} + +.muted { + color: var(--ink-3); +}