feat(notifications): settings notification preferences

This commit is contained in:
Gabriel Brown
2026-07-11 16:41:35 -04:00
parent c241941e41
commit 8d7486bc8c
6 changed files with 463 additions and 1 deletions
+10 -1
View File
@@ -3,7 +3,15 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
import {
Bell,
Brain,
FileCog,
Github,
ServerCog,
Shield,
User,
} from 'lucide-react';
import { cn } from '@spoon/ui';
@@ -12,6 +20,7 @@ const settingsItems = [
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
{ href: '/settings/notifications', label: 'Notifications', icon: Bell },
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
@@ -0,0 +1,19 @@
import { NotificationPreferencesPanel } from '@/components/settings/notification-preferences-panel';
const SettingsNotificationsPage = () => {
return (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Notifications</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Choose which notifications also reach you by email. In-app
notifications always show in the bell; these toggles only control the
email copies.
</p>
</div>
<NotificationPreferencesPanel />
</section>
);
};
export default SettingsNotificationsPage;
@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, Label, Switch } from '@spoon/ui';
type PreferenceField =
| 'emailEnabled'
| 'emailMaintenance'
| 'emailAgentTurnFinished'
| 'emailAgentNeedsInput'
| 'emailSyncFailed'
| 'emailConnectionReauth';
const perKindToggles: {
field: Exclude<PreferenceField, 'emailEnabled'>;
label: string;
description: string;
}[] = [
{
field: 'emailMaintenance',
label: 'Maintenance threads',
description: 'A maintenance review thread opens for one of your Spoons.',
},
{
field: 'emailAgentTurnFinished',
label: 'Agent finished a turn',
description: 'An agent finishes a turn and changes are ready to review.',
},
{
field: 'emailAgentNeedsInput',
label: 'Agent needs your input',
description: 'An agent pauses and needs your input to continue.',
},
{
field: 'emailSyncFailed',
label: 'A sync fails',
description: 'A scheduled sync with upstream fails for one of your Spoons.',
},
{
field: 'emailConnectionReauth',
label: 'A connection needs re-authentication',
description: 'A git connection is revoked and needs to be reconnected.',
},
];
export const NotificationPreferencesPanel = () => {
// Render directly from the reactive query: after a save resolves, the query
// re-emits the stored value, so there is no local copy to fall out of sync.
const prefs = useQuery(api.notifications.getPreferences);
const updatePreferences = useMutation(api.notifications.updatePreferences);
const [saving, setSaving] = useState(false);
const onToggle = async (field: PreferenceField, next: boolean) => {
setSaving(true);
try {
await updatePreferences({ [field]: next });
toast.success('Preferences saved.');
} catch {
toast.error('Could not save preferences.');
} finally {
setSaving(false);
}
};
if (prefs === undefined) {
return (
<Card className='p-4 shadow-none'>
<p className='text-muted-foreground text-sm'>Loading preferences</p>
</Card>
);
}
const perKindDisabled = saving || !prefs.emailEnabled;
return (
<Card className='divide-border divide-y p-0 shadow-none'>
<div className='flex items-center justify-between gap-4 p-4'>
<div className='space-y-0.5'>
<Label className='text-sm font-medium'>Email notifications</Label>
<p className='text-muted-foreground text-sm'>
Send email for your notifications. Turn this off to silence every
kind below.
</p>
</div>
<Switch
aria-label='Email notifications'
checked={prefs.emailEnabled}
disabled={saving}
onCheckedChange={(next) => void onToggle('emailEnabled', next)}
/>
</div>
{perKindToggles.map(({ field, label, description }) => (
<div
key={field}
className='flex items-center justify-between gap-4 p-4'
>
<div className='space-y-0.5'>
<Label className='text-sm font-medium'>{label}</Label>
<p className='text-muted-foreground text-sm'>{description}</p>
</div>
<Switch
aria-label={label}
checked={prefs.emailEnabled && prefs[field]}
disabled={perKindDisabled}
onCheckedChange={(next) => void onToggle(field, next)}
/>
</div>
))}
</Card>
);
};
@@ -0,0 +1,117 @@
import type { ComponentProps, ReactNode } from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { NotificationPreferencesPanel } from '../../src/components/settings/notification-preferences-panel';
// Stable sentinels: the real Convex `api` proxy hands back a fresh reference on
// every access, so `===` comparisons in the mocks below would never match.
vi.mock('@spoon/backend/convex/_generated/api.js', () => ({
api: {
notifications: {
getPreferences: 'notifications:getPreferences',
updatePreferences: 'notifications:updatePreferences',
},
},
}));
const { mockUpdatePreferences, mockUseMutation, mockUseQuery } = vi.hoisted(
() => ({
mockUpdatePreferences: vi.fn(),
mockUseMutation: vi.fn(),
mockUseQuery: vi.fn(),
}),
);
vi.mock('convex/react', () => ({
useMutation: mockUseMutation,
useQuery: mockUseQuery,
}));
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// Pass-through UI primitives so the switches are deterministically present and
// clickable under jsdom (Radix pointer behavior is flaky here). We exercise the
// panel's logic, not the design-system components.
vi.mock('@spoon/ui', () => ({
Card: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Label: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
Switch: ({
checked,
onCheckedChange,
disabled,
...props
}: {
checked?: boolean;
onCheckedChange?: (value: boolean) => void;
disabled?: boolean;
} & ComponentProps<'button'>) => (
<button
role='switch'
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
{...props}
/>
),
}));
const prefs = {
emailEnabled: true,
emailMaintenance: true,
emailAgentTurnFinished: true,
emailAgentNeedsInput: false,
emailSyncFailed: true,
emailConnectionReauth: true,
};
const seed = () => {
mockUpdatePreferences.mockResolvedValue(undefined);
mockUseMutation.mockReturnValue(mockUpdatePreferences);
mockUseQuery.mockImplementation((reference: unknown) =>
reference === api.notifications.getPreferences ? prefs : undefined,
);
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('NotificationPreferencesPanel', () => {
it('renders toggle state from the query values', () => {
seed();
render(<NotificationPreferencesPanel />);
const needsInput = screen.getByRole('switch', {
name: /needs your input/i,
});
expect(needsInput).toHaveAttribute('aria-checked', 'false');
const master = screen.getByRole('switch', { name: /email notifications/i });
expect(master).toHaveAttribute('aria-checked', 'true');
});
it('calls updatePreferences with the flipped field when a toggle changes', () => {
seed();
render(<NotificationPreferencesPanel />);
fireEvent.click(
screen.getByRole('switch', { name: /sync fails/i }),
);
expect(mockUpdatePreferences).toHaveBeenCalledWith({
emailSyncFailed: false,
});
});
it('does not render toggles until the query resolves', () => {
mockUseMutation.mockReturnValue(mockUpdatePreferences);
mockUseQuery.mockReturnValue(undefined);
render(<NotificationPreferencesPanel />);
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
});
});