diff --git a/README.md b/README.md index efa7519..a0c0c71 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ $ npm install @conform-to/react valibot conform-to-valibot ## API Reference - [parseWithValibot](#parseWithValibot) +- [getValibotConstraint](#getValibotConstraint) @@ -73,3 +74,26 @@ export async function action({ request }) { // ... } ``` + +### getValibotConstraint + +A helper that returns an object containing the validation attributes for each field by introspecting the valibot schema. + +```tsx +import { getValibotConstraint } from "conform-to-valibot"; +import { useForm } from "@conform-to/react"; +import { object, string, minLength, maxLength, optional } from "valibot"; + +const schema = object({ + title: string([minLength(10), maxLength(100)]), + description: optional(string([minLength(100), maxLength(1000)])), +}); + +function Example() { + const [form, fields] = useForm({ + constraint: getValibotConstraint(schema), + }); + + // ... +} +``` diff --git a/coercion.ts b/coercion.ts index 3de05b3..2b919f9 100644 --- a/coercion.ts +++ b/coercion.ts @@ -1,30 +1,5 @@ -import type { - NullSchema, - NullableSchema, - OptionalSchema, - NullishSchema, - NonNullableSchema, - NonOptionalSchema, - NonNullishSchema, - ObjectSchema, - ObjectEntries, - UnionSchema, - UnionOptions, - BaseSchema, - ArraySchema, - BigintSchema, - BooleanSchema, - DateSchema, - EnumSchema, - Enum, - LiteralSchema, - Literal, - NumberSchema, - PicklistSchema, - PicklistOptions, - StringSchema, - UndefinedSchema, -} from "valibot"; +import type { WrapSchema, AllSchema, ObjectSchema } from "./types/schema"; + import { coerce } from "valibot"; /** @@ -67,37 +42,6 @@ export function coerceFile(file: unknown) { return file; } -type WrapWithDefaultSchema = - | NullSchema - | OptionalSchema - | NullableSchema - | NullishSchema; -type WrapWithoutDefaultSchema = - | NonNullableSchema - | NonOptionalSchema - | NonNullableSchema - | NonNullishSchema; -type WrapSchema = WrapWithDefaultSchema | WrapWithoutDefaultSchema; - -type ValibotSchema = - | ObjectSchema - | StringSchema - | ArraySchema - | BigintSchema - | BooleanSchema - | DateSchema - | EnumSchema - | LiteralSchema - | NumberSchema - | PicklistSchema - | UndefinedSchema; - -type AllSchema = - | ValibotSchema - | WrapSchema - | UnionSchema - | BaseSchema; - type WrapOption = { wrap?: WrapSchema; cache?: Map; @@ -316,7 +260,7 @@ export function enableTypeCoercion( enableTypeCoercion(def, { cache }), ]), ), - } as ObjectSchema; + } as ObjectSchema; schema = options?.wrap ? { ...options.wrap, diff --git a/constraint.ts b/constraint.ts new file mode 100644 index 0000000..a328099 --- /dev/null +++ b/constraint.ts @@ -0,0 +1,153 @@ +import type { Constraint } from "@conform-to/dom"; +import type { WrapSchema, AllSchema, ObjectSchema } from "./types/schema"; + +const keys: Array = [ + "required", + "minLength", + "maxLength", + "min", + "max", + "step", + "multiple", + "pattern", +]; + +export function getValibotConstraint( + schema: AllSchema, +): Record { + function updateConstraint( + schema: AllSchema, + data: Record, + name = "", + ): void { + if (name !== "" && !data[name]) { + data[name] = { required: true }; + } + const constraint = name !== "" ? data[name] : {}; + + if (!("type" in schema)) { + return; + } + + if (schema.type === "object") { + for (const key in schema.entries) { + updateConstraint( + schema.entries[key], + data, + name ? `${name}.${key}` : key, + ); + } + } else if (schema.type === "intersect") { + for (const option of schema.options) { + const result: Record = {}; + updateConstraint(option, result, name); + + Object.assign(data, result); + } + } else if (schema.type === "union" || schema.type === "variant") { + Object.assign( + data, + schema.options + .map((option) => { + const result: Record = {}; + + updateConstraint(option, result, name); + + return result; + }) + .reduce((prev, next) => { + const list = new Set([...Object.keys(prev), ...Object.keys(next)]); + const result: Record = {}; + + for (const name of list) { + const prevConstraint = prev[name]; + const nextConstraint = next[name]; + + if (prevConstraint && nextConstraint) { + const constraint: Constraint = {}; + + result[name] = constraint; + + for (const key of keys) { + if ( + typeof prevConstraint[key] !== "undefined" && + typeof nextConstraint[key] !== "undefined" && + prevConstraint[key] === nextConstraint[key] + ) { + // @ts-expect-error Both are on the same type + constraint[key] = prevConstraint[key]; + } + } + } else { + result[name] = { + ...prevConstraint, + ...nextConstraint, + required: false, + }; + } + } + + return result; + }), + ); + } else if (name === "") { + // All the cases below are not allowed on root + throw new Error("Unsupported schema"); + } else if (schema.type === "array") { + constraint.multiple = true; + updateConstraint(schema.item, data, `${name}[]`); + } else if (schema.type === "string") { + const minLength = schema.pipe?.find( + (v) => "type" in v && v.type === "min_length", + ); + if (minLength && "requirement" in minLength) { + constraint.minLength = minLength.requirement as number; + } + const maxLength = schema.pipe?.find( + (v) => "type" in v && v.type === "max_length", + ); + if (maxLength && "requirement" in maxLength) { + constraint.maxLength = maxLength.requirement as number; + } + } else if (schema.type === "optional") { + constraint.required = false; + updateConstraint(schema.wrapped, data, name); + } else if (schema.type === "number") { + const minValue = schema.pipe?.find( + (v) => "type" in v && v.type === "min_value", + ); + if (minValue && "requirement" in minValue) { + constraint.min = minValue.requirement as number; + } + const maxValue = schema.pipe?.find( + (v) => "type" in v && v.type === "max_value", + ); + if (maxValue && "requirement" in maxValue) { + constraint.max = maxValue.requirement as number; + } + } else if (schema.type === "enum") { + constraint.pattern = Object.entries(schema.enum) + .map(([_, option]) => + // To escape unsafe characters on regex + typeof option === "string" + ? option + .replace(/[|\\{}()[\]^$+*?.]/g, "\\$&") + .replace(/-/g, "\\x2d") + : option, + ) + .join("|"); + } else if (schema.type === "tuple") { + for (let i = 0; i < schema.items.length; i++) { + updateConstraint(schema.items[i], data, `${name}[${i}]`); + } + } else { + // FIXME: If you are interested in this, feel free to create a PR + } + } + + const result: Record = {}; + + updateConstraint(schema, result); + + return result; +} diff --git a/index.ts b/index.ts index 82d305d..fab7098 100644 --- a/index.ts +++ b/index.ts @@ -1 +1,2 @@ +export { getValibotConstraint } from "./constraint"; export { parseWithValibot } from "./parse"; diff --git a/tests/constraint.test.ts b/tests/constraint.test.ts new file mode 100644 index 0000000..7b0eaff --- /dev/null +++ b/tests/constraint.test.ts @@ -0,0 +1,301 @@ +import { describe, test, expect } from "vitest"; +import { + custom, + maxLength, + minLength, + object, + string, + number, + minValue, + maxValue, + date, + optional, + boolean, + array, + enum_, + instance, + intersect, + union, + literal, + tuple, + variant, +} from "valibot"; +import { getValibotConstraint } from "../constraint"; + +enum TestEnum { + a = "a", + b = "b", + c = "c", +} + +describe("constraint", () => { + test("getValibotConstraint", () => { + const schema = object( + { + text: string("required", [ + minLength(10, "min"), + maxLength(100, "max"), + custom(() => false, "refine"), + ]), + number: number("required", [ + minValue(1, "min"), + maxValue(10, "max"), + custom((v) => v % 2 === 0, "step"), + ]), + timestamp: optional( + date([minValue(new Date(1), "min"), maxValue(new Date(), "max")]), + new Date(), + ), + flag: optional(boolean()), + options: array(enum_(TestEnum), [minLength(3, "min")]), + nested: object( + { + key: string([custom(() => false, "refine")]), + }, + [custom(() => false, "refine")], + ), + list: array( + object( + { + key: string("required", [custom(() => false, "refine")]), + }, + [custom(() => false, "refine")], + ), + [maxLength(0, "max")], + ), + files: array(instance(Date, "Invalid file"), [ + minLength(1, "required"), + ]), + tuple: tuple([ + string([minLength(3, "min")]), + optional(number([maxValue(100, "max")])), + ]), + }, + [custom(() => false, "refine")], + ); + const constraint = { + text: { + required: true, + minLength: 10, + maxLength: 100, + }, + number: { + required: true, + min: 1, + max: 10, + }, + timestamp: { + required: false, + }, + flag: { + required: false, + }, + options: { + required: true, + multiple: true, + }, + "options[]": { + required: true, + pattern: "a|b|c", + }, + files: { + required: true, + multiple: true, + }, + "files[]": { + required: true, + }, + nested: { + required: true, + }, + "nested.key": { + required: true, + }, + list: { + required: true, + multiple: true, + }, + "list[]": { + required: true, + }, + "list[].key": { + required: true, + }, + tuple: { + required: true, + }, + "tuple[0]": { + required: true, + minLength: 3, + }, + "tuple[1]": { + required: false, + max: 100, + }, + }; + + expect(getValibotConstraint(schema)).toEqual(constraint); + + // Non-object schemas will throw an error + expect(() => getValibotConstraint(string())).toThrow(); + expect(() => getValibotConstraint(array(string()))).toThrow(); + + // Intersection is supported + expect( + getValibotConstraint( + intersect([ + schema, + object({ text: optional(string()), something: string() }), + ]), + ), + ).toEqual({ + ...constraint, + text: { required: false }, + something: { required: true }, + }); + + // Union is supported + expect( + getValibotConstraint( + intersect([ + union([ + object({ + type: literal("a"), + foo: string([minLength(1, "min")]), + baz: string([minLength(1, "min")]), + }), + object({ + type: literal("b"), + bar: string([minLength(1, "min")]), + baz: string([minLength(1, "min")]), + }), + ]), + object({ + qux: string([minLength(1, "min")]), + }), + ]), + ), + ).toEqual({ + type: { required: true }, + foo: { required: false, minLength: 1 }, + bar: { required: false, minLength: 1 }, + baz: { required: true, minLength: 1 }, + qux: { required: true, minLength: 1 }, + }); + + // Discriminated union is also supported + expect( + getValibotConstraint( + intersect([ + variant("type", [ + object({ + type: literal("a"), + foo: string([minLength(1, "min")]), + baz: string([minLength(1, "min")]), + }), + object({ + type: literal("b"), + bar: string([minLength(1, "min")]), + baz: string([minLength(1, "min")]), + }), + ]), + object({ + qux: string([minLength(1, "min")]), + }), + ]), + ), + ).toEqual({ + type: { required: true }, + foo: { required: false, minLength: 1 }, + bar: { required: false, minLength: 1 }, + baz: { required: true, minLength: 1 }, + qux: { required: true, minLength: 1 }, + }); + + // // Recursive schema should be supported too + // const baseCategorySchema = z.object({ + // name: z.string(), + // }); + + // type Category = z.infer & { + // subcategories: Category[]; + // }; + + // const categorySchema: z.ZodType = baseCategorySchema.extend({ + // subcategories: z.lazy(() => categorySchema.array()), + // }); + + // expect( + // getZodConstraint(categorySchema), + // ).toEqual({ + // name: { + // required: true, + // }, + // subcategories: { + // required: true, + // multiple: true, + // }, + + // 'subcategories[].name': { + // required: true, + // }, + // 'subcategories[].subcategories': { + // required: true, + // multiple: true, + // }, + + // 'subcategories[].subcategories[].name': { + // required: true, + // }, + // 'subcategories[].subcategories[].subcategories': { + // required: true, + // multiple: true, + // }, + // }); + + // type Condition = { type: 'filter' } | { type: 'group', conditions: Condition[] } + + // const ConditionSchema: z.ZodType = z.discriminatedUnion('type', [ + // z.object({ + // type: z.literal('filter') + // }), + // z.object({ + // type: z.literal('group'), + // conditions: z.lazy(() => ConditionSchema.array()), + // }), + // ]); + + // const FilterSchema = z.object({ + // type: z.literal('group'), + // conditions: ConditionSchema.array(), + // }) + + // expect( + // getZodConstraint(FilterSchema), + // ).toEqual({ + // type: { + // required: true, + // }, + // conditions: { + // required: true, + // multiple: true, + // }, + + // 'conditions[].type': { + // required: true, + // }, + // 'conditions[].conditions': { + // required: true, + // multiple: true, + // }, + + // 'conditions[].conditions[].type': { + // required: true, + // }, + // 'conditions[].conditions[].conditions': { + // required: true, + // multiple: true, + // }, + // }); + }); +}); diff --git a/types/schema.ts b/types/schema.ts new file mode 100644 index 0000000..9380e60 --- /dev/null +++ b/types/schema.ts @@ -0,0 +1,70 @@ +import type { + NullSchema, + NullableSchema, + OptionalSchema, + NullishSchema, + NonNullableSchema, + NonOptionalSchema, + NonNullishSchema, + ObjectSchema as ValibotObjectSchema, + ObjectEntries, + UnionSchema, + UnionOptions, + IntersectSchema, + IntersectOptions, + BaseSchema, + ArraySchema, + BigintSchema, + BooleanSchema, + DateSchema, + EnumSchema, + Enum, + LiteralSchema, + Literal, + NumberSchema, + PicklistSchema, + PicklistOptions, + StringSchema, + UndefinedSchema, + TupleSchema, + TupleItems, + VariantSchema, + VariantOptions, +} from "valibot"; + +export type WrapWithDefaultSchema = + | NullSchema + | OptionalSchema + | NullableSchema + | NullishSchema; + +export type WrapWithoutDefaultSchema = + | NonNullableSchema + | NonOptionalSchema + | NonNullableSchema + | NonNullishSchema; + +export type WrapSchema = WrapWithDefaultSchema | WrapWithoutDefaultSchema; + +export type ObjectSchema = ValibotObjectSchema; + +export type ValibotSchema = + | ObjectSchema + | StringSchema + | ArraySchema + | BigintSchema + | BooleanSchema + | DateSchema + | EnumSchema + | LiteralSchema + | NumberSchema + | PicklistSchema + | UndefinedSchema; + +export type OptionsSchema = + | UnionSchema + | VariantSchema> + | IntersectSchema + | TupleSchema; + +export type AllSchema = ValibotSchema | WrapSchema | OptionsSchema | BaseSchema;