- Add comprehensive TDD documentation to CLAUDE.md with coverage requirements and examples - Extract reusable UI components to frontend/src/components/ui/ (Modal, FormInput, Button, Alert, etc.) - Add shared constants (schedulePresets) and utility hooks (useCrudMutation, useFormValidation) - Update frontend/CLAUDE.md with component documentation and usage examples - Refactor CreateTaskModal to use shared components and constants - Fix test assertions to be more robust and accurate across all test files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
252 lines
6.4 KiB
TypeScript
252 lines
6.4 KiB
TypeScript
import { useState, useCallback, useMemo } from 'react';
|
|
|
|
type ValidationRule<T> = (value: T, formData?: Record<string, unknown>) => string | undefined;
|
|
|
|
type ValidationSchema<T extends Record<string, unknown>> = {
|
|
[K in keyof T]?: ValidationRule<T[K]>[];
|
|
};
|
|
|
|
interface UseFormValidationResult<T extends Record<string, unknown>> {
|
|
/** Current validation errors */
|
|
errors: Partial<Record<keyof T, string>>;
|
|
/** Whether the form is valid (no errors) */
|
|
isValid: boolean;
|
|
/** Validate a single field */
|
|
validateField: (field: keyof T, value: T[keyof T]) => string | undefined;
|
|
/** Validate all fields */
|
|
validateForm: (data: T) => boolean;
|
|
/** Set a specific error */
|
|
setError: (field: keyof T, error: string) => void;
|
|
/** Clear a specific error */
|
|
clearError: (field: keyof T) => void;
|
|
/** Clear all errors */
|
|
clearAllErrors: () => void;
|
|
/** Get error for a field */
|
|
getError: (field: keyof T) => string | undefined;
|
|
/** Check if a field has an error */
|
|
hasError: (field: keyof T) => boolean;
|
|
}
|
|
|
|
/**
|
|
* Form validation hook with schema-based validation
|
|
*
|
|
* @example
|
|
* const schema = {
|
|
* email: [required('Email is required'), email('Invalid email')],
|
|
* password: [required('Password is required'), minLength(8, 'Password must be at least 8 characters')],
|
|
* };
|
|
*
|
|
* const { errors, validateForm, validateField, isValid } = useFormValidation(schema);
|
|
*
|
|
* const handleSubmit = () => {
|
|
* if (validateForm(formData)) {
|
|
* // Submit form
|
|
* }
|
|
* };
|
|
*/
|
|
export function useFormValidation<T extends Record<string, unknown>>(
|
|
schema: ValidationSchema<T>
|
|
): UseFormValidationResult<T> {
|
|
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
|
|
|
const validateField = useCallback(
|
|
(field: keyof T, value: T[keyof T], formData?: T): string | undefined => {
|
|
const rules = schema[field];
|
|
if (!rules) return undefined;
|
|
|
|
for (const rule of rules) {
|
|
const error = rule(value, formData as Record<string, unknown>);
|
|
if (error) return error;
|
|
}
|
|
return undefined;
|
|
},
|
|
[schema]
|
|
);
|
|
|
|
const validateForm = useCallback(
|
|
(data: T): boolean => {
|
|
const newErrors: Partial<Record<keyof T, string>> = {};
|
|
let isValid = true;
|
|
|
|
for (const field of Object.keys(schema) as (keyof T)[]) {
|
|
const error = validateField(field, data[field], data);
|
|
if (error) {
|
|
newErrors[field] = error;
|
|
isValid = false;
|
|
}
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return isValid;
|
|
},
|
|
[schema, validateField]
|
|
);
|
|
|
|
const setError = useCallback((field: keyof T, error: string) => {
|
|
setErrors((prev) => ({ ...prev, [field]: error }));
|
|
}, []);
|
|
|
|
const clearError = useCallback((field: keyof T) => {
|
|
setErrors((prev) => {
|
|
const newErrors = { ...prev };
|
|
delete newErrors[field];
|
|
return newErrors;
|
|
});
|
|
}, []);
|
|
|
|
const clearAllErrors = useCallback(() => {
|
|
setErrors({});
|
|
}, []);
|
|
|
|
const getError = useCallback(
|
|
(field: keyof T): string | undefined => errors[field],
|
|
[errors]
|
|
);
|
|
|
|
const hasError = useCallback(
|
|
(field: keyof T): boolean => !!errors[field],
|
|
[errors]
|
|
);
|
|
|
|
const isValid = useMemo(() => Object.keys(errors).length === 0, [errors]);
|
|
|
|
return {
|
|
errors,
|
|
isValid,
|
|
validateField,
|
|
validateForm,
|
|
setError,
|
|
clearError,
|
|
clearAllErrors,
|
|
getError,
|
|
hasError,
|
|
};
|
|
}
|
|
|
|
// ============= Built-in validation rules =============
|
|
|
|
/**
|
|
* Required field validation
|
|
*/
|
|
export const required = (message = 'This field is required'): ValidationRule<unknown> => {
|
|
return (value) => {
|
|
if (value === undefined || value === null || value === '') {
|
|
return message;
|
|
}
|
|
if (Array.isArray(value) && value.length === 0) {
|
|
return message;
|
|
}
|
|
return undefined;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Email validation
|
|
*/
|
|
export const email = (message = 'Invalid email address'): ValidationRule<string> => {
|
|
return (value) => {
|
|
if (!value) return undefined;
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(value) ? undefined : message;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Minimum length validation
|
|
*/
|
|
export const minLength = (min: number, message?: string): ValidationRule<string> => {
|
|
return (value) => {
|
|
if (!value) return undefined;
|
|
return value.length >= min
|
|
? undefined
|
|
: message || `Must be at least ${min} characters`;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Maximum length validation
|
|
*/
|
|
export const maxLength = (max: number, message?: string): ValidationRule<string> => {
|
|
return (value) => {
|
|
if (!value) return undefined;
|
|
return value.length <= max
|
|
? undefined
|
|
: message || `Must be at most ${max} characters`;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Minimum value validation (for numbers)
|
|
*/
|
|
export const minValue = (min: number, message?: string): ValidationRule<number> => {
|
|
return (value) => {
|
|
if (value === undefined || value === null) return undefined;
|
|
return value >= min
|
|
? undefined
|
|
: message || `Must be at least ${min}`;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Maximum value validation (for numbers)
|
|
*/
|
|
export const maxValue = (max: number, message?: string): ValidationRule<number> => {
|
|
return (value) => {
|
|
if (value === undefined || value === null) return undefined;
|
|
return value <= max
|
|
? undefined
|
|
: message || `Must be at most ${max}`;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Pattern/regex validation
|
|
*/
|
|
export const pattern = (regex: RegExp, message = 'Invalid format'): ValidationRule<string> => {
|
|
return (value) => {
|
|
if (!value) return undefined;
|
|
return regex.test(value) ? undefined : message;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* URL validation
|
|
*/
|
|
export const url = (message = 'Invalid URL'): ValidationRule<string> => {
|
|
return (value) => {
|
|
if (!value) return undefined;
|
|
try {
|
|
new URL(value);
|
|
return undefined;
|
|
} catch {
|
|
return message;
|
|
}
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Match another field (e.g., confirm password)
|
|
*/
|
|
export const matches = <T extends Record<string, unknown>>(
|
|
fieldName: keyof T,
|
|
message = 'Fields must match'
|
|
): ValidationRule<unknown> => {
|
|
return (value, formData) => {
|
|
if (!formData) return undefined;
|
|
return value === formData[fieldName as string] ? undefined : message;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Phone number validation (basic)
|
|
*/
|
|
export const phone = (message = 'Invalid phone number'): ValidationRule<string> => {
|
|
return (value) => {
|
|
if (!value) return undefined;
|
|
const phoneRegex = /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/;
|
|
return phoneRegex.test(value) ? undefined : message;
|
|
};
|
|
};
|
|
|
|
export default useFormValidation;
|