omnidiag / frontend /src /utils /schemaToZod.js
yahyoha's picture
refactor(frontend): update components, hooks, utils for v2 platform
93a6b8c
Raw
History Blame Contribute Delete
4.5 kB
/**
* schemaToZod.js
* ==============
* Converts JSON Schema field metadata into a Zod validation schema object.
* Mirrors the Pydantic server-side validation (ge/le/type/required/enum)
* so the client provides instant feedback without a round-trip.
*
* Dependencies: zod (must be installed in package.json)
*
* Usage:
* import { buildZodSchema } from '../utils/schemaToZod';
* import { z } from 'zod';
*
* const fields = parseSchema(schemaJson);
* const zodSchema = buildZodSchema(fields);
* const result = zodSchema.safeParse(formData);
*/
import { z } from 'zod';
/**
* Build a Zod object schema from parsed FieldMetadata array.
*
* @param {import('./schemaFieldParser').FieldMetadata[]} fields
* @returns {z.ZodObject} A Zod schema that validates form data
*/
export function buildZodSchema(fields) {
const shape = {};
for (const field of fields) {
let validator;
switch (field.component) {
// ── Toggle (binary 0/1 integer) ──
case 'toggle': {
validator = z
.number({ invalid_type_error: `${field.title} is required` })
.int()
.min(0)
.max(1);
// Accept numbers and also 0/1 strings (coerce)
validator = validator.or(z.literal(0)).or(z.literal(1));
break;
}
// ── Segmented (binary 0/1 radio-group, e.g. Sex) ──
case 'segmented': {
validator = z
.number({ invalid_type_error: `${field.title} is required` })
.int()
.min(0)
.max(1);
break;
}
// ── Dropdown (string enum) ──
case 'select': {
if (field.validation.enum && field.validation.enum.length > 0) {
// Handle both string and number enums
const enumValues = field.validation.enum;
if (typeof enumValues[0] === 'string') {
validator = z.enum(enumValues, {
errorMap: () => ({ message: `Select a valid ${field.title}` }),
});
} else {
validator = z.number().refine(
(v) => enumValues.includes(v),
{ message: `Select a valid ${field.title}` }
);
}
} else {
validator = z.string().min(1, `${field.title} is required`);
}
break;
}
// ── Slider (small-range integer) ──
case 'slider': {
let numVal = z
.number({ invalid_type_error: `${field.title} is required` })
.int();
if (field.validation.minimum !== undefined) {
numVal = numVal.min(field.validation.minimum);
}
if (field.validation.maximum !== undefined) {
numVal = numVal.max(field.validation.maximum);
}
validator = numVal;
break;
}
// ── Number (float or wide-range integer) ──
case 'number': {
// Build the base numeric validator first (BEFORE .or() string-coercion)
// so that .min()/.max() chain directly on ZodNumber, not on ZodUnion.
let numVal;
if (field.type === 'number') {
numVal = z.number({
invalid_type_error: `${field.title} must be a number`,
});
} else {
numVal = z
.number({ invalid_type_error: `${field.title} must be a number` })
.int();
}
if (field.validation.minimum !== undefined) {
numVal = numVal.min(field.validation.minimum, {
message: `${field.title} must be β‰₯ ${field.validation.minimum}`,
});
}
if (field.validation.maximum !== undefined) {
numVal = numVal.max(field.validation.maximum, {
message: `${field.title} must be ≀ ${field.validation.maximum}`,
});
}
// Now chain the string-coercion AFTER min/max are set
if (field.type === 'number') {
numVal = numVal.or(z.string().transform((v) => parseFloat(v)));
} else {
numVal = numVal.or(z.string().transform((v) => parseInt(v, 10)));
}
validator = numVal;
break;
}
// ── Text (fallback string) ──
case 'text':
default: {
validator = z.string().min(1, `${field.title} is required`);
break;
}
}
// Make optional if not required
if (!field.validation.required) {
validator = validator.optional();
}
shape[field.name] = validator;
}
return z.object(shape);
}