Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

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.

Download the Source Code

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:

Shameless plug time

Hello Rails Course

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.

Link this article
Est. reading time: 35 minutes
Stats: 4,662 views

Collection

Part of the Let's Build: With Ruby on Rails collection

Products and courses