fix(ui): error-handle fire-and-forget mutations in settings panels
This commit is contained in:
@@ -285,10 +285,18 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
<Select
|
<Select
|
||||||
value={defaultProfile?._id ?? ''}
|
value={defaultProfile?._id ?? ''}
|
||||||
onValueChange={async (value) => {
|
onValueChange={async (value) => {
|
||||||
await setDefaultProfile({
|
try {
|
||||||
profileId: value as Id<'aiProviderProfiles'>,
|
await setDefaultProfile({
|
||||||
});
|
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 () => {
|
||||||
await removeProfile({ profileId: profile._id });
|
try {
|
||||||
toast.success('AI provider removed.');
|
await removeProfile({ profileId: profile._id });
|
||||||
|
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,13 +160,17 @@ export const DotfilesManager = () => {
|
|||||||
|
|
||||||
const saveSelected = async (next: string) => {
|
const saveSelected = async (next: string) => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
await putFile({
|
try {
|
||||||
path: selected.path,
|
await putFile({
|
||||||
content: next,
|
path: selected.path,
|
||||||
isExecutable: selected.isExecutable,
|
content: next,
|
||||||
});
|
isExecutable: selected.isExecutable,
|
||||||
setSavedContent(next);
|
});
|
||||||
toast.success('Saved.');
|
setSavedContent(next);
|
||||||
|
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;
|
||||||
await removeFile({ fileId: selected._id as never });
|
try {
|
||||||
setSelected(undefined);
|
await removeFile({ fileId: selected._id as never });
|
||||||
setContent('');
|
setSelected(undefined);
|
||||||
setSavedContent('');
|
setContent('');
|
||||||
toast.success('Deleted.');
|
setSavedContent('');
|
||||||
|
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) => {
|
||||||
await updateEnv(values);
|
try {
|
||||||
toast.success('Saved.');
|
await updateEnv(values);
|
||||||
|
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 () => {
|
||||||
await removeRemote({ remoteId: remote._id });
|
try {
|
||||||
toast.success('Remote removed.');
|
await removeRemote({ remoteId: remote._id });
|
||||||
|
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;
|
||||||
for (const secret of parsedBulk.secrets) {
|
const failed: string[] = [];
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
setBulkText('');
|
|
||||||
setBulkDialogOpen(false);
|
|
||||||
toast.success(`${parsedBulk.secrets.length} secrets imported.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Could not import secrets.');
|
|
||||||
} finally {
|
|
||||||
setBulkSaving(false);
|
|
||||||
}
|
}
|
||||||
|
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('');
|
||||||
|
setBulkDialogOpen(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 () => {
|
||||||
await removeSecret({ secretId: secret._id });
|
try {
|
||||||
toast.success('Secret removed.');
|
await removeSecret({ secretId: secret._id });
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user