A Return To Familiar Waters: How I Created a Single-Page App using JS + Rails (UPDATED)

refrigerator-3646826_1280.png

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:

  1. 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.
  2. 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

Comments (1)

Daily Dev Tips's photo

Very nice man! perhaps post some more details on the actual functions? Never experienced Rails first hand, but I think it comes close to Laravel these days.