Subiendo archivos al servidor utilizando redux-form con Dropzone en ReactJS

Buenos días en estos días estaba buscando documentación sobre como hacer un formulario en ReactJS con redux-form pero que además me
permitiera la posibilidad de subir archivos hacia un servidor usando la librería Javascript Dropzone. A continuación les describo como quedó
finalmente el proyecto paso a paso.

1- Lo primero es crear el proyecto y agregar las dependencias:

  • create-react-app form-redux-dropzone
  • cd form-redux-dropzone
  • npm install
  • npm install react-toastify prop-types react-dropzone react-icons react-loading-overlay react-redux react-router-dom react-router-redux redux-form redux-logger redux-thunk
  • npm start

2-La estructura del proyecto donde vamos a estar trabajando es la siguiente:

public
 src
   |__core
     |_actions
     |_reducers
     |_service
     |_helper
   |__views
     |_base
          |_form-input
     |_form
   App.js
   App.css
   index.js
   ….

3-Ahora explicaremos que ficheros modificaremos o crearemos y con qué contenido:

1-Definir las actions de redux:

#src/core/actions/index.js
const types = {
    HTTP_PENDING: 'HTTP_PENDING',
    HTTP_ERROR: 'HTTP_ERROR',
    HTTP_SUCCESS: 'HTTP_SUCCESS',
    FILE_ADD: 'FILE_ADD',
    FILE_REMOVE: 'FILE_REMOVE',
    HTTP_PENDING_UPLOAD: 'HTTP_PENDING_UPLOAD',
    HTTP_SUCCESS_UPLOAD: 'HTTP_SUCCESS_UPLOAD',
}
const actions = {
      //HTTP
    startHTTPRequest: () => ({
        type: types.HTTP_PENDING,
    }),
    stopHTTPRequestError: (error) => ({
        type: types.HTTP_ERROR,
        error: error
    }),
    stopRequestSuccess: (payload) => ({
        type: types.HTTP_SUCCESS,
        payload: payload
    }),
    //File
    fileAdd: (file) => ({
        type: types.FILE_ADD,
        file: file
    }),
    fileRemove: (listFiles) => ({
        type: types.FILE_REMOVE,
        listFiles: listFiles
    }),
    startUpload: () => ({
        type: types.HTTP_PENDING_UPLOAD,
    }),
    stopUpload: (file) => ({
        type: types.HTTP_SUCCESS_UPLOAD,
        file: file
    }),
}
export { types, actions };

2-Definir los reducers:

#src/core/reducers/fileReducers.js
import { types } from '../actions';
const initialState = {
    listFiles: [],
};
export default (state = initialState, action) => {
    switch (action.type) {
        case types.FILE_ADD:
            let listAdd = state.listFiles
            listAdd.push(action.file)
            return {
                ...state,
                listFiles: listAdd
            }
        case types.FILE_REMOVE:
            let listDelete = state.listFiles
            let index = listDelete.findIndex(data => data.id === action.id);
            listDelete.splice(index, 1)
            return {
                ...state,
                listFiles: listDelete
            }
        default:
            return state;
    }
}
#src/core/reducers/uploadReducers.js
import { types } from '../actions';
const initialState = {
    pending: false,
    file: {},
};
export default (state = initialState, action) => {
    switch (action.type) {
        case types.HTTP_PENDING_UPLOAD:
            return {
                ...state,
                pending: true
            }
        case types.HTTP_SUCCESS_UPLOAD:
            return {
                ...state,
                pending: false,
                file: action.file
            }
        default:
            return state;
    }
}
#src/core/reducers/httpReducers.js
import { types } from '../actions';
const initialState = {
    pending: false,
    payload: {},
    error: null
};
export default (state = initialState, action) => {
    switch (action.type) {
        case types.HTTP_PENDING:
            return {
                ...state,
                pending: true
            }
        case types.HTTP_SUCCESS:
            return {
                ...state,
                pending: false,
                payload: action.payload            }
        case types.HTTP_ERROR:
            return {
                ...state,
                pending: false,
                error: action.error,
            }
        default:
            return state;
    }
}
#src/core/reducers/index.js
import { combineReducers } from 'redux';
import httpReducer from './httpReducer'
import { reducer as formReducer } from "redux-form";
import { routerReducer } from 'react-router-redux';
import fileReducer from './fileReducer'
import uploadReducer from './uploadReducer'
export default combineReducers({
    httpReducer,
    fileReducer,
    uploadReducer,
    routing: routerReducer,
    form: formReducer,
});

3-Definir el store:

#src/core/helper/store.js
import rootReducer from '../../core/reducers';
import { createStore, applyMiddleware } from 'redux'
import { createLogger } from 'redux-logger'
import thunk from 'redux-thunk';
const loggerMiddleware = createLogger()
export const store = createStore(rootReducer,
    applyMiddleware(
        loggerMiddleware,
        thunk
    )
);

4-Definir el local storage:

#src/core/helper/storage.js
const save = (key, payload) => {
    localStorage.setItem(key, JSON.stringify(payload))
}
const load = (key) => {
    return JSON.parse(localStorage.getItem(key))
}
const remove = (key) => {
    localStorage.removeItem(key)
}
export default { save, load, remove }

5-Definir el http client:

#src/core/services/service.js
import storage from "../helper/storage";
import { toast } from "react-toastify";
const post = (endpoint, body) => {
    const requestOptions = {
        method: 'POST',
        headers: header(),
        body: JSON.stringify(body)
    };
    return fetch(process.env.REACT_APP_BACKEND + endpoint, requestOptions).then(handleResponse)
}
const put = (endpoint, body) => {
    const requestOptions = {
        method: 'PUT',
        headers: header(),
        body: JSON.stringify(body)
    };
    return fetch(process.env.REACT_APP_BACKEND + endpoint, requestOptions)
        .then(handleResponse)
}
const get = (endpoint) => {
    const requestOptions = {
        method: 'GET',
        headers: header(),
    };
    return fetch(process.env.REACT_APP_BACKEND + endpoint, requestOptions)
        .then(handleResponse)
}
const del = (endpoint) => {
    const requestOptions = {
        method: 'DELETE',
        headers: header()
    };
    return fetch(process.env.REACT_APP_BACKEND + endpoint, requestOptions)
        .then(handleResponse)
}
const header = () => {
    let token;
    let data = storage.load(storage.keys.auth)
    if (data && data.access_token) {
        token = data.access_token
    }
    let header = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
    if (token) {
        Object.assign(header, { 'Authorization': 'Bearer ' + token })
    }
    return header;
}
const handleResponse = (response) => {
    if (!response.ok) {
        mapErrorToMessage(response.clone())
        throw response
    } else {
        return response.json().then(data => {
            return data;
        });
    }
}
const mapErrorToMessage = (response) => {
    let code = response.status
    switch (code) {
        case 404:
            toast.error( 'Element not found')
            break;
        case 500:
        toast.error( 'Internal Error Server (This message is only for development proyect)')
            break;
        default:
            return response.json().then(data => {
                if (!data.message) {
                    toast.error(response.statusText)
                } else {
                    toast.error( data.message)
                }
            });
    }
}
export default { post, put, del, get, mapErrorToMessage }

6- Definir los componentes de los campos del formulario en este caso usamos el framework de diseño Boostrap 4 y definimos un componente
para un campo de texto y otro para Dropzone.

#src/views/base/form-input/TextFiled.js
import React from 'react';
export const TextField = ({
  label,
  input,
  type,
  id,
  disabled,
  meta: { touched, invalid, error },
  ...custom
}) => (
    <div>
      <label htmlFor={id}>{label}</label>
      <input {...input} id={id} disabled={disabled} className={(touched && invalid) ? 'form-control form-control-lg is-invalid' : 'form-control form-control-lg'} type={type} placeholder={label} />
       {touched && ((error && <div className="invalid-feedback"> {error}</div>))}
    </div>
  )
#src/views/base/form-input/DropZoneField.js
import React, { useEffect } from "react";
import PropTypes from "prop-types";
import DropZone from "react-dropzone";
import FilePreview from "./FilePreview";
import Placeholder from "./Placeholder";
import ShowError from "./ShowError";
import { compose } from 'redux';
import { connect } from 'react-redux';
import LoadingOverlay from 'react-loading-overlay';
const allowType = [
  "image/jpeg",
  "image/png",
  "image/gif",
  "image/bmp",
  "application/zip",
  "application/vnd.oasis.opendocument.presentation",
  "application/pdf",
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  "application/msword",
  "application/vnd.oasis.opendocument.spreadsheet",
  "application/vnd.oasis.opendocument.text",
  "application/vnd.ms-excel",
  "application/x-rar-compressed",
  "text/plain"
]
const DropZoneField = (props) => {
  const {
    handleOnDrop, label, disabled, pendingUpload,
    input: { onChange },
    listFiles,
    meta: { error, touched }
  } = props
  useEffect(() => {
    onChange(listFiles)
  }, [listFiles.length])
  return (
    <div className="preview-container">
      <label  >{label}  </label>
      <LoadingOverlay
        active={pendingUpload}
        spinner
        text='Uploading file and updating file list...'
      >
        <DropZone
          disabled={disabled || pendingUpload}
          accept={allowType.join(",")}
          className="upload-container"
          onDrop={file => handleOnDrop(file, onChange)}
          multiple={false}
        >
          {props => <div className="row">
            <div style={{ padding: '10px' }} className="col-md-6 col-xs-12">
              {listFiles && listFiles.length > 0 ? (<FilePreview />) : (<div className="empty-preview"> Not file uploaded</div>)
              }
            </div>
            <div className="col-md-6 col-xs-12">
              <Placeholder {...props} error={error} touched={touched} />
            </div>
          </div>
          }
        </DropZone>
        <ShowError error={error} touched={touched} />
      </LoadingOverlay>
    </div>
  )
};
DropZoneField.propTypes = {
  error: PropTypes.string,
  handleOnDrop: PropTypes.func.isRequired,
  imagefile: PropTypes.arrayOf(
    PropTypes.shape({
      file: PropTypes.file,
      name: PropTypes.string,
      preview: PropTypes.string,
      size: PropTypes.number
    })
  ),
  onChange: PropTypes.func,
  touched: PropTypes.bool
};
let mapStateToProps = state => {
  return {
    ...state,
    listFiles: state.fileReducer.listFiles,
    pendingUpload: state.uploadReducer.pending
  }
}
const mapDispatchToProps = (dispatch) => ({});
let enhance = compose(
  connect(mapStateToProps, mapDispatchToProps)
)
export default enhance(DropZoneField);
#src/views/base/form-input/FilePreview.js
#Crea una carpeta en el proyecto y pon dentro los iconos de los ficheros requeridos
#esta carpera esta en src/resource
import React from "react";
import PropTypes from "prop-types";
import { compose } from 'redux';
import { connect } from 'react-redux';
import { actions } from "../../../core/actions";
import pdf from '../../../resource/pdf.png'
import zip from '../../../resource/zip.jpeg'
import doc from '../../../resource/doc.jpeg'
const getIcon = (type, image) => {
    switch (type) {
        case "image/jpeg":
        case "image/png":
        case "image/gif":
        case "image/bmp":
            return image;
            break;
        case "application/zip":
        case "application/x-rar-compressed":
            return zip;
            break;
        case "application/pdf":
            return pdf;
            break;
        case "application/vnd.oasis.opendocument.presentation":
        case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
        case "application/msword":
        case "application/vnd.oasis.opendocument.spreadsheet":
        case "application/vnd.oasis.opendocument.text":
        case "application/vnd.ms-excel":
        case "text/plain":
            return doc;
            break;
    }
}
const FilePreview = (props) => {
    const { listFiles, delFile, pending } = props
    let deleteFile = (id) => {
         delFile(id)
    }
    return (listFiles.map(({ name, preview, humanSize, type, id }) => (
        <div key={name} className="row items">
            <div className="col-3">
                <img width="40" src={getIcon(type, preview)} alt={name} />
            </div>
            <div className="col-5">
                <span className="nameFile">{humanSize}</span>
                <span className="nameFile">{name}</span>
            </div>
            <div className="col-4" style={{ textAlign: "right" }}>
                <button disabled={pending} onClick={() => deleteFile(id)} className="btn btn-danger">X</button>  
            </div>
        </div>
    ))
    )
};
let mapStateToProps = state => {
    return {
        ...state,
        listFiles: state.fileReducer.listFiles,
        pending: state.httpReducer.pending,
    }
}
const mapDispatchToProps = (dispatch) => ({
    delFile: (id) => {
        dispatch(actions.fileRemove(id));
    }
});
let enhance = compose(
    connect(mapStateToProps, mapDispatchToProps)
)
export default enhance(FilePreview);
#src/views/base/form-input/Placeholder.js
import React from "react";
import PropTypes from "prop-types";
import { MdCloudUpload } from "react-icons/md";
const Placeholder = ({ getInputProps, getRootProps, error, touched }) => (
  <div
    {...getRootProps()}
    className={`placeholder-preview ${error && touched ? "has-error" : ""}`}
  >
    <input {...getInputProps()} />
    <MdCloudUpload style={{ fontSize: "40px",marginTop:'50px' }} />
    <p>Click or drag file to this area to upload.</p>
  </div>
);
Placeholder.propTypes = {
  error: PropTypes.string,
  getInputProps: PropTypes.func.isRequired,
  getRootProps: PropTypes.func.isRequired,
  touched: PropTypes.bool
};
export default Placeholder;
#src/views/base/form-input/ShowError.js
import React from "react";
import PropTypes from "prop-types";
import { MdInfoOutline } from "react-icons/md";
const ShowError = ({ error, touched }) =>
  touched && error ? (
    <div className="error-file">{error} </div>
  ) : null;
ShowError.propTypes = {
  error: PropTypes.string,
  touched: PropTypes.bool
};
export default ShowError;

7- Crear el formulario

#src/views/form/Form.js
import React, { useRef, useEffect, useState } from 'react';
import { compose } from 'redux';
import { Field, reduxForm } from 'redux-form'
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom'
import { TextArea } from '../../base/form-input/TextField';
import DropZoneField from '../../base/form-input/DropZoneField';
import { toast } from 'react-toastify';
import service from '../service';
import LoadingOverlay from 'react-loading-overlay';
export const required = value => (value || typeof value === 'number' ? undefined : 'Required')
export const atLeastOne = value =>
{
       return (value  && value.length===0 ? 'At least one file must be uploaded' : undefined)
}
const Form = props => {
    const { handleSubmit, onSubmit, pending = true, upload, pendingUpload, match, } = props
    const { token } = match.params
    let handleOnDrop = (newFile) => {
        if (newFile.length === 0) {
            toast.info("Archivo no permitido")
        } else {
            upload(newFile[0])
        }
    }
    return (
        <section className="testimonial py-5" id="testimonial">
            <div className="container">
                <div className="row ">
                    <div className="col-md-12 py-5 formbox">
                        <LoadingOverlay
                            active={pending}
                            spinner
                            text='Sending data...'
                        >
                            <h4 className="pb-4">Please fill with your details</h4>
                            <form onSubmit={handleSubmit((values) => { onSubmit(values, token) })}>
                                <div className="form-row">
                                    <div className="form-group col-md-6">
                                        <Field
                                            name="exampleField"
                                            component={TextField}
                                            label="Example Field"
                                            validate={[required]}
                                            disabled={pending}
                                            type="text"
                                            id="exampleField"
                                        />
                                    </div>
				   <div className="form-group col-md-6">
                                        <Field
                                            name="exampleField2"
                                            component={TextField}
                                            label="Example Field #2"
                                            validate={[required]}
                                            disabled={pending}
                                            type="text"
                                            id="exampleField2"
                                        />
                                    </div>
                                    <div className="form-group col-md-12">
                                        <Field
                                            name="files"
                                            component={DropZoneField}
                                            label="Evidences"
                                            disabled={pending}
                                            validate={[atLeastOne]}
                                            type="file"
                                            id="files"
                                            handleOnDrop={handleOnDrop}
                                        />
                                    </div>
                                </div>
                                <div style={{ textAlign: "right" }} className="form-row">
                                    <div className="form-group col-md-12">
                                        <button disabled={pendingUpload} type="submit" className="btn btn-danger">Send</button>
                                    </div>
                                </div>
                            </form>
                        </LoadingOverlay>
                    </div>
                </div>
            </div>
        </section>
    );
}
let mapStateToProps = state => {
    return {
        ...state,
        pending: state.httpReducer.pending,
        pendingUpload: state.uploadReducer.pending
    }
}
const mapDispatchToProps = (dispatch) => ({
    onSubmit: (values) => {
        //submit data form service
    },
    upload: file => {
        //upload file service
    },
});
let enhance = compose(
    connect(mapStateToProps, mapDispatchToProps),
    reduxForm({
        form: 'form',
    }),
    withRouter
)
export default enhance(Form);

7- Configurar el router App.js

#src/App.js
import React from 'react';
import './App.css';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.min.css'
import Form from './views/form/Form';
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
function App() {
  return (
    <div className="masthead">
      <ToastContainer position={toast.POSITION.BOTTOM_RIGHT} />
      <Router history={history}>
        <Switch>
          <Route path={`/`} component={Form} />
        </Switch>
      </Router>
    </div>
  );
}
export default App;

8- Configurar el index.js

#src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { Provider } from 'react-redux';
import { store } from './core/helper/store';
ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>
    , document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Listo proyecto terminado!!!!