Skip to content

5b How to create the Rails 7 App

John R. D'Orazio edited this page Jan 8, 2023 · 41 revisions
  1. Prepare the Database
  2. Initialize the rails app
  3. Create the Home page
  4. Test tailwind styling
  5. Import jQuery and Fontawesome and test stimulus controller
  6. Enable I18n
  7. Enable i18n-js
  8. Scaffold our resources
  9. Set up optional user login
  10. Create custom i18n-tasks scanner
  11. Fix the tests

Prepare the Database

You will need to have PostgreSQL >= v9.3 installed (sudo apt install postgresql libpq-dev) and running (sudo service postgresql start). If you prefer to use a docker container, rather than install postgresql directly on your system, skip ahead to Initialize the rails app and make sure you follow the steps to setup the docker container from there.

If you are installing PostgreSQL for the first time, a default user postgres will be created, and will be locked. You will need to set a password for this user: first run sudo -u postgres psql template1, then at the Postgres CLI type \password. You will be prompted for the new password twice, after which you can exit with CTRL-D. Edit the pg_hba.conf file (path /etc/postgresql/12/main/pg_hba.conf, according to your Postgres version) and change "peer" to "md5" for both the postgres user and all users:

# Database administrative login by Unix domain socket
-local      all     postgres     peer
+local      all     postgres     md5

# TYPE  DATABASE        USER            ADDRESS                 METHOD

# "local" is for Unix domain socket connections only
-local   all             all                                     peer
+local   all             all                                     md5

Restart the PostgreSQL service sudo service postgresql restart and test that the password is working: psql -U postgres. You should be prompted for the password, and it should be accepted as you set it in the previous steps.

Let's create a database user marriage_booklet for our application:

sudo createuser -U postgres -d -e -E -l -P -r -s marriage_booklet

You'll be prompted twice for a password for the new user, then you will be prompted for the postgres user password. You should then see something like this:

SELECT pg_catalog.set_config('search_path', '', false);
CREATE ROLE marriage_booklet PASSWORD '[password-hash-here]' SUPERUSER CREATEDB CREATEROLE INHERIT LOGIN;

Test that you can log into the PostgreSQL CLI as the new user: psql -U marriage_booklet template1.

Let's create an environment variable with the database password: edit your ~/.bash_profile or ~/.bashrc and add these lines at the end:

export MARRIAGE_BOOKLET_DATABASE_USER="marriage_booklet"
export MARRIAGE_BOOKLET_DATABASE_PASSWORD="[password-here]"

Close your terminal and open a new terminal for the environment variable to be picked up. Double check that the environment variables are available:

echo $MARRIAGE_BOOKLET_DATABASE_USER
echo $MARRIAGE_BOOKLET_DATABASE_PASSWORD

Initialize the rails app

This will take for granted that you have an active ruby environment, for example with rbenv global 3.2.0. If this is your first time setting up a ruby environment, please follow the instructions in the rbenv README.

You also need to have at least the rails gem installed (gem install rails).

rails new marriage-booklet --css tailwind --database=postgresql

This creates a rails project, adding the tailwind css framework ruby gem and using PostgreSQL as the database instead of the default SQLITE database.

This will also initialize a .git repository, create a .ruby-version file and generate a Gemfile.

So basically bundler is already installed and ready to use.

cd marriage-booklet

For first thing let's complete the database setup. If you already have PostgreSQL installed and running you can go straight to Configure the database.


If you haven't yet installed PostgreSQL because you would prefer to use a docker container, do this now:

PostgreSQL with Docker

Create a docker-compose.yml in your marriage-booklet-new application folder:

docker-compose.yml

version: '3.8'

services:

  db:
    image: postgres:15
    restart: always
    volumes:
      - /var/run/postgresql:/var/run/postgresql
    environment:
      POSTGRES_DB: marriage_booklet_development
      POSTGRES_USER: ${MARRIAGE_BOOKLET_DATABASE_USER}
      POSTGRES_PASSWORD: ${MARRIAGE_BOOKLET_DATABASE_PASSWORD}
  adminer:
    image: adminer
    restart: always
    ports:
      - ${ADMINER_PORT:-8080}:8080
  redis:
    image: redis:7
    restart: always
    ports:
      - '6379:6379'
    command: redis-server

Create an .env file with these two environment variables that will be used in the Rails database.yml in a bit, switching out [password-here] with any random password that you would like to use:

MARRIAGE_BOOKLET_DATABASE_USER="marriage_booklet"
MARRIAGE_BOOKLET_DATABASE_PASSWORD="[password-here]"

CAUTION: do not check the .env file into the repository! Add it to .gitignore to avoid it getting added and pushed.

Run docker-compose up --detach to spin up the PostgreSQL instance, which should now be available on the unix socket.

You will also have an instance of Adminer, which will allow you to inspect the database tables. If you prefer to use a port other than :8080 to access adminer, you may specify a different port in the .env file with the variable ADMINER_PORT (i.e. ADMINER_PORT=8187).

For convenience, we have also added redis to the mix, seeing it is required for hotwire live reloading to work.


Configure the database
nano config/database.yml

You may find that a username of marriage_booklet has been inserted automatically, though it's probably commented out. Go ahead and uncomment it, you can either leave it as marriage_booklet or, even better, use the environment variable we created. Same goes for the password. You can leave the database names as is.

/* config/database.yml */

development:
  <<: *default
  database: marriage_booklet_development

  # The specified database role being used to connect to postgres.
  # To create additional roles in postgres see `$ createuser --help`.
  # When left blank, postgres will use the default role. This is
  # the same name as the operating system user running Rails.
- #username: marriage_booklet
+ username: <%= ENV['MARRIAGE_BOOKLET_DATABASE_USER'] %>

  # The password associated with the postgres role (username).
- #password:
+ password: <%= ENV['MARRIAGE_BOOKLET_DATABASE_PASSWORD'] %>

# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default
  database: marriage_booklet_test
+ username: <%= ENV['MARRIAGE_BOOKLET_DATABASE_USER'] %>
+ password: <%= ENV['MARRIAGE_BOOKLET_DATABASE_PASSWORD'] %>

production:
  <<: *default
  database: marriage_booklet_production
- #username: marriage_booklet
- #password:
+ username: <%= ENV['MARRIAGE_BOOKLET_DATABASE_USER'] %>
+ password: <%= ENV['MARRIAGE_BOOKLET_DATABASE_PASSWORD'] %>

Now run bundle exec rails db:create. This should create the development and test databases:

Created database 'marriage_booklet_development'
Created database 'marriage_booklet_test'

Now let's put all of our needed gems into place.

nano Gemfile

/* Gemfile */

# Use Active Model has_secure_password
-# gem 'bcrypt', '~> 3.1.7'
+ gem 'bcrypt', '~> 3.1'

Ctrl-O to save and Ctrl-X to exit, if using nano.

From now on, when there is diff syntax highlighting, we won't tell you to use nano beforehand, you can just use the editor of your choice.

bundle

This will install the bcrypt gem, which will be useful for user accounts and logins.

Let's add some more useful gems:

bundle add 'inline_svg' --version '~> 1.8'
bundle add 'rails-i18n' --version '~> 7.0'
bundle add 'i18n-js' --version '~> 4.2'
bundle add 'i18n-tasks' --version '~> 1.0' --group 'development, test'
bundle add 'hotwire-livereload' --group 'development'
bundle add 'rexml' --version '~> 3.2'

No need to run bundle or bundle install after using bundle add. The last gem rexml seems to be required by the testing environment.

The hotwire-livereload gem requires redis-server to be installed and running.

In order for hot reloading to work with tailwind, we need to listen to changes in the relative folder:

/* config/environments/development.rb */

  # Uncomment if you wish to allow Action Cable access from any origin.
  # config.action_cable.disable_request_forgery_protection = true
+
+ config.hotwire_livereload.listen_paths << Rails.root.join("app/assets/builds")
end

Start up rails server (or rails s for short) in another terminal window and access localhost:3000, you should see the welcome to the rails app page.

Let's commit our initial app state to the local git repo:

git add .
git commit -m "create app"

Create the Home page

First of all we need to create the home page.

/* config/routes.rb */

Rails.application.routes.draw do
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
- # root "articles#index"
+ root 'pages#home'
end

Create app/controllers/pages_controller.rb:

touch app/controllers/pages_controller.rb

/* app/controllers/pages_controller.rb */

+ class PagesController < ApplicationController
+   def home
+   end
+ end

Create the pages folder:

mkdir app/views/pages

And create the home page: /* app/views/pages/home.html.erb */

+ <h1>Hello World!</h1>

Now if you spin up the app with rails server and access localhost:3000, you should see 'Hello World!'.

Let's commit to the local git repo:

git add .
git commit -m "create homepage"

Test tailwind styling

A few of the official tailwind plugins should already be installed. Let's go ahead and add another one:

/* config/tailwind.config.js */

  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/aspect-ratio'),
    require('@tailwindcss/typography'),
+   require('@tailwindcss/line-clamp'),
  ]

/* app/views/layouts/application.html.erb */

<!DOCTYPE html>
<html>
  <head>
    <title>WeddingBookletNew</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

- <body>
+ <body class="bg-black text-blue-400 text-center text-2xl font-sans">
-   <main class="container mx-auto mt-28 px-5 flex">
+   <main class="bg-black text-blue-400 text-center text-2xl font-sans">
      <%= yield %>
    </main>
  </body>
</html>

Run ./bin/dev. Now when accessing localhost:3000 you should see "Hello World!" with a black background and blue text.

Let's commit to the local git repo:

git add .
git commit -m "setup tailwind"

Import jQuery and Fontawesome and test stimulus controller

./bin/importmap pin jquery
./bin/importmap pin @fortawesome/fontawesome-free @fortawesome/fontawesome-svg-core @fortawesome/free-brands-svg-icons @fortawesome/free-regular-svg-icons @fortawesome/free-solid-svg-icons
./bin/importmap pin @rails/ujs

This last import of @rails/ujs is necessary for delete actions to function correctly

/* app/javascript/application.js */

  // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
  import "@hotwired/turbo-rails"
  import "controllers"
+ import "./src/jquery"
+ import {far} from "@fortawesome/free-regular-svg-icons"
+ import {fas} from "@fortawesome/free-solid-svg-icons"
+ import {fab} from "@fortawesome/free-brands-svg-icons"
+ import {library} from "@fortawesome/fontawesome-svg-core"
+ import "@fortawesome/fontawesome-free"
+ library.add(far, fas, fab)

While we're at it, let's set up @rails/ujs, needed for any delete actions to function correctly.

/* app/javascript/application.js */

import { Application } from "@hotwired/stimulus"
+import Rails from '@rails/ujs';
+Rails.start();

const application = Application.start()

Create the app/javascript/src folder if it doesn't exist:

mkdir app/javascript/src

/* app/javascript/src/jquery.js */

+ import jquery from "jquery"
+ window.jQuery = jquery
+ window.$ = jquery

Let's test that everything is working:

/* app/views/pages/home.html.erb */

<h1>Hello World!</h1>
+<div class="text-center">
+        <button id="click-me" data-controller="home" data-home-name-value="the Home page" class="p-5 m-4 text-xl text-green-800 bg-green-200 hover:text-green-900 hover:bg-green-100 font-bold uppercase rounded-md inline-block">
+            <i class="fas fa-hand-point-right  mr-3"></i>Click me!
+        </button>
+</div>

/* app/javascript/controllers/home_controller.js */

// Run this example by adding `data-controller="home"` to the dom element that it will apply to
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+  static values = { name }
+  connect() {
+    this.element.addEventListener("click", ev => {
+        console.log(`Hello world from ${this.nameValue}`);
+        alert(`Hello world from ${this.nameValue}`);
+    });
+  }
+}

For more information on how to pass data values to a stimulus controller, see this video and the stimulus documentation page.

Now run ./bin/dev and fire up localhost:3000. You should now see a green button with a pointing finger icon (this means Fontawesome is working), and when clicking on the button you should get an alert "Hello World from the Home page!", this means that the stimulus controller and jQuery are working!

Using ./bin/dev rather than rails s will ensure that the css styles are rebuilt correctly

Let's commit to the local git repo:

git add .
git commit -m "import jquery + fontawesome"

Enable I18n

Rails 7 already includes the I18n API at it's core. We have also already added the rails-i18n gem and the i18n-tasks gem:

  • rails-i18n provides the basic localization info (such as full and abbreviated names of the days of the week, full and abbreviated names of the months, the order in which day, month and year should appear in rails views... basic error messages...) for those locales that have been enabled for the application; the actual translation strings are not in fact included by default in the core of the Rails I18n API, and without this gem we would have to manually recreate and translate these strings as found here. Without these translation strings, we would easily get errors when opening a view that has a date control in a form, for example.
  • i18n-tasks provides some useful maintenance scripts, which allow you to, for example, automatically add new translation keys defined in rails views (erb files) to the .yml translation files in config/locales

Let's create an i18n-tasks binstub to make life easier:

bundle binstubs i18n-tasks

If after creating the binstub, you still can't issue a command like i18n-tasks missing, try issuing rbenv rehash. You should then be able to run i18n-tasks [task] directly.

Copy the default configuration file for i18n-tasks:

cp $(i18n-tasks gem-path)/templates/config/i18n-tasks.yml config/

Copy rspec test to test for missing and unused translations as part of the suite (optional):

mkdir spec
cp $(i18n-tasks gem-path)/templates/rspec/i18n_spec.rb spec/

Now let's create our base setup and manage our routing.

/* app/controllers/application_controller.rb */

class ApplicationController < ActionController::Base
+  def default_url_options
+    { locale: I18n.locale }
+  end
+
+  around_action :switch_locale
+
+  def switch_locale(&action)
+    locale = params[:locale] || I18n.default_locale
+    I18n.with_locale(locale, &action)
+  end
+
end

/* config/application.rb */

    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")
+ 
+   # Permitted locales available for the application: start with English, Italian and Spanish
+   config.i18n.available_locales = [:en, :it, :es]
+   # Set a default locale as fallback in any case
+   config.i18n.default_locale = :en
  end
end

/* config/routes.rb */

Rails.application.routes.draw do
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
+  get '/:locale' => 'pages#home'
-  root to: 'pages#home'
+  root to: redirect("/#{I18n.default_locale}"), as: :redirected_root
+
+  scope "/:locale" do
+    root to: 'pages#home'
+    resources :projects
+  end
end

Let's update our base application page adding a lang attribute to the <html> tag:

/* app/views/layouts/application.html.erb */

<!DOCTYPE html>
-<html>
+<html lang="<%= I18n.locale %>">
   <head>
-    <title>MarriageBooklet</title>
+    <title><%= t('application.title') %></title>
+    <meta charset="UTF-8">
+    <meta name="description" content="<%= t('application.description') %>">
+    <meta name="keywords" content="<%= t('application.keywords') %>">

/* app/views/pages/home.html.erb */

-<h1>Hello World!</h1>
+<h1><%= t('pages.home.helloWorld') %>!</h1>
<div class="text-center">
-       <button id="click-me" data-controller="home" data-home-name-value="the Home Page" class="p-5 m-4 text-xl text-green-800 bg-green-200 hover:text-green-900 hover:bg-green-100 font-bold uppercase rounded-md inline-block">
+       <button id="click-me" data-controller="home" data-home-name-value="<%= t('pages.home.theHomePage') %>" class="p-5 m-4 text-xl text-green-800 bg-green-200 hover:text-green-900 hover:bg-green-100 font-bold uppercase rounded-md inline-block">
-            <i class="fas fa-hand-point-right  mr-3"></i>Click me!
+            <i class="fas fa-hand-point-right  mr-3"></i><%= t('pages.home.clickMe') %>
        </button>
</div>

Let's create translation files for Italian and Spanish:

cp config/locales/en.yml config/locales/es.yml
cp config/locales/en.yml config/locales/it.yml

Let's check for the translation keys we added to home.html.erb:

i18n-tasks missing

We should get output something like this:

Missing translations (4) | i18n-tasks v0.9.34
+--------+-----------------------+----------------------------------+
| Locale | Key                   | Value in other locales or source |
+--------+-----------------------+----------------------------------+
|  all   | pages.home.clickMe    | app/views/pages/home.html.erb:4  |
|  all   | pages.home.helloWorld | app/views/pages/home.html.erb:1  |
|   es   | hello                 | en Hello world                   |
|   it   | hello                 | en Hello world                   |
+--------+-----------------------+----------------------------------+

Let's add the missing keys to our translation files:

i18n-tasks add-missing

We should see an output similar to the one above, saying instead Added 8 keys.

Let's go ahead and translate our strings:

/* config/locales/en.yml */

---
en:
  hello: Hello world
  pages:
    home:
-     clickMe: Clickme
-     helloWorld: Helloworld
-     theHomePage: Thehomepage
+     clickMe: Click me
+     helloWorld: Hello World
+     theHomePage: Home Page

/* config/locales/it.yml */

---
it:
  hello: Hello world
  pages:
    home:
-     clickMe: Click me
-     helloWorld: Hello World
-     theHomePage: Thehomepage
+     clickMe: Cliccami
+     helloWorld: Ciao Mondo
+     theHomePage: Pagina Iniziale

/* config/locales/es.yml */

---
es:
  hello: Hello world
  pages:
    home:
-     clickMe: Click me
-     helloWorld: Hello World
-     theHomePage: Thehomepage
+     clickMe: Haz click en mi
+     helloWorld: Hola Mundo
+     theHomePage: Pagina Inicial

Let's test that everything is working. Run rails s.

  • Opening localhost:3000/en in our browser, we should see Hello World! and the green button with Click me!
  • Opening localhost:3000/es in our browser, we should see Hola Mundo! and the green button with Haz click en mi!
  • Opening localhost:3000/it in our browser, we should see Ciao Mondo! and the green button with Cliccami!
  • Opening localhost:3000 in our browser should default to English

Let's commit to the local git repo:

git add .
git commit -m "set up i18n"

Enable i18n-js

Now let's deal with the Javascript side of things. We have already added the i18n-js gem. Now we need to pin the i18n-js package:

./bin/importmap pin i18n-js

And let's create a basic configuration file (running i18n init would also create an example):

/* config/i18n.yml */

+translations:
+  - file: "app/javascript/src/translations.json"
+    patterns:
+      - "*"

Let's create an i18n config that can be loaded by any javascript file using translations:

mkdir app/javascript/config

/* app/javascript/config/i18n.js */

+import { I18n } from "i18n-js"
+import translations from "../src/translations.json" assert { type: "json" }
+export const i18n = new I18n(translations);
+i18n.locale = document.documentElement.lang || "en";
+

Let's update the stimulus controller for our home page:

/* app/javascript/controllers/home_controller.js */

import { Controller } from "@hotwired/stimulus"
+import { i18n } from "../config/i18n"
+const I18n = i18n; //must use capitalized `I18n` for `i18n-tasks add-missing` to work!

export default class extends Controller {
  static values = { name }
  connect() {
    this.element.addEventListener("click", ev => {
-       console.log(`Hello world from ${this.nameValue}`);
-       alert(`Hello world from ${this.nameValue}`);
+       const helloWorld = I18n.t('pages.home.helloWorldFrom', { name: this.nameValue });
+       console.log(`${helloWorld}`);
+       alert(`${helloWorld}`);
    });
  }
}

Add the new pages.home.helloWorldFrom key to all translation files:

i18n-tasks add-missing

Let's edit our translations:

--- a/config/locales/en.yml       2021-07-17 22:56:37.448080900 +0200
+++ b/config/locales/en.yml       2021-07-17 22:56:37.448080900 +0200
@@ -5,4 +5,4 @@
     home:
       clickMe: Click me
       helloWorld: Hello World
-      helloWorldFrom: Helloworldfrom
+      helloWorldFrom: Hello World from the %{name}!

--- a/config/locales/es.yml       2021-07-17 22:56:46.198080900 +0200
+++ b/config/locales/es.yml       2021-07-17 22:55:36.188080900 +0200
@@ -5,4 +5,4 @@
     home:
       clickMe: Haz click en mi
       helloWorld: Hola Mundo
-      helloWorldFrom: Helloworldfrom
+      helloWorldFrom: Hola Mundo desde la %{name}!

--- config/locales/it.yml       2021-07-17 22:56:56.288080900 +0200
+++ config/locales/it.yml       2021-07-17 22:55:43.288080900 +0200
@@ -5,4 +5,4 @@
     home:
       clickMe: Cliccami
       helloWorld: Ciao Mondo
-      helloWorldFrom: Helloworldfrom
+      helloWorldFrom: Ciao Mondo dalla %{name}!

And export them to our translations.json:

i18n export

Now when we run bin/dev and access localhost:3000/es in a browser and open the browser console, we should not only see our translated button Haz click en mi! but when we click on the button we should see this message in our browser console, after the console messages from above regarding the locale and defaultLocale:

Hola Mundo desde la Pagina Inicial!

Let's commit to the local git repo:

git add .
git commit -m "set up i18n-js"

Scaffold our resources

Each wedding booklet is referred to as a project. Each project has:

  • a data model associated with it
  • views which contain all the UI (forms, displays, basically the user facing booklet creation process and display of the results)
  • controllers which contain the application logic for managing the views in relation to the model
  • routes which make the views accessible to browser navigation

But first, for added security, we want our resource IDs to be UUIDs (see here for more information: https://pawelurbanek.com/uuid-order-rails).

Let's create the following file:

touch config/initializers/generators.rb

/* config/initializers/generators.rb */

+Rails.application.config.generators do |g|
+  g.orm :active_record, primary_key_type: :uuid
+end

Now when we run our scaffolding generators, ID fields will automatically be generated as UUID fields. And consequently, our resources will be accessed as localhost:3000/en/projects/b436517a-e294-4211-8312-8576933f2db1 instead of localhost:3000/en/projects/1. This can prevent scanning techniques that try to gain access to user's resources / projects by checking one ID at a time starting from 1,2,3 etc.

We also need to enable the pgcrypto extension in our PostgreSQL instance, which will allow for automatic generation of UUIDs:

rails g migration enable_uuid_extension

This will create the migration file in the db/migrate folder, let's edit it like this:

/* ##############_enable_uuid_extension.rb */

class EnableUuidExtension < ActiveRecord::Migration[6.1]
  def change
+   enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto')
  end
end

Now we can generate our Projects scaffolding, together with the User and WeddingPartyMember models:

bundle exec rails generate scaffold Project liturgy:integer weddingdate:datetime church:string city:string celebrantNamePrefix:string celebrantFirstName:string celebrantLastName:string brideFirstName:string brideLastName:string groomFirstName:string groomLastName:string isSecured:boolean
bundle exec rails generate scaffold WeddingPartyMember namePrefix:string firstName:string lastName:string role:integer relationship:integer relationshipTo:string project:references
bundle exec rails generate scaffold User username:string email:string role:integer avatar:string password_digest:string
bundle exec rails generate migration CreateJoinTableProjectsUsers projects users

We need to define the relationships in our models, and we'll also define our ENUMs while we're at it:

/* app/models/project.rb */

class Project < ApplicationRecord
+  has_and_belongs_to_many :users
+  has_many :wedding_party_members, dependent: :delete_all
+  enum liturgy: { withMass: 0, withoutMass: 1, catholicNonCatholic: 2 }, _prefix: true
end

/* app/models/user.rb */

class User < ApplicationRecord
+ has_and_belongs_to_many :projects
+ enum role: { bride: 0, groom: 1, celebrant: 2, guest: 3 }, _prefix: true
+ has_secure_password
end

/* app/models/wedding_party_member.rb */

class WeddingPartyMember < ApplicationRecord
  belongs_to :project
+   enum role: { bridesmaid: 0, groomsman: 1, ringbearer: 2, flowergirl: 3, fatherbride: 4, motherbride: 5, fathergroom: 6, mothergroom: 7, bestman: 8, maidofhonor: 9, matronofhonor: 10 }, _prefix: true
+   enum relationship: { undefined: 0, father: 1, mother: 2, grandfather: 3, grandmother: 4, brother: 5, sister: 6, aunt: 7, uncle: 8, cousin: 9, nephew: 10, niece: 11, relative: 12, friend: 13, stepfather: 14, stepmother: 15, stepbrother: 16, stepsister: 17, greatgrandfather: 18, greatgrandmother: 19, greatuncle: 20, greataunt: 21 }, _prefix: true
end

/* db/migrate/##############_create_join_table_projects_users.rb */

class CreateJoinTableProjectsUsers < ActiveRecord::Migration[6.1]
  def change
-    create_join_table :projects, :users do |t|
-      # t.index [:project_id, :user_id]
-      # t.index [:user_id, :project_id]
+    create_join_table(:projects, :users, column_options: {type: :uuid}) do |t|
+      t.index [:project_id, :user_id]
+      t.index [:user_id, :project_id]
    end
  end
end

/* db/migrate/##############_create_projects.rb */

class CreateProjects < ActiveRecord::Migration[6.1]
  def change
    create_table :projects, id: :uuid do |t|
      t.integer :liturgy
      t.datetime :weddingdate
      t.string :church
      t.string :city
      t.string :celebrantNamePrefix
      t.string :celebrantFirstName
      t.string :celebrantLastName
      t.string :brideFirstName
      t.string :brideLastName
      t.string :groomFirstName
      t.string :groomLastName
-     t.boolean :isSecured
+     t.boolean :isSecured, :null => false, :default => 0
      t.timestamps
    end
  end
end

Let's run our migrations, to create the Projects, Users, and WeddingPartyMembers tables:

bundle exec rails db:migrate RAILS_ENV=development

If the operation is aborted with error PG::UndefinedFunction: ERROR: function gen_random_uuid() does not exist, try renaming db/migrate/##############_enable_uuid_extension.rb to db/migrate/0_enable_uuid_extension.rb. This will ensure that the enable_uuid_extension migration is run first.

You should get an output similar to this:

== 0 EnableUuidExtension: migrating ===========================================
-- extension_enabled?("pgcrypto")
   -> 0.0967s
-- enable_extension("pgcrypto")
   -> 0.1910s
== 0 EnableUuidExtension: migrated (0.2879s) ==================================

== 20210714081351 CreateProjects: migrating ===================================
-- create_table(:projects, {:id=>:uuid})
   -> 0.0691s
== 20210714081351 CreateProjects: migrated (0.0692s) ==========================

== 20210714081408 CreateWeddingPartyMembers: migrating ========================
-- create_table(:wedding_party_members, {:id=>:uuid})
   -> 0.0878s
== 20210714081408 CreateWeddingPartyMembers: migrated (0.0879s) ===============

== 20210714081421 CreateUsers: migrating ======================================
-- create_table(:users, {:id=>:uuid})
   -> 0.0264s
== 20210714081421 CreateUsers: migrated (0.0265s) =============================

== 20210714081430 CreateJoinTableProjectsUsers: migrating =====================
-- create_join_table(:projects, :users)
   -> 0.0258s
== 20210714081430 CreateJoinTableProjectsUsers: migrated (0.0259s) ============

Let's change any fields which we defined as enums in our models from number fields to translatable selects:

app/views/projects/_form.html.erb

-    <%= form.number_field :liturgy %>
+    <%= form.select :liturgy, Project.liturgies.keys.map{ |key, value| [ t("activerecord.attributes.project.liturgy_#{key}"), key] } %>

app/views/users/_form.html.erb

-    <%= form.number_field :role %>
+    <%= form.select :role, User.roles.keys.map{ |key, value| [ t("activerecord.attributes.user.role_#{key}"), key ] } %>

app/views/wedding_party_members/_form.html.erb

-    <%= form.number_field :role %>
+    <%= form.select :role, WeddingPartyMember.roles.keys.map{ |key, value| [ t("activerecord.attributes.wedding_party_member.role_#{key}"), key ] } %>

-    <%= form.number_field :relationship %>
+    <%= form.select :relationship, WeddingPartyMember.relationships.keys.map{ |key, value| [ t("activerecord.attributes.wedding_party_member.relationship_#{key}"), key ] } %>

And commit to our git history:

git add .
git commit -m "scaffold resources"

Set up optional user login

Let's define some helper functions for determining whether a user is logged in, and if so the current user's information (refer here):

/* app/helpers/application_helper.rb */

module ApplicationHelper
+   def logged_in?
+       !!session[:user_id]
+   end
+
+   def current_user
+       @current_user ||= User.find_by_id(session[:user_id]) if !!session[:user_id]
+   end
end

Let's generate a controller for Sessions:

bundle exec rails generate controller Sessions

/* app/controllers/sessions_controller.rb */

class SessionsController < ApplicationController
+   def create
+       @user = User.where(username: params[:username]).or(User.where(email: params[:username])).first
+       #authenticate user credentials
+       if !!@user && @user.authenticate(params[:password])
+           #set session and redirect on success
+           session[:user_id] = @user.id
+           respond_to do |format|
+               format.html { redirect_to projects_url, notice: "User was successfully logged in as #{@user.username}." }
+               format.json { head :no_content }
+           end
+       else
+           respond_to do |format|
+               #error message on fail
+               message = "Something went wrong! Make sure your username and password are correct."
+               format.html { redirect_to login_url, notice: message }
+               format.json { head :no_content }
+           end
+       end
+   end
+
+   def destroy
+       session.clear
+       respond_to do |format|
+           format.html { redirect_to projects_url, notice: "User was successfully logged out." }
+           format.json { head :no_content }
+       end
+   end
end

In order for the bcrypt gem / module to do it's magic in hashing passwords, we have to change the password_digest field to simply password; without this change, the passwords would simply be stored in plain text and login would not work correctly. We also need to introduce session logic into our User actions, so that a session will be created when the user is created, and destroyed when the user is destroyed.

/* app/controllers/users_controller.rb */

  def create
    @user = User.new(user_params)

    respond_to do |format|
      if @user.save
-        format.html { redirect_to @user, notice: "User was successfully created." }
-        format.json { render :show, status: :created, location: @user }
+        session[:user_id] = @user.id
+        format.html { redirect_to projects_url, notice: "User was successfully created." }
+        format.json { render :show, status: :created, location: projects_url }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end
#[...]
  # PATCH/PUT /users/1 or /users/1.json
  def update
    respond_to do |format|
      if @user.update(user_params)
-        format.html { redirect_to @user, notice: "User was successfully updated." }
-        format.json { render :show, status: :ok, location: @user }
+        format.html { redirect_to projects_url, notice: "User was successfully updated." }
+        format.json { render :show, status: :ok, location: projects_url }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end
#[...]
  # DELETE /users/1 or /users/1.json
  def destroy
    @user.destroy
+    session.clear
#[...]
    # Only allow a list of trusted parameters through.
    def user_params
-      params.require(:user).permit(:username, :email, :role, :avatar, :password_digest)
+      params.require(:user).permit(:username, :email, :role, :avatar, :password)
    end

And update the new user / update user form:

/* app/views/users/_form.html.erb */

  <div class="field">
-    <%= form.label :password_digest %>
-    <%= form.password_field :password_digest %>
+    <%= form.label :password %>
+    <%= form.password_field :password %>
  </div>

Create a login view:

touch app/views/sessions/login.html.erb

/* app/views/sessions/login.html.erb */

+<p id="notice"><%= notice %></p>
+<div>
+    <h2>Log In</h2>
+    <%= form_with do |form| %>
+        <div class="field">
+            <%= form.label :username %>
+            <%= form.text_field :username %>
+        </div>
+        <div class="field">
+            <%= form.label :password %>
+            <%= form.password_field :password %>
+        </div>
+        <div class="actions">
+            <%= form.submit %>
+        </div>
+    <% end %>
+</div>
+<%= link_to 'Register', new_user_path %>
+<%= link_to 'Home', root_path %>

In our routes we have to place resources :users within the /:locale scope, and create our session login / logout routes:

/* config/routes.rb */

Rails.application.routes.draw do
-  resources :users
-  resources :wedding_party_members
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  get '/:locale' => 'pages#home'
  root to: redirect("/#{I18n.default_locale}"), as: :redirected_root

  scope "/:locale" do
    root to: 'pages#home'
    resources :projects
+    resources :users
+    resources :wedding_party_members
+    get '/login', to: 'sessions#login'
+    post '/login', to: 'sessions#create'
+    post '/logout', to: 'sessions#destroy'
+    get '/logout', to: 'sessions#destroy'
  end
end

And let's add some visual feedback which will let us know whether a user is currently logged in or not:

app/views/layouts/application.html.erb

  <body class="bg-black text-blue-400 text-center text-2xl font-sans">
+    <nav class="bg-green-100">
+      <% if logged_in? %>
+        <% case current_user.role.to_sym %>
+        <% when :bride %>
+        <%   icon = "fas fa-female text-red-400" %>
+        <% when :groom %>
+        <%   icon = "fas fa-user-tie text-blue-400" %>
+        <% when :celebrant %>
+        <%   icon = "fas fa-cross text-purple-400" %>
+        <% when :guest %>
+        <%   icon = "fas fa-user-circle text-green-400" %>
+        <% end %>
+        <div><i class="<%= icon %> mr-3"></i><span><%= t('application.welcome') %></span>, <%= current_user.username %></div>
+      <% else %>
+        <div><i class="fas fa-user-alt-slash text-gray-400 mr-3"></i><span><%= t('application.hello') %></span>, <%= t('application.guest') %></div>
+      <% end %>
+    </nav>
    <%= yield %>
  </body>

Let's track our progress:

git add .
git commit -m "create user login and session handling"

Create custom i18n-tasks scanner

Seeing that form labels can be translated by simply adding activerecord.attributes.[model].[attribute] keys to our translation files (see here), let's create a custom scanner for i18n-tasks which will detect form labels as created by the scaffolding generator, and add the corresponding keys to our translation files:

touch lib/tasks/scan_resource_form_labels.rb

lib/tasks/scan_resource_form_labels.rb

+require 'i18n/tasks/scanners/file_scanner'
+class ScanResourceFormLabels < I18n::Tasks::Scanners::FileScanner
+  include I18n::Tasks::Scanners::OccurrenceFromPosition
+
+  # @return [Array<[absolute key, Results::Occurrence]>]
+  def scan_file(path)
+    text = read_file(path)
+    text.scan(/^\s*<%= f(?:orm){0,1}.label :(.+?)[, ](?:.*?)%>$/).map do |attribute|
+      occurrence = occurrence_from_position(
+          path, text, Regexp.last_match.offset(0).first)
+      model = File.dirname(path).split('/').last
+      # p "================"
+      # p model
+      # p attribute
+      # p ["activerecord.attributes.%s.%s" % [model.singularize, attribute.first], occurrence]
+      # p "================"
+      ["activerecord.attributes.%s.%s" % [model.singularize, attribute.first], occurrence]
+    end
+  end
+end
+
+I18n::Tasks.add_scanner 'ScanResourceFormLabels'
+

And let's create another custom scanner for our enum definitions:

lib/tasks/scan_model_enums.rb

+require 'i18n/tasks/scanners/file_scanner'
+class ScanModelEnums < I18n::Tasks::Scanners::FileScanner
+  include I18n::Tasks::Scanners::OccurrenceFromPosition
+
+  # @return [Array<[absolute key, Results::Occurrence]>]
+  def scan_file(path)
+    result = []
+    text = read_file(path)
+
+    text.scan(/enum ([a-zA-Z]*?)\: \{ (.*?) \}\, _prefix: true$/).each do |prefix, body|
+      occurrence = occurrence_from_position(path, text, Regexp.last_match.offset(0).first)
+      model = File.basename(path, ".rb")
+      body.scan(/\b(\w+?)\b\:/).flatten.each do |attr|
+        name = "#{prefix}_#{attr}" 
+        result << ["activerecord.attributes.#{model}.#{name}", occurrence]
+      end
+    end
+    result
+  end
+end
+
+I18n::Tasks.add_scanner 'ScanModelEnums'
+

Then we just need to add the new scanners to the bottom of our config/i18n-tasks.yml:

+<% require './lib/tasks/scan_resource_form_labels.rb' %>
+<% require './lib/tasks/scan_model_enums.rb' %>

Now when we run i18n-tasks missing, we should get all the keys corresponding to the form labels.

Let's add the keys from our enums and form labels, and track our progress:

i18n-tasks add-missing
git add .
git commit -m "custom i18n-tasks scanners"

Fix the tests

The scaffolding generators we used will have also created tests, to help us make sure that our app behaves the way we want it to. At this point, these tests don't know about the locale parameter in our URLs. Let's fix that:

config/environments/test.rb

+  routes.default_url_options[:locale]= I18n.default_locale
end

We also directed users towards the Projects index page after creating their account, instead of towards a Users index page. We need to let the tests know about that too:

test/controllers/users_controller_test.rb

diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb
index 02941b8..62dc470 100644
--- a/test/controllers/users_controller_test.rb
+++ b/test/controllers/users_controller_test.rb
@@ -20,7 +20,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
       post users_url, params: { user: { avatar: @user.avatar, email: @user.email, password_digest: @user.password_digest, role: @user.role, username: @user.username } }
     end

-    assert_redirected_to user_url(User.last)
+    assert_redirected_to projects_url
   end

   test "should show user" do
@@ -35,7 +35,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest

   test "should update user" do
     patch user_url(@user), params: { user: { avatar: @user.avatar, email: @user.email, password_digest: @user.password_digest, role: @user.role, username: @user.username } }
-    assert_redirected_to user_url(@user)
+    assert_redirected_to projects_url
   end

Extra steps

Implement @fullhuman/postcss-purgecss to reduce final file size... See: https://web-crunch.com/posts/how-to-install-tailwind-css-using-ruby-on-rails

     «            GO BACK TO THE PREVIOUS SECTION                     CONTINUE TO THE NEXT SECTION           »      
     4 How tos 6 Webpack setup