forge42dev / remix-hook-form

Open source wrapper for react-hook-form aimed at Remix.run
MIT License
330 stars 27 forks source link

File Upload With Other Form Data - Question #82

Closed alexwhb closed 2 months ago

alexwhb commented 6 months ago

Hey I love this library! It works super well. I do, however have one conundrum I've been struggling with for a while. I have a form that has a file upload input as well as a few other text inputs, because I want to submit all of this data to the server at once. I can't seem to figure out a way to do it. Any advice? I've tried everything from sending the file as submit data in useRemixForm to messing with stringifyAllValues, and many other things. No luck.

I'm using react dropzone for my file upload component. I have gotten the file upload to work without the use of remix-hook-form, but I really want to be able to validate my other form elements.

here's what my form looks like currently, though I've played with a million variations:

<Form method="post" onSubmit={handleSubmit} encType="multipart/form-data">

                        <Input
                            placeholder="Event Title"
                            errorMsg={errors.title?.message}
                            {...register("title")}
                        />

                        <Textarea placeholder="Event Description"
                                  errorMsg={errors.description?.message}
                                  {...register("description")}
                        />

                        <ImageUpload previewUrl={imagePreview}
                                     minImageWidth={800}
                                     minImageHeight={500}
                                     onDrop={file => {
                                         setValue('upload', file)
                                         setImageFile(file)
                                         setImagePreview(URL.createObjectURL(file))
                                     }} onError={err => console.log(err)} onRemove={() => {
                            if (imagePreview != null) {
                                URL.revokeObjectURL(imagePreview);
                                setImageFile(null);
                                setImagePreview(null)
                            }
                        }
                        }/>

                        {errors.upload?.message && (<p className="text-red-500">{errors.upload?.message}</p>)}

                        <AddressSearchInput onAddressSelection={(item) => {
                            const loc = item.selectedItem
                            if (loc != null) {
                                setValue('location', {
                                    ...loc,
                                    state: loc.stateCode,
                                    lat: loc.latitude,
                                    long: loc.latitude,
                                    zip: loc.postalCode,
                                })
                            }
                            setLocation(item.selectedItem)
                        }} errorMsg={errors.location?.message}
                                            addressData={addressData}
                        />

                        <DateTimePicker
                            value={dateTime}
                            onChange={function (value: { date: Date; hasTime: boolean; }): void {
                                setValue('eventDateTime', value.date)
                                setDateTime(value)
                            }}
                            className="mt-3 bg-pingray-100 font-light focus:ring-2 focus:outline-none focus:ring-bg-pinblue-500"
                        />

                        {errors.eventDateTime?.message != null && (
                            <p className="text-red-500">{errors.eventDateTime?.message}</p>
                        )}

                        <button
                            type="submit"
                            disabled={isSubmitting}
                            className="bg-pinblue-500 shadow hover:shadow-md w-full text-gray-100 p-2 rounded mt-8 font-['bree'] focus:ring-2 focus:outline-none focus:ring-bg-pinblue-500"
                        >
                            {isSubmitting ? 'Submitting...' : 'Create Event'}
                        </button>
</Form>

note I'm registering anything using setValue in a useEffect.

on the action side I've attempted everything from removing my upload value from zod all together to using remix's Multipart form parser and many other variations.

Should I maybe use multiple actions, and somehow split up my file upload from the other form data processing? And if so... how can I do that in the same component?

AlemTuzlak commented 6 months ago

@alexwhb Hey Alex, thank you for the kind words! Before I answer this, what happens with the images after you upload them?

alexwhb commented 6 months ago

@AlemTuzlak Absolutely. This library is a huge help, and you also introduced me to Zod, which I've subsequently used on another project too.

I'll answer that in two ways, because I'm not 100% sure what way you mean.

1.) I'm currently uploading the file just to my local file system, but eventually I'll likely use S3. I've tested using S3 with S3rver locally using the image buffer and that also works when I'm not using Remix-Hook-Form.

2.) So when I attempt to upload an image this way either I get just a string with the image name in my request data in the action, or I get nothing in the action depending on how I configure the form. If I attempt to use the standard Remix multipart file upload method I get an error: TypeError: Could not parse content as FormData.

here's what my action code looks like in that case:

export const action: ActionFunction = async ({request}) => {
    const directory = "/public/images"

    const uploadHandler = unstable_composeUploadHandlers(
        unstable_createFileUploadHandler({
            directory,
            maxPartSize: 5_000_000,
            file: ({filename}) => {
                const newFileName = `${uuidv4()}.${filename.split('.').pop()}`
                prisma.image.create({
                    data: {
                        url: `/images/${newFileName}`
                    }
                }).then(res => console.log(res))
                return newFileName
            },
        }),
        // parse everything else into memory
    );

     const formData = await unstable_parseMultipartFormData(
        request,
        uploadHandler
    );

    console.log(formData) // this never gets hit. 
    return {};

Let me know if you want any additional info. Happy to to provide it. And thank you for taking a look. Much appreciated.

aknegtel commented 6 months ago

Hi, I'm also having trouble with file upload mixed with other fields.

The setup you use in the documentation for "File upload" doesn't seem to work. I've encountered your problem @alexwhb, where the action says it could not parse as FormData. I resolved it but I don't remember how exactly (I'll try to remember).

My problem resides in the parsing of values not working correctly when mixing files and other content. The validateFormData function returns errors because my resolver expects an array and it encounters a string, as you can see here:

Entering action function: 
Files: [{}]
{
  files: {
    message: 'Expected array, received string',
    type: 'invalid_type',
    ref: undefined
  },
  images: {
    message: 'Expected array, received string',
    type: 'invalid_type',
    ref: undefined
  },
  published: {
    message: 'Expected boolean, received string',
    type: 'invalid_type',
    ref: undefined
  }
}

My action (down below) function is almost exactly like the example, however, I've tried countless variations. I've tried with stringifyAllValues true and false. I've even tried using a custom generateFormData.

const formData = await unstable_parseMultipartFormData(
    request,
    unstable_createMemoryUploadHandler(),
)
// The file will be there
console.log("Files: ", formData.get("files"))
// validate the form data
const { errors, data: product } = await validateFormData<Product>(
    formData,
    resolver,
)

if (errors) {
    console.log(errors)
    return json({ errors, product })
}
AlemTuzlak commented 6 months ago

@alexwhb Sorry for the slow reply! So I wrote an article a while back where I upload images directly to supabase (which I think uploads it to an S3 bucket) so you could do it like this: https://alemtuzlak.hashnode.dev/uploading-images-to-supabase-with-remix What I usually would recommend with file uploads is to have validation where its: z.instanceOf(File).or(z.string()) and when you upload it you parse it and return the url on the server so you can actually save the url to wherever and pass the validation.

When it comes to your problem @aknegtel did you try using the text decoder (you can refer to the same link I provided above) for parsing the rest of the values? unstable_parseMultipartFormData relies on you parsing everything and giving it back to formData, so you need to parse the non file values as well and return them as text.

alexwhb commented 6 months ago

@AlemTuzlak Awesome! Thanks for that resource. I'll give it a try.

cbude commented 6 months ago

@alexwhb Sorry for the slow reply! So I wrote an article a while back where I upload images directly to supabase (which I think uploads it to an S3 bucket) so you could do it like this: https://alemtuzlak.hashnode.dev/uploading-images-to-supabase-with-remix What I usually would recommend with file uploads is to have validation where its: z.instanceOf(File).or(z.string()) and when you upload it you parse it and return the url on the server so you can actually save the url to wherever and pass the validation.

When it comes to your problem @aknegtel did you try using the text decoder (you can refer to the same link I provided above) for parsing the rest of the values? unstable_parseMultipartFormData relies on you parsing everything and giving it back to formData, so you need to parse the non file values as well and return them as text.

I wrote mine like this which works quite well 😄

 image: zod.instanceof(File).refine((file) => {
        return file && file.size <= MAX_FILE_SIZE
    }, `Max image size is 5MB.`)
        .refine(
            (file) => file && ACCEPTED_IMAGE_TYPES.includes(file.type),
            "Only .jpg, .jpeg, .png formats are supported."
    ).or(zod.string().url()).optional(),
cbude commented 6 months ago

Hi, I'm also having trouble with file upload mixed with other fields.

The setup you use in the documentation for "File upload" doesn't seem to work. I've encountered your problem @alexwhb, where the action says it could not parse as FormData. I resolved it but I don't remember how exactly (I'll try to remember).

My problem resides in the parsing of values not working correctly when mixing files and other content. The validateFormData function returns errors because my resolver expects an array and it encounters a string, as you can see here:

Entering action function: 
Files: [{}]
{
  files: {
    message: 'Expected array, received string',
    type: 'invalid_type',
    ref: undefined
  },
  images: {
    message: 'Expected array, received string',
    type: 'invalid_type',
    ref: undefined
  },
  published: {
    message: 'Expected boolean, received string',
    type: 'invalid_type',
    ref: undefined
  }
}

My action (down below) function is almost exactly like the example, however, I've tried countless variations. I've tried with stringifyAllValues true and false. I've even tried using a custom generateFormData.

const formData = await unstable_parseMultipartFormData(
  request,
  unstable_createMemoryUploadHandler(),
)
// The file will be there
console.log("Files: ", formData.get("files"))
// validate the form data
const { errors, data: product } = await validateFormData<Product>(
  formData,
  resolver,
)

if (errors) {
  console.log(errors)
  return json({ errors, product })
}

Hi!

I had similar issued until I used parseFormData before calling validateFormData. That seems to do the trick with getting everything parsed correctly 😄

cluk3 commented 5 months ago

I also was able to get it working by following the various useful tips in the comments, this is the code in case anybody needs it for reference

async function decodeTextFields({ filename, data })  {
  if (!filename) {
    const chunks = [];
    for await (const chunk of data) {
      chunks.push(chunk);
    }
    const buffer = Buffer.concat(chunks);

    const textDecoder = new TextDecoder();
    return textDecoder.decode(buffer);
  }
  // if it has a filename it's the file we want to upload, by returning undefined we delegate the processing of the file to
  // the next uploadHandler, in this example the s3UploadHandler
  return undefined;
};

export async function action({ request }: ActionFunctionArgs) {

  const uploadHandler: UploadHandler = composeUploadHandlers(
    decodeTextFields,
    s3UploadHandler,
  );

  const formData = await parseMultipartFormData(request, uploadHandler);
  const avatarUrl = formData.get("avatar");

  const {
    errors,
    data,
    receivedValues: defaultValues,
  } = await getValidatedFormData<CreateEventSchema>(
    formData as unknown as Request,
    resolver,
  );
  if (errors) {
    return json({ errors, defaultValues }, { status: 422 });
  }

 // do whatever you need to do with the data here, send it to the db I guess :)
}

@AlemTuzlak I have a couple of proposals:

chiptus commented 5 months ago

while doing all of this, I still get "Could not parse content as FormData.". I added some console.log inside remix node_modules/@remix-run/server-runtime/dist/formData.js and I see that content type is application/x-www-form-urlencoded and not multipart/form-data as required. @AlemTuzlak do you change it somewhere?

my Form encType is multipart/form-data

chiptus commented 5 months ago

while doing all of this, I still get "Could not parse content as FormData.". I added some console.log inside remix node_modules/@remix-run/server-runtime/dist/formData.js and I see that content type is application/x-www-form-urlencoded and not multipart/form-data as required. @AlemTuzlak do you change it somewhere?

my Form encType is multipart/form-data

adding

submitConfig: {
      encType: "multipart/form-data",
    },

to my useRemixForm fixed that issue, although I would expect the encType from the Form element to be used.

cluk3 commented 5 months ago

That's s good point, I forgot to mention it in my example. @AlemTuzlak sorry for pinging you again, but it would be nice to update the docs, I can open a PR but first I want to be sure you'll be up to review and eventually merge it

chiptus commented 5 months ago

That's s good point, I forgot to mention it in my example. @AlemTuzlak sorry for pinging you again, but it would be nice to update the docs, I can open a PR but first I want to be sure you'll be up to review and eventually merge it

I think this is something to fix in the library and not something to document

cluk3 commented 5 months ago

I don't think so cause the library is using useSubmit under the hood and this is how useSubmit works

chiptus commented 5 months ago

do you mean useSubmit changes the encType? so why have the encType prop on the form component anyway?

cluk3 commented 5 months ago

You don't need it on the form, when using useSubmit you are managing the form submission through the submit api and not through the Form component. You can check how it works on Remix documentation

chiptus commented 5 months ago

You don't need it on the form, when using useSubmit you are managing the form submission through the submit api and not through the Form component. You can check how it works on Remix documentation

remix docs says to put it on the form. I don't use useSubmit directly

AlemTuzlak commented 2 months ago

About to release a new release with the following improvements:

AlemTuzlak commented 2 months ago

I'm closing this as completed with v5.0.0