SPFx – Uploading files using react-dropzone

Introduction

Hi friends, let us learn one of the simplest way to upload files to SharePoint document library. We will use react-dropzone for file selection and validation related to files. Let us examine how we can use the control and some of the pnp methods to upload the files.

The main focus of this post is to explain how we can leverage on the react-dropzone module, pnp/sp to upload the files asynchronously without making the users to wait until the files are uploaded and also we can also stop the upload and show error message, if there is any network failure in the middle of upload.

Base project setup & npm packages

Regarding the basic creation of the project and other stuff, please refer to my previous blog post.

I hope those who are familiar with SPFx would know how to create the project and add the dependent npm packages. The following are some of the pre-requisites for the solution to work.

  • SharePoint Yeoman generator version 1.11.0
  • @pnp/sp version 2.6.0
  • react-dropzone version 11.3.4
  • lodash version 4.17.21
Final Output

Let’s start coding

Once the project is created with all the dependencies. Add the following code to the webpart.ts file

import { sp } from '@pnp/sp';

Add the following code to initialize the pnp/sp with the current context.

public onInit(): Promise < void> {
    sp.setup(this.context);
    return Promise.resolve();
}

Add the following code to define the property to pass the below properties

  • webSerUrl – Web server relative URL
  • asyncUpload – To allow asynchronous upload to SharePoint or not
webSerUrl: this.context.pageContext.web.serverRelativeUrl
asyncUpload: this.properties.asyncUpload

Declare the web part property in the getPropertyPaneConfiguration() method

PropertyPaneToggle('asyncUpload', {
    label: 'Use Async Upload',
    onText: 'Enable',
    offText: 'Disable',
    key: 'useAsyncUploadFieldToggle',
    checked: this.properties.asyncUpload
})

Create a new file utils.ts and copy-paste the below code. All the hard-coded properties and the SharePoint transaction methods are defined in this file. Comments metioned in the file will guide you to understand its usage.

import { sp } from "@pnp/sp";
import '@pnp/sp/webs';
import '@pnp/sp/folders';
import '@pnp/sp/files';
import { trim } from 'lodash';

/** Loader type */
export enum LoaderType {
    Spinner = 0,
    Indicator = 1
}

/** Special characters to be replaced if exists in filename */
export const regex_fle_invalidChars = /.[~!@#$%^&*+=\';:\",\/\\\{\}]/g;
/** Maximum length of filename to be used and beyond this length the filename will be truncated */
export const fle_nameMaxLength = 70;
/** Maximum size of all the files should not exceed 50MB */
export const fle_maxFileListLength = 52428800;
/** Maximum size per file is 10MB */
export const fle_maxFileLength = 10485760;
/** Number of files allowed */
export const fle_maxfilesallowed = 10;

/** File upload interface */
export interface IFileUploadInfo {
    trimmedName: string;
    displayName: string;
    name: string;
    uploadedFilename: string;
    content: any;
    size: string;
    actualSize: number;
    id: string;
    uploadStatus: boolean;
    FileServerRelUrl?: string;
}

/** Cleaned file name after special character replacement
 * This clean name can be used for the display purpose
 */
export function returnCleanedFilename(filename: string) {
    let newfilename = filename.replace(new RegExp(regex_fle_invalidChars), '');
    if (newfilename.length > 0) {
        let fleExtn = newfilename.split('.').pop();
        return trim(newfilename.replace(`.${fleExtn}`, '')) + `.${fleExtn}`;
    }
}
/** Trimmed file name based on the length and also after replacing the special characters
 * This name will be used while uploading the file to SharePoint
 */
export function trimmedFilename(filename: string) {
    let newfilename = filename.replace(new RegExp(regex_fle_invalidChars), '');
    if (newfilename.length > 0) {
        let fleExtn = newfilename.split('.').pop();
        let filenameWOExtn = trim(newfilename.replace(`.${fleExtn}`, ''));
        console.log(filenameWOExtn);
        if (filenameWOExtn.length > fle_nameMaxLength) return filenameWOExtn.substr(0, fle_nameMaxLength) + `.${fleExtn}`;
        else return newfilename;
    }
}
/** Upload files to the folder */
export async function uploadFiles(folderPath: string, filename: string, filecontent: any) {
    return await sp.web.getFolderByServerRelativeUrl(folderPath).files.add(filename, filecontent, true);
}

Create a new file ContentLoader.tsx and copy-paste the below code. Content loader component to display different loading.

import * as React from 'react';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { LoaderType } from './utils';

export interface IContentLoaderProps {
    loaderMsg?: string;
    loaderType: LoaderType;
    spinSize?: SpinnerSize;
}

const ContentLoader: React.FunctionComponent<IContentLoaderProps> = (props) => {
    return (
        <div className="ms-Grid-row">
            {props.spinSize === SpinnerSize.xSmall ? (
                <div style={{ margin: "10px", marginRight: '14px' }}>
                    <Spinner label={props.loaderMsg} size={SpinnerSize.xSmall} ariaLive="assertive" labelPosition="top" />
                </div>
            ) : (
                <div style={{ margin: "20px" }}>
                    {props.loaderType == LoaderType.Spinner &&
                        <Spinner label={props.loaderMsg} size={props.spinSize ? props.spinSize : SpinnerSize.large} ariaLive="assertive" labelPosition="top" />
                    }
                    {props.loaderType == LoaderType.Indicator &&
                        <ProgressIndicator label={props.loaderMsg} description="Please wait..." />
                    }
                </div>
            )}
        </div>
    );
};

export default ContentLoader;

Create a file Message.tsx and copy-paste the below code. Message component to display different status message.

import * as React from 'react';
import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import styles from './SampleFileUpload.module.scss';
import { css } from 'office-ui-fabric-react/lib/Utilities';

export enum MessageScope {
    Success = 0,
    Failure = 1,
    Warning = 2,
    Info = 3
}

export interface IMessageContainerProps {
    Message?: string;
    MessageScope: MessageScope;
}


const MessageContainer: React.FunctionComponent<IMessageContainerProps> = (props) => {
    return (
        <div className={styles.MessageContainer}>
            {
                props.MessageScope === MessageScope.Success &&
                <MessageBar messageBarType={MessageBarType.success} className={styles.msgText}>{props.Message}</MessageBar>
            }
            {
                props.MessageScope === MessageScope.Failure &&
                <MessageBar messageBarType={MessageBarType.error} className={styles.msgText}>{props.Message}</MessageBar>
            }
            {
                props.MessageScope === MessageScope.Warning &&
                <MessageBar messageBarType={MessageBarType.warning} className={styles.msgText}>{props.Message}</MessageBar>
            }
            {
                props.MessageScope === MessageScope.Info &&
                <MessageBar className={css(styles.infoMessage, styles.msgText)}>{props.Message}</MessageBar>
            }
        </div>
    );
};

export default MessageContainer;

Create a file FileDropZone.tsx and copy-paste the below code. This component is mainly responsible for handling the files and also based on the web part property the component will trigger the async upload.

import * as React from 'react';
import { useEffect, useState, FC } from 'react';
import styles from './SampleFileUpload.module.scss';
import { FileError, useDropzone } from 'react-dropzone';
import { css } from 'office-ui-fabric-react/lib/Utilities';
import { fle_maxFileLength, fle_maxFileListLength, fle_maxfilesallowed, IFileUploadInfo } from './utils';
import { differenceBy, remove, slice, trim } from 'lodash';
import { returnCleanedFilename, trimmedFilename } from './utils';
import MessageContainer, { MessageScope } from './Message';

export interface IFileDropZoneProps {
    tempUpload: (filename: string, fileContent: any) => Promise<boolean>;
    dropCallback: (selectedFiles) => void;
    documentToUpload: any[];
    clearMessage?: boolean;
    disableUpload: boolean;
    showUploadProgress: () => void;
    hideUploadProgress: () => void;
    useAsyncUpload: boolean;
}

const FileDropZone: FC<IFileDropZoneProps> = (props) => {
    /** State Variables */
    const [disableUpload, setDisableUpload] = useState<boolean>(false);
    const [docsToUpload, setDocsToUpload] = useState<IFileUploadInfo[]>([]);
    const [errorMessage, setErrorMessage] = useState<string>('');

    /** Method triggered to read the contents of the file and 
     * return it along with the other file properties 
    */
    const _handleFileUpload = async (file, tempDocsToUpload) => {
        return new Promise((res, rej) => {
            const fileReader = new FileReader();
            fileReader.onload = ((fle) => {
                return ((e) => {
                    tempDocsToUpload.push({
                        trimmedName: fle.TrimmedName,
                        displayName: fle.DisplayName,
                        name: fle.name,
                        content: e.target.result,
                        size: fle.size > 1048576 ? Math.round(fle.size / 1024 / 1024).toString() + 'MB' : Math.round(fle.size / 1024).toString() + 'KB',
                        actualSize: fle.size,
                        id: Math.random().toString(),
                        uploadStatus: false
                    });
                    res(tempDocsToUpload);
                });
            })(file);
            fileReader.readAsArrayBuffer(file);
        });
    };
    /** Method triggered when the file is selected or dropped in the dropZone */
    const _handleFileChange = async (files) => {
        setErrorMessage('');
        let tempDocsToUpload: IFileUploadInfo[] = docsToUpload;
        let batch = [];
        if (tempDocsToUpload.length > 0) {
            var diffFiles: any[] = differenceBy(files, tempDocsToUpload, (f) => trim(f.name));
            if (diffFiles.length > 0) {
                for (let i = 0; i < diffFiles.length; i++) {
                    batch.push(await _handleFileUpload(diffFiles[i], tempDocsToUpload));
                }
            }
        } else {
            for (let i = 0; i < files.length; i++) {
                batch.push(await _handleFileUpload(files[i], tempDocsToUpload));
            }
        }
        if (batch.length > 0) {
            props.showUploadProgress();
            await Promise.all(batch);
            if (tempDocsToUpload.length <= fle_maxfilesallowed) {
                if (props.useAsyncUpload) {
                    for (let fletoupload of tempDocsToUpload) {
                        if (!fletoupload.uploadStatus) {
                            let uploadStatus: boolean = await props.tempUpload(fletoupload.trimmedName, fletoupload.content);
                            fletoupload.uploadStatus = uploadStatus;
                        }
                    }
                    remove(tempDocsToUpload, (o) => !o.uploadStatus);
                }
                setDocsToUpload(tempDocsToUpload);
                props.dropCallback(tempDocsToUpload);
            } else {
                tempDocsToUpload = slice(tempDocsToUpload, 0, fle_maxfilesallowed);
                setDocsToUpload(tempDocsToUpload);
                props.dropCallback(tempDocsToUpload);
                setErrorMessage('Sorry, you have reached the max number and no more files can be uploaded!');
            }
        }
        setDisableUpload(false);
    };
    /** Method triggered to validate the file */
    const _fileValidation = (file: File): FileError | FileError[] => {
        if (file.size > fle_maxFileLength) {
            return {
                code: 'file_max_size',
                message: `File is greater than max size of ${fle_maxFileLength / 1024 / 1024}MB`
            };
        }
        return null;
    };
    /** Method triggered to store custom properties apart from the default file properties
     * In our case we are having different filenames, we can use this method to return
     * Trimmeed Filename and Cleaned Filename
     */
    const _customFileProperties = async (e): Promise<(File | DataTransferItem)[]> => {
        const files = [];
        const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files;
        for (var i = 0; i < fileList.length; i++) {
            const file = fileList.item(i);
            Object.defineProperty(file, 'DisplayName', {
                value: returnCleanedFilename(file.name)
            });
            Object.defineProperty(file, 'TrimmedName', {
                value: trimmedFilename(file.name)
            });
            files.push(file);
        }
        return files;
    };
    /** Method triggered when the file is dropped or selected */
    const _onDropDocuments = async (selFiles) => {
        setDisableUpload(true);
        _handleFileChange(selFiles);
    };
    /** Initialize the dropZone component with the configured properties */
    const { getRootProps, getInputProps, fileRejections, acceptedFiles } = useDropzone({
        //accept: 'image/jpeg, image/jpg, image/png',
        maxFiles: fle_maxfilesallowed,
        multiple: true,
        disabled: props.disableUpload || disableUpload,
        noClick: disableUpload,
        noDrag: disableUpload,
        noDragEventsBubbling: disableUpload,
        noKeyboard: disableUpload,
        onDrop: _onDropDocuments,
        validator: _fileValidation,
        getFilesFromEvent: e => _customFileProperties(e)
    });
    /** Method to display validation failed file info */
    const fileRejectionItems = fileRejections.map(({ file, errors }) => (
        <li key={file.name}>
            {file.name} - {file.size > 1048576 ? Math.round(file.size / 1024 / 1024) + ' MB' : Math.round(file.size / 1024) + ' KB'}
            <ul>
                {errors.map(e => (
                    <li key={e.code} style={{ fontWeight: 'normal' }}>{e.message}</li>
                ))}
            </ul>
        </li>
    ));

    useEffect(() => {
        if (props.clearMessage) setErrorMessage('');
    }, [props.clearMessage]);
    useEffect(() => {
        setDocsToUpload(props.documentToUpload);
        setDisableUpload(props.disableUpload);
    }, [props.documentToUpload, props.disableUpload]);

    return (
        <section className={styles.dropZoneContainer}>
            <div {...getRootProps({ className: css(styles.dropzone, disableUpload ? styles.dropZonedisabled : '') })}>
                <input {...getInputProps()} />
                <p>{"Drag 'n' drop the documents, or click to select the documents"}</p>
            </div>
            <div className={styles.dropZoneInfo}>
                {`Maximum of ${fle_maxfilesallowed} files can be uploaded. Each file can have a max size of ${fle_maxFileLength / 1024 / 1024}MB. The total file size allowed per case is ${fle_maxFileListLength / 1024 / 1024}MB.`}
            </div>
            {errorMessage.length > 0 &&
                <MessageContainer MessageScope={MessageScope.Failure} Message={errorMessage} />
            }
            {fileRejections.length > 0 &&
                <aside className={styles.fileswitherror}>
                    <h4>Error with files</h4>
                    <ul>{fileRejectionItems}</ul>
                </aside>
            }
        </section>
    );
};

export default FileDropZone;

Update the webpart.module.scss file with the below styles.

@import '~office-ui-fabric-react/dist/sass/References.scss';

.sampleFileUpload {
    .container {
        margin: 10px;
    }
    /* File Drop Zone */
    .dropZoneContainer {
        display: flex;
        flex-direction: column;
        font-family: sans-serif;    
        .dropzone {
            flex: 1;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 10px;
            border-width: 2px;
            border-radius: 3px;
            border-color: "[theme:themePrimary]";
            border-style: dashed;
            background-color: "[theme:themeTertiary]";
            color: "[theme:themeWhite]";
            outline: none;
            font-weight: bold;
            cursor: pointer;
            transition: border .24s ease-in-out;
            &:hover, &::after {
                border-width: 2px;
                border-style: dashed;
                border-color: "[theme:white]";
                background-color: "#c9ccab";
                transition: border .24s ease-in-out;
            }
            p {
                margin: 5px;
                color: "[theme:white]";
                cursor: pointer;
            }
        }
        .dropZonedisabled {
            border-color: #CCC !important;
            background-color: lightgrey !important;
            cursor: not-allowed;
        }
        .thumbsContainer {
            display: flex;
            flex-direction: row;
            flex-wrap: wrap;
            margin-top: 16px;
            .thumb {
                display: inline-flex;
                border-radius: 2;
                border: 1px solid #eaeaea;
                margin-bottom: 8px;
                margin-right: 8px;
                width: 100px;
                height: 100px;
                padding: 4px;
                box-sizing: border-box;
                .thumbInner {
                    display: flex;
                    min-width: 0;
                    overflow: hidden;
                    img {
                        display: block;
                        width: auto;
                        height: 100%;
                    }
                }
            }
        }
        .dropZoneInfo {
            font-size: 12px;
            font-weight: bold;
            margin-top: 3px;
            font-style: italic;
            text-align: center;
            color: gray;
        }
        .fileswitherror {
            h4 {
                margin: 10px 0px;
            }
            ul {
                padding-left: 17px;
                margin: 10px 0px;
                list-style-type: disc;
                li {
                    font-weight: bold;
                    ul {
                        padding-left: 17px;
                        margin: 5px 0px;
                        font-weight: normal;
                        list-style-type: circle;
                    }
                }
            }
        }
    }
    /* File Drop Zone */
    /** Message */
    .MessageContainer {
        text-align: center;
        font-style: italic;
        line-height: 1.5em;
        width: 100%;
        margin-top: 10px;
        .msgText {
        font-size: 16px;
            color: black !important;
        font-weight: bold !important;
        }
        i {
            font-weight: bold !important;
            color: black !important;
        }
        .errorMessage{
        color: #E84142 !important;
        padding-top: 10px !important;
        text-align: center;
        }
        .successMessage{
        color: #64BE1A !important;
        padding-top: 10px !important;
        text-align: center;
        }
        .warningMessage{
        color: #BEBB1A !important;
        padding-top: 10px !important;
        text-align: center;
        }
        .infoMessage{
        background-color: rgb(148, 210, 230) !important;
        }
        span {
        font-size: 14px !important;
        font-weight: 500;
        font-family: 'Calibri' !important;
        }
        div[class^="ms-MessageBar-icon"] {
        margin: 10px 0px 10px 10px !important;
        }
        div[class^="ms-MessageBar-text"] {
        margin: 10px 10px 10px 6px !important;
        }
    }
    /** Message */
}

Now comes the main webpart.tsx file. Copy-paste the below code.

import * as React from 'react';
import { useEffect, useState, FC } from 'react';
import styles from './SampleFileUpload.module.scss';
import { escape } from '@microsoft/sp-lodash-subset';
import FileDropZone from './FileDropZone';
import { IFileUploadInfo } from './utils';
import { LoaderType, uploadFiles } from './utils';
import ContentLoader from './ContentLoader';
import { find, set } from 'lodash';
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';

export interface ISampleFileUploadProps {
    webSerUrl: string;
    asyncUpload: boolean;
}

const SampleFileUpload: FC<ISampleFileUploadProps> = (props) => {
    const [docsToUpload, setDocsToUpload] = useState<IFileUploadInfo[]>([]);
    const [clearFileErrorMsg, setClearFileErrorMsg] = useState<boolean>(false);
    const [tempFilesUploaded, setTempFilesUploaded] = useState<any[]>([]);
    const [uploadingFiles, setUploadingFiles] = useState<boolean>(false);

    /** Show the upload progress */
    const _showUploadProgress = () => {
        setUploadingFiles(true);
    };
    /** Hide the upload progress */
    const _hideUploadProgress = () => {
        setUploadingFiles(false);
    };
    /** Callback method after selecting the files */
    const _afterSelectingFiles = (selFiles) => {
        setDocsToUpload([...selFiles]);
        setClearFileErrorMsg(false);
        _hideUploadProgress();
    };
    /** Uploading the case files
     * Method passed to the FileDropZone component and it is triggered when the file is selected.
     * This method will upload the files asynchronously once the file is selected
     */
    const _uploadFiles = async (filename: string, filecontent: any): Promise<boolean> => {
        let retStatus: boolean = false;
        try {
            let tmpFileUploaded = await uploadFiles(`${props.webSerUrl}/Shared Documents/`, filename, filecontent);
            let tempFiles: any[] = tempFilesUploaded;
            tempFiles.push({ uploadedFileName: tmpFileUploaded.data.Name, trimmedName: filename, FileServerRelUrl: tmpFileUploaded.data.ServerRelativeUrl });
            setTempFilesUploaded(tempFiles);
            let tempDocsToUpload: IFileUploadInfo[] = docsToUpload;
            tempFilesUploaded.map(tmpfile => {
                var fil = find(tempDocsToUpload, (f: IFileUploadInfo) => f.trimmedName === tmpfile.trimmedName);
                if (fil) {
                    set(fil, 'FileServerRelUrl', tmpfile.FileServerRelUrl);
                    set(fil, 'uploadedFileName', tmpfile.uploadedFileName);
                }
            });
            setDocsToUpload(tempDocsToUpload);
            retStatus = true;
        } catch (err) {
            console.log(err);
        }
        return retStatus;
    };
    /** Upload on button click
     * This method will upload the files selected.
     */
    const _uploadOnSave = async () => {
        setUploadingFiles(true);
        for (let doc of docsToUpload) {
            let tmpFileUploaded = await uploadFiles(`${props.webSerUrl}/Shared Documents/`, doc.trimmedName, doc.content);
            set(doc, 'FileServerRelUrl', tmpFileUploaded.data.ServerRelativeUrl);
            set(doc, 'uploadedFileName', tmpFileUploaded.data.Name);
        }
        setUploadingFiles(false);
        setDocsToUpload(docsToUpload);
    };

    return (
        <div className={styles.sampleFileUpload}>
            {uploadingFiles &&
                <ContentLoader loaderType={LoaderType.Indicator} loaderMsg={"Processing files..."} />
            }
            <FileDropZone dropCallback={_afterSelectingFiles} documentToUpload={docsToUpload} tempUpload={_uploadFiles} clearMessage={clearFileErrorMsg}
                showUploadProgress={_showUploadProgress} hideUploadProgress={_hideUploadProgress} disableUpload={uploadingFiles}
                useAsyncUpload={props.asyncUpload} />
            {docsToUpload && docsToUpload.length > 0 &&
                docsToUpload.map((doc) =>
                    <ul>
                        <div>{doc.name} {doc.FileServerRelUrl ? `('${doc.FileServerRelUrl}')` : ''}</div>
                    </ul>
                )
            }
            {!props.asyncUpload &&
                <div style={{ float: 'right' }}>
                    <PrimaryButton text="Upload the files" onClick={_uploadOnSave} disabled={uploadingFiles || docsToUpload.length <= 0}
                        style={{ float: 'right' }}></PrimaryButton>
                </div>
            }
        </div>
    );
};

export default SampleFileUpload;

Features

  • Allow the file selection and also drag & drop is allowed
  • React-Dropzone will clear the files on the next selection. The code above will store all the files selected untill it is cleared or clicked the upload button
  • Most of the file validations are done based on the configurations.
  • The file can be uploaded synchronously or asynchronously.

Conclusion

We have come to the end of the post, I hope you learned something new and I encourage you to pull the code and try the solution. You can also use the component in your project. The full source code can be found in the below link.

spfx-file-upload

Cheers. Happy Coding…

Advertisement

One thought on “SPFx – Uploading files using react-dropzone

  1. I started using same and would love to try your approach.
    I wonder if you also know of a way to read properties of files once, they are dropped.
    In my case office files have properties like document ID given from when they were in another library in SPO and I would love to be able to read it before I do anything with the file (some business logic)
    Thank you

    Like

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 )

Facebook photo

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

Connecting to %s