Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(admin): replace success toasts with proximate button-state feedback (#173)

* refactor(admin): replace success toasts with proximate button-state feedback

Replace far-corner toast notifications with a two-layer feedback system:
- SaveButton component cycles idle/saving/saved for form saves
- Visual changes (item disappears, dialog closes) serve as feedback
for deletions, toggles, and dialog submissions

Add useSaveState hook (idle -> saving -> saved -> idle with 2s auto-
reset timer) and SaveButton component with CheckCircle icon, aria-live
status region for screen readers, and matching admin button styling.

Update 4 hooks, 8 pages, 6 child components to use the new system.
Remove all 24 success toast calls and useToast imports from admin.
Move toast viewport to bottom-left (aligned with admin sidebar).
Remove useToast mock from 6 admin test files.

Net change: -62 lines, 21 new tests (1021 total).

* style: fix prettier formatting in 4 components

authored by

Guido X Jansen and committed by
GitHub
cc9b8c01 c42e54dc

+434 -148
-4
src/app/admin/categories/page.test.tsx
··· 51 51 return { useAuth: () => mockAuth } 52 52 }) 53 53 54 - vi.mock('@/hooks/use-toast', () => ({ 55 - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 56 - })) 57 - 58 54 describe('AdminCategoriesPage', () => { 59 55 it('renders categories heading', () => { 60 56 render(<AdminCategoriesPage />)
+6 -5
src/app/admin/categories/page.tsx
··· 17 17 import { getCategories, createCategory, updateCategory, deleteCategory } from '@/lib/api/client' 18 18 import type { CategoryTreeNode } from '@/lib/api/types' 19 19 import { useAuth } from '@/hooks/use-auth' 20 - import { useToast } from '@/hooks/use-toast' 20 + import { useSaveState } from '@/hooks/use-save-state' 21 21 22 22 export default function AdminCategoriesPage() { 23 23 const { getAccessToken } = useAuth() 24 - const { toast } = useToast() 24 + const saveMachine = useSaveState() 25 25 const [categories, setCategories] = useState<CategoryTreeNode[]>([]) 26 26 const [loading, setLoading] = useState(true) 27 27 const [editing, setEditing] = useState<EditingCategory | null>(null) ··· 71 71 try { 72 72 await deleteCategory(id, getAccessToken() ?? '') 73 73 void fetchCategories() 74 - toast({ title: 'Category deleted' }) 75 74 } catch { 76 75 setActionError('Failed to delete category. Please try again.') 77 76 } ··· 79 78 80 79 const handleSave = async () => { 81 80 if (!editing) return 82 - 81 + saveMachine.startSaving() 83 82 try { 84 83 if (editing.id) { 85 84 await updateCategory( ··· 108 107 } 109 108 setEditing(null) 110 109 void fetchCategories() 111 - toast({ title: editing.id ? 'Category updated' : 'Category created' }) 110 + saveMachine.reset() 112 111 } catch { 112 + saveMachine.reset() 113 113 setActionError('Failed to save category. Please try again.') 114 114 } 115 115 } ··· 137 137 onChange={setEditing} 138 138 onSave={() => void handleSave()} 139 139 onCancel={() => setEditing(null)} 140 + saveStatus={saveMachine.status} 140 141 /> 141 142 )} 142 143
-4
src/app/admin/moderation/page.test.tsx
··· 51 51 return { useAuth: () => mockAuth } 52 52 }) 53 53 54 - vi.mock('@/hooks/use-toast', () => ({ 55 - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 56 - })) 57 - 58 54 describe('AdminModerationPage', () => { 59 55 it('renders moderation heading', () => { 60 56 render(<AdminModerationPage />)
+2
src/app/admin/moderation/page.tsx
··· 35 35 handleResolveFirstPost, 36 36 handleBatchResolveFirstPost, 37 37 handleSaveThresholds, 38 + thresholdsSaveStatus, 38 39 } = useModerationData() 39 40 40 41 return ( ··· 135 136 <ModerationThresholdsTab 136 137 thresholds={thresholds} 137 138 onSave={(updated) => void handleSaveThresholds(updated)} 139 + saveStatus={thresholdsSaveStatus} 138 140 /> 139 141 )} 140 142 </div>
-4
src/app/admin/onboarding/page.test.tsx
··· 54 54 return { useAuth: () => mockAuth } 55 55 }) 56 56 57 - vi.mock('@/hooks/use-toast', () => ({ 58 - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 59 - })) 60 - 61 57 describe('AdminOnboardingPage', () => { 62 58 it('renders onboarding fields heading', () => { 63 59 render(<AdminOnboardingPage />)
+2 -2
src/app/admin/onboarding/page.tsx
··· 20 20 loading, 21 21 editing, 22 22 setEditing, 23 - saving, 23 + saveStatus, 24 24 error, 25 25 loadError, 26 26 actionError, ··· 59 59 {editing && ( 60 60 <OnboardingFieldForm 61 61 editing={editing} 62 - saving={saving} 62 + saveStatus={saveStatus} 63 63 error={error} 64 64 onChange={setEditing} 65 65 onSave={() => void handleSave()}
-4
src/app/admin/settings/page.test.tsx
··· 51 51 return { useAuth: () => mockAuth } 52 52 }) 53 53 54 - vi.mock('@/hooks/use-toast', () => ({ 55 - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 56 - })) 57 - 58 54 describe('AdminSettingsPage', () => { 59 55 it('renders community settings heading', () => { 60 56 render(<AdminSettingsPage />)
+7 -9
src/app/admin/settings/page.tsx
··· 20 20 } from '@/lib/api/client' 21 21 import type { CommunitySettings, PdsTrustFactor } from '@/lib/api/types' 22 22 import { useAuth } from '@/hooks/use-auth' 23 - import { useToast } from '@/hooks/use-toast' 23 + import { useSaveState } from '@/hooks/use-save-state' 24 24 25 25 export default function AdminSettingsPage() { 26 26 const { getAccessToken } = useAuth() 27 - const { toast } = useToast() 27 + const saveMachine = useSaveState() 28 28 const [settings, setSettings] = useState<CommunitySettings | null>(null) 29 29 const [pdsProviders, setPdsProviders] = useState<PdsTrustFactor[]>([]) 30 30 const [loading, setLoading] = useState(true) 31 - const [saving, setSaving] = useState(false) 32 31 const [loadError, setLoadError] = useState<string | null>(null) 33 32 const [saveError, setSaveError] = useState<string | null>(null) 34 33 const [pdsError, setPdsError] = useState<string | null>(null) ··· 55 54 56 55 const handleSave = async () => { 57 56 if (!settings) return 58 - setSaving(true) 57 + saveMachine.startSaving() 59 58 setSaveError(null) 60 59 try { 61 60 const updated = await updateCommunitySettings( ··· 69 68 getAccessToken() ?? '' 70 69 ) 71 70 setSettings(updated) 72 - toast({ title: 'Settings saved' }) 71 + saveMachine.onSaved() 73 72 } catch { 73 + saveMachine.reset() 74 74 setSaveError('Failed to save settings. Please try again.') 75 - } finally { 76 - setSaving(false) 77 75 } 78 76 } 79 77 ··· 88 86 } 89 87 return [...prev, updated] 90 88 }) 91 - toast({ title: 'PDS trust factor updated' }) 89 + // Dialog closes = visual feedback 92 90 } catch { 93 91 setPdsError('Failed to update PDS trust factor.') 94 92 } ··· 115 113 settings={settings} 116 114 onChange={setSettings} 117 115 onSave={() => void handleSave()} 118 - saving={saving} 116 + saveStatus={saveMachine.status} 119 117 saveError={saveError} 120 118 onDismissError={() => setSaveError(null)} 121 119 />
+3 -7
src/app/admin/sybil-detection/page.test.tsx
··· 52 52 return { useAuth: () => mockAuth } 53 53 }) 54 54 55 - vi.mock('@/hooks/use-toast', () => ({ 56 - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 57 - })) 58 - 59 55 describe('AdminSybilDetectionPage', () => { 60 56 it('renders heading and explanation text', async () => { 61 57 render(<AdminSybilDetectionPage />) ··· 190 186 }) 191 187 }) 192 188 193 - it('recompute button sends POST and re-enables after completion', async () => { 189 + it('recompute button shows started state after completion', async () => { 194 190 const user = userEvent.setup() 195 191 render(<AdminSybilDetectionPage />) 196 192 await waitFor(() => { ··· 199 195 const recomputeBtn = screen.getByRole('button', { name: /recompute now/i }) 200 196 expect(recomputeBtn).toBeEnabled() 201 197 await user.click(recomputeBtn) 202 - // After the mock resolves, the button should re-enable 198 + // After the mock resolves, button shows "Started" state 203 199 await waitFor(() => { 204 - const btn = screen.getByRole('button', { name: /recompute now/i }) 200 + const btn = screen.getByRole('button', { name: /started/i }) 205 201 expect(btn).toBeEnabled() 206 202 }) 207 203 })
+3 -3
src/app/admin/sybil-detection/page.tsx
··· 29 29 loadError, 30 30 actionError, 31 31 setActionError, 32 - recomputing, 32 + recomputeStatus, 33 33 confirmAction, 34 34 setConfirmAction, 35 35 fetchData, ··· 64 64 <TrustGraphStatusCard 65 65 status={graphStatus} 66 66 onRecompute={() => void handleRecompute()} 67 - recomputing={recomputing} 67 + saveStatus={recomputeStatus} 68 68 /> 69 69 )} 70 70 ··· 128 128 129 129 {/* Live region for status updates */} 130 130 <div aria-live="polite" className="sr-only"> 131 - {recomputing && 'Trust graph recomputation started.'} 131 + {recomputeStatus === 'saving' && 'Trust graph recomputation started.'} 132 132 </div> 133 133 </div> 134 134 </AdminLayout>
-4
src/app/admin/trust-seeds/page.test.tsx
··· 52 52 return { useAuth: () => mockAuth } 53 53 }) 54 54 55 - vi.mock('@/hooks/use-toast', () => ({ 56 - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 57 - })) 58 - 59 55 describe('AdminTrustSeedsPage', () => { 60 56 it('renders heading and help text', async () => { 61 57 render(<AdminTrustSeedsPage />)
-4
src/app/admin/trust-seeds/page.tsx
··· 16 16 import { getTrustSeeds, createTrustSeed, deleteTrustSeed } from '@/lib/api/client' 17 17 import type { TrustSeed } from '@/lib/api/types' 18 18 import { useAuth } from '@/hooks/use-auth' 19 - import { useToast } from '@/hooks/use-toast' 20 19 21 20 export default function AdminTrustSeedsPage() { 22 21 const { getAccessToken } = useAuth() 23 - const { toast } = useToast() 24 22 const [seeds, setSeeds] = useState<TrustSeed[]>([]) 25 23 const [loading, setLoading] = useState(true) 26 24 const [loadError, setLoadError] = useState<string | null>(null) ··· 56 54 const newSeed = await createTrustSeed(data, getAccessToken() ?? '') 57 55 setSeeds((prev) => [...prev, newSeed]) 58 56 setAddDialogOpen(false) 59 - toast({ title: 'Trust seed added' }) 60 57 } catch { 61 58 setActionError('Failed to add trust seed.') 62 59 } ··· 72 69 try { 73 70 await deleteTrustSeed(seed.id, getAccessToken() ?? '') 74 71 setSeeds((prev) => prev.filter((s) => s.id !== seed.id)) 75 - toast({ title: 'Trust seed removed' }) 76 72 } catch { 77 73 setActionError('Failed to remove trust seed.') 78 74 }
+11 -8
src/components/admin/categories/category-form.tsx
··· 4 4 */ 5 5 6 6 import type { MaturityRating } from '@/lib/api/types' 7 + import { SaveButton } from '@/components/admin/save-button' 7 8 import { FormLabel } from '@/components/ui/form-label' 9 + import type { SaveStatus } from '@/hooks/use-save-state' 8 10 9 11 export interface EditingCategory { 10 12 id: string | null ··· 20 22 onChange: (cat: EditingCategory) => void 21 23 onSave: () => void 22 24 onCancel: () => void 25 + saveStatus: SaveStatus 23 26 } 24 27 25 - export function CategoryForm({ editing, onChange, onSave, onCancel }: CategoryFormProps) { 28 + export function CategoryForm({ 29 + editing, 30 + onChange, 31 + onSave, 32 + onCancel, 33 + saveStatus, 34 + }: CategoryFormProps) { 26 35 return ( 27 36 <div className="rounded-lg border border-border bg-card p-4"> 28 37 <h2 className="mb-4 text-lg font-semibold text-foreground"> ··· 86 95 </select> 87 96 </div> 88 97 <div className="flex gap-2"> 89 - <button 90 - type="button" 91 - onClick={onSave} 92 - className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 93 - > 94 - Save 95 - </button> 98 + <SaveButton status={saveStatus} onClick={onSave} className="px-3 py-1.5" /> 96 99 <button 97 100 type="button" 98 101 onClick={onCancel}
+9 -8
src/components/admin/moderation/thresholds-tab.tsx
··· 7 7 'use client' 8 8 9 9 import { useState } from 'react' 10 + import { SaveButton } from '@/components/admin/save-button' 10 11 import type { ModerationThresholds } from '@/lib/api/types' 12 + import type { SaveStatus } from '@/hooks/use-save-state' 11 13 12 14 interface ModerationThresholdsTabProps { 13 15 thresholds: ModerationThresholds 14 16 onSave: (updated: Partial<ModerationThresholds>) => void 17 + saveStatus: SaveStatus 15 18 } 16 19 17 - export function ModerationThresholdsTab({ thresholds, onSave }: ModerationThresholdsTabProps) { 20 + export function ModerationThresholdsTab({ 21 + thresholds, 22 + onSave, 23 + saveStatus, 24 + }: ModerationThresholdsTabProps) { 18 25 const [values, setValues] = useState(thresholds) 19 26 20 27 return ( ··· 147 154 /> 148 155 </div> 149 156 </div> 150 - <button 151 - type="button" 152 - onClick={() => onSave(values)} 153 - className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 154 - > 155 - Save Thresholds 156 - </button> 157 + <SaveButton status={saveStatus} onClick={() => onSave(values)} label="Save Thresholds" /> 157 158 </div> 158 159 ) 159 160 }
+5 -10
src/components/admin/onboarding/onboarding-field-form.tsx
··· 4 4 */ 5 5 6 6 import type { OnboardingFieldType } from '@/lib/api/types' 7 + import { SaveButton } from '@/components/admin/save-button' 7 8 import { FormLabel } from '@/components/ui/form-label' 9 + import type { SaveStatus } from '@/hooks/use-save-state' 8 10 9 11 const FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 10 12 age_confirmation: 'Age Confirmation', ··· 35 37 36 38 interface OnboardingFieldFormProps { 37 39 editing: EditingField 38 - saving: boolean 40 + saveStatus: SaveStatus 39 41 error: string | null 40 42 onChange: (field: EditingField) => void 41 43 onSave: () => void ··· 44 46 45 47 export function OnboardingFieldForm({ 46 48 editing, 47 - saving, 49 + saveStatus, 48 50 error, 49 51 onChange, 50 52 onSave, ··· 151 153 </p> 152 154 )} 153 155 <div className="flex gap-2"> 154 - <button 155 - type="button" 156 - onClick={onSave} 157 - disabled={saving} 158 - className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 159 - > 160 - {saving ? 'Saving...' : 'Save'} 161 - </button> 156 + <SaveButton status={saveStatus} onClick={onSave} className="px-3 py-1.5" /> 162 157 <button 163 158 type="button" 164 159 onClick={onCancel}
+16 -14
src/components/admin/plugins/plugin-settings-modal.tsx
··· 8 8 9 9 import { useState } from 'react' 10 10 import { X } from '@phosphor-icons/react' 11 + import { SaveButton } from '@/components/admin/save-button' 11 12 import { SettingsField } from '@/components/admin/plugins/settings-field' 12 13 import type { Plugin } from '@/lib/api/types' 14 + import type { SaveStatus } from '@/hooks/use-save-state' 13 15 14 16 interface PluginSettingsModalProps { 15 17 plugin: Plugin 16 18 onClose: () => void 17 19 onSave: (settings: Record<string, boolean | string | number>) => void 20 + saveStatus: SaveStatus 18 21 } 19 22 20 - export function PluginSettingsModal({ plugin, onClose, onSave }: PluginSettingsModalProps) { 23 + export function PluginSettingsModal({ 24 + plugin, 25 + onClose, 26 + onSave, 27 + saveStatus, 28 + }: PluginSettingsModalProps) { 21 29 const [values, setValues] = useState<Record<string, boolean | string | number>>(() => ({ 22 30 ...plugin.settings, 23 31 })) 24 32 25 33 const handleChange = (key: string, value: boolean | string | number) => { 26 34 setValues((prev) => ({ ...prev, [key]: value })) 27 - } 28 - 29 - const handleSubmit = (e: React.FormEvent) => { 30 - e.preventDefault() 31 - onSave(values) 32 35 } 33 36 34 37 return ( ··· 51 54 </button> 52 55 </div> 53 56 54 - <form onSubmit={handleSubmit} className="space-y-4"> 57 + <div className="space-y-4"> 55 58 {Object.entries(plugin.settingsSchema).map(([key, schema]) => ( 56 59 <SettingsField 57 60 key={key} ··· 70 73 > 71 74 Cancel 72 75 </button> 73 - <button 74 - type="submit" 75 - className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 76 - > 77 - Save 78 - </button> 76 + <SaveButton 77 + status={saveStatus} 78 + onClick={() => onSave(values)} 79 + className="px-3 py-1.5" 80 + /> 79 81 </div> 80 - </form> 82 + </div> 81 83 </div> 82 84 </div> 83 85 )
+94
src/components/admin/save-button.test.tsx
··· 1 + /** 2 + * Tests for SaveButton component. 3 + * Renders button with state-driven text/icon for idle, saving, and saved states. 4 + */ 5 + 6 + import { describe, it, expect, vi } from 'vitest' 7 + import { render, screen } from '@testing-library/react' 8 + import userEvent from '@testing-library/user-event' 9 + import { axe } from 'vitest-axe' 10 + import { SaveButton } from './save-button' 11 + 12 + describe('SaveButton', () => { 13 + it('renders default label in idle state', () => { 14 + render(<SaveButton status="idle" onClick={vi.fn()} />) 15 + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 16 + expect(screen.getByRole('button')).toBeEnabled() 17 + }) 18 + 19 + it('renders custom label in idle state', () => { 20 + render(<SaveButton status="idle" onClick={vi.fn()} label="Save Settings" />) 21 + expect(screen.getByRole('button', { name: /save settings/i })).toBeInTheDocument() 22 + }) 23 + 24 + it('renders saving label and is disabled in saving state', () => { 25 + render(<SaveButton status="saving" onClick={vi.fn()} />) 26 + const button = screen.getByRole('button', { name: /saving/i }) 27 + expect(button).toBeDisabled() 28 + }) 29 + 30 + it('renders custom saving label', () => { 31 + render(<SaveButton status="saving" onClick={vi.fn()} savingLabel="Recomputing..." />) 32 + expect(screen.getByRole('button', { name: /recomputing/i })).toBeDisabled() 33 + }) 34 + 35 + it('renders saved label with check icon in saved state', () => { 36 + render(<SaveButton status="saved" onClick={vi.fn()} />) 37 + const button = screen.getByRole('button', { name: /saved/i }) 38 + expect(button).toBeEnabled() 39 + }) 40 + 41 + it('renders custom saved label', () => { 42 + render(<SaveButton status="saved" onClick={vi.fn()} savedLabel="Started" />) 43 + expect(screen.getByRole('button', { name: /started/i })).toBeInTheDocument() 44 + }) 45 + 46 + it('calls onClick when clicked in idle state', async () => { 47 + const user = userEvent.setup() 48 + const onClick = vi.fn() 49 + render(<SaveButton status="idle" onClick={onClick} />) 50 + await user.click(screen.getByRole('button')) 51 + expect(onClick).toHaveBeenCalledOnce() 52 + }) 53 + 54 + it('does not call onClick when disabled in saving state', async () => { 55 + const user = userEvent.setup() 56 + const onClick = vi.fn() 57 + render(<SaveButton status="saving" onClick={onClick} />) 58 + await user.click(screen.getByRole('button')) 59 + expect(onClick).not.toHaveBeenCalled() 60 + }) 61 + 62 + it('has aria-live status region for screen readers', () => { 63 + const { rerender } = render(<SaveButton status="idle" onClick={vi.fn()} />) 64 + const liveRegion = screen.getByRole('status') 65 + expect(liveRegion).toBeInTheDocument() 66 + expect(liveRegion).toHaveTextContent('') 67 + 68 + rerender(<SaveButton status="saved" onClick={vi.fn()} />) 69 + expect(liveRegion).toHaveTextContent('Saved.') 70 + }) 71 + 72 + it('announces custom saved label to screen readers', () => { 73 + render(<SaveButton status="saved" onClick={vi.fn()} savedLabel="Started" />) 74 + expect(screen.getByRole('status')).toHaveTextContent('Started.') 75 + }) 76 + 77 + it('applies custom className', () => { 78 + render(<SaveButton status="idle" onClick={vi.fn()} className="mt-4" />) 79 + const button = screen.getByRole('button') 80 + expect(button.className).toContain('mt-4') 81 + }) 82 + 83 + it('passes axe accessibility check in idle state', async () => { 84 + const { container } = render(<SaveButton status="idle" onClick={vi.fn()} />) 85 + const results = await axe(container) 86 + expect(results).toHaveNoViolations() 87 + }) 88 + 89 + it('passes axe accessibility check in saved state', async () => { 90 + const { container } = render(<SaveButton status="saved" onClick={vi.fn()} />) 91 + const results = await axe(container) 92 + expect(results).toHaveNoViolations() 93 + }) 94 + })
+52
src/components/admin/save-button.tsx
··· 1 + /** 2 + * SaveButton - Button with state-driven text/icon for save operations. 3 + * Cycles through idle, saving (disabled), and saved (with CheckCircle icon). 4 + */ 5 + 6 + import { CheckCircle } from '@phosphor-icons/react' 7 + import { cn } from '@/lib/utils' 8 + import type { SaveStatus } from '@/hooks/use-save-state' 9 + 10 + interface SaveButtonProps { 11 + status: SaveStatus 12 + onClick: () => void 13 + label?: string 14 + savingLabel?: string 15 + savedLabel?: string 16 + className?: string 17 + } 18 + 19 + export function SaveButton({ 20 + status, 21 + onClick, 22 + label = 'Save', 23 + savingLabel = 'Saving...', 24 + savedLabel = 'Saved', 25 + className, 26 + }: SaveButtonProps) { 27 + return ( 28 + <> 29 + <button 30 + type="button" 31 + onClick={onClick} 32 + disabled={status === 'saving'} 33 + className={cn( 34 + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50', 35 + className 36 + )} 37 + > 38 + {status === 'idle' && label} 39 + {status === 'saving' && savingLabel} 40 + {status === 'saved' && ( 41 + <span className="flex items-center gap-1.5"> 42 + <CheckCircle size={16} aria-hidden="true" /> 43 + {savedLabel} 44 + </span> 45 + )} 46 + </button> 47 + <span role="status" aria-live="polite" className="sr-only"> 48 + {status === 'saved' ? `${savedLabel}.` : ''} 49 + </span> 50 + </> 51 + ) 52 + }
+5 -10
src/components/admin/settings/community-settings-form.tsx
··· 6 6 'use client' 7 7 8 8 import { ErrorAlert } from '@/components/error-alert' 9 + import { SaveButton } from '@/components/admin/save-button' 9 10 import { FormLabel } from '@/components/ui/form-label' 10 11 import type { CommunitySettings, MaturityRating } from '@/lib/api/types' 12 + import type { SaveStatus } from '@/hooks/use-save-state' 11 13 12 14 interface CommunitySettingsFormProps { 13 15 settings: CommunitySettings 14 16 onChange: (updated: CommunitySettings) => void 15 17 onSave: () => void 16 - saving: boolean 18 + saveStatus: SaveStatus 17 19 saveError: string | null 18 20 onDismissError: () => void 19 21 } ··· 22 24 settings, 23 25 onChange, 24 26 onSave, 25 - saving, 27 + saveStatus, 26 28 saveError, 27 29 onDismissError, 28 30 }: CommunitySettingsFormProps) { ··· 126 128 127 129 {saveError && <ErrorAlert message={saveError} onDismiss={onDismissError} />} 128 130 129 - <button 130 - type="button" 131 - onClick={onSave} 132 - disabled={saving} 133 - className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 134 - > 135 - {saving ? 'Saving...' : 'Save Settings'} 136 - </button> 131 + <SaveButton status={saveStatus} onClick={onSave} label="Save Settings" /> 137 132 </div> 138 133 ) 139 134 }
+10 -16
src/components/admin/sybil/trust-graph-status-card.tsx
··· 4 4 */ 5 5 6 6 import { formatRelativeTime, formatNumber } from '@/lib/format' 7 - import { cn } from '@/lib/utils' 7 + import { SaveButton } from '@/components/admin/save-button' 8 + import type { SaveStatus } from '@/hooks/use-save-state' 8 9 9 10 interface TrustGraphStatusCardProps { 10 11 status: { ··· 14 15 clustersFlagged: number 15 16 } 16 17 onRecompute: () => void 17 - recomputing: boolean 18 + saveStatus: SaveStatus 18 19 } 19 20 20 21 export function TrustGraphStatusCard({ 21 22 status, 22 23 onRecompute, 23 - recomputing, 24 + saveStatus, 24 25 }: TrustGraphStatusCardProps) { 25 26 return ( 26 27 <div className="rounded-lg border border-border bg-card p-4"> ··· 38 39 <span>{status.clustersFlagged} clusters flagged</span> 39 40 </p> 40 41 </div> 41 - <button 42 - type="button" 42 + <SaveButton 43 + status={saveStatus} 43 44 onClick={onRecompute} 44 - disabled={recomputing} 45 - aria-label="Recompute now" 46 - className={cn( 47 - 'rounded-md px-4 py-2 text-sm font-medium transition-colors', 48 - recomputing 49 - ? 'cursor-not-allowed bg-muted text-muted-foreground' 50 - : 'bg-primary text-primary-foreground hover:bg-primary/90' 51 - )} 52 - > 53 - {recomputing ? 'Recomputing...' : 'Recompute Now'} 54 - </button> 45 + label="Recompute Now" 46 + savingLabel="Recomputing..." 47 + savedLabel="Started" 48 + /> 55 49 </div> 56 50 </div> 57 51 )
+1 -1
src/components/ui/toast.tsx
··· 15 15 <ToastPrimitives.Viewport 16 16 ref={ref} 17 17 className={cn( 18 - 'fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 overflow-hidden p-4 sm:max-w-[420px]', 18 + 'fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 overflow-hidden p-4 sm:max-w-[420px]', 19 19 className 20 20 )} 21 21 {...props}
+6 -6
src/hooks/admin/use-moderation-data.ts
··· 25 25 ReportResolution, 26 26 } from '@/lib/api/types' 27 27 import { useAuth } from '@/hooks/use-auth' 28 - import { useToast } from '@/hooks/use-toast' 28 + import { useSaveState } from '@/hooks/use-save-state' 29 29 30 30 export type ModerationTabId = 31 31 | 'reports' ··· 44 44 45 45 export function useModerationData() { 46 46 const { getAccessToken } = useAuth() 47 - const { toast } = useToast() 47 + const thresholdsSave = useSaveState() 48 48 const [activeTab, setActiveTab] = useState<ModerationTabId>('reports') 49 49 const [reports, setReports] = useState<ModerationReport[]>([]) 50 50 const [firstPostQueue, setFirstPostQueue] = useState<FirstPostQueueItem[]>([]) ··· 86 86 try { 87 87 await resolveReport(id, resolution, getAccessToken() ?? '') 88 88 setReports((prev) => prev.filter((r) => r.id !== id)) 89 - toast({ title: 'Report resolved' }) 90 89 } catch { 91 90 setActionError('Failed to resolve report. Please try again.') 92 91 } ··· 97 96 try { 98 97 await resolveFirstPost(id, action, getAccessToken() ?? '') 99 98 setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) 100 - toast({ title: action === 'approved' ? 'Post approved' : 'Post rejected' }) 101 99 } catch { 102 100 setActionError( 103 101 `Failed to ${action === 'approved' ? 'approve' : 'reject'} post. Please try again.` ··· 110 108 try { 111 109 await Promise.all(ids.map((id) => resolveFirstPost(id, action, getAccessToken() ?? ''))) 112 110 setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) 113 - toast({ title: action === 'approved' ? 'Posts approved' : 'Posts rejected' }) 114 111 } catch { 115 112 setActionError('Failed to process batch action. Some items may not have been updated.') 116 113 } ··· 118 115 119 116 const handleSaveThresholds = async (updated: Partial<ModerationThresholds>) => { 120 117 setActionError(null) 118 + thresholdsSave.startSaving() 121 119 try { 122 120 const result = await updateModerationThresholds(updated, getAccessToken() ?? '') 123 121 setThresholds(result) 124 - toast({ title: 'Thresholds saved' }) 122 + thresholdsSave.onSaved() 125 123 } catch { 124 + thresholdsSave.reset() 126 125 setActionError('Failed to save thresholds. Please try again.') 127 126 } 128 127 } ··· 144 143 handleResolveFirstPost, 145 144 handleBatchResolveFirstPost, 146 145 handleSaveThresholds, 146 + thresholdsSaveStatus: thresholdsSave.status, 147 147 } 148 148 }
+6 -9
src/hooks/admin/use-onboarding-fields.ts
··· 16 16 import { EMPTY_FIELD } from '@/components/admin/onboarding/onboarding-field-form' 17 17 import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' 18 18 import { useAuth } from '@/hooks/use-auth' 19 - import { useToast } from '@/hooks/use-toast' 19 + import { useSaveState } from '@/hooks/use-save-state' 20 20 21 21 export function useOnboardingFields() { 22 22 const { getAccessToken } = useAuth() 23 - const { toast } = useToast() 23 + const saveMachine = useSaveState() 24 24 const [fields, setFields] = useState<OnboardingField[]>([]) 25 25 const [hostingMode, setHostingMode] = useState<HostingMode>('selfhosted') 26 26 const [loading, setLoading] = useState(true) 27 27 const [editing, setEditing] = useState<EditingField | null>(null) 28 - const [saving, setSaving] = useState(false) 29 28 const [error, setError] = useState<string | null>(null) 30 29 const [loadError, setLoadError] = useState<string | null>(null) 31 30 const [actionError, setActionError] = useState<string | null>(null) ··· 69 68 try { 70 69 await deleteOnboardingField(id, getAccessToken() ?? '') 71 70 void fetchFields() 72 - toast({ title: 'Field deleted' }) 73 71 } catch { 74 72 setActionError('Failed to delete field. Please try again.') 75 73 } ··· 82 80 return 83 81 } 84 82 85 - setSaving(true) 83 + saveMachine.startSaving() 86 84 setError(null) 87 85 try { 88 86 if (editing.id) { ··· 109 107 } 110 108 setEditing(null) 111 109 void fetchFields() 112 - toast({ title: editing.id ? 'Field updated' : 'Field created' }) 110 + saveMachine.reset() 113 111 } catch { 112 + saveMachine.reset() 114 113 setError('Failed to save field') 115 - } finally { 116 - setSaving(false) 117 114 } 118 115 } 119 116 ··· 149 146 loading, 150 147 editing, 151 148 setEditing, 152 - saving, 149 + saveStatus: saveMachine.status, 153 150 error, 154 151 loadError, 155 152 actionError,
+6 -6
src/hooks/admin/use-plugin-management.ts
··· 9 9 import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' 10 10 import type { Plugin } from '@/lib/api/types' 11 11 import { useAuth } from '@/hooks/use-auth' 12 - import { useToast } from '@/hooks/use-toast' 12 + import { useSaveState } from '@/hooks/use-save-state' 13 13 14 14 interface DependencyWarning { 15 15 plugin: Plugin ··· 18 18 19 19 export function usePluginManagement() { 20 20 const { getAccessToken } = useAuth() 21 - const { toast } = useToast() 21 + const settingsSave = useSaveState() 22 22 const [plugins, setPlugins] = useState<Plugin[]>([]) 23 23 const [loading, setLoading] = useState(true) 24 24 const [settingsPlugin, setSettingsPlugin] = useState<Plugin | null>(null) ··· 62 62 setPlugins((prev) => 63 63 prev.map((p) => (p.id === plugin.id ? { ...p, enabled: !p.enabled } : p)) 64 64 ) 65 - toast({ title: plugin.enabled ? 'Plugin disabled' : 'Plugin enabled' }) 66 65 } catch { 67 66 setActionError(`Failed to ${plugin.enabled ? 'disable' : 'enable'} plugin. Please try again.`) 68 67 } ··· 76 75 setPlugins((prev) => 77 76 prev.map((p) => (p.id === dependencyWarning.plugin.id ? { ...p, enabled: false } : p)) 78 77 ) 79 - toast({ title: 'Plugin disabled' }) 80 78 } catch { 81 79 setActionError('Failed to disable plugin. Please try again.') 82 80 } ··· 86 84 const handleSaveSettings = async (settings: Record<string, boolean | string | number>) => { 87 85 if (!settingsPlugin) return 88 86 setActionError(null) 87 + settingsSave.startSaving() 89 88 try { 90 89 await updatePluginSettings(settingsPlugin.id, settings, getAccessToken() ?? '') 91 90 setPlugins((prev) => prev.map((p) => (p.id === settingsPlugin.id ? { ...p, settings } : p))) 92 - toast({ title: 'Plugin settings saved' }) 91 + settingsSave.reset() 93 92 } catch { 93 + settingsSave.reset() 94 94 setActionError('Failed to save plugin settings. Please try again.') 95 95 } 96 96 setSettingsPlugin(null) ··· 101 101 try { 102 102 await uninstallPlugin(plugin.id, getAccessToken() ?? '') 103 103 setPlugins((prev) => prev.filter((p) => p.id !== plugin.id)) 104 - toast({ title: 'Plugin uninstalled' }) 105 104 } catch { 106 105 setActionError('Failed to uninstall plugin. Please try again.') 107 106 } ··· 122 121 confirmDisable, 123 122 handleSaveSettings, 124 123 handleUninstall, 124 + settingsSaveStatus: settingsSave.status, 125 125 } 126 126 }
+7 -10
src/hooks/admin/use-sybil-data.ts
··· 23 23 BehavioralFlag, 24 24 } from '@/lib/api/types' 25 25 import { useAuth } from '@/hooks/use-auth' 26 - import { useToast } from '@/hooks/use-toast' 26 + import { useSaveState } from '@/hooks/use-save-state' 27 27 28 28 export function useSybilData() { 29 29 const { getAccessToken } = useAuth() 30 - const { toast } = useToast() 30 + const recomputeSave = useSaveState() 31 31 const [clusters, setClusters] = useState<SybilCluster[]>([]) 32 32 const [graphStatus, setGraphStatus] = useState<TrustGraphStatus | null>(null) 33 33 const [flags, setFlags] = useState<BehavioralFlag[]>([]) ··· 36 36 const [loading, setLoading] = useState(true) 37 37 const [loadError, setLoadError] = useState<string | null>(null) 38 38 const [actionError, setActionError] = useState<string | null>(null) 39 - const [recomputing, setRecomputing] = useState(false) 40 39 const [confirmAction, setConfirmAction] = useState<{ 41 40 title: string 42 41 message: string ··· 96 95 ) 97 96 setClusters((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) 98 97 setSelectedDetail({ ...selectedDetail, ...updated }) 99 - toast({ title: 'Cluster status updated' }) 98 + // Visual change (cluster status badge update) is the feedback 100 99 } catch { 101 100 setActionError('Failed to update cluster status.') 102 101 } ··· 105 104 } 106 105 107 106 const handleRecompute = async () => { 108 - setRecomputing(true) 107 + recomputeSave.startSaving() 109 108 try { 110 109 await recomputeTrustGraph(getAccessToken() ?? '') 111 - toast({ title: 'Trust graph recomputation started' }) 110 + recomputeSave.onSaved() 112 111 } catch { 112 + recomputeSave.reset() 113 113 setActionError('Failed to start recomputation.') 114 - } finally { 115 - setRecomputing(false) 116 114 } 117 115 } 118 116 ··· 121 119 try { 122 120 const updated = await updateBehavioralFlag(id, 'dismissed', getAccessToken() ?? '') 123 121 setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) 124 - toast({ title: 'Flag dismissed' }) 125 122 } catch { 126 123 setActionError('Failed to dismiss flag.') 127 124 } ··· 139 136 loadError, 140 137 actionError, 141 138 setActionError, 142 - recomputing, 139 + recomputeStatus: recomputeSave.status, 143 140 confirmAction, 144 141 setConfirmAction, 145 142 fetchData,
+136
src/hooks/use-save-state.test.ts
··· 1 + /** 2 + * Tests for useSaveState hook. 3 + * State machine: idle -> saving -> saved -> idle (with 2s auto-reset). 4 + */ 5 + 6 + import { describe, it, expect, vi, afterEach } from 'vitest' 7 + import { renderHook, act } from '@testing-library/react' 8 + import { useSaveState } from './use-save-state' 9 + 10 + describe('useSaveState', () => { 11 + afterEach(() => { 12 + vi.restoreAllMocks() 13 + }) 14 + 15 + it('starts in idle status', () => { 16 + const { result } = renderHook(() => useSaveState()) 17 + expect(result.current.status).toBe('idle') 18 + }) 19 + 20 + it('transitions to saving when startSaving is called', () => { 21 + const { result } = renderHook(() => useSaveState()) 22 + act(() => { 23 + result.current.startSaving() 24 + }) 25 + expect(result.current.status).toBe('saving') 26 + }) 27 + 28 + it('transitions to saved when onSaved is called', () => { 29 + const { result } = renderHook(() => useSaveState()) 30 + act(() => { 31 + result.current.startSaving() 32 + }) 33 + act(() => { 34 + result.current.onSaved() 35 + }) 36 + expect(result.current.status).toBe('saved') 37 + }) 38 + 39 + it('auto-resets to idle after 2 seconds', () => { 40 + vi.useFakeTimers() 41 + const { result } = renderHook(() => useSaveState()) 42 + act(() => { 43 + result.current.startSaving() 44 + }) 45 + act(() => { 46 + result.current.onSaved() 47 + }) 48 + expect(result.current.status).toBe('saved') 49 + 50 + act(() => { 51 + vi.advanceTimersByTime(2000) 52 + }) 53 + expect(result.current.status).toBe('idle') 54 + vi.useRealTimers() 55 + }) 56 + 57 + it('does not reset before 2 seconds', () => { 58 + vi.useFakeTimers() 59 + const { result } = renderHook(() => useSaveState()) 60 + act(() => { 61 + result.current.startSaving() 62 + }) 63 + act(() => { 64 + result.current.onSaved() 65 + }) 66 + 67 + act(() => { 68 + vi.advanceTimersByTime(1999) 69 + }) 70 + expect(result.current.status).toBe('saved') 71 + vi.useRealTimers() 72 + }) 73 + 74 + it('resets to idle immediately when reset is called', () => { 75 + const { result } = renderHook(() => useSaveState()) 76 + act(() => { 77 + result.current.startSaving() 78 + }) 79 + act(() => { 80 + result.current.reset() 81 + }) 82 + expect(result.current.status).toBe('idle') 83 + }) 84 + 85 + it('clears timer on unmount', () => { 86 + vi.useFakeTimers() 87 + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') 88 + const { result, unmount } = renderHook(() => useSaveState()) 89 + act(() => { 90 + result.current.startSaving() 91 + }) 92 + act(() => { 93 + result.current.onSaved() 94 + }) 95 + unmount() 96 + expect(clearTimeoutSpy).toHaveBeenCalled() 97 + vi.useRealTimers() 98 + }) 99 + 100 + it('clears previous timer when onSaved is called again', () => { 101 + vi.useFakeTimers() 102 + const { result } = renderHook(() => useSaveState()) 103 + 104 + // First save cycle 105 + act(() => { 106 + result.current.startSaving() 107 + }) 108 + act(() => { 109 + result.current.onSaved() 110 + }) 111 + 112 + // Advance 1s, then start another cycle 113 + act(() => { 114 + vi.advanceTimersByTime(1000) 115 + }) 116 + act(() => { 117 + result.current.startSaving() 118 + }) 119 + act(() => { 120 + result.current.onSaved() 121 + }) 122 + 123 + // After 1.5s from second onSaved, should still be saved 124 + act(() => { 125 + vi.advanceTimersByTime(1500) 126 + }) 127 + expect(result.current.status).toBe('saved') 128 + 129 + // After full 2s from second onSaved, should reset 130 + act(() => { 131 + vi.advanceTimersByTime(500) 132 + }) 133 + expect(result.current.status).toBe('idle') 134 + vi.useRealTimers() 135 + }) 136 + })
+47
src/hooks/use-save-state.ts
··· 1 + /** 2 + * State machine hook for save button feedback. 3 + * Cycles: idle -> saving -> saved -> idle (2s auto-reset). 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useCallback, useEffect, useRef } from 'react' 9 + 10 + export type SaveStatus = 'idle' | 'saving' | 'saved' 11 + 12 + export function useSaveState() { 13 + const [status, setStatus] = useState<SaveStatus>('idle') 14 + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 15 + 16 + const clearTimer = useCallback(() => { 17 + if (timerRef.current !== null) { 18 + clearTimeout(timerRef.current) 19 + timerRef.current = null 20 + } 21 + }, []) 22 + 23 + useEffect(() => { 24 + return clearTimer 25 + }, [clearTimer]) 26 + 27 + const startSaving = useCallback(() => { 28 + clearTimer() 29 + setStatus('saving') 30 + }, [clearTimer]) 31 + 32 + const onSaved = useCallback(() => { 33 + clearTimer() 34 + setStatus('saved') 35 + timerRef.current = setTimeout(() => { 36 + setStatus('idle') 37 + timerRef.current = null 38 + }, 2000) 39 + }, [clearTimer]) 40 + 41 + const reset = useCallback(() => { 42 + clearTimer() 43 + setStatus('idle') 44 + }, [clearTimer]) 45 + 46 + return { status, startSaving, onSaved, reset } 47 + }