Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate by schema (zod or other) #17

Closed
MiroslavPetrik opened this issue Jan 24, 2023 · 3 comments
Closed

Validate by schema (zod or other) #17

MiroslavPetrik opened this issue Jan 24, 2023 · 3 comments

Comments

@MiroslavPetrik
Copy link
Member

MiroslavPetrik commented Jan 24, 2023

Is your feature request related to a problem? Please describe.

The atoms can be validated by passing validate function into the config, but this does not scale well, as we don't want to write code for each field atom separately.

import { z } from "zod";

export const productSchema = z.object({
  name: z.string().min(3),
  // rest of the fields
});

const nameAtom = fieldAtom({
    value: "",
    validate: ({ value }) => {
      try {
        productSchema.pick({ name: true }).parse({ name: value });
        return [];
      } catch (err) {
        if (err instanceof z.ZodError) {
          return err.issues.map((issue) => issue.message);
        }
      }
    },
  }),

Ultimately, the library should accept a schema against which the atoms would automatically validate. This is common practice e.g. in react-hook-form.

Describe the solution you'd like

Solution would be to provide a integration with one or more popular schema validation libraries:

import { validateBySchema } from "form-atoms/zod";

const nameAtom = fieldAtom({
    value: "",
    validate: validateBySchema(productSchema, "name"),
  }),

// definition of validation helper
export const validateBySchema =
  <V, T extends ZodRawShape>(
    schema: ZodObject<T>,
    fieldName: keyof T
  ): FieldAtomConfig<V>["validate"] =>
  ({ value }) => {
    try {
      // TODO: generic type help needed
      // @ts-ignore
      schema.pick({ [fieldName]: true }).parse({ [fieldName]: value });
      return [];
    } catch (err) {
      console.info({ err });
      if (err instanceof ZodError) {
        return err.issues.map((issue) => issue.message);
      }
    }
  };

This reduces the boilerplate code as we can easily validate other fields:

export const productSchema = z.object({
  name: z.string().min(3),
  description: z.string().min(20),
});

const descriptionAtom = fieldAtom({
  value: "",
  validate: validateBySchema(productSchema, "description"),
}),

Describe alternatives you've considered

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.

Additional context

The same issue has discussion in the jotai-form project:
jotaijs/jotai-form#2

@jaredLunde
Copy link
Collaborator

jaredLunde commented Jan 28, 2023

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.

@jaredLunde jaredLunde closed this as not planned Won't fix, can't repro, duplicate, stale Jan 28, 2023
@jaredLunde
Copy link
Collaborator

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.

@jaredLunde
Copy link
Collaborator

jaredLunde commented Jan 28, 2023

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 [];
    },
  }),
});

@jaredLunde jaredLunde mentioned this issue Jan 28, 2023
Closed
14 tasks
jaredLunde added a commit that referenced this issue Feb 1, 2023
Refactor library to support Jotai v2 breaking changes. Rename form and field hooks to exclude
"Atom". Rename types to be consistent across the library.

BREAKING CHANGE: Renames form and field hooks to exclude "Atom" and be more terse. Renames most
exported types and several type signatures.

fix #27 #28 #18 #17
jaredLunde added a commit that referenced this issue Feb 2, 2023
- Refactor library to support Jotai v2 breaking changes. 
- Rename form and field hooks to exclude "Atom". 
- Rename types to be consistent across the library.

BREAKING CHANGE: Renames form and field hooks to exclude "Atom" and be more terse. Renames most
exported types and several type signatures.

fix #27 #28 #18 #17
github-actions bot pushed a commit that referenced this issue Feb 2, 2023
# [2.0.0-next.1](v1.3.0-next.3...v2.0.0-next.1) (2023-02-02)

### Code Refactoring

* upgrade to jotai v2 ([#29](#29)) ([e533e40](e533e40)), closes [#27](#27) [#28](#28) [#18](#18) [#17](#17)

### BREAKING CHANGES

* Renames form and field hooks to exclude "Atom" and be more terse. Renames most
exported types and several type signatures.
github-actions bot pushed a commit that referenced this issue Feb 2, 2023
# [2.0.0](v1.2.5...v2.0.0) (2023-02-02)

### Bug Fixes

* empty arrays not included in submit values ([#31](#31)) ([837140d](837140d)), closes [#26](#26)
* fix nested array walk ([#34](#34)) ([448f538](448f538))
* fix package entries ([#24](#24)) ([a18cfc5](a18cfc5))
* fix release ([#22](#22)) ([a4fff3b](a4fff3b))
* fix reset w/ initial value ([#33](#33)) ([8b49243](8b49243))

### Code Refactoring

* upgrade to jotai v2 ([#29](#29)) ([e533e40](e533e40)), closes [#27](#27) [#28](#28) [#18](#18) [#17](#17)

### Features

* add zod validator ([#23](#23)) ([06ca2c4](06ca2c4))
* bump next major ([#35](#35)) ([fb3400e](fb3400e)), closes [#20](#20)
* update build scripts and tests ([#21](#21)) ([b242e02](b242e02))

### BREAKING CHANGES

* Renames form and field hooks to exclude "Atom" and be more terse. Renames most
exported types and several type signatures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants