diff --git a/frontend/src/components/SavedViewsPanel.tsx b/frontend/src/components/SavedViewsPanel.tsx new file mode 100644 index 00000000..cff7dcbf --- /dev/null +++ b/frontend/src/components/SavedViewsPanel.tsx @@ -0,0 +1,366 @@ +import React, { useRef, useState } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { FilterPreset, SavedView, UseSavedViewsReturn } from '../hooks/useSavedViews' + + +interface Props extends UseSavedViewsReturn { + currentPreset: FilterPreset + onApply: (preset: FilterPreset) => void +} + + +function formatRelative(iso: string): string { + try { + const diff = Date.now() - new Date(iso).getTime() + const minutes = Math.floor(diff / 60_000) + if (minutes < 1) return 'just now' + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` + } catch { + return '' + } +} + +function presetSummary(p: FilterPreset): string { + const parts: string[] = [] + if (p.severity !== 'all') parts.push(p.severity.toUpperCase()) + if (p.target !== 'all') parts.push(p.target) + if (p.scanner !== 'all') parts.push(p.scanner) + if (p.sortMode !== 'severity') parts.push(`sort:${p.sortMode}`) + if (p.dateFrom) parts.push(`from:${p.dateFrom}`) + if (p.dateTo) parts.push(`to:${p.dateTo}`) + if (p.searchQuery) parts.push(`"${p.searchQuery}"`) + return parts.length ? parts.join(' · ') : 'All findings' +} + + +interface ViewRowProps { + view: SavedView + onApply: () => void + onRename: (name: string) => void + onDelete: () => void +} + +function ViewRow({ view, onApply, onRename, onDelete }: ViewRowProps) { + const [editing, setEditing] = useState(false) + const [editName, setEditName] = useState(view.name) + const [confirmDelete, setConfirmDelete] = useState(false) + const inputRef = useRef(null) + + function commitRename() { + const trimmed = editName.trim() + if (trimmed && trimmed !== view.name) { + onRename(trimmed) + } else { + setEditName(view.name) + } + setEditing(false) + } + + return ( + +
+ {/* Apply button + name */} + + + {/* Action icons — visible on hover */} + {!editing && ( +
+ {/* Rename */} + + + + {confirmDelete ? ( + + ) : ( + + )} +
+ )} +
+
+ ) +} + + +export default function SavedViewsPanel({ + views, + loading, + saveView, + deleteView, + renameView, + currentPreset, + onApply, +}: Props) { + const [open, setOpen] = useState(false) + const [saveName, setSaveName] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [successMsg, setSuccessMsg] = useState(null) + + async function handleSave() { + const trimmed = saveName.trim() + if (!trimmed) { + setError('Enter a name for this view') + return + } + setSaving(true) + setError(null) + try { + const saved = await saveView(trimmed, currentPreset) + const isOverwrite = views.some( + (v) => v.name.toLowerCase() === trimmed.toLowerCase(), + ) + setSuccessMsg( + isOverwrite ? `Updated "${saved.name}"` : `Saved "${saved.name}"`, + ) + setSaveName('') + setTimeout(() => setSuccessMsg(null), 2000) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to save view') + } finally { + setSaving(false) + } + } + + const panelVariants = { + hidden: { opacity: 0, y: -8, scaleY: 0.96 }, + visible: { opacity: 1, y: 0, scaleY: 1, transition: { duration: 0.18, ease: 'easeOut' as const } }, + exit: { opacity: 0, y: -6, scaleY: 0.97, transition: { duration: 0.12, ease: 'easeOut' as const } }, + } + + return ( +
+ + + + + + {open && ( + + {/* Header */} +
+

+ Filter_Presets +

+ +
+ +
+

+ Save Current Filters +

+
+ { + setSaveName(e.target.value) + setError(null) + }} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + placeholder="Name this view…" + maxLength={60} + aria-label="Saved view name" + className="flex-1 border-2 border-silver-bright/10 bg-charcoal-dark px-3 py-2 text-[10px] font-mono text-silver-bright placeholder:text-silver/25 focus:border-rag-blue focus:outline-none" + /> + +
+ + + {error && ( + + ⚠ {error} + + )} + {successMsg && ( + + ✓ {successMsg} + + )} + +
+ +
+ {loading ? ( +

+ Loading presets… +

+ ) : views.length === 0 ? ( +
+

+ No Saved Views +

+

+ Configure filters then save above. +

+
+ ) : ( + + {views.map((view) => ( + { + onApply(view.preset) + setOpen(false) + }} + onRename={(name) => renameView(view.id, name)} + onDelete={() => deleteView(view.id)} + /> + ))} + + )} +
+ + {views.length > 0 && ( +
+

+ Click a preset to apply · Hover to rename or delete +

+
+ )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/hooks/useSavedViews.ts b/frontend/src/hooks/useSavedViews.ts new file mode 100644 index 00000000..1e2eddec --- /dev/null +++ b/frontend/src/hooks/useSavedViews.ts @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { API_BASE } from '../api' + +export interface FilterPreset { + severity: string + target: string + scanner: string + sortMode: string + dateFrom: string + dateTo: string + searchQuery: string +} + +export interface SavedView { + id: string + name: string + preset: FilterPreset + createdAt: string // ISO string + updatedAt: string // ISO string +} + +// Pydantic-shaped payload the backend expects. +interface BackendPayload { + name: string + filter_json: string +} + +interface BackendRow { + id: string + name: string + filter_json: string + created_at: string + updated_at: string +} + +const VALID_SORT_MODES = ['severity', 'newest', 'oldest', 'target'] as const +const VALID_SEVERITIES = ['all', 'critical', 'high', 'medium', 'low', 'info'] as const + +/** Returns true when obj looks like a real FilterPreset (not garbage data). */ +export function isValidPreset(obj: unknown): obj is FilterPreset { + if (!obj || typeof obj !== 'object') return false + const p = obj as Record + if (typeof p.severity !== 'string') return false + if (typeof p.target !== 'string') return false + if (typeof p.scanner !== 'string') return false + if (typeof p.sortMode !== 'string') return false + if (typeof p.dateFrom !== 'string') return false + if (typeof p.dateTo !== 'string') return false + if (typeof p.searchQuery !== 'string') return false + if (!(VALID_SORT_MODES as readonly string[]).includes(p.sortMode)) return false + if (!(VALID_SEVERITIES as readonly string[]).includes(p.severity)) return false + return true +} + +export function isValidSavedView(obj: unknown): obj is SavedView { + if (!obj || typeof obj !== 'object') return false + const v = obj as Record + if (typeof v.id !== 'string' || !v.id) return false + if (typeof v.name !== 'string' || !v.name) return false + if (typeof v.createdAt !== 'string') return false + if (typeof v.updatedAt !== 'string') return false + return isValidPreset(v.preset) +} + + +const LS_KEY = 'secuscan-saved-views' + +function readFromStorage(): SavedView[] { + try { + const raw = localStorage.getItem(LS_KEY) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter(isValidSavedView) + } catch { + return [] + } +} + +function writeToStorage(views: SavedView[]): void { + try { + localStorage.setItem(LS_KEY, JSON.stringify(views)) + } catch { + // Storage quota exceeded — silently ignore. + } +} + + +function rowToView(row: BackendRow): SavedView | null { + try { + const preset: unknown = JSON.parse(row.filter_json) + if (!isValidPreset(preset)) return null + return { + id: row.id, + name: row.name, + preset, + createdAt: row.created_at, + updatedAt: row.updated_at, + } + } catch { + return null + } +} + +/** + * Backend sync failure behaviour + * ───────────────────────────── + * All backend calls are fire-and-forget with a hard 8-second timeout. + * On any network error, non-2xx response, or timeout: + * + * • The optimistic local state (already written to localStorage) is + * kept as-is — the user never sees an error for a backend outage. + * • `backendAvailable` stays false so subsequent mutations skip the + * network entirely rather than hammering an unreachable server. + * • On the next page load the hook retries the backend hydration; if + * it succeeds, remote state is merged (remote wins on timestamp). + * • There is intentionally no retry queue — SecuScan is local-first + * and localStorage is the source of truth. The backend is a + * convenience sync layer, not a required dependency. + * + * Callers (SavedViewsPanel) can rely on the returned Promise always + * resolving — it never rejects due to a backend failure. + */ +async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + try { + const res = await fetch(`${API_BASE}${path}`, { + ...init, + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) return null + return (await res.json()) as T + } catch { + return null + } +} + + +export interface UseSavedViewsReturn { + views: SavedView[] + loading: boolean + /** Save a new preset or overwrite an existing one (matched by name). */ + saveView: (name: string, preset: FilterPreset) => Promise + /** Delete by id. */ + deleteView: (id: string) => Promise + /** Rename an existing view. */ + renameView: (id: string, newName: string) => Promise +} + +export function useSavedViews(): UseSavedViewsReturn { + const [views, setViews] = useState([]) + const [loading, setLoading] = useState(true) + // Track whether we managed to hydrate from the backend at least once. + const backendAvailable = useRef(false) + + // ── Mount: prefer backend, fall back to localStorage ────────────────────── + useEffect(() => { + let cancelled = false + + async function hydrate() { + // Try backend first + const data = await apiFetch<{ views: BackendRow[] }>('/saved-views') + if (!cancelled) { + if (data && Array.isArray(data.views)) { + const parsed = data.views.map(rowToView).filter(Boolean) as SavedView[] + backendAvailable.current = true + setViews(parsed) + writeToStorage(parsed) // keep local in sync + } else { + // Backend unreachable — use localStorage + setViews(readFromStorage()) + } + setLoading(false) + } + } + + hydrate() + return () => { cancelled = true } + }, []) + + + const syncSet = useCallback((next: SavedView[]) => { + setViews(next) + writeToStorage(next) + }, []) + + const saveView = useCallback( + async (name: string, preset: FilterPreset): Promise => { + const trimmed = name.trim() + if (!trimmed) throw new Error('View name cannot be empty') + if (!isValidPreset(preset)) throw new Error('Invalid filter preset') + + // Check whether we're overwriting an existing name + const existing = views.find( + (v) => v.name.toLowerCase() === trimmed.toLowerCase(), + ) + + const now = new Date().toISOString() + + if (existing) { + const updated: SavedView = { ...existing, preset, updatedAt: now } + const next = views.map((v) => (v.id === existing.id ? updated : v)) + syncSet(next) + + // Backend sync (optimistic, fire-and-forget) + if (backendAvailable.current) { + const payload: BackendPayload = { + name: trimmed, + filter_json: JSON.stringify(preset), + } + apiFetch(`/saved-views/${existing.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + } + return updated + } + + const tempId = `local-${Date.now()}-${Math.random().toString(36).slice(2)}` + const created: SavedView = { + id: tempId, + name: trimmed, + preset, + createdAt: now, + updatedAt: now, + } + + const next = [...views, created] + syncSet(next) + + if (backendAvailable.current) { + const payload: BackendPayload = { + name: trimmed, + filter_json: JSON.stringify(preset), + } + const result = await apiFetch<{ id: string }>('/saved-views', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (result?.id) { + const withRealId: SavedView = { ...created, id: result.id } + const finalNext = next.map((v) => (v.id === tempId ? withRealId : v)) + syncSet(finalNext) + return withRealId + } + } + + return created + }, + [views, syncSet], + ) + + const deleteView = useCallback( + async (id: string): Promise => { + syncSet(views.filter((v) => v.id !== id)) + + if (backendAvailable.current && !id.startsWith('local-')) { + apiFetch(`/saved-views/${id}`, { method: 'DELETE' }) + } + }, + [views, syncSet], + ) + + const renameView = useCallback( + async (id: string, newName: string): Promise => { + const trimmed = newName.trim() + if (!trimmed) throw new Error('Name cannot be empty') + + const now = new Date().toISOString() + const next = views.map((v) => + v.id === id ? { ...v, name: trimmed, updatedAt: now } : v, + ) + syncSet(next) + + if (backendAvailable.current && !id.startsWith('local-')) { + const target = views.find((v) => v.id === id) + if (target) { + const payload: BackendPayload = { + name: trimmed, + filter_json: JSON.stringify(target.preset), + } + apiFetch(`/saved-views/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + } + } + }, + [views, syncSet], + ) + + return { views, loading, saveView, deleteView, renameView } +} \ No newline at end of file diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index fb9f849b..3fa376b2 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react' import { motion } from 'framer-motion' import { getFindings } from '../api' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' +import SavedViewsPanel from '../components/SavedViewsPanel' +import { useSavedViews, FilterPreset } from '../hooks/useSavedViews' type RiskFactor = { factor: string label: string @@ -119,6 +121,29 @@ export default function Findings() { const [reviewState, setReviewState] = useState({}) const [copiedFindingId, setCopiedFindingId] = useState(null) + // ── Saved views ──────────────────────────────────────────────────────────── + const { views, loading: viewsLoading, saveView, deleteView, renameView } = useSavedViews() + + const currentPreset: FilterPreset = { + severity: filterSeverity, + target: filterTarget, + scanner: filterScanner, + sortMode, + dateFrom, + dateTo, + searchQuery, + } + + function applyPreset(preset: FilterPreset) { + setFilterSeverity(preset.severity) + setFilterTarget(preset.target) + setFilterScanner(preset.scanner) + setSortMode(preset.sortMode as SortMode) + setDateFrom(preset.dateFrom) + setDateTo(preset.dateTo) + setSearchQuery(preset.searchQuery) + } + useEffect(() => { setLoading(true) getFindings() @@ -555,13 +580,32 @@ export default function Findings() { - +
+ + +
@@ -791,4 +835,4 @@ export default function Findings() { ) -} +} \ No newline at end of file diff --git a/frontend/testing/unit/hooks/useSavedViews.test.ts b/frontend/testing/unit/hooks/useSavedViews.test.ts new file mode 100644 index 00000000..d2ead794 --- /dev/null +++ b/frontend/testing/unit/hooks/useSavedViews.test.ts @@ -0,0 +1,409 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + FilterPreset, + isValidPreset, + isValidSavedView, + useSavedViews, +} from '../../../src/hooks/useSavedViews' + + +const mockFetch = vi.fn(() => Promise.reject(new Error('Network offline'))) +vi.stubGlobal('fetch', mockFetch) + +const storage: Record = {} +const localStorageMock = { + getItem: (k: string) => storage[k] ?? null, + setItem: (k: string, v: string) => { + storage[k] = v + }, + removeItem: (k: string) => { + delete storage[k] + }, + clear: () => { + Object.keys(storage).forEach((k) => delete storage[k]) + }, +} +vi.stubGlobal('localStorage', localStorageMock) + + +const VALID_PRESET: FilterPreset = { + severity: 'critical', + target: 'example.com', + scanner: 'nmap', + sortMode: 'newest', + dateFrom: '2025-01-01', + dateTo: '2025-12-31', + searchQuery: 'port scan', +} + +const ALL_PRESET: FilterPreset = { + severity: 'all', + target: 'all', + scanner: 'all', + sortMode: 'severity', + dateFrom: '', + dateTo: '', + searchQuery: '', +} + + +beforeEach(() => { + localStorageMock.clear() + mockFetch.mockReset() + mockFetch.mockRejectedValue(new Error('Network offline')) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + + +describe('isValidPreset', () => { + it('accepts a complete valid preset', () => { + expect(isValidPreset(VALID_PRESET)).toBe(true) + }) + + it('accepts the all-defaults preset', () => { + expect(isValidPreset(ALL_PRESET)).toBe(true) + }) + + it('rejects null', () => { + expect(isValidPreset(null)).toBe(false) + }) + + it('rejects a non-object', () => { + expect(isValidPreset('string')).toBe(false) + expect(isValidPreset(42)).toBe(false) + }) + + it('rejects an object missing required fields', () => { + expect(isValidPreset({ severity: 'all' })).toBe(false) + }) + + it('rejects invalid sortMode', () => { + expect(isValidPreset({ ...VALID_PRESET, sortMode: 'by_moon_phase' })).toBe(false) + }) + + it('rejects invalid severity', () => { + expect(isValidPreset({ ...VALID_PRESET, severity: 'apocalyptic' })).toBe(false) + }) +}) + + +describe('isValidSavedView', () => { + const view = { + id: 'abc-123', + name: 'My View', + preset: VALID_PRESET, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + it('accepts a complete valid view', () => { + expect(isValidSavedView(view)).toBe(true) + }) + + it('rejects a view with a missing id', () => { + expect(isValidSavedView({ ...view, id: '' })).toBe(false) + }) + + it('rejects a view with an invalid preset', () => { + expect(isValidSavedView({ ...view, preset: { severity: 'bad' } })).toBe(false) + }) + + it('rejects a non-object', () => { + expect(isValidSavedView(null)).toBe(false) + }) +}) + + +describe('useSavedViews — localStorage fallback (no backend)', () => { + it('starts empty when localStorage is empty', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.views).toHaveLength(0) + }) + + it('creates a new view and returns it', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + let saved: Awaited> | undefined + + await act(async () => { + saved = await result.current.saveView('My Scan', VALID_PRESET) + }) + + expect(saved).toBeDefined() + expect(saved?.name).toBe('My Scan') + expect(saved?.preset).toEqual(VALID_PRESET) + expect(result.current.views).toHaveLength(1) + }) + + it('persists the new view to localStorage', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Persistent', VALID_PRESET) + }) + + const raw = localStorage.getItem('secuscan-saved-views') + expect(raw).not.toBeNull() + + const parsed = JSON.parse(raw!) + expect(parsed).toHaveLength(1) + expect(parsed[0].name).toBe('Persistent') + }) + + it('restores a saved view correctly (apply simulation)', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Apply Me', VALID_PRESET) + }) + + const view = result.current.views[0] + expect(view.preset).toEqual(VALID_PRESET) + expect(view.preset.sortMode).toBe('newest') + expect(view.preset.severity).toBe('critical') + }) + + it('overwrites a view when the same name is saved again', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Repeat', VALID_PRESET) + }) + + const firstId = result.current.views[0].id + + const updatedPreset: FilterPreset = { + ...VALID_PRESET, + severity: 'high', + sortMode: 'oldest', + } + + await act(async () => { + await result.current.saveView('Repeat', updatedPreset) + }) + + expect(result.current.views).toHaveLength(1) + expect(result.current.views[0].id).toBe(firstId) + expect(result.current.views[0].preset.severity).toBe('high') + expect(result.current.views[0].preset.sortMode).toBe('oldest') + }) + + it('overwrite is case-insensitive on name comparison', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Alpha', VALID_PRESET) + }) + + await act(async () => { + await result.current.saveView('alpha', ALL_PRESET) + }) + + expect(result.current.views).toHaveLength(1) + expect(result.current.views[0].preset.severity).toBe('all') + }) + + it('renames a view', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Old Name', VALID_PRESET) + }) + + const id = result.current.views[0].id + + await act(async () => { + await result.current.renameView(id, 'New Name') + }) + + expect(result.current.views[0].name).toBe('New Name') + }) + + it('throws when renaming with an empty string', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Name', VALID_PRESET) + }) + + const id = result.current.views[0].id + + await expect( + act(async () => { + await result.current.renameView(id, ' ') + }), + ).rejects.toThrow() + }) + + it('deletes a view by id', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Delete Me', VALID_PRESET) + }) + + const id = result.current.views[0].id + + await act(async () => { + await result.current.deleteView(id) + }) + + expect(result.current.views).toHaveLength(0) + }) + + it('deleting a non-existent id is a no-op', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Keep', VALID_PRESET) + }) + + await act(async () => { + await result.current.deleteView('ghost-id') + }) + + expect(result.current.views).toHaveLength(1) + }) + + it('deleting one view leaves others intact', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Keep', VALID_PRESET) + }) + + await act(async () => { + await result.current.saveView('Remove', ALL_PRESET) + }) + + const removeId = result.current.views.find((v) => v.name === 'Remove')!.id + + await act(async () => { + await result.current.deleteView(removeId) + }) + + expect(result.current.views).toHaveLength(1) + expect(result.current.views[0].name).toBe('Keep') + }) + + it('throws when saving with an empty name', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await expect( + act(async () => { + await result.current.saveView('', VALID_PRESET) + }), + ).rejects.toThrow() + }) + + it('throws when saving with a whitespace-only name', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await expect( + act(async () => { + await result.current.saveView(' ', VALID_PRESET) + }), + ).rejects.toThrow() + }) + + it('throws when saving with an invalid preset (bad sortMode)', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + const badPreset = { ...VALID_PRESET, sortMode: 'random_order' } as any + + await expect( + act(async () => { + await result.current.saveView('Bad Preset', badPreset) + }), + ).rejects.toThrow() + }) + + it('ignores corrupt localStorage data on mount', async () => { + localStorage.setItem('secuscan-saved-views', 'this is not JSON!!!!') + + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.views).toHaveLength(0) + }) + + it('filters out invalid saved-view entries from localStorage on mount', async () => { + const mixed = [ + { + id: 'ok', + name: 'Good', + preset: VALID_PRESET, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 'bad', + name: 'Bad', + }, + ] + + localStorage.setItem('secuscan-saved-views', JSON.stringify(mixed)) + + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.views).toHaveLength(1) + expect(result.current.views[0].name).toBe('Good') + }) + + it('supports multiple independent saved views', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('View A', VALID_PRESET) + }) + + await act(async () => { + await result.current.saveView('View B', ALL_PRESET) + }) + + await act(async () => { + await result.current.saveView('View C', { ...VALID_PRESET, severity: 'low' }) + }) + + expect(result.current.views).toHaveLength(3) + expect(result.current.views.map((v) => v.name)).toEqual(expect.arrayContaining(['View A', 'View B', 'View C'])) + }) +}) diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index 561ae086..768e38a1 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -10,6 +10,20 @@ vi.mock('../../../src/api', () => ({ API_BASE: 'http://127.0.0.1:8000', })) +vi.mock('../../../src/hooks/useSavedViews', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSavedViews: () => ({ + views: [], + loading: false, + saveView: vi.fn(), + deleteView: vi.fn(), + renameView: vi.fn(), + }), + } +}) + // ── Fixtures ────────────────────────────────────────────────────────────────── const criticalFinding = {