diff --git a/apps/studio/src/app/scenarios/page.test.tsx b/apps/studio/src/app/scenarios/page.test.tsx new file mode 100644 index 0000000..7d05efa --- /dev/null +++ b/apps/studio/src/app/scenarios/page.test.tsx @@ -0,0 +1,125 @@ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderWithConfig } from '@/test-utils'; +import Scenarios from './page'; + +vi.mock('next/navigation', () => ({ + useSearchParams: () => ({ get: () => null }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +// Lightweight stub so the tests drive the page's refresh state machine +// (loader, defer/flush, error notice) without rendering the full bento tree. +vi.mock('@/components/svm/scenarios-bento', () => ({ + default: ({ scenarios, onRefresh, onDetailPaneChange }: any) => ( +
+ + + + +
+ ), +})); + +const okResponse = (items: Array<{ id: string; name: string }>) => ({ + ok: true, + status: 200, + text: async () => JSON.stringify(items), +}); + +const failResponse = () => ({ ok: false, status: 500, text: async () => '' }); + +describe('scenarios page refresh state machine', () => { + beforeEach(() => { + global.fetch = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows the loader on first load, then the list', async () => { + (global.fetch as any).mockResolvedValue(okResponse([{ id: 'a', name: 'Alpha' }])); + + renderWithConfig(); + + expect(screen.getByText(/Loading scenarios/i)).toBeInTheDocument(); + expect(await screen.findByText('Alpha')).toBeInTheDocument(); + }); + + it('refreshes in the background without blanking the list', async () => { + (global.fetch as any) + .mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }])) + .mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }, { id: 'b', name: 'Beta' }])); + + renderWithConfig(); + await screen.findByText('Alpha'); + + fireEvent.click(screen.getByText('refresh')); + + // The full-screen loader must not reappear during a background refetch. + expect(screen.queryByText(/Loading scenarios/i)).not.toBeInTheDocument(); + expect(await screen.findByText('Beta')).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + }); + + it('defers a refresh while the pane is open and flushes exactly once on close', async () => { + (global.fetch as any).mockResolvedValue(okResponse([{ id: 'a', name: 'Alpha' }])); + + renderWithConfig(); + await screen.findByText('Alpha'); + const callsAfterLoad = (global.fetch as any).mock.calls.length; + + fireEvent.click(screen.getByText('open-pane')); + act(() => { + window.dispatchEvent(new Event('scenarioUpdated')); + window.dispatchEvent(new Event('scenarioUpdated')); + }); + + // Deferred: no refetch fires while the detail pane is open. + expect((global.fetch as any).mock.calls.length).toBe(callsAfterLoad); + + fireEvent.click(screen.getByText('close-pane')); + + await waitFor(() => { + expect((global.fetch as any).mock.calls.length).toBe(callsAfterLoad + 1); + }); + }); + + it('keeps the list and shows a notice when a background refresh fails', async () => { + (global.fetch as any) + .mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }])) + .mockResolvedValueOnce(failResponse()); + + renderWithConfig(); + await screen.findByText('Alpha'); + + fireEvent.click(screen.getByText('refresh')); + + expect(await screen.findByText(/Couldn't refresh/i)).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + }); + + it('clears the notice and reloads when the failed refresh is retried', async () => { + (global.fetch as any) + .mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }])) + .mockResolvedValueOnce(failResponse()) + .mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }, { id: 'b', name: 'Beta' }])); + + renderWithConfig(); + await screen.findByText('Alpha'); + + fireEvent.click(screen.getByText('refresh')); + await screen.findByText(/Couldn't refresh/i); + + fireEvent.click(screen.getByText('Retry')); + + expect(await screen.findByText('Beta')).toBeInTheDocument(); + expect(screen.queryByText(/Couldn't refresh/i)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/studio/src/app/scenarios/page.tsx b/apps/studio/src/app/scenarios/page.tsx index e651aca..84d2526 100644 --- a/apps/studio/src/app/scenarios/page.tsx +++ b/apps/studio/src/app/scenarios/page.tsx @@ -6,7 +6,7 @@ import { parseScenariosJson } from '@/lib/scenarios-api'; import { Scenario } from '@/lib/scenarios-data'; import { logger } from '@surfpool/shared'; import { useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; function ScenariosContent() { const searchParams = useSearchParams(); @@ -15,6 +15,9 @@ function ScenariosContent() { const [loading, setLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); const [isDetailPaneOpen, setIsDetailPaneOpen] = useState(false); + const [pendingRefresh, setPendingRefresh] = useState(false); + const [refreshError, setRefreshError] = useState(null); + const hasLoadedRef = useRef(false); // Read search params reactively - these will update when URL changes const selectedId = searchParams?.get('id') || undefined; @@ -28,7 +31,10 @@ function ScenariosContent() { useEffect(() => { async function loadScenarios() { try { - setLoading(true); + // Full-screen spinner only on the first load. Later refetches (create, + // close-with-pending-update) swap the list in the background, so the page + // does not blank out and feel like a hard reload. + if (!hasLoadedRef.current) setLoading(true); const response = await fetch(`${studioUrl}/v1/scenarios`); @@ -162,26 +168,34 @@ function ScenariosContent() { } setScenarios(loadedScenarios); + setRefreshError(null); } catch (error) { console.error('Error loading scenarios:', error); - setScenarios([]); + // Only blank the list if we never had one. A failed background refetch + // keeps the current list rather than wiping it, but says the refresh failed. + if (!hasLoadedRef.current) { + setScenarios([]); + } else { + setRefreshError("Couldn't refresh the scenarios list."); + } } finally { setLoading(false); + hasLoadedRef.current = true; } } loadScenarios(); }, [refreshKey, studioUrl]); - // Listen for scenario updates (but not when detail pane is open to avoid refresh loops) + // The editor dispatches this while its pane is open; defer the refresh to close. useEffect(() => { const handleScenarioUpdate = () => { - // Only refresh if detail pane is closed - if (!isDetailPaneOpen) { + if (isDetailPaneOpen) { + logger.log('Scenario updated while detail pane open - deferring refresh until close'); + setPendingRefresh(true); + } else { logger.log('Scenario updated event received, refreshing scenarios'); setRefreshKey((prev) => prev + 1); - } else { - logger.log('Scenario updated event received, but detail pane is open - skipping refresh'); } }; @@ -189,10 +203,23 @@ function ScenariosContent() { return () => window.removeEventListener('scenarioUpdated', handleScenarioUpdate); }, [isDetailPaneOpen]); + // Flush the deferred refresh once the detail pane closes. + useEffect(() => { + if (!isDetailPaneOpen && pendingRefresh) { + logger.log('Detail pane closed with a pending update - refreshing scenarios'); + setRefreshKey((prev) => prev + 1); + setPendingRefresh(false); + } + }, [isDetailPaneOpen, pendingRefresh]); + const handleRefresh = () => { setRefreshKey((prev) => prev + 1); }; + const handleDismissRefreshError = () => { + setRefreshError(null); + }; + if (loading) { return (
@@ -202,13 +229,38 @@ function ScenariosContent() { } return ( - + <> + + {!!refreshError && ( +
+ {refreshError} + + +
+ )} + ); } diff --git a/apps/studio/src/components/svm/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx index 58548ad..1a6c5e4 100644 --- a/apps/studio/src/components/svm/scenario-editor.tsx +++ b/apps/studio/src/components/svm/scenario-editor.tsx @@ -354,6 +354,8 @@ export default function ScenarioEditor({ console.error('Failed to sync scenario with backend:', response.status, errorText); } else { logger.log('Scenario synced with backend successfully'); + // Fire after the PATCH resolves so a refetch reads the saved state. + window.dispatchEvent(new Event('scenarioUpdated')); } } catch (error) { console.error('Error syncing scenario with backend:', error); @@ -365,7 +367,6 @@ export default function ScenarioEditor({ }; syncWithBackend(); - window.dispatchEvent(new Event('scenarioUpdated')); } else { isFirstSlotsChangeRef.current = false; } diff --git a/apps/studio/src/components/svm/scenarios-bento.test.tsx b/apps/studio/src/components/svm/scenarios-bento.test.tsx index dbafa41..b33298b 100644 --- a/apps/studio/src/components/svm/scenarios-bento.test.tsx +++ b/apps/studio/src/components/svm/scenarios-bento.test.tsx @@ -1,14 +1,22 @@ import { renderWithConfig } from '@/test-utils'; -import { screen } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import type { ScenarioBentoItem } from './scenarios-bento.types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import ScenariosBento from './scenarios-bento'; +import type { ScenarioBentoItem } from './scenarios-bento.types'; const editorMounts = vi.hoisted(function createEditorMountSpy() { return vi.fn(); }); +const toastError = vi.hoisted(function createToastErrorSpy() { + return vi.fn(); +}); + +vi.mock('sonner', function mockSonner() { + return { toast: { error: toastError } }; +}); + vi.mock('next/dynamic', async function mockNextDynamic() { const React = await import('react'); @@ -74,16 +82,28 @@ vi.mock('./generic-bento', function mockGenericBentoModule() { items: ScenarioBentoItem[]; initialSelectedId?: string; initialTab?: string; + renderDetailHeader: (item: ScenarioBentoItem) => ReactNode; renderDetailContent: (item: ScenarioBentoItem, activeTab: string) => ReactNode; } - function GenericBentoMock({ items, initialSelectedId, initialTab, renderDetailContent }: GenericBentoMockProps) { + function GenericBentoMock({ + items, + initialSelectedId, + initialTab, + renderDetailHeader, + renderDetailContent, + }: GenericBentoMockProps) { function isSelectedItem(item: ScenarioBentoItem) { return item.id === initialSelectedId; } const selectedItem = items.find(isSelectedItem); - return selectedItem ? renderDetailContent(selectedItem, initialTab ?? 'editor') : null; + return selectedItem ? ( + <> + {renderDetailHeader(selectedItem)} + {renderDetailContent(selectedItem, initialTab ?? 'editor')} + + ) : null; } return { default: GenericBentoMock }; @@ -104,6 +124,20 @@ const scenarios = [ }, ]; +function createPendingResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise(function captureResolve(promiseResolve) { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + +afterEach(function restoreGlobals() { + vi.unstubAllGlobals(); + toastError.mockClear(); +}); + describe('ScenariosBento', function scenariosBentoTests() { it('remounts the scenario editor when the selected scenario changes', function remountsEditorOnScenarioChange() { const { rerender } = renderWithConfig( @@ -119,4 +153,57 @@ describe('ScenariosBento', function scenariosBentoTests() { expect(editorMounts).toHaveBeenNthCalledWith(1, 'scenario-a'); expect(editorMounts).toHaveBeenNthCalledWith(2, 'scenario-b'); }); + + it('serializes overlapping edits and keeps failed changes out of the UI', async function serializesFailedEdits() { + const firstResponse = createPendingResponse(); + const secondResponse = createPendingResponse(); + const fetchMock = vi.fn().mockReturnValueOnce(firstResponse.promise).mockReturnValueOnce(secondResponse.promise); + vi.stubGlobal('fetch', fetchMock); + + renderWithConfig(); + + fireEvent.click(screen.getByText('Scenario A')); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Failed name' } }); + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }); + + fireEvent.click(screen.getByText('First scenario')); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Failed description' } }); + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }); + + expect(screen.getByText('Failed name')).toBeInTheDocument(); + expect(screen.getByText('Failed description')).toBeInTheDocument(); + + await waitFor(function waitsForFirstUpdate() { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + await act(async function failFirstUpdate() { + firstResponse.resolve({ ok: false, status: 500 } as Response); + await firstResponse.promise; + }); + + await waitFor(function waitsForSecondUpdate() { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + const secondRequest = fetchMock.mock.calls[1]?.[1] as RequestInit; + const secondPayload = JSON.parse(secondRequest.body as string); + expect(secondPayload.name).toBe('Scenario A'); + expect(secondPayload.description).toBe('Failed description'); + + await act(async function failSecondUpdate() { + secondResponse.resolve({ ok: false, status: 500 } as Response); + await secondResponse.promise; + }); + + expect(screen.getByText('Scenario A')).toBeInTheDocument(); + expect(screen.getByText('First scenario')).toBeInTheDocument(); + expect(screen.queryByText('Failed name')).not.toBeInTheDocument(); + expect(screen.queryByText('Failed description')).not.toBeInTheDocument(); + expect(toastError).toHaveBeenCalledTimes(2); + expect(toastError).toHaveBeenLastCalledWith( + "Couldn't save the scenario changes.", + expect.objectContaining({ action: expect.objectContaining({ label: 'Retry' }) }) + ); + }); }); diff --git a/apps/studio/src/components/svm/scenarios-bento.tsx b/apps/studio/src/components/svm/scenarios-bento.tsx index 47c8461..7400de5 100644 --- a/apps/studio/src/components/svm/scenarios-bento.tsx +++ b/apps/studio/src/components/svm/scenarios-bento.tsx @@ -9,6 +9,7 @@ import { serializeScenarioJson, } from '@/lib/scenarios-api'; import type { Scenario } from '@/lib/scenarios-data'; +import { reinsertScenario } from '@/lib/scenarios-list-ops'; import { PencilIcon, PlusIcon, SparklesIcon, TrashIcon } from '@heroicons/react/24/solid'; import { logger } from '@surfpool/shared'; import { @@ -25,6 +26,7 @@ import { import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; import AIHeader from './ai-header'; import DraftField from './draft-field'; import GenericBento from './generic-bento'; @@ -56,6 +58,12 @@ export default function ScenariosBento({ const [isDetailPaneOpen, setIsDetailPaneOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [scenarioToDelete, setScenarioToDelete] = useState<{ id: string; onClose?: () => void } | null>(null); + const [importError, setImportError] = useState(null); + + // REFS + const importInputRef = useRef(null); + const scenariosRef = useRef(scenarios); + const scenarioUpdateQueuesRef = useRef(new Map>()); // Sync scenarios when initialScenarios changes useEffect(() => { @@ -68,6 +76,11 @@ export default function ScenariosBento({ setScenarios(initialScenarios); }, [initialScenarios]); + // Keep imperative retry reads synchronized with the rendered list. + useEffect(() => { + scenariosRef.current = scenarios; + }, [scenarios]); + // Notify parent when detail pane state changes useEffect(() => { onDetailPaneChange?.(isDetailPaneOpen); @@ -88,9 +101,6 @@ export default function ScenariosBento({ }; // Import scenario from a downloaded file - const importInputRef = useRef(null); - const [importError, setImportError] = useState(null); - const handleImportScenario = async (file: File) => { setImportError(null); const result = scenarioImportPayload(await file.text(), crypto.randomUUID()); @@ -155,41 +165,79 @@ export default function ScenariosBento({ }; // Update scenario - const handleUpdateScenario = async (id: string, updates: Partial) => { - const scenario = scenarios.find((s) => s.id === id); + const handleUpdateScenario = (id: string, updates: Partial) => { + const scenario = scenariosRef.current.find((item) => item.id === id); if (!scenario) return; - const updatedScenario = { ...scenario, ...updates, updated_at: new Date().toISOString() }; + async function persistQueuedUpdate(previousScenario: Scenario | undefined) { + if (!previousScenario) return undefined; - // Optimistic update - setScenarios(scenarios.map((s) => (s.id === id ? updatedScenario : s))); + const updatedScenario = { ...previousScenario, ...updates, updated_at: new Date().toISOString() }; - try { - const response = await fetch(`${studioUrl}/v1/scenarios/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: serializeScenarioJson(buildUpdatePayload(updatedScenario)), - }); + try { + const response = await fetch(`${studioUrl}/v1/scenarios/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: serializeScenarioJson(buildUpdatePayload(updatedScenario)), + }); - if (!response.ok) { - throw new Error(`Failed to update scenario: ${response.status}`); + if (!response.ok) { + throw new Error(`Failed to update scenario: ${response.status}`); + } + + logger.log('Scenario updated successfully:', id); + + if (!isDetailPaneOpen && onRefresh) { + onRefresh(); + } + + return updatedScenario; + } catch (error) { + console.error('Error updating scenario:', error); + + function retryUpdate() { + handleUpdateScenario(id, updates); + } + + toast.error("Couldn't save the scenario changes.", { + action: { label: 'Retry', onClick: retryUpdate }, + }); + return previousScenario; } + } - logger.log('Scenario updated successfully:', id); + const previousUpdate = scenarioUpdateQueuesRef.current.get(id) ?? Promise.resolve(scenario); + const queuedUpdate = previousUpdate.then(persistQueuedUpdate); - if (!isDetailPaneOpen && onRefresh) { - onRefresh(); + function reconcileCompletedUpdate(persistedScenario: Scenario | undefined) { + if (scenarioUpdateQueuesRef.current.get(id) === queuedUpdate) { + scenarioUpdateQueuesRef.current.delete(id); + if (persistedScenario) { + setScenarios((current) => current.map((item) => (item.id === id ? persistedScenario : item))); + } } - } catch (error) { - console.error('Error updating scenario:', error); - // Revert optimistic update - setScenarios(scenarios.map((s) => (s.id === id ? scenario : s))); } + + setScenarios((current) => + current.map((item) => (item.id === id ? { ...item, ...updates, updated_at: new Date().toISOString() } : item)) + ); + scenarioUpdateQueuesRef.current.set(id, queuedUpdate); + void queuedUpdate.then(reconcileCompletedUpdate); }; // Delete scenario const handleDeleteScenario = async (id: string) => { + const index = scenarios.findIndex((s) => s.id === id); + const deleted = scenarios[index]; + if (!deleted) return; + + // Drop the card immediately so it doesn't linger until the background + // refetch resolves; restore it if the delete fails. + setScenarios((prev) => prev.filter((s) => s.id !== id)); + let scenarioToRestore = deleted; + try { + scenarioToRestore = (await scenarioUpdateQueuesRef.current.get(id)) ?? deleted; const response = await fetch(`${studioUrl}/v1/scenarios/${id}`, { method: 'DELETE', }); @@ -202,6 +250,8 @@ export default function ScenariosBento({ onRefresh?.(); } catch (error) { console.error('Error deleting scenario:', error); + // Restore against the current list without overwriting a newer same-ID item. + setScenarios((prev) => reinsertScenario(prev, scenarioToRestore, index)); } }; diff --git a/apps/studio/src/lib/scenarios-list-ops.test.ts b/apps/studio/src/lib/scenarios-list-ops.test.ts new file mode 100644 index 0000000..9dd7c54 --- /dev/null +++ b/apps/studio/src/lib/scenarios-list-ops.test.ts @@ -0,0 +1,49 @@ +import type { Scenario } from '@/lib/scenarios-data'; +import { reinsertScenario } from '@/lib/scenarios-list-ops'; +import { describe, expect, it } from 'vitest'; + +const s = (id: string): Scenario => ({ id, name: id }); + +describe('reinsertScenario', () => { + it('restores the item at its original index when nothing else changed', () => { + const a = s('a'); + const result = reinsertScenario([s('b')], a, 0); + expect(result.map((x) => x.id)).toEqual(['a', 'b']); + }); + + it('preserves a concurrent create that landed while the delete was in flight', () => { + // Deleted A from [A, B]; C was created concurrently, so current is [B, C]. + const a = s('a'); + const result = reinsertScenario([s('b'), s('c')], a, 0); + expect(result.map((x) => x.id)).toEqual(['a', 'b', 'c']); + }); + + it('does not resurrect a scenario removed concurrently', () => { + // Deleted A from [A, B]; B was also deleted concurrently, so current is [C]. + const a = s('a'); + const result = reinsertScenario([s('c')], a, 0); + expect(result.map((x) => x.id)).toEqual(['a', 'c']); + expect(result.some((x) => x.id === 'b')).toBe(false); + }); + + it('is a no-op if the item is already present', () => { + const a = s('a'); + const current = [a, s('b')]; + expect(reinsertScenario(current, a, 0)).toBe(current); + }); + + it('preserves a newer version of the item after a failed delete', () => { + const deleted = { ...s('a'), name: 'stale' }; + const current = [{ ...s('a'), name: 'updated' }, s('b')]; + + expect(reinsertScenario(current, deleted, 0)).toBe(current); + expect(current[0].name).toBe('updated'); + }); + + it('clamps the index when the current list is shorter than the original', () => { + const a = s('a'); + // Original index 3, but concurrent deletes shrank the list to one item. + const result = reinsertScenario([s('c')], a, 3); + expect(result.map((x) => x.id)).toEqual(['c', 'a']); + }); +}); diff --git a/apps/studio/src/lib/scenarios-list-ops.ts b/apps/studio/src/lib/scenarios-list-ops.ts new file mode 100644 index 0000000..b08b826 --- /dev/null +++ b/apps/studio/src/lib/scenarios-list-ops.ts @@ -0,0 +1,13 @@ +import type { Scenario } from '@/lib/scenarios-data'; + +// Reinsert a scenario that was optimistically removed back into the *current* +// list. The list may have changed while the delete request was in flight (a +// create or another delete landed), so we operate on the current array rather +// than a stale snapshot: concurrent additions survive and a concurrently +// removed item is not resurrected. The item is placed back at its original +// index, clamped to the current length. +export function reinsertScenario(current: Scenario[], scenario: Scenario, index: number): Scenario[] { + if (current.some((s) => s.id === scenario.id)) return current; + const at = Math.max(0, Math.min(index, current.length)); + return [...current.slice(0, at), scenario, ...current.slice(at)]; +}