diff --git a/dev/upload.html b/dev/upload.html index ac70860b67..f68ba867ac 100644 --- a/dev/upload.html +++ b/dev/upload.html @@ -62,5 +62,50 @@ +
+

Batch Mode Demo (threshold: 5 files) - Simulated XHR

+

Upload more than 5 files to see batch mode. Use the button below to add test files.

+ Add 10 Test Files + + +
+

Batch Mode Demo - Real Endpoint

+

This upload uses the real /api/fileupload endpoint. Select multiple files to test batch mode.

+ + + diff --git a/packages/upload/src/vaadin-upload-file-list-mixin.js b/packages/upload/src/vaadin-upload-file-list-mixin.js index 8849a86907..8cdea6d7c1 100644 --- a/packages/upload/src/vaadin-upload-file-list-mixin.js +++ b/packages/upload/src/vaadin-upload-file-list-mixin.js @@ -34,11 +34,48 @@ export const UploadFileListMixin = (superClass) => value: false, reflectToAttribute: true, }, + + /** + * Number of files that triggers batch mode. + */ + batchModeFileCountThreshold: { + type: Number, + }, + + /** + * Batch progress percentage (0-100). + */ + batchProgress: { + type: Number, + }, + + /** + * Total bytes to upload in batch. + */ + batchTotalBytes: { + type: Number, + }, + + /** + * Bytes uploaded so far in batch. + */ + batchLoadedBytes: { + type: Number, + }, + + /** + * Array of progress samples for calculating upload speed. + */ + batchProgressSamples: { + type: Array, + }, }; } static get observers() { - return ['__updateItems(items, i18n, disabled)']; + return [ + '__updateItems(items, i18n, disabled, batchModeFileCountThreshold, batchProgress, batchTotalBytes, batchLoadedBytes, batchProgressSamples)', + ]; } /** @private */ @@ -54,29 +91,138 @@ export const UploadFileListMixin = (superClass) => * It is not guaranteed that the update happens immediately (synchronously) after it is requested. */ requestContentUpdate() { - const { items, i18n, disabled } = this; + const { items, i18n, disabled, batchModeFileCountThreshold } = this; + + // Determine if we should show batch mode + const isBatchMode = items && batchModeFileCountThreshold && items.length > batchModeFileCountThreshold; + + if (isBatchMode) { + // Render batch mode UI + this._renderBatchMode(); + } else { + // Render individual file items + render( + html` + ${items.map( + (file) => html` +
  • + +
  • + `, + )} + `, + this, + ); + } + } + + /** @private */ + _renderBatchMode() { + const { items, batchProgress, batchTotalBytes, batchLoadedBytes, batchProgressSamples } = this; + + // Calculate current file and remaining count + const currentFile = items.find((f) => f.uploading); + const completedCount = items.filter((f) => f.complete).length; + const errorCount = items.filter((f) => f.error).length; + const allComplete = items.every((f) => f.complete || f.error || f.abort); + + // Format bytes + const formatBytes = (bytes) => { + if (bytes === 0) return '0 B'; + const k = 1000; + const sizes = ['B', 'kB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; + }; + + // Determine status text + let statusText; + if (allComplete) { + if (errorCount > 0) { + statusText = `Complete with ${errorCount} error${errorCount > 1 ? 's' : ''}`; + } else { + statusText = 'All files uploaded successfully'; + } + } else if (currentFile) { + statusText = `Uploading: ${currentFile.name}`; + } else { + statusText = 'Processing...'; + } + + // Calculate ETA based on 10-second rolling average of upload speed + let etaText = ''; + if (!allComplete) { + if (batchProgressSamples && batchProgressSamples.length >= 2) { + // Get oldest and newest samples from the window + const oldestSample = batchProgressSamples[0]; + const newestSample = batchProgressSamples[batchProgressSamples.length - 1]; + + // Calculate speed based on the sample window + const bytesDiff = newestSample.bytes - oldestSample.bytes; + const timeDiff = newestSample.timestamp - oldestSample.timestamp; // milliseconds + + if (timeDiff > 0 && bytesDiff > 0) { + const bytesPerSecond = bytesDiff / (timeDiff / 1000); + const remainingBytes = batchTotalBytes - batchLoadedBytes; + const remainingSeconds = remainingBytes / bytesPerSecond; + + if (remainingSeconds < 60) { + etaText = `${Math.ceil(remainingSeconds)}s`; + } else if (remainingSeconds < 3600) { + etaText = `${Math.ceil(remainingSeconds / 60)}m`; + } else { + etaText = `${Math.ceil(remainingSeconds / 3600)}h`; + } + } else { + etaText = 'calculating...'; + } + } else { + etaText = 'calculating...'; + } + } + + // Handler for cancel all button + const handleCancelAll = () => { + this.dispatchEvent(new CustomEvent('batch-cancel-all', { bubbles: true, composed: true })); + }; render( html` - ${items.map( - (file) => html` -
  • - -
  • - `, - )} +
  • +
    +
    ${statusText}
    +
    + ${completedCount} of ${items.length} files • ${batchProgress}% • ${formatBytes(batchLoadedBytes)} / + ${formatBytes(batchTotalBytes)}${etaText ? ` • ETA: ${etaText}` : ''} +
    +
    +
    + +
    + +
  • `, this, ); diff --git a/packages/upload/src/vaadin-upload-file-list.js b/packages/upload/src/vaadin-upload-file-list.js index 28a8711757..77489f75a3 100644 --- a/packages/upload/src/vaadin-upload-file-list.js +++ b/packages/upload/src/vaadin-upload-file-list.js @@ -4,6 +4,7 @@ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import './vaadin-upload-file.js'; +import '@vaadin/progress-bar/src/vaadin-progress-bar.js'; import { html, LitElement } from 'lit'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; diff --git a/packages/upload/src/vaadin-upload-mixin.js b/packages/upload/src/vaadin-upload-mixin.js index b64f1e8f96..80354b64d7 100644 --- a/packages/upload/src/vaadin-upload-mixin.js +++ b/packages/upload/src/vaadin-upload-mixin.js @@ -45,6 +45,9 @@ const DEFAULT_I18N = { start: 'Start', remove: 'Remove', }, + batch: { + cancelAll: 'Cancel All', + }, units: { size: ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], }, @@ -328,6 +331,18 @@ export const UploadMixin = (superClass) => */ capture: String, + /** + * Number of files that triggers batch mode. When the number of files being uploaded + * exceeds this threshold, the UI switches to batch mode showing aggregated progress + * instead of individual file progress bars. + * @attr {number} batch-mode-file-count-threshold + * @type {number} + */ + batchModeFileCountThreshold: { + type: Number, + value: 5, + }, + /** @private */ _addButton: { type: Object, @@ -347,6 +362,35 @@ export const UploadMixin = (superClass) => _files: { type: Array, }, + + /** @private */ + _batchTotalBytes: { + type: Number, + value: 0, + }, + + /** @private */ + _batchLoadedBytes: { + type: Number, + value: 0, + }, + + /** @private */ + _batchProgress: { + type: Number, + value: 0, + }, + + /** @private */ + _batchStartTime: { + type: Number, + }, + + /** @private */ + _batchProgressSamples: { + type: Array, + value: () => [], + }, }; } @@ -355,6 +399,8 @@ export const UploadMixin = (superClass) => '__updateAddButton(_addButton, maxFiles, __effectiveI18n, maxFilesReached, disabled)', '__updateDropLabel(_dropLabel, maxFiles, __effectiveI18n)', '__updateFileList(_fileList, files, __effectiveI18n, disabled)', + '__updateFileListBatchMode(_fileList, batchModeFileCountThreshold, _batchProgress)', + '__updateFileListBatchBytes(_fileList, _batchTotalBytes, _batchLoadedBytes, _batchProgressSamples)', '__updateMaxFilesReached(maxFiles, files)', ]; } @@ -455,6 +501,7 @@ export const UploadMixin = (superClass) => this.addEventListener('file-abort', this._onFileAbort.bind(this)); this.addEventListener('file-start', this._onFileStart.bind(this)); this.addEventListener('file-reject', this._onFileReject.bind(this)); + this.addEventListener('batch-cancel-all', this._onBatchCancelAll.bind(this)); this.addEventListener('upload-start', this._onUploadStart.bind(this)); this.addEventListener('upload-success', this._onUploadSuccess.bind(this)); this.addEventListener('upload-error', this._onUploadError.bind(this)); @@ -566,6 +613,23 @@ export const UploadMixin = (superClass) => } } + /** @private */ + __updateFileListBatchMode(list, batchModeFileCountThreshold, batchProgress) { + if (list) { + list.batchModeFileCountThreshold = batchModeFileCountThreshold; + list.batchProgress = batchProgress; + } + } + + /** @private */ + __updateFileListBatchBytes(list, batchTotalBytes, batchLoadedBytes, batchProgressSamples) { + if (list) { + list.batchTotalBytes = batchTotalBytes; + list.batchLoadedBytes = batchLoadedBytes; + list.batchProgressSamples = batchProgressSamples; + } + } + /** @private */ _onDragover(event) { event.preventDefault(); @@ -695,7 +759,24 @@ export const UploadMixin = (superClass) => files = [files]; } files = files.filter((file) => !file.complete); - Array.prototype.forEach.call(files, this._uploadFile.bind(this)); + // Upload only the first file in the queue, not all at once + if (files.length > 0) { + this._uploadFile(files[0]); + } + } + + /** @private */ + _processNextFileInQueue() { + // Find the next file that is queued but not yet uploaded + // Search from the end since files are prepended (newest first) + // This ensures files upload in the order they were added + const nextFile = this.files + .slice() + .reverse() + .find((file) => !file.complete && !file.uploading && !file.abort); + if (nextFile) { + this._uploadFile(nextFile); + } } /** @private */ @@ -736,6 +817,7 @@ export const UploadMixin = (superClass) => } } + this._updateBatchProgress(); this._renderFileList(); this.dispatchEvent(new CustomEvent('upload-progress', { detail: { file, xhr } })); }; @@ -775,7 +857,10 @@ export const UploadMixin = (superClass) => detail: { file, xhr }, }), ); + this._updateBatchProgress(); this._renderFileList(); + // Process the next file in the queue after this one completes + this._processNextFileInQueue(); } }; @@ -881,9 +966,22 @@ export const UploadMixin = (superClass) => file.xhr.abort(); } this._removeFile(file); + // Process the next file in the queue after aborting this one + this._processNextFileInQueue(); } } + /** @private */ + _abortAllFiles() { + // Abort all files in the batch + const filesToAbort = [...this.files]; + filesToAbort.forEach((file) => { + if (!file.complete && !file.abort) { + this._abortFileUpload(file); + } + }); + } + /** @private */ _renderFileList() { if (this._fileList && typeof this._fileList.requestContentUpdate === 'function') { @@ -891,9 +989,52 @@ export const UploadMixin = (superClass) => } } + /** @private */ + _updateBatchProgress() { + // Calculate total bytes across all files + this._batchTotalBytes = this.files.reduce((sum, file) => sum + (file.size || 0), 0); + + // Calculate loaded bytes: completed files + current file progress + this._batchLoadedBytes = this.files.reduce((sum, file) => { + if (file.complete) { + return sum + (file.size || 0); + } + if (file.uploading) { + return sum + (file.loaded || 0); + } + return sum; + }, 0); + + // Calculate overall progress percentage + this._batchProgress = this._batchTotalBytes > 0 ? ~~((this._batchLoadedBytes / this._batchTotalBytes) * 100) : 0; + + // Initialize start time on first upload + if (!this._batchStartTime && this.files.some((f) => f.uploading)) { + this._batchStartTime = Date.now(); + this._batchProgressSamples = []; + } + + // Track progress samples for speed calculation (keep last 10 seconds) + if (this._batchStartTime && this._batchLoadedBytes > 0) { + const now = Date.now(); + this._batchProgressSamples.push({ timestamp: now, bytes: this._batchLoadedBytes }); + + // Remove samples older than 10 seconds + const tenSecondsAgo = now - 10000; + this._batchProgressSamples = this._batchProgressSamples.filter((sample) => sample.timestamp > tenSecondsAgo); + } + + // Reset when all complete + if (this.files.length > 0 && this.files.every((f) => f.complete || f.error || f.abort)) { + this._batchStartTime = null; + this._batchProgressSamples = []; + } + } + /** @private */ _addFiles(files) { Array.prototype.forEach.call(files, this._addFile.bind(this)); + this._updateBatchProgress(); } /** @@ -934,7 +1075,11 @@ export const UploadMixin = (superClass) => this.files = [file, ...this.files]; if (!this.noAuto) { - this._uploadFile(file); + // Only start uploading if no other file is currently being uploaded + const isAnyFileUploading = this.files.some((f) => f.uploading); + if (!isAnyFileUploading) { + this._uploadFile(file); + } } } @@ -1011,6 +1156,11 @@ export const UploadMixin = (superClass) => this._abortFileUpload(event.detail.file); } + /** @private */ + _onBatchCancelAll() { + this._abortAllFiles(); + } + /** @private */ _onFileReject(event) { announce(`${event.detail.file.name}: ${event.detail.error}`, { mode: 'alert' }); diff --git a/packages/upload/test/adding-files.test.js b/packages/upload/test/adding-files.test.js index 8e2787e142..85dc36c744 100644 --- a/packages/upload/test/adding-files.test.js +++ b/packages/upload/test/adding-files.test.js @@ -336,8 +336,12 @@ describe('adding files', () => { upload.addEventListener('upload-start', uploadStartSpy); files.forEach(upload._addFile.bind(upload)); - expect(uploadStartSpy.calledTwice).to.be.true; - expect(upload.files[0].held).to.be.false; + // With queue behavior, only the first file starts uploading immediately + expect(uploadStartSpy.calledOnce).to.be.true; + // Files are prepended, so the first file added is at index 1 + expect(upload.files[1].held).to.be.false; + // Second file (at index 0) should be held in queue + expect(upload.files[0].held).to.be.true; }); it('should not automatically start upload when noAuto flag is set', () => { diff --git a/packages/upload/test/upload.test.js b/packages/upload/test/upload.test.js index ecf5664969..0fd5299277 100644 --- a/packages/upload/test/upload.test.js +++ b/packages/upload/test/upload.test.js @@ -437,16 +437,21 @@ describe('upload', () => { upload.files.forEach((file) => { expect(file.uploading).not.to.be.ok; }); + let firstUploadStartFired = false; upload.addEventListener('upload-start', (e) => { - expect(e.detail.xhr).to.be.ok; - expect(e.detail.file).to.be.ok; - expect(e.detail.file.name).to.equal(tempFileName); - expect(e.detail.file.uploading).to.be.ok; + if (!firstUploadStartFired) { + firstUploadStartFired = true; + expect(e.detail.xhr).to.be.ok; + expect(e.detail.file).to.be.ok; + expect(e.detail.file.name).to.equal(tempFileName); + expect(e.detail.file.uploading).to.be.ok; - for (let i = 0; i < upload.files.length - 1; i++) { - expect(upload.files[i].uploading).not.to.be.ok; + for (let i = 0; i < upload.files.length - 1; i++) { + expect(upload.files[i].uploading).not.to.be.ok; + } + done(); } - done(); + // With queue behavior, other files will start after the first completes - ignore those events }); upload.uploadFiles([upload.files[2]]); }); @@ -539,6 +544,141 @@ describe('upload', () => { }); }); + describe('Upload Queue', () => { + let clock, files; + + beforeEach(() => { + upload._createXhr = xhrCreator({ size: file.size, uploadTime: 200, stepTime: 50 }); + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should upload multiple files one at a time', async () => { + files = createFiles(3, 512, 'application/json'); + upload._addFiles(files); + + // Files are prepended, so files[0] is at index 2, files[1] at index 1, files[2] at index 0 + // First file added (files[0]) should start uploading + await clock.tickAsync(10); + expect(upload.files[2].uploading).to.be.true; + expect(upload.files[2].held).to.be.false; + expect(upload.files[1].held).to.be.true; + expect(upload.files[0].held).to.be.true; + + // Wait for first file to complete (connectTime + uploadTime + serverTime = 10 + 200 + 10 = 220ms) + await clock.tickAsync(220); + expect(upload.files[2].complete).to.be.true; + expect(upload.files[2].uploading).to.be.false; + + // Second file (files[1]) should now start uploading + await clock.tickAsync(10); + expect(upload.files[1].uploading).to.be.true; + expect(upload.files[1].held).to.be.false; + expect(upload.files[0].held).to.be.true; + + // Wait for second file to complete + await clock.tickAsync(220); + expect(upload.files[1].complete).to.be.true; + expect(upload.files[1].uploading).to.be.false; + + // Third file (files[2]) should now start uploading + await clock.tickAsync(10); + expect(upload.files[0].uploading).to.be.true; + expect(upload.files[0].held).to.be.false; + + // Wait for third file to complete + await clock.tickAsync(220); + expect(upload.files[0].complete).to.be.true; + expect(upload.files[0].uploading).to.be.false; + }); + + it('should process next file in queue after one completes with error', async () => { + upload._createXhr = xhrCreator({ + size: 512, + uploadTime: 200, + stepTime: 50, + serverValidation: () => { + return { status: 500, statusText: 'Server Error' }; + }, + }); + + const errorSpy = sinon.spy(); + const startSpy = sinon.spy(); + upload.addEventListener('upload-error', errorSpy); + upload.addEventListener('upload-start', startSpy); + + files = createFiles(2, 512, 'application/json'); + upload._addFiles(files); + + // First file should start + await clock.tickAsync(10); + expect(startSpy.callCount).to.equal(1); + + // Wait for first file to complete with error + await clock.tickAsync(220); + expect(errorSpy.callCount).to.equal(1); + + // Second file should now start + await clock.tickAsync(10); + expect(startSpy.callCount).to.equal(2); + expect(upload.files.some((f) => f.uploading)).to.be.true; + }); + + it('should process next file in queue after one is aborted', async () => { + files = createFiles(2, 512, 'application/json'); + upload._addFiles(files); + + // First file added (at index 1) should start uploading + await clock.tickAsync(10); + expect(upload.files[1].uploading).to.be.true; + expect(upload.files[0].held).to.be.true; + + // Abort the first file (at index 1) + upload._abortFileUpload(upload.files[1]); + + // Second file (now at index 0 after first is removed) should now start uploading + await clock.tickAsync(10); + expect(upload.files[0].uploading).to.be.true; + }); + + it('should only start one file when uploadFiles is called with multiple files', async () => { + upload.noAuto = true; + files = createFiles(3, 512, 'application/json'); + upload._addFiles(files); + + // No files should be uploading yet - all should be held + await clock.tickAsync(10); + expect(upload.files[0].held).to.be.true; + expect(upload.files[1].held).to.be.true; + expect(upload.files[2].held).to.be.true; + + // Call uploadFiles + upload.uploadFiles(); + + // Only first file (at index 2) should start uploading - wait for it to begin + await clock.tickAsync(20); + expect(upload.files.length).to.equal(3); + // One file should be uploading (the oldest one added) + const uploadingFile = upload.files.find((f) => f.uploading); + expect(uploadingFile).to.be.ok; + // The other two should still be held + const heldFiles = upload.files.filter((f) => f.held); + expect(heldFiles.length).to.equal(2); + + // Wait for first file to complete + await clock.tickAsync(220); + + // Second file should start automatically + await clock.tickAsync(10); + expect(upload.files.some((f) => f.uploading)).to.be.true; + const remainingHeldFiles = upload.files.filter((f) => f.held); + expect(remainingHeldFiles.length).to.equal(1); + }); + }); + describe('Upload format', () => { let clock; diff --git a/web-dev-server.config.js b/web-dev-server.config.js index 1e020710ec..de96cb8314 100644 --- a/web-dev-server.config.js +++ b/web-dev-server.config.js @@ -59,6 +59,33 @@ export function enforceThemePlugin(theme) { }; } +/** @return {import('@web/dev-server').Plugin} */ +export function fileUploadEndpointPlugin() { + return { + name: 'file-upload-endpoint', + serve(context) { + // Handle file upload endpoint + if (context.path === '/api/fileupload' && context.request.method === 'POST') { + // Log the upload (for demo purposes) + console.log(`📤 Received upload request to ${context.path}`); + + // Return success response immediately + // Note: In dev mode, we don't actually read the body, just acknowledge the upload + return { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + success: true, + message: 'File uploaded successfully', + }), + }; + } + }, + }; +} + export default { plugins: [ { @@ -91,5 +118,8 @@ export default { // Lumo / Aura CSS ['lumo', 'aura'].includes(theme) && cssImportPlugin(), + + // File upload endpoint for testing + fileUploadEndpointPlugin(), ].filter(Boolean), };