Now updated for Rails 7.2
Here is my unique Rails cookbook for bootstrapping any Rails project on Rails 7.
While most developers should pay attention to the Gems and setup you are adding to your codebases, these quick start recipies are great as a teaching tool and for setting demo apps up quickly.
1/ Rails 7 JS Strategy Setup (pick A, B, C or D)
Ensure you understand if you are building a Rails app using (A) Importmap, (B) JSBundling, (C ) Shakapacker, or (D) Vite Rails. To help you decide, read this post.
STOP! Before continuing, check your versions. These scripts will use the currently installed Rails, Ruby, and Node. |
rails -v
ruby -v
node -v
You should be using Language manager which is a tool that switches between versions of your language. These tools work similarly, but you should should choose one to work with your system. These tools are like nvm/rvm (“Node version manager” and “Ruby version manager”), or rbenv/nenv (“Ruby environment” or “Node environment”), or Asdf.
Be sure to switch to the desired Ruby and Node versions before copying & pasting below.
For all scripts in this, you will be prompted for your new app name as soon as you hit “Return” Type the name name of your new Rails app in TitleCase as-is Rails convention.
1A: Make a new JSBundling Rails App (recommended by me for people getting started with Rails 7)
echo "please give your new JSBundling Rails app a TitleCase name:" && read APP_NAME && rails new $APP_NAME --javascript=esbuild --database=postgresql && cd $APP_NAME && CURRENT_NODE=$(nvm current) && echo $CURRENT_NODE >> .node-version && printf "Node + Ruby versions are in \`.node-version\` and \`.ruby-version\`, respectively.\n\n# Setup\n\n\`bin/setup\`\n\n# Start Rails\n\n\`bin/dev\`\n\n# Run Specs\n\nrun with \`bin/rake\`" > README.md && git add . && git commit -m "initial commit with $(rails -v), Node $CURRENT_NODE, Ruby $(more ./.ruby-version)" && sed -i '' -e 's/ruby-//g' .ruby-version && RUBY_STRING="ruby \"$(more ./.ruby-version)\"" && sed -i '' -e "s/$RUBY_STRING/ruby File.read('.ruby-version').strip/g" Gemfile && git add . && git commit -m "fixes .ruby-version file and sets Gemfile to use .ruby-version file" && bin/rails db:create db:migrate && git add . && git commit -m "adds schema file"
For the complete JS Bundling setup guide, see this post.
Be sure to start up your app with ./bin/dev
Typescript install for JSBundling app (optional)
First, confirm that you have correctly installed the tsc
executable on your system. Assuming you are using Homebrew on macOS, type which tsc
to confirm that you see this as the output:
/opt/homebrew/bin/tsc
If you don’t see this, run
brew install typescript
Next, install Typescript, the watcher, and modify the Procfile with this quick script:
1A-Typescript
yarn add --dev tsc-watch && yarn add typescript && git add . && git commit -m "adds typescript and tsc-watch as node packages" && tsc --init && sed -i '' -e "s/\/\/ \"noImplicitAny\": true,/\"noImplicitAny\": true, /g" tsconfig.json && git add . && git commit -m "initial tsconfig.json file with noImplicitAny" && sed -i '' -e "s/\"scripts\": {/\"scripts\": {\n \"failure:js\": \"rm .\/app\/assets\/builds\/application.js \&\& rm .\/app\/assets\/builds\/application.js.map\", \n \"compile:typescript\": \"tsc-watch --noClear -p tsconfig.json --onSuccess 'yarn build' --onFailure 'yarn failure:js'\", /g" package.json && sed -i '' -e "s/js: yarn build --watch/js: yarn compile:typescript --watch/g" Procfile.dev && touch app/javascript/empty.ts && git add . && git commit -m "adds typescript support"
1B: Make a new Rails app using ImportMap — No Node.
Use this setup for a Node-free Rails 7 app.
echo "please give your new Importmap Rails app a TitleCase name:" && read APP_NAME && rails new $APP_NAME --database=postgresql && cd $APP_NAME && git add . && git commit -m "initial commit with $(rails -v), Ruby $(more ./.ruby-version)" && printf "Ruby version is in \`.ruby-version\`.\n\n# Setup\n\n\`bin/setup\`\n\n# Start Rails\n\n\`bin/rails s\`\n\n# Run Specs\n\nrun with \`bin/rake\`" > README.md && sed -i '' -e 's/ruby-//g' .ruby-version && RUBY_STRING="ruby \"$(more ./.ruby-version)\"" && sed -i '' -e "s/$RUBY_STRING/ruby File.read('.ruby-version').strip/g" Gemfile && git add . && git commit -m "fixes .ruby-version file and set Gemfile to use .ruby-version file" && ./bin/setup && git add . && git commit -m "adds schema file"
Start up your app with rails server
For more information about Importmap, see this post.
1C: Shakapacker
echo "please give your new Shakapcker Rails app a TitleCase name:" && read APP_NAME && rails new $APP_NAME --database=postgresql --skip-javascript && cd $APP_NAME && git add . && git commit -m "initial commit with $(rails -v), Ruby $(more ./.ruby-version)" && printf "Ruby version is in \`.ruby-version\`.\n\n# Setup\n\n\`bin/setup\`\n\n# Start Rails\n\nbin/rails s\\n\n# Run Specs\n\nrun with \bin/rake" > README.md && sed -i '' -e 's/ruby-//g' .ruby-version && RUBY_STRING="ruby \"$(more ./.ruby-version)\"" && sed -i '' -e "s/$RUBY_STRING/ruby File.read('.ruby-version').strip/g" Gemfile && git add . && git commit -m "fixes .ruby-version file and set Gemfile to use .ruby-version file" && bundle exec rails db:create db:migrate && git add . && git commit -m "adds schema file" && bundle add react_on_rails shakapacker && bundle install && git add . && git commit -m "Adds shakapacker and react_on_rails to gemfile" && bundle exec rails webpacker:install && git add . && git commit -m "webpacker install" && rails generate react_on_rails:install && git add . && git commit -m "react_on_rails install" && rm app/controllers/hello_world_controller.rb && rm app/views/layouts/hello_world.html.erb && rm -rf app/views/hello_world/ && sed -i '' -e "s/get \'hello_world\', to: \'hello_world#index\'//g" config/routes.rb && git add . && git commit -m "removing shakapacker hello world" && rails generate controller Welcome &&
sed -i '' -e 's/class WelcomeController < ApplicationController/class WelcomeController < ApplicationController\n def index\n\n end/g' app/controllers/welcome_controller.rb &&
printf "Hello Shakapacker" > app/views/welcome/index.html.erb &&
sed -i '' -e 's/# root "articles#index"//g' config/routes.rb &&
sed -i '' -e 's/Rails.application.routes.draw do/Rails.application.routes.draw do\n root to: "welcome#index"/g' config/routes.rb && git add . && git commit -m "generates Welcome controller" && echo "\nimport './hello-world-bundle'" >> app/javascript/packs/application.js && echo '\n\n<%= react_component("HelloWorld", props: {name: '\"`echo $APP_NAME`\"'}, prerender: false) %>' >> app/views/welcome/index.html.erb && git add . && git commit -m "adding the HelloWorld react component to our Welcome index"
For more about Shakapacker, see this blog post
1C-StimulusJS
To add Stimulus JS on top of script 1C, use this:
bundle add stimulus-rails && echo "\nimport '../controllers'" >> app/javascript/packs/application.js && bin/rails stimulus:install && git add . && git commit -m "adds stimulus"
1D: Vite Rails with React
echo "please give your new Vite Rails app a TitleCase name:" && read APP_NAME && rails new $APP_NAME --skip-javascript --database=postgresql && cd $APP_NAME && git checkout -b main && CURRENT_NODE=$(nvm current) && printf $CURRENT_NODE >> .node-version && printf "Node + Ruby versions are in \`.node-version\` and \`.ruby-version\`, respectively.\n\n# Setup\n\n\`bin/setup\`\n\n# Start Rails\n\n\`bin/vite dev\` + \`bin/rails s\` in two separate windows\nor run \`bin/dev\` in single window and access site at http://127.0.0.1:5100\n\n# Run Specs\n\nrun with \`bin/rake\`" > README.md && git add . && git commit -m "initial commit with $(rails -v), Node $CURRENT_NODE, Ruby $(more ./.ruby-version)" && ./bin/setup && sed -i '' -e 's/ruby-//g' .ruby-version && RUBY_STRING="ruby \"$(more ./.ruby-version)\"" && sed -i '' -e "s/$RUBY_STRING/ruby File.read('.ruby-version').strip/g" Gemfile && git add . && git commit -m "fixes .ruby-version file and sets Gemfile to use .ruby-version file" && npm init -y && git add . && git commit -m "initalizes npm" && bundle add vite_rails && bundle install && git add . && git commit -m "adds vite-rails gem" && bundle exec vite install && git add . && git commit -m "vite rails default setup" && yarn add react react-dom && git add . && git commit -m "yarn add react react-dom" && rails generate controller Welcome && sed -i '' -e 's/class WelcomeController < ApplicationController/class WelcomeController < ApplicationController\n def index\n\n end/g' app/controllers/welcome_controller.rb &&
printf "<div id=\"${APP_NAME}App\"></div>" > app/views/welcome/index.html.erb &&
sed -i '' -e 's/# root "articles#index"//g' config/routes.rb &&
sed -i '' -e 's/Rails.application.routes.draw do/Rails.application.routes.draw do\n root to: "welcome#index"/g' config/routes.rb && git add . && git commit -m "generates Welcome controller" && printf "import App from '~/components/App'\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nconst root = createRoot(document.getElementById('${APP_NAME}App'))\nroot.render(React.createElement(App))" >> app/frontend/entrypoints/application.js && mkdir app/frontend/components && printf "import React from 'react';\nexport default function App () {\n return (<h1>Hello Vite Rails</h1>)\n}" >> app/frontend/components/App.jsx && git add . && git commit -m "basic shell for React app" && printf '#!/usr/bin/env sh\n\nif ! gem list foreman -i --silent; then\n echo "Installing foreman..."\n gem install foreman\nfi\n\nexec foreman start -f Procfile.dev "$@"' > bin/dev && chmod 0755 bin/dev && git add . && git commit -m "adding bin/dev option to start both vite + rails in one terminal window using Foreman"
You will find your new React app root component (the root of your React app) in app/frontend/components/App.jsx. Look at the bottom of the file app/frontend/entrypoints/application.js to see how it gets mounted into the Welcome controller’s index view using a simple DOM ID.
You can use two windows to start Vite + Rails according to the Vite instructions(bin/vite dev
+ bin/rails s
) and access your site at the normal http://localhost:3000/
. Alternatively, you can avoid two-terminal windows by running bin/dev
(which runs Foreman). Under this setup, your app is accessible at port 5100 (http://
)localhost
:5100
When you go to the home page, you should see this in your browser:
Important: Vite server boots by default only at http://localhost:3036/vite-dev/ (not 127.0.0.1). Because the Rails server passes its own domain onto where it looks for the the Vite server (at a different port), you must then also develop locally at localhost, or when accessing Rails, at http://localhost:3000
.
If you try to access your site at 127.0.0.1, you will see errors like this:
GET http://127.0.0.1:3036/vite-dev/ net::ERR_CONNECTION_REFUSED
Prefer Typescript over JS? If so, run this additional script, which will switch your existing JSX setup to Typescript.
1D-TypeScript
sed -i '' -e 's/vite_javascript_tag/vite_typescript_tag/g' app/views/layouts/application.html.erb && mv app/frontend/components/App.jsx app/frontend/components/App.tsx && mv app/frontend/entrypoints/application.js app/frontend/entrypoints/application.ts && sed -i '' -e "s/import App from '~\/components\/App'/import App from '..\/components\/App'/g" app/frontend/entrypoints/application.ts && git add . && git commit -m "switches to typescript"
2/ Testing Setup
Recommend: Always install this section. You will install either A or B (not both).
Choose your testing paradigm: Minitest or Rspec. Minitest is for you if you like the simplicity of Ruby’s raw structures. Rspec is a language DSL where you’ll need to learn some extra syntax to write specs. Minitest is older and more elite and for people who prefer Rubyish syntax. Rspec is more popular and — to some — feels more natural than Minitest. (If you are starting out, I recommend trying both to see how the ergonomics feel for you— that is, how efficiently they make you code.)
With either choice (Minitest or Rspec), these scripts also add Dotenv, FactoryBot, FFaker, VCR, and SimpleCov. These are excellent defaults for beginners just getting started with TDD.
Adds to and modifies .gitignore
for DotEnv and configures coverage reports with SimpeCov, setting them up to print out automatically after every test (see coverage/
— a folder not checked into your repo).
Remember to install one option (Option 2A for Minitest or Option 2B for Rspec) — not both. Both 2A and 2B have an extra block to run for Capybara: marked as 2A-Capy and 2B-Capy below.
2A: Minitest + Friends
bundle add minitest-rails minitest-spec-rails factory_bot_rails ffaker vcr simplecov dotenv-rails --group "development, test" && bundle add simplecov-rcov launchy --group "test" && git add . && git commit -m "adds minitest, factory_bot_rails, ffaker, VCR, simplecov, dotenv-rails" && printf "\n.env\n.env.local\n.env.*.local\n\ncoverage/" >> .gitignore && printf "" >> .env.local && git add . && git commit -m "adds .env, etc and coverage/ to .gitignore file" && sed -i '' -e "s/require \"rails\/test_help\"/require \"rails\/test_help\"\nrequire 'simplecov'\nrequire 'simplecov-rcov'\nclass SimpleCov::Formatter::MergedFormatter\n def format(result)\n SimpleCov::Formatter::HTMLFormatter.new.format(result)\n SimpleCov::Formatter::RcovFormatter.new.format(result)\n end\nend\nSimpleCov.formatter = SimpleCov::Formatter::MergedFormatter\nSimpleCov.start 'rails' do\n add_filter \"\/vendor\"\nend\n\n/g" test/test_helper.rb &&
git add . && git commit -m "configuring simplecov"
2A-Capy: Capybara for Minitest
printf "require 'minitest/autorun'\nrequire 'application_system_test_case'\n\n\nclass HomepageTest < Minitest::Test \n test 'can load' do\n visit '/'\n assert(page.has_content?('Hello World'), 'page missing Hello World')\n end\nend" > test/system/homepage_test.rb && printf "Capybara.register_driver :selenium do |app|\n options = Selenium::WebDriver::Chrome::Options.new(\n # It's the headlese arg that make Chrome headless\n # + you also need the disable-gpu arg due to a bug\n args: ['headless', 'disable-gpu window-size=1366,1200'],\n )\n\n Capybara::Selenium::Driver.new(\n app,\n browser: :chrome,\n options: options\n )\nend\n\nCapybara.default_driver = :selenium" >> test/test_helper.rb && git add . && git commit -m "basic capybara example"
2B: Rspec + Friends
rm -rf test/ && bundle add rspec-rails rspec-wait factory_bot_rails ffaker vcr simplecov dotenv-rails webmock --group "development, test" && bundle add simplecov-rcov launchy --group "test" &&
rails generate rspec:install &&
git add . && git commit -m "adds rspec, factory bot, ffaker, vcr, simplecov, and launchy" && printf "\n.env\n.env.local\n.env.*.local\n\ncoverage/" >> .gitignore && printf "" >> .env.local && git add . && git commit -m "adds .env, etc and coverage/ to .gitignore file" && sed -i '' -e 's/RSpec.configure do |config|/RSpec.configure do |config|\n config.include FactoryBot::Syntax::Methods\n/g' spec/rails_helper.rb && sed -i '' -e "s/RSpec.configure do |config|/require 'simplecov'\nrequire 'simplecov-rcov'\nclass SimpleCov::Formatter::MergedFormatter\n def format(result)\n SimpleCov::Formatter::HTMLFormatter.new.format(result)\n SimpleCov::Formatter::RcovFormatter.new.format(result)\n end\nend\nSimpleCov.formatter = SimpleCov::Formatter::MergedFormatter\nSimpleCov.start 'rails' do\n add_filter \"\/vendor\"\nend\n\nVCR.configure do |config|\n config.cassette_library_dir = \"spec\/fixtures\/vcr_cassettes\"\n config.hook_into :webmock\n config.ignore_request do |request|\n [\"127.0.0.1\", \"chromedriver.storage.googleapis.com\" , \"googlechromelabs.github.io\", \"edgedl.me.gvt1.com\"].include? URI(request.uri).host\n end\nend\n\n\nRSpec.configure do |config|/g" spec/rails_helper.rb && git add . && git commit -m "adding factorybot and simplecov to Rspec config" && sed -i '' -e 's/< Rails::Application/< Rails::Application\n config.generators do |generate|\n generate.helper false\n\n generate.assets false\n generate.helper false\n generate.stylesheets false\n generate.test_framework :rspec,\n request_specs: false,\n view_specs: false,\n controller_specs: false,\n helper_specs: false,\n routing_specs: false,\n fixture: false,\n fixture_replacement: "factory_bot"\n end\n/g' config/application.rb && git add . && git commit -m "disables extraneous generators"
2B-Capy: Capybara for Rspec
mkdir spec/features && printf "require 'rails_helper'\n\ndescribe 'homepage' do\n it 'can load' do\n visit '/'\n expect(page).to have_content('Hello World')\n end\nend" >> spec/features/homepage_spec.rb && printf "Capybara.register_driver :selenium do |app|\n options = Selenium::WebDriver::Chrome::Options.new(\n # It's the headlese arg that make Chrome headless\n # + you also need the disable-gpu arg due to a bug\n args: ['headless', 'disable-gpu window-size=1366,1200'],\n )\n\n Capybara::Selenium::Driver.new(\n app,\n browser: :chrome,\n options: options\n )\nend\n\nCapybara.default_driver = :selenium" >> spec/rails_helper.rb && git add . && git commit -m "basic capybara example"
To get the Hello World working (to make the spec pass), see section #3.
If you are not on Rails 7.0.7 or higher, be sure to run bundle remove webdrivers
.
3/ Generate a Default Welcome Controller
Recommend: Install for a Quick Home Page
Use this for quick demo apps that need a “home page.” All you get here is a root route pointing to Welcome#index
, a corresponding WelcomeController with an index action, and a view at app/views/welcome/index.html.erb. This can be considered your “home page” for your new app.
You can mix this with a default setup from Section 1 if you have chosen JSBundling (1A) or Importmap (1B). Shakapacker (1C) and Vite Rails (1D) have their own hello world page (created in React) which is already included.
rails generate controller Welcome &&
sed -i '' -e 's/class WelcomeController < ApplicationController/class WelcomeController < ApplicationController\n def index\n\n end/g' app/controllers/welcome_controller.rb &&
printf "Hello World" > app/views/welcome/index.html.erb &&
sed -i '' -e 's/# root "articles#index"//g' config/routes.rb &&
sed -i '' -e 's/Rails.application.routes.draw do/Rails.application.routes.draw do\n root to: "welcome#index"/g' config/routes.rb && git add . && git commit -m "generates Welcome controller"
4/ Misc Options: Generator Settings, Lint, Typecheck, GUID, Github Actions, or CircleCI
In this section, we have six optional setup tools (unrelated to each other). You may want to start with if you are starting a new Rails app. If you don’t know these, skip this section and move on to section 5.
4A/ Disables generators (Rspec)
Disables Rails from generating auxiliary files — empty helpers, assets, and stylesheets. Also disables all but model specs from being generated automatically by Rspec. I don’t like having the empty files around, which happens as you use the generators and don’t actually add anything to the empty files it creates. I realize that the empty files are supposed to remind you to use them, but I prefer to have them not created at all.
sed -i '' -e "s/class Application < Rails::Application/class Application < Rails::Application\n config.generators do |generate|\n generate.helper false\n\n generate.assets false\n generate.stylesheets false\n generate.test_framework :rspec,\n request_specs: false,\n view_specs: false,\n controller_specs: false,\n helper_specs: false,\n routing_specs: false,\n fixture: false,\n fixture_replacement: 'factory_bot'\n end\n/g" config/application.rb && git add . && git commit -m "disables auxillary files in generators, disables most rspec generators"
4B/ Add Rubocop (Linting)
bundle add rubocop && printf "AllCops:\n NewCops: enable\n SuggestExtensions: false\n\nStyle/Documentation:\n Enabled: false\n\nStyle/FetchEnvVar:\n Enabled: false\n\nMetrics/MethodLength:\n Enabled: false\n\nLayout/LineLength:\n Max: 120\n\nMetrics/BlockLength:\n Enabled: false\n\n\nLint/EmptyBlock:\n Enabled: false\n\n" >> .rubocop.yml && printf '#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n#\n# This file was generated by Bundler.\n#\n# The application 'rubocop' is installed as part of a gem, and\n# this file is here to facilitate running it.\n#\n\nrequire "pathname"\nENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",\n Pathname.new(__FILE__).realpath)\n\nbundle_binstub = File.expand_path("../bundle", __FILE__)\n\nif File.file?(bundle_binstub)\n if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/\n load(bundle_binstub)\n else\n abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.\nReplace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")\n end\nend\n\nrequire "rubygems"\nrequire "bundler/setup"\n\nload Gem.bin_path("rubocop", "rubocop")' >> bin/rubocop && chmod 0755 bin/rubocop && git add . && git commit -m "adds rubocop" && (bin/rubocop -A) || git add . && git commit -m "rubocop autocorrections"
Run rubocop with bin/rubocop -A
Because the default Rails code produces code that does not lint correctly with Rubocop, you’ll need to fix the Rubocop errors, check in the fixes, and re-run bin/rubocop -A
until you see it lint cleanly. For details, see this post.
When bin/rubocop -A
runs with only green dots, it will say “no offenses detected” like so:
4C/ Sorbet (Ruby Type checking)
bundle add sorbet-static-and-runtime && bundle add tapioca --group development && bundle exec srb typecheck -e 'puts "Hello, world!"' && bundle exec ruby -e 'puts(require "sorbet-runtime")' && bundle exec tapioca init && git add . && git commit -m "adds sorbet"
Now run srb tc
to run the type checker.
4D/ Postgres UUIDs
Makes all new models in Postgres use UUIDs. Be sure that you create foreign keys using the uuid
field type. It will not work retroactively, so I recommend you do this at the start of your app. Sets your database up for Postgres UUIDs. After running this script, new models that are created with the model generator will have id: :uuid
in the create_table
syntax in the migration. This will mean that their primary keys use UUID and not integers for the id
column.
mkdir db/migrate && echo "# frozen_string_literal: true\n\nclass CreatePostgresExtensions < ActiveRecord::Migration[7.0]\n def change\n ActiveRecord::Base.connection.execute('CREATE EXTENSION pgcrypto;')\n end\nend\n" >> db/migrate/00000000000000_create_postgres_extensions.rb && sed -i '' -e 's/class Application < Rails::Application/class Application < Rails::Application\n config.generators do |generate|\n generate.orm :active_record, primary_key_type: :uuid\n end\n/g' config/application.rb && git add . && git commit -m "sets up for postgres UUIDs"
When creating a foreign key to another table, be sure to use the uuid
field type instead of an integer like so. Notice that the author_id
field is a uuid
and not an integer
as a typical foreign key.
bin/rails generate model Book title:string author_id:uuid
4E/ Add Postgres Enum Extension
echo 'class EnablePostgresEnums < ActiveRecord::Migration[7.1]\n def up\n enable_extension "plpgsql"\n end\nend\n\n' >> db/migrate/00000000000000_enable_postgres_enums.rb
4F/ Github Actions CI Setup — Adds a .github/workflows/test_suite.yml
file for a Rails app with Node and Postgres
TODO:
fix the section below steps
mkdir .github && mkdir .github/workflows && printf '# This workflow uses actions that are not certified by GitHub. They are\n# provided by a third-party and are governed by separate terms of service,\n# privacy policy, and support documentation.\n#\n# This workflow will install a prebuilt Ruby version, install dependencies, and\n# run tests and linters.\nname: "Test Suite"\non:\n push:\n branches: [ "main" ]\n pull_request:\n branches: [ "main" ]\njobs:\n test:\n runs-on: ubuntu-latest\n services:\n postgres:\n image: postgres:11-alpine\n ports:\n - "5432:5432"\n env:\n POSTGRES_DB: rails_test\n POSTGRES_USER: rails\n POSTGRES_PASSWORD: password\n chrome:\n image: selenium/standalone-chrome:latest\n ports:\n - 4444:4444\n env:\n RAILS_ENV: test\n DATABASE_URL: "postgres://rails:password@localhost:5432/rails_test"\n\n\n steps:\n - name: Checkout code\n uses: actions/checkout@v3\n - name: Install Ruby and gems\n uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0\n with:\n bundler-cache: true\n\n - name: Setup Node\n uses: actions/setup-node@v2\n with:\n node-version: 18\n\n - name: npm install\n run: npm install\n \n - name: Set up database schema\n run: bin/rails db:schema:load\n - name: Run tests\n run: bin/rake\n\n' > .github/workflows/test_suite.yml && bundle lock --add-platform x86_64-linux && git add . && git commit -m "github actions CI setup" && bin/rails db:migrate && git add db/schema.rb && git commit -m "db/schema.rb file"
You can use this script after 2A or 2B to set up your app for running on Github Actions as your CI runner. Use this for a JSBundling, Vite, or Shakapacker app only.
If you are using Rubocop, add this lint job: (see Section 3B)
sed -i '' -e 's/jobs:/jobs:\n lint:\n runs-on: ubuntu-latest\n steps:\n - uses: actions\/checkout@v3\n - uses: ruby\/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0\n \n\n - run: bundle install\n - name: Rubocop\n run: rubocop/g' .github/workflows/test_suite.yml && git add . && git commit -m "adds rubocop lint job to Github Actions"
4G/ Circle CI (Rspec)
APP_NAME=$(bundle exec rails runner "puts Rails.application.class.module_parent_name") && bundle add rspec_junit_formatter && mkdir .circleci && printf "# more about orbs: https://circleci.com/docs/2.0/using-orbs/\nversion: 2.1\n\norbs:\n ruby: circleci/ruby@1.0\n node: circleci/node@2\n browser-tools: circleci/browser-tools@1.4.4\n\njobs:\n build:\n docker:\n - image: cimg/ruby:3.2.2-browsers\n auth:\n username: mydockerhub-user\n password: $DOCKERHUB_PASSWORD\n - image: redis:6.2.6\n\n steps:\n - checkout\n - ruby/install-deps\n - node/install-packages:\n pkg-manager: yarn\n cache-key: 'yarn.lock'\n - run:\n name: Build assets\n command: bundle exec rails assets:precompile\n\n test:\n parallelism: 1\n docker:\n - image: cimg/ruby:3.2.2-browsers\n auth:\n username: mydockerhub-user\n password: $DOCKERHUB_PASSWORD\n - image: redis:6.2.6\n - image: cimg/postgres:13.7\n auth:\n username: mydockerhub-user\n password: $DOCKERHUB_PASSWORD\n environment:\n POSTGRES_USER: circleci-demo-ruby\n POSTGRES_DB: `$APP_NAME`_test\n POSTGRES_PASSWORD: ''\n\n environment:\n BUNDLE_JOBS: '3'\n BUNDLE_RETRY: '3'\n PGHOST: 127.0.0.1\n PGUSER: circleci-demo-ruby\n POSTGRES_USERNAME: circleci-demo-ruby\n POSTGRES_PASSWORD: ''\n PGPASSWORD: ''\n RAILS_ENV: test\n\n steps:\n - run: sudo apt-get update\n - browser-tools/install-browser-tools:\n chrome-version: 116.0.5845.96 # TODO: remove when chromedriver downloads are fixed\n replace-existing-chrome: true\n - browser-tools/install-chrome:\n # TODO remove following line when fixed https://github.com/CircleCI-Public/browser-tools-orb/issues/90\n chrome-version: 116.0.5845.96\n replace-existing: true\n - browser-tools/install-chromedriver\n - checkout\n - ruby/install-deps\n - node/install-packages:\n pkg-manager: yarn\n cache-key: 'yarn.lock'\n - run:\n name: Wait for DB\n command: dockerize -wait tcp://localhost:5432 -timeout 1m\n - run:\n name: Load schema\n command: bin/rails db:schema:load RAILS_ENV=test\n - ruby/rspec-test\n\nworkflows:\n version: 2\n build_and_test:\n jobs:\n - build\n - test:\n requires:\n - build\n" > .circleci/config.yml && git add . && git commit -m "adds cirleci default config"
• bundle add rspec_junit_formatter
5/ Debugging Tools
Recommend: Always install
Adds Byebug, Bullet, Active Record Query Trace, and Ruby Critic as default debugging tools.
Byebug can be your go-to debugger and you can drop into a debugger anywhere by putting the statement byebug
on its own line of code. (Tip: try not to put this at the end of a block or method.)
Bullet is used to detect and alert you any N+1 queries you have made by accident. It is always enabled (look for it in your Rails logs).
Active Record Query Trace allows you to examine each and every database query and see exactly which line of code it comes from — yours or lines of code in your gem code. This default setup does not enable it, but a config file is added for you. To use it, enable set ActiveRecordQueryTrace.enabled
to true in config/initializers/active_record_query_trace.rb (then restart your Rails server and look for the trace output in your Rails logs). To get to understand the tool, try to adjust the setting for ActiveRecordQueryTrace.lines
— this determines how long (many lines) the backtrace is for each traced database query. You use this tool to identify where in your code a specific database query is being invoked.
Finally, Ruby Critic is a tool for measuring your code’s cyclomatic complexity. You run it using bin/rubycritic
.
bundle add bullet active_record_query_trace byebug --group "development, test" && sed -i '' -e "s/Rails.application.configure do/Rails.application.configure do\n config.after_initialize do\n Bullet.enable = true\n Bullet.alert = false\n Bullet.bullet_logger = true\n Bullet.console = true\n Bullet.rails_logger = true\n Bullet.add_footer = true\n end\n/g" config/environments/development.rb && printf "if Rails.env.development?\n ActiveRecordQueryTrace.enabled = false\n\n ActiveRecordQueryTrace.level = :full # :app, :rails, or :full\n ActiveRecordQueryTrace.lines = 10\n \nend" >> config/initializers/active_record_query_trace.rb && echo "\n.byebug_history" >> .gitignore && git add . && git commit -m "adding byebug, bullet, active_record_query_trace" && bundle add rubycritic --group dev && bundle install && printf "bundle exec rubycritic" > bin/rubycritic && chmod 0755 bin/rubycritic && git add . && git commit -m "adds rubycritic; run with bin/rubycritic"
6/ Hot Glue (pick A or B)
Hot Glue is built by yours truly. It is a rapid prototype toolkit for building bespoke dashboard-like Rails Turbo interfaces quickly. It lets you build list & CRUD views instantly, plus a boatload more features. Be sure to run Rspec install first (see section 3), which is required. When you install Hot Glue, you need to pick a theme: Bootstrap, Tailwind, or no theme. (Tailwind is experimental, so I recommend Bootstrap for getting started.). Although to get it to work with Bootstrap, you’ll have to also install Bootstrap (see Section 7, below), you may run the Hot Glue step here using the Bootstrap theme before you install CSSBundling+Bootstrap below.
6A: Add Hot Glue With Bootstrap
bundle add hot-glue && git add . && git commit -m "adds hot-glue" &&
rails generate hot_glue:install --layout=bootstrap && git add . && git commit -m "hot glue setup (bootstrap)"
6B: Add Hot Glue with Tailwind (experimental)
bundle add hot-glue && git add . && git commit -m "adds hot-glue" &&
rails generate hot_glue:install --layout=tailwind && git add . && git commit -m "hot glue setup (bootstrap)"
see also
7/ CSS Options (pick A or B)
If you go through the steps above, you will have a JSBundlnig app without CSSBundling. Add cssbundling-rails with Bootstrap (A) or Tailwind (B).
7A: Bootstrap + SASSC-Rails
bundle add sassc-rails && bundle install && git add . && git commit -m "adds sassc-rails" && bundle add cssbundling-rails && rails css:install:bootstrap && sed -i '' -e 's/Rails.application.configure do/Rails.application.configure do\n config.sass.inline_source_maps = true/g' config/environments/development.rb && git add . && git commit -m "adds cssbundling-rails with bootstrap" && sed -i '' -e 's/# gem "sassc-rails"/gem "sassc-rails"/g' Gemfile && bundle install && git add . && git commit -m "adds sassc-rails"
Add Bootstrap Navbar:
bundle exec rails runner "puts Rails.application.class.module_parent_name" > $APP_NAME && sed -i '' -e 's/<body>/<body>\n<nav class="navbar navbar-expand-lg navbar-light bg-light">\n <div class="container-fluid">\n <a class="navbar-brand" href="#">'$APP_NAME'<\/a>\n <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">\n <span class="navbar-toggler-icon"><\/span>\n <\/button>\n <div class="collapse navbar-collapse" id="navbarSupportedContent">\n <ul class="navbar-nav me-auto mb-2 mb-lg-0">\n <li class="nav-item">\n <a class="nav-link active" aria-current="page" href="#">Home<\/a>\n <\/li>\n <li class="nav-item">\n <a class="nav-link" href="#">Link<\/a>\n <\/li>\n <li class="nav-item dropdown">\n <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">\n Dropdown\n <\/a>\n <ul class="dropdown-menu" aria-labelledby="navbarDropdown">\n <li><a class="dropdown-item" href="#">Action<\/a><\/li>\n <li><a class="dropdown-item" href="#">Another action<\/a><\/li>\n <li><hr class="dropdown-divider"><\/li>\n <li><a class="dropdown-item" href="#">Something else here<\/a><\/li>\n <\/ul>\n <\/li>\n <li class="nav-item">\n <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled<\/a>\n <\/li>\n <\/ul>\n <form class="d-flex">\n <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">\n <button class="btn btn-outline-success" type="submit">Search<\/button>\n <\/form>\n <\/div>\n <\/div>\n<\/nav>/g' app/views/layouts/application.html.erb && git add . && git commit -m "adds bootstrap navbar"
Be sure to confirm that the drop-down menu works in the navbar. It will look like this when you click on (or tap for mobile) the menu “Dropdown”.
If the drop-down menu does not show up, you have not installed the Javascript correctly. Try to fix by taking these steps manually.
Also check the mobile view, which shows a hamburger menu like so:
QUICK TROUBLESHOOTING STEPS FOR BOOTSTRAP ON RAILS 7
- If you have a JSBundlnig, Vite Rails, or Shakapacker app, be sure you are starting it correctly using Foreman (
bin/dev
. If you boot Rails only the old way (rails server
orrails s
), your CSS compiler isn’t recompiling your assets. - run
yarn add @popperjs/core bootstrap bootstrap-icons sass
and confirm that these are added as dependencies inpackage.json
- confirm that the
Procfile.dev
containscss: yarn watch:css
- confirm that the
package.json
file contains ascripts
entry forwatch:css
andbuild:css
- confirm there is a file at
app/assets/sytlesheets/application.bootstrap.css
that contains:@import 'bootstrap/scss/bootstrap';
@import 'bootstrap-icons/font/bootstrap-icons';
- confirm that
app/javascript/application.js
containsimport * as bootstrap from "bootstrap"
7B: Tailwind
bundle add cssbundling-rails &&
./bin/rails css:install:tailwind &&
git add . && git commit -m "adds cssbundling-rails with tailwind" &&
rails generate controller Articles &&
printf '<div class="bg-blue-900 text-center py-4 lg:px-4"><div class="p-2 bg-blue-800 items-center text-blue-100 leading-none lg:rounded-full flex lg:inline-flex" role="alert"><span class="flex rounded-full bg-blue-500 uppercase font-bold px-2 py-1 text-xs mr-3">New</span><span class="font-semibold mr-2 text-left flex-auto">Hello Tailwind</span></div></div>' >> app/views/articles/index.html.erb
sed -i '' -e 's/# root "articles#index"/ root "articles#index"/g' config/routes.rb
git add . && git commit -m "adds tailwind element"
8/ Kaminari for Pagination
bundle add kaminari --git="https://github.com/jasonfb/kaminari.git" --branch="master" &&
rails generate kaminari:views bootstrap4 &&
sed -i '' -e "s/class: 'page-link'/class: 'page-link', 'data-turbo-action': 'advance'/g" app/views/kaminari/_first_page.html.erb app/views/kaminari/_gap.html.erb app/views/kaminari/_last_page.html.erb app/views/kaminari/_next_page.html.erb app/views/kaminari/_page.html.erb app/views/kaminari/_prev_page.html.erb && git add . && git commit -m "adding kaminari and views"
Note 1: This generates default Kaminari views and adds 'data-turbo-action': 'advance'
to all links.
Note 2: The templates for bootstrap4
work identically for Bootstrap 5, and there is no option to generate for Bootstrap 5, so use bootstrap4
here, even though you are building for Bootstrap 5
9/ 12-Factor, Nonschema Migrations (for Heroku/ Fly.io/any other 12-Factor provider)
Note that 12-Factor apps have special requirements (like not writing to the disk). Read more about 12-factor apps here. The most important parts of building a 12 Factor app are:
- Dev-prod parity (you run things the same way in dev and prod)
- Nothing can be written to the localfile system, so you must use an external service for things like images
- The logs themselves should not be written to the filesystem. Instead, they’ll get piped out to an external service.
For all apps that you intend to deploy to a cloud environment. Not needed for demo apps or teaching apps that you will only run locally. Here, we add:
- a
Procfile
that defines for Heroku the types of processes your app will run - a
release-tasks.sh
file that tells Heroku what to do on releasing - my gem Nondestructive Migrations
(Note that the old “12Factor Gem” is no longer needed for new Rails apps, so it is not installed anymore even though this step is called the “12 Factor” step in this guide.)
printf "web: bundle exec rails server -p \$PORT\nrelease: ./release-tasks.sh" >> Procfile && printf "# Step to execute\nbundle exec rails db:migrate data:migrate\n# check for a good exit\nif [ $? -ne 0 ]\nthen\n puts '*** RELEASE COMMAND FAILED'\n # something went wrong; convey that and exit\n exit 1\nfi" >> ./release-tasks.sh && chmod 0755 release-tasks.sh && sed -i '' -e 's/# config.force_ssl = true/config.force_ssl = true/g' config/environments/production.rb && bundle install && bundle lock --add-platform x86_64-linux && git add . && git commit -m "setup for Heroku" && bundle add nonschema_migrations && rails generate data_migrations:install && rails db:migrate && git add . && git commit -m "adds nonschema_migrations"
Recipe 10/ Users & Roles (with Devise)
10A/ Install Devise for Rails
Devise 4.9.0 and above now works with Rails 7 with no issues
bundle add devise && bundle install &&
git add . && git commit -m "adding devise gem" &&
rails generate devise:install &&
git add . && git commit -m 'devise install' &&
sed -i '' -e 's/Rails.application.configure do/Rails.application.configure do\n config.action_mailer.default_url_options = { host: "localhost", port: 3000 }/g' config/environments/development.rb &&
git add . && git commit -m 'devise setup'
To make an authenticated user. This also adds a quick Logout button in the main layout app/views/layouts/application.html.erb, but you can move that anywhere (like your nav bar).
10B/ Create a user model
rails generate devise User name:string && rails db:migrate && git add . && git commit -m "generating devise user" && sed -i '' -e 's/<body>/<body><\% if user_signed_in? %>\n <div> Logged in as <%= current_user.email %>\\n <%= button_to "Logout", destroy_user_session_path, method: :delete %><hr \/><\/div>\n <% elsif ! controller.class.to_s.include?("Devise")%>\n <%= link_to "Login", new_user_session_path %> | <%= link_to "Signup", new_user_registration_path %><hr \/><\/div>\n <% end %>/g' app/views/layouts/application.html.erb
10C/ Create a Role
model and Join it to User
using UserRole
Note: This requires 9, 10A, 10B
bin/rails generate model Role name:string label:string && bin/rails generate model UserRole user_id:integer role_id:integer && sed -i '' -e 's/end/\n has_many :user_roles\n has_many :roles, through: :user_roles\nend/g' app/models/user.rb && sed -i '' -e 's/end/\n has_many :user_roles\n has_many :roles, through: :user_roles\nend/g' app/models/user.rb && sed -i '' -e 's/class UserRole < ApplicationRecord/class UserRole < ApplicationRecord\n belongs_to :user\n belongs_to :role\n/g' app/models/user_role.rb && sed -i '' -e 's/class Role < ApplicationRecord/class Role < ApplicationRecord\n has_many :user_roles\n has_many :users, through: :user_roles\n/g' app/models/role.rb && git add . && git commit -m "adds UserRole and Role"
Make some new roles with
rails generate data_migration AddRoles
Then add some roles to your newly created data migration which was created for your in the folder db/data_migrate/
Add roles appropriate for your app. Notice here that I use a name field which will be queried off directly from the code (eg Role.find_by(name: "admin")
), but display it using a label field. This design allows easy changing of the label but hard-codes the role name into your codebase.
class AddRoles < ActiveRecord::Migration[7.0]
def change
Role.create(name: "admin", label: "Admin")
Role.create(name: "user", label: "User")
Role.create(name: "superadmin", label: "Superadmin")
end
end
Recipe 10D: Use Hot Glue to build a Roles Checkbox Set
Requires: 2B (Rspec), 6 (Hot Glue), 10A, 10B, and 10C.
rails generate hot_glue:scaffold User --related-sets=roles --include=email,roles --namespace=admin --gd
Be sure to add this to your config/routes.rb file too:
namespace :admin do
resources :users
end
In this example, I’ve created a Gd controller in the admin namespace to edit users, which isn’t properly authenticated.
Notice that even if it were properly authenticated, it has two large problems:
(1) Privileged escalation — When a user has access to upgrade their own role to a role allowing them more access than the are supposed to have. (This is a common security vulnerability.) In most designs, only users with superadmin role can assign roles to other users. Alterntiavely, a system can allow you to assign roles that are your role or below. A privileged escalation vulnerability would be if an admin user could assign the superadmin role to themselves, giving themselves more power than they should have.
(2) Accidental plunge — The opposite of privileged escalation, when an user accidentally unassigns themselves (or someone else) from the role giving them the ability to assign & unassign roles. (For example, you accidentally unassign youself the superadmin role or you accidentally unassign yourself the admin role and you can no longer access your own interface.)
To fix both of these problems, see Example 17 in the HOT GLUE TUTORIAL
Recipe 11/ Image Processing with Amazon S3 + Dropzone
if ( command -v vips &> /dev/null ); then; printf "vips already installed..."; else; echo "installing vips with brew..."; brew install vips; fi && bundle add image_processing aws-sdk-s3 && ./bin/rails active_storage:install && ./bin/rails db:migrate && git add . && git commit -m "installing active storage" && sed -i '' -e 's/config.active_storage.service = :local/config.active_storage.service = :amazon/g' config/environments/development.rb && sed -i '' -e 's/config.active_storage.service = :local/config.active_storage.service = :amazon/g' config/environments/production.rb && yarn add dropzone && yarn add @rails/activestorage &&
sed -i '' -e 's/# amazon:/amazon:/g' config/storage.yml && sed -i '' -e 's/# service: S3/ service: S3/g' config/storage.yml &&
sed -i '' -e 's/# access_key_id: \<%= Rails.application.credentials.dig(:aws, :access_key_id) %\>/ access_key_id: \<%= Rails.application.credentials.dig(:aws, :access_key_id) %\>/g' config/storage.yml &&
sed -i '' -e 's/# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>/ secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>/g' config/storage.yml &&
sed -i '' -e 's/# region: us-east-1/ region: us-east-1/g' config/storage.yml && git add . && git commit -m "adds image processing, amazon s3, configures for S3" && echo "REMEMBER TO 1) Set your bucket name is config/storage.yml and 2) set your access_key_id and secret_access_key in Rails credentials and 3) Configure CORS on the bucket"
Also, add this to your github actions
- name: Install libvips library ubuntu
run: |
sudo apt-get update
sudo apt-get install -y libvips
12/ Sidekiq & Sidekiq Scheduler
For all apps that will require jobs or scheduled jobs.
• Be sure to run the 12-Factor (Heroku) setup above first.
bundle add sidekiq sidekiq-scheduler && printf "require 'sidekiq/web'\n" >> config/initializers/sidekiq.rb && printf "\nworker: bundle exec sidekiq" >> Procfile && printf "\nworker: bundle exec sidekiq" >> Procfile.dev && sed -i '' -e 's/Rails.application.routes.draw do/Rails.application.routes.draw do\n mount Sidekiq::Web => "\/sidekiq" /g' config/routes.rb && printf "#:scheduler:\n# add your scheduled jobs here and uncomment line above" >> config/sidekiq.yml && git add . && git commit -m "adds sidekiq with configuration"
13/ Pundit
Add Pundit
bundle add pundit && sed -i '' -e 's/ApplicationController < ActionController::Base/ApplicationController < ActionController::Base\n include Pundit::Authorization/g' app/controllers/application_controller.rb && bundle install && rails g pundit:install && git add . && git commit -m "pundit gem"
14/ Passwordless with Email or SMS Login, Twilio
14-A: Passwordless implementation giving user the choice of Email or SMS to login
APP_NAME=$(bundle exec rails runner "puts Rails.application.class.module_parent_name") && bundle add passwordless &&
bin/rails passwordless_engine:install:migrations &&
sed -i '' -e 's/< ActionController::Base/< ActionController::Base\n include Passwordless::ControllerHelpers\n\n\n helper_method :current_user\n\n private\n\n def current_user\n @current_user ||= authenticate_by_session(User)\n end\n\n def require_user!\n return if current_user\n save_passwordless_redirect_location!(User) # <-- optional, see below\n redirect_to root_path, alert: "You are not logged in."\n end/g' app/controllers/application_controller.rb &&
bin/rails generate model user email:string phone:string last_confirmed_at:datetime &&
bin/rails db:migrate && git add . &&
git commit -m "user model with phone and email" &&
echo "# frozen_string_literal: true\n\nclass User < ApplicationRecord\n passwordless_with :email_or_phone\n\n validates_presence_of :email, if: -> { phone.blank? }\n validates_presence_of :phone, if: -> { email.blank? }\n\n def email_or_phone\n email || phone\n end\n\n def self.fetch_resource_for_passwordless(email_or_phone)\n if email_or_phone.include?('@')\n user = find_or_create_by(email: email_or_phone)\n else\n user = find_or_create_by(phone: email_or_phone)\n end\n user\n end\n\n def self.passwordless_phone_field\n :phone\n end\nend\n" > app/models/user.rb &&
sed -i '' -e 's/< Rails::Application/< Rails::Application\n overrides = "#{Rails.root}\/app\/overrides"\n Rails.autoloaders.main.ignore(overrides)\n\n config.to_prepare do\n Dir.glob("#{overrides}\/**\/*_override.rb").sort.each do |override|\n load override\n end\n end/g' config/application.rb &&
mkdir app/overrides && mkdir app/overrides/controllers && mkdir app/overrides/controllers/passwordless &&
mkdir lib/passwordless &&
echo '# frozen_string_literal: true\n\nPasswordless::SessionsController.class_eval do\n helper_method :phone_field\n \n def phone_field\n authenticatable_class.passwordless_email_field\n rescue NoMethodError => e\n raise(\n StandardError,\n <<~MSG\n undefined method passwordless_phone_field for #{authenticatable_type}\n MSG\n .strip_heredoc,\n caller[1..-1]\n )\n end\n\n def find_authenticatable\n if passwordless_session_params[:signin_method] == "email"\n if authenticatable_class.respond_to?(:fetch_resource_for_passwordless)\n authenticatable_class.fetch_resource_for_passwordless(normalized_email_param)\n else\n User.find_by("lower(#{email_field}) = ?", normalized_email_param).first\n if !user\n User.find_or_create_by(email: normalized_email_param)\n end\n end\n elsif passwordless_session_params[:signin_method] == "sms"\n User.find_or_create_by(phone: passwordless_session_params[:phone])\n end\n end\n \n def passwordless_session_params\n params.require(:passwordless).permit(:token,\n :signin_method,\n authenticatable_class.passwordless_phone_field,\n authenticatable_class.passwordless_email_field)\n end\nend\n\n' > app/overrides/controllers/passwordless/sessions_controller_override.rb &&=
echo 'module Passwordless\n class ShortCodeGenerator\n CHARS = [*"0".."9"].freeze\n\n def call(_session)\n CHARS.sample(6).join\n end\n end\nend\n' > lib/passwordless/short_code_generator.rb &&
sed -i '' -e 's/require "rails\/all"/require "rails\/all"\nrequire ".\/lib\/passwordless\/short_code_generator"/g' config/application.rb && bundle exec rails runner "puts Rails.application.class.module_parent_name" > $APP_NAME &&
echo "Passwordless.configure do |config|\n config.parent_mailer = \"`echo $APP_NAME`Mailer\"\n\n config.token_generator = Passwordless::ShortCodeGenerator.new # Used to generate magic link tokens.\n\n config.after_session_save = lambda do |session, request|\n if request.params[:passwordless][:signin_method] == \"email\"\n Passwordless::Mailer.sign_in(session, session.token).deliver_now\n elsif request.params[:passwordless][:signin_method] == \"sms\"\n SmsService.send_sms(phone_number: session.authenticatable.phone,\n message: \"Use code #{session.token} on quickvideo.chat to sign in (code will expire in 10 minutes)\")\n end\n end\nend\n" > config/initializers/passwordless.rb &&
git add . && git commit -m "passwordless implementation" && mkdir mkdir app/views/passwordless && mkdir app/views/passwordless/sessions &&
echo '\n\n<div class="container">\n\n <div data-controller="login-screen-selector">\n <h1>How would you like to sign in?</h1>\n <div class="row">\n\n <div class="col-md-6">\n <%= radio_button_tag :contact_method, "Email", true,\n "data-login-screen-selector-target": "emailButton" %>\n <%= label_tag :contact_method_email, "Email",\n\n for: "contact_method_Email" %>\n </div>\n\n <div class="col-md-6">\n <%= radio_button_tag :contact_method, "SMS",\n "data-login-screen-selector-target": "smsButton" %>\n <%= label_tag :contact_method_sms, "SMS", for: "contact_method_SMS" %>\n </div>\n </div>\n <div class="row">\n <div class="col-md-6">\n\n <%= form_with(model: @session,\n url: url_for(action: "new"),\n data: { turbo: "false",\n "login-screen-selector-target": "emailForm",\n }) do |f| %>\n\n <% email_field_name = :"passwordless[#{email_field}]" %>\n\n <%= hidden_field_tag "passwordless[signin_method]", "email" %>\n\n <%= f.label email_field_name,\n t("passwordless.sessions.new.email.label"),\n for: "passwordless_#{email_field}" %>\n\n\n <%= email_field_tag email_field_name,\n params.fetch(email_field_name, nil),\n id: "passwordless_#{email_field}",\n required: true,\n autofocus: true,\n placeholder: t("passwordless.sessions.new.email.placeholder") %>\n <%= f.submit t("passwordless.sessions.new.submit"), class: " btn-primary btn btn-large" %>\n <% end %>\n\n </div>\n\n <div class="col-md-6">\n <%= form_with(model: @session,\n url: url_for(action: "new"), data: {\n turbo: "false" ,\n "login-screen-selector-target": "smsForm",\n }) do |f| %>\n\n <% phone_field_name = :"passwordless[phone]" %>\n\n <span>\n By using your phone number to access this website, you consent to receiving verification and access codes from QuickVideo.chat (no promotional spam).\n <strong>Carrier rates may apply. </strong>\n </span>\n\n <br />\n <%= hidden_field_tag "passwordless[signin_method]", "sms" %>\n\n\n <%= f.label :phone,\n "Phone Number",\n for: "passwordless_phone" %>\n\n\n <%= phone_field_tag phone_field_name,\n params.fetch(:phone, nil),\n required: true,\n autofocus: true,\n placeholder: "000-000-0000" %>\n <%= f.submit t("passwordless.sessions.new.submit"), class: "btn-primary btn btn-large" %>\n <% end %>\n\n </div>\n </div>\n </div>\n</div>\n</div>\n' > app/views/passwordless/sessions/new.html.erb &&
git add . && git commit -m "Adding views" &&
sed -i '' -e 's/Rails.application.routes.draw do/Rails.application.routes.draw do\n passwordless_for :users/g' config/routes.rb &&
git add . && git commit -m "adds passwordless route" &&
echo "class `bundle exec rails runner "puts Rails.application.class.module_parent_name"`Mailer < ApplicationMailer \n\n # default from: ''\n # default reply_to: ''\n layout '`bundle exec rails runner "puts Rails.application.class.module_parent_name.underscore"`_mailer'\nend\n" > app/mailers/`bundle exec rails runner "puts Rails.application.class.module_parent_name.underscore"`_mailer.rb &&
echo '<!DOCTYPE html>\n<html>\n<head>\n <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />\n <style>\n <%= yield :inline_css %>\n .footer-box {\n position: relative;\n\n height: 100%;\n }\n\n\n body {\n -webkit-text-size-adjust: none;\n -ms-text-size-adjust: none;\n margin: 0;\n padding: 0;\n border: 0;\n outline: 0;\n background-color: white;\n color: #444;\n font-weight: 400;\n font-family: "Trebuchet MS", sans-serif;\n }\n\n header, footer {\n background-color: #dcdcdc;\n }\n\n .header-box, .footer-box {\n min-height: 10px;\n padding: 10px;\n color: #212121;\n\n }\n\n .header-box a, .footer-box a {\n color: #2E86C1;\n }\n\n .btn {\n border-radius: 16px;\n padding: 0.5rem 1rem;\n }\n\n .btn-primary {\n font-size: 1.2rem;\n }\n .btn {\n border-color: #FBEBA4;\n border: solid 4px #F62E5B;\n background-color: #F62E5B;\n color: white;\n\n }\n\n @media(min-width: 600px) {\n /* for desktop */\n\n /* Prevents Webkit and Windows Mobile platforms from changing default font sizes. */\n html {\n margin: 0;\n padding: 0;\n border: 0;\n outline: 0;\n }\n\n body div#mainContainer {\n margin-left: auto;\n margin-right: auto;\n width: 100%;\n }\n\n\n body .aligned-container, .entry-content, body div.header-box, div.footer-box {\n margin-left: auto;\n margin-right: auto;\n width: 600px;\n }\n\n .entry-content {\n border: solid 1px lightslategrey;\n border-radius: 3px;\n padding: 10px;\n }\n div.post-title {\n font-size: 3.5em;\n }\n\n .header-box {\n text-align: left;\n padding-left: 1em;\n }\n\n footer {\n height: 200px;\n }\n }\n\n\n\n @media(max-width: 600px) {\n /* for mobile */\n .aligned-container {\n\n }\n body, html {\n min-width: 100%;\n margin: 2em 0 3em 0;\n padding: 0;\n }\n body div#mainContainer {\n min-width: 100%;\n }\n\n header {\n height: 75px;\n }\n\n footer {\n height: 75px;\n }\n .header-box, .footer-box {\n padding: 5px;\n font-weight: bold;\n }\n\n .footer-box {\n min-height: 400px;\n }\n div.post-title {\n font-size: 2em;\n max-width: 60%;\n }\n\n div.entry-content {\n padding: 0 10px;\n }\n .header-box {\n height: 100px;\n }\n header.top-banner {\n\n }\n\n }\n </style>\n</head>\n\n<body>\n\n <div id="mainContainer" >\n\n\n <div style="clear: both; margin-top: 10px;" />\n <div style="float: none">\n <div class="aligned-container entry-content">\n <header class="top-banner">\n <div class="header-box">\n <!-- your logo here -->\n </div>\n\n </header>\n\n\n\n <%= yield %>\n\n <br />\n <footer class="">\n <div class="footer-box">\n <p style="font-size: 0.8em; ">\n <div style=" position: absolute; bottom: 30px; left: 10px; padding: 5px; ">\n <div style="width: 50%; float: left; font-size: 0.8em; color: lightslategray">\n You are receiving this email because you supplied it on our website.\n </div>\n\n <div style="width: 48%; float: left; text-align: center; position: relative;">\n </div>\n </div>\n </p>\n </div>\n </footer>\n </div>\n </div>\n </div>\n </div>\n</body>\n</html>\n' > app/views/layouts/`bundle exec rails runner "puts Rails.application.class.module_parent_name.underscore"`_mailer.html.erb && echo '<%= yield %>' > app/views/layouts/`bundle exec rails runner "puts Rails.application.class.module_parent_name.underscore"`_mailer.text.erb &&
git add . &&
git commit -m "Adds mailer views" &&
echo "import { Controller } from '@hotwired/stimulus'\n\n// Connects to data-controller='login-screen-selector'\nexport default class extends Controller {\n\n static targets = ['emailForm', 'smsForm' , 'emailButton', 'smsButton'\n ];\n\n connect() {\n\n this.emailButtonTarget.addEventListener('click', () => {\n console.log('email button clicked')\n this.showEmailForm();\n })\n\n this.smsButtonTarget.addEventListener('click', () => {\n console.log('sms button clicked')\n this.showSmsForm();\n });\n this.showEmailForm();\n }\n showEmailForm() {\n this.emailFormTarget.classList.remove('disabled');\n this.smsFormTarget.classList.add('disabled');\n\n for (let element of this.emailFormTarget.elements) {\n element.disabled = false;\n }\n\n // Disable the other form\n for (let element of this.smsFormTarget.elements) {\n element.disabled = true;\n }\n }\n\n showSmsForm() {\n this.emailFormTarget.classList.add('disabled');\n this.smsFormTarget.classList.remove('disabled');\n\n for (let element of this.smsFormTarget.elements) {\n element.disabled = false;\n }\n\n // Disable the other form\n for (let element of this.emailFormTarget.elements) {\n element.disabled = true;\n }\n }\n}\n" > app/javascript/controllers/login_screen_selector_controller.js && bin/rails stimulus:manifest:update &&
git add . &&
git commit -m "adds login selector js"
14-B: To get Passwordless working fully, be sure to add the SmsService to your app + Twilio config, using this script.
bundle add twilio-ruby && echo "TWILIO_ACCOUNT_SID=\nTWILIO_AUTH_TOKEN=\nTWILIO_SENDER_NUMBER=+1" >> .env && mkdir app/services && echo "class SmsService\n def self.send_sms(phone_number: ,\n message: )\n\n raise \"Configure your Twilio creds in your environment ENV['TWILIO_ACCOUNT_SID'] and ENV['TWILIO_AUTH_TOKEN'] \" if ENV['TWILIO_ACCOUNT_SID'].nil?
\n actual_phone = "+1" + phone_number.to_s\n\n @client = Twilio::REST::Client.new ENV['TWILIO_ACCOUNT_SID'],\n ENV['TWILIO_AUTH_TOKEN']\n message = @client.messages.create(\n body: message,\n to: actual_phone, # Text this number\n from: ENV['TWILIO_SENDER_NUMBER'] # From a valid Twilio number\n )\n\n puts message.sid\n end\nend\n" > app/services/sms_service.rb
Be sure to see the .env
file to configure your Twilio Account SID and Auth Token, and sender phone number (which should begin with +1
)
14-C: Form Styling (For Bootstrap only, be sure to run Section 7A first)
echo "form.disabled {\n pointer-events: none;\n opacity: 0.5;\n}" >> app/assets/stylesheets/application.bootstrap.scss