January 14, 2019
•Last updated November 5, 2023
Let’s Build: With Ruby On Rails - Project Management App
Welcome to my next installment to the ongoing series called "Let's Build: With Ruby on Rails". This 10-part series will cover building a project management type of application from the ground up.
Similar to the previous installments I'll dive a bit further into more technical aspects of Ruby on Rails. We'll discuss things such as nested attributes, model relations, and getting a little fancy with forms using Vue.js.
As a prerequisite, I recommend following at least one of the previous parts of the "Let's Build: With Ruby on Rails" series. These will help explain some foundational concepts of which I may skim over in this series. You can find those below:
- Introduction to the series
- Installing Ruby on Rails
- Build a blog with comments
- Build a Twitter clone
- Build a Dribbble clone
What are we building? Projekt
For lack of a better name, I titled the app "Projekt". The goal of the app is to be a home for any amount of projects(think Basecamp but much more stripped down). A project lives within a team and can have as many users as necessary. A user can only belong to one team at a time (this is a small side-effect of the Devise gem. Ultimately, we'd want to extend this to allow a single user to belong to multiple teams.)
The app will have 3 overlying models/relationships to tie together each other as we press forward but I'll outline the "wants" below:
- A User can create a project if they belong to a team.
- Creating a team assigns both your own account plus those you invite to a team.
- Projects require a team in order to be created.
Part 2
Part 3
Part 4
Part 5
Part 6
Part 7
Part 8
Part 9
Part 10
The tech stack
I think it's pretty obvious we'll be using Ruby on Rails in this series. On top of Rails, I wanted to approach our app with a little more modern of a mindset set. Much of the interactivity I introduce has Vue.js to think. In our Teams model, for example, I implement a form with nested user attributes. The end goal is to create a team and invite new users to the team all at once. Nested attributes and Vue.js make this a very fluid process.
We also will be using some new gems in this series that I hadn't before. Find the full list and general description of each below.
The Gem List:
- Bulma Rails - For general styles and layout. I introduce more custom CSS for small tweaks to UI as well.
- SimpleForm - For simpler forms!
- Devise - Authentication and allowing us to have a complete User system out of the gate. We'll extend this gem quite a bit in this series so stay tuned.
- Gravatar Image Tag - a handy gem for displaying Gravatars based on a user's email address.
- jQuery Rails - We need jQuery for the gem just below.
- WYSIWYG-Rails - A handy and highly customizable WYSIWYG editor for Rails.
- Public Activity - A great way to track the history of a model in Ruby on Rails. In our case, we'll track a given user's activity to display a history of CRUD actions they've performed throughout the app.
- Better Errors - enough said
- Guard - think Grunt or Gulp for Ruby on Rails.
- Guard Livereload - Handy for updating changes when hitting save. The browser automatically refreshes.
Webpacker - Webpacker comes bundled with Rails 5.1+. We'll create our app with a flag of
--webpacker=vue
from the get-go to install Vue.js and all of its dependencies(you can always add it later if need be). In a new tab on our terminal, we can then runwebpack-dev-server
to run webpack on our project. This compiles and minifies our JavaScript files(more to come about this) and we are left with more optimized code.MailCatcher - http://127.0.0.1:1080/ - Rails is a complete web app framework so much like Models, Views, and Controller, Emails can be sent programmatically using what are known as Mailers. I like using MailCatcher to preview such emails locally rather than trying to hook into an online service such as Gmail. MailCatcher is quick to install and highly dependable. You may also consider MailHog if MailCatcher isn't your thing.
gem install mailcatcher
mailcatcher
- Go to http://localhost:1080/
# projekt/config/environments/development.rb
config.action_mailer.default_url_options = { :host => 'localhost: 3000' }
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = { :address => "localhost", :port => 1025 }
Kicking things off
While I recommend following along in the videos, below is a summarized approach to the main functionality of our application. I'll cover primarily the models here and within the videos, you can gather the rest. As always, check the github repo for the entire project to reference as you go.
Creating a New Project
rails new projekt --webpack=vue
- Requires Rails 5.1+ to run initially. If you're using an older version of rails head to the installation section of the repo for the necessary gem
info to add to your Gemfile
.
Initial setup
Refer to my Gemfile
for all the gems associated with this project. If you're using the exact version of Rails as me you should be fine to just copy and paste the contents of my Gemfile
into your own.
Run bundle
from within your project folder and you should install the same gems needed for this series.
Home Controller
To set a root path both for our app and for the sake of the Devise gem we need a controller and action set. Run the following migration to create a new home controller.
$ rails g controller home index
This creates a file called home_controller.rb
within app/controllers/
. From there we need to set a root path. Head to config/routes.rb
and add the following:
Rails.application.routes.draw do
root 'home#index'
end
This ultimately lets us visit localhost:3000
when after typing rails server
in the command line to fire up the server. Our "root" path now defaults to the home controller index action.
Generating models
USER Model
$ rails generate devise:install
$ rails generate devise User
The commands above will install devise and create a migration in which will create a new table for users
. You can verify the migration by heading to db/migrate/
within your app folder. This folder will be the home to all of your apps migrations. It's essentially responsible for modifying tables and columns in your database through very convenient Rails commands.
By running the second line above, our User
model will get generated with Devise. The Devise gem does the hard work for us and revolves around any type of model you pass. It doesn't have to be called User
every time. You could for example run rails generate devise Member
and achieve the same effect.
Next, we need to migrate the changes Devise made during the install. Simply run:
bash$ rails db:migrate
Now our schema.rb
file within db/
should contain a new table called users
with a bunch of columns associated with the Devise gem.
Associating Users to teams
I know already I need to associate a User
with a Team
. This requires me to add a team_id
column to the users
table. We can do this easily with a migration. The code below does the heavy lifting for us.
$ rails g migration addTeamIdToUsers team_id:integer
After that generates a migration you can ultimately run the migration by typing:
$ rails db:migrate
Now, our users
table has a team_id
column. This column will be responsible for allowing us to find a user or set of users who belong to a given team.
Devise confirmable
Devise comes loaded with a :confirmable
adapter. If you head to app/models/user.rb
you should see all the available adapters possible with Devise. By default, a new user needs to sign up with an email, password, and password confirmation. I wanted our app to allow users to sign up with just a name and email field. Then they would be sent a link to confirm their account via email and by clicking that link is prompted to provide a password and password confirmation at a later time.
Implementing this is possible and you'll see in the videos that I achieved the goal I was after. We need this type of functionality so that I can invite users to a team without needing to first provide a password for them. I want new invites to create their own password upon confirming their account. Still with me?
To do this requires a bit of hacking and creating two new custom controllers that extend devise. I'll create both a registrations_controller.rb
file and a confirmations_controller.rb
file that allow us to add a name
field to a user account as well as bypass the initial requirement of a password and password confirmation that comes out of the box with Devise.
Since this part of the setup is a little tedious I invite you to watch me code it in the videos as well as check out the code on github for the end result.
For more information on adding the :confirmable
feature check out this article and for adding users with only an email/name check out this article.
Projects
Our project model will be relatively simple. I simply want a name
, description
and team_id
field on each project. The team_id
will be how we associate any given project with a team.
To first create our model run the following:
$ rails g scaffold Project name:string description:text
Then following that run:
$ rails db:migrate
I already know to associate a project with both a User
and a Team
I need to add a column for each type.
That looks like the following:
$ rails g migration addUserIdToProjects user_id:integer
$ rails g migration addTeamIdToProjects team_id:integer
$ rails db:migrate
Since a Project will belong to both a User
and Team
we need an associative id column for each model.
Teams
Teams are similar to projects but we only need to add the user_id
field. To properly associate a User
to a Team
. We already added a team_id
column to the users
table in a previous step.
$ rails g scaffold Team name:string
$ rails g migration addUserIdToTeams user_id:integer
$ rails db:migrate
While we are at it, our Team
model is going to be what we use Vue.js for. To do so requires a few dependencies. Go ahead and add the following via yarn.
yarn add vue-turbolinks
yarn add vue-resource
Models
With everything scaffolded we now have three new models within app/models/
. Each model will require a different relationship with one another. The other code within our models at this point is omitted for brevity.
#app/models/user.rb
class User < ApplicationRecord
# users can have many projects
has_many :projects
# users can have many teams...sort of. Devise limits us here.
has_many :teams
end
#app/models/team.rb
class Team < ApplicationRecord
# we want projects within a team to be destroyed if a team is
has_many :projects, dependent: :destroy
# teams can have many users
has_many :users
# To create a team I need nested forms in which I can invite users. This is what allows us to do such a thing.
accepts_nested_attributes_for :users, allow_destroy: true
end
# app/models/project.rb
class Project < ApplicationRecord
# a project will always belong to a team.
belongs_to :team
# A project belongs to a user when created.
belongs_to :user
# To associated all projects to a team requires us to add a nested form for the team in mention.
accepts_nested_attributes_for :team
end
Controllers
The functionality of our controllers between each model is fairly consistent. We also make use of our home_controller.rb
file to be the landing area for all users who are logged in. You can view each controller below. I do a better job explaining what's happening inside each controller in the videos so be sure to watch them!
Registrations Controller - User
# app/controllers/registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController
private
def sign_up_params
params.require(:user).permit(:name, :email, :password, :password_confirmation)
end
def account_update_params
params.require(:user).permit(:name, :email, :password, :password_confirmation, :current_password)
end
end
Confirmations Controller - User
# app/controllers/confirmations_controller.rb
# app/controllers/confirmations_controller.rb
class ConfirmationsController < Devise::ConfirmationsController
# Remove the first skip_before_filter (:require_no_authentication) if you
# don't want to enable logged users to access the confirmation page.
# If you are using rails 5.1+ use: skip_before_action
# skip_before_filter :require_no_authentication
# skip_before_filter :authenticate_user!
# PUT /resource/confirmation
def update
with_unconfirmed_confirmable do
if @confirmable.has_no_password?
@confirmable.attempt_set_password(params[:user])
if @confirmable.valid? and @confirmable.password_match?
do_confirm
else
do_show
@confirmable.errors.clear #so that we wont render :new
end
else
@confirmable.errors.add(:email, :password_already_set)
end
end
if [email protected]?
self.resource = @confirmable
render 'devise/confirmations/new' #Change this if you don't have the views on default path
end
end
# GET /resource/confirmation?confirmation_token=abcdef
def show
with_unconfirmed_confirmable do
if @confirmable.has_no_password?
do_show
else
do_confirm
end
end
unless @confirmable.errors.empty?
self.resource = @confirmable
render 'devise/confirmations/new' #Change this if you don't have the views on default path
end
end
protected
def with_unconfirmed_confirmable
@confirmable = User.find_or_initialize_with_error_by(:confirmation_token, params[:confirmation_token])
if [email protected]_record?
@confirmable.only_if_unconfirmed {yield}
end
end
def do_show
@confirmation_token = params[:confirmation_token]
@requires_password = true
self.resource = @confirmable
render 'devise/confirmations/show' #Change this if you don't have the views on default path
end
def do_confirm
@confirmable.confirm
set_flash_message :notice, :confirmed
sign_in_and_redirect(resource_name, @confirmable)
end
end
Home Controller
# app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
if user_signed_in?
@teams = Team.where('id = ?', current_user.team_id)
@projects = Project.where('team_id = ?', current_user.team_id)
end
@activities = PublicActivity::Activity.order("created_at DESC").where(owner_id: current_user, owner_type: "User")
end
end
Projects Controller
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
before_action :set_project, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!, only: [:edit, :update, :destroy]
# GET /projects
# GET /projects.json
def index
@projects = Project.all.order("created_at DESC")
end
# GET /projects/1
# GET /projects/1.json
def show
end
# GET /projects/new
def new
@project = current_user.projects.build
@teams = Team.where('id = ?', current_user.team_id)
end
# GET /projects/1/edit
def edit
@teams = current_user.teams
end
# POST /projects
# POST /projects.json
def create
@project = current_user.projects.build(project_params)
respond_to do |format|
if @project.save
format.html { redirect_to @project, notice: 'Project was successfully created.' }
format.json { render :show, status: :created, location: @project }
else
format.html { render :new }
format.json { render json: @project.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /projects/1
# PATCH/PUT /projects/1.json
def update
respond_to do |format|
if @project.update(project_params)
format.html { redirect_to @project, notice: 'Project was successfully updated.' }
format.json { render :show, status: :ok, location: @project }
else
format.html { render :edit }
format.json { render json: @project.errors, status: :unprocessable_entity }
end
end
end
# DELETE /projects/1
# DELETE /projects/1.json
def destroy
@project.destroy
respond_to do |format|
format.html { redirect_to root_path, notice: 'Project was successfully destroyed.' }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_project
@project = Project.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the whitelist through.
def project_params
params.require(:project).permit(:name, :description, :team_id)
end
end
Teams Controller
# app/controllers/teams_controller.rb
class TeamsController < ApplicationController
before_action :set_team, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!, only: [:edit, :update, :destroy]
# GET /teams
# GET /teams.json
def index
@teams = Team.all.order("created_at DESC")
end
# GET /teams/1
# GET /teams/1.json
def show
end
# GET /teams/new
def new
@team = current_user.teams.build
@user = current_user
end
# GET /teams/1/edit
def edit
end
# POST /teams
# POST /teams.json
def create
@team = current_user.teams.build(team_params)
@team.users << current_user
respond_to do |format|
if @team.save
format.html { redirect_to root_path, notice: 'Team was successfully created.' }
format.json { render :show, status: :created, location: @team }
else
format.html { render :new }
format.json { render json: @team.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /teams/1
# PATCH/PUT /teams/1.json
def update
respond_to do |format|
if @team.update(team_params)
format.html { redirect_to @team, notice: 'Team was successfully updated.' }
format.json { render :show, status: :ok, location: @team }
else
format.html { render :edit }
format.json { render json: @team.errors, status: :unprocessable_entity }
end
end
end
# DELETE /teams/1
# DELETE /teams/1.json
def destroy
@team.destroy
respond_to do |format|
format.html { redirect_to teams_url, notice: 'Team was successfully destroyed.' }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_team
@team = Team.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the whitelist through.
def team_params
params.require(:team).permit(:name, users_attributes: [:id, :name, :email, :_destroy])
end
end
Routing
Our routes file is fairly straightforward minus a put
method that occurs when a user signs up and confirms their account initially. The file ends up looking like the code below.
# config/routes.rb
Rails.application.routes.draw do
resources :projects
resources :teams
as :user do
put '/user/confirmation' => 'confirmations#update', :via => :patch, :as => :update_user_confirmation
end
devise_for :users, controllers: {
registrations: 'registrations',
confirmations: 'confirmations'
}
root 'home#index'
end
Views
For the views, I invite you to take a peek at the GitHub view directory. You can copy and paste most of what's inside these files but I recommend typing out the main parts of the app which include the views for projects
, teams
and home
.
What about the Vue.Js?
We use Vue.Js
on our nested teams form located in app/views/teams/new.html.erb
. Within both this file and the edit.html.erb
file in the same directory we render a partial called _form.html.erb
.
Typically from this point forward, I would opt to use SimpleForm to generate and html
form that does the heavy lifting. The method works really well but it would be neat to add some more interactivity into the mix.
Enter Vue.js. Within both the edit.html.erb
and new.html.erb
files I render a content_tag
in rails that spits out some data we need to make it all work.
<!-- app/views/teams/new.html.erb -->
<section class="section">
<%= content_tag :div,
id: "team-form",
data: {
id: @team.id,
team: @team.to_json(except: [:id, :created_at, :update_at]),
users_attributes: @team.users.to_json(except: [:created_at, :update_at])
} do %>
<div class="column is-5" :class="{ formstatic: scrollPosition < 100, formfixed: scrollPosition > 100 }">
<h1 class="subtitle is-2">Create a new team</h1>
<%= render 'form', team: @team %>
</div>
<div class="column is-5" :class="{ datastatic: scrollPosition < 100, datafixed: scrollPosition > 100 }">
<h2 class="subtitle is-3">{{ team.name }}</h2>
<table class="table is-fullwidth" v-if="team.users_attributes.length">
<thead>
<th>Name</th>
<th>Email</th>
</thead>
<tr class="user" v-for="(user, index) in team.users_attributes">
<td>{{ user.name}}</td><td>{{ user.email}}</td>
</tr>
</table>
</div>
</div>
<% end %>
</section>
<!--app/views/teams/edit.html.erb-->
<section class="section">
<%= content_tag :div,
id: "team-form",
data: {
id: @team.id,
team: @team.to_json(except: [:id, :created_at, :update_at]),
users_attributes: @team.users.to_json(except: [:created_at, :update_at])
} do %>
<div class="columns is-centered">
<div class="column is-4">
<h1 class="title is-2">Editing Team</h1>
<%= render 'form', team: @team %>
</div>
</div>
<div class="utility-buttons">
<%= link_to 'Cancel', @team, class:'button is-gray' %>
</div>
<% end %>
</section>
Inside both those templates is a _form.html.erb
partial that gets rendered. That file contains the following:
<div class="field">
<div class="control">
<label class="label">Team Name</label>
<input type="text" class="input" v-model="team.name" />
</div>
</div>
<div class="content">
<h2 class="title is-4">Invite users</h2>
<p>Invite users to create an account and join this team. Each user will be sent a confirmation email in which they can supply their own unique password.</p>
</div>
<div v-for="(user, index) in team.users_attributes">
<div v-if="user._destroy =='1'">
{{ user.name }} will be removed.
<button v-on:click="undoRemove(index)" class="button is-small">Undo</button>
</div>
<div v-else>
<div class="field">
<div class="control">
<label class="label">Name</label>
<input type="text" class="input" v-model="user.name" />
</div>
</div>
<div class="field">
<div class="control">
<label class="label">Email</label>
<input type="text" class="input" v-model="user.email" />
<div id="errors" class="has-text-danger" v-if="errors.length">{{ errors }}</div>
</div>
</div>
<input type="hidden" value="<%= current_user.id %>" />
<div class="field">
<div class="control">
<button v-on:click="removeUser(index)" class="button is-small">Remove</button>
</div>
</div>
</div>
<hr />
</div>
<div class="field">
<div class="control">
<button v-on:click="addUser" class="button is-link">Add User</button>
</div>
</div>
<hr />
<div class="field">
<div class="control">
<% if current_page?(new_team_path) %>
<button v-on:click="saveTeam" class="button is-success is-large">Create Team</button>
<% else %>
<button v-on:click="saveTeam" class="button is-success is-large">Save Team</button>
<% end %>
</div>
</div>
Without our Vue app initialized this leaves little to the imagination. To go ahead and set this up I add the following code to our app/javascript
directory that got generated in the very beginning when we ran rails new projekt --webpacker=vue
.
/* app/javascript/packs/projekt.js */
import Vue from 'vue/dist/vue.esm'
import VueResource from 'vue-resource'
Vue.use(VueResource)
document.addEventListener('turbolinks:load', () => {
Vue.http.headers.common['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
var element = document.getElementById("team-form")
if (element != null) {
var id = element.dataset.id
var team = JSON.parse(element.dataset.team)
var users_attributes = JSON.parse(element.dataset.usersAttributes)
users_attributes.forEach(function(user) { user._destroy = null })
team.users_attributes = users_attributes
var app = new Vue({
el: element,
data: function() {
return {
id: id,
team: team,
errors: [],
scrollPosition: null
}
},
mounted() {
window.addEventListener('scroll', this.updateScroll);
},
methods: {
updateScroll() {
this.scrollPosition = window.scrollY
},
addUser: function() {
this.team.users_attributes.push({
id: null,
name: "",
email: "",
_destroy: null
})
},
removeUser: function(index) {
var user = this.team.users_attributes[index]
if (user.id == null) {
this.team.users_attributes.splice(index, 1)
} else {
this.team.users_attributes[index]._destroy = "1"
}
},
undoRemove: function(index) {
this.team.users_attributes[index]._destroy = null
},
saveTeam: function() {
// Create a new team
if (this.id == null) {
this.$http.post('/teams', { team: this.team }).then(response => {
Turbolinks.visit(`/teams/${response.body.id}`)
}, response => {
console.log(response)
if (response.status = 422) {
var json = JSON.parse(response.bodyText);
this.errors = json["users.email"][0];
}
})
// Edit an existing team
} else {
this.$http.put(`/teams/${this.id}`, { team: this.team }).then(response => {
Turbolinks.visit(`/teams/${response.body.id}`)
}, response => {
console.log(response)
})
}
},
existingTeam: function() {
return this.team.id != null
}
}
})
}
})
This code is responsible for the same things rails can do but in a more interactive way. Adding a new user happens what appears instantly with the help of Vue.js and its interactive DOM.
To get this to render correctly remember to update the application.html.erb
layout file to the following:
<!-- views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>Projekt</title>
<%= csrf_meta_tags %>
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= stylesheet_link_tag "https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'projekt' %>
</head>
<body>
<% if flash[:notice] %>
<div class="notification is-success global-notification">
<p class="notice"><%= notice %></p>
</div>
<% end %>
<% if flash[:alert] %>
<div class="notification is-danger global-notification">
<p class="alert"><%= alert %></p>
</div>
<% end %>
<nav class="navbar is-link" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<%= link_to root_path, class:"navbar-item" do %>
<h1 class="title is-5 has-text-white">Projekt</h1>
<% end %>
<div class="navbar-burger burger" data-target="navbar">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div id="navbar" class="navbar-menu">
<div class="navbar-end">
<% if user_signed_in? %>
<div class="navbar-item has-dropdown is-hoverable">
<%= link_to 'New', '#', class: "navbar-link" %>
<div class="navbar-dropdown">
<%= link_to 'Project', new_project_path, class:"navbar-item" %>
<%= link_to 'Team', new_team_path, class:"navbar-item" %>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<%= link_to 'Account', edit_user_registration_path, class: "navbar-link" %>
<div class="navbar-dropdown is-right">
<%= link_to current_user.name, edit_user_registration_path, class:"navbar-item" %>
<%= link_to "Log Out", destroy_user_session_path, method: :delete, class:"navbar-item" %>
</div>
</div>
<% else %>
<%= link_to "Sign In", new_user_session_path, class:"navbar-item" %>
<%= link_to "Sign up", new_user_registration_path, class:"navbar-item"%>
<% end %>
</div>
</div>
</nav>
<%= yield %>
</body>
</html>
Activity
To add a new activity timeline there is a series of steps in which you can follow by watching me in the videos. The goal with the activity feed is to show a timeline of events for your own account as you're logged in. Feel free to extend this to include other users, models, and more. Look to the public_activity
gem for more information on how to do such a thing as well as my own code to see how I came about with my own solution.
Finishing Up
I think that about wraps it up! As I stated before, the videos are more of a step-by-step approach whereas this blog post is more of a quick reference. Use both to follow along and build something awesome. I invite you to extend this app even further!
If you have any question or are having trouble anywhere along the way feel free to reach out with a comment on my blog below or on YouTube. I'm also on twitter at @webcrunchblog or @justalever.
Categories
Collection
Part of the Let's Build: With Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.