Skip to content

Commit

Permalink
feat(extend-schema): better schema callback with the initial schema f…
Browse files Browse the repository at this point in the history
…or 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
  • Loading branch information
MiroslavPetrik authored Mar 12, 2024
1 parent 587ec5b commit 44aa7ac
Show file tree
Hide file tree
Showing 39 changed files with 476 additions and 125 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand Down
8 changes: 2 additions & 6 deletions src/components/checkbox-group/CheckboxGroup.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,14 @@ const checkboxGroupStory = <Option, Field extends ZodArrayField>(
Omit<Partial<CheckboxGroupProps<Option, Field>>, "field">;
} & Omit<StoryObj<typeof meta>, "args">,
) => ({
...storyObj,
decorators: [
(Story: () => JSX.Element) => (
<StoryForm fields={{ field: storyObj.args.field }}>
{() => (
<p>
<Story />
</p>
)}
{() => <Story />}
</StoryForm>
),
],
...storyObj,
});

export const Required = checkboxGroupStory({
Expand Down
4 changes: 2 additions & 2 deletions src/components/checkbox-group/CheckboxGroupField.mock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ export const CheckboxGroupField = <Option, Field extends ZodArrayField>({
}: CheckboxGroupFieldProps<Option, Field>) => (
<>
<FieldLabel field={field} label={label} />
<p>
<section>
<CheckboxGroup
field={field}
getLabel={getLabel}
getValue={getValue}
options={options}
/>
</p>
</section>
<PicoFieldErrors field={field} />
</>
);
4 changes: 2 additions & 2 deletions src/components/multi-select/MultiSelect.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ const MultiSelectField = <Option, Field extends ZodArrayField>({
}: {
label: ReactNode;
} & MultiSelectProps<Option, Field>) => (
<div style={{ margin: "20px 0" }}>
<>
<FieldLabel field={field} label={label} />
<MultiSelect field={field} {...props} />
<FieldErrors field={field} />
</div>
</>
);

export const RequiredArrayString = formStory({
Expand Down
8 changes: 2 additions & 6 deletions src/components/radio-group/RadioGroup.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,14 @@ const radioGroupStory = <Option, Field extends SelectField>(
Omit<Partial<RadioGroupProps<Option, Field>>, "field">;
} & Omit<StoryObj<typeof meta>, "args">,
) => ({
...storyObj,
decorators: [
(Story: () => JSX.Element) => (
<StoryForm fields={{ field: storyObj.args.field }}>
{() => (
<p>
<Story />
</p>
)}
{() => <Story />}
</StoryForm>
),
],
...storyObj,
});

const bashAnswers = [
Expand Down
8 changes: 2 additions & 6 deletions src/components/select/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,14 @@ const selectStory = <Option, Field extends TSelectField>(
Omit<Partial<SelectProps<Option, Field>>, "field">;
} & Omit<StoryObj<typeof meta>, "args">,
) => ({
...storyObj,
decorators: [
(Story: () => JSX.Element) => (
<StoryForm fields={{ field: storyObj.args.field }}>
{() => (
<p>
<Story />
</p>
)}
{() => <Story />}
</StoryForm>
),
],
...storyObj,
});

export const RequiredString = selectStory({
Expand Down
27 changes: 25 additions & 2 deletions src/fields/array-field/arrayField.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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)",
]);
});
});
});
38 changes: 21 additions & 17 deletions src/fields/array-field/arrayField.ts
Original file line number Diff line number Diff line change
@@ -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<Element extends ZodSchema = ZodAny> = ZodField<
ZodArray<Element, ArrayCardinality>,
ZodArray<Element, ArrayCardinality>
>;

export type ArrayFieldParams<ElementSchema extends z.Schema> = Partial<
ZodFieldConfig<
ZodArray<ElementSchema, "atleastone">,
ZodArray<ElementSchema, "many">
>
> &
ZodParams;
export type ArrayFieldParams<ElementSchema extends z.Schema> = FieldConfig<
ZodArray<ElementSchema, "atleastone">,
ZodArray<ElementSchema, "many">
>;

export const arrayField = <ElementSchema extends z.Schema>({
required_error = defaultParams.required_error,
value = [],
elementSchema,
schema,
optionalSchema,
...config
}: { elementSchema: ElementSchema } & ArrayFieldParams<ElementSchema>) =>
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,
});
7 changes: 4 additions & 3 deletions src/fields/boolean-field/booleanField.ts
Original file line number Diff line number Diff line change
@@ -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<typeof booleanField>;

Expand All @@ -13,7 +14,7 @@ export type BooleanFieldValue = ExtractAtomValue<
export const booleanField = ({
required_error = defaultParams.required_error,
...config
}: Partial<ZodFieldConfig<ZodBoolean>> & ZodParams = {}) =>
}: Omit<FieldConfig<ZodBoolean>, "schema" | "optionalSchema"> = {}) =>
zodField({
value: undefined,
schema: z.boolean({ required_error }),
Expand Down
5 changes: 3 additions & 2 deletions src/fields/checkbox-field/CheckboxInput.mock.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,7 +20,7 @@ export const CheckboxInput = ({
<input type="checkbox" {...props} {...requiredProps} />
<FieldLabel field={field} label={label} />
<div>
<FieldErrors field={field} />
<PicoFieldErrors field={field} />
</div>
</div>
);
Expand Down
10 changes: 5 additions & 5 deletions src/fields/checkbox-field/checkboxField.ts
Original file line number Diff line number Diff line change
@@ -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<typeof checkboxField>;

Expand All @@ -16,11 +17,10 @@ export const checkboxField = ({
...config
}: Partial<
Omit<
ZodFieldConfig<ZodLiteral<true>, ZodBoolean>,
FieldConfig<ZodLiteral<true>, ZodBoolean>,
"schema" | "optionalSchema" | "validate"
>
> &
ZodParams = {}) =>
> = {}) =>
zodField({
value,
/**
Expand Down
5 changes: 3 additions & 2 deletions src/fields/date-field/DateInput.mock.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -15,7 +16,7 @@ export const DateInput = ({ field, label, initialValue }: DateFieldProps) => {
{...props}
value={`${value ? getDateString(value) : ""}`}
/>
<FieldErrors field={field} />
<PicoFieldErrors field={field} />
</div>
);
};
17 changes: 17 additions & 0 deletions src/fields/date-field/dateField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<DateInput
field={fields.deadline}
label="Dead line (can't be in the past)"
/>
),
},
});

const nowPlusDays = (days = 0) => {
const date = new Date();
date.setDate(date.getDate() + days);
Expand Down
25 changes: 25 additions & 0 deletions src/fields/date-field/dateField.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
});
19 changes: 14 additions & 5 deletions src/fields/date-field/dateField.ts
Original file line number Diff line number Diff line change
@@ -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<typeof dateField>;

Expand All @@ -12,10 +14,17 @@ export type DateFieldValue = ExtractAtomValue<

export const dateField = ({
required_error = defaultParams.required_error,
schema,
optionalSchema,
...config
}: Partial<ZodFieldConfig<ZodDate>> & ZodParams = {}) =>
}: FieldConfig<ZodDate> = {}) =>
zodField({
value: undefined,
schema: z.date({ required_error }),
...prepareSchema({
initial: {
schema: z.date({ required_error }),
},
user: { schema, optionalSchema },
}),
...config,
});
10 changes: 5 additions & 5 deletions src/fields/digit-field/digitField.ts
Original file line number Diff line number Diff line change
@@ -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<typeof digitField>;

Expand All @@ -25,9 +25,9 @@ const zodDigitSchema = z.union([

type ZodDigitSchema = typeof zodDigitSchema;

export const digitField = ({
...config
}: Partial<ZodFieldConfig<ZodDigitSchema>> & ZodParams = {}) =>
export const digitField = (
config: Omit<FieldConfig<ZodDigitSchema>, "schema" | "optionalSchema"> = {},
) =>
zodField({
value: undefined,
schema: zodDigitSchema,
Expand Down
Loading

0 comments on commit 44aa7ac

Please sign in to comment.