/** * Fluent schema-style builder for form field configuration. * * @module bquery/forms */ import type { FieldConfig, FormConfig, Validator } from './types'; import { between, email, integer, length, matchField, max, maxLength, min, minLength, notOneOf, numeric, oneOf, pattern, required, url, } from './validators'; /** * Fluent builder for a single field's validator chain. * * Builders are immutable: every chain method returns a new builder, so the * same starting point can be reused safely. */ export type FieldSchema = { /** Underlying validator list (frozen). */ readonly validators: readonly Validator[]; /** Append a `required()` validator. */ required: (message?: string) => FieldSchema; /** Append a `min()` validator. */ min: (limit: number, message?: string) => FieldSchema; /** Append a `max()` validator. */ max: (limit: number, message?: string) => FieldSchema; /** Append a `minLength()` validator. */ minLength: (limit: number, message?: string) => FieldSchema; /** Append a `maxLength()` validator. */ maxLength: (limit: number, message?: string) => FieldSchema; /** Append a `length()` validator. */ length: (exact: number, message?: string) => FieldSchema; /** Append an `email()` validator. */ email: (message?: string) => FieldSchema; /** Append a `url()` validator. */ url: (message?: string) => FieldSchema; /** Append a `pattern()` validator. */ pattern: (regex: RegExp, message?: string) => FieldSchema; /** Append an `integer()` validator. */ integer: (message?: string) => FieldSchema; /** Append a `numeric()` validator. */ numeric: (message?: string) => FieldSchema; /** Append a `between()` validator. */ between: (minLimit: number, maxLimit: number, message?: string) => FieldSchema; /** Append a `oneOf()` validator. */ oneOf: (values: readonly T[], message?: string) => FieldSchema; /** Append a `notOneOf()` validator. */ notOneOf: (values: readonly T[], message?: string) => FieldSchema; /** Append a `matchField()` validator. */ matchField: (ref: { readonly value: T }, message?: string) => FieldSchema; /** Append an arbitrary custom validator. */ custom: (validator: Validator) => FieldSchema; /** Finalize as a {@link FieldConfig} with the given initial value. */ toConfig: (initialValue: T, extras?: Omit, 'initialValue' | 'validators'>) => FieldConfig; }; const chain = (validators: readonly Validator[]): FieldSchema => { const append = (v: Validator): FieldSchema => chain([...validators, v]); return { validators: Object.freeze([...validators]), required: (m?: string) => append(required(m) as Validator), min: (n: number, m?: string) => append(min(n, m) as Validator), max: (n: number, m?: string) => append(max(n, m) as Validator), minLength: (n: number, m?: string) => append(minLength(n, m) as Validator), maxLength: (n: number, m?: string) => append(maxLength(n, m) as Validator), length: (n: number, m?: string) => append(length(n, m) as Validator), email: (m?: string) => append(email(m) as Validator), url: (m?: string) => append(url(m) as Validator), pattern: (r: RegExp, m?: string) => append(pattern(r, m) as Validator), integer: (m?: string) => append(integer(m) as Validator), numeric: (m?: string) => append(numeric(m) as Validator), between: (lo: number, hi: number, m?: string) => append(between(lo, hi, m) as Validator), oneOf: (values: readonly T[], m?: string) => append(oneOf(values, m) as Validator), notOneOf: (values: readonly T[], m?: string) => append(notOneOf(values, m) as Validator), matchField: (ref: { readonly value: T }, m?: string) => append(matchField(ref, m) as Validator), custom: (validator: Validator) => append(validator), toConfig: (initialValue: T, extras = {}) => ({ initialValue, validators: [...validators], ...extras, }), }; }; /** * Start a fluent field schema chain. * * @example * ```ts * import { field, schema } from '@bquery/bquery/forms'; * * const form = createForm(schema({ * name: field().required().minLength(2), * email: field().required().email(), * age: field().integer().between(0, 150), * })); * ``` */ export const field = (): FieldSchema => chain([]); /** * A schema entry can be either a fluent builder (with optional initial value), * a fully-formed {@link FieldConfig}, or just a plain initial value (no validators). */ export type SchemaEntry = FieldSchema | FieldConfig | T; const isFieldSchema = (value: unknown): value is FieldSchema => { return ( typeof value === 'object' && value !== null && typeof (value as { toConfig?: unknown }).toConfig === 'function' && Array.isArray((value as { validators?: unknown }).validators) ); }; const isFieldConfig = (value: unknown): value is FieldConfig => { if (typeof value !== 'object' || value === null) return false; if (!Object.prototype.hasOwnProperty.call(value, 'initialValue')) return false; return ( Object.prototype.hasOwnProperty.call(value, 'validators') || Object.prototype.hasOwnProperty.call(value, 'validateOn') || Object.prototype.hasOwnProperty.call(value, 'debounceMs') || Object.prototype.hasOwnProperty.call(value, 'parse') || Object.prototype.hasOwnProperty.call(value, 'format') || Object.prototype.hasOwnProperty.call(value, 'disabled') ); }; /** * Schema-style declaration helper that converts a map of fluent field * builders, raw {@link FieldConfig}s, or plain initial values into a * `FormConfig['fields']` object ready for {@link createForm}. * * @param shape - Map of field name → {@link SchemaEntry} * @param defaults - Optional initial values, used when an entry is a `FieldSchema` without an initial value * @returns Partial form config containing `fields`; merge with `onSubmit`, `crossValidators`, etc. * * @example * ```ts * import { createForm, schema, field } from '@bquery/bquery/forms'; * * const form = createForm({ * ...schema({ * name: field().required(), * email: field().required().email(), * }, { name: '', email: '' }), * onSubmit: async (values) => { ... }, * }); * ``` */ export const schema = >( shape: { [K in keyof T]: SchemaEntry }, defaults?: Partial ): Pick, 'fields'> => { const fields = {} as { [K in keyof T]: FieldConfig }; for (const key of Object.keys(shape) as (keyof T & string)[]) { const entry = shape[key]; if (isFieldSchema(entry)) { if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, key)) { throw new Error( `bQuery forms: schema() requires a default value for fluent field "${key}"` ); } const initial = defaults[key] as T[typeof key]; fields[key] = entry.toConfig(initial); } else if (isFieldConfig(entry)) { fields[key] = entry; } else { fields[key] = { initialValue: entry as T[typeof key] }; } } return { fields }; };