refactor(webui): share the API-error → message mapping across account forms

The three account forms duplicated the getApiErrorCode + ERROR_BY_CODE lookup pattern
verbatim, and each carried a dead `AuthRequired` entry that the axios interceptor
already handles (hard redirect) before the form's catch can render it.

- add resolveApiErrorMessage(err, map, fallback) next to the axios error helpers
- email/name/password forms use it and drop the unreachable AuthRequired entry
- unit-test the helper's precedence (mapped code > server msg > fallback)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-06-14 11:40:39 +07:00
co-authored by Claude Fable 5
parent 789caf0dfe
commit 9b9a74bec0
8 changed files with 43 additions and 15 deletions
@@ -8,7 +8,7 @@ const { patchUser, put, refreshAuthInfo } = vi.hoisted(() => ({
refreshAuthInfo: vi.fn().mockResolvedValue(undefined),
}));
// Keep the pure helpers (`getApiErrorCode`, `getApiErrorMessage`) real — only the network call is stubbed.
// Keep the real error-mapping helper (`resolveApiErrorMessage`) — only the network call is stubbed.
vi.mock('@/lib/axios', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/axios')>();
@@ -9,7 +9,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { FormSubmitButton } from '@/components/ui/form-submit-button';
import { Input } from '@/components/ui/input';
import { InputPassword } from '@/components/ui/input-password';
import { api, getApiErrorCode, getApiErrorMessage } from '@/lib/axios';
import { api, resolveApiErrorMessage } from '@/lib/axios';
import { useUser } from '@/providers/user-provider';
const emailChangeSchema = z.object({
@@ -24,7 +24,6 @@ const emailChangeSchema = z.object({
});
const ERROR_BY_CODE: Record<string, string> = {
AuthRequired: 'Authentication required',
'Users.ChangeEmailCurrentUser.EmailAlreadyExists': 'Email address is already in use',
'Users.ChangeEmailCurrentUser.InvalidCurrentPassword': 'Current password is incorrect',
'Users.ChangeEmailCurrentUser.InvalidEmail': 'New email does not meet requirements',
@@ -68,8 +67,7 @@ export function EmailChangeForm({ isModal = true, onCancel, onSuccess }: EmailCh
onSuccess?.();
} catch (err: unknown) {
const code = getApiErrorCode(err);
setError((code && ERROR_BY_CODE[code]) ?? getApiErrorMessage(err, 'Failed to update email'));
setError(resolveApiErrorMessage(err, ERROR_BY_CODE, 'Failed to update email'));
}
};
@@ -8,7 +8,7 @@ const { patchUser, put, refreshAuthInfo } = vi.hoisted(() => ({
refreshAuthInfo: vi.fn().mockResolvedValue(undefined),
}));
// Keep the pure helpers (`getApiErrorCode`, `getApiErrorMessage`) real — only the network call is stubbed.
// Keep the real error-mapping helper (`resolveApiErrorMessage`) — only the network call is stubbed.
vi.mock('@/lib/axios', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/axios')>();
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { FormSubmitButton } from '@/components/ui/form-submit-button';
import { Input } from '@/components/ui/input';
import { api, getApiErrorCode, getApiErrorMessage } from '@/lib/axios';
import { api, resolveApiErrorMessage } from '@/lib/axios';
import { useUser } from '@/providers/user-provider';
const nameChangeSchema = z.object({
@@ -20,7 +20,6 @@ const nameChangeSchema = z.object({
});
const ERROR_BY_CODE: Record<string, string> = {
AuthRequired: 'Authentication required',
'Users.ChangeNameCurrentUser.InvalidName': 'New name does not meet requirements',
'Users.NotFound': 'User not found',
};
@@ -57,8 +56,7 @@ export function NameChangeForm({ isModal = true, onCancel, onSuccess }: NameChan
onSuccess?.();
} catch (err: unknown) {
const code = getApiErrorCode(err);
setError((code && ERROR_BY_CODE[code]) ?? getApiErrorMessage(err, 'Failed to update name'));
setError(resolveApiErrorMessage(err, ERROR_BY_CODE, 'Failed to update name'));
}
};
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const { put } = vi.hoisted(() => ({ put: vi.fn() }));
// Keep the pure helpers (`getApiErrorCode`, `getApiErrorMessage`) real — only the network call is stubbed.
// Keep the real error-mapping helper (`resolveApiErrorMessage`) — only the network call is stubbed.
vi.mock('@/lib/axios', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/axios')>();
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { FormSubmitButton } from '@/components/ui/form-submit-button';
import { InputPassword } from '@/components/ui/input-password';
import { api, getApiErrorCode, getApiErrorMessage } from '@/lib/axios';
import { api, resolveApiErrorMessage } from '@/lib/axios';
const passwordChangeSchema = z
.object({
@@ -48,7 +48,6 @@ const passwordChangeSchema = z
});
const ERROR_BY_CODE: Record<string, string> = {
AuthRequired: 'Authentication required',
'Users.ChangePasswordCurrentUser.InvalidCurrentPassword': 'Current password is incorrect',
'Users.ChangePasswordCurrentUser.InvalidNewPassword': 'New password does not meet requirements',
'Users.ChangePasswordCurrentUser.InvalidPassword': 'Password validation failed',
@@ -98,8 +97,7 @@ export function PasswordChangeForm({
onSuccess?.();
} catch (err: unknown) {
const code = getApiErrorCode(err);
setError((code && ERROR_BY_CODE[code]) ?? getApiErrorMessage(err, 'Failed to change password'));
setError(resolveApiErrorMessage(err, ERROR_BY_CODE, 'Failed to change password'));
}
};
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { resolveApiErrorMessage } from './axios';
const apiError = (code: string, msg: string) => ({ response: { data: { code, msg, status: 'error' } } });
const messages = { 'Users.NotFound': 'User not found' };
describe('resolveApiErrorMessage', () => {
it('prefers a mapped code over the raw server message', () => {
expect(resolveApiErrorMessage(apiError('Users.NotFound', 'user not found'), messages, 'fallback')).toBe(
'User not found',
);
});
it('falls back to the server message for an unmapped code', () => {
expect(resolveApiErrorMessage(apiError('Some.Other.Code', 'raw server message'), messages, 'fallback')).toBe(
'raw server message',
);
});
it('uses the fallback when there is neither a mapped code nor a server message', () => {
expect(resolveApiErrorMessage({}, messages, 'fallback')).toBe('fallback');
});
});
+10
View File
@@ -255,5 +255,15 @@ export const getApiErrorCode = (error: unknown): string | undefined => {
return responseData && typeof responseData === 'object' ? (responseData as ApiErrorResponse).code : undefined;
};
export const resolveApiErrorMessage = (
error: unknown,
messagesByCode: Record<string, string>,
fallback: string,
): string => {
const code = getApiErrorCode(error);
return (code && messagesByCode[code]) ?? getApiErrorMessage(error, fallback);
};
export default axios;
export { axios };