loading...

Making a Rails 6 app in 2020

majov84382672 profile image Majo Updated on ・26 min read

The idea of ​​this article is that someone can read it and by executing all the steps have a simple blog with:

  • Authentication with Devise
  • Readable URL for Blogs with FriendlyId
  • Platform administration panel with Active Admin
  • React component with React-Rails
    • Add CSRF token to post request
  • Soft-Delete with Discard
  • Restore feature in Active Admin
  • Rich text with ActionText for Blog content
    • Modify Trix editor styles

Introduction

I've spent the last few years making all kinds of applications, from desktop apps using Java, Python or Qt to web applications using mostly a MERN stack. Lately, the architecture of the web apps that I have been working on had a separate client and server. Which means having to develop and maintain two applications. I like this approach but a couple of months ago in my work we had to make a simple application in a short amount of time, similar to a blog, it had to be fast and simple, and I wanted to try a technology I haven't used before. For this reasons we decided not to complicate the project too much and keep a monolith architecture using Ruby on Rails integrating the React-Rails gem to render some React components.

Why I'm writing this?

Sometimes it was a bit frustrating because all the articles and tutorials I found online were over 5 years old. At some point, to find the solution to a problem, I had to dig deep online or I end up implementing workaround solutions.

In this article, I am creating a very simple blog app with the features that cause me the most trouble as a Rails newbie, such as adding the Active Admin panel, soft-deletes, modifying the Trix editor, and more.

What did I learn?

  1. Rails has very good official documentation.
  2. Some of the problems that I encounter had already been solved 5 years ago.
  3. New Rails features have little coverage online. Most articles and tutorials date more that 4 years.
  4. Questions I made on Stack Overflow and Reddit had little or no answer.
  5. Writing this article taught me that there is more information than I realized, but at that time, due to my lack of knowledge in Rails, I did not know what to look for.
Note

One thing I'm not going to be doing is adding styles. In the real project we used Tailwind CSS, which I liked but in my opinion if you are not careful the .erb files can become too clutter. One of the future refactors I have in mind would be extracting css components like this https://tailwindcss.com/docs/extracting-components.

Prerequisites

  • Ruby 2.6.6
  • Rails 6.0.3.2
  • PostgreSQL -> psql (PostgreSQL) 11.5

Create a Ruby on Rails application*

*without test and with a postgresql database

  • $ rails new PersonalBlog -T -d=postgresql

This will create lots of files.

  • $ cd PersonalBlog Screen Shot 2020-09-09 at 20.38.04
  • Start the server: $ rails server or $ rails s
    If everything is okay the app will be running on http://localhost:3000/
    Screen Shot 2020-09-09 at 20.40.21

  • Create the database: $ rails db:create

Create a Controller for static pages

For more info on Rails controllers here are some resources: https://guides.rubyonrails.org/action_controller_overview.html
https://www.tutorialspoint.com/ruby-on-rails/rails-controllers.htm

$ rails g controller Pages home about
This will create a controller without the CRUD actions. In this case the actions will be home and about actions. It will also create the routes and the views.

Routes: pages/home, pages/about

class PagesController < ApplicationController
  def home
  end

  def about
  end
end

Implement custom routes

With root to: 'pages#home' in routes.rb means the root of the application will go to pages#home.
If I add get 'about', to: 'pages#about' I re-route /pages/about to /about.

Example:

Rails.application.routes.draw do
  get "about-me", to: "pages#about"
  root to: "pages#home"
end

http://localhost:3000/
Screen Shot 2020-09-10 at 12.44.14

http://localhost:3000/about-me
Screen Shot 2020-09-10 at 12.45.24

Implement the Blog

  1. $ rails g scaffold Blog title:string body:text

Scaffold is a rails generator. For more of rails generators https://guides.rubyonrails.org/command_line.html#rails-generate

This will create

  • migration file
  • model
  • controller
  • views
  • helpers
  • default stylesheets
  • add routes to route.rb
  1. To run the migrations: $ rails db:migrate

Because this is the first time running this command it will create the schema.rb file. You shouldn't directly modify this file, it will be updated when running the migrations.

  1. To check the routes: $ rake routes Screen Shot 2020-09-09 at 20.54.44

Now if you go to http://localhost:3000/blogs you are going to see a simple UI to blogs.
Screen Shot 2020-09-09 at 20.56.28

You can create new blogs, edit the blogs, see them and destroy them.
Screen Shot 2020-09-09 at 20.57.50
Screen Shot 2020-09-09 at 20.58.22

Implementing Data Validations

In the model add validates_presence_of :title, :body. This means a blog can't be created without title and body.

Add Comments to Blog

Because I don't think we need views or a controller for comments right now, let's use the model generator.

  1. $ rails g model Comment content:text
  2. $ rails db:migrate

If you forgot and want to add blog reference to comments

  1. $ rails g migration add_blog_reference_to_comments blog:references
  2. rails db:migrate

Not only do you have to run a migration to add the connection between the tables, you have to specify the connection in the models (belongs_to and has_many)

class Blog < ApplicationRecord
  validates_presence_of :title, :body

  has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
  validates_presence_of :content

  belongs_to :blog
end

Now you can do things like this:
$ rails c
> blog = Blog.first
> blog.comments.create!(content: "First comment")
> blog.comments

 => #<ActiveRecord::Associations::CollectionProxy [#<Comment id: 1, content: "First comment", created_at: "2020-09-10 16:18:05", updated_at: "2020-09-10 16:18:05", blog_id: 1>]> 

And it will return all the comments associated to the blog.
You can also get the blog a comment is from.
> Comment.last.blog

 => #<Blog id: 1, title: "My first blog", body: "Blog body", created_at: "2020-09-09 23:57:38", updated_at: "2020-09-09 23:57:38"> 

Implementing the User

Let's create the user with the Devise gem. Fallow the documentation instructions at https://github.com/heartcombo/devise#getting-started

  • Go to https://rubygems.org/gems/devise, copy the gem version, in my case gem 'devise', '~> 4.7', '>= 4.7.2', and add it to the Gemfile.

  • $ bundle install

  • $ rails g devise:install

After fallowing the instructions you will be ready to add Devise to any of your models using the generator.

  • $ rails generate devise User
  • $ rails db:migrate

Screen Shot 2020-09-10 at 13.43.23

  • /registrations → signing up into the application
  • /sessions → Singing into the application

This will create a migration to add Users to the database, a User model and add the routes to routes.rb.

Screen Shot 2020-09-10 at 12.50.59

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
end

Now you can register users, login, logout and more.

Screen Shot 2020-09-10 at 12.51.41

Screen Shot 2020-09-10 at 13.46.16

Screen Shot 2020-09-10 at 13.46.32

Building Custom Routes for Authentication Pages with Devise

  • The default routes are:
    • users/sign_up
    • users/sign_in
    • users/sign_out

We can change that.
Changing devise_for :users for:
devise_for :users, path: "", path_names: { sign_in: "login", sign_out: "logout", sign_up: "register" }

Now to check the new routes: $ rake routes we can see that:

  • users/sign_up -> register
  • users/sign_in -> login
  • users/sign_out -> logout

Enable Users to Logout and Dynamically Render View Content

<% if current_user %>
  <%= link_to "Logout", destroy_user_session_path, method: :delete %>
<% else %>
  <%= link_to "Register", new_user_registration_path %>
  <%= link_to "Login", new_user_session_path %>
<% end %>

current_user is a helper method from devise that gives the current signed in user. You can see more Devise helpers at https://www.rubydoc.info/github/plataformatec/devise/Devise/Controllers/Helpers

How to Add Custom Attributes to a Devise Based Authentication System

What if we want to add name to the register form?

  1. Add a name column to our User table.
    $ rails g migration AddNameColumnToUsers name:string
    $ rails db:migrate

  2. Add the name field in the forms.
    In app/views/devise/registrations/edit.html.erb and app/views/devise/registrations/new.html.erb add:

  <div class="field">
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </div>
  1. Lastly, we have to permit :name

In application_controller.rb

class ApplicationController < ActionController::Base

  before_action :configure_permitted_parameters, if: :devise_controller?

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
    devise_parameter_sanitizer.permit(:account_update, keys: [:name])
  end
end

Add validation to the User model

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  validates :name, :email, presence: true
  validates :email, uniqueness: true

  validates :password, :password_confirmation, presence: true, on: :create
  validates :password, confirmation: true
end
Refactor the User Validation into a Concern

We can refactor application_controller.rb and move the devise code into a concern.

Create app/controllers/concerns/devise_whitelist.rb and put the code there:

module DeviseWhitelist
  extend ActiveSupport::Concern

  included do
    before_action :configure_permitted_parameters, if: :devise_controller?
  end

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :role])
    devise_parameter_sanitizer.permit(:account_update, keys: [:name, :role])
  end
end

Then in application_controller.rb add:
include DeviseWhitelist

Integrate Virtual Attributes to Extract First and Last Name Data from a User

In app/models/user.rb add:

  # Virtual Attributes
  def first_name
    self.name.split.first
  end

  def last_name
    self.name.split.last
  end

Add guest user

Create the model app/models/guest_user.rb:

class GuestUser < User
  attr_accessor :name, :first_name, :last_name, :email
end

In app/controllers/concerns/current_user_concern.rb:

module CurrentUserConcern
  extend ActiveSupport::Concern
  # Override devise current_user
  def current_user
    super || guest_user
  end

  def guest_user
    guest = GuestUser.new
    guest.name = "Guest User"
    guest.first_name = "Guest"
    guest.last_name = "User"
    guest.email = "guest@example.com"
    guest
  end
end

And then in application_controller.rb add
include CurrentUserConcern

Then I have to update the login/logout functionality:

<% if !current_user.is_a?(GuestUser) %>
  <%= link_to "Logout", destroy_user_session_path, method: :delete %>
<% else %>
  <%= link_to "Register", new_user_registration_path %>
  <%= link_to "Login", new_user_session_path %>
<% end %>

Add the relationship between Blog, Comments and Users.

  • $ rails g migration AddUserReferenceToComments user:references
  • $ rails db:migrate
class AddUserReferenceToComments < ActiveRecord::Migration[6.0]
  def change
    add_reference :comments, :user, null: false, foreign_key: true
  end
end
  • In app/models/comment.rb
class Comment < ApplicationRecord
  validates_presence_of :content

  belongs_to :blog
  belongs_to :user
end
  • $ rails g migration AddUserReferenceToBlogs user:references
  • $ rails db:migrate
class AddUserReferenceToBlogs < ActiveRecord::Migration[6.0]
  def change
    add_reference :blogs, :user, null: false, foreign_key: true
  end
end
  • In app/models/blog.rb
class Blog < ApplicationRecord
  validates_presence_of :title, :body

  has_many :comments, dependent: :destroy
  belongs_to :user
end
  • In app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  validates :name, :email, presence: true
  validates :email, uniqueness: true

  validates :password, :password_confirmation, presence: true, on: :create
  validates :password, confirmation: true

  has_many :blogs
  has_many :comments
end

Guest users can only see blogs.

For this we are going to user a devise method: authenticate_user!

  • In blogs_controller.rb let's add a before_action filter
class BlogsController < ApplicationController
  before_action :set_blog, only: [:show, :edit, :update, :destroy]
  before_action :authenticate_user!, except: [:index, :show]

  # GET /blogs
  # GET /blogs.json
  def index
    @blogs = Blog.all
  end

  # GET /blogs/1
  # GET /blogs/1.json
  def show
  end

  # GET /blogs/new
  def new
    @blog = Blog.new
  end

  # GET /blogs/1/edit
  def edit
  end

  # POST /blogs
  # POST /blogs.json
  def create
    @blog = Blog.new(blog_params)

    respond_to do |format|
      if @blog.save
        format.html { redirect_to @blog, notice: "Blog was successfully created." }
        format.json { render :show, status: :created, location: @blog }
      else
        format.html { render :new }
        format.json { render json: @blog.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /blogs/1
  # PATCH/PUT /blogs/1.json
  def update
    respond_to do |format|
      if @blog.update(blog_params)
        format.html { redirect_to @blog, notice: "Blog was successfully updated." }
        format.json { render :show, status: :ok, location: @blog }
      else
        format.html { render :edit }
        format.json { render json: @blog.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /blogs/1
  # DELETE /blogs/1.json
  def destroy
    @blog.destroy
    respond_to do |format|
      format.html { redirect_to blogs_url, notice: "Blog was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_blog
    @blog = Blog.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def blog_params
    params.require(:blog).permit(:title, :body)
  end
end

This means that before the actions index and show the devise method authenticate_user! will be executed.

  • We are also going to redirect login users to blogs index page
class PagesController < ApplicationController
  def home
    if !current_user.is_a?(GuestUser)
      redirect_to blogs_path
    end
  end

  def about
  end
end

Add current_user to Blog create action

  def create
    @blog = Blog.new(blog_params.merge(user_id: current_user.id))

    respond_to do |format|
      if @blog.save
        format.html { redirect_to @blog, notice: "Blog was successfully created." }
        format.json { render :show, status: :created, location: @blog }
      else
        format.html { render :new }
        format.json { render json: @blog.errors, status: :unprocessable_entity }
      end
    end
  end

Add content to seeds file, db/seeds.rb, to have some data to work with.

User.create!(email: "test@test.com",
             name: "test",
             password: "123123",
             password_confirmation: "123123") if Rails.env.development?

3.times do |blog|
  Blog.create!(
    title: "My blog #{blog}",
    body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
    user_id: 1,
  ) if Rails.env.development?
end

Blog.last.comments.create!([{ content: "This is a very good post", user_id: 1 }, 
                            { content: "I agree with all of this", user_id: 1 }, 
                            { content: "I don't agree with all of this", user_id: 1 }])

Now, every time we empty the database we can fill it executing:
$ rails db:seed.

One command that I often use is rails db:setup. This will runs db:create, db:schema:load and db:seed.

Create a controller for the comments

Before I created the model. Now I realized I also need a controller.

  1. $ rails g controller comments

  2. Open comments_controller.rb and add the create and destroy actions.

class CommentsController < ApplicationController
  before_action :set_comment, only: [:destroy]

  def create
    comment = current_user.comments.new(content: params[:newComment], blog_id: params[:blog_id])

    if comment.save
      render json: { comment: comment }, status: :ok
    else
      render json: { error: comment.errors.message }, status: 422
    end
  end

  def destroy
    if @comment.destroy
      head :no_content
    else
      render json: { error: @comment.errors.message }, status: 422
    end
  end

  private

  def set_comment
    @comment = Comment.find(params[:id])
  end

  def comment_params
    params.require(:comment).permit(:content, :blog_id)
  end
end

Add an action to blogs controller to get the comments of a blog.

  • In app/controllers/blogs_controller.rb
  def get_comments
    comments = @blog.comments.select("comments.*, users.name").joins(:user).by_created_at
    render json: { comments: comments }
  end
  • Update before_action filter
  before_action :authenticate_user!, except: [:index, :show, :get_comments]
  • Add the routes
  resources :blogs do
    resources :comments, only: [:create]
    member do
      get :get_comments
    end
  end

Create a scope to get the comments of a blog in descending order by the creation date.

In app/models/comment.rb

class Comment < ApplicationRecord
  validates_presence_of :content

  belongs_to :blog
  belongs_to :user

  def self.by_created_at
    order("created_at DESC")
  end
end

Add react-rails gem

Fallow the instructions from https://github.com/reactjs/react-rails

  1. Add gem 'react-rails', '~> 2.6', '>= 2.6.1' to Gemfile
  2. $ bundle install
  3. $ rails webpacker:install
  4. $ rails webpacker:install:react
  5. $ rails generate react:install
  6. $ rails g react:component HelloWorld greeting:string

This will create a HelloWorld component at app/javascript/components/HelloWorld.js

Now you can add the component in the view like this:

<%= react_component("HelloWorld", { greeting: "Hello from react-rails." }) %>

Implement Comments component to render the comments of a blog

I used axios. For that, go to the axios npm page, get the version and add it to package.json:

{
  "name": "PersonalBlog",
  "private": true,
  "dependencies": {
    "@babel/preset-react": "^7.10.4",
    "@rails/actioncable": "^6.0.0",
    "@rails/activestorage": "^6.0.0",
    "@rails/ujs": "^6.0.0",
    "@rails/webpacker": "4.3.0",
    "axios": "0.20.0",
    "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
    "prop-types": "^15.7.2",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react_ujs": "^2.6.1",
    "turbolinks": "^5.2.0"
  },
  "version": "0.1.0",
  "devDependencies": {
    "webpack-dev-server": "^3.11.0"
  }
}

Then run $ yarn install

Now in app/javascript/components create a file for the comments component, in my case I called it Comments.js.

Here we are going to use axios to make http request to create
new comments and get the comments of a blog.
We are going to use useEffect to fetch the comments when the component first render and every time a new comment is created.
We also are going to add the csrf token to the post request to avoid Rails error ActionController::InvalidAuthenticityToken.

Comments component

Screen Shot 2020-09-15 at 10.53.01

// Comments.js
import React, { useEffect, useState } from "react";
import axios from "axios";
import CommentForm from "./CommentForm";
import Comment from "./Comment";

export default function Comments({ user, blogId }) {
  const [comments, setComments] = useState([]);
  const [updateComments, setUpdateComments] = useState(0);
  const [newComment, setNewComment] = useState("");
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    axios
      .get(`/blogs/${blogId}/get_comments`)
      .then((data) => {
        setComments(data.data.comments);
        setLoading(true);
      })
      .catch((err) => {
        console.log(err);
      });
  }, [updateComments]);

  const handleSubmit = (e) => {
    e.preventDefault();
    var token = document.getElementsByName("csrf-token")[0].content;
    axios.defaults.headers.common["X-CSRF-TOKEN"] = token;

    axios
      .post(`/blogs/${blogId}/comments`, {
        newComment,
      })
      .then((response) => {
        console.log("Server response:", response);
        setNewComment("");
        setUpdateComments(updateComments + 1);
      })
      .catch((err) => {
        console.log("Error:", err);
      });
  };

  const commentsComp = comments.map((comment) => {
    return <Comment comment={comment} key={comment.id} />;
  });

  return (
    <div className="comments-container">
      {user.id !== null && (
        <CommentForm
          handleSubmit={handleSubmit}
          setNewComment={setNewComment}
          blogId={blogId}
        />
      )}
      <p className="comments-heading">Comments</p>
      {comments.length === 0 && <p>There are no comments!</p>}
      {loading ? commentsComp : <p>Loading</p>}
    </div>
  );
}

If you want to use fetch

  useEffect(() => {
    fetch(`/blogs/${blogId}/comments`)
      .then((res) => res.json())
      .then((data) => {
        setComments(data.comments);
        setLoading(true);
      })
      .catch((err) => {
        console.log(err);
      });
  }, [updateComments]);
// Comment.js
export default function Comment({ comment }) {
  if (!comment) {
    return <div />;
  }

  return (
    <div className="comment">
      <div className="user-name">{comment.name} | </div>
      <div className="comment-actions">{comment.content}</div>
    </div>
  );
}
// CommentForm.js
import React, { useState, useEffect } from "react";

export default function CommentForm({ handleSubmit, setNewComment }) {
  const [comment, setComment] = useState("");

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <p>Write a comment</p>

        <textarea
          maxLength="200"
          type="text"
          name="comment"
          className="comment-textarea"
          value={comment}
          onChange={(e) => {
            setComment(e.target.value);
            setNewComment(e.target.value);
          }}
        >
          Enter text here...
        </textarea>
      </div>

      <button type="submit" onClick={() => setComment("")}>
        Confirm
      </button>
    </form>
  );
}

Adding some styles to blogs.scss

.comments-container {
  padding: 2px;

  .comments-heading {
    font-size: 18px;
  }
}

.comment {
  display: flex;
  border: 1px solid grey;
  padding: 8px;
  margin: 8px 0;
  width: 400px;

  .user-name {
    margin-right: 4px;
  }
}

.comment-textarea {
  width: 400px;
  height: 100px;
  padding: 8px;
}

Some screenshots of the result so far

Screen Shot 2020-09-14 at 12.03.31
Screen Shot 2020-09-14 at 12.04.12
Screen Shot 2020-09-14 at 12.05.17
Screen Shot 2020-09-14 at 12.11.04
Screen Shot 2020-09-14 at 12.14.01

Add Active Admin gem to manage the application

https://activeadmin.info/documentation.html

  1. Add gem 'activeadmin', '~> 2.8' to the Gemfile
  2. $ bundle install
  3. $ rails generate active_admin:install
  4. $ rails db:migrate

This will create:

  • app/models/admin_user.rb
  • config/initializers/active_admin.rb
  • db/migrate/20200914152255_devise_create_admin_users.rb
  • db/migrate/20200914152304_create_active_admin_comments.rb
  • app/assets/stylesheets/active_admin.scss
  • app/assets/javascripts/active_admin.js
  • app/admin/dashboard.rb
  • app/admin/admin_users.rb

And it will add an admin user to the seed file

AdminUser.create!(email: "admin@example.com", password: "password", password_confirmation: "password") if Rails.env.development?

It's also going to add the routes to routes.rb

 devise_for :admin_users, ActiveAdmin::Devise.config
  ActiveAdmin.routes(self)

After this you will be able to access the admin dashboard:

Visit http://localhost:3000/admin and log in using:

User: admin@example.com
Password: password
Note: You may have to run the seed file to create the Admin User.

By default this is going to create an AdminUser model and a AdminUser table, different that the User table you already have.
In my case I wanted to unified those two tables. For this I based my solution on this article about how to implement a single user model with rails active admin and devise:

  • Add superuser? boolean flag to User. $ rails generate migration AddSuperadminToUser superadmin:boolean
  • Add null: false, default: false to the migration created in step 1.
class AddSuperadminToUser < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :superadmin, :boolean, null: false, default: false
  end
end
  • Configure ActiveAdmin In /initializers/active_admin.rb
config.authentication_method = :authenticate_active_admin_user!
config.current_user_method   = :current_user
config.logout_link_path      = :destroy_user_session_path
config.logout_link_method    = :delete
  • Create the method authenticate_active_admin_user! in ApplicationController
class ApplicationController < ActionController::Base
  include DeviseWhitelist
  include CurrentUserConcern

  def authenticate_active_admin_user!
    authenticate_user!
    unless current_user.superadmin?
      flash[:alert] = "Unauthorized Access!"
      redirect_to root_path
    end
  end
end
  • Cleaning up the code left behind by ActiveAdmin
    $ rails destroy model AdminUser
    $ rm ./db/migrate/*_devise_create_admin_users.rb

  • The Devise routes that Admin Admin generated for the AdminUser model still exist and must be removed. For this delete devise_for :admin_users, ActiveAdmin::Devise.config from routes.rb

  • Rename app/admin/admin_users.rb to app/admin/users.rb, add the name and superadmin params

ActiveAdmin.register User do
  permit_params :email, :name, :password, :password_confirmation, :superadmin

  index do
    selectable_column
    id_column
    column :email
    column :name
    column :superadmin
    column :current_sign_in_at
    column :sign_in_count
    column :created_at
    actions
  end

  filter :email
  filter :name
  filter :superadmin
  filter :current_sign_in_at
  filter :sign_in_count
  filter :created_at

  form do |f|
    f.inputs do
      f.input :email
      f.input :name
      f.input :password
      f.input :password_confirmation
      f.input :superadmin, :label => "Super Administrator"
    end
    f.actions
  end
end
  • Update the seeds file to change the AdminUser to create an admin user.
User.create!(email: "admin@example.com", name: "Admin Example", password: "password", password_confirmation: "password", superadmin: true) if Rails.env.development?

Let's reset the database rails db:setup and restart the server and see what's happens.

If everything worked, now the AdminUser and User is one. Now you can create, edit and destroy users in the Active Admin dashboard.

Screen Shot 2020-09-14 at 14.21.32
Screen Shot 2020-09-14 at 14.22.03

Note: Installing Active Admin gem may do some changes to the styles.

You can avoid this adding *= stub "active_admin" to app/assets/stylesheets/application.css

/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 *= stub "active_admin"
 */

Add Blogs and Comments to Active Admin

  • $ rails g active_admin:resource Blog
# app/admin/blogs.rb
ActiveAdmin.register Blog do
  permit_params :title, :body, :user_id
end
  • $ rails g active_admin:resource Comment Because ActiveAdmin has it's own built-in Comment model/class we have to change the name of the Comment resource.
# app/admin/comments.rb
ActiveAdmin.register Comment, as: "BlogsComment" do
  permit_params :content, :blog_id, :user_id
end

Update the Active Admin dashboard with the Users, Blogs and Comments.

# app/admin/dashboard.rb
ActiveAdmin.register_page "Dashboard" do
  menu priority: 1, label: proc { I18n.t("active_admin.dashboard") }

  content title: proc { I18n.t("active_admin.dashboard") } do

    # Here is an example of a simple dashboard with columns and panels.

    columns do
      panel "Info" do
        para "Welcome to ActiveAdmin."
      end
      column do
        panel "Recent Users" do
          ul do
            User.last(5).reverse.map do |user|
              li link_to(user.email, admin_user_path(user))
            end
          end
        end
        panel "Recent Blogs" do
          ul do
            Blog.last(5).reverse.map do |blog|
              li link_to(blog.title, admin_blog_path(blog))
            end
          end
        end
      end

      column do
        panel "Recent Comments" do
          ul do
            Comment.last(5).reverse.map do |comment|
              li link_to(comment.content, admin_comment_path(comment))
            end
          end
        end
      end
    end
  end # content
end

Screen Shot 2020-09-14 at 16.01.10

Add link to admin panel in the application for admins users

In app/views/blogs/index.html.erb add

<%= link_to 'Admin Panel',  admin_root_path, class: "adminlink", target: :_blank if current_user.superadmin? %>

Now, if you are an admin a link to access the Active Admin panel will appear.

Create pretty URL’s for blogs instead of numeric ids.

For this we are going to use the friendly-id gem https://github.com/norman/friendly_id.

  1. Add gem 'friendly_id', '~> 5.1' to the Gemfile
  2. $ rails g migration AddSlugToBlogs slug:uniq
  3. $ rails generate friendly_id
  4. $ rails db:migrate
  5. Add to blog model:
extend FriendlyId
friendly_id :title, use: :slugged

Lastly let's edit the app/controllers/blogs_controller.rb file and replace Blog.find for Blog.friendly.find

Now when you create a blog with the title "my-blog-0", to access the blog show page you use the URL http://localhost:3000/blogs/my-blog-0

Update Active Admin config to work with friendlyId gem

In config/initializers/active_admin.rb add

  # == Friendly Id
  ActiveAdmin::ResourceController.class_eval do
    def find_resource
      resource_class.is_a?(FriendlyId) ? scoped_collection.friendly.find(params[:id]) : scoped_collection.find(params[:id])
    end
  end

Let's add soft-delete to hide the records instead of delete them from the database.

For this I initially used paranoia gem but I encounter lots of issues so I change to discard.

  1. Add gem 'discard', '~> 1.2' to the Gemfile.
  2. $ bundle install
  3. Add include Discard::Model to the models you want to discard. In my case: blog.rb, comment.rb and user.rb
  4. Generate the migrations to add discarded_at column.

    • $ rails g migration add_discarded_at_to_blogs discarded_at:datetime:index
    • $ rails g migration add_discarded_at_to_users discarded_at:datetime:index
    • $ rails g migration add_discarded_at_to_comments discarded_at:datetime:index
  5. $ rails db:migrate

  6. Update Blog and Comments controller to discard instead of destroy.

  7. Add kept discard method to just bring the records that are not discarded.

# blogs_controller.rb
class BlogsController < ApplicationController
  ...

  # GET /blogs
  # GET /blogs.json
  def index
    @blogs = Blog.kept.all
  end

  ...

  # DELETE /blogs/1
  # DELETE /blogs/1.json
  def destroy
    @blog.discard
    respond_to do |format|
      format.html { redirect_to blogs_url, notice: "Blog was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  def get_comments
    comments = @blog.comments.kept.select("comments.*, users.name").joins(:user).by_created_at
    render json: { comments: comments }
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_blog
    begin
      @blog = Blog.kept.friendly.find(params[:id])
    rescue ActiveRecord::RecordNotFound
      redirect_to root_path, alert: "Blog does not exists!"
      return
    end
  end

  ...
end
# comments_controller.rb
class CommentsController < ApplicationController
  ...

  def destroy
    if @comment.discard
      head :no_content
    else
      render json: { error: @comment.errors.message }, status: 422
    end
  end

  private

  def set_comment
    @comment = Comment.kept.find(params[:id])
  end

  ...
end
If you want to discard the comments after discarding the blog you can add the discard callback after_discard.

Example blogs model:

class Blog < ApplicationRecord
  extend FriendlyId
  friendly_id :title, use: :slugged

  include Discard::Model

  validates_presence_of :title, :body

  has_many :comments, dependent: :destroy
  belongs_to :user

  after_discard do
    comments.discard_all
  end

  after_undiscard do
    comments.undiscard_all
  end
end
Add discard to Active Admin

For this we have to override the destroy action in Active Admin.

# app/admin/blogs.rb
ActiveAdmin.register Blog do
  controller do
    def destroy
      @blog = Blog.friendly.find(params[:id])
      @blog.discard
      redirect_to admin_blogs_path
    end
  end

  permit_params :title, :body, :user_id
end
# app/admin/comments.rb
ActiveAdmin.register Comment, as: "BlogsComment" do
  controller do
    def destroy
      @comment = Comment.find(params[:id])
      @comment.discard
      redirect_to admin_blogs_comments_path
    end
  end

  permit_params :content, :blog_id, :user_id
end

Now when we delete a blog or comment it doesn't get erase from the database, instead the column discarded_at gets populate with a timestamp.

For example, here we delete a blog with three comments. The blog is going to be discarded and all the comments too.
Screen Shot 2020-09-15 at 14.09.38
Screen Shot 2020-09-15 at 14.09.56

  • Do the same thing for users.
    • Add to app/admin/users.rb
  controller do
    def destroy
      @user = User.find_by_id(params[:id])
      if @user != current_user
        @user.discard
        redirect_to admin_users_path
      end
    end
  end
  • Add to app/models/user.rb
  after_discard do
    blogs.discard_all
    comments.discard_all
  end

  after_undiscard do
    blogs.undiscard_all
    comments.undiscard_all
  end
  • We also have to add dependent: :destroy to User relationship with blogs and comments.
# app/models/user.rb
  has_many :blogs, dependent: :destroy
  has_many :comments, dependent: :destroy

Add restore feature to the Active Admin

For this we are going to add a deleted_by column to User, Blog and Comment, and then we are going to update it with the admin user that delete the record.
We also are going to add a Restore button in Active Admin.

  • $ rails g migration AddDeleteByToUsers
class AddDeleteByToUsers < ActiveRecord::Migration[6.0]
  def change
    add_reference :users, :deleted_by
  end
end
  • $ rails db:migrate
  • Add belongs_to :deleted_by, class_name: "User", optional: true to app/models/user.rb.
  • Add @user.update(deleted_by: current_user) to destroy action in app/admin/users.rb.
  • Finally, add the restore button and functionally to app/admin/users.rb
  action_item :restore, only: :show do
    link_to "Restore User", restore_admin_user_path(user), method: :put if user.discarded?
  end

  member_action :restore, method: :put do
    @user = User.find_by_id(params[:id])
    if @user != current_user
      @user.update(deleted_by: nil)
      @user.undiscard
      redirect_to admin_users_path
    end
  end

Screen Shot 2020-09-15 at 15.24.34

  • Do the same thing for Blogs and Comments.
    • $ rails g migration AddDeleteByToBlogs
   class AddDeleteByToBlogs < ActiveRecord::Migration[6.0]
     def change
       add_reference :blogs, :deleted_by
     end
   end
  • $ rails db:migrate
  • Add belongs_to :deleted_by, class_name: "User", optional: true to app/models/blog.rb.
  • How app/admin/blogs.rb is looking.
ActiveAdmin.register Blog do
  controller do
    def destroy
      @blog = Blog.friendly.find(params[:id])
      @blog.update(deleted_by: current_user)
      @blog.discard
      redirect_to admin_blogs_path
    end
  end

  action_item :restore, only: :show do
    link_to "Restore Blog", restore_admin_blog_path(blog), method: :put if blog.user.undiscarded? && blog.discarded?
  end

  member_action :restore, method: :put do
    @blog = Blog.friendly.find(params[:id])
    @blog.update(deleted_by: nil)
    @blog.undiscard
    redirect_to admin_blogs_path
  end

  permit_params :title, :body, :user_id
end
# app/admin/comments.rb
ActiveAdmin.register Comment, as: "BlogsComment" do
  controller do
    def destroy
      @comment = Comment.find(params[:id])
      @comment.update(deleted_by: current_user)
      @comment.discard
      redirect_to admin_blogs_comments_path
    end
  end

  action_item :restore, only: :show do
    link_to "Restore Comment", restore_admin_blogs_comment_path(blogs_comment), method: :put if blogs_comment.user.undiscarded? && blogs_comment.discarded?
  end

  member_action :restore, method: :put do
    @comment = Comment.find(params[:id])
    @comment.update(deleted_by: nil)
    @comment.undiscard
    redirect_to admin_blogs_comments_path
  end

  permit_params :content, :blog_id, :user_id
end

One thing we have to adjust is when we call undiscard_all.

We need to tell discard to undiscard the records that where not discard by an admin user.

# app/models/user.rb
 after_undiscard do
    blogs.where(deleted_by: nil).undiscard_all
    comments.where(deleted_by: nil).undiscard_all
  end
# app/models/blog.rb
  after_undiscard do
    comments.where(deleted_by: nil).undiscard_all
  end

Get started with Action Text

I used this video as a reference how to use action text

  1. Install Active Storage: $ rails active_storage:install
  2. Install Active Text: $ rails action_text:install
  3. $ rails db:migrate

This will create tables to store the rich text and attachments like images and audio files.

Add rich text to Blog

  1. In app/models/blog.rb add has_rich_text :body
  2. In app/views/blogs/_form.html.erb replace <%= form.text_area :body %> for <%= form.rich_text_area :body %>

If everything goes well it's going to look like this:
Screen Shot 2020-09-15 at 18.22.33

Now you can create blogs with more styles.
Screen Shot 2020-09-15 at 18.27.08

The image here is broken because you need to also add the gem image_processing.

Add images to Blog rich text body

  1. Uncomment gem 'image_processing', '~> 1.2' in the Gemfile
  2. $ bundle install
  3. Restart server

Now we can see the image:
Screen Shot 2020-09-15 at 18.32.48

Truncate Blog body in index

If you don't want to show all the blog content in app/views/blogs/index.html.erb you can replace <%= blog.body %> for something like <%= truncate(blog.body.to_plain_text , length: 300, omission: '...') { link_to 'Read More', blog } %>

Screen Shot 2020-09-15 at 18.38.49

Let's clean up a little bit
  1. Remove the old body column from the blog table.
    • $ rails g migration remove_body_from_blogs
    • Add remove_column :blogs, :body
  2. $ rails db:migrate
  3. Remove old admin_users table from the database.
    • $ rails g migration delete_admin_user_table
    • Add drop_table :admin_users
  4. $ rails db:migrate

Change styles of Trix editor

There may be other ways of doing this but honestly the only way we found was going to the Trix source code and edit it.
What we needed to do was change the colors and remove some of the toolbar options.

  • Create app/javascript/utils/trix.js
  • Create app/javascript/stylesheets/application.scss
  • In app/javascript/packs/application.js replace require("trix") with the one you just created: require("../utils/trix")
  • In app/javascript/packs/application.js add require("../stylesheets/application.scss")

In Trix source code there is this file src/trix/config/toolbar.coffee. Copy the content and change it to javascript. The result app/javascript/utils/trix.js

import Trix from "trix";

if (Trix) {
  const { lang } = Trix.config;
  Trix.config.toolbar.getDefaultHTML = () => `
    <div class="trix-button-row">
      <span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
        <button type="button" class="trix-button trix-button--icon trix-button--icon-bold my-button" data-trix-attribute="bold" data-trix-key="b" title="${lang.bold}" tabindex="-1">${lang.bold}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="${lang.italic}" tabindex="-1">${lang.italic}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="${lang.strike}" tabindex="-1">${lang.strike}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="${lang.link}" tabindex="-1">${lang.link}</button>
      </span>

      <span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
        <button type="button" class="trix-button trix-button--icon trix-button--icon-heading-1" data-trix-attribute="heading1" title="${lang.heading1}" tabindex="-1">${lang.heading1}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-quote" data-trix-attribute="quote" title="${lang.quote}" tabindex="-1">${lang.quote}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-code" data-trix-attribute="code" title="${lang.code}" tabindex="-1">${lang.code}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="${lang.bullets}" tabindex="-1">${lang.bullets}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="${lang.numbers}" tabindex="-1">${lang.numbers}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-decrease-nesting-level" data-trix-action="decreaseNestingLevel" title="${lang.outdent}" tabindex="-1">${lang.outdent}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-increase-nesting-level" data-trix-action="increaseNestingLevel" title="${lang.indent}" tabindex="-1">${lang.indent}</button>
      </span>

      <span class="trix-button-group trix-button-group--file-tools" data-trix-button-group="file-tools">
        <button type="button" class="trix-button trix-button--icon trix-button--icon-attach" data-trix-action="attachFiles" title="${lang.attachFiles}" tabindex="-1">${lang.attachFiles}</button>
      </span>

      <span class="trix-button-group-spacer"></span>

      <span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
        <button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="${lang.undo}" tabindex="-1">${lang.undo}</button>
        <button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="${lang.redo}" tabindex="-1">${lang.redo}</button>
      </span>
    </div>

    <div class="trix-dialogs" data-trix-dialogs>
      <div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
        <div class="trix-dialog__link-fields">
          <input type="url" name="href" class="trix-input trix-input--dialog" placeholder="${lang.urlPlaceholder}" aria-label="${lang.url}" required data-trix-input>
          <div class="trix-button-group">
            <input type="button" class="trix-button trix-button--dialog" value="${lang.link}" data-trix-method="setAttribute">
            <input type="button" class="trix-button trix-button--dialog" value="${lang.unlink}" data-trix-method="removeAttribute">
          </div>
        </div>
      </div>
    </div>
      `;
} else {
  console.error(`Trix not yet loaded, toolbar not customized`);
}

Then in app/javascript/stylesheets/application.scss find the css classes and override the one you want. For example:

$opacity-active: 1;

trix-editor {
  min-height: 10em;
}

trix-toolbar {
  .trix-button-group {
    border: 1px solid #bbb;
    border-top-color: #ccc;
    border-bottom-color: #888;
  }

  .trix-button {
    border-bottom: 1px solid paleturquoise;
    background: paleturquoise;

    &:not(:first-child) {
      border-left: 1px solid #ccc;
    }

    &.trix-active {
      background: pink;
      color: rgba(0, 0, 0, $opacity-active);
    }
  }
}

.trix-content {
  max-height: 20rem !important;
  max-width: 100%;
  overflow-y: auto;
  overflow-x: auto;
  background-color: #edf2f7;
  border-radius: 0.5rem;
  padding: 1rem;
}

In app/javascript/utils/trix.js you can also remove elements that you don't want from the toolbar, like bold or italics. Or you can remove the Trix classes and create your own.

Example:
Screen Shot 2020-09-15 at 19.57.27

Uploading images to Amazon S3

One thing that is not mentioned in this article, otherwise it would be longer than it already is, is uploading images and audio files to, for example, Amazon S3. But with Active Storage it was pretty easy and straightforward.

Conclusion

Overall I liked Rails. I love the simplicity and all the out-of-the-box solutions that already come with it like Action Text and Action Storage. But this experience didn't motivate me in continue to study the language. From little to none new articles, gems not being updated, and not a very active community on sites like Reddit or Stack Overflow makes me wonder: Is it worth it? I don't have an answer right now.

What do you think?

Posted on by:

Discussion

markdown guide