UNPKG

trellis

Version:

Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications

279 lines (275 loc) 8.85 kB
// src/forms/core/validate.ts import { z } from "zod"; async function validateWithZod(fieldSchema, value) { try { await fieldSchema.parseAsync(value); return null; } catch (err) { if (err instanceof z.ZodError) { return err.errors[0]?.message ?? "Invalid value"; } return "Validation failed"; } } function isEmpty(value) { return value === void 0 || value === null || typeof value === "string" && value.trim() === "" || Array.isArray(value) && value.length === 0; } function validateWithMetadata(field, value) { if (field.computed) return null; if (field.required && isEmpty(value)) { return "Required"; } if (isEmpty(value)) return null; const v = field.validation; if (typeof value === "number") { if (v?.min !== void 0 && value < v.min) { return `Must be at least ${v.min}`; } if (v?.max !== void 0 && value > v.max) { return `Must be at most ${v.max}`; } } if (typeof value === "string") { if (v?.minLength !== void 0 && value.length < v.minLength) { return `Must be at least ${v.minLength} characters`; } if (v?.maxLength !== void 0 && value.length > v.maxLength) { return `Must be at most ${v.maxLength} characters`; } if (v?.pattern !== void 0 && !new RegExp(v.pattern).test(value)) { return "Invalid format"; } } if (field.selectOptions && field.selectOptions.length > 0 && !field.selectOptions.includes(value)) { return "Invalid option"; } return null; } async function validateFieldValue(field, value, zodShape) { const fieldSchema = zodShape?.[field.name]; if (fieldSchema) return validateWithZod(fieldSchema, value); return validateWithMetadata(field, value); } // src/forms/core/schema.ts function fieldConfigFromSpec(spec) { return { name: spec.name, valueType: spec.valueType, required: spec.required ?? false, ...spec.selectOptions && spec.selectOptions.length > 0 ? { selectOptions: spec.selectOptions } : {}, ...spec.relation && spec.relation.targetSchema && spec.relation.cardinality ? { relation: { targetSchema: spec.relation.targetSchema, cardinality: spec.relation.cardinality } } : {}, computed: spec.computed === true, isTitle: spec.valueType === "title", ...spec.min !== void 0 || spec.max !== void 0 || spec.pattern !== void 0 || spec.minLength !== void 0 || spec.maxLength !== void 0 ? { validation: { ...spec.min !== void 0 ? { min: spec.min } : {}, ...spec.max !== void 0 ? { max: spec.max } : {}, ...spec.pattern !== void 0 ? { pattern: spec.pattern } : {}, ...spec.minLength !== void 0 ? { minLength: spec.minLength } : {}, ...spec.maxLength !== void 0 ? { maxLength: spec.maxLength } : {} } } : {} }; } function toFormSchema(schema) { const def = schema.definition; const fields = def.fields.map(fieldConfigFromSpec); return { typeName: def["@id"].replace("trellis:", ""), fields, zod: schema.zod.shape ? { shape: schema.zod.shape } : void 0, titleKey: fields.find((f) => f.isTitle)?.name }; } function formSchemaFromDescriptor(descriptor) { const fields = descriptor.fields.map((f) => ({ name: f.name, valueType: f.valueType, required: f.required, ...f.options && f.options.length > 0 ? { selectOptions: f.options.map((o) => o.value) } : {}, ...f.relation && f.relation.targetSchema && f.relation.cardinality ? { relation: { targetSchema: f.relation.targetSchema, cardinality: f.relation.cardinality } } : {}, computed: f.readonly, isTitle: f.name === descriptor.titleField, ...f.validation ? { validation: f.validation } : {} })); return { typeName: descriptor.entityType, fields, titleKey: descriptor.titleField }; } function formSchemaFrom(input) { return "definition" in input ? toFormSchema(input) : formSchemaFromDescriptor(input); } // src/forms/core/index.ts function createInitialState(initialValues, fields) { const shape = {}; for (const f of fields) { if (initialValues[f.name] !== void 0) { shape[f.name] = initialValues[f.name]; } else if (f.required && f.valueType !== "checkbox") { shape[f.name] = ""; } else { shape[f.name] = void 0; } } return { values: shape, errors: Object.fromEntries(fields.map((f) => [f.name, null])), dirty: Object.fromEntries(fields.map((f) => [f.name, false])), touched: Object.fromEntries(fields.map((f) => [f.name, false])), isSubmitting: false, isValid: true, isDirty: false }; } function computeIsValid(errors) { return Object.values(errors).every((e) => e === null); } function computeIsDirty(values, initialValues, dirty) { return Object.keys(values).some( (k) => dirty[k] || values[k] !== initialValues[k] ); } function createFormCore(formSchema, initialValues = {}) { let state = createInitialState(initialValues, formSchema.fields); const initialRef = { current: { ...state.values } }; const subscribers = /* @__PURE__ */ new Set(); const notify = () => subscribers.forEach((fn) => fn()); const validateField = async (fieldName, value) => { const field = formSchema.fields.find((f) => f.name === fieldName); if (!field) return null; return validateFieldValue(field, value, formSchema.zod?.shape); }; const validate = async (values) => { const toValidate = values ?? state.values; const errors = []; for (const field of formSchema.fields) { if (field.computed) continue; const err = await validateField(field.name, toValidate[field.name]); if (err) errors.push({ field: field.name, message: err }); } return { valid: errors.length === 0, errors, data: toValidate }; }; const actions = { setValue: (field, value) => { state = { ...state, values: { ...state.values, [field]: value }, dirty: { ...state.dirty, [field]: true } }; notify(); }, setValues: (values) => { const newDirty = { ...state.dirty }; for (const k of Object.keys(values)) newDirty[k] = true; state = { ...state, values: { ...state.values, ...values }, dirty: newDirty }; notify(); }, setError: (field, error) => { state = { ...state, errors: { ...state.errors, [field]: error } }; notify(); }, setErrors: (errors) => { state = { ...state, errors: { ...state.errors, ...errors } }; notify(); }, setTouched: (field, touched) => { state = { ...state, touched: { ...state.touched, [field]: touched } }; notify(); }, setDirty: (field, dirty) => { state = { ...state, dirty: { ...state.dirty, [field]: dirty } }; notify(); }, validate: async (values) => { const result = await validate(values); if (!values) { state = { ...state, errors: Object.fromEntries( result.errors.map((e) => [e.field, e.message]) ) }; notify(); } return result; }, validateField: async (field, value) => { const err = await validateField(field, value); actions.setError(field, err); return err; }, reset: (values) => { const next = values ? { ...initialRef.current, ...values } : initialRef.current; state = createInitialState({ ...next }, formSchema.fields); initialRef.current = { ...state.values }; notify(); }, submit: async (onSubmit) => { state = { ...state, isSubmitting: true }; notify(); const result = await validate(); if (result.valid) { try { await onSubmit({ ...state.values }); } finally { state = { ...state, isSubmitting: false }; notify(); } } else { state = { ...state, isSubmitting: false, errors: Object.fromEntries( result.errors.map((e) => [e.field, e.message]) ) }; notify(); } } }; const core = { get state() { return { ...state, isValid: computeIsValid(state.errors), isDirty: computeIsDirty(state.values, initialRef.current, state.dirty) }; }, actions, field: (name) => { const s = core.state; return { value: s.values[name], error: s.errors[name], dirty: s.dirty[name], touched: s.touched[name], onChange: (value) => actions.setValue(name, value), onBlur: () => actions.setTouched(name, true) }; }, subscribe: (listener) => { subscribers.add(listener); return () => subscribers.delete(listener); } }; return core; } export { validateFieldValue, toFormSchema, formSchemaFromDescriptor, formSchemaFrom, createFormCore };