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> |
This is a literal exact copy of: https://web-crunch.com/posts/rails-drag-drop-active-storage-stimulus-dropzone
My is the original 😂😂😂😂
Ah my bad. well in that case, got any suggestions for testing with jest (or mocha karma other)?
No, sorry... I use rails system tests (Selenium) for test javascript.
@lazaronixon thanks for replying. I don't suppose you'd have an example system test you can share? I like to write both javascript tests for functionality and system tests for behavior, so a system test template would still be a big help
Thanks for this extremely helpful code.
Do you know of the benefit of manually setting the dropzone file status? For example: this.file.status = Dropzone.UPLOADING
The only information I can seem to find in the docs regarding status is:
When a file gets added to the dropzone, its status gets set to Dropzone.QUEUED (after the accept function check passes) which means that the file is now in the queue.
Thanks for this! also don't forget to import the css
@import "dropzone/dist/min/dropzone.min.css";
@import "dropzone/dist/min/basic.min.css";
Hi, Mr @lazaronixon. Thanks for your sharing. Could you also share any tips to implement the edit/patch/update feature? What would be the best way to restore files in the form when loading it?
@lazaronixon thanks for this gist! (Abraços 👍 )
Can you share how you test the "attachment" using Selenium? Do you mock the s3 response?
Just sharing how I tested this approach using Capybara
If you are using a custom port for capybara add this on your test.rb
Rails.application.routes.default_url_options = { host: 'your.host', port: your_custom_port }
#testing 2 images beign dropped on dropzone
#you have do add an image/file logo.png in your fixture folder (eg spec/fixtures/logo.png)
let(:image_file){ File.open(File.join(fixture_path, 'logo.png')) }
scenario do
visit your_route_path
drop_in_dropzone(image_file.path)
drop_in_dropzone(image_file.path, count: 2)
expect(page).to have_content('File uploaded')
expect_direct_upload_input_hidden(param_name: "myparam[my_attr][]", count: 2)
end
#testing validations
scenario do
visit your_route_path
drop_in_dropzone(invalid_content_type_file.path)
expect(page).to have_content("You can't upload files of this type.", count: 1)
expect(page).not_to have_css("input[name='myparam[my_attr][]']", wait: 0.5)
end
#custom helper methods (you can put them in /spec/support/capybara_helper.rb
def drop_in_dropzone(file_path, upload_count: 1)
# Generate a fake input selector
page.execute_script <<-JS
fakeFileInput#{upload_count} = window.$('<input/>').attr({id: 'fakeFileInput#{upload_count}', type:'file'}).appendTo('body');
JS
# Attach the file to the fake input selector
attach_file("fakeFileInput#{upload_count}", file_path)
# Trigger dropzone upload programatically (handleFiles will call the proper events, thumbnail and all dropzone stuff)
page.execute_script <<-JS
var file = fakeFileInput#{upload_count}.get(0).files[0]
myDropzone = Dropzone.forElement(".dropzone");
myDropzone.handleFiles([file])
JS
end
def expect_direct_upload_input_hidden(param_name:, count: 1)
expect(page).to have_css("input[name='#{param_name}']", visible: false, count: count)
end
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.
@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.
@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 :)
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).
The example above actually implements progress. I am using it with multiple files and the progress events are working fine.
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:
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?
Did you have to change anything in the view? I'm getting the same error even after making the changes you suggest.
@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)
}
@lohannon I'll give it a try. Thanks.
Does anyone know what is the best way to display the files already uploaded in edit case? Thanks
That's great! Is there any way to add client side image resize?