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({
)}
-
+
)
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 }
+}