An ActiveStorage S3 Direct Uploader: Part 4 - Bonus Features

Our uploader is super functional and fast.

Final Product

Quick Review

Just for fun, I thought we'd add a feature where after the upload is complete we display an audio player for the track that was just uploaded. If we look back at our code, in our Upload class, we have the process method:

app/javascript/controllers/uploadzone_controller.js

  process() {
  this.insertUpload();

  this.directUpload.create((error, blob) => {
    if (error) {
      // Handle the error
    } else {
      const trackData = { track: { filename: blob.filename }, signed_blob_id: blob.signed_id };

      post("/tracks", {
        body: trackData,
        contentType: "application/json",
        responseKind: "json",
      });
    }
  });
}

After the upload is sent to s3 via this.directUpload.create, in the callback for that we have our post call where we send the uploaded track information to our rails backend so that we can create the Track in our database and attach the file we uploaded to it. That post request, if you remember, returns a JSON representation of the Track created.

app/controller/tracks_controller.rb

def create
  @track = Track.new(track_params)
  @track.audio_file.attach(params[:signed_blob_id])
  if @track.save
    render json: @track, status: :created
  end
end

The return for that looks like:

Returning JSON

Adding the Audio URL to JSON

Commit

If we just include the URL of the track we just uploaded in that JSON, we could insert an audio tag into the DOM. Let's modify the JSON to include the URL for the Track. Let's update the TracksController to include a signed URL from S3 that will allow the Mp3 to be played:

app/controller/tracks_controller.rb

def create
  @track = Track.new(track_params)
  @track.audio_file.attach(params[:signed_blob_id])
  if @track.save
    render json: {
      track: @track,
      audio_url: @track.audio_file.url
    }, status: :created
  end
end

Now the response from our post looks like this:

Response

Processing the JSON Response

With that we can grab the audio_url and replace the progress bar with an audio tag. Let's jump back to our Upload class in our uploadzone_controller.js and make that happen.

The first change is to make the callback to this.directUpload.create an async method so that we can tell it to await for the post request to complete. Then we need to capture the resulting JSON. For now, let's just log it to the console to make sure it works.

  process() {
    this.insertUpload();

    this.directUpload.create(async (error, blob) => {
      if (error) {
        // Handle the error
      } else {
        const trackData = { track: { filename: blob.filename }, signed_blob_id: blob.signed_id };

        const trackResponse = await post("/tracks", {
          body: trackData,
          contentType: "application/json",
          responseKind: "json",
        });

        if (trackResponse.ok) {
          const trackJSON = await trackResponse.json;
          console.log(trackJSON);
        }
      }
    });
  }

Becaues this method is getting a bit long, let's make a mental note to refactor it, but the relevant addition we made is:

const trackResponse = await post("/tracks", {
  body: trackData,
  contentType: "application/json",
  responseKind: "json",
});

if (trackResponse.ok) {
  const trackJSON = await trackResponse.json;
  console.log(trackJSON);
}

We're getting the response, making sure it was a 200 and it worked, then converting it to json and logging it. If we upload a track we'll see:

Track JSON

Inserting the Audio Tag

With the audio_url, we have all the data we need to take the next step and replace the progress bar with an audio tag.

Let's make a new method for that, insertAudio and pass in the audio_url.

insertAudio(audioURL) {
  // Find the progressBarDiv
  const progressBarDiv = document.querySelector(`#upload_${this.directUpload.id} .progress`);

}

The first thing we're doing is finding the progress bar div that we made in insertUpload. Once we have that div, we want to create an audio element, make some adjustments to the wrapper of the progress bar to make it taller, and then append the audio tag and remove the progress bar:

insertAudio(audioURL) {
  const progressBarDiv = document.querySelector(`#upload_${this.directUpload.id} .progress`);

  // Create audio element
  const audio = document.createElement("audio");
  audio.controls = true;
  audio.src = audioURL;
  audio.classList.add("w-full");

  // Make the parent progressWrapper div taller
  progressBarDiv.parentElement.classList.add("h-14");
  progressBarDiv.parentElement.classList.remove("h-4");

  // Insert the audio tag and remove the progress bar.
  progressBarDiv.parentElement.appendChild(audio);
  progressBarDiv.remove();
}

With that we get this really awesome effect:

Audio Track

A fully functional audio player after the upload.

Refactoring

Commit

There are 3 code smells for me.

  1. The process method is a bit long.
  2. We're passing an argument into insertAudio.
  3. We're finding a DOM element that we previously created.

Smells 2 and 3 are somewhat related, which is that whenever I'm in a pure object oriented paradigm, if I'm passing data that relates to the instance, like the audio_url, or finding data that I previously made again (like firing a SQL statement twice), I think to myself, hmm, shouldn't this be a part of the instance as a property?

Smell 1 is just that process seems to be doing 2 things, uploading the track and now processing json, that seems like a clear division. Let's do that refactor first and remove the logic for creating the track into its own method that we'll pass to the this.directUpload.create callback:

process() {
 this.insertUpload();

 this.directUpload.create((error, blob) => this.createTrack(error, blob));
}

async createTrack(error, blob) {
 if (error) {
   // Handle the error
 } else {
   const trackData = { track: { filename: blob.filename }, signed_blob_id: blob.signed_id };

   const trackResponse = await post("/tracks", {
     body: trackData,
     contentType: "application/json",
     responseKind: "json",
   });

   if (trackResponse.ok) {
     const trackJSON = await trackResponse.json;

     this.insertAudio(trackJSON.audio_url);
   }
 }
}

Tada! Love a good extract method refactor.

Now the basic premise of passing arguments or refinding data in object orientation is make it a property. If we want to use trackJSON.audio_url in insertAudio, let's just make it a property of the instance of Upload instead of passing it around.

Instead of const trackJSON = await trackResponse.json; we'll do this.trackJSON = await trackResponse.json;

That's it. Once the trackJSON is part of the instance, inside insertAudio we can just read it when we need it. And we don't need to pass it when we call insertAudio.

insertAudio() {
 const progressBarDiv = document.querySelector(`#upload_${this.directUpload.id} .progress`);

 // Create audio element
 const audio = document.createElement("audio");
 audio.controls = true;
 audio.src = this.trackJSON.audio_url;
 audio.classList.add("w-full");

 // Make the parent progressWrapper div taller
 progressBarDiv.parentElement.classList.add("h-14");
 progressBarDiv.parentElement.classList.remove("h-4");

 // Insert the audio tag and remove the progress bar.
 progressBarDiv.parentElement.appendChild(audio);
 progressBarDiv.remove();
}

No more argument and the important line is audio.src = this.trackJSON.audioURL;

The final code for createTrack looks like:

async createTrack(error, blob) {
 if (error) {
   // Handle the error
 } else {
   const trackData = { track: { filename: blob.filename }, signed_blob_id: blob.signed_id };

   const trackResponse = await post("/tracks", {
     body: trackData,
     contentType: "application/json",
     responseKind: "json",
   });

   if (trackResponse.ok) {
     this.trackJSON = await trackResponse.json;

     this.insertAudio();
   }
 }
}

We're going to do a similar thing for the progress div. Once we create that element in insertUpload, we're just going to make a reference to that element in the DOM a property of the instance. We'll change const progressBar = document.createElement("div"); to this.progressBar = document.createElement("div");`.

Our insertUpload method now looks like this, with every reference to progressBar changed to be the property.

insertUpload() {
  const fileUpload = document.createElement("div");

  fileUpload.id = `upload_${this.directUpload.id}`;
  fileUpload.className = "p-3 border-b";

  fileUpload.textContent = this.directUpload.file.name;

  const progressWrapper = document.createElement("div");
  progressWrapper.className = "relative h-4 overflow-hidden rounded-full bg-secondary w-[100%]";
  fileUpload.appendChild(progressWrapper);

  this.progressBar = document.createElement("div");
  this.progressBar.className = "progress h-full w-full flex-1 bg-primary";
  this.progressBar.style = "transform: translateX(-100%);";
  progressWrapper.appendChild(this.progressBar);

  const uploadList = document.querySelector("#uploads");
  uploadList.appendChild(fileUpload);
}

Now that the progressBar is a property of the instance, we can simplify our insertAudio yet again:

insertAudio() {
  // Create audio element
  const audio = document.createElement("audio");
  audio.controls = true;
  audio.src = this.trackJSON.audio_url;
  audio.classList.add("w-full");

  // Make the parent progressWrapper div taller
  this.progressBar.parentElement.classList.add("h-14");
  this.progressBar.parentElement.classList.remove("h-4");

  // Insert the audio tag and remove the progress bar.
  this.progressBar.parentElement.appendChild(audio);
  this.progressBar.remove();
}

Everything still works! The final javascript controller looks like:

Final Product

That's it for this series, post comments below and please follow me @aviflombaum.

Did you find this article valuable?

Support Avi Flombaum by becoming a sponsor. Any amount is appreciated!