Admin dashboard in Rails with Devise and Bootstrap

1 November 2015

The existing solutions like RailsAdmin and ActiveAdmin offer too many features: templates, export, etc. That introduces many unnecessary dependencies. In this article, I will build a simple admin dashboard using Devise and Bootstrap.

Features

We aim to build a simple and good-looking admin dashboard without making our structure and design too complex.

The basic features that we will be implementing:

  1. Single-user system: no registrations. New admins can be added via the admin interface
  2. Bootstrap views: Bootstrap adds minimal styles to the user interface
  3. Pagination: show only a limited number of records. Show the page switcher at the bottom

Setting up the project

We will build a blog app with only two resources: posts and categories.

$ rails new blog
$ cd blog

Generating models

Next, we'll have to scaffold our models and run migrations:

$ rails generate scaffold category name
$ rails generate scaffold post title content:text category:belongs_to
$ rake db:migrate

I will add some basic validations:

# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :category

  validates :title, presence: true
  validates :content, presence: true
  validates :category, presence: true
end
# app/models/category.rb
class Category < ActiveRecord::Base
  validates :name, presence: true
end

Devise

Installation

Then, we will add Devise to Gemfile:

# Gemfile
gem 'devise'

And install it:

$ bundle install
$ rails generate devise:install

Let's point our root path to the posts controller:

# config/routes.rb
root 'posts#index'

Then, generate Admin model and run migrations:

$ rails generate devise Admin
$ rake db:migrate

These commands will create admins table in our database. You can also modify columns in the generated migration.

Configuration

If you open the admin model, you'll see enabled Devise modules:

class Admin < ActiveRecord::Base
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable
end

All we need to do here is remove the :registerable module to prevent all undesirable signups.

class Admin < ActiveRecord::Base
  devise :database_authenticatable, :recoverable, :rememberable,
         :trackable, :validatable
end

From now on, Devise will hide all signup views and routes.

Because we will have resources in both the user and admin areas, we'll have to namespace admin areas. To do this, we need to pass additional params to a Devise route:

# config/routes.rb
devise_for :admins, path: 'admin', skip: :registrations

Admin area will be namespaced in URLs: /admin/sign_in, /admin/posts. This is how our sign in page looks like:

Admin sign in page

Admin resources

Now we can add categories and posts to the dashboard. To do that, we'll create namespaced controllers to separate them from previously scaffolded controllers.

We can use the default generator. I prefer to skip JSON formatters in controllers for the admin area, and to do that we need to comment out the jbuilder gem from the Gemfile. We can uncomment it later.

# Gemfile
# gem 'jbuilder', '~> 2.0'
$ bundle install

We already have models for posts and categories. We can skip models when generating resources by using scaffold_controller generator that will create controllers and views only.

$ rails generate scaffold_controller admin/posts

Below is the output of the scaffold controller generator. We can see that it hasn't generated models and migrations:

create  app/controllers/admin/posts_controller.rb
invoke  erb
create    app/views/admin/posts
create    app/views/admin/posts/index.html.erb
create    app/views/admin/posts/edit.html.erb
create    app/views/admin/posts/show.html.erb
create    app/views/admin/posts/new.html.erb
create    app/views/admin/posts/_form.html.erb

The are a few things that we need to change in these controllers:

  1. Remove namespace for models: Admin::Post to Post and the same for categories
  2. Change redirect statements: redirect_to [:admin, @admin_post]. Without it, admins would have been redirected outside the dashboard
  3. Don't forget to update strong parameters:
def admin_post_params
  params.require(:post).permit(:title, :content, :category_id)
end

Now we can add admin resources to the routes and map /admin to some pages like the posts index.

# config/routes.rb
namespace :admin do
  resources :posts
  resources :categories
end

get 'admin' => 'admin/posts#index'

Let's also generate controllers and views for categories:

$ rails g scaffold_controller admin/categories

At this moment, all admin views will look the same as previously generated ones. It is time to run our server and see all the changes we've made:

$ rails server

List of posts

Authentication

Currently, everyone can visit admin area, so let's enforce authentication for dashboard. It's a good idea to create AdminController that all other admin controllers will inherit:

# app/controllers/admin/admin_controller.rb
class Admin::AdminController < ActionController::Base
  before_action :authenticate_admin!
  layout 'admin'
end

Inherit all admin controllers from Admin::AdminController:

class Admin::PostsController < Admin::AdminController
end

class Admin::CategoriesController < Admin::AdminController
end

All actions in admin controllers will require authentication. authenticate_admin! is a Devise method that will check if the current user is logged in and redirect to the sign-in page otherwise.

Sign in page

Views

The new post form looks like this:

New post form

We'll add fields to our form and make it look good with Bootstrap.

Bootstrap

Add Bootstrap to the Gemfile and run bundle install.

# Gemfile
gem 'bootstrap-sass'

Next, we need to apply Bootstrap to our layouts. Bootstrap should be imported before all other CSS assets.

$ cd app/assets/stylesheets
$ mv application.css application.scss

We will remove require self and require_tree . statements and import Bootstrap via SCSS's @import statement. Bootstrap can work properly only with SCSS @import statements.

Do not use \*= require in Sass, or your other stylesheets will not be able to access the Bootstrap mixins or variables.

// app/assets/stylesheets/application.scss
@import 'bootstrap-sprockets';
@import 'bootstrap';

Now let's import Bootstrap in JavaScript, it should be imported after jQuery:

// app/assets/javascripts/application.js
//= require jquery
//= require jquery_ujs
//= require bootstrap-sprockets
//= require turbolinks
//= require_tree .

We can verify that everything was installed correctly by visiting our admin posts page again. To see the changes, we need to restart the web server.

Admin sign in page with Bootstrap

Let's create the first admin in the seeds so that it exists after we set up the database:

# config/db/seeds.rb
Admin.create!(email: '[email protected]', password: 'password')

Now, seed the database:

$ rake db:seed

We can now log in to the admin area with this admin account:

Signed in successfully

We want to keep our admin and application layouts separated. Admin dashboard will have its own design and will not be related to the main layout. We will create a layout file and tell AdminController to use the new layout.

<!-- app/views/layouts/admin.erb -->
<!DOCTYPE html>
<html>
<head>
  <title>Blog Admin</title>

  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track' => true %>
  <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
  <%= csrf_meta_tags %>
</head>
<body>

<%= render 'layouts/header' %>

<div class="container">
  <% flash.each do |key, value| %>
    <div class="alert alert-<%= key %>">
      <%= value %>
    </div>
  <% end %>

  <%= yield %>
</div>

</body>
</html>

Header partial:

<!-- app/views/layouts/_header.erb -->
<nav class="navbar navbar-default navbar-static-top">
  <div class="container-fluid">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <%= link_to 'Admin', admin_path, class: 'navbar-brand' %>
    </div>

    <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
      <ul class="nav navbar-nav">
        <li class="dropdown">
          <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Sections <span class="caret"></span></a>
          <ul class="dropdown-menu">
            <li><%= link_to 'Posts', admin_posts_path %></li>
            <li><%= link_to 'Categories', admin_categories_path %></li>
          </ul>
        </li>
      </ul>
      <ul class="nav navbar-nav navbar-right">
        <% if admin_signed_in? %>
          <li><%= link_to 'Sign out', destroy_admin_session_path, method: :delete %></li>
        <% else %>
          <li><%= link_to 'Sign in', new_admin_session_path %></li>
        <% end %>
      </ul>
    </div>
  </div>
</nav>

This layout includes meta tags required by Bootstrap to preserve compatibility and enable mobile device responsiveness. It is also essential to have a container for flash messages; this container uses alert Bootstrap class. I have also added code to custom.scss for flash alerts to match Bootstrap classes: notices will have alert-info class.

// app/assets/stylesheets/custom.scss
// Alerts
.alert-notice {
  @extend .alert-info !optional;
}

.alert-alert {
  @extend .alert-danger !optional;
}

Import new styles in the main file:

// app/assets/stylesheets/application.scss
@import 'bootstrap-sprockets';
@import 'bootstrap';
@import 'custom';

Now we can tell ApplicationController to use the new layout if in admin area:

class ApplicationController < ActionController::Base
  layout :set_layout

  private

  def set_layout
    devise_controller? ? 'admin' : false
  end
end

This is how the admin area looks at the moment:

Admin dashboard with Bootstrap

Bootstrap for Devise views

The dashboard looks better now, but Devise views still don't have Bootstrap styles. We could override Devise views and customize them as we want, but there's devise-bootstrap-views gem that does the same thing.

# Gemfile
gem 'devise-bootstrap-views'
$ bundle install

Then we need to import devise views styles. At the very bottom of application.scss before importing custom:

@import 'bootstrap-sprockets';
@import 'bootstrap';
@import 'devise_bootstrap_views';
@import 'custom';

That's it! This is what the sign in form looks like now:

Sign-in form with Bootstrap views

Customizing index

First, we need to remove <%= notice %> code from all index pages because it is already included in the layout.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>

<div class="form-group">
  <%= link_to 'New Post', new_admin_post_path, class: 'btn btn-default' %>
</div>

<table class="table">
  <thead>
    <tr>
      <th>Title</th>
      <th>Content</th>
      <th>Category</th>
      <th></th>
    </tr>
  </thead>

  <tbody>
    <% @admin_posts.each do |post| %>
      <tr>
        <td><%= link_to post.title, [:admin, post] %></td>
        <td><%= post.content %></td>
        <td><%= post.category.name %></td>
        <td>
          <%= link_to 'Edit', edit_admin_post_path(post), class: 'btn btn-default' %>
          <%= link_to 'Delete', [:admin, post], method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger' %>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

The new index page: The new index page

Next, we'll update the form. Notice that the argument for form_for method is an array, :admin specifies the controller's namespace that will receive the form data.

<!-- app/views/posts/_form.html.erb -->
<%= form_for([:admin, @admin_post]) do |f| %>
  <% if @admin_post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@admin_post.errors.count, "error") %> prohibited this user from being saved:</h2>

      <ul>
      <% @admin_post.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <%= f.label :title %>
    <%= f.text_field :title, class: 'form-control' %>
  </div>

  <div class="form-group">
    <%= f.label :content %>
    <%= f.text_area :content, class: 'form-control' %>
  </div>

  <div class="form-group">
    <%= f.label :category %>
    <%= f.collection_select(:id, Category.all, :id, :name, {}, class: 'form-control') %>
  </div>

  <div class="form-group">
    <%= f.submit class: 'btn btn-default' %>
  </div>
<% end %>

Now let's update and clean up new.html.erb, edit.html.erb and show.html.erb. I prefer not to have Edit and Back buttons that were generated by Rails:

<!-- app/views/posts/new.html.erb -->
<h1>New Post</h1>

<%= render 'form' %>
<!-- app/views/posts/edit.html.erb -->
<h1>Editing Post</h1>

<%= render 'form' %>
<!-- app/views/posts/show.html.erb -->
<div class="form-group">
  <strong>Title:</strong> <%= @admin_post.title %>
</div>

<div class="form-group">
  <strong>Content:</strong> <%= @admin_post.content %>
</div>

<div class="form-group">
  <strong>Category:</strong> <%= @admin_post.category.name %>
</div>

The new show page

The steps for categories resource are the same. The dashboard looks much better now.

Pagination

I will use Kaminari for pagination, but you can use will_paginate as an alternative. Both gems have Bootstrap styles that can be installed with a generator.

Seeds

For testing purposes, I'll create many a lot of posts in the Rails console.

100.times do |i|
  Post.create(
    title: "Post #{i + 1}",
    content: "Lorem ipsum dolor sit amet",
    category: Category.first
  )
end

Install and configure Kaminari

# Gemfile
gem 'kaminari'
$ bundle install

Then, we need to add append .page() method to our ActiveRecord relations:

# app/controllers/admin/posts_controller.rb
def index
  @admin_posts = Post.all.page(params[:page])
end

In our views we'll add a block for pagination. This will render Kaminari views.

<!-- app/views/posts/index.html.erb -->
<%= paginate @admin_posts %>

Kaminari comes without styles. But Kaminari has themes (including one for Bootstrap) that are available via a generator. You can find the list of all themes here.

$ rails g kaminari:views bootstrap3

Pagination now has Bootstrap styles applied:

Pagination with Bootstrap

Future development

  1. I18n
  2. Search
  3. Extract into gem