Prototyping a Web App using Ruby on Rails and JavaScript

Eyal Toledano
9 min readOct 22, 2019

Checkout the project on GitHub

Over the weekend, I prototyped the initial brand interaction for Ticketkey, an event management software with a big future.

The scope of the project is to list a brand’s events, enables brands to interact with and create new events without impacting the user experience through redirects or page refreshes. The work involved in enabling this UX will then carry through to the rest of the web app.

This project relies on an internal API built on Ruby on Rails which exposes the app data as JSON objects which can be collected through the API. In this case, the JSON response is used to create JavaScript objects and prototype methods which are leveraged to populate the HTML of the web app based on page interactions.

This document outlines a code review of the app’s front-end integration, method-by-method so as to highlight the important aspects of the process used to complete this project’s requirements.

The flow of the app expects users to be in-session (registered, logged in) in order to interact with their events. When a user is logged in, they are routed to their dashboard, where all of the UX and UI lives for interacting with events.

Website Dashboard

The dashboard has two primary interactions: browsing a brand’s many events and accessing a given event’s information. The goal is to expose this information visually from the API endpoints without refreshing the page.

The Rails code for the dashboard is straightforward. Tailwind.css is used to construct the user interface:

<section class="section">
<div class="container mx-auto my-auto">
<div class="text-center" id="form-cta-group">
<%= render partial: 'shared/pages/dashboard_new_event_modal' %><%= link_to "Create a new event", nil , class: 'shadow bg-indigo-400 hover:bg-indigo-500 focus:shadow-outline focus:outline-none text-white font-bold py-4 px-8 rounded shadow-lg hover:text-white modal-open', id: "new-event-button" %><%= link_to "Browse Events", nil , class: 'shadow bg-gray-400 hover:bg-gray-500 focus:shadow-outline focus:outline-none text-grey-darker font-bold py-4 px-8 rounded shadow-lg', id: "browse-events-button", data: { id: "#{@brand.id}"} %><%= link_to "Sort Events", nil, class: 'shadow bg-gray-400 hover:bg-gray-500 focus:shadow-outline focus:outline-none text-grey-darker font-bold py-4 px-8 rounded shadow-lg', id: "sort-events-button" %>
</div>
</div>
</section>
<section class="section" id="content">
<div class="container mx-auto">
<div class="max-w-6xl w-full lg-flex mx-auto shadow">
<div class="border border-grey-light lg:border lg:border lg:border-grey-light bg-white rounded-b lg:rounded-r p-5 flex flex-col justify-between leading-normal min-w-full">
<div class="px-2">
<div class="flex flex-wrap" id="eventList">
<!-- Events will appear via AJAX -->
</div>
</div>
</div>
</div>
</div>
</section>

The first section lists the call to action buttons #browse-events-button and #new-event-button. There is also a #sort-events-button which is hidden upon page load, as there is nothing to sort. It is made visible when the #eventList div in the content section has any children.

Great, with the page structure ready to accept new data, let’s dive into the JavaScript:

First things first, we’ll do some set up upon page load. At document ready, hide the main content div (which is empty). Prevent unrecognized resource errors upon form submissions by ensuring that the Rails CSRF token is passed in as a header for any Ajax call to be made.

We’ll add the HTML and interaction listeners for a modal. We’ll listen for a ‘new event’ button click to refresh the form within it and also listen for the ‘browse events’ button click to populate the #eventList. We’ll also replace any broken images from lorem picsum with a custom missing.jpg image:

$(() => {
$('#content').hide()
$('#sort-events-button').hide()
$.ajaxSetup({
headers: {
'X-CSRF-Token': Rails.csrfToken()
}
});
$('img').on("error", function() {
$(this).attr('src', '/images/missing.jpg');
});
prepareModal();
listenBrowseEventsButtonClick();
listenForNewEventButtonClick();
listenForNewFormSubmission();
});

In the listenBrowseEventsButtonClick() function, when the browse events button is clicked, we prevent the default behaviour and then call getEvents(). If the #eventList already contains content, then empty it first before calling getEvents():

function listenBrowseEventsButtonClick() {
$('#browse-events-button').on('click', function(event) {
event.preventDefault();
// Don't repopulate the index if one already exists, but do reload it to grab any other items that may have been created not on the page.
if ($(`#eventList`).children().length == 0) {
getEvents();
} else {
$(`#eventList`).empty()
getEvents();
}
});
};

The getEvents() function first collects the brand’s id which is stored in the data-id attribute of the #browse-events-button and interpolates it into the AJAX get request pointing to the brand’s show page.

The brand’s (many) events are grabbed from the successful response. From there, it iterates over each event by triggering a callback and creating a new Evvent (named so to prevent conflict issues with javascript’s Event) object for each event in the array.

In each iteration, the custom functioneventCard() is called on the newly created Evvent, which is a prototype method that spits out the card front’s HTML with interpolated values both in the child nodes and adding a event-${this.id}-front identifier to each event card, which will be used by the functionflipOnClick(), which is called on the event. While in the iteration, each JavaScript object is also added to an independent array so that it may be sorted for the below functionality.

Additionally, the #content div is toggled to show, which shows the whole (newly populated) div with all event cards, and the #sort-events-button is also toggled to show. Under it lives a callback function that is called to sort the independent event array by the percent.sold attribute, which spits out an integer representing the percentage of tickets that have been sold for a given event.

function getEvents() {
var id = $('#browse-events-button').data("id")
$.ajax({
url: `http://localhost:3000/brands/${id}.json`,
method: 'get',
success: function(response) {
var events = response["events"]
var sorted_events = []
events.forEach(function(event) {
var classed_event = new Evvent(event)
sorted_events.push(classed_event)
$('#eventList').append(classed_event.eventCard())
flipOnClick(classed_event);
})
$('#content').show()
$('#sort-events-button').show()
sorted_events.sort(function(first_event, second_event) {
if (first_event.percent_sold < second_event.percent_sold) {
return -1
}
if (first_event.percent_sold > second_event.percent_sold) {
return 1
} else {
return 0
}
})
// For the use case involving event names composed of more than one word, we could do a join on the event name to ensure that the sort is sorting the whole word and not just the first word (and then resplit)function listenForSortEventButtonClick() {
$('#sort-events-button').on('click', function(event) {
event.preventDefault();
$('#eventList').empty()
sorted_events.forEach(function(event) {
$('#eventList').append(event.eventCard())
})
})
}
listenForSortEventButtonClick();
}
})
}

The flipOnClick() function changes the content of the clicked card with an alternative version of the card that display it’s show information. It is inserted first right after the existing card front, and then the card front is hidden. This enables the card flipping card UX to work regardless of the card position in #eventList.

function flipOnClick(classed_event) {
$(`#card-${classed_event.id}-flip`).on("click", function(event) {
event.preventDefault();
$(`#event-${classed_event.id}-front`).after(classed_event.eventCardFlipSide());
$(`#event-${classed_event.id}-front`).hide();
})
}

A constructor class is used to create JavaScript objects out of the JSON responses received from our GET request in getEvents() .

class Evvent {
constructor(object) {
this.id = object.id;
this.name = object.name;
this.category = object.category;
this.date_start = object.date_start;
this.date_end = object.date_end;
this.total_available_tickets = object.total_available_tickets;
this.total_tickets = object.total_tickets;
this.percent_sold = object.percent_sold;
}
}

We listen for a #new-event-button click and call fillNewFormDiv() on it.

The fillNewFormDiv() function lives in the _new_event_form_div.html.erb partial in the /views/ folder because Rails’render doesn’t work in the assets path, and we don’t want to move our entire JavaScript page (pages.js) to the views folder.

The _new_event_form_div.html.erb partial itself contains a #formDiv div which renders the new_event_form partial.

<div id="formDiv">
// Partial will appear here
</div>
<!-- This JS method needs to be created in the /views/ folder because render doesn't work in the assets path, and we don't want to move pages.js to the views folder. This is a good trade-off. --><script type="text/javascript">
$(() => {
function fillNewFormDiv() {
var form = "<%= j (render partial: 'shared/events/new_event_form', locals: { event: @event } ) %>"
$('#formDiv').empty()
$('form#new_event').unbind('submit');
$('#formDiv').append(form)
// listenForNewFormSubmission()
}
})
</script>

It’s good trade-off that still enables us to utilize the power of Rails partials while still doing so dynamically via JavaScript.

function listenForNewEventButtonClick() {
$('#new_event_button').on("click", function(event) {
fillNewFormDiv()
})
}

fillNewFormDiv() sets the ‘form’ variables to the rendered partial using escaped javascript. It then empties the current #formDiv and unbinds the listenForNewFormSubmission() listener connected to the form in the formDiv that was just emptied.

Finally it appends the form to the #formDiv again and creates a new listener by calling listenForNewFormSubmission()

listenForNewFormSubmission() listens for a form submission of the #new_event form. Upon submission, it prevents the default behaviour of the form. It serializes the values of the form inputs and posts a POST request to /events to create a new event with the serialized values.

When the request is done, the data returned by the response is used to instantiate a new Evvent object. The form is also reset. Note that you can’t just call .reset() on $(‘form#new_event’) because .reset() is a form method, not a jQuery method.

Once the new event is created, a check verifies if the eventList already contains children (events). If it doesn’t, getEvents() is called to populate it before prepending the newly created event to the events. Prepending unshifts the new event at the top of the array, as expected of a good user experience.

The flipOnClick() function is called on the event to set up a listener for its show information to be accessible, and the modal is then closed.

The form is not emptied here because fillNewFormDiv() deals with that, and it will be called anytime the #new-event-button is clicked, guaranteeing a fresh form every time.

function listenForNewFormSubmission() {
$('form#new_event').submit(function(event) {
var values = $(this).serialize();
var posting = $.post('/events', values);
posting.done(function(data) {
$('form#new_event').each(function() {
this.reset();
})
var newEvent = new Evvent(data);
if ($(`#eventList`).children().length == 0) {
getEvents();
} else {
$('#eventList').prepend(newEvent.eventCard());
}
flipOnClick(newEvent);
var body = document.querySelector('body')
var modal = document.querySelector('.modal')
modal.classList.toggle('opacity-0')
modal.classList.toggle('pointer-events-none')
body.classList.toggle('modal-active')
})
event.preventDefault();
});
};

We then define the two prototype methods which return the front and back of the event cards while taking care to interpolate the event attributes into their respective .

Evvent.prototype.eventCard = function() {
return `
<div class="w-1/3 my-1 index" id="event-${this.id}-front">
<div class="max-w-sm rounded overflow-hidden shadow-lg mx-2" data-event-id="${this.id}">
<a class="mx-auto my-auto" id="card-${this.id}-flip">
<div class="bg-gray-900 px-1 py-3 relative">
<p class="text-gray-200 antialiased text-xl font-bold p-2">
${this.name}
<span class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 float-right">${this.percent_sold}% sold</span>
</p>
</div>
<img class="w-full" src="https://picsum.photos/200/180?random=${this.id}&grayscale">
</a>
</div>
</div>
`
}
Evvent.prototype.eventCardFlipSide = function() {
return `
<div class="w-1/3 my-1" id="event-${this.id}-back">
<div class="max-w-sm rounded overflow-hidden shadow-lg mx-2">
<a href="/events/${this.id}" id="card-${this.id}-reflip">
<img class="w-full" src="https://picsum.photos/200/100?grayscale&random=${this.id}" alt="A placeholder image">
</a>
<div class="px-6 py-4">
<div class="font-bold text-xl mb-2"><a href="/events/${this.id}">${this.name}</a></div>
<p class="text-gray-700 text-base">
Starts on ${this.date_start}
</p>
<p class="text-gray-700 text-base">
Ends on ${this.date_end}
</p>
<p class="text-gray-700 text-base">
${this.total_available_tickets} tickets left (${this.percent_sold}% sold)
</p>
</div>
<div class="px-6 py-4">
<span class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2">#${this.category}</span>
</div>
</div>
</div>
`
}

Finally, we have some code to set up the modal interactions, namely the listeners for opening/closing, being able to close the modal with ESC key as well as clicking away from the modal, and lastly the form’s “cancel” button, which also closes the modal.

function prepareModal() {
var openmodal = document.querySelectorAll('.modal-open')
for (var i = 0; i < openmodal.length; i++) {
openmodal[i].addEventListener('click', function(event){
event.preventDefault()
toggleModal()
})
}
let overlay = document.querySelector('.modal-overlay')
overlay.addEventListener('click', toggleModal)
var closemodal = document.querySelectorAll('.modal-close')
var modalCancel = document.querySelector('.modal-cancel')
var closemodalArray = Array.from(closemodal)
closemodalArray.push(modalCancel)
for (var i = 0; i < closemodalArray.length; i++) {
closemodalArray[i].addEventListener('click', toggleModal)
}
document.onkeydown = function(evt) {
evt = evt || window.event
var isEscape = false
if ("key" in evt) {
isEscape = (evt.key === "Escape" || evt.key === "Esc")
} else {
isEscape = (evt.keyCode === 27)
}
if (isEscape && document.body.classList.contains('modal-active')) {
toggleModal()
}
};
// toggleModal() makes the modal fade away based on the above listeners.function toggleModal () {
var body = document.querySelector('body')
var modal = document.querySelector('.modal')
modal.classList.toggle('opacity-0')
modal.classList.toggle('pointer-events-none')
body.classList.toggle('modal-active')
}
}

That rounds out the code for the front-end. The resulting interactions look like this:

What you end up with after integrating the JavaScript

This was a fun, educational project that allowed to get my hands dirty getting and posting to an internal API and building a highly interactive user interface using that data without having to expose the user of the app to any refreshing or redirects.

Next project will make use of React to put most of this stuff into practice on a wider-scale. Until then, thanks for reading!

Check out the project on GitHub

--

--

Eyal Toledano

Win by helping others win. Writing about digital products, growth and engineering to help you lead a more successful life.