Zack Snyder
Zack Snyder

Reputation: 65

Is there a way to validate react dropzone files using zod schema react react hook form

am using shadcn Form that uses react hook form, zod for validation and react dropzone together to validate my input before i submit a form.

below is the schema am using and how am mounting the component:

in The Validations

import { z } from "zod";
export const myFormSchema = z.object({
   images: z
    .custom<File>(),
  title: z.string(),
});

and here is my dropzone component

import { FileWithPath, useDropzone } from "react-dropzone";
import { useState, useCallback } from "react";
import Image from "next/image";
import { Upload } from "lucide-react";

type FileUploaderProps = {
  fieldChange: (FILES: File[]) => void;
  mediaUrl: string[] | null;
};

const FileUploader = ({ fieldChange, mediaUrl }: FileUploaderProps) => {
  const [fileUrl, setFileUrl] = useState<string[] | null>(mediaUrl);

  const onDrop = useCallback(
    (acceptedFiles: FileWithPath[]) => {
      fieldChange(acceptedFiles);
      const urls = acceptedFiles.map((file) => URL.createObjectURL(file));
      setFileUrl(urls);
    },
    [fieldChange],
  );
  const { getRootProps, getInputProps } = useDropzone({
    onDrop,
    accept: {
      "image/*": [".png", ".jpeg", ".jpg", ".svg"],
    },
  });

  return (
    <div
      {...getRootProps()}
      className="flex-center bg-dark-3 flex cursor-pointer flex-col rounded-xl"
    >
      <input {...getInputProps()} className="cursor-pointer" />
      {fileUrl ? (
        <>
          <div className="grid grid-cols-3 gap-2">
            {fileUrl.map((image, index) => (
              <button key={index}>
                <Image
                  alt="Product image"
                  className="aspect-square w-full rounded-md object-cover"
                  height="84"
                  src={image}
                  width="84"
                />
              </button>
            ))}
          </div>
          <p className="mt-10 text-center">Click or drag photo to replace</p>
        </>
      ) : (
        <div className="mt-2 grid grid-cols-3 gap-2">
          <button className="flex aspect-square w-full items-center justify-center rounded-md border border-dashed shadow">
            <Upload className="h-4 w-4 text-muted-foreground" />
            <span className="sr-only">Upload</span>
          </button>
        </div>
      )}
    </div>
  );
};

export default FileUploader;

here is my Form Component that connects them all here am using shadcn ui form to validate that the dropzone is not empty and it must show error if user tries to submit it without selecting images but the validations are not working:

'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';

import FileUploader from '../shared/FileUploader';
import Link from 'next/link';

const InfoForm = () => {
  // 1. Form Validation
  const form = useForm<z.infer<typeof myFormSchema>>({
    resolver: zodResolver(myFormSchema),
    defaultValues: {},
  });

  // 2. Form Submission
  async function onSubmit(values: z.infer<typeof myFormSchema>) {
    // Do something with the form values.
    // ✅ This will be type-safe and validated.

    console.log('work submitted');
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Title</FormLabel>
              <FormControl>
                <Input placeholder="" {...field} />
              </FormControl>
              <FormDescription>
                Mention the key features of your item (e.g. brand, model, age,
                type)
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="images"
          render={({ field }) => (
            <FormItem>
              <FormLabel>UPLOAD UP TO 20 PHOTOS</FormLabel>
              <FormControl>
                <FileUploader fieldChange={field.onChange} mediaUrl={null} />
                {/* <Input id="picture" type="file" {...field} /> */}
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <div className="flex justify-between">
          <Link href="/">
            <Button variant="outline">Cancel</Button>
          </Link>
          <Button>Post</Button>
        </div>
      </form>
    </Form>
  );
};

export default InfoForm;

am i doing something wrong here? also excuse me as i am new to using react, nextjs and coding in general.

Upvotes: 0

Views: 905

Answers (1)

YusufcanY
YusufcanY

Reputation: 1

Controlling dropzone input with useForm hooks in onDrop callback is the solution here.

const form = useForm<HotelFormValues>({
  resolver: zodResolver(hotelFormSchema),
});
const {
  fields: imageFields,
  append: appendImage,
  remove: removeImage,
} = useFieldArray({
  name: 'images',
  control: form.control,
});
const onDrop = useCallback(
  async (acceptedFiles: File[]) => {
    appendImage(acceptedFiles.map((file) => ({ value: file })));
    await form.trigger('images');
  },
  [form],
);

Here is the full code of mine. It's accepting multiple images but i think you can turn this to single image. I tried to remove unnecessary parts for not to confuse you. Let me know it helps. It works very well for me.

'use client';
import { useDropzone } from 'react-dropzone';
import { useCallback, useEffect} from 'react';
import { toast } from 'sonner';
import { zodResolver } from '@hookform/resolvers/zod';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import Image from 'next/image';

const hotelFormSchema = z.object({
  name: z
    .string({ required_error: 'Please enter a name.' })
    .min(1, { message: 'Please enter a name.' }),
  country: z.string({ required_error: 'Please select a country.' }),
  city: z.string({ required_error: 'Please select a city.' }),
  description: z
    .string({ required_error: 'Please enter a description.' })
    .min(1, { message: 'Please enter a description.' }),
  rooms: z
    .array(
      z.object({
        name: z.string().min(1, { message: 'Please enter a name' }),
        price: z.number().min(1, { message: 'Please enter a valid price.' }),
        occupantCount: z.number().min(1, { message: 'Please enter a valid occupant count.' }),
        squareMeters: z.number().min(1, { message: 'Please enter a valid square meters.' }),
      }),
    )
    .min(1, { message: 'Please add at least one room.' }),
  images: z.array(
    z.object({
      value: z.custom<File>(),
    }),
    { message: 'Please upload at least one image.' },
  ),
});

type HotelFormValues = z.infer<typeof hotelFormSchema>;

export default function AddHotelForm() {
  const form = useForm<HotelFormValues>({
    resolver: zodResolver(hotelFormSchema),
  });
  const {
    fields: imageFields,
    append: appendImage,
    remove: removeImage,
  } = useFieldArray({
    name: 'images',
    control: form.control,
  });
  const onDrop = useCallback(
    async (acceptedFiles: File[]) => {
      appendImage(acceptedFiles.map((file) => ({ value: file })));
      await form.trigger('images');
    },
    [form],
  );

  const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections, open } =
    useDropzone({
      onDrop,
      maxFiles: 10,
      maxSize: 25 * 1024 * 1024,
      accept: {
        'image/jpeg': [],
        'image/png': [],
        'image/webp': [],
      },
    });

  function onSubmit(data: HotelFormValues) {
    toast('You submitted the following values:', {
      description: (
        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
        </pre>
      ),
    });
  }

  useEffect(() => {
    if (fileRejections.length > 0) {
      const errorType = fileRejections[0].errors[0].code;
      if (errorType === 'file-invalid-type') {
        toast.error('Wrong file type', {
          description: 'Please upload a file with the correct format',
        });
      } else if (errorType === 'file-too-large') {
        toast.error('File too large', {
          description: 'Please upload a file with the correct size',
        });
      } else {
        toast.error('Uh oh! Something went wrong.', {
          description: 'There was a problem with your request. Please try again',
        });
      }
    }
  }, [fileRejections]);

  return (
    <>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 grid grid-cols-12 gap-4">
          {...}
          <div className="col-span-12">
            <FormField
              control={form.control}
              name="images"
              render={() => (
                <FormItem>
                  <FormLabel>Images</FormLabel>
                  <div
                    className={classNames(
                      'relative flex flex-col items-center justify-center rounded-lg border border-dashed px-4 py-10',
                      {
                        'border-green-500 bg-green-500/10': isDragActive && !isDragReject,
                        'border-destructive bg-destructive/10': isDragActive && isDragReject,
                        'border-border bg-card': !isDragActive,
                      },
                    )}
                    {...getRootProps()}
                  >
                    <input {...getInputProps()} id="images" />
                    <ImageUpIcon className="h-12 w-12 fill-primary/75" />
                    <div className="mb-2 mt-4">
                          Drop or{' '}
                          <span
                            onClick={() => open()}
                            className="cursor-pointer text-primary hover:underline"
                          >
                            select
                          </span>
                        </div>
                    <span
                      className={classNames('absolute bottom-2 left-1/2 -translate-x-1/2 text-xs', {
                        'text-destructive': isDragReject || fileRejections.length > 0,
                        'text-muted-foreground': !isDragReject && !(fileRejections.length > 0),
                      })}
                    >
                      Max size: 25MB, JPG or PNG
                    </span>
                  </div>
                  <FormMessage />
                </FormItem>
              )}
            />
            <div className="mt-2 grid grid-cols-5 gap-2">
              {imageFields.map((field, index) => (
                <div key={field.id} className="space-y-2">
                  <Image
                    src={URL.createObjectURL(field.value)}
                    alt="Hotel Image"
                    width={200}
                    height={100}
                    className="h-[100px] rounded-sm"
                  />
                  <Button
                    variant="destructive"
                    onClick={() => removeImage(index)}
                    type="button"
                    className="w-full"
                  >
                    Remove
                  </Button>
                </div>
              ))}
            </div>
          </div>
          <div className="col-span-12 flex justify-end">
            <Button type="submit">Publish</Button>
          </div>
        </form>
      </Form>
      {...}
    </>
  );
}

Upvotes: 0

Related Questions