shadcn-ui / ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.
https://ui.shadcn.com
MIT License
74.35k stars 4.59k forks source link

problem with form onSubmit #2371

Closed cblberlin closed 10 months ago

cblberlin commented 10 months ago

hi everyone, i'm writing a from which is a multi-entries form, but after submit, it return an object not an array

the console.log show me the submit value as an object:

{
  {
    "school": "University A",
    "major": "Computer Science",
    "degree": "Bachelor's",
    "startdate": "2020-09-01",
    "enddate": "2024-06-01",
    "isCurrent": false
  },
  {
    "school": "University B",
    "major": "Graphic Design",
    "degree": "Master's",
    "startdate": "2025-09-01",
    "enddate": "2027-06-01",
    "isCurrent": false
  }
}

but it expect the output as an array otherwise it won't pass the validation:

[
  {
    "school": "University A",
    "major": "Computer Science",
    "degree": "Bachelor's",
    "startdate": "2020-09-01",
    "enddate": "2024-06-01",
    "isCurrent": false
  },
  {
    "school": "University B",
    "major": "Graphic Design",
    "degree": "Master's",
    "startdate": "2025-09-01",
    "enddate": "2027-06-01",
    "isCurrent": false
  }
]

here is my schema:

import * as z from "zod"

export const educationEntrySchema = z.object({
  school: z.string().min(2, {
    message: "please enter a valid school name.",
  }),
  major: z.string().min(2, {
    message: "please enter a valid major.",
  }),
  degree: z.string().min(2, {
    message: "please enter a valid degree.",
  }),
  startdate: z.coerce.date({
    errorMap: (issue, {defaultError}) => ({
      message: issue.code === "invalid_date" ? "please select a valid start day" : defaultError,
    }),
  }),
  isCurrent: z.boolean().optional(),
  enddate: z.coerce.date({
    errorMap: (issue, {defaultError}) => ({
      message: issue.code === "invalid_date" ? "please select a valid end day" : defaultError,
    }),
  }).optional(),
});

export type educationEntry = z.infer<typeof educationEntrySchema>;

export const educationschema = z.array(educationEntrySchema);

and here is the form:

"use client"

import React, { useState } from "react"

import { cn } from "@/lib/utils"
import { format } from "date-fns"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"

import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Input } from "@/components/ui/input"
import { useToast } from "@/components/ui/use-toast"

import { Calendar } from "@/components/ui/calendar"
import { Checkbox } from "@/components/ui/checkbox"

import { CalendarIcon } from "lucide-react"
import { educationschema, educationEntry } from "./validators/education-schema"

export function Education() {
  const { toast } = useToast();

  // set education
  const [educations, setEducations] = useState<educationEntry[]>(
    [
      {
        school: "",
        major: "",
        degree: "",
        startdate: new Date(),
        enddate: new Date(),
        isCurrent: false,
      },
    ]
  );

  const currentYear = new Date().getFullYear()

  // 1. Define your form.
  const form = useForm<z.infer<typeof educationschema>>({
    resolver: zodResolver(educationschema),
    defaultValues: [
      {
        school: "",
        major: "",
        degree: "",
        startdate: new Date(),
        enddate: new Date(),
        isCurrent: false,
      },
    ],
  })

  // 2. Define a submit handler.
  function onSubmit(values: z.infer<typeof educationschema>) {
    console.log("submited values: ", form.getValues());
    toast({
      title: "You submitted the following values:",
      description: (
        <p className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
          <code className="text-white">{JSON.stringify(values, null, 2)}</code>
        </p>
      ),
    })
    console.log(values)
  }

  // allow to add more education entries
  function addEducation() {
    setEducations([
      ...educations, 
      {
        school: "",
        major: "",
        degree: "",
        startdate: new Date(),
        enddate: new Date(),
        isCurrent: false,
      },
    ]);
  };

  // delete education entry, but keep at least one entry
  function deleteEducation(index: number) {
    if (educations.length > 1) {
      const newEducations = [...educations];
      newEducations.splice(index, 1);
      setEducations(newEducations);
    } else {
      alert("at least one entry");
    }
  };

  const handleCurrentChange = (index: number, isCurrent: boolean) => {
    const updatedEducation = [...educations];
    updatedEducation[index].isCurrent = isCurrent;
    setEducations(updatedEducation);
  };

  console.log("submited values: ", form.getValues());
  console.log("Form errors:", form.formState.errors);

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 my-4">
        {educations.map((edu, index) => (
          <div key={index}>
            <FormField
              control={form.control}
              name={`${index}.school`}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>school</FormLabel>
                  <FormControl>
                    <Input 
                      placeholder="Université xxx" 
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name={`${index}.major`}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>major</FormLabel>
                  <FormControl>
                    <Input placeholder="ex: Design" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name={`${index}.degree`}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>degree</FormLabel>
                  <FormControl>
                    <Input placeholder="ex: Master Design" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name={`${index}.startdate`}
              render={({ field }) => (
                <FormItem className="flex flex-col">
                  <FormLabel>start day</FormLabel>
                  <Popover>
                    <PopoverTrigger asChild>
                      <FormControl>
                        <Button
                          variant={"outline"}
                          className={cn(
                            "w-[240px] pl-3 text-left font-normal",
                            !field.value && "text-muted-foreground"
                          )}
                        >
                          {field.value ? (
                            format(field.value, "PPP")
                          ) : (
                            <span>please select start day</span>
                          )}
                          <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
                        </Button>
                      </FormControl>
                    </PopoverTrigger>
                    <PopoverContent className="w-auto p-0" align="start">
                      <Calendar
                        mode="single"
                        selected={field.value}
                        onSelect={field.onChange}
                        disabled={(date) =>
                          date > new Date() || date < new Date("1900-01-01")
                        }
                        captionLayout="dropdown-buttons"
                        fromYear={1900}
                        toYear={currentYear}
                        initialFocus
                      />
                    </PopoverContent>
                  </Popover>
                  <FormMessage />
                </FormItem>
              )}
            />

            {/* a checkbox isCurrent: if not selected then show enddate, else show nothing */}
            <FormField
              control={form.control}
              name={`${index}.isCurrent`}
              render={({ field }) => (
                <FormItem className="flex flex-col">
                  <FormLabel>is current?</FormLabel>
                  <FormControl>
                    <Checkbox 
                      checked={field.value}
                      onCheckedChange={(isCurrent) => handleCurrentChange(index, Boolean(isCurrent))}
                    />
                  </FormControl>
                </FormItem>
              )}
            />

            {!edu.isCurrent && (
              <FormField
                control={form.control}
                name={`${index}.enddate`}
                render={({ field }) => (
                  <FormItem className="flex flex-col">
                    <FormLabel>end day</FormLabel>
                    <Popover>
                      <PopoverTrigger asChild>
                        <FormControl>
                          <Button
                            variant={"outline"}
                            className={cn(
                              "w-[240px] pl-3 text-left font-normal",
                              !field.value && "text-muted-foreground"
                            )}
                          >
                            {field.value ? (
                              format(field.value, "PPP")
                            ) : (
                              <span>please select end day</span>
                            )}
                            <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
                          </Button>
                        </FormControl>
                      </PopoverTrigger>
                      <PopoverContent className="w-auto p-0" align="start">
                        <Calendar
                          mode="single"
                          selected={field.value}
                          onSelect={field.onChange}
                          disabled={(date) =>
                            date > new Date() || date < new Date("1900-01-01")
                          }
                          captionLayout="dropdown-buttons"
                          fromYear={1900}
                          toYear={currentYear}
                          initialFocus
                        />
                      </PopoverContent>
                    </Popover>
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            {index === educations.length - 1 && (
              <div className="flex-wrap-gap-2 mb-2">
                {index !== 0 && (
                  <Button
                    type="button"
                    variant="secondary"
                    onClick={() => deleteEducation(index)}
                  >
                    Delete
                  </Button>
                )}
                <Button type="button" onClick={addEducation}>
                  Add
                </Button>
              </div>
            )}
          </div>
        ))}
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  )
}

i don't know at which step the submit result turn into an object instead of an array

rahulpunase commented 10 months ago

@cblberlin can you deploy this code somewhere?

cblberlin commented 10 months ago

@cblberlin can you deploy this code somewhere? tried to find a way to do that, like in codesandbox?

rahulpunase commented 10 months ago

@cblberlin can you deploy this code somewhere? tried to find a way to do that, like in codesandbox?

Yes... will be able to understand the code better. And can run it also

cblberlin commented 10 months ago

@cblberlin can you deploy this code somewhere? tried to find a way to do that, like in codesandbox?

Yes... will be able to understand the code better. And can run it also

I've successfully deployed a multiple-entries form on CodeSandbox: multiple-entries-form, although it's quite slow. Additionally, you can review the project on this GitHub repository: here. While there are some Chinese characters in it, they are primarily for basic functions like add, remove, and submit.

rahulpunase commented 10 months ago

I got what you are trying to do...

I think you should create a new form every time you hit the add button and validate that form fields only instead of validating all the fields in one single form.

One you validate the 1st form, add the values to an array and then validate the second form and so on... and finally use the final array on Submit button. That way all the objects in your array will already be validated and the code will be much simpler.

You are using index and adding that to name field, which means the value property will always change and zod validation will most likely fail

cblberlin commented 10 months ago

I got what you are trying to do...

I think you should create a new form every time you hit the add button and validate that form fields only instead of validating all the fields in one single form.

One you validate the 1st form, add the values to an array and then validate the second form and so on... and finally use the final array on Submit button. That way all the objects in your array will already be validated and the code will be much simpler.

You are using index and adding that to name field, which means the value property will always change and zod validation will most likely fail

that's a very good approach, i'll try to figure out how to fix this and i will submit my result

cblberlin commented 10 months ago

I got what you are trying to do...

I think you should create a new form every time you hit the add button and validate that form fields only instead of validating all the fields in one single form.

One you validate the 1st form, add the values to an array and then validate the second form and so on... and finally use the final array on Submit button. That way all the objects in your array will already be validated and the code will be much simpler.

You are using index and adding that to name field, which means the value property will always change and zod validation will most likely fail

Another potential method involves utilizing useFieldArray from react-hook-form to handle adding and removing actions, which helps in maintaining the array's structure when managing forms. I will experiment with both approaches.

cblberlin commented 10 months ago

i've implemented it by using useFieldArray, please check here

rahulpunase commented 10 months ago

Yup... works fine. But it never was a shadcn/ui issue. You can close it.

cblberlin commented 10 months ago

I've successfully implemented the multi-entries form. Although it wasn't an issue specific to shadcn-ui, this solution might be useful for others looking to create a similar form. By modifying the shadcn-ui example provided on Form component, I hope it can assist anyone facing the same issue. Thanks to everyone who helped me resolve the error.