pnp / pnpjs

Fluent JavaScript API for SharePoint and Microsoft Graph REST APIs
https://pnp.github.io/pnpjs/
Other
751 stars 304 forks source link

Cancelling file upload not working with multiple simultaneous uploads #2322

Closed stevebeauge closed 2 years ago

stevebeauge commented 2 years ago

Category

Version

Please specify what version of the library you are using: [3.4.1]

Please specify what version(s) of SharePoint you are targeting: [Online ]

If you are not using the latest release, please update and see if the issue is resolved before submitting an issue.

Expected / Desired Behavior / Question

Follow up of #2228

Latest update includes Cancelable behavior of requests. It should allow cancelling chunked file upload.

It should support canceling multiple file uploads, and have no error in the console.

My requirement is to build a webpart that accept droping files and/or folders:

image

Observed Behavior

Canceling a single file upload actually cancels all file uploads. Found later in the documentation that parallel cancelable requests are not supported.

I also get a 429 error everytime I cancel the file upload.

From my point of view, I guess it's because the Cancelable promise has no "identifier" to respond to the observed event (though I admit the inner code of pnpjs is somehow obscure).

But on top of this issue, I think the Cancelable promise aloneis not an answer to the chunked upload cancellation. Because chunked upload is under the hood a series of requests (one for each chunk + start and finish). That's why I wonder if something else is needed for the very specific case of file uploads.

I guess it's not an issue easy to fix :(

Steps to Reproduce

I can hardly give my testing code because it's part of a larger project. But basically, I have a "service":

import { BaseComponentContext } from '@microsoft/sp-component-base';
import { Guid } from '@microsoft/sp-core-library';
import { CancelablePromise } from '@pnp/queryable';

import { SPFx } from '@pnp/sp';
import { IFileAddResult } from '@pnp/sp/files';
import { Web } from '@pnp/sp/webs';

type ProgressCallback = (id: string, readBytes: number, totalBytes: number, isFinished: boolean) => void;

type UploadJob = {
    uploadId: string;
    targetWebUrl: string;
    targetFileServerRelativeUrl: string;
}

class UploadService {
    constructor(private context: BaseComponentContext) { }

    private _runningOperations : Record<string, CancelablePromise<IFileAddResult>> = {};

    public StartUpload(
        file: File,
        webUrl: string,
        targetFolderUrl: string,
        onProgress?: ProgressCallback,
        chunkSize: number = 1024 * 1000 // 1MB
    ): UploadJob {
        const fileUploadId = Guid.newGuid().toString();

        const webUrl2 = new URL(webUrl, document.location.href);
        const folderServerRelativeUrl = decodeURIComponent(new URL(targetFolderUrl, webUrl2).pathname);
        const targetWeb = Web(webUrl2.toString()).using(SPFx(this.context));
        // const targetWeb =spfi().using(SPFx(this.context)).web;

        const folder = targetWeb.getFolderByServerRelativePath(folderServerRelativeUrl);

        const addFileOperation = folder.files.addChunked(
            file.name,
            file,
            (data) => {
                console.log('Upload progress', data);
                if (onProgress) {
                    onProgress(fileUploadId, data.currentPointer, data.fileSize, data.stage === 'finishing');
                }
            },
            true,
            chunkSize
        );

        this._runningOperations[fileUploadId] = addFileOperation as CancelablePromise<IFileAddResult>;

        return {
            uploadId: fileUploadId,
            targetWebUrl: webUrl,
            targetFileServerRelativeUrl: folderServerRelativeUrl + '/' + file.name
        };

    }

    public CancelUpload(fileUploadId: string) {
        const operation = this._runningOperations[fileUploadId];
        if (operation) {
            operation.cancel();
        }
    }
}
export { UploadService, ProgressCallback };

And a consumer visual component :

import { useSpfxContext, useUploadServiceContext } from './context';
import * as React from 'react';
import { useDropzone } from 'react-dropzone';
import { ListPicker } from '@pnp/spfx-controls-react';
import { SitePicker } from '@pnp/spfx-controls-react/lib/SitePicker';
import { SPFx } from '@pnp/sp';
import { Cancelable } from "@pnp/queryable";
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/folders';

import { IconButton, Label, ProgressIndicator, Stack } from 'office-ui-fabric-react';
import { Web } from '@pnp/sp/webs';
import { ProgressCallback } from './UploadService';

type fileState = {
    fileName: string;
    progress: number;
    totalSize: number;
    status: 'initializing' | 'uploading' | 'uploaded' | 'error';
    isFinished: boolean;
    uploadId: string;
};

export const UploadServiceTest = () => {
    // Spfx context and upload service injected through the React context api
    const spfxContext = useSpfxContext(); 
    const uploadService = useUploadServiceContext();

    const [uploadingFiles, setUploadingFiles] = React.useState<Record<string, fileState>>({});
    const [targetWeb, setTargetWeb] = React.useState<string | undefined>(spfxContext.pageContext.web.absoluteUrl);
    const [targetListId, setTargetListId] = React.useState<string | undefined>(undefined);

    const onProgress: ProgressCallback = (id, readBytes, totalBytes, isFinished) => {

        setUploadingFiles(prev => ({
            ...prev,
            [id]: {
                ...prev[id],
                progress: isFinished ? 1 : readBytes / totalBytes,
                isFinished,
                status: isFinished ? 'uploaded' : 'uploading',
            },
        }));
    };
    const onDrop = async (acceptedFiles: File[]) => {
        if (!targetListId || !targetWeb) return;

        const web = Web(targetWeb).using(SPFx(spfxContext), Cancelable());
        const list = web.lists.getById(targetListId);

        const targetListUrl = (await list.rootFolder()).ServerRelativeUrl;

        for (const file of acceptedFiles) {
            console.log(file.name);

            const uploadJob = uploadService.StartUpload(
                file,
                targetWeb,
                targetListUrl,
                onProgress,
                100
            );

            setUploadingFiles(prev => ({
                ...prev,
                [uploadJob.uploadId]: {
                    fileName: file.name,
                    progress: 0,
                    status: 'initializing',
                    totalSize: file.size,
                    isFinished: false,
                    uploadId: uploadJob.uploadId,

                },
            }));
        }
    };

    const { getRootProps, getInputProps } = useDropzone({
        onDrop,
        noDragEventsBubbling: true,
    });

    const cancelUpload = (id: string) => {
        uploadService.CancelUpload(id);
    };
    const deleteFile = (id: string) => {
        alert('TODO');
    };
    return (
        <Stack>
            <SitePicker
                context={spfxContext}
                label="Target Web"
                onChange={(web) => setTargetWeb(web[0].url)}
                initialSites={[{ url: targetWeb }]}
                multiSelect={false}
            />
            <ListPicker
                context={spfxContext}
                label="Select your list(s)"
                placeHolder="Select your list(s)"
                baseTemplate={101}
                onSelectionChanged={(lst) => setTargetListId(Array.isArray(lst) ? lst[0] : lst)}
                selectedList={targetListId}
                webAbsoluteUrl={targetWeb}
            />
            {targetWeb && targetListId && (
                <>
                    <div
                        {...getRootProps()}
                        style={{
                            border: 'solid 2px blue',
                        }}
                    >
                        <input {...getInputProps()} />
                        <Label>Drop files here</Label>
                    </div>
                    {Object.keys(uploadingFiles).map((key) => {
                        const file = uploadingFiles[key];
                        return (
                            <Stack key={key} >
                                <Stack horizontal>
                                    <Label>{file.fileName}</Label>
                                    <IconButton iconProps={{ iconName: "Cancel" }} onClick={() => cancelUpload(file.uploadId)} />
                                </Stack>
                                <ProgressIndicator label={file.fileName} percentComplete={file.progress} />
                            </Stack>
                        );
                    })}
                </>
            )}
        </Stack>
    );
};
patrick-rodgers commented 2 years ago

@stevebeauge - this will be available tomorrow in the nightly build if you want to give the updates a try. I believe I've solved for your case so interested to hear how it goes. Thanks!

github-actions[bot] commented 2 years ago

This issue is locked for inactivity or age. If you have a related issue please open a new issue and reference this one. Closed issues are not tracked.