-
Notifications
You must be signed in to change notification settings - Fork 2
5b How to create the Rails 7 App
- Prepare the Database
- Initialize the rails app
- Create the Home page
- Test tailwind styling
- Import jQuery and Fontawesome and test stimulus controller
- Enable I18n
- Enable i18n-js
- Scaffold our resources
- Set up optional user login
- Create custom i18n-tasks scanner
- Fix the tests
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 runsudo -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 withCTRL-D
. Edit thepg_hba.conf
file (path/etc/postgresql/12/main/pg_hba.conf
, according to your Postgres version) and change "peer" to "md5" for both thepostgres
user andall
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 md5Restart 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
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:
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.
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
orbundle install
after usingbundle add
. The last gemrexml
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"
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"
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"
./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 fordelete
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 thanrails 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"
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 inconfig/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 seeHello World!
and the green button withClick me!
- Opening
localhost:3000/es
in our browser, we should seeHola Mundo!
and the green button withHaz click en mi!
- Opening
localhost:3000/it
in our browser, we should seeCiao Mondo!
and the green button withCliccami!
- 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"
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"
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"
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"
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"
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
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 |