From 5210f9bd096f01de5310cdcfdd51c6ba8d764871 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:09:24 -0500 Subject: [PATCH 1/4] feat(dashboard): replace manual save with auto-save (500ms debounce) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'Save Changes' button; saving now fires automatically 500ms after the last config change (no changes → no network call) - Add saveStatus state ('idle' | 'saving' | 'saved' | 'error') with AutoSaveStatus component showing spinner, check, or error+retry - Add isLoadingConfigRef guard so the initial fetchConfig load never triggers a spurious PATCH - Ctrl+S still works: clears debounce timer and saves immediately - Keep 'beforeunload' warning for validation errors that block save - Replace yellow unsaved-changes banner with a destructive validation error banner (only shown when save is actually blocked) - Error state shows 'Save failed' + 'Retry' button for user recovery Closes #189 --- .../components/dashboard/config-editor.tsx | 135 ++++++++++++++---- 1 file changed, 107 insertions(+), 28 deletions(-) diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index bf617ca0..8e0ced3a 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,13 @@ 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); + const updateDraftConfig = useCallback((updater: (prev: GuildConfig) => GuildConfig) => { setDraftConfig((prev) => updater((prev ?? {}) as GuildConfig)); }, []); @@ -178,6 +185,7 @@ export function ConfigEditor() { setLoading(true); setError(null); + isLoadingConfigRef.current = true; try { const res = await fetch(`/api/guilds/${encodeURIComponent(id)}/config`, { @@ -209,6 +217,12 @@ export function ConfigEditor() { } setSavedConfig(data); setDraftConfig(structuredClone(data)); + // Reset status after a successful reload; clear any stale error + setSaveStatus('idle'); + // Use a microtask 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) { @@ -272,10 +286,7 @@ export function ConfigEditor() { } const patches = computePatches(savedConfig, draftConfig); - if (patches.length === 0) { - toast.info('No changes to save.'); - return; - } + if (patches.length === 0) return; // Group patches by top-level section for batched requests const bySection = new Map>(); @@ -290,6 +301,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 +366,58 @@ export function ConfigEditor() { return updated; }); } + setSaveStatus('error'); toast.error('Some sections failed to save', { description: `Failed: ${failedSections.join(', ')}`, }); } else { - toast.success('Config saved successfully!'); + setSaveStatus('saved'); // Full success: reload to get the authoritative version from the server await fetchConfig(guildId); } } 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 ────────────── + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally omits saveChanges and saving to prevent re-trigger loops + 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]); + // ── 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 +738,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 +2268,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 { From eb894ad5aa968f1bdc34c9463f6de105863ce9e5 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:09:28 -0500 Subject: [PATCH 2/4] test(dashboard): add auto-save tests for ConfigEditor - No PATCH on initial config load - Validation error banner suppresses auto-save - 'Saving...' spinner visible while PATCH in-flight - 'Save failed' + Retry button on PATCH error --- .../dashboard/config-editor-autosave.test.tsx | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 web/tests/components/dashboard/config-editor-autosave.test.tsx 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 00000000..0ebebb4f --- /dev/null +++ b/web/tests/components/dashboard/config-editor-autosave.test.tsx @@ -0,0 +1,240 @@ +/** + * Tests for the auto-save feature in ConfigEditor. + * + * Covers: + * - AutoSaveStatus component renders the correct UI for each state + * - 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; + }) => ( +