From 44aa7ac778428e4df8ba723e06f19fbbe5219037 Mon Sep 17 00:00:00 2001 From: MiroslavPetrik Date: Tue, 12 Mar 2024 10:24:28 +0100 Subject: [PATCH] feat(extend-schema): better schema callback with the initial schema for extension (#122) * feat(extendable-schema): add combinator for user & initial schema * configure datefield * configure arrayField * configure string, booleans, digit * story examples * listfield with extendable schema * fix stories * fix nesting errors * update rejdme --- README.md | 2 +- .../checkbox-group/CheckboxGroup.stories.tsx | 8 +- .../CheckboxGroupField.mock.tsx | 4 +- .../multi-select/MultiSelect.stories.tsx | 4 +- .../radio-group/RadioGroup.stories.tsx | 8 +- src/components/select/Select.stories.tsx | 8 +- src/fields/array-field/arrayField.test.ts | 27 ++++++- src/fields/array-field/arrayField.ts | 38 +++++----- src/fields/boolean-field/booleanField.ts | 7 +- .../checkbox-field/CheckboxInput.mock.tsx | 5 +- src/fields/checkbox-field/checkboxField.ts | 10 +-- src/fields/date-field/DateInput.mock.tsx | 5 +- src/fields/date-field/dateField.stories.tsx | 17 +++++ src/fields/date-field/dateField.test.ts | 25 +++++++ src/fields/date-field/dateField.ts | 19 +++-- src/fields/digit-field/digitField.ts | 10 +-- src/fields/field.ts | 15 ++++ src/fields/files-field/FilesInput.mock.tsx | 5 +- src/fields/files-field/filesField.stories.tsx | 10 +-- src/fields/files-field/filesField.test.ts | 32 +++++++- src/fields/list-field/Docs.mdx | 8 +- src/fields/list-field/listField.stories.tsx | 7 +- src/fields/list-field/listField.test.tsx | 32 +++++++- src/fields/list-field/listField.ts | 32 +++++--- src/fields/number-field/NumberInput.mock.tsx | 5 +- .../number-field/numberField.stories.tsx | 17 +++++ src/fields/number-field/numberField.test.ts | 24 ++++++ src/fields/number-field/numberField.ts | 18 +++-- src/fields/string-field/stringField.test.ts | 22 ++++++ src/fields/string-field/stringField.ts | 17 ++++- src/fields/text-field/TextInput.mock.tsx | 5 +- src/fields/text-field/textField.stories.tsx | 11 +++ src/fields/text-field/textField.test.ts | 26 ++++++- src/fields/text-field/textField.ts | 27 +++++-- src/fields/zod-field/zodField.ts | 10 +-- src/scenarios/StoryForm.tsx | 4 +- src/utils/index.ts | 2 + src/{atoms => utils}/schemaValidate.ts | 0 src/utils/userValidateConfig.ts | 75 +++++++++++++++++++ 39 files changed, 476 insertions(+), 125 deletions(-) create mode 100644 src/fields/date-field/dateField.test.ts create mode 100644 src/fields/field.ts create mode 100644 src/fields/number-field/numberField.test.ts create mode 100644 src/fields/string-field/stringField.test.ts create mode 100644 src/utils/index.ts rename src/{atoms => utils}/schemaValidate.ts (100%) create mode 100644 src/utils/userValidateConfig.ts diff --git a/README.md b/README.md index 722e333..1954262 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ import { z } from "zod"; const personForm = formAtom({ name: textField(), - age: numberField({ schema: z.number().min(18) }); // override default schema + age: numberField({ schema: (s) => s.min(18) }); // extend the default schema character: stringField().optional(); // make field optional }); diff --git a/src/components/checkbox-group/CheckboxGroup.stories.tsx b/src/components/checkbox-group/CheckboxGroup.stories.tsx index 5a913a6..4c635e8 100644 --- a/src/components/checkbox-group/CheckboxGroup.stories.tsx +++ b/src/components/checkbox-group/CheckboxGroup.stories.tsx @@ -24,18 +24,14 @@ const checkboxGroupStory = ( Omit>, "field">; } & Omit, "args">, ) => ({ - ...storyObj, decorators: [ (Story: () => JSX.Element) => ( - {() => ( -

- -

- )} + {() => }
), ], + ...storyObj, }); export const Required = checkboxGroupStory({ diff --git a/src/components/checkbox-group/CheckboxGroupField.mock.tsx b/src/components/checkbox-group/CheckboxGroupField.mock.tsx index 41257e8..5a1909e 100644 --- a/src/components/checkbox-group/CheckboxGroupField.mock.tsx +++ b/src/components/checkbox-group/CheckboxGroupField.mock.tsx @@ -13,14 +13,14 @@ export const CheckboxGroupField = ({ }: CheckboxGroupFieldProps) => ( <> -

+

-

+
); diff --git a/src/components/multi-select/MultiSelect.stories.tsx b/src/components/multi-select/MultiSelect.stories.tsx index 2909145..5c0612f 100644 --- a/src/components/multi-select/MultiSelect.stories.tsx +++ b/src/components/multi-select/MultiSelect.stories.tsx @@ -19,11 +19,11 @@ const MultiSelectField = ({ }: { label: ReactNode; } & MultiSelectProps) => ( -
+ <> -
+ ); export const RequiredArrayString = formStory({ diff --git a/src/components/radio-group/RadioGroup.stories.tsx b/src/components/radio-group/RadioGroup.stories.tsx index 06d1607..2d10b24 100644 --- a/src/components/radio-group/RadioGroup.stories.tsx +++ b/src/components/radio-group/RadioGroup.stories.tsx @@ -27,18 +27,14 @@ const radioGroupStory = ( Omit>, "field">; } & Omit, "args">, ) => ({ - ...storyObj, decorators: [ (Story: () => JSX.Element) => ( - {() => ( -

- -

- )} + {() => }
), ], + ...storyObj, }); const bashAnswers = [ diff --git a/src/components/select/Select.stories.tsx b/src/components/select/Select.stories.tsx index 64682a1..7ef4b72 100644 --- a/src/components/select/Select.stories.tsx +++ b/src/components/select/Select.stories.tsx @@ -27,18 +27,14 @@ const selectStory = ( Omit>, "field">; } & Omit, "args">, ) => ({ - ...storyObj, decorators: [ (Story: () => JSX.Element) => ( - {() => ( -

- -

- )} + {() => }
), ], + ...storyObj, }); export const RequiredString = selectStory({ diff --git a/src/fields/array-field/arrayField.test.ts b/src/fields/array-field/arrayField.test.ts index 2f9b0c9..415b7d7 100644 --- a/src/fields/array-field/arrayField.test.ts +++ b/src/fields/array-field/arrayField.test.ts @@ -1,5 +1,10 @@ -import { renderHook } from "@testing-library/react"; -import { formAtom, useFormValues } from "form-atoms"; +import { act, renderHook } from "@testing-library/react"; +import { + formAtom, + useFieldActions, + useFieldErrors, + useFormValues, +} from "form-atoms"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -20,4 +25,22 @@ describe("arrayField()", () => { explicitUndefined: [], }); }); + + describe("schema", () => { + it("extends the internal schema", async () => { + const field = arrayField({ + elementSchema: z.number(), + value: [1, 2, 3], + schema: (s) => s.max(2), + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + expect(errors.current).toEqual([ + "Array must contain at most 2 element(s)", + ]); + }); + }); }); diff --git a/src/fields/array-field/arrayField.ts b/src/fields/array-field/arrayField.ts index a44ee5f..2225fc4 100644 --- a/src/fields/array-field/arrayField.ts +++ b/src/fields/array-field/arrayField.ts @@ -1,35 +1,39 @@ -import { ArrayCardinality, ZodAny, ZodArray, ZodSchema, z } from "zod"; +import type { ArrayCardinality, ZodAny, ZodArray, ZodSchema } from "zod"; +import { z } from "zod"; -import { - ZodField, - ZodFieldConfig, - ZodParams, - defaultParams, - zodField, -} from "../zod-field"; +import { prepareSchema } from "../../utils"; +import { FieldConfig } from "../field"; +import { type ZodField, defaultParams, zodField } from "../zod-field"; export type ZodArrayField = ZodField< ZodArray, ZodArray >; -export type ArrayFieldParams = Partial< - ZodFieldConfig< - ZodArray, - ZodArray - > -> & - ZodParams; +export type ArrayFieldParams = FieldConfig< + ZodArray, + ZodArray +>; export const arrayField = ({ required_error = defaultParams.required_error, value = [], elementSchema, + schema, + optionalSchema, ...config }: { elementSchema: ElementSchema } & ArrayFieldParams) => zodField({ value, - schema: z.array(elementSchema).nonempty(required_error), - optionalSchema: z.array(elementSchema), + ...prepareSchema({ + initial: { + schema: z.array(elementSchema).nonempty(required_error), + optionalSchema: z.array(elementSchema), + }, + user: { + schema, + optionalSchema, + }, + }), ...config, }); diff --git a/src/fields/boolean-field/booleanField.ts b/src/fields/boolean-field/booleanField.ts index af722a1..840a50e 100644 --- a/src/fields/boolean-field/booleanField.ts +++ b/src/fields/boolean-field/booleanField.ts @@ -1,8 +1,9 @@ import { ExtractAtomValue } from "jotai"; import { ZodBoolean, z } from "zod"; -import { ZodFieldConfig, zodField } from ".."; -import { ZodParams, defaultParams } from "../zod-field/zodParams"; +import { zodField } from ".."; +import { FieldConfig } from "../field"; +import { defaultParams } from "../zod-field/zodParams"; export type BooleanField = ReturnType; @@ -13,7 +14,7 @@ export type BooleanFieldValue = ExtractAtomValue< export const booleanField = ({ required_error = defaultParams.required_error, ...config -}: Partial> & ZodParams = {}) => +}: Omit, "schema" | "optionalSchema"> = {}) => zodField({ value: undefined, schema: z.boolean({ required_error }), diff --git a/src/fields/checkbox-field/CheckboxInput.mock.tsx b/src/fields/checkbox-field/CheckboxInput.mock.tsx index 3c59fc1..27a5fcc 100644 --- a/src/fields/checkbox-field/CheckboxInput.mock.tsx +++ b/src/fields/checkbox-field/CheckboxInput.mock.tsx @@ -1,9 +1,10 @@ -import { FieldErrors, FieldLabel } from "../../components"; +import { FieldLabel } from "../../components"; import { type CheckboxFieldProps, useCheckboxFieldProps, useRequiredProps, } from "../../hooks"; +import { PicoFieldErrors } from "../../scenarios/PicoFieldErrors"; export const CheckboxInput = ({ field, @@ -19,7 +20,7 @@ export const CheckboxInput = ({
- +
); diff --git a/src/fields/checkbox-field/checkboxField.ts b/src/fields/checkbox-field/checkboxField.ts index c10ddff..f4b64c0 100644 --- a/src/fields/checkbox-field/checkboxField.ts +++ b/src/fields/checkbox-field/checkboxField.ts @@ -1,8 +1,9 @@ import { ExtractAtomValue } from "jotai"; import { ZodBoolean, ZodLiteral, z } from "zod"; -import { ZodFieldConfig, zodField } from ".."; -import { ZodParams, defaultParams } from "../zod-field/zodParams"; +import { zodField } from ".."; +import { FieldConfig } from "../field"; +import { defaultParams } from "../zod-field/zodParams"; export type CheckboxField = ReturnType; @@ -16,11 +17,10 @@ export const checkboxField = ({ ...config }: Partial< Omit< - ZodFieldConfig, ZodBoolean>, + FieldConfig, ZodBoolean>, "schema" | "optionalSchema" | "validate" > -> & - ZodParams = {}) => +> = {}) => zodField({ value, /** diff --git a/src/fields/date-field/DateInput.mock.tsx b/src/fields/date-field/DateInput.mock.tsx index 9bc147a..9e38f69 100644 --- a/src/fields/date-field/DateInput.mock.tsx +++ b/src/fields/date-field/DateInput.mock.tsx @@ -1,5 +1,6 @@ -import { FieldErrors, FieldLabel } from "../../components"; +import { FieldLabel } from "../../components"; import { DateFieldProps, useDateFieldProps } from "../../hooks"; +import { PicoFieldErrors } from "../../scenarios/PicoFieldErrors"; export const getDateString = (date: Date = new Date()) => date.toISOString().slice(0, 10); @@ -15,7 +16,7 @@ export const DateInput = ({ field, label, initialValue }: DateFieldProps) => { {...props} value={`${value ? getDateString(value) : ""}`} /> - + ); }; diff --git a/src/fields/date-field/dateField.stories.tsx b/src/fields/date-field/dateField.stories.tsx index a0a346c..2e6a1f1 100644 --- a/src/fields/date-field/dateField.stories.tsx +++ b/src/fields/date-field/dateField.stories.tsx @@ -45,6 +45,23 @@ export const InitializedInput = formStory({ }, }); +export const ExtendSchema = formStory({ + args: { + fields: { + deadline: dateField({ + name: "deadline", + schema: (s) => s.min(new Date(), { message: "Must be in the future" }), + }), + }, + children: ({ fields }) => ( + + ), + }, +}); + const nowPlusDays = (days = 0) => { const date = new Date(); date.setDate(date.getDate() + days); diff --git a/src/fields/date-field/dateField.test.ts b/src/fields/date-field/dateField.test.ts new file mode 100644 index 0000000..d8f679f --- /dev/null +++ b/src/fields/date-field/dateField.test.ts @@ -0,0 +1,25 @@ +import { act, renderHook } from "@testing-library/react"; +import { useFieldActions, useFieldErrors } from "form-atoms"; +import { describe, expect, it } from "vitest"; + +import { dateField } from "./dateField"; + +describe("dateField()", () => { + describe("schema", () => { + it("extends the internal schema", async () => { + const field = dateField({ + value: new Date("2000-01-01"), + schema: (s) => + s.max(new Date("1999-12-31"), { + message: "Date can't be in the 21st century", + }), + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + expect(errors.current).toEqual(["Date can't be in the 21st century"]); + }); + }); +}); diff --git a/src/fields/date-field/dateField.ts b/src/fields/date-field/dateField.ts index de5e0f6..3f31a53 100644 --- a/src/fields/date-field/dateField.ts +++ b/src/fields/date-field/dateField.ts @@ -1,8 +1,10 @@ -import { ExtractAtomValue } from "jotai"; +import { type ExtractAtomValue } from "jotai"; import { ZodDate, z } from "zod"; -import { ZodFieldConfig, zodField } from ".."; -import { ZodParams, defaultParams } from "../zod-field/zodParams"; +import { zodField } from ".."; +import { prepareSchema } from "../../utils"; +import { type FieldConfig } from "../field"; +import { defaultParams } from "../zod-field/zodParams"; export type DateField = ReturnType; @@ -12,10 +14,17 @@ export type DateFieldValue = ExtractAtomValue< export const dateField = ({ required_error = defaultParams.required_error, + schema, + optionalSchema, ...config -}: Partial> & ZodParams = {}) => +}: FieldConfig = {}) => zodField({ value: undefined, - schema: z.date({ required_error }), + ...prepareSchema({ + initial: { + schema: z.date({ required_error }), + }, + user: { schema, optionalSchema }, + }), ...config, }); diff --git a/src/fields/digit-field/digitField.ts b/src/fields/digit-field/digitField.ts index 0a5689a..942e5a4 100644 --- a/src/fields/digit-field/digitField.ts +++ b/src/fields/digit-field/digitField.ts @@ -1,8 +1,8 @@ import { ExtractAtomValue } from "jotai"; import { z } from "zod"; -import { ZodFieldConfig, zodField } from ".."; -import { ZodParams } from "../zod-field/zodParams"; +import { zodField } from ".."; +import { FieldConfig } from "../field"; export type DigitField = ReturnType; @@ -25,9 +25,9 @@ const zodDigitSchema = z.union([ type ZodDigitSchema = typeof zodDigitSchema; -export const digitField = ({ - ...config -}: Partial> & ZodParams = {}) => +export const digitField = ( + config: Omit, "schema" | "optionalSchema"> = {}, +) => zodField({ value: undefined, schema: zodDigitSchema, diff --git a/src/fields/field.ts b/src/fields/field.ts new file mode 100644 index 0000000..9f8819e --- /dev/null +++ b/src/fields/field.ts @@ -0,0 +1,15 @@ +import { FieldAtomConfig } from "form-atoms"; +import { type ZodUndefined, z } from "zod"; + +import { type ZodParams } from "./zod-field"; +import type { UserValidateConfig } from "../utils"; + +/** + * A public config for a field. + */ +export type FieldConfig< + Schema extends z.Schema, + OptSchema extends z.Schema = ZodUndefined, +> = Partial | z.output>> & + UserValidateConfig & + ZodParams; diff --git a/src/fields/files-field/FilesInput.mock.tsx b/src/fields/files-field/FilesInput.mock.tsx index 1c29eed..6cd8dc9 100644 --- a/src/fields/files-field/FilesInput.mock.tsx +++ b/src/fields/files-field/FilesInput.mock.tsx @@ -1,7 +1,8 @@ import { InputHTMLAttributes } from "react"; -import { FieldErrors, FieldLabel } from "../../components"; +import { FieldLabel } from "../../components"; import { type FilesFieldProps, useFilesFieldProps } from "../../hooks"; +import { PicoFieldErrors } from "../../scenarios/PicoFieldErrors"; export const FilesInput = ({ field, @@ -15,7 +16,7 @@ export const FilesInput = ({
- +
); diff --git a/src/fields/files-field/filesField.stories.tsx b/src/fields/files-field/filesField.stories.tsx index 8b2cbd8..3968ae0 100644 --- a/src/fields/files-field/filesField.stories.tsx +++ b/src/fields/files-field/filesField.stories.tsx @@ -1,5 +1,3 @@ -import { z } from "zod"; - import { filesField } from "./filesField"; import { FilesInput } from "./FilesInput.mock"; import { formStory, meta } from "../../scenarios/StoryForm"; @@ -31,25 +29,25 @@ export const Optional = formStory({ }, }); -export const Multiple = formStory({ +export const MultipleMax3 = formStory({ parameters: { docs: { description: { story: - "Pass custom schema to field config e.g. `z.array(z.instanceof(File)).nonempty().max(2)` to limit min/max number of files.", + "Pass a custom schema function to extend the default schema to limit min/max number of files. Here, we add `{schema: (s) => s.max(3)}` to the field config.", }, }, }, args: { fields: { attachments: filesField({ - schema: z.array(z.instanceof(File)).nonempty().max(2), + schema: (s) => s.max(3), }), }, children: ({ fields }) => ( ), diff --git a/src/fields/files-field/filesField.test.ts b/src/fields/files-field/filesField.test.ts index 09ccb2d..6b9cb7d 100644 --- a/src/fields/files-field/filesField.test.ts +++ b/src/fields/files-field/filesField.test.ts @@ -1,11 +1,16 @@ -import { renderHook } from "@testing-library/react"; -import { formAtom, useFormValues } from "form-atoms"; +import { act, renderHook } from "@testing-library/react"; +import { + formAtom, + useFieldActions, + useFieldErrors, + useFormValues, +} from "form-atoms"; import { describe, expect, it } from "vitest"; import { filesField } from "./filesField"; describe("filesField()", () => { - it("is initialized as empty string", () => { + it("is initialized as empty array", () => { const classic = filesField(); const explicitUndefined = filesField({ value: undefined }); @@ -17,4 +22,25 @@ describe("filesField()", () => { explicitUndefined: [], }); }); + + describe("schema", () => { + it("extends the internal schema", async () => { + const field = filesField({ + value: [ + new File(["logo"], "logo.jpeg", { type: "image/jpeg" }), + new File(["img"], "img.jpeg", { type: "image/jpeg" }), + new File(["avatar"], "avatar.jpeg", { type: "image/jpeg" }), + ], + schema: (s) => s.max(2), + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + expect(errors.current).toEqual([ + "Array must contain at most 2 element(s)", + ]); + }); + }); }); diff --git a/src/fields/list-field/Docs.mdx b/src/fields/list-field/Docs.mdx index 5ab999f..c9e028b 100644 --- a/src/fields/list-field/Docs.mdx +++ b/src/fields/list-field/Docs.mdx @@ -63,20 +63,20 @@ const environmentVariables = listField({ #### Type -`ZodArray` +`ZodArray | (schema: ZodArray) => ZodArray` #### Description -You can constrain the min or max number of list items by passing custom schema. +You can constrain the min or max number of list items by extending the default schema or passing a custom one: #### Usage -As an example, we can require the user to insert between 1-3 recovery phrases: +As an example, we can require the user to insert one or two recovery phrases: ```js const recoveryPhrases = listField({ value: [], - schema: z.array(z.any()).nonempty().max(2), + schema: (s) => s.max(2), fields: ({ hint, phrase }) => ({ hint: textField({ value: hint }), phrase: textField({ value: phrase }), diff --git a/src/fields/list-field/listField.stories.tsx b/src/fields/list-field/listField.stories.tsx index 643b9fc..78d27d0 100644 --- a/src/fields/list-field/listField.stories.tsx +++ b/src/fields/list-field/listField.stories.tsx @@ -6,7 +6,6 @@ import { } from "@form-atoms/list-atom"; import { StoryObj } from "@storybook/react"; import { FormFields, InputField } from "form-atoms"; -import { z } from "zod"; import { ListField } from "./ListField.mock"; import { TextField, listField, textField } from ".."; @@ -40,7 +39,6 @@ const listStory = ( args: ListProps; } & Omit, "args">, ) => ({ - ...storyObj, decorators: [ (Story: () => JSX.Element) => ( @@ -51,6 +49,7 @@ const listStory = ( render: (props: ListProps) => { return ; }, + ...storyObj, }); export const RequiredListField = listStory({ @@ -177,7 +176,7 @@ export const RequiredListFieldWithCustomSchema = listStory({ hint: "trees in garden, front to back, lower case, spaced", }, ], - schema: z.array(z.any()).nonempty().max(2), + schema: (s) => s.max(2), fields: ({ hint, phrase }) => ({ hint: textField({ name: "hint", value: hint }), phrase: textField({ name: "phrase", value: phrase }), @@ -214,6 +213,6 @@ export const RequiredListFieldWithCustomSchema = listStory({ ), }, render: (props) => { - return ; + return ; }, }); diff --git a/src/fields/list-field/listField.test.tsx b/src/fields/list-field/listField.test.tsx index 008fab6..8ba32f8 100644 --- a/src/fields/list-field/listField.test.tsx +++ b/src/fields/list-field/listField.test.tsx @@ -1,10 +1,16 @@ import { act, renderHook } from "@testing-library/react"; -import { formAtom, useFormSubmit } from "form-atoms"; +import { + formAtom, + useFieldActions, + useFieldErrors, + useFormSubmit, +} from "form-atoms"; import { describe, expect, it, vi } from "vitest"; import { listField } from "./listField"; import { useFieldError } from "../../hooks"; import { numberField } from "../number-field"; +import { textField } from "../text-field"; describe("listField()", () => { describe("when required (default)", () => { @@ -69,6 +75,30 @@ describe("listField()", () => { }); }); + describe("schema", () => { + it("extends the internal schema", async () => { + const field = listField({ + value: [ + { email: "primary@email.com" }, + { email: "secondary@email.com" }, + { email: "other@email.com" }, + ], + fields: ({ email }) => ({ + email: textField({ value: email }), + }), + schema: (s) => s.max(2), + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + expect(errors.current).toEqual([ + "Array must contain at most 2 element(s)", + ]); + }); + }); + // describe("empty atom", () => { // it("is true when values is empty array", () => { // const list = listField({ diff --git a/src/fields/list-field/listField.ts b/src/fields/list-field/listField.ts index eac2a5c..e22c279 100644 --- a/src/fields/list-field/listField.ts +++ b/src/fields/list-field/listField.ts @@ -4,14 +4,14 @@ import { Atom } from "jotai"; import { ZodAny, ZodArray, z } from "zod"; import { extendAtom } from "../../atoms/extendAtom"; +import { FormFieldSubmitValues } from "../../components"; +import { UserValidateConfig, prepareSchema } from "../../utils"; import { DefaultRequiredAtom, ReadRequired, - ValidateConfig, WritableRequiredAtom, schemaValidate, -} from "../../atoms/schemaValidate"; -import { FormFieldSubmitValues } from "../../components"; +} from "../../utils/schemaValidate"; import { ZodParams, defaultParams } from "../zod-field"; export type ExtendListAtom = @@ -50,20 +50,28 @@ export type OptionalListField = ListField< WritableRequiredAtom >; +type ListFieldConfig = ListAtomConfig< + Fields, + Value +> & + ZodParams & + UserValidateConfig, ZodArray>; + export const listField = ({ required_error = defaultParams.required_error, schema, optionalSchema, ...config -}: ListAtomConfig & - ZodParams & - Partial< - ValidateConfig, ZodArray> - >) => { - const { validate, requiredAtom, makeOptional } = schemaValidate({ - schema: schema ?? z.array(z.any()).nonempty(required_error), - optionalSchema: optionalSchema ?? z.array(z.any()), - }); +}: ListFieldConfig) => { + const { validate, requiredAtom, makeOptional } = schemaValidate( + prepareSchema({ + initial: { + schema: z.array(z.any()).nonempty(required_error), + optionalSchema: z.array(z.any()), + }, + user: { schema, optionalSchema }, + }), + ); const listFieldAtom = extendAtom( listAtom({ ...config, validate }) as any, diff --git a/src/fields/number-field/NumberInput.mock.tsx b/src/fields/number-field/NumberInput.mock.tsx index d364778..e2214f1 100644 --- a/src/fields/number-field/NumberInput.mock.tsx +++ b/src/fields/number-field/NumberInput.mock.tsx @@ -1,7 +1,8 @@ import { ComponentProps } from "react"; -import { FieldErrors, FieldLabel } from "../../components"; +import { FieldLabel } from "../../components"; import { NumberFieldProps, useNumberFieldProps } from "../../hooks"; +import { PicoFieldErrors } from "../../scenarios/PicoFieldErrors"; export const NumberInput = ({ field, @@ -15,7 +16,7 @@ export const NumberInput = ({
- +
); }; diff --git a/src/fields/number-field/numberField.stories.tsx b/src/fields/number-field/numberField.stories.tsx index 3508d17..72681d9 100644 --- a/src/fields/number-field/numberField.stories.tsx +++ b/src/fields/number-field/numberField.stories.tsx @@ -39,3 +39,20 @@ export const Initialized = formStory({ ), }, }); + +export const ExtendSchema = formStory({ + args: { + fields: { + degrees: numberField({ + name: "degrees", + schema: (s) => s.min(0).max(360), + }), + }, + children: ({ fields }) => ( + + ), + }, +}); diff --git a/src/fields/number-field/numberField.test.ts b/src/fields/number-field/numberField.test.ts new file mode 100644 index 0000000..6c62b61 --- /dev/null +++ b/src/fields/number-field/numberField.test.ts @@ -0,0 +1,24 @@ +import { act, renderHook } from "@testing-library/react"; +import { useFieldActions, useFieldErrors } from "form-atoms"; +import { describe, expect, it } from "vitest"; + +import { numberField } from "./numberField"; + +describe("numberField()", () => { + describe("schema", () => { + it("extends the internal schema", async () => { + const field = numberField({ + value: 9, + schema: (s) => s.max(6), + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + expect(errors.current).toEqual([ + "Number must be less than or equal to 6", + ]); + }); + }); +}); diff --git a/src/fields/number-field/numberField.ts b/src/fields/number-field/numberField.ts index e4b6eba..4f11c4c 100644 --- a/src/fields/number-field/numberField.ts +++ b/src/fields/number-field/numberField.ts @@ -1,9 +1,10 @@ import { ExtractAtomValue } from "jotai"; import { ZodNumber, z } from "zod"; -import { ZodFieldConfig, zodField } from ".."; -import { ZodParams, defaultParams } from "../zod-field/zodParams"; - +import { zodField } from ".."; +import { prepareSchema } from "../../utils"; +import { FieldConfig } from "../field"; +import { defaultParams } from "../zod-field/zodParams"; export type NumberField = ReturnType; export type NumberFieldValue = ExtractAtomValue< @@ -12,10 +13,17 @@ export type NumberFieldValue = ExtractAtomValue< export const numberField = ({ required_error = defaultParams.required_error, + schema, + optionalSchema, ...config -}: Partial> & ZodParams = {}) => +}: FieldConfig = {}) => zodField({ value: undefined, - schema: z.number({ required_error }), + ...prepareSchema({ + initial: { + schema: z.number({ required_error }), + }, + user: { schema, optionalSchema }, + }), ...config, }); diff --git a/src/fields/string-field/stringField.test.ts b/src/fields/string-field/stringField.test.ts new file mode 100644 index 0000000..88f3987 --- /dev/null +++ b/src/fields/string-field/stringField.test.ts @@ -0,0 +1,22 @@ +import { act, renderHook } from "@testing-library/react"; +import { useFieldActions, useFieldErrors } from "form-atoms"; +import { describe, expect, it } from "vitest"; + +import { stringField } from "./stringField"; + +describe("stringField()", () => { + describe("schema", () => { + it("extends the internal schema", async () => { + const field = stringField({ + value: "bad@email", + schema: (s) => s.email(), + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + expect(errors.current).toEqual(["Invalid email"]); + }); + }); +}); diff --git a/src/fields/string-field/stringField.ts b/src/fields/string-field/stringField.ts index 17e5381..35b3176 100644 --- a/src/fields/string-field/stringField.ts +++ b/src/fields/string-field/stringField.ts @@ -1,8 +1,10 @@ import { ExtractAtomValue } from "jotai"; import { ZodString, z } from "zod"; -import { ZodFieldConfig, zodField } from ".."; -import { ZodParams, defaultParams } from "../zod-field/zodParams"; +import { zodField } from ".."; +import { prepareSchema } from "../../utils"; +import { FieldConfig } from "../field"; +import { defaultParams } from "../zod-field/zodParams"; export type StringField = ReturnType; @@ -12,10 +14,17 @@ export type StringFieldValue = ExtractAtomValue< export const stringField = ({ required_error = defaultParams.required_error, + schema, + optionalSchema, ...config -}: Partial> & ZodParams = {}) => +}: FieldConfig = {}) => zodField({ value: undefined, - schema: z.string({ required_error }), + ...prepareSchema({ + initial: { + schema: z.string({ required_error }), + }, + user: { schema, optionalSchema }, + }), ...config, }); diff --git a/src/fields/text-field/TextInput.mock.tsx b/src/fields/text-field/TextInput.mock.tsx index 519996e..9074515 100644 --- a/src/fields/text-field/TextInput.mock.tsx +++ b/src/fields/text-field/TextInput.mock.tsx @@ -1,7 +1,8 @@ import { ComponentProps } from "react"; -import { FieldErrors, FieldLabel } from "../../components"; +import { FieldLabel } from "../../components"; import { TextFieldProps, useTextFieldProps } from "../../hooks"; +import { PicoFieldErrors } from "../../scenarios/PicoFieldErrors"; export const TextInput = ({ field, @@ -15,7 +16,7 @@ export const TextInput = ({

- +

); }; diff --git a/src/fields/text-field/textField.stories.tsx b/src/fields/text-field/textField.stories.tsx index 6e1ec98..63219c4 100644 --- a/src/fields/text-field/textField.stories.tsx +++ b/src/fields/text-field/textField.stories.tsx @@ -43,3 +43,14 @@ export const Initialized = formStory({ ), }, }); + +export const ExtendedSchema = formStory({ + args: { + fields: { + email: textField({ name: "email", schema: (s) => s.email() }), + }, + children: ({ fields }) => ( + + ), + }, +}); diff --git a/src/fields/text-field/textField.test.ts b/src/fields/text-field/textField.test.ts index b4bb89d..2e2cb93 100644 --- a/src/fields/text-field/textField.test.ts +++ b/src/fields/text-field/textField.test.ts @@ -1,5 +1,10 @@ -import { renderHook } from "@testing-library/react"; -import { formAtom, useFormValues } from "form-atoms"; +import { act, renderHook } from "@testing-library/react"; +import { + formAtom, + useFieldActions, + useFieldErrors, + useFormValues, +} from "form-atoms"; import { describe, expect, it } from "vitest"; import { textField } from "./textField"; @@ -17,4 +22,21 @@ describe("textField()", () => { explicitUndefined: "", }); }); + + describe("schema", () => { + it("extends the internal schema", async () => { + const field = textField({ + value: "1234567", + schema: (s) => s.max(6), + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + expect(errors.current).toEqual([ + "String must contain at most 6 character(s)", + ]); + }); + }); }); diff --git a/src/fields/text-field/textField.ts b/src/fields/text-field/textField.ts index c007280..097397e 100644 --- a/src/fields/text-field/textField.ts +++ b/src/fields/text-field/textField.ts @@ -1,8 +1,10 @@ import { ExtractAtomValue } from "jotai"; import { ZodString, z } from "zod"; -import { ZodFieldConfig, zodField } from ".."; -import { ZodParams, defaultParams } from "../zod-field/zodParams"; +import { zodField } from ".."; +import { prepareSchema } from "../../utils"; +import { FieldConfig } from "../field"; +import { defaultParams } from "../zod-field/zodParams"; export type TextField = ReturnType; @@ -13,12 +15,23 @@ export type TextFieldValue = ExtractAtomValue< export const textField = ({ required_error = defaultParams.required_error, value = "", + schema, + optionalSchema, ...config -}: Partial> & ZodParams = {}) => - zodField({ +}: FieldConfig = {}) => { + return zodField({ value, - // https://github.com/colinhacks/zod/issues/63 - schema: z.string().trim().min(1, required_error), - optionalSchema: z.string().trim(), + ...prepareSchema({ + initial: { + // https://github.com/colinhacks/zod/issues/63 + schema: z.string().trim().min(1, required_error), + optionalSchema: z.string().trim(), + }, + user: { + schema, + optionalSchema, + }, + }), ...config, }); +}; diff --git a/src/fields/zod-field/zodField.ts b/src/fields/zod-field/zodField.ts index 23f8877..af98319 100644 --- a/src/fields/zod-field/zodField.ts +++ b/src/fields/zod-field/zodField.ts @@ -1,18 +1,18 @@ -import { FieldAtom, FieldAtomConfig, fieldAtom } from "form-atoms"; +import { type FieldAtom, type FieldAtomConfig, fieldAtom } from "form-atoms"; import { Atom } from "jotai"; -import { ZodAny, ZodUndefined, z } from "zod"; +import { type ZodAny, type ZodUndefined, z } from "zod"; import { extendAtom } from "../../atoms/extendAtom"; +import type { ExtendFieldAtom, PrimitiveFieldAtom } from "../../atoms/types"; import { type DefaultRequiredAtom, type ReadRequired, type ValidateConfig, type WritableRequiredAtom, schemaValidate, -} from "../../atoms/schemaValidate"; -import { ExtendFieldAtom, PrimitiveFieldAtom } from "../../atoms/types"; +} from "../../utils"; -export type ZodFieldConfig< +type ZodFieldConfig< Schema extends z.Schema, OptSchema extends z.Schema = ZodUndefined, > = FieldAtomConfig & diff --git a/src/scenarios/StoryForm.tsx b/src/scenarios/StoryForm.tsx index 0264ebd..9d17423 100644 --- a/src/scenarios/StoryForm.tsx +++ b/src/scenarios/StoryForm.tsx @@ -25,7 +25,7 @@ export const StoryForm = ({ return (
- {children({ fields, required, form })} +
{children({ fields, required, form })}
{resettable && ( @@ -42,7 +42,7 @@ export type FormStory = StoryObj; export const meta = { component: StoryForm, - args: { required: true }, + args: { required: false }, argTypes: { required: { description: "Whether browser should require", // TODO: does not work diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..4a5bae8 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./schemaValidate"; +export * from "./userValidateConfig"; diff --git a/src/atoms/schemaValidate.ts b/src/utils/schemaValidate.ts similarity index 100% rename from src/atoms/schemaValidate.ts rename to src/utils/schemaValidate.ts diff --git a/src/utils/userValidateConfig.ts b/src/utils/userValidateConfig.ts new file mode 100644 index 0000000..0cf73d3 --- /dev/null +++ b/src/utils/userValidateConfig.ts @@ -0,0 +1,75 @@ +import { type Getter } from "jotai"; +import { z } from "zod"; + +import { type ValidateConfig } from "./schemaValidate"; + +interface MakeSchema { + (fieldSchema: Schema): Schema; + (fieldSchema: Schema, get: Getter): Schema; +} + +export type UserValidateConfig< + Schema extends z.Schema, + OptSchema extends z.Schema, +> = { + /** + * User's zod schema, or a function to enhance the initial field schema. + */ + schema?: Schema | MakeSchema; + optionalSchema?: OptSchema | MakeSchema; +}; + +export type InitialSchemas< + Schema extends z.Schema, + OptSchema extends z.Schema, +> = { + /** + * The default schema of a field. + */ + schema: Schema; + optionalSchema?: OptSchema; +}; + +/** + * A helper to combine user schema with the initial field schema. + * When the user schema is not dependend on other atoms (single argument function), + * the extended zod functions are pre-computed as to not combine them in each validate call. + * @param initial the default schemas of a field. + * @param user the optional user schemas or extend functions. + * @returns + */ +export function prepareSchema< + Schema extends z.Schema, + OptSchema extends z.Schema, +>({ + initial, + user, +}: { + initial: InitialSchemas; + user: Partial>; +}): ValidateConfig { + return { + schema: user.schema + ? applySchema(initial.schema, user.schema) + : initial.schema, + optionalSchema: + user.optionalSchema && initial.optionalSchema + ? applySchema(initial.optionalSchema, user.optionalSchema) + : initial.optionalSchema, + }; +} + +function applySchema( + initialSchema: Schema, + userSchema: Schema | MakeSchema, +) { + if (typeof userSchema === "function") { + if (userSchema.length === 1) { + return userSchema(initialSchema); + } else { + return (get: Getter) => userSchema(initialSchema, get); + } + } else { + return userSchema; + } +}