fix(ui): error-handle fire-and-forget mutations in settings panels

This commit is contained in:
Gabriel Brown
2026-07-11 16:59:16 -04:00
parent 8c579d060a
commit bca86196fb
5 changed files with 185 additions and 34 deletions
@@ -285,10 +285,18 @@ export const AiProviderProfilesPanel = () => {
<Select <Select
value={defaultProfile?._id ?? ''} value={defaultProfile?._id ?? ''}
onValueChange={async (value) => { onValueChange={async (value) => {
try {
await setDefaultProfile({ await setDefaultProfile({
profileId: value as Id<'aiProviderProfiles'>, profileId: value as Id<'aiProviderProfiles'>,
}); });
toast.success('Default AI provider updated.'); toast.success('Default AI provider updated.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not update default AI provider.',
);
}
}} }}
> >
<SelectTrigger> <SelectTrigger>
@@ -332,8 +340,16 @@ export const AiProviderProfilesPanel = () => {
size='icon' size='icon'
aria-label='Remove provider' aria-label='Remove provider'
onClick={async () => { onClick={async () => {
try {
await removeProfile({ profileId: profile._id }); await removeProfile({ profileId: profile._id });
toast.success('AI provider removed.'); toast.success('AI provider removed.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not remove AI provider.',
);
}
}} }}
> >
<Trash2 className='size-4' /> <Trash2 className='size-4' />
@@ -160,6 +160,7 @@ export const DotfilesManager = () => {
const saveSelected = async (next: string) => { const saveSelected = async (next: string) => {
if (!selected) return; if (!selected) return;
try {
await putFile({ await putFile({
path: selected.path, path: selected.path,
content: next, content: next,
@@ -167,6 +168,9 @@ export const DotfilesManager = () => {
}); });
setSavedContent(next); setSavedContent(next);
toast.success('Saved.'); toast.success('Saved.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Save failed.');
}
}; };
const importAll = async (incoming: UploadFile[]) => { const importAll = async (incoming: UploadFile[]) => {
@@ -231,11 +235,15 @@ export const DotfilesManager = () => {
const deleteSelected = async () => { const deleteSelected = async () => {
if (!selected) return; if (!selected) return;
try {
await removeFile({ fileId: selected._id as never }); await removeFile({ fileId: selected._id as never });
setSelected(undefined); setSelected(undefined);
setContent(''); setContent('');
setSavedContent(''); setSavedContent('');
toast.success('Deleted.'); toast.success('Deleted.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Delete failed.');
}
}; };
return ( return (
@@ -345,8 +353,12 @@ export const DotfilesManager = () => {
<RepoPanel <RepoPanel
settings={settings} settings={settings}
onSave={async (values) => { onSave={async (values) => {
try {
await updateEnv(values); await updateEnv(values);
toast.success('Saved.'); toast.success('Saved.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Save failed.');
}
}} }}
/> />
@@ -223,8 +223,16 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
remoteName={remote.remoteName} remoteName={remote.remoteName}
url={remote.url} url={remote.url}
onRemove={async () => { onRemove={async () => {
try {
await removeRemote({ remoteId: remote._id }); await removeRemote({ remoteId: remote._id });
toast.success('Remote removed.'); toast.success('Remote removed.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not remove remote.',
);
}
}} }}
/> />
))} ))}
@@ -130,24 +130,35 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
return; return;
} }
setBulkSaving(true); setBulkSaving(true);
try { let imported = 0;
const failed: string[] = [];
for (const secret of parsedBulk.secrets) { for (const secret of parsedBulk.secrets) {
try {
await createSecret({ await createSecret({
spoonId, spoonId,
name: secret.name, name: secret.name,
value: secret.value, value: secret.value,
description: 'Imported from .env', description: 'Imported from .env',
}); });
imported += 1;
} catch (error) {
console.error(error);
failed.push(secret.name);
}
}
setBulkSaving(false);
if (imported > 0) {
toast.success(`${imported} secret${imported === 1 ? '' : 's'} imported.`);
}
if (failed.length) {
toast.error(
`Could not import ${failed.length} secret${failed.length === 1 ? '' : 's'}: ${failed.join(', ')}`,
);
return;
} }
setBulkText(''); setBulkText('');
setBulkDialogOpen(false); setBulkDialogOpen(false);
toast.success(`${parsedBulk.secrets.length} secrets imported.`);
} catch (error) {
console.error(error);
toast.error('Could not import secrets.');
} finally {
setBulkSaving(false);
}
}; };
const readEnvFile = async (file?: File) => { const readEnvFile = async (file?: File) => {
@@ -310,8 +321,16 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
size='icon' size='icon'
aria-label={`Remove ${secret.name}`} aria-label={`Remove ${secret.name}`}
onClick={async () => { onClick={async () => {
try {
await removeSecret({ secretId: secret._id }); await removeSecret({ secretId: secret._id });
toast.success('Secret removed.'); toast.success('Secret removed.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not remove secret.',
);
}
}} }}
> >
<Trash2 className='size-4' /> <Trash2 className='size-4' />
@@ -0,0 +1,96 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { getFunctionName } from 'convex/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { toast } from 'sonner';
import { AiProviderProfilesPanel } from '../../src/components/integrations/ai-provider-profiles-panel';
// Radix Select (rendered by the panel form) relies on ResizeObserver, which
// jsdom does not implement.
class ResizeObserverStub {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
globalThis.ResizeObserver ??= ResizeObserverStub as never;
const { mockUseAction, mockUseMutation, mockUseQuery } = vi.hoisted(() => ({
mockUseAction: vi.fn(),
mockUseMutation: vi.fn(),
mockUseQuery: vi.fn(),
}));
vi.mock('convex/react', () => ({
useAction: mockUseAction,
useMutation: mockUseMutation,
useQuery: mockUseQuery,
}));
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}));
const profile = {
_id: 'profile-1',
name: 'OpenAI',
provider: 'openai',
authType: 'api_key',
secretPreview: 'sk-***',
defaultModel: 'gpt-4o',
reasoningEffort: 'medium',
isDefault: true,
enabled: true,
configured: true,
baseUrl: undefined,
modelOptions: ['gpt-4o'],
};
const removeName = getFunctionName(api.aiProviderProfiles.remove);
const setup = (removeSpy: ReturnType<typeof vi.fn>) => {
mockUseAction.mockReturnValue(vi.fn());
mockUseQuery.mockReturnValue([profile]);
mockUseMutation.mockImplementation((ref: Parameters<typeof getFunctionName>[0]) => {
if (getFunctionName(ref) === removeName) return removeSpy;
return vi.fn();
});
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('AiProviderProfilesPanel remove handler', () => {
it('shows an error toast and no success toast when the remove mutation rejects', async () => {
const removeSpy = vi.fn().mockRejectedValue(new Error('boom'));
setup(removeSpy);
render(<AiProviderProfilesPanel />);
fireEvent.click(screen.getByRole('button', { name: /remove provider/i }));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(removeSpy).toHaveBeenCalledWith({ profileId: 'profile-1' });
expect(toast.error).toHaveBeenCalledWith('boom');
expect(toast.success).not.toHaveBeenCalled();
});
it('shows a success toast only after the remove mutation resolves', async () => {
const removeSpy = vi.fn().mockResolvedValue(undefined);
setup(removeSpy);
render(<AiProviderProfilesPanel />);
fireEvent.click(screen.getByRole('button', { name: /remove provider/i }));
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith('AI provider removed.'),
);
expect(toast.error).not.toHaveBeenCalled();
});
});