IdoPesok / zsa

https://zsa.vercel.app
MIT License
763 stars 23 forks source link

Progressive enhancement #70

Closed gbouteiller closed 4 months ago

gbouteiller commented 4 months ago

Hello, I'm trying to use zsa on a form with javascript disabled but it does not seem to work. Is progressive enhancement manageable with zsa?

IdoPesok commented 4 months ago

It should work with javascript disabled. Here is some example code that I just tried.

import { zsaAction } from "./action";

export const ZSAForm = () => {
  return (
    <form action={zsaAction}>
      <input type="text" name="name" style={{ color: "black" }} />
      <button type="submit">Submit</button>
    </form>
  );
};

In actions.ts:

"use server";

import { z } from "zod";
import { createServerAction } from "zsa";

export const zsaAction = createServerAction()
  .input(
    z.object({
      name: z.string().min(4),
    }),
    {
      type: "formData",
    }
  )
  .handler(async ({ input }) => {
    console.log("input", input);
    await new Promise((resolve) => setTimeout(resolve, 4000));
    return {
      hello: "world",
    };
  });

Happy to investigate further if you have a code snippet that isn't working or a replication.

gbouteiller commented 4 months ago

Hello, ok I managed to do it. I was porting a newsletter form from RHF + Server Actions to RHF + ZSA + Server Actions. The problem was that I took your example from another issue :

"use client";

import { zsaAction } from "./action";
import { useActionState } from "react";
import { inferServerActionReturnType } from "zsa";

export const ZSAForm = () => {
  const [[data, err], submit, isPending] = useActionState(
    async (prevState: any, formData: FormData) => await zsaAction(formData),
    [null, null] as inferServerActionReturnType<typeof zsaAction> | [null, null]
  );

  return (
    <form action={submit}>
      <input type="text" name="name" />
      <button type="submit">Submit</button>
      <pre>{JSON.stringify({ isPending }, null, 2)}</pre>
      {err && <pre>{JSON.stringify(err.fieldErrors?.name)}</pre>}
    </form>
  );
};

and, correct me if I'm wrong but defining an anonymous function as arg of useActionState doesn't work with progressive enhancement. But creating the exact same function in actions.ts and using it works:

actions.ts

"use server";

import { z } from "zod";
import { createServerAction } from "zsa";

export const zsaAction = createServerAction()
  .input(
    z.object({
      name: z.string().min(4),
    }),
    {
      type: "formData",
    }
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    console.log(input);
  });

export const zsaFormAction = async (prevState: any, formData: FormData) => await zsaAction(formData)

and in client:

"use client";

import { type zsaAction, zsaFormAction } from "./action";
import { useActionState } from "react";
import { inferServerActionReturnType } from "zsa";

export const ZSAForm = () => {
  const [[data, err], submit, isPending] = useActionState(
    zsaFormAction,
    [null, null] as inferServerActionReturnType<typeof zsaAction> | [null, null]
  );

  return (
    <form action={submit}>
      <input type="text" name="name" />
      <button type="submit">Submit</button>
      <pre>{JSON.stringify({ isPending }, null, 2)}</pre>
      {err && <pre>{JSON.stringify(err.fieldErrors?.name)}</pre>}
    </form>
  );
};
IdoPesok commented 4 months ago

Hi, happy to report that this is resolved in zsa@0.3.0. Please refer to our useActionState docs.

Thank you for bringing this up and contributing these examples! Going to close this issue now, but do let me know if it needs to be reopened.

gbouteiller commented 4 months ago

Hi @IdoPesok , I played a little bit with the new version. The new type "state" works with progressive enhancement as the exported method is directly used but it doesn't seem to be the case with the custom state example. Also, I can share with you the way I'm using it in my project with RHF as I think it can be interesting especially for error handling:

actions.ts

"use server"

import {z} from "zod"
import {createServerAction} from "zsa"
import {subscribeToNewsletter} from "@/lib/hashnode"
import {type Data, rhfErrorsFromZsa, type State, zData} from "./utils"

const subscribeToNewsletterZSA = createServerAction()
  .input(zData, {type: "formData"})
  .output(z.enum(["SUCCESS", "CONFLICT", "INTERNAL_SERVER_ERROR"]))
  .handler(async ({input: {email}}) => subscribeToNewsletter(email))

export const subscribeToNewsletterAction = async (_prev: State | undefined, formData: FormData): Promise<State> => {
  const [status, error] = await subscribeToNewsletterZSA(formData)
  const data = Object.fromEntries(formData.entries()) as Data
  return {data, errors: rhfErrorsFromZsa(error), status: status ?? "INPUT_PARSE_ERROR"}
}

utils.ts

export const defaultData: Data = {email: ""}

export const zData = z.object({
  email: z.string().trim().min(1).email(),
})

export type Data = z.infer<typeof zData>

export function rhfErrorsFromZsa<T extends FieldValues = FieldValues>(
  error: TZSAError<any> | null
): FieldErrors<T> | undefined {
  if (!error) return
  const {code: type, fieldErrors, formErrors, message} = error
  return {
    root: {type, message: message ?? formErrors?.[0]},
    ...Object.fromEntries(Object.entries(fieldErrors ?? {}).map(([name, errors]) => [name, {message: errors?.[0]}])),
  } as FieldErrors<T>
}

export type State = {
  data?: Data
  errors?: FieldErrors<Data>
  status: "SUCCESS" | "INPUT_PARSE_ERROR" | "BAD_REQUEST" | "CONFLICT" | "INTERNAL_SERVER_ERROR"
}

newsletter-form.tsx

"use client"

import {Form} from "@/components/ui/form"
import {zodResolver} from "@hookform/resolvers/zod"
import {useActionState} from "react"
import {useForm} from "react-hook-form"
import {defaultData, zData, type Data} from "./utils"
import {subscribeToNewsletterAction} from "./actions"

export default function NewsletterForm() {
  const [state, action, pending] = useActionState(subscribeToNewsletterAction, undefined)

  const form = useForm<Data>({
    mode: "onTouched",
    resolver: zodResolver(zData),
    errors: state?.errors,
    defaultValues: state?.data ?? defaultData,
  })

  return (
    <Form {...form}>
      <form action={action} onSubmit={form.formState.isValid ? undefined : form.handleSubmit(() => true)}>
        {/* ... */}
      </form>
    </Form>
  )
}
IdoPesok commented 4 months ago

Hi thanks for pointing that out -- the custom state example in the docs is now updated to support PE.

W.r.t. error handling, would you be interested in contributing your rhf error solution to zsa lib? What I was thinking was sending rhfError alongside fieldError, formError, and formattedErrors here. That way people can just do errors: state.rhfErrors. I am happy to write this as well, but I want you to have the "Credit" : )

gbouteiller commented 3 months ago

@IdoPesok Sorry for the late reply. As the way I format the error is quite opinionated and as I saw your work on shapeError, maybe is best not to pollute your library code with a direct reference to another library like RHF (zod is an exception here as it is the "standard" for many of us 😄 ). For instance, we could just shape the error in a procedure and then use it in our actions with type: "state" without the need to create a custom state. What do you think?

IdoPesok commented 3 months ago

Yep, sounds like a great use of shapeError. Will get that merged ASAP