Building a Rails dashboard app with the DoneDone API

Last week, I talked about my experiences using DoneDone to manage household issues. To accompany my post, I wrote a simple Rails application to query data from the DoneDone API and display it on a publicly-accessible dashboard. Today, I'd like to walk through the process of building that application from the ground up.

If you'd like to see the end result, you can see the source code on GitHub. Otherwise, follow along to recreate the demo step-by-step.

Setting up your environment

First, make sure you have Ruby, RubyGems, and Rails installed. You might be interested in using RailsInstaller or RVM to simplify this process on your system.

For this demo, I'm using Ruby 2.1.1 and Rails 4.2.0.

Creating a new Rails project

Using your Terminal or Command Prompt, navigate to the directory where you'd like to store your project, then run:

rails new donedone-public-dashboard -T

Here, we're indicating that we don't want Rails to install a test framework inside our app with the -T modifier. This is simply to keep our directory a bit cleaner, as we won't be writing tests for this short demo.

Next, switch into your new donedone-public-dashboard directory for the remainder of the demo.

Creating models

Although we'll be retrieving all of our data from the DoneDone API, we don't want to do this every time a user visits our dashboard. Instead, we want to download all our data from DoneDone behind the scenes and store it in our local database. Our application will then display the local data, saving us from making unnecessary requests from DoneDone.

But first, we'll need to create models to hold all this data. Our models will closely follow the structure of the data that's returned by the DoneDone API methods. Enter this in your console:

rails generate model Project project_id:integer title:string

This command creates a database table named projects with two columns: project_id (which stores integers) and title (which stores strings). This also creates an ActiveRecord class named Project, which you can find in /app/models/project.rb. This class allows you to interact with the database records very easily using Ruby code.

Note: Why didn't we simply call the first column id? Well, Rails automatically gives every database table a column named id, which it uses to internally lookup records. But, since the projects we retrieve from DoneDone will have their own ID values, we need another column to keep track of these. You can think of the id column as the local ID, and the project_id column as the DoneDone ID.

Now let's create a model to store issues:

rails g model Issue project:references order_number:integer title description created_on:datetime last_updated_on:datetime last_updater tester fixer creator priority status due_date:date

Here, we've used a few shortcuts to make the command less wordy. The generate keyword can be shortened to g, and any columns that don't specify a data type will automatically be created as string types.

Notice that the project column uses a references type - this will create a foreign key relationship between projects (the parent table) and issues (the child table).

Finally, let's generate a model definition for issue history data, so we can view how each issue has changed over time.

rails g model History issue:references history_id:integer created_on:datetime action description creator

To recap, each Project can have multiple Issues, and each Issue can have multiple Histories.

Now that we've finished our model generation, we need to actually create these tables in our database. Each time we've used the rails generate model command, Rails has created a small piece of database migration code, which you can view in the /db/migrate directory. We need to run all these migration methods to synchronize our schema to our database:

rake db:migrate

Improving relationships

Now that our models have been created (both in code and in our database), we need to give Rails a bit more information about how our models are related to each other.

We used the references data type to indicate that some of our models are related to others. But, we need to specify exactly how these relationships should be enforced. Open /app/models/issue.rb and you'll see this:

class Issue < ActiveRecord::Base
belongs_to :project
end

The belongs_to line indicates that each Issue record belongs to a Project record. But we still need to tell Rails that each Issue can have its own History records. Change your model declaration to look like this:

class Issue < ActiveRecord::Base
belongs_to :project
has_many :histories
end

Now Rails knows each Issue belongs to a Project, but can also have many History items.

Update the remaining models with their appropriate relationships:

/app/models/history.rb:

class History < ActiveRecord::Base
belongs_to :issue
end

/app/models/project.rb:

class Project < ActiveRecord::Base
has_many :issues
end

Connecting to the DoneDone API

Now that our models and relationships are ready, we can retrieve remote data from DoneDone and populate our local database. To get our data, we'll be using a Ruby gem called Faraday.

Open /Gemfile and add the following line to the bottom of the file:

gem 'faraday'

Save your Gemfile, then return to your console window and run this command:

bundle install

This will download the faraday gem and install it for use with your app. Faraday gives you an easy way to interact with remote APIs, just like DoneDone's REST API.

Next, we need to define a Ruby class that will retrieve data from the DoneDone API and store it in our local database. Create a new file named /app/services/done_done_api.rb. Place the following code in the new file:

class DoneDoneApi
 def self.conn
   connection = Faraday.new(:url => 'DONEDONE_API_BASE_URL')
   connection.basic_auth('DONEDONE_API_USERNAME', 'DONEDONE_API_TOKEN')
   connection
 end
 def self.sync_projects
   response = conn.get '/issuetracker/api/v2/projects.json'
   if response.body
     api_projects = JSON.parse(response.body)
     api_projects.each do |api_project|
       p = Project.find_or_initialize_by(project_id: api_project['id'])
       p.title = api_project['title']
       p.save
     end
   end
 end
 def self.sync_issues
   issues_response = conn.get '/issuetracker/api/v2/issues/all.json'
   if issues_response.body
     api_issues = JSON.parse(issues_response.body)['issues']
     api_issues.each do |api_issue|
       project_id = api_issue['project']['id']
       order_number = api_issue['order_number']
       created_on = convert_to_datetime(api_issue['created_on'])
       last_updated_on = convert_to_datetime(api_issue['last_updated_on'])
       last_updater = api_issue['last_updater']['name']
       p = Project.find_by(project_id: project_id)
       sync_issue_detail(p.id, project_id, order_number, created_on, last_updated_on, last_updater)
     end
   end
 end
 private
   def self.sync_issue_detail(local_project_id, project_id, order_number, created_on, last_updated_on, last_updater)
     issue_response = conn.get "/issuetracker/api/v2/projects/#{project_id}/issues/#{order_number}.json"
     if issue_response.body
       api_issue_detail = JSON.parse(issue_response.body)
       i = Issue.find_or_initialize_by(project_id: local_project_id, order_number: api_issue_detail['order_number'])
       i.title = api_issue_detail['title']
       i.description = api_issue_detail['description']
       i.status = api_issue_detail['status']['name']
       i.priority = api_issue_detail['priority']['name']
       i.fixer = api_issue_detail['fixer']['name']
       i.tester = api_issue_detail['tester']['name']
       i.creator = api_issue_detail['creator']['name']
       i.created_on = created_on
       i.last_updated_on = last_updated_on
       i.last_updater = last_updater
       if api_issue_detail['due_date']
         i.due_date = convert_to_date(api_issue_detail['due_date'])
       end
       i.save
       sync_issue_histories(i, api_issue_detail['histories'])
     end
   end
   def self.sync_issue_histories(issue, histories)
     histories.each do |api_history|
       h = History.find_or_initialize_by(issue: issue, history_id: api_history['id'])
       h.created_on = convert_to_datetime(api_history['created_on'])
       h.action = api_history['action']
       h.description = api_history['description']
       h.creator = api_history['creator']['name']
       h.save
     end
   end
   def self.convert_to_date(raw_val)
     num = raw_val.gsub(/[^0-9]/, '').to_f
     Time.at(num / 1000).to_date
   end
   def self.convert_to_datetime(raw_val)
     num = raw_val.gsub(/[^0-9]/, '').to_f
     Time.at(num / 1000).to_datetime
   end
end

This code may look a bit overwhelming, so let's step through what each method does:

The self.conn method creates a new Faraday connection, using the URL of your DoneDone account. It also uses your DoneDone username and API token to authenticate each request. Replace the placeholders here with your appropriate DoneDone values. Tip: Use ENV variables to store this data on a production environment.

The self.sync_projects and self.sync_issues methods simply use our connection function to request a URL, parse the data that's returned, and insert or update the data in our local database. self.sync_issues also uses a few private methods (defined at the bottom of the class) to loop through issues and their histories.

Since our service class lives in the /app folder, we can use it anywhere in our application's codebase. So, let's make it easy to execute our API functions directly from our model objects.

In /app/models/project.rb, add the following method just above the last end keyword:

def self.sync_from_api
DoneDoneApi.sync_projects
end

Now we can call Project.sync_from_api from anywhere in our application, and Rails will use our service method to connect to the API, download data, and populate our database. Easy! Let's do the same for Issues. In /app/models/issue.rb, add the following method just above the last end keyword:

def self.sync_from_api
DoneDoneApi.sync_issues
end

Now we can call Issue.sync_from_api to synchronize our issues.

Adding controllers and views

We've spent some time creating our back-end logic, but we still have no way to actually view any data in our app. Let's add some controllers and views so we can actually see our data.

Back in your console, run this command:

rails g controller issues index show

This creates a controller named issues_controller, which has two action methods: index and show. When a user visits our index URL, they'll see a list of all our issues. When a user clicks on a specific issue, they'll view its detail via the show URL.

Since the controller actions are responsible for preparing data for display to the user, let's update these methods in preparation for our views:

/controllers/issues_controller.rb:

class IssuesController < ApplicationController
 def index
   @issues = Issue.order(last_updated_on: :desc)
 end
 def show
   @issue = Issue.find(params[:id])
   @histories = @issue.histories.order(:created_on)
 end
end

The index method simply returns all Issues in our database, ordered by their last_updated_on date. The show method looks up a specific issue specified by the :id parameter (which will be part of the URL), and also retrieves its history data.

Each controller action also created its own view - this is where we'll write HTML to display our data. Let's update these:

/app/views/issues/index.html.erb:

<% if @issues.any? %>
<h2>Issues</h2>
<table class="table table-striped">
<thead>
<tr>
<th>Project</th>
<th>Priority</th>
<th>Title</th>
<th>Status</th>
<th>Fixer</th>
<th>Tester</th>
</tr>
</thead>
<tbody>
<% @issues.each do |issue| %>
<tr>
<td><%= issue.project.title %></td>
<td><span class="label label-<%= issue.priority.downcase %>"><%= issue.priority %></span></td>
<td><strong><%= link_to '#' + issue.order_number.to_s + ': ' + issue.title, issue %></strong></td>
<td><%= issue.status %></td>
<td><%= issue.tester %></td>
<td><%= issue.fixer %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>

/app/views/issues/show.html.erb:

<div class="row issue-detail">
<div class="col-md-8">
<ul class="list-inline">
<li>#<%= @issue.order_number %></li>
<li><span class="label label-<%= @issue.priority.downcase %>"><%= @issue.priority %></span></li>
<li><%= @issue.status %></li>
<% if @issue.due_date %>
<li>Due <%= @issue.due_date %></li>
<% end %>
</ul>
<h1><%= @issue.title %></h1>
<hr />
<p><%= @issue.description.html_safe %></p>
<hr />
<% @histories.each do |h| %>
<div class="panel panel-default">
<div class="panel-heading">
<h6><%= h.created_on %></h6>
<h4><%= h.action %></h4>
</div>
<div class="panel-body">
<%= h.description.html_safe %>
</div>
</div>
<% end %>
</div>
<div class="col-md-4">
<table class="table">
<tbody>
<tr>
<th>Project</th>
<td><%= @issue.project.title %></td>
</tr>
<tr>
<th>Fixer</th>
<td><%= @issue.fixer %></td>
</tr>
<tr>
<th>Tester</th>
<td><%= @issue.tester %></td>
</tr>
<tr>
<th>Created</th>
<td><%= @issue.created_on %><br />by <%= @issue.creator %></td>
</tr>
<tr>
<th>Last Updated</th>
<td><%= @issue.last_updated_on %><br />by <%= @issue.last_updater %></td>
</tr>
</tbody>
</table>
</div>
</div>

Finally, let's make our view look a bit nicer by including the Bootstrap CSS framework. Add the following to the bottom of /Gemfile:

gem 'twitter-bootstrap-rails'

Then run bundle install. Next, open /app/views/layouts/application.html.erb and replace the <%= yield %> line with the following:

<div class="container">
<div class="row">
<div class="col-md-12">
<%= yield %>
</div>
</div>
</div>

Next, add the following CSS content (you'll need to create the first file):

/app/assets/stylesheets/bootstrap_and_overrides.css:

/*
 =require twitter-bootstrap-static/bootstrap
 Use Font Awesome icons (default)
 To use Glyphicons sprites instead of Font Awesome, replace with "require twitter-bootstrap-static/sprites"
 =require twitter-bootstrap-static/fontawesome
*/

/app/assets/stylesheets/issues.scss:

.label-low {
 background: #6da79b;
}
.label-medium {
 background: #f39f09;
}
.label-high {
 background: #f26f21;
}
.label-critical {
 background: #952715;
}
.issue-detail {
 margin-top: 20px;
}
img {
 max-width: 100%;
}
.panel-heading h6 {
 margin-top: 0;
}
.panel-heading h4 {
 margin-bottom: 0;
}
.panel-body p:last-child {
 margin-bottom: 0;
}

This will give all our content a nice, simple layout.

Let's also generate a quick URL for manually synchronizing our issues. We'll then be able to visit this URL anytime we want to refresh data from the API:

rails g controller sync index

And in /app/controllers/sync_controller:

class SyncController < ApplicationController
 def index
   Project.sync_from_api
   Issue.sync_from_api
   redirect_to root_url
 end
end

This performs our sync functions, then redirects to the application root URL. Speaking of which, let's update our routes so that the root URL simply displays the list of issues. Open /config/routes.rb and update it to this:

Rails.application.routes.draw do
root 'issues#index'
resources :issues, only: [:show]
resources :sync, only: [:index]
end

This sets our Issue list view as the root URL. Finally, we're ready to go! In your console, run the following:

rails server

This will start a local web server at http://localhost:3000 - open that up in your web browser.

You'll see an empty page, but that's OK! We haven't synchronized our data from the API yet. Just go to http://localhost:3000/sync to populate your local data. Now you should see all your issues!

Note: In a production environment, we would typically create a recurring task to call the sync methods every few minutes, instead of triggering them manually.

Wrapping up

While this demo has covered quite a bit of ground, it's only meant to serve as a quick introduction to querying RESTful APIs with Rails. If you'd like to learn more, be sure to check out the following links:

More from our blog...

We're on a quest to build the best issue tracking, help desk, and project management tool for all kinds of teams.

Subscribe to news and updates and follow us on Twitter.
We will never share your email address with third parties.

Give DoneDone a try today!

No credit card needed. Just sign up for a free trial, invite your team, and start getting things done with DoneDone.