Fix upload UI stuck on "Processing files..."

The `uploading` flag was only cleared by `x-init="uploading = false"` on the
file list, which Alpine runs when the element is initialized. Adding a second
batch of files to the same share only patches that existing element, so the
flag stayed true forever: the spinner never went away, the drop zone stayed
disabled and the submit button stayed disabled, making the share impossible
to create.

`updatedFiles()` now dispatches `files-processed`, which runs for every batch,
and the form resets its upload state on that event instead.

Also fixes two adjacent bugs in the drag & drop path: `uploadMultiple()` was
called without callbacks so dropped files showed no progress at all, and
`relativePaths` was replaced instead of appended, shifting every earlier
file's path onto the wrong file on a second drop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-07-23 11:44:35 +02:00
co-authored by Claude Opus 4.8
parent 084cefe023
commit d2e8a135c5
3 changed files with 61 additions and 6 deletions
+9
View File
@@ -65,8 +65,17 @@ class FileUploader extends Component
]); ]);
} }
/**
* Validate a freshly uploaded batch of files.
*
* Dispatches `files-processed` so the front end can drop its "uploading" state.
* This runs for every batch, including additional files added to an existing
* selection, which a one-off `x-init` on the file list cannot cover.
*/
public function updatedFiles(): void public function updatedFiles(): void
{ {
$this->dispatch('files-processed')->self();
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024); $maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
$maxFileSizeMb = $maxFileSize / (1024 * 1024); $maxFileSizeMb = $maxFileSize / (1024 * 1024);
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50); $maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
@@ -38,16 +38,36 @@
} }
setTimeout(() => { setTimeout(() => {
if (! files.length) {
return;
}
const dt = new DataTransfer(); const dt = new DataTransfer();
const paths = []; const paths = [];
files.forEach(f => { files.forEach(f => {
dt.items.add(f.file); dt.items.add(f.file);
paths.push(f.path); paths.push(f.path);
}); });
$wire.relativePaths = paths;
$wire.uploadMultiple('files', dt.files); $wire.relativePaths = [...($wire.relativePaths ?? []), ...paths];
this.uploading = true;
this.progress = 0;
$wire.uploadMultiple(
'files',
dt.files,
() => this.progress = 100,
() => this.resetUpload(),
(event) => this.progress = event.detail.progress,
() => this.resetUpload(),
);
}, 500); }, 500);
}, },
resetUpload() {
this.uploading = false;
this.progress = 0;
},
traverseEntry(entry, path, files) { traverseEntry(entry, path, files) {
if (entry.isFile) { if (entry.isFile) {
entry.file(file => { entry.file(file => {
@@ -61,10 +81,11 @@
} }
} }
}" }"
x-init="$wire.$on('files-processed', () => resetUpload())"
x-on:livewire-upload-start="uploading = true; progress = 0" x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-finish="progress = 100" x-on:livewire-upload-finish="progress = 100"
x-on:livewire-upload-cancel="uploading = false" x-on:livewire-upload-cancel="resetUpload()"
x-on:livewire-upload-error="uploading = false" x-on:livewire-upload-error="resetUpload()"
x-on:livewire-upload-progress="progress = $event.detail.progress" x-on:livewire-upload-progress="progress = $event.detail.progress"
> >
{{-- Drop Zone --}} {{-- Drop Zone --}}
@@ -118,12 +139,12 @@
{{-- Errors --}} {{-- Errors --}}
@error('files') @error('files')
<div x-init="uploading = false" class="alert alert-error mb-4">{{ $message }}</div> <div class="alert alert-error mb-4">{{ $message }}</div>
@enderror @enderror
{{-- File List --}} {{-- File List --}}
@if (count($files)) @if (count($files))
<div x-init="uploading = false" class="mb-6"> <div class="mb-6">
<h3 class="font-semibold mb-2">{{ __('Selected Files') }} ({{ count($files) }})</h3> <h3 class="font-semibold mb-2">{{ __('Selected Files') }} ({{ count($files) }})</h3>
<div class="space-y-1 max-h-60 overflow-y-auto"> <div class="space-y-1 max-h-60 overflow-y-auto">
@foreach ($files as $index => $file) @foreach ($files as $index => $file)
+25
View File
@@ -94,6 +94,31 @@ test('file upload with max downloads sets limit', function () {
expect($share->max_downloads)->toBe(5); expect($share->max_downloads)->toBe(5);
}); });
test('every upload batch dispatches files-processed to clear the uploading state', function () {
Storage::fake('shares');
Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
->assertDispatched('files-processed')
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
->assertDispatched('files-processed');
});
test('files added in multiple batches end up in the same share', function () {
Storage::fake('shares');
Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
->call('createShare')
->assertHasNoErrors()
->assertRedirectContains('/share/');
$share = Share::query()->first();
expect($share->files->pluck('original_name')->all())->toBe(['first.txt', 'second.txt']);
});
test('file upload requires at least one file', function () { test('file upload requires at least one file', function () {
Livewire::test(FileUploader::class) Livewire::test(FileUploader::class)
->set('files', []) ->set('files', [])