A complete Drag and Drop file uploader with progressbar using Wire Drupal, Alpine.js and Tailwindcss
If you've ever tried building a drag and drop file uploader with a progress bar in Drupal, you know it can be quite the challenge. The raw power of Drupal's API is undeniable, but - creating a user-friendly file uploader for end-users without a purpose-built JavaScript library can feel like tackling a daunting process.
In this how-to post, I'll show you how to overcome these obstacles by harnessing the strengths of Wire Drupal, the dynamic capabilities of Alpine.js, and the stylish finesse of Tailwind CSS.
Before we dive in, let me clarify our mission - we're not building an admin interface using Field API. Our focus is on providing a file upload experience for the end-users of our app.
If you prefer browsing the final result instead reading the article, check hugronaphor/wire_demo_file_upload repository.
If you haven't installed Wire in your Drupal project yet, follow the steps on the Installation page.
Let's get started 💻🚀
Making the component
Create the Wire component with the following Drush command
drush generate wire
We'll assume you entered the Wire Label as
Drag And Drop File Upload
in a module withwire_demo_file_upload
machine name
Two new files were created in your chosen module
../wire_demo_file_upload/ src/Plugin/WireComponent/ DragAndDropFileUpload.php
../wire_demo_file_upload/ templates/wire/ drag_and_drop_file_upload.html.twig
Designing the component
I use Tailwind CSS in my projects, so I'll stick with it here. But you can find many ready styled versions on the web. I personally took as base this one.
Open your drag_and_drop_file_upload.html.twig
and paste this html into it
<div class="flex flex-col h-screen justify-center items-center">
<div class="w-1/2 text-gray-500">
<label
class="flex justify-center w-full h-12 pl-4 transition bg-white border-dashed border-2 rounded-md appearance-none cursor-pointer hover:border-gray-400 focus:outline-none"
>
<span class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-gray-500" fill="none"
viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<span class="ml-2 font-medium">
Drop the file here, or <span class="text-accent underline">browse</span>
</span>
</span>
<input type="file" style="display:none;"/>
</label>
</div>
</div>
We now have the basis to start implementing the interactivity.
Rendering the component
We won't stop here for long but you can include your Wire component in any rend-arable array or in your twig files. Also wire/alpinejs
library is being attached as we're going to need Alpine.Js in the next step.
In the future version of Wire(>2.x) Alpine.Js will be included by default.
In final code I'm including it via a Controller. See bellow.
namespace Drupal\wire_demo_file_upload\Controller;
use Drupal\Core\Controller\ControllerBase;
class WireDemoFileUploadController extends ControllerBase {
public function build(): array {
return [
'content' => [
'#type' => 'wire',
'#id' => 'drag_and_drop_file_upload',
],
'#attached' => [
'library' => [
'wire/alpinejs',
],
],
];
}
}
Read more about Rendering Wire components.
Make Upload UI behave
Open your drag_and_drop_file_upload.html.twig
and replace your HTML with the following.
<div class="flex flex-col h-screen justify-center items-center">
<div x-data="fileUpload()" class="w-1/2 text-gray-500">
<label
x-ref="filedrop"
x-on:drop="isDropping = false"
x-on:drop.prevent="handleFileDrop($event)"
x-on:dragover.prevent="isDropping = true"
x-on:dragleave.prevent="isDropping = false"
@dragover="$refs.filedrop.classList.add('border-blue-400');"
@dragleave="$refs.filedrop.classList.remove('border-blue-400');"
@drop="$refs.filedrop.classList.remove('border-blue-400');"
class="flex justify-center w-full h-12 pl-4 transition bg-white border-dashed border-2 rounded-md appearance-none cursor-pointer hover:border-gray-400 focus:outline-none"
>
<span class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-gray-500" fill="none"
viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<span class="ml-2 font-medium">
Drop the file here, or <span class="text-accent underline">browse</span>
</span>
</span>
<input type="file" @change="handleFileSelect" style="display:none;"/>
</label>
<div x-show="progress !== 0" class="bg-gray-200 h-[2px] mt-1">
<div
x-show="isUploading"
class="bg-blue-500 h-[2px]"
style="transition: width 1s"
:style="`width: ${progress}%;`"
></div>
</div>
{% if wireError('myFile') %}
<div class="mt-2 text-red-500 text-sm">{{ wireError('myFile') }}</div>
{% endif %}
{% if myFile %}
<div class="mt-2">
<span>{{ myFile.getClientOriginalName() }}</span>
<button class="ml-2 text-red-500" wire:click="removeMyFile">X</button>
</div>
<button
wire:click="save"
wire:loading.attr="disabled"
class="mt-4"
>Save
</button>
{% endif %}
{% if successMessage %}
<div class="mt-2 text-green-500">{{ successMessage }}</div>
{% endif %}
</div>
<script>
function fileUpload() {
return {
isDropping: false,
isUploading: false,
progress: 0,
handleFileSelect(event) {
if (event.target.files.length) {
this.uploadFile(event.target.files[0])
}
},
handleFileDrop(event) {
if (event.dataTransfer.files.length > 0) {
this.uploadFile(event.dataTransfer.files[0])
}
},
uploadFile(file) {
const $this = this
this.isUploading = true
// Successfully finished upload.
@this.upload('myFile', file, function (success) {
$this.isUploading = false
$this.progress = 0
},
// Errored.
function (error) {
console.error(error)
},
// Upload progress.
function (event) {
$this.progress = event.detail.progress
}
)
}
}
}
</script>
</div>
I know, a lot is going on here so let's break it down.
With x-data="fileUpload()"
we created an Alpine.Js component for handling the file drop.
The x-on:drop
, @drop
, x-on:dragover
, @dragover
, x-on:dragleave
, @dragleave
and @change
attributes is the way AlpineJs register Event listeners in JavaScript. So when a file is being dropped in our container, the handleFileDrop
is called and when we click on "Browse" link the handleFileSelect
is called as we created an "onchange" event with @change
.
These functions then make usage of functions exposed by Wire dedicated to handling file uploads. Learn more about Upload API here.
We're also showing a loading progress by simply increasing a div width % based on upload progress.
We'll get on handling the uploaded file on the server soon but you probably noticed the following addition:
{% if myFile %}
<div class="mt-2">
<span>{{ myFile.getClientOriginalName() }}</span>
<button class="ml-2 text-red-500" wire:click="removeMyFile">X</button>
</div>
<button
wire:click="save"
wire:loading.attr="disabled"
class="mt-4"
>Save
</button>
{% endif %}
In the above code we're checking if a File has been uploaded and if true, we're getting its original name to be displayed among a wire:click="removeMyFile"
and wire:click="save"
.
removeMyFile
will trigger a method in our PHP class to remove our temporary uploaded file.
save
will trigger a method in our PHP class to save our temporary uploaded file as permanent Drupal File entity.
{{ myFile.getClientOriginalName() }}
is a function coming from TemporaryUploadedFile
Object. To have this working, due to TwigSandboxPolicy
, in order to use the method you need to register the method as allowed in your settings.php
file as following:
$settings['twig_sandbox_allowed_classes'] = [
'\Drupal\wire\TemporaryUploadedFile',
];
Handling The Uploaded File on the server-side
Open your DragAndDropFileUpload.php
and replace your PHP code with the following.
namespace Drupal\wire_demo_file_upload\Plugin\WireComponent;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\wire\TemporaryUploadedFile;
use Drupal\wire\View;
use Drupal\wire\WireComponent;
use Drupal\wire\WithFileUploads;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Session\AccountInterface;
use function Hugronaphor\PhpHelpers\rescue;
/**
* Implementation for DragAndDropFileUpload Wire Component.
*
* @WireComponent(
* id = "drag_and_drop_file_upload",
* label = @Translation("Drag And Drop File Upload"),
* )
*/
class DragAndDropFileUpload extends WireComponent {
use WithFileUploads;
protected ?AccountInterface $account;
public $myFile = NULL;
public ?string $successMessage;
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
$instance = new static($configuration, $plugin_id, $plugin_definition);
$instance->account = $container->get('current_user');
return $instance;
}
public function save(): void {
$this->resetValidation();
$this->successMessage = rescue(
callback: function () {
$tempFile = $this->myFile;
$this->myFile = NULL;
if (!$file = $this->getFile($tempFile)) {
$this->addError('myFile', 'Please select a file.');
return NULL;
}
$file->save();
throw_if(!$file instanceof FileInterface, new \Exception('File could not be saved.'));
return sprintf('File created successfully with ID: %s.', $file->id());
},
rescue: function () {
$this->myFile = NULL;
$this->addError('myFile', 'Something went wrong while saving the file.');
},
);
}
public function updatingMyFile(): void {
$this->successMessage = NULL;
}
public function removeMyFile(): void {
isset($this->myFile) && $this->removeUpload('myFile', $this->myFile->getFilename());
}
private function getFile($tempFile): ?File {
if (!$tempFile instanceof TemporaryUploadedFile) {
return NULL;
}
$filepath = $tempFile->store();
return File::create([
'filename' => basename($filepath),
'uri' => $filepath,
'status' => 0,
'uid' => $this->account->id(),
]);
}
public function render(): ?View {
return View::fromTpl('drag_and_drop_file_upload');
}
}
Let's break it down.
save()
is a method we call to save our temporary uploaded file as permanent Drupal File entity. You might notice 2 unusual functions here.
rescue()
- this is wrapper function around atry {} catch() {}
code-block inspired from Laravel framework used for convenience and better looking code(at least for my taste). It's part of my own repo hugronaphor/php-helpers. A try-catch version can be seen in the initial repo demo commit.-
throw_if()
- This one also comes from Laravel world because Wire depends on illuminate/support.
removeMyFile()
is a method we call to remove our Temporary Uploaded File. The $this->removeUpload()
is a helper method provided by WithFileUploads
trait.
updatingMyFile()
is part of Lifecycle hooks, meaning that whever the $this->myFile
public property is being updated, we're resetting the $this->successMessage
public property.
This is it!
We now have a fully functional Drag and Drop file uploader with progressbar.
If you never used Wire before you might still be a bit confused. Best thing is to see the Docs and see my other articles on this Topic.