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
JavaScript
// 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
};