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:
134
frontend/tests/lib/forms/utils/getFirstValidationError.test.ts
Normal file
134
frontend/tests/lib/forms/utils/getFirstValidationError.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Tests for getFirstValidationError utility
|
||||
*/
|
||||
|
||||
import {
|
||||
getFirstValidationError,
|
||||
getAllValidationErrors,
|
||||
} from '@/lib/forms/utils/getFirstValidationError';
|
||||
import type { FieldErrors } from 'react-hook-form';
|
||||
|
||||
describe('getFirstValidationError', () => {
|
||||
it('returns null for empty errors object', () => {
|
||||
const result = getFirstValidationError({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts direct error message', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
expect(result).toEqual({ field: 'name', message: 'Name is required' });
|
||||
});
|
||||
|
||||
it('extracts nested error message', () => {
|
||||
const errors: FieldErrors = {
|
||||
model_params: {
|
||||
temperature: { message: 'Temperature must be a number', type: 'type' },
|
||||
},
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
expect(result).toEqual({
|
||||
field: 'model_params.temperature',
|
||||
message: 'Temperature must be a number',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns first error when multiple fields have errors', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
slug: { message: 'Slug is required', type: 'required' },
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
// Object.keys order is insertion order, so 'name' comes first
|
||||
expect(result?.field).toBe('name');
|
||||
expect(result?.message).toBe('Name is required');
|
||||
});
|
||||
|
||||
it('handles deeply nested errors', () => {
|
||||
const errors: FieldErrors = {
|
||||
config: {
|
||||
nested: {
|
||||
deep: { message: 'Deep error', type: 'validation' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
expect(result).toEqual({ field: 'config.nested.deep', message: 'Deep error' });
|
||||
});
|
||||
|
||||
it('skips null error entries', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: null as unknown as undefined,
|
||||
slug: { message: 'Slug is required', type: 'required' },
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
expect(result).toEqual({ field: 'slug', message: 'Slug is required' });
|
||||
});
|
||||
|
||||
it('handles error object with ref but no message', () => {
|
||||
// react-hook-form errors may have 'ref' property but no 'message'
|
||||
// We cast to FieldErrors to simulate edge cases
|
||||
const errors = {
|
||||
name: { type: 'required', ref: { current: null } },
|
||||
slug: { message: 'Slug is required', type: 'required' },
|
||||
} as unknown as FieldErrors;
|
||||
const result = getFirstValidationError(errors);
|
||||
// Should skip name (no message) and find slug
|
||||
expect(result).toEqual({ field: 'slug', message: 'Slug is required' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllValidationErrors', () => {
|
||||
it('returns empty array for empty errors object', () => {
|
||||
const result = getAllValidationErrors({});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all errors as flat array', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
slug: { message: 'Slug is required', type: 'required' },
|
||||
};
|
||||
const result = getAllValidationErrors(errors);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({ field: 'name', message: 'Name is required' });
|
||||
expect(result).toContainEqual({ field: 'slug', message: 'Slug is required' });
|
||||
});
|
||||
|
||||
it('flattens nested errors', () => {
|
||||
const errors: FieldErrors = {
|
||||
model_params: {
|
||||
temperature: { message: 'Invalid temperature', type: 'type' },
|
||||
max_tokens: { message: 'Invalid max tokens', type: 'type' },
|
||||
},
|
||||
};
|
||||
const result = getAllValidationErrors(errors);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({
|
||||
field: 'model_params.temperature',
|
||||
message: 'Invalid temperature',
|
||||
});
|
||||
expect(result).toContainEqual({
|
||||
field: 'model_params.max_tokens',
|
||||
message: 'Invalid max tokens',
|
||||
});
|
||||
});
|
||||
|
||||
it('combines direct and nested errors', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
model_params: {
|
||||
temperature: { message: 'Invalid temperature', type: 'type' },
|
||||
},
|
||||
};
|
||||
const result = getAllValidationErrors(errors);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({ field: 'name', message: 'Name is required' });
|
||||
expect(result).toContainEqual({
|
||||
field: 'model_params.temperature',
|
||||
message: 'Invalid temperature',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user