A complete Drag and Drop file uploader with progressbar using Wire Drupal, Alpine.js and Tailwindcss

drag and drop wire drupal

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 with wire_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.

Rendered Drag & Drop file area with tailwind

 

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 a try {} 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 is 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.

********************************** ************************* ************************ **************** ****************** *********** ************** ************* ************ *************