Usecases:
This was covered in RailsCasts #393 Guest User Record, however I have a different approach.
Whenever the app is opened in a new browser, find or create a permanent cookie.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :current_guest_id
helper_method :current_guest_id
private
def current_guest_id
cookies.permanent.signed[:guest_id] ||= SecureRandom.uuid
end
end
šØšØšØ
The problem with my approach - many conditions on whether there is a current_guest_id
or current_user
that are different entities.
This approach is best for cases where you will not want a user to finally authenticate.
šØšØšØ
So, when a Guest creates a message, assign the current_guest_id
to it;
Scope records to current_guest_id
if there is no current_user
;
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
before_action :set_message, only: %i[ show edit update destroy ]
def index
@messages = if current_user
current_user.messages
else
Message.where(guest_id: current_guest_id)
end
end
def create
if current_user
@message = Message.new(message_params)
@message.guest_id = current_guest_id
else
@message = current_user.messages.new(message_params)
end
@message.save!
redirect_to message_url(@message)
end
private
def set_message
@message = if current_user
current_user.messages.find(params[:id])
else
Message.where(guest_id: current_guest_id).find(params[:id])
end
end
end
Show links only to the Guest that created them.
link_to "Edit", edit_message_path(message) if message.guest_id == current_guest_id
Result:
Finally, assign guest records to a user when he signs in.
# app/controllers/application_controller.rb
# in Devise resource = User
def after_sign_in_path(resource)
assign_guest_records_to(resource)
end
def after_sign_up_path(resource)
assign_guest_records_to(resource)
end
private
def assign_guest_records_to(resource)
Message.where(guest_id: current_guest_id).where(user_id: nil).update_all(user: resource, guest_id: nil)
Post.where(guest_id: current_guest_id).where(user_id: nil).update_all(user: resource, guest_id: nil)
Comment.where(guest_id: current_guest_id).where(user_id: nil).update_all(user: resource, guest_id: nil)
end
Perfecto!
]]>.ics
format.
Recently Hey Calendar added a feature to import events to their calendar.
This got me intrigued on how I can parse an .ics
file and import it into my app.
Obviously, it can be done with the same gem icalendar
, specifically with the .parse
method.
First, install icalendar gem:
bundle add icalendar
Next, create a method to receive a .ics
file, parse it and create an event:
# app/models/event.rb
# require 'icalendar'
class Event < ApplicationRecord
def self.create_from_ics(file)
cal_file = File.open(file)
cal = Icalendar::Calendar.parse(cal_file).first
cal_event = cal.events.first
Event.create(
name: cal_event.summary.strip,
description: cal_event.description.strip,
starts_at: cal_event.dtstart,
ends_at: cal_event.dtend,
location: cal_event.location.strip
)
end
end
Hereās a dummy .ics event you can play with!
route
# config/routes.rb
Rails.application.routes.draw do
resources :events
+ post "events/import" => "events#import"
end
view
# app/views/events/index.html.erb
<%= form_with url: events_import_path, method: :post do |form| %>
<%#= form.file_field :file %>
<%= form.file_field :files, multiple: true %>
<%= form.submit "Import" %>
<% end %>
controller
# app/controllers/events_controller.rb
class EventsController < ApplicationController
def import
files = params[:files]
files = files.reject(&:blank?)
files.each do |file|
Event.create_from_ics(file)
end
redirect_to events_url, notice: "Importing event from ICS file"
end
end
Voila! Now we can upload multiple .ics
files and create events from them:
Create a job rails g job IcsImport
# app/jobs/ics_import_job.rb
# gem icalendar
require 'icalendar'
class IcsImportJob < ApplicationJob
queue_as :default
def perform(file_path:)
# file_path = 'test/fixtures/files/Sunday+morning+yoga+in+Antibes+.ics'
# cal_file = File.open(file_path)
cal_file = File.open(file_path)
cal = Icalendar::Calendar.parse(cal_file).first
cal_event = cal.events.first
event_object = OpenStruct.new(
name: cal_event.summary.strip,
description: cal_event.description.strip,
starts_at: cal_event.dtstart,
ends_at: cal_event.dtend,
location: cal_event.location.strip
)
Event.create(event_object.to_h)
end
end
šØ Itās tricker with the controller!
If you just call IcsImportJob.perform_later(params[:file])
, you will get an error ActionDispatch::Http::UploadedFile
.
Copilot to the rescue:
update the events controller accordingly:
# app/controllers/events_controller.rb
class EventsController < ApplicationController
def import
files = params[:files]
files = files.reject(&:blank?)
# files.each do |file|
# Event.create_from_ics(file)
# end
files.each do |file|
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(file.read)
tempfile.close
IcsImportJob.perform_later(file_path: tempfile.path)
end
redirect_to events_url, notice: "Importing event from ICS file"
end
end
Again, hereās a dummy .ics event you can play with. I suggest storing it in test/fixtures/files/*
.
Write some tests
# test/jobs/ics_import_job_test.rb
require "test_helper"
class IcsImportJobTest < ActiveJob::TestCase
test "imports an event from an ICS file" do
file_path = "test/fixtures/files/Sunday+morning+yoga+in+Antibes+.ics"
IcsImportJob.perform_now(file_path:)
event = Event.last
assert_equal "Sunday morning yoga in Antibes", event.name
assert_equal "Plage de la Gravette, Antibes (Small beach behind Port Vauban, Antibes, France)", event.location
assert_equal "2024-03-03 10:30:00 +0100", event.starts_at
assert_equal "2024-03-03 11:30:00 +0100", event.ends_at
end
end
Thatās it! Good luck building your calendar app!
]]>First, follow the official installation guide.
After installation, you can run ngrok
in one tab, and your rails server
or bin/dev
in a second tab.
# run ngrok
ngrok http http://localhost:3000
# or
ngrok http 3000
# rails server in another tab
rails s
See how it gave me a public URL https://7631-2a01-cb1d-6cf-cd00-e5f4-7f3f-31e5-fb6f.ngrok-free.app
. This URL will be valid until you stop the ngrok runtime. If you restart, you will be given a different URL.
When you visit the URL, you will get the āWelcome screenā.
When you click āVisit siteā in a Rails app, you might get a blocked hosts
error
Whitelist the current public URL, or better yet, whitelist any URL for development environment:
# config/environments/development.rb
require "active_support/core_ext/integer/time"
Rails.application.configure do
# whitelist current URL
+ config.hosts < "https://7631-2a01-cb1d-6cf-cd00-e5f4-7f3f-31e5-fb6f.ngrok-free.app"
# whitelist any URL
+ config.hosts = nil
Hooray, you have a public URL for your localhost!
You can also visit http://127.0.0.1:4040/inspect/http
to view the āInspector toolā
ERR_NGROK_6024 - You are about to visit
āWelcome screenāš” I tried running these in the console, but it did not help me (I still see the Welcome screen when opening in a new browser):
curl -H "ngrok-skip-browser-warning: true" https://7631-2a01-cb1d-6cf-cd00-e5f4-7f3f-31e5-fb6f.ngrok-free.app
curl -H "User-Agent: MyCustomUserAgent123" https://7631-2a01-cb1d-6cf-cd00-e5f4-7f3f-31e5-fb6f.ngrok-free.app
ngrok http 3000 --request-header-add='ngrok-skip-browser-warning: true'
Instead, the Ngrok Edges feature solved the problem for me:
Create an edge - a persistent URL:
Run the edge
ngrok tunnel --label edge=edghts_2cG9u6S5VTParQWSRDd1l5RnrAi http://localhost:3000
# or
ngrok tunnel --label edge=edghts_2cG9u6S5VTParQWSRDd1l5RnrAi 3000
Visit the URL
Works!
š” I like to add ngrok to the
Procfile.dev
when I start development. @candland
Started using https://localcan.com www.localcan.com over ngrok. Nicer interface. @aviflombaum
Have you considered alternatives such as zrok.io? Itās open source and has a more generous free SaaS tier. @ThePGriffiths
]]>If you own a domain, Cloudflare Tunnel is also really good! @bjarke_vad
Usual requirements:
Today
, prev
, next
Month
/Week
/Day
viewLetās build this month view:
First, create a route:
get "calendar/month", to: "calendar#month"
# get "calendar/week", to: "calendar#week"
# get "calendar/day", to: "calendar#day"
If you add a date
param in the URL like http://localhost:3000/calendar/month?date=2023-08-01
, it will open the month that contains this date
# app/controllers/calendar_controller.rb
class CalendarController < ApplicationController
def month
@date = Date.parse(params.fetch(:date, Date.today.to_s))
@events = Event.where(start_date: @date.all_month)
end
# def week
# end
# def day
# end
end
View helpers for the month view:
# app/helpers/calendar_helper.rb
module CalendarHelper
# if month starts on Monday => 0
# if month starts on Wed => 2
def month_offset(date)
# you might want to update this based on your first day of the week (Sun/Mon)
date.beginning_of_month.wday - 1
end
def today?(day)
day == Date.today
end
def today_class(day)
"bg-rose-200" if today?(day)
end
end
Finally, display the calendar and events per day:
# app/views/calendar/month.html.erb
<%= tag.div class: "flex justify-between" do %>
<%= @date.strftime('%B %Y') %>
<%= tag.div class: "flex space-x-4" do %>
<%= link_to "<", calendar_month_path(date: @date - 1.month) %>
<%= link_to "Today", calendar_month_path %>
<%= link_to ">", calendar_month_path(date: @date + 1.month) %>
<% end %>
<% end %>
<%= tag.div class: "grid grid-cols-7" do %>
<% Date::ABBR_DAYNAMES.rotate.each do |day| %>
<%= tag.div class: "border" do %>
<%= day %>
<% end %>
<% end %>
<% month_offset(@date).times do %>
<%= tag.div %>
<% end %>
<% @date.all_month.each do |day| %>
<%= tag.div class: "border min-h-24 #{today_class(day)}" do %>
<%= day.strftime('%d') %>
<% @events.where(start_date: day.all_day).each do |event| %>
<%= render 'events/event', event: event %>
<% end %>
<% end %>
<% end %>
<% end %>
ā¹ļø
Date::ABBR_DAYNAMES
=> ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
irb(main):002> Date::ABBR_DAYNAMES.rotate
=> ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
Date::ABBR_DAYNAMES.rotate(3)
=> ["Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Tue"]
We can wrap the calendar into a similar helper like excid3/simple_calendar does:
<%= month_calendar do |date| %>
<%= date %>
<% end %>
First, abstract the month calendar wrapper:
# app/views/calendar/_month.html.erb
<%= tag.div class: "flex justify-between" do %>
<%= date.strftime('%B %Y') %>
<%= tag.div class: "flex space-x-4" do %>
<%= link_to "<", calendar_month_path(date: date - 1.month) %>
<%= link_to "Today", calendar_month_path %>
<%= link_to ">", calendar_month_path(date: date + 1.month) %>
<% end %>
<% end %>
<%= tag.div class: "grid grid-cols-7" do %>
<% Date::ABBR_DAYNAMES.rotate.each do |day| %>
<%= tag.div class: "border" do %>
<%= day %>
<% end %>
<% end %>
<% month_offset(date).times do %>
<%= tag.div %>
<% end %>
<% date.all_month.each do |day| %>
<%= tag.div class: "border min-h-24 #{today_class(day)}" do %>
<%= yield day %>
<% end %>
<% end %>
<% end %>
Next, render the day
(the thing you want to have whole control of) inside the wrapper:
# app/views/calendar/month.html.erb
<%= render 'calendar/month', date: @date do |day| %>
<%= day.strftime('%d') %>
<% @events.where(start_date: day.all_day).each do |event| %>
<%= render 'events/event', event: event %>
<% end %>
<% end %>
With the above approach, we perform an additional query for each day in the calendar:
Instead, we can group events by day in the controller, and display events per day in the view:
# app/controllers/calendar_controller.rb
class CalendarController < ApplicationController
def month
@date = Date.parse(params.fetch(:date, Date.today.to_s))
- @events = Event.where(start_date: @date.all_month)
+ @events = Event.where(start_date: @date.all_month).group_by{ |e| e.start_date.to_date }
end
end
# app/views/calendar/month.html.erb
<%= render 'calendar/month', date: @date do |day| %>
<%= day.strftime('%d') %>
- <% @events.where(start_date: day.all_day).each do |event| %>
+ <% @events[day]&.each do |event| %>
<%= render 'events/event', event: event %>
<% end %>
<% end %>
1 query in the controller + 31 queries in the view => 1 query in the controller!
An absolutely different, alternative way to view or download a page as PDF
/PNG (screenshot)
would be via a headless browser API.
You could use gem Grover that uses a Node.js API for Chrome named āPuppeteerā. However Grover/Puppeteer has a NodeJS depencency šš©š©š©
Gem Ferrum does the same, but without a NodeJS dependency! š¢
As a browser API tool, Ferrum lets you open a headless chrome browser and perform different actions:
Yes, we can use Ferrum to open or save a file as PDF! Hereās a basic flow:
When a user clicks on āView as PDFā link ā¤µļø
Ferrum visits this page, opens it as PDF, and opens it as PDF in a new tab ā¤µļø
Full flow #1: click to download as PDF
Full flow #2: click to open as PDF
Or saves it as a screenshot š¼ļø
Letās try to make it work
Install the gem Ferrum:
# terminal
# gem "ferrum"
bundle add ferrum
# create a job to generate PDFs
rails g job UrlToPdf
A basic job to visit an URL and save is as PDF:
# ToPdfJob.perform_now("https://superails.com/posts")
class ToPdfJob < ApplicationJob
queue_as :default
def perform(url)
browser = Ferrum::Browser.new
browser.goto(url)
sleep(0.3)
browser.pdf(
path: "#{url.parameterize}.pdf",
landscape: false,
format: :A4,
preferCSSPageSize: false,
printBackground: true)
browser.quit
end
end
Display users a link_to
download or open the URL as PDF:
link_to 'PDF', home_path(format: :pdf), target: :_blank
# link_to 'PDF', invoice_path(invoice, format: :pdf), target: :_blank
Handle the request in the controller
# app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
respond_to do |format|
format.html
format.pdf do
# url = "https://superails.com/posts"
url = home_url
pdf_data = ToPdfJob.perform_now(url)
send_data(pdf_data,
filename: "#{url.parameterize}.pdf",
type: "application/pdf",
disposition: "inline") # open in browser
# disposition: "attachment") # default # download
end
end
end
end
Finally, generate a āpdf stringā with Ferrum:
# ToPdfJob.perform_now("https://superails.com/posts")
class ToPdfJob < ApplicationJob
queue_as :default
def perform(url)
tmp = Tempfile.new
browser = Ferrum::Browser.new(headless: true,
process_timeout: 30,
timeout: 200,
pending_connection_errors: true)
browser.goto(url)
sleep(0.3)
browser.pdf(
path: tmp.path,
landscape: false,
format: :A4,
preferCSSPageSize: false,
printBackground: true)
File.read(tmp.path)
ensure
browser.quit
tmp.close
tmp.unlink
end
end
ā¹ļø we added process_timeout: 30, timeout: 200, pending_connection_errors: true
and sleep(0.3)
to try preventing this error:
Ferrum::PendingConnectionsError (Request to http://localhost:3000/home/index reached server, but there are still pending connections: http://localhost:3000/home/index)
send_file
vs send_data
send_data
if you already did File.read(path)
send_file
and you donāt need to do File.read(path)
# ToImageJob
- browser.pdf
+ browser.screenshot(path: tmp.path, quality: 60, format: "png", full: true)
# not full page, only element with <id="posts_list">
# browser.screenshot(path: tmp.path, quality: 60, format: "png", selector: "#posts_list")
# controller
url = "https://superails.com"
image_data = ToImageJob.perform(url)
send_data image_data, type: "image/png", disposition: "attachment", filename: "#{url.parameterize}.png"
Generating documents āon the flyā can actually take some time (you spin up a browser each time), and can be expensive for popular pages. Instead, you can store the generated files.
Scenario: when an Invoice is created, generate a PDF/PNG and attach it to the record.
# app/models/invoice.rb
has_one_attached :document
after_create_commit do
self.generate_and_attach_pdf
end
def generate_and_attach_pdf
browser = Ferrum::Browser.new(headless: true)
browser.goto(Rails.application.routes.url_helpers.invoice_url(self))
tmp = Tempfile.new
# browser.pdf(path: tmp.path)
browser.screenshot(path: tmp.path, full: true, quality: 60, format: "png")
# browser.screenshot(path: tmp.path, full: true, quality: 60, format: "png", selector: "#invoice")
self.document.attach(io: File.open(tmp), filename: "invoice_#{id}.png")
browser.quit
tmp.close
tmp.unlink
end
A download link:
link_to "View", rails_blob_path(@invoice.document, disposition: "inline"), target: :_blank if @invoice.document.attached?
link_to "Download", rails_blob_path(@invoice.document, disposition: "attachment"), target: :_blank if @invoice.document.attached?
You can add CSS that will apply only to āPrint/PDFā using @media print
.
Add .no-print
CSS class to elements that should not be displayed in print
media type:
/* app/assets/stylesheets/application.css */
@media print {
.no-print {
display: none !important;
}
}
Example:
<!-- app/views/home/index.html.erb -->
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>
-<div>
+<div class="no-print">
<%= link_to "View as PDF", home_index_path(format: :pdf) %>
</div>
Other css classes you might want to consider:
@media print {
body {
-webkit-print-color-adjust: exact;
background: #fff;
background-color: #fff;
float: none;
display: block;
}
.no_margin {
padding-left: 0px !important;
}
.printer-preview-content,
.printer-preview-content_landscape {
max-width: 100% !important;
width: 100% !important;
height: auto !important;
padding: 0 !important;
margin: 0 !important;
}
}
It works well for publicly accessible URLs, however it is not so straightforwar for links that require current_user
authentication.
I did not yet figure out how to sign in a devise user as you would do in tests with sign_in(User.first)
.
In this flow we:
user = User.create!(email: "foo@bar.com", password: "password", admin: true)
browser = Ferrum::Browser.new
browser.go_to("https://superails.com/users/sign_in")
headless_sign_in(user)
sleep(0.3)
browser.go_to
browser.go_to("https://superails.com/admin")
private
def headless_sign_in(user)
email_input = browser.at_css('input[name="user[email]"]')
email_input.focus.type(user.email)
password_input = browser.at_css('input[name="user[password]"]')
password_input.focus.type("password")
login_button = browser.at_css('input[name="commit"]')
login_button.click
end
My idea: make download path public (not require current_user
), but restrict them with HTTP basic authentication. Next, perform the authentication with the headless browser to access content.
# app/controllers/export_controller.rb
class ExportController < ActionController::Base
before_action :http_authenticate
skip_before_action :http_authenticate, only: :report, if: -> { request.format.pdf? }
def report
respond_to do |format|
format.html
format.pdf do
image_data = ToPdfJob.perform(export_report_url)
send_data image_data, type: "image/png", disposition: "attachment", filename: "#{filename}.png"
end
end
end
private
def http_authenticate
authenticate_or_request_with_http_basic do |username, password|
username == Rails.application.credentials.dig(:http_basic_auth, :username) &&
password == Rails.application.credentials.dig(:http_basic_auth, :password)
end
end
http_basic_auth:
username: # generate something with SecureRandom.hex
password: # generate something with SecureRandom.hex
browser = Ferrum::Browser.new
browser.network.authorize(user: Rails.application.credentials.dig(:http_basic_auth, :username),
password: Rails.application.credentials.dig(:http_basic_auth, :password)) { |req| req.continue }
browser.go_to(url)
If users of your app can use Bearer token authentication to access API endpoints in your app, you can add auth headers to the browser:
# *This is not fully tested
browser.headers.add({"Authorization" => "Bearer MyBearerToken123"})
browser.headers.add({"accept" => "application/html"})
browser.go_to(url)
To make Ferrum work in production, you need to install google-chrome
in your production ENV.
For heroku, you can add google-chrome buildpack with the command:
heroku buildpacks:add heroku/google-chrome -a myappname
šØ IMPORTANT: this buildpack has to be added ABOVE the ruby buildpack!
steps:
- name: Setup Chrome
uses: browser-actions/setup-chrome@latest
with:
chrome-version: stable
Thatās it! I might be adding more to this article later.
]]>Gem pagy also offers a pagination solution out of the box:
Hereās how we can (and canāt) use it.
First, letās add a list of events that we can paginate:
# /db/seeds.rb
path = "https://raw.githubusercontent.com/ruby-conferences/ruby-conferences.github.io/master/_data/conferences.yml"
uri = URI.open(path)
yaml = YAML.load_file uri, permitted_classes: [Date]
yaml.each do |event|
Event.create!(
name: event["name"],
location: event["location"],
start_date: event["start_date"]
)
end
rails g scaffold Event name location start_date:datetime
rails db:migarte db:seed
# terminal
bundle add pagy
Enable pagy calendar plugin:
# config/initializers/pagy.rb
require 'pagy/extras/calendar'
# optionally enable frontend libraries
# require 'pagy/extras/bootstrap' # https://ddnexus.github.io/pagy/docs/extras/bootstrap/
# https://ddnexus.github.io/pagy/docs/extras/tailwind/
Pagy does not know what date
attribute we will use for pagination (created_at
? starts_at
? start_time
?), so we have to define it:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# enable pagy backend helpers globally
include Pagy::Backend
# start and end of calendar (first and last record in the list)
def pagy_calendar_period(collection)
collection.minmax.map(&:start_date)
# between first event and Today
# start_date = collection.min_by(&:start_date).start_date
# end_date = Time.zone.now
# [start_date, end_date]
end
# optionally: end on last event or today
# def end_date(collection)
# last_event_date = collection.max_by(&:start_date).start_date
# return last_event_date if last_event_date > Time.zone.now
# Time.zone.now
# end
# query to paginate within start_date
def pagy_calendar_filter(collection, from, to)
collection.where(start_date: from..to)
end
end
Enable pagy froentend helpers like pagy_nav
:
# app/helpers/application_helper.rb
module ApplicationHelper
include Pagy::Frontend
end
In the controller, wrap your collection into pagy_calendar
.
Uncomment for any pagination granularity that you like:
def index
collection = Event.all.order(start_date: :asc)
@calendar, @pagy, @events = pagy_calendar(collection,
year: {size: 4},
# year: { size: [1, 1, 1, 1] },
month: {size: 12, format: '%b'},
# month: { size: [0, 12, 12, 0], format: '%b' },
week: { size: 53, format: '%W' },
# week: { size: [0, 53, 53, 0], format: '%W' },
day: {size: 31, format: '%d'},
# day: { size: [0, 31, 31, 0], format: '%d' },
pagy: { items: 10 }, # items per page
active: !params[:skip]
)
end
ā¹ļø size
attribute defines how many pagy links to show: [pagination start, before current, after current, pagination end]
. For example, if current selected page is 11
and size: [1, 2, 2, 1]
, the pagination links displayed can be [1, 9-10, 12-13, 100]
.
Display records (events) and pagination in a view:
# app/views/events/index.html.erb
<h1>Events</h1>
<div>
<% if params[:skip] %>
<%= link_to 'Show Calendar', events_path %>
<% else %>
<%= link_to 'Hide Calendar', events_path(skip: true) %>
<br>
<%= link_to 'Today', pagy_calendar_url_at(@calendar, Time.zone.now, fit_time: true) %>
<% end %>
</div>
<% if @calendar %>
<%== pagy_info(@pagy) %>
for
<%= @calendar.showtime %>
<%#== @calendar[:year].label %>
<%#== @calendar[:day]&.label %>
<%#== @calendar[:month].label(format: '%B %Y') %>
<%#== @calendar[:week].label %>
<%== pagy_nav(@calendar[:year]) %>
<%== pagy_nav(@calendar[:month]) %>
<%== pagy_nav(@calendar[:week]) if @calendar[:week] %>
<%== pagy_nav(@calendar[:day]) if @calendar[:day] %>
<% end %>
<%== pagy_nav(@pagy) %>
<hr>
<% if @calendar %>
<%#= link_to "New event", new_event_path(start_date: [@calendar[:day]&.label, @calendar[:month].label(format: '%m-%Y')].compact.join('-')) %>
<%= link_to "New event", new_event_path(start_date: @calendar.showtime) %>
<% else %>
<%= link_to "New event", new_event_path %>
<% end %>
<hr>
<% if @events.any? %>
<% @events.each do |event| %>
<%= render 'event', event: event %>
<% end %>
<% elsif @events.empty? %>
No events found
<% end %>
In the view, add a link_to
add an event for a date.
@calendar.showtime
will always give you the current date/month/year.
# app/views/events/index.html.erb
# for current_date
link_to "New event", new_event_path(start_date: @calendar.showtime)
# for any date
link_to "Add event (Today)", new_event_path(start_date: Date.today)
# other approaches
# if no format defined in controller
link_to "Add event", new_event_path(start_date: @calendar[:day].label)
# if :day format is defined in controller, we have to deduce todays date
link_to "New event", new_event_path(start_date: [@calendar[:day]&.label, @calendar[:month].label(format: '%m-%Y')].compact.join('-'))
Display the selected date in a form:
# app/views/events/_form.html.erb
<% if params[:start_date] %>
<%= form.datetime_field :start_date, value: params[:start_date]&.to_date&.strftime('%Y-%m-%dT%H:%M:%S') || form.object.start_date %>
<% else %>
<%= form.datetime_field :start_date %>
<% end %>
To redirect to the calendar page with this event, we need to define @calendar
in the #create
action the same way we did for #index
.
# app/controllers/events_controller.rb
+ before_action :set_calendar, only: %i[ index create ]
def create
if @event.save
- redirect_to events_path
+ redirect_to helpers.pagy_calendar_url_at(@calendar, @event.start_date)
private
# this will be shared for both #index and #create actions
+ def set_calendar
+ # @events = Event.all
+ collection = Event.all.order(start_date: :asc)
+ @calendar, @pagy, @events = pagy_calendar(collection,
+ year: {size: 4},
+ month: {size: 12, format: '%b'},
+ day: {size: 31, format: '%d'},
+ pagy: { items: 10 }, # items per page
+ active: !params[:skip]
+ )
+ end
If we could have actual year in params, not page index, it would make URLs predictable:
# bad
http://localhost:3000/events?year_page=10&month_page=10&day_page=5
# good
http://localhost:3000/events?year_page=2023&month_page=10&day_page=5
Overall, Pagy Calendar is a great out of the box solution.
Huge respect to ddnexus for his work! šŖ
To explore later:
Continue
Start
Stop
Became a father
Learned windsurfing and catamaran sailing
Visited Cannes film festival, Cannes Lions marketing festival
Visited 5 Ruby conferences: RailsSaaS, EURUKO, FriendlyRB, RailsWorld, HelveticRB and met many new wonderful Ruby friends. I am so happy to have you in my life!
Recorded 68 screencasts and published them on SupeRails.com & SupeRails youtube
Published 55 blogposts right here
Completed Season 1 (10 episodes) of FriendlyShow with Adrian
Created SupeRails.com beta. It still needs a lot of improvement to become valuable without the context of youtube. But it is a stepping stone to add premium features in the future.
Spoke at RailsWorld: Hotwire Cookbook
]]>Using the gem you can easily produce a collection of countries for select:
# bundle add countries
def countries_for_select
countries = ISO3166::Country.all.map! { |country| [country.translations[I18n.locale.to_s], country.alpha2] }
countries.sort_by! { |country| country[0] }
end
# countries_for_select
# =>
# [["Afghanistan", "AF"],
# ["Albania", "AL"],
# ["Algeria", "DZ"],
# ["American Samoa", "AS"],
# ["Andorra", "AD"],
# ["Angola", "AO"],
# ["Anguilla", "AI"],
# ["Antarctica", "AQ"],
# ["Antigua and Barbuda", "AG"],
# ["Argentina", "AR"]
# ...
# ]
You can use this collection of key-values in a select dropdown:
<%= form.select(:country, countries_for_select) %>
Gem āCountry Selectā provides select field helpers based on the āCountriesā gem, and some simple_form styling.
The country_select gem gives us a form.country_select
helper:
# bundle add countries
<%= form.country_select :address_format,
priority_countries: ["US","GB","AU","CA","DE","DK"],
selected: @setting.address_format&.upcase,
iso_codes: true
%>
It produces a list:
Notice the divider between the priority countries and other countries.
To mimic this exact behaviour of country_select gem, we would have to write all the following code:
# app/helpers/country_select_helper.rb
module CountrySelectHelper
PRIORITY_COUNTRIES = ["US", "GB", "AU", "CA", "DE", "DK"]
def priority_countries
countries = PRIORITY_COUNTRIES.map { |country| ISO3166::Country[country] }
countries.map! { |country| [country.translations[I18n.locale.to_s], country.alpha2] }
countries.sort_by! { |country| country[0] }
end
def other_countries
all_countries = ISO3166::Country.all.map! { |country| [country.translations[I18n.locale.to_s], country.alpha2] }
other_countries = all_countries - priority_countries
# sort Ć
land Islands correctly
other_countries.sort_by! { |country| country[0].tr("Ć
", "A") }
end
# add a divider
def countries_for_select
priority_countries + [["----------------", "----------------"]] + other_countries
end
end
Now you can use countries_for_select
with a regular form. To disable the divider, use the form disabled
option:
<%= form.select(:country,
countries_for_select,
disabled: ["----------------"]) %>
Result will be the same as above, but without this extra gem dependency.
Going further, using option groups is better than using a divider:
<%= form.select(:country,
{
"Popular": priority_countries,
other: other_countries
}) %>
Result:
Looks good!
In my case I need a countries selector with a UI library that provides itās own form.polaris_select
instead of form.select
or form.country_select
.
Hereās how I use the CountrySelectHelper
with polaris_select
:
<%= form.polaris_select(:address_format,
disabled_options: ["----------------"],
options: countries_for_select,
selected: @setting.address_format&.upcase) %>
Final result:
Thatās it š¤
]]>In the meantime Jorge Manrubia talked about new/future Hotwire/Turbo features. Mainly, about āTurbo Morphingā. It was introduced in this pull request.
Morphing = refresh current page with preserving the scroll position;
Full page refresh animation is skipped, because before the refresh happens, there is a ādiffā of old VS new page, and only the diff gets updated.
Morphing will be released in Turbo 8, so currently the best way to try it together with all the other new features is to use the main
git branch:
# Gemfile
gem "turbo-rails", github: "hotwired/turbo-rails", branch: "main"
Enable turbo 8 morphing:
Old, default page refresh behaviour:
# app/views/layouts/application.html.erb
<head>
# <meta name="turbo-refresh-method" content="replace">
# <meta name="turbo-refresh-scroll" content="reset">
<%= turbo_refreshes_with method: :replace, scroll: :reset %>
<%= yield :head %>
</head>
New, add morphing to your app by adding this:
# app/views/layouts/application.html.erb
<head>
# <meta name="turbo-refresh-method" content="morph">
# <meta name="turbo-refresh-scroll" content="preserve">
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= yield :head %>
</head>
Now, whenever you redirect_to
current page, the scroll position will be preserved.
The easiest way to reproduce this behaviour is deleting a record from a list:
This means you can use fewer TurboStreams in your app!
If an element should not be refreshed, add data-turbo-permanent
attribute.
Previously, data-turbo-permanent
required the presence of an [id] attribute. Now it does not!
For example: if you add data-turbo-permanent
to this <details>
dropdown, it will not close after a morph-refresh:
-<details>
+<details data-turbo-permanent>
<summary>Details</summary>
Something small enough to escape casual notice.
</details>
refresh
redirect_to
;TurboStreams
;For this we have a new turbo stream redirect
action has been introduced:
<turbo-stream action="refresh"></turbo-stream>
Now you can trigger refreshes in a model, and send updates to all clients/browser tabs!
# app/models/project.rb
# For this to work out of the box, consider using very RESTful (scaffold-default) conventions.
broadcasts_refreshes
# same as:
after_create_commit -> { broadcast_refresh_later_to(model_name.plural) }
after_update_commit -> { broadcast_refresh_later }
after_destroy_commit -> { broadcast_refresh }
This would require having an open ActionCable/Websockets channel for this specific record:
# app/views/projects/index.html.erb
<% @projects.each do |project| %>
<%= turbo_stream_from project %>
<%= render project %>
<% end %>
or directly within project partial
# app/views/projects/_project.html.erb
<%= turbo_stream_from @project %>
I donāt like invoking view-related logic from a model.
Instead, we can trigger refreshes from from the console:
Turbo::StreamsChannel.broadcast_refresh_to @project
will refresh all pages that have the listener
<%= turbo_stream_from @project %>
Refresh current_page(s) for all users in the app š¤Ŗ
# app/views/application.html.erb
Turbo::StreamsChannel.broadcast_refresh_to :global
<%= turbo_stream_from :global %>
Refresh all current page(s) for current_user
:
# app/views/application.html.erb
Turbo::StreamsChannel.broadcast_refresh_to current_user
<%= turbo_stream_from current_user %>
After a page morph stimulus controllers can lose the default state. You can manually āre-connectā a stimulus controller:
<div data-action="turbo:morph@window->dropdown#connect">
You can also trigger a callback on a value change.
In the below scenario, a page morph would not affect elements inside a div, but we trigger some turbo
/form
behaviours manually:
<div data-turbo-permanent data-action="
turbo:submit-end->dropdown#close
turbo:submit-end->form-reset#connect">
<form ....>
Resources:
]]>Todays mission: āWhen an invoice is created, generate a PDF and email it to the clientā. Example:
How would you do that?
Usually I would:
But thereās a problem:
Since 2015 I have always relied on the gem wicked_pdf for generating PDFs out of my HTML templates. With wicked_pdf, we could design an app/views/invoices/show.pdf.erb
HTML document, and format.pdf
would render more-less what we designed. Designing PDFs felt like WYSIWYG (what you see is what you get).
However in 2023 the underlying technology behind this gem, wkhtmltopdf, has been archived. This means that gem "wicked_pdf"
is no longer recommended for any new projects. In fact, we should consider replacing it in existing projects!
While Prawn and Ferrum offer fundamentally different approaches to generating PDF, I think DocRaptor might be the the best āplug-and-playā replacement for wicked_pdf, because it uses the same technological principle (HTML-to-PDF).
I think that CSS Paged Media is the killer feature of DocRaptor/Prince technology: it allows us to have maximum CSS control of what is rendered on a single PDF page.
By the way, I first casually heard about DocRaptor on IndieRails Podcast: Matt Gordon - Going from Consulting to Products. Letās give it a try!
Useful resources:
Add the gem:
# Gemfile
gem "docraptor"
Create a job that would generate a PDF for an Invoice
record.
DocRaptor::DocApi.new.create_doc
makes an API request to DocRaptor.
The API request will try to turn the app/views/invoices/show.html.erb
template into PDF.
The API response will be saved locally as a PDF in your appsā root folder.
# rails g job Invoices::ToPdf
# app/jobs/invoices/to_pdf_job.rb
DocRaptor.configure do |config|
config.username = "YOUR_API_KEY_HERE" # THIS key works in test mode!
end
class Invoices::ToPdfJob < ApplicationJob
queue_as :default
def perform(invoice)
# document_content = ActionController::Base.render( # bad
document_content = ApplicationController.render(
template: 'invoices/show',
# layout: 'layouts/application', # in development fails with "File system access is not allowed"
layout: 'layouts/pdf',
assigns: { invoice: }
)
response = DocRaptor::DocApi.new.create_doc(
test: true,
document_type: "pdf",
document_content: document_content,
)
# Generate a unique filename for each invoice PDF
filename = "invoice_#{invoice.id}.pdf"
# Save the PDF locally
File.write(filename, response, mode: "wb")
puts "Successfully created #{filename}!"
rescue StandardError => error
puts "#{error.class}: #{error.message}"
end
end
Now you can run this job whenever an invoice is created:
# app/models/invoice.rb
after_create_commit do
Invoices::ToPdfJob.perform(self)
end
Inside the root folder of your app you will have a downloaded PDF! It will look more-less like this:
File system access is not allowed.
The DocRaptor API does not have access to these assets inside your localhost:3000
app by default:
# app/views/layouts/application.html.erb
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
The official docs suggest using Ngrok.
My easiest solution: create a separate PDF layout that will not contain internal asset path.
<!-- app/views/layouts/pdf.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>DocraptorHtmlToPdf</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<style>
/* your inline CSS goes here */
</style>
<body>
<%= yield %>
</body>
</html>
Normally you will want to store generated PDFs in app/cloud storage (not local file storage). Letās do it!
Install ActiveStorage:
rails active_storage:install
rails db:migrate
Declare the ActiveStorage association on the Invoice model:
# app/models/invoice.rb
has_one_attached :pdf_document
Finally, instead of storing a file locally, upload it to ActiveStorage!
# app/jobs/invoices/to_pdf_job.rb
# Save the PDF locally
- File.write(filename, docraptor_api_response, mode: "wb")
# Save in active storage
+ invoice.pdf_document.attach(io: StringIO.new(docraptor_api_response), filename: filename, content_type: 'application/pdf')
Now that we have a generated & attached PDF, we can:
To make PDF preview work, add gem image_processing:
# Gemfile
gem "image_processing", ">= 1.2"
Now we can display the attached PDF in our views:
# invoices/show.html.erb
# download pdf_document
link_to "Download", rails_blob_path(@invoice.pdf_document, disposition: "attachment")
# open pdf_document in browser
link_to "Download", rails_blob_path(@invoice.pdf_document, disposition: "inline")
# metadata
@invoice.pdf_document.representable?
@invoice.pdf_document.url
@invoice.pdf_document.blob.filename
@invoice.pdf_document.blob.content_type
number_to_human_size(@invoice.pdf_document.blob.byte_size)
# preview
image_tag @invoice.pdf_document.representation(resize_to_limit: [100, 100])
image_tag @invoice.pdf_document.preview(resize_to_limit: [100, 100])
Example image preview of an attached PDF with a link to download it:
<% if @invoice.pdf_document.attached? %>
<%= link_to rails_blob_path(@invoice.pdf_document, disposition: "inline") do %>
<% if @invoice.pdf_document.representable? %>
<%= image_tag @invoice.pdf_document.representation(resize_to_limit: [200, 200]) %>
<% end %>
<br>
<%= @invoice.pdf_document.blob.filename %>
<%= number_to_human_size @invoice.pdf_document.blob.byte_size %>
<% end %>
<% end %>
Will look like this:
Clicking the link will open the file:
Amazing! What if we want to now email the generated PDF?
# rails g mailer invoice created
# InvoiceMailer.created(@invoice).deliver_later
class InvoiceMailer < ApplicationMailer
def created(invoice)
@invoice = invoice
# Attach the PDF to the email
attachments["invoice.pdf"] = invoice.pdf_document.download if invoice.pdf_document.attached?
mail(to: invoice.email, subject: 'Your invoice')
end
end
Voila! Now your email will have an attached invoice PDF:
Are using ActiveStorage only for DocRaptor-generated documents?
You can host generated documents directly with DocRaptor and have fewer dependencies (no need for ActiveStorage, AWS S3ā¦)
According to the docs:
.create_doc
returns a pdf string.create_hosted_doc
returns a URL to the hosted documentSo we simply replace create_doc
with create_hosted_doc
:
- response = DocRaptor::DocApi.new.create_doc(
+ response = DocRaptor::DocApi.new.create_hosted_doc(
+ invoice.update(pdf_url: response.download_url)
We can add a new attribute like pdf_url
to our Invoice
and update it.
Thatās it: now DocRaptor replaced both ActiveStorage and our cloud storage provider!
Instead of rendering an internal template, we can create pdf from any public url using document_url:
# # app/jobs/invoices/to_pdf_job.rb
- document_content: document_content,
+ document_url: 'https://blog.corsego.com/ruby-on-rails-developer-interview-questions',
+ document_url: invoice_url(@invoice),
To remove the āTEST DOCUMENTā branding from the generated PDFs, we will need to register a DocRaptor account and get an API key:
Use YOUR_API_KEY_HERE
for development
, and our real API key for production
:
- config.username = "YOUR_API_KEY_HERE" # THIS key works in test mode!
+ config.username = "EGergerVAEmkivmreVaerr-rgveW"
In a post-wicked_pdf world, I think Prince is the only easy HTML-to-PDF tool.
Prince is a well maintained technology that lets you perform very advanced PDF features.
Purchasing Prince directly is an expensive upfront payment investment:
Via DocRaptor, we can get āpay-as-you-goā access to:
So the price is between 12 cents
and 2,5 cents
per PDF document.
So whenever a user visits the /pricing
page, I would make an API request to Stripe to get the prices.
This is BAD: these API requests increase the page load time, and moreover you can hit an API limit.
Getting static content via an API request is a perfect example of something you can CACHE.
Caching = storing temporarily with an expiry date.
Cache data with Rails.cache.write
:
# Rails.cache.write(key, value, expires_in: 24.hours)
price.each do |price|
Rails.cache.write("price-#{price.id}", [price.name, price.amount, price.interval].join(';'), expires_in: 24.hours)
end
Read stored data Rails.cache.read
:
Rails.cache.read("price-7484845785247")
# => ["price-7484845785247", #<ActiveSupport::Cache::Entry:0x0000000107be9170 @value="Premium;300;month", @version=nil, @created_at=0.0, @expires_in=1700163180.17973>]
Example of testing a CacheStripePricingJob
:
require "test_helper"
class CacheStripePricingJobTest < ActiveJob::TestCase
test "perform" do
# Rails.cache.clear
original_cache_store = Rails.cache
# MIMIC A NEW CACHE INSTANCE
Rails.cache = ActiveSupport::Cache::MemoryStore.new
# Rails.cache
# EMPTY CACHE LOOKS LIKE THIS:
# => #<ActiveSupport::Cache::MemoryStore entries=0, size=0, options={:compress=>false, :compress_threshold=>1024}>
perform_enqueued_jobs
assert_performed_jobs 1
# cache_data.first
# ["price-7484845785247", #<ActiveSupport::Cache::Entry:0x0000000107be9170 @value="Premium;300;month", @version=nil, @created_at=0.0, @expires_in=1700163180.17973>]
cache_data = Rails.cache.instance_variable_get(:@data)
assert_not_nil cache_data
value = "Premium;300;month"
assert cache_data.values.map(&:value).any?(value)
key = "price-7484845785247"
assert cache_data.keys.any?(key)
cache_data.first
assert_equal Rails.cache.read(key), value
Rails.cache = original_cache_store
end
end
Thatās it! I might add more details about Rails caching inside this blogpost in the future.
]]>2) HTML Modals with Dialog element
We can combine the two to make the perfect modals!
Requirements:
<dialog>
(default styling, close behaviours)Working example:
We will import dialog_controller.js
from the previous post.
Additionally to handle turbo frames, we will:
frame_target
that we clean up when dialog is closedsubmitEnd
that is fired when a form is submitted successfully// app/javascript/controllers/dialog_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dialog"
export default class extends Controller {
- static targets = ["modal"]
+ static targets = ["modal", "frame"]
connect() {
this.modalTarget.addEventListener("close", this.enableBodyScroll.bind(this))
}
disconnect() {
this.modalTarget.removeEventListener("close", this.enableBodyScroll.bind(this))
}
open() {
this.modalTarget.showModal()
document.body.classList.add('overflow-hidden')
}
+ submitEnd(e) {
+ if (e.detail.success) {
+ this.close()
+ }
+ }
close() {
this.modalTarget.close()
// clean up the frame
+ this.frameTarget.removeAttribute("src")
+ this.frameTarget.innerHTML = ""
}
enableBodyScroll() {
document.body.classList.remove('overflow-hidden')
}
clickOutside(event) {
if (event.target === this.modalTarget) {
this.close()
}
}
}
Add dialog to layout to enable access to it globally.
data-controller="dialog"
should be on <body>
, so that click->dialog#open
works anywhereturbo_frame_tag :modal
inside <dialog>
data: {dialog_target: "frame"}
needed to remove content from frame when dialog is closed# app/views/layouts/application.html.erb
<body data-controller="dialog" data-action="click->dialog#clickOutside">
<dialog data-dialog-target="modal"
class="backdrop:bg-gray-400 backdrop:bg-opacity-90 z-10 rounded-md border-4 bg-sky-900 w-full md:w-2/3 mt-24">
<div class="p-8">
<button class="font-bold float-right" data-action="dialog#close">X</button>
<%= turbo_frame_tag :modal, data: {dialog_target: "frame"} %>
</div>
</dialog>
<main class="">
<button data-action="click->dialog#open">Open dialog</button>
<%= yield %>
</main>
</body>
Now you can add data: { turbo_frame: :modal, action: "dialog#open" }
to any link in your app. It will:
1) open dialog
2) replace the content of turbo_frame: :modal
with content from the rendered page
<%= link_to 'Add comment', new_comment_path, data: { turbo_frame: :modal, action: "dialog#open" } %>
Content missing
?=> Wrap the page into turbo_frame_tag :modal
data-action="turbo:submit-end->dialog#submitEnd"
to ensure that dialog is closed after successful form submit# comments/new.html.erb
+<%= turbo_frame_tag :modal do %>
<h1 class="font-bold text-4xl">New comment</h1>
+ <div data-action="turbo:submit-end->dialog#submitEnd">
<%= render "form", comment: @comment %>
+ </div>
+<% end %>
Problems with the above approach:
Solution:
Letās remove the <dialog>
from the layout file, and leave an empty turbo_frame_tag :modal
# app/views/layouts/application.html.erb
<body>
<%= turbo_frame_tag :modal %>
<%= yield %>
</body>
We will open
# app/views/layouts/_turbo_dialog.html.erb
<%= turbo_frame_tag :modal do %>
<dialog data-controller="dialog" data-action="click->dialog#clickOutside"
class="backdrop:bg-gray-400 backdrop:bg-opacity-90 z-10 rounded-md border-4 bg-sky-900 w-full md:w-2/3 mt-24">
<div class="p-8">
<button class="bg-slate-400" data-action="dialog#close">Cancel</button>
<%= yield %>
</div>
</dialog>
<% end %>
Wrap the views that should be rendered inside the modal with the new _turbo_dialog
partial:
# app/views/comments/new.html.erb
# app/views/comments/edit.html.erb
# app/views/comments/show.html.erb
- <%= turbo_frame_tag :modal do %>
+ <%= render 'layouts/turbo_dialog' do %>
So now when a user clicks on a link that should open inside a modal, the content will be rendered within a hidden <dialog>
. Letās update the stimulus controller to automatically open this dialog:
// app/javascript/controllers/dialog_controller.js
// app/javascript/controllers/dialog_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dialog"
export default class extends Controller {
connect() {
+ this.open()
// needed because ESC key does not trigger close event
this.element.addEventListener("close", this.enableBodyScroll.bind(this))
}
disconnect() {
this.element.removeEventListener("close", this.enableBodyScroll.bind(this))
}
// hide modal on successful form submission
// data-action="turbo:submit-end->turbo-modal#submitEnd"
submitEnd(e) {
if (e.detail.success) {
this.close()
}
}
open() {
this.element.showModal()
document.body.classList.add('overflow-hidden')
}
close() {
this.element.close()
// clean up modal content
+ const frame = document.getElementById('modal')
+ frame.removeAttribute("src")
+ frame.innerHTML = ""
}
enableBodyScroll() {
document.body.classList.remove('overflow-hidden')
}
clickOutside(event) {
if (event.target === this.element) {
this.close()
}
}
}
We also cleaned up the redundant stimulus targets!
Perfect! Visually everything works the same, but this code is much better! šÆ
# app/controllers/comments_controller.rb
before_action :ensure_frame_response, only: [:new, :create, :edit, :update]
def ensure_frame_response
redirect_to root_path unless turbo_frame_request?
end
get new_comment_path, headers: {"Turbo-Frame" => "new_comment"}
assert_response :success
post comment_path(comment), params: {comment: {body: 'foo'}}, headers: {"Turbo-Frame" => "new_comment"}
assert_response :success
Well, thatās it!
This approach works well for hotwire-based modals.
]]>Example actions
you could create:
A common problem I have is being able to have a full-page redirect after submitting a form in a turbo_frame
modal.
With modals, in some cases we would want a response to be:
# app/controllers/*_controller.rb
# request.variant = :turbo_frame
def create
respond_to do |format|
format.turbo_stream do
# impossible full-page redirect?
end
end
end
Previously, to perform a full-page redirect in this scenario I would turbo_stream a link to the top of the page <body>
and auto-click it.
Stimulus controller to autoclick an element:
// app/javascript/controllers/autoclick_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.click()
}
}
Next I would add a helper that:
<a href=>
link with a given urlturbo_stream.append_all("body")
adds the generated link to the top of the document# app/helpers/turbo_stream_actions_helper.rb
module TurboStreamActionsHelper
def turbo_stream_navigate(url)
link = tag.a(
nil,
style: 'display: none;'
href: url,
data: {controller: "autoclick", turbo_cache: false}
)
turbo_stream.append_all("body") { link }
end
end
So, a turbo response would add the link to a page and click it.
# app/controllers/*_controller.rb
# request.variant = :turbo_frame
def create
respond_to do |format|
format.turbo_stream do
+ render turbo_stream: helpers.turbo_stream_navigate(admin_assessment_form_path(@assessment_form))
end
end
end
Smart workaround!
But a ācorrectā approach would be to use custom Turbo Stream Actions.
console.log
You can add custom turbo stream actions directily in your app/javascript/application.js
.
// app/javascript/application.js
import { Turbo } from "@hotwired/turbo-rails"
// <turbo-stream action="console_log" message="<%= Time.zone.now"></turbo-stream>
// turbo_stream.action(:console_log, message: "foo") // will this work?
Turbo.StreamActions.console_log = function() {
const message = this.getAttribute("message")
console.log(message)
}
Look here
if you have JS errors with importing StreamActions.
Now you can add a helper to use the usual Rails syntax for rendering turbo_streams:
# rails generate helper TurboStreamActions
# app/helpers/turbo_stream_actions_helper.rb
module TurboStreamActionsHelper
# render turbo_stream: turbo_stream.console_log("foobar")
def console_log(message)
turbo_stream_action_tag :console_log, message: message
end
end
# you need this line to tell the app that this file includes custom turbo stream action helpers
Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)
Now you have 3 ways of invoking this turbo_stream:
<turbo-stream action="console_log" message="<%= Time.zone.now"></turbo-stream>
turbo_stream.action(:console_log, message: "foo") // will this work?
turbo_stream.console_log("foobar")
Add the javascript redirect:
// app/javascript/application.js
// enable Turbo.StreamActions
import { Turbo } from "@hotwired/turbo-rails"
// <turbo-stream action="redirect" target="/projects"><template></template></turbo-stream>
// turbo_stream.action(:redirect, projects_path)
Turbo.StreamActions.redirect = function () {
Turbo.visit(this.target);
};
Use it in your controller:
# request.variant = :turbo_frame
def create
respond_to do |format|
format.turbo_stream do
- render turbo_stream: helpers.turbo_stream_navigate(projects_path)
+ render turbo_stream: turbo_stream.action(:redirect, projects_path)
end
end
end
And it will redirect! YAY!
Just add flash.keep
to make flash work on a double-redirect š
flash[:notice] = "Comment created."
flash.keep(:notice)
In this case, we will pass the url not as a target but as a param. You could pass multiple params like this.
// app/javascript/application.js
import { Turbo } from "@hotwired/turbo-rails"
// <turbo-stream action="redirect_advanced" url="<%= projects_path %>"></turbo-stream>
Turbo.StreamActions.redirect_advanced = function () {
const url = this.getAttribute('url') || '/'
// Turbo.visit(url, { frame: '_top', action: 'advance' })
Turbo.visit(url)
}
Create a helper:
# app/helpers/turbo_stream_actions_helper.rb
module TurboStreamActionsHelper
# render turbo_stream: turbo_stream.redirect_advanced(projects_path)
def redirect_advanced(url)
turbo_stream_action_tag :redirect_advanced, url: url
end
end
Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)
Use it in your controller:
# request.variant = :turbo_frame
def create
respond_to do |format|
format.turbo_stream do
- render turbo_stream: helpers.turbo_stream_navigate(projects_path)
- render turbo_stream: turbo_stream.action(:redirect, projects_path)
+ render turbo_stream: turbo_stream.redirect_advanced(projects_path)
end
end
end
Test the helper:
# spec/helpers/turbo_stream_actions_helper_spec.rb
it "returns a turbo-stream tag" do
expect(helper.redirect("/projects")).to eq(
"<turbo-stream url=\"/projects\" action=\"redirect_advanced\"><template></template></turbo-stream>"
)
end
Thatās it!
P.S. The gem marcoroth/turbo_power-rails offers many custom turbo stream actions that you can import into your app. No need to reinvent the wheel!
]]>AVO offers a similar behaviour:
Hereās how You, dear @Elgnonvis
, can add this kind of functionality to your app.
Posts
, Tags
, Users
on a pagerails g model Post title description
rails g model Tag name
rails g model User first_name last_name email
# config/routes.rb
get "dashboard", to: "pages#index"
# app/controllers/dashboard_controller.rb
def index
@posts = Post.all
@users = User.all
@tags = Tag.all
end
# app/views/dashboard/index.html.erb
<div>
Posts
<% @posts.each do |post| %>
<div>
<%= highlight post.title, params[:query] %>
<%= highlight post.description, params[:query] %>
</div>
<% end %>
</div>
<div>
Users
<% @users.each do |user| %>
<div>
<%= highlight user.first_name, params[:query] %>
<%= highlight user.last_name, params[:query] %>
<%= highlight user.email, params[:query] %>
</div>
<% end %>
</div>
<div>
Tags
<% @tags.each do |tag| %>
<div>
<%= highlight tag.name, params[:query] %>
</div>
<% end %>
</div>
<%= link_to "Clear", dashboard_path if params[:query] %>
<%= form_with url: dashboard_path, method: :get do |form| %>
<%= form.search_field :query, value: params[:query], placeholder: "Find anything", autofocus: true %>
<%= form.submit %>
<% end %>
ILIKE
is case-insensitive search anywhere in the text.
# app/controllers/dashboard_controller.rb
def index
if params[:query].present?
@posts = Post.where("title ILIKE ? OR description ILIKE ?", "%#{params[:query]}%", "%#{params[:query]}%")
@tags = Tag.where("name ILIKE ?", "%#{params[:query]}%")
@users = User.where("first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ?", "%#{params[:query]}%", "%#{params[:query]}%", "%#{params[:query]}%")
else
@posts = []
@tags = []
@users = []
end
end
In most cases, it is enough to stop on āStep 2ā.
This is what I use at superails.com
No need to overcomplicate.
However sometimes you would want to automate the process of extending the list of models
and attributes
that can be searched.
models
and attributes
that can be searched and displayedclass DashboardController < ApplicationController
SEARCHABLE_MODEL_ATTRIBUTES = {
"Post" => ["title", "description"],
"Tag" => ["name"],
"User" => ["first_name", "last_name", "email"]
}
def index
@search_results = {}
if params[:query].present?
SEARCHABLE_MODEL_ATTRIBUTES.each do |model_name, searchable_fields|
model_results = model_name.constantize.
where(searchable_fields.map { |field| "#{field} ILIKE :query" }.join(" OR "), query: "%#{params[:query]}%")
.order(created_at: :desc)
@search_results[model_name] = model_results
end
end
end
end
Render a collection for each searchable model.
Render either a partial for each searchable model (app/views/posts/_post.html.erb
, users/_user.html.erb
, etc.), or the searchable fields.
# app/views/dashboard/index.html.erb
<% if params[:query].present? %>
<% DashboardController::SEARCHABLE_MODEL_ATTRIBUTES.each do |model_name, _searchable_fields| %>
<% results = @search_results[model_name] %>
<% next if results.empty? %>
<h2>
<%= model_name.pluralize %>
<%= results.count %>
</h2>
<div>
<% results.each do |result| %>
<% searchable_fields.each do |searchable_field| %>
<%= highlight result[searchable_field], params.dig(:query) %>
<% end %>
<%#= render "#{model_name.downcase.pluralize}/#{model_name.downcase}", model_name.downcase.to_sym => result %>
<% end %>
</div>
<% end %>
<% end %>
Final result:
Now you can search any models and attributes by just updating SEARCHABLE_MODEL_ATTRIBUTES
!
Thatās it! š¤
]]>In many cases, you donāt need to store a barcode. Instead, you can use Javascript to generate and display it within the browser.
To you as a developer, this saves storage and compute power.
JsBarcode is a good library.
I used JsBarcode to generate 500+ barcodes on a single page on the fly:
BARCODE THEORY: There are a few barcode encoding algorythms (CODE128
, MSI
, EAN
, etc.). It is usually up to a business to chose which algorythm to use. The same string would look different, when encoded by different algorythms. When you are using a barcode reader, you might have to define which encoding should it try to read/decrypt.
# shell
bin/importmap pin jsbarcode
rails g stimulus barcode
// app/assets/javascripts/controllers/barcode_controller.js
import { Controller } from '@hotwired/stimulus'
import JsBarcode from 'jsbarcode'
// html example:
// <img data-controller="barcode" data-barcode="1234567890" class="barcode">foo</img>
// <svg data-controller="barcode" data-barcode="1234567890" class="barcode">bar</svg>
export default class extends Controller {
connect() {
this.loadJsBarcodeLibrary();
}
loadJsBarcodeLibrary() {
// https://github.com/lindell/JsBarcode/wiki/Options
const options = {
format: "CODE128", // CODE39
// font: "monospace", // fantasy
// textAlign: "left",
// textPosition: "top",
width: 3,
height: 60,
quite: 1,
margin: 0,
lineColor: "#000000", // #0000FF
displayValue: false,
};
let barcodeVal = this.element.dataset.barcode;
// frontent validation
if (barcodeVal && barcodeVal !== "" && barcodeVal !== "-" && barcodeVal !== "undefined") {
// generate the barcode
JsBarcode(this.element, barcodeVal, options);
}
}
}
A barcode has to be generated in an img
or svg
html tag.
Minimal HTML setup example:
<img data-controller="barcode" data-barcode="1234567890">
Example of rendering a list of products and their barcodes:
Thatās it! š¤
]]>Example 1:
Example 2:
Goal: If a user presses a hotkey combination on a keyboard, trigger a click on a link, button or element.
In our example a user will always have to click ā Command
+ yourKey
(Mac), or Ctrl
+ yourKey
(Linux/Windows).
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="hotkeys"
export default class extends Controller {
static targets = ["button"]
connect() {
document.addEventListener('keydown', this.handleKeydown.bind(this))
}
disconnect() {
document.removeEventListener('keydown', this.handleKeydown.bind(this))
}
handleKeydown(event) {
// meta for Mac, ctrl for Linux/Windows
let pressedCtrl = event.metaKey || event.ctrlKey
let pressedKey = event.key
if (pressedCtrl) {
// find a buttonTarget that has hotkey set to the pressed key
let buttonTarget = this.buttonTargets.find((el) => el.dataset.hotkey == pressedKey)
if (buttonTarget) {
event.preventDefault()
buttonTarget.focus()
buttonTarget.click()
}
}
}
}
HTML usage example:
<body data-controller="hotkeys">
<a href="#" data-hotkeys-target="button" data-hotkey="e">Edit</a>
<button data-hotkeys-target="button" data-hotkey="s">Save</button>
<button data-hotkeys-target="button" data-hotkey="d">Delete</button>
</body>
Instead of EventListeners, we can use Stimulus KeyboardEvent Filter
-<body data-controller="hotkeys">
+<body data-controller="hotkeys" data-action="keydown->hotkeys#handleKeydown">
<a href="#" data-hotkeys-target="button" data-hotkey="e">Edit</a>
<button data-hotkeys-target="button" data-hotkey="s">Save</button>
<button data-hotkeys-target="button" data-hotkey="d">Delete</button>
</body>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "button" ]
handleKeydown(event) {
// Check if Cmd (Mac) or Ctrl (Windows/Linux) is pressed simultaneously with the key
let pressedCtrl = event.metaKey || event.ctrlKey
let pressedKey = event.key.toLowerCase()
if (pressedCtrl) {
// find a button with data-hotkey attribute that matches the pressed key
let buttonTarget = this.buttonTargets.find((el) => el.dataset.hotkey == pressedKey)
if (buttonTarget) {
event.preventDefault();
buttonTarget.focus()
buttonTarget.click()
}
}
}
}
Important considerations:
Ctrl+yourKey
combination are reserved by the browser. Different browsers can have different reserved combinations. A few characters that seem to work across browsers are k
, u
, b
, k
.Thatās it! š¤
]]>When a user clicks a link to redirect to a new page, the <body>
element gets re-rendered and the stimulus controller gets re-initialized.
# app/views/layouts/application.html.erb
-<body>
+<body data-controller="transition-animation">
// app/javscript/controllers/transition_animation_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="transition-animation"
export default class extends Controller {
connect() {
let divs = this.element.querySelectorAll("div")
// add css class to all <div> elements
divs.forEach((div) => div.classList.add("animate-spin"))
// disconnect after 0,5 seconds
setTimeout(() => {
// remove css class from all <div> elements
divs.forEach((div) => div.classList.remove("animate-spin"))
this.disconnect()
}, 500)
}
}
animate-spin
is a TailwindCSS class that represents the following raw css:
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
Thatās it! š¤
]]><meter>
element that is gradually filled and disappears on 100%.
In a web app this could be a great progress indicator, or animation on a disappearing flash message.
0,5 seconds (500ms)
<meter data-controller="meter" data-meter-duration-value="500"></meter>
2 seconds (2000ms)
<meter data-controller="meter" data-meter-duration-value="2000"></meter>
5 seconds (5000ms)
<meter data-controller="meter"></meter>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
duration: { type: Number, default: 5000 }
}
connect() {
this.element.value = 0
this.element.min = 0
this.element.max = 100
this.startProgress();
}
startProgress() {
// 50ms interval for 1% over 5 seconds
let interval = this.durationValue / 100
this.progressInterval = setInterval(() => {
this.incrementProgress();
}, interval);
}
incrementProgress() {
const currentProgress = this.element.value;
if (currentProgress < 100) {
this.element.value = currentProgress + 1;
} else {
clearInterval(this.progressInterval);
this.element.classList.add('hidden')
}
}
}
Thatās it! š¤
]]><dialog>
HTML element, and now it is supported by all browsers!
HTML <dialog>
is basically a āmodalā:
Example:
<dialog open>
<span>You can see me</span>
</dialog>
method="dialog"
on form
<dialog open>
<span>You can see me</span>
<form method="dialog">
<button type="submit" autofocus>Cancel</button>
</form>
</dialog>
formmethod="dialog"
on button
<dialog open>
<span>You can see me</span>
<form>
abc
<button formmethod="dialog" type="submit">Cancel</button>
<button>Submit</button>
</form>
</dialog>
<div data-controller="dialog">
<button data-action="dialog#open">
Open modal
</button>
<dialog data-dialog-target="modal">
<span>You can see me</span>
<form method="dialog">
<button type="submit" autofocus>Cancel</button>
</form>
</dialog>
</div>
// app/javascript/controllers/dialog_controller.js
static targets = ["modal"]
open() {
this.modalTarget.showModal()
// this.modalTarget.show()
}
.show()
- background is clickable, can be used like a regular dropdown.showModal()
- background is not clickable, you can apply css styles like blur
. You can use āEscā key close it!Most likely you want to use exclusively .showModal()
.
dialog::backdrop {
backdrop-filter: blur(8px);
background-color: hsl(250, 100%, 50%, 0.25);
}
clickOutside(event) {
if (event.target === this.dialogTarget) {
this.close()
}
}
-<div data-controller="dialog">
+<div data-controller="dialog" data-action="click->dialog#clickOutside">
open() {
this.dialogTarget.showModal()
document.body.classList.add("overflow-hidden");
}
close() {
this.dialogTarget.close()
document.body.classList.remove("overflow-hidden");
}
Important: It will not work with the default behaviour of closing by clicking Escape
or by method="dialog"
.
To make it actually work you need to listen to the close
event on <dialog>
:
connect() {
this.modalTarget.addEventListener("close", this.enableBodyScroll.bind(this))
}
enableBodyScroll() {
document.body.classList.remove('overflow-hidden')
}
/* app/assets/stylesheets/application.css */
dialog::backdrop {
backdrop-filter: blur(8px);
/* background-color: hsl(250, 100%, 50%, 0.25); */
}
<div data-controller="dialog" data-action="click->dialog#clickOutside">
<button data-action="click->dialog#open">Open dialog</button>
<dialog data-dialog-target="modal"
class="backdrop:bg-gray-400 backdrop:bg-opacity-90 z-10 rounded-md border-4 bg-sky-900 w-full md:w-2/3 mt-24">
<div class="p-8">
<button class="bg-slate-400" data-action="dialog#close">Cancel</button>
<p>Greetings, one and all!</p>
<form>
<button formmethod="dialog">Cancel</button>
<button>OK</button>
</form>
</div>
</dialog>
</div>
// app/javascript/controllers/dialog_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dialog"
export default class extends Controller {
static targets = ["modal"]
connect() {
this.modalTarget.addEventListener("close", this.enableBodyScroll.bind(this))
}
disconnect() {
this.modalTarget.removeEventListener("close", this.enableBodyScroll.bind(this))
}
open() {
// this.modalTarget.show()
this.modalTarget.showModal()
document.body.classList.add('overflow-hidden')
}
close() {
this.modalTarget.close()
// document.body.classList.remove('overflow-hidden')
}
enableBodyScroll() {
document.body.classList.remove('overflow-hidden')
}
clickOutside(event) {
if (event.target === this.modalTarget) {
this.close()
}
}
}
Inspired by:
To explore in the future:
format.html
, format.turbo_stream
Thatās it for now! š¤
]]>LG ššš
MD šš
SM š
<div id="products" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<%= render @products %>
</div>
ā”ļøšā¬
ļø
<div class="mx-auto md:w-2/3 w-full border p-8 rounded-xl shadow-lg">
<div>
Element
</div>
</div>
LG
šš
SM
š
š
Big screen - inline. Small screen - column.
flex-1
or flex-grow
- on the div
within flex
that should take up the responsive max space in a row.
prefer space-x-4
, space-y-4
over margins (ml-4
, mr-4
, mt-4
, m-4
).
prefer gap-4
over space-
when possible
<div class="flex flex-col md:flex-row gap-2 bg-rose-200">
<div class="flex-1 bg-rose-300">
Element 1
</div>
<hr class="my-2">
<div class="bg-rose-400">
Element 2
</div>
</div>
use w-4/5
and similar, instead of flex-1
:
<div class="flex flex-col lg:flex-row gap-2 bg-green-200">
<div class="lg:w-4/5 bg-green-300">
Element 1
</div>
<hr class="my-2">
<div class="lg:ml-4 lg:w-1/5 bg-green-400">
Element 2
</div>
</div>
<div class="flex flex-col md:flex-row gap-2">
<%= link_to 'Edit this product', edit_product_path(@product), class: "rounded-lg py-3 px-5 bg-gray-100" %>
<%= button_to 'Destroy this product', product_path(@product), method: :delete, class: "w-full rounded-lg py-3 px-5 bg-gray-100" %>
<%= link_to 'Back to products', products_path, class: "rounded-lg py-3 px-5 bg-gray-100 inline-block" %>
</div>
The buttons are imperfect, but not too bad.
Thatās it! š¤
]]>Now, letās create a dropdown menu that is accessible only on mobile (small screen).
Hereās how a perfectly styled mobile menu looks on Superails.com:
But not so fast! Hereās the mobile menu that we will build now:
First, add a generic stimulus controller to show/hide content:
// app/javasctipt/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dropdown"
export default class extends Controller {
static targets = ["content"]
connect() {
this.close()
}
toggle() {
if (this.contentTarget.classList.contains("hidden")) {
this.open()
}
else {
this.close()
}
}
open() {
this.contentTarget.classList.remove("hidden")
}
close() {
this.contentTarget.classList.add("hidden")
}
}
Now, update the layout file from the previous post:
<!-- app/views/layouts/application.html.erb -->
<body class="bg-slate-500">
+ <div class="sticky top-0 z-10" data-controller="dropdown">
+ <nav class="bg-slate-200 p-4 flex justify-between items-center h-20">
+ <div class="">
+ logo
+ </div>
+ <div class="flex space-x-2 items-center">
+ <div class="">
+ email
+ </div>
+ <div class=" text-3xl cursor-pointer" data-action="click->dropdown#toggle" role="button">
+ ☰
+ </div>
+ </div>
+ </nav>
+ <nav class="absolute hidden bg-green-100 w-full h-80 overflow-y-auto" data-dropdown-target="content">
+ dropdown content
+ <% (200..300).each do |i| %>
+ <p><%= i%></p>
+ <% end %>
+ </nav>
+ </div>
<div class="bg-slate-300 flex">
<nav class="bg-slate-400 w-1/6 hidden md:flex flex-col text-center p-4 justify-between sticky top-20 h-[calc(100vh-80px)]">
<div>
sidebar top
</div>
<div>
sidebar bottom
</div>
</nav>
<main class="bg-slate-500 w-5/6 p-4 flex-grow">
main
<% (1..100).each do |i| %>
<p><%= i%></p>
<% end %>
<%= yield %>
</main>
</div>
</body>
Notice that the ānavbarā (with logo and email) and ādropdown contantā are in the same div;
absolute
class on ādropdown contentā, because:
Now the layout file is getting really big, so it makes sence to abstract navbar
and sidebar
into partials:
<body class="bg-slate-500">
+ <%= render 'shared/navbar' %>
<div class="bg-slate-300 flex">
+ <%= render 'shared/sidebar' %>
<main class="bg-slate-500 w-5/6 p-4 flex-grow">
main
<% (1..100).each do |i| %>
<p><%= i%></p>
<% end %>
<%= yield %>
</main>
</div>
</body>
Install stimulus-use
bin/importmap pin stimulus-use
sm
(768 px)<main>
area and display ONLY dropdown on page<main>
<body>
content// app/javasctipt/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
// https://github.com/stimulus-use/stimulus-use/blob/main/docs/use-click-outside.md
import { useClickOutside } from 'stimulus-use'
// Connects to data-controller="dropdown"
export default class extends Controller {
static targets = ["content"]
connect() {
useClickOutside(this)
}
clickOutside(event) {
this.close()
}
closeWithKeyboard(event) {
if (event.key === "Escape") {
this.close()
}
}
closeOnBigScreen(event) {
if (window.innerWidth > 768) {
this.close()
}
}
toggle() {
if (this.contentTarget.classList.contains("hidden")) {
this.open()
}
else {
this.close()
}
}
open() {
this.contentTarget.classList.remove("hidden")
// let main = document.querySelector("main")
// main.classList.add("blur")
// document.body.classList.add("overflow-hidden");
// main.classList.add("hidden")
}
close() {
this.contentTarget.classList.add("hidden")
// let main = document.querySelector("main")
// main.classList.remove("blur")
// document.body.classList.remove("overflow-hidden");
// main.classList.remove("hidden")
}
}
Update the navbar:
<!-- app/views/shared/navbar.html.erb -->
<div class="sticky top-0 z-10" data-controller="dropdown">
<nav class="bg-slate-200 p-4 flex justify-between h-20 items-center">
<div class="">
logo
</div>
<div class="flex space-x-2 items-center">
<div class="">
email
</div>
<div class="md:hidden text-3xl" data-action="click->dropdown#toggle" role="button">
☰
</div>
</div>
</nav>
<nav class="absolute hidden bg-rose-300 w-full h-40 overflow-y-auto" data-dropdown-target="content" data-action="keyup@window->dropdown#closeWithKeyboard resize@window->dropdown#closeOnBigScreen">
dropdown
<% (1..100).each do |i| %>
<p><%= i%></p>
<% end %>
</nav>
</div>
Thatās it! š¤
āTailwind on Railsā agenda:
Tailwind CSS: It looks awful, and it works.
Adam Wathan, TailwindCSS creator
As of Rails 7, you can automatically install TailwindCSS when generating a new rails app by running rails new -c=tailwind -d=postgresql
. Adam also personally helped with the default styles.
I am super excited that just like me, Adam will be also speaking on Rails World.
In this mini-series I will cover the main aspects of using TailwindCSS when building a Rails app.
When you create a new Rails app, first of all you want to figure out navigation (navbar, sidebar, footer) and UI responsiveness (make it work on all screen sizes).
Letās build a responsive layout with a sidebar that is replaced by a dropdown on a small screen:
Hereās the HTML for this layout:
<!-- app/views/layouts/application.html.erb -->
<body class="bg-green-200">
<header class="bg-slate-500 flex justify-between p-4 sticky top-0 h-20 items-center">
<div>
logo
</div>
<div>
current_user.email
</div>
<div class="md:hidden">
ā°
</div>
</header>
<div class="flex flex-grow">
<nav class="bg-slate-400 w-1/6 md:flex flex-col hidden justify-between p-4 text-center sticky top-20 h-[calc(100vh-80px)]">
<div>
right sidebar TOP
</div>
<div>
right sidebar BOTTOM
</div>
</nav>
<main class="w-5/6 p-4 bg-rose-300 flex-grow">
<% (1..100).each do |i| %>
<p><%= i %></p>
<% end %>
<%= yield %>
</main>
</div>
</body>
Colors are present for you to see the different elements.
Now, itās up to you to give unique styles for each page inside yield
.
For example, for a page with centered, not full-width content you can do:
<!-- app/views/posts/new.html.erb -->
<div class="mx-auto max-w-md">
New Post
<%= render "form" %>
</div>
Thatās it! š¤
Next step: Make the navbar dropdown actually work on small screen when the sidebar is hidden š
]]>Now, before doing such a destructive operation as replacing production db with development db, it is important to start with a backup!
For it all to work, be sure you are logged into heroku via your console (heroku login
).
# create backup
heroku pg:backups:capture --app superails
# see a list of all backups
heroku pg:backups -a superails
# download a backup from heroku, store it as latest.dump
heroku pg:backups:download --app superails
This command will take your database named superails_development
and generate a file named mydb.dump
pg_dump -Fc --no-acl --no-owner -h localhost -U postgres -d superails_development -f mydb.dump
mydb.dump
) to a publicly accessible cloud storageCreate a public S3 bucket, or go to bucket permissions and click the āeditā button to make it public:
Unselect āblock public accessā:
Upload a file (your mydb.dump
file) and make it public:
Copy the public url for your mydb.dump
:
mydb.dump
With the copied URL, run a command to replace your production database with the one you are uploading:
heroku pg:backups restore 'https://corsego-public.s3.eu-central-1.amazonaws.com/mydb.dump' DATABASE -a superails-production
Thatās it! š¤
]]>To decrease the gravity of attacks, you can use gem rack-attack.
bundle add rack-attack
# config/application.rb
module Myapp
class Application < Rails::Application
config.load_defaults 7.0
+ config.middleware.use Rack::Attack
end
end
Simple rack-attack initializer setup:
# config/initializers/rack_attack.rb
Rack::Attack.throttle("req/ip", limit: 1000, period: 5.minutes) do |req|
unless req.path.start_with?("/assets")
Rails.logger.error("Rack::Attack Too many requests from IP: #{req.ip}")
req.ip
end
end
Rack::Attack.throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/users/sign_in" && req.post?
end
Rack::Attack.throttle("logins/email", limit: 5, period: 20.seconds) do |req|
if req.path == "/users/sign_in" && req.post?
req.params["email"].to_s.downcase.gsub(/\s+/, "").presence
end
end
Rack::Attack.throttle("users/sign_up", limit: 3, period: 15.minutes) do |req|
req.ip if req.path == "/users" && req.post?
end
Decrease the request limits for your testing purposes:
-Rack::Attack.throttle("req/ip", limit: 1000, period: 5.minutes) do |req|
+Rack::Attack.throttle("req/ip", limit: 8, period: 5.minutes) do |req|
While running rails s
in one console tab, open another console tab and run > 8 CURL requests to your app:
for i in {1..10}; do curl -X GET http://localhost:3000/users/sign_in;
done
In your rails s
logs you should get Rack::Attack Too many requests from IP: 127.0.0.1
:
P.S. When installing the gem based on the readme, I had issues. This stackoverflow question had all the answers.
Thatās it! š¤
]]>Approach #2 stays my favorite one, because it is the least intrusive (lowest quantity of files to change and maintain).
This is an improvement of approach #1, where we add our error pages to our routes.
First, say that you want to handle errors via your routes:
# config/application.rb
config.exceptions_app = self.routes
Add error paths to routes:
# config/routes.rb
match "/404", to: "errors#not_found", via: :all
match "/422", to: "errors#unprocessable_entity", via: :all
match "/500", to: "errors#internal_server_error", via: :all
Controller to handle all errors:
# app/controllers/errors_controller.rb
class ErrorsController < ApplicationController
def not_found
render status: :not_found,
template: 'errors/index'
end
def internal_server_error
render status: :internal_server_error,
template: 'errors/index'
end
def unprocessable_entity
render status: :unprocessable_entity,
template: 'errors/index'
end
end
Error layout file that accepts TailwindCSS:
<!-- app/views/layouts/errors.html.erb -->
<!DOCTYPE html>
<html class="h-full antialiased">
<head>
<%= render 'shared/meta_tags' %>
<%= 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 class="font-sans font-normal leading-normal bg-sky-950 text-gray-200 flex flex-col min-h-screen">
<main class="grid h-screen place-items-center">
<%= yield %>
</main>
</body>
</html>
One generic template file that is populated by text from an i18n file:
<!-- app/views/errors/index.html.erb -->
<div class='text-center'>
<h1 class="font-bold text-4xl">
<%= response.status %>
<%= action_name.humanize %>
</h1>
<p>
<%= t :message, scope: [:errors, action_name] %>
</p>
<%= link_to 'Return to homepage', root_url, class: 'text-blue-500' %>
<%= image_tag 'errors.png' %>
</div>
I18n file that handles different text based on error message:
# config/locales/en.yml
en:
errors:
internal_server_error:
message: It's not you. Something went wrong on our end.
not_found:
message: Sorry, we couldn't find that page.
unprocessable_entity:
message: Sorry, we couldn't process that request.
Thatās it! š¤
]]>Usecase examples:
Install ActiveStorage:
bin/rails active_storage:install
bin/rails db:migrate
bundle add image_processing
Attach cover_image
to Post
via ActiveStorage
:
# app/models/post.rb
has_one_attached :cover_image
Job that attaches an image from image_url
to a post
:
require 'open-uri'
class StoreCoverImageJob < ApplicationJob
queue_as :default
PERMIT_IMAGE_FORMAT = %w[png jpg jpeg].freeze
def perform(post, image_url)
valid_image_format = get_content_type(image_url)
return unless valid_image_format
uri = URI.parse(image_url)
image = uri.open
image_name = File.basename(image_url)
post.cover_image.attach(io: image, filename: image_name, content_type: valid_image_format)
end
private
def get_content_type(image_url)
ext_name = File.extname(image_url).delete('.')
return unless PERMIT_IMAGE_FORMAT.include?(ext_name)
"image/#{ext_name}"
end
end
Try it in your rails console
:
post = Post.first
post.cover_image.attached? #=> false
image_url = "https://i.ytimg.com/vi/Ubrr9mqE94o/maxresdefault.jpg"
StoreCoverImageJob.perform_now(post, image_url)
post.cover_image.attached? #=> true
Test StoreCoverImageJob
:
# test/jobs/youtube/store_cover_image_job_test.rb
require 'test_helper'
class StoreCoverImageJobTest < ActiveJob::TestCase
test 'attach cover image' do
post = posts(:one)
assert_not post.cover_image.attached?
StoreCoverImageJob.perform_now
assert post.reload.cover_image.attached?
end
end
Test has_one_attached :cover_image
# test/models/post_cover_image_test.rb
require 'test_helper'
class PostTest < ActiveSupport::TestCase
test 'cover_image is attached to post' do
post = Post.new
post.cover_image.attach(io: Rails.root.join('test/fixtures/files/image.jpg').open,
filename: 'image.jpg',
content_type: 'image/jpeg')
assert post.valid?
assert post.cover_image.attached?
assert_equal 'image.jpg', post.cover_image.filename.to_s
assert_equal 'image/jpeg', post.cover_image.content_type
end
end
Thatās it!
]]>An easy way to do it is to introduce tags, and find āsimilarā records by the amount of matching tags.
This is how I show similar posts on SupeRails.com:
# app/models/post.rb
class Post < ApplicationRecord
has_many :post_tags, dependent: :destroy
has_many :tags, through: :post_tags
def similar_posts
Post.joins(:tags)
.where.not(id:) # do not show current post within similar records
.where(tags: { id: tags.ids })
.group('posts.id')
.select('posts.*, COUNT(tags.id) AS tags_count')
.order(tags_count: :desc)
.limit(5) # max similar records
end
end
Now you can call
post = Post.first
post.similar_posts
# test/models/similar_posts_test.rb
require 'test_helper'
class SimilarPostsTest < ActiveSupport::TestCase
test 'similar_posts returns correct posts' do
post = Post.create(title: 'Test Post')
tag1 = Tag.create(title: 'Tag 1')
tag2 = Tag.create(title: 'Tag 2')
tag3 = Tag.create(title: 'Tag 3')
post.tags << tag1
post.tags << tag2
similar_post1 = Post.create(title: 'Similar Post 1')
similar_post1.tags << tag1
similar_post2 = Post.create(title: 'Similar Post 2')
similar_post2.tags << tag2
unrelated_post = Post.create(title: 'Unrelated Post')
unrelated_post.tags << tag3
similar_posts = post.similar_posts
assert_includes similar_posts, similar_post1
assert_includes similar_posts, similar_post2
assert_not_includes similar_posts, unrelated_post
end
end
Traditionally, content creators can assign tags manually (jekyll blog, dev.to, youtube):
You can also use different software to assign tags automatically:
Thatās it!
]]>This means that you can make tasks run in parallel to the rails server
request-response cycle in a separate process.
There are different adapter tools for processing ActiveJobs.
Previously I wrote about processing ActiveJobs with gem good_job and Postgres without Redis.
This tweet was a real āparadigm shiftā for me:
Since I read this post, I do not create a ā/services
ā folder in my Rails apps. Instead, I put everything under ā/jobs
ā
Gem Sidekiq might be the most popular ActiveJob adapter. It uses Redis database to store a que of jobs that should be performed, and their execution statuses.
# Gemfile
# bundle add sidekiq
gem 'sidekiq'
Run sidekiq in a separate terminal tab, or add it to Procfile.dev
# Procfile.dev
web: bin/rails server -p 3000
css: bin/rails tailwindcss:watch
+worker: bundle exec sidekiq -c 5 -q default
By default, ActiveJob runs within with your rails server
in development. Enforce sidekiq for the development environment:
# config/environments/development.rb
config.active_job.queue_adapter = :sidekiq
Sidekiq provides a dashboard to view the execution statuses of all your jobs.
Enable the dashboard in routes:
# config/routes.rb
mount Sidekiq::Web => '/sidekiq'
Now you can visit http://localhost:3000/sidekiq to view the dashboard! š„³
Enable access to the sidekiq dashboard only for authenticated admin users:
# config/routes.rb
require 'sidekiq/web'
Rails.application.routes.draw do
authenticate :user, ->(user) { user.admin? } do
mount Sidekiq::Web => '/sidekiq'
end
end
There are 3 things you need to do:
# config/environments/production.rb
config.active_job.queue_adapter = :sidekiq
bundle exec sidekiq -c 5 -q default
(heroku example):REDIS_URL
to your worker (render example):Thatās it! š¬
]]>I could copypaste the information manually, but that would take ages.
I could use web scraping, but whatās the point, if there is an easy to use Youtube API?
Youtube API is accessible in Ruby via the gem āgoogle-api-clientā
First, access Google Cloud Console. Search for YouTube Data API v3:
Click to enable the API:
Create an API key:
View Youtube API docs
To make the API call to list videos, you need to provide a channel ID. Hereās how you can access any channel ID:
Install the gem:
# Gemfile
# gem 'google-api-client' # https://github.com/googleapis/google-api-ruby-client
gem 'google-apis-youtube_v3'
Make a list_searches
API call to get up to 50 videos per page form the selected channel.
# rails c
require 'google/apis/youtube_v3'
CHANNEL_ID = 'UCyr6ZTmztFW3FB4qG_97FoA'
YOUTUBE_KEY = 'MySecretKey'
youtube = Google::Apis::YoutubeV3::YouTubeService.new
youtube.key = YOUTUBE_KEY
# list videos and playlists
response = youtube.list_searches('snippet', channel_id: CHANNEL_ID, max_results: 50)
# list only videos
response = youtube.list_searches('snippet', channel_id: CHANNEL_ID, max_results: 5, type: 'video')
This will provide you basic information about up to 50 videos per page.
If you want to have more detailed info about a specific video, you should make a list_videos
API call while passing the video_id
.
# rails c
require 'google/apis/youtube_v3'
CHANNEL_ID = 'UCyr6ZTmztFW3FB4qG_97FoA'
YOUTUBE_KEY = 'MySecretKey'
youtube = Google::Apis::YoutubeV3::YouTubeService.new
youtube.key = YOUTUBE_KEY
video_id = "07XQY8nRvd0"
video_response = youtube.list_videos('snippet', id: video_id)
Genarate a Post
model, and jobs to make async API calls:
rails g model Post video_id title description:text tags:text published_at:datetime cover_image_url
rails g job Youtube::ListVideosJob
rails g job Youtube::CreateVideoJob
The API call will give us tags as an array, so letās store them as such:
# app/models/post.rb
serialize :tags, Array
Job to LIST and Paginate all the videos on a Youtube channel:
# app/jobs/youtube/list_videos_job.rb
require 'google/apis/youtube_v3'
# Youtube::ListVideosJob.perform_later
class Youtube::ListVideosJob < ApplicationJob
queue_as :default
CHANNEL_ID = Rails.application.credentials.dig(:youtube, :channel_id)
YOUTUBE_KEY = Rails.application.credentials.dig(:youtube, :youtube_key)
def perform
youtube = Google::Apis::YoutubeV3::YouTubeService.new
youtube.key = YOUTUBE_KEY
video_ids = fetch_all_videos(youtube)
process_videos(video_ids)
end
private
def fetch_all_videos(youtube)
video_ids = []
next_page_token = nil
loop do
response = youtube.list_searches('snippet', channel_id: CHANNEL_ID, max_results: 50, page_token: next_page_token)
response_ids = response.items.map { |item| item.id.video_id }.compact
video_ids += response_ids
next_page_token = response.next_page_token
break if next_page_token.nil?
end
video_ids
end
def process_videos(video_ids)
video_ids.each do |video_id|
Youtube::CreateVideoJob.perform_later(video_id)
end
end
end
Job to get detailed info for a specific youtube video and create a local record:
# app/jobs/youtube/create_video_job.rb
require 'google/apis/youtube_v3'
# Youtube::CreateVideoJob.perform_later
class Youtube::CreateVideoJob < ApplicationJob
queue_as :default
CHANNEL_ID = Rails.application.credentials.dig(:youtube, :channel_id)
YOUTUBE_KEY = Rails.application.credentials.dig(:youtube, :youtube_key)
def perform(video_id)
youtube = Google::Apis::YoutubeV3::YouTubeService.new
youtube.key = YOUTUBE_KEY
video_response = youtube.list_videos('snippet', id: video_id)
video = video_response.items.first.snippet
video_hash = {
video_id: video_id,
title: video.title,
description: video.description,
tags: video.tags,
published_at: video.published_at,
cover_image_url: video.thumbnails.maxres.url
}
Post.find_or_create_by(video_id: video_hash[:video_id]).update(video_hash)
end
end
<% @posts.each do |post| %>
<h3><%= post.title %></h3>
<p><%= post.description %></p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/<%= post.video_id %>" frameborder="0" allowfullscreen></iframe>
<hr>
<% end %>
Example fixture of a stored video:
one:
youtube_video_id: "Ubrr9mqE94o"
title: "Ruby on Rails #27 Gem Letter Opener - best way to preview emails in development"
description: "gem letter_opener:\nhttps://github.com/ryanb/letter_opener\nSource Code for the Post:\nhttps://github.com/corsego/26-action-mailer/commit/24fb10065fb5c4502b15ea75d651aec8e61413e0\n\nTo fix Launchy error - run these commands in console:\nexport BROWSER=/dev/null\nexport LAUNCHY_DRY_RUN=true"
tags: ["ruby", "rails", "ruby on rails", "tutorial", "programming"]
published_at: "2021-05-19T13:00:15Z"
cover_image_url: "https://i.ytimg.com/vi/Ubrr9mqE94o/maxresdefault.jpg"
Daterangepicker is an ultra popular library. Unfortunately, it depends on jQuery. Luckily, there is a re-write that uses vanilla JS: vanilla-datetimerange-picker.
rails g stimulus daterangepicker
// app/javascript/controllers/daterangepicker_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
new DateRangePicker(this.element, {})
}
}
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/gh/alumuko/vanilla-datetimerange-picker@latest/dist/vanilla-datetimerange-picker.css">
<script src="https://cdn.jsdelivr.net/momentjs/latest/moment.min.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/alumuko/vanilla-datetimerange-picker@latest/dist/vanilla-datetimerange-picker.js"></script>
<input type="text" data-controller="daterangepicker" size="24" style="text-align:center">
Importing cdn in a rails file is a bad practice.
We can move the <link>
tag into application.css
:
/* app/assets/stylesheets/application.css */
@import url('https://cdn.jsdelivr.net/gh/alumuko/vanilla-datetimerange-picker@latest/dist/vanilla-datetimerange-picker.css');
And import moment
with importmaps:
./bin/importmap pin moment
// app/javascript/application.js
import moment from 'moment'
window.moment = moment
So now you can remove 2 of the 3 CDN links:
-<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/gh/alumuko/vanilla-datetimerange-picker@latest/dist/vanilla-datetimerange-picker.css">
-<script src="https://cdn.jsdelivr.net/momentjs/latest/moment.min.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/alumuko/vanilla-datetimerange-picker@latest/dist/vanilla-datetimerange-picker.js"></script>
vanilla-daterange-picker does not exist as an npm package, so there is no straightforward way to import it.
I tried to copy the code of the DateRangePicker into app/assets/javascripts/libraries/vanilla-daterange-picker@3-1.js
and import it in application.js:
// app/javascript/application.js
import { DateRangePicker } from 'vanilla-daterange-picker@3-1'
window.DateRangePicker = DateRangePicker
<%= form_with url: events_path, method: :get do |form| %>
<%= form.text_field :start_date_between, value: params[:start_date_between], data: {controller: "daterangepicker"} %>
<%= form.submit %>
<% end %>
This will submit data in the format params[:start_date_between] = "05/01/2023 - 06/30/2023"
We will search Event
model by start_date:datetime
:
# app/controllers/events_controller.rb
class EventsController < ApplicationController
def index
# params[:start_date_between] = "05/01/2023 - 06/30/2023"
if params[:start_date_between].present?
between_data_range = params[:start_date_between].split(' - ').map { |date| Date.strptime(date, '%m/%d/%Y') }
@events = Event.where(start_date: between_data_range[0]..between_data_range[1]).order(start_date: :desc)
else
@events = Event.all.order(start_date: :desc)
end
end
end
Other ways to parse the date and search date range:
starts = params[:start_date_between].split(" - ").first.to_date
ends = params[:start_date_between].split(" - ").last.to_date
@events = Event.where(start_date: starts..ends).order(start_date: :desc)
starts = params[:start_date_between].split(" - ").first
ends = params[:start_date_between].split(" - ").last
@events = Event.where("start_date >= ? AND start_date <= ?", starts, ends).order(start_date: :desc)
Result:
bundle add ransack
# app/controllers/events_controller.rb
class EventsController < ApplicationController
def index
@q = Event.all.ransack(params[:q])
@events = @q.result(distinct: true)
end
end
# app/models/event.rb
class Event < ApplicationRecord
def self.ransackable_attributes(auth_object = nil)
["start_date"]
end
end
Enable a new _between
ransacker that accepts data in the format 06 Feb 2022 - 25 Apr 2023
and searches a datetime attribute between these dates. So, start_date_between
will accept the above data format.
# config/initializers/ransack.rb
Ransack.configure do |config|
config.add_predicate "between",
arel_predicate: "between",
formatter: proc { |v| Range.new(*v.split(" - ").map { |s| DateTime.parse(s) }) },
validator: proc { |v| v.present? },
type: :string
end
Display the form with search:
<%= form_with url: events_path, method: :get do |form| %>
<%= form.text_field :start_date_between, value: params.dig(:q, :start_date_between), data: {controller: "daterangepicker"} %>
<%= form.submit %>
<% end %>
Now everything should work!
Here are some great configs that you can use to make your daterangepicker look like this:
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="daterangepicker"
export default class extends Controller {
initialize() {
const ranges = {
Today: [moment(), moment()],
Yesterday: [moment().subtract('days', 1), moment().subtract('days', 1)],
'Last 7 Days': [moment().subtract('days', 6), moment()],
'Last 30 Days': [moment().subtract('days', 29), moment()],
'This Month': [moment().startOf('month'), moment().endOf('month')],
'Last Month': [moment().subtract('month', 1).startOf('month'), moment().subtract('month', 1).endOf('month')],
'Last 365 Days': [moment().subtract('days', 364), moment()],
}
this.dateRangePicker = new DateRangePicker(this.element, {
alwaysShowCalendars: true,
ranges: ranges,
opens: 'left',
autoApply: true,
showWeekNumbers: true,
// locale: { format: 'MMM DD, YYYY' }, // Apr 27, 2023 - Apr 27, 2023
})
}
}
Thatās it!
]]>Whenever a user can have more than Ā±50 records in a list, you must add pagination to your API. Itās not that hard!
Example API GET request that contains pagination parameters:
curl -X 'GET' 'http://localhost:3000/api/v1/posts?page=2'
Response (current page is 2
):
{
"pagination": {
"prev_url": "/api/v1/posts?page=1",
"next_url": "/api/v1/posts?page=3",
"count": 4,
"page": 2,
"next": 3
},
"data": [
{"id": 1, "title": "first post"},
{"id": 2, "title": "second post"}
]
}
Without pagination, our default API response would look like this:
[
{"id": 1, "title": "first post"},
{"id": 2, "title": "second post"}
]
To add pagination parameters, we will use Pagy and update our jbuilder file.
bundle add pagy
Enable pagy_metadata()
method, rescue from Pagy::OverflowError
:
# config/initializers/pagy.rb
require 'pagy/extras/metadata'
require 'pagy/extras/overflow'
Pagy::DEFAULT[:overflow] = :empty_page
Add pagination, include pagy_metadata(@pagy)
:
# app/controllers/api/v1/posts_controller.rb
include Pagy::Backend
def index
items_per_page = 12
user_posts = current_user.posts.all
@pagy, @posts = pagy(user_posts, items: items_per_page)
@pagination = pagy_metadata(@pagy)
end
Rendering most important pagy_metadata
within the json response:
# app/views/api/v1/posts/index.json.jbuilder
json.pagination do
json.extract! @pagination, :prev_url, :next_url, :count, :page, :next
end
# json.links do
# json.prev @pagination[:prev_url]
# json.next @pagination[:next_url]
# end
json.data do
json.array! @posts, partial: "api/v1/posts/post", as: :post
end
Add pagination to your OpenAPI yaml file:
paths:
/api/v1/posts:
get:
+ parameters:
+ - in: query
+ name: page
+ schema:
+ type: integer
+ minimum: 1
+ description: Page number
responses:
'200':
Now, if you have followed me so far, you can make paganated API requests in your Swagger UI:
Thatās it!
]]>If you donāt track API usage, how do you know if it is even used?
Apps like Twitter and Shopify have daily or monthly (30-day) API usage limits:
In this tutorial we will track API usage per user and add a 30-day max limit of API requests per user.
Example of API request being blocked by status 429 Too Many Requests
:
ApiRequest
model will store the usage data:
rails g model ApiRequest user:references path method
On the user model:
# app/models/user.rb
class User < ApplicationRecord
...
has_many :api_requests
MAX_API_REQUESTS_PER_30_DAYS = 10_000
def api_requests_within_last_30_days
api_requests.where("created_at > ?", 30.days.ago).count
end
def api_request_limit_exceeded?
api_requests_within_last_30_days >= MAX_API_REQUESTS_PER_30_DAYS
end
end
In the base API controller, log API requests with log_api_request
and check limit:
# app/controllers/api/v1/authenticated_controller.rb
before_action :check_api_limit
before_action :log_api_request
...
private
...
def log_api_request
current_user.api_requests.create!(path: request.path, method: request.method)
# in the response header, include remaining api request count
response.headers['X-Superails-User-Api-Call-Limit'] = "#{current_user.api_requests_within_last_30_days.to_s}/#{User::MAX_API_REQUESTS_PER_30_DAYS.to_s}"
end
def check_api_limit
if current_user.api_request_limit_exceeded?
render json: { message: "API request limit exceeded" }, status: :too_many_requests
end
end
Now when a user tries to make an API request, we will first check if the user has exceeded the limit.
If the limit is not exceeded, the request is performed and data about it is stored in the ApiRequest model.
Shopify also includes a Rate limits header. With response.headers['X-Superails-User-Api-Call-Limit']
we are doing the same!
To display request headers in a response from a curl request, you can include the -v
option: curl -v -X GET localhost:3000/api/v1/home/index.json -H "Authorization: Bearer MySecretToken"
.
Thatās it!
]]>OpenAPI (previously Swagger) offers a standard for API documentation. OpenAPI documenation is basically a structured .yml
manifest file. Swagger UI is a tool that reads your .yml
file and displays it in a fancy UI.
Swagger UI Example:
In an ideal scenario, the process of API development would look like this:
.yml
file) based on testsIt is totally possible and okay to host your API docs separately from your Ruby app.
There seem to be a few approaches to creating API documentation (.yml
file):
# Gemfile
# display Swagger UI
gem "rswag-ui"
# make API requests from Swagger UI
gem "rswag-api"
Run the installation scripts:
bundle
rails g rswag:api:install
rails g rswag:ui:install
Set the path to access the api docs;
In this case it would be https://localhost:3000/docs/api
;
Optionally require authentication to access the API docs using devise authenticated :user
in routes:
# config/routes.rb
authenticated :user do
mount Rswag::Ui::Engine => '/api-docs'
mount Rswag::Api::Engine => '/api-docs'
end
Set the path to your OpenAPI manifest file:
# config/initializers/rswag_ui.rb
c.swagger_endpoint '/api-docs/v1/openapi.yaml', 'API V1 Docs'
Path to your yml file should be Rails.root/swaggger/v1/openapi.yaml
:
# swagger/v1/openapi.yaml
openapi: 3.0.3
info:
title: SupeRails API
Now if you visit http://localhost:3000/api-docs/index.html
you will see your API skeleton!
Check the official docs for that. I just recently personally pushed a PR to explain how to do it:
You can leverage ChatGPT for this! Prompt that I use:
Text version of the prompt:
You need to generate an OpenAPI manifest file. An API request looks like this
curl -X GET "http://localhost:3000/api/v1/posts/1" -H "Authorization: Bearer mySecretToken"
and a response looks like this
{
"id": 0,
"title": "string",
"created_at": "2023-04-15T19:32:53.182Z",
"updated_at": "2023-04-15T19:32:53.182Z",
"url": "string"
}
You should include UnauthorizedError
and RecordNotFound
responses. In the future there will be many more API endpoints, so you should make the manifest file reusable from the start.
Do not forget to abstract schema and include bearer authentication
Keep in mind: ChatGPT does not get everything right. Read through the generated responses attentively and be ready to solve inconsistencies!
Great resources about OpenAPI:
]]>Many API adapter gems rely on Faraday or HTTParty.
To try making and HTTP request locally from your console with Faraday, you can run a rails server
in one tab, rails console
in another tab, and make Faraday requests in the console. You will see http requests happening in your server logs:
Letās create a Post
scaffold and make some CRUD requests:
bundle add faraday
rails g scaffold Post title:string body:text published:boolean published_at:datetime
rails db:migrate
Before we continue, enable non-get API requests:
# app/controllers/posts_controller.rb
protect_from_forgery with: :null_session
# skip_before_action :authenticate_user!
GET/read
Basic GET request (with optional Bearer authentication):
require 'faraday'
conn = Faraday.new(url: 'http://localhost:3000') do |faraday|
# Set the Authorization header with the Bearer token
faraday.request :authorization, 'Bearer', 'MySecretKey'
end
response = conn.get('/posts.json')
# response = conn.get('/api/v1/home/index')
response.headers
response.status
response.body
body_json = JSON.parse(response.body)
# transform a JSON object to a ruby object
first_record = body_json.first
first_record_object = OpenStruct.new(first_record)
first_record_object.id
POST/create
require 'faraday'
require 'json'
conn = Faraday.new(url: 'http://localhost:3000')
post_data = { post: { title: 'Example Title', body: 'Example Body', published: true, published_at: '2023-04-12T12:34:56Z' } }
response = conn.post('/posts.json') do |req|
req.headers['Content-Type'] = 'application/json'
req.body = JSON.generate(post_data)
end
puts response.body
PATCH/update
require 'faraday'
require 'json'
conn = Faraday.new(url: 'http://localhost:3000')
post_data = { post: { title: 'Updated Title' } }
response = conn.patch('/posts/1.json') do |req|
req.headers['Content-Type'] = 'application/json'
req.body = JSON.generate(post_data)
end
puts response.body
DELETE/destroy
require 'faraday'
conn = Faraday.new(url: 'http://localhost:3000')
response = conn.delete('/posts/1.json')
puts response.body
I usually keep Faraday calls in app/services/*
and invoke them from there.
Thatās it! Now you can build your own API adapter.
]]>You can make API CRUD requests to a Rails controller via cURL.
Letās create a Post
scaffold and make some CRUD requests:
rails g scaffold Post title:string body:text published:boolean published_at:datetime
index
curl -X GET http://localhost:3000/posts.json
show
curl -X GET http://localhost:3000/posts/1.json
If you try performing Create
, Update
, Delete
actions, you will get a ActionController::InvalidAuthenticityToken
error due to RequestForgeryProtection.
Add protect_from_forgery with: :null_session
to your controller to enable these requests:
# app/controllers/posts_controller.rb
# skip_before_action :verify_authenticity_token
# skip_before_action :authenticate_user!
protect_from_forgery with: :null_session
Important: now anybody on the internet will be able to make changes to your database. In a real environment, you would want to add an authentication layer to your cURL requests.
create
curl -X POST -H "Content-Type: application/json" -d '{"post": {"title": "Example Title", "body": "Example Body", "published": true, "published_at": "2023-04-12T12:34:58Z"}}' http://localhost:3000/posts.json
update
curl -X PATCH -H "Content-Type: application/json" -d '{"post": {"title": "Updated Title"}}' http://localhost:3000/posts/1.json
destroy
curl -X DELETE http://localhost:3000/posts/1.json
Start with building an url path:
# config/routes.rb
namespace :api do
namespace :v1 do
defaults format: :json do
get "home/index", to: "home#index" # /api/v1/home/index
end
end
end
Controller to handle the url and provide a basic text responce:
# app/controllers/api/v1/home_controller.rb
class Api::V1::HomeController < ActionController::Base
def index
render json: { message: "Welcome to the app!" }
end
end
Try starting rails s
in one terminal tab, and doing a CURL request in another tab:
curl -X GET "http://localhost:3000/api/v1/home/index"
# or
curl -X 'GET' \
'http://localhost:3000/api/v1/home/index' \
-H 'accept: application/json'
Great! Youāve just made a request and received a JSON responce.
Prerequisites:
User
modelrails g model api_tokens user:references active:boolean token:text
When a User creates an ApiToken record, generate and encrypt a token:
# app/models/api_token.rb
class ApiToken < ApplicationRecord
belongs_to :user
# before_create :generate_token
validates :token, presence: true, uniqueness: true
before_validation :generate_token, on: :create
encrypts :token, deterministic: true
private
def generate_token
self.token = Digest::MD5.hexdigest(SecureRandom.hex)
# self.active = true
end
end
Create a token in the console:
current_user = User.first
token = current_user.api_tokens.create!
Building the frontend for this must be quite straightforward.
Weāve got an API and weāve got tokens. Now, letās allow only requests that have a valid API token in the header access our API.
A Basic usecase example would be allowing a user to access only his own posts.
A CURL GET request with a Bearer Authorization header with token mySecretToken
could look like this:
curl -X GET "http://localhost:3000/api/v1/home/index" -H "Authorization: Bearer mySecretToken"
curl -X GET "http://localhost:3000/api/v1/posts/1" -H "Authorization: Bearer mySecretToken"
curl -X GET "http://localhost:3000/api/v1/posts" -H "Authorization: Bearer mySecretToken"
Create a BaseController
. API controllers that require authentication should inherit from it. Require authentication to perform API requests with a valid active API token and find the current_user
(owner of the token):
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
before_action :authenticate
attr_reader :current_user
private
def authenticate
authenticate_user_with_token || handle_bad_authentication
end
def authenticate_user_with_token
authenticate_with_http_token do |token, options|
current_api_token = ApiToken.where(active: true).find_by_token(token)
@current_user = current_api_token&.user
end
end
def handle_bad_authentication
render json: { message: "Bad credentials" }, status: :unauthorized
end
def handle_not_found
render json: { message: "Record not found" }, status: :not_found
end
end
Inherit from the BaseController
:
# app/controllers/api/v1/home_controller.rb
-class Api::V1::HomeController < ActionController::Base
+class Api::V1::HomeController < Api::V1::BaseController
def index
- render json: { message: "Welcome to the app!" }
+ render json: { message: "Welcome to the app! #{current_user.email}" }
end
end
Now, if somebody tries to make an API request without a valid API token, he will get the āBad credentialsā message.
# test/integration/api_welcome_page_test.rb
require 'test_helper'
class ApiWelcomePageTest < ActionDispatch::IntegrationTest
test 'when auth token is invalid' do
get api_v1_welcome_path, headers: { HTTP_AUTHORIZATION: 'Token token=123' }
assert_includes request.headers['HTTP_AUTHORIZATION'], '123'
assert_response :unauthorized
assert_includes response.body, 'Bad credentials'
end
test 'with valid auth token' do
user = User.create!
api_token = user.api_tokens.create!
raw_token = api_token.raw_token
get api_v1_welcome_path, headers: { HTTP_AUTHORIZATION: "Token token=#{raw_token}" }
assert_response :success
assert_includes response.body, 'Welcome to the app'
assert_includes response.body, api_token.user.mail
end
end
Inspired by:
Thatās it!
]]>Hereās a reusable approach to doing this!
First, fix the user fixtures to have unique emails:
# test/fixtures/users.yml
one:
email: yaro@superails.com
two:
email: shm@superails.com
Importing devise into your test_helper
will enable the sign_in @user
method.
# test/test_helper.rb
class ActiveSupport::TestCase
...
include Devise::Test::IntegrationHelpers
end
Controller (integration) test for login (assuming dashboard
is a page available only for authenticated users):
# test/integration/devise_auth_test.rb
require "test_helper"
class DeviseAuthTest < ActionDispatch::IntegrationTest
test "user can login" do
get static_dashboard_path
assert_response :redirect
assert_redirected_to new_user_session_path
user = users(:one)
sign_in user
get static_dashboard_path
assert_response :success
end
end
System (browser) tests:
# test/system/devise_auth_system_test.rb
require 'application_system_test_case'
class DeviseAuthSystemTest < ApplicationSystemTestCase
test 'sign in existing user' do
user = users(:one)
sign_in user
visit static_dashboard_path
assert_current_path static_dashboard_path
assert_text 'Find me in app/views/static/dashboard.html.erb'
end
test 'create user and sign in' do
email = Faker::Internet.email
password = Faker::Internet.password(min_length: 10, max_length: 30)
User.create(email: email, password: password)
visit static_dashboard_path
# visit new_user_session_path
fill_in 'Email', with: email
fill_in 'Password', with: password
click_button 'Log in'
assert_current_path static_dashboard_path
assert_text 'Signed in successfully.'
end
end
If you donāt want to look at the popup browser when running system tests, you can use headless_chrome
:
# test/application_system_test_case.rb
# driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
Commands to run the tests:
rails test
rails test:system
rails test:all
Reset test database:
rake db:drop RAILS_ENV=test
rake db:create RAILS_ENV=test
rake db:migrate RAILS_ENV=test
Thatās it!
]]>If you are using gem "faker"
you can mock a few popular omniauth payloads.
The omniauth gem allows you to mock a successful authentication using OmniAuth.config.mock_auth
.
:github
omniauth example:
# test/test_helper.rb
module OmniauthGithubHelper
def login_with_github_oauth
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new(Faker::Omniauth.github)
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:github]
end
end
Now, to authenticate in a controller test you can run:
login_with_github_oauth
post user_github_omniauth_callback_path
In a system test you can do:
login_with_github_oauth
visit user_github_omniauth_callback_path
Unfortunately, not all omniauth payload are covered by Faker. In this case, you can introduce a mock omniauth payload directly within your app:
# test/fixtures/azure_activedirectory_v2.json
{"provider": "azure_activedirectory_v2",
"uid": "c9546ade-d57e-414e-8e99",
"info": {"name": "Yaro Shm", "email": "yaro@superails.com", "nickname": "yaro", "first_name": "Yaro", "last_name": "Shm"},
"credentials":
{"token": "eyJ0eXAiOi",
"expires_at": 1680218216,
"expires": true},
"extra":
{"raw_info":
{"aud": "00000003-0000-0000-c000-000000000000",
"iss": "https://sts.windows.net/7a306d84-95aa-48d4-85d6/",
"iat": 1680213810,
"nbf": 1680213810,
"exp": 1680258227,
"email": "yaro@superails.com",
"name": "Yaro Shm",
"oid": "c9546ade-d57e-414e-8e99",
"preferred_username": "yaro@superails.com",
"rh": "0.AU4AhG0weqqV.",
"sub": "kqf4_v-TPdpt5",
"tid": "7a306d84-95aa",
"uti": "jYe4xjm75EW",
"ver": "1.0",
"acct": 0,
"acr": "1",
"aio": "AVQAq/8TAAAA/y2xH6WocplaNttawB6iaOboLXz4j",
"amr": ["pwd", "mfa"],
"app_displayname": "superails",
"appid": "4cc835b1-cfb0-4a24-90ea",
"appidacr": "1",
"family_name": "Shm",
"given_name": "Yaro",
"idtyp": "user",
"ipaddr": "77.205.16.21",
"platf": "5",
"puid": "1003200283",
"scp": "Contacts.Read email openid profile User.Read",
"signin_state": ["kmsi"],
"tenant_region_scope": "EU",
"unique_name": "yaro@superails.com",
"upn": "yaro@superails.com",
"wids": ["b79fbf4d-3ef9-4689-8143"],
"xms_st": {"sub": "QTfz4TlRSckh1yZfnzt0r6lHbec0"},
"xms_tcdt": 1643572,
"xms_tdbr": "EU"}}}
Now, create a helper method to authenticate using the above omniauth payload:
# test/test_helper.rb
module OmniauthMicrosoftHelper
def login_with_azure_activedirectory_v2_oauth
file = File.read('test/fixtures/azure_activedirectory_v2.json')
parsed_file = JSON.parse(file)
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:azure_activedirectory_v2] = parsed_file
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:azure_activedirectory_v2]
end
end
Finally, test the authentication in a controller test:
# test/controllers/omniauth_login_controller_test.rb
require 'test_helper'
class OmniauthLoginTest < ActionDispatch::IntegrationTest
include OmniauthMicrosoftHelper
test 'auth success' do
assert_not User.pluck(:email).include?(JSON.parse(File.read('test/fixtures/azure_activedirectory_v2.json'))['info']['email'])
login_with_azure_activedirectory_v2_oauth
post user_azure_activedirectory_v2_omniauth_callback_path
assert_response :redirect
assert_redirected_to root_path
assert User.pluck(:email).include?(JSON.parse(File.read('test/fixtures/azure_activedirectory_v2.json'))['info']['email'])
assert_equal controller.current_user, User.last
end
test 'auth failure' do
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:azure_activedirectory_v2] = :invalid_credentials
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:azure_activedirectory_v2]
post user_azure_activedirectory_v2_omniauth_callback_path
assert_response :redirect
assert_redirected_to root_path
assert_nil controller.current_user
end
end
Based on the Official docs for testing omniauth
Thatās it!
]]>Previously Iāve written about setting up omniauth with Github.
Now, letās add a āSign in with Microsoftā button:
After clicking the button, the user will be prompted to authenticate with one of his Microsoft accounts:
The approach is more-less similar for any omniauth provider. However I think the hardest part is navigating provider dashboards, getting valid API keys, registering your app, enabling the app in production š¬.
First, create a Microsoft Azure account and connect a credit card. When I created my account, I was granted Ā±200$ in credits for 1 year. Thatās enough to experiement with the platform.
Find and open āapp registrationā:
Click ānew registrationā:
Input your app name;
Important: Select supported account types. When you are building a B2B app, often you will want to let only email accounts provided by an organization to access your app. This filters out some lurkers and spies.
Redirect URI for localhost should be http://localhost:3000/users/auth/azure_activedirectory_v2/callback
Success! Youāve created an app! Now you see your client_id
for omniauth:
Generate a client_secret
. Copy the value.
I suggest storing them in Rails credentials as:
microsoft_oauth:
client_id: b810ccfa...
client_secret: hGb8Q...
If you donāt want to use devise, see how to add omniauth without devise.
Here we assume that you already use Devise.
First, install the gem āomniauth-azure-activedirectory-v2ā:
# Gemfile
gem 'omniauth-azure-activedirectory-v2'
gem 'omniauth-rails_csrf_protection'
Add the generated API keys to your app:
# config/initializers/devise.rb
config.omniauth :azure_activedirectory_v2,
client_id: Rails.application.credentials.dig(:microsoft_oauth, :client_id),
client_secret: Rails.application.credentials.dig(:microsoft_oauth, :client_secret)
Afterwards, itās the same process as for any other omniauth provider:
# app/models/user.rb
devise :omniauthable, omniauth_providers: [:azure_activedirectory_v2]
# config/routes.rb
devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }
rails g migration add_oauth_attributes_to_users provider uid
rails db:migrate
# app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def azure_activedirectory_v2
data = request.env['omniauth.auth']['info']
@user = User.where(email: data['email']).first
@user ||= User.create(
email: data['email'],
password: Devise.friendly_token[0, 20],
provider: request.env['omniauth.auth']['provider'],
uid: request.env['omniauth.auth']['uid']
)
if @user.persisted?
flash[:notice] = 'Welcome!'
sign_in_and_redirect @user, event: :authentication
else
flash[:alert] = I18n.t 'Authentication failed, please try again.'
redirect_to new_user_registration_url, notice: @user.errors.full_messages.join("\n")
end
end
def failure
redirect_to root_path, alert: 'Authentication failed, please try again.'
end
end
# app/views/devise/shared/_links.html.erb
<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), method: :post, data: { turbo: "false" } %>
If you get an unauthorized_client
error, most likely the āsupported account typesā that you selected for your App registration donāt include your current account type.
To update it:
signInAudience
setting.AzureADMyOrg
AzureADMultipleOrgs
AzureADandPersonalMicrosoftAccount
PersonalMicrosoftAccount
Thatās it!
]]>Ruby conferences in 2023 that I know of, ordered chronologically:
I myself will be definitely visiting at least the highlighted ones. I hope to meet YOU there in person and get some drinks together! š„š»
]]>Nevertheless, there is one easy way to find a saved wifi password stays valid no matter what version of Windows you are on.
First, open the command prompt:
Type this in the command prompt to display all the saved WIFI Network names:
netsh wlan show profiles
Type this in the command prompt to display the details of any network:
netsh wlan show profile name="NETWORK" key=clear
In my case, I wanted to get the password of the network named Biblioteka
, so I typed:
netsh wlan show profile name="Biblioteka" key=clear
The saved network password is highlighted in blue:
Thatās it!
]]>Broadcaster
pattern.
By moving your broadcasts rendering logic from a controller into the app/services
directory, you can:
Hereās an example of moving broadcast logic from a controller into app/services/broadcasters/*
:
def create
if @record.save
- Turbo::StreamsChannel.broadcast_action_later_to(:posts_list, target: :posts, action: :prepend, partial: 'posts/post', locals: { post: post } )
- Turbo::StreamsChannel.broadcast_update_to(:posts_list, target: :posts_counter, html: Post.count )
+ Broadcasters::Posts::Created.new(post).call
respond_to do |format|
format.html
format.turbo_stream
end
end
end
Create a Service Object that would contain the above broadcasting logic:
# app/services/broadcasters/posts/created.rb
class Broadcasters::Posts::Created
attr_reader :post
def initialize(post)
@post = post
end
def call
# as many broadcasts as you wish!
prepend_post
update_counter
end
def prepend_post
Turbo::StreamsChannel.broadcast_action_later_to(
:posts_list,
target: :posts,
action: :prepend,
partial: 'posts/post',
locals: { post: post }
)
end
def update_counter
Turbo::StreamsChannel.broadcast_update_to(
:posts_list,
target: :posts_counter,
html: Post.count
)
end
end
Now you can trigger this broadcasters from anywhere in your app:
# rails console
post = Post.last
Broadcasters::Posts::Created.new(post).call
Thatās it!
P.S. For everything to work, sometimes you might have to include specific Rails helpers in your service object:
class Broadcasters::Posts::Created
+ include ActionView::RecordIdentifier
+ include Turbo::StreamsHelper
+ include Rails.application.routes.url_helpers
Here are some good and timeless questions that I have been asked when applying for jobs. I like these questions, because they donāt go too much into tiny details, but can demonstrate the interviewees range of knowledge and opinions. I myself also ask these questions when hiring:
Hotwire is quite a fresh technology that was released in December of 2020, but these question can let you easily distinguish a PRO from a wannabe:
initialize
, or disconnect
in StimulusJS?In theory, I could have autogenerated content on my blog, but goal would it serve? I donāt write to gain traffic or followers.
I appreciate everybody who reads my blog and watches my videos, but I write for myself. I am my number one reader. I write to learn and summerize my learnings. I write to reflect on my life, and to plan my future.
Dear friend, You should write too! It will help you tell your story, become better as a professional and can also serve as a portfolio.
It is okay to look for content in source material, in blogposts, on wikipedia, on chatgpt - whatever servers a better answer. As long as you know which questions to ask, thereās forever been a load of good information out there. The problem is putting information into your head and structuring it (turning information into knowledge).
There is no tool yet that can safely upload unbiased updated knowledge into your brain. Enjoy your life and your unique learning path!
Note: this post was written by a human, not an AI.
]]>Rails 7 added Active Record Encryption, that replaces gems like attr_encrypted. It is a long-awaited default feature by organizations that have high data-security standarts and requirements.
Why you need attribute encryption:
Security-oriented static code analysis tools like bearer/bearer can hint on what attributes you should encrypt:
If you run this command in the console
bin/rails db:encryption:init
it will generate a few lines, that you should add to credentials.yml
:
active_record_encryption:
primary_key: 1qRx9LKs1ON5gbk0q5Affs898O0S0sXo
deterministic_key: pCgz9AgTkwO8zcn3hrZBL6tbNVQyxGvL
key_derivation_salt: pOIy8FEWO3hVpt1f05LKuWETU1uOICPb
Try to encrypt attributes:
# app/models/client.rb
class Client < ApplicationRecord
encrypts :name, deterministic: true, downcase: true # string
encrypts :annual_income # integer
encrypts :date_of_birth # datetime
encrypts :health_condition # text
has_rich_text :description, encrypted: true # ActionText
end
You can really encrypt only string
and text
fields (because we store a long hashed string on the database level).
It is recommended to store encryptable attributes as text
, not string
.
If you want to encrypt an integer
or datetime
, you will get errors. You have to store encryptable data as text
.
I think it is quite safe to change column type from integer to text. If you do so, further encryption will be easy.
class StoreIntegersAsText < ActiveRecord::Migration[7.0]
def change
change_column :clients, :annual_income, :text
end
end
Only if you use deterministic encryption, you will be able to query the database, but only for an exact match like Client.find_by(username: 'yarotheslav')
.
If you want to add encryption to existing attributes that already store data, you will get an error.
Add these lines to application.rb
to allow encrypted and unencrypted data to co-exist:
# config/application.rb
config.active_record.encryption.support_unencrypted_data = true
config.active_record.encryption.extend_queries = true
client = Client.last
# encrypt all attributes that use encrypts
client.encrypt
# decrypt all attributes that use encrypts
client.decrypt
# get the value that is stored in the database, not the decrypted version
client.ciphertext_for :sexual_orientation
# is this attribute encrypted?
client.encrypted_attribute? :sexual_orientation
# encrypt all records
Client.all.map(&:encrypt)
# decrypt all records
Client.all.map(&:decrypt)
Example:
Summary:
ApiToken.secret_token
in an app.We should be able to use drag-and-drop to:
Hereās a demo of what final solution will look like:
ranked-model is superior to acts_as_list. You can check the gems docs to learn why.
# terminal
bundle add ranked-model
rails g migration AddRowOrderToListsAndTasks
These should allow null values
class AddRowOrderToListsAndTasks < ActiveRecord::Migration[7.0]
def change
add_column :lists, :row_order, :integer
add_column :tasks, :row_order, :integer
end
end
Add RankedModel to List
# app/models/list.rb
validates :name, presence: true
has_many :tasks
include RankedModel
ranks :row_order
Add RankedModel to Task. Use with_same
to correctly calculate row_order within a list
# app/models/task.rb
validates :name, presence: true
belongs_to :list
include RankedModel
ranks :row_order, with_same: :list_id
It will allow us to make HTTP requests from our Stimulus controllers into our rails controllers
bundle add requestjs-rails
./bin/rails requestjs:install
./bin/importmap pin stimulus-sortable sortablejs
This stimulus controller will:
row_order_position
of an element (newIndex
), and if the element was moved to another parent list - with a parent id (sortableListId
)group
param that enables us to move tasks within lists (should be set on tasks <ul>)import { Controller } from "@hotwired/stimulus"
import { put } from "@rails/request.js";
import Sortable from 'sortablejs';
// Connects to data-controller="sortable"
export default class extends Controller {
static values = {
group: String
}
connect() {
this.sortable = Sortable.create(this.element, {
onEnd: this.onEnd.bind(this),
group: this.groupValue
});
}
onEnd(event) {
var sortableUpdateUrl = event.item.dataset.sortableUpdateUrl
var newIndex = event.newIndex
var sortableListId = event.to.dataset.sortableListId
console.log(sortableUpdateUrl)
console.log(newIndex)
console.log(sortableListId)
put(sortableUpdateUrl, {
body: JSON.stringify({row_order_position: newIndex, list_id: sortableListId}),
})
}
}
sortableUpdateUrl
will have to lead to either sort_task_path(task)
or sort_list_path(list)
# config/routes.rb
resources :tasks do
member do
put :sort
end
end
resources :lists do
member do
put :sort
end
end
.rank(:row_order)
sorts elements by their rank (as opposed to created_at
or name
).
Through in the database we store row_order
, the update param should be row_order_position
according to the RankedModel docs.
# app/controllers/lists_controller.rb
def index
@lists = List.rank(:row_order)
end
def sort
@list = List.find(params[:id])
@list.update(row_order_position: params[:row_order_position])
head :no_content
end
def list
@tasks = @list.tasks.rank(:row_order)
end
To update the task we will also need the list_id
, because tasks can be moved between lists.
# app/controllers/tasks_controller.rb
def sort
@task = Task.find(params[:id])
@task.update(row_order_position: params[:row_order_position], list_id: params[:list_id])
head :no_content
end
Initialize sortable stimulus controller with data-controller="sortable"
.
Set the update path that should be triggered when an item gets sorted with data-sortable-update-url="<%= sort_list_path(list) %>"
.
<!-- app/views/lists/index.html.erb -->
<div id="lists">
<ul data-controller="sortable" style="display:flex;">
<% @lists.each do |list| %>
<li data-sortable-update-url="<%= sort_list_path(list) %>" style="border:solid; width: 400px">
<%= render list %>
</li>
<% end %>
</ul>
</div>
Within a sortable list we have sortable tasks.
If you allow moving tasks within lists, data-sortable-group-value="tasks"
specifies that tasks can be moved only to lists that have the same group name.
data-sortable-id="<%= list.id %>"
will let your stimulus controller access the ID of the list to which the item was moved.
<!-- app/views/lists/_list.html.erb -->
<div id="<%= dom_id list %>">
<strong>List:</strong>
<%= list.id %>
<%= list.name %>
<%= list.row_order %>
<ul data-controller="sortable" data-sortable-group-value="tasks" data-sortable-list-id="<%=list.id%>">
<% list.tasks.rank(:row_order).each do |task| %>
<li data-sortable-id="<%= task.id %>" data-sortable-update-url="<%= sort_task_path(task) %>">
<%= render task %>
</li>
<% end %>
</ul>
</div>
Just in case, hereās a very basic task partial:
<!-- app/views/tasks/_task.html.erb -->
<div id="<%= dom_id task %>">
<strong>Task:</strong>
<%= task.name %>
<%= task.row_order %>
</div>
Final result:
And thatās it! Now we have advanced sorting functionality for a very small price.
]]>These same principles were used in all the organizations I worked in and apply to any Rails dev team.
Prefer written communication.
Have meetings only when writing would take longer.
Promote independency in decision making.
main
/master
branch should accept changes only via Pull Request.
Do not review DRAFT Pull Requests.
Pull Request should require 1 approval and a passing CI to be merged.
Do not merge a PR if you are not the author.
Trust your colleagues best intentions.
If you are stuck - ASK FOR HELP.
Pair coding can help in getting unstuck and create a āproductivity rushā.
The less code you write, the less code there is to maintain. In a mature app, aim to be ācode-neutralā (delete at least as much as you add).
Do not overcomplicate. Look for the shortest good path to solve a problem.
Do not reinvent the wheel. Search for an existing solution before rolling out your own.
Good code does not require a lot of comments to explain it.
Do not disable turbo by default in the app. The limitations of turbolinks are irrelevant in 2023.
Do not use <a>
tag. Use link_to
instead.
Do not embed SVG code in an HTML page. Store the SVG as a separate object and use gem inline_svg
to render it.
Prefer using ViewComponents over _partials. Here are some rules of thumb:
When using ViewComponent, try to store all the view login in the .rb
file, not in the .html.erb
file.
Do not store view logic in a Rails model. Use app/helpers
, app/components
, app/decorators
instead.
Do not use jQuery and jQuery-based libraries.
If you canāt render a turbo_stream as a one-liner in a controller, use a *.turbo_stream.erb
template.
When writing text, use āsentence case capitalizationā:
Use app/services
to extract complex logic and test it in isolation.
As you application gets complex, you can use a more advanced system of design patterns:
app/services
for working with exteranal APIs (Twilio::SendSms
)app/operations
for working with (Book::GenerateBarcode
)app/interactors
for extracting logical sequences (if Book::GenerateBarcode.success? ? Twilio::SendSms
)When doing database migrations, donāt forget to validate null
and default
on the database level.
Avoid using ActiveRecord callbacks. Call methods explicitly when needed.
Always use a CI tool for:
Minitest or Rspec? Does not matter. FactoryBot is more versatile that fixtures. Ultimately, use the tools that you feel more comfortable with.
When testing, focus on writing good Controller and System tests.
š¤š I will be adding more to the list, if I remember something.
Prefer storing text in en.yml
i18n file and inheriting from there.
redirect_to posts_path, notice: 'post created!'
redirect_to posts_path, notice: t('.success')
Prefer symbols over strings:
"payment pending"
:payment_pending
Use gem Pagy for pagination. WillPaginate and Kaminari are simply not as great.
Do not use magic strftime
Do not use magic strings:
tax_amount = price * 0,17
TAX_AMOUNT = 0,17
&& tax_amount = price * TAX_AMOUNT
When Rails 4 was released, there was one major change in the way you write backend for your app in comparison to Rails 3: strong parameters have been introduced. This allows you to securely define which parameters of a model can be updated on a controller level with this kind of syntax: params.require(:task).permit(:title, :project_id, :user_id)
.
When writing frontend, there were some strange obstacles:
*.js.erb
templates for server-side rendering frontend updates. In the future this evolved into Hotwire Turbo Streams.I was very excited for the release of Rails 5. It came with new core libraries:
Rails is an opinionated framework, so you donāt have to waste time on making some decisions!
Ruby on Rails has always been considered slightly more of a backend framework, and backend developers donāt care to learn about the crazy world of compiling Javascript and CSS in an app. The āgem to use a JS libraryā apprach was not sustainable, and the world had moved on. To stay relevant for building frontend applications, Rails needed a better way of importing external CSS and JS packages.
So, Webpacker was introduced. It allowed you do import JS/CSS packages from YARN. You could run something like yarn add @popperjs/core
. Webpacker came with itās own problems, but it was a real breakthrough. All the libraries like āgem bootstrapā became irrelevant, because now you could import frontend libraries directly from the source.
Gems like rubyconfig/config
were replaced by config_for
.
Gems for encrypting secrets were replaced by Rails Credentials
Gems for encrypting database table attributes like attr_encrypted got replaced with Active Record Encryption.
Not so many changes in the backend, but a lot of changes to the frontend!
Webpacker was good for itās time. A nessesary evil. Now, the frontend has evolved and Rails 7 offers new approaches to importing new JS packages out of the box: Importmaps and JS/CSS bundling. Importmaps is default in new Rails apps, but it can have troubling importing CSS; this technology is still developing. JS/CSS bundling very good and used in most projects.
Also when generating a new Rails app you can have BootstrapCSS or TailwindCSS installed out of the box. No more wasting time on that!
Turbolinks is replaced with Hotwire/Turbo, that is easier to configure behaviours for. However specifying responce formats became more important, and one of the most popular gems - Devise, was hard to work with for a while. So, you had to disable turbo on all Devise forms and buttons. It is fixed now.
A ābreaking changeā was the depreciation of rails-ujs
, that allowed link_to
that is a DSL for html <a>
tag to do non-GET requests. Now the best practice is to use button_to
for non-GET requests (POST
, PUT
, PATCH
, DELETE
).
Hotwire/TurboStreams became default for AJAX server-side rendering. *.js.erb
format was replaced with *.turbo_stream.erb
. It became very easy to serve content without refreshing or redirecting.
Hotwire/TurboStreamBroadcasts use ActionCable to do server-side rendering for all clients listening to the same channel. This made building live functionality (like live chat) very easy.
StimulusJS is now the default way of writing Javascript for Rails. As of now, <script>
tags are frowned upon.
Overall, since Rails 4 there have been few changes on the already solid backend architecture, but many changes in the always-evolving frontend.
When upgrading Rails versions, the biggest challenge is updating the frontend. You should also be sure that the gems you used in the old version are still maintained; core features of some gems have been integrated into the Rails framework.
P.S. Did I miss something important?
]]>Previously I wrote about adding Adding custom error pages in a Rails app. If you donāt want to go that way, you can just style the existing error pages that are stored in the /public
folder with CSS.
Here is some example CSS styling I did for a page:
Feel free to copy my public/404.html
and use it in your app:
<!DOCTYPE html>
<html>
<head>
<title>The page you were looking for doesn't exist (404)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
.rails-default-error-page {
background-color: white;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
}
.button {
font-size: 14px;
text-decoration: none;
padding-left: 16px;
padding-right: 16px;
padding-top: 7px;
padding-bottom: 7px;
border-radius: 4px;
display: inline-block;
}
.button-home {
color: white;
background-color: #008060;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.08), inset 0px -1px 0px rgba(0, 0, 0, 0.2);
}
.button-help {
color: black;
border: 1px solid;
border-color: #BABFC3;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.05);
}
.container {
position: relative;
text-align: center;
}
.centered {
position: absolute;
top: 80%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
</head>
<body class="rails-default-error-page">
<div class="container">
<img src="404logo.png" alt="mylogo">
<div class="centered" style="font-size: 96px; font-weight: 900;">
404.
</div>
</div>
<div style="font-size: 28px; font-weight: bold;">
Page not found
</div>
<div style="margin-top: 20px; margin-bottom: 14px;">
<a href="/" class="button button-home">Back to home page</a>
</div>
<div>
<a href="https://superails.com/" target="_blank" class="button button-help">Visit help center</a>
</div>
</body>
</html>
You can use the same template for /422.html
and /500.html
, just change the text.
To make it look nice, be sure to have a public/404logo.png
file present.
Here is the image I use. Please do not reuse it!
Hope this post helps you to build faster!
]]>It is an online language testing platform, used by businesses to evaluate an individuals skills.
As a pleasant surprise, the platform does not offer multiple-choice questions.
Instead, it has open-ended questions.
That way candidates are less likely to be able to cheat!
šØāš» In this session I am planning to navigate their UI and map-out how I would reproduce their apps functionality.
ā ļø I am in no way affiliated with Pipplet. I do not endorse their platform either.
Youtube video: Reverse Engineering a Web App: Pipplet Language Testing Platform
Target audience (customers) on registration:
Onboarding flow
Buy credits. Taking the test by each individual costs around $40.
Create campaign with a subject (language), that will have a group of test takers:
Add test takers that will invited to take the test.
Company dashboard with all test takers and campaigns
Question with audio answer
Question with text answer
Language Certificate example
4 types of users and interfaces:
Link to the Database Diagram that I created:
The tables with core associations and attirbutes, in DBML format:
Table users {
id int [pk]
email string
}
Table organization_users {
id int [pk]
user_id int [ref: > users.id]
organization_id int [ref: > organization.id]
}
Table organization {
id int [pk]
credits int
}
Table campaign {
id int [pk]
name str
organization_id int [ref: > organization.id]
subject_id int [ref: > subject.id]
}
Table subject {
id int [pk]
name str
}
Table question {
id int [pk]
subject_id int [ref: > subject.id]
examiner_id int [ref: > examiner.id]
type str // reading/image_description_audio/dialogue_answer/image_description_text
instruction_text text
body text
max_characters int
// active storage image attachment
// active storage audio attachment
}
Table answer {
id int [pk]
test_taker_id int [ref: > test_taker.id]
examiner_id int [ref: > examiner.id]
question_id int [ref: > question.id]
// active storage audio attachment
body text
answered boolean // !skipped
score integer
score_description text
}
Table test_taker {
id int [pk]
email str
campaign_id int [ref: > campaign.id]
status string // not_started/evaluation_pending/evaluated
score integer
score_description text
}
Table examiner {
id int [pk]
email str
}
Thatās it!
]]>Wtf Kredis? Kredis makes using Redis easier in Rails, and most importantly helps to associate Redis object with Rails Models.
Previously I wrote about āRecently visited pages with Kredisā
āRecent search historyā is a very similar feature in terms of implementation.
Feature description:
Prerequisites:
Posts title:string
current_user
Implementation:
First, associate a Redis entity of kredis_unique_list
with the user.rb
model:
# app/models/user.rb
class User < ApplicationRecord
kredis_unique_list :recent_searches, limit: 5
end
Add a search form:
# app/views/posts/index.html.erb
<%= form_with url: posts_path do |form| %>
<%= form.text_field :query %>
<%= form.submit %>
<% end %>
When there search form is submitted, save the search params to current_user.recent_searches
. prepend
will add it at the top of the list:
# app/controllers/posts_controller.rb
def index
@posts = if params[:query].present?
Post.where(name: params[:query])
current_user.recent_searches.prepend(params[:query]) if params[:query].present?
else
Post.all
end
end
Display a list of clickable recent searches:
# app/views/users/_recently_searches.html.erb
<% current_user.recent_searches.elements.each do |query| %>
<%= link_to query, posts_path(query:) %>
<% end %>
Thatās it!
]]>Common reasons to be using SMS in 2023:
For example, when I was creating a chatgpt account, it required a confirmed phone number. Most likely to decrease the amount of bots:
Twilio offers a very easy way to send SMS using Rails.
After creating an account, navigate to https://console.twilio.com
Save twilio credentials in your apps credentials.yml
:
twilio:
account_sid: AC714f90d7750d7cf2
auth_token: c8671c5e8233b0
from_phone_number: "+18305801212"
dummy_to_phone_number: "+48537623523"
If you send messages while in trial mode, you must first verify your āToā phone number (dummy_to_phone_number
). Ensure that your phone number is in a valid geo & is verified.
Install gem 'twilio-ruby'
:
bundle add twilio-ruby
Create a Service that you can call in your app:
# app/services/twilio/send_sms_service.rb
module Twilio
class SendSmsService
require 'twilio-ruby'
def call(body, to_phone_number)
account_sid = Rails.application.config_for(:settings).dig(:credentials, :twilio, :account_sid)
auth_token = Rails.application.config_for(:settings).dig(:credentials, :twilio, :auth_token)
from_phone_number = Rails.application.config_for(:settings).dig(:credentials, :twilio, :from_phone_number)
dummy_to_phone_number = Rails.application.config_for(:settings).dig(:credentials, :twilio, :dummy_to_phone_number)
begin
@client = Twilio::REST::Client.new account_sid, auth_token
message = @client.messages.create(
body:,
from: from_phone_number,
to: to(to_phone_number),
# media_url: ['https://demo.twilio.com/owl.png'] # MMS example
)
puts message.sid
rescue Twilio::REST::TwilioError => exception
puts exception.message
end
end
private
def to(to_phone_number)
return dummy_to_phone_number if Rails.env.development?
to_phone_number
end
end
end
Call the service and send an SMS:
body = 'some text'
to_phone_number = '+38050554470367'
Twilio::SendSmsService.new.call(body, to_phone_number)
Assuming from_phone_number = 18305803384
, the test and stubbed Webmock request could look like this:
# spec/services/send_sms_service_spec.rb
require 'rails_helper'
RSpec.describe Twilio::SendSmsService do
let(:body) { 'lorem ipsum' }
let(:to_phone_number) { '+123456789' }
let(:twilio_api_url) { "https://api.twilio.com/2010-04-01/Accounts/#{Rails.application.config_for(:settings).dig(:credentials, :twilio, :account_sid)}/Messages.json" }
subject(:call) { described_class.new.call(body, to_phone_number) }
before do
stub_request(:post, twilio_api_url).to_return(status: 200)
end
context 'calls twilio api' do
it 'sends request' do
call
expect(WebMock).to have_requested(:post, twilio_api_url).with(body: 'Body=lorem+ipsum&From=%2B18305803384&To=%2B123456789')
end
end
end
Ideally, before sending transactional SMS messages, I would build a mechanism for the user to verify his phone number.
Hereās how it would work:
Also, we might be able to track if an SMS has been delivered on Twilioās side. Than we would mark the phone number as invalid if the SMS is not deliverable.
We can also try to validate the to_phone_number via Twilio lookup API.
It all depends on your unique usecase and how far your are willing to go :)
]]>Instead, you could use Redis to store this temporary data. After all, isnāt that what Redis exists for?!
Also, a more accurate way to track whether someone is actually online is to check if he has an active Websockets connection with your app.
In the previous post I wrote about tracking live visitor count. We identified a vistor by a session_id
without authentication.
The only difference here is that we identify an authenticated current_user
if there is one.
Prerequisites:
Result - you can have a ālive list of online usersā:
First, pass the current_user.id
to the ActionCable Connection.
This can vary on the authentication solution that you are using.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
# if current_user = User.find_by(id: cookies.encrypted[:user_id])
if current_user = env['warden'].user # For Devise. Credit: @secretpray
current_user
else
# reject_unauthorized_connection
nil # Allow not logged in users to access the connection
end
end
end
end
Now you can access current_user
in a Channel.
Create a turbo stream that would target an ActionCable Channel.
# app/views/layouts/application.html.erb
<%= turbo_stream_from "online_users", channel: OnlineChannel %>
Next, create
< Turbo::StreamsChannel
& super
to make turbo_stream_from
correctly connect to ActionCableKredis.unique_list
where you will store ids of all users online# app/channels/online_channel.rb
class OnlineChannel < Turbo::StreamsChannel
def subscribed
super
return unless current_user
users_online = Kredis.unique_list "users_online", expires_in: 5.seconds
users_online << current_user.id
# users_online.elements
# users_online.elements.count
Turbo::StreamsChannel.broadcast_prepend_to(
verified_stream_name_from_params,
target: 'users-list',
partial: 'users/user',
locals: { user: current_user }
)
end
def unsubscribed
return unless current_user
users_online = Kredis.unique_list "users_online"
users_online.remove current_user.id
Turbo::StreamsChannel.broadcast_remove_to(
verified_stream_name_from_params,
target: "user_#{current_user.id}",
)
end
end
In the controller, find all users that are online from the Kredis list:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
# skip_before_action :authenticate_user!, only: %i[ index ]
def index
users_online = Kredis.unique_list "users_online"
@users = User.find(users_online.elements)
end
end
Display the users in the view:
# app/views/users/index.html.erb
<div id="users-list">
<% @users.each do |user| %>
<%= render partial: 'users/user', locals: { user: } %>
<% end %>
<div>
# app/views/users/_user.html.erb
<div id="<%= dom_id(user) %>">
<%= user.email %>
</div>
Turbo broadcasts that we have added in app/channels/online_channel.rb
will add/remove users from the list!
Important notice: when a user logs out, he will still be online until he refreshes the pages, closes the tab, or redirects to another website. This is because the ActionCable connection needs to be reset.
Thatās it. Now you can track all visitors on the website, track visitors in a room, track all online users. Thanks to ActionCable (Websockets), Kredis and Turbo Broadcasts!
]]>A ālive visitor countā feature can give users a āyou are not aloneā feeling. For example, when browsing a Reddit post:
Or when watching a live stream on youtube:
Another example - see other people that have the same google doc open:
ālive visitor countā can also boost urgency (booking website example):
By the end of this article you will have created:
Explanation of the above GIF:
1
to 2
.1
.Room
page, the visitor count goes from 1
to 2
for this particular Room.1
.Technologies we will need to implement the ālive visitor countā:
session_id
).In this example we will not implement user authentication. We will identify separate users by sessions. One browser = one session.
To make session_id
available within an ActionCable Channel, you have to add it inside Connection. The Connection has access to request and cookie data.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :session_user
def connect
self.session_user = request.session.id
end
end
end
Next, add a turbo_stream_from
broadcast to your application layout.
Importantly, append the channel: VisitorChannel
to connect to an ActionCable channel.
Add a placeholder div visitors-counter
where we will update the number of current visitors.
# app/views/layouts/application.html.erb
<%= turbo_stream_from :visitors, channel: VisitorChannel %>
Current website visitors:
<span id="visitors-counter"></span>
Create an action cable channel. Donāt use the generator, because you will not need the other extra files and code that rails g channel visitor
would give you. It has to inherit from Turbo::StreamsChannel
. Add super
. That way you will have streams_form
automatically imported. You will have access to verified_stream_name_from_params
that is :visitors
for our current case.
In this case verified_stream_name_from_params
= :visitors
.
Create a redis unique list and add the session_user on subscribed
, remove the user on unsubscribed
.
Send a turbo stream broadcast to update this count in everyones views with Turbo::StreamsChannel.broadcast_update_to
.
# app/channels/visitor_channel.rb
# class VisitorChannel < ApplicationCable::Channel
class VisitorChannel < Turbo::StreamsChannel
def subscribed
super
visitors_online = Kredis.unique_list "visitors_online"
visitors_online << session_user
update_visitors_count(visitors_online)
end
def unsubscribed
visitors_online = Kredis.unique_list "visitors_online"
visitors_online.remove session_user
update_visitors_count(visitors_online)
end
private
def update_visitors_count(visitors_online)
Turbo::StreamsChannel.broadcast_update_to(
verified_stream_name_from_params,
target: 'visitors-counter',
html: visitors_online.elements.count
)
end
end
Success! Now every visitor of your application will see the visitor count!
Try opening/closing pages in your app from 2 different browsers and see the visitor_count
change.
Maybe a more useful feature would be to see the quantity of users on a specific page. For example, we have a scaffold of Room
and each room has a #show
page.
In this case we will pass an additional room_id
param to the turbo_stream_from
.
Make the target
also unique based on the room_id
:
# app/views/rooms/show.html.erb
<%= turbo_stream_from @room, channel: RoomChannel, params: { room_id: @room.id } %>
<%#= turbo_stream_from @room, channel: RoomChannel, data: { room_id: @room.id } %>
People inside this room: <span id="room-<%= params[:room_id]%>-counter"></span>
Now we can access params[:room_id]
from our room_channel.rb
:
# app/channels/room_channel.rb
class RoomChannel < Turbo::StreamsChannel
def subscribed
super
room_visitors = Kredis.unique_list "room_visitors"
room_visitors << session_user
target = "room-#{params[:room_id]}-counter"
update_visitors_count(room_visitors, target)
end
def unsubscribed
room_visitors = Kredis.unique_list "room_visitors"
room_visitors.remove session_user
target = "room-#{params[:room_id]}-counter"
update_visitors_count(room_visitors, target)
end
private
def update_visitors_count(room_visitors, target)
Turbo::StreamsChannel.broadcast_update_to(
verified_stream_name_from_params,
target:,
html: room_visitors.elements.count
)
end
end
Success! Now when you open a specific room page, it will register a new connection. See the āPeople inside this roomā counter get updated:
Thatās it. See you in the next one!
]]>To request usersā location, we will use Web/API/Geolocation/getCurrentPosition with StimulusJS.
Create stimulus controller:
rails g stimulus geolocation
// app/javascript/controllers/geolocation_controller.js
import { Controller } from "@hotwired/stimulus"
const options = {
enableHighAccuracy: true,
maximumAge: 0
};
// Connects to data-controller="geolocation"
export default class extends Controller {
static values = { url: String }
search() {
navigator.geolocation.getCurrentPosition(this.success.bind(this), this.error, options);
}
success(pos) {
const crd = pos.coords;
// redirect with coordinates in params
location.assign(`/locations/?place=${crd.latitude},${crd.longitude}`)
}
error(err) {
console.warn(`ERROR(${err.code}): ${err.message}`);
}
}
Finally, a button to get location:
<div data-controller="geolocation">
<button data-action="geolocation#search">search near me</button>
</div>
š” Interestingly, if you use this.success
instead of this.success.bind(this)
, stimulus targets will not work within the success function.
Get address based on coordinates using geocoder, and search near
the address:
# app/javascript/controllers/geolocation_controller.js
- location.assign(`/locations/?place=${crd.latitude},${crd.longitude}`)
+ location.assign(`/locations/?coordinates=${crd.latitude},${crd.longitude}`)
# app/controllers/locations_controller.rb
def index
+ if params[:coordinates].present?
+ place = Geocoder.search(params[:coordinates])
+ params[:place] = place.first.address
+ end
if params[:place].present?
@locations = Location.near(params[:place], params[:distance] || 10, order: :distance)
else
@locations = Location.all
end
end
Thatās it! Now you can get Geolocation with Javascript and use it within a Rails app!
]]>mapkick-rb is a Ruby on Rails adapter for MapkickJS. It allows you to easily feed a JSON with coordinates and display a map within your Rails app.
To display a marker on a map, you need to know latitude and longitude GPS coordinates. gem Geocoder allows you to get coordinates based on an address (house, street, city, state, country).
After installing the gem, initialize your Mapkick API key.
# echo > config/initializers/mapbox.rb
# config/initializers/mapbox.rb
ENV["MAPBOX_ACCESS_TOKEN"] = "pk.eyJ1..."
# ENV["MAPBOX_ACCESS_TOKEN"] = Rails.application.credentials.dig(:mapkick_api_key)
Basic map with multiple options:
<%= js_map [{latitude: 37.7829,
longitude: -122.4190,
label: 'My home',
tooltip: 'Hello!'
}],
id: "cities-map",
width: "800px",
height: "500px",
markers: {color: "#00FF00"},
tooltips: { hover: false, html: true},
style: "mapbox://styles/mapbox/outdoors-v12",
zoom: 15,
controls: true,
refresh: 60 %>
Result - display a marker on draggable map:
Create a helper with a link to the location
page:
# app/helpers/locations_helper.rb
module LocationsHelper
def html_link_to_location(location)
link_to location.name,
location_url(location),
target: '_blank',
style: 'font-weight: bold; color: green'
end
end
To be able to click on the tooltip, use the option { hover: false, html: true}
.
Render the helper method in the tooltip param:
<%= js_map [{latitude: location.latitude,
longitude: location.longitude,
label: location.name,
tooltip: html_link_to_location(location)}],
tooltips: { hover: false, html: true} %>
Result - map with clickable links to locations:
For this, the best way will be to render /locations.json
:
<%= js_map locations_path(format: :json) %>
Customize the JSON:
// app/views/locations/_location.json.jbuilder
json.extract! location, :latitude, :longitude
json.label location.name
json.tooltip html_link_to_location(location)
// json.tooltip "#{html_link_to_location(location)} <br> #{location.address}"
Result - @locations
is rendered from app/views/locations/index.json.jbuilder
:
In this final example, we will factor in having a search form for place
and distance
:
# app/controllers/locations_controller.rb
class LocationsController < ApplicationController
before_action :set_location, only: %i[ show edit update destroy ]
# GET /locations or /locations.json
def index
if params[:place].present?
@locations = Location.near(params[:place], params[:distance] || 10, order: :distance)
# distance 10 km => zoom 13x; distance 100 km => zoom 10x;
# @zoom = params[:distance].eql?('10') ? 13 : 10
else
@locations = Location.all
end
respond_to do |format|
format.html
format.json
end
end
Be sure to add the query params to the path in the view:
<%= js_map locations_path(format: :json, place: params[:place], distance: params[:distance]), zoom: @zoom %>
Result - show only location within set distance
from geocoded coordinates of place
:
Business problem #1: Find hotels that have a SPA
Business problem #2: Find hotels that have a Massage
Here we are solving the problem: āfind all parents with children that have a particular attributeā.
In the below example location has_many :products
&& Product.name = String
.
Add product_name
search field:
<%= form_with url: locations_path, method: :get do |form| %>
<%= form.text_field :product_name, value: params[:product_name] %>
<%= form.text_field :place, value: params[:place] %>
<%= form.select :distance, [10, 100], selected: params[:distance] %>
<%= form.submit %>
<% end %>
Find locations that have product you are searching for:
# app/controllers/locations_controller.rb
def index
locations = Location.joins(:products).includes(:products) # initially select only locations that have products
if params[:product_name].present?
products = Product.where('name ILIKE ?', "%#{params[:product_name]}%")
location_ids = products.select(:location_id).distinct
locations = locations.where(id: location_ids)
end
if params[:place].present?
locations = locations.near(params[:place], params[:distance] || 10, order: :distance)
end
@locations = locations
end
Donāt forget to add product_name: params[:product_name]
to the JSON map path:
<%= js_map locations_path(format: :json, place: params[:place], product_name: params[:product_name], distance: params[:distance]) %>
Result: find locations that offer a specific product/service:
Thatās it! Now you can build your own AIRNBN search frontend!
]]>Useful usage examples:
latitude
and longitude
coordinates by address
,address
by lat-lon
coordinates,within_bounding_box
)distance_from
)near
)nearbys
)Afterwards, when you have the coordinates of a location, you can use a separate Maps API to display them on a map.
Geocoder gem does a search via a Places API, and returns coordinates. The more detailed address you search for, the more precise the coordinates you will receive.
bundle add geocoder
# search
ua = Geocoder.search('Kyiv')
ua.first.coordinates
# => [50.4500336, 30.5241361] # latitude and longitude
ua.first.country
# => 'Ukraine'
fr = Geocoder.search('Notre dame cathedral paris')
fr.first.city
# => 'Paris'
# geographic_center
Geocoder::Calculations.geographic_center([ua.first.coordinates, fr.first.coordinates])
# => [50.51223060957045, 16.201193185230583]
You can get the current web requests country/ip/etc.
You can use it, for example, to geoblock countries like Ruzzia
# controller or view
request.location
request.location.try(:country)
# request.ip
Storing the address as a single string looks like a simple straightforward solution, however storing each address detail separately gives you more power.
A usual address on Google Maps has the sequence street, city, state, country, zip
.
Scaffold your location model:
rails g scaffold Location latitude:float:index longitude:float:index street city state country zip
rails g scaffold Location latitude:float:index longitude:float:index address
rails db:migrate
Geocoder will automatically perform a search and find the latitude
and longitude
of your location.
To save compute power and API thresholds, it makes sence to geocode only if the address has changed.
Basic (one address
field):
# app/models/location.rb
geocoded_by :address
after_validation :geocode, if: :address_changed?
# after_validation :geocode, if: ->(obj){ obj.address.present? and obj.address_changed? }
Advanced (multiple address
fields):
# app/models/location.rb
geocoded_by :address
after_validation :geocode, if: :address_changed?
def address
[street, city, state, country, zip].compact.join(', ')
end
private
def address_changed?
country_changed? ||
state_changed?
city_changed? ||
street_changed? ||
zip_changed? ||
end
DEMO DATA: seeds with a few real hotels in France
# db/seeds.rb
name = "HĆ“tel Martinez - The Unbound Collection by Hyatt"
address = "73 Bd de la Croisette, 06400 Cannes"
Location.create(name:, address:)
name = "Exclusive Hotel Belle Plage"
address = "2 Rue Brougham, 06400 Cannes"
Location.create(name:, address:)
name = "Best Western Premier Le Patio des Artistes - Cannes"
address = "6 Rue de BĆ“ne, 06400 Cannes"
Location.create(name:, address:)
name = "Le Negresco"
address = "37 Prom. des Anglais, 06000 Nice"
Location.create(name:, address:)
name = "Caesars Palace"
address = "3570 S Las Vegas Blvd, Las Vegas, NV 89109, United States"
Location.create(name:, address:)
Now you can find call geocoder methods on the model.
# geocode a single record:
address = Address.first
address.geocode
address.save
# geocode all:
Location.all.each { |location| location.geocode && location.save }
Location.geocoded
# => return objects with coordinates
Location.not_geocoded
# => return objects without coordinates
Location.first.to_coordinates
# => [51.51436195, 31.31593525714063]
Location.first.nearbys(20)
Location.first.nearbys(20, units: :km)
# => array of locations within 20 km of coordinates, excluding selected location
# ! useful to show "similar" or "nearby" feature
Location.near(Location.first, 20, units: :km, order: :distance)
Location.near('Omaha, NE, US', 20)
# => all locations within 20 km of coordinates
# ! useful for "find all next to...." feature
Location.first.distance_from(Location.second)
Location.first.distance_to(Location.second) # same as above
Location.first.distance_form([40.714,-100.234])
# => 1.8493403104012456
# all locations within square
sw_corner = [40.71, 100.23]
ne_corner = [36.12, 88.65]
Location.within_bounding_box(sw_corner, ne_corner)
For example, hereās how you can list all nearby locations within 10km/10mi from current location, and exact distance to them:
# app/views/locations/show.html.erb
<% @location.nearbys(10).each do |location| %>
<%= location.name %>
<b>Distance:</b>
<%= location.distance_to(@location).round(2) %>
<%= Geocoder.config.units.to_s %>
<br>
<% end %>
Result:
Having a search for for place
and distance
, you can find relevant Locations. This can be a vital feature when building a website like AirBnB or Booking.com.
Example query in human words: Find all locations within 10km distance from Chernihiv, Ukraine
# app/controllers/locations_controller.rb
class LocationsController < ApplicationController
def index
if params[:place].present?
@locations = Location.near(params[:place], params[:distance] || 10, order: :distance)
else
@locations = Location.all
end
end
end
# a view
<%= form_with url: locations_path, method: :get do |form| %>
<%= form.label :place, "City, Country" %>
<%= form.text_field :place, value: params[:place] %>
<%= label :distance, "Distance, km" %>
<%= text_field_tag :distance, [10, 20, 30], params[:distance] %>
<%= form.submit "Search" %>
<% end %>
Result:
To display a market on a static image map, you would need to connect a places API.
There are a few options for using Places API:
From the above, Iāve tried only Mapbox. As long as you receive a Mapbox API key, you can display the map with an image_tag
:
<%= image_tag "https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/pin-s+ff2700(#{location.longitude},#{location.latitude})/#{location.longitude},#{location.latitude},13,0/300x200?access_token=#{Rails.application.credentials.dig(:mapbox_key)}" %>
Result:
Thereās much more that we can do with coordinates. In the future I hope to explore:
Thatās it for now.
]]>Sometimes you just have a JSON that you have to feed into your app.
First, hereās an example json file:
// db/view_data/superails-episodes.json
[
{
"rank_number": 103,
"title": "Ruby on Rails #103 Simple Omniauth without Devise",
"description": "Previously Iāve covered \"Github omniauth with Devise\".\nAn even simpler solution would be to sign in via a social login provider (Github) without Devise at all! \nHereās the easiest way to create your whole social authentication solution from zero!\n",
"tags": [
"omniauth",
"authentication"
]
},
{
"rank_number": 102,
"title": "Ruby on Rails #102 Email Calendar Invite",
"description": "In THIS episode we will EMAIL calendar invites and automatically add them to a users calendar!\nWe will also handle updating/cancelling events!\n",
"tags": [
"icalendar",
"email",
"action-mailer"
]
},
{
"rank_number": 101,
"title": "Ruby on Rails #101 iCalendar and .ics format. Add events to calendar",
"description": "Learn to create valid calendar events, that a user can download as an .ics file and add to his calendar!\n",
"tags": [
"icalendar"
]
}
]
path = "/Users/yaroslavshmarov/Downloads/superails-episodes.json"
data = File.read(path)
require 'json'
json = JSON.parse(data)
json.first['rank_number']
# => 103
json.map { |element| element['title'] }
# =>
# ["Ruby on Rails #103 Simple Omniauth without Devise",
# "Ruby on Rails #102 Email Calendar Invite",
# "Ruby on Rails #101 iCalendar and .ics format. Add events to calendar"]
json.first['title'] = 'New title'
# write to the json file
File.write(path, JSON.dump(data))
data = File.read('./db/fixtures/superails-episodes.json')
json = JSON.parse(data)
require 'open-uri'
path = "https://raw.githubusercontent.com/erik-sytnyk/movies-list/master/db.json"
uri = URI.open(path)
uri_json = JSON.load(uri)
Thatās it! š¤
]]>I really like the data structure of a .yml
file, because the syntax is much cleaner than .json
.
Hereās an example list of SupeRails episodes in the YAML format:
# db/view_data/superails-episodes.yml
- rank_number: 103
title: "Ruby on Rails #103 Simple Omniauth without Devise"
description: |
Previously Iāve covered "Github omniauth with Devise".
An even simpler solution would be to sign in via a social login provider (Github) without Devise at all!
Hereās the easiest way to create your whole social authentication solution from zero!
tags:
- omniauth
- authentication
- rank_number: 102
title: "Ruby on Rails #102 Email Calendar Invite"
description: |
In THIS episode we will EMAIL calendar invites and automatically add them to a users calendar!
We will also handle updating/cancelling events!
tags:
- icalendar
- email
- action-mailer
- rank_number: 101
title: "Ruby on Rails #101 iCalendar and .ics format. Add events to calendar"
description: |
Learn to create valid calendar events, that a user can download as an .ics file and add to his calendar!
tags:
- icalendar
You can parse this data (convert it into a Hash or Array) using Ruby on Rails native yaml parsers!
require 'yaml'
path = "/Users/yaroslavshmarov/Downloads/superails-episodes.yml"
@episodes = YAML::load File.open(path)
@episodes.first.fetch('title')
# => "Ruby on Rails #103 Simple Omniauth without Devise"
# write to the yaml file
@episodes.first['title'] = 'New title'
File.write(path, @episodes.to_yaml)
Source: Ruby YAML docs
# a controller action
# @episodes = YAML::load File.open("#{Rails.root.to_s}/db/fixtures/superails-episodes.yml") # ruby way
@episodes = YAML.load_file('db/fixtures/superails-episodes.yml') # rails way
@episodes.inspect
Render the results in a view:
# a view
<% @episodes.each do |episode| %>
<%= episode.fetch('name') %>
<%= episode['title'] %>
<% end %>
Psych::DisallowedClass
require 'open-uri'
path = "https://raw.githubusercontent.com/ruby-conferences/ruby-conferences.github.io/master/_data/conferences.yml"
uri = URI.open(path)
yaml = YAML.load_file uri, permitted_classes: [Date]
# yaml = YAML.load File.read(uri), permitted_classes: [Date]
yaml.each do |event|
Event.create!(
name: event["name"],
location: event["location"],
start_date: event["start_date"]
)
end
Source: Rails YAML.load_file docs
Thatās it! š¤
]]>An even simpler solution would be to sign in via a social login provider without Devise at all! Hereās the easiest way to do it.
Before at superails.com was using devise and omniauth, but for simplicity (I do not want to manage user passwords, confirm accounts via email) I decided to remove devise and keep only oAuth login!
This is how I moved to oAuth-only login.
First, add the gem omniauth
gems:
# Gemfile
gem 'omniauth-github', github: 'omniauth/omniauth-github', branch: 'master'
gem 'omniauth-google-oauth2'
gem "omniauth-rails_csrf_protection", "~> 1.0"
If you are using Github omniauth, you can generate API credentials here
For development environment you can use
Homepage URL:
http://localhost:3000
Authorization callback URLs
http://localhost:3000/auth/github/callback
http://localhost:3000/auth/google_oauth2/callback
Add your social provider API credentials to the Rails app:
# echo > config/initializers/omniauth.rb
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :github, "GITHUB_ID", "GITHUB_SECRET"
provider :google_oauth2, "GOOGLE_ID", "GOOGLE_SECRET"
end
Create a user model. We will also add a few static pages:
/landing_page
that can be accessed without authentication/dashboard
that requires authenticationrails g controller static_pages landing_page dashboard
rails g model User provider uid name email image
Routes:
# config/routes.rb
root 'static_pages#landing_page'
get 'dashboard', to: 'static_pages#dashboard'
get 'auth/github/callback', to: 'sessions#create'
get 'auth/google_oauth2/callback', to: 'sessions#create'
get 'auth/failure', to: 'sessions#failure'
delete 'sign_out', to: 'sessions#destroy'
# get 'login', to: redirect('/auth/github'), as: 'login'
Gems like devise provide some default methods, that we will have to add on our own now:
def current_user
- get the current user from session params.def user_signed_in?
- check if there is a current user.def authenticate_user!
- to restrict controller actions for non-authenticated users.helper_method :current_user
- to make current_user
available in views.helper_method :user_signed_in
- to make user_signed_in
available in views.# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
helper_method :current_user
helper_method :user_signed_in?
def authenticate_user!
redirect_to root_path, alert: "Requires authentication" unless user_signed_in?
end
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
def user_signed_in?
# converts current_user to a boolean by negating the negation
!!current_user
end
end
The button to /auth/github
will redirect to the github login page.
# app/views/layouts/application.html.erb
<%= link_to 'Home', root_path %>
<% if current_user %>
<%= current_user.email %>
<%= link_to 'Dashboard', dashboard_path %>
<%= button_to 'Logout', sign_out_path, method: :delete, data: { turbo: false } %>
<% else %>
<%= button_to "Sign in with Github", "/auth/github", data: { turbo: false } %>
<%= button_to "Sign in with Google", "/auth/google_oauth2", data: { turbo: false } %>
<% end %>
After successful authentication, the user should be redirected to sessions#create
with request.env['omniauth.auth']
.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
@user = User.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
session[:user_id] = @user.id
redirect_path = request.env['omniauth.origin'] || dashboard_path
redirect_to redirect_path, notice: "Logged in as #{@user.name}"
else
redirect_to root_url, alert: "Failure"
end
end
def destroy
session[:user_id] = nil
redirect_to root_path, notice: "Logged out"
end
def failure
redirect_to root_path, alert: "Failure"
end
end
from_omniauth
will find the users email
and uid
in the data provided by github/google, and find or create the user.
# app/models/user.rb
class User < ApplicationRecord
validates :provider, presence: true
validates :uid, presence: true, uniqueness: true
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true, uniqueness: true
def self.from_omniauth(omniauth_params)
# TODO: what if 2 providers have same email?
# TODO: what if email is not present in the omniauth payload?
provider = omniauth_params.provider
uid = omniauth_params.uid
user = User.find_or_initialize_by(provider:, uid:)
user.email = omniauth_params.info.email
user.name = omniauth_params.info.name
user.image = omniauth_params.info.image
user.save
user
end
end
Finally, require authentication to visit /dashboard
:
# app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController
before_action :authenticate_user!, only: %i[dashboard]
def landing_page
end
def dashboard
end
end
Instead of using a before_action :authenticate_user!
on a controller level, you can authorize user on the route level even before the controller gets touched!
Gems like devise offer helpers like authenticate :user
that is available inside routes out of the box.
In this case we will have to build our own route constraint that has access to request.session
:
# app/constraints/user_constraint.rb
class UserConstraint
def initialize(&block)
@block = block
end
def matches?(request)
user = current_user(request)
user.present? && @block.call(user)
end
def current_user(request)
User.find_by(id: request.session[:user_id])
end
end
Now we can wrap routes that require authenticated user, or admin user in this constraint:
# config/routes.rb
# authenticate :user, ->(user) { user.admin? } do # <- devise syntax
constraints UserConstraint.new { |user| user.admin? } do
mount GoodJob::Engine, at: "good_job"
mount Avo::Engine, at: Avo.configuration.root_path
end
# authenticated :user do # <- devise syntax
constraints UserConstraint.new { |user| user.present? } do
get 'dashboard', to: 'static#dashboard'
end
Nice!
bundle add faker
- add Gem Faker to mock Omniauth hashes like Faker::Omniauth.github
, Faker::Omniauth.google
.
Add some helper methods to log in as a random user, or as a defined user:
# test/test_helper.rb
# log in as a user from the database
def sign_in(user)
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new(
provider: user.provider,
uid: user.uid,
info: {
email: user.email
}
)
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:github]
post '/auth/github'
follow_redirect!
end
# random github user
def login_with_github
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new(Faker::Omniauth.github)
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:github]
post '/auth/github'
follow_redirect!
end
# random google user
def login_with_google_oauth2
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(Faker::Omniauth.google)
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:google]
post '/auth/google_oauth2'
follow_redirect!
end
Write a lot of tests for different scenarios:
# test/controllers/sessions_controller_test.rb
require 'test_helper'
class SessionsControllerTest < ActionDispatch::IntegrationTest
setup do
# without this omniauth.origin can be set to the one in previous test (tags_url) resulting in flaky tests
OmniAuth.config.before_callback_phase do |env|
env['omniauth.origin'] = nil
end
end
test 'authenticated github user should get dashboard' do
assert_raises(ActionController::RoutingError) do
get dashboard_url
end
# get dashboard_url
# assert_response :redirect
# assert_redirected_to root_url
# assert_equal 'Requires authentication', flash[:alert]
login_with_github
get dashboard_url
assert_response :success
delete sign_out_path
assert_response :redirect
assert_redirected_to root_url
assert_equal 'Signed out', flash[:notice]
end
test 'google auth success' do
login_with_google_oauth2
assert_response :redirect
assert_redirected_to dashboard_url
assert_match 'Logged in as', flash[:notice]
assert_equal controller.current_user, User.last
assert User.pluck(:email).include?(OmniAuth.config.mock_auth[:google_oauth2][:info][:email])
end
test 'github oauth success' do
login_with_github
assert_response :redirect
assert_redirected_to dashboard_url
email = OmniAuth.config.mock_auth[:github][:info][:email]
name = OmniAuth.config.mock_auth[:github][:info][:name]
assert_equal "Logged in as #{name}", flash[:notice]
assert User.pluck(:email).include?(email)
assert_equal controller.current_user.email, email
end
test 'github oauth failure' do
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:github] = :invalid_credentials
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:github]
get '/auth/github/callback'
follow_redirect!
assert_response :redirect
assert_redirected_to root_path
assert_equal 'Failure. Please try again', flash[:alert]
assert_nil controller.current_user
end
test 'github auth failure with no email' do
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new(
provider: 'github',
uid: '123545',
info: {
nickname: 'test',
name: 'test',
email: nil,
image: 'https://avatars.githubusercontent.com/u/123545?v=3'
}
)
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:github]
post '/auth/github'
follow_redirect!
assert_response :redirect
assert_redirected_to root_url
assert_equal 'Failure. Please try again', flash[:alert]
end
test 'redirect to previous page after login' do
OmniAuth.config.before_callback_phase do |env|
env['omniauth.origin'] = tags_url
end
login_with_github
assert_response :redirect
assert_redirected_to tags_url
end
test 'redirect to dashboard when origin is blank' do
OmniAuth.config.before_callback_phase do |env|
env['omniauth.origin'] = nil
end
login_with_github
assert_response :redirect
assert_redirected_to dashboard_url
end
end
Add a helper to log in as a user via system tests:
# test/application_system_test_case.rb
def login_with_oauth(user)
OmniAuth.config.test_mode = true
OmniAuth.config.add_mock(:github, {
provider: user.provider,
uid: user.uid,
info: { email: user.email }
})
visit '/auth/github/callback'
end
Add a browser test for being able to access the dashboard only as a logged in user:
require 'application_system_test_case'
class StaticTest < ApplicationSystemTestCase
test 'landing_page' do
visit root_url
assert_current_path root_path
end
test 'dashboard' do
user = users(:one)
login_with_oauth(user)
visit dashboard_url
assert_current_path dashboard_path
end
end
# config/initializers/avo.rb
config.current_user_method do
User.find_by(id: session[:user_id]) if session[:user_id]
end
Thatās it! Now you can use omniauth without devise! FREEDOM! šļø
]]>In reality, the email just has a file with .ics
extension attached. You email client recognizes it and adds it to your calendar.
An .ics
file can look more-less like this:
You can use gem icalendar to generate .ics
files with Ruby.
Letās assume that a User wants to add a sports Game to his calendar.
Letās generate some dummy events:
# config/seeds.rb
5.times do |index|
starts_at = index.days.ago
ends_at = starts_at + 2 * 60 * 60
title = Faker::Sports.sport
description = Faker::Quote.famous_last_words
address = Faker::Address.full_address
game = Game.create!(starts_at:, ends_at:, title:, description:, address:)
end
5.times do |index|
starts_at = index.days.ago
ends_at = starts_at + 2 * 60 * 60
title = Faker::Sports.sport
description = Faker::Quote.famous_last_words
address = Faker::Address.full_address
game = Game.create!(starts_at:, ends_at:, title:, description:, address:)
end
bundle add faker
rails g scaffold Game starts_at:datetime ends_at:datetime title:string description:text address:text
rails db:migrate db:seed
.ics
fileWe will generate .ics
files for game/user in a Service, so that it is reusable:
bundle add icalendar
mkdir app/services
mkdir app/services/games
echo > app/services/games/icalendar_event.rb
The icalendar object with almost all possible options:
# app/services/games/icalendar_event.rb
# Games::IcalendarEvent.new(game: Game.last).call
# Games::IcalendarEvent.new(game: Game.last, user: User.last).call
class Games::IcalendarEvent
require 'icalendar'
include Rails.application.routes.url_helpers
def initialize(game:, user: nil)
@game = game
@user = user
@url = game_url(@game)
end
def call
ical = ::Icalendar::Calendar.new
event = ::Icalendar::Event.new
event.dtstart = Icalendar::Values::DateTime.new @game.starts_at
event.dtend = Icalendar::Values::DateTime.new @game.ends_at
event.summary = @game.title
event.description = "Watch #{@game.title} live at #{@url}"
event.uid = @game.id.to_s # important for updating/canceling an event
event.sequence = Time.now.to_i # important for updating/canceling an event
event.url = @url
event.location = @game.address # location on map
# event.attendee = %w(mailto:abc@example.com mailto:xyz@example.com)
if @user.present?
event.attendee = Icalendar::Values::CalAddress.new("mailto:#{@user.email}", partstat: 'accepted') # DECLINED # TENTATIVE
end
# event.organizer = "mailto:organizer@example.com"
event.organizer = Icalendar::Values::CalAddress.new("mailto:#{ApplicationMailer.default_params[:from]}", cn: 'Yaro from Superails')
event.status = 'CONFIRMED' # 'CANCELLED'
event.ip_class = 'PUBLIC' # 'PRIVATE'
# event.attach = Icalendar::Values::Uri.new @url
# event.append_attach = Icalendar::Values::Uri.new(@url, "fmttype" => "application/binary")
event.created = @game.created_at
event.last_modified = @game.updated_at
event.alarm do |a|
a.summary = "#{@game.title} starts in 30 minutes!"
a.trigger = '-PT30M'
end
ical.add_event(event)
ical.append_custom_property('METHOD', 'REQUEST') # add event to calendar by default!
ical.publish
# ical.ip_method = 'REQUEST'
# ical.ip_method = 'PUBLISH'
# ical.ip_method = 'CANCEL'
ical
end
end
The complete icalendar documentation can give you more insight on all the above options.
This way you can call
Games::IcalendarEvent.new(game: Game.last).call
or
Games::IcalendarEvent.new(game: Game.last, user: User.last).call
and generate an ical object for any game-user pair.
How can we deliver the .ics
file?
That there are 3 common ways to share calendar events with users:
This way a user can download a game/event as an .ics
file, click on it and add it to his calendar:
# app/controllers/games_controller.rb
def show
respond_to do |format|
format.html
format.ics do
# calendar = Games::IcalendarEvent.new(game: @game, user: User.last).call
calendar = Games::IcalendarEvent.new(game: @game).call
send_data calendar.to_ical, type: 'text/calendar', disposition: 'attachment', filename: "Game#{@game.id}.ics"
# render body: calendar.to_ical
# render inline: calendar.to_ical
# render plain: calendar.to_ical
end
end
end
<%= link_to game.title, game_path(game, format: :ics) %>
Adding a downloaded .ics
file to calendar:
Allow a user to āsubscribeā to your calendar. His calendar app will āpingā your calendar endpoint from time to time to get the updated events list.
In the below example, we add all games/events to the calendar:
# app/controllers/games_controller.rb
# class GamesController < ActionController::Base
class GamesController < ApplicationController
skip_before_action :authenticate_user, only: :index
def index
@games = Game.all
respond_to do |format|
# format.html
format.ics do
cal = Icalendar::Calendar.new
cal.x_wr_calname = 'All games'
@games.each do |game|
cal.event do |event|
event.dtstart = game.starts_at
event.dtend = game.ends_at
event.summary = game.title
event.description = game.description
event.uid = game.id.to_s
event.sequence = Time.now.to_i
end
end
cal.publish
render plain: cal.to_ical
end
end
end
end
Add a link to āsubscribeā to the calendar. When the user clicks the link, it will open in the users calendar app and suggest adding an external calendar:
<%= link_to 'subscribe', games_url(protocol: :webcal, format: :ics) %>
When you click the link, it will open your calendar app and suggest adding/syncing a calendar:
In a few seconds, you will see all the calendar events in your calendar app:
Important notes:
protocol
must be webcal
, not http
format
must be ics
render plain
in controllercal.x_wr_calname
to set default calendar nameThis will not work on localhost, because you donāt have a real web url that the calendar app can send a request to! => use nginx, or do it on staging/production.
We are all used to getting invites via email and accepting/declining them.
Letās create a button to email the event to the calendar:
rails g mailer game_mailer add_game
# config/routes.rb
resources :games do
member do
post :email_to_calendar
end
end
# app/controllers/streaming/game_streams_controller.rb
def email_to_calendar
@game = Game.find(params[:id])
# send email here
GameMailer.with(game: @game, user: current_user).add_game.deliver_later
redirect_to games_path, notice: 'Email sent'
end
# app/mailers/game_mailer.rb
class GameMailer < ApplicationMailer
def add_game
@game = params[:game]
@user = params[:user]
@game_url = streaming_sport_game_stream_url(sport_slug: @game.sport_slug, game_slug: @game.slug)
@subject = [@game.title, @game.display_starts_at].join(', ')
ical = Games::IcalendarEvent.new(game: @game, user: @user).call # generate the ical file
# attach ICS file
mail.attachments["#{@subject}.ics"] = { mime_type: 'application/ics', content: ical.to_ical }
# mail.attachments["#{@subject}.ics"] = { mime_type: 'text/calendar', content: ical.to_ical }
# mail.attachments["#{@subject}.ics"] = { mime_type: 'text/calendar; method=REQUEST', content: ical.to_ical }
mail to: @user.email,
from: 'Yaro from Superails <admin@superails.com>',
cc: Rails.application.config_for(:settings)[:support_email],
subject: @subject
end
end
<%= button_to 'Email to calendar', email_to_calendar_game_path(game) %>
Now when you click the link, an email invite will be sent:
If you send follow-up emails with changed start/end time of the event, it will be automatically updated in the users calendar! Also, if you send an update with event.status = 'CANCELLED'
, the event will be removed from the calendar!
Thatās it! šš„³š¾
References:
]]>development
Use gem 'letter_opener'
. This way you can see how emails look when they are delivered.
# config/environments/development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true
On top of that, gem 'letter_opener_web'
provides a beautiful UI for emails in your tmp folder š¤©
If you still have an error missing host to link to
, you can add this:
# app/config/development.rb
require "active_support/core_ext/integer/time"
+ Rails.application.routes.default_url_options[:host] = 'localhost:3000'
Rails.application.configure do
test
/staging
You might want to test real email delivery in PR apps / staging. You should be sure that you do not deliver these test emails to real people! The easiest solution would be to use an AWS sandbox domain. This way the emails will be delivered only to users from your domain. In the below example we do not want to get out of the sandbox:
production
Check out my post: Sending emails in production with Amazon SES
Generate a mailer:
rails g mailer post post_created
Always use deliver_later
, not deliver_now
:
# app/controllers/posts_controller.rb
PostMailer.with(user: current_user, post: @post).post_created.deliver_later
You can pass multiple params and attachments:
# app/mailers/post_mailer.rb
class PostMailer < ApplicationMailer
def post_created
@user = params[:user]
@post = params[:post]
@greeting = "Hi"
# attach from assets
attachments['logo.png'] = File.read('app/assets/images/logo.png')
# attach from /public folder
attachments.inline['logo.png'] = File.read("#{Rails.root}/public/images/logo.png")
# attach an ical event
attachments['calendar-event.ics'] = { mime_type: 'application/ics', content: icalendar.to_ical }
# attach an ActiveStorage file
file = @user.avatar
attachments['avatar.png'] = { mime_type: file.blob.content_type, content: file.blob.download }
# mail options
mail(
from: "Yaroslav <hello@superails.com>",
to: email_address_with_name(User.first.email, User.first.full_name),
cc: User.all.pluck(:email),
bcc: "secret@superails.com",
subject: "New post created"
)
end
end
Rendering attachments in the email html:
# app/views/post_mailer/post_created.html.erb
<%= asset_url('/images/games/game-card-backgrounds/football.png', host: 'https://superails.com') %>
<%= image_tag attachments['logo.png'].url, style: 'max-width: 16em;' %>
<%= image_tag attachments['avatar.png'].url, alt: 'My Photo', width: 100 %>
<h1>Post#post_created</h1>
<%= @user.email %> create <%= @post.title %>
Styling emails with CSS is tricky: you canāt be sure that different email clients (outlook/gmail) render the CSS in the same way.
I usually style emails with plain CSS, not a CSS framework.
Best way for previewing your email html without actually sending it to an inbox:
# test/mailers/previews/post_mailer_preview.rb
class PostMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/post_mailer/post_created
def post_created
PostMailer.with(user: User.first, post: Post.first).post_created
end
end
Minitest example:
# test/mailers/post_mailer_test.rb
require "test_helper"
class PostMailerTest < ActionMailer::TestCase
test "post_created" do
mail = PostMailer.post_created
assert_equal "Post created", mail.subject
assert_equal ["to@example.org"], mail.to
assert_equal ["from@example.com"], mail.from
assert_match "Hi", mail.body.encoded
end
end
Rspec example:
# main/spec/mailers/game_spec.rb
require 'rails_helper'
RSpec.describe PostMailer, type: :mailer do
let(:user) { create(:user) }
let(:post) { create(:post) }
describe 'reminder' do
let(:mail) { PostMailer.with(user:, game:).post_created }
it 'renders the headers' do
expect(mail.subject).to match('vs')
expect(mail.to).to eq([user.email])
expect(mail.from).to eq(['hello@superails.com'])
end
it 'renders the body' do
expect(mail.body.encoded).to match('Post created')
expect(mail.body.encoded).to match('logo')
expect(mail.body.encoded).to match('avatar')
end
end
end
# app/mailers/application_mailer.rb
default from: 'Yaro <hello@corsego.com>'
# app/views/layouts/mailer.html.erb
<body>
<%= yield %>
+ Regards, Yaroslav Shmarov
+ <br>
+ hello@superails.com
</body>
</html>
Thatās it! šš„³š¾
]]>Notice the different favicon (with a red circle), and the title text (with notifications count).
Assuming you have a current_user
that has_many :notifications
. Notifications model has seen:boolean, default: false
.
You would need two separate favicons:
Default favicon
favicon with a notifications symbol
Now you can set a different favicon
and title
each time you refresh/revisit a page in your application:
# app/views/layouts/application.html.erb
-<title>LinkedIn</title>
+<% new_notifications = current_user.notifications.where(seen: [false, nil]) %>
+<% if new_notifications.any? %>
+ <%= favicon_link_tag asset_path('linkedin-notify.png') %>
+ <title>(<%= new_notifications.count %>)LinkedIn</title>
+<% else %>
+ <%= favicon_link_tag asset_path('linkedin.png') %>
+ <title>LinkedIn</title>
+<% end %>
No notifications:
With notifications:
If you want to be more persuasive, you can make the tab blink
# app/views/layouts/application.html.erb
<% new_notifications = current_user.notifications.where(seen: [false, nil]) %>
<% if new_notifications.any? %>
<%= favicon_link_tag asset_path('linkedin-bell.png') %>
- <title>(<%= new_notifications.count %>)LinkedIn</title>
+ <title data-controller="text-blink" data-text-blink-newtitle-value="(<%= new_notifications.count %>) Alerts">LinkedIn</title>
<% else %>
<%= favicon_link_tag asset_path('linkedin.png') %>
<title>LinkedIn</title>
<% end %>
To remove the blinking effect when there are no notifications, you have to explicitly disconnect the controller:
// app/javascript/controllers/text_blink_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
newtitle: String
}
connect() {
// var newTitle = "(3) Alerts"
// var oldTitle = "LinkedIn"
var newTitle = this.newtitleValue
var oldTitle = document.title
var blink = function() { document.title = document.title == newTitle ? oldTitle : newTitle; }
this.myInterval = setInterval(blink, 1000)
}
disconnect() {
clearInterval(this.myInterval);
}
}
You can move the page title into a partial, and update it each time a new notification is created using Turbo Stream Broadcasts:
<%#= turbo_stream_from (current_user, :global_notifications) %>
<%= turbo_stream_from :global_notifications %>
# app/controllers/notifications_controller.rb
@notification.save
# Turbo::StreamsChannel.broadcast_replace_to([current_user, :global_notifications],
Turbo::StreamsChannel.broadcast_replace_to(:global_notifications,
target: 'page-title',
partial: "shared/page_title")
Anyway, this particular feature does not look like top priority functionality. It does not matter to me.
So,
Thatās it! šš„³š¾
]]>It can be tricky to make elements above an image readable. A good solution - add a semi-transparent darkness layer.
box-shadow: inset 0 0 0 100vmax rgba(0,0,0,.3);
gives a perfect contrast for items above an image background. My case (left - without overlay, right - after):
The css:
<div>
<div style="box-shadow: inset 0 0 0 100vmax rgba(0,0,0,.3); background-image: url('app/assets/images/background.png');">
Some text above image
</div>
</div>
Similar approach suggested on reddit
In this case, the āNextā button floats above content, and I wanted to ādarkenā the content behind the button. Applying a simple shadow does the trick:
The css:
<div>
<div style='box-shadow: 0 0 50px 15px rgba(0, 0, 0, 0.9);'>
Shadow around button
</div>
</div>
More about easing gradients
If you just apply greyscale
on a <div>
, it will make all elements inside the <div>
grayscale. If you donāt want to apply greyscale to anything except the main background image, hereās what you can do:
The css:
<div style="box-shadow: inset 0 0 0 100vmax rgba(0,0,0,.3); background-blend-mode: saturation; background-image: linear-gradient(black, black), url('app/assets/images/background.png');">
<div style="color: green;">
Green text over greyscale background image
</div>
</div>
Thatās it! šš„³š¾
]]>Itās been Ā±9 months since I did my first post about pagination & search with Hotwire.
This is my perfected approach, extracted from my latest implementation. Hereās how search + infinite pagination works on my website:
Letās implement something similar!
Install pagy:
bundle add pagy
# config/initializers/pagy.rb
require 'pagy/extras/countless'
# app/helpers/application_helper.rb
module ApplicationHelper
include Pagy::Frontend
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Pagy::Backend
end
Search (without a gem) and pagination (with pagy) in the controller:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
users = User.order(created_at: :desc)
users = users.where('last_name ilike ?', "%#{params[:last_name]}%") if params[:last_name].present?
users = users.where(category: params[:category]) if params[:category].present?
@pagy, @users = pagy_countless(users, items: 5)
end
end
The index view:
turbo_frame
; submit on input/changeturbo_frame
will make a request and respond with index.turbo_stream.erb
# app/views/users/index.html.erb
<%= form_with url: users_path,
method: :get,
data: { turbo_frame: 'results' } do |form| %>
<%= form.text_field :last_name,
placeholder: 'User last_name',
value: params[:last_name],
autocomplete: 'off',
autofocus: true,
oninput: 'this.form.requestSubmit()' %>
<%= form.select :gender,
['male', 'female'],
{ include_blank: 'Category' },
{ onchange: 'this.form.requestSubmit()' } %>
<% end %>
<%= turbo_frame_tag 'results', target: '_top', data: { turbo_action: 'advance' } do %>
<div id="users"></div>
<%= turbo_frame_tag 'pagination',
src: users_path(last_name: params[:last_name],
category: params[:category],
format: :turbo_stream),
loading: :lazy %>
<% end %>
loading: :lazy
on a turbo_frame
means that the request will perform as soon as the element becomes visible in the page. You will replace the empty div with a collection of users, and re-render the pagination turbo_stream under the added users collection:
# app/views/users/index.turbo_stream.erb
<%= turbo_stream.append "users" do %>
<% @users.each do |user| %>
<%= render partial: 'users/user', locals: { user: } %>
<% end %>
<% end %>
<% if @pagy.next.present? %>
<%= turbo_stream.replace "pagination" do %>
<%= turbo_frame_tag "pagination",
src: users_path(page: @pagy.next,
last_name: params[:last_name],
category: params[:category],
format: :turbo_stream),
loading: :lazy %>
<% end %>
<% end %>
This should work well! However we search only by last_name
. Letās add more advanced search: by last_name/first_name/email
. We can easily do such a query with the gem ransack.
bundle add ransack
# app/views/users/index.html.erb
<%= search_form_for @q, data: { turbo_frame: :results } do |f| %>
<%= f.label :last_name_or_body_cont %>
<%= f.search_field :last_name_or_body_cont, autofocus: true, autocomplete: 'off', oninput: 'this.form.requestSubmit()' %>
<% end %>
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@q = User.ransack(params[:q])
@pagy, @users = pagy_countless(@q.result(distinct: true).order(created_at: :asc), items: 2)
end
end
# app/views/users/index.html.erb
<%= turbo_frame_tag :pagination,
loading: :lazy,
src: users_path(format: :turbo_stream, q: params[:q]) %>
# app/views/users/index.turbo_stream.erb
<%= turbo_frame_tag :pagination,
loading: :lazy,
src: users_path(format: :turbo_stream, q: params[:q], page: @pagy.next) %>
ActionController::UnfilteredParameters
If you add params[:q]
to an url, you might get an error unable to convert unpermitted parameters to hash
:
There are 2 ways to fix it.
Option 1: Permit all incoming query params params[:q]&.permit!
:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@q = User.ransack(params[:q]&.permit!)
@pagy, @users = pagy_countless(@q.result(distinct: true).order(created_at: :asc), items: 2)
end
end
However this can be considered not very safe, because a malicious actor could try to dig sensitive data this way.
Option 2: safer approach.
Allow an unsafe hash input in the views with params[:q]&.to_unsafe_h
:
users_path(format: :turbo_stream, q: params[:q]&.to_unsafe_h, page: @pagy.next)
However in the controller you can explicitly state the query params that you want to enable with params.permit
. In this case, we would also need to permit format
:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
search_params = params.permit([:format, q: [:s,:last_name_or_body_cont]], :page)
@q = User.ransack(search_params[:q])
@pagy, @users = pagy_countless(@q.result(distinct: true).order(created_at: :asc), items: 2)
end
end
Final result:
Thatās it! šš„³š¾
]]>This way you can have fewer full-page redirects; donāt have to always render a full edit form.
5 years ago I would have used gem best_in_place for this, but now it can be easily achieved with turbo_frames!
List attributes that you want to be inline-editable
# app/models/customer.rb
class Customer < ApplicationRecord
INLINE_EDITABLE_ATTRS = [:first_name, :last_name, :dob, :tel, :description]
validates :first_name, presence: true
end
Render each inline-editable field inside a separate turbo_frame
. Redirect to the edit_customer_path
. Pass the attribute in the url params attribute: attribute
# app/views/customers/_customer.html.erb
<div id="<%= dom_id customer %>">
<% Customer::INLINE_EDITABLE_ATTRS.each do |attribute| %>
<%= turbo_frame_tag attribute do %>
<p>
<strong><%= attribute %>:</strong>
<%= link_to (customer[attribute].presence || 'Edit'), [:edit, customer, attribute: attribute] %>
</p>
<% end %>
<% end %>
</div>
Render the form with a dynamicly-set turbo_frame
name. Render only the field with a matching name as params[:attribute]
.
# app/views/customers/_inline_attribute_form.html.erb
<%= turbo_frame_tag params[:attribute] do %>
<%= form_with(model: customer, url: [customer, attribute: params[:attribute]], method: :patch) do |form| %>
<% if customer.errors.any? %>
<div style="color: red">
<ul>
<% customer.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<% if params[:attribute].eql? 'first_name' %>
<div>
<%= form.label :first_name, style: "display: block" %>
<%= form.text_field :first_name, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% if params[:attribute].eql? 'last_name' %>
<div>
<%= form.label :last_name, style: "display: block" %>
<%= form.text_field :last_name, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% if params[:attribute].eql? 'dob' %>
<div>
<%= form.label :dob, style: "display: block" %>
<%= form.datetime_field :dob, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% if params[:attribute].eql? 'tel' %>
<div>
<%= form.label :tel, style: "display: block" %>
<%= form.number_field :tel, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% if params[:attribute].eql? 'description' %>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_area :description, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<div>
<%= form.submit %>
</div>
<% end %>
<% end %>
Finally, you can use both the attribute-edit form and the normal form. Just render the normal fomr if no params[:attribute]
is present:
# app/views/customers/edit.html.erb
<% if params[:attribute].present? %>
<%= render "inline_attribute_form", customer: @customer %>
<% else %>
<%= render "form", customer: @customer %>
<% end %>
Thatās it! šš„³š¾
]]>āBackground jobsā or ābackground workersā are a very important concept in software development. You can create complete architecture based on tasks that will perform independently from the client side request-response cycle.
Background jobs are often used for processing heavy tasks that take too long to perform during a typical web request. Additionally, you can use them when the user is not expecting an output immediately.
Jobs can be scheduled to be processed:
Example usecases:
It is also normal for a job to trigger multiple jobs!
Real life scenario: In my app insta2blog.com, I use jobs to:
The Rails feature for processing jobs is called ActiveJob. It is an inbuilt feature, and you have a /jobs
folder in any new Rails app by deafault.
Generate new job:
rails g job MyThing
This job will just log something into the console:
# jobs/my_thing_job.rb
class MyThingJob < ApplicationJob
queue_as :default
def perform(text)
puts "Console message: #{text}"
end
end
Schedule a job for later:
MyThingJob.perform_later
MyThingJob.perform_later(text)
MyThingJob.set(wait: 1.week).perform_later(text)
MyThingJob.set(wait_until: Date.tomorrow.noon).perform_later(text)
MyThingJob.set(queue: :another_queue).perform_later(text)
To handle job processing, you will need an external adapter. The most popular one is Redis-based Sidekiq. You will encounter this solution in most enterprise Rails apps, at mostly any Rails job you have in your career.
However Postgresql adapters are enough for most usecases, and this way wonāt need Redis as an extra dependency. A really good gem for this is good_job.
Add the gem:
bundle add good_job
Use it as the default adapter:
# config/application.rb
class Application < Rails::Application
+ config.active_job.queue_adapter = :good_job
end
Run the good_job worker in your console:
# Procfile.dev
web: bin/rails server -p 3000
css: bin/rails tailwindcss:watch
+ worker: bundle exec good_job start --max-threads=8
Different job execution modes:
# echo > config/initializers/good_job.rb
# config/initializers/good_job.rb
Rails.application.configure do
# dev env: rails server, separate thread
# config.good_job.execution_mode = :async
# test env
# config.good_job.execution_mode = :inline
# config.good_job.inline_execution_respects_schedule = true
# prod env: separate $ worker
# config.good_job.execution_mode = :external
end
You can monitor all past and scheduled jobs in a web GUI. To do so, add the route:
# config/routes.rb
+ mount GoodJob::Engine, at: "good_job"
Voila, now when you visit http://localhost:3000/good_job/, you will have a dashboard:
To restrict unauthorize users from accessing this dashboard in production, you migth want to require HTTP basic authentication:
# config/routes.rb
+ GoodJob::Engine.middleware.use(Rack::Auth::Basic) do |username, password|
+ ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.dig(:http_auth, :username), username) &&
+ ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.dig(:http_auth, :password), password)
+ end
With that, your credentials.yml
file could look something like this:
# config/credentials.yml
http_auth:
username: superails
password: 123
CRON - 5 * * * * *
symbols that represent a recurring period. For example:
Schedule cron with good_job:
# config/initializers/good_job.rb
# show in logs
# STDOUT.sync = true
# $stdout.sync = true
Rails.application.configure do
config.good_job.enable_cron = true
config.good_job.cron = {
every_minute: {
cron: '* * * * *',
class: "MyThingJob"
}
weekly_sunday: {
cron: '0 0 * * 0',
class: "MyThingJob"
}
}
end
# config/initializers/good_job.rb
# exception_notification
+ config.good_job.on_thread_error = -> (exception) { ExceptionNotifier.notify_exception(exception) }
# honeybadger
+ config.good_job.on_thread_error = -> (exception) { Honeybadger.notify(exception) }
# sentry
+ config.good_job.on_thread_error = -> (exception) { Sentry.capture_exception(exception) }
If you are using Digital Ocean App platform, inside your app Create Resource From Source Code
with the same source repository as your main one, but the type should be Worker
.
Run Command for your Web app:
bin/rails db:migrate:with_data
rails server -p $PORT -e ${RAILS_ENV:-production}
Run Command for your Worker:
bundle exec good_job start --max-threads=8
Donāt forget to all all the same ENV VARS, like RAILS_MASTER_KEY
, DATABASE_URL
as you would for your normal Rails app. After deploying, it should start working!
Procfile:
web: bundle exec rails s
worker: bundle exec good_job start
release: bin/rails db:migrate:with_data
After adding a procfile and deploying to heroku, it will create a worker resource in https://dashboard.heroku.com/apps/myapp/resources
. You might need to upgrade it to a paid $7/mo dyno for it to work.
Thatās it! šš„³š¾
]]>Itās a great way to abstract scripts that you can run anywhere from your app. You can run it from any controller action, a model, from a job, or even from another Service Object!
This is a common way to abstract API calls to external services or business logic.
Chances are high, that in any real-life production application you will encounter /app/services/
folder. Common aliases are /app/integrations/
, /app/operations/
.
# app/services/publish_post.rb
class PublishPost
def initialize(post, user)
@post = post
@user = user
end
def call
return if @post.published!
@post.update(user: @user)
@post.published!
end
end
Call the command:
post = Post.first
user = User.first
PublishPost.new(post, user).call
# app/services/posts/publish.rb
require 'abc'
module Posts
class Publish
attr_reader :post, :user
def initialize(post, user)
@post = post
@user = user
end
def call
return unless callable?
post.update(user:)
post.published!
end
private
def callable?
!post.published?
end
end
end
Call the command:
post = Post.first
user = User.first
Posts::Publish.new(post, user).call
Thatās it! šš„³š¾
]]>For the first ever time I saw passwordless login in Slack:
A passwordless authentication flow looks like this:
Iāve implemented passwordless authentication in insta2blog.com, and for now I am super happy with the solution š. Feel free to try it out!
In a way this is a more secure authenication strategy, because there is no compromised password point of failure. It is as secure as your email account.
However to even start using this solution in production, you will need to set up sending emails in production.
It is not hard to create this kind of authentication solution on your own, however I prefer not to reinvent the wheel. Gem passwordless
neatly solves the problem.
Hereās how the authentication (login) flow looks in my app:
Apart of following the official installation guide, here are some of my improvements:
The routes helper
# config/routes.rb
passwordless_for :users
will generate
Prefix Verb URI Pattern Controller#Action
users_sign_in GET /users/sign_in(.:format) passwordless/sessions#new {:authenticatable=>:user, :resource=>:users}
POST /users/sign_in(.:format) passwordless/sessions#create {:authenticatable=>:user, :resource=>:users}
verify_users_sign_in GET /users/sign_in/:id(.:format) passwordless/sessions#show {:authenticatable=>:user, :resource=>:users}
confirm_users_sign_in GET /users/sign_in/:id/:token(.:format) passwordless/sessions#confirm {:authenticatable=>:user, :resource=>:users}
PATCH /users/sign_in/:id(.:format) passwordless/sessions#update {:authenticatable=>:user, :resource=>:users}
users_sign_out GET|DELETE /users/sign_out(.:format) passwordless/sessions#destroy {:authenticatable=>:user, :resource=>:users}
Update user model, enable user creation:
# app/models/user.rb
# add email regex validation
validates :email,
presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
passwordless_with :email
# add this so that users can be created!
# don't add this if you want your app to be invite-only
def self.fetch_resource_for_passwordless(email)
find_or_create_by(email:)
end
Requiring user only for specific actions in a controller:
# app/controllers/posts_controller.rb
before_action :require_user!, only: %i[new create edit update destroy]
Skip user requirement for specific actions:
# app/controllers/posts_controller.rb
skip_before_action :require_user!, only: %i[index show]
Login/Logout links:
# app/views/layouts/application.html.erb
<%= notice %>
<%= alert %>
<% if current_user %>
<%= current_user.email %>
<%= button_to 'Sign out', users_sign_out_path, method: :delete, form: { data: { turbo_confirm: 'Log out?' } } %>
<% else %>
<%= link_to 'Sign in', users_sign_in_path %>
<% end %>
Preview magic link email with Rails ActionMailer previews:
# test/mailers/previews/passwordless_mailer_preview.rb
class PasswordlessMailerPreview < ActionMailer::Preview
# http://localhost:3000/rails/mailers/passwordless_mailer/sign_in
def sign_in
user = User.build(email: 'foo@bar.com')
session = Passwordless::Session.create!(authenticatable: user)
Passwordless::Mailer.sign_in(session)
end
end
To automaticall open previews of sent emails (so that you can confirm a magic link), you will need the gem letter_opener gem:
$ bundle add letter_opener
# /config/environments/development.rb
+ Rails.application.routes.default_url_options[:host] = 'localhost:3000'
Rails.application.configure do
+ config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
+ config.action_mailer.delivery_method = :letter_opener
+ config.action_mailer.perform_deliveries = true
+ config.action_mailer.raise_delivery_errors = true
# config/initializers/passwordless.rb
Passwordless.configure do |config|
config.default_from_address = "login@insta2blog.com"
config.success_redirect_path = '/dashboard'
end
Thatās it!
]]>When you click copy
:
copy
text with copied
2 seconds later:
copied
text with copy
Final result:
Stimulus controller:
source
- content that will be copiedtrigger
- button that gets clicked/replaced// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source", "trigger"]
copy(event) {
event.preventDefault()
navigator.clipboard.writeText(this.sourceTarget.value)
this.sourceTarget.focus()
var triggerElement = this.triggerTarget
var initialHTML = triggerElement.innerHTML
triggerElement.innerHTML = "<span style='color:grey;'>Copied</span>"
setTimeout(() => {
triggerElement.innerHTML = initialHTML
// unfocus
this.sourceTarget.blur()
}, 2000)
}
}
HTML:
<div data-controller="clipboard">
<input data-clipboard-target="source"
class="font-extralight text-xs h-6 rounded-md"
type="text" value="<%= insta_user_post_url(post.insta_user, post) %>" readonly>
<button data-clipboard-target="trigger" data-action="clipboard#copy">Copy</button>
</div>
Thatās it!
]]>When a select dropdown has too many values, it becomes uncomfortable to pick a value.
You would need to add search functionality to the dropdown.
The easies solution might be HTML <datalist>
tag:
However this approach still allows plain-text input, requires input validation and styling.
A better approach would be to use a javascript library. Some years ago my go-to library would be āselectize-jsā, however it reliest on jQuery. And in 2022 you donāt want jQuery as a dependency in your app!
So, now I would prefer to use tom-select or slim-select. Neither of these libaries relies on jQuery!
Boilerplate app where we will add slim-select:
# yarn add slim-select
rails g model user email
rails g scaffold payment amount:integer user:references
rails db:migrate
bundle add faker
rails c
10.times { User.create(email: Faker::Internet.email) }
# add the JS library with importmaps
./bin/importmap pin slim-select
# alternative - add the JS library with yarn
yarn add slim-select
# create stimulus controller that will initialize the JS
rails g stimulus slim
Initialize SlimSelect in the stimulus controller
// app/javascript/controllers/slim_controller.js
import { Controller } from "@hotwired/stimulus"
// add the JS
import SlimSelect from 'slim-select'
// add the CSS
import 'slim-select/dist/slimselect.css'
// import "slim-select/dist/slimselect.min.css";
// Connects to data-controller="slim-select"
export default class extends Controller {
static targets = ['field']
connect() {
new SlimSelect({
select: this.fieldTarget,
// closeOnSelect: false
})
}
}
Initialize the stimulus contorller on a rails field:
# app/views/posts/_form.html.erb
<%= form.select :user_id, User.pluck(:email, :id), {include_blank: true}, {data: { controller: 'slim', slim_target: 'field' } } %>
Result: dropdown select with search using slim-select:
In this scenario, we will add multiple Tags
to a Post
.
Prerequisites:
rails g model tag name
rails g model post_tag post:references tag:references
10.times { Tag.create(name: Faker::Movie.title) }
# app/models/post.rb
has_many :post_tags
has_many :tags, through: :post_tags
# app/models/tag.rb
has_many :post_tags
has_many :posts, through: :post_tags
# app/models/post_tag.rb
belongs_to :post
belongs_to :tag
Whitelist params:
# app/controllers/posts_controller.rb
- params.require(:post).permit(:user_id)
+ params.require(:post).permit(:user_id, tag_ids: [])
Finally, just add the multiple: true
in the html options of the select field:
# app/views/posts/_form.html.erb
-<%= form.select :user_id, User.pluck(:email, :id), {include_blank: true}, {data: { controller: 'slim', slim_target: 'field' } } %>
+<%= form.select :tag_ids, Tag.all.pluck(:name, :id), {}, { multiple: true, data: { controller: 'slim', slim_target: 'field' } } %>
Result: dropdown multi-select with search using slim-select:
If the CSS for the multiselect does not render, you can try including it in a stylesheet:
<!-- app/views/layouts/application.html.erb -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/slim-select/1.27.1/slimselect.min.css" rel="stylesheet" crossorigin="" />
or
# app/views/layouts/application.html.erb
<%= stylesheet_link_tag "https://cdnjs.cloudflare.com/ajax/libs/slim-select/1.27.1/slimselect.min.css", "data-turbo-track": "reload" %>
or
/* app/assets/stylesheets/application.css */
@import url("https://cdnjs.cloudflare.com/ajax/libs/slim-select/1.27.1/slimselect.min.css");
You can also try manually running rails assets:precompile
, if you are not sure that the build happened š¤·
target
You donāt really have to define the target
, since we are adding the controller on the same element!
connect() {
this.select = new SlimSelect({
select: this.element
})
}
disconnect() {
this.select.destroy()
}
-<%= form.select :user_id, User.pluck(:email, :id), {include_blank: true}, {data: { controller: 'slim', slim_target: 'field' } } %>
+<%= form.select :user_id, User.pluck(:email, :id), {include_blank: true}, {data: { controller: 'slim' } } %>
Thatās it!
Resources:
]]>An interesting challenge was receiving a raw text from Instagram Basic Display API, and scanning it for mentions and hashtags, and converting hashtags into URLs:
The below regex searches for words starting with @
and replaces these words with themselves wrapped into some html:
# app/helpers/application_helper.rb
def with_mentions(text)
return nil if text.blank?
text.gsub!(/\S*@(\[[^\]]+\]|\S+)/, '<span style="color: blue;">\1</span>')
end
text = '@yaro is the coolest #ruby programmer in #europe'
with_mentions(text)
# => "<span style=\"color: blue;\">yaro</span> is the coolest #ruby programmer in #europe"
You could try doing it in a simple way as above, but the below approach will give you much more control over the result.
So I will pass the Post
record, scan the the Post.body
for #
, replace each hashtagged word with a link.
If you use the below method in a Rails helper, you wonāt need ActionController::Base.helpers
, Rails.application.routes.url_helpers
, onlypath: true
. Iāve added them so that you can use it in the console.
# app/helpers/application_helper.rb
# delegate :link_to, to: 'ActionController::Base.helpers'
# delegate :posts_path, to: 'Rails.application.routes.url_helpers'
include Rails.application.routes.url_helpers
def with_hashtags(text)
return nil if text.blank?
hashtags = text.scan(/#\w+/)
hashtags.flatten.each do |hashtag|
hashtag_link =
ActionController::Base.helpers.link_to hashtag, posts_path(caption: hashtag, onlypath: true), class: 'hashtag'
text.gsub!(hashtag, hashtag_link)
end
text
end
text = "ŠŃŃŠŗŠ°Ń Š² Š²ŠøŃ
ŃŠ“Š½Ń š #cannes #cotedazur #frenchriviera #france #friday"
with_hashtags(text)
# => "ŠŃŃŠŗŠ°Ń Š² Š²ŠøŃ
ŃŠ“Š½Ń š <a class=\"hashtag\" href=\"/?caption=%23cannes&onlypath=true\">#cannes</a> <a class=\"hashtag\" href=\"/?caption=%23cotedazur&onlypath=true\">#cotedazur</a> <a class=\"hashtag\" href=\"/?caption=%23frenchriviera&onlypath=true\">#frenchriviera</a> <a class=\"hashtag\" href=\"/?caption=%23france&onlypath=true\">#france</a> <a class=\"hashtag\" href=\"/?caption=%23friday&onlypath=true\">#friday</a>"
Hereās the final result in my case: click hashtag -> refresh page with this hashtag in the search bar (search by hashtag):
P.S. here is the pull request where I added this functionality to the app.
]]>Heroku played an important role in my career as a software engineer: it let me easily and quickly deploy applications to production for free. This way I could showcase my app to the world, or compare how an app works locally vs in a production environment. It was so easy for anybody to have a prototype app!
If you wanted to upgrade and turn your free app into a real-world-ready app, you could buy the minimum paid tier for $5/mo (so that your app does not sleep after 30 min of inactivity). To have more than 10,000 lines in your database you would pay an additional $7/mo. So a total minimum of $12/mo to run a production app!
It was Freemium at itās finest!
But in 2022 Salesforce (Heroku owner) decided to kill the free tier. As a result, This will limit thousands of learners from deploying their first app to production.
Without the free tier, I see no point in sticking with Heroku any more for the paid tiers.
My second best choice is DigitalOcean.
It has no free option, but itās minimal paid tier is comparable to the one of Heroku. And it is relatively easy to set up!
In the end, the luxury of āoutsourcingā your DevOps + cloud computing does come at a costā¦
First of all, try to register on DigitalOcean only via a referral link!
That way you can get $200 credits to start with.
After registration, you will have to add a credit card or PayPal to use the platform. IMHO PayPal money is less liquid, so I always prefer to use it first.
After that, (if appliable to you) set the billing address to the USA. That way you will not pay Ā±20% VAT.
An example address:
1601 Millersville Rd, Millersville, MD 21108, United States
Additionally, a coupon code that was valid on 15-Oct-2022 was DO10
. Feel free to try it too.
Before deploying, ensure that your app can run on linux. Otherwise you will get errors:
bundle lock --add-platform x86_64-linux --add-platform ruby
This command will add a line in your Gemfile.lock
. Now commit these changes to git, and letās start!
DigitalOcean App Platform allows you to deploy and manage apps easily, nearly like Heroku.
The cheapest paid tier would be $12/mo.
First, connect a Git repo:
Click on Edit Plan
and select the cheapest one for $5:
To add a $7/mo database, click on Database
, Add
:
Attach postgres cluster:
The $7/mo database can be upgraded later on to a $12/mo production-level database.
Add master.key
as RAILS_MASTER_KEY
, so that the production application can decrypt your credentials.yml
file.
Previously, When you deployed an app to heroku, it would āautomagicallyā add other config vars:
Here are the variables that I usually add to run the app:
# rails app vars
RAILS_ENV=production
RACK_ENV=production
RAILS_LOG_TO_STDOUT=enabled
RAILS_SERVE_STATIC_FILES=enabled
RAILS_MASTER_KEY=d23a37793ce1d23a37793ce1
DATABASE_URL=postgresql://doadmin:wergerge@abc-db-prod-do-user-324243-0.b.db.ondigitalocean.com:25060/defaultdb?sslmode=require
# DATABASE_URL = ${db-postgresql-ams3-147.DATABASE_URL}
# DATABASE_URL = ${db.DATABASE_URL}
Important: add these variables on the rails app-level, not on the DigitalOcean container-level.
If you want to create a managed database (not the āsandboxā one for $7/mo), it will cost $15/month.
To get the DATABASE_URL
, you will need to copy the connection string.
šØ You will need Redis, if you are using Rails 7 Hotwire Broadcasting feature! šØ
You will also need it if you are using Sidekiq and background jobs.
The same way you did with Postgres, you can create and reference a Redis database:
REDIS_URL=rediss://default:abcdef@db-redis-fra1-12345-do-user-123456-0.b.db.ondigitalocean.com:25069
# REDIS_URL=${redis-db.REDIS_URL}
After deplyoment, you can either manually run migrations, or add them to the Run Command:
# Run Command
bin/rails db:migrate
rails server -p $PORT -e ${RAILS_ENV:-production}
Analyzing application logs is usually important in the development lifecycle. It letās you find errors, long queries, popular queriesā¦ So that you can optimize.
IMHO the easiest (and free) way to store and track logs is Papertrail.
After you create an account, go to Settings/Log Destinations and copy the connection URL.
A valid one would look like logs5.papertrailapp.com:30016
.
Paste it into the app config:
Now if you open your logs in papertrail, they will look somewhat like this:
Get CNAMES for yourdomain.com
& www.yourdomain.com
:
And add them in the domain name provider settings:
In a few minutes your domain should be working for your app!
DigitalOcean Apps similar to Heroku apps, but require a bit more config.
DigitalOcean Droplets are like AWS EC2 containers. They are cheaper than Apps, but require even more config (maybe worth deploying via Dockerfile
).
P.S. I am not paid by DigitalOcean for this post š¢
Like an app manifest with all the DigitalOcean settings that I used:
databases:
- cluster_name: i2s-db-prod
engine: PG
name: i2s-db-prod
production: true
size: professional-xs
version: "14"
domains:
- domain: insta2blog.com
type: PRIMARY
- domain: www.insta2blog.com
type: ALIAS
name: lobster-app
region: fra
services:
- environment_slug: ruby-on-rails
envs:
- key: RAILS_ENV
scope: RUN_AND_BUILD_TIME
value: production
- key: RACK_ENV
scope: RUN_AND_BUILD_TIME
value: production
- key: RAILS_LOG_TO_STDOUT
scope: RUN_AND_BUILD_TIME
value: enabled
- key: RAILS_SERVE_STATIC_FILES
scope: RUN_AND_BUILD_TIME
value: enabled
- key: RAILS_MASTER_KEY
scope: RUN_AND_BUILD_TIME
value: d23a37d23a37d23a37d23a37d23a37
- key: DATABASE_URL
scope: RUN_AND_BUILD_TIME
value: postgresql://doadmin:d23a37d23a37@i2s-db-prod-do-user-d23a37-0.b.db.ondigitalocean.com:12345/defaultdb?sslmode=require
github:
branch: main
deploy_on_push: true
repo: yshmarov/insta2blog.com
http_port: 8080
instance_count: 1
instance_size_slug: professional-xs
log_destinations:
- name: paper1
papertrail:
endpoint: syslog+tls://logs6.papertrailapp.com:12345
name: insta2blog-com
routes:
- path: /
run_command: |-
bin/rails db:migrate
rails server -p $PORT -e ${RAILS_ENV:-production}
source_dir: /
Thatās it!
]]>A very common feature request is to receive a Slack messagewhen something happens in the application.
This can be though of as receiving āwebhooksā by your Slack app.
Incoming webhooks are a simple way to post messages from external sources into Slack.
Example notifications:
To implement this kind of functionality, we can use Slack API.
First, create a Slack channel (obviously š¤·āāļø).
Next, visit the Slack API website and create a bot:
Easiest way - to create an app via manifest. Hereās mine:
display_information:
name: message bot
features:
bot_user:
display_name: message bot
always_online: false
oauth_config:
scopes:
bot:
- chat:write
- chat:write.public
- files:write
- im:write
- links:write
- links.embed:write
settings:
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
Alternatively - create an app from scratch.
After creation go to āOAuth & Permissionsā tab:
These are the permissions that I would usually select to send messages:
Invite the bot to your slack workspace:
Allow access:
After that you will granted an API token. Copy it:
Try to connect to the token via the console:
slack --slack-api-token=[token] auth test
# slack --slack-api-token=xoxb-123432423-rgwrgerge-657567 auth test
Works? Now letās make it work with Rails
Add the generated API key to your Rails app credentials:
# credentials.yml
slack:
slack_api_token: xoxb-123432423-rgwrgerge-657567
To comfortably interact with the bot using Ruby, install gem slack-ruby-client:
bundle add slack-ruby-client
Initialize Slack token:
# echo > config/initializers/slack.rb
# config/initializers/slack.rb
Slack.configure do |config|
# config.token = xoxb-123432423-rgwrgerge-657567
config.token = Rails.application.credentials.dig(:slack, :slack_api_token)
end
Connect to Slack API and send your first message on behalf of the ābotā:
client = Slack::Web::Client.new
client.auth_test
client.chat_postMessage(channel: '#general', text: 'Hello World', as_user: true)
Add a service that will allow you to connect to the Slack client in the future:
# mkdir app/services
# echo > app/services/slack_client.rb
# app/services/slack_client.rb
module SlackClient
def client
Slack::Web::Client.new.tap(&:auth_test)
rescue Slack::Web::Api::Errors::NotAuthed
nil
end
module_function :client
end
# now you can use
SlackClient.client.chat_postMessage(channel: '#general', text: 'Hello World', as_user: true)
The main difference between sending inline text and markdown is the inclusion of line breaks.
This can be accomplished with squiggly heredoc (<<~
). This way you can have a string with \n
line breaks:
def text
<<~TEXT
:alert: *#something happened*
`code inline`
and
```
code block
```
that's it!
a link: https://blog.corsego.com
a video: https://www.youtube.com/watch?v=dVbDkWbHX6M
TEXT
end
# => ":alert: *#something happened*\n`code inline`\nand\n```\ncode block\n```\nthat's it!\na link: https://blog.corsego.com\na video: https://www.youtube.com/watch?v=dVbDkWbHX6M\n"
SlackClient.client.chat_postMessage(channel: '#general', text:, as_user: true)
Sending an image from assets:
filename = 'sample-image.png'
file_path = Rails.root.join('app', 'assets', 'images', filename).to_s
file = Faraday::UploadIO.new(file_path, 'image/png')
SlackClient.client.files_upload(
channels: '#general',
as_user: true,
file:,
title: 'file caption',
filename:,
initial_comment: 'normal text above file'
)
Example result:
To send a text file, you would use text/plain
mime type:
file_to_upload = 'test.txt'
Faraday::UploadIO.new(file_to_upload, 'text/plain')
If your app generates a new file, that you want to send, you can:
In the below example I:
gem caxlsx
to export all users created today into an Excel fileapp/assets/csv/filename.xlsx
using IO.binwrite
xls
file to Slack# requires gem caxlsx
def export_daily_users_to_slack
users_for_period = User.where(created_at: Date.today.all_day)
.order(created_at: :desc)
xlsx = ActionController::Base.new.render_to_string(
layout: false,
handlers: [:axlsx],
formats: [:xlsx],
template: 'csv/users',
locals: { users: users_for_period }
)
title = [Time.now.to_s(:dmy), 'Users'].join(' ')
filename = title.concat('.xlsx')
folder_path = Rails.root.join('app', 'assets', 'csv', filename).to_s
FileUtils.mkdir_p 'app/assets/csv'
IO.binwrite(folder_path, xlsx.to_s)
file = Faraday::UploadIO.new(folder_path, 'xlsx')
SlackClient.client.files_upload(
channels: '#general',
as_user: true,
file:,
title:,
filename:,
initial_comment: 'users created in the last 24 hours'
)
end
Example result:
Test slack authentication, message delivery (considering you use rspec
)
# spec/operations/reports/users_csv_report_spec.rb
require 'rails_helper'
RSpec.describe Reports::UsersCsvReport do
subject(:service) { described_class.call }
before do
stub_request(:post, 'https://slack.com/api/auth.test').to_return(status: 200)
stub_request(:post, 'https://slack.com/api/files.upload').to_return(status: 200)
stub_request(:post, "https://slack.com/api/chat.postMessage").to_return(status: 200)
end
it 'calls slack api and logs event' do
service
expect(WebMock).to have_requested(:post, 'https://slack.com/api/files.upload')
expect(WebMock).to have_requested(:post, "https://slack.com/api/chat.postMessage").with(body: "abc")
end
end
Thatās it!
]]>main
branch;Usually to ensure code quality you would use run tests
and linters
locally.
Usually code storage solutions like Github/Gitlab allow you to run these tests in a virtual environment.
That way, you and your team can be sure that the tests are successful before merging a PR without having to re-run the tests on their machines.
The Github tool that allows you to run this sort of scripts in a virtual environment is called Github Actions.
To start using Github Actions on a repo, you would need to add a folder like .github/workflows
in the root directory of your app.
Next, you could add yml
scripts inside the folder. Hereās an example script:
The above script would run rubocop
on your app whenever a pull request is created, or commits are pushed to it and provide a success
/failure
state:
Setting up the CI to run for linters is usally straightforward. You can find the code version of the above script in the article Install and use Rubocop.
The common āChecksā are:
This is usually harder, because it requires running the app.
Common āproblemsā are:
Now letās
rails g controller static_pages landing_page
<!-- app/views/static_pages/landing_page.html.erb -->
<h1>hello world</h1>
# test/system/static_pages_test.rb
require 'application_system_test_case'
class StaticPagesTest < ApplicationSystemTestCase
test 'visiting the homepage' do
visit static_pages_landing_page_url
assert_text 'hello world'
end
end
To run system tests in CI, you will need to use headless chrome
:
# test/application_system_test_case.rb
require 'test_helper'
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
# driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end
Run tests:
# bin/rails test
# bin/rails test:all
bin/rails test --fail-fast
Run system tests:
bin/rails test:system
Running some tests would require having valid credentials.
Hereās how you can add master.key
with a value 34tjk3ngiovv3j409jc34jt90v3q4jt4
to Github Actions using the Github GUI:
(Name should be RAILS_MASTER_KEY
).
It can later be accessed in the CI yaml file as secrets.RAILS_MASTER_KEY
ā¤µļø
This script works for me to install postgres, redis, run tests, re-run seeds.
# .github/workflows/.tests.yml
name: CI
on:
pull_request:
branches:
- "*"
push:
branches:
- main
jobs:
install-cache:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout Commit
uses: actions/checkout@v3
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
test:
runs-on: ubuntu-latest
needs: install-cache
timeout-minutes: 10
services:
postgres:
image: postgres
ports: ["5432:5432"]
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- "6379:6379"
steps:
- name: Checkout Commit
uses: actions/checkout@v3
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install system dependencies
run: |
sudo apt-get -y update
- name: Run tests
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/myapp_test
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: |
bin/rails tailwindcss:build
bin/rails db:create
bin/rails db:schema:load
bin/rails test --fail-fast
bin/rails test:system
- name: Smoke test database seeds
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/myapp_test
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: bundle exec rails db:reset
Finally, here are example CI scripts from open source RoR projects:
Thatās it!
]]>Hereās how you can create an āestimated reading timeā calculator with Ruby.
Letās suppose, that the average reading time is 150 words per minute.
Than we just need to count the words in a text and devide the value by average:
text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
text.split.size
# => 91
def reading_time(text)
word_count = text.split.size
words_per_minute = 150
(word_count.to_f / words_per_minute.to_f).round(1)
end
reading_time(text)
# => 0.6 (minutes)
Thatās it! šš§āš«
]]>Hereās what I came up with for generating radom RGB colors and their opposites:
Considering that an RGB consists of 3 values within 0 and 255, we can just generate these 3 values:
# app/helpers/random_color_helper.rb
module RandomColorHelper
def color_and_opposite
color = random_rgb
opposite_color = invert_rgb(color)
{ color:, opposite_color: }
end
def random_rgb
r = rand(0..255)
g = rand(0..255)
b = rand(0..255)
[r, g, b].join(',')
end
# invert_rgb(random_rgb)
def invert_rgb(color)
color = color.split(',')
color = color.map(&:to_i)
r = 255 - color[0]
g = 255 - color[1]
b = 255 - color[2]
[r, g, b].join(',')
end
end
For a view, be sure to instantiate the color_and_opposite
method from the controller, so that you do it once and get the correct color-opposite pair:
# app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
@color_and_opposite = helpers.color_and_opposite
end
end
Display color and opposite in a view:
<div style="background-color: rgb(<%= @color_and_opposite[:color] %>); color: rgb(<%= @color_and_opposite[:opposite_color] %>)">
<div>
Color: <%= @color_and_opposite[:color] %>
</div>
<div>
Opposite: <%= @color_and_opposite[:opposite_color] %>
</div>
</div>
For a more sophisticated solution you can try using gem color-generator.
Thatās it! š
]]>On the homepage of the app you can see a list of sports games.
Each game has a preview image (thumbnail).
The thumnail should be composed of:
home_team
and guest_team
logosstarts_at
textid
Right now the thumbnail is not one solid image.
It is a composite of images overlaying each other with HTML+CSS:
Now we want to generate an actual image thumbnail for each game and store it in the database.
This way we can utilize the generated image anywhere outside of our app (share on social, print posters, etc.)
To automatically generate and image we will use Rmagick.
To store and attach the image to a record, we will use ActiveStorage.
Rmagick is a Ruby library for interacting with Imagemagick - a powerful open source library for scripting images.
š Final image that we will create:
š Images used:
Stadium:
Team 1 logo:
Team 2 logo:
ā ļø Before continuing, find a way to install rmagick on your machine. ā ļø
The below might not be enough:
bundle add rmagick
For this, we can use the method new
.
š rmagick#new
docs
rails c
require 'RMagick'
include Magick
FileUtils.mkdir_p 'app/assets/images/generator'
# width, height
image = Image.new(256, 64) do |img|
img.background_color = 'red'
end
image.write("app/assets/images/generator/red_rectangle.png")
image = Image.read('canvas:#ff5abb') do |img|
img.size = '700x700'
end.first
image.write("app/assets/images/generator/pink_square.png")
red_rectangle.png
pink_square.png:
For this, we will use the method composite
.
š rmagick#composite
(image overlay) docs
rails c
require 'RMagick'
include Magick
# read existing image
image_path = Rails.root.join('app/assets/images/boys_basketball.png')
image = Magick::Image.read(image_path).first
width = image.columns
height = image.rows
# image.write("app/assets/images/generator/image-basic.png")
# create gradient of the same size as above image
gradient = Magick::Image.read('gradient:rgba(0,0,0,0.7)-rgba(0,0,0,0.0)') do |img|
img.size = "#{width}x#{height}"
end.first
# gradient.write("app/assets/images/generator/gradient.png")
# overlay gradient over image
image = image.composite(gradient, 0, 0, OverCompositeOp)
# image.write("app/assets/images/generator/image-with-gradient.png")
# read another image, overlay image2 over image
image2_path = Rails.root.join('app/assets/images/Kashmere-Fighting-Rams.png')
image2 = Magick::Image.read(image2_path).first
image = image.composite(image2, CenterGravity, -200, 0, OverCompositeOp)
image.write("app/assets/images/generator/image-above-image-with-gradient.png")
image-above-image-with-gradient.png:
notice that the top is darker than the bottom;
notice the avatar 200px to the left from the center;
So, for composite
you specify:
All available gravity
options:
For this, we will use the methods annotate
with Draw
:
rails c
require 'RMagick'
include Magick
# create a canvas to write on
image = Image.new(512, 256) do |img|
img.background_color = '#FFDD00'
end
text = Magick::Draw.new
text.fill = '#0057B7'
text.pointsize = 48
text.gravity = CenterGravity
# 50px up Y axis = "-pt-5
text.annotate(image, 0, 0, 0, -50, 'Glory to Ukraine')
text.gravity = SouthEastGravity
text.pointsize = 16
# 5px padding X (left) and Y (top) = "pl-5 pt-5"
text.annotate(image, 0, 0, 5, 5, 'by @yarotheslav')
image.write('ukraine.png')
ukraine.png
Consider that the padding is calculate from gravity center š
annotate
also allows to assign text params in a block:
input_text = 'Glory to Ukraine'
# 0, 0, 0, 50 = width, height, x, y
text.annotate(image, 0, 0, 0, 50, input_text) do |txt|
txt.pointsize = 48
# txt.font_weight = BoldWeight
txt.font_weight = NormalWeight
txt.fill = '#ff5abb'
txt.gravity = CenterGravity
end
rails c
require 'RMagick'
include Magick
image_path = Rails.root.join('app/assets/images/boys_basketball.png')
image = Image.read(image_path).first
# => PNG 3335x2500 3335x2500+0+0 DirectClass 8-bit 825kb
smaller_image = image.scale(0.5)
# => PNG 3335x2500=>1668x1250 1668x1250+0+0 DirectClass 8-bit
larger_image = image.scale(1.75)
# => PNG 3335x2500=>5836x4375 5836x4375+0+0 DirectClass 8-bit
px_scaled_image = image.scale(300, 100)
# => PNG 3335x2500=>300x200 300x200+0+0 DirectClass 8-bit
px_scaled_image.write("app/assets/images/generator/scaled-image.png")
disproportionally scaled image:
proportionally scaled image with calculation:
image_path = Rails.root.join('app/assets/images/boys_basketball.png')
image = Image.read(image_path).first
# calculation
width = image.columns
height = image.rows
new_width = 100
new_height = height*new_width/width
# => 74
proportionally_scaled_image = image.scale(new_width, new_height)
proportionally_scaled_image.write("app/assets/images/generator/proportionally_scaled_image.png")
Actually, you donāt need to do the above calculation if you use resize_to_fit
.
Just specify one aspect ration based on which you want to scale the other one:
image.resize_to_fit(0, 75)
# => PNG 3335x2500=>100x75
image.resize_to_fit(100, 0)
# => PNG 3335x2500=>100x75
rails c
require 'RMagick'
include Magick
image_path = Rails.root.join('app/assets/images/boys_basketball.png')
image = Image.read(image_path).first
# initial image was too big. scale it to something standard
image = image.resize_to_fit(1668, 0)
# central crop
central_focus_crop = image.crop(CenterGravity, 800, 418, true)
central_focus_crop.write('central_focus_crop.png')
The true
in the end is very important, as it forces further manipulations to be based on the new size.
The image was very big initially, so we scaled it down.
Next, we focused on the center of the image and cropped 800px width and 418px height (the ideal twitter size).
This kind of crop can help you standartize cropping images while always keeping focus on the center:
bin/rails g scaffold game starts_at:datetime sport home_team visiting_team
bin/rails active_storage:install
bin/rails db:migrate
# app/services/generate_thumbnail_service.rb
# event = Event.create(title: 'Madison Square Garden', description: 'VS')
# GenerateThumbnail.new(event)
class GenerateThumbnailService
require 'RMagick'
include Magick
attr_reader :event
def initialize(event)
@event = event
call
end
def call
filename = create_image(event)
# find & attach generated tmp image
image_file_io = File.open("app/assets/images/generator/#{filename}.png")
event.thumbnail.attach(io: image_file_io, filename:, content_type: 'image/png')
# delete tmp file
File.delete(image_file_io)
end
private
def create_image(event)
image_path = Rails.root.join('app/assets/images/boys_basketball.png')
image = Magick::Image.read(image_path).first
image = image.resize_to_fit(1000, 0)
image = image.crop(CenterGravity, 800, 418, true)
hero1_path = Rails.root.join('app/assets/images/Kashmere-Fighting-Rams.png')
hero1 = Magick::Image.read(hero1_path).first
image = image.composite(hero1, CenterGravity, -200, 0, OverCompositeOp)
hero2_path = Rails.root.join('app/assets/images/Bellaire-Cardinals.png')
hero2 = Magick::Image.read(hero2_path).first
image = image.composite(hero2, CenterGravity, 200, 0, OverCompositeOp)
text = Magick::Draw.new
input_text = event.title.truncate(50)
text.annotate(image, 0, 0, 0, 10, input_text) do |txt|
txt.pointsize = 48
txt.font_weight = BoldWeight
txt.fill = '#ff5abb'
txt.gravity = NorthGravity
end
input_text = event.description
text.annotate(image, 0, 0, 0, 0, input_text) do |txt|
txt.pointsize = 32
txt.font_weight = NormalWeight
txt.fill = 'white'
txt.gravity = CenterGravity
end
input_text = event.created_at.to_s
text.annotate(image, 0, 0, 5, 5, input_text) do |txt|
txt.pointsize = 32
txt.font_weight = NormalWeight
txt.fill = 'white'
txt.gravity = SouthWestGravity
end
input_text = event.id.to_s
text.annotate(image, 0, 0, 5, 5, input_text) do |txt|
txt.pointsize = 32
txt.font_weight = NormalWeight
txt.fill = 'white'
txt.gravity = NorthWestGravity
end
filename = [event.model_name.human, event.id].join.downcase
FileUtils.mkdir_p 'app/assets/images/generator'
image.write("app/assets/images/generator/#{filename}.png")
filename
end
end
When an event
is created, generate and attach the thumbnail:
# app/models/event.rb
class Event < ApplicationRecord
has_one_attached :thumbnail, dependent: :destroy
after_create do
GenerateThumbnailService.new(self)
end
end
Display the generated image in a view:
# app/views/events/_event.html.erb
<% if event.thumbnail.attached? %>
<%= image_tag event.thumbnail %>
<%#= event.thumbnail.blob.key %>
<% end %>
Result:
Other rmagick topics worth learning (that are not covered here):
Useful resources:
time
field I recommend not storing time, but instead using a datetime
field.
That way you wonāt encounter many unexpected problems.
But if you do have to store time like 19:30:00
, you might as well store it in integer
.
Hereās how you can convert integer
to hh:mm:ss
For example, follow along to see how to convert 54,234 seconds to hours, minutes, and seconds.
def seconds_to_time(seconds)
# t = 236 # seconds
Time.at(seconds).utc.strftime('%H:%M:%S')
# => "00:03:56"
end
def time_to_seconds(time)
# t = '00:03:56'
h = time.split(':').first.to_f
m = time.split(':').second.to_f
s = time.split(':').third.to_f
tts = (h * 60 * 60) + (m * 60) + s
tts.to_i
# => 236
end
# First, find the number of whole hours
now = 54234
hours = now / 3600
# => 15
# Find the number of whole minutes
raw_hours = now / 3600.to_f
# => 15.065
raw_minutes = raw_hours - hours
# => 0.0649
full_minutes = raw_minutes * 60
# => 3.89
minutes = full_minutes.to_i
# => 3
# Find the remaining seconds
raw_seconds = full_minutes - minutes
# => 0.89
seconds = (raw_seconds * 60).round
# => 54
# Finish up by rewriting as HH:MM:SS
time = [hours, minutes, seconds].join(':')
# => "15:3:54"
Thatās it!
]]>However, you donāt want to let the user navigate to a previous url that is from another website.
Hereās how I prevent
Determine if the URL is from the current website:
def internal_request?
previous_url = request.referrer
return false if previous_url.blank?
referrer = URI.parse(previous_url)
return true if referrer.host == request.host
false
end
Hide link āBackā if the request is not from current website:
if internal_request?
# link_to 'Back', 'javascript:history.back()'
link_to 'Back', request.referrer
end
Thatās it!
]]>Over the last 5 years Iāve participated in ~15 events (and won Ā±7 of them)
How hackathons/startup_weekends work:
1ļøā£Ā on Friday you gather a team, decide on an idea, start work. Each event usually has a āthemeā (legaltech/medtech/fintech/fashion etc.)
2ļøā£Ā on Saturday you work on the idea, get feedback from mentors
3ļøā£Ā on Sunday you prepare the MVP, the presentation, and finally pitch the idea on stage; possibly win a prize!
ā¦ and on Monday you most likely never come back to that idea šµ
Some project highlights that Iāve worked on:
š sunorwind/gRoof, 2017
š Project summary: enter your home address to get calculations on savings & costs of installing solar/wind energy/green roof.
It was organized as part of Nasa Space Apps Challenge.
That was my first victory!
Prize - paid trip to visit š©ļøĀ AirBus in Toulouse, š«š·.
With my great teammates - Emil, Kasia and Mateusz:
š Ā LastPost*, 2017
š Project summary: will/testament on the blockchain.
Prize - $2500/8 people š¤Ŗ.
š„Donna* 2019
āHackyeahā - annual hackathon in Poland with 2500 participants.
The Prime Minister of šµš±Ā came to visit and I took a picture with him!
Prize - $750/4 people.
š Project summary: Chatbot to answer FAQ for employees of an organization.
š„Screen Notary 2019
š Project summary: store a webpage screenshot on blockchain, certifying that this is what it looked like at a moment in time (so that it can be used in legal proceedings).
Dream team: Me, wife, sister, sisters BF šØāš©āš§āš¦
š„Ā Intelilex 2019
š Project summary: Microsoft Word autocomplete plugin. Biggest success, longest ride.
Youtube video: intelilex (we paid Ā±$1000 for this video).
In spring 2022 we sold our company for a 4-digit $ number / 7 people š°š°š°
š„Easygration 2020
Hackathon organizaed by WoltersKluwer in the last days befor COVID lockdown in March 2020.
š Project summary: Turning immigration law 2 code and automating immigration processā.
š„š½ļø āStepByStep Menuā, May 2022
My first hackathon after COVID!
š Project summary: QR menu, ordering, payment, live queue (provide the McDonalds ordering experience to any restaurant).
š„āļø āVeriCertiā June 2022
š Project summary: bulk Linkedin certificate generator
Why do I keep participating???
I think that visiting hackathons is a great way to š¤Ā get acquainted with like-minded people, š©āš»try to create something over weekend, š”Ā validate ideas, š¤Ā pitch practice.
šĀ Itās always a fun experience to live through!
If youāre interested in this kind of experience - Hereās a š calendar of startup weekends around the globe.
With love, Yaro š»
]]>It might be because when you open the page, it first loads a cached version of the page, and than loads the content.
It can be easily reproduced by adding a stimulus controller that logs connect
& disconnect
, and initializing the controller on a page.
It happens when clicking a link_to
or navigating back in browser history via javascript:history.back()
.
<!-- app/views/posts/show.html.erb -->
<div data-controller="hello"></div>
// app/javascript/hello_controller.js
import { Controller } from 'stimulus'
export default class extends Controller {
connect() {
console.log('hello on')
}
disconnect() {
console.log('hello off')
}
}
In some cases you might want to prevent this double-loading.
Solution #1
# app/views/posts/index.html.erb
-<%= link_to 'Posts', posts_path %>
+<%= link_to 'Posts', posts_path, data: { turbo: false } %>
This will prevent double-loading only when clicking a link. Not when clicking through cached browser history.
Solution #2
# with gem meta-tags
# app/controllers/posts_controller.rb
def index
@posts = Post.all
+ set_meta_tags 'turbo-cache-control' => 'no-cache'
end
This is a general solution that will cause a whole page reload each time you visit.
Thatās it!
]]># terminal
bundle add swearjar
# app/models/message.rb
def moderated_body
Swearjar.default.censor(body)
end
# app/views/messages/_message.html.erb
-<%= message.body %>
+<%= message.moderated_body %>
Result:
Thatās it!
]]>*.html.erb
tends to get ugly and inconsistent.
Luckily, Shopify has created some good static-code-analysis libraries, that scan your ERB or HTML and show/correct style errors.
Think āRubocop for *.html.erb
ā:
At work I use only the erb-lint
one.
Hereās how you can install, configure, and use the erb-lint
gem:
# Gemfile
group :development, :test do
gem "erb_lint", require: false
end
# console
echo > .erb-lint.yml
To run ERB Lint locally, use any one of the following:
# find issues
bundle exec erblint --lint-all
# find issues and autocorrect
bundle exec erblint --lint-all --autocorrect
bundle exec erblint -la -a
My erb linter looks like this:
# .erb-lint.yml
---
EnableDefaultLinters: false
linters:
Rubocop:
enabled: true
exclude:
- "**/vendor/**/*"
- "**/vendor/**/.*"
- "bin/**"
- "db/**/*"
- "spec/**/*"
- "config/**/*"
- "node_modules/**/*"
rubocop_config:
inherit_from:
- .rubocop.yml
Layout/InitialIndentation:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: false
Layout/TrailingWhitespace:
Enabled: false
Naming/FileName:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Layout/LineLength:
Enabled: false
Lint/UselessAssignment:
Enabled: false
Layout/FirstHashElementIndentation:
Enabled: false
You can find how to set up Github CI/CD to run the linter in this post: Rubocop with Github Actions
]]>Thatās a great appraoch, but not always will you want a separate route-controller-view for each tab.
Sometimes JS tabs are just enough.
Now, Iām not a JS god, but I present you with my minimalistic approach to handling tabs with StimulusJS.
How it works:
HOWTO:
rails g stimulus tabs
install stimulus-use
to add the āclick outside to close tab(s) behaviour:
bin/importmap pin stimulus-use
// app/javascript/controllers/tabs_controller.js
import { Controller } from "@hotwired/stimulus"
import { useClickOutside } from "stimulus-use";
// Connects to data-controller="tabs"
export default class extends Controller {
static targets = ["btn", "tab"]
static values = { defaultTab: String }
connect() {
this.tabTargets.map(x => x.hidden = true) // hide all tabs by default
// OPEN DEFAULT TAB
try {
let selectedBtn = this.btnTargets.find(element => element.id === this.defaultTabValue)
let selectedTab = this.tabTargets.find(element => element.id === this.defaultTabValue)
selectedTab.hidden = false
selectedBtn.classList.add("active")
} catch { }
useClickOutside(this)
}
select(event) {
// find tab with same id as clicked btn
let selectedTab = this.tabTargets.find(element => element.id === event.currentTarget.id)
if (selectedTab.hidden) {
// CLOSE CURRENT TAB
this.tabTargets.map(x => x.hidden = true) // hide all tabs
this.btnTargets.map(x => x.classList.remove("active")) // deactive all btns
selectedTab.hidden = false // show current tab
event.currentTarget.classList.add("active") // active current btn
} else {
// OPEN CURRENT TAB
this.tabTargets.map(x => x.hidden = true) // hide all tabs
this.btnTargets.map(x => x.classList.remove("active")) // deactive all btns
selectedTab.hidden = true // hide current tab
event.currentTarget.classList.remove("active") // deactive current btn
}
}
clickOutside() {
this.tabTargets.forEach(x => x.classList.add("hidden")); // hide all tabs
this.btnTargets.forEach(x => x.classList.remove("active")); // deactivate all btns
}
}
.active
class:/* app/assets/stylesheets/application.css */
.active {
color: blue;
}
data-tabs-default-tab-value="two"
- optional default open tabbutton
must have a target="btn"
and action="click->tabs#select"
tab
must have a target="tab"
button
-tab
combination must have the same id
<div data-controller="tabs" data-tabs-default-tab-value="two">
<button type="button" id="one" data-tabs-target="btn" data-action="click->tabs#select">UK</button>
<button type="button" id="two" data-tabs-target="btn" data-action="click->tabs#select">France</button>
<button type="button" id="abc" data-tabs-target="btn" data-action="click->tabs#select">Ukraine</button>
<div data-tabs-target="tab" id="one">
London, Glasgow
</div>
<div data-tabs-target="tab" id="two">
Paris, Lyon
</div>
<div data-tabs-target="tab" id="abc">
Kyiv, Lviv
</div>
</div>
If you know a better solution or if you can improve this controller please comment below.
Thatās it!
]]>We can export to CSV without any external gems, because Ruby has an in-built CSV processor.
I see two good approaches to generating CSV:
*.csv.erb
template without Ruby::CSV
(elementary approach)A rails app can respond to format csv by default.
We can create a template with some data formatted as CSV lines and have a link to download the rendered page.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.all
respond_to do |format|
format.html
format.csv
end
end
# app/views/users/index.hmtl.erb
<%= link_to "CSV export", users_path(format: :csv), download: ['Users', Date.today].join(' ') %>
# app/views/users/index.csv.erb
# send all fields
<% fields = [:id, :email, :last_name] %>
<%= fields.map(&:to_s).join(";") %>
<% @users.each do |user| %>
<%= fields.map { |field| user[field].to_s }.join(";") %>
<% end %>
# or send selected fields
<%= User.column_names.map(&:to_sym).join(";") %>
<% @users.each do |user| %>
<%= user.attributes.values_at(*User.column_names).join(";") %>
<% end %>
This approach requires quite a lot of data transformation.
*.csv.erb
template with Ruby::CSV
(more correct approach)Instead of transforming data in the above template, we can let Ruby::CSV
handle the generation of correctly formatted CSV lines.
# app/views/users/index.hmtl.erb
<%= link_to 'Export Users', users_path(format: :csv) %>
# app/controllers/users_controller.rb
class UsersController < ApplicationController
require 'csv'
def index
@users = User.all
respond_to do |format|
format.html
format.csv do
filename = ['Users', Date.today].join(' ')
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv"
render template: 'users/index'
end
end
end
# app/views/users/index.csv.erb
<%- headers = ['Last Name', 'Email'] -%>
<%= CSV.generate_line headers %>
<%- @users.each do |user| -%>
<%= CSV.generate_line([user.last_name, user.email]) -%>
<%- end -%>
This approach is more pure/less hacky.
Use
[send_file
method](https://api.rubyonrails.org/v6.1.4/classes/ActionController/DataStreaming.html]
to assign user attributes that should be present in the generated CSV:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
require 'csv'
def index
@users = User.all
respond_to do |format|
format.html
format.csv do
filename = ['Users', Date.today].join(' ')
send_data User.to_csv(@users), filename:, content_type: 'text/csv'
end
end
end
Add to_csv
method to the model
# app/models/user.rb
class User < ApplicationRecord
def self.to_csv(collection)
CSV.generate(col_sep: ';') do |csv|
csv << column_names
collection.find_each do |record|
csv << record.attributes.values
end
end
end
end
I think this is a great approach to export all records and their attributes without messing with templates.
To make the #3 approach more scalable, we can extract the to_csv
into a model convern that can be shared across different models:
module GenerateCsv
extend ActiveSupport::Concern
require 'csv'
class_methods do
def to_csv(collection)
CSV.generate(col_sep: ';') do |csv|
# csv << attribute_names
csv << column_names
collection.find_each do |record|
csv << record.attributes.values
end
end
end
end
end
# app/models/user.rb
class User < ApplicationRecord
include GenerateCsv
end
Thatās it!
]]>We donāt need a gem for that, because CSV processing is inbuilt in the Ruby language: Official Ruby CSV documentation
rails g scaffold user username email name surname phone preferences
First, create a route for the import:
# app/config/routes.rb
resources :users do
collection do
post :import
end
end
Now we can have a form that will accept only CSV files:
# app/views/users/_import.html.erb
<%= form_tag import_users_path, method: :post, multipart: true do %>
<%= file_field_tag :file, accept: ".csv" %>
<%= submit_tag 'āļø Import' %>
<% end %>
Render the _import
partial in any view:
# app/views/users/index.html.erb
<%= render "import" %>
Handle the form submission in the controller.
# app/controllers/users_controller.rb
def import
return redirect_to request.referer, notice: 'No file added' if params[:file].nil?
return redirect_to request.referer, notice: 'Only CSV files allowed' unless params[:file].content_type == 'text/csv'
CsvImportService.new.call(params[:file])
redirect_to request.referer, notice: 'Import started...'
end
There will be a lot of CSV-related logic, so itās better to extract it into a Service
:
# app/services/csv_import_service.rb
class CsvImportService
require 'csv'
def call(file)
opened_file = File.open(file)
options = { headers: true, col_sep: ';' }
CSV.foreach(opened_file, **options) do |row|
# map the CSV columns to your database columns
user_hash = {}
user_hash[:email] = row['Email Address']
user_hash[:username] = user_hash[:email].split('@').first
user_hash[:name] = row['First Name']
user_hash[:surname] = row['Last Name']
user_hash[:preferences] = row['Favorite Food']
user_hash[:phone] = row['Mobile phone number']
User.find_or_create_by!(user_hash)
# for performance, you could create a separate job to import each user
# CsvImportJob.perform_later(user_hash)
end
end
end
Result:
Very useful CSV commands that I discovered along the way:
# csv inside file block
File.open(file) do |file|
CSV.parse(file, headers: true, col_sep: ";")
end
# open a file
file = File.read(file) => string
file = File.open(file) => object
csv = CSV.parse(file, headers: true, col_sep: ";")
options = { headers: true, col_sep: ";" }
csv = CSV.parse(file, **options)
csv = CSV.open(file, **options)
csv = CSV.read(file.path, **options) # alternative - no need to read/open file
kv_hash_csv = csv.each.to_a.compact
kv_hash = csv.map(&:to_h)
csv.headers
# gets header values (first row)
csv.each { |row| p row }
csv.each { |row| p row.to_hash }
# manipulate a row
csv.each do |row|
row
row.to_hash
row.headers
row.fields
# option 1 (if CSV headers = User.attributes)
row_hash = row.to_hash
User.find_or_create_by!(row_hash)
# option 2
email = row['Email Address']
username = email.split('@').first
name = row['First Name']
surname = row['Last Name']
preferences = row['Favorite Food']
phone = row['Mobile phone number']
User.find_or_create_by!(email:, username:, name:, surname:, preferences:, phone:)
# option 3 (best)
user_hash = Hash.new
user_hash[:email] = row['Email Address']
user_hash[:username] = user_hash[:email].split('@').first
user_hash[:name] = row['First Name']
user_hash[:surname] = row['Last Name']
user_hash[:preferences] = row['Favorite Food']
user_hash[:phone] = row['Mobile phone number']
# user_hash[:abc] = "xyz"
User.find_or_create_by!(user_hash)
end
Alternatively to find_or_create_by
you might sometimes want to use
upsert
or
upsert_all
to find existing records by a unique key and update their attributes based on the CSV.
Thatās it! P.S. Kudos @secretpray
]]>š¤ What if we leverage turbo_frames
src
and load each partial from a collection as a separate request?
This way each partial can be loaded in a separate request at itās own pace without slowing the whole page down.
# app/views/posts/index.html.erb
<% @posts.each do |post| %>
- <%= render partial: "posts/post", locals:{ post: post } %>
+ <%= turbo_frame_tag post, src: post_path(post), loading: :lazy, turbo_frame: "_top" do %>
+ Loading...
+ <% end %>
<% end %>
Wrap the post view into a turbo_frame_tag
:
# app/views/posts/show.html.erb
+<%= turbo_frame_tag post do %>
<%= post.title %>
<%= post.body %>
+<% end %>
Finally, instead of rendering the template show.html.erb
we will use the action to render the _post.html.erb
partial:
# app/controllers/posts_controller.rb
def index
@posts = Post.all
end
def show
post = Post.find(params[:id])
render partial: 'posts/post', locals: { post: }
end
Result:
Before: Sending 1 request:
After: Sending 101 requests:
āļøš¤ So thereās the question: 1
heavy request OR 100
light requests?
If you want to reserve the #show
action for the default use (rendering show.html.erb
), consider generating a separate route for the turbo_frame-loaded _post partial.
To increase loading performance even more, the next step would be to replace the partial with a ViewComponent, as ViewComponents load faster than partials.
]]>Select all
, Deselect all
:
# terminal
rails g stimulus checkbox-select-all
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="checkbox-select-parent"
export default class extends Controller {
static targets = ["parent", "child"]
connect() {
// set all to false on page refresh
this.childTargets.map(x => x.checked = false)
this.parentTarget.checked = false
}
toggleChildren() {
if (this.parentTarget.checked) {
this.childTargets.map(x => x.checked = true)
// this.childTargets.forEach((child) => {
// child.checked = true
// })
} else {
this.childTargets.map(x => x.checked = false)
}
}
toggleParent() {
if (this.childTargets.map(x => x.checked).includes(false)) {
this.parentTarget.checked = false
} else {
this.parentTarget.checked = true
}
}
}
In the HTML:
data-controller="checkbox-select-all"
around all the checkboxesdata-checkbox-select-all-target="parent"
data-action="change->checkbox-select-all#toggleChildren"
data-checkbox-select-all-target="child"
data-action="change->checkbox-select-all#toggleParent"
id
, name
, value
<!-- html -->
<div data-controller="checkbox-select-all">
<input type="checkbox" id="all" name="all" value="all" data-checkbox-select-all-target="parent" data-action="change->checkbox-select-all#toggleChildren">
<label for="all"> Select all</label><br>
<input type="checkbox" id="vehicle1" name="vehicle1" value="Bike" data-checkbox-select-all-target="child" data-action="change->checkbox-select-all#toggleParent">
<label for="vehicle1"> I have a bike</label><br>
<input type="checkbox" id="vehicle2" name="vehicle2" value="Car" data-checkbox-select-all-target="child" data-action="change->checkbox-select-all#toggleParent">
<label for="vehicle2"> I have a car</label><br>
<input type="checkbox" id="vehicle3" name="vehicle3" value="Boat" data-checkbox-select-all-target="child" data-action="change->checkbox-select-all#toggleParent">
<label for="vehicle3"> I have a boat</label><br><br>
</div>
That will work.
Hereās how you can do the same using rails check_box_tag
:
+<div data-controller="checkbox-select-all">
+ <%= label_tag "select all" %>
+ <%= check_box_tag "select all", nil, nil, { data: { checkbox_select_all_target: "parent", action: "change->checkbox-select-all#toggleChildren" } } %>
<%= form_with url: bulk_update_users_path, method: :patch, id: :bulk_actions_form do |form| %>
<%= form.submit "active" %>
<%= form.submit "disabled" %>
<% end %>
<%= render partial: "user", collection: @users %>
+</div>
+<%= check_box_tag "user_ids[]", user.id, nil, { data: { checkbox_select_all_target: "child", action: "change->checkbox-select-all#toggleParent" }, multiple: true, form: :bulk_actions_form } %>
Thatās it!
]]>Typical bulk actions are delete
/assign
/change_status
/bookmark
.
In this example we will allow a user to select multiple records and change their status between active
/disabled
.
Initial app setup:
# terminal
bundle add faker
rails g scaffold user email status
rails db:migrate
rails c
10.times { User.create(email: Faker::Internet.email, status: [:active, :disabled].sample) }
Enum status to easily get all active/disabled users, use a bang method to mark user active
/disabled
:
# app/models/user.rb
class User < ApplicationRecord
enum status: { active: 'active', disabled: 'disabled' }
# User.active
# User.first.active!
end
Add a route to handle bulk actions:
# config/routes.rb
resources :users do
collection do
patch :bulk_action # bulk_action_users_path
end
end
Add a form with a unique id
and route it to bulk_action_users_path
.
data: {controller: "form-reset"})
- so that old selected checkboxes are not checked on page refresh. See how to use form_reset_controller.js
here.
IMPORTANT: Different form buttons can send a different param to the controller. Based on this you will be able to handle different bulk actions:
form.button type: :submit, value: :active
=> params[:button] == 'active'
form.submit "disabled"
=> params[:commit] == "disabled"
# app/views/users/index.html.erb
<p style="color: green"><%= notice %></p>
<h1>Users</h1>
<%= @users.active.count %>
<%= @users.disabled.count %>
<%= form_with(url: bulk_action_users_path, id: :bulk_actions_form, method: :patch, data: {controller: "form-reset"}) do |form| %>
<%#= form.submit "active" %>
<%#= form.submit "disabled" %>
<%= form.button type: :submit, value: :active do %>
Activate selected
<% end %>
<%= form.button type: :submit, value: :disabled do %>
Disable selected
<% end %>
<% end %>
<%= render partial: "user", collection: @users %>
Add a check_box_tag
to the user partial:
user_ids[]
- on form submit we will pass params(:user_ids)
multiple: true
- allow multiple items to be checkedform: :bulk_actions_form
- it will submit to the above form# app/views/users/_user.html.erb
<div id="<%= dom_id user %>">
<%= check_box_tag "user_ids[]",
user.id,
nil,
{
multiple: true,
form: :bulk_actions_form,
checked: false
} %>
<%= user.status %>
<%= user.email %>
</div>
Finally, either disable
or activate
selected users based on which form button was clicked:
class UsersController < ApplicationController
def index
@users = User.all
end
def bulk_action
# find users
@selected_users = User.where(id: params.fetch(:user_ids, []).compact)
# update
@selected_users.update_all(status: :active) if mass_active?
@selected_users.each { |u| u.disabled! } if mass_disabled?
# redirect
flash[:notice] = "#{@selected_users.count} users: #{params[:button]}"
redirect_to action: :index
end
private
def mass_active?
params[:button] == 'active'
# params[:commit] == "active"
end
def mass_disabled?
params[:button] == 'disabled'
# params[:commit] == "disabled"
end
end
Final result:
Relevant reading:
First, letās mimic a current_user
without installing Devise
:
# Terminal
rails g model User session:string
rails db:migrate
class ApplicationController < ActionController::Base
helper_method :current_user
def current_user
@current_user ||= User.find_or_create_by(session: session.id.to_s)
end
end
Next, lets add a Movies table:
# Terminal
./bin/rails g scaffold movie title
rails db:migrate
bundle add faker
rails c
20.times { Movie.create!(title: Faker::Movie.title) }
Install kredis:
# Terminal
./bin/bundle add kredis
./bin/rails kredis:install
Kredis.unique_list
is basically an array with a max number of elements:
ab = Kredis.unique_list("recent")
ab.limit = 5
ab.elements
# []
ab.prepend(Movie.first.id)
# ["1"]
ab.prepend(Movie.last.id)
# ["1", "20"]
ab.elements
# ["1", "20"]
ab.prepend(20)
# ["20", "1"]
ab.remove(["20", "1"])
# []
Add a kredis column association to User
:
# app/models/user.rb
class User < ApplicationRecord
kredis_unique_list :recent_movies, limit: 5
end
Next, when the user opens the page, prepend
(add to start of list) the movie.id
to current_user.recent_movies
:
# app/controllers/movies_controller.rb
class MoviesController < ApplicationController
def show
@movie = Movie.find(params[:id])
current_user.recent_movies.prepend(@movie.id)
# current_user&.recent_movies.clear # clear array
# current_user.recent_movies.remove(current_user.recent_movies.elements) # stupid clear array
end
end
Display the list:
# app/views/users/_recently_opened_movies.html.erb
<% current_user.recent_movies.elements.each do |movie| %>
<% movie = Movie.find(movie) %>
<%= link_to movie.title, movie_path(movie) %>
<% end %>
Thatās it!
]]>postgresql
or mysql
in a Rails app.
We also would normally add Redis
as a database for background job processors like Sidekiq.
As of Rails 7, we require Redis
to be able to process Turbo Broadcasts
.
Also, now when we start a new Rails 7 app, a new
gem 'kredis'
is recommended in the Gemfile.
The gem allows us to access Redis via ActiveRecord and easily write/read data on Redis.
Butā¦ when and what should we store in Redis?
Postgres - a normal relational database stores data on disc and expects most of the data not to be frequently read. Long-term data storage.
Redis - an in-memory database - data is faster accessible. Short-term data storage.
Redis is good to use for data that should be easily retrievable and losable.
To try it out, first install the gem
preferences = Kredis.hash('preferences')
# <Kredis::Types::Hash:0x0000000107514368
preferences.keys
# []
preferences.update(view: 'card')
# 1
preferences.keys
# ["view"]
preferences['view']
# "card"
preferences.delete('view')
# 1
preferences.keys
# []
s = Kredis.string "mystr"
s.value = "abc"
s.value
# "abc"
i = Kredis.integer "myint"
i.value = 5
i.value
# 5
And importantly, you can āassociateā a redis column with an ActiveRecord object:
# app/models/user.rb
class User < ApplicationRecord
kredis_hash :preferences
end
User.first.preferences.update(color_mode: "dark")
User.first.preferences
# => [color_mode: "dark"]
User.first.preferences[:color_mode]
# => dark
This opens us a to a wide range of opportunities.
Here are some example usecases that come to my mind:
n
recently visited pages kredis_unique_list
(array)n
latest search results kredis_unique_list
(array)kredis_hash
kredis_hash
kredis_hash
kredis_string
kredis_integer
kredis_list
accept
/reject
cookies.
A basic cookies modal can look like this:
We can store this decision in session[:cookies_accepted]
.
If a user accepts - expect normal website behavior.
If a user rejects - you might want to disable some tracking services:
<%= render "shared/google_analytics" if (session[:cookies_accepted] == true) %>
Generate views
, controllers
, routes
for our cookies:
# Terminal
rails g controller cookies index
turbo_frame_tag
;accept
/reject
modals:# app/views/shared/cookies_banner.html.erb
<%= turbo_frame_tag :cookies_modal do %>
<% if session[:cookies_accepted].nil? # don't re-render if a true/false selected %>
<section class="cookies-modal">
<h3>We process cookies.</h3>
<%= link_to "Accept cookies", cookies_path(cookies: true), method: :post %>
<%= link_to "Reject cookies", cookies_path(cookies: false), method: :post %>
</section>
<% end %>
<% end %>
Positioning the modal with css:
/* app/assets/stylesheets/application.css */
.cookies-modal {
position: absolute;
padding: 0.5rem;
z-index: 2;
left: 0.5rem;
bottom: 0.5rem;
min-width: 50%;
max-width: 24rem;
word-break: break-word;
border-radius: 6px;
background: #bad5ff;
}
A simplistic route:
# config/routes.rb
get 'cookies', to: 'cookies#index'
Display cookies template, update preferences based on selected accept/reject params:
# app/controllers/cookies_controller.rb
def index
session[:cookies_accepted] = params[:cookies] if params[:cookies]
end
Finally, to make the cookies modal visible in the app, conditionally display a turbo_frame
pointing to the cookies_banner
in the layout, if the cookies true
/false
has not yet been selected:
# app/views/application.html.erb
<%= session[:cookies_accepted] # display selected value for testing purposes %>
<%= turbo_frame_tag :cookies_modal, src: cookies_path if session[:cookies_accepted].nil? %>
P.S. for setting session[:cookies_accepted]
to nil
and re-testing the above, you can simply add the below line to any #GET controller action in your app and visit the page:
# app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
+ session[:cookies_accepted] = nil
end
end
Thatās it!
Alternatively, there are some cookies/GDPR gems:
infinum/cookies_eu
- Gem to add cookie consent to Rails applicationprey/gdpr_rails
- Rails Engine for the GDPR complianceosano/cookieconsent
- A free solution to the EU, GDPR, and California Cookie LawsInstead, you might want to use your own SVGs.
You can import SVGs into your appsā assets folder:
<!-- app/assets/images/svg/search.svg -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.828 14.871l-4.546-4.545a6.34 6.34 0 10-.941.942l4.545 4.545a.666.666 0 00.942-.942zm-9.461-3.525a4.995 4.995 0 114.994-4.994 5 5 0 01-4.994 4.994z" fill="#272727"/>
</svg>
BTW, usually when I import an SVG, I:
fill
to currentColor
em
instead of px
style
and class
attributes-fill="#FFFFFF"
+fill="currentColor"
-width="16px" height="16px"
+width="1em" height="1em"
The final SVG that is ready to be used in the app will look like this:
<!-- app/assets/images/svg/search2.svg -->
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.828 14.871l-4.546-4.545a6.34 6.34 0 10-.941.942l4.545 4.545a.666.666 0 00.942-.942zm-9.461-3.525a4.995 4.995 0 114.994-4.994 5 5 0 01-4.994 4.994z" fill="currentColor"/>
</svg>
To display the SVG, you can try to use image_tag
and render the SVG as an image:
<%= image_tag "svg/search.svg", style: "color: green; background-color: red; height: 50px; width: 20px;" %>
<%= image_tag "svg/search2.svg", style: "color: green; background-color: red; height: 50px; width: 20px;" %>
<%= image_tag "svg/search2.svg", style: "color: green; background-color: red; font-size: 40px;" %>
And here we see the problem: we canāt style the SVGs with CSS class
& style
:
To overcome this issue, we can use a helper that will parse an SVG, add html options and render it in html:
# app/helpers/svg_helper.rb
module SvgHelper
def svg_tag(icon_name, options={})
file = File.read(Rails.root.join('app', 'assets', 'images', 'svg', "#{icon_name}.svg"))
doc = Nokogiri::HTML::DocumentFragment.parse file
svg = doc.at_css 'svg'
options.each {|attr, value| svg[attr.to_s] = value}
doc.to_html.html_safe
end
end
<%= svg_tag 'search', style: "color: green; background-color: red; height: 50px; width: 20px;" %>
<%= svg_tag 'search2', style: "color: green; background-color: red; height: 50px; width: 20px;" %>
<%= svg_tag 'search2', style: "color: green; background-color: red; font-size: 40px;" %>
Now you see that the SVG was correctly rendered as an SVG in HTML:
Finally, instead of writing custom SVG helpers and āreinventing the wheelā, you can use an out-of-the-box solution: gem inline_svg
# terminal
bundle add inline_svg
<%= inline_svg_tag 'svg/search', style: "color: green; background-color: red; height: 50px; width: 20px;" %>
<%= inline_svg_tag 'svg/search2', style: "color: green; background-color: red; height: 50px; width: 20px;" %>
<%= inline_svg_tag 'svg/search2', style: "color: green; background-color: red; font-size: 40px;" %>
class StyleguideController < ApplicationController
def index
@svg_names = Rails.root.join("app", "assets", "images", "svg").children.map { |path| path.basename.to_s.split(".")[0] }
end
end
<% @svg_names.each do |svg_name| %>
<%= inline_svg_tag "svg/#{svg_name}.svg", class: "h-6 w-6" %>
<%= svg_name %>
<% end %>
Thatās it!
References:
]]>strftime
is used to format date
and time
in Rails views.
The common (bad) approach would be to format a datetime
with strftime
directly in the views:
post.created_at.strftime("%d %b, %Y")
# => 11 June, 2022
However, this way you store datetime formatting logic in views and thereās a high chance of avoidable duplication.
What if we store strftime
in the model?
# app/models/post.rb
def created_at_dmy
date.strftime("%d %b, %Y") # 11 June, 2022
end
post.created_at_dmy
# => 11 June, 2022
Thatās better, but there is a high chance that you will want to use the same strftime
for other models in the app, and this method wonāt be accessible for them.
So you can just create a helper so that your strftime
is available everywhere:
# app/helpers/time_helper.rb
module TimeHelper
def created_at_dmy(date)
date.strftime("%d %b, %Y") # 11 June, 2022
end
end
created_at_dmy(post.created_at)
# => 11 June, 2022
Thatās quite good. But thereās an even better way offered by Rails!
The Rails API has inbuilt
Date::DATE_FORMATS
and
Time::DATE_FORMATS
classes, that we can use out of the box:
post.created_at.to_fs(:iso8601)
# => "2022-06-04T08:57:37Z"
post.created_at.to_fs(:rfc822)
# => "Sat, 04 Jun 2022 08:57:37 +0000"
post.created_at.to_fs(:short)
# => "04 Jun 08:57"
post.created_at.to_fs(:long)
# => "June 04, 2022 08:57"
This way you can create an initializer to add your own methods:
# config/initializers/date_format.rb
Time::DATE_FORMATS[:dmy] = "%d %b, %Y" # 04 June, 2022
Time::DATE_FORMATS[:my] = "%m/%Y" # 06/2022
post.created_at.to_fs(:dmy)
# => 11 June, 2022
post.created_at.to_fs(:my)
# => 06/2022
Ok, but whatās the difference between Date::DATE_FORMATS
and Time::DATE_FORMATS
?
Well, the two classes can seem similar but they have a few different methods and can provide different outcomes:
Date::DATE_FORMATS[:date1] = ->(date) { date.strftime("#{date.day.ordinalize} %B, %Y") }
post.created_at.to_fs(:date1)
# => "2022-06-04 08:57:37 UTC"
Time::DATE_FORMATS[:time1] = ->(date) { date.strftime("#{date.day.ordinalize} %B, %Y") }
post.created_at.to_fs(:time1)
# => "11th June, 2022"
Fantastic!
Additionally, as Jerome suggested, another good way to display strftime
would be via locales
:
Thatās it!
]]>However, when adding this gem you will encounter a classic problem of:
state
based on country
city
based on state
Good validations
are most important to making this work.
You want to make it impossible to save an invalid country-state-city combination.
Something like country: "Ukraine, city: "New York
should be invalid. Oops, there actually exists a city New York, Ukraineš
bundle add city-state
rails g scaffold address country state city address_line_1
rails db:migrate
You need to validate that:
state
belongs to a selected country
;city
belongs to a selected country-state
combination.Also, condider that some countries donāt have states or cities (Vatican, Antarctica), so you should not validate presence of a city
and state
for them.
# app/models/address.rb
class Address < ApplicationRecord
validates :country, presence: true
validates :address_line_1, presence: true
# state has to be valid when changing a country
validates :state, inclusion: { in: ->(record) { record.state_opts.keys }, allow_blank: true }
validates :state, presence: { if: ->(record) { record.state_opts.present? } }
# some countries don't have any cities, like Vatican.
# city has to be valid when changing a country/state
validates :city, inclusion: { in: ->(record) { record.city_opts }, allow_blank: true }
validates :city, presence: { if: ->(record) { record.city_opts.present? } }
def country_opts
CS.countries.with_indifferent_access
end
def state_opts
CS.states(country).with_indifferent_access
end
def city_opts
CS.cities(state, country) || []
end
def country_name
country_opts[country]
end
def state_name
state_opts[state]
end
end
Now, the form can look like this:
# app/views/addresses/_form.html.erb
<%= form_with(model: address) do |form| %>
<%= form.label :country, style: "display: block" %>
<%= form.select :country, address.country_opts.invert, {include_blank: true}, { onchange: "this.form.requestSubmit();" } %>
<%= form.label :state, style: "display: block" %>
<%= form.select :state, address.state_opts.invert, {include_blank: true}, { onchange: "this.form.requestSubmit();" } %>
<%= form.label :city, style: "display: block" %>
<%= form.select :city, address.city_opts, {include_blank: true}, {} %>
<%= form.submit %>
<% end %>
However, this way there is a full page refresh each time you select something.
Letās improve it.
rails g stimulus form-reset
rails g stimulus form-element
To fix a common problem of refreshing the page and still having values in a form:
// app/javascript/controllers/form_reset_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="form"
export default class extends Controller {
connect() {
this.element.reset()
}
}
Considering the learning from the previous post, we will add a stimulus controller that will help us to submit a āremoteā button:
// app/javascript/controllers/form_element_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="form"
export default class extends Controller {
static targets = ["submitbtn"]
connect() {
this.submitbtnTarget.hidden = true
}
autosumbit() {
this.submitbtnTarget.click()
}
}
and in the controller we need to allow passing address_params
in the new
action:
# app/controllers/addresses_controller.rb
def new
- @address = Address.new
+ @address = Address.new address_params
end
def address_params
- params.require(:address).permit(:country, :city, :state, :address_line_1)
+ params.fetch(:address, {}).permit(:country, :city, :state, :address_line_1)
end
Finally, we will update our form:
country
or state
is selected# app/views/addresses/_form.html.erb
<%= form_with(model: address, data: {controller: "form-reset"}) do |form| %>
<div data-controller="form-element">
<%= form.button "Validate", formaction: new_address_path, formmethod: :get, data: {form_element_target: "submitbtn", turbo_frame: :dynamic_fields} %>
<%= turbo_frame_tag :dynamic_fields do %>
<%= form.label :country, style: "display: block" %>
<%= form.select :country, CS.countries.invert, {include_blank: true}, {data: { action: "change->form-element#autosumbit"}} %>
<%= form.label :state, style: "display: block" %>
<%= form.select :state, address.state_opts.invert, {include_blank: true}, {data: { action: "change->form-element#autosumbit"}} %>
<%= form.label :city, style: "display: block" %>
<%= form.select :city, address.city_opts, {include_blank: true}, {} %>
<% end %>
</div>
<%= form.submit %>
<% end %>
Thatās it!
Now your dynamic select should work perfectly:
]]>validations
select
fields in a formMission: Select car brand and model
rails g scaffold cars brand model description:text
Define collections and validations:
# app/models/car.rb
class Car < ApplicationRecord
# 2 levels of data. sometimes level 2 can be blank.
CARS = { audi: %w[a1 a2 a3 a4 a5 a6 s8],
kia: %w[ceed sportage],
tesla: ['model x', 'model 3'],
nissan: [],
bmw: %w[i3 s8] }.freeze
validates :description, presence: true
validates :brand, presence: true
# sportage can not be in bmw
validates :model, inclusion: { in: ->(record) { record.models }, allow_blank: true }
# nissan should have no model
validates :model, presence: { if: ->(record) { record.models.present? } }
def brands
CARS.keys
end
def models
return [] unless brand.present?
CARS[brand.to_sym] || []
end
end
Select fields in form:
# app/views/addresses/_form.html.erb
<%= form_with(model: car) do |form| %>
<%= form.select :brand, car.brands, {include_blank: true}, {} %>
<%= form.select :model, car.models, {include_blank: true}, {} %>
<%= form.text_area :description %>
<%= form.submit %>
<% end %>
In this case weāll have our data in the form of a hash of hashes (slightly different data structure):
rails g scaffold order country city
# order.rb
class Order < ApplicationRecord
CS2 = {
'usa' => { los_angeles: 'los angeles', new_york: 'new york', chicago: 'chicago' },
'poland' => { gdansk: 'gdansk', wroclaw: 'wroclaw', warsaw: 'warsaw' },
'ukraine' => { kyiv: 'kyiv', lviv: 'lviv', kharkiv: 'kharkiv', ivano_frankivsk: "ivano-frankivsk" }
}
validates :country, presence: true
validates :city, presence: true
validates :city, inclusion: { in: ->(record) { record.city_opts.values.map(&:to_s) }, allow_blank: true }
def country_opts
CS2.keys
end
def city_opts
return [] unless country.present?
CS2[country].invert || []
end
end
We will update the form as in the previous example:
# orders/_form.html.erb
<%= form_with(model: order) do |form| %>
<%= form.select :country, order.country_opts, { include_blank: true }, {} %>
<%= form.select :city, order.city_opts, {include_blank: true}, {} %>
<%= form.submit %>
<% end %>
Result:
Mission: Select address country, region, city
rails g scaffold address country region city description:text
Define collections and validations:
# app/models/address.rb
# 3 levels of data. sometimes level 2 or level 3 can be blank
CS3 = { us:
{ california: ['sacramento', 'los angeles'],
maryland: %w[annapolis baltimore] },
de: { bayern: {}, turingen: {} },
pl: {},
ua:
{ north: %w[chernihiv kyiv],
west: %w[lviv bukovel] } }.freeze
validates :description, presence: true
validates :country, presence: true
# california can not be in de
validates :region, inclusion: { in: ->(record) { record.regions.map(&:to_s) }, allow_blank: true }
# pl should have no region
validates :region, presence: { if: ->(record) { record.regions.present? } }
# sacramento can not be in bayern
validates :city, inclusion: { in: ->(record) { record.cities }, allow_blank: true }
# de should have no city
validates :city, presence: { if: ->(record) { record.cities.present? } }
def countries
CS3.keys
end
def regions
return [] unless country.present?
CS3[country.to_sym].keys || []
end
def cities
return [] unless country.present? && region.present?
CS3[country.to_sym][region.to_sym] || []
end
Select fields in form:
# app/views/addresses/_form.html.erb
<%= form_with(model: address) do |form| %>
<%= form.select :country, address.countries, {include_blank: true}, {} %>
<%= form.select :region, address.regions, {include_blank: true}, {} %>
<%= form.select :city, address.cities.map { | k, v | [k.capitalize, k] }, {include_blank: true}, {} %>
<%= form.text_area :description %>
<%= form.submit %>
<% end %>
Now when your backend is solid, feel free to add some interactivity to your form with Hotwire. This will be explored in the next post.
]]>At this point you can create a static website. Without relying on any $$$ proprietary tools!
Surely, instead of learning all that you can become a professional in WordPress/Drupal/Wix or another website builder, but that way you are bound to a specific proprietary tool. And this niche skill would not pay much ;)
To polish the above skills of developing static websites, you can learn:
Congratulations! You are now a frontend web developer!
Thatās means:
This all can be handled by a framework like Ruby on Rails.
First steps to learn Rails:
Itās not easy.
Learning programming takes a lot of time, discipline and focus.
Select one tech stack and donāt get destracted along the way.
Just as it takes years to get a university degree, it takes time to learn programming on a level to be eligible for the job market.
Millions of people ātry programmingā and ditch it a few months later.
Perserverance > Talent.
]]>I myself chose web development because of freedom. Freedom from Applesā AppStore or Googlesā Android Market. Fewer doorkeepers & regulators to worry about.
You are also not bound to any propritary software.
When I was a kid in the early 2000ās, a web browser on a Desktop computer was the only way to connect to the internet.
Since the first iphone in 2006, the internet has become much more āaccessibleā via smartphones.
Today, most people have a smartphone, but many donāt have a desktop/laptop computer. They just donāt need it.
Moreover, for many people āthe internetā comes down to a few social media apps.
Naturally, the Web has been losing dominance in favor of mobile app development.
Apart of freedom, I think that productivity remains the killer feature of desktop/laptop VS mobile.
You just canāt be as productive on a smartphone.
You canāt build a mobile app on a mobile phone, right? :D
I perceive a smartphone more as a consumption device, whereas a desktop/laptop is a production device.
Business users are more likely to use a desktop/laptop computer and a Web Browser. Consumers are more likely to use their phone and download an app (and rarely open a web browser on their phone).
I think that businesses are more likely to pay for services, and itās easier to become successful on B2B than on B2C.
Overall, I think choosing Web Development is a safe bet in 2022. Demand is high as never before, and so are the salaries.
]]>Luckily there exist Classless CSS frameworks like SimpleCSS or MVP.CSS.
A classless CSS frameworks directly styles HTML attributes.
<!-- Tailwind CSS -->
<body class="border-s rounded-m p-16 shadow-l">
<!-- Bootstrap CSS -->
<body class="container">
<!-- Classless CSS -->
<body>
So if you write semantic HTML, when adding a classless CSS framework, the styling will be applied automatically!
Just include the CDN in your layout:
# app/views/layouts/application.html.erb
<head>
...
+ <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
</head>
Hereās how your app can look Without VS With an included Classless CSS framework:
Also, if you read the actual CSS file that you import, you can learn some great practices to writing CSS.
Thinking further, you can potentially swap classless CSS frameworks in your app like themes!ā¦
Thereās also a great discussion about classless css frameworks on ycombinator news
]]>div
and span
, whereas there are many other HTML tags that we just tend to overlook or forget.
Writing semantic HTML means actually using the right HTML tags for different parts of a page.
Semantic HTML is important for accessibility, screen readers, SEO. And just looks more professional ;)
Good resources on writing semantic HTML:
<header>
for logao and navigation<main>
for <%= yield %>
<footer>
- sitemap, copyright, author, contact, sitemap, back-to-top# app/views/layouts/application.html.erb
<body>
<header>
<%= render "shared/navbar" %>
</header>
<main>
<%= render "shared/flash" %>
<%= render "shared/sidebar" %>
<%= yield %>
</main>
<footer>
<%= render "shared/footer" %>
</footer>
</body>
<nav>
to define navbar<nav>
for navigation# app/views/shared/_navbar.html.erb
<nav>
<ul>
<li>
<%= link_to "Home", root_path %>
</li>
<li>
<%= link_to "Posts", posts_path %>
</li>
</ul>
</nav>
<aside>
for sidebar (related links, advertisements)# app/views/shared/_sidebar.html.erb
<aside>
...
</aside>
<section>
should usually have a heading <h1>
-<h6>
# app/views/posts/index.html.erb
<section>
<h1>Posts</h1>
<%= render @posts %>
</section>
# app/views/posts/show.html.erb
<section>
<h1>Post <%= @post.id %><h1>
<%= render @post %>
</section>
# app/views/posts/new, edit
<section>
<h1>Create/Edit post<h1>
...
</section>
<article>
- an object that makes sence outside of other context# app/views/posts/_post.html.erb
<article>
<%= @post.title %>
<%= @post.body %>
</article>
The above is my current-best approach.
If you have any suggestions on how to make it better, please tell me!
]]>Iāve been using it in most of my projects over the last 7 years.
Hereās how you can make it work in Rails 7 + importmaps.
So, you need to import fontawesome from npm. If you visit fontawesome npm homepage, you will see a command npm i @fortawesome/fontawesome-free
.
For importmaps you can run:
# console
./bin/importmap pin @fortawesome/fontawesome-free
This will generate the following code:
# config/importmap.rb
++pin "@fortawesome/fontawesome-free", to: "https://ga.jspm.io/npm:@fortawesome/fontawesome-free@6.1.1/js/fontawesome.js"
Change the line:
# config/importmap.rb
--pin "@fortawesome/fontawesome-free", to: "https://ga.jspm.io/npm:@fortawesome/fontawesome-free@6.1.1/js/fontawesome.js"
++pin '@fortawesome/fontawesome-free', to: 'https://ga.jspm.io/npm:@fortawesome/fontawesome-free@6.1.1/js/all.js'
Include fontawesome in your js:
// app/javascript/application.js
import "@fortawesome/fontawesome-free"
Now you can use icons in your code:
# app/views/any_file.html.erb
<i class="fa-solid fa-flag"></i>
<i class="fa-brands fa-amazon"></i>
<i class="fa-regular fa-bell"></i>
Thatās it!
Bonus: style fontawesome icons
Itās super easy to add size
, animation
, rotation
:
# app/views/any_file.html.erb
<i class="fa-solid fa-refresh fa-2xl fa-spin"></i>
<i class="fa-solid fa-gem fa-rotate-180"></i>
<i class="fa-solid fa-gem fa-rotate-by" style="color: green; --fa-rotate-angle: 45deg"></i>
<i class="fa-brands fa-youtube" style="color: red;"></i>
<i class="fa-regular fa-bell fa-beat"></i>
This is my current-best approach to reddit-style voting with Hotwire.
Prerequisites:
gem devise
for Users
model. Hereās how you can make Devise work with Rails 7# console
rails g scaffold message body:text
bundle add acts_as_votable
rails generate acts_as_votable:migration
rails db:migrate
# app/models/user.rb
acts_as_voter
I strongly recommend to use vote_scopes. This way you can always easily add multiple vote types on a same model, like like
, bookmark
, star
.
# app/models/message.rb
acts_as_votable
# upvote or remove vote
def upvote!(user)
if user.voted_up_on? self, vote_scope: 'like'
unvote_by user, vote_scope: 'like'
else
upvote_by user, vote_scope: 'like'
end
end
# downvote or remove vote
def downvote!(user)
if user.voted_down_on? self, vote_scope: 'like'
unvote_by user, vote_scope: 'like'
else
downvote_by user, vote_scope: 'like'
end
end
Now you can like
/unlike
/dislike
via the console:
user = User.first
message = Message.create(body: SecureRandom.hex)
message.upvote!(user) # like
message.upvote!(user) # if a vote exists => unlike
user.voted_for? message, vote_scope: "like" # true
message.get_upvotes(vote_scope: 'like').size # 1
message.get_downvotes(vote_scope: 'like').size # 0
message.find_votes_for(vote_scope: 'like').size # total votes count
ActsAsVotable::Vote.count # 1
Problem: I donāt see a way to get message.weighted_score
with a vote_scope
.
This is important for performance. Also to order posts by votes like Post.order(cached_weighted_like_score: :desc)
# console
rails g migration AddCachedScopedLikeVotesToMessages
class AddCachedScopedLikeVotesToMessages < ActiveRecord::Migration[7.0]
def change
change_table :messages do |t|
t.integer :cached_scoped_like_votes_total, default: 0
t.integer :cached_scoped_like_votes_score, default: 0
t.integer :cached_scoped_like_votes_up, default: 0
t.integer :cached_scoped_like_votes_down, default: 0
t.integer :cached_weighted_like_score, default: 0
t.integer :cached_weighted_like_total, default: 0
t.float :cached_weighted_like_average, default: 0.0
# calculate the existing votes
# Message.find_each { |p| p.update_cached_votes("like") }
end
end
end
Letās allow the user to upvote/downvote on a message from the browser.
# config/routes.rb
resources :messages do
member do
patch :vote
end
end
So, the path to vote will be vote_message_path(message)
.
Instead of having 2 methods upvote
and downvote
, we have just one method vote
.
To distinguish whether we want to upvote
or downvote
, we will be sending an additional param[:type]
with the button:
vote_message_path(message, type: :upvote)
vote_message_path(message, type: :downvote)
The controller action will respond accordingly:
# app/controllers/messages_controller.rb
def vote
# return unless %w[upvote downvote].include?(params[:type])
@message = Message.find(params[:id])
case params[:type]
when 'upvote'
@message.upvote! current_user
when 'downvote'
@message.downvote! current_user
else
# redirect_to request.url, alert: "no such vote type" and return
return redirect_to request.url, alert: "no such vote type"
end
flash.now[:notice] = params[:type]
respond_to do |format|
format.html do
redirect_to request.url
end
format.turbo_stream do
render turbo_stream:
turbo_stream.replace(@message,
partial: 'messages/message',
locals: { message: @message })
end
end
end
Logic for the upvote/downvote button text:
# app/helpers/application_helper.rb
def upvote_label(message, user)
vote_message = if user.voted_up_on? message, vote_scope: 'like'
'UN-vote'
else
'UP-vote'
end
tag.span do
"#{message.cached_scoped_like_votes_up} #{vote_message}"
end
end
def downvote_label(message, user)
vote_message = if user.voted_down_on? message, vote_scope: 'like'
'UN-vote'
else
'DOWN-vote'
end
tag.span do
"#{message.cached_scoped_like_votes_down} #{vote_message}"
end
end
Letās display:
(upvotes + downvotes)
(upvotes - downvotes)
# app/views/messages/_message.html.erb
<div id="<%= dom_id message %>">
<%= simple_format(message.body) %>
<%= button_to [:vote, message], params: { type: :upvote }, method: :patch do %>
<%= upvote_label(message, current_user) %>
<% end %>
Total votes:
<%= message.cached_scoped_like_votes_total %>
Rating:
<%= message.cached_weighted_like_score %>
<%= button_to [:vote, message], params: { type: :downvote }, method: :patch do %>
<%= downvote_label(message, current_user) %>
<% end %>
</div>
Cool stuff! You can respond with either html
(full page redirect), or turbo_stream
(ajax):
<%= button_to upvote_label(message, current_user), vote_message_path(message, type: :upvote, format: :html), method: :patch %>
<%= button_to upvote_label(message, current_user), vote_message_path(message, type: :upvote, format: :turbo_stream), method: :patch %>
# all voted entities
user.find_voted_items # voted
user.find_up_voted_items # upvoted
user.find_down_voted_items # downvoted
# all voted entities with a scope
user.find_voted_items(vote_scope: 'like')
user.find_up_voted_items(vote_scope: 'like')
user.find_down_voted_items(vote_scope: 'like')
# only voted Messages with a scope
user.find_votes_for_class(Message, vote_scope: "like")
user.find_up_votes_for_class(Message, vote_scope: "like")
user.find_down_votes_for_class(Message, vote_scope: "like")
Thatās it!
]]># config/seeds.rb
3.times do
Person.create(name: Faker::Name.first_name,
surname: Faker::Name.last_name,
address: Faker::Address.full_address,
phone: Faker::PhoneNumber.cell_phone)
end
bundle add faker
rails g scaffold person name surname address phone
rails db:migrate
rails db:seed
hover
/* app/assets/stylesheets/application.css */
.hoverWrapper #hoverContent {
display: none;
position: absolute;
background-color: black;
color: white;
padding: 10px;
border-radius: 4px
}
.hoverWrapper:hover #hoverContent {
display: block;
}
hoverContent
is hidden by default.
hoverContent
is visible when hovering hoverWrapper
.
<span class="hoverWrapper">
Hover me...
<div id="hoverContent">
Hidden content
</div>
</span>
How it works:
Add a route for the hovercard
# config/routes.rb
root to: redirect("/people")
resources :people do
member do
get :hovercard
end
end
Add a controller action
class PeopleController < ApplicationController
def hovercard
@person = Person.find(params[:id])
end
end
@person
target: "_top"
- for links inside the turbo_frame
to work# app/views/people/hovercard.html.erb
<%= turbo_frame_tag dom_id(@person, :hovercard), target: "_top" do %>
<%= link_to "Show this person", @person %>
<%= @person.id %>
<%= @person.address %>
<%= @person.phone %>
<% end %>
hovercard_person_path
in the hidden area of the HTML.turbo_stream
with loading: :lazy
is loaded only when it becomes visible on the screen.role="button"
, aria-describedby="id"
, role="tooltip"
are just some fancy HTML tags that help the browser read your HTML in a better way. You can do without them.# app/views/people/_person.html.erb
<div class="hoverWrapper">
<span role="button" aria-describedby="<%= dom_id(person, :hovercard) %>">
Details...
</span>
<div id="hoverContent">
<%= turbo_frame_tag dom_id(person, :hovercard), target: "_top", role: "tooltip", src: hovercard_person_path(person), loading: :lazy do %>
Loading...
<% end %>
</div>
</div>
Now, when you hover on Details...
, the <div id="hoverContent">
will become visible and will load the template app/views/people/hovercard.html.erb
.
Final result:
Thatās it!
Inspired by Steve Polioās post
]]>You can use it as a lightweight approach to display markdown in your app.
# Terminal
bin/rails g scaffold messages content:text
bin/rails g stimulus markdown
bin/rails g stimulus markdown-display
# using importmaps
bin/importmap pin marked
# or using jsbundling
yarn add marked
# views/messages/_form.html.erb
<div data-controller="markdown">
<%= form.text_area :content, rows: 15, data: { action: "keyup->markdown#change", markdown_target: "input" } %>
<div data-markdown-target="output">
</div>
// javascript/controllers/markdown_controller.js
import { Controller } from "@hotwired/stimulus"
import { marked } from "marked"
// Connects to data-controller="markdown"
export default class extends Controller {
static targets = ["input", "output"]
connect() {
this.parse()
}
change() {
this.parse()
}
parse() {
this.outputTarget.innerHTML = marked.parse(this.inputTarget.value)
}
}
html
to markdown
on page load# views/messages/_message.html.erb
<%= content_tag :div, message.content, data: { controller: "markdown-display" } %>
# javascript/controllers/markdown_display_controller.js
import { Controller } from "@hotwired/stimulus"
import { marked } from "marked"
// Connects to data-controller="markdown-display"
export default class extends Controller {
connect() {
this.element.innerHTML = marked.parse(this.element.innerHTML)
}
}
Full credit to source. Iāve added the solution on my blog primarily in case I lose access to the authors content.
š¤ However at some point you might want to have more control of the rendered markdown.
Than you will want to use:
character count
, error validation
, and makdown preview
while you type, without page refresh, without extra JS
different URL
different URL
different URL
will respond with a turbo_streamrails generate scaffold Message content:text
rails g stimulus form
# app/models/message.rb
validates :content, presence: true
validates :content, length: { in: 5..1000 }
# app/views/messages/_form.html.erb
<%= form_with(model: message, data: { controller: "form", action: "input->form#remotesubmit" }) do |form| %>
<div>
<%= form.label :content, style: "display: block" %>
<%= form.text_area :content %>
</div>
<div id="message_preview">
<%= markdown message.content %>
</div>
<div>
<%= form.button "Preview Message", formaction: preview_messages_path, name: "_method", value: "post", data: { form_target: "submitbtn" } %>
<%= form.submit %>
</div>
<% end %>
The Stimulus controller to:
submit
button// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["submitbtn"]
// hide the submit button
connect() {
this.submitbtnTarget.hidden = true
}
// click the hidden button -> submit the form
remotesubmit() {
this.submitbtnTarget.click()
}
// same as above, but with "debounce"
// remotesubmit() {
// clearTimeout(this.timeout)
// this.timeout = setTimeout(() => {
// this.submitbtnTarget.click()
// }, 500)
// }
}
So now we try to send a request to the server each time we change something in the formā¦
We need a way to respond to it!
submit
button# config/routes.rb
resources :messages do
collection do
post :preview
end
end
# app/controllers/messages_controller.rb
def preview
# params.dig(:message, :content)
@preview = Message.new(message_params)
respond_to do |format|
format.turbo_stream
end
end
message_preview
with the attributes from the @preview
object@preview.content
, or anything based on the @preview
object# app/views/messages/preview.turbo_stream.erb
<%= turbo_stream.update "message_preview" do %>
<%= @preview.content.length %>
<%= @preview.valid? %>
<%= @preview.errors.full_messages %>
<%= @preview.attributes %>
<%= simple_format @preview.content %>
<% end %>
The drawback of this approach is having to exchange MANY request-responces with the server. Unlike a pure JS approach, that would handle everything on the client side.
This is inspired by the amazing idea of a form having a second submit button to a different URL, that was described in Thoughtbotās: Server-rendered live previews
]]><textarea>
does not autogrow while you add new rows:
Improved <textarea>
with autogrow:
Just connect the below stimulus controller to a <textarea>
and youāre good to go!
rails g stimulus autogrow
StimulusJS controller inspired by MDN HTMLTextAreaElement example:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.style.overflow = 'hidden';
this.grow();
}
grow() {
this.element.style.height = 'auto';
this.element.style.height = `${this.element.scrollHeight}px`;
}
}
Usage with html.erb
:
<%= form.text_area :content,
# rows: 5,
data: { controller: 'autogrow',
action: "input->autogrow#grow" } %>
// app/javascript/controllers/autogrow.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="autogrow"
// <%= form.text_area :content, data: {controller: "autogrow" } %>
// <textarea data-controller="autogrow" name="article[content]"></textarea>
export default class extends Controller {
initialize() {
this.autogrow = this.autogrow.bind(this);
}
connect() {
this.element.style.overflow = 'hidden';
this.autogrow();
this.element.addEventListener('input', this.autogrow);
window.addEventListener('resize', this.autogrow);
}
disconnect() {
window.removeEventListener('resize', this.autogrow);
}
autogrow() {
this.element.style.height = 'auto';
this.element.style.height = `${this.element.scrollHeight}px`;
}
}
This old version is based on the fantastic @guillaumebridayās stimulus-textarea-autogrow
]]>This controller can be considered a much improved version of it:
Basically, a StimulusJS controller that can handle:
opened
and closed
statesopened
stateopened
or closed
dropdown by defaultI think this is a perfect example of leveraging Stimulus targets, values and classes all together.
HOWTO:
rails g stimulus dropdown
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["dropdownContent", "openButton", "closeButton", "active"]
static values = { open: Boolean }
static classes = ["opened"]
connect() {
if (this.openValue) {
this.openDropdown()
} else {
this.closeDropdown()
}
// this.dropdownContentTarget.hidden = true
// this.closeButtonTarget.hidden = true
// console.log("hello")
}
toggleDropdown() {
if (this.dropdownContentTarget.hidden == true) {
this.openDropdown()
} else {
this.closeDropdown()
}
}
openDropdown() {
this.dropdownContentTarget.hidden = false
try {
this.openButtonTarget.hidden = true
this.closeButtonTarget.hidden = false } catch {}
try {
// this.activeTarget.classList.add("bg-zinc-400")
this.activeTarget.classList.add(this.openedClass)
} catch {}
}
closeDropdown() {
this.dropdownContentTarget.hidden = true
try {
this.openButtonTarget.hidden = false
this.closeButtonTarget.hidden = true } catch {}
try {
// this.activeTarget.classList.remove("bg-zinc-400")
this.activeTarget.classList.remove(this.openedClass)
} catch {}
}
}
<div data-controller="dropdown" data-dropdown-open-value="false" data-dropdown-opened-class="bg-slate-300">
<button data-dropdown-target="activated" data-action="mouseenter->dropdown#toggleDropdown mouseleave->dropdown#toggleDropdown">
hover me
</button>
<div data-dropdown-target="dropdownContent" class="bg-red-500 fixed p-4 rounded-md">
<h1>this could be a tooltip!</h1>
</div>
</div>
<div data-controller="dropdown" data-dropdown-open-value="false" data-dropdown-opened-class="bg-slate-300">
<button data-dropdown-target="activated" data-action="click->dropdown#toggleDropdown">
click to toggle
</button>
<div data-dropdown-target="dropdownContent" class="bg-red-500 fixed p-4 rounded-md">
<h1>HIDDEN_CONTENT</h1>
regular html
</div>
</div>
opened
and closed
states:<div data-controller="dropdown" data-dropdown-open-value="false" data-dropdown-opened-class="bg-slate-300">
<div role="button" data-dropdown-target="openButton" data-action="click->dropdown#openDropdown">open ā¬ļø</div>
<span role="button" data-dropdown-target="closeButton" data-action="click->dropdown#closeDropdown">close ā¬ļø</span >
<div data-dropdown-target="dropdownContent" class="bg-red-500 fixed p-4 rounded-md">
<h1>HIDDEN_CONTENT</h1>
</div>
</div>
Surely, the same can be achieved without using a CSS framework.
You can apply something like this to the div
of dropdownContent
: style="background-color: red; position: fixed; padding: 4px; z-index: 2; border-radius: 6px;"
P.S. position: fixed;
stays on the same place when page scrolls, whereas position: absolute;
- scrolls down with page.
As a software developer, this might be one of the easiest things you can do:
š Block access to your software to anybody from Russia IPs š
Complete technological embargo.
If youāre running a Ruby on Rails app, it takes just one gem and one before_action to do:
bundle add geocoder
request.ip # => 233.543.123.235
request.location.country # => RU
# app/controllers/application_controller.rb
before_action do
if request.location.country.eql?("RU")
redirect_to "https://www.ukr.net/"
end
end
Resources:
Update: @rameerez has created a gem that blocks all requests from Russia.
]]>You are free to pass a HASH to a flash message. Not just a string š
# app/controllers/posts_controller.rb
flash[:post_status] = "Success. Post created" # a string
flash[:post_status] = { title: "Success", subtitle: "Post created" } # a hash!
# app/views/shared/_flash.html.erb
<% flash.each do |type, message| %>
<%= type %>
<%= message[:title] %>
<%= message[:subtitle] %>
<% end %>
We all know the distance_of_time_in_words method.
Now, we want something like distance_of_time_in_percent
.
Hereās a quick way to do it:
# app/helpers/application_helper.rb
def distance_of_time_in_percent(from_time, current_time, to_time, precision = nil)
precision ||= 0
distance = to_time - from_time
result = ((current_time - from_time) / distance) * 100
result.round(precision).to_s + '%'
end
Voila! Now you can run something like:
# irb / rails c
current_time = Time.now
from_time = Time.now - 12*60*60*24
to_time = Time.now + 12*60*60*24
>> distance_of_time_in_percent(from_time, current_time, to_time)
=> '45%'
>> distance_of_time_in_percent(from_time, current_time, to_time, 1)
=> '45.4%'
>> distance_of_time_in_percent(from_time, current_time, to_time, precision = 3)
=> '45.456%'
>> distance_of_time_in_percent("01-01-2020".to_time, "31-06-202020".to_time, "31-12-2020".to_time, precision = 1)
=> '49.9%'
Iāve initially extracted this method from gem distance_of_time_in_words.
Interestingly, methods like "01-01-2020".to_time
or number_with_precision
work in rails
, but not ruby
. Reminds me of the tweet talking about Rails dialect of Ruby language
turbo:submit-end
.
Full version of this article (also written by me for Bearer.com)
Stack:
HOWTO:
Add a modal that is available globally:
# app/views/layouts/application.html.erb
<%= turbo_frame_tag "modal" %>
Modal component:
# app/components/turbo_modal_component.html.erb
<%= turbo_frame_tag "modal" do %>
<%= tag.div data: { controller: "turbo-modal",
turbo_modal_target: "modal",
action: "turbo:submit-end->turbo-modal#submitEnd keyup@window->turbo-modal#closeWithKeyboard click@window->turbo-modal#closeBackground" },
class: "p-5 bg-slate-300 absolute z-10 top-10 right-10 rounded-md w-96 break-words" do %>
<h1 class="font-bold text-4xl"><%= @title %></h1>
<%= yield %>
<%= button_tag "Close", data: { action: "turbo-modal#hideModal" }, type: "button", class: "rounded-lg py-3 px-5 bg-red-600 text-white" %>
<% end %>
<% end %>
Alternatively to ViewComponent you can just use a partial. Hereās how
Wrap views that should be rendered in a modal into the Modal component:
# app/views/posts/new.html.erb
<%= render TurboModalComponent.new(title: "New Post:") do %>
<%= render "form", post: @post %>
<% end %>
# app/views/posts/edit.html.erb
<%= render TurboModalComponent.new(title: "Editing Post") do %>
<%= render "form", post: @post %>
<% end %>
Stimulus controller to handle form submission & common modal behavior:
// app/javascript/controllers/turbo_modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal"]
// hide modal
// action: "turbo-modal#hideModal"
hideModal() {
this.element.parentElement.removeAttribute("src")
// Remove src reference from parent frame element
// Without this, turbo won't re-open the modal on subsequent click
this.modalTarget.remove()
}
// hide modal on successful form submission
// action: "turbo:submit-end->turbo-modal#submitEnd"
submitEnd(e) {
if (e.detail.success) {
this.hideModal()
}
}
// hide modal when clicking ESC
// action: "keyup@window->turbo-modal#closeWithKeyboard"
closeWithKeyboard(e) {
if (e.code == "Escape") {
this.hideModal()
}
}
// hide modal when clicking outside of modal
// action: "click@window->turbo-modal#closeBackground"
closeBackground(e) {
if (e && this.modalTarget.contains(e.target)) {
return;
}
this.hideModal()
}
}
Final step - add data: { turbo_frame: 'modal' }
to links to Create
and Edit
.
# app/views/posts/index.html.erb
<%= button_to 'New post', new_post_path, method: :get, data: { turbo_frame: 'modal' }, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
Thatās it! Source code
What can be improved here:
ā¦Sometimes when doing code changes, you need to update data in your production database.
A normal migration adds/removes/renames database tables/columns, adds indexes, adds database attribute validations and default values.
But what if you want to run such a command in production?
Service.where(status: "Frozen").update_all(status: "paused")
First thought: āI should make a backup, and enter the production console and do thatā.
But thatās always risky! Better use a migration. A data migration.
Thereās a gem for that! gem data-migrate
# install the gem
bundle add data_migrate
# add a migration
rails g data_migration change_frozen_courses_to_paused
# db/data/20220125151511_change_frozen_courses_to_paused.rb
puts "updating #{Service.where(status: "Frozen").count} -> #{Service.where(status: "paused").count}"
Service.where(status: "Frozen").update_all(status: "paused")
puts "done updating #{Service.where(status: "Frozen").count} -> #{Service.where(status: "paused").count}"
# run the migration and update data in the database
rake data:migrate
To automatically run data migrations when deploying to production, update your Procfile
# Procfile
web: bundle exec rails s
--release: rails db:migrate
++release: rails db:migrate:with_data
Great! One reason less to enter the production console! š
]]>For example, modals, multi-step forms, or a wizard:
Not to duplicate HTML, use Partials to Simplify Views. With yield
and do
-blocks.
# new.html.haml
<div class="modal">
<div class="modal-header">
New post
</div>
<div class="modal-body">
<%= render "form", post: @post %>
</div>
</div>
# edit.html.haml
<div class="modal">
<div class="modal-header">
Edit post
</div>
<div class="modal-body">
<%= render "form", post: @post %>
</div>
</div>
# shared/_modal.html.haml
<div class="modal">
<div class="modal-header">
<%= title %>
</div>
<div class="modal-body">
<%= yield %>
</div>
</div>
# new.html.haml
<%= render "shared/modal", title: "New post" do %>
<%= render "form", post: @post %>
<% end %>
# edit.html.haml
<%= render "shared/modal", title: "Edit post" do %>
<%= render "form", post: @post %>
<% end %>
Much cleaner, isnāt it?! ;)
It is also very common to use ViewCompoenent for doing exactly this (rendering content inside an HTML block)!
= render layout: ācourses/course_wizard/stepā do
]]>Iāve done something very similar before in Stimulus Read More - MY WAY!!!, however this time itās a bit different:
And this is finally a good example to utilize Stimulus Values!
Hereās the stimulus controller:
import { Controller } from "@hotwired/stimulus"
// Minimalistic dropdown toggle controller.
// Click to open/close dropdown.
// When clicked, dropdown icon can be changed.
// Dropdown can be open by default, if you set data-mini-dropdown-open-value="true" (closed by default if not set)
export default class extends Controller {
static targets = ["dropdownContent", "openButton", "closeButton"]
static values = { open: Boolean }
connect() {
if (this.openValue) {
this.openDropdown()
} else {
this.closeDropdown()
}
}
openDropdown() {
this.openButtonTarget.hidden = true
this.closeButtonTarget.hidden = false
this.dropdownContentTarget.hidden = false
}
closeDropdown() {
this.openButtonTarget.hidden = false
this.closeButtonTarget.hidden = true
this.dropdownContentTarget.hidden = true
}
}
And here is the HTML:
<div data-controller="mini-dropdown" data-mini-dropdown-open-value="false">
<button role="button" tabindex=0 data-mini-dropdown-target="openButton" data-action="mini-dropdown#openDropdown">
Click to Open
</button>
<button role="button" tabindex=0 data-mini-dropdown-target="closeButton" data-action="mini-dropdown#closeDropdown" >
Click to Close
</button>
<div data-mini-dropdown-target="dropdownContent">
Dropdown content
</div>
</div>
You can make the component open/closed by default by adding some rails true/false
logic to data-mini-dropdown-open-value
, like data-mini-dropdown-open-value="<%= @post.published? %>"
Result:
Simple, huh? Add some nice CSS on top - and it can look very sexy! Hereās an example of a double-dropdown in a real app:
Thatās it!
]]># config/seeds.rb
100.times do
Post.create(title: Faker::Movie.unique.title)
end
# console
rails g scaffold post title
bundle add faker
rails db:migrate
rails db:seed
# app/models/post.rb
class Post < ApplicationRecord
validates :title, presence: true, uniqueness: true
end
post
request, so that we can respond with turbo_stream# config/routes.rb
resources :posts do
collection do
post :search
end
end
<div id="search_results"></div>
as a target where to render search results:# app/views/posts/_search_form.html.erb
<%= form_with url: search_posts_path, method: :post do |form| %>
<%= form.search_field :title_search, value: params[:title_search], oninput: "this.form.requestSubmit()" %>
<% end %>
<div id="search_results"></div>
search
action in the controller;# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def search
@posts = Post.where('title ILIKE ?', "%#{params[:title_search]}%").order(created_at: :desc)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.update("search_results",
partial: "posts/search_results",
locals: { posts: @posts })
]
end
end
end
end
# app/views/posts/_search_results.html.erb
<%= posts.count %>
<% posts.each do |post| %>
<br>
<%= link_to post do %>
<%= highlight(post.title, params.dig(:title_search)) %>
<% end %>
<% end %>
Now you can render the search form anywhere:
# app/views/posts/index.html.erb
<%= render "posts/search_form" %>
Thatās it!
ā¦ or do you still want to go further?
As @gvpmahesh suggested, In the above approach, if you clear your search input, you still query the database to return 0 results. Makes no sence!
# app/controllers/posts_controller.rb
def search
if params.dig(:title_search).present?
@posts = Post.where('title ILIKE ?', "%#{params[:title_search]}%").order(created_at: :desc)
else
@posts = []
end
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.update("search_results",
partial: "posts/search_results",
locals: { posts: @posts })
]
end
end
end
Letās improve even more!
# app/models/post.rb
scope :filter_by_title, -> (title) { where('title ILIKE ?', "%#{title}%") }
# app/controllers/posts_controller.rb
@posts = Post.filter_by_title(params[:title_search]).order(created_at: :desc)
# @posts = Post.where('title ILIKE ?', "%#{params[:title_search]}%").order(created_at: :desc)
This approach is much more mature!
To send fewer requests to the databse, you can add a stimulus controller to submit form with a 500ms delay.
// app/javascript/controllers/debounce_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "form" ]
connect() { console.log("debounce controller connected") }
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.formTarget.requestSubmit()
}, 500)
}
}
data: { controller: 'debounce' }
data: { debounce_target: 'form' }
data: { action: "input->debounce#search" }
# app/views/posts/_search_form.html.erb
<%= form_with url: search_posts_path, method: :post, data: { controller: 'debounce', debounce_target: 'form' } do |form| %>
<%= form.search_field :title_search, value: params[:title_search], data: { action: "input->debounce#search" } %>
<% end %>
Do we expect the posts table to get quite big?
I think that even if this table is small now, we may need to create two indexes for the title column.
One of type BTREE (for equality comparisons) and one of type GIN (for pattern matching).
For the latter, we will also need to add the pg_trgm extension first in a separate migration.
]]>button_to
respond with HTML or TURBO_STREAM.
Now, we will make a form_with
respond with or HTML or TURBO_STREAM.
Example:
rails g scaffold message body:text status:string done:boolean
Itās good to add some defaults:
default: "active", null: false
to status
default: false, null: false
to done
So the migration will look like this:
# db/migrate/20211225112627_create_messages.rb
class CreateMessages < ActiveRecord::Migration[7.0]
def change
create_table :messages do |t|
t.text :body
t.string :status, default: "active", null: false
t.boolean :done, default: false, null: false
t.timestamps
end
end
end
# app/models/message.rb
STATUSES [:active, :inactive]
Feel frree to add a form inside the message partial!
And you can define the format that you want the form to respond_to in the URL params.
You can also add 'this.form.requestSubmit();'
to submit the form whenever anything changes!
# app/views/messages/_message.html.erb
<div id="<%= dom_id message, :field_list %>">
<%= message.done %>
<br>
<%= message.status %>
<br>
<%= message.body %>
<br>
<%= message.updated_at %>
<%= form_with model: message, url: message_path(message), format: :turbo_stream, method: :put do |form| %>
<%= form.check_box :done, onchange: 'this.form.requestSubmit();' %>
<%= form.select :status, Message::STATUSES, {}, onchange: "this.form.requestSubmit()" %>
<%= form.text_field :body, oninput: "this.form.requestSubmit()" %>
<%#= form.submit %>
<% end %>
</div>
Add the format.turbo_stream responce in the controller to re-render _message.html.erb
whenever you submit anything in the form:
# app/controllers/messages_controller.rb
def update
respond_to do |format|
if @message.update(message_params)
format.html { redirect_to @message, notice: "Message updated." }
format.turbo_stream do
render turbo_stream: turbo_stream.update(@message, partial: "messages/message", locals: {message: @message})
end
else
format.html { render :edit, status: :unprocessable_entity }
end
end
end
However now your regular create/edit form will also try to respond to TURBO_STREAM by default (and not do the redirect).
Fix it by specifying a format to respond to:
# app/views/messages/_form.html.erb
--<%= form_with(model: message) do |form| %>
++<%= form_with(model: message, format: :html) do |form| %>
Perfect! Now you know how to respond to either HTML or TURBO_STREAM in a form!
In the first scenario, each time you change anything in the form the whole _message
partial gets re-rendered, including the form!
So each time you type something in the text_field, you lose focus:
Letās fix this!
First, move the html that you want to re-render with TURBO into a separate partial:
# app/views/messages/_attribute_list.html.erb
<b>Done:</b>
<%= message.done %>
<br>
<b>Status:</b>
<%= message.status %>
<br>
<b>Body:</b>
<%= message.name %>
<br>
<b>Last updated:</b>
<%= message.updated_at %>
Render the attribute_list
inside the message
partial:
# app/views/messages/_message.html.erb
<div id="<%= dom_id message %>">
<div id="<%= dom_id message, :attributes_target %>">
<%= render 'messages/attribute_list', message: message %>
</div>
<%= form_with model: message, url: message_path(message), format: :turbo_stream, method: :put do |form| %>
<%= form.check_box :done, onchange: 'this.form.requestSubmit();' %>
<%= form.select :status, Message::STATUSES, {}, onchange: "this.form.requestSubmit()" %>
<%= form.text_field :name, oninput: "this.form.requestSubmit()" %>
<%#= form.submit %>
<% end %>
</div>
Re-render only the attribute_list
. Not the messages
partial!
# app/controllers/messages_controller.rb
def update
respond_to do |format|
if @message.update(message_params)
format.html { redirect_to @message, notice: "Message updated." }
format.turbo_stream do
-- render turbo_stream: turbo_stream.update(@message, partial: "messages/message", locals: {message: @message})
++ render turbo_stream: turbo_stream.update(ActionView::RecordIdentifier.dom_id(@message, :attributes_target), partial: "messages/attribute_list", locals: {message: @message})
end
else
format.html { render :edit, status: :unprocessable_entity }
end
end
end
Perfect! Now when you auto-submit the form on input, you wonāt lose focus!
According to the form_with docs, you should use either url
or format
. Meaning, the below can lead to unexpected behavior:
<%= form_with model: message, url: message_path(message), format: :turbo_stream, method: :put do |form| %>
So in case you want to respond with format: :turbo_stream
, you donāt have to specify a format at all, because a non-get
request will try to respond with turbo_stream by default (and then fallback to html):
<%= form_with model: message, url: message_path(message), method: :put do |form| %>
However if you do want the form to respond with HTML, you might want to do it like this:
<%= form_with model: message, url: message_path(message, format: :html), method: :put do |form| %>
Homework: How would you handle validation errors in the this turbo form?
]]>For example:
So, in one case:
We will have to:
Examples:
button1 - upvote and redirect (HTML):
button2 - upvote without redirect (TURBO):
rails g scaffold message body:text likes_count:integer
Itās good to add some defaults:
default: 0, null: false
to likes_count
So the migration will look like this:
# db/migrate/20211225112627_create_messages.rb
class CreateMessages < ActiveRecord::Migration[7.0]
def change
create_table :messages do |t|
t.text :body
t.text :likes_count, default: 0, null: false
t.timestamps
end
end
end
# app/config/routes.rb
resources :messages do
member do
put :upvote
end
end
# app/controllers/messages_controller.rb
def upvote
@message = Message.find(params[:id])
@message.increment!(:likes_count)
redirect_to messages_url, notice: "Upvoted #{@message.id}"
end
# app/views/messages/_message.html.erb
<%= message.likes_count %>
<%= button_to "Upvote HTML", upvote_message_path(message), method: :put %>
So now we have a button that will upvote and redirect!
button_to
Now we want to do the same, but without page refresh.
Turbo Streams to the rescue!
format.turbo_stream
in the controller:ActionView::RecordIdentifier
to make dom_id
work in a controller# app/controllers/messages_controller.rb
def upvote
@message = Message.find(params[:id])
@message.increment!(:likes_count)
respond_to do |format|
format.html do
redirect_to messages_url, notice: "Works #{@message.id}"
end
format.turbo_stream do
render turbo_stream: turbo_stream.update(ActionView::RecordIdentifier.dom_id(@message, :likes), html: "#{@message.likes_count} #{Time.zone.now}")
end
end
end
dom_id
that will be updated by the above turbo_stream responce:# app/views/messages/_message.html.erb
<div id="<%= dom_id(message, :likes) %>">
<%= message.likes_count %>
</div>
BUT now if you click the button_to
from above, it will respond with format turbo_stream by default!
So if you donāt specify a format, it will try turbo_stream (not HTML) by default.
Good practice - explicitly state the format that you want to respond to.
# app/views/messages/_message.html.erb
<%= button_to "Upvote and redirect", upvote_message_path(message, format: :html), method: :put %>
# app/views/messages/_message.html.erb
<%= button_to "Upvote without redirect", upvote_message_path(message), method: :put, form: {"data-type": "turbo_stream" } %>
link_to
method?Since rails-ujs
is depreciated and itsā functionality is take over by turbo-rails
.
Now, you are supposed to use:
link_to
- for get
requests;button_to
- for post
, patch
, put
, and delete
requests.However if you do want to have link_to
another method, you can use
data-turbo-method
:
--<%= link_to "link html", upvote_message_path(post) %>
++<%= link_to "link_to turob_stream1", upvote_message_path(message), 'data-turbo-method': :patch %>
++<%= link_to "link_to turob_stream2", upvote_message_path(message), data: {turbo_method: "patch"} %>
IMPORTANT: the below DOES NOT WORK:
# app/views/messages/_message.html.erb
<%= button_to "Upvote without redirect (broken)", upvote_message_path(message, format: :turbo_stream), method: :put %>
<%= button_to "Upvote and redirect (broken)", upvote_message_path(message), method: :put, :form => {"data-type" => "html" } %>
Thatās it! Now you can have 2 āupvoteā buttons that will respond to either HTML
or TURBO_STREAM
format!
Homework: have a look at the HTML that is generated by each of these buttons. See the difference?
]]>if-else
conditionals.
When I just started, this looked perfect to me:
If-Elsif
# app/models/task.rb
class Task < ApplicationRecord
def status_color(status)
if status == 'incoming'
'grey'
elsif status == 'todo'
'orange'
elsif status == 'done'
'green'
elsif status == 'spam' || status == "error"
'red'
else
'black'
end
end
end
Task.status_color('done')
=> 'green'
One beautiful day, I learnt about
Rubocop
and it introduced me to
case-when
Case-When
# app/models/task.rb
class Task < ApplicationRecord
def status_color(status)
case status
when 'incoming'
'grey'
when 'todo'
'orange'
when 'done'
'green'
when 'spam', 'error'
'red'
else
'black'
end
end
end
Task.status_color('done')
=> 'green'
A bit cleaner and less duplication, right?
However, in some cases you can just define a hash
and get a value from a hash:
hash[key] => value
# app/models/task.rb
class Task < ApplicationRecord
COLOR_STATUSES = { incoming: 'grey', todo: 'orange', done: 'green', spam: 'red', error: 'red' }.freeze
end
Task::COLOR_STATUSES['todo'] || 'black'
=> 'orange'
# Task::COLOR_STATUSES[@task.status.to_sym] || 'black'
In some cases this is better and simpler!
Hereās a real-world scenario where this approach is better (kudos @secretpray)
]]>This:
<% @inbox.messages.each do |message| %>
<%= render partial: 'messages/message', locals: { message: message } %>
<% end %>
Equals this:
<%= render @inbox.messages %>
<%= render @messages %>
<%= render partial: "messages/message", collection: @messages, locals: { a: "b" } %>
<%= render @message %>
<%= render partial: "messages/message", locals: { message: @message, a: "b" } %>
<%= render "layouts/foo", locals: { a: "b" } %>
<%= render partial: "layouts/foo", locals: { a: "b" } %>
<%= render "layouts/foo", a: "b" %>
local_assigns[:a].present?
# => true
local_assigns[:a].presence
# => "b"
local_assigns[:a]
# => "b"
Found this ^ one here
Resources:
]]>Rule of thumb:
I would use broadcasts for:
I would NOT use broadcasts for:
If you look at the docs for turbo broadcasts, the suggested way to trigger them are ActiveRecordCallbacks in a model.
Callbacks to use:
after_create_commit
after_update_commit
after_destroy_commit
(btw, delete
doesnāt fire a callback. destroy
does)after_save_commit
append
# add on bottom of DOM ID (<div id="abc">
)prepend
# add on top of DOM IDreplace
# replace a DOM ID (example: with an element with another id)update
# update content INSIDE a DOM IDremove
# no template required for this one!before
# add before DOM ID (not inside it)!after
# add after DOM ID (not inside it)!rails g scaffold inbox name
rails db:migrate
bundle add faker
turbo_stream_from
target with an ID anywhere on a page.# app/views/inboxes/index.html.erb
++<%= turbo_stream_from "inbox_list" %>
<div id="inboxes">
<%= render @inboxes %>
</div>
inbox_list
turbo_stream_from
, you will see something like this in the console:inbox_list
in the model:# app/models/inbox.rb
class Inbox < ApplicationRecord
validates :name, presence: true, uniqueness: true
++broadcasts_to ->(inbox) { :inbox_list }
end
The above
<%= turbo_stream_from :inbox_list %>
<div id="inboxes">
a default partial - "inboxes/_inbox"
This will let you broadcast all activity (create, update, destroy).
Inbox.create(name: Faker::Quote.famous_last_words)
Inbox.first.update(name: "Edited at #{Time.zone.now}")
Inbox.first.destroy
broadcasts_to
is too magical. Letās unbuild it!broadcasts_to ->(inbox) { :inbox_list }
translates to:# app/models/inbox.rb
--broadcasts_to ->(inbox) { :inbox_list }
++
++broadcasts_to ->(inbox) { :inbox_list }, inserts_by: :append, target: 'inboxes'
# app/models/inbox.rb
class Inbox < ApplicationRecord
--broadcasts_to ->(inbox) { :inbox_list }
--
--broadcasts_to ->(inbox) { :inbox_list }, inserts_by: :append, target: 'inboxes'
++
++after_create_commit { broadcast_append_to "inbox_list" }
++after_update_commit { broadcast_replace_to "inbox_list" }
++after_destroy_commit { broadcast_remove_to "inbox_list" }
++
++# after_create_commit { broadcast_prepend_to "inbox_list" } # would add on top
++# after_update_commit { broadcast_update_to "inbox_list" } # would add dom_id(inbox) inside dom_id(inbox)
end
# app/models/inbox.rb
class Inbox < ApplicationRecord
--broadcasts_to ->(inbox) { :inbox_list }
--
--broadcasts_to ->(inbox) { :inbox_list }, inserts_by: :append, target: 'inboxes'
--
--after_create_commit { broadcast_append_to "inbox_list" }
--after_update_commit { broadcast_replace_to "inbox_list" }
--after_destroy_commit { broadcast_remove_to "inbox_list" }
--
--# after_create_commit { broadcast_prepend_to "inbox_list" } # would add on top
--# after_update_commit { broadcast_update_to "inbox_list" } # would add dom_id(inbox) inside dom_id(inbox)
++
++after_create_commit do
++ broadcast_append_to('inbox_list', target: 'inboxes', partial: "inboxes/inbox", locals: { inbox: self })
++end
++
++after_update_commit do
++ broadcast_replace_to('inbox_list', target: self, partial: "inboxes/inbox", locals: { inbox: self })
++end
++
++after_destroy_commit do
++ broadcast_remove_to('inbox_list', target: self)
++end
end
So, in a turbo_stream you can specify:
turbo_stream_from
broadcast (connection ID) to listen toDOM ID
(<div id="abc">
) that gets replaced/updated/appended/destroyedā¦ with a partial or HTMLI recommend to use explicity paths. No shortcut magic!
HTML
: Update inboxes count on create/destroy.div id
in the view that will be updated by the broadcast.turbo_stream_from
. You can use the same stream from above.# app/views/inboxes/index.html.erb
<%= turbo_stream_from "inbox_list" %>
<div id="inbox_count">
<%= @inboxes.count %>
</div>
div id
when an inbox is created/destroyedinbox_list
)# app/models/inbox.rb
after_commit :send_html_counter, on: [ :create, :destroy ]
def send_html_counter
broadcast_update_to('inbox_list', target: 'inbox_count', html: "There are #{Inbox.count} inboxes")
# broadcast_update_to('inbox_list', target: 'inbox_count', html: Inbox.count)
end
Now, you can create/destroy a record in the rails console
and the counter will be updated!
Partial
: Update inboxes count on create/destroy.# app/views/inboxes/_inbox_count.html.erb
Total inboxes:
<%= inbox_q %>
# app/views/inboxes/index.html.erb
<%= turbo_stream_from "inbox_list" %>
<div id="inbox_count">
<%= render partial: "inboxes/inbox_count", locals: {inbox_q: Inbox.count} %>
</div>
<div id="inbox_count">
# app/models/inbox.rb
after_commit :send_partial_counter, on: [ :create, :destroy ]
def send_partial_counter
broadcast_update_to('inbox_list', target: 'inbox_count', partial: "inboxes/inbox_count", locals: { inbox_q: Inbox.count })
end
Surely, you can also send a partial without locals ;)
button_to
Before rails 7.0.0rc
you might have a CSFR error when streaming button_to
:
`ensure_session_is_enabled!': Request forgery protection requires a working session store but your application has sessions disabled. You need to either disable request forgery protection, or configure a working session store. (ActionController::RequestForgeryProtection::DisabledSessionError)
For example, in a case like this:
# app/views/inboxes/_inbox.html.erb
<div id="<%= dom_id inbox %>">
<%= inbox.id %>
<%= inbox.name %>
++<%= button_to "Destroy this inbox", inbox_path(inbox), method: :delete %>
</div>
It can be fixed by adding:
# config/application.rb
++ config.action_controller.silence_disabled_session_errors = true
Previously I wrote about (4 ways to Turbo Stream ViewComponent). They wonāt work from a model.
However, there is a way:
bundle add view_component
rails g component inbox inbox
# app/components/inbox_component.html.erb
<div id="<%= dom_id inbox %>">
<%= inbox.name %>
<%= link_to "Show this inbox", inbox %>
<%= link_to "Edit this inbox", edit_inbox_path(inbox) %>
<%= button_to "Destroy this inbox", inbox_path(inbox), method: :delete %>
</div>
attr_reader :inbox
to be able to access inbox
without @inbox
# app/components/inbox_component.rb
class InboxComponent < ViewComponent::Base
attr_reader :inbox
def initialize(inbox:)
@inbox = inbox
end
end
<%= render(InboxComponent.with_collection(@inboxes)) %>
<%= render InboxComponent.new(inbox: Inbox.first) %>
# app/views/inboxes/_inbox.html.erb
<%= turbo_stream_from "inbox_list" %>
<div id="inboxes">
<%= render(InboxComponent.with_collection(@inboxes)) %>
<%#= render @inboxes %>
</div>
# app/models/inbox.rb
after_create_commit do
# these will not render the HTML
# InboxComponent.new(inbox: self)
# render_to_string(InboxComponent.new(inbox: self))
# view_context.render(InboxComponent.new(inbox: self))
# InboxComponent.new(inbox: self).render_in(view_context)
# this will:
broadcast_append_to('inbox_list', target: 'inboxes', html: ApplicationController.render(InboxComponent.new(inbox: self)))
end
messages
to inboxes
rails g scaffold message body:text inbox:references
# app/models/inbox.rb
has_many :messages
# app/models/message.rb
belongs_to :inbox
turbo_stream_from
target that is UNIQUE for this inbox# app/views/inboxes/show.html.erb
<%= render @inbox %>
<%= turbo_stream_from @inbox, :messages %>
<div id="<%= dom_id(@inbox, :messages) %>">
<%= render @inbox.messages %>
</div>
[inbox, :messages]
will stream to dom_id(@inbox, :messages)
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :inbox
# lets you use dom_id in a model
include ActionView::RecordIdentifier
after_create_commit do
broadcast_prepend_to [inbox, :messages], target: dom_id(inbox, :messages), partial: "messages/message", locals: { message: self }
# broadcast_prepend_to [inbox, :messages], target: ActionView::RecordIdentifier.dom_id(inbox, :messages)
end
after_update_commit do
broadcast_update_to [inbox, :messages], target: self, partial: "messages/message", locals: { message: self }
end
after_destroy_commit do
broadcast_remove_to [inbox, :messages], target: self
end
end
Inbox.first.messages.create body: SecureRandom.hex
Inbox.first.messages.last.update body: "hello world"
Inbox.first.messages.last.destroy
It is never recommended to use callbacks in a model.
I highly recommend to trigger broadcasts in controller actions instead.
This way, your code will be more predictable and reliable.
# app/controllers/messages_controller.rb
def destroy
Turbo::StreamsChannel.broadcast_update_to([inbox, :messages],
target: @message,
partial: "messages/message",
locals: { message: @message })
Turbo::StreamsChannel.broadcast_update_to('global_notifications',
target: 'flash',
partial: "shared/flash",
locals: { flash: flash })
end
dom_id
?!Hereās how ActionView::RecordIdentifier dom_id
works:
# dom_id(Inbox.first)
# => inbox_1
# dom_id(Inbox.first, :hello)
# => hello_inbox_1
Thatās it!
Official Turbo/Broadcastable docs
Next, I hope to explore Broadcasts + Devise + Authorization
]]>Install the latest version of Ruby 3, Rails 7, and Postgresql
rvm list
rvm get head
rvm install ruby-3.1.0
rvm --default use 3.1.0
gem install rails -v 7.0.1
gem update bundler
gem update rails
gem update --system
sudo apt update
sudo apt install postgresql libpq-dev redis-server redis-tools
ruby -v
rails -v
bundler -v
pg_config --version
Create app:
rails help
rails new askdemos -j=importmap -c=tailwind -d=postgresql
rails new askdemos -j=importmap -c=bootstrap -d=postgresql
To start the app, donāt use rails c
any more. You can use ./bin/dev
or bin/dev
to start it via Procfile.dev
In you are using Cloud9, you might want to do this:
# Procfile.dev
--web: bin/rails server -p 3000
++web: bin/rails server -p 8080
css: bin/rails tailwindcss:watch
BTW, if you get errors running ./bin/dev
, try running gem install foreman
.
Next Step - install Postgresql
Bonus: using Rails main in Gemfile
gem 'rails', git: 'https://github.com/rails/rails.git'
# or
# gem 'rails', github: 'rails/rails'
# Gemfile
gem "unicode-emoji" # emoji list and regex (for validations)
gem "unicode-sequence_name" # emoji names
# app/helpers/emoji_helper.rb
module EmojiHelper
# ["š¦šØ Ascension Island", "š¦š© Andorra"]...
EMOJI_LIST = Unicode::Emoji.list("Flags")["country-flag"].map do |c|
"#{c} #{Unicode::SequenceName.of(c).delete_prefix('FLAG:').downcase.gsub(/\b('?[^0-9])/) do
Regexp.last_match(1).capitalize
end }"
end
# [["š¦šØ Ascension Island", "š¦šØ"], ["š¦š© Andorra", "š¦š©"]...
def emoji_for_select
EMOJI_LIST.map do |k|
[k, k.split.first]
end
end
end
# app/views/posts/_form.html.erb
<%= form.select :icon, emoji_for_select, { include_blank: "Select a flag" }, {} %>
Thatās it!
]]>Here are some cool HTML elements that Iāve just recently learned about:
<datalist>
<datalist>
- lightweight autocomplete
Example of using <datalist>
in a rails form:
# app/models/post.rb
DEFAULT_COUNTRIES = ["European Union", "United States", "China", "Ukraine", "United Kingdom"].freeze
'default-countries'
)list
attribute to a text field, pointing to the datalist collection with an ID ('default-countries'
)# app/views/posts/_form.html.erb
<%= form.text_field :icon, list: 'default-countries', placeholder: "select or add your own" %>
<datalist id="default-countries">
<% Post::DEFAULT_COUNTRIES.each do |x| %>
<option value="<%= x %>"></option>
<% end %>
</datalist>
# app/views/posts/_form.html.erb
<%= form.text_field :icon, list: "default-countries", placeholder: "select or add your own" %>
<datalist id="default-countries">
<%= options_for_select(Post::DEFAULT_COUNTRIES) %>
</datalist>
<details>
<details>
- dropdowns without any extra CSS/JS!
<meter>
<meter>
- progress bar that changes color based on fill
<progress>
<progress>
- progress bar with ZERO extra CSS
time
, date
, date_time
fields in a rails app:
<%= form.time_select :published_at %>
<%= form.time_field :published_at %>
<%= form.date_select :published_at %>
<%= form.date_field :published_at %>
<%= form.datetime_select :published_at %>
<%= form.datetime_field :published_at %>
Safari view:
Firefox view:
I like the *_field
much more than *_select
variants in this case.
Surely, you can do it with some existing CSS stuff like Tailwind#pulse
But on this blog, we donāt like CSS frameworks.
So letās add something on our own!
Final result:
Example of the final result applied in a real application:
html
<div class="placeholder-item loader">
</div>
<div class="placeholder-item loader">
Loading...
</div>
<div class="placeholder-item" style="width: 200px;">
Loading...
</div>
loader
- the grey background, has nothing to do with the animationplaceholder-item
- the loading animationcss
.loader {
background-color: grey;
text-align: center;
border-radius: 12px;
padding: 6px;
}
.placeholder-item {
position: relative;
overflow: hidden;
}
.placeholder-item::before {
content: "";
z-index: 9999;
display: block;
position: absolute;
left: -150px;
top: 0;
height: 100%;
width: 50px;
background-image: linear-gradient(
to right,
rgba(144, 144, 144, 0),
rgb(224, 231, 233)
);
animation: load 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes load {
from {
left: -150px;
}
to {
left: 100%;
}
}
Very much inspired by Ferenc Almasiās post
]]>rails g scaffold post title
rails db:migrate
# app/models/post.rb
class Post < ApplicationRecord
++ validates :title, presence: true
end
# app/views/layouts/application.html.erb
++ <div id="modal"></div>
<%= yield %>
/* app/assets/application.css */
#modal {
position: absolute;
z-index: 2;
right: 10px;
width: 200px;
word-break: break-word;
border-radius: 6px;
background: #bad5ff;
}
Edit
form with Turbo Streams:Edit
button respond_to
method POST
, so that it can respond to a turbo_stream
:# config/routes.rb
-- resources :posts
++ resources :posts do
++ member do
++ post :edit
++ end
++ end
Edit
button_to
with method: :post
to the partial:# app/views/posts/_post.html.erb
++ <%= button_to "Edit", edit_post_path(post), method: :post %>
def edit
++ respond_to do |format|
++ format.turbo_stream do
++ # render turbo_stream: turbo_stream.update('modal', partial: "posts/form", locals: {post: @post})
++ render turbo_stream: turbo_stream.update('modal', template: "posts/edit", locals: {post: @post})
++ end
++ end
end
partial
or a template
.# app/controllers/posts_controller.rb
def update
respond_to do |format|
if @post.update(post_params)
++ format.turbo_stream do
++ render turbo_stream: [
++ turbo_stream.update(@post, partial: 'posts/post', locals: {post: @post}),
++ turbo_stream.update('modal', nil)
++ ]
end
format.html { redirect_to @post, notice: "Post was successfully updated." }
else
++ format.turbo_stream do
++ # render turbo_stream: turbo_stream.update('modal', partial: "posts/form", locals: {post: @post})
++ render turbo_stream: turbo_stream.update('modal', template: "posts/edit", locals: {post: @post})
++ end
format.html { render :edit, status: :unprocessable_entity }
end
end
end
// app/javascript/controllers/click2hide_controller.js
import { Controller } from "@hotwired/stimulus"
// <div data-controller="click2hide">
// <button data-action="click->click2hide#dismiss">
// Close
// </button>
export default class extends Controller {
connect() {
console.log("click2hide controller connected")
}
dismiss () {
this.element.remove();
}
}
# app/views/posts/_post.html.erb
++<div data-controller="click2hide">
<%= form_with(model: post) do |form| %>
<%= form.text_field :title %>
<%= form.submit %>
<% end %>
++<button data-action="click->click2hide#dismiss">
++ Cancel
++</button>
++</div>
Thatās it!
]]>Thereās a simple solution!
Just add this line:
# config/environments/development.rb
# Annotate rendered view with file names.
-- # config.action_view.annotate_rendered_view_with_filenames = true
++ config.action_view.annotate_rendered_view_with_filenames = true
Works for:
Resources:
]]>There are 2 ways to use Turbo Streams:
In most cases streaming from a controller action is enough.
Plan:
turbo_stream.erb
template!rails new askdemos -d=postgresql
rails g scaffold inbox name --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
# app/models/inbox.rb
++ validates :name, presence: true, allow_blank: false
# app/views/inboxes/index.html.erb
<div id="new_inbox">
<%= render partial: "inboxes/form", locals: { inbox: Inbox.new } %>
</div>
update
, not replace
# app/controllers/inboxes_controller.rb
def create
@inbox = Inbox.new(inbox_params)
respond_to do |format|
if @inbox.save
++ format.turbo_stream do
++ render turbo_stream: turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: Inbox.new })
++ end
format.html { redirect_to @inbox, notice: 'Inbox created.' }
else
++ format.turbo_stream do
++ render turbo_stream: turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: @inbox})
++ end
format.html { render :new, status: :unprocessable_entity }
end
end
end
# app/controllers/inboxes_controller.rb
def create
@inbox = Inbox.new(inbox_params)
respond_to do |format|
if @inbox.save
format.turbo_stream do
-- render turbo_stream: turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: Inbox.new })
++ render turbo_stream: [
++ turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: Inbox.new }),
++ turbo_stream.prepend('inboxes', partial: 'inboxes/inbox', locals: { inbox: @inbox })
++ ]
end
format.html { redirect_to @inbox, notice: 'Inbox created.' }
else
format.turbo_stream do
render turbo_stream: turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: @inbox })
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
Source:
# app/views/inboxes/_inbox.html.erb
<div id="<%= dom_id inbox %>" class="scaffold_record">
<p>
<strong>Name:</strong>
<%= inbox.name %>
</p>
<p>
<%= link_to "Show this inbox", inbox %>
++ <%= button_to "Destroy this inbox", inbox_path(inbox), method: :delete %>
</p>
</div>
remove
turbo_stream action is the only one that does not require a partial/html that will replace it.# app/controllers/inboxes_controller.rb
def destroy
@inbox.destroy
respond_to do |format|
++ format.turbo_stream { render turbo_stream: turbo_stream.remove(@inbox) }
format.html { redirect_to inboxes_url, notice: 'Inbox destroyed.' }
end
end
DISCLAIMER: Consider this approach experimental. I donāt really recommend this approach in production. You might want to use a turbo_frame
instead of this!
method: :post
- turbo_stream
does not respond to get
# app/views/inboxes/_inbox.html.erb
<div id="<%= dom_id inbox %>" class="scaffold_record">
<p>
<strong>Name:</strong>
<%= inbox.name %>
</p>
<p>
<%= link_to "Show this inbox", inbox %>
-- <%= link_to "Edit this inbox", edit_inbox_path(inbox) %>
++ <%= button_to "Edit this inbox", edit_inbox_path(inbox), method: :post %>
<%= button_to "Destroy this inbox", inbox_path(inbox), method: :delete %>
</p>
</div>
"inbox_#{@inbox.id}"
= @inbox
# app/controllers/inboxes_controller.rb
def edit
++ respond_to do |format|
++ format.turbo_stream do
++ render turbo_stream: turbo_stream.update(@inbox, partial: 'inboxes/form', locals: { inbox: @inbox })
++ end
++ end
end
edit
should respond to post
, not only to get
# config/routes.rb
-- resources :inboxes
++ resources :inboxes do
++ member do
++ post :edit
++ end
++ end
update
action:
_inbox
_form
# app/controllers/inboxes_controller.rb
def update
respond_to do |format|
if @inbox.update(inbox_params)
++ format.turbo_stream do
++ render turbo_stream: turbo_stream.update(@inbox, partial: 'inboxes/inbox', locals: { inbox: @inbox })
++ end
format.html { redirect_to @inbox, notice: "Inbox was successfully updated." }
else
++ format.turbo_stream do
++ render turbo_stream: turbo_stream.update(@inbox, partial: 'inboxes/form', locals: { inbox: @inbox })
++ end
format.html { render :edit, status: :unprocessable_entity }
end
end
end
# app/views/inboxes/index.html.erb
++<span id="inbox_count">
++ <%= @inboxes.count %>
++</span>
# app/controllers/inboxes_controller.rb
def create
@inbox = Inbox.new(inbox_params)
respond_to do |format|
if @inbox.save
format.turbo_stream do
render turbo_stream: [
turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: Inbox.new }),
turbo_stream.prepend('inboxes', partial: 'inboxes/inbox', locals: { inbox: @inbox })
++ # turbo_stream.update('inbox_count', html: "#{Inbox.count}")
++ turbo_stream.update('inbox_count', html: inboxes_count.html_safe)
]
end
format.html { redirect_to @inbox, notice: 'Inbox created.' }
else
format.turbo_stream do
render turbo_stream: turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: @inbox })
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
++ def inboxes_count
++ "<b>#{Inbox.count}</b>"
++ end
def destroy
@inbox.destroy
respond_to do |format|
-- format.turbo_stream { render turbo_stream: turbo_stream.remove(@inbox) }
++ format.turbo_stream do
++ render turbo_stream: [
++ turbo_stream.update('inbox_count', html: "#{Inbox.count}"),
++ turbo_stream.remove(@inbox)
++ ]
++ end
format.html { redirect_to inboxes_url, notice: 'Inbox destroyed.' }
end
end
# app/views/layouts/_messages.html.erb
<%= message %>
# app/views/layouts/application.html.erb
++<div id="notifications"></div>
<%= yield %>
# app/controllers/inboxes_controller.rb
# add this to any action
# update - replace current message if present
turbo_stream.update(:notifications, partial: 'layouts/messages', locals: { message: "#{Time.zone.now}" })
# prepend - add to list
# turbo_stream.prepend(:feed, partial: 'layouts/messages', locals: { message: "#{Time.zone.now}" })
turbo_stream.erb
template!Writing bulky turbo_streams in the conroller can feel wrong.
Instead, the correct way to respond to format.turbo_stream
is to render a template.
Example:
#app/controllers/inboxes_controller.rb
def create
@inbox = Inbox.new(inbox_params)
respond_to do |format|
if @inbox.save
format.turbo_stream
#app/views/inboxes/create.turbo_stream.erb
<%= turbo_stream.update "inbox_count" do %>
<%= render partial: 'count', locals: { inboxes_count: Inbox.count } %>
<% end %>
# app/controllers/inboxes_controller.rb
def destroy
@inbox.destroy
respond_to do |format|
++ format.turbo_stream { render turbo_stream: turbo_stream.update(@inbox, html: "Inbox #{@inbox.id} deleted") }
format.html { redirect_to inboxes_url, notice: "Inbox was successfully destroyed." }
end
end
#app/views/inboxes/_count.html.erb
<%= inboxes_count %>
#app/views/inboxes/index.html.erb
<div id="inbox_count">
<%= render partial: 'inboxes/count', locals: { inboxes_count: Inbox.count } %>
</div>
#app/controllers/inboxes_controller.rb
def create
...
respond_to do |format|
if @inbox.save
format.turbo_stream do
render turbo_stream: [
++ turbo_stream.update('inbox_count', partial: 'inboxes/count', locals: { inboxes_count: Inbox.count })
]
end
def destroy
...
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
++ turbo_stream.update('inbox_count', partial: 'inboxes/count', locals: { inboxes_count: Inbox.count })
]
end
app/javascript/controllers/autopaste_controller.js
import { Controller } from "@hotwired/stimulus"
// EXAMPLE 1: display input from a field in HTML
// <div class="field" data-controller="autopaste">
// <%= form.text_field :name, data: { autopaste_target: 'input', action: "keyup->autopaste#paste" } %>
// <span data-autopaste-target="output"></span>
// EXAMPLE 2: copy input from one field to other field
// <div data-controller="autopaste">
// <%= form.text_field :name, data: { autopaste_target: 'input', action: "keyup->autopaste#paste" } %>
// <%= form.text_field :slug, data: { autopaste_target: 'output' } %>
// </div>
export default class extends Controller {
static targets = [ "input", "output" ]
connect() {
console.log('autopaste in da house')
}
paste () {
// console.log(this.outputTarget.value)
// console.log(this.inputTarget.value)
this.outputTarget.value = this.inputTarget.value
}
paste_regex () {
// Alternatively, you could add regex to the output field. Example:
this.final = this.inputTarget.value.replace(/_/g, "-")
this.final = this.final.replace(/ /g, "-")
this.outputTarget.value = this.final
}
}
fix NoMethodError in Devise::RegistrationsController#create undefined method
user_urlā for #<Devise::RegistrationsController:0x0000000000de58>`
fix No route matches [GET] "/users/sign_out"
fix sign_in
- validation errors do not display
TLDR: add , data: { turbo: "false" }
/ "data-turbo" => "false"
to Devise forms
and buttons
Iāve been hearing that ādevise & turbo donāt play well yetā for many months, and I just waitedā¦
Well, itās been 12 months since turbo was announced (Dec 20, 2020).
Now we have Turbo 7.0, Rails 7, and the Devise maintainer says that Devise is ready.
So, letās try to install devise on a Rails 7 app (that uses Turbo by default).
gem devise
Gemfile - use main
-- gem 'devise'
++ gem 'devise', github: 'heartcombo/devise', branch: 'main'
rails generate devise:install
rails generate devise User
rails db:migrate
# app/controllers/application_controller.rb
before_action :authenticate_user!
log_out
link_to
button_to
with data: { turbo: "false" }
# app/views/layouts/application.html.erb
<% if signed_in? %>
<%= link_to current_user.email, edit_user_registration_path %>
-- <%#= link_to "Log out", destroy_user_session_path, method: :delete %>
-- <%#= button_to "Log out", destroy_user_session_path, method: :delete, form: { "data-turbo" => "false" } %>
++ <%= button_to "Log out", destroy_user_session_path, method: :delete, data: { turbo: "false" } %>
<% else %>
<%= link_to "Log in", new_user_session_path %>
<%= link_to "Register", new_user_registration_path %>
<% end %>
Alternatively, you can try a link_to
with data-turbo-method
delete
:
<%= link_to "Log Out", destroy_user_session_path, 'data-turbo-method': :delete %>
<%= link_to "Log out", destroy_user_session_path, data: { turbo_method: :delete } %>
We have to disable Turbo for devise (add data: {turbo: false}
to all devise forms).
This is illustrated as a passable solution in this discussion.
rails generate devise:views
# rails generate devise:views -v confirmations passwords registrations sessions
# app/views/devise/registrations/new.html.erb
-- <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
++ <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { data: { turbo: false} } ) do |f| %>
# app/views/devise/sessions/new.html.erb
-- <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
++ <%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { data: { turbo: false} } ) do |f| %>
Thatās it! Should work OK.
Iāve also updated the Devise Wiki
Yes, it is quite manual, but I do not see a better solution at the moment :(
]]>bundle add friendly_id
# will install the gem
#app/models/inbox.rb
extend FriendlyId
friendly_id :name
#app/controllers/inboxes_controller.rb
def set_inbox
-- @inbox = Inbox.find(params[:id])
++ @inbox = Inbox.friendly.find(params[:id])
end
:finders
- always add it!#app/models/inbox.rb
extend FriendlyId
-- friendly_id :name
++ friendly_id :name, use: [:finders]
#app/controllers/inboxes_controller.rb
def set_inbox
++ @inbox = Inbox.find(params[:id])
-- @inbox = Inbox.friendly.find(params[:id])
end
:slugged
- always add it too!rails g migration AddSlugToInboxes slug:uniq
# will add column that will store the friendly_id
#app/models/inbox.rb
extend FriendlyId
-- friendly_id :name, use: [:finders]
++ friendly_id :name, use: [:finders, :slugged]
Inbox.find_each(&:save)
# update the slug for all existing records
# in the above case, slug = name
#app/models/inbox.rb
extend FriendlyId
-- friendly_id :name, use: [:finders, :slugged]
++ friendly_id :generate_random_slug, use: [:finders, :slugged]
++ def generate_random_slug
++ slug? ? slug : SecureRandom.uuid
++ end
#app/models/inbox.rb
extend FriendlyId
-- friendly_id :name, use: [:finders, :slugged]
++ friendly_id :slug_candidates, use: [:finders, :slugged]
++ def slug_candidates
++ [
++ :name,
++ [:name, :description],
++ [:name, :descroption, :created_at]
++ ]
++ end
#app/models/inbox.rb
extend FriendlyId
friendly_id :name
++ def should_generate_new_friendly_id?
++ name_changed?
++ end
NOTE: this can be a bit buggy:
edit
name
to something invalidname
to something validActiveRecord::RecordNotFound (can't find record with friendly id: "ger"):
:history
. Customizing friendly_id.rb
All the new slugs will be saved in a separate database table -> you will be able to access records via their OLD URLs!
#app/models/inbox.rb
extend FriendlyId
-- friendly_id :name, use: [:finders, :slugged]
++ friendly_id :name, use: [:finders, :slugged, :history]
rails generate friendly_id
# create db/migrate/20211105220542_create_friendly_id_slugs.rb
# create config/initializers/friendly_id.rb
Note: This is a clean CRUD approach.
No responsive behaviours (they can be added with TURBO in the future).
Prerequisites:
button_to delete
, render :new, status: :unprocessable_entity
)app/views/shared/_flash
# console
rails generate model Comment user:references body:text commentable:references{polymorphic} deleted_at:datetime:index
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :user
belongs_to :commentable, polymorphic: true, inverse_of: :comments
has_many :comments, as: :commentable, dependent: :destroy
MIN_BODY_LENGTH = 2
MAX_BODY_LENGTH = 1000
validates :body, presence: true
validates :body, length: { minimum: MIN_BODY_LENGTH, maximum: MAX_BODY_LENGTH }
# soft delete
def destroy
update(deleted_at: Time.zone.now)
end
def find_top_parent
return commentable unless commentable.is_a?(Comment)
commentable.find_top_parent
end
end
# app/models/inbox.rb
has_many :comments, -> { order(created_at: :desc) }, as: :commentable, dependent: :destroy, inverse_of: :commentable
# config/routes.rb
resources :comments, only: [] do
resources :comments, only: %i[new create destroy], module: :comments
end
resources :inboxes do
resources :comments, only: %i[new create destroy], module: :inboxes
end
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :set_commentable
def new
@comment = Comment.new
end
def create
@comment = @commentable.comments.build(comment_params)
if @comment.save
redirect_to @commentable unless @commentable.is_a?(Comment)
redirect_to @commentable.find_top_parent if @commentable.is_a?(Comment)
flash[:notice] = 'Comment created'
else
render :new, status: :unprocessable_entity
end
end
def destroy
@comment = Comment.find(params[:id])
if @comment.destroy
redirect_to @commentable unless @commentable.is_a?(Comment)
redirect_to @commentable.find_top_parent if @commentable.is_a?(Comment)
flash[:notice] = 'Comment deleted'
else
redirect_to @commentable, alert: 'Something went wrong'
end
end
private
# not very nice, in my opinion
# def set_commentable
# if params[:inbox_id].present?
# @commentable = Inbox.find(params[:inbox_id])
# elsif params[:comment_id]
# @commentable = Comment.find(params[:comment_id])
# else
# "SOME ERROR"
# end
# end
def comment_params
params.require(:comment).permit(:body).merge(user: current_user)
end
end
If-Else
mess.# controllers/inboxes/comments_controller.rb
module Inboxes
class CommentsController < CommentsController
private
def set_commentable
@commentable = Inbox.find(params[:inbox_id])
end
end
end
# app/controllers/comments/comments_controller.rb
module Comments
class CommentsController < CommentsController
private
def set_commentable
@commentable = Comment.find(params[:comment_id])
end
end
end
-> NEW
# app/views/comments/new.html.erb
New comment for
<%= link_to @commentable.name, @commentable unless @commentable.is_a?(Comment) %>
<%= link_to @commentable.find_top_parent.name, @commentable.find_top_parent if @commentable.is_a?(Comment) %>
<%= render 'comments/form', comment: @comment %>
-> FORM
# app/views/comments/_form.html.erb
<%= form_with(model: [@commentable, comment]) do |form| %>
<%= render 'shared/errors', form: form %>
<div class="field">
<%= form.text_area :body,
style: "width: 100%",
maxlength: Comment::MAX_BODY_LENGTH,
placeholder: 'Add a comment here' %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
-> SHOW
# app/views/comments/_comment.html.erb
<div class='comment'>
<% if comment.deleted_at.present? %>
<i>Comment has been deleted</i>
<% else %>
<%= comment.created_at %>
by
<%= link_to comment.user.name, comment.user %>
<%= simple_format(comment.body) %>
<%= button_to 'Delete', [@commentable, comment], method: :delete %>
<% end %>
comments:
<%= comment.comments.count %>
<%= link_to 'Reply', new_comment_comment_path(comment) %>
<br>
<%= render comment.comments %>
</div>
# app/controllers/inboxes_controller.rb
def show
@commentable = @inbox
@comment = Comment.new
@comments = @inbox.comments
end
# app/views/inboxes/show.html.erb
<%= render template: 'comments/new' %>
<%= render @comments %>
app/assets/stylesheets/application.css
.comment {
margin: 1em 0em 1em 1em;
padding-left: 1em;
border-left: 2px solid lightgray;
}
<%= link_to "return", request.referrer %>
to get to the previous page, but it is better to use
<%= link_to "return", :back %>
because the :back
thing provides the following code logic:
request.referrer || "javascript:history.back()"
<%= link_to "return", request.referrer %>
# Will generate an absolute url to origin page.
# If no previous page, will be an url to CURRENT page
javascript:history.back()
# The javascript leverages in-browser navigation.
# If no previous page, will be link to something like "New tab"
Rails/UniqueValidationWithoutIndex
rails g scaffold inbox name
rails g scaffold message inbox:references body:text
# app/models/inbox.rb
validates :name, uniqueness: true
class CreateInboxes < ActiveRecord::Migration[7.0]
def change
create_table :inboxes do |t|
t.string :name, null: false
++ t.string :name, null: false, index: { unique: true }
t.timestamps
end
++ # add_index :messages, :body, unique: true
end
end
# app/models/message.rb
validates :body, uniqueness: { scope: :inbox_id }
class CreateMessages < ActiveRecord::Migration[7.0]
def change
create_table :messages do |t|
t.references :inbox, null: false, foreign_key: true
t.text :body, null: false
t.timestamps
end
++ add_index :messages, [:body, :inbox_id], unique: true
end
end
render_to_string(PaginationComponent.new(results: @results))
view_context.render(PaginationComponent.new(results: @results))
PaginationComponent.new(results: @results).render_in(view_context)
# most universal:
ApplicationController.render(PaginationComponent.new(results: @results), layout: false)
All work, use either one with Turbo Streams:
# app/controllers/hello_controller.rb
def some_action
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
# turbo_stream.update('inboxes-pagination', render_to_string(PaginationComponent.new(results: @results))),
# turbo_stream.update("inboxes-pagination", view_context.render(PaginationComponent.new(results: @results))),
turbo_stream.update("inboxes-pagination", PaginationComponent.new(results: @results).render_in(view_context))
]
end
end
end
some_action.turbo_stream.erb
template:# app/controllers/hello_controller.rb
def some_action
respond_to do |format|
format.turbo_stream
end
end
# index.turbo_stream.erb
<%= turbo_stream.update "inventory-pagination" do %>
<%= render PaginationComponent.new(results: @results) %>
<% end %>
Thatās it!
]]>pagination
= tabs
.
From this point of view, Itās easy to add pagination/tabs by any attribute.
For example, pagination/tabs by date:
rails g scaffold post name category published_at:datetime
rails db:migrate
# config/seeds.rb
5.times do
random_date = Time.at(rand * Time.now.to_i)
random_category = %w[ruby python java].sample
post = Post.create(name: SecureRandom.hex,
category: random_category,
published_at random_date)
end
# app/controllers/posts_controller.rb
def index
@posts = if params[:date].present?
Post.where(published_at: params[:date])
else
Post.where(published_at: Date.today)
end
end
# app/views/posts/index.html.erb
Publication date:
<% Inbox.pluck(:published_at).uniq.sort.each do |i| %>
<br>
<%= link_to_unless_current i.strftime('%d/%m/%y'), inboxes_path(date: i) %>
<% end %>
<br>
<div id="inboxes">
<%= render @inboxes %>
</div>
# app/controllers/posts_controller.rb
def index
@posts = if params[:category].present?
Post.where(category: params[:category])
else
Post.all
end
end
# app/views/posts/index.html.erb
Categories:
<% Post.all.pluck(:category).uniq.sort.each do |category| %>
<%= link_to_unless_current category, posts_path(category: category) %>
<% end %>
<%= link_to "Clear filters", request.path if request.query_parameters.any? %>
<br>
<div id="inboxes">
<%= render @inboxes %>
</div>
Here are a few lines of CSS that I add to new Rails apps that leverage rails 7 scaffold templates.
app/assets/application.css
/* display turbo frame - good for development */
turbo-frame {
display: block;
border: 1px solid blue;
}
/* error messages */
#error_explanation {
color: red;
border-radius: 6px;
border: 2px solid red;
padding: 6px;
margin-bottom: 5px;
}
/* scaffold partial like inboxes/_inbox.html.erb */
.scaffold_record {
border-radius: 6px;
border: 2px solid #a3a3a3;
padding: 6px;
word-wrap: break-word;
margin-bottom: 5px;
}
/* default flash message */
#notice {
border-radius: 6px;
padding: 6px;
color: white;
background: green;
}
/* center content, max width */
body {
margin: 0 auto;
max-width: 500px;
display: block;
}
/* a new default font */
html, body {
font-family: sans-serif;
background-color: rgb(247, 250, 252);
}
/* to not always have links highlighted (often comes with bootstrap) */
a:link { text-decoration:none; }
a { text-decoration: none; }
Next step - adding some CSS for the form inputs
You can also add a bottom footer like this
Other things I do when setting up a new Rails project:
rails g controller static_pages landing_page pricing privacy terms
errors
, flash
, footer
, navigation
console
bundle add pagy
#app/controllers/application_controller.rb
++ include Pagy::Backend
#app/helpers/application_helper.rb
++ include Pagy::Frontend
#app/controllers/inboxes_controller.rb
def index
-- @inboxes = Inbox.order(created_at: :desc)
++ # @pagy, @posts = pagy(Inbox.order(created_at: :desc), items: 5)
++ @pagy, @inboxes = pagy(Inbox.order(created_at: :desc))
end
# config/initializers/pagy.rb
# See https://ddnexus.github.io/pagy/api/pagy#instance-variables
Pagy::DEFAULT[:page] = 1 # default page to start with
Pagy::DEFAULT[:items] = 3 # items per page
Pagy::DEFAULT[:cycle] = true # when on last page, click "Next" to go to first page
require 'pagy/extras/items'
Pagy::DEFAULT[:max_items] = 100 # max items possible per page
require 'pagy/extras/overflow'
Pagy::DEFAULT[:overflow] = :last_page # default (other options: :empty_page and :exception)
# app/views/inboxes/index.html.erb
++ <%= @inboxes.count %> <!-- items on this page -->
++ <%= @pagy.count %> <!-- items in total -->
++ <%= link_to_unless_current "10", inboxes_path(items: 10) %>
++ <%= link_to_unless_current "50", inboxes_path(items: 50) %>
++ <%== pagy_nav(@pagy) %>
-- <%#= raw pagy_nav(@pagy) %>
-- <%#== pagy_bootstrap_nav(@pagy) %>
request.url
to see the search query inside the frame# app/views/inboxes/index.html.erb
++ <%= turbo_frame_tag 'search' do %>
<%= link_to_unless_current "10", inboxes_path(items: 10) %>
<%= link_to_unless_current "50", inboxes_path(items: 50) %>
++ <%= link_to 'Clear search', request.path if request.query_parameters.any? %>
++ <%= request.url %>
<div id="inboxes">
<%= render @inboxes %>
</div>
<%== pagy_nav(@pagy) %>
++ <% end %>
HOWEVER in the above case, ALL navigation is scoped to the turbo frame.
You will want to make only pagination links work within the turbo_frame, so that you can navigate to an Inbox/show page, for example
, target: '_top'
to the turbo_frame_tag
, data: { turbo_frame: 'search' }
to the links that should be scoped to the search
frame# app/views/inboxes/index.html.erb
<%= turbo_frame_tag 'search', target: '_top' do %>
-- <%= link_to_unless_current "10", inboxes_path(items: 10) %>
-- <%= link_to_unless_current "50", inboxes_path(items: 50) %>
-- <%= link_to 'Clear search', request.path if request.query_parameters.any? %>
++ <%= link_to_unless_current "3", inboxes_path(items: 3), data: { turbo_frame: 'search' } %>
++ <%= link_to_unless_current "10", inboxes_path(items: 10), data: { turbo_frame: 'search' } %>
++ <%= link_to 'Clear search', request.path, data: { turbo_frame: 'search' } if request.query_parameters.any? %>
<%= request.url %>
<div id="inboxes">
<%= render @inboxes %>
</div>
<%== pagy_nav(@pagy) %>
<% end %>
Next, update the <%== pagy_nav(@pagy) %>
:
# app/controllers/inboxes_controller.rb
def index
-- @pagy, @inboxes = pagy(Inbox.order(created_at: :desc))
++ @pagy, @inboxes = pagy(Inbox.order(created_at: :desc), link_extra: 'data-turbo-frame="search"')
end
Gemfile
gem "view_component", require: "view_component/engine"
# console
bin/rails generate component Pagination results
# app/components/pagination_component.rb
class PaginationComponent < ViewComponent::Base
++ include Pagy::Frontend
++ attr_reader :results
def initialize(results:)
@results = results
end
end
# app/components/pagination_component.html.erb
++ <%== pagy_nav(results) %>
# app/views/inboxes/index.html.erb
++ <%= render PaginationComponent.new(results: @pagy) %>
Thatās it!
Althrough, it is a problem that there is no simple way to update URL when using turbo.
This way, when you refresh the page, the filters and page donāt persist.
However there is a PR for this
If I find a reliable way to do it with Turbo Drive, I will add it here.
Resources:
]]>If a page has a turbo frame with lazy loading, the lazy loading will occur only when the frame element becomes not āhiddenā.
If you than change the state back to hidden, the loaded content will stay.
<details>
<summary>Details</summary>
<%= turbo_frame_tag 'new_inbox', src: new_inbox_path, loading: :lazy do %>
Loading...
<% end %>
</details>
=> This is great for having a lot of content that should not be directly visible.
Think Tabs, Dropdowns, Modals.
]]>When doing CRUD via turbo, without page redirect, you would STILL want to inform user with a flash message, right?
notice
, alert
flash.now[:success]
- available only in current action (good for turbo)flash[:success]
- available in next action (good for redirect)redirect_to
: redirect_to inboxes_path, notice: "Inbox '#{inbox.id}' deleted."
redirect_to inboxes_path, flash: {new_type: "Inbox '#{inbox.id}' deleted."}
#app/views/shared/_flash.html.erb
<div id="flash">
<% flash.each do |key, value| %>
<%= content_tag :div, value, id: key %>
<% end %>
</div>
#app/views/layouts/application.html.erb
<%= render 'shared/flash' %>
#app/assets/stylesheets/application.css
#notice {
border-radius: 6px;
padding: 6px;
color: white;
background: green;
}
#alert {
border-radius: 6px;
padding: 6px;
color: white;
background: red;
}
#app/views/inboxes/index.html.erb
++ <div id="new_inbox">
++ <%= render partial: "inboxes/form", locals: { inbox: Inbox.new } %>
++ </div>
<div id="inboxes">
<%= render @inboxes %>
</div>
#app/controllers/inboxes_controller.rb
def create
@inbox = Inbox.new(inbox_params)
respond_to do |format|
if @inbox.save
++ format.turbo_stream do
++ render turbo_stream: [
++ turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: Inbox.new }),
++ turbo_stream.prepend('inboxes', partial: 'inboxes/inbox', locals: { inbox: @inbox }),
++ ]
++ end
format.html { redirect_to @inbox, notice: 'Inbox created.' }
else
++ format.turbo_stream do
++ render turbo_stream: [
++ turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: @inbox }),
++ ]
++ end
format.html { render :new, status: :unprocessable_entity }
end
end
end
def destroy
@inbox.destroy
respond_to do |format|
++ format.turbo_stream do
++ render turbo_stream: [
++ turbo_stream.remove(@inbox)
++ ]
end
format.html { redirect_to inboxes_url, notice: 'Inbox destroyed.' }
end
end
#app/controllers/inboxes_controller.rb
def create
@inbox = Inbox.new(inbox_params)
respond_to do |format|
if @inbox.save
++ flash.now[:notice] = "Inbox #{@inbox.id} created!"
format.turbo_stream do
render turbo_stream: [
turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: Inbox.new }),
turbo_stream.prepend('inboxes', partial: 'inboxes/inbox', locals: { inbox: @inbox }),
++ turbo_stream.update("flash", partial: "shared/flash")
]
end
format.html { redirect_to @inbox, notice: 'Inbox created.' }
else
++ flash.now[:alert] = "Something went wrong"
format.turbo_stream do
render turbo_stream: [
turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: @inbox }),
++ turbo_stream.update("flash", partial: "shared/flash")
]
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
def destroy
@inbox.destroy
++ flash.now[:alert] = "Inbox #{@inbox.id} destroyed!"
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
++ turbo_stream.update("flash", partial: "shared/flash"),
turbo_stream.remove(@inbox)
]
end
format.html { redirect_to inboxes_url, notice: 'Inbox destroyed.' }
end
end
#app/controllers/application_controller.rb
++ def render_turbo_flash
++ turbo_stream.update("flash", partial: "shared/flash")
++ end
#app/controllers/inboxes_controller.rb
def create
@inbox = Inbox.new(inbox_params)
respond_to do |format|
if @inbox.save
++ flash.now[:notice] = "Inbox #{@inbox.id} created!"
format.turbo_stream do
render turbo_stream: [
turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: Inbox.new }),
turbo_stream.prepend('inboxes', partial: 'inboxes/inbox', locals: { inbox: @inbox }),
++ render_turbo_flash,
-- turbo_stream.update("flash", partial: "shared/flash")
]
end
format.html { redirect_to @inbox, notice: 'Inbox created.' }
else
++ flash.now[:alert] = "Something went wrong"
format.turbo_stream do
render turbo_stream: [
turbo_stream.update('new_inbox', partial: 'inboxes/form', locals: { inbox: @inbox }),
++ render_turbo_flash,
-- turbo_stream.update("flash", partial: "shared/flash")
]
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
def destroy
@inbox.destroy
++ flash.now[:alert] = "Inbox #{@inbox.id} destroyed!"
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
++ render_turbo_flash,
-- turbo_stream.update("flash", partial: "shared/flash"),
turbo_stream.remove(@inbox)
]
end
format.html { redirect_to inboxes_url, notice: 'Inbox destroyed.' }
end
end
app/javascript/controllers/autohide_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
setTimeout(() => {
this.dismiss()
}, 5000)
}
dismiss() {
this.element.remove()
}
}
# app/views/shared/_flash.html.erb
<div id="flash">
++<div data-controller="autohide">
<% flash.each do |key, value| %>
<%= content_tag :div, value, id: "#{key}" %>
<% end %>
++</div>
</div>
# app/views/layouts/application.html.erb
++<div id="flash" style="position:absolute; z-index:2; right:10px; width:200px;">
<%= render 'shared/flash' %>
++</div>
# app/views/shared/_flash.html.erb
--<div id="flash">
<div data-controller="autohide">
<% flash.each do |key, value| %>
<%= content_tag :div, value, id: "#{key}" %>
<% end %>
</div>
--</div>
-- turbo_stream.update("flash", partial: "shared/flash")
++ turbo_stream.prepend("flash", partial: "shared/flash")
Thatās it!
]]>For example title
, description
, favicon
help with for SEO and visibility:
og:image
, og:title
, og:description
, og:url
, make link previews look nice when sharing on social:
title
tag makes browser tab readable:
Hereās how basic meta tags can look in the <head>
of an HMTL document:
<head>
<title>Playlists | SupeRails</title>
<link rel="icon" type="image/x-icon" href="/images/favicon.ico">
<meta name="description" content="Gem Meta Tags for better SEO">
<meta name="author" content="Yaroslav Shmarov">
<meta name='copyright' content='SupeRails'>
<meta name='language' content='EN'>
<meta name='robots' content='index,follow'>
<meta name='revised' content='Sunday, July 18th, 2010, 5:15 pm'>
<!-- <meta name='revised' content="<%= @post.updated_at.strftime('%A, %B %eth, %Y, %l:%M %P')"> -->
<meta property="og:title" content="Title of the shared link">
<meta property="og:description" content="Description of the content">
<meta property="og:image" content="URL of the image">
<meta property="og:url" content="URL of the shared link">
<meta property="og:type" content="Type of content">
<meta property="og:site_name" content="Name of the website">
<meta property="og:locale" content="Language and country of the content">
<meta name="twitter:card" content="Type of Twitter card">
<meta name="twitter:title" content="Title of the shared link">
<meta name="twitter:description" content="Description of the content">
<meta name="twitter:image" content="http://blog.corsego.com/assets/images/og/posts/meta-tags-without-a-gem.png">
<meta name="twitter:url" content="https://blog.corsego.com/meta-tags-without-a-gem">
<meta name="twitter:site" content="@rails">
<meta name="twitter:creator" content="@yarotheslav"></head>
ā¹ļø the keywords
meta tags is no longer relevant ā ļø
- <meta content="seo, meta-tags, ruby, rails" name="keywords"/>
First, add the favicon image into the app/assets/images/thumbnail.png
folder.
# app/views/layouts/application.html.erb
<head>
<%= favicon_link_tag 'thumbnail.png' %>
# produces
<link rel="icon" type="image/x-icon" href="/assets/thumbnail.png">
Great in-depth article: How to Favicon in 2024: Six files that fit most needs
<title>
and other meta tagsIf a page has a custom title, display it. Otherwise display just āSupeRailsā:
# app/views/layouts/application.html.erb
<title><%= content_for?(:title) ? yield(:title) : "SupeRails" %></title>
<%= yield :meta_tags %>
Now you can add a title tag to any page:
# app/views/posts/index.html.erb
<% content_for :title do %>
<%= pluralize Post.count, 'post' %>
|
<%#= Rails.application.class.parent_name %>
<% end %>
The above generates a title 5 posts | SupeRails
.
Set other meta tags; assuming you stored an image in app/assets/images/banner.png
:
# app/views/posts/index.html.erb
<% content_for :meta_tags do %>
<meta name="og:description" content="<%= @event.location %>">
<meta name="og:image" content="<%= asset_url('banner.png') %>">
<% end %>
# app/views/posts/show.html.erb
<% content_for :title do %>
<%= @post.title %>
|
<%= Rails.application.class.module_parent_name %>
<% end %>
# => "How to create meta tags | SupeRails"
Assuming you have a Post
model that has_one_attached :cover_image
, you can use it OR fallback to a default image if nothing is attached:
def og_image(post)
post.cover_image.attached? ? url_for(post.cover_image) : asset_url('banner.png')
end
<meta name="og:image" content="<%= og_image(post) %>">
For more complex behaviour and more meta_tag types (like description
, tags/keywords
, image
, OpenGraph), better use gem meta-tags.
# console
bundle add meta-tags
rails generate meta_tags:install
Add display_meta_tags
to the layout, where you can set the default meta tags for all pages:
# app/views/layouts/application.html.erb
<head>
<%= display_meta_tags site: Rails.application.class.module_parent.name,
description: 'Modern Ruby on Rails screencasts',
keywords: 'ruby, rails, ruby-on-rails',
reverse: true, # app name at the end like `5 posts | SupeRails`
image: asset_url("logo.png"),
og: {
image: asset_url("logolong.png"),
}
icon: "/favicon.png", type: "image/png"
%>
</head>
Override the default meta tags from a controller action with set_meta_tags
:
# app/controllers/posts_controller.rb
def index
set_meta_tags title: "#{Post.size} posts"
end
def show
set_meta_tags title: @post.name,
description: @post.description,
keywords: @post.tags.pluck(:name).split(', ')
end
def new
set_meta_tags title: "#{action_name.capitalize} #{controller_name.singularize}" # New post
end
You can also customize the settings in a config file:
# config/initializers/meta_tags.rb
MetaTags.configure do |config|
config.title_limit = 200
config.truncate_site_title_first = true
end
Example implementations:
ā¹ļø OpenGraph API meta tags are used to improve the appearance and functionality of shared links on social media platforms
Use Ngrok to get a public URL to your localhost, and use a meta tag previe tool to test how your website renders.
Preview tools:
opengraph.xyz - VERY GOOD PREVIEW TOOL ā
Twitter OG cards docs. They have discontinued their preview tool. Log in, click on ācreate postā, paste a link, and wait for it to render.
š” There are many tags and it can seem a bit overwhelming to set them all. First focus on the more important meta tags, than do the other ones.
š” Open Graph image generator sounds like a good indie SaaS idea:
]]>In the previous post we did tabbed content with turbo streams by replacing a DOM ID with a template served by a POST request.
Now we will add tabbed content functionality with turbo frames.
Use the boilerplate functionality from the previous post.
app/config/routes.rb
resources :projects do
member do
get :comments
get :tasks
end
end
partial
or template
. I just prefer partial here. Does not matter much.turbo_frame_request?
- to make this format.html
available ONLY via turbo request, not as a separate pageelse redirect
- if someone tries to open a tab in a new tabturbo_frame_request
source code
app/controllers/projects_controller.rb
def comments
if turbo_frame_request?
respond_to do |format|
format.html { render partial: 'projects/comments',
locals: { comments: @project.comments, project: @project }}
end
else
redirect_to @project, alert: "Not allowed"
end
end
def tasks
if turbo_frame_request?
respond_to do |format|
format.html { render partial: 'projects/tasks',
locals: { tasks: @project.tasks, project: @project }}
end
else
redirect_to @project, alert: "Not allowed"
end
end
app/views/projects/_tabs.html.erb
<%= request.url %>
<br>
<%= link_to "Tasks", tasks_project_path(@project),
style: "#{"font-weight: bold" if current_page?(tasks_project_path(@project))}" %>
<%= link_to "Comments", comments_project_path(@project),
style: "#{"font-weight: bold" if current_page?(comments_project_path(@project))}" %>
app/views/projects/show.html.erb
<%= turbo_frame_tag 'frame_dropdowns' do %>
<%= render partial: "projects/tabs" %>
<% end %>
tasks
and comments
into a turbo_frame_tag
_tabs
partialapp/views/projects/_tasks.html.erb
<%= turbo_frame_tag 'frame_dropdowns' do %>
<%= render partial: 'projects/tabs' %>
<h3>
Tasks for project #
<%= project.id %>
</h3>
<ul>
<% tasks.each do |task| %>
<li>
<%= task.id %>
<%= task.name %>
</li>
<% end %>
</ul>
<% end %>
app/views/projects/_comments.html.erb
<%= turbo_frame_tag 'frame_dropdowns' do %>
<%= render partial: 'projects/tabs' %>
<h3>
Comments for project #
<%= project.id %>
</h3>
<ul>
<% comments.each do |comment| %>
<li>
<%= comment.id %>
<%= comment.name %>
</li>
<% end %>
</ul>
<% end %>
app/views/projects/show.html.erb
<%= turbo_frame_tag 'frame_dropdowns' do %>
<%= render partial: "projects/tasks", locals: { project: @project, tasks: @project.tasks } %>
<% end %>
class ProjectsController < ApplicationController
before_action :set_project
before_action :turbo_frame_request_variant
def show
end
def comments
respond_to do |format|
format.html { render template: 'projects/comments',
locals: { comments: @project.comments, project: @project }}
end
end
def tasks
respond_to do |format|
format.html { render template: 'projects/tasks',
locals: { tasks: @project.tasks, project: @project }}
end
end
private
def turbo_frame_request_variant
request.variant = :turbo_frame if turbo_frame_request?
end
def set_project
@project = Project.find(params[:id])
end
end
+turbo_frame
estention to the templates to respond with them
-- _comments.html.erb
-- _tasks.html.erb
++ comments.html+turbo_frame.erb
++ tasks.html+turbo_frame.erb
Template is missing
error.You can achieve adding tabbed content with both, turbo frames and turbo streams.
Hereās how you can do it with turbo streams.
console
rails g resource project name
rails g model task name project:references
rails g model comment name project:references
rails db:migrate
project.rb
has_many :tasks
has_many :comments
console
rails c
Project.create name: SecureRandom.hex
Project.first.tasks << Task.create(name: SecureRandom.hex)
Project.first.tasks << Task.create(name: SecureRandom.hex)
Project.first.comments << Comment.create(name: SecureRandom.hex)
Project.first.comments << Comment.create(name: SecureRandom.hex)
app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
before_action :set_project
def show
end
private
def set_project
@project = Project.find(params[:id])
end
end
app/views/projects/_comments.html.erb
Comments for
<%= project.name %>
<br>
<% comments.each do |comment| %>
<%= comment.id %>
<%= comment.name %>
<br>
<% end %>
app/views/projects/_tasks.html.erb
Tasks for
<%= project.name %>
<br>
<% tasks.each do |task| %>
<%= task.id %>
<%= task.name %>
<br>
<% end %>
app/views/projects/show.html.erb
<%= @project.id %>
<hr>
<%= render partial: 'projects/tasks', locals: { project: @project, tasks: @project.tasks } %>
<hr>
<%= render partial: 'projects/comments', locals: { project: @project, comments: @project.comments } %>
#config/routes.rb
resources :projects do
member do
post :dropdowns
end
end
#app/controllers/projects_controller.rb
def dropdowns
respond_to do |format|
format.turbo_stream do
render turbo_stream:
turbo_stream.update('dropdown_target', partial: "projects/#{params[:type]}", locals: { tasks: @project.tasks, comments: @project.comments, project: @project })
end
end
end
current_page
with non-get requestsapp/views/projects/_tabs.html.erb
<% [:tasks, :comments].each do |type| %>
<%= button_to dropdowns_project_path(@project, type: type), method: :post do %>
<%= content_tag :span, type, style: "#{"font-weight: bold" if request.fullpath.eql?(dropdowns_project_path(@project, type: type))}" %>
<% end %>
<% end %>
app/views/projects/show.html.erb
<div id="dropdown_target">
<%= render partial: 'projects/tabs' %>
this will be replaced by tasks or comments partials
</div>
app/views/projects/_comments.html.erb
++ <%= render partial: 'projects/tabs' %>
...
app/views/projects/_comments.html.erb
++ <%= render partial: 'projects/tabs' %>
...
app/config/routes.rb
resources :projects do
member do
++ post :comments
++ post :tasks
-- post :dropdowns
end
end
def comments
respond_to do |format|
format.turbo_stream do
render turbo_stream:
turbo_stream.update('dropdown_target',
partial: "projects/comments",
locals: { comments: @project.comments, project: @project })
end
end
end
def tasks
respond_to do |format|
format.turbo_stream do
render turbo_stream:
turbo_stream.update('dropdown_target',
partial: "projects/tasks",
locals: { tasks: @project.tasks, project: @project })
end
end
end
<%= button_to tasks_project_path(@project), method: :post do %>
<%= content_tag :span, "Tasks", style: "#{"font-weight: bold" if request.fullpath.eql?(tasks_project_path(@project))}" %>
<% end %>
<%= button_to comments_project_path(@project), method: :post do %>
<%= content_tag :span, "Comments", style: "#{"font-weight: bold" if request.fullpath.eql?(comments_project_path(@project))}" %>
<% end %>
_tabs
partial for tasks
to be current both for base url and url with paramsapp/views/projects/show.html.erb
<div id="dropdown_target">
<%= render partial: 'projects/tasks', locals: { project: @project, tasks: @project.tasks } %>
this will be replaced by tasks or comments
</div>
Consideration: you might want to have an URL to be also changed/be available to something like /projects/1/tasks
or projects/1/comments
. This is not available by default so
Thatās it!
Althrough, it might seem unnatural to use non-get requests for tabbed content.
We can achieve a similar result with turbo frames.
]]>rails new omnify -d=postgresql
cd omnify
rails db:setup
rails g controller static_pages landing_page
routes.rb
root 'static_pages#landing_page'
bundle add devise
rails g devise:install
rails generate devise User
rails db:migrate
application.html.erb
<%= notice %>
<%= alert %>
<% if current_user %>
<%= link_to current_user.email, edit_user_registration_path %>
<%= link_to "Log out", destroy_user_session_path, method: :delete %>
<% else %>
<%= link_to "Log in", new_user_session_path %>
<%= link_to "Register", new_user_registration_path %>
<% end %>
Disable unused devise routes in routes.rb:
devise_for :users, controllers: {omniauth_callbacks: "users/omniauth_callbacks"},
skip: [:sessions, :registrations]
Disable unused devise resources in user.rb:
devise :database_authenticatable,
#:registerable,
#:recoverable,
:rememberable,
:validatable,
:omniauthable, omniauth_providers: [:github]
This will disable the users/sign_up
routes.
users/sign_in
will still work. Existing users will still see a field to sign in.
You might want to generate the devise views for users/sessions/new
and remove the login form, if you want only social login:
rails generate devise:views -v sessions
Now you have the following routes:
rails routes | grep devise
new_user_session GET /users/sign_in(.:format) devise/sessions#new
user_session POST /users/sign_in(.:format) devise/sessions#create
destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy
That translate to:
routes.rb
devise_for :users, controllers: {omniauth_callbacks: "users/omniauth_callbacks"},
skip: [:sessions, :registrations]
# as :user do
devise_scope :user do
get "/users", to: "devise/sessions#new", as: :new_user_session
post "/users/sign_in", to: "devise/sessions#create", as: :user_session
delete "/users/sign_out", to: "devise/sessions#destroy", as: :destroy_user_session
end
You might want to disable the get
route to remove users/sign_in
- Not really recommended.
if post.published?
'published'
else
'draft'
end
can be written like this
post.published? 'published' : 'draft'
b) This
if post.published?
'published'
elsif post.draft?
'draft'
else
'archived'
end
can be written like this
post.published? ? 'published' : post.draft? 'draft' : 'archived'
rails g scaffold inbox name
rails db:migrate
rails c
5.times { Inbox.create(name: SecureRandom.hex) }
rails s
#app/controllers/inboxes_controller.rb
def index
if params[:name].present?
@inboxes = Inbox.where('name ilike ?', "%#{params[:name]}%")
else
@inboxes = Inbox.all
end
end
// app/javascript/controllers/debounce_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "form" ]
connect() { console.log("debounce controller connected") }
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.formTarget.requestSubmit()
}, 500)
}
}
turbo_frame: 'search'
with list of inboxesturbo_frame_tag 'search'
#app/views/inboxes/index.html.erb
<%= form_with url: inboxes_path,
method: :get,
data: { controller: 'debounce',
debounce_target: 'form',
turbo_frame: 'search' } do |form| %>
<%= form.text_field :name,
placeholder: 'Name',
value: params[:name],
autocomplete: 'off',
autofocus: true,
data: { action: 'input->debounce#search' } %>
<% end %>
<%= turbo_frame_tag 'search' do %>
<%= request.url %>
<%= link_to 'Clear search', request.path if request.query_parameters.any? %>
<div id="inboxes">
<%= render @inboxes %>
</div>
<% end %>
target: '_top'
on such a āglobalā turbo_frame, so that when you click a link on content inside the frame, it does not look for a turbo_frame_tag "search"
#app/views/inboxes/index.html.erb
-- <%= turbo_frame_tag 'search' do %>
++ <%= turbo_frame_tag 'search', target: '_top' do %>
-- <%= link_to 'Clear search', request.path if request.query_parameters.any? %>
++ <%= link_to 'Clear search', request.path, data: { turbo_frame: 'search'} if request.query_parameters.any? %>
Basic search without Turbo:
#Gemfile
gem 'ransack', github: 'activerecord-hackery/ransack'
#app/controllers/inboxes_controller.rb
def index
@q = Inbox.ransack(params[:q])
@inboxes = @q.result(distinct: true)
end
#app/views/inboxes/index.html.erb
++<%= search_form_for @q do |f| %>
++ <%= f.label :name_cont %>
++ <%= f.search_field :name_cont %>
++ <%= f.submit %>
++<% end %>
++<%= sort_link @q, :messages_count, 'Popular' %>
++<%= sort_link @q, :created_at, 'Fresh' %>
++<%= link_to 'Clear search', request.path if request.query_parameters.any? %>
<div id="inboxes">
<%= render @inboxes %>
</div>
target: "_top"
- explicitly target what you need by the framesearch_field
on INBPUT#app/views/inboxes/index.html.erb
--<%= search_form_for @q, data: { turbo_frame: 'search'} do |f| %>
++<%= search_form_for @q, data: { controller: 'debounce',
++ debounce_target: 'form',
++ turbo_frame: 'search' } do |f| %>
<%= f.label :name_cont %>
-- <%= f.search_field :name_cont %>
++ <%= f.search_field :name_cont,
++ autocomplete: "off",
++ data: { action: "input->debounce#search" } %>
<%= f.submit %>
<% end %>
++<%= turbo_frame_tag 'search', target: "_top" do %>
--<%= sort_link @q, :messages_count, 'Popular' %>
--<%= sort_link @q, :created_at, 'Fresh' %>
++<%= sort_link @q, :messages_count, 'Popular', {}, { data: { turbo_frame: 'search'} } %>
++<%= sort_link @q, :created_at, 'Fresh', {}, { data: { turbo_frame: 'search'} } %>
<%= link_to 'Clear search', request.path, data: { turbo_frame: 'search'} if request.query_parameters.any? %>
<!-- request.url - to see the URL returned by the turbo_frame -->
<%= request.url %>
<div id="inboxes">
<%= render @inboxes %>
</div>
++<% end %>
#app/javascript/controllers/reset_controller.js
import { Controller } from "@hotwired/stimulus"
//<div data-controller="reset">
// <input data-reset-target=clearme>
// <button data-action="click->reset#clean">clear</button>
//</div>
export default class extends Controller {
static targets = [ "clearme" ]
connect() { console.log("reset controller connected") }
clean() {
console.log(this.clearmeTarget)
this.clearmeTarget.value=''
}
}
<div data-controller="reset">
data: { reset_target: 'clearme',
action: "click->reset#clean"
++<div data-controller="reset">
<%= search_form_for @q, data: { controller: 'debounce',
debounce_target: 'form',
turbo_frame: 'search' } do |f| %>
<%= f.label :name_cont %>
<%= f.search_field :name_cont,
autocomplete: "off",
++ data: { reset_target: 'clearme',
action: "input->debounce#search" } %>
<%= f.submit %>
<% end %>
<%= turbo_frame_tag 'search', target: "_top" do %>
<%= sort_link @q, :messages_count, 'Popular', {}, { data: { turbo_frame: 'search'} } %>
<%= sort_link @q, :created_at, 'Fresh', {}, { data: { turbo_frame: 'search'} } %>
--<%= link_to 'Clear search', request.path, data: { turbo_frame: 'search'} if request.query_parameters.any? %>
++<%= link_to 'Clear search', request.path, data: { action: "click->reset#clean", turbo_frame: 'search'} if request.query_parameters.any? %>
<!-- request.url - to see the URL returned by the turbo_frame -->
<%= request.url %>
<div id="inboxes">
<%= render @inboxes %>
</div>
<% end %>
++</div>
PERFECTO!!!
Howeverā¦
A BIG DRAWBACK of this approach = this way we do not update the URL on search.
How can we do it? Turbo Drive? Some js? Who knowsā¦
]]>def create
@inbox = Inbox.new(inbox_params)
@inbox.user = current_user
end
def create
@inbox = current_user.inboxes.new(post_params)
end
def create
@inbox = Inbox.new(inbox_params.merge({ user: current_user }))
end
def create
@inbox = Inbox.new(inbox_params)
@inbox.user = current_user
end
def inbox_params
params.require(:inbox).permit(:name).merge(user: current_user)
end
gem 'rails-erd', group: :development
sudo apt-get install graphviz
bundle exec erd
It will generate erd.pdf
in the root folder of your Rails app.
Here are some examples of what it can look like:
You can also create an .erdconfig
file in the root folder and add some configs
_likes
partial inside _inbox
partial, not the whole _inbox
partial.
EVERYTHING IS PERSISTED IN THE DATABASE.
rails g migration add_likes_to_inboxes like:integer
add_column :inboxes, :likes, :integer, default: 0, null: false
#config/routes.rb
Rails.application.routes.draw do
resources :inboxes do
member do
patch :like, to: 'inboxes#like'
end
end
end
_likes
partialinclude ActionView::RecordIdentifier
- to use dom_id
#app/controllers/inboxes_controller.rb
include ActionView::RecordIdentifier
def like
@inbox = Inbox.find(params[:id])
@inbox.increment!(:likes)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"#{dom_id(@inbox)}_likes",
partial: 'inboxes/likes',
locals: { inbox: @inbox }
)
end
end
end
#app/views/inboxes/_likes.html.erb
<%= turbo_frame_tag "#{dom_id(inbox)}_likes" do %>
<%= inbox.likes %>
<% end %>
data: { turbo_frame: '_top' }
- might be optional#app/views/inboxes/_inbox.html.erb
<div id="<%= dom_id inbox %>">
<h1><%= link_to inbox.name, inbox %></h1>
<%= render partial: 'inboxes/likes', locals: { inbox: inbox } %>
<%= button_to 'Like!',
like_inbox_path(inbox),
method: :patch, data: { turbo_frame: '_top' } %>
</div>
Like!
button into the partial.Thereās currently some problem turbo_streaming button_to
(for example Delete
):
sh
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [b1e06c35-6ac8-4a34-b717-41236ebcc593] Error performing Turbo::Streams::ActionBroadcastJob (Job ID: b1e06c35-6ac8-4a34-b717-41236ebcc593) from Async(default) in 44.89ms: ActionView::Template::Error (Request forgery protection requires a working session store but your application has sessions disabled. You need to either disable request forgery protection, or configure a working session store.):
To fix this, add this line:
#config/application.rb
config.action_controller.silence_disabled_session_errors = true
EVERYTHING IS PERSISTED IN THE DATABASE.
rails g migration add_active_to_inboxes active:boolean
add_column :inboxes, :active, :boolean, default: 0, null: false
inbox.rb
# ideally should be moved to a decorator or a helper
def active_color
if active?
:green
else
:red
end
end
def active_text
active? ? 'deactivate!' : 'activate!'
end
#config/routes.rb
resources :inboxes do
resources :messages, only: %i[new create destroy], module: :inboxes
member do
patch :toggle_active_state, to: 'inboxes#toggle_active_state'
end
end
turbo_stream.replace
_inbox
partial.#app/controllers/inboxes_controller.rb
# to get dom_id
include ActionView::RecordIdentifier
def toggle_active_state
@inbox = Inbox.find(params[:id])
@inbox.toggle!(:active)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
dom_id(@inbox),
partial: 'inboxes/inbox',
locals: { inbox: @inbox }
)
end
end
end
#app/views/inboxes/_inbox.html.erb
<%= turbo_frame_tag dom_id(inbox), style: "background: <%= inbox.active_color %>" do %>
<%= inbox.active %>
<%= button_to inbox.active_text,
toggle_active_state_inbox_path(inbox),
method: :patch, data: { turbo_frame: '_top' } %>
<%= link_to inbox.name, inbox, data: { turbo_frame: '_top' } %>
<%= link_to "Edit", edit_inbox_path(inbox) %>
<% end %>
link_to "Edit"
will find the frame in EDIT page#app/views/inboxes/edit.html.erb
<%= turbo_frame_tag dom_id(@inbox) do %>
<%= render "form", inbox: @inbox %>
<%= link_to "Cancel", @inbox %>
<% end %>
<%= request.path %>
<%# /inboxes %>
<%= request.fullpath %>
<%# /inboxes?q%5Bs%5D=messages_count+asc %>
<%= request.url %>
<%# localhost:3000/inboxes?q%5Bs%5D=messages_count+asc %>
<%= controller_name %>
<%= action_name %>
<%= link_to "Refresh", controller: controller_name, action: action_name %>
<%= link_to āRefreshā, request.path %>
controller_name.eql?('x')
action_name.eql?('y')
<%= current_page?(root_path) %>
localhost:3000/users?created_at=asc
would return {"created_at"=>"asc"}
<%= request.query_parameters %>
<%= request.query_parameters.empty? %>
now you can do
<%= link_to 'clear search', request.path if request.query_parameters.any? %>
<%= params %>
request.query_parameters.present?
<%=(params.keys - ['controller'] - ['action']).present? %>
<%= params.key?(:messages_count) %>
params[:messages_count].presence
request
with removing params<%= link_to "Refresh", request.params.slice("query", "filter", "sort") %>
#app/helpers/application_helper.rb
module ApplicationHelper
def current_page_params
# remove query params that you might have
request.params.slice("query", "filter", "sort")
end
end
<%= link_to "Refresh with helper", current_page_params %>
<%= link_to "with foo", current_page_params.merge(foo: true) %>
<%= link_to_unless_current 'Inboxes', inboxes_path %>
<%= link_to "Login", controller: "user", action: "login" %>
<%= link_to "Profile", controller: "profiles", action: "show", id: @profile %>
# => <a href="/profiles/show/1">Profile</a>
<% @posts.each do |post| %>
<%= link_to_if @user.admin?, post.title, manage_post_path(post) %>
<% end %>
controller=inboxes
, action=index
<%= link_to_unless controller_name.eql?('inboxes') && action_name.eql?('index'), 'Inboxes', inboxes_path %>
controller=inboxes
<%= link_to_unless controller_name.eql?('inboxes'), 'Inboxes', inboxes_path %>
inboxes/index
or inboxes/new
<%=
link_to_unless_current("New inbox", { controller: "inboxes", action: "new" }) do
link_to("SCOTS", { controller: "inboxes", action: "index" })
end
%>
This is initially inspired by https://boringrails.com/tips/rails-link-to-unless-current
]]>data: { turbo_frame: 'search' }
to the links to act WITHIN a trubo frame search
#app/helpers/sort_helper.rb
module SortHelper
def sort_link(attribute, label = nil)
attribute_or_label = label.presence || attribute.to_s.humanize
@attribute = attribute
link_to "#{icon} #{attribute_or_label}",
url_for(controller: controller_name, action: action_name, @attribute => sort_direction),
data: { turbo_frame: 'search' }
end
private
def sort_direction
case params[@attribute]
when 'desc'
'asc'
when 'asc'
'desc'
else
'desc'
end
end
def icon
case params[@attribute]
when 'desc'
'ā¼'
when 'asc'
'ā²'
end
end
end
#app/controllers/inboxes_controller.rb
def index
@search_param = params.keys - ['controller'] - ['action']
@search_param = @search_param[0]
@inboxes = if params[@search_param].present?
Inbox.order(@search_param => params[@search_param])
else
Inbox.order(created_at: :desc)
end
end
#app/views/inboxes/index.html.erb
<%= sort_link(:messages_count, 'Popular') %>
<%= sort_link(:created_at, 'Fresh') %>
<%= sort_link(:updated_at) %>
<%= link_back_if_params %>
<div id="inboxes">
<%= render @inboxes %>
</div>
turbo_frame_tag
with the same ID as the sort linkstarget: '_top'
- not to break any other behavior inside the frameapp/helpers/search_helper.rb
) should direct to the frame that you want to ārefreshā with data: { turbo_frame: 'search' }
#app/views/inboxes/index.html.erb
<%= turbo_frame_tag 'search', target: '_top' do %>
<%= sort_link(:messages_count, 'Popular') %>
<%= sort_link(:created_at, 'Fresh') %>
<%= sort_link(:updated_at) %>
<%= link_to 'Clear search', request.path if request.query_parameters.any? %>
<div id="inboxes">
<%= render @inboxes %>
</div>
<% end %>
Try inspecting all the requests by adding params.to_yaml
or params.inspect
or debug(params)
or params.to_unsafe_h
to your layout file:
#app/views/layouts/application.html.erb
<body>
<%= params.to_yaml %>
<%= params.inspect %>
<%= debug(params) %>
<%= params.to_unsafe_h %>
<hr>
<%= yield %>
</body>
For example, <%= params.inspect %>
will give you
#<ActionController::Parameters {"controller"=>"inboxes", "action"=>"edit", "id"=>"4"} permitted: false>
<%= params.to_unsafe_h %>
will give you
{"controller"=>"inboxes", "action"=>"edit", "id"=>"4"}
<%= debug(params) %>
will give you (BEST)
#<ActionController::Parameters {"controller"=>"inboxes", "action"=>"edit", "id"=>"4"} permitted: false>
Source: Debugging Rails Applications
]]>#app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_current_user, if: :user_signed_in?
private
def set_current_user
Current.user = current_user
end
end
#app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
#static_pages/landing_page.html.erb
<%= Current.user.username %>
#app/models/post.rb
class Post < ApplicationRecord
belongs_to :user, default: -> { Current.user }
validates :title, presence: true
end
Read more here:
#app/views/layouts/application.html.erb
<body>
<%= render 'shared/audio' %>
</body>
div
with an id
and data-turbo-permanent
<!-- app/views/shared/_persistent.html.erb -->
<div id='hello' data-turbo-permanent="true">
<%= audio_tag 'sample-audio-15s', controls: true %>
<%= video_tag 'sample-video-5s', controls: true, width: '500px' %>
</div>
<div id="player1" data-turbo-permanent>
<audio src="<%= audio_path 'song.mp3'%>" type="audio/mp3" controls>
</audio>
</div>
<div id="player2" data-turbo-permanent="">
<audio controls="">
<source src="https://media.transistor.fm/9283b16f.mp3" type="audio/mp3">
</audio>
</div>
In this case song.mp3
is sourced from #app/assets/images/song.mp3
Another example of using data-turbo-permanent
on superails.com - search form and results are persisted across pages:
<script>
function pictureOpen() {
document.querySelector('video').requestPictureInPicture();
// if you are a prick, you can hide the source so that the video is harder to dismiss!
// document.getElementById('myvideo').style.display = 'none';
}
</script>
<div>
<%= link_to 'Home', root_path, onclick: 'pictureOpen()' %>
</div>
<div id='myvideo' data-turbo-permanent="true">
<%= video_tag 'sample-video-5s', controls: true, width: '500px' %>
</div>
Resources:
]]>#app/models/inbox.rb
class Inbox < ApplicationRecord
has_many :messages, -> { order(created_at: :desc) }, dependent: :destroy
validates :name, presence: true, uniqueness: true
# add on top
# after_create_commit {broadcast_prepend_to "inboxes"}
# add on bottom
# after_create_commit {broadcast_append_to "inboxes"}
# after_destroy_commit { broadcast_remove_to "inboxes" }
# after_update_commit { broadcast_update_to "inboxes" }
# broadcast all activity (create, update, destroy)
broadcasts_to ->(inbox) { :inboxes }
end
#app/views/inboxes/index.html.erb
<%= turbo_stream_from "inboxes" %>
<div id="inboxes">
<%= render @inboxes %>
</div>
#console
Inbox.create(name: Faker::Lorem.sentence(word_count: 3))
Inbox.first.update(name: SecureRandom.hex)
Inbox.first.destroy
turbo_frame_tag
in inboxes/new.html.erb (and should find it in the partial)#app/views/inboxes/index.html.erb
<%= turbo_frame_tag 'inbox_form', src: new_inbox_path, loading: :lazy do %>
Loading...
<% end %>
#app/controllers/inboxes_controller.rb
def create
@inbox = Inbox.new(inbox_params)
respond_to do |format|
if @inbox.save
format.turbo_stream { render turbo_stream: turbo_stream.replace(
'inbox_form',
partial: 'inboxes/form',
locals: { inbox: Inbox.new }
) }
end
end
end
#app/views/inboxes/new.html.erb
<%= turbo_frame_tag 'inbox_form', target: "_top" do %>
<%= render "form", inbox: @inbox %>
<% end %>
#app/views/inboxes/_form.html.erb
<%= turbo_frame_tag 'inbox_form' do %>
<%= form_with(model: inbox) do |form| %>
...
<% end %>
<% end %>
#app/views/inboxes/_form.html.erb
<%= form_with(model: inbox, data: { controller: 'reset-form', action: 'turbo:submit-end->reset_form#reset'}) do |form| %>
...
#app/javascript/controllers/reset_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
console.log("Reset Form Stimulus Controller connected")
}
reset() {
this.element.reset()
}
}
Resources:
]]>My setup:
Some options to create new Rails 7 app:
rails new superails -d=postgresql
rails new superails --main --d=postgresql --css=bulma
rails new superails -d=postgresql --skip-javascript
rails new superails -d=postgresql --css tailwind
rails new superails -d=postgresql --css bootstrap
rails new superails -d=postgresql --javascript esbuild --css bootstrap
Basic views and models:
rails g controller static_pages landing_page pricing privacy terms --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework
rails g scaffold Inbox name:string --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
rails g scaffold Message body:text inbox:references --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
rails db:migrate
#app/models/message.rb
# lets you use dom_id in a model
include ActionView::RecordIdentifier
# add on top
after_create_commit { broadcast_prepend_to [inbox, :messages], target: "#{dom_id(inbox)}_messages" }
# add on bottom
# after_create_commit { broadcast_append_to [inbox, :messages], target: "#{dom_id(inbox)}_messages" }
after_destroy_commit { broadcast_remove_to [inbox, :messages], target: "#{dom_id(self)}" }
after_update_commit { broadcast_update_to [inbox, :messages], target: "#{dom_id(self)}" }
#app/views/inboxes/show.html.erb
<%= turbo_stream_from @inbox, :messages %>
<div id="<%= "#{dom_id(@inbox)}_messages" %>">
<%= render @inbox.messages %>
</div>
#app/views/messages/_message.html.erb
<div id="<%= dom_id(message) %>">
<%= message.body %>
</div>
Test in console
Inbox.create(name: Faker::Lorem.sentence(word_count: 3))
Inbox.first.messages << Message.create(body: Faker::Lorem.sentence(word_count: 3))
Inbox.first.messages.first.update(body: SecureRandom.hex)
Inbox.first.messages.first.destroy
#config/routes.rb
resources :inboxes do
resources :messages, only: %i[create]
end
#app/views/inboxes/show.html.erb
<%= render partial: "messages/form", locals: { message: Message.new } %>
#app/views/messages/_form.html.erb
<%= turbo_frame_tag "message_form" do %>
<%= form_with model: message, url: inbox_messages_path(@inbox) do |form| %>
...
<% end %>
<% end %>
format.turbo_stream
to replace turbo frame with id message_form
with partial messages/form
#app/controllers/messages_controller.rb
def create
@inbox = Inbox.find(params[:inbox_id])
@message = @inbox.messages.new(message_params)
respond_to do |format|
if @message.save
format.turbo_stream { render turbo_stream: turbo_stream.replace(
'message_form',
partial: 'messages/form',
locals: { message: Message.new }
) }
# format.html { render partial: 'messages/form', locals: { message: Message.new }}
format.html { redirect_to @message, notice: "Message was successfully created." }
format.json { render :show, status: :created, location: @message }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace(
'message_form',
partial: 'messages/form',
locals: { message: @message }
) }
# format.html { render partial: 'messages/form', locals: { message: @message }}
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @message.errors, status: :unprocessable_entity }
end
end
end
Resources:
]]>console
rails g scaffold MenuCategory name
rails g scaffold MenuItem menu_category:references name price:integer active:boolean
rails db:migrate
config/seeds.rb
3.times do
menu_category = MenuCategory.create(name: SecureRandom.hex)
rand(1..3).times do
menu_item = MenuItem.create(name: SecureRandom.hex,
menu_category: menu_category,
price: rand(10..100))
end
end
console
rails db:seed
console
bundle add devise
rails generate devise:install
console
bundle add activeadmin
rails generate active_admin:install --use_webpacker
# rails generate active_admin:install
rails db:migrate db:seed
This will:
Now you login to localhost:3000/admin
with:
login: admin@example.com
password: password
rails generate active_admin:resource MenuCategory
rails generate active_admin:resource MenuItem
app/admin/menu_items.rb
permit_params :name, :price, :menu_category_id
app/admin/menu_items.rb
filter :name
filter :menu_category
filter :menu_category_name, as: :string
filter :created_at
filter :active
filter :price
app/models/menu_item.rb
def has_price
price.present?
end
scope :has_price, -> { where.not(price: nil) }
scope :active, -> { where(active: true) }
app/admin/menu_items.rb
scope :has_price
scope :active
app/admin/menu_items.rb
index do
selectable_column
id_column
column :name
column :price
column :created_at
column :active
actions
end
app/admin/menu_items.rb
show do
attributes_table do
row :id
row :name
row :has_price
row :menu_category
row :price
row :created_at
end
active_admin_comments
end
app/admin/menu_items.rb
form do |f|
f.inputs :name, :price, :menu_category
# input multiple (good for many-to-many relationship)
# f.inputs 'Menu Categories' do
# f.input :menu_categories, as: :check_boxes
# end
actions
end
app/models/menu_item.rb
def switch_active!
toggle!(:active)
end
app/admin/menu_items.rb
# adds route -> controller action -> model function
member_action :activate, method: :put do
resource.switch_active!
redirect_to resource_path, notice: "Active status changed to #{resource.active}"
end
# adds button to SHOW view
action_item :mark_active, only: :show do |model|
link_to "#{resource.active? ? 'deactivate!' : 'activate!'}", [:activate, :admin, resource], method: :put
end
# adds button to index view
index do
id_column
column :mark_active do |model|
link_to "#{model.active? ? 'deactivate!' : 'activate!'}", [:activate, :admin, model], method: :put
end
end
# conditionally display button
# action_item :activate, only: :show, if: proc { !resource.active? } do
# link_to 'Activate', [:activate, :admin, resource], method: :put
# end
app/admin/menu_items.rb
includes :menu_category
config/initializers/active_admin.rb:294
config.include_default_association_filters = true
rails generate active_admin:assets
rails generate active_admin:devise
rails generate active_admin:install
rails generate active_admin:page
rails generate active_admin:resource
rails generate active_admin:webpacker
You can use HTTP basic authentication to restrict access to different controllers/actions.
The easiest way is to use a http_basic_authenticate_with
callback in a controller:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
+ http_basic_authenticate_with name: 'superails', password: '123456', except: :index
A better approach where you have more control is to use authenticate_or_request_with_http_basic(realm = "Application", message = nil, &login_procedure)
, because it allows you to pass multple options and a block.
realm
- āscopeā. You can have different http authentication for different parts of your app. Defaults to "Application"
.message
- failure message. Default: *"HTTP Digest: Access denied."
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :http_authenticate, only: %i[show]
def index
@posts = Post.all
end
def show
end
private
def http_authenticate
# conditionally enable the feature only in production:
# return true if %w(test staging).include? Rails.env
# return true unless Rails.env == 'production'
authenticate_or_request_with_http_basic do |username, password|
username == 'superails' && password == '123456'
# better to hide password in credentials:
username == Rails.application.credentials.dig(:http_auth, :username) &&
password == Rails.application.credentials.dig(:http_auth, :password)
end
end
end
# credentials.yml
http_auth:
username: superails
password: 123456
request.headers[:HTTP_AUTHORIZATION].present?
request.authorization.present?
You can create a controller that will require authentication, and than inherit further controllers from it:
# app/controllers/secured_controller.rb
class SecuredController < ApplicationController
before_action :http_authenticate
private
def http_authenticate
authenticate_or_request_with_http_basic do |username, password|
username == 'superails' && password == '123456'
end
end
end
# app/controllers/posts_controller.rb
-class PostsController < ApplicationController
+class PostsController < SecuredController
# app/controllers/posts_controller.rb
-class TasksController < ApplicationController
+class TasksController < SecuredController
# app/controllers/concerns/http_auth_concern.rb
module HttpAuthConcern
extend ActiveSupport::Concern
included do
before_action :http_authenticate
end
def http_authenticate
authenticate_or_request_with_http_basic do |username, password|
username == 'superails' && password == '123456'
end
end
end
# app/controllers/application_controller.rb
class PostsController < ApplicationController
+ include HttpAuthConcern
# app/controllers/posts_controller.rb
class TasksController < ApplicationController
+ include HttpAuthConcern
As mentioned here, you can append username and password in an URL like http://username:password@example.com/
to auto-sign in.
Hereās how you can do it in a Rails app:
class HomeController < ApplicationController
NAME = 'superails'
PASSWORD = '12345678'
http_basic_authenticate_with name: NAME, password: PASSWORD, only: :dashboard
def landing_page
end
def dashboard
end
def pricing
end
private
# "http://localhost:3000/home/dashboard"
# http://NAME:PASSWORD@localhost:3000/home/dashboard/
# add_http_basic_auth(home_dashboard_path) => http://superails:12345678@localhost:3000/home/dashboard/
def add_http_basic_auth(url)
uri = URI.parse(url)
uri.userinfo = "#{NAME}:#{PASSWORD}"
uri.to_s
end
end
Tools:
console
rails g scaffold Product name
bin/rails active_storage:install
bin/rails db:migrate
bundle add rqrcode
app/models/product.rb
# store qr code in ActiveStorage
has_one_attached :qr_code
after_create :generate_qr
def generate_qr
GenerateQrService.new(self).call
end
console
mkdir app/services
echo > app/services/generate_qr.rb
app/services/generate_qr_service.rb
class GenerateQrService
attr_reader :product
def initialize(product)
@product = product
end
# url_for helper
include Rails.application.routes.url_helpers
# ensure rqrcode works here
require "rqrcode"
def call
# https://superails.com/products/5?abc=d+e+f
qr_url = url_for(controller: 'products',
action: 'show',
id: product.id,
host: 'superails.com',
only_path: false,
abc: 'd e f'
)
# generate QR code
qr_code = RQRCode::QRCode.new(qr_url)
# QR code to image
qr_png = qr_code.as_png(
bit_depth: 1,
border_modules: 4,
color_mode: ChunkyPNG::COLOR_GRAYSCALE,
color: "black",
file: nil,
fill: "white",
module_px_size: 6,
resize_exactly_to: false,
resize_gte_to: false,
size: 120
)
# name the image
image_name = SecureRandom.hex
# attach the generated image object
product.qr_code.attach(
io: StringIO.new(qr_png.to_s),
filename: "#{image_name}.png",
content_type: "image/png"
)
end
end
app/views/products/_product.html.erb
<%= image_tag(product.qr_code) if product.qr_code.attached? %>
Previously, before using StringIO
, I would save the file locally, upload it to storage, and later attach it:
# save the image in TMP
image = IO.binwrite("tmp/storage/#{image_name}.png", qr_png.to_s)
# save TMP file to ActiveStorage
# blob = ActiveStorage::Blob.create_after_upload!(
blob = ActiveStorage::Blob.create_and_upload!(
io: File.open("tmp/storage/#{image_name}.png"),
filename: image_name,
content_type: 'png'
)
# attach ActiveStorage::Blob to the product
product.qr_code.attach(blob)
What if we write not to tmp/storage/..
, but to Tempfile?
file = Tempfile.new(['hello', '.jpg'])
file.path # => something like: "/tmp/foo2843-8392-92849382--0.jpg"
product.qr_code.attach(io: File.open("/path/to/face.jpg"), filename: "face.jpg", content_type: "image/jpg")
blob = ActiveStorage::Blob.create_after_upload!(
io: File.open("tmp/storage/04755c23b32185f09bb1a20aabcc823c.png"),
filename: '04755c23b32185f09bb1a20aabcc823c',
content_type: 'png'
)
ActiveStorage::Blob.first
The previous post focused on QR codes.
Final result with both Barcodes and QR for each product:
There difference between generating QRcodes and BARcodes is very minor.
But for Barcodes we will use another gem - https://github.com/toretore/barby.
We generated QR codes for the product URL. We will generate Barcodes for the product name.
console
bundle add barby
bundle add chunky_png
app/models/product.rb
has_one_attached :barcode
after_create :generate_code
def generate_code
GenerateBarcodeService.new(self).call
end
app/services/generate_barcode_service.rb
class GenerateBarcodeService
attr_reader :product
def initialize(product)
@product = product
end
require 'barby'
require 'barby/barcode/code_128'
require 'barby/outputter/ascii_outputter'
require 'barby/outputter/png_outputter'
def call
barcode = Barby::Code128B.new(product.title)
# chunky_png required for THIS action
png = Barby::PngOutputter.new(barcode).to_png
image_name = SecureRandom.hex
IO.binwrite("tmp/#{image_name}.png", png.to_s)
blob = ActiveStorage::Blob.create_after_upload!(
io: File.open("tmp/#{image_name}.png"),
filename: image_name,
content_type: 'png'
)
product.barcode.attach(blob)
end
end
Resources:
Special thanks to secretpray for helping me with this one!
console
yarn add tom-select
app/javascript/stylesheets/application.scss
@import "tom-select/dist/css/tom-select.bootstrap5";
or
@import 'tom-select/dist/css/tom-select.css';
app/controllers/tags_controller.rb
class TagsController < ApplicationController
def create
tag = Tag.new(tag_params)
if tag.valid?
tag.save
render json: tag
end
end
private
def tag_params
params.require(:tag).permit(:name)
end
end
config/routes.rb
resources :tags, only: :create
app/views/posts/_form.html.erb
<div class="field">
<%= form.label :tags %>
<%= form.select :tag_ids, Tag.all.pluck(:name, :id), {}, { multiple: true, id: "select-tags" } %>
</div>
app/views/layouts/application.html.erb
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
app/javascript/packs/application.js
require("utilities/tom_select")
app/javascript/utilities/tom_select.js
import 'tom-select'
import TomSelect from "tom-select"
document.addEventListener("turbolinks:load", () => {
const selectInput = document.getElementById('select-tags')
if (selectInput) {
new TomSelect(selectInput, {
plugins: {
remove_button:{
title:'Remove this item',
}
},
onItemAdd:function(){
this.setTextboxValue('');
this.refreshOptions();
},
persist: false,
create: function(input, callback) {
const data = { name: input }
const token = document.querySelector('meta[name="csrf-token"]').content
fetch('/tags', {
method: 'POST',
headers: {
"X-CSRF-Token": token,
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify(data)
})
.then((response) => {
return response.json();
})
.then((data) => {
callback({value: data.id, text: data.name })
});
},
onDelete: function(values) {
return confirm(values.length > 1 ? 'Are you sure you want to remove these ' + values.length + ' items?' : 'Are you sure you want to remove "' + values[0] + '"?');
}
})
}
})
create
app/javascript/utilities/tom_select.js
create: async function(input, callback) {
const data = { name: input }
const token = document.querySelector('meta[name="csrf-token"]').content
let response = await fetch('/tags', {
method: 'POST',
headers: {
"X-CSRF-Token": token,
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify(data)
})
let newTag = await response.json();
return await callback({ value: newTag.id, text: newTag.name })
},
app/views/posts/_form.html.erb
<div class="field" data-controller="tom-select">
<%= form.label :tags %>
<%= form.select :tag_ids, Tag.all.pluck(:name, :id), {}, { multiple: true, id: "select-tags" } %>
</div>
app/javascript/controllers/tom_select_controller.js
IN THIS CASE YOU CAN SKIP THE TURBOLINKS RELOAD LINE HERE!
document.addEventListener("turbolinks:load", () => {
`
app/javascript/controllers/tom_select_controller.js
import { Controller } from "stimulus"
import 'tom-select'
import TomSelect from "tom-select"
export default class extends Controller {
connect() {
console.log('tom_select_controller connected')
...
const selectInput = document.getElementById('select-tags')
if (selectInput) {
new TomSelect(selectInput, {
...
}
}
Thatās it!
]]>console
bundle add traceroute
rake traceroute
console
echo > .traceroute.yaml
.traceroute.yaml
ignore_unused_routes:
- ^rails/conductor/action_mailbox/inbound_emails#edit
- ^rails/conductor/action_mailbox/inbound_emails#update
- ^rails/conductor/action_mailbox/inbound_emails#update
- ^rails/conductor/action_mailbox/inbound_emails#destroy
ignore_unreachable_actions:
- ^devise\/
- ^devise_invitable\/
Example:
Gemfile
gem 'public_activity'
console
rails g public_activity:migration
rake db:migrate
app/models/post.rb
include PublicActivity::Model
tracked
console
PublicActivity::Activity.count
# => 0
p = Post.create(title: 'first post')
PublicActivity::Activity.count
# => 1
p.destroy!
PublicActivity::Activity.count
# => 2
app/controllers/application_controller.rb
include PublicActivity::StoreController
app/models/post.rb
include PublicActivity::Model
tracked owner: proc { |controller, model| controller.current_user }
# tracked owner: Proc.new{ |controller, model| controller.current_user }
app/models/user.rb
include PublicActivity::Model
tracked owner: :itself
app/models/user.rb
include PublicActivity::Model
tracked only: [:create, :destroy]
# Disable globally
PublicActivity.enabled = false
# Perform some operations that would normally be tracked by p_a:
Article.create(title: 'New article')
# Switch it back on
PublicActivity.enabled = true
# Disable p_a for Article class
Article.public_activity_off
# p_a will not do anything here:
@article = Article.create(title: 'New article')
# But will be enabled for other classes:
# (creation of the comment will be recorded if you are tracking the Comment class)
@article.comments.create(body: 'some comment!')
# Enable it again for Article:
Article.public_activity_on
controllers/posts_controller.rb
def show
@post = Post.find(params[:id])
@activities = PublicActivity::Activity
.where(trackable_type: "Post", trackable_id: @post)
.order(created_at: :desc)
end
app/models/post.rb
include PublicActivity::Model
tracked owner: proc { |controller, model| controller.current_user }
has_many :activities, as: :trackable, class_name: 'PublicActivity::Activity', dependent: :destroy
@post.create_activity :change_status, parameters: {status: @post.status}
# or
@post.create_activity action: 'poke', params: {reason: 'bored'}, recipient: @friend, owner: @user
any controller
def activity
@activities = PublicActivity::Activity.order(created_at: :desc)
end
###
activity.html.haml
- @activities.each do |activity|
= activity.created_at
= time_ago_in_words(activity.created_at)
= activity.trackable_type
= link_to activity.trackable, activity.trackable
= activity.key
= link_to activity.owner, user_path(activity.owner) if activity.owner.present?
= activity.parameters
Gemfile
gem 'caxlsx'
gem 'caxlsx_rails'
/app/controllers/posts_controller.rb
def index
respond_to do |format|
format.html do
@posts = Post.order(created_at: :desc)
end
format.xlsx do
@posts = Post.all
render xlsx: 'posts', template: 'posts/whatever'
# response.headers['Content-Disposition'] = 'attachment; filename="all_posts_that_we_have.xlsx"'
# render xlsx: 'posts', template: 'posts/whatever', filename: "my_posts.xlsx", disposition: 'inline', xlsx_created_at: 3.days.ago, xlsx_author: "Elmer Fudd"
end
end
end
/app/views/posts/whatever.xlsx.axlsx
wb = xlsx_package.workbook
wb.add_worksheet(name: "Post") do |sheet|
sheet.add_row ['name', 'creation date']
@posts.each do |post|
sheet.add_row [post.name, post.created_at]
end
end
any view:
= link_to 'xlsx', posts_path(format: :xlsx), target: :_blank
I will be adding more info here later on.
Here is a more detailed article (that initially inspired me): https://www.sitepoint.com/generate-excel-spreadsheets-rails-axlsx-gem/
]]>Hereās a quick easy way to do it:
app/helpers/greetings_helper.rb
module GreetingsHelper
def greeting
now = Time.zone.now
if now.between?(now.beginning_of_day, now.noon)
'Good Morning'
elsif now.between?(now.noon, now.change(hour: 17, min: 30))
'Good Afternoon'
else
'Good Evening'
end
end
end
Will display as follows:
# Time.zone.now = 07:00
# greeting
# => Good Morning
# Time.zone.now = 13:00
# greeting
# => Good Afternoon
# Time.zone.now = 18:00
# greeting
# => Good Evening
Test it:
spec/helpers/greetings_helper_spec.rb
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe GreetingsHelper, type: :helper do
describe '#greeting' do
it 'displays Good Morning before noon' do
Timecop.freeze(Time.zone.now.change(hour: 10))
expect(helper.greeting).to eq 'Good Morning,'
end
it 'displays Good Afternoon after noon' do
Timecop.freeze(Time.zone.now.change(hour: 15))
expect(helper.greeting).to eq 'Good Afternoon,'
end
it 'displays Good Evening after 5:30pm' do
Timecop.freeze(Time.zone.now.change(hour: 20))
expect(helper.greeting).to eq 'Good Evening,'
end
end
end
/app/models/user.rb
def username
return name if name.present?
email.split('@')[0]
end
# or
/app/helpers/users_helper.rb
def username
return name if name.present?
email.split('@')[0]
end
/app/decorators/user_decorator.rb
def username
return name if name.present?
email.split('@')[0]
end
Gemfile
gem 'draper'
console
bundle
rails generate draper:install
rails generate decorator User
initialize the decorator in a controller:
# app/controllers/users_controller.rb
def index
@users = User.all.decorate
end
def show
@user = User.friendly.find(params[:id]).decorate
end
OR more complex - with pagy and ransack:
# app/controllers/users_controller.rb
def index
@q = User.all.ransack(params[:q])
@pagy, @users = pagy(@q.result(distinct: true)
@users = @users.decorate
end
Now you can call (in a view):
<%= @user.username %>
OR decorate a signle call (in a view):
<%= current_user.decorate.username %>
Example of calling associations, decorating a price:
app/decorators/user_decorator.rb
class PostDecorator < Draper::Decorator
# belongs_to :user
# has_many :comments
delegate_all
decorates_association :user, with: UserDecorator
decorates_association :comments #, with: CommentDecorator
def price
h.number_to_currency(price, precision: 0)
end
end
More real-life usage examples coming later.
Useful Resources:
]]>db/migrate/20210813135727_add_gdpr_to_clients.rb
class AddGdprToClients < ActiveRecord::Migration
def change
# add_column :clients, :gdpr, :string, null: true, array: true
add_column :clients, :gdpr, :string, array: true, default: []
end
end
app/controllers/clients_controller.rb
def client_params
params.require(:client).permit(:first_name, :last_name, gdpr: [])
end
app/models/client.rb
GDPRS = [:analyze_data, :take_photos, :publish_photos]
app/views/clients/_form.html.haml
<% Client::GDPRS.each do |key, value| %>
<%= form.check_box :gdpr, { multiple: true, checked: form.object.gdpr&.include?(key.to_s) }, key, nil %>
<%= form.label key %>
<% end %>
app/views/clients/show.html.haml
<%= @client.gdpr&.to_sentence %>
# or
<% @client.gdpr&.each do |item| %>
<%= item.humanize %>
<% end %>
add to array:
post.gdpr << 'face_recognition'
# or
post.gdpr.push 'face_recognition'
# or
post.gdpr += ['face_recognition']
scopes:
# This is valid
Post.where("'face_recognition' = ANY (gdpr)")
# This is more secure
Post.where(":gdpr = ANY (gdpr)", gdpr: 'face_recognition')
# This is also valid
Post.where("gdpr @> ?", "{face_recognition}")
# This is valid
Post.where("gdpr @> ARRAY[?]::varchar[]", ["face_recognition", "geodata"])
# This is valid
Post.where("gdpr && ?", "{face_recognition,geodata}")
# %oda% is geODAta
Post.where("array_to_string(gdpr, '||') LIKE :gdpr", gdpr: "%oda%")
When you deploy to production, afterwards you most likely write a console command like heroku run rails db:migrate
, right?
Well, thereās the Procfile
- a file where you list commands that should be run on deploy. Heroku automatically scans for it.
For other platforms (not heroku) you might need a tool like foreman.
Create a file named Procfile
in your application root folder
Inside Procfile
add these lines to run migrations right when the app gets deployed:
web: bundle exec rails s
release: rails db:migrate
Procfile
web: bundle exec rails s
worker: bundle exec sidekiq -c 2
release: rails db:migrate
Rails 8 will have Rubocop included by default.
# Gemfile
group :development, :test do
gem 'rubocop-rails', require: false
end
Create a config file:
# console
bundle
echo > .rubocop.yml
My basic setup:
# .rubocop.yml - basic setup example
require:
- rubocop-rails
AllCops:
NewCops: enable
TargetRubyVersion: 3.3.0
Exclude:
- vendor/bundle/**/*
- '**/db/schema.rb'
- '**/db/**/*'
- 'config/**/*'
- 'bin/*'
- 'config.ru'
- 'Rakefile'
Style/Documentation:
Enabled: false
Style/ClassAndModuleChildren:
Enabled: false
Rails/Output:
Enabled: false
Style/EmptyMethod:
Enabled: false
Bundler/OrderedGems:
Enabled: false
Lint/UnusedMethodArgument:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
# console - run check:
bundle exec rubocop
# console - run check on specific file/folder:
rubocop app/models/user.rb
app/models/user.rb
# rubocop: disable Metrics/AbcSize, Metrics/MethodLength
def full_name
...
end
# rubocop: enable Metrics/AbcSize, Metrics/MethodLength
# .rubocop.yml
Metrics/ClassLength:
Exclude:
- 'app/models/user.rb'
- 'app/controllers/users_controller.rb'
# console - safe auto correct
rubocop -a
# console - dangerous auto correct
rubocop - A
# console - autocorrect a single specific cop
bundle exec rubocop -a --only Style/FrozenStringLiteralComment
bundle exec rubocop -A --only Layout/EmptyLineAfterMagicComment
# generate comments for uncorrected problems and stop flagging them as TODO:
rubocop --auto-correct --disable-uncorrectable
# mkdir .github
# mkdir .github/workflows
# echo > .github/workflows/.lint.yml
name: Code style
on: [pull_request]
jobs:
lint:
name: all linters
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: rubocop
run: bundle exec rubocop --parallel
# - name: erb-lint
# run: bundle exec erblint --lint-all
Thatās it!
]]>read_more_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["shortText", "longText", "moreButton", "lessButton"]
connect() {
this.showLess()
}
showMore() {
this.shortTextTarget.hidden = true
this.moreButtonTarget.hidden = true
this.longTextTarget.hidden = false
this.lessButtonTarget.hidden = false
console.log('show more')
}
showLess() {
this.shortTextTarget.hidden = false
this.moreButtonTarget.hidden = false
this.longTextTarget.hidden = true
this.lessButtonTarget.hidden = true
console.log('show less')
}
}
any view (html):
<div data-controller="read-more">
<div data-read-more-target="shortText">
ABC
</div>
<div data-read-more-target="longText">
ABCDEFG
</div>
<button role="button" tabindex=0 data-read-more-target="moreButton" data-action="read-more#showMore">
Show more
</button>
<button role="button" tabindex=0 data-read-more-target="lessButton" data-action="read-more#showLess">
Show less
</button>
</div>
or any view (sexy haml):
%div{ data: { controller: 'read-more'} }
%div{ data: { target: 'read-more.shortText' } }
ABC
%div{ data: { target: 'read-more.longText' } }
ABCDEFG
%a{ data: { action: 'read-more#showMore', target: 'read-more.moreButton' } }
Show more...
%a{ data: { action: 'read-more#showLess', target: 'read-more.lessButton' } }
Show less...
haml - with data
%div{ data: { controller: 'read-more'} }
%div{ data: { target: 'read-more.shortText' } }
= truncate(post.body, length: 20, separator: ' ', omission: '...')
%div{ data: { target: 'read-more.longText' } }
= post.body
%b
%a.text-success{ role: 'button', tabindex: '0', data: { action: 'read-more#showMore', target: 'read-more.moreButton' } }
more Ā»
%b
%a.text-success{ role: 'button', tabindex: '0', data: { action: 'read-more#showLess', target: 'read-more.lessButton' } }
less Ā«
As I know, Pundit and CanCanCan are the 2 best approaches to adding AUTHORIZATION into a Rails app.
AUTHORIZATION - allow users to perform different actions / see different content based on their roles / other conditions.
I personally just prefer Pundit.
# Gemfile
gem "pundit"
# console
bundle
rails g pundit:install
rails g pundit:policy post
rails g pundit:policy user
# app/controllers/application_controller.rb
include Pundit
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized
flash[:alert] = "You are not authorized to perform this action."
redirect_to(request.referrer || root_path)
end
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def index?
true
# false - nobody has access
end
def show?
@user.has_any_role? :admin, :newuser || @record.user == @user
# index?
# @user.has_role? :admin
end
end
# app/controllers/posts_controller.rb
def index
@posts = Post.order(created_at: :desc)
authorize @posts
end
def show
authorize @post
end
This allows you to let users with different authorizations to see different scopes of items.
Below example - admins can see all posts, other users can see posts that have content not blank.
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
class Scope < Scope
def resolve
if @user.has_role? :admin
scope.all
else
scope.where.not(content: "")
end
end
end
# app/controllers/posts_controller.rb
def index
@posts = policy_scope(Post).order(created_at: :desc)
authorize @posts
end
In the above case @record
= selected post
Allow users different authorizations to see content in a view:
views:
<b>current user can see particular users show page?</b>
<%= policy(@user).show? %>
<b>current user can see users index?</b>
<%= policy(User).index? %>
<b>current user can edit a user?</b>
<%= policy(User).edit? %>
<%= link_to 'Edit user roles', edit_user_path(user) if policy(User).edit? %>
Instead of adding authorize @posts
or authorize @post
to each controller action,
just list the actions that you want to authorize either in a before_action:
# app/controllers/posts_controller.rb
before_action :authorize_valuations, only: %i[edit update destroy]
# after_action :authorize_valuations, except: %i[create report_quotes]
def authorize_valuations
authorize(@valuations || @valuation)
end
Enums are a Rails feature, not a Ruby feature.
# app/models/post.rb
enum status: %i[draft reviewed published]
# migration
add_column :posts, :status, :integer, default: 0
# app/models/post.rb
# this is automatic!!!
validates :status, inclusion: { in: Post.statuses.keys }
In this case, the order of enums is very important:
0 = draft 1 = reviewed 2 = published
If we add new values - add at the end of the array!
Post.statuses.keys
=> ["draft", "published"]
Post.statuses.values
=> [0, 1]
# basic
<%= form.select :status, Post.statuses.keys %>
# advanced
<%= form.select :status, options_for_select(Post.statuses.keys, { selected: @post.status || Post.new.status }), include_blank: true %>
# app/models/post.rb
enum status: { draft: 2, reviewed: 1, published: 0 }
# app/models/post.rb
enum status: {
draft: "draft",
reviewed: "reviewed",
published: "published"
}
# migration
add_column :posts, :status, :string
Rails 7 now supports postgresql enum
migrations:
# migration
class CreatePosts < ActiveRecord::Migration[7.0]
def up
create_enum :post_status, ["draft", "reviewed", "published"]
create_table :posts do |t|
t.enum :status, enum_type: "post_status", default: "draft", null: false
# t.column :status, :post_status, null: false, index: true
end
end
# to drop the enum table:
def down
remove_column :posts, :status
execute <<-SQL
DROP TYPE post_status;
SQL
end
end
instead of setting defaults on database level like:
# migration
add_column :posts, :status, :string, default: 'draft'
add_column :posts, :category, :string, default: 'Rails'
you could (better) do it in the model:
# app/models/post.rb
enum status: %i[draft reviewed published], _default: 'draft'
enum category: { rails: 'Rails', ruby: 'Ruby' }, _default: 'Rails'
to get the default value:
Post.new.status # => "draft"
Post::STATUSES[:draft] # => "draft"
post.draft! # => true
post.draft? # => true
post.status # => "draft"
post.reviewed! # => true
post.draft? # => false
post.status # => "reviewed"
post.reviewed? # => true
Post.draft # => Collection of all Posts in draft status
Post.not_draft # => Collection of all Posts NOT in draft status
Post.reviewed # => Collection of all Posts in reviewed status
Post.published # => Collection of all Posts in published status
However there still are many great libraries that are based on jQuery that you may want to make use of.
Hereās the simple way to install jQuery in Rails 6:
console
yarn add jquery
config/environment.js
const { environment } = require('@rails/webpacker')
const webpack = require("webpack")
environment.plugins.append("Provide", new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
'window.jQuery': 'jquery',
Popper: ['popper.js', 'default']
}))
module.exports = environment
Thatās it!
]]>The gem provides some nice methods to find records that a User voted for:
Voted:
<%= user.find_voted_items(vote_scope: 'like') %>
Upvoted:
<%= user.find_up_voted_items(vote_scope: 'like') %>
Downvoted:
<%= user.find_down_voted_items(vote_scope: 'like') %>
Bookmarked:
<%= user.find_voted_items(vote_scope: 'bookmark') %>
However, you might want to write some scopes of your own:
post.rb
scope :my_voted, -> (user) { where(id: user.find_voted_items.map(& :id)) }
scope :my_un_voted, -> (user) { where.not(id: user.find_voted_items.map(& :id)) }
scope :my_up_voted, -> (user) { where(id: user.find_up_voted_items.map(& :id)) }
scope :my_down_voted, -> (user) { where(id: user.find_down_voted_items.map(& :id)) }
a view:
<%= Post.my_voted(current_user).pluck(:id) %>
<%= Post.my_un_voted(current_user).pluck(:id) %>
<%= Post.my_up_voted(current_user).pluck(:id) %>
<%= Post.my_down_voted(current_user).pluck(:id) %>
a view:
Liked by:
- @post.votes_for.up.by_type(User).voters.each do |user|
= link_to user, user
Disliked by:
- @post.votes_for.down.by_type(User).voters.each do |user|
= link_to user, user
<%= Post.first.votes_for.up.by_type(User).voters.pluck(:name) %>
alternatively you can use gem config
create /config/settings.yml
:
production:
url: http://127.0.0.1:8080
namespace: my_app_production
development:
url: http://localhost:3001
namespace: my_app_development
shared:
foo:
bar:
baz: 1
languages:
en: english
de: german
fr: french
create /config/programming.yml
:
shared:
languages:
ruby: Ruby
python: Python
seniority_levels:
junior: Junior
middle: Middle
senior: Senior
getting values in the rails console:
Rails.application.config_for(:settings).dig(:languages).to_h.invert.to_a
=> [["english", :en], ["german", :de], ["french", :fr]]
Rails.application.config_for(:settings).dig(:languages)
=> {:en=>"english", :de=>"german", :fr=>"french"}
Rails.application.config_for(:settings).dig(:namespace)
=> "my_app_development"
Rails.application.config_for(:programming).dig(:languages)
=> {:ruby=>"Ruby", :python=>"Python"}
use as select in a form:
<%= form.select :language, Rails.application.config_for(:settings).dig(:languages) %>
=> select from [en, de, fr], save to database [english, german, french]
<%= form.select :language, Rails.application.config_for(:settings).dig(:languages).to_h.invert.to_a %>
=> select from [english, german, french], save to database [en, de, fr]
NOTE: I donāt like this code any more. Thereās a newer post available ;)
1 console
rails generate model Comment user:references body:text commentable:references{polymorphic} deleted_at:datetime:index
2 db/migrate/20210711135608_create_comments.rb
class CreateComments < ActiveRecord::Migration[6.1]
def change
create_table :comments do |t|
t.references :user, null: false, foreign_key: true
t.references :commentable, polymorphic: true, null: false
t.text :body
t.datetime :deleted_at
t.timestamps
end
add_index :comments, :deleted_at
end
end
3 app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :user
belongs_to :commentable, polymorphic: true
has_many :comments, as: :commentable
validates :body, presence: true
validates :body, length: { minimum: 5 }
def destroy
update(deleted_at: Time.zone.now)
end
end
4 app/models/post.rb
class Post < ApplicationRecord
has_many :comments, as: :commentable
end
5 config/routes.rb
resources :posts, except: :index do
resources :comments, only: %i[new create destroy], module: :posts
end
resources :comments, only: [] do
resources :comments, only: %i[new create destroy], module: :comments
end
6 app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def create
@comment = @commentable.comments.new(comment_params)
@comment.user = current_user
if @comment.save
respond_to do |format|
# format.html { redirect_to @commentable }
format.html { redirect_back(fallback_location: root_url) }
format.js # create.js.erb
end
else
redirect_to @commentable, alert: 'Comment could not be created.'
end
end
def destroy
@comment = @commentable.comments.find(params[:id])
@comment.destroy # update(deleted_at: Time.zone.now)
redirect_back(fallback_location: root_url)
end
private
def comment_params
params.require(:comment).permit(:body)
end
end
7 app/controllers/comments/comments_controller.rb
class Comments::CommentsController < CommentsController
before_action :set_commentable
def new
@comment = current_user.comments.new(commentable: @commentable)
end
private
def set_commentable
@commentable = Comment.find(params[:comment_id])
end
end
8 app/controllers/posts/comments_controller.rb
class Posts::CommentsController < CommentsController
before_action :set_commentable
private
def set_commentable
@commentable = Post.friendly.find(params[:post_id])
end
end
9 app/controllers/posts_controller.rb
def show
@post = Post.includes(:comments).friendly.find(params[:id])
end
10 app/javascript/stylesheets/application.scss
.display-none {
display: none;
}
.comment {
margin: 1em 0em 1em 1em;
padding-left: 1em;
border-left: 2px solid lightgray;
}
11 app/views/comments/_comment.html.erb
<%= content_tag :div, id: dom_id(comment), class: 'comment' do %>
<% if comment.deleted_at? %>
<strong>[deleted]</strong>
<% else %>
<strong><%= comment.user.email %></strong>
<p><%= comment.body %></p>
<% end %>
<div class='links'><small>
<%= link_to 'Reply', [:new, comment, :comment], remote: true %>
<% if current_user == comment.user %>
<%= link_to 'Delete', [comment.commentable, comment], method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>
</small></div>
<%= render 'comments/form', commentable: comment, comment: Comment.new %>
<%= render comment.comments %>
<% end %>
12 app/views/comments/_form.html.erb
<%= form_with model: [commentable, comment], id: dom_id(commentable, 'form'), class: 'display-none' do |form| %>
<%= form.text_area :body, placeholder: 'Add a comment', style: "width: 100%", rows: 5, required: true %>
<%= form.submit %>
<% end %>
13 app/views/comments/create.js.erb
var form = document.querySelector("#<%= dom_id(@commentable, 'form') %>")
if (form != null) {
form.classList.toggle("display-none")
}
var comments = document.querySelector("#<%= dom_id(@commentable) %>")
if (comments == null) {
comments = document.querySelector("#comments")
}
comments.insertAdjacentHTML('beforeend', '<%= j render 'comments/comment', commentable: @commentable, comment: @comment %>')
14 app/views/comments/new.js.erb
var form = document.querySelector('#<%= dom_id(@commentable, 'form') %>')
form.classList.toggle('display-none')
15 app/views/posts/show.html.erb
<p><%= render 'comments/form', commentable: @post, comment: Comment.new %></p>
<%= link_to "Add Comment", [:new, @post, :comment], remote: true %>
<h2>Comment</h2>
<div id='comments'>
<%= render @post.comments %>
</div>
Strong params authorization can look like this:
# app/controllers/users_controller.rb:
def user_params
list_allowed_params = []
list_allowed_params += [:name] if current_user == @user || current_user.admin?
list_allowed_params += [:role, :salary] if current_user.admin?
params.require(:user).permit(list_allowed_params)
end
# app/controllers/users_controller.rb:
ADMIN_ATTRIBUTES = [:a, :b, :c, :d]
MANAGER_ATTRIBUTES = [:a, :c, :d]
EDITOR_ATTRIBUTES = [:b, :d]
def user_params
case current_user.role
when :admin
params.require(:user).permit(ADMIN_ATTRIBUTES)
when :manager
params.require(:user).permit(MANAGER_ATTRIBUTES)
when :editor
params.require(:user).permit(EDITOR_ATTRIBUTES)
end
end
More about Rails strong params
]]>Example code for editing user roles:
app/controllers/users_controller.rb
def edit
@user = User.find(params[:id])
end
def update
@user = User.find(params[:id])
@user.update(user_params)
if @user.update(user_params)
redirect_to users_url, notice: "User was successfully updated."
else
render :edit
end
end
private
def user_params
params.require(:user).permit({role_ids: []})
end
app/views/users/edit.html.haml
<%= simple_form_for @user do |f| %>
<%= f.collection_check_boxes :role_ids, Role.all, :id, :name %>
<%= f.error :roles %>
<%= f.button :submit %>
OR app/views/users/edit.html.haml
<%= simple_form_for @user do |f| %>
<% Role.all.each do |role| %>
<%= check_box_tag "user[role_ids][]", role.id, @user.role_ids.include?(role.id) %>
<%= role.name %>
<%= role.resource_type %>
<%= role.resource %>
<% end %>
<%= f.button :submit %>
<% end %>
display all user roles in a view:
- @user.roles.each do |role|
= role.name
@user.roles.pluck(:name)
app/models/user.rb
validate :must_have_a_role, on: :update
private
def must_have_a_role
unless roles.any?
errors.add(:roles, "must have at least one role")
end
end
after_create do
if User.count == 1
add_role(:admin) if roles.blank?
end
add_role(:teacher)
add_role(:student)
end
I HAVE READ 10+ BLOGS ON THIS TOPIC. HERE IS THE MOST IDEAL ANSWER BASED ON ALL OF THEM.
Using markdown? With gem redcarpet?
Add gem rouge to add code highlight your markdown.
bundle add redcarpet
bundle add rouge
application_helper.rb
require 'redcarpet'
require 'rouge'
require 'rouge/plugins/redcarpet'
class HTML < Redcarpet::Render::HTML
include Rouge::Plugins::Redcarpet
end
def markdown(text)
return '' if text.nil?
options = {
filter_html: true,
hard_wrap: true,
link_attributes: { rel: 'nofollow', target: '_blank' },
prettify: true
}
extensions = {
autolink: true,
tables: true,
fenced_code_blocks: true,
lax_spacing: true,
no_intra_emphasis: true,
strikethrough: true,
superscript: true,
disable_indented_code_blocks: true,
}
# Redcarpet::Markdown.new(HTML.new(options), extensions).render(text).html_safe
# these 3 lines do same as above 1 line
renderer = HTML.new(options)
markdown = Redcarpet::Markdown.new(renderer, extensions)
markdown.render(text).html_safe
end
Create rouge.css.erb
and use one of 10+ available themes.
Here are my famorite ones:
# app/assets/stylesheets/rouge.css.erb
<%= Rouge::Themes::Base16.mode(:light).render(scope: '.highlight') %>
<%#= Rouge::Themes::ThankfulEyes.render %>
<%#= Rouge::Themes::Base16.mode(:dark).render %>
# app/assets/stylesheets/application.css
@import "rouge";
# style ```code block```
pre.highlight {
padding: 10px;
}
# style `code`
code.prettyprint {
color: red;
background-color: #F2F2F2;
}
# console
rougify style github > app/assets/stylesheets/github.css
app/assets/stylesheets/application.scss
@import "github";
badge badge
with badge bg
jumbotron
with card bg-light
card-deck
with row row-cols-1 row-cols-md-2 g-4
or card-group
form-group
with mb-3
font-weight
with fw
left
and right
replaced with something like start
and end
.
Meaning, we would have <ul class="navbar-nav me-auto">
and <ul class="navbar-nav me-auto">
for navbar-end (right) and navbar-start (left)
Post title:string body:text
.
In the form you already have a field <%= form.text_area :body %>
and you donāt need to change anything there.
The thing about markdown is just about parsing text and displaying it with the propper formatting.
To parse text into markdown you will need the gem redcarpet.
Also, hereās a good markdown cheatsheet
Gemfile
bundle add redcarpet
Basic implementation:
# application_helper.rb
def markdown(text)
return '' if text.nil?
extensions = %i[
hard_wrap autolink no_intra_emphasis tables fenced_code_blocks
disable_indented_code_blocks strikethrough lax_spacing space_after_headers
quote footnotes highlight underline
]
Markdown.new(text, *extensions).to_html.html_safe
end
More advanced implementation with render_options
:
# application_helper.rb
def markdown(text)
return '' if text.nil?
render_options = { hard_wrap: true, link_attributes: { target: '_blank' } }
extensions = { fenced_code_blocks: true, strikethrough: true, tables: true, autolink: true }
renderer = Redcarpet::Render::HTML.new(render_options)
Redcarpet::Markdown.new(renderer, extensions).render(text).html_safe
end
The options variable defines parsing settings that you use from Redcarpet.
Now in a view you can parse anything to markdown:
<%= markdown(@post.body) %>
<%= form.text_area :content,
style: "width: 100%",
rows: 8,
maxlength: 5000,
placeholder: 'User Markdown for formatting'
%>
Meaning, 2 different posts can easily have different admins and moderators.
But Rolify has no default scopes to see all user with a role for a post, or all posts that a user has a role for.
Here are some example relationship scopes that you can add to your models and fix the āproblemā:
user.rb
has_many :posts, through: :roles, source: :resource, source_type: :Post
has_many :moderated_posts, -> { where(roles: {name: :moderator}) }, through: :roles, source: :resource, source_type: :Post
letās you do
@user.posts
# => [ all the posts where the @user has a role ]
@user.moderated_posts
# => [ all the posts where the @user has a moderator ]
post.rb
has_many :users, through: :roles, class_name: 'User', source: :users
has_many :moderators, -> { where(:roles => {name: :moderator}) }, through: :roles, class_name: 'User', source: :users
letās you do
@post.users
# => [ all the users that have a role in this post ]
@post.moderators
# => [ all the users that have a moderator role in this post ]
Gemfile
gem 'pg_search'
app/controllers/posts_controller.rb
def index
if params[:query].present?
@posts = Post.order(created_at: :desc).global_search(params[:query])
else
@posts = Post.order(created_at: :desc)
end
end
app/models/post.rb - search by title
include PgSearch::Model
# pg_search_scope :global_search, against: :title
OR app/models/post.rb - search by title AND content
include PgSearch::Model
pg_search_scope :global_search, against: [:title, :content], using: { tsearch: { prefix: true } }
OR app/models/post.rb - search by title AND content AND associations
include PgSearch::Model
pg_search_scope :global_search, associated_against: { tags: [:name, :category], user: :email, :title, :content }
app/views/posts/index.html.erb
<%= form_with(url: posts_url, method: :get) do |f| %>
<%= label_tag(:query, "Search for") %>
<%= text_field_tag(:query) %>
<%= submit_tag("Search", class: "btn btn-primary") %>
<% end %>
Prerequisites:
Final solution demo:
HOWTO:
<%= form_with(model: post) do |form| %>
<%= content_tag :div, nil, data: { controller: "tweet", tweet_character_count_value: 140, tweet_over_limit_class: "text-danger" } do %>
<%= form.text_area :content, data: { controller: "textarea-autogrow", tweet_target: "field", action: "keyup->tweet#change" } %>
<div data-tweet-target="output"></div>
<% end %>
<% end %>
tweet_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "field", "output" ]
static classes = [ "overLimit" ]
static values = {
characterCount: Number,
}
connect() {
this.change()
}
change() {
let length = this.fieldTarget.value.length
this.outputTarget.textContent = `${length} characters`
if (length > this.characterCountValue) {
this.outputTarget.classList.add(this.overLimitClass)
} else {
this.outputTarget.classList.remove(this.overLimitClass)
}
}
}
Disclaimer 2: This particular code is 99% based on a go rails episode
]]>Prerequisites:
Final solution demo:
HOWTO:
/app/javascript/controllers/countchar_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "name", "counter" ]
countCharacters(event) {
let characters = this.nameTarget.value.length;
this.counterTarget.innerText = characters;
}
}
/app/views/posts/_form.html.erb
<div data-controller="countchar">
<%= form.text_area :content, data: { countchar_target: 'name', action: 'keyup->countchar#countCharacters' } %>
Characters: <span data-countchar-target='counter'><%= post.content.to_s.size %></span>
</div>
This solution is flexible, as you donāt hard-code any values in the stimulus controller.
Prerequisites:
Final solution:
// /app/javascript/controllers/showhide_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="showhide"
export default class extends Controller {
static targets = ["input", "output"]
static values = { showIf: String }
connect() {
this.toggle()
}
toggle() {
if (this.inputTarget.value != this.showIfValue) {
this.outputTarget.hidden = true
} else if (this.inputTarget.value = this.showIfValue) {
this.outputTarget.hidden = false
}
}
}
HMTL example:
<div data-controller="showhide" data-showhide-show-if-value="human">
<select data-showhide-target="input" data-action="change->showhide#toggle">
<option selected="selected" value=""></option>
<option value="human">human</option>
<option value="animal">animal</option>
</select>
<div data-showhide-target="output" hidden="">
an output if human
</div>
</div>
Rails form example:
# /app/views/posts/_form.html.erb
<%= form_with(model: post) do |form| %>
<div data-controller="showhide" data-showhide-show-if-value="human">
<%= form.select "abc", ["", "human", "animal"], {allow_blank: true}, {data: {showhide_target: "input", action: "change->showhide#toggle"}} %>
<div data-showhide-target="output">
an output if human
</div>
</div>
<% end %>
classes
Final solution:
/* app/assets/stylesheets/application.css */
.hidden {
display: none
}
// /app/javascript/controllers/showhide_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "field", "output" ]
static classes = [ "hide" ]
static values = {
showIf: String,
}
connect() {
this.toggle()
}
toggle() {
if (this.fieldTarget.value != this.showIfValue) {
this.outputTarget.classList.add(this.hideClass)
} else {
this.outputTarget.classList.remove(this.hideClass)
}
}
}
Rails form example:
# /app/views/posts/_form.html.erb
<%= form_with(model: post) do |form| %>
<%= content_tag :div, nil, data: { controller: "showhide", showhide_show_if_value: "lorem", showhide_hide_class: "hidden" } do %>
<%= form.select :content, [nil, "lorem", "other"], {}, {data: { showhide_target: "field", action: "change->showhide#toggle" }} %>
<div data-showhide-target="output">
you can see this text if selected value = lorem
</div>
<% end %>
<% end %>
P.S. Donāt forget that you are free to have multiple same stimulus controllers on a page ;)
]]>console
yarn add stimulus
mkdir app/javascript/controllers
touch app/javascript/controllers/index.js
#app/javascript/packs/application.js
import 'controllers'
#app/javascript/controllers/index.js
import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"
const application = Application.start()
const context = require.context("../controllers", true, /\.js$/)
application.load(definitionsFromContext(context))
console
touch app/javascript/controllers/hello_controller.js
#app/javascript/controllers/hello_controller.js
import { Controller } from 'stimulus';
export default class extends Controller {
connect() {
console.log("hello from StimulusJS")
}
}
in a view:
<div data-controller="hello">
Anything
</div>
#app/javascript/controllers/hello_controller.js
welcome() {
console.log("click")
}
in a view:
<div data-controller="hello">
<div class="btn btn-primary" data-action="click->hello#welcome">log a click in console</div>
</div>
Now when you open the view, the div
will call the hello_controller
and add hello from StimulusJS
in your browser console.
Display something only when a specific value is selected in a form
In this example - we display the field quantity
only if the value in field kids
is set to yes
application.scss
#hidden {
display: none;
}
/app/views/posts/_form.html.erb
<%= form_with(model: booking) do |form| %>
<%= form.label :kids %>
<%= form.select :kids, [nil, "yes", "no"] %>
<div id="hidden">
<%= form.label "How many kids?" %>
<%= form.text_field :quantity %>
</div>
<% end %>
The above form generates HTML like this:
<form action="/bookings" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="xxx">
<div class="field">
<label for="booking_title">Kids</label>
<select name="booking[title]" id="booking_kids">
<option selected="selected" value=""></option>
<option value="yes">yes</option>
<option value="no">no</option>
</select>
</div>
<div id="hidden" style="display: none;">
<div class="field">
<label for="booking_quantity">quantity</label>
<textarea name="post[quantity]" id="post_quantity"></textarea>
</div>
</div>
</form>
application.js:
booking_kids
(autogenerated by the form id + form field)kids
value is set to yes
- unhide quantity
inputdocument.addEventListener("turbolinks:load", () => {
const elem = document.getElementById('booking_kids');
elem.addEventListener('change', () => {
if (elem.value === 'yes') {
document.getElementById("hidden").style.display = "initial";
} else {
document.getElementById("hidden").style.display = "none";
}
});
});
Bonus - more sophisticated select fields:
f.select :score, [['horrible', 1], ['poor', 2], ['mediocre', 3], ['good', 4], ['great', 5]]
f.collection_select :score_id, Score.all, :id, :name
First, create a github oauth app.
example callback url: https://superails.com/users/auth/github/callback
/Gemfile
gem 'omniauth-github', github: 'omniauth/omniauth-github', branch: 'master'
gem "omniauth-rails_csrf_protection" # for omniauth 2.0
/config/initializers/devise.rb
config.omniauth :github, 'APP_ID', 'APP_SECRET'
/config/routes.rb
devise_for :users, controllers: {omniauth_callbacks: "users/omniauth_callbacks"}
/app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def github
handle_auth "Github"
end
def handle_auth(kind)
@user = User.from_omniauth(request.env["omniauth.auth"])
if @user.persisted?
flash[:notice] = I18n.t "devise.omniauth_callbacks.success", kind: kind
sign_in_and_redirect @user, event: :authentication
else
session["devise.auth_data"] = request.env["omniauth.auth"].except(:extra)
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n")
end
end
def failure
redirect_to root_path, alert: "Failure. Please try again"
end
end
/app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:omniauthable, omniauth_providers: [:github]
def self.from_omniauth(access_token)
data = access_token.info
user = User.where(email: data["email"]).first
user ||= User.create(
email: data["email"],
password: Devise.friendly_token[0, 20]
)
user.name = access_token.info.name
user.image = access_token.info.image
user.provider = access_token.provider
user.uid = access_token.uid
user.save
user
end
end
console
rails g migration add_omniauth_data_to_users name image provider uid
views
<% if user_signed_in? %>
<%= image_tag current_user.image, size: '30x30', alt: "#{current_user.email}" if current_user.image? %>
<%= current_user.name %>
<%= current_user.uid %>
<%= current_user.provider %>
<% else %>
<%= button_to 'Sign in with Github', omniauth_authorize_path(User, :github), method: :post, data: { turbo: 'false' } %>
<%#= button_to "Sign in with Github", omniauth_authorize_path(User, :github), method: :post, data: { disable_with: "Connecting..." } %>
<% end %>
It is based on wkhtmltopdf
technology.
By the end of the post we will be able to:
Gemfile
gem 'wicked_pdf'
gem "wkhtmltopdf-binary", group: :development
gem "wkhtmltopdf-heroku", group: :production
# terminal
bundle
rails g wicked_pdf
echo > app/assets/stylesheets/pdf.scss
Now you can test the installation by running something like wkhtmltopdf http://google.com google.pdf
to generate a pdf from this URL
To make the initializer work correctly on Heroku:
# config/initializers/wicked_pdf.rb
WickedPdf.config ||= {}
WickedPdf.config.merge!({
layout: "pdf.html.erb",
})
Optionally you might need to add a mime type:
# config/initializers/mime_types.rb
Mime::Type.register "application/pdf", :pdf
Create a separate HTML layout file for generating your PDFs:
# app/views/layouts/pdf.html.erb
<!DOCTYPE html>
<html>
<head>
<title>Appname</title>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= wicked_pdf_stylesheet_link_tag "pdf" %>
</head>
<body>
<div id="header">
<!--= wicked_pdf_image_tag 'thumbnail.png', height: "30", width: "auto"-->
</div>
<%= yield %>
</body>
</html>
# app/controllers/posts_controller.rb
def index
@posts = Post.all
respond_to do |format|
format.html
format.pdf do
# Rails 6
# render template: "posts/index.html.erb",
# pdf: "Posts: #{@posts.count}"
# Rails 7
# https://github.com/mileszs/wicked_pdf/issues/1005
render pdf: "Posts: #{@posts.count}", # filename
template: "hello/print_pdf",
formats: [:html],
disposition: :inline,
layout: 'pdf'
end
end
end
# app/views/posts/index.html.erb
<%= link_to "PDF", posts_path(format: :pdf) %>
/* app/assets/stylesheets/pdf.css */
body {
background-color: green;
}
table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
table {
width: 100%;
}
You might want to REMOVE this line from application.css, so that pdf.css
is not available anywhere else around the app:
- *= require_tree .
# config/initializers/wicked_pdf.rb
WickedPdf.config ||= {}
WickedPdf.config.merge!({
layout: "pdf.html.erb",
orientation: "Landscape", # Portrait
page_size: "A4",
lowquality: true,
zoom: 1,
dpi: 75
})
disposition: 'attachment'
- by default download PDF
disposition: 'inline'
- by default open PDF in browser
# app/controllers/posts_controller.rb
def show
respond_to do |format|
format.html
format.pdf do
# Rails 6:
# render template: "posts/show.html.erb",
# pdf: "Post ID: #{@post.id}"
# Rails 7:
render pdf: [@post.id, @post.name].join('-'),
template: "posts/show.html.erb",
formats: [:html],
disposition: :inline,
layout: 'pdf'
end
end
end
# app/views/posts/index.html.erb
<%= link_to 'This post in PDF', post_path(post, format: :pdf) %>
You can also have a separate template for PDF-only like render template: "pdfs/payment_received.html.erb"
# terminal
rails g mailer PostMailer new_post
action to trigger the mailer (in any controller, for example posts#show):
PostMailer.new_post.deliver_later
# app/mailers/post_mailer.rb
# def pdf_attachment_method(post_id)
def new_post
# post = Post.find(post_id)
# @post = Post.first
post = Post.first
attachments["post_#{post.id}.pdf"] = WickedPdf.new.pdf_from_string(
render_to_string(template: 'posts/show.html.erb', layout: 'pdf.html.erb', pdf: 'filename')
)
mail to: "to@example.org"
end
end
& now if you navigate to the email preview path rails/mailers/post_mailer/new_post
, you will see an attachment!
Part 2 of this post would potentially be wicked_pdf + AWS S3:
sudo apt-get install mysql-server mysql-client libmysqlclient-dev
gem 'mysql2', '~> 0.5.2'
default: &default
adapter: mysql2
encoding: utf8
pool: 5
username: root
password: root
socket: /var/run/mysqld/mysqld.sock
development:
<<: *default
database: projectname_development
test:
<<: *default
database: projectname_test
production:
<<: *default
database: projectname_production
username: projectname
password: <%= ENV['PROJECTNAME_DATABASE_PASSWORD'] %>
note that above we set a password root
- we need it to make it work correctly
root
. source on stackoverflowconsole
sudo mysql
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';
press Ctrl + D
to exit
console
rails db:create
rails db:migrate
Final result:
routes.rb
resources :posts do
member do
patch "upvote", to: "posts#upvote"
patch "downvote", to: "posts#downvote"
end
end
posts/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.all.order(cached_votes_score: :desc)
end
def upvote
@post = Post.find(params[:id])
if current_user.voted_up_on? @post
@post.unvote_by current_user
else
@post.upvote_by current_user
end
render "vote.js.erb"
end
def downvote
@post = Post.find(params[:id])
if current_user.voted_down_on? @post
@post.unvote_by current_user
else
@post.downvote_by current_user
end
render "vote.js.erb"
end
end
posts/index.html.erb
<h1>Posts</h1>
<% @posts.each do |post| %>
<%= post.name %>
<% if current_user %>
<br>
<%= render 'posts/upvote_link', post: post %>
<%= render 'posts/like_count', post: post %>
<%= render 'posts/downvote_link', post: post %>
<% end %>
<hr>
<% end %>
posts/_upvote_link.html.erb
<%= content_tag "div", id: "upvote-#{post.id}" do %>
<%= link_to upvote_post_path(post), method: :patch, remote: true, data: { disable_with: "voting..." } do %>
<% if current_user.voted_up_on? post %>
<i class="far fa-thumbs-up" style="color: green;"></i>
unvote
<% else %>
<i class="far fa-thumbs-up"></i>
upvote
<% end %>
<% end %>
<% end %>
posts/_downvote_link.html.erb
<%= content_tag "div", id: "downvote-#{post.id}" do %>
<%= link_to downvote_post_path(post), method: :patch, remote: true, data: { disable_with: "voting..." } do %>
<% if current_user.voted_down_on? post %>
<i class="far fa-thumbs-down" style="color: red;"></i>
unvote
<% else %>
<i class="far fa-thumbs-down"></i>
downvote
<% end %>
<% end %>
<% end %>
posts/_like_count.html.erb
<%= content_tag "div", id: "like-count-#{post.id}" do %>
<%= post.cached_votes_score %>
<% end %>
posts/vote.js.erb
document.getElementById("like-count-<%= @post.id %>").innerHTML = "<%= j render "posts/like_count", post: @post %>";
document.getElementById("downvote-<%= @post.id %>").innerHTML = "<%= j render "posts/downvote_link", post: @post %>";
document.getElementById("upvote-<%= @post.id %>").innerHTML = "<%= j render "posts/upvote_link", post: @post %>";
# console
rails g scaffold post body:text
bundle add acts_as_votable
rails generate acts_as_votable:migration
rails g migration AddCachedVotesToPosts
rails db:migrate
class AddCachedVotesToPosts < ActiveRecord::Migration[6.1]
def change
change_table :posts do |t|
t.integer :cached_votes_total, default: 0
t.integer :cached_votes_score, default: 0
t.integer :cached_votes_up, default: 0
t.integer :cached_votes_down, default: 0
t.integer :cached_weighted_score, default: 0
t.integer :cached_weighted_total, default: 0
t.float :cached_weighted_average, default: 0.0
end
# Uncomment this line to force caching of existing votes
# Post.find_each(&:update_cached_votes)
end
end
# app/models/post.rb
class Post < ApplicationRecord
acts_as_votable
end
# app/models/user.rb
class User < ApplicationRecord
acts_as_voter
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.all.order(cached_votes_score: :desc)
end
def like
@post = Post.find(params[:id])
if current_user.voted_up_on? @post
@post.downvote_by current_user
elsif current_user.voted_down_on? @post
@post.upvote_by current_user
else #not voted
@post.upvote_by current_user
end
respond_to do |format|
format.js
end
end
end
# config/routes.rb
resources :posts do
member do
patch "like", to: "posts#like"
end
end
# app/views/posts/index.html.erb
<h1>Posts</h1>
<% @posts.each do |post| %>
<%= post.name %>
<% if current_user %>
<%= content_tag "div", id: "like-link-#{post.id}" do %>
<%= render 'posts/like_link', post: post %>
<% end %>
<% end %>
<hr>
<% end %>
# app/views/posts/_like_link.html.erb
<%= link_to like_post_path(post), method: :patch, remote: true, id: "like-link-#{post.id}" do %>
<% if current_user.voted_up_on?(post) %>
UP voted
<b>
<%= post.cached_votes_score %>
</b>
DOWN vote
<% elsif current_user.voted_down_on?(post) %>
UP vote
<b>
<%= post.cached_votes_score %>
</b>
DOWN voted
<% else %>
UP
<b>
<%= post.cached_votes_score %>
</b>
DOWN
<% end %>
<% end %>
// like.js.haml
document.getElementById("like-link-#{@post.id}").innerHTML = "#{j render "posts/like_link", post: @post}";
// like.js.erb
document.getElementById("like-link-<%= @post.id %>").innerHTML = "<%= j render "posts/like_link", post: @post %>";
# posts_controller.rb
def upvote
@post = Post.find(params[:id])
@post.upvote_by current_user
respond_to do |format|
format.js
end
end
def downvote
@post = Post.find(params[:id])
@post.downvote_by current_user
respond_to do |format|
format.js
end
end
# app/views/posts/_downvote_link.html.erb
<%= link_to downvote_post_path(post), method: :patch, remote: true, id: "downvote-#{post.id}" do %>
Liked. Go Dislike
<% end %>
# app/views/posts/_like_count.html.erb
<%= post.cached_votes_score %>
# app/views/posts/_upvote_link.html.erb
<%= link_to upvote_post_path(post), method: :patch, remote: true, id: "upvote-#{post.id}" do %>
Disliked. Go Like
<% end %>
// app/views/posts/downvote.js.erb
document.getElementById("like-count-<%= @post.id %>").innerHTML = "<%= j render "posts/like_count", post: @post %>";
document.getElementById("downvote-<%= @post.id %>").innerHTML = "<%= j render "posts/upvote_link", post: @post %>";
# app/views/posts/index.html.erb
<% if current_user.voted_up_on? post %>
<%= render 'downvote_link', post: post %>
<% elsif current_user.voted_down_on? post %>
<%= render 'upvote_link', post: post %>
<% end %>
<%= content_tag "div", id: "like-count-#{post.id}" do %>
<%= render 'posts/like_count', post: post %>
<% end %>
// app/views/posts/upvote.js.erb
document.getElementById("like-count-<%= @post.id %>").innerHTML = "<%= j render "posts/like_count", post: @post %>";
document.getElementById("upvote-<%= @post.id %>").innerHTML = "<%= j render "posts/downvote_link", post: @post %>";
# config/routes.rb
patch "upvote", to: "posts#upvote"
patch "downvote", to: "posts#downvote"
app/views/posts/_downvote_link.html.erb
<%= content_tag "div", id: "downvote-#{post.id}" do %>
Liked
<%= link_to downvote_post_path(post), method: :patch, remote: true do %>
Go Dislike
<% end %>
<% end %>
app/views/posts/_upvote_link.html.erb
<%= content_tag "div", id: "upvote-#{post.id}" do %>
Disliked
<%= link_to upvote_post_path(post), method: :patch, remote: true do %>
Go Like
<% end %>
<% end %>
app/views/posts/like.js.erb
document.getElementById("like-link-<%= @post.id %>").innerHTML = "<%= j render "posts/like_link", post: @post %>";
document.getElementById("like-count-<%= @post.id %>").innerHTML = "<%= j render "posts/like_count", post: @post %>";
Course name | Ruby on Rails 6: Learn to Build a Multitenancy Subscriptions SaaS app MVP |
---|---|
33% Discount coupon | presale33 |
Expires | N/A |
Coupon limit | 50 |
Link | https://gumroad.com/l/ror6saas/presale33 |
@popperjs/core
, not popper.js
console
yarn add bootstrap
yarn add @popperjs/core
mkdir app/javascript/stylesheets
echo > app/javascript/stylesheets/application.scss
application.html.erb
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
app/javascript/packs/application.js
import 'bootstrap/dist/js/bootstrap'
import 'bootstrap/dist/css/bootstrap'
import 'stylesheets/application'
Serve all your stylesheets via webpacker. Keep styles inside the /javascript folder.
Example:
/superdemo/app/javascript/stylesheets/application.scss
body { background-color: #ede3d5; }
app/views/layouts/application.html.erb
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
app/javascript/packs/application.js
import 'bootstrap'
import "../stylesheets/application"
/superdemo/app/javascript/stylesheets/application.scss
@import "bootstrap";
app/views/layouts/application.html.erb
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
app/javascript/packs/application.js
import * as bootstrap from 'bootstrap'
import "../stylesheets/application"
app/javascript/stylesheets/application.scss
@import "bootstrap"
console
yarn remove jquery popper.js
environment.js - leave only this:
const { environment } = require('@rails/webpacker')
module.exports = environment
console
yarn add bootstrap
yarn add @popperjs/core
mkdir app/javascript/stylesheets/
cd app/javascript/stylesheets/
touch application.scss
app/javascript/stylesheets/application.scss
@import "bootstrap"
app/views/layouts/application.html.erb
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
++ <%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
app/javascript/packs/application.js
import Rails from "@rails/ujs"
import Turbolinks from "turbolinks"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
++ import * as bootstrap from 'bootstrap'
++ import "../stylesheets/application"
Rails.start()
Turbolinks.start()
ActiveStorage.start()
First, install stimulus
Next, create controllers:
touch app/javascript/controllers/popover_controller.js
touch app/javascript/controllers/tooltip_controller.js
app/javascript/controllers/tooltip_controller.js
import * as bootstrap from 'bootstrap'
import { Controller } from 'stimulus';
export default class extends Controller {
connect() {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
}
}
app/javascript/controllers/popover_controller.js
import * as bootstrap from 'bootstrap'
import { Controller } from 'stimulus';
export default class extends Controller {
connect() {
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'))
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl)
})
}
}
Now, initialize the controllers in an HTML file where you will be including a tooltip or popover:
<div data-controller="tooltip">
Anything
</div>
<div data-controller="popover">
Anything
</div>
Feel free to add HTML for a Tooltip or Popover. Should work!
prerequisites:
Create a widget: https://core.telegram.org/widgets/login
Example result that can be added to any view:
<script async
src="https://telegram.org/js/telegram-widget.js?14"
data-telegram-login="gorocrm_bot"
data-size="small"
data-userpic="false"
data-auth-url="https://localhost:3000/"
data-request-access="write">
</script>
after pressing the button and authenticating with telergam, the user will be redirected to the data-auth-url
with the following params
https://localhost:3000/?id=123456&first_name=Yaro&last_name=Shm&username=yarotheslav&auth_date=1613682858&hash=fa242eca
these params can be accessed in the redirect view .html.erb
file by adding:
<%= params[:id] %>
<%= params[:first_name] %>
<%= params[:last_name] %>
<%= params[:username] %>
At this point the user is authenticated, and our bot is allowed to send him private messages.
Now we want to save the users telegram id to the database to be able to send him chat messages.
For this we will:
params[:id]
to current_user.telegram_id
user_path(current_user)
console - add telegram_id
to users
table
rails g migration add_telegram_id_to_users telegram_id:integer
Change the telegram data-auth-url
to redirect to a controller action.
Example 1: to users_path(current_user)
data-auth-url="<%= url_for(controller: "users", action: 'show', id: current_user.id) %>"
Example 2 (our way): to telegram_controller.rb
, action telegram_auth
data-auth-url="<%= url_for(controller: "telegram", action: 'telegram_auth') %>"
telegram_controller.rb save the telegram_id and redirect
class TelegramController < ApplicationController
def telegram_auth
if params[:id].present?
current_user.update(telegram_id: params[:id])
end
redirect_to user_path(current_user), notice: "Telegram authentication: success"
end
end
routes.rb
get "telegram_auth", to: "telegram#telegram_auth"
post "telegram_auth", to: "telegram#telegram_auth"
now in users/show.html.erb you can access the user.telegram_id
<%= @user.telegram_id %>
app/controllers/posts_controller.rb - add a new TelegramMailer
that specifies a user
def create
@post = Post.new(post_params)
if @post.save
text = "#{current_user} created post: #{@post.title}"
TelegramMailer.private_message(text, current_user).deliver_now
redirect_to @post, notice: 'Post was successfully created.'
else
render :new
end
end
app/mailers/telegram_mailer.rb - action for bot to send message to the user
def private_message(text, user)
api_secret_key = "1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM"
chat_id = user.telegram_id
HTTParty.post("https://api.telegram.org/bot#{api_secret_key}/sendMessage",
headers: {
'Content-Type' => 'application/json'
},
body: {
chat_id: chat_id,
text: text
}.to_json
)
end
prerequisites:
console:
rails g mailer TelegramMailer
app/mailers/telegram_mailer.rb - action for bot to send message to the chat
class TelegramMailer < ApplicationMailer
def group_message(text)
api_secret_key = "1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM"
chat_id = "-574253305"
HTTParty.post("https://api.telegram.org/bot#{api_secret_key}/sendMessage",
headers: {
'Content-Type' => 'application/json'
},
body: {
chat_id: chat_id,
text: text
}.to_json
)
end
end
api_secret_key
- access key of the bot.
chat_id
- ID of the group chat.
app/controllers/posts_controller.rb
def create
@post = Post.new(post_params)
if @post.save
text = "#{current_user} created post: #{@post.title}"
TelegramMailer.group_message(text).deliver_now
redirect_to @post, notice: 'Post was successfully created.'
else
render :new
end
end
text
- customizable text that will be send via telegram.
TelegramMailer.group_message(text).deliver_now
- action to send the message from telegram_mailer.rb
post.title
that contains characters
posts_controller.rb
def index
if params[:title]
@posts = Post.where('title ILIKE ?', "%#{params[:title]}%").order(created_at: :desc) #case-insensitive
else
@posts = Post.all.order(created_at: :desc)
end
end
any view (posts/index.html.haml or in a bootstrap navbar)
.form-inline.my-2.my-lg-0
= form_tag(courses_path, method: :get) do
.input-group
= text_field_tag :title, params[:title], autocomplete: 'off', placeholder: "Find a course", class: 'form-control-sm'
%span.input-group-append
%button.btn.btn-primary.btn-sm{:type => "submit"}
%span.fa.fa-search{"aria-hidden" => "true"}
.html.erb without bootstrap
<%= form_tag(posts_path, method: :get) do %>
<%= text_field_tag :title, params[:title], autocomplete: 'off', placeholder: "post title" %>
<%= submit_tag "Search" %>
<% end %>
user.posts.count
, user.comments.count
).
Storing this data in a the database is more efficient (like user.posts_count
, user.comments_count
) than recalculating it each time.
counter_cache
gives us a way to recalculate the database field containing count of child records whenever a child record is created/deleted
user.rb
has_many :posts
post.rb - add counter_cache: true
to recalculate posts_count
field in user table
belongs_to :user, counter_cache: true
console:
rails g migration add_posts_count_to_users posts_count:integer
migration:
add_column :users, :posts_count, :integer, default: 0, null: false
rails c - recalculate posts_count for all existing posts
and users
User.find_each { |u| User.reset_counters(u.id, :posts) }
create a telegram bot: save the API key that you were given to access the app:
bot1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM
Next - create a telegram group, invite the bot bot to group, make the bot a group admin
Next - get the group id via an API call in the browser:
https://api.telegram.org/bot1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM/getUpdates
result: from the request we will see the group id:
-574253305
Now we can post a message to the group via the browser:
https://api.telegram.org/bot1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM/sendMessage?chat_id=-574253305&text=yo
result:
any view:
<%= link_to "Send message via View", "https://api.telegram.org/bot1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM/sendMessage?chat_id=-574253305&text=yo", method: :post %>
We will need to send HTTP requests from a controller action.
For this we will use gem "httparty"
.
gemfile
gem "httparty", "~> 0.18" # Makes http fun! Also, makes consuming restful web services dead easy
routes.rb
post "bots/say_hello", to: "bots#say_hello", as: :say_hello
bots_controller.rb - displays a few different ways of sending an HTTParty requests.
class BotsController < ApplicationController
def say_hello
# https://api.telegram.org/bot1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM/getUpdates
api_secret_key = "1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM"
chat_id = "-574253305"
text = "#{current_user} @yarotheslav #{request.url} sorry for spam"
# HTTParty.post("https://api.telegram.org/bot#{api_secret_key}/sendMessage?chat_id=#{chat_id}&text=#{text}")
# HTTParty.post('https://api.telegram.org/bot1629298034:AAGMejWo9WFeZ-XP51f4Tpbb_L_0t8nO4xM/sendMessage?chat_id=-574253305&text=yo%20bro')
# HTTParty.post("https://api.telegram.org/bot#{api_secret_key}/sendMessage?chat_id=#{chat_id}&text=#{text}")
# body = {text: "#{current_user} is alive", chat_id: chat_id}
# HTTParty.post("https://api.telegram.org/bot#{api_secret_key}/sendMessage", body: body)
HTTParty.post("https://api.telegram.org/bot#{api_secret_key}/sendMessage",
headers: {
'Content-Type' => 'application/json'
},
body: {
chat_id: chat_id,
text: text
}.to_json
)
redirect_to root_path, notice: "message sent"
end
end
any view - invoke this controller action:
<%= link_to "Send message via Controller", say_hello_path, method: :post %>
or a button that has method: :post
included by default:
<%= button_to "Send message via Controller", say_hello_path %>
Useful links & future readings:
]]>status
of a post
HOWTO:
migration - add status
column to posts
add_column :posts, :status, :string, null: false, default: "planned"
post.rb - list available statuses
validates :status, presence: true
STATUSES = %i[planned progress done].map(&:to_s).freeze
validates :quote_requestor, inclusion: { in: Post::STATUSES }
posts_controller.rb - add action to change status
def change_status
@post = Post.find(params[:id])
if params[:status].present? && Post::STATUSES.include?(params[:status].to_sym)
@post.update(status: params[:status])
end
redirect_to @post, notice: "Status updated to #{@post.status}"
end
routes.rb
resources :posts do
member do
patch :change_status
end
end
posts/show.html.erb
<% Post::STATUSES.each do |status| %>
<%= link_to change_status_post_path(@post, status: status), method: :patch do %>
<%= status %>
<% end %>
<% end %>
or with a block
<% Post::STATUSES.each do |status| %>
<%= link_to_unless post.status.eql?(status.to_s), status, change_status_post_path(post, status: status), method: :patch %>
<% end %>
How does it work:
student
/lesson
/teacher
)student
/lesson
/teacher
, you can select multiple tags that belong to the respective category.Tables/Models:
Tag
fields: name
category
Tagging
(joint table) fields: tag_id
taggable_id
taggable_type
Client
fields: anythingLesson
fields: anythingStudent
fields: anythingconsole
rails g scaffold tags name category --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
rails g migration create_taggings tag:references taggable:references{polymorphic}
tag.rb
class Tag < ApplicationRecord
validates :name, :category, presence: true
validates :name, length: {minimum: 1, maximum: 25}, uniqueness: { scope: :category, message: "uniquene per category" }
def to_s
name
end
CATEGORIES = [:student, :lesson, :teacher]
def self.categories
CATEGORIES.map { |category| [category, category] }
end
has_many :taggings, dependent: :destroy
end
tagging.rb
class Tagging < ApplicationRecord
belongs_to :taggable, polymorphic: true
belongs_to :tag
end
tags_controller.rb - nothing special. Regular CRUD
taggings_controller.rb - not needed
To select multiple tags we will use selectize.js
.
console
yarn add selectize
app/javascript/packs/application.js - add these on the bottom
require("selectize")
require("packs/tags")
app/javascript/packs/tags.js - create this file
$(document).on("turbolinks:load", function() {
$(".selectize-tags").selectize({
create: function(input, callback) {
$.post('/tags.json', { tag: { name: input } })
.done(function(response){
console.log(response)
callback({value: response.id, text: response.name });
})
}
});
});
Student
student.rb - add relationships
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
students_controller.rb - whitelist multiple tags
def student_params
params.require(:student).permit(:name, tag_ids: [])
end
students/_form.html.erb - input multiple tags with selectize
<%= f.select :tag_ids, Tag.where(category: "student").pluck(:name, :id), {}, { multiple: true, class: "selectize-tags" } %>
student/show - display tags
<% @student.tags.each do |tag| %>
<%= tag.name %>
<% end %>
This tutorial does not cover creating NEW tags with selectize. Good idea for the future?
]]>In this example we will:
Example: Clients
and Teachers
can both create Payments
.
To create a payment
we:
payment
(teacher
or client
)teacher
or client
recordconsole
rails g scaffold payments amount:integer payable:references{polymorphic} --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
payment.rb
belongs_to :payable, polymorphic: true
validates :amount, presence: true
def to_s
[payable_type, payable_id, amount].join(" ")
end
teacher.rb and client.rb
has_many :payments, as: :payable, dependent: :restrict_with_error
payments/index.html.erb
<%= link_to "Client Payment", new_payment_path(payable_type: "Client") %>
<%= link_to "Teacher Payment", new_payment_path(payable_type: "Teacher") %>
By pressing one of the above links we will be redirected to an url like /payments/new?payable_type=Client
or /payments/new?payable_type=Teacher
.
Based on ?payable_type=Teacher
we give a collection of teachers to choose from:
payments/_form.html.erb
<%= simple_form_for(@payment) do |f| %>
<%= f.input :payable_type, input_html: {value: @payment.payable_type || params[:payable_type]}, as: :hidden %>
<% if @payment.payable_type.present? %>
<%= f.input :payable_id, collection: @payment.payable_type.classify.constantize.all %>
<% elsif params[:payable_type].present? %>
<%= f.input :payable_id, collection: params[:payable_type].classify.constantize.all %>
<% end %>
<%= f.input :amount %>
<%= f.button :submit %>
<% end %>
@payment.payable_type.classify.constantize.all
gives us a collection of @clients
or @teachers
if we are EDITING a payment
.
params[:payable_type].classify.constantize.all
gives us a collection of @clients
or @teachers
if we are CREATING a payment
.
This approach is good by not depending on any JS.
However it can be improved by being able to select the payable_type
inside the form and than rendering the collection, rather than using params
.
This example - creating polymorphic Comments
table that can be integrated easily into any model.
console
rails g migration create_comments content:text commentable:references{polymorphic}
app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
validates :content, presence: true
def to_s
content
end
end
app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :set_commentable
def new
@comment = Comment.new
end
def create
@comment = @commentable.comments.build(comment_params)
if @comment.save
redirect_to @commentable, notice: "Comment created"
else
render :new
end
end
def destroy
@comment = Comment.find(params[:id])
if @comment.destroy
redirect_to @commentable, notice: "Comment deleted"
else
redirect_to @commentable, alert: "Something went wrong"
end
end
private
def comment_params
params.require(:comment).permit(:content)
end
def set_commentable
if params[:user_id].present?
@commentable = User.find_by_id(params[:user_id])
elsif params[:post_id].present?
@commentable = Post.find_by_id(params[:post_id])
end
end
end
app/views/comments/_form.html.erb
<%= form_for [@commentable, @comment] do |f| %>
<% if @comment.errors.any? %>
<% @comment.errors.each do |error| %>
<%= error.full_message %>
<% end %>
<% end %>
<%= f.label :content %>
<%= f.text_area :content %>
<%= f.submit %>
<% end %>
app/views/comments/_index.html.erb
Comments:
<%= @commentable.comments.count %>
<% commentable.comments.each do |comment| %>
<%= comment.created_at.strftime('%d-%m-%Y %H:%m') %>
<%= link_to "Destroy", [@commentable, comment], method: :delete %>
<%= simple_format(comment.content) %>
<br>
<% end %>
app/views/comments/new.html.erb
New Comment for:
<%= link_to @commentable, @commentable %>
<%= render 'comments/form' %>
routes.rb
resources :posts do
resources :comments, only: [:new, :create, :destroy]
end
post
app/controllers/posts_controller.rb
def show
@commentable = @post
@comment = Comment.new
end
app/models/post.rb
has_many :comments, as: :commentable, dependent: :destroy
app/views/posts/show.html.erb - with a link to create a comment
<%= link_to "New Comment", new_post_comment_path(@commentable, @comment) %>
<%= render partial: "comments/index", locals: {commentable: @commentable} %>
app/views/posts/show.html.erb - alternative - with a comments form in the view
<%= render template: "comments/new" %>
<%= render partial: "comments/index", locals: {commentable: @commentable} %>
In comments_controller
you have to update the action set_commentable
with each model that you want to make commentable.
This is better than creating a separate comments_controller
for each commentable model.
To add polymorphic to one more model, repeat step 2
by replacing the word post
with whatever model you want.
rails g migration add_user_references_to_comments user:references
current_user
who created a comment, in comments_controller
create
action above line if @comment.save
add the line
@comment.user_id = current_user.id
simple_form
<%= simple_form_for [@commentable, @comment] do |f| %>
<%= f.error_notification %>
<%= f.error_notification message: f.object.errors[:base].to_sentence if f.object.errors[:base].present? %>
<%= f.input :content, label: false, required: true %>
<%= f.button :submit %>
<% end %>
index.html.erb
.
To continue having tables in your scaffolds, paste this code into lib/templates/erb/scaffold/index.html.erb.tt
within your Rails app.
If you are using tailwindcss-rails
, you will also want to specify that you want to use your own template_engine
:
# config/application.rb
config.generators.template_engine = :erb
In this same way you can overwrite the default scaffold templates to include:
For example, you can try adding simple_form
& bootstrap
styling for your scaffolds by default.
Example of final result:
If you run the below command in your terminal, it will run a script to add styled *.html.erb
scaffold templates to your app:
rm ./lib/templates/erb/scaffold/_form.html.erb
rails app:template LOCATION="https://blog.corsego.com/script-custom-scaffold-templates.txt"
Regenerating scaffold views for existing model based on attributes:
rails g erb:scaffold Post title content
Hereās how a bootstrap-styled scaffold template erb file can look:
<!-- lib/templates/erb/scaffold/index.html.erb -->
<h3>
<div class="text-center">
<%= plural_table_name.capitalize %>
<div class="badge badge-info">
<%%= @<%= plural_table_name%>.count %>
</div>
<%%= link_to "New <%= singular_table_name %>", new_<%= singular_table_name %>_path, class: 'btn btn-primary' %>
</div>
</h3>
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover table-sm table-light shadow">
<thead>
<tr>
<th>Id</th>
<% attributes.each do |attribute| %>
<th><%= attribute.human_name %></th>
<% end %>
<th>Actions</th>
</tr>
</thead>
<tbody>
<%% @<%= plural_table_name%>.each do |<%= singular_table_name %>| %>
<%%= content_tag :tr, id: dom_id(<%= singular_table_name %>), class: dom_class(<%= singular_table_name %>) do %>
<td><%%= link_to <%= singular_table_name %>.id, <%= singular_table_name %> %></td>
<% attributes.each do |attribute| %>
<td><%%= <%= singular_table_name %>.<%= attribute.name %> %></td>
<% end %>
<td>
<%%= link_to 'Edit', edit_<%= singular_table_name %>_path(<%= singular_table_name %>), class: 'btn btn-sm btn-warning' %>
<%%= link_to 'Destroy', <%= singular_table_name %>, method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-sm btn-danger' %>
</td>
<%% end %>
<%% end %>
</tbody>
</table>
</div>
Inspiration:
]]>https://eu-central-1.console.aws.amazon.com/ses/home?region=eu-central-1#smtp-settings:
Getting the API Keys:
HOWTO
production.rb:
config.action_mailer.default_url_options = {host: "corsego.herokuapp.com", protocol: "https"}
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
port: 587,
address: 'email-smtp.eu-central-1.amazonaws.com',
user_name: 'SMTP_CREDENTIALS_USER_NAME',
password: 'SMTP_CREDENTIALS_PASSWORD',
authentication: :plain,
enable_starttls_auto: true
}
app/mailers/application_mailer.rb:
default from: "Corsego <hello@corsego.com>"
useful links:
]]>Why? For fewer bots to sign up!
Final result:
HOWTO
gemfile:
gem 'invisible_captcha'
console:
bundle
rails g devise:controllers users -c=registrations
app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
invisible_captcha only: [:create]
routes.rb:
devise_for :users, controllers: {
registrations: 'users/registrations'
}
app/views/devise/registrations/new.html.erb, inside the form:
<%= invisible_captcha %>
true
/false
values like this:
app/helpers/application_helper.rb:
# boolean green or red
def boolean_label(value)
case value
when true
text = 'Yes'
badge_color = 'badge bg-success text-light'
when false
text = 'No'
badge_color = 'badge bg-danger text-light'
end
tag.span(text, class: badge_color)
end
your view:
= boolean_label(user.confirmed?)
rolify
or try to add a few role
fields to the users
table,
** but there is a better way **:
migration:
rails g migration add_roles_to_users
class AddRolesToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :roles, :jsonb, null: false, default: {}
add_index :users, :roles, using: :gin
end
end
app/models/concerns/roleable.rb:
module Roleable
extend ActiveSupport::Concern
included do
# List user roles
ROLES = [:admin, :teacher, :student]
# json column to store roles
store_accessor :roles, *ROLES
# Cast roles to/from booleans
ROLES.each do |role|
scope role, -> { where("roles @> ?", {role => true}.to_json) }
define_method(:"#{role}=") { |value| super ActiveRecord::Type::Boolean.new.cast(value) }
define_method(:"#{role}?") { send(role) }
end
def active_roles # Where value true
ROLES.select { |role| send(:"#{role}?") }.compact
end
# role validation
validate :must_have_a_role, on: :update
validate :must_have_an_admin
private
def must_have_an_admin
if persisted? &&
(User.where.not(id: id).pluck(:roles).count { |h| h["admin"] == true } < 1) &&
roles_changed? && admin == false
errors.add(:base, "There should be at least one admin")
end
end
def must_have_a_role
if roles.values.none?
errors.add(:base, "A user must have at least one role")
end
end
end
end
app/models/user.rb:
include Roleable
whitelist editing roles in the controller:
def edit
@user = User.find(params[:id])
end
def update
@user = User.find(params[:id])
@user.update(user_params)
if @user.update(user_params)
redirect_to @user, notice: "User was successfully updated."
else
render :edit
end
end
private
def user_params
params.require(:user).permit(*User::ROLES)
end
app/views/users/edit.html.erb:
<%= form_for(@user) do |f| %>
<% if @user.errors.any? %>
<div id="error_explanation">
<h2>
<%= pluralize(@user.errors.count, "error") %>
prohibited this user from being saved:
</h2>
<ul>
<% @user.errors.full_messages.each do |message| %>
<li>
<%= message %>
</li>
<% end %>
</ul>
</div>
<% end %>
<% User::ROLES.each do |role| %>
<label>
<%= f.check_box role %>
<%= role.to_s.humanize %>
</label>
<br>
<% end %>
<%= f.button :submit %>
<% end %>
add a role in the console
User.first.update(admin: true)
list roles in a view:
<%= user.active_roles.join(", ") %>
<%= user.roles %>
<%= user.roles.class %>
<%= user.admin? %>
<% if user.admin? || user.viewer? %>
admin or viewer
<% end %>
<%= current_user.admin? %>
controller validation for authorization:
before_action :only_admin, only: [:edit, :update]
def only_admin
unless current_user.admin?
redirect_to dashboard_path, notice: "Not authorized!"
end
end
Helpful materials:
2 Reasons:
*.html.erb
*.html.erb
Clearly, HAML is a modern and popular choice for web development with Ruby on Rails 6.
VERY useful websites that help converting bulk code from haml to erb, that I use on a daily basis:
.erb
After years of using and advocating for HAML
, Iām switching back to ERB
.
Why? Not because I like it more.
data
attributes and dom_id
s, and I want to keep everything consistent. (Better reason)Althrough, haml-like logical nesting will forever be a must-have in my code. And if I had to draft an HTML page right away (without a framework), I would be able to do it faster and more elegantly in HAML. Haml is a viable way to writing beautiful HTML, that can be used way beyond the context of Ruby on Rails.
Thatās life.
]]>Source: https://github.com/CodeSeven/toastr
console:
yarn add toastr
Import toastr in app/javascripts/packs/application.js:
global.toastr = require("toastr")
app/javascript/stylesheets/application.scss:
@import 'toastr'
app/javascript/packs/application.js:
import "../stylesheets/application"
app/views/layouts/application.rb:
<% unless flash.empty? %>
<script type="text/javascript">
<% flash.each do |f| %>
<% type = f[0].to_s.gsub('alert', 'error').gsub('notice', 'info') %>
toastr['<%= type %>']('<%= f[1] %>');
<% end %>
</script>
<% end %>
customizing the flash:
<% unless flash.empty? %>
<script type="text/javascript">
<% flash.each do |f| %>
<% type = f[0].to_s.gsub('alert', 'error').gsub('notice', 'info') %>
toastr['<%= type %>']('<%= f[1] %>', '', {"closeButton": true,
"positionClass": "toast-top-center",
"onclick": null,
"showDuration": "300",
"hideDuration": "1000",
"timeOut": "5000",
"extendedTimeOut": "1000",
"showEasing": "swing",
"hideEasing": "linear",
"showMethod": "fadeIn",
"progressBar": true,
"hideMethod": "fadeOut" });
<% end %>
</script>
<% end %>
Footnote: require("stylesheets/application.scss")
= import "../stylesheets/application"
gem devise
for a User
model, add these links to your application:
<% if current_user %>
<%= link_to current_user.email, edit_user_registration_path %>
<%= link_to "Log out", destroy_user_session_path, method: :delete %>
<% else %>
<%= link_to "Log in", new_user_session_path %>
<%= link_to "Register", new_user_registration_path %>
<% end %>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">
<i class="fas fa-flag"></i>
Brand
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<%= link_to root_path, class: "nav-link #{'active font-weight-bold' if current_page?(root_path)}" do %>
<div class="fa fa-home"></div>
Home
<% end %>
</ul>
<ul class="navbar-nav mr-right">
<% if current_user %>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<div class="fa fa-user"></div>
<b><%= current_user.email %></b>
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<%= link_to edit_user_registration_path, class: "dropdown-item #{'active font-weight-bold' if current_page?(edit_user_registration_path)}" do %>
<div class="fa fa-cog"></div>
<b>Account settings</b>
<% end %>
<%= link_to destroy_user_session_path, method: :delete, class: "dropdown-item" do %>
<div class="fa fa-sign-out-alt"></div>
<b>Sign out</b>
<% end %>
</div>
</li>
<% else %>
<%= link_to "Log in", new_user_session_path, class: "nav-link #{'active font-weight-bold' if current_page?(new_user_session_path)}" %>
<%= link_to "Sign up", new_user_registration_path, class: "nav-link #{'active font-weight-bold' if current_page?(new_user_registration_path)}" %>
<% end %>
</ul>
</div>
</nav>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="fas fa-flag"></i>
Brand
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto">
<%= link_to root_path, class: "nav-link #{'active fw-bold' if current_page?(root_path)}" do %>
<div class="fa fa-home"></div>
Home
<% end %>
</ul>
<ul class="navbar-nav ms-auto">
<% if current_user %>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<div class="fa fa-user"></div>
<b><%= current_user.email %></b>
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<%= link_to edit_user_registration_path, class: "dropdown-item #{'active fw-bold' if current_page?(edit_user_registration_path)}" do %>
<div class="fa fa-cog"></div>
<b>Account settings</b>
<% end %>
<%= link_to destroy_user_session_path, method: :delete, class: "dropdown-item" do %>
<div class="fa fa-sign-out-alt"></div>
<b>Sign out</b>
<% end %>
</ul>
</li>
<% else %>
<%= link_to "Log in", new_user_session_path, class: "nav-link #{'active fw-bold' if current_page?(new_user_session_path)}" %>
<%= link_to "Sign up", new_user_registration_path, class: "nav-link #{'active fw-bold' if current_page?(new_user_registration_path)}" %>
<% end %>
</ul>
</div>
</div>
</nav>
Other good Bootstrap 5 navbar:
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<%= link_to "Brand", root_path, class: "navbar-brand" %>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<%= link_to "Posts", posts_path, class: "nav-item nav-link" %>
<%= link_to "Categories", categories_path, class: "nav-item nav-link" %>
<%= link_to "Notifications", notifications_path, class: "nav-item nav-link" %>
</ul>
<ul class="navbar-nav mb-2 mb-lg-0">
<% if user_signed_in? %>
<%= link_to current_user.email, edit_user_registration_path, class: "nav-item nav-link" %>
<%= link_to "Sign out", destroy_user_session_path, method: :delete, class: "nav-item nav-link" %>
<% else %>
<%= link_to "Sign up", new_user_registration_path, class: "nav-item nav-link" %>
<%= link_to "Login", new_user_session_path, class: "nav-item nav-link" %>
<% end %>
</ul>
</div>
</div>
</nav>
It is often easy to distinguish a Rails app by going to /404
or /500
. You know this screen, right?
When you create a new rails app, error pages like 404
and 500
are automatically created and kept in public
folder.
At some point of time, you will what to integrate these pages into your app and style them, like I did here:
Hereās how you can integrate the error pages into your app:
# terminal
rails g controller errors not_found internal_server_error --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework
rm public/{404,500}.html
echo > app/views/layouts/errors.html.erb
# app/config/application.rb
config.exceptions_app = self.routes
# app/config/routes.rb
match "/404", via: :all, to: "errors#not_found"
match "/500", via: :all, to: "errors#internal_server_error"
# app/views/controllers/errors_controller.rb
class ErrorsController < ActionController::Base
def not_found
render status: 404
end
def internal_server_error
render status: 500
end
end
<!-- app/views/layouts/errors.html.erb -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= Rails.application.class.module_parent_name %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<%= yield %>
</body>
</html>
<!-- app/views/errors/internal_server_error.html.erb -->
<div class="text-center">
<br>
<h2>
500
<%= action_name.humanize %>
</h2>
<hr>
<b>
We had a problem loading this page.
</b>
<hr>
<%= link_to "Back", root_path %>
</div>
<!-- app/views/errors/not_found.html.erb -->
<div class="text-center">
<br>
<h2>
<%= response.status %>
<%= action_name.humanize %>
</h2>
<hr>
<b>
The page you were looking for doesn't exist.
</b>
<br>
You may have mistyped the address or the page may have moved.
<hr>
<%= link_to "Back", root_path %>
</div>
<!-- app/views/layouts/errors.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>CustomErrorPages</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>
<main class="grid h-screen place-items-center">
<%= yield %>
</main>
</body>
</html>
<!-- app/views/errors/not_found.html.erb -->
<div class='text-center'>
<h1 class="font-bold text-4xl"><%= action_name.humanize %></h1>
<p>This page does not exist</p>
<%= link_to 'Return to homepage', root_url, class: 'text-blue-500' %>
<%= image_tag "#{response.status}.png" %>
</div>
Often when working on a Rails app, you will have to handle vulnerable data.
Most often these are API keys to services that you integrate.
Most common examples:
Here you can see a client_id
and client_secret
provided by Github, so that you can add āLog in with Githubā functionality:
To use these keys, you could directly place them in your devise.rb
file like
config.omniauth :github, "23r32t34t4rg", "regregbesgbvtegc4g43g343"
However this approach creates a security threat.
For example, if your repository is ever open sourced or shared with third parties, anybody can misuse your API keys.
That can lead to your account:
Thatās why should use credentials to encrypt sensitive data.
An encrypted line in devise.rb
would look like:
config.omniauth :github, (Rails.application.credentials[Rails.env.to_sym][:github][:client]).to_s, (Rails.application.credentials[Rails.env.to_sym][:github][:secret]).to_s
So how do you make it work?
When you create a Rails 6 app, under app/config you have a file named credentials.yml.enc
:
If you open the credentials.yml.enc
file, it will usually look like this:
It is encrypted and safe to share in a public repository.
To decrypt the credentials.yml
file, the master.key
file is used:
NEVER SHARE THE MASTER KEY WITH THE PUBLIC.
IF YOU LOSE THE MASTER KEY, YOU WILL NOT BE ABLE TO DECRYPT YOUR CREDENTIALS
By default, master.key
is not included into your git commits.
To decrypt and view or edit your credentials.yml
,
you can run rails credentials:edit
or EDITOR=vim rails credentials:edit
.
When decripted, the credentials.yml
file would typically looks somewhat like this:
To retrieve any data from credentials.yml
in your rails app or in the console, you can run something like
rails c
Rails.application.credentials.dig(:aws, :access_key_id)
#=> sdgb89dngfm6cg8jmbdb8f9bfg6n8fnd7bd9f
Rails.application.credentials[:github][Rails.env.to_sym][:secret]
#=> 6hl65knh4l5vgm8
Editing the file in VIM inside a terminal can a feel tricky and unnatural.
To edit the file, press i
. You will see INSERT
appear on the bottom of the file, prompting that you are currently able to edit the file:
When youāre done, press ESC
. next press :wq
+ ENTER
to exit with saving.
or press ESC
+ :q!
+ ENTER
to exit without saving.
To set your master key in production (heroku example):
heroku config:set RAILS_MASTER_KEY=YOURMASTERKEY
or
heroku config:set RAILS_MASTER_KEY=`cat config/master.key`
Thatās it :)
]]>rails credentials:edit
EDITOR=vim rails credentials:edit
rails credentials:show
config/credentials.yml
example:awss3:
access_key_id: YOUR_CODE_FOR_S3_STORAGE
secret_access_key: YOUR_CODE_FOR_S3_STORAGE
google_analytics: YOUR_CODE_FOR_GOOGLE_ANALYTICS
recaptcha:
site_key: YOUR_CODE_FOR_RECAPTCHA
secret_key: YOUR_CODE_FOR_RECAPTCHA
google_oauth2:
client_id: YOUR_CODE_FOR_OAUTH
client_secret: YOUR_CODE_FOR_OAUTH
development:
github:
client: YOUR_CODE_FOR_OAUTH
secret: YOUR_CODE_FOR_OAUTH
stripe:
publishable: YOUR_STRIPE_PUBLISHABLE
secret: YOUR_STRIPE_SECRET
production:
github:
client: YOUR_CODE_FOR_OAUTH
secret: YOUR_CODE_FOR_OAUTH
stripe:
publishable: YOUR_STRIPE_PUBLISHABLE
secret: YOUR_STRIPE_SECRET
facebook:
client: YOUR_CODE_FOR_OAUTH
secret: YOUR_CODE_FOR_OAUTH
To enable editing press i
For exiting with saving press Esc
& :wq
& Enter
For exiting without saving press Esc
& :q!
& Enter
To make Ctrl+V work properly Esc
& :set paste
& i
& Ctrl +
V`
devise.rb
:config.omniauth :github, Rails.application.credentials.dig(Rails.env.to_sym, :github, :id), Rails.application.credentials.dig(Rails.env.to_sym, :github, :secret)
or
if Rails.application.credentials[Rails.env.to_sym].present? && Rails.application.credentials[Rails.env.to_sym][:github].present?
config.omniauth :github, Rails.application.credentials[Rails.env.to_sym][:github][:id], Rails.application.credentials[Rails.env.to_sym][:github][:secret]
end
stripe.rb
:Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret)
.dig
is saferrails c
Rails.application.credentials.dig(:aws, :access_key_id)
Rails.application.credentials[Rails.env.to_sym][:aws][:access_key_id]
Rails.application.credentials.some_variable
Rails.application.credentials[:production][:aws][:id]
Rails.application.credentials.production[:aws][:id]
rails credentials:diff --enroll
git diff config/credentials.yml.enc
EDITOR=vim bin/rails credentials:edit --environment development
will generate
config/credentials/development.yml.enc
config/credentials/development.key
master.key
in production (heroku):By default master.key
is in .gitignore
heroku config:set RAILS_MASTER_KEY=123456789
heroku config:set RAILS_MASTER_KEY=`cat config/master.key`
heroku config:set RAILS_MASTER_KEY=`cat config/credentials/production.key`
Bonus: in config/environments/production.rb uncomment config.require_master_key = true
The config/credentials.yml
file should NOT be in gitignore.
The config/master.key
that decrypts the credentials SHOULD be in gitignore.
You are charged
to postpone the end date
.
From this perspective, a āOne-time paymentā = subscription with lifetime access.
Minimalistic Database representation:
In this case a user can buy a premium
subscription for $9,99
for a limited time 1 month
to remove adds.
Hereās how it will work:
# app/models/user.rb
# email :string
# ends_at :datetime
def premium_price
# how much to charge for postponing ends_at
# always keep money in integer. last 2 digits are cents
999.to_i
end
def premium_interval
# by how long to postpone the ends_at
# monthly or yearly or forever
1.month
end
def active_subscription?
ends_at > Time.now
end
def show_annoying_adds?
# business logic
unless active_subscription?
end
# app/models/charge.rb
# user_id :integer
# amount :integer # to track how much was paid at this moment of time
belongs_to :user
# app/controllers/charges_controller.rb
def create
@charge.amount = current_user.premium_price
# PAYMENT PROVIDER LOGIC GOES HERE
if @charge.save
current_user.update(ends_at: Time.now + current_user.premium_interval)
end
end
This way whenever a user makes a payment and creates a charge
,
his subscription ends_at
is set to a later date based on premium_interval
.
Of course, in this simple example charges
are not automatic and the user has to explicitly create
a charge
.
Thatās it! š¤
The SaaS business model can usually have 2 collection_methods
:
charge_automatically
send_invoice
Original Youtube video where it is introduced by Chris Oliver
app/views/youtubes/_thumbnail.html.erb
app/views/youtubes/_youtube.html.erb
packs/application.js
application.rb
routes.rb
models/youtube.rb
controllers/youtube_controller.rb
javascript/youtube.js
]]>It can be really useful for debugging, or experimenting with potentially dangerous operations.
Hereās how you can run your Heroku Postgresql database in development:
heroku pg:backups:capture --app myappname
heroku pg:backups:download --app myappname
rails db:drop
rails db:drop DISABLE_DATABASE_ENVIRONMENT_CHECK=1
rails db:create
(place development database name from database.yml)
pg_restore -h localhost -d myappname_development latest.dump
rails db:migrate
rm latest.dump
Thatās it! Now you have a copy of your production database running in developmentš„³!
Create a new postgresql user and password (source)
createuser --interactive --pwprompt
yaro
pass
pg_restore -h localhost -U yaro -d myappname_development latest.dump
pass
or
PGPASSWORD=pass pg_restore -h localhost -U yaro -d myappname_development latest.dump
or
set "PGPASSWORD=pass"
pg_restore --verbose --clean --no-acl --no-owner -h localhost -U yaro -d myappname_development latest.dump
My case:
pg_restore --verbose --clean --no-acl --no-owner -h localhost -U yaro -d saas_development latest.dump
pg_restore --verbose --clean --no-acl --no-owner -h localhost -U yaro -d corsego_development latest.dump
Adding latest.dump
to gitignore:
echo 'latest.dump*' >> .gitignore
You can disable scaffolds from generating certain files in your app config:
# config/initializers/generators.rb
Rails.application.config.generators do |g|
g.assets false
g.helper false
g.test_framework false
g.jbuilder false
end
TLDR 2: add a few --no
tags to your rails generators to produce only the crucial files:
rails g controller home index --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework
rails g scaffold product name description:text --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
Now, Letās dive in and make our rails generators cleaner!
A usual scaffold like
rails g scaffold product name description:text
produces:
Thatās a lot! However you wonāt be needing most of these in most hobby applications.
A shorter scaffold would be:
rails g scaffold product name description:text --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
that produces:
Much cleaner! Bravo!
A usual command like
rails g controller home index
produces:
Thatās a lot! And you wonāt need most of it for a basic hobby app!
Letās modify our generator and run:
rails g controller home index --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework
That produces only:
Much cleaner, and does not create mess that you will likely not be using!
rails g scaffold product name description:text
rails g scaffold product name description:text --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
add gem "pg"
remove gem "sqlite"
SCRIPT TO INSTALL LATEST VERSION OF POSTGRESQL
Install Postgresql and create a user:
sudo apt install postgresql libpq-dev
sudo su postgres
createuser --interactive
ubuntu
y
exit
You can fix Connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed
by just starting/restarting postgresql:
sudo service postgresql restart
check version of postgresql
pg_config --version
To create another user with a password:
sudo su postgres
createuser --interactive --pwprompt
username
password
y
exit
Set default password for a postgresql user: in this case we set password myPassword
for user postgres
sudo -u postgres psql
ALTER USER postgres PASSWORD 'myPassword';
\q
Now you can install the gem and run migrations:
bundle
rails db:create db:migrate
rails s
Thatās it š„³ !
yarn add @fortawesome/fontawesome-free
import "@fortawesome/fontawesome-free/css/all"
Add couple of icons in any .html.erb (view) file:
<i class="far fa-address-book"></i>
<i class="fab fa-github"></i>
fa-3x
for font size.fa-spin
to make any icon spin. Animating Icons<%= link_to root_path do %>
<i class="far fa-gem fa-spin fa-3x"></i>
Home
<% end %>
Thatās it!š
Every time when you start a new rails project you have to repeat everything over again:
There are 2 kinds of solutions solving this problem:
Below are the most prominent ones
run a command like
rails app:template LOCATION=https://www.rubidium.io/templates/bootstrap-v4/consume
to install bootstrap.
No gems or installation required.
The website contains lots of different templates to install different gems and features.
Built by the Driftingruby author.
great, just like above! Run a command like
rails app:template LOCATION=āhttps://railsbytes.com/script/x9Qsqxā to install bootstrap. No gems or installation required. Itās easy to contribute and add your own template to the list! Built by Chris from Gorails.
a new gem containing different templates, that you install into the development environment.
Run a command like rails generate boring:bootstrap:install
to install bootstrap.
This project is good in comparison to the ones above, because it is completely open source.
Of course, all the solutions above are built based on the official Rails Application Templates and Rails Generators docs, that are definitely worth exploring:
one of the first boilerplates you find as a RoR developer.
OUTDATED and NOT RECOMMENDED for new projects.
well maintained boilerplate with a lot of pre-configured defaults.
an up-to-date boilerplate with good docs and a vibrant community. Source code worth studying.
Rails 6 boilerplate with schema-level multitenancy, Stimulus, Docker
very fresh Rails 6 boilerplate with Stimulus, Tailwind and more.
Special thanks to the authors of the above projects:
]]>There are many guides and many paths to make bootstrap available in Rails 6.
Having analyzed dosend of tutorials and production applications, below seems to be the most ācorrectā path.
In Rails 5 you would normally use gem bootstrap that would ādownload the bootstrap JS and CSS files.
As we are using webpacker in Rails 6, the right way is to download a package. Webpacker is fit to work with the āyarn package managerā.
Bootstrap has an official yarn package, meaning that it can be installed with a command like yarn add bootstrap (instead of installing a gem).
But bootstrap has some dependencies like jquery and popper.js that need to be installed too.
As well, in Rails 5 we used stylesheets in app/assets/stylesheets/application.css
:
And to tell our application to use this file, in application.html.erb in Rails 5 we added the line to application.html.erb:
= stylesheet_link_tag āapplicationā, media: āallā, ādata-turbolinks-trackā: āreloadā
Now, we will be compiling the stylesheets inside the javascripts folder.
console
run the command:yarn add jquery popper.js bootstrap
it will install the yarn packages that you need to run bootstrap.
const { environment } = require('@rails/webpacker')
const webpack = require("webpack")
environment.plugins.append("Provide", new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
'window.jQuery': 'jquery',
Popper: ['popper.js', 'default']
}))
module.exports = environment
app/javascript/stylesheets
.In the folder app/javascript/stylesheets
create a new file application.scss
:
We will be placing all the css there.
app/javascript/stylesheets
available in your application.html.erb
.application.html.erb
should look like this:
= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload'
Notice the stylesheet_pack_tag
.
application.js
we addimport 'bootstrap/dist/js/bootstrap'
import 'bootstrap/dist/css/bootstrap'
require("stylesheets/application.scss")
The first 2 āimportā commands add the bootstrap JS and CSS that was imported by yarn.
The last ārequireā makes anything that you add in app/javascript/stylesheets/application.scss
compile whenever you add a change to it.
<span class="badge badge-secondary">Thanks Yaro! It works!</span>
and it will add a Bootstrap badge.
Hooray! Bootstrap is installed!š
P.S. If you will be adding actiontext, itās good to place it under app/javascript/stylesheets/application.scss
:
@import "./actiontext.scss";
yarn add jquery popper.js bootstrap
mkdir app/javascript/stylesheets
echo > app/javascript/stylesheets/application.scss
const { environment } = require('@rails/webpacker')
const webpack = require("webpack")
environment.plugins.append("Provide", new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
'window.jQuery': 'jquery',
Popper: ['popper.js', 'default']
}))
module.exports = environment
= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
import 'bootstrap/dist/js/bootstrap'
import 'bootstrap/dist/css/bootstrap'
require("stylesheets/application.scss")
Thatās it!š
Workaround: SSH connection!
ssh-keygen -t ed25519 -C "yshmarov@gmail.com"
You donāt have to type in a password. Just press Enter
id_ed25519
):eval `ssh-agent -s`
ssh-add ~/.ssh/id_ed25519.pub
ssh -T git@github.com
Type git remote -v
If youāre updating to use HTTPS, your URL might look like:
https://[github]/USERNAME/REPOSITORY.git
If youāre updating to use SSH, your URL might look like:
git@github.com:USERNAME/REPOSITORY.git
To switch remote URLs from HTTPS to SSH type:
git remote set-url origin git@github.com:USERNAME/REPOSITORY.git
git remote set-url origin git@github.com:yshmarov/REPOSITORY.git
When creating a remote, make sure you āclone with SSHā instead of āclone with HTTPSā.
Thatās it! Next time you git push
anything, it should authenticate automatically, and youāll not have to enter your credentials on C9 again.
P.S. If for some reason some of your commits are anonymous, you will want to run something like this in the console:
git config --global user.name "Yaro"
git config --global user.email yshmarov@gmail.com
Use active_link_to
instead of link_to
to apply classes underline font-bold
to links in your app.
# app/helpers/application_helper.rb
def active_link_to(text = nil, path = nil, **options, &)
link = block_given? ? text : path
options[:class] = class_names(options[:class], 'underline font-bold') if current_page?(link)
if block_given?
link_to(link, options, &)
else
link_to text, path, options
end
end
class_names
that we are using was introduced in Rails 6.1
active_link_to
works with both inline link_to
and block:
<%= active_link_to "Posts", posts_path %>
<%= active_link_to posts_path do %>
Posts
<% end %>
Writing tests for active_link_to
:
# test/helpers/application_helper_test.rb
class ApplicationHelperTest < ActionView::TestCase
test 'active_link_to' do
def current_page?(link)
true
end
assert_equal(
active_link_to('Home', static_pages_pricing_path),
link_to('Home', static_pages_pricing_path, class: "active")
)
def current_page?(link)
false
end
assert_equal(
active_link_to('Home', static_pages_pricing_path),
link_to('Home', static_pages_pricing_path)
)
end
end
The simple way to do it (assuming a bootstrap navbar):
or if you want to add some fancy fontawesome:
however when you have a lot of links, your code will look ādirtyā.
To make it look cleaner, you can add the following lines to application_helper.rb
:
this way you can write links like this
or
]]>Hereās how dark mode works on one of my apps:
Live Demo - log in and click yourself!
By default, our app will inherit users device prefers-color-scheme
, but we will also let the user manually switch the preference in the app.
Cookie storage is the easyest way to store a users theme preferences without unnesesary complications.
First, add links to switch the color theme and allow setting a class on the <body>
based on the theme set in the cookies.
# app/views/layouts/application.html.erb
-<body>
+<body class="<%= cookies[:theme] %>">
+ <%= cookies[:theme] %>
+ <%= link_to 'light', set_theme_path(theme: 'light') %>
+ <%= link_to 'dark', set_theme_path(theme: 'dark') %>
+ <%= link_to 'system default', set_theme_path %>
<%= yield %>
</body>
Controller to switch the prefered theme in cookies:
# app/controllers/theme_controller.rb
class ThemeController < ApplicationController
def update
cookies[:theme] = params[:theme]
redirect_to(request.referrer || root_path)
end
end
Add route to theme switch:
# config/routes.rb
get 'set_theme', to: 'theme#update'
Update your css file to either use device color scheme (body
styles), or override it with color scheme from cookies (body.light
and body.dark
styles):
/* app/assets/stylesheets/application.css */
:root {
--light-bg-color: silver;
--light-text-color: white;
--dark-bg-color: black;
--dark-text-color: white;
}
@media (prefers-color-scheme: dark) {
body {
background: var(--dark-bg-color);
color: var(--dark-text-color);
}
body.light {
background: var(--light-bg-color);
color: var(--light-text-color);
}
}
@media (prefers-color-scheme: light) {
body {
background: var(--light-bg-color);
color: var(--light-text-color);
}
body.dark {
background: var(--dark-bg-color);
color: var(--dark-text-color);
}
}
Useful readings:
]]>š fixed to the end of the page
š dynamic height
š responsive
š not sticky
We are keeping footer
outside body
:
# application.html
<!DOCTYPE html>
<html>
<head></head>
<body></body>
<footer></footer>
</html>
# application.scss
html {
margin: 0;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
footer {
margin-top: auto;
}
Thatās it! š¤
]]>rvm install ruby-2.7.2
rvm --default use 2.7.2
rvm uninstall 2.7.1
gem install rails -v 5.2.4.3
sudo apt install postgresql libpq-dev
sudo su postgres
createuser --interactive
ubuntu
y
exit
Extended Tutorial on installing Postgresql
]]>rails -v
ruby -v
rvm list
rvm get head
rvm install ruby-3.0.1
rvm --default use 3.0.1
rvm uninstall 2.7.3
gem install rails -v 6.1.4
gem install rails -v 7.0.0.alpha2
gem update rails
gem update --system
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt update
sudo apt install postgresql libpq-dev redis-server redis-tools yarn
ruby -v
rails -v
pg_config --version
Next Step - install Postgresql
Oldschool? Try to install Ruby on Rails 5
]]>3 months ago I posted my course on Skillshare.
The Skillshare business model:
To see how well the course will sell organically, I did not do any promotion of the course myself.
Here are my results:
STUDENTS ENROLLED =
15 students āenrolledā into the course in 3 months.
EARNINGS =
The enrolled students watched 1,109 minutes of my video lectures, resulting in me earning a total of 29$. 29/1109=0,026 (2,5 cents per minute watched).
My course: āRuby on Rails 6: Learn 25+ gems and build a Startup MVP 2020ā
Target audience: Ruby on Rails developers that want to advance their knowledge.
LESSONS LEARNT =
=> If you are a course creator/educator - sell courses, not minutes watched!
=> If you are an entertainer/animator - sell minutes watched!
]]>Result:
Tools:
Features:
current_user
does any action, his updated_at
will be set to Time.now
:
# app/controllers/application_controller.rb
after_action :update_user_online, if: :user_signed_in?
private
def update_user_online
current_user.try :touch
end
Next, we will just say that the user
is online
if he was updated_at
within the last 2.minutes
:
# app/models/user.rb
def online?
updated_at > 2.minutes.ago
end
Now we can get true
or false
if we make a call like @user.online?
:
# app/views/users/show.html.erb
<%= @user.online? %>
Problems with this approach:
update_at
Add a separate attribute to the User model:
# terminal
rails g migration add_last_online_at_to_users last_online_at:datetime
āThrottleā writes to the database: do not write last_online_at
to the database more than once in 5 minutes:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user!
after_action :update_user_online, if: :user_signed_in?
private
def update_user_online
return if session[:last_online_at] && session[:last_online_at] > 5.minutes.ago
current_user.update!(last_online_at: Time.current)
session[:last_online_at] = Time.current
end
end
Thatās much better!
]]>:string - short text
:text - long text
:integer - whole numbers [-1, 0, 1, 445]
:bigint - large whole numbers [345654765]
:float - double-precision floating-point numbers [5645,24]
:decimal - high-prescision floating-point numbres [5645,2342343241212]
:datetime
:time
:date
:boolean - true or false
These data types are used in instances such as migrations.
def change
create_table :categories do |t|
t.string :title
t.text :content
t.boolean :publised
end
end
A few months ago I asked IH whether it would make sense to make a Ruby on Rails course andā¦ you motivated me for work!
I spend 2 whole months of quarantine recording 18 hours of video andā¦
Released my course āRuby on Rails 6: Learn 25+ gems and build a Startup MVP 2020ā in the end of May andā¦
Now is the first of July and:
Some takeaways:
The course itself: Ruby on Rails 6: Learn 25+ gems and build a Startup MVP 2020
Have a beautiful beautiful day!š„³
P.S. If youāre interested in my udemy experience, course, or anything - will answer in comments :)
]]>Now, thatās not a lot but itās always nice to find ways to spend less. So I bumped into this program:
I filled in the form and briefly described a startup that Iām working on, and a few days later such an email came in:
When I logged into the AWS billing dashboard, this was already there:
Well, $1000+$350 is a very pleasant bonus!
]]>