forked from cardosofelipe/fast-next-template
- 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>
213 lines
5.8 KiB
TypeScript
213 lines
5.8 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|