1

It's been a long time since I've used Rails for the frontend of a web app, and I want to use the most update version of course, but it seems a lot has changed and I don't know which is the most Rails Way to do it anymore.

I've tried to use JQuery and the FileUpload plugin, but we don't have JQuery anymore, I mean I've tried to add it but it was a pain in the ass using the new import map (problem with me, I know if I look up some tutorials I can do i), but that seems to go agains the current mentality of JS in rails apps.

Then I've went to checkout the new Hotwire + Stimulus but I don't even now where to start, but from the little that I saw don't know if will handle this scenario: I already have a presigned_url from my S3 Bucket, and simply have a form with a f.file_field which I want to upload this file from the clients browser directly to S3 doing a POST request, so the user don't get blocked waiting the upload to finish

Correct me if I'm wrong but to trigger JS functions the Rails Way now is to use Stimulus with HTML Data Attributes but I'm not sure if I could pass the file in this data attribute.

Looking other tutorials I'm starting to think that the best approach would be to have a turbo_stream_tag to wrap my form, and then when submitting the form will hit this turbo controller which will act as a ajax request, running asynchronously doing a post request using Net:HTTP or even the s3 gem itself, I'm just not sure if I would have access to the file.

Any kind soul to clarify this? Thanks and sorry for the long post.

Roland Studer
  • 4,315
  • 3
  • 27
  • 43
Matheus Mendes
  • 155
  • 2
  • 14

2 Answers2

8

You're probably want to look at this if you haven't: https://edgeguides.rubyonrails.org/active_storage_overview.html#example

Here is a starter with default Rails 7 setup with importmaps. Mostly taken from example link above and wrapped in Stimulus.

# config/importmap.rb
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"

pin "@rails/activestorage", to: "https://ga.jspm.io/npm:@rails/activestorage@7.0.2/app/assets/javascripts/activestorage.esm.js"
<!-- inside your form -->
<div data-controller="upload">
  <%= form.file_field :avatar, direct_upload: true,
    data: { upload_target: "input",
            action:        "change->upload#uploadFile" } %>
  <div data-upload-target="progress"></div>
</div>

This will start uploading as soon as file is selected. With Turbo it is non blocking. As long as the browser is not refreshed it will be uploading.

// app/javascript/controllers/upload_controller.js

import { Controller } from "@hotwired/stimulus";
import { DirectUpload } from "@rails/activestorage";

export default class extends Controller {
  static targets = ["input", "progress"];

  uploadFile() {
    Array.from(this.inputTarget.files).forEach((file) => {
      const upload = new DirectUpload(
        file,
        this.inputTarget.dataset.directUploadUrl,
        this // callback directUploadWillStoreFileWithXHR(request)
      );
      upload.create((error, blob) => {
        if (error) {
          console.log(error);
        } else {
          this.createHiddenBlobInput(blob);
          // if you're not submitting the form after upload, you need to attach
          // uploaded blob to some model here and skip hidden input.
        }
      });
    });
  }

  // add blob id to be submitted with the form
  createHiddenBlobInput(blob) {
    const hiddenField = document.createElement("input");
    hiddenField.setAttribute("type", "hidden");
    hiddenField.setAttribute("value", blob.signed_id);
    hiddenField.name = this.inputTarget.name;
    this.element.appendChild(hiddenField);
  }

  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress", (event) => {
      this.progressUpdate(event);
    });
  }

  progressUpdate(event) {
    const progress = (event.loaded / event.total) * 100;
    this.progressTarget.innerHTML = progress;

    // if you navigate away from the form, progress can still be displayed 
    // with something like this:
    // document.querySelector("#global-progress").innerHTML = progress;
  }
}
Alex
  • 16,409
  • 6
  • 40
  • 56
  • Been having a problem, I can get the request to happen now, but I'm getting 403 from S3, one thing that I noticed is that this is doing a `POST` request, and I believe this should be a `PUT` request but don't how to to specify in this `upload.create` method. I created the url like this `s3.bucket(ENV['S3_BUCKET']).object(upload_key)..presigned_url(:put,{ acl: 'public-read' })` This is the error I get, maybe is just S3 configuration? `The request signature we calculated does not match the signature you provided. Check your key and signing method.` – Matheus Mendes Mar 27 '22 at 22:32
  • Maybe set up a regular form to make sure all the configs are correct and upload is working and then move on to direct upload. I haven't worked with direct uploads much, but shouldn't the upload url still come from ActiveStorage as configured in storage.yml. – Alex Mar 27 '22 at 23:20
1

Soooooo, sorry for the delay just got into this, the easiest and most forward way of implementing active storage direct upload, it's pretty simple although the docs are not really clear about how to achieve it with Stimulus. So if you follow this docs and you want to make it work with Stimulus make sure to override the javascript with this section, first, create the Stimulus controller, add event listeners for the actions you want, in my example, it would be onDrop and onChange event and then bind it with the custom uploader class that we will create.

// rails generate stimulus direct_uploads
// => creates a js file under app/javascript/controllers/direct_uploads_controller.js

import { Controller } from '@hotwired/stimulus';

// don't worry about this import you'll find the class right below
import Uploader from '../utils/direct_uploads';

// Connects to data-controller="direct-uploads"
export default class extends Controller {
  connect() {
  }

  // drop->direct-uploads#handleInputChange
  handleOnDrop(event) {
    event.preventDefault();
    const { files } = event.dataTransfer;
    Array.from(files).forEach((file) => this.uploadFile(file));
  }

  // change->direct-uploads#handleInputChange
  handleInputChange({ currentTarget }) {
    const { files } = currentTarget;
    Array.from(files).forEach((file) => this.uploadFile(file, currentTarget));
  }

  uploadFile(file, currentTarget) {
    const upload = new Uploader(file, currentTarget);
    upload.start();
  }
}

// now let's create the uploader class.
// => app/javascript/utils/direct_uploads.js

import { DirectUpload } from '@rails/activestorage';

export default class Uploader {
  constructor(file, currentTarget) {
    const { dataset: { directUploadUrl } } = currentTarget;

    this.directUpload = new DirectUpload(file, directUploadUrl, this);
    this.targetForm = currentTarget.closest('form');
    this.file = file;
    this.currentTarget = currentTarget;
  }

  start() {
    this.directUpload.create((error, blob) => {
      if (error) {
        // Handle the error
      } else {
        // Add an appropriately-named hidden input to the form
        // with a value of blob.signed_id
      }
    });
  }

  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener(
      'progress',
      (event) => this.directUploadDidProgress(event),
    );
  }

  directUploadDidProgress(event) {
    // Use event.loaded and event.total to update the progress bar
  }
}


let me know if you know a better way to do it, without using any extra libraries

dandush03
  • 219
  • 4
  • 6