ui icon indicating copy to clipboard operation
ui copied to clipboard

Type Errors When Using File Input in Form

Open Lermatroid opened this issue 2 years ago • 5 comments
trafficstars

Hi,

I am using the <Form /> component with a file input inside of it. I am running into various type errors however, primarily with the deconstructed field values on the input saying type File cannot be assigned as its value. Here is my code:

useForm hook:

	const form = useForm<z.infer<typeof formValidator>>({
		resolver: zodResolver(formValidator),
		defaultValues: {
			name: "",
			bio: "",
			tag: "",
			photo: new File([], ""),
		},
	});

Validator:

	const formValidator = newTeamValidator.merge(
		z.object({
			photo: z.instanceof(File).refine((f) => f.size < c.maxProfilePhotoSizeInBytes),
		})
	);

Input:

				<FormField
					control={form.control}
					name="photo"
					render={({ field }) => (
						<FormItem>
							<FormLabel>Team Photo</FormLabel>
							<FormControl>
								<Input
									accept="image/png, image/jpeg"
									multiple={false}
									className="dark:bg-transparent cursor-pointer file:cursor-pointer file:text-primary dark:border-primary dark:ring-offset-primary"
									{...field}
								/>
							</FormControl>
							<FormDescription>If you do not select a photo one will be generated.</FormDescription>
							<FormMessage />
						</FormItem>
					)}
				/>

The error:

image

Lermatroid avatar Jul 31 '23 18:07 Lermatroid

This is due to react-hook-form uses the field value by default, you need to update the onChange to use theFile object.

For example:

In the form schema:

media: z.instanceof(File)

the default value:

media: new File([], "")

then the input field:

<FormField
control={form.control}
name="media" 
render={({ field }) => (
  <FormItem>
    <FormLabel>
      Media
    </FormLabel>
    <FormControl>
      <Input
        accept=".jpg, .jpeg, .png, .svg, .gif, .mp4"
        type="file"
        onChange={(e) =>
          field.onChange(e.target.files ? e.target.files[0] : null)
        }
      />
    </FormControl>
    <FormMessage />
  </FormItem>
)}
/>

pyk avatar Aug 11 '23 18:08 pyk

onChange={(e) =>
          field.onChange(e.target.files ? e.target.files[0] : null)
        }

Have you find a solution to this?

dewodt avatar Aug 18 '23 11:08 dewodt

Anyone has any idea on how to style the file upload as a button without hacking the entire world? I feel dirty.

My current code below works... but the button is not accessible. I have a working solution using a Button without the asChild and with Input with sr-only classname. It supports single and multiple files and it works great, but it relies on having an innerRef that can be programmatically clicked when the button is clicked or being able to pass an explicity ref to the component from the consumer and since hook-form does not provide an easy access to the ref being passed to Form.Input I can't use that solution...

That's how I got to the nasty code below, which works and integrates well with React Hook Form and respective Zod validation, but is: 1) not accessible, 2) not very scalable (flimsy implementation) , 3) only supports one file

Edit: I was able to make the div that contains the input "focusable" by adding tabIndex={0} role="button" but I still find this ugly AF. There must be a better way.

// Implementation that works with React Hook Form but is not accessible
<Form.Field
  control={form.control}
  name="media"
  render={({ field }) => (
    <Form.Item>
      <Form.Label>Media</Form.Label>
      <Form.Control>
        <Button asChild size="icon" variant="outline">
          <div
            tabIndex={0}
            role="button"
            className={cn("relative flex w-full items-center space-x-2", {
              hidden: field.value !== null,
            })}
          >
            <PlusIcon />
            <span>Choose an image</span>
            <Form.Input
              className="absolute inset-0 cursor-pointer opacity-0"
              accept="image/png, image/jpeg"
              type="file"
              onChange={(e) => {
                field.onChange(e.target.files ? e.target.files[0] : null);
              }}
            />
          </div>
        </Button>
      </Form.Control>
      {field.value !== null ? (
        <div className="flex items-center gap-x-2 rounded bg-red-50">
          <Button size="icon" variant="destructive" onClick={handleFileRemoval}>
            <Cross1Icon />
          </Button>
          <span className="truncate py-2.5 text-xs">{field.value.name}</span>
        </div>
      ) : null}
      <Form.FieldMessage />
    </Form.Item>
  )}
/>

// Implementation that does not work with React Hook Form but is accessible
const FileUpload = forwardRef<FileUploadElement, FileUploadProps>(
  (
    {
      children,
      className,
      id,
      invalid,
      multiple,
      label,
      onFileChanges,
      size = 'md',
      variant = 'secondary',
      ...props
    },
    ref,
  ) => {
    /**
     * Fallback `ref` for when consumer of `FileUpload` does not provide their own `ref`
     */
    const innerRef = useRef<HTMLInputElement>(null)
    const [innerFiles, setInnerFiles] = useState<File[]>([])

    /**
     * Notify consumers for file changes. Could be done without `useEffect` if changes
     * came only from the `input` element, but since we can remove files through `chips`
     * click useEffect becomes easier to handle for the consumers, since remove routine
     * is handled internally and consumers only get the "full source".
     *
     * The wrapping button default event is prevented, allowing it's safe usage within
     * forms.
     */
    useEffect(() => {
      onFileChanges(innerFiles)
    }, [innerFiles, onFileChanges])

    const hasLabel = !!label

    function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
      const newFiles = Array.from(e.currentTarget.files ?? [])

      setInnerFiles((currentFiles) => [...currentFiles, ...newFiles])
    }

    function handleClick() {
      if (ref !== null && typeof ref === 'object') {
        console.info('File upload interaction handled with consumer forwarded ref')
        ref.current?.click()
      } else {
        console.info('File upload interaction handled with inner ref consumer')
        innerRef.current?.click()
      }
    }

    function handleRemove(
      e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
      fileName: string,
    ) {
      e.preventDefault()

      setInnerFiles((current) => current.filter((f) => f.name !== fileName))
    }

    return (
      <div>
        {hasLabel && <Label htmlFor={id}>{label}</Label>}
        {!multiple && innerFiles.length > 0 ? null : (
          <Button
            className={cn(className, 'flex items-center', { 'mt-1': hasLabel })}
            size={size}
            variant={variant}
            onClick={handleClick}
          >
            {children}
            <input
              accept="image/png, image/jpeg"
              data-invalid={invalid ? '' : undefined}
              id={id}
              {...props}
              className="sr-only"
              onChange={handleChange}
              ref={ref ?? innerRef}
              type="file"
            />
          </Button>
        )}
        {innerFiles.length > 0 ? (
          <div className="mt-1 space-y-1">
            {innerFiles.map((file) => (
              <div
                className="flex items-center gap-x-2 rounded bg-red-50"
                key={file.name}
              >
                <Button
                  size="icon"
                  variant="destructive"
                  onClick={(e) => handleRemove(e, file.name)}
                >
                  <Cross1Icon />
                </Button>
                <span className="truncate py-2.5 text-xs">{file.name}</span>
              </div>
            ))}
          </div>
        ) : null}
      </div>
    )
  },
)

FileUpload.displayName = 'FileUpload'

FranciscoKloganB avatar Oct 15 '23 13:10 FranciscoKloganB

Facing same issue ith this simple code

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"

// Updated form schema to include file input
const formSchema = z.object({
  username: z.string().min(2, {
    message: "Username must be at least 2 characters.",
  }),
  file: z.instanceof(File, {
    message: "Please upload a file.",
  }),
})

const FileUpload = () => {
  const form = useForm({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      file: "",
    },
  })

  // Updated submit handler to include file handling
  function onSubmit(values) {
    console.log(values)
    // Handle file upload logic here
  }

  return (
    <div>
      <Card className="w-[350px]">
        <CardHeader>
          <CardTitle>Upload</CardTitle>
          <CardDescription>Generate </CardDescription>
        </CardHeader>
        <CardContent>
          <Form {...form}>
            <form className="space-y-8" onSubmit={form.handleSubmit(onSubmit)}>
              {/* New File Input Field */}
              <FormField
                control={form.control}
                name="username"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Username</FormLabel>
                    <FormControl>
                      <Input placeholder="shadcn" {...field} />
                    </FormControl>
                    <FormDescription>
                      This is your public display name.
                    </FormDescription>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="file"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>File</FormLabel>
                    <FormControl>
                      <Input accept=".pdf, .docx" type="file" {...field} />
                    </FormControl>
                    <FormDescription>
                      Upload your syllabus here.
                    </FormDescription>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <Button type="submit">Submit</Button>
            </form>
          </Form>
        </CardContent>
        <CardFooter className="flex justify-between">
        </CardFooter>
      </Card>
    </div>
  )
}

export default FileUpload

tkejr avatar Jan 22 '24 22:01 tkejr

@tkejr I don't recommend to use {...field} in

<FormControl>
  <Input accept=".pdf, .docx" type="file" {...field} />
</FormControl>

It caused me a lot of pain. Do it like below

<FormControl>
  <Input
    accept=".pdf, .docx" 
    type="file"
    onChange={(e) => field.onChange(e.target.files?.[0])}
  />
</FormControl>

Then, if you want to keep the validation tidy, I might recommend to remove also accept=".pdf, .docx" and implement the your formSchema like

const MAX_MB = 10
const MAX_UPLOAD_SIZE = 1024 * 1024 * MAX_MB;
const ACCEPTED_FILE_TYPES = [
  'application/pdf',
  '...', // other MIME types
];
const formSchema = z.object({
  // ...
  file: z
    .instanceof(File)
    .refine((file) => {
      return !file || file.size <= MAX_UPLOAD_SIZE;
    }, `File size must be less than ${MAX_MB}MB`)
    .refine((file) => {
      return ACCEPTED_FILE_TYPES.includes(file.type);
    }, 'File must be a ...'),
})

Don't forget to initialise the file default value in the correct way

export const formConfig = {
  resolver: zodResolver(formSchema),
  defaultValues: {
    // ...
    file: new File([], ''), // <--- Typescript will appreciate
  }
}

rppala3 avatar May 21 '24 16:05 rppala3

This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.

shadcn avatar Jul 07 '24 23:07 shadcn