Skip to content

Instantly share code, notes, and snippets.

@rmoorman
Last active April 15, 2024 18:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rmoorman/16587e63a4ee114fd47b8057ee791eec to your computer and use it in GitHub Desktop.
Save rmoorman/16587e63a4ee114fd47b8057ee791eec to your computer and use it in GitHub Desktop.
A small Zod schema to Antd Form adapter

A small Zod schema to Antd Form adapter

Disclaimer

  • Might only work in Antd v4. I guess I will find out soon.
  • Don't add rules on the schema related Form.Item components.
  • Might not do everything you need (e.g. async?)
  • May have bugs. But what doesn't 🤣

What it does and how to use it

The hook found in zodAntdFormAdapter.tsx might be useful when trying to run zod schema validation when the form is changed by the user.

It takes the form instance, the schema, and onFinish and onFinishFailed callback functions. The expected onFinish callback differs a bit from the callback you would normally pass to the Form component as it tries to infer the type of the values from the passed in schema. onFinishFailed has the same signature as the regular form prop though.

The hook basically adds a watch callback to the form store that runs the schema validation and sets the schema errors on the matching form fields whenever the form changes.

It returns a props object which is meant to be spread into the form props. The props provide wrapped onFinish and onFinishFailed callbacks that take care of calling your callbacks, taking the schema validation into account. For convenience, the props also contain the form prop already set.

Example

import { countBy } from "lodash";
import { z } from "zod";
import { Button, Card, Form, Input } from "antd";

import { useZodAntdFormAdapter } from "./zodAntdFormAdapter";

const initialValues = {
  name: "foo",
  items: [{ id: "foo" }],
};

const schema = z.object({
  name: z.string().min(3),
  items: z.array(
    z.object({
      id: z.string().min(3),
    })
  ).superRefine((items, ctx) => {
    const idCounts = countBy(items, "id");
    items.forEach(({ id }, index) => {
      if (idCounts[id] > 1) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "The item id has to be unique",
          path: [index, "id"],
        });
      }
    });
  }),
});


function MyForm() {
  const [form] = Form.useForm();

  const formAdapter = useZodAntdFormAdapter({
    form,
    schema,
    onFinish(values) {
      console.log("my onFinish", { values });
    },
    onFinishFailed(error) {
      console.log("my onFinishFailed", { error });
    },
  });

  return (
    <Form
      {...formAdapter}
      initialValues={initialValues}
    >
      <Form.Item name={["name"]} label="Name" required>
        <Input />
      </Form.Item>

      <Form.List name={["items"]}>
        {(fields, { add, remove }) => (
          <Form.Item label="Items">
            <Button onClick={() => add({ "id": "bar" })}>Add one</Button>
            {fields.map((field) => (
              <Card key={field.key}>
                <Form.Item name={[field.name, "id"]} label="Item id" required>
                  <Input />
                </Form.Item>
                <Button onClick={() => remove(field.name)}>Remove</Button>
              </Card>
            ))}
          </Form.Item>
        )}
      </Form.List>
      <Button onClick={form.submit}>Submit</Button>
    </Form>
  );
}
/**
* Small integration module for antd form and zod.
*
* Provides a hook that can be used to connect a zod schema to an antd Form component.
*
* Note: Don't add rules to fields that are based of a schema as
* `onFieldsChange` will trigger the validation logic three times then
*/
import type { ComponentProps } from "react";
import { useEffect } from "react";
import type { z } from "zod";
import type { Form, FormInstance } from "antd";
import type { FieldData, InternalFormInstance, InternalNamePath, InternalHooks } from "rc-field-form/lib/interface";
import { HOOK_MARK } from "rc-field-form/lib/FieldContext";
type FormComponentProps = ComponentProps<typeof Form>;
type FieldDataMap = Record<string, FieldData>;
function updatesThatClearErrors(schemaFields: FieldDataMap) {
return Object.values(schemaFields).reduce((updates, field) => {
if (field.errors?.length) {
updates[`${field.name}`] = { name: field.name, errors: [] };
}
return updates;
}, {} as FieldDataMap);
}
function updatesFromZodError(schemaFields: FieldDataMap, error: z.ZodError) {
return error.errors.reduce((updates, error) => {
if (schemaFields[`${error.path}`] === undefined) {
return updates;
}
updates[`${error.path}`] = { name: error.path, errors: [error.message] };
return updates;
}, {} as FieldDataMap);
}
function getInternalHooks(form: FormInstance) {
const internalForm = form as unknown as InternalFormInstance;
return internalForm.getInternalHooks(HOOK_MARK) as InternalHooks;
}
type ValidateSchemaFieldsProps<S extends z.ZodRawShape> = {
form: FormInstance;
schema: z.ZodObject<S>;
ignoreUntouched: boolean;
};
function validateSchemaFields<S extends z.ZodRawShape>(props: ValidateSchemaFieldsProps<S>) {
const { form, schema, ignoreUntouched } = props;
const hooks = getInternalHooks(form);
const allFields = hooks.getFields();
const relevantSchemaFields = allFields
.filter((field) => field.name[0] in schema.shape)
.reduce((acc, field) => {
if (ignoreUntouched && !field.touched) {
return acc;
}
acc[`${field.name}`] = field;
return acc;
}, {} as FieldDataMap);
if (Object.values(relevantSchemaFields).length === 0) {
return;
}
// Initialize the updates for the schema fields with updates that would
// clear all errors that might be there from previous runs.
let updates = updatesThatClearErrors(relevantSchemaFields);
// Run the validation, on error generate updates for the schema errors
// and merge them with the previously established updates.
const result = schema.safeParse(form.getFieldsValue());
if (!result.success) {
const errorUpdates = updatesFromZodError(relevantSchemaFields, result.error);
updates = { ...updates, ...errorUpdates };
}
// Update the schema related fields.
form.setFields(Object.values(updates));
}
function registerWatchForSetFields<S extends z.ZodRawShape>(form: FormInstance, schema: z.ZodObject<S>) {
let inWatch = false;
const watch = (_values: unknown, _allValues: unknown, namePaths: Array<InternalNamePath>) => {
if (inWatch) {
return;
}
const inSchema = namePaths.some((path) => path[0] in schema.shape);
if (!inSchema) {
return;
}
inWatch = true;
validateSchemaFields({ form, schema, ignoreUntouched: true });
inWatch = false;
};
const unregisterWatch = getInternalHooks(form).registerWatch(watch);
return unregisterWatch;
}
type HookProps<S extends z.ZodRawShape> = {
form: FormInstance;
schema: z.ZodObject<S>;
onFinish?: (values: z.infer<z.ZodObject<S>>) => void;
onFinishFailed?: FormComponentProps["onFinishFailed"];
};
function useZodAntdFormAdapter<S extends z.ZodRawShape>(props: HookProps<S>) {
const { form, schema } = props;
useEffect(() => registerWatchForSetFields(form, schema), [form, schema]);
/**
* Provide an implementation for `onFinishFailed` that also passes on the
* errors that are set using `setFields`.
*/
const onFinishFailed: FormComponentProps["onFinishFailed"] = (error) => {
const errorFields = form.getFieldsError().filter(({ errors }) => errors.length > 0);
props.onFinishFailed?.({ ...error, errorFields });
};
/**
* Provide an implementation of `onFinish` that also takes the errors into
* account that are set using `setFields` and call the provided `onFinish`
* and `onFinishFailed` callbacks based on that.
*
* Needed as the default implementation does only seem to care about the
* validation errors directly returned from running the rules set on the
* fields.
*/
const onFinish: FormComponentProps["onFinish"] = (values) => {
validateSchemaFields({ form, schema, ignoreUntouched: false });
const errorFields = form.getFieldsError().filter(({ errors }) => errors.length > 0);
if (!errorFields.length) {
props.onFinish?.(values as z.infer<typeof schema>);
return;
}
props.onFinishFailed?.({
values: form.getFieldsValue(),
errorFields,
outOfDate: false,
});
};
return {
form,
onFinish,
onFinishFailed,
};
}
export { useZodAntdFormAdapter };
@grodrigues101
Copy link

I mean, it's cool, but why we still have to pass the required parameter to the UI, can't we infer it from zod?

@rmoorman
Copy link
Author

rmoorman commented Apr 13, 2024

@grodrigues101 it would, of course, be possible but would require adjusting antd's form and rc-field-form code. One could also go on and have a rules generating function on all fields, but I found the rules themselves conflicting with hooking validation on field changes and the finish callbacks (which I needed to do to run custom validation logic on form lists, and integrating better with dnd reordering for example) and cause the validation to run even more often with the approach in the gist (hooking into field changes).

Given the way rc-field-form currently handles the form state, it would seem to me that those adjustments would be quite involved. And if we are already busy converting everything, one might as well do it in such a way that you can integrate different schema libraries.

I am still not finished with the part of the application I am using this snippet in though, so I will try to update it once that it is sufficiently finished.

I guess we could also add a helper function to just infer the required prop if that would help but that would require to pass around the schema in some way to nested components and add the helper to all relevant form item components. Or we could try to sneak in the schema by adding it to the FormInstance and then have a wrapper component for Form.Item that retrieves the schema from the field context. But is that something we would want?

@grodrigues101
Copy link

I understand, makes a lot of sense. Anyway, congrats on your awesome contributionton the community!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment