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

feat(conform-dom,conform-react,conform-zod,conform-yup)!: server validation #40

Merged
merged 21 commits into from
Oct 16, 2022

Conversation

edmundhung
Copy link
Owner

@edmundhung edmundhung commented Oct 3, 2022

Context

This PR redesigns the validation flow and make conform a server-validation first solution.
Documentation will be added in another PR and all changed would be detailed in the release note soon.

Example

Try it out on Codesandbox

import type { FormState } from '@conform-to/react';
import {
  conform,
  parse,
  useFieldset,
  useForm,
  hasError,
  reportValidity,
} from '@conform-to/react';
import { getError } from '@conform-to/zod';
import type { ActionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
import { z } from 'zod';

/**
 * Some changes on the parsing logic that will affect your zod schema:
 *
 * Before v0.4, empty field value are removed from the form data before passing to the schema
 * This allows empty string being treated as `undefiend` by zod to utilise `required_error`
 * e.g. `z.string({ required_error: 'Required' })`
 *
 * However, due to my lack of experience with zod, this introduced an unexpected behaviour
 * which stop the schema from running `.refine()` calls until all the defined fields are filled with at least 1 characters
 *
 * In short, please use `z.string().min(1, 'Required')` instead of `z.string({ required_error: 'Required' })` now
 */
const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().min(1, 'Email is required').email('Email is invalid'),
  title: z.string().min(1, 'Title is required').max(20, 'Title is too long'),
});

type Schema = z.infer<typeof schema>;

export let action = async ({ request }: ActionArgs) => {
  const formData = await request.formData();

  /**
   * The `schema.parse(formData: FormData)` helper is no longer available.
   * Instead, you need to use `parse(formData: FormData)` to find out the submission details.
   * It includes:
   * (1) `submission.value`: Structured form value based on the name (path)
   * (2) `submission.error`: Error (if any) while parsing the FormData object,
   * (3) `submission.type` : Type of the submission.
   *     Set only when the user click on named button with pattern (`conform/${type}`),
   *     e.g. `validate`
   * (4) `submission.scope`: Scope of the submission. Name of the fields that should be validated.
   *     e.g. The scope will be `name` only when the user is typing on the name field.
   */
  const submission = parse(formData);
  const result = await schema
    .refine(
      async (employee) => {
        // Zod does
        if (!submission.scope.includes('email')) {
          return true;
        }

        // Async validation. e.g. checking uniqueness
        return new Promise((resolve) => {
          setTimeout(() => {
            resolve(employee.email === 'hey@conform.guide');
          }, Math.random() * 100);
        });
      },
      {
        message: 'Email is already used',
        path: ['email'],
      },
    )
    .safeParseAsync(submission.value);

  // Return the state to the client if the submission is made for validation purpose
  if (!result.success || submission.type === 'validate') {
    return json({
      scope: submission.scope,
      value: submission.value,
      error: submission.error.concat(
        !result.success ? getError(result.error, submission.scope) : [],
      ),
    });
  }

  console.log('Result:', result.data);

  return redirect('/');
};

export default function TodoForm() {
  // FormState returned from the server
  const state = useActionData<FormState<Schema>>();

  /**
   * The useForm hook now returns a `Form` object
   * It includes:
   * (1) form.props: Properties to be passed to the form element
   * (2) form.config: Fieldset config to be passed to the useFieldset hook.
   *      [Optional] Needed only if the fields have default value / nojs support is needed)
   * (3) form.ref: Ref object of the form element. Same as `form.props.ref`
   * (4) form.error: Form error. Set when an error with an empty string name is provided by the form state.
   */
  const form = useForm<Schema>({
    // Just hook it up with the result from useActionData()
    state,
    initialReport: 'onBlur',

    /**
     * The validate hook - `onValidate(context: FormContext): boolean`
     * Changes includes:
     *
     * (1) Renamed from `validate` to `onValidate`
     * (2) Changed the function signature with a new context object, including `form`, `formData` and `submission`
     * (3) It should now returns a boolean indicating if the server validation is needed
     *
     * If both `onValidate` and `onSubmit` are commented out, then it will validate the form completely by server validation
     */
    onValidate({ form, submission }) {
      // Similar to server validation without the extra refine()
      const result = schema.safeParse(submission.value);
      const error = submission.error.concat(
        !result.success ? getError(result.error) : [],
      );

      /**
       * Since only `email` requires extra validation from the server.
       * We skip reporting client error if the email is being validated while there is no error found from the client.
       * e.g. Client validation would be enough if the email is invalid
       */
      if (submission.scope.includes('email') && !hasError(error, 'email')) {
        // Server validation is needed
        return true;
      }

      /**
       * The `reportValidity` helper does 2 things for you:
       * (1) Set all error to the dom and trigger the `invalid` event through `form.reportValidity()`
       * (2) Return whether the form is valid or not. If the form is invalid, stop it.
       */
      return reportValidity(form, {
        ...submission,
        error,
      });
    },
    async onSubmit(event, { submission }) {
      /**
       * The `onSubmit` hook will be called only if `onValidate` returns true,
       * or when `noValidate` / `formNoValidate` is configured
       */
      switch (submission.type) {
        case 'validate': {
          if (submission.data !== 'email') {
            // We need server validation only for the email field, stop the rest
            event.preventDefault();
          }
          break;
        }
      }
    },
  });
  const { name, email, title } = useFieldset(form.ref, form.config);

  return (
    <Form method="post" {...form.props}>
      <fieldset>
        <label>
          <div>Name</div>
          <input
            className={name.error ? 'error' : ''}
            {...conform.input(name.config)}
          />
          <div>{name.error}</div>
        </label>
        <label>
          <div>Email</div>
          <input
            className={email.error ? 'error' : ''}
            {...conform.input(email.config)}
          />
          <div>{email.error}</div>
        </label>
        <label>
          <div>Title</div>
          <input
            className={title.error ? 'error' : ''}
            {...conform.input(title.config)}
          />
          <div>{title.error}</div>
        </label>
      </fieldset>
      <button type="submit">Save</button>
    </Form>
  );
}

@cloudflare-pages
Copy link

cloudflare-pages bot commented Oct 3, 2022

Deploying with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6586bae
Status: ✅  Deploy successful!
Preview URL: https://35563a94.conform.pages.dev
Branch Preview URL: https://simplify-validation.conform.pages.dev

View logs

@brandonpittman
Copy link
Contributor

Does async validation land in this?

@edmundhung
Copy link
Owner Author

edmundhung commented Oct 13, 2022

Does async validation land in this?

Hi @brandonpittman! Maybe. (update: Maybe not in this PR but definitely in the coming release.) I have a working prototype locally already but I am still refining the API surface. The new validation flow changes quite many things internally and I am trying to break it down into smaller chunks. Anyway, I am planning to cut a release by the end of this week (or a pre-release depends on the progress).

The current PR replaces the Submission abstraction with some low level APIs for validation and simplifies the error back to client.

For example, you can find how to manually validate a login form with the new approach here.

It no longer couples with the schema resolver with the error simplifies to Array<[string, string]> with the tuple being a [field name, error message] pair.

// Before
const submission = schema.parse(formData);
const state = submission.form;

// After
const state = parse(formData)
const result = schema.safeParse(state.value);

if (!result.success) {
  // getError is a new util provided by `conform-to/zod`, which returns Array<[string, string]>
  state.error = state.error.concat(getError(result.error));
}

There will also be some mindset change on how validation is done in the new version. It is not pushed to this branch yet. But the idea would be making server validation the default and you are progressively enhancing it with optional client side validation (which works/acts like a middleware). The adjustment hopefully will reflect this idea 🔥

@brandonpittman
Copy link
Contributor

the idea would be making server validation the default and you are progressively enhancing it with optional client side validation (which works/acts like a middleware). The adjustment hopefully will reflect this idea 🔥

@edmundhung That sounds great to me. I may be jumping the gun, but I'm using Conform in a production app that will ship in December.

@edmundhung
Copy link
Owner Author

Oh man, @brandonpittman. I am so grateful for your support on this project. Is there any other pain-points you are having with conform? I will be focusing on improving the tests coverage after this release but if there is anything I can prioritise to support you better, let me know.

@brandonpittman
Copy link
Contributor

brandonpittman commented Oct 15, 2022

@edmundhung As of now, the parseAsync thing (I worked around it but it would be cleaner if Conform had its own parseAsync) and the other issue with shadow inputs that aren’t Conform shadow inputs.

@edmundhung
Copy link
Owner Author

@edmundhung As of now, the parseAsync thing (I worked around it but it would be cleaner if Conform had its own parseAsync)

If you are referring to parseAsync with zod, this should be resolved in this PR. The docs are not ready yet. But I have prepared an example sandbox using zod that you will be able to try it out shortly.

@edmundhung edmundhung changed the title feat!: simplify validation feat(conform-dom,conform-react,conform-zod,conform-yup)!: simplify validation Oct 16, 2022
@edmundhung edmundhung changed the title feat(conform-dom,conform-react,conform-zod,conform-yup)!: simplify validation feat(conform-dom,conform-react,conform-zod,conform-yup)!: server validation Oct 16, 2022
@edmundhung edmundhung merged commit 50ae1c8 into main Oct 16, 2022
@edmundhung edmundhung deleted the simplify-validation branch October 16, 2022 22:15
@edmundhung
Copy link
Owner Author

Hi @brandonpittman, I have just published v0.4.0-pre.0. You can try it out here.

@edmundhung
Copy link
Owner Author

Note: The API surface on onValidate and onSubmit is not final. (I just realize the current approach break the PE philosophy on how form button works with validation. 😞)

@brandonpittman
Copy link
Contributor

@edmundhung Thanks! I'll take a look at it.

@edmundhung edmundhung mentioned this pull request Oct 19, 2022
17 tasks
@brandonpittman
Copy link
Contributor

brandonpittman commented Oct 25, 2022

I'd have success using zod-form-data in place of Zod's standard methods. I think it might get around some of the issues with required and min(1).

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

Successfully merging this pull request may close these issues.

None yet

2 participants