refactor(forms): extract reusable form utilities and components
- Add getFirstValidationError utility for nested FieldErrors extraction - Add mergeWithDefaults utilities (deepMergeWithDefaults, type guards) - Add useValidationErrorHandler hook for toast + tab navigation - Add FormSelect component with Controller integration - Add FormTextarea component with register integration - Refactor AgentTypeForm to use new utilities - Remove verbose debug logging (now handled by hook) - Add comprehensive tests (53 new tests, 100 total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Tests for useValidationErrorHandler hook
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { toast } from 'sonner';
|
||||
import { useValidationErrorHandler } from '@/lib/forms/hooks/useValidationErrorHandler';
|
||||
import type { FieldErrors } from 'react-hook-form';
|
||||
|
||||
// Mock sonner toast
|
||||
jest.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock console.error to track debug logging
|
||||
const originalConsoleError = console.error;
|
||||
let consoleErrorMock: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorMock.mockRestore();
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
describe('useValidationErrorHandler', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('shows toast with first error message', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Please fix form errors', {
|
||||
description: 'name: Name is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom toast title when provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidationErrorHandler({
|
||||
toastTitle: 'Validation Failed',
|
||||
debug: false,
|
||||
})
|
||||
);
|
||||
|
||||
const errors: FieldErrors = {
|
||||
email: { message: 'Invalid email', type: 'pattern' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Validation Failed', {
|
||||
description: 'email: Invalid email',
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when no errors', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
result.current.onValidationError({});
|
||||
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested errors', () => {
|
||||
it('handles nested field errors', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
const errors: FieldErrors = {
|
||||
model_params: {
|
||||
temperature: { message: 'Temperature must be between 0 and 2', type: 'max' },
|
||||
},
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Please fix form errors', {
|
||||
description: 'model_params.temperature: Temperature must be between 0 and 2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab navigation', () => {
|
||||
it('navigates to correct tab when mapping provided', () => {
|
||||
const setActiveTab = jest.fn();
|
||||
const tabMapping = {
|
||||
name: 'basic',
|
||||
model_params: 'model',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useValidationErrorHandler({
|
||||
tabMapping,
|
||||
setActiveTab,
|
||||
debug: false,
|
||||
})
|
||||
);
|
||||
|
||||
const errors: FieldErrors = {
|
||||
model_params: {
|
||||
temperature: { message: 'Invalid', type: 'type' },
|
||||
},
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(setActiveTab).toHaveBeenCalledWith('model');
|
||||
});
|
||||
|
||||
it('does not navigate if field not in mapping', () => {
|
||||
const setActiveTab = jest.fn();
|
||||
const tabMapping = {
|
||||
name: 'basic',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useValidationErrorHandler({
|
||||
tabMapping,
|
||||
setActiveTab,
|
||||
debug: false,
|
||||
})
|
||||
);
|
||||
|
||||
const errors: FieldErrors = {
|
||||
unknown_field: { message: 'Error', type: 'validation' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not crash when setActiveTab not provided', () => {
|
||||
const tabMapping = { name: 'basic' };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useValidationErrorHandler({
|
||||
tabMapping,
|
||||
// setActiveTab not provided
|
||||
debug: false,
|
||||
})
|
||||
);
|
||||
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Required', type: 'required' },
|
||||
};
|
||||
|
||||
expect(() => result.current.onValidationError(errors)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('debug logging', () => {
|
||||
it('logs errors when debug is true', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: true }));
|
||||
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Required', type: 'required' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(consoleErrorMock).toHaveBeenCalledWith('[Form Validation] Errors:', errors);
|
||||
});
|
||||
|
||||
it('does not log errors when debug is false', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Required', type: 'required' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(consoleErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoization', () => {
|
||||
it('returns stable callback reference', () => {
|
||||
const { result, rerender } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
const firstCallback = result.current.onValidationError;
|
||||
rerender();
|
||||
const secondCallback = result.current.onValidationError;
|
||||
|
||||
expect(firstCallback).toBe(secondCallback);
|
||||
});
|
||||
|
||||
it('returns new callback when options change', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ title }) => useValidationErrorHandler({ toastTitle: title, debug: false }),
|
||||
{ initialProps: { title: 'Error A' } }
|
||||
);
|
||||
|
||||
const firstCallback = result.current.onValidationError;
|
||||
rerender({ title: 'Error B' });
|
||||
const secondCallback = result.current.onValidationError;
|
||||
|
||||
expect(firstCallback).not.toBe(secondCallback);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user