forge42dev / remix-hook-form

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

Files/Blobs are not getting returned in Action #48

Closed Centerworx closed 10 months ago

Centerworx commented 10 months ago

getValidatedFormData is not returning the file data.

{
  firstName: 'Jack',
  lastName: 'Sparrow',
  screenName: 'sparrow1',
  profileImage: { '0': {} } // No blob/file data
}

Issue is here: https://github.com/Centerworx/remix-hook-form/blob/d082e1d0e7929451505db9ac0519952860d56bc4/src/utilities/index.ts#L49

FormData Blob/FIles are not handled. JSON cannot hold a file.

formData.set(pathString, blob) or  formData.set(pathString, blob, filenameString)

you can use react-hook-form set function to do this.

import { set } from "react-hook-form";

set(
  object: FieldValues, // in your case currentObject
  path: string, // formData key
  value?: unknown, // formData blob 
)

With this set function you can eliminate all the string parsing and conversion you are doing in "generateFormData" it would just become the below:

/**
 * Generates an output object from the given form data, where the keys in the output object retain
 * the structure of the keys in the form data. Keys containing integer indexes are treated as arrays.
 *
 * @param {FormData} formData - The form data to generate an output object from.
 * @param {boolean} [preserveStringified=false] - Whether to preserve stringified values or try to convert them
 * @returns {Object} The output object generated from the form data.
 */
export const generateFormData = (
  formData: FormData,
  preserveStringified = false,
) => {
  // Initialize an empty output object.
  const outputObject: Record<any, any> = {};

  // Iterate through each key-value pair in the form data.
  for (const [key, value] of formData.entries()) {
    if (value instanceof Blob || toString.call(value) === "[object Blob]") {
        set(currentObject, key, value)
        continue;
    }

    // Try to convert data to the original type, otherwise return the original value
    const data = preserveStringified ? value : tryParseJSON(value.toString());

    set(currentObject, key, data)
  }

  // Return the output object.
  return outputObject;
};

The only issue with using react-hook-form set function is it will not use empty [] in keys urlSearchParams or dotKey syntax. You can use my "cleanArrayStringKeys" function to allow for empty [] or remove those test to conform to react-hook-form standards, you could even throw an error if there is an empty [].

/**
 * cleanArrayStringUrl - This is a Fix for react-hook-form url empty [] brackets set conversion,
 * react-hook-form will not load array values that use the empty [] keys.
 *
 * This utility will add number keys to empty [].
 *
 * Note: empty url arrays and no js forms don't seam to be very popular either.
 *
 * @param {string} urlString
 * @returns {string}
 */
export const cleanArrayStringKeys = (urlString: URLSearchParams) => {
  const cleanedFormData = new URLSearchParams();
  const counter = {} as Record<string, number>;

  for (const [path, value] of urlString.entries()) {
    const keys = path.split(/([\D]$\[\])/g);
    // early return if single key
    if (keys.length === 1 && !/\[\]/g.test(keys[0])) {
      cleanedFormData.set(keys.join(""), value);
      continue;
    }

    const cleanedKey = [] as string[];

    // adds indexes to empty arrays to match react-hook-form
    keys.forEach((key) => {
      key = key.replace(/\.\[/g, "[");
      counter[key] = (counter[key] === undefined ? -1 : counter[key]) + 1;

      cleanedKey.push(key.replace(/\[\]/g, () => `[${counter[key]}]`));

      // merge array keys back into path
      const cleanedPath = cleanedKey.join("");

      cleanedFormData.set(cleanedPath, value);
    });
  }

If you don't want to simplify your code, you can just add this to "tryParseJSON", this should do the trick, but should be tested:

 tryParseJSON= (jsonString) => {
  if (jsonString === "null") {
    return null;
  }
  if (jsonString === "undefined") {
    return void 0;
  }
  if (jsonString instanceof File || jsonString instanceof Blob) { // new check before JSON.parse 
    return jsonString;
  }

on a side note: value instanceof Blob -> Some node servers (<16, It can be imported in 16+) may not have access to Blob and so it could break some projects. See ref: https://stackoverflow.com/questions/14653349/node-js-cant-create-blobs#:~:text=41-,Since%20Node.js%2016,-%2C%20Blob%20can

Centerworx commented 10 months ago

@AlemTuzlak See above.

AlemTuzlak commented 10 months ago

The issue here is that you can't parse files from request.formData(), you have to use Remix unstable_parseMultiPartFormData. You get the streamed data from the frontend there and you can return it to your action however you like, I wrote an article explaining how yo ucan upload your images to supabase and return paths here: https://alemtuzlak.hashnode.dev/uploading-images-to-supabase-with-remix Although this set helper seems really interesting, is there a doc page anywhere on how it works?

Centerworx commented 10 months ago

No, it's an internal react-hook-form method, but it is exposed form repo and it's basically doing a similar thing to what you're already doing with the added advantage that it automatically appends the data set to the original data object. I found it by deep diving react-hook-form library, and how it handled dot syntax parsing.