An ActiveStorage S3 Direct Uploader: Part 4 - Bonus Features
Our uploader is super functional and fast.
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:
Adding the Audio URL to JSON
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:
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:
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:
A fully functional audio player after the upload.
Refactoring
There are 3 code smells for me.
- The
process
method is a bit long. - We're passing an argument into
insertAudio
. - 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:
That's it for this series, post comments below and please follow me @aviflombaum.