diff --git a/bun.lockb b/bun.lockb index 4ef05dd..22706bc 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index cdf297a..2adc4f6 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "devDependencies": { "@effect/schema": "^0.72.0", "@prisma/studio-server": "^0.502.0", + "@tanstack/form-core": "^0.30.0", "@types/jsonwebtoken": "^9.0.6", "bun-types": "^1.1.26", "effect": "^3.7.0", diff --git a/src/Schema/TanStackForm/makeSchemaFormValidator.ts b/src/Schema/TanStackForm/makeSchemaFormValidator.ts new file mode 100644 index 0000000..4349e3a --- /dev/null +++ b/src/Schema/TanStackForm/makeSchemaFormValidator.ts @@ -0,0 +1,138 @@ +import { ArrayFormatter, Schema } from "@effect/schema" +import type { ParseOptions } from "@effect/schema/AST" +import type { DeepKeys, DeepValue, FieldApi, FormApi, FormValidationError, ValidationError, Validator } from "@tanstack/react-form" +import { Array, Effect, Either, flow, Layer, ManagedRuntime, pipe } from "effect" +import { mapValues } from "remeda" + + +export const makeSchemaFormValidator = ( + schema: Schema.Schema, + options?: ParseOptions, +) => { + const decodeEither = Schema.decodeEither(schema, options) + + return < + TFormData extends I, + TFormValidator extends Validator | undefined, + >(props: { + value: TFormData + formApi: FormApi + }): FormValidationError => + decodeEither(props.value).pipe(result => + Either.isLeft(result) + ? { + form: "Please check the fields", + fields: issuesToFieldsRecord(ArrayFormatter.formatErrorSync(result.left), props.formApi) as any, + } + : null + ) +} + +export const makeSchemaAsyncFormValidator = ( + schema: Schema.Schema, + options?: ParseOptions, +) => { + const runtime = ManagedRuntime.make(Layer.empty) + const decode = Schema.decode(schema, options) + + return < + TFormData extends I, + TFormValidator extends Validator | undefined, + >(props: { + value: TFormData + formApi: FormApi + signal: AbortSignal + }): Promise> => + decode(props.value).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.succeed(null), + + onFailure: e => ArrayFormatter.formatError(e).pipe( + Effect.map(issues => ({ + form: "Please check the fields", + fields: issuesToFieldsRecord(issues, props.formApi) as any, + })) + ), + }), + + prgm => runtime.runPromise(prgm, { signal: props.signal }), + ) +} + +/** + * TODO: handle nested array fields + */ +const issuesToFieldsRecord = < + TFormData, + TFormValidator extends Validator | undefined = undefined, +>( + issues: readonly ArrayFormatter.Issue[], + _formApi: FormApi, +) => pipe(issues, + Array.groupBy(issue => issue.path.join(".")), + + mapValues(flow( + Array.map(issue => issue.message), + Array.join("\n"), + )), +) + + +export const makeSchemaFieldValidator = ( + schema: Schema.Schema, + options?: ParseOptions, +) => { + const decodeEither = Schema.decodeEither(schema, options) + + return < + TParentData, + TName extends DeepKeys, + TFieldValidator extends Validator, unknown> | undefined, + TFormValidator extends Validator | undefined, + TData extends I & DeepValue, + >(props: { + value: TData + fieldApi: FieldApi + }): ValidationError => + decodeEither(props.value).pipe(result => + Either.isLeft(result) + ? ArrayFormatter.formatErrorSync(result.left) + .map(issue => issue.message) + .join("\n") + : null + ) +} + +export const makeSchemaAsyncFieldValidator = ( + schema: Schema.Schema, + options?: ParseOptions, +) => { + const runtime = ManagedRuntime.make(Layer.empty) + const decode = Schema.decode(schema, options) + + return < + TParentData, + TName extends DeepKeys, + TFieldValidator extends Validator, unknown> | undefined, + TFormValidator extends Validator | undefined, + TData extends I & DeepValue, + >(props: { + value: TData + fieldApi: FieldApi + signal: AbortSignal + }): Promise => + decode(props.value).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.succeed(null), + + onFailure: e => ArrayFormatter.formatError(e).pipe( + Effect.map(flow( + Array.map(issue => issue.message), + Array.join("\n"), + )) + ), + }), + + prgm => runtime.runPromise(prgm, { signal: props.signal }), + ) +}