diff --git a/src/app/admin/categories/page.test.tsx b/src/app/admin/categories/page.test.tsx index 1baa34e..26a4909 100644 --- a/src/app/admin/categories/page.test.tsx +++ b/src/app/admin/categories/page.test.tsx @@ -51,10 +51,6 @@ vi.mock('@/hooks/use-auth', () => { return { useAuth: () => mockAuth } }) -vi.mock('@/hooks/use-toast', () => ({ - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), -})) - describe('AdminCategoriesPage', () => { it('renders categories heading', () => { render() diff --git a/src/app/admin/categories/page.tsx b/src/app/admin/categories/page.tsx index 35df1d1..ea5fca5 100644 --- a/src/app/admin/categories/page.tsx +++ b/src/app/admin/categories/page.tsx @@ -17,11 +17,11 @@ import type { EditingCategory } from '@/components/admin/categories/category-for import { getCategories, createCategory, updateCategory, deleteCategory } from '@/lib/api/client' import type { CategoryTreeNode } from '@/lib/api/types' import { useAuth } from '@/hooks/use-auth' -import { useToast } from '@/hooks/use-toast' +import { useSaveState } from '@/hooks/use-save-state' export default function AdminCategoriesPage() { const { getAccessToken } = useAuth() - const { toast } = useToast() + const saveMachine = useSaveState() const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) const [editing, setEditing] = useState(null) @@ -71,7 +71,6 @@ export default function AdminCategoriesPage() { try { await deleteCategory(id, getAccessToken() ?? '') void fetchCategories() - toast({ title: 'Category deleted' }) } catch { setActionError('Failed to delete category. Please try again.') } @@ -79,7 +78,7 @@ export default function AdminCategoriesPage() { const handleSave = async () => { if (!editing) return - + saveMachine.startSaving() try { if (editing.id) { await updateCategory( @@ -108,8 +107,9 @@ export default function AdminCategoriesPage() { } setEditing(null) void fetchCategories() - toast({ title: editing.id ? 'Category updated' : 'Category created' }) + saveMachine.reset() } catch { + saveMachine.reset() setActionError('Failed to save category. Please try again.') } } @@ -137,6 +137,7 @@ export default function AdminCategoriesPage() { onChange={setEditing} onSave={() => void handleSave()} onCancel={() => setEditing(null)} + saveStatus={saveMachine.status} /> )} diff --git a/src/app/admin/moderation/page.test.tsx b/src/app/admin/moderation/page.test.tsx index 5e5c18f..3e9bb80 100644 --- a/src/app/admin/moderation/page.test.tsx +++ b/src/app/admin/moderation/page.test.tsx @@ -51,10 +51,6 @@ vi.mock('@/hooks/use-auth', () => { return { useAuth: () => mockAuth } }) -vi.mock('@/hooks/use-toast', () => ({ - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), -})) - describe('AdminModerationPage', () => { it('renders moderation heading', () => { render() diff --git a/src/app/admin/moderation/page.tsx b/src/app/admin/moderation/page.tsx index 74e8f44..68ea838 100644 --- a/src/app/admin/moderation/page.tsx +++ b/src/app/admin/moderation/page.tsx @@ -35,6 +35,7 @@ export default function AdminModerationPage() { handleResolveFirstPost, handleBatchResolveFirstPost, handleSaveThresholds, + thresholdsSaveStatus, } = useModerationData() return ( @@ -135,6 +136,7 @@ export default function AdminModerationPage() { void handleSaveThresholds(updated)} + saveStatus={thresholdsSaveStatus} /> )} diff --git a/src/app/admin/onboarding/page.test.tsx b/src/app/admin/onboarding/page.test.tsx index 1250fd3..52fecd6 100644 --- a/src/app/admin/onboarding/page.test.tsx +++ b/src/app/admin/onboarding/page.test.tsx @@ -54,10 +54,6 @@ vi.mock('@/hooks/use-auth', () => { return { useAuth: () => mockAuth } }) -vi.mock('@/hooks/use-toast', () => ({ - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), -})) - describe('AdminOnboardingPage', () => { it('renders onboarding fields heading', () => { render() diff --git a/src/app/admin/onboarding/page.tsx b/src/app/admin/onboarding/page.tsx index 93b5795..3340c7a 100644 --- a/src/app/admin/onboarding/page.tsx +++ b/src/app/admin/onboarding/page.tsx @@ -20,7 +20,7 @@ export default function AdminOnboardingPage() { loading, editing, setEditing, - saving, + saveStatus, error, loadError, actionError, @@ -59,7 +59,7 @@ export default function AdminOnboardingPage() { {editing && ( void handleSave()} diff --git a/src/app/admin/settings/page.test.tsx b/src/app/admin/settings/page.test.tsx index 9f91fda..9279478 100644 --- a/src/app/admin/settings/page.test.tsx +++ b/src/app/admin/settings/page.test.tsx @@ -51,10 +51,6 @@ vi.mock('@/hooks/use-auth', () => { return { useAuth: () => mockAuth } }) -vi.mock('@/hooks/use-toast', () => ({ - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), -})) - describe('AdminSettingsPage', () => { it('renders community settings heading', () => { render() diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 5486496..f2503bb 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -20,15 +20,14 @@ import { } from '@/lib/api/client' import type { CommunitySettings, PdsTrustFactor } from '@/lib/api/types' import { useAuth } from '@/hooks/use-auth' -import { useToast } from '@/hooks/use-toast' +import { useSaveState } from '@/hooks/use-save-state' export default function AdminSettingsPage() { const { getAccessToken } = useAuth() - const { toast } = useToast() + const saveMachine = useSaveState() const [settings, setSettings] = useState(null) const [pdsProviders, setPdsProviders] = useState([]) const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) const [loadError, setLoadError] = useState(null) const [saveError, setSaveError] = useState(null) const [pdsError, setPdsError] = useState(null) @@ -55,7 +54,7 @@ export default function AdminSettingsPage() { const handleSave = async () => { if (!settings) return - setSaving(true) + saveMachine.startSaving() setSaveError(null) try { const updated = await updateCommunitySettings( @@ -69,11 +68,10 @@ export default function AdminSettingsPage() { getAccessToken() ?? '' ) setSettings(updated) - toast({ title: 'Settings saved' }) + saveMachine.onSaved() } catch { + saveMachine.reset() setSaveError('Failed to save settings. Please try again.') - } finally { - setSaving(false) } } @@ -88,7 +86,7 @@ export default function AdminSettingsPage() { } return [...prev, updated] }) - toast({ title: 'PDS trust factor updated' }) + // Dialog closes = visual feedback } catch { setPdsError('Failed to update PDS trust factor.') } @@ -115,7 +113,7 @@ export default function AdminSettingsPage() { settings={settings} onChange={setSettings} onSave={() => void handleSave()} - saving={saving} + saveStatus={saveMachine.status} saveError={saveError} onDismissError={() => setSaveError(null)} /> diff --git a/src/app/admin/sybil-detection/page.test.tsx b/src/app/admin/sybil-detection/page.test.tsx index 98e636d..d4b7c1e 100644 --- a/src/app/admin/sybil-detection/page.test.tsx +++ b/src/app/admin/sybil-detection/page.test.tsx @@ -52,10 +52,6 @@ vi.mock('@/hooks/use-auth', () => { return { useAuth: () => mockAuth } }) -vi.mock('@/hooks/use-toast', () => ({ - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), -})) - describe('AdminSybilDetectionPage', () => { it('renders heading and explanation text', async () => { render() @@ -190,7 +186,7 @@ describe('AdminSybilDetectionPage', () => { }) }) - it('recompute button sends POST and re-enables after completion', async () => { + it('recompute button shows started state after completion', async () => { const user = userEvent.setup() render() await waitFor(() => { @@ -199,9 +195,9 @@ describe('AdminSybilDetectionPage', () => { const recomputeBtn = screen.getByRole('button', { name: /recompute now/i }) expect(recomputeBtn).toBeEnabled() await user.click(recomputeBtn) - // After the mock resolves, the button should re-enable + // After the mock resolves, button shows "Started" state await waitFor(() => { - const btn = screen.getByRole('button', { name: /recompute now/i }) + const btn = screen.getByRole('button', { name: /started/i }) expect(btn).toBeEnabled() }) }) diff --git a/src/app/admin/sybil-detection/page.tsx b/src/app/admin/sybil-detection/page.tsx index f451e22..cf27e6b 100644 --- a/src/app/admin/sybil-detection/page.tsx +++ b/src/app/admin/sybil-detection/page.tsx @@ -29,7 +29,7 @@ export default function AdminSybilDetectionPage() { loadError, actionError, setActionError, - recomputing, + recomputeStatus, confirmAction, setConfirmAction, fetchData, @@ -64,7 +64,7 @@ export default function AdminSybilDetectionPage() { void handleRecompute()} - recomputing={recomputing} + saveStatus={recomputeStatus} /> )} @@ -128,7 +128,7 @@ export default function AdminSybilDetectionPage() { {/* Live region for status updates */}
- {recomputing && 'Trust graph recomputation started.'} + {recomputeStatus === 'saving' && 'Trust graph recomputation started.'}
diff --git a/src/app/admin/trust-seeds/page.test.tsx b/src/app/admin/trust-seeds/page.test.tsx index 57abc22..61f2834 100644 --- a/src/app/admin/trust-seeds/page.test.tsx +++ b/src/app/admin/trust-seeds/page.test.tsx @@ -52,10 +52,6 @@ vi.mock('@/hooks/use-auth', () => { return { useAuth: () => mockAuth } }) -vi.mock('@/hooks/use-toast', () => ({ - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), -})) - describe('AdminTrustSeedsPage', () => { it('renders heading and help text', async () => { render() diff --git a/src/app/admin/trust-seeds/page.tsx b/src/app/admin/trust-seeds/page.tsx index 929af49..7f583c9 100644 --- a/src/app/admin/trust-seeds/page.tsx +++ b/src/app/admin/trust-seeds/page.tsx @@ -16,11 +16,9 @@ import { TrustSeedCard } from '@/components/admin/trust-seeds/trust-seed-card' import { getTrustSeeds, createTrustSeed, deleteTrustSeed } from '@/lib/api/client' import type { TrustSeed } from '@/lib/api/types' import { useAuth } from '@/hooks/use-auth' -import { useToast } from '@/hooks/use-toast' export default function AdminTrustSeedsPage() { const { getAccessToken } = useAuth() - const { toast } = useToast() const [seeds, setSeeds] = useState([]) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) @@ -56,7 +54,6 @@ export default function AdminTrustSeedsPage() { const newSeed = await createTrustSeed(data, getAccessToken() ?? '') setSeeds((prev) => [...prev, newSeed]) setAddDialogOpen(false) - toast({ title: 'Trust seed added' }) } catch { setActionError('Failed to add trust seed.') } @@ -72,7 +69,6 @@ export default function AdminTrustSeedsPage() { try { await deleteTrustSeed(seed.id, getAccessToken() ?? '') setSeeds((prev) => prev.filter((s) => s.id !== seed.id)) - toast({ title: 'Trust seed removed' }) } catch { setActionError('Failed to remove trust seed.') } diff --git a/src/components/admin/categories/category-form.tsx b/src/components/admin/categories/category-form.tsx index c35a2c3..34f8611 100644 --- a/src/components/admin/categories/category-form.tsx +++ b/src/components/admin/categories/category-form.tsx @@ -4,7 +4,9 @@ */ import type { MaturityRating } from '@/lib/api/types' +import { SaveButton } from '@/components/admin/save-button' import { FormLabel } from '@/components/ui/form-label' +import type { SaveStatus } from '@/hooks/use-save-state' export interface EditingCategory { id: string | null @@ -20,9 +22,16 @@ interface CategoryFormProps { onChange: (cat: EditingCategory) => void onSave: () => void onCancel: () => void + saveStatus: SaveStatus } -export function CategoryForm({ editing, onChange, onSave, onCancel }: CategoryFormProps) { +export function CategoryForm({ + editing, + onChange, + onSave, + onCancel, + saveStatus, +}: CategoryFormProps) { return (

@@ -86,13 +95,7 @@ export function CategoryForm({ editing, onChange, onSave, onCancel }: CategoryFo

- +
- + onSave(values)} label="Save Thresholds" /> ) } diff --git a/src/components/admin/onboarding/onboarding-field-form.tsx b/src/components/admin/onboarding/onboarding-field-form.tsx index 19e3d48..838823e 100644 --- a/src/components/admin/onboarding/onboarding-field-form.tsx +++ b/src/components/admin/onboarding/onboarding-field-form.tsx @@ -4,7 +4,9 @@ */ import type { OnboardingFieldType } from '@/lib/api/types' +import { SaveButton } from '@/components/admin/save-button' import { FormLabel } from '@/components/ui/form-label' +import type { SaveStatus } from '@/hooks/use-save-state' const FIELD_TYPE_LABELS: Record = { age_confirmation: 'Age Confirmation', @@ -35,7 +37,7 @@ export const EMPTY_FIELD: EditingField = { interface OnboardingFieldFormProps { editing: EditingField - saving: boolean + saveStatus: SaveStatus error: string | null onChange: (field: EditingField) => void onSave: () => void @@ -44,7 +46,7 @@ interface OnboardingFieldFormProps { export function OnboardingFieldForm({ editing, - saving, + saveStatus, error, onChange, onSave, @@ -151,14 +153,7 @@ export function OnboardingFieldForm({

)}
- + - + onSave(values)} + className="px-3 py-1.5" + />
- + ) diff --git a/src/components/admin/save-button.test.tsx b/src/components/admin/save-button.test.tsx new file mode 100644 index 0000000..e8c7f12 --- /dev/null +++ b/src/components/admin/save-button.test.tsx @@ -0,0 +1,94 @@ +/** + * Tests for SaveButton component. + * Renders button with state-driven text/icon for idle, saving, and saved states. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { SaveButton } from './save-button' + +describe('SaveButton', () => { + it('renders default label in idle state', () => { + render() + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() + expect(screen.getByRole('button')).toBeEnabled() + }) + + it('renders custom label in idle state', () => { + render() + expect(screen.getByRole('button', { name: /save settings/i })).toBeInTheDocument() + }) + + it('renders saving label and is disabled in saving state', () => { + render() + const button = screen.getByRole('button', { name: /saving/i }) + expect(button).toBeDisabled() + }) + + it('renders custom saving label', () => { + render() + expect(screen.getByRole('button', { name: /recomputing/i })).toBeDisabled() + }) + + it('renders saved label with check icon in saved state', () => { + render() + const button = screen.getByRole('button', { name: /saved/i }) + expect(button).toBeEnabled() + }) + + it('renders custom saved label', () => { + render() + expect(screen.getByRole('button', { name: /started/i })).toBeInTheDocument() + }) + + it('calls onClick when clicked in idle state', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render() + await user.click(screen.getByRole('button')) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('does not call onClick when disabled in saving state', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render() + await user.click(screen.getByRole('button')) + expect(onClick).not.toHaveBeenCalled() + }) + + it('has aria-live status region for screen readers', () => { + const { rerender } = render() + const liveRegion = screen.getByRole('status') + expect(liveRegion).toBeInTheDocument() + expect(liveRegion).toHaveTextContent('') + + rerender() + expect(liveRegion).toHaveTextContent('Saved.') + }) + + it('announces custom saved label to screen readers', () => { + render() + expect(screen.getByRole('status')).toHaveTextContent('Started.') + }) + + it('applies custom className', () => { + render() + const button = screen.getByRole('button') + expect(button.className).toContain('mt-4') + }) + + it('passes axe accessibility check in idle state', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('passes axe accessibility check in saved state', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/admin/save-button.tsx b/src/components/admin/save-button.tsx new file mode 100644 index 0000000..c82c4b6 --- /dev/null +++ b/src/components/admin/save-button.tsx @@ -0,0 +1,52 @@ +/** + * SaveButton - Button with state-driven text/icon for save operations. + * Cycles through idle, saving (disabled), and saved (with CheckCircle icon). + */ + +import { CheckCircle } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' +import type { SaveStatus } from '@/hooks/use-save-state' + +interface SaveButtonProps { + status: SaveStatus + onClick: () => void + label?: string + savingLabel?: string + savedLabel?: string + className?: string +} + +export function SaveButton({ + status, + onClick, + label = 'Save', + savingLabel = 'Saving...', + savedLabel = 'Saved', + className, +}: SaveButtonProps) { + return ( + <> + + + {status === 'saved' ? `${savedLabel}.` : ''} + + + ) +} diff --git a/src/components/admin/settings/community-settings-form.tsx b/src/components/admin/settings/community-settings-form.tsx index 1416740..706c579 100644 --- a/src/components/admin/settings/community-settings-form.tsx +++ b/src/components/admin/settings/community-settings-form.tsx @@ -6,14 +6,16 @@ 'use client' import { ErrorAlert } from '@/components/error-alert' +import { SaveButton } from '@/components/admin/save-button' import { FormLabel } from '@/components/ui/form-label' import type { CommunitySettings, MaturityRating } from '@/lib/api/types' +import type { SaveStatus } from '@/hooks/use-save-state' interface CommunitySettingsFormProps { settings: CommunitySettings onChange: (updated: CommunitySettings) => void onSave: () => void - saving: boolean + saveStatus: SaveStatus saveError: string | null onDismissError: () => void } @@ -22,7 +24,7 @@ export function CommunitySettingsForm({ settings, onChange, onSave, - saving, + saveStatus, saveError, onDismissError, }: CommunitySettingsFormProps) { @@ -126,14 +128,7 @@ export function CommunitySettingsForm({ {saveError && } - + ) } diff --git a/src/components/admin/sybil/trust-graph-status-card.tsx b/src/components/admin/sybil/trust-graph-status-card.tsx index d781a76..2845016 100644 --- a/src/components/admin/sybil/trust-graph-status-card.tsx +++ b/src/components/admin/sybil/trust-graph-status-card.tsx @@ -4,7 +4,8 @@ */ import { formatRelativeTime, formatNumber } from '@/lib/format' -import { cn } from '@/lib/utils' +import { SaveButton } from '@/components/admin/save-button' +import type { SaveStatus } from '@/hooks/use-save-state' interface TrustGraphStatusCardProps { status: { @@ -14,13 +15,13 @@ interface TrustGraphStatusCardProps { clustersFlagged: number } onRecompute: () => void - recomputing: boolean + saveStatus: SaveStatus } export function TrustGraphStatusCard({ status, onRecompute, - recomputing, + saveStatus, }: TrustGraphStatusCardProps) { return (
@@ -38,20 +39,13 @@ export function TrustGraphStatusCard({ {status.clustersFlagged} clusters flagged

- + label="Recompute Now" + savingLabel="Recomputing..." + savedLabel="Started" + /> ) diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index a813268..a510086 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef< ('reports') const [reports, setReports] = useState([]) const [firstPostQueue, setFirstPostQueue] = useState([]) @@ -86,7 +86,6 @@ export function useModerationData() { try { await resolveReport(id, resolution, getAccessToken() ?? '') setReports((prev) => prev.filter((r) => r.id !== id)) - toast({ title: 'Report resolved' }) } catch { setActionError('Failed to resolve report. Please try again.') } @@ -97,7 +96,6 @@ export function useModerationData() { try { await resolveFirstPost(id, action, getAccessToken() ?? '') setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) - toast({ title: action === 'approved' ? 'Post approved' : 'Post rejected' }) } catch { setActionError( `Failed to ${action === 'approved' ? 'approve' : 'reject'} post. Please try again.` @@ -110,7 +108,6 @@ export function useModerationData() { try { await Promise.all(ids.map((id) => resolveFirstPost(id, action, getAccessToken() ?? ''))) setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) - toast({ title: action === 'approved' ? 'Posts approved' : 'Posts rejected' }) } catch { setActionError('Failed to process batch action. Some items may not have been updated.') } @@ -118,11 +115,13 @@ export function useModerationData() { const handleSaveThresholds = async (updated: Partial) => { setActionError(null) + thresholdsSave.startSaving() try { const result = await updateModerationThresholds(updated, getAccessToken() ?? '') setThresholds(result) - toast({ title: 'Thresholds saved' }) + thresholdsSave.onSaved() } catch { + thresholdsSave.reset() setActionError('Failed to save thresholds. Please try again.') } } @@ -144,5 +143,6 @@ export function useModerationData() { handleResolveFirstPost, handleBatchResolveFirstPost, handleSaveThresholds, + thresholdsSaveStatus: thresholdsSave.status, } } diff --git a/src/hooks/admin/use-onboarding-fields.ts b/src/hooks/admin/use-onboarding-fields.ts index 741d942..d72685f 100644 --- a/src/hooks/admin/use-onboarding-fields.ts +++ b/src/hooks/admin/use-onboarding-fields.ts @@ -16,16 +16,15 @@ import type { OnboardingField, CreateOnboardingFieldInput, HostingMode } from '@ import { EMPTY_FIELD } from '@/components/admin/onboarding/onboarding-field-form' import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' import { useAuth } from '@/hooks/use-auth' -import { useToast } from '@/hooks/use-toast' +import { useSaveState } from '@/hooks/use-save-state' export function useOnboardingFields() { const { getAccessToken } = useAuth() - const { toast } = useToast() + const saveMachine = useSaveState() const [fields, setFields] = useState([]) const [hostingMode, setHostingMode] = useState('selfhosted') const [loading, setLoading] = useState(true) const [editing, setEditing] = useState(null) - const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [loadError, setLoadError] = useState(null) const [actionError, setActionError] = useState(null) @@ -69,7 +68,6 @@ export function useOnboardingFields() { try { await deleteOnboardingField(id, getAccessToken() ?? '') void fetchFields() - toast({ title: 'Field deleted' }) } catch { setActionError('Failed to delete field. Please try again.') } @@ -82,7 +80,7 @@ export function useOnboardingFields() { return } - setSaving(true) + saveMachine.startSaving() setError(null) try { if (editing.id) { @@ -109,11 +107,10 @@ export function useOnboardingFields() { } setEditing(null) void fetchFields() - toast({ title: editing.id ? 'Field updated' : 'Field created' }) + saveMachine.reset() } catch { + saveMachine.reset() setError('Failed to save field') - } finally { - setSaving(false) } } @@ -149,7 +146,7 @@ export function useOnboardingFields() { loading, editing, setEditing, - saving, + saveStatus: saveMachine.status, error, loadError, actionError, diff --git a/src/hooks/admin/use-plugin-management.ts b/src/hooks/admin/use-plugin-management.ts index 93144e8..c700aa0 100644 --- a/src/hooks/admin/use-plugin-management.ts +++ b/src/hooks/admin/use-plugin-management.ts @@ -9,7 +9,7 @@ import { useState, useEffect, useCallback } from 'react' import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' import type { Plugin } from '@/lib/api/types' import { useAuth } from '@/hooks/use-auth' -import { useToast } from '@/hooks/use-toast' +import { useSaveState } from '@/hooks/use-save-state' interface DependencyWarning { plugin: Plugin @@ -18,7 +18,7 @@ interface DependencyWarning { export function usePluginManagement() { const { getAccessToken } = useAuth() - const { toast } = useToast() + const settingsSave = useSaveState() const [plugins, setPlugins] = useState([]) const [loading, setLoading] = useState(true) const [settingsPlugin, setSettingsPlugin] = useState(null) @@ -62,7 +62,6 @@ export function usePluginManagement() { setPlugins((prev) => prev.map((p) => (p.id === plugin.id ? { ...p, enabled: !p.enabled } : p)) ) - toast({ title: plugin.enabled ? 'Plugin disabled' : 'Plugin enabled' }) } catch { setActionError(`Failed to ${plugin.enabled ? 'disable' : 'enable'} plugin. Please try again.`) } @@ -76,7 +75,6 @@ export function usePluginManagement() { setPlugins((prev) => prev.map((p) => (p.id === dependencyWarning.plugin.id ? { ...p, enabled: false } : p)) ) - toast({ title: 'Plugin disabled' }) } catch { setActionError('Failed to disable plugin. Please try again.') } @@ -86,11 +84,13 @@ export function usePluginManagement() { const handleSaveSettings = async (settings: Record) => { if (!settingsPlugin) return setActionError(null) + settingsSave.startSaving() try { await updatePluginSettings(settingsPlugin.id, settings, getAccessToken() ?? '') setPlugins((prev) => prev.map((p) => (p.id === settingsPlugin.id ? { ...p, settings } : p))) - toast({ title: 'Plugin settings saved' }) + settingsSave.reset() } catch { + settingsSave.reset() setActionError('Failed to save plugin settings. Please try again.') } setSettingsPlugin(null) @@ -101,7 +101,6 @@ export function usePluginManagement() { try { await uninstallPlugin(plugin.id, getAccessToken() ?? '') setPlugins((prev) => prev.filter((p) => p.id !== plugin.id)) - toast({ title: 'Plugin uninstalled' }) } catch { setActionError('Failed to uninstall plugin. Please try again.') } @@ -122,5 +121,6 @@ export function usePluginManagement() { confirmDisable, handleSaveSettings, handleUninstall, + settingsSaveStatus: settingsSave.status, } } diff --git a/src/hooks/admin/use-sybil-data.ts b/src/hooks/admin/use-sybil-data.ts index 35cf239..ef9b1c3 100644 --- a/src/hooks/admin/use-sybil-data.ts +++ b/src/hooks/admin/use-sybil-data.ts @@ -23,11 +23,11 @@ import type { BehavioralFlag, } from '@/lib/api/types' import { useAuth } from '@/hooks/use-auth' -import { useToast } from '@/hooks/use-toast' +import { useSaveState } from '@/hooks/use-save-state' export function useSybilData() { const { getAccessToken } = useAuth() - const { toast } = useToast() + const recomputeSave = useSaveState() const [clusters, setClusters] = useState([]) const [graphStatus, setGraphStatus] = useState(null) const [flags, setFlags] = useState([]) @@ -36,7 +36,6 @@ export function useSybilData() { const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [actionError, setActionError] = useState(null) - const [recomputing, setRecomputing] = useState(false) const [confirmAction, setConfirmAction] = useState<{ title: string message: string @@ -96,7 +95,7 @@ export function useSybilData() { ) setClusters((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) setSelectedDetail({ ...selectedDetail, ...updated }) - toast({ title: 'Cluster status updated' }) + // Visual change (cluster status badge update) is the feedback } catch { setActionError('Failed to update cluster status.') } @@ -105,14 +104,13 @@ export function useSybilData() { } const handleRecompute = async () => { - setRecomputing(true) + recomputeSave.startSaving() try { await recomputeTrustGraph(getAccessToken() ?? '') - toast({ title: 'Trust graph recomputation started' }) + recomputeSave.onSaved() } catch { + recomputeSave.reset() setActionError('Failed to start recomputation.') - } finally { - setRecomputing(false) } } @@ -121,7 +119,6 @@ export function useSybilData() { try { const updated = await updateBehavioralFlag(id, 'dismissed', getAccessToken() ?? '') setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) - toast({ title: 'Flag dismissed' }) } catch { setActionError('Failed to dismiss flag.') } @@ -139,7 +136,7 @@ export function useSybilData() { loadError, actionError, setActionError, - recomputing, + recomputeStatus: recomputeSave.status, confirmAction, setConfirmAction, fetchData, diff --git a/src/hooks/use-save-state.test.ts b/src/hooks/use-save-state.test.ts new file mode 100644 index 0000000..abf6582 --- /dev/null +++ b/src/hooks/use-save-state.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for useSaveState hook. + * State machine: idle -> saving -> saved -> idle (with 2s auto-reset). + */ + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useSaveState } from './use-save-state' + +describe('useSaveState', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('starts in idle status', () => { + const { result } = renderHook(() => useSaveState()) + expect(result.current.status).toBe('idle') + }) + + it('transitions to saving when startSaving is called', () => { + const { result } = renderHook(() => useSaveState()) + act(() => { + result.current.startSaving() + }) + expect(result.current.status).toBe('saving') + }) + + it('transitions to saved when onSaved is called', () => { + const { result } = renderHook(() => useSaveState()) + act(() => { + result.current.startSaving() + }) + act(() => { + result.current.onSaved() + }) + expect(result.current.status).toBe('saved') + }) + + it('auto-resets to idle after 2 seconds', () => { + vi.useFakeTimers() + const { result } = renderHook(() => useSaveState()) + act(() => { + result.current.startSaving() + }) + act(() => { + result.current.onSaved() + }) + expect(result.current.status).toBe('saved') + + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(result.current.status).toBe('idle') + vi.useRealTimers() + }) + + it('does not reset before 2 seconds', () => { + vi.useFakeTimers() + const { result } = renderHook(() => useSaveState()) + act(() => { + result.current.startSaving() + }) + act(() => { + result.current.onSaved() + }) + + act(() => { + vi.advanceTimersByTime(1999) + }) + expect(result.current.status).toBe('saved') + vi.useRealTimers() + }) + + it('resets to idle immediately when reset is called', () => { + const { result } = renderHook(() => useSaveState()) + act(() => { + result.current.startSaving() + }) + act(() => { + result.current.reset() + }) + expect(result.current.status).toBe('idle') + }) + + it('clears timer on unmount', () => { + vi.useFakeTimers() + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + const { result, unmount } = renderHook(() => useSaveState()) + act(() => { + result.current.startSaving() + }) + act(() => { + result.current.onSaved() + }) + unmount() + expect(clearTimeoutSpy).toHaveBeenCalled() + vi.useRealTimers() + }) + + it('clears previous timer when onSaved is called again', () => { + vi.useFakeTimers() + const { result } = renderHook(() => useSaveState()) + + // First save cycle + act(() => { + result.current.startSaving() + }) + act(() => { + result.current.onSaved() + }) + + // Advance 1s, then start another cycle + act(() => { + vi.advanceTimersByTime(1000) + }) + act(() => { + result.current.startSaving() + }) + act(() => { + result.current.onSaved() + }) + + // After 1.5s from second onSaved, should still be saved + act(() => { + vi.advanceTimersByTime(1500) + }) + expect(result.current.status).toBe('saved') + + // After full 2s from second onSaved, should reset + act(() => { + vi.advanceTimersByTime(500) + }) + expect(result.current.status).toBe('idle') + vi.useRealTimers() + }) +}) diff --git a/src/hooks/use-save-state.ts b/src/hooks/use-save-state.ts new file mode 100644 index 0000000..6ecc031 --- /dev/null +++ b/src/hooks/use-save-state.ts @@ -0,0 +1,47 @@ +/** + * State machine hook for save button feedback. + * Cycles: idle -> saving -> saved -> idle (2s auto-reset). + */ + +'use client' + +import { useState, useCallback, useEffect, useRef } from 'react' + +export type SaveStatus = 'idle' | 'saving' | 'saved' + +export function useSaveState() { + const [status, setStatus] = useState('idle') + const timerRef = useRef | null>(null) + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + useEffect(() => { + return clearTimer + }, [clearTimer]) + + const startSaving = useCallback(() => { + clearTimer() + setStatus('saving') + }, [clearTimer]) + + const onSaved = useCallback(() => { + clearTimer() + setStatus('saved') + timerRef.current = setTimeout(() => { + setStatus('idle') + timerRef.current = null + }, 2000) + }, [clearTimer]) + + const reset = useCallback(() => { + clearTimer() + setStatus('idle') + }, [clearTimer]) + + return { status, startSaving, onSaved, reset } +}