Skip to content

Instantly share code, notes, and snippets.

@lazaronixon
Last active March 9, 2024 05:14
Show Gist options
  • Star 75 You must be signed in to star a gist
  • Fork 12 You must be signed in to fork a gist
  • Save lazaronixon/dca1b48c241422d6347f4b0c93bec739 to your computer and use it in GitHub Desktop.
Save lazaronixon/dca1b48c241422d6347f4b0c93bec739 to your computer and use it in GitHub Desktop.
Dropzone.js + Stimulus + Active Storage
import { Controller } from "stimulus"
import { DirectUpload } from "@rails/activestorage"
import Dropzone from "dropzone"
import { getMetaValue, findElement, removeElement, insertAfter } from "helpers"
Dropzone.autoDiscover = false
export default class extends Controller {
static targets = [ "input" ]
connect() {
this.dropZone = createDropZone(this)
this.hideFileInput()
this.bindEvents()
}
// Private
hideFileInput() {
this.inputTarget.disabled = true
this.inputTarget.style.display = "none"
}
bindEvents() {
this.dropZone.on("addedfile", (file) => {
setTimeout(() => { file.accepted && createDirectUploadController(this, file).start() }, 500)
})
this.dropZone.on("removedfile", (file) => {
file.controller && removeElement(file.controller.hiddenInput)
})
this.dropZone.on("canceled", (file) => {
file.controller && file.controller.xhr.abort()
})
this.dropZone.on("processing", (file) => {
this.submitButton.disabled = true
})
this.dropZone.on("queuecomplete", (file) => {
this.submitButton.disabled = false
})
}
get headers() { return { "X-CSRF-Token": getMetaValue("csrf-token") } }
get url() { return this.inputTarget.getAttribute("data-direct-upload-url") }
get maxFiles() { return this.data.get("maxFiles") || 1 }
get maxFileSize() { return this.data.get("maxFileSize") || 256 }
get acceptedFiles() { return this.data.get("acceptedFiles") }
get addRemoveLinks() { return this.data.get("addRemoveLinks") || true }
get form() { return this.element.closest("form") }
get submitButton() { return findElement(this.form, "input[type=submit], button[type=submit]") }
}
class DirectUploadController {
constructor(source, file) {
this.directUpload = createDirectUpload(file, source.url, this)
this.source = source
this.file = file
}
start() {
this.file.controller = this
this.hiddenInput = this.createHiddenInput()
this.directUpload.create((error, attributes) => {
if (error) {
removeElement(this.hiddenInput)
this.emitDropzoneError(error)
} else {
this.hiddenInput.value = attributes.signed_id
this.emitDropzoneSuccess()
}
})
}
// Private
createHiddenInput() {
const input = document.createElement("input")
input.type = "hidden"
input.name = this.source.inputTarget.name
insertAfter(input, this.source.inputTarget)
return input
}
directUploadWillStoreFileWithXHR(xhr) {
this.bindProgressEvent(xhr)
this.emitDropzoneUploading()
}
bindProgressEvent(xhr) {
this.xhr = xhr
this.xhr.upload.addEventListener("progress", event => this.uploadRequestDidProgress(event))
}
uploadRequestDidProgress(event) {
const element = this.source.element
const progress = event.loaded / event.total * 100
findElement(this.file.previewTemplate, ".dz-upload").style.width = `${progress}%`
}
emitDropzoneUploading() {
this.file.status = Dropzone.UPLOADING
this.source.dropZone.emit("processing", this.file)
}
emitDropzoneError(error) {
this.file.status = Dropzone.ERROR
this.source.dropZone.emit("error", this.file, error)
this.source.dropZone.emit("complete", this.file)
}
emitDropzoneSuccess() {
this.file.status = Dropzone.SUCCESS
this.source.dropZone.emit("success", this.file)
this.source.dropZone.emit("complete", this.file)
}
}
// Top level...
function createDirectUploadController(source, file) {
return new DirectUploadController(source, file)
}
function createDirectUpload(file, url, controller) {
return new DirectUpload(file, url, controller)
}
function createDropZone(controller) {
return new Dropzone(controller.element, {
url: controller.url,
headers: controller.headers,
maxFiles: controller.maxFiles,
maxFilesize: controller.maxFileSize,
acceptedFiles: controller.acceptedFiles,
addRemoveLinks: controller.addRemoveLinks,
autoQueue: false
})
}
export function getMetaValue(name) {
const element = findElement(document.head, `meta[name="${name}"]`)
if (element) {
return element.getAttribute("content")
}
}
export function findElement(root, selector) {
if (typeof root == "string") {
selector = root
root = document
}
return root.querySelector(selector)
}
export function removeElement(el) {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
}
export function insertAfter(el, referenceNode) {
return referenceNode.parentNode.insertBefore(el, referenceNode.nextSibling);
}
<div class="dropzone dropzone-default dz-clickable" data-controller="dropzone" data-dropzone-max-file-size="5" data-dropzone-accepted-files="text/csv">
<%= form.file_field :file, direct_upload: true, data: { target: 'dropzone.input' } %>
<div class="dropzone-msg dz-message needsclick">
<h3 class="dropzone-msg-title">Solte o arquivo aqui ou clique para fazer o upload.</h3>
<span class="dropzone-msg-desc">Use os templates abaixo para criar o arquivo, o tamanho maximo é de 5 MiB.</span>
</div>
</div>
@Svashta
Copy link

Svashta commented Jan 5, 2022

Hi there.
I am trying to implement this with Rails 7.
I've tried everything that I could possibly think of and that's in my knowledge (limited, tho), but for some reason I can't get it to work.
When i add/drop a file and DirectUploadController is triggered, i get JS error like this:

activestorage.esm.js? [sm]:547 Uncaught TypeError: Converting circular structure to JSON --> starting at object with constructor 'DirectUploadController' | property 'directUpload' -> object with constructor 'DirectUpload' | property 'file' -> object with constructor 'File' --- property 'controller' closes the circle at JSON.stringify (<anonymous>) at BlobRecord.create (activestorage.esm.js? [sm]:547:24) at FileChecksum.callback (activestorage.esm.js? [sm]:627:12) at FileChecksum.fileReaderDidLoad (activestorage.esm.js? [sm]:441:12) at FileReader.<anonymous> (activestorage.esm.js? [sm]:432:61)

Does anyone know what's going on here. Is it my configuration that's wrong or is there something is the activestorage itself?
Thanks for any tip.

@lohannon
Copy link

@Svashta I ran into the same issue. The constructor for DirectUpload needs a token and an attachmentName, so I passed those in when creating the DirectUploadController, like this:

    this.dropZone.on("addedfile", (file) => {
      const token = this.inputTarget.getAttribute("data-direct-upload-token")
      const attachmentName = this.inputTarget.getAttribute("data-direct-upload-attachment-name")
      setTimeout(() => { file.accepted && createDirectUploadController(this, file, token, attachmentName).start() }, 500)
    })

This seemed to resolve the issue for me.

@Svashta
Copy link

Svashta commented Jan 26, 2022

@lohannon Thanks for this. I actually figured it out, but forgot to mention it here.
I was thinking the method expects an actual file name, but instead, after digging into how the token is made in the first place, i figured out it expects field name as this is what the token is generated with and to decode it it needs same parameter value(s).
It's a bit confusing as the parameter is called "file name" while it should be field name. It's a bit counter intuitive to me, but it works :)

@ktimothy
Copy link

ktimothy commented Feb 2, 2022

Any ideas about progress for multiple files?

Rails' DirectUpload seems to be an extremely old and shity js-code, which does not even emit events (wtf).

@pedromamede
Copy link

@ktimothy

The example above actually implements progress. I am using it with multiple files and the progress events are working fine.

@jasper502
Copy link

Is there a quick way to not have the controller overwrite the existing files? With my plain form files input I was doing something like this:

https://stackoverflow.com/questions/59803964/what-is-the-correct-way-to-update-images-with-has-many-attached-in-rails-6

        if params[:location][:files].present?
          params[:location][:files].each do |image|
            @location.files.attach(image)
          end
        end

This would just add new files to the existing ones which is what I am looking for. Now I could add another controller in this case as this page has just the files for the parent model and no other parent model attributes.

Side question in my code above I was going to add a turbo append to each attach and do a live update to the page showing the files added (setting auto-queue to true). Can you some how add this via drop zone?

@tfolk
Copy link

tfolk commented May 11, 2022

@lohannon

Did you have to change anything in the view? I'm getting the same error even after making the changes you suggest.

@lohannon
Copy link

@tfolk I also had to overwrite some method signatures to include the token and attachment name like this:

class DirectUploadController {
  constructor(source, file, token, attachmentName) {
    this.directUpload = createDirectUpload(file, source.url, token, attachmentName)
    this.source = source
    this.file = file
  }
  ...
  }

and

// Top level...
function createDirectUploadController(source, file, token, attachmentName) {
  return new DirectUploadController(source, file, token, attachmentName)
}

function createDirectUpload(file, url, token, attachmentName) {
  return new DirectUpload(file, url, token, attachmentName)
}

@tfolk
Copy link

tfolk commented May 11, 2022

@lohannon I'll give it a try. Thanks.

@elalemanyo
Copy link

Does anyone know what is the best way to display the files already uploaded in edit case? Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment