Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions dev/upload.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,50 @@
<vaadin-radio-button value="rejected" label="Rejected"></vaadin-radio-button>
<vaadin-radio-button value="error" label="Server error"></vaadin-radio-button>
</vaadin-radio-group>
<hr style="margin-block: 24px" />
<h3>Batch Mode Demo (threshold: 5 files) - Simulated XHR</h3>
<p>Upload more than 5 files to see batch mode. Use the button below to add test files.</p>
<vaadin-button id="add-batch-files">Add 10 Test Files</vaadin-button>
<vaadin-upload id="batch-upload" batch-mode-file-count-threshold="5" target="/api/fileupload"></vaadin-upload>

<hr style="margin-block: 24px" />
<h3>Batch Mode Demo - Real Endpoint</h3>
<p>This upload uses the real /api/fileupload endpoint. Select multiple files to test batch mode.</p>
<vaadin-upload id="real-upload" batch-mode-file-count-threshold="5" target="/api/fileupload"></vaadin-upload>

<script type="module">
import '@vaadin/button';
import { createFiles, xhrCreator } from '@vaadin/upload/test/helpers.js';

const batchUpload = document.querySelector('#batch-upload');
const addBatchFilesBtn = document.querySelector('#add-batch-files');

// Configure fake XHR for batch upload with slower speed to see progress
batchUpload._createXhr = xhrCreator({ size: 512, uploadTime: 2000, stepTime: 500 });

addBatchFilesBtn.addEventListener('click', () => {
// Create 10 test files with varying sizes
const testFiles = [];
for (let i = 0; i < 10; i++) {
const size = 100000 + Math.random() * 500000; // 100KB - 600KB
const fileBlob = createFiles(1, size, 'application/pdf')[0];
fileBlob.name = `TestFile_${i + 1}.pdf`;
testFiles.push(fileBlob);
}

// Add files to the upload component
batchUpload._addFiles(testFiles);
});

// Real upload component doesn't need XHR override - it will use the actual endpoint
const realUpload = document.querySelector('#real-upload');
// Add event listeners to see what's happening
realUpload.addEventListener('upload-success', (e) => {
console.log('✅ Upload success:', e.detail.file.name);
});
realUpload.addEventListener('upload-error', (e) => {
console.error('❌ Upload error:', e.detail.file.name, e.detail.file.error);
});
</script>
</body>
</html>
188 changes: 167 additions & 21 deletions packages/upload/src/vaadin-upload-file-list-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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`
<li>
<vaadin-upload-file
.disabled="${disabled}"
.file="${file}"
.complete="${file.complete}"
.errorMessage="${file.error}"
.fileName="${file.name}"
.held="${file.held}"
.indeterminate="${file.indeterminate}"
.progress="${file.progress}"
.status="${file.status}"
.uploading="${file.uploading}"
.i18n="${i18n}"
></vaadin-upload-file>
</li>
`,
)}
`,
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`
<li>
<vaadin-upload-file
.disabled="${disabled}"
.file="${file}"
.complete="${file.complete}"
.errorMessage="${file.error}"
.fileName="${file.name}"
.held="${file.held}"
.indeterminate="${file.indeterminate}"
.progress="${file.progress}"
.status="${file.status}"
.uploading="${file.uploading}"
.i18n="${i18n}"
></vaadin-upload-file>
</li>
`,
)}
<li class="batch-mode-container">
<div class="batch-mode-info">
<div class="batch-mode-status">${statusText}</div>
<div class="batch-mode-progress-text">
${completedCount} of ${items.length} files • ${batchProgress}% • ${formatBytes(batchLoadedBytes)} /
${formatBytes(batchTotalBytes)}${etaText ? ` • ETA: ${etaText}` : ''}
</div>
</div>
<div class="batch-mode-commands">
<button
type="button"
part="batch-cancel-all-button"
?hidden="${allComplete}"
@click="${handleCancelAll}"
aria-label="${this.i18n && this.i18n.batch && this.i18n.batch.cancelAll
? this.i18n.batch.cancelAll
: 'Cancel All'}"
>
${this.i18n && this.i18n.batch && this.i18n.batch.cancelAll ? this.i18n.batch.cancelAll : 'Cancel All'}
</button>
</div>
<vaadin-progress-bar class="batch-mode-progress-bar" value="${batchProgress / 100}"></vaadin-progress-bar>
</li>
`,
this,
);
Expand Down
1 change: 1 addition & 0 deletions packages/upload/src/vaadin-upload-file-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading