A Return To Familiar Waters: How I Created a Single-Page App using JS + Rails (UPDATED)
Author's note (07/07/2020): This article was originally published on July 1st, 2020. Since then, I made changes to talk more about the specific functions I used on the frontend to allow for client-server communication.
For my fourth project at the Flatiron School, I was tasked with building a single-page application using Javascript on the frontend and Rails on the backend. I guess you could sort of call it "JS on Rails". For the backend, I chose Rails because it has an excellent ORM and it's incredibly faithful to MVC & RESTful conventions. While Rails would work for the frontend, Javascript has incredible flexibility when it comes to DOM manipulation thanks in large part to event listeners .
Getting Set Up
I decided to build an application called "FIFO Fridge". In it, a user can create "fridges" that can store multiple "food items". Each food item contains various information like its name
and food_group
.
At the start of this project, I made two important decisions that would affect the experience from then on:
- I went with a single repo ("monorepo") instead of using multiple repos ("multirepo") for version control. This was mainly because I wanted a "single source of truth" that I could push all my changes to. Personally, I haven't built a project large/ complex enough to warrant using a monorepo pattern. I'll reconsider this if/ when the project grows and becomes more complex.
- I decided to stick with SQLite for database management since it is already supported in Rails. For future projects, I'm definitely open to using other DB systems like Postgres. However, as I begin to retackle the likes of React and Redux in the next few weeks, I'm going to make an effort to use it!
Building the Backend
As I said, Rails works wonderfully as a platform for building RESTful APIs.
After creating and changing into the working directory for my project, I specifically added the --api
flag at the end of the following command so that Rails wouldn't create anything pertaining to views:
rails new fifo-fridge-app-backend --api
Because I'm going to have JavaScript handle the "V" in "MVC", I need a way to pass the data from the backend to the frontend in a JS-friendly format. Therefore, I ultimately went with Rails' inherent active model serializers to do the job!
class FridgesController < ApplicationController
before_action :set_fridge, only: [:show, :update, :destroy]
def index
@fridges = Fridge.all
render json: @fridges.as_json(include: {food_items: {only:[:id, :name, :food_group, :expiration_date, :quantity]}})
end
# ... other actions
end
I implemented most of the CRUD-controller actions for Fridge
as well as a food_items#destroy
action for FoodItem
. I also incorporated strong params by limiting the permitted params
data to only whatever fridge data was submitted from the frontend or supplied by the seed data.
Building the Frontend
The unique twist for this project was building a frontend that utilized JavaScript's amazing ability to work with the DOM and communicate with servers. Separate from the backend
folder, I established a folder solely for the frontend: fifo-fridge-app-frontend
.
This would house all my stylesheets, class files, markup and source code. All the rendering, eventListeners and fetches to the backend will be handled here. For the sake of staying faithful to SOLID design (make no mistake, I have much to learn ;) ), I abstracted the work of modeling Fridge
and FoodItem
objects in their own class files. I also created an API
class file who's sole duty was to perform AJAX requests to the backend.
So far, my project is able to perform 4 AJAX requests: GET (fridges#index
), PATCH (fridges#update
), POST ('fridges#create'), and DELETE (fridges#destroy
).
// fifo-fridge-app-frontend/services/api.js
// render all fridges to the index page
static loadFridges() {
fetch(`http://localhost:3000/fridges`)
.then(res => res.json())
.then(fridgeData => {
for(let fridge of fridgeData) {
const {name, capacity, food_items, id} = fridge;
new Fridge(name, capacity, food_items, id);
}})
};
// fetch a POST request with the submitted form data to add a new Fridge
static addFridge(e) {
e.preventDefault();
let foodGroupSelectBox = e.target.getElementsByTagName("select")[0]
let data = {
'name': e.target.name[0].value,
'capacity': parseInt(e.target.capacity.value),
'food_items_attributes': [{
'name': e.target.name[1].value,
'food_group': foodGroupSelectBox.options[foodGroupSelectBox.selectedIndex].value,
'expiration_date': e.target.expiration_date.value,
'quantity': parseInt(e.target.quantity.value)
}]
};
fetch(`http://localhost:3000/fridges`, {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(res => res.json())
.then(fridge => {
const {name, capacity, food_items, id} = fridge;
new Fridge(name, capacity, food_items, id);
document.getElementById('fridge-form').reset();
})
};
// delete our fridges
static deleteFridge(fridgeID) {
fetch(`http://localhost:3000/fridges/${fridgeID}`, {method: "DELETE"});
document.getElementsByClassName('fridge-card')[fridgeID-1].remove();
return "The fridge was deleted!";
};
static deleteFoodItem(foodItemID) {
debugger;
fetch(`http://localhost:3000/food_items/${foodItemID}`, {method: "DELETE"});
document.getElementById(foodItemID.toString()).remove();
return "The food item was deleted!";
};
//add food item
static addFoodItem(fridgeCard, fridgeID) {
let newFoodItemForm = fridgeCard.getElementsByTagName('form')[0];
newFoodItemForm.style.display="block";
newFoodItemForm.addEventListener("submit", (e) => {
e.preventDefault();
let foodGroupSelectBox = e.target.getElementsByTagName("select")[0]
let data = {
'food_items_attributes': [{
'name': e.target.name.value,
'food_group': foodGroupSelectBox.options[foodGroupSelectBox.selectedIndex].value,
'expiration_date': e.target.expiration_date.value,
'quantity': parseInt(e.target.quantity.value)
}]
};
fetch(`http://localhost:3000/fridges/${fridgeID}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
})
.then(res => res.json())
.then(result => {
const currentFridge = document.getElementsByClassName('fridge-card')[result.id-1]
const foodItemsContainer = currentFridge.querySelector("#food-items-container");
let newFoodItem = result.food_items[result.food_items.length-1];
FoodItem.addItemToFridge(currentFridge, newFoodItem)
});
});
}
Using object-oriented programming to create Fridge
and FoodItem
objects from the fetched JSON data, we can render them in multiple ways.
// fifo-fridge-app-frontend/models/fridge.js
// sets up the container elements for the fetched Fridge (and FoodItem) data
renderFridge() {
const fridgeContainer = document.getElementById("fridge-container");
const fridgeCard = document.createElement("div");
fridgeCard.setAttribute("class", "fridge-card");
fridgeCard.setAttribute("data-fridge-id", this.id);
fridgeCard.innerHTML += this.fridgeCardHTML();
// create "Add Food Item" button
// button comes with event listener that invokes API.addFoodItem() when clicked
const addFoodItem = document.createElement('button');
addFoodItem.innerText = "Add Food Item"
addFoodItem.setAttribute("class", "add-food-item-btn")
addFoodItem.addEventListener("click", (e) => {
e.preventDefault();
API.addFoodItem(fridgeCard, this.id);
});
fridgeCard.appendChild(addFoodItem);
// form for adding a new food item to the current fridgeCard
let newFoodItemForm = document.createElement('form');
newFoodItemForm.id = "new-food-item-form";
newFoodItemForm.innerHTML = `
<input type="text" name="name" placeholder="food name" />
<br />
<select name="food-group-options" id="food-group-options">
<option value="Choose a Food Group" selected>Choose A Food Group</option>
<option value="Fruits">Fruits</option>
<option value="Vegetables">Vegetables</option>
<option value="Grains">Grains</option>
<option value="Protein Foods">Protein Foods (Meats, Poultry, Seafood, etc)</option>
<option value="Dairy">Dairy</option>
</select>
<br />
<input type="text" name="expiration_date" placeholder="expiration date (##/##/####)" />
<br />
<input type="number" name="quantity" placeholder="quantity" min=0 />
<br />
<input type="submit" value="Add Item" />
`
// form is attached to to current fridgeCard and set to "dislpay:none" by default
// (changes to "dislpay: block" when clicked and back to "none" when submitted)
newFoodItemForm.style.display="none";
fridgeCard.appendChild(newFoodItemForm);
// button for deleting the current fridgeCard when clicked
// uses API.deleteFridge() to send AJAX request to Rails API to
// delete fridge from database
const deleteBtn = document.createElement("button");
deleteBtn.innerText = "Delete";
deleteBtn.setAttribute("class", "fridge-delete-btn");
deleteBtn.addEventListener("click", (e) => {
API.deleteFridge(parseInt(e.target.parentElement.getAttribute('data-fridge-id')))
});
fridgeCard.appendChild(deleteBtn);
// create "food-items-container <div> for current fridgeCard"
// invoke FoodItem.renderFoodItems() to populate the container
const foodItemsContainer = document.createElement("div");
foodItemsContainer.setAttribute("class", "food-items-container");
// render the food items and then append to foodItemsContainer?
// TODO: Build FoodItem.renderFoodItems(foodItemsContainer, this.foodItems) <-- returns updated copy of foodItemsContainer with added food items
// renderedFoodItems = FoodItem.renderFoodItems(foodItemsContainer, this.foodItems)
// fridgeCard.appendChild(renderedFoodItems)
const renderedFoodItems = FoodItem.renderFoodItems(foodItemsContainer, this.foodItems);
fridgeCard.appendChild(renderedFoodItems);
// finally, we append the fridgeCard to the existing fridgeContainer
// it is now rendered
fridgeContainer.appendChild(fridgeCard);
};
// fifo-fridge-app-frontend/models/food_item.js
// given a foodItemsContainer and an array of item-object data,
// this render
static renderFoodItems(foodItemsContainer, items) {
for(let item of this.sortByDate(items)) {
const {name, food_group, expiration_date, quantity} = item;
let newFoodItem = new FoodItem(name, food_group, expiration_date, quantity);
let foodItemCard = document.createElement("div");
foodItemCard.setAttribute("class", "food-item-card");
foodItemCard.id = item.id;
foodItemCard.innerHTML += this.foodItemsCardHTML(item);
const deleteBtn = document.createElement("button");
deleteBtn.innerText = "Delete Item";
deleteBtn.setAttribute("class", "food-item-delete-btn");
deleteBtn.addEventListener("click", (e) => {
debugger;
API.deleteFoodItem(parseInt(e.target.parentElement.id))
});
foodItemCard.appendChild(deleteBtn);
foodItemsContainer.appendChild(foodItemCard);
};
return foodItemsContainer
};
// takes an item and fridge and adds the item to that fridge
static addItemToFridge(fridge, item) {
const {name, food_group, expiration_date, quantity} = item;
const newFoodItem = new FoodItem(name, food_group, expiration_date, quantity);
let foodItemsContainer = fridge.querySelector(".food-items-container");
let foodItemCard = document.createElement('div');
foodItemCard.setAttribute("class", "food-item-card");
foodItemCard.innerHTML += `
<h4>${item.name}</h4>
<br>
<strong>Food Group: </strong> ${item.food_group[0].toUpperCase() + item.food_group.slice(1)} <br />
<strong>Expiration Date: </strong> ${item.expiration_date} <br />
<strong>Quantity: </strong> ${item.quantity} <br /><br />
`
const deleteBtn = document.createElement("button");
deleteBtn.innerText = "Delete Item";
deleteBtn.setAttribute("class", "food-item-delete-btn");
deleteBtn.addEventListener("click", (e) => {
e.preventDefault();
API.deleteFoodItem(parseInt(e.target.parentElement.id))
});
let linkBreak = document.createElement("br");
foodItemCard.appendChild(deleteBtn);
foodItemsContainer.appendChild(foodItemCard);
fridge.getElementsByTagName("form")[0].reset();
fridge.getElementsByTagName("form")[0].style.display="none";
};
Tying It All Together
As of this writing, my project has achieved minimum-viable-product status. The backend is handled by Rails and works with a JS frontend to pass along JSON data via AJAX programming.
My frontend utilizes object-oriented programming to better represent the data coming from the Rails API, lending to client-server communication.
Conclusion
There are a ton of features and other cool things I'd like to do with this app. This is honestly the first application I've built where I confidently see it growing and blossoming over time. After this project, I'm inspired to go back to previous projects, review them with a new perspective and try to improve them. Every great application that we know and love (and sometimes hate) started as a "minimum-viable-product".
If there's anything that I would take away from this project, it's that no matter how anxious I may get before starting, once I starts, it's gets better from there. I'm slowly learning to get comfortable with being uncomfortable; it's a skill in and of itself. But it's those moments of discomfort that will allow me to grow and become a great developer.
Happy Coding!
Resources
Arctic Color Palette
AJAX (MDN)
Mono- or Multi-repo?
Using Rails for API-only Applications