diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index bf617ca04..7e3255c50 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Loader2, Save } from 'lucide-react'; +import { AlertCircle, CheckCircle2, Loader2, RefreshCw } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -136,6 +136,15 @@ export function ConfigEditor() { const abortRef = useRef(null); + /** Auto-save status indicator. */ + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + /** Debounce timer for auto-save. */ + const autoSaveTimerRef = useRef | null>(null); + /** True while the initial config load is in progress — suppresses auto-save. */ + const isLoadingConfigRef = useRef(false); + /** Ref tracking latest draftConfig for race-condition detection during save. */ + const draftConfigRef = useRef(null); + const updateDraftConfig = useCallback((updater: (prev: GuildConfig) => GuildConfig) => { setDraftConfig((prev) => updater((prev ?? {}) as GuildConfig)); }, []); @@ -169,7 +178,7 @@ export function ConfigEditor() { }, []); // ── Load config when guild changes ───────────────────────────── - const fetchConfig = useCallback(async (id: string) => { + const fetchConfig = useCallback(async (id: string, { skipSaveStatusReset = false } = {}) => { if (!id) return; abortRef.current?.abort(); @@ -178,6 +187,7 @@ export function ConfigEditor() { setLoading(true); setError(null); + isLoadingConfigRef.current = true; try { const res = await fetch(`/api/guilds/${encodeURIComponent(id)}/config`, { @@ -209,10 +219,19 @@ export function ConfigEditor() { } setSavedConfig(data); setDraftConfig(structuredClone(data)); + // Reset status after a successful reload; clear any stale error + if (!skipSaveStatusReset) { + setSaveStatus('idle'); + } + // Use a macrotask so the state setter for draftConfig fires before we clear the flag + setTimeout(() => { + isLoadingConfigRef.current = false; + }, 0); setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n')); setProtectRoleIdsRaw((data.moderation?.protectRoles?.roleIds ?? []).join(', ')); } catch (err) { if ((err as Error).name === 'AbortError') return; + isLoadingConfigRef.current = false; const msg = (err as Error).message || 'Failed to load config'; setError(msg); toast.error('Failed to load config', { description: msg }); @@ -226,6 +245,11 @@ export function ConfigEditor() { return () => abortRef.current?.abort(); }, [guildId, fetchConfig]); + // Keep draftConfigRef in sync for race-condition detection in saveChanges + useEffect(() => { + draftConfigRef.current = draftConfig; + }, [draftConfig]); + // ── Derived state ────────────────────────────────────────────── const hasChanges = useMemo(() => { if (!savedConfig || !draftConfig) return false; @@ -272,10 +296,10 @@ export function ConfigEditor() { } const patches = computePatches(savedConfig, draftConfig); - if (patches.length === 0) { - toast.info('No changes to save.'); - return; - } + if (patches.length === 0) return; + + // Snapshot draft before saving to detect changes made during in-flight save + const draftSnapshot = draftConfig; // Group patches by top-level section for batched requests const bySection = new Map>(); @@ -290,6 +314,7 @@ export function ConfigEditor() { } setSaving(true); + setSaveStatus('saving'); // Shared AbortController for all section saves - aborts all in-flight requests on 401 const saveAbortController = new AbortController(); @@ -354,29 +379,64 @@ export function ConfigEditor() { return updated; }); } + setSaveStatus('error'); toast.error('Some sections failed to save', { description: `Failed: ${failedSections.join(', ')}`, }); } else { - toast.success('Config saved successfully!'); - // Full success: reload to get the authoritative version from the server - await fetchConfig(guildId); + setSaveStatus('saved'); + // Full success: check if draft changed during save (user edited while saving) + if (deepEqual(draftConfigRef.current, draftSnapshot)) { + // No changes during save — safe to reload authoritative version + await fetchConfig(guildId, { skipSaveStatusReset: true }); + } else { + // Draft changed during save — don't overwrite user's new changes + // Update savedConfig to reflect what was successfully persisted + setSavedConfig(structuredClone(draftSnapshot)); + } } } catch (err) { const msg = (err as Error).message || 'Failed to save config'; + setSaveStatus('error'); toast.error('Failed to save config', { description: msg }); } finally { setSaving(false); } }, [guildId, savedConfig, draftConfig, hasValidationErrors, fetchConfig]); + // ── Auto-save: debounce 500ms after draft changes ────────────── + useEffect(() => { + // Don't auto-save during initial config load or if no changes + if (isLoadingConfigRef.current || !hasChanges || hasValidationErrors || saving) return; + + // Clear any pending timer + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + + autoSaveTimerRef.current = setTimeout(() => { + void saveChanges(); + }, 500); + + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + }; + }, [draftConfig, hasChanges, hasValidationErrors, saving, saveChanges]); + // ── Keyboard shortcut: Ctrl/Cmd+S to save ────────────────────── useEffect(() => { function onKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); if (hasChanges && !saving && !hasValidationErrors) { - saveChanges(); + // Cancel any pending debounce timer and save immediately + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + void saveChanges(); } } } @@ -697,38 +757,24 @@ export function ConfigEditor() { Manage AI, welcome messages, and other settings.

-
+
+ {/* Auto-save status indicator */} + -
- {/* Unsaved changes banner */} - {hasChanges && ( + {/* Validation error banner */} + {hasChanges && hasValidationErrors && ( - You have unsaved changes.{' '} - - Ctrl+S - {' '} - to save. + Fix validation errors before changes can be saved. )} @@ -2241,6 +2287,58 @@ export function ConfigEditor() { ); } +// ── Auto-Save Status Indicator ──────────────────────────────── + +interface AutoSaveStatusProps { + status: 'idle' | 'saving' | 'saved' | 'error'; + onRetry: () => void; +} + +/** + * Displays the current auto-save status with an icon and text. + * + * Shows nothing when idle, a spinner while saving, a check icon on success, + * and an error state with a retry button on failure. + */ +function AutoSaveStatus({ status, onRetry }: AutoSaveStatusProps) { + if (status === 'idle') return null; + + if (status === 'saving') { + return ( + + + ); + } + + if (status === 'saved') { + return ( + + + ); + } + + // error state + return ( + + + ); +} + // ── Toggle Switch ─────────────────────────────────────────────── interface ToggleSwitchProps { diff --git a/web/tests/components/dashboard/config-editor-autosave.test.tsx b/web/tests/components/dashboard/config-editor-autosave.test.tsx new file mode 100644 index 000000000..92959e08b --- /dev/null +++ b/web/tests/components/dashboard/config-editor-autosave.test.tsx @@ -0,0 +1,317 @@ +/** + * Tests for the auto-save feature in ConfigEditor. + * + * Covers: + * - AutoSaveStatus component renders the correct UI for idle, saving, saved, and error states + * - ConfigEditor loads config without triggering auto-save (no PATCH on mount) + * - Validation error banner appears when system prompt exceeds max length + * - Retry button is present in the error state + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'; + +// ── Mocks ───────────────────────────────────────────────────────── + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() }, + Toaster: () => null, +})); + +vi.mock('@/components/dashboard/reset-defaults-button', () => ({ + DiscardChangesButton: ({ + onReset, + disabled, + }: { + onReset: () => void; + disabled: boolean; + }) => ( + + ), +})); + +vi.mock('@/components/dashboard/system-prompt-editor', () => ({ + SystemPromptEditor: ({ + value, + onChange, + }: { + value: string; + onChange: (v: string) => void; + }) => ( +