July 31, 2018
•Last updated November 5, 2023
Let's Build: With Ruby on Rails - Multitenancy Workout Tracker App
Welcome to my 13th Let's Build: With Ruby on Rails. This build will feature a multitenancy workout app that uses a popular gem called Apartment, nested attributes, and Vue.js integration.
We'll be building a workout tracker type of app to demonstrate the multitenancy approach to a. web app with Ruby on Rails. Working locally we will utilize a lvh.me
domain (a domain registered to point to 127.0.0.1
) to allow our app to function as it should during development. Follow along using both the written guide and videos below to learn how to take advantage of what Ruby on Rails and the multitude of gems available offer us.
Kicking things off
I'll be referencing my kickoff template to scaffold our new application. Be sure to star or fork it on Github. If you're following along you are free to use it and/or start from scratch. The template aids in setting up some gems we make use of a lot in this series such as Devise, SimpleForm, Sidekiq, Bulma, and more.
The videos
Part 1 – Introduction
Part 2 – Initial App Setup
Part 3 – Subdomain Setup
Part 4 – Controller Setup
Part 5 – Configure VueJS and Views
Part 6 – Writing the JavaScript
Part 7 – Views and Ending
Prefer the written format?
I document my entire process typically since these builds can get intense. I figured I would share the written format so you can both follow along in your preferred way as well as do a bit of code checking between my own code and yours.
Create the app
$ rails new workout_tracker -m template.rb
Add the gem to your Gemfile
gem 'apartment'
Run the Apartment generator install script/migration
$ bundle exec rails generate apartment:install
This will create a config/initializers/apartment.rb
initializer file.
Exclude our User
model inside the initializer file:
# config/initializers/apartment.rb
# Means to keep these models global to our app entirely.
config.excluded_models = %w{ User }
Modify the following line to the line thereafter:
# config.tenant_names = lambda { ToDo_Tenant_Or_User_Model.pluck :database }
config.tenant_names = lambda { User.pluck :subdomain }
This means we find an attribute subdomain
on the user model and use it to find the associated subdomain.
Important Step for the User Model
Add a migration to add subdomain
to the User
model be sure to comment out config.excluded_models = %w{ User }
before running the migration and migrating it after that.
$ rails g migration add_subdomain_to_users subdomain
This generates a migration that looks like the following:
class AddSubdomainToUsers < ActiveRecord::Migration[5.2]
def change
add_column :users, :subdomain, :string
end
end
We also need to accept this parameter as a trusted source. Devise won't like it otherwise. Much like we've done with adding a name or username field in the past we can do the same with the subdomain
field as well.
This means we need to update our application_controller.rb
to permit the subdomain
attribute as well as the name
attribute we've already set up when we generated the app using my Kickoff template.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :subdomain])
devise_parameter_sanitizer.permit(:account_update, keys: [:name, :subdomain])
end
end
Next, the Apartment gem to uses some middleware between our app so it knows what subdomain to associate with any given user. It's conveniently within the initializer file we generated so there's nothing extra to do. It's worth noting that all future processes travel through this middleware.
# config/initalizaters/apartment.rb
# no need to add this code as it was already generated
Rails.application.config.middleware.use Apartment::Elevators::Subdomain
Then finally migrate:
$ rails db:migrate
You may get the following error but don't fear:
[WARNING] - The list of tenants to migrate appears to be empty. This could mean a few things:
1. You may not have created any, in which case you can ignore this message
2. You've run `apartment:migrate` directly without loading the Rails environment * `apartment:migrate` is now deprecated. Tenants will automatically be migrated with `db:migrate`
Note that your tenants currently haven't been migrated. You'll need to run `db:migrate` to rectify this.
This mostly means that your app is generating separate schemas and databases for each "apartment". This allows the possibility of keeping on subdomain and it's data complete separate from another which is what we are after.
Go ahead and uncomment the snippet from earlier:
# config/initializers/apartment.rb
config.excluded_models = %w{ User }
Next, we'll add a subdomain attribute to our user model so we can let a user define their own subdomain when they register for an account using Devise.
Update the registration view to include the domain field
Assuming you used my Kickoff template head to the views
folder to find the devise
folder. Inside we want to modify a couple files to include our subdomain
field. You can put the field anywhere inside the simple_form_for
block. I chose right after the :email
field.
# app/views/devise/registrations/new.html.erb
...
<div class="field">
<div class="control">
<%= f.input :subdomain, required: true, input_html: { class: "input"}, wrapper: false, label_html: { class: "label" } %>
</div>
</div>
...
For now, we won't allow a user to update their domain once they create an account so leave the code above out of the form found in app/views/devise/registrations/edit.html.erb
.
For now hold off creating a new user account. We need to modify our model first.
Inside the User model add the following:
# app/models/user.rb
class User < ApplicationRecord
after_create :create_tenant
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
private
def create_tenant
Apartment::Tenant.create(subdomain)
end
end
Start your server and check out the new form at localhost:3000/users/sign_up
:
$ rails s
Create a new User by signing up within the browser at localhost:3000
and be sure to choose a subdomain you'll remember.
For this tutorial, my subdomain will be webcrunch
.
Subdomains in a development environment
Simply put, subdomains don't work locally. Luckily someone registered a domain that points at your localhost (127.0.0.1) so we can use it to test our app. With any luck, you can visit the following link (use your own subdomain if you chose differently) and you should see your app. Notice that you're logged out.
http://webcrunch.lvh.me:3000/
If you enter a subdomain that hasn't been saved you'll get an error. Thanks to the Apartment initializer and this line config.tenant_names = lambda { User.pluck :subdomain }
the app is looking strictly within the available subdomains of a given user's account.
Managing different data on different domains
Creating another user will allow you to access their domain freely. We want to make sure a user can access only the subdomain it is associated with. We've excluded the User
model on purpose here so it's available everywhere.
Let's make a new scaffold for our Workouts to show more context of how this will scope better across multiple subdomains
Generate the Workout Model
$ rails g scaffold Workout title:string date:datetime
Here we aren't generating any association to our User model on purpose. Apartment will handle this for us and scope it to a separate schema which is pretty killer.
The generation above should generate the following in your logs:
Running via Spring preloader in process 73425
invoke active_record
create db/migrate/20180719041217_create_workouts.rb
create app/models/workout.rb
invoke test_unit
create test/models/workout_test.rb
create test/fixtures/workouts.yml
invoke resource_route
route resources :workouts
invoke scaffold_controller
create app/controllers/workouts_controller.rb
invoke erb
create app/views/workouts
create app/views/workouts/index.html.erb
create app/views/workouts/edit.html.erb
create app/views/workouts/show.html.erb
create app/views/workouts/new.html.erb
create app/views/workouts/_form.html.erb
invoke test_unit
create test/controllers/workouts_controller_test.rb
create test/system/workouts_test.rb
invoke helper
create app/helpers/workouts_helper.rb
invoke test_unit
invoke jbuilder
create app/views/workouts/index.json.jbuilder
create app/views/workouts/show.json.jbuilder
create app/views/workouts/_workout.json.jbuilder
invoke assets
invoke coffee
create app/assets/javascripts/workouts.coffee
invoke scss
create app/assets/stylesheets/workouts.scss
invoke scss
create app/assets/stylesheets/scaffolds.scss
Note* - I always delete the stylesheet app/assets/stylesheets/scaffolds.scss
Next, we need to migrate the generated migration to add the new model to our database schemas
$ rails db:migrate
Your logs should show you the following (with your chosen subdomain of course)
== 20180719041217 CreateWorkouts: migrating ===================================
-- create_table(:workouts)
-> 0.0011s
== 20180719041217 CreateWorkouts: migrated (0.0012s) ==========================
Migrating webcrunch tenant
== 20180719041217 CreateWorkouts: migrating ===================================
-- create_table(:workouts)
-> 0.0010s
== 20180719041217 CreateWorkouts: migrated (0.0011s) ==========================
Sampling the Workout data across two subdomains
So now we want to verify the data created on our workout models are separate on each domain. You can use the lvh.me
domain to head to your /workouts
path to create a new example workout based on the subdomain you created before. In my case, I would go to webcrunch.lvh.me:3000/workouts
.
Obviously, our workouts only have a title
and date
field of which we will enhance to incorporate exercises in a bit. You can also be logged out to create a workout. This shouldn't be the case long-term but again we will enhance a bit later!
Create a workout or two for two different users on two different subdomains to see the effect we are after. Notice how the workout data remains scoped to the given subdomain. Success!
http://webcrunch.lvh.me:3000/
http://jsmitty.lvh.me:3000/
At this stage, our data remains separate per subdomain but we have an issue on our main lvh.me:3000/workouts
instance where the first record available displays. We don't want this.
How do we fix this Andy?
Redirection! Sorta...We need a www
subdomain for our main instance. With a multitenant app, this is pretty crucial. It will end up looking like www.lvh.me:3000/workouts
but for now, it's broken.
First, we don't want a user to be able to register the www
subdomain since it's ours.
Excluding specific subdomains is pretty trivial. Add the following in a new file within the apartment configuration folder. Note that by default there is only an apartment.rb
file. Here we created an apartment
folder with the following file subdomain_exclusions.rb
inside.
# config/initializers/apartment/subdomain_exclusions.rb
Apartment::Elevators::Subdomain.excluded_subdomains = ['www']
For grins, it's probably best to restart your server ctrl + c
and then rails s
again.
Heading to http://www.lvh.me:3000/workouts
shows no workouts which is what we are after. This is essentially the same as our regular localhost:3000
path at this point.
Routing
We should probably make our routes a little more scalable using a constraint block. Doing that looks like this inside your routes.rb file within config/
require 'sidekiq/web'
require 'subdomain'
Rails.application.routes.draw do
constraints Subdomain do
resources :workouts
end
devise_for :users
root to: 'home#index'
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end
Here I'm wrapping the first of our resources :workouts
routes within a constraint mapped to a class written in lib/subdomain
.
# lib/subdomain.rb
class Subdomain
def self.matches?(request)
subdomains %w{ www admin } # reserved subdomains
request.subdomain.present? && !subdomains.include?(request.subdomain)
end
end
Within the Sudomain
class we are checking the inbound request and first making sure there is a domain and also making sure the domain is not www
. We save any reserved subdomains with the subdomains
variable and check to make sure each requested subdomain doesn't match it.
Heading toowww.lvh.me:3000/workouts
should now give you a routing error. Likewise, going to webcrunch.lvh.me:3000/workouts
does in fact work.
_Note: Changing your routes might require yet another server restart!
It's also worth noting that, our site still loads at www.lvh.me:3000 which is useful for marketing/sales/blog types of pages on your site that aren't app-specific. This is ultimately where new users will land so you'll want it to work properly!
Scoping the domain session
If you visit the site you'll notice any history isn't cached and/or saved. Rails sets a basic session cookie by default scoped to your root path. This needs to be modified to scope to a given user's subdomain. We can modify this within the following file. Create a new file in the path below:
# config/initializers/sessions_store.rb
# Be sure to restart your server when you modify this file.
Rails.application.config.session_store :cookie_store, key: '_demo_workout_tracker_session', domain: "lvh.me"
You'll need to make the key _your_app_name_session
and append your final domain. In development, this is perfectly fine but you'll likely want to make an app-wide constant variable to define your final production site URL. The key parameter will also need to be a secret which is defined within the encrypted credentials file in config/credentials.yml.enc
You can modify this by running `bin/rails credentials:edit
but we won't do so, for now, to keep things simple.
Making Devise work with our App
Up until now, we haven't necessarily integrated Devise completely into the app. Let's scope each login to their respective subdomain. We need to make a new migration to accomplish this:
We'll need to make the email
field a non-unique constraint that normally ships with Devise. This is so an email can be used on multiple domains if necessary.
Let's generate the migration:
$ rails g migration reindex_users_by_email_and_subdomain
This creates a new file in db/migrate/
. Add the following to do the trick!
class ReindexUsersByEmailAndSubdomain < ActiveRecord::Migration[5.2]
def up
remove_index :users, :email
add_index :users, [:email, :subdomain], unique: true
end
def down
remove_index :users, [:email, :subdomain]
add_index :users, :email, unique: true
end
end
In more modern migrations you're probably used to seeing the change
method of which should be within your file. You can also define up
and down
methods as well. In the event that you need to roll back a migration rails would target the down
method and if you wanted to migrate like normal it would target the up
method. Pretty straightforward.
$ rails db:migrate
Change login keys
Within the user model, we need to make some changes to how devise works by providing keys.
Our amended user.rb
file looks like this
class User < ApplicationRecord
after_create :create_tenant
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, request_keys: [:subdomain]
def create_tenant
Apartment::Tenant.create(subdomain)
end
end
Notice the addition of request_keys: [:subdomain]
and the deletion of :validateable
. And with that, a User should now be scoped to each subdomain.
Keeping signed in Users out of different subdomains
We'll need to reinforcements to make sure users can't visit another subdomain without it being their own.
Let's add the following to our application_controller.rb
file:
class ApplicationController < ActionController::Base
protect_from_forgery prepend: true, except: :sign_in # devise bug making this all weird currently
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :redirect_to_subdomain
private
def redirect_to_subdomain # redirects to subdomain on signup
return if self.is_a?(DeviseController)
if current_user.present? && request.subdomain != current_user.subdomain
redirect_to workouts_url(subdomain: current_user.subdomain)
end
end
def after_sign_in_path_for(resource_or_scope)
root_url(subdomain: resource_or_scope.subdomain)
end
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :subdomain])
devise_parameter_sanitizer.permit(:account_update, keys: [:name, :subdomain])
end
end
This is the whole file for clarity sake. We've added the before_action called:redirect_to_subdomain
. which does just as it describes based on the user's defined subdomain.
You'll also notice the after_sign_in_path_for
helper which is baked into devise. Within this, we can say where the user gets redirected to after signing in. There's also another for sign up if you ever require it. I believe it is after_sign_up_path_for
.
We need to make use of lvh.me from now on. Restart your server by running the following bash command. We are telling rails to bind lvh.me to port 3000. Or just run rails server
like normal but modify your address URL to point to lvh.me instead of localhost.
$ rails s -p 3000 -b lvh.me
Gotchyas
So as is we are using sqlite3
in this tutorial. Sadly, there are some issues with using puma as our server and the number of threads causes some errors in our console.
If you see something like this:
ActiveRecord::ConnectionNotEstablished - No connection pool with 'primary' found.:
It's an Apartment related issue. To fix this for now, based on our set up, you'll need to head to config/puma.rb
and change the following line:
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 1 # change this from 5 to 1 }
If you plan to take something like this to a production environment it is highly recommended to run a postgresql
type of database which supports multi-threaded and multi-schema apps. If you go that route be sure to change that RAILS_MAX_THREADS
value back to 5
. Heroku only supports this so it's not that uncommon.
Using postgresql
and even mysql
is pretty trival to setup with Rails but I won't be covering it in this tutorial. If you want to go that route you'll need either Postgresql running locally installed via Homebrew or an app like Postgres.app which is more of a GUI approach to postgresql. From there you'll need to modify your config/database.yml
file to connect to the appropriate ports and databases of your choice and you should be all set.
Finishing the app
With our subdomain woes taken care of, we can finish out the app. Our workouts will have nested exercises all scoped to a user on their subdomain.
Workouts setup
Our Workout
model needs to be associated with a user so we'll add the following:
# app/models/workout.rb
class Workout < ApplicationRecord
belongs_to :user
end
And our User model now looks like this:
# app/models/user.rb
class User < ApplicationRecord
after_create :create_tenant
after_destroy :delete_tenant
has_many :workouts
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, request_keys: [:subdomain]
validates :email, uniqueness: true
def create_tenant
Apartment::Tenant.create(subdomain)
end
def delete_tenant
Apartment::Tenant.drop(subdomain)
end
def self.find_for_authentication(warden_conditions)
where(:email => warden_conditions[:email], :subdomain => warden_conditions[:subdomain]).first
end
end
Update the controller to include our new user-related methods:
# app/controllers/workouts_controller.rb
class WorkoutsController < ApplicationController
before_action :set_workout, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!
# GET /workouts
# GET /workouts.json
def index
@workouts = Workout.all
end
# GET /workouts/1
# GET /workouts/1.json
def show
end
# GET /workouts/new
def new
@workout = current_user.workouts.build
end
# GET /workouts/1/edit
def edit
end
# POST /workouts
# POST /workouts.json
def create
@workout = current_user.workouts.build(workout_params)
respond_to do |format|
if @workout.save
format.html { redirect_to @workout, notice: 'Workout was successfully created.' }
format.json { render :show, status: :created, location: @workout }
else
format.html { render :new }
format.json { render json: @workout.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /workouts/1
# PATCH/PUT /workouts/1.json
def update
respond_to do |format|
if @workout.update(workout_params)
format.html { redirect_to @workout, notice: 'Workout was successfully updated.' }
format.json { render :show, status: :ok, location: @workout }
else
format.html { render :edit }
format.json { render json: @workout.errors, status: :unprocessable_entity }
end
end
end
# DELETE /workouts/1
# DELETE /workouts/1.json
def destroy
@workout.destroy
respond_to do |format|
format.html { redirect_to workouts_url, notice: 'Workout was successfully destroyed.' }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_workout
@workout = Workout.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the whitelist through.
def workout_params
params.require(:workout).permit(:title, :date)
end
end
Not a lot changed in the controller other than a new before_action
which makes sure the user is logged in before doing anything with a Workout record. The new
and create
actions get a new classification current_user.workouts.build
which essentially associates any new workout data with a given user's id
.
Add the missing user_id
column to Workout
At this stage, we should see an error in the browser because when we ran the initial Workout
scaffold we didn't pass a user:references
. To fix this we need to add a user_id
column to the Workout
table.
Run the following:
$ rails g migration add_user_id_to_workouts user_id:integer
This creates the following:
# db/migrate/XXXXXXXX_add_user_id_to_workouts.rb
class AddUserIdToWorkouts < ActiveRecord::Migration[5.2]
def change
add_column :workouts, :user_id, :integer
end
end
We can then migrate that in:
$ rails db:migrate
Again note your logs. You should see that migration apply the changes to all of our schemas.
Success! Now our workouts will be associated to the logged in user creating them.
Exercises
Next comes our exercises. These are interesting because we will be using nested attributes to append them to our parent Workout model. This allows us to have two separate models teaming up to act as one in essence. It get's a little tricky with regards to setting up our models and controllers but let's kick things off by generating a scaffold.
$ rails g scaffold Exercise name:string sets:string weight:string workout:references
I removed the generated scaffold.scss
file after this step. I suggest you do the same to avoid conflicts if you haven't removed it already.
Update the exercise model
# app/models/exercise.rb
class Exercise < ApplicationRecord
belongs_to :workout
end
Here we associate an exercise to a model with a belongs_to
association.
Update the workout model
# app/models/workout.rb
class Workout < ApplicationRecord
belongs_to :user
has_many :exercises, dependent: :destroy
accepts_nested_attributes_for :exercises, allow_destroy: true
end
The workout model has_many :exercises
as it should. We aren't doing any validations on either model. You can and should do this for some server site flair. As you can probably guess, this keeps stuff you don't want out of your database. I'll let you figure up your own validations. Check the Rails docs for more information.
accepts_nested_attributes_for :excercises
tells the Workout
model to nest the Excercise
model attributes within. We are also allowing the user to destroy the nested attributes with allow_destroy: true
which isn't enabled by default.
If you haven't run rails db:migrate
yet you should.
Update the new action on the workout controller
Since we are going to have nested attributes in our view we need a new way to make sure they display by default. If you do not declare the following code in your controller your nested fields won't display.
# app/controllers/workouts_controller.rb
class WorkoutsController < ApplicationController
...
# GET /workouts/new
def new
@workout = Workout.new
@workout.exercises.build
@workout.user_id = current_user.id
end
...
def workout_params
params.require(:workout).permit(:title, :date, exercises_attributes: [:id, :_destroy, :name, :sets, :weight])
end
end
Within the new
action we are using the default Workout.new
declaration and then using that instance variable @workout
to build the exercises and associate them with a new Workout
. Following the creation of a new Workout
we inject the current_user.id
into the @workout.user_id
column.
At the bottom of workouts_controller.rb
we now have to permit both the Workout
attributes and the Excercise
attributes. The naming convention matters here for exercise_attributes: []
. Any nested attributes declared in a modal will follow a similar naming convention (_modal_attributes
). Within the exercise_attritbutes
array we add new symbols that point to columns on our Exercise
model. We also pass the :id
, and :_destroy
symbols.
Our views get some love here as well. The main one in mention is the _form.html.erb
file within our app/views/workouts
folder.
Vue.js meets Rails
In another build, I used Vue.js to build a dynamic form that allowed us to easily accept nested attributes using JavaScript and some data attributes on a div
tag. I paired this with some sweet Rails helpers to spit out the data we need to make it all work. Before we can even begin coding we need to install Vue and it's dependencies.
We need to reference and install the webpacker gem. Add the following to your gem file and run bundle
gem 'webpacker', git: 'https://github.com/rails/webpacker.git'
To make use of the JavaScript dependencies required for Vue and Webpacker we also need some node packages.
Run the following:
gem 'webpacker', '~> 3.5'
Finally, run following to install Webpacker:
$ bundle exec rails webpacker:install
$ bundle exec rails webpacker:install:vue
This should ultimately create all we need for Vue to do its thing. Webpacker sets up our app to run Vue alongside Rails so we can mix and match as we see fit. You'll notice a new dedicated javascript
directory in your project now.
5.2 Vue Security Woes
Since we are using 5.2 currently we need to do one more step which added the following code to config/initializers/content_security_policy.rb
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
if Rails.env.development?
policy.script_src :self, :https, :unsafe_eval
else
policy.script_src :self, :https
end
end
Note, you will likely need to uncomment the block Rails.application.config.content_security_policy do |policy|
.
Hooking Up the Form with Vue
With Webpacker and Vue all ready to roll we have to now include new script tags in our project.
Add the following within the head
tag on your application.html.erb
layout:
<!-- app/views/layouts/application.html.erb-->
<head>
...
<%= javascript_pack_tag 'workouts' %>
</head>
Our new workout form won't be a traditional Rails form. Instead, we are going to create a div
with a bunch of properties and use it to essentially be a mini Vue app. Our form app/views/workouts/_form.html.erb
looks like this
<%= content_tag :div,
id: "workout-form",
data: {
id: workout.id,
workout: workout.to_json(except: [:id, :created_at, :updated_at]),
exercises_attributes: workout.exercises.to_json(except: [:workout_id, :created_at, :updated_at]),
} do %>
<div class="field">
<div class="control">
<label class="label">Workout Title</label>
<input type="text" class="input" v-model="workout.title" />
</div>
</div>
<div class="field">
<div class="control">
<label class="label">Date</label>
<input type="date" class="input" v-model="workout.date" />
</div>
</div>
<h4 class="title is-4 mt3">Exercises</h4>
<div v-for="(exercise, index) in workout.exercises_attributes">
<div v-if="exercise._destroy == '1'">
{{ exercise.name }} will be removed. <button v-on:click="undoRemove(index)" class="button is-light">Undo</button>
</div>
<div v-else>
<div class="pa4 bg-light border-radius-3 border">
<div class="field">
<div class="control">
<label class="label">Exercise Name</label>
<input type="text" v-model="exercise.name" class="input" />
</div>
</div>
<div class="columns">
<div class="column">
<div class="field">
<div class="control">
<label class="label">Sets</label>
<input type="text" v-model="exercise.sets" class="input" />
</div>
</div>
</div>
<div class="column">
<div class="field">
<div class="control">
<label class="label">Weight</label>
<input type="text" v-model="exercise.weight" class="input" />
</div>
</div>
</div>
</div>
<button v-on:click="removeExercise(index)" class="button is-danger">Remove</button>
</div>
</div>
<hr />
</div>
<button v-on:click="addExercise" class="button is-dark">Add Exercise</button>
<hr />
<button v-on:click="saveWorkout" class="button is-success is-large mt4">Save Workout</button>
<% end %>
On to the JavaScript
We require a few more node packages to make things work. Let's add those with Yarn now:
$ yarn add vue-resource
$ yarn add webpack-cli -D # probably don't need
$ yarn add webpack-dev-server # probably don't need
With that out of the way we can modify app/javascript/application.js
to our liking.
// Create this file - app/javascript/workouts.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("workout-form")
if (element != null) {
var id = element.dataset.id
var workout = JSON.parse(element.dataset.workout)
var exercises_attributes = JSON.parse(element.dataset.exercisesAttributes)
exercises_attributes.forEach(function(exercise) { exercise._destroy = null })
workout.exercises_attributes = exercises_attributes
var app = new Vue({
el: element,
data: function() {
return { id: id, workout: workout }
},
methods: {
addExercise: function() {
this.workout.exercises_attributes.push({
id: null,
name: "",
sets: "",
weight: "",
_destroy: null
})
},
removeExercise: function(index) {
var exercise = this.workout.exercises_attributes[index]
if (exercise.id == null) {
this.workout.exercises_attributes.splice(index, 1)
} else {
this.workout.exercises_attributes[index]._destroy = "1"
}
},
undoRemove: function(index) {
this.workout.exercises_attributes[index]._destroy = null
},
saveWorkout: function() {
// Create a new workout
if (this.id == null) {
this.$http.post('/workouts', { workout: this.workout }).then(response => {
Turbolinks.visit(`/workouts/${response.body.id}`)
}, response => {
console.log(response)
})
// Edit an existing workout
} else {
this.$http.put(`/workouts/${this.id}`, { workout: this.workout }).then(response => {
Turbolinks.visit(`/workouts/${response.body.id}`)
}, response => {
console.log(response)
})
}
},
existingWorkout: function() {
return this.workout.id != null
}
}
})
}
})
Webpack and Compiling the JavaScript
To get things to compile down we need to open a new tab in our terminal and run:
$ ./bin/webpack-dev-server
This should watch for changes and compile down during saves.
From here you should be able to create a new workout with exercises as nested fields. Notice how we don't even need an exercise controller at this point? Kinda cool eh? Since we scaffolded the entire Exercise model you'll still have the controller and all the RESTful views.
It's up to you or your app if you require those down the line. You could essentially delete them from this project and still have all you need so long as you kept your app/models/exercise.rb
file. Up to you there! I'll leave the code the Github repo in for brevity's sake.
A Dash of Fancy Helpers
We make use of a few helpers throughout this build. I've declared them all in the main application_helper.rb
file within app/helpers
.
module ApplicationHelper
def workout_author(workout)
user_signed_in? && current_user.id == workout.user_id
end
def verbose_date(date)
date.strftime('%B %d, %Y')
end
def has_subdomain
user_signed_in? && current_user.subdomain
end
def verify_subdomain_presence
request.subdomains.present?
end
end
Doctoring up the views
With our logic in place, we can clean up our views to render a little nicer. This series isn't a huge focus on design as I want you to understand both the subdomain logic and nested attributes as well as some Vue JS. Feel free to extend this further on your own.
Application Layout - Our main template for the app
<!-- app/views/layouts/application.html.erb-->
<!DOCTYPE html>
<html>
<head>
<title><%= Rails.configuration.application_name %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<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 'workouts' %>
</head>
<body class="<%= yield (:body_class) %>">
<% 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" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<%= link_to root_path, class:"navbar-item" do %>
<h1 class="title is-5"><%= Rails.configuration.application_name %></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">
<div class="navbar-item"><%= link_to 'Create new Workout', new_workout_path, class: 'button is-dark' if user_signed_in? %></div>
<div class="navbar-item">
<div class="field is-grouped">
<% if user_signed_in? %>
<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 %>
<% if has_subdomain || verify_subdomain_presence %>
<p class="control">
<%= link_to "Sign In", new_user_session_path, class:"navbar-item button" %>
</p>
<% end %>
<p class="control">
<%= link_to "Sign up", new_user_registration_path, class:"navbar-item button"%>
</p>
<% end %>
</div>
</div>
</div>
</div>
</nav>
<%= yield :hero %>
<section class="section">
<div class="container">
<%= yield %>
</div>
</section>
</body>
</html>
Home Index - where registered users land after signing in on their respective subdomain (/
)
<!-- app/views/home/index.html.erb-->
<% if !user_signed_in? %>
<% content_for :hero do %>
<section class="hero is-medium is-dark home-hero has-text-centered">
<div class="hero-body">
<div class="container">
<h1 class="title">
Welcome to <%= Rails.configuration.application_name %>!
</h1>
<h2 class="subtitle">
Host your own workouts and share your results together.
</h2>
</div>
</div>
</section>
<% end %>
<% else %>
<% content_for :hero do %>
<section class="hero is-dark has-text-centered">
<div class="hero-body">
<div class="container">
<h1 class="title">
Welcome <%= current_user.name %>!
</h1>
<h2 class="subtitle">
Access your workouts to take full advantage of the platform.
</h2>
</div>
</div>
<div class="hero-foot">
<nav class="tabs">
<div class="container">
<ul>
<li><%= link_to "View all Workouts", workouts_path %></li>
</ul>
</div>
</nav>
</div>
</section>
<% end %>
<% end %>
<div class="columns is-multiline">
<div class="column">
<div class="content has-text-centered">
<% if !user_signed_in? %>
<p class="subtitle is-4">Ready to get in shape? <br />Start hosting your own workouts and exercises now.</p>
<% if has_subdomain %>
<%= link_to "Let's do this", workouts_path, class: 'button is-dark is-large' %>
<% else %>
<%= link_to "Let's do this", new_user_registration_path, class: 'button is-dark is-large' %>
<% end %>
<% else %>
<h3>Recent workouts</h3>
<div class="columns is-multiline">
<% @workouts.each do |workout| %>
<%= render 'workouts/workout', workout: workout %>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
Workouts Index - The main feed of workouts ( /workouts
)
<!-- app/views/workouts/index.html.erb -->
<h1 class="title is-3">Workouts</h1>
<div class="columns is-multiline">
<% @workouts.each do |workout| %>
<%= render "workout", workout: workout %>
<% end %>
</div>
Workouts New - The template for creating a new Workout (/workouts/new
)
<!-- app/views/workouts/new.html.erb -->
<div class="columns is-centered">
<div class="column is-8">
<h1 class="title is-3">New Workout</h1>
<%= render 'form', workout: @workout %>
</div>
</div>
Workouts Edit - The template for editing an existing Workout (/workouts/:id/edit
)
<!-- app/views/workouts/edit.html.erb -->
<div class="columns is-centered">
<div class="column is-8">
<h1 class="title is-1">Editing Workout</h1>
<%= render 'form', workout: @workout %>
</div>
</div>
Workouts Show - A single display view of a Workout (workouts/:id
)
<!-- app/views/workouts/edit.html.erb -->
<% content_for :page do %>
<h1 class="title is-1"><%= @workout.title %></h1>
<p><%= verbose_date(@workout.date) %></p>
<table class="table">
<thead>
<tr>
<th>Exercise</th>
<th># of Sets</th>
<th>Weight</th>
</tr>
</thead>
<tbody>
<% @workout.exercises.each do |exercise| %>
<tr>
<td><%= exercise.name %></td>
<td><%= exercise.sets %></td>
<td><%= exercise.weight %></td>
</tr>
<% end %>
</tbody>
</table>
<%= link_to 'Edit Workout', edit_workout_path(@workout), class: "button mt4" if workout_author(@workout) %>
<% end %>
<%= render 'page_template' %>
You may notice the partial <%= render 'page_template %>'
here. This lives in app/views/application/_page_template.html.erb
Rails is smart to find this partial in the application
directory by default. You can add global partials here and access them from any other view as needed.
Application Page Template Partial - Renders a wrapper around our page content. This cuts down on us repeating a bunch of html
with the help of yield
and content_for
methods.
Workouts form
The main bread and butter for creating and editing workouts.
<!-- app/views/workouts/_form.html.erb -->
<%= content_tag :div,
id: "workout-form",
data: {
id: workout.id,
workout: workout.to_json(except: [:id, :created_at, :updated_at]),
exercises_attributes: workout.exercises.to_json(except: [:workout_id, :created_at, :updated_at]),
} do %>
<div class="field">
<div class="control">
<label class="label">Workout Title</label>
<input type="text" class="input" v-model="workout.title" />
</div>
</div>
<div class="field">
<div class="control">
<label class="label">Date</label>
<input type="date" class="input" v-model="workout.date" />
</div>
</div>
<h4 class="title is-4 mt3">Exercises</h4>
<div v-for="(exercise, index) in workout.exercises_attributes">
<div v-if="exercise._destroy == '1'">
{{ exercise.name }} will be removed. <button v-on:click="undoRemove(index)" class="button is-light">Undo</button>
</div>
<div v-else>
<div class="pa4 bg-light border-radius-3 border">
<div class="field">
<div class="control">
<label class="label">Exercise Name</label>
<input type="text" v-model="exercise.name" class="input" />
</div>
</div>
<div class="columns">
<div class="column">
<div class="field">
<div class="control">
<label class="label">Sets</label>
<input type="text" v-model="exercise.sets" class="input" />
</div>
</div>
</div>
<div class="column">
<div class="field">
<div class="control">
<label class="label">Weight</label>
<input type="text" v-model="exercise.weight" class="input" />
</div>
</div>
</div>
</div>
<button v-on:click="removeExercise(index)" class="button is-danger">Remove</button>
</div>
</div>
<hr />
</div>
<button v-on:click="addExercise" class="button is-dark">Add Exercise</button>
<hr />
<button v-on:click="saveWorkout" class="button is-success is-large mt4">Save Workout</button>
<% end %>
Aside from those views and partials, we make use of my Kickoff template to utilize some custom Devise views which should already be ready to roll if you're following along.
Where to go from here?
If you're interested in taking this further I highly recommend ditching sqlite
for postgresql
. The support is much better for multiple schemas and puma, the web server, plays a lot nicer. Aside from that I recommend deploying early to a testing environment so you can reassure everything continues to work as your app scales. Heroku offers a free tier to start but costs a lot more later once you are production ready. For that I recommend HatchBox.io, a new competitor to Heroku at about half the cost. Tell Chris I sent ya!
Finishing up
This app could be so much better but I hope you learned something about multi-tenancy applications using Ruby on Rails as a framework to deliver it on. You can build scalable apps in no time flat thanks to the Apartment gem, Devise, and Rails itself. If you have any questions or what clarification on anything please don't hesitate to comment. Subdomain based apps were all too common for a while. Harvest, Basecamp, Freshbooks, and more used them to host their clients on different domains which really does make sense. Your app may require such an effort to build subdomain based accounts. If so I hope this is a good primer to building such a service or application.
Thanks for reading! If you enjoyed this maybe you'll enjoy my YouTube channel or Patreon page. You can buy me a coffee as well to keep me awake.
Check out my previous builds and videos on Ruby on Rails:
- Let’s Build: With Ruby on Rails – Introduction
- Let’s Build: With Ruby on Rails – Installation
- Let’s Build: With Ruby on Rails – Blog with Comments
- Let’s Build: With Ruby on Rails – A Twitter Clone
- Let’s Build: With Ruby on Rails – A Dribbble Clone
- Let’s Build: With Ruby on Rails – Project Management App
- Let’s Build: With Ruby on Rails – Discussion Forum
- Let’s Build: With Ruby on Rails – Deploying an App to Heroku
- Let’s Build: With Ruby on Rails – eCommerce Music Shop
- Let’s Build: With Ruby on Rails – Book Library App with Stripe Subscription Payments
- Let’s Build: With Ruby on Rails – Trade App With In-App Messaging
Shameless plug time
I have a new course called Hello Rails. Hello Rails is modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. View the course!
Follow @hello_rails and myself @justalever on Twitter.
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.