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 for the comments of the blog.
- 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?
- Rails has very good official documentation.
- Some of the problems that I encounter had already been solved 5 years ago.
- New Rails features have little coverage online. Most articles and tutorials date more that 4 years.
- Questions I made on Stack Overflow and Reddit had little or no answer.
- Writing this article taught me that there is more information than I realized but I did not know what to look for at the moment.
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
Start the server:
$ rails server
or$ rails s
If everything is okay the app will be running onhttp://localhost:3000/
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/about-me
Implement the Blog
$ 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
- 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.
- To check the routes:
$ rake routes
Now if you go to http://localhost:3000/blogs
you are going to see a simple UI to blogs.
You can create new blogs, edit the blogs, see them and destroy them.
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.
$ rails g model Comment content:text
$ rails db:migrate
If you forgot and want to add blog reference to comments
$ rails g migration add_blog_reference_to_comments blog:references
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. Follow the documentation instructions at https://github.com/heartcombo/devise#getting-started
Go to
https://rubygems.org/gems/devise
, copy the gem version, in my casegem 'devise', '~> 4.7', '>= 4.7.2'
, and add it to theGemfile
.$ 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
-
/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
.
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.
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?
Add a
name
column to ourUser
table.
$ rails g migration AddNameColumnToUsers name:string
$ rails db:migrate
Add the
name
field in the forms.
Inapp/views/devise/registrations/edit.html.erb
andapp/views/devise/registrations/new.html.erb
add:
<div class="field">
<%= f.label :name %><br />
<%= f.text_field :name %>
</div>
- 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 run 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.
$ rails g controller comments
Open
comments_controller.rb
and add thecreate
anddestroy
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
- Add
gem 'react-rails', '~> 2.6', '>= 2.6.1'
toGemfile
$ bundle install
$ rails webpacker:install
$ rails webpacker:install:react
$ rails generate react:install
$ 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
// 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
Add Active Admin gem to manage the application
https://activeadmin.info/documentation.html
- Add
gem 'activeadmin', '~> 2.8'
to theGemfile
$ bundle install
$ rails generate active_admin:install
$ 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 toUser
.$ 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 deletedevise_for :admin_users, ActiveAdmin::Devise.config
fromroutes.rb
Rename
app/admin/admin_users.rb
toapp/admin/users.rb
, add thename
andsuperadmin
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.
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 theComment
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
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.
- Add
gem 'friendly_id', '~> 5.1'
to theGemfile
$ rails g migration AddSlugToBlogs slug:uniq
$ rails generate friendly_id
$ rails db:migrate
- 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.
- Add
gem 'discard', '~> 1.2'
to theGemfile
. $ bundle install
- Add
include Discard::Model
to the models you want to discard. In my case:blog.rb
,comment.rb
anduser.rb
-
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
$ rails db:migrate
Update
Blog
andComments
controller todiscard
instead ofdestroy
.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.
- Do the same thing for users.
- Add to
app/admin/users.rb
- Add to
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 withblogs
andcomments
.
# 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
toapp/models/user.rb
. - Add
@user.update(deleted_by: current_user)
to destroy action inapp/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
- 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
toapp/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
- Install Active Storage:
$ rails active_storage:install
- Install Active Text:
$ rails action_text:install
$ rails db:migrate
This will create tables to store the rich text and attachments like images and audio files.
Add rich text to Blog
- In
app/models/blog.rb
addhas_rich_text :body
- 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:
Now you can create blogs with more styles.
The image here is broken because you need to also add the gem image_processing
.
Add images to Blog rich text body
- Uncomment
gem 'image_processing', '~> 1.2'
in theGemfile
$ bundle install
- Restart server
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 } %>
Let's clean up a little bit
- Remove the old
body
column from theblog
table.$ rails g migration remove_body_from_blogs
- Add
remove_column :blogs, :body
$ rails db:migrate
- Remove old
admin_users
table from the database.$ rails g migration delete_admin_user_table
- Add
drop_table :admin_users
$ 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
replacerequire("trix")
with the one you just created:require("../utils/trix")
- In
app/javascript/packs/application.js
addrequire("../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.
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?
Update: It's 2023 and I'm still using Rails :)
Top comments (4)
Great post.
I've been developing in rails for many years right now. lately, lately i use rails to develop APIs, but I keep the front-end independent using React. i use this framework because of the speed of the development, and my customers need solutions fast.
As an advice, give another opportunity to Rails
Great post.
I think Rails have a problem actually, the rails developments are seniors or change to others languages or responsibilities, because this the new release does not have the same strong then a community with a lot of juniors and begginers searching by knowledge.
You just write this post make de difference, I will try write about Rails to help the community.
Recently I just have searched about a rails gem and I get a post from 10 years ago.
Other point hard to search is about JWT. In other languages is so simple.
If you join the right communities, Ruby and Rails are viable. One is gorails.com.
Thanks for the documentation. More folks used to chip in. BTW, I stumbled upon a typo, "Fallow the documentation." I think you meant "Follow the documentation."