For my brother’s nano-brewery, I wanted to build a simple, single-page application in which he could create, display, update, and delete content via a database. He also needed to be able easily upload images. I found a few tutorials on building a Rails-backend API with React-frontend, but at the time of this article, I didn’t yet know much about React (or Angular for that matter) so we just went with Rails and Bootstrap.

For the content managememt I went with Active Admin, and Active Storage for images.

Building a simple Ruby on Rails single page app with Active Admin and Active Storage

Step 1. Generate a Ruby on Rails application, sans the boilerplate test directory (I prefer RSpec). Start the local server and verify it works on http://localhost:3000/

$ rails new SPA -T
$ cd SPA
$ rails s

Welcome to Rails!

Yay! We’re on Rails version 5.2.3, Ruby version 2.6.3

Step 2. Setup dependencies.

$ vim Gemfile

We’re going to do some alterations here. First, since we might want deploy this app to Heroku we’ll need to use PostgreSQL as our production database.

group :production do
  gem 'pg'
end

In development and test we’re going to do the following…

group :development, :test do
  gem 'sqlite3'
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'shoulda-matchers'
  gem 'faker'
  gem 'database_cleaner'
end

Lastly, setup dependencies for Active Admin and Active Storage.

# Active Admin
gem 'activeadmin'
gem 'devise'
#Use ActiveStorage variant
gem 'mini_magick', '~> 4.8'

The Gemfile should look something like this…

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.6.3'

gem 'rails', '~> 5.2.3'
gem 'puma', '~> 3.11'
gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.2'
gem 'turbolinks', '~> 5'
gem 'jbuilder', '~> 2.5'

# Active Admin
gem 'activeadmin'
gem 'devise'
#Use ActiveStorage variant
gem 'mini_magick', '~> 4.8'

gem 'bootsnap', '>= 1.1.0', require: false

group :development, :test do
  gem 'sqlite3'
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'shoulda-matchers'
  gem 'faker'
  gem 'database_cleaner'
end

group :development do
  gem 'web-console', '>= 3.3.0'
  gem 'listen', '>= 3.0.5', '< 3.2'
  # gem 'spring'
  # gem 'spring-watcher-listen', '~> 2.0.0'
end

group :production do
  gem 'pg'
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

Install dependencies. You should see a lot of installation activity, culminating with a message similar to this.

$ bundle install
...
...
...
Bundle complete! 22 Gemfile dependencies, 100 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

Step 3. Cofigure the test suite.

Generate RSpec.

$ rails g rspec:install
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

Configure the test suite to use factory_bot_rails

  • Create and edit a support file.
$ mkdir spec/support
$ touch spec/support/factory_bot.rb
$ vim spec/support/factory_bot.rb
  • Include configuration.
RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end
  • Require this file in rails_helper.rb
require 'factory_bot'

Configure the test suite to use shoulda-matchers

  • Place this code at the bottom of rails_helper.rb
Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end

Optional. Update .rspec format.

$ vim .rspec

Mine looks like this…

--require spec_helper --format documentation

Run RSpec (making sure it’s not broken).

$ rspec
No examples found.


Finished in 0.00045 seconds (files took 0.14884 seconds to load)
0 examples, 0 failures

Step 4. Set our single page.

Generate the Controller. Notice the test specs being generated.

$ rails g controller page home
      create  app/controllers/page_controller.rb
       route  get 'page/home'
      invoke  erb
      create    app/views/page
      create    app/views/page/home.html.erb
      invoke  rspec
      create    spec/controllers/page_controller_spec.rb
      create    spec/views/page
      create    spec/views/page/home.html.erb_spec.rb
      invoke  helper
      create    app/helpers/page_helper.rb
      invoke    rspec
      create      spec/helpers/page_helper_spec.rb
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/page.coffee
      invoke    scss

Set the route

$ vim config/routes

Our config/routes file should look something like this…

Rails.application.routes.draw do
  root 'page#home'
end

Start the local server and verify it works on http://localhost:3000/

boilerplate Page#home

Go ahead an remove view and page_helper specs, I not going to use these for this application.

$ rm -rf spec/views
$ rm spec/helpers/page_helper_spec.rb

Since we have no database schema, now would be a good time migrate.

$ rails db:migrate

Verify tests are green.

$ rspec

first test

Step 5. Install Active Admin.

$ rails g active_admin:install
      invoke  devise
    generate    devise:install
      create    config/initializers/devise.rb
      create    config/locales/devise.en.yml
  ===============================================================================

Some setup you must do manually if you haven't yet:

  1. Ensure you have defined default url options in your environments files. Here
     is an example of default_url_options appropriate for a development environment
     in config/environments/development.rb:

       config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

     In production, :host should be set to the actual host of your application.

  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root to: "home#index"

  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

  4. You can copy Devise views (for customization) to your app by running:

       rails g devise:views

===============================================================================
      invoke    active_record
      create      db/migrate/20190811205449_devise_create_admin_users.rb
      create      app/models/admin_user.rb
      invoke      rspec
      create        spec/models/admin_user_spec.rb
      invoke        factory_bot
      create          spec/factories/admin_users.rb
      insert      app/models/admin_user.rb
       route    devise_for :admin_users
        gsub    app/models/admin_user.rb
        gsub    config/routes.rb
      append    db/seeds.rb
      create  config/initializers/active_admin.rb
      create  app/admin
      create  app/admin/dashboard.rb
      create  app/admin/admin_users.rb
      insert  config/routes.rb
    generate  active_admin:assets
      create  app/assets/javascripts/active_admin.js
      create  app/assets/stylesheets/active_admin.scss
      create  db/migrate/20190811205453_create_active_admin_comments.rb

Migrate and seed the database.

$ rails db:migrate db:seed
== 20190811205449 DeviseCreateAdminUsers: migrating ===========================
-- create_table(:admin_users)
   -> 0.0017s
-- add_index(:admin_users, :email, {:unique=>true})
   -> 0.0008s
-- add_index(:admin_users, :reset_password_token, {:unique=>true})
   -> 0.0008s
== 20190811205449 DeviseCreateAdminUsers: migrated (0.0039s) ==================

== 20190811205453 CreateActiveAdminComments: migrating ========================
-- create_table(:active_admin_comments)
   -> 0.0030s
-- add_index(:active_admin_comments, [:namespace])
   -> 0.0010s
== 20190811205453 CreateActiveAdminComments: migrated (0.0046s) ===============

Add an admin_user spec.

$ vim spec/models/admin_user_spec.rb

Should look something like this…

require 'rails_helper'

RSpec.describe AdminUser, type: :model do
  it { should validate_presence_of(:email) }
  it { should validate_presence_of(:password) }
end

Verify tests are green.

$ rspec

second test

Verify we can log in to our new Active Admin Content Management System.

  • User: admin@example.com
  • Password: password
$ rails s

active admin login page

active admin console

Step 6. Generate Beer Model.

$ rails g model beer name:string style:string ibu:string abv:string description:text
      invoke  active_record
      create    db/migrate/20190813025408_create_beers.rb
      create    app/models/beer.rb
      invoke    rspec
      create      spec/models/beer_spec.rb
      invoke      factory_bot
      create        spec/factories/beers.rb

Create an Active Admin resource for Beer.

$ rails g active_admin:resource Beer
      create  app/admin/beers.rb

Write some model tests.

$ vim spec/models/beer_spec.rb

Should look something like this…

require 'rails_helper'

RSpec.describe Beer, type: :model do
  it { should validate_presence_of(:name) }
  it { should validate_presence_of(:style) }
  it { should validate_presence_of(:ibu) }
  it { should validate_presence_of(:abv) }
  it { should validate_presence_of(:description) }
end

Migrate the database.

$ rails db:migrate
== 20190811205453 CreateActiveAdminComments: migrating ========================
-- create_table(:active_admin_comments)
   -> 0.0030s
-- add_index(:active_admin_comments, [:namespace])
   -> 0.0009s
== 20190811205453 CreateActiveAdminComments: migrated (0.0043s) ===============

== 20190813025408 CreateBeers: migrating ======================================
-- create_table(:beers)
   -> 0.0009s
== 20190813025408 CreateBeers: migrated (0.0010s) =============================

Run tests…

$ rspec

Failed examples:

rspec ./spec/models/beer_spec.rb:4 # Beer should validate that :name cannot be empty/falsy
rspec ./spec/models/beer_spec.rb:5 # Beer should validate that :style cannot be empty/falsy
rspec ./spec/models/beer_spec.rb:6 # Beer should validate that :ibu cannot be empty/falsy
rspec ./spec/models/beer_spec.rb:7 # Beer should validate that :abv cannot be empty/falsy
rspec ./spec/models/beer_spec.rb:8 # Beer should validate that :description cannot be empty/falsy

Set Active Record validations in the Beer Model, and the ability to add images

$ vim app/models/beer.rb

Should look something like this…

class Beer < ApplicationRecord
  validates :name, presence: true
  validates :style, presence: true
  validates :ibu, presence: true
  validates :abv, presence: true
  validates :description, presence: true
  has_one_attached :image
end

Now tests are green.

test after beer model

Start the local server and verify we have the option to add beers http://localhost:3000/

$ rails s

option to add beers

Cool, let’s try it.

Whoops!

ActiveRecord::StatementInvalid in Admin::Beers#index

ActiveRecord::StatementInvalid in Admin::Beers#index

We need to update permitted parameters.

$ vim app/admin/beers.rb

Should look something like this…

ActiveAdmin.register Beer do
  permit_params :name, :style, :abv, :ibu, :description, :image

  form do |f|
    f.semantic_errors # shows errors on :base
    f.inputs do
      f.input :name
      f.input :style
      f.input :ibu
      f.input :abv
      f.input :description
      f.input :image, as: :file
      f.actions
    end
  end

  show do
    attributes_table do
      row :name
      row :style
      row :ibu
      row :abv
      row :description
      row :image do |ad|
        image_tag url_for(ad.image)
      end
      active_admin_comments
    end
  end
end

And we need to install Install Active Storage

$ rails active_storage:install
Copied migration 20190813042929_create_active_storage_tables.active_storage.rb from active_storage

Migrate the database.

$ rails db:migrate
== 20190813042929 CreateActiveStorageTables: migrating ========================
-- create_table(:active_storage_blobs)
   -> 0.0021s
-- create_table(:active_storage_attachments)
   -> 0.0020s
== 20190813042929 CreateActiveStorageTables: migrated (0.0046s) ===============

Setup the homepage skeleton.

$ vim app/views/page/home.html.erb

Something like this…

<h1>Page#home</h1>
<p>Find me in app/views/page/home.html.erb</p>

<h2>Here are some Beers!</h2>
<% @beers = Beer.all %>
<% @beers.each do |beer| %>
  <h3><%= beer.name %> | <%= beer.style %></h3>
  <p>ABV: <%= beer.abv %> IBU: <%= beer.ibu %></p>
  <h4><%= beer.description %></h4>
  <p><%= image_tag beer.image.variant(resize: "100x50") %></p>
<% end %>

Step 7. Start adding beers.

Schaefer

Hamms