Coder Social home page Coder Social logo

Comments (3)

jaredLunde avatar jaredLunde commented on June 8, 2024 1

This doesn't belong in this library. I use my own zod validator with it in my personal projects. That seems right to me. You're always free to maintain and distribute your own NPM package for this 😃

I understand form-atoms/zod style imports are what some other libraries do, but it's not what I want to do. The community should own it if it wills it and we can put it in the README if it's good.

from form-atoms.

jaredLunde avatar jaredLunde commented on June 8, 2024 1

Here is what I'm using:

// zod-validate.ts
import type { FieldAtomValidateOn, Validate } from "form-atoms";
import type { Getter } from "jotai";
import type { z } from "zod";
import { ZodError, ZodType } from "zod";

export function zodValidate<Value>(
  schema: ((get: Getter) => z.Schema) | z.Schema,
  config: ZodValidateConfig = {}
) {
  const {
    on,
    ifDirty,
    ifTouched,
    formatError = (err) => err.flatten().formErrors,
    fatal = false,
  } = config;
  const ors: ((
    state: Parameters<Exclude<Validate<Value>, undefined>>[0]
  ) => Promise<string[] | undefined>)[] = [];

  const chain = Object.assign(
    async (
      state: Parameters<Exclude<Validate<Value>, undefined>>[0]
    ): Promise<string[] | undefined> => {
      let result: string[] | undefined;
      const shouldHandleEvent = !on || on.includes(state.event);

      if (shouldHandleEvent) {
        const shouldHandleDirty =
          ifDirty === undefined || ifDirty === state.dirty;
        const shouldHandleTouched =
          ifTouched === undefined || ifTouched === state.touched;

        if (shouldHandleDirty && shouldHandleTouched) {
          const validator =
            schema instanceof ZodType ? schema : schema(state.get);

          try {
            await validator.parseAsync(state.value);
            result = [];
          } catch (err) {
            if (err instanceof ZodError) {
              return formatError(err);
            }

            throw err;
          }
        }
      }

      if (ors.length > 0) {
        for (const or of ors) {
          const errors = await or(state);

          if (errors?.length) {
            result = errors;
            break;
          } else if (errors) {
            result = errors;
          }

          if (fatal && result) {
            return result;
          }
        }
      }

      return result;
    },
    {
      or(config: Omit<ZodValidateConfig, "fatal" | "formatError">) {
        const or = zodValidate(schema, { formatError, fatal, ...config });
        ors.push(or);
        return chain;
      },
    }
  );

  return chain;
}

export type ZodValidateConfig = {
  on?: FieldAtomValidateOn | FieldAtomValidateOn[];
  ifTouched?: boolean;
  ifDirty?: boolean;
  formatError?: (error: ZodError) => string[];
  fatal?: boolean;
};
// zod-validate.test.ts
import { act as domAct, renderHook } from "@testing-library/react";
import { fieldAtom, useFieldAtom } from "form-atoms";
import { z } from "zod";

import { zodValidate } from "./zod-validate";

describe("zodValidate()", () => {
  it("should validate without a config", async () => {
    const nameAtom = fieldAtom({
      value: "",
      validate: zodValidate(z.string().min(3, "3 plz")),
    });

    const field = renderHook(() => useFieldAtom(nameAtom));

    domAct(() => {
      field.result.current.actions.validate();
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("invalid");
    expect(field.result.current.state.errors).toEqual(["3 plz"]);
  });

  it("should throw multiple errors", async () => {
    const nameAtom = fieldAtom({
      value: "",
      validate: zodValidate(
        z.string().min(3, "3 plz").regex(/foo/, "must match foo"),
        {
          fatal: false,
        }
      ),
    });

    const field = renderHook(() => useFieldAtom(nameAtom));

    domAct(() => {
      field.result.current.actions.validate();
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("invalid");
    expect(field.result.current.state.errors).toEqual([
      "3 plz",
      "must match foo",
    ]);
  });
  it("should use custom error formatting", async () => {
    const nameAtom = fieldAtom({
      value: "",
      validate: zodValidate(
        z.string().min(3, "3 plz").regex(/foo/, "must match foo"),
        {
          formatError: (err) =>
            err.errors.map((e) =>
              JSON.stringify({ code: e.code, message: e.message })
            ),
          fatal: false,
        }
      ),
    });

    const field = renderHook(() => useFieldAtom(nameAtom));

    domAct(() => {
      field.result.current.actions.validate();
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("invalid");
    expect(field.result.current.state.errors).toEqual([
      JSON.stringify({ code: "too_small", message: "3 plz" }),
      JSON.stringify({ code: "invalid_string", message: "must match foo" }),
    ]);
  });

  it("should validate 'on' a given event", async () => {
    const nameAtom = fieldAtom({
      value: "",
      validate: zodValidate(z.string().min(3, "3 plz"), { on: "change" }),
    });

    const field = renderHook(() => useFieldAtom(nameAtom));

    domAct(() => {
      field.result.current.actions.validate();
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("valid");
    expect(field.result.current.state.errors).toEqual([]);

    domAct(() => {
      field.result.current.actions.setValue("f");
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("invalid");
    expect(field.result.current.state.errors).toEqual(["3 plz"]);
  });

  it("should validate only when dirty", async () => {
    const nameAtom = fieldAtom({
      value: "",
      validate: zodValidate(z.string().min(3, "3 plz"), {
        ifDirty: true,
      }),
    });

    const field = renderHook(() => useFieldAtom(nameAtom));

    domAct(() => {
      field.result.current.actions.validate();
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("valid");
    expect(field.result.current.state.errors).toEqual([]);

    domAct(() => {
      field.result.current.actions.setValue("f");
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("invalid");
    expect(field.result.current.state.errors).toEqual(["3 plz"]);
  });

  it("should validate only when touched", async () => {
    const nameAtom = fieldAtom({
      value: "",
      validate: zodValidate(z.string().min(3, "3 plz"), {
        ifTouched: true,
      }),
    });

    const field = renderHook(() => useFieldAtom(nameAtom));

    domAct(() => {
      field.result.current.actions.setTouched(true);
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("invalid");
    expect(field.result.current.state.errors).toEqual(["3 plz"]);
  });

  it("should validate multiple conditions", async () => {
    const nameAtom = fieldAtom({
      value: "",
      validate: zodValidate(z.string().min(3, "3 plz"), {
        on: "user",
      }).or({ on: "change", ifDirty: true }),
    });

    const field = renderHook(() => useFieldAtom(nameAtom));

    domAct(() => {
      field.result.current.actions.validate();
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("invalid");
    expect(field.result.current.state.errors).toEqual(["3 plz"]);

    domAct(() => {
      field.result.current.actions.setValue("foo bar");
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("valid");
    expect(field.result.current.state.errors).toEqual([]);

    domAct(() => {
      field.result.current.actions.setValue("fo");
    });

    await domAct(() => Promise.resolve());
    expect(field.result.current.state.validateStatus).toBe("invalid");
    expect(field.result.current.state.errors).toEqual(["3 plz"]);
  });
});
// validate.ts
import type { Getter } from "jotai";
import type { z } from "zod";

import type { ZodValidateConfig } from "./zod-validate";
import { zodValidate } from "./zod-validate";

export const validate = Object.assign(
  (
    schema: ((get: Getter) => z.Schema) | z.Schema,
    config: ValidateConfig = {}
  ) => {
    return zodValidate(schema, {
      on: ["submit", "user"],
      formatError(error) {
        return error.errors.map((err) => JSON.stringify(err));
      },
      ...config,
    });
  },
  {
    onBlur: validateOnBlur,
    onChange: validateOnChange,
  }
);

export function validateOnBlur(
  schema: ((get: Getter) => z.Schema) | z.Schema,
  config: ValidateConfig = {}
) {
  return validate(schema).or({ on: "blur", ifDirty: true, ...config });
}

export function validateOnChange(
  schema: ((get: Getter) => z.Schema) | z.Schema,
  config: ValidateConfig = {}
) {
  return validate(schema)
    .or({
      on: ["blur"],
      ifDirty: true,
    })
    .or({
      on: ["change"],
      ifTouched: true,
      ...config,
    });
}

type ValidateConfig = Omit<ZodValidateConfig, "abortEarly" | "formatError">;

Usage

const signUpFormAtom = formAtom({
  email: fieldAtom({
    name: "email",
    value: "",
    validate: validate.onChange(authSchema.email),
  }),

  password: fieldAtom({
    name: "password",
    value: "",
    validate: validate.onChange(authSchema.password),
  }),

  tos: fieldAtom({
    name: "tos",
    value: true,
    validate({ value }) {
      if (!value) {
        return ["You must accept the terms of service to continue"];
      }

      return [];
    },
  }),
});

from form-atoms.

jaredLunde avatar jaredLunde commented on June 8, 2024

Alternative would be to have the validation not on the individual atoms, but on the formAtom which would just accept the schema object and validate each atom respectively.

Also this would not work within the design of the library - and that's intentional. It is in the README if you'd like to know why.

from form-atoms.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.