Build(Rip off) a React file handler with drag & drop

In the process of building Jagra (My open source enterprise task management system), I have always encountered interesting problems that I need to solve. Such as how to hand the control of signing up a user to the admin, how to securely verify users, how to build a comment system. One of these problems is building a file handler with drag & drop interface.

A Little Side Track Here: Why do I need to have a file handler?

In Jagra, there are a lot of features that need to work with files. For example, admin of a company need to on board new employees. Well, he/she could easily enter the info of one employee and click sign up, but that’s slow if a company is adopting this platform and needs to on board all of their employees all at once. So we support importing from a file. Clever. This platform also will have the features that allow admin to download all of their company’s data and delete the account. That would require writing to a file too. And, if it works one way(exporting), it should work the other too(importing back all the data). Users will also be able to upload file attachments to the tasks, so that part needs to have file handler too. (Or probably a validator too given not all data are reliable)

Ok, What Exactly Are We Building?

I want to build a React component that can handle the process from user input a file to passing this file to the validator and eventually the handler and the backend. A user should be able to choose between clicking a button to open the file picker dialog or dragging and dropping to start the import process. Since we are going to use the same file handler in a lot of places, I’m going to need it to be generic. So the validator needs to be separate, so is the handler.

Well, It Sounds So Complicated, Is There Any NPM Package That We Can Use?

I’m legally lazy here. I don’t want to build something that someone has already built. Naturally, I went searching for a npm package, and here is what I found. React Dropzone. It sounds perfect to me. I can drag and drop files or click the area to start the process. Here is a code sample of how to do it.

import React, {useCallback} from 'react'
import {useDropzone} from 'react-dropzone'

function MyDropzone() {
  const onDrop = useCallback(acceptedFiles => {
    console.log(acceptedFiles);
  }, [])
  const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop})

  return (
    <div {...getRootProps()}>
      <input {...getInputProps()} />
      {
        isDragActive ?
          <p>Drop the files here ...</p> :
          <p>Drag 'n' drop some files here, or click to select files</p>
      }
    </div>
  )
}

With a simple hook, you can get the files you want using a onDrop callback. From there, you can get all the properties of the file, like the name, size, fake path, creation date. etc…

Wait…Only The Properties? Not The Content?

This thing is useless to me then…Right? Not really, but we will get back to it later. For now, I’m pissed. I went searching for something better. Something gives full control of the file content. Something that can make me legally lazy. And, this is what I found. React File Reader Input. The name is quite a mouth full, but the functionality is really good. I get the full control of the file content and the file properties. It’s like the best of both worlds! Here is a sample code of how it works.

import React from 'react';
import FileReaderInput from 'react-file-reader-input';


class MyComponent extends React.Component {
  handleChange = (e, results) => {
    results.forEach(result => {
      const [e, file] = result;
      this.props.dispatch(uploadFile(e.target.result));
      console.log(`Successfully uploaded ${file.name}!`);
    });
  }
  render() {
    return (
      <form>
        <label htmlFor="my-file-input">Upload a File:</label>
        <FileReaderInput as="binary" id="my-file-input"
                         onChange={this.handleChange}>
          <button>Select a file!</button>
        </FileReaderInput>
      </form>
    );
  }
}

With a simple click, you can start the import process. After that, you can use the e.target.result to get the content of the file.

Wait…Click? No Drag & Drop?

This is where everything went wrong. I have found two npm packages, each only does half of what I need. Normally I go back to searching for a better one, but I don’t know what was wrong with me this time, I started to look into how these two packages work. I thought, if both half could be done, then combining them together shouldn’t be too hard.(spoilers, really not hard) The strategy is to find out which is easier, having a event handler that can give me access to the file, or implementing the drag & drop functionality. Then I can just implement the easier one into the other one.

Let’s Read The Source Code!

Reading React File Reader Input‘s source code isn’t too hard. In fact, it’s extremely easy. There is basically only one file that mattered. That’s index.js, and there is only one function that we need to pay attention to. Let’s start from the entry point, the render function.

render() {
    const { as, children, style, ...props } = this.props;
    const hiddenInputStyle = children ? { 
        // If user passes in children, display children and hide input. 
        position: 'absolute', top: '-9999px' }
        : {};
    return ( 
        <div
          className="_react-file-reader-input"
          onClick={this.triggerInput}
          style={style}>
            <input
              {...props}
              type="file"
              ref={(c) => { this._reactFileReaderInput = c; }}
              onChange={this.handleChange}
              onClick={() => { this._reactFileReaderInput.value = null; }}
              style={hiddenInputStyle}
            /> 
            {children}
        </div>
    );
}

Now this is pretty simple. This package is just using standard HTML input with a “file” type.  The onChange event handler get’s an event, and just get’s the file from there. Here is the code for the handler.

handleChange = (e: any) => {
    const files = Array.prototype.slice.call(e.target.files);
    const readAs = (this.props.as || 'url').toLowerCase();
    ...

Hmm… In the onDrop handler from React Dropzone, we only get the files, not the event. Maybe it’s the same files from the event? Yep, a swift look into the the React Dropzone source code, we can see how it uses the onDrop handler and the file input. Remember how to use dropzone? You put props into input tag with this helper.

{...getInputProps()}

So… Let’s see what exactly is this helper.

const getInputProps = useMemo(
    () => ({ refKey = 'ref', onChange, onClick, disabled, ...rest } = {}) => {
        const inputProps = {
            accept,
            multiple,
            type: 'file',
            style: { display: 'none' },
            onChange: composeHandler(composeEventHandlers(onChange, onDropCb)),
            onClick: composeHandler(composeEventHandlers(onClick, onInputElementClick)),
            autoComplete: 'off',
            tabIndex: -1,
            disabled: disabled !== undefined ? disabled : noClick,
            [refKey]: inputRef
        }
        return { ...inputProps, ...rest }
    }, [inputRef, accept, multiple, onDropCb, disabled, noClick]
)

So, basically it’s just making the input tag a file type, and use the onDrop for the onChange handler. Except it’s not quite like that. It’s not the onDrop handler that we pass in, it’s onDropCb. A little digging, onDropCb calls our onDrop handler with 3 parameters.

if (onDrop) { onDrop(acceptedFiles, rejectedFiles, event) }

There we have it. We can use a onDrop handler with only one acceptedFiles parameter, or all 3. This is something the documentation failed to mention. However, through reading both repo’s source code and thinking about the similarities, we were able to find how it works and how we should use it.

Now, Let’s Use It

I want to build a Dropzone component, and it will be used anywhere that requires a file import functionality. Like I said from the beginning, the dropzone should be separate with the handler, validator and the content consumer.

import React, { useCallback } from "react";
import { useDropzone } from "react-dropzone";

const MyDropzone = ({
    handleChange, onChange, as, wrapperStyle, activeText, inActiveText,
}) => {
    const onDrop = useCallback(acceptedFiles => {
        handleChange(acceptedFiles, onChange, as);
    }, []);
    const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
    return (
        <div {...getRootProps({ className: wrapperStyle })}>
            <input {...getInputProps()} />
                <p className="element--dropzone__text">
                    {isDragActive ? activeText : inActiveText}
                </p>
        </div>
    );
};

export default MyDropzone;

Now, wherever the MyDropzone component will be used, the parent component should provide the handleChange handler. This handler will be from the react-file-reader-input package that we talked about. We can customize what format we want the file read as, such as binary, text or buffer. Then, the parent component will provide the onChange handler as the consumer of the file content. From there, we can use the validator of our choice. Now, let’s take a look at the handleChange method. We couldn’t just use it from the react-file-reader-input package since it all come as a whole component, but single functions. Here, we just make a copy of our own.

function handleChange(files, onChange, as) {
    const readAs = (as || "url").toLowerCase();
    Promise.all(files.map(file => new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = result => {
            resolve([result, file]);
        };
        switch (readAs) {
            case "binary": {
                (reader).readAsBinaryString(file);
                break;
            }
            case "buffer": {
                reader.readAsArrayBuffer(file);
                break;
            }
            case "text": {
                reader.readAsText(file);
                break;
            }
            case "url": {
                reader.readAsDataURL(file);
                break;
            }
            default: {
                reader.readAsDataURL(file);
                break;
            }
        }
    })))
    .then(zippedResults => {
        onChange(zippedResults);
    });
}

Finally, we just need the onChange from the parent component as the content consumer.

onChange(results) {
    results.forEach(result => {
        const [e, file] = result;
        const validator = validateFile(file, e.target.result, FORMAT.JSON, employeeTemplate);
        if (validator.isValid) {
            const res = JSON.parse(e.target.result);
            console.log("res: ", res);
            res.data.map(item => signup(item.email, item.firstName, item.lastName));
            console.log(`Successfully uploaded ${ file.name }!`);
        } else {
            console.log("Validator error: ", validator.error);
        }
    });
}

The results parameter that the onChange method will get will be from the handleChange function when it calls onChange at the end with the variable zippedResults. onChange will get the results and read the content from the event argument. The validator will then validate the data.

function validateFile(file, content, format, template) {
    if (!file.name || !file.name.endsWith(format)) {
        return {
            isValid: false,
            error: `File is not in the correct format which is ${ format }.`,
        };
    }
    let error = "";
    const validate = data => {
        console.log(data);
        let hasErr = false;
        const result = JsonValidator.validate(data, template, (err, message) => {
            if (err) { hasErr = true; return; }
            console.log(message);
        });
        console.log("Validator's result of each entry: ", result);
        if (hasErr) {
            error = result;
            return false;
        }
        return true;
    };
    const reducer = (x, y) => (x && y);
    const obj = JSON.parse(content);
    console.log("Validating file ", obj);
    return {
        isValid: obj.data && obj.data.length && obj.data.map(d => validate(d)).reduce(reducer),
        error,
    };
}

This is the validator that we are using. It right now specifies a json validator, but in the future, we will need to update this to use more file formats.

Conclusion

I don’t think we need any form of conclusion. I built it and it works. I will be improving it in the future. Big thanks to the authors to the two npm packages. Without them, I would not have been able to be legally lazy. So please check out their work here and here.

 

 

Like the content? Buy me a coffee!

$2.99

 

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s