diff --git a/packages/runtime/src/form.test.ts b/packages/runtime/src/form.test.ts new file mode 100644 index 0000000..ae92bea --- /dev/null +++ b/packages/runtime/src/form.test.ts @@ -0,0 +1,282 @@ +// ============================================================================ +// form.test.ts — Tests for createForm reactive form validation +// ============================================================================ + +import { describe, it, expect, vi } from 'vitest'; +import { effect } from '@matthesketh/utopia-core'; +import { + createForm, + required, + minLength, + maxLength, + min, + max, + email, + pattern, + validate, +} from './form'; + +// --------------------------------------------------------------------------- +// Validation rules +// --------------------------------------------------------------------------- + +describe('validation rules', () => { + describe('required', () => { + const rule = required(); + it('fails on empty string', () => expect(rule('')).toBeTruthy()); + it('fails on whitespace-only string', () => expect(rule(' ')).toBeTruthy()); + it('fails on null', () => expect(rule(null)).toBeTruthy()); + it('fails on undefined', () => expect(rule(undefined)).toBeTruthy()); + it('passes on non-empty string', () => expect(rule('hello')).toBeNull()); + it('passes on zero', () => expect(rule(0)).toBeNull()); + it('passes on false', () => expect(rule(false)).toBeNull()); + it('accepts custom message', () => { + expect(required('fill this in')('')).toBe('fill this in'); + }); + }); + + describe('minLength', () => { + const rule = minLength(3); + it('fails on short string', () => expect(rule('ab')).toBeTruthy()); + it('passes on exact length', () => expect(rule('abc')).toBeNull()); + it('passes on longer string', () => expect(rule('abcd')).toBeNull()); + }); + + describe('maxLength', () => { + const rule = maxLength(5); + it('fails on long string', () => expect(rule('abcdef')).toBeTruthy()); + it('passes on exact length', () => expect(rule('abcde')).toBeNull()); + it('passes on shorter string', () => expect(rule('abc')).toBeNull()); + }); + + describe('min', () => { + const rule = min(18); + it('fails on low number', () => expect(rule(17)).toBeTruthy()); + it('passes on exact', () => expect(rule(18)).toBeNull()); + it('passes on higher', () => expect(rule(25)).toBeNull()); + }); + + describe('max', () => { + const rule = max(100); + it('fails on high number', () => expect(rule(101)).toBeTruthy()); + it('passes on exact', () => expect(rule(100)).toBeNull()); + it('passes on lower', () => expect(rule(50)).toBeNull()); + }); + + describe('email', () => { + const rule = email(); + it('passes on valid email', () => expect(rule('user@example.com')).toBeNull()); + it('fails on missing @', () => expect(rule('userexample.com')).toBeTruthy()); + it('fails on missing domain', () => expect(rule('user@')).toBeTruthy()); + it('passes on empty string (use required for presence)', () => expect(rule('')).toBeNull()); + }); + + describe('pattern', () => { + const rule = pattern(/^\d+$/, 'Numbers only'); + it('passes on matching', () => expect(rule('123')).toBeNull()); + it('fails on non-matching', () => expect(rule('abc')).toBe('Numbers only')); + it('passes on empty (use required for presence)', () => expect(rule('')).toBeNull()); + }); + + describe('validate (custom)', () => { + const rule = validate((v) => v % 2 === 0, 'Must be even'); + it('passes when predicate returns true', () => expect(rule(4)).toBeNull()); + it('fails when predicate returns false', () => expect(rule(3)).toBe('Must be even')); + }); +}); + +// --------------------------------------------------------------------------- +// createForm +// --------------------------------------------------------------------------- + +describe('createForm', () => { + it('creates fields with initial values', () => { + const form = createForm({ + name: { initial: '' }, + age: { initial: 0 }, + }); + + expect(form.fields.name.value()).toBe(''); + expect(form.fields.age.value()).toBe(0); + }); + + it('field.set() updates the value reactively', () => { + const form = createForm({ + name: { initial: '' }, + }); + + form.fields.name.set('Matt'); + expect(form.fields.name.value()).toBe('Matt'); + }); + + it('field errors are reactive to value changes', () => { + const form = createForm({ + name: { initial: '', rules: [required(), minLength(2)] }, + }); + + // Initially invalid (empty string) + expect(form.fields.name.errors()).toEqual([ + 'This field is required', + 'Must be at least 2 characters', + ]); + expect(form.fields.name.error()).toBe('This field is required'); + + form.fields.name.set('A'); + expect(form.fields.name.errors()).toEqual(['Must be at least 2 characters']); + + form.fields.name.set('AB'); + expect(form.fields.name.errors()).toEqual([]); + expect(form.fields.name.error()).toBeNull(); + }); + + it('field.valid is reactive', () => { + const form = createForm({ + name: { initial: '', rules: [required()] }, + }); + + expect(form.fields.name.valid()).toBe(false); + form.fields.name.set('hello'); + expect(form.fields.name.valid()).toBe(true); + }); + + it('field.dirty tracks changes from initial value', () => { + const form = createForm({ + name: { initial: 'original' }, + }); + + expect(form.fields.name.dirty()).toBe(false); + form.fields.name.set('changed'); + expect(form.fields.name.dirty()).toBe(true); + form.fields.name.set('original'); + expect(form.fields.name.dirty()).toBe(false); + }); + + it('field.touched tracks blur state', () => { + const form = createForm({ + name: { initial: '' }, + }); + + expect(form.fields.name.touched()).toBe(false); + form.fields.name.touch(); + expect(form.fields.name.touched()).toBe(true); + }); + + it('form.valid is true when all fields are valid', () => { + const form = createForm({ + name: { initial: '', rules: [required()] }, + email: { initial: '', rules: [required(), email()] }, + }); + + expect(form.valid()).toBe(false); + + form.fields.name.set('Matt'); + expect(form.valid()).toBe(false); // email still invalid + + form.fields.email.set('matt@example.com'); + expect(form.valid()).toBe(true); + }); + + it('form.dirty is true when any field is dirty', () => { + const form = createForm({ + name: { initial: '' }, + email: { initial: '' }, + }); + + expect(form.dirty()).toBe(false); + form.fields.name.set('changed'); + expect(form.dirty()).toBe(true); + }); + + it('form.data() returns current values as plain object', () => { + const form = createForm({ + name: { initial: 'Matt' }, + age: { initial: 25 }, + }); + + expect(form.data()).toEqual({ name: 'Matt', age: 25 }); + + form.fields.age.set(30); + expect(form.data()).toEqual({ name: 'Matt', age: 30 }); + }); + + it('form.handleSubmit calls callback when valid', () => { + const form = createForm({ + name: { initial: 'Matt', rules: [required()] }, + }); + + const onSubmit = vi.fn(); + form.handleSubmit(onSubmit); + + expect(onSubmit).toHaveBeenCalledWith({ name: 'Matt' }); + }); + + it('form.handleSubmit does not call callback when invalid', () => { + const form = createForm({ + name: { initial: '', rules: [required()] }, + }); + + const onSubmit = vi.fn(); + form.handleSubmit(onSubmit); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('form.handleSubmit touches all fields to show errors', () => { + const form = createForm({ + name: { initial: '', rules: [required()] }, + email: { initial: '', rules: [required()] }, + }); + + expect(form.fields.name.touched()).toBe(false); + expect(form.fields.email.touched()).toBe(false); + + form.handleSubmit(() => {}); + + expect(form.fields.name.touched()).toBe(true); + expect(form.fields.email.touched()).toBe(true); + }); + + it('form.reset() resets all fields', () => { + const form = createForm({ + name: { initial: '' }, + age: { initial: 0 }, + }); + + form.fields.name.set('Matt'); + form.fields.age.set(25); + form.fields.name.touch(); + + form.reset(); + + expect(form.fields.name.value()).toBe(''); + expect(form.fields.age.value()).toBe(0); + expect(form.fields.name.touched()).toBe(false); + expect(form.fields.name.dirty()).toBe(false); + }); + + it('fields without rules are always valid', () => { + const form = createForm({ + notes: { initial: '' }, + }); + + expect(form.fields.notes.valid()).toBe(true); + expect(form.fields.notes.errors()).toEqual([]); + }); + + it('integrates with effect() for reactive UI', () => { + const form = createForm({ + name: { initial: '', rules: [required()] }, + }); + + const states: boolean[] = []; + const dispose = effect(() => { + states.push(form.valid()); + }); + + form.fields.name.set('hello'); + + expect(states).toEqual([false, true]); + + dispose(); + }); +}); diff --git a/packages/runtime/src/form.ts b/packages/runtime/src/form.ts new file mode 100644 index 0000000..2b9a32c --- /dev/null +++ b/packages/runtime/src/form.ts @@ -0,0 +1,309 @@ +// ============================================================================ +// @matthesketh/utopia-runtime — Reactive form validation +// ============================================================================ +// +// Provides createForm() — a first-class reactive form primitive with +// declarative validation, dirty/touched tracking, and type-safe field access. +// +// Usage: +// const form = createForm({ +// name: { initial: '', rules: [required(), minLength(2)] }, +// email: { initial: '', rules: [required(), email()] }, +// age: { initial: 0, rules: [required(), min(18)] }, +// }); +// +// form.fields.name.value() // current value +// form.fields.name.error() // first error message or null +// form.fields.name.errors() // all error messages +// form.fields.name.touched() // has the field been blurred? +// form.fields.name.dirty() // has the value changed from initial? +// form.fields.name.set('Matt') +// form.valid() // is the entire form valid? +// form.data() // { name: 'Matt', email: '', age: 0 } +// form.handleSubmit(fn) // validates all, calls fn if valid +// ============================================================================ + +import { + signal, + computed, + batch, + type Signal, + type ReadonlySignal, +} from '@matthesketh/utopia-core'; + +// --------------------------------------------------------------------------- +// Validation rule types +// --------------------------------------------------------------------------- + +/** A validation rule returns null if valid, or an error message string. */ +export type ValidationRule = (value: T) => string | null; + +/** Field configuration. */ +export interface FieldConfig { + /** Initial value. */ + initial: T; + /** Validation rules to apply. */ + rules?: ValidationRule[]; +} + +// --------------------------------------------------------------------------- +// Reactive field state +// --------------------------------------------------------------------------- + +/** Reactive state for a single form field. */ +export interface FormField { + /** Current field value (reactive signal). */ + value: ReadonlySignal; + /** Set the field value. */ + set(newValue: T): void; + /** First validation error or null. */ + error: ReadonlySignal; + /** All validation errors. */ + errors: ReadonlySignal; + /** Whether the field has been touched (blurred). */ + touched: ReadonlySignal; + /** Mark the field as touched. */ + touch(): void; + /** Whether the value differs from the initial value. */ + dirty: ReadonlySignal; + /** Whether the field is valid. */ + valid: ReadonlySignal; + /** Reset the field to its initial value and clear touched state. */ + reset(): void; +} + +// --------------------------------------------------------------------------- +// Form state +// --------------------------------------------------------------------------- + +/** Maps field config to reactive field state. */ +export type FormFields>> = { + [K in keyof T]: FormField; +}; + +/** Extracts the data type from field configs. */ +export type FormData>> = { + [K in keyof T]: T[K]['initial']; +}; + +/** The reactive form instance. */ +export interface Form>> { + /** Reactive field accessors. */ + fields: FormFields; + /** Whether all fields are valid (reactive). */ + valid: ReadonlySignal; + /** Whether any field is dirty (reactive). */ + dirty: ReadonlySignal; + /** Extract current form data as a plain object. */ + data(): FormData; + /** Validate all fields, touch them all, and call onSubmit if valid. */ + handleSubmit(onSubmit: (data: FormData) => void | Promise): void; + /** Reset all fields to their initial values. */ + reset(): void; +} + +// --------------------------------------------------------------------------- +// createForm() +// --------------------------------------------------------------------------- + +/** + * Create a reactive form with declarative validation. + * + * ```ts + * const form = createForm({ + * name: { initial: '', rules: [required(), minLength(2)] }, + * email: { initial: '', rules: [required(), email()] }, + * }); + * ``` + */ +export function createForm>>(config: T): Form { + const fieldEntries: [string, FormField][] = []; + + for (const [key, fieldConfig] of Object.entries(config)) { + fieldEntries.push([key, createField(fieldConfig)]); + } + + const fields = Object.fromEntries(fieldEntries) as FormFields; + + const valid = computed(() => { + for (const [, field] of fieldEntries) { + if (!field.valid()) return false; + } + return true; + }); + + const dirty = computed(() => { + for (const [, field] of fieldEntries) { + if (field.dirty()) return true; + } + return false; + }); + + return { + fields, + valid, + dirty, + + data(): FormData { + const result: Record = {}; + for (const [key, field] of fieldEntries) { + result[key] = field.value(); + } + return result as FormData; + }, + + handleSubmit(onSubmit) { + // Touch all fields to show errors. + batch(() => { + for (const [, field] of fieldEntries) { + field.touch(); + } + }); + + if (valid()) { + onSubmit(this.data()); + } + }, + + reset() { + batch(() => { + for (const [, field] of fieldEntries) { + field.reset(); + } + }); + }, + }; +} + +// --------------------------------------------------------------------------- +// createField() — internal +// --------------------------------------------------------------------------- + +function createField(config: FieldConfig): FormField { + const _value = signal(config.initial); + const _touched = signal(false); + const rules = config.rules ?? []; + + const errors = computed(() => { + const val = _value(); + const errs: string[] = []; + for (const rule of rules) { + const result = rule(val); + if (result !== null) { + errs.push(result); + } + } + return errs; + }); + + const error = computed(() => { + const errs = errors(); + return errs.length > 0 ? errs[0] : null; + }); + + const dirty = computed(() => !Object.is(_value(), config.initial)); + const valid = computed(() => errors().length === 0); + + return { + value: _value, + set(newValue: T) { + _value.set(newValue); + }, + error, + errors, + touched: _touched, + touch() { + _touched.set(true); + }, + dirty, + valid, + reset() { + _value.set(config.initial); + _touched.set(false); + }, + }; +} + +// --------------------------------------------------------------------------- +// Built-in validation rules +// --------------------------------------------------------------------------- + +/** Field must have a non-empty value. */ +export function required(message = 'This field is required'): ValidationRule { + return (value) => { + if (value === '' || value === null || value === undefined) return message; + if (typeof value === 'string' && value.trim() === '') return message; + return null; + }; +} + +/** String must be at least `n` characters. */ +export function minLength(n: number, message?: string): ValidationRule { + return (value) => { + if (typeof value === 'string' && value.length < n) { + return message ?? `Must be at least ${n} characters`; + } + return null; + }; +} + +/** String must be at most `n` characters. */ +export function maxLength(n: number, message?: string): ValidationRule { + return (value) => { + if (typeof value === 'string' && value.length > n) { + return message ?? `Must be at most ${n} characters`; + } + return null; + }; +} + +/** Number must be at least `n`. */ +export function min(n: number, message?: string): ValidationRule { + return (value) => { + if (typeof value === 'number' && value < n) { + return message ?? `Must be at least ${n}`; + } + return null; + }; +} + +/** Number must be at most `n`. */ +export function max(n: number, message?: string): ValidationRule { + return (value) => { + if (typeof value === 'number' && value > n) { + return message ?? `Must be at most ${n}`; + } + return null; + }; +} + +/** String must match a valid email format. */ +export function email(message = 'Invalid email address'): ValidationRule { + return (value) => { + if (typeof value !== 'string') return null; + if (value === '') return null; // Use required() for presence check. + // Simple but practical email regex. + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return message; + return null; + }; +} + +/** String must match the given regex pattern. */ +export function pattern(regex: RegExp, message = 'Invalid format'): ValidationRule { + return (value) => { + if (typeof value !== 'string' || value === '') return null; + if (!regex.test(value)) return message; + return null; + }; +} + +/** Custom validation rule from a predicate function. */ +export function validate( + predicate: (value: T) => boolean, + message = 'Invalid value', +): ValidationRule { + return (value) => { + if (!predicate(value)) return message; + return null; + }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 1512ea1..6e9e617 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -64,3 +64,20 @@ export function createEffect(fn: () => void | (() => void)): () => void { pushDisposer(dispose); return dispose; } + +// --------------------------------------------------------------------------- +// Form validation +// --------------------------------------------------------------------------- +export { + createForm, + required, + minLength, + maxLength, + min, + max, + email, + pattern, + validate, +} from './form.js'; + +export type { ValidationRule, FieldConfig, FormField, Form } from './form.js';