SVG image/icon has the benefit that it can be rendered as a part of HTML rendering
<!DOCTYPE html>
<html>
<head>
<title>SVG can be inline</title>
</head>
<body>
<h2>Here is a SVG inline š</h2>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9.563C9 9.252 9.252 9 9.563 9h4.874c.311 0 .563.252.563.563v4.874c0 .311-.252.563-.563.563H9.564A.562.562 0 0 1 9 14.437V9.564Z" />
</svg>
<p>Solution will save us extra HTTP call </p>
<h2>Here is a SVG rendered as a regular image š (non-inline)</h2>
<img src="/assets/images/my_svg_image.svg">
<p>
Solution is not as good as it will create extra HTTP call.
In 2024 not that a big deal (browser cache, CDNs, HTTP2) but still
if you have lot of SVG images (icons) it's nice to avoid it
</p>
</body>
</html>
So how to render SVG images inline in Ruby on Rails?
Best choice is to use old but very relevant and maintained gem inline_svg
<%= inline_svg_tag("my_svg_image", height: 50, class: "red-icon" ) %>
But if you donāt want to install another gem just to render icon you can just render it from partial
<!-- app/views/application/_my_svg_image.html.erb -->
<svg height="<%= local_assigns[:height] %>" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9.563C9 9.252 9.252 9 9.563 9h4.874c.311 0 .563.252.563.563v4.874c0 .311-.252.563-.563.563H9.564A.562.562 0 0 1 9 14.437V9.564Z" />
</svg>
<%= render "my_svg_image" %>
<%= render "my_svg_image", height: 50 %>
Obvious benefit is that these are just partials = your IDE will provide same benefits (e.g. RubyMine has feature āfind usageā and you will find every line of code loading the partial/svg file)
But once you have more icons you will find out this is difficult to maintain as you cannot preview the icons from partials (browser, IDE,..).
But more important you need to edit every partial where SVG needs to accept HTML argument (e.g. height)
So here is a solution where no extra gem is needed (given your project already uses Nokogiri) & you can preview SVG images & you can pass HTML arguments
You will load SVG content and ouutput it in ERB and if any SVG HTML tag arguments require addition/alteration you do that with Nokogiri
module SvgHelper
SVGFileNotFoundError = Class.new(StandardError)
def inline_svg_tag(svg_path, options = {})
path = Rails.root.join("app/assets/images/#{svg_path}.svg")
File.exist?(path) || raise(SVGFileNotFoundError, "SVG image file does not exist: #{path}")
svg_file_content = File.binread(path)
if options.any?
doc = Nokogiri::XML::Document.parse(svg_file_content)
svg = doc.at_css("svg")
svg["height"] = options[:height] if options[:height]
svg["width"] = options[:width] if options[:width]
svg["class"] = options[:class] if options[:class]
svg_file_content = doc.to_html.strip
end
raw svg_file_content
end
end
Note the Helper code is pretty much what inline_svg gem does.
<%= inline_svg_tag("my_svg_image", height: 50, width: 50 class: "red-icon" ) %>
require "rails_helper"
RSpec.describe SvgHelper do
describe "#inline_svg_tag" do
it "raises an error when the file does not exist" do
expect { helper.inline_svg_tag("does-not-exist") }.to raise_error(SvgHelper::SVGFileNotFoundError)
end
it "when no options passed returns the SVG file contents with original HTML attribute values" do
result = helper.inline_svg_tag("my_test_svg_image")
expect(result).to include("<svg")
expect(result).to include('height="20"')
expect(result).to include('width="20"')
expect(result).not_to include("class")
end
it "when class option passed returns the SVG file contents with class HTML attribute" do
result = helper.inline_svg_tag("my_test_svg_image", class: "whatever")
expect(result).to include("<svg")
expect(result).to include('class="whatever"')
end
it "when height passed returns the SVG file contents with new height" do
result = helper.inline_svg_tag("my_test_svg_image", height: "12345")
expect(result).to include("<svg")
expect(result).not_to include('height="20"')
expect(result).to include('height="12345"')
end
it "when width passed returns the SVG file contents with new width" do
result = helper.inline_svg_tag("my_test_svg_image", width: "54321")
expect(result).to include("<svg")
expect(result).not_to include('width="20"')
expect(result).to include('width="54321"')
end
end
end
image app/assets/images/my_test_svg_image.svg
:
<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9.563C9 9.252 9.252 9 9.563 9h4.874c.311 0 .563.252.563.563v4.874c0 .311-.252.563-.563.563H9.564A.562.562 0 0 1 9 14.437V9.564Z" />
</svg>
SVG in example is outline
stop-circle
icon from https://heroicons.com
def inline_svg_tag(svg_path, options = {})
# ...
rescue SVGFileNotFoundError => error
if Rails.env.production?
Appsignal.send_error(error)
return raw("<!-- SVG file missing: #{svg_path}.svg -->")
else
raise error
end
end
# ...
it "when the file does not exist in production sends an error to Appsignal and output a comment" do
expect(Rails).to receive_message_chain(:env, :production?).and_return(true)
expect(Appsignal).to receive(:send_error)
result = helper.inline_svg_tag("icons/does-not-exist")
expect(result).to eq("<!-- SVG file missing: icons/does-not-exist.svg -->")
end
https://www.adobe.com/creativecloud/file-types/image/comparison/png-vs-svg.html
PATH
settings = you need to use full path to rvm.
In order to use puma under RVM with systemd you need to create a wrapper for RVM:
cd /root/myapp
rvm current # e.g.: ruby-3.2.2@myapp_2023
# create RVM wrapper
rvm alias create myapp ruby-3.2.2@myapp_2023 # rybyversion@gemsetname
Depending where is your RVM instaled (check with which rvm
) this creates a wrapper in RVM folder. Mine is /usr/share/rvm/wrappers/myapp
. This you can refer in systemd service file. (RVM wrapper is something similar to aias or symbolic link)
# /etc/systemd/system/myapp_puma.service
[Unit]
Description=Puma HTTP Server
After=network.target
# Uncomment for socket activation (see below)
# Requires=puma.socket
[Service]
Type=notify
WatchdogSec=10
# Preferably configure a non-privileged user
# User=
WorkingDirectory=/root/myapp_tw
# Explicitly define your ENV variables as they may be ignored
Environment=WEB_CONCURRENCY=3
Environment=RAILS_ENV=production
# Helpful for debugging socket activation, etc.
# Environment=PUMA_DEBUG=1
ExecStart=/usr/share/rvm/wrappers/myapp/bundle exec puma -C ./config/puma.rb
Restart=always
[Install]
WantedBy=multi-user.target
note: I
ln -s
mymaster.key
to appconfig/master.key
so I donāt useRAILS_MASTER_KER
systemctl daemon-reload #each time you change the service file
systemctl start myapp_puma.service
systemctl status myapp_puma.service
# something goes wrong
journalctl -u myapp_puma -e -f
Note: any time you change ruby version or gemset you need to recreate the RVM wrapper
source:
Rails 7.1, Ruby 3.2, Puma 6, RVM rvm 1.29.12, Ubuntu 22.04. Created 2023-11-30
keywords: init.d, system.d, puma webserver, ruby on rails, rvm
]]>TL;DR: create a Sidekiq job that would construct a single UPSERT (or UPDATE) fields SQL call for that batch of records given your business rules. Then schedule the job in small batches and monitor how fast will the job finish. Tweak the batch size and number of threads for your Sidekiq worker until you find the sweet spot. Then schedule it by millions.
Longer version:
# app/workers/update_addresses_worker.rb
class UpdateAddressesWorker
include Sidekiq::Worker
sidekiq_options queue: :manual
def perform(min_id, max_id, batch_size = 1_000)
Address
.where(id: min_id..max_id)
.in_batches(of: batch_size) do |address_batch|
MyService.new.call(address_batch)
end
end
end
note: my queue name is āmanualā you can use ādefaultā or whatever you use in your app.
For simplicity MyService
will just downcase city
name & state
for entire batch of Address objects.
Yes this can be done with a single SQL query (If you can afford to lock entire table for couple of minutes) Please consider this is just an example and the real script where you want to use this will be more complex with business logic code directly involved.
In this example Iām using gem activerecord-import in order to update/insert multiple records with one SQL query (including validations). Project I work for already uses this gem so itās well tested solution for our use case.
However Vanilla Rails has .upsert_all that serves similar purpose and you can achieve the same result with it.
Reason why the article is not using .upsert_all
is because I didnāt used it in production yet so Iām not going to recommend something I didnāt truly use š. But itās worth checking it out.
Note
upsert
SQL operation is pretty much āinsert or updateā = is slower thanupdate
operation where you already know the IDs and you donāt expect conflict (Thank you Seuros for pointing this out).
class MyService
def call(address_batch)
addresses = address_batch.map do |address|
# some real business logic code here manipulating the address object state, this is just an example
address.city.downcase!
address.state.downcase!
address
end
# `Model.import` is from activerecord-import gem
::Address.import(
addresses,
on_duplicate_key_update: {
conflict_target: %i[id],
validate: true,
columns: [:city, :state]
}
)
end
end
Notice on_duplicate_key_update options which takes care of updates of city
& state
(columns) when addresses
db row matching the id
(conflict_target) already exist.
Given the scenario you may do this even faster by avoiding ActiveRecord and constructing (& calling) a custom SQL query within this service (check BiackPandaās comment). Depends on your use case.
Once you deploy the worker code to prod run a rails c
console in production env.
Now try scheduling the worker in small chunks like this:
Address.in_batches(of: 1_000, start: 300_000_000, finish: 300_010_000) do |address_batch|
min_id = address_batch.minimum(:id)
max_id = address_batch.maximum(:id)
worker_batch_size = 250
puts "#{min_id} - #{max_id} [#{worker_batch_size}]"
UpdateAddressesWorker.perform_async(min_id, max_id, worker_batch_size)
end
# 300_000_000 - 300_002_000 [250]
# 300_002_001 - 300_004_029 [250]
# 300_004_030 - 300_006_567 [250]
# ...
Because we (PostPilot) are dealing with several hundred millions of records itās not easy to get those numbers right. You need to schedule few thousand record samples and monitor how well/bad will your worker perform
e.g in Heroku monitor your worker dyno Memory usage, in tool like NewRelic or AppSignal monitor DB Load & I/O Operations, Monitor errors, In Sidekiq Web UI monitor number of jobs in queue and how long the job takes to finish (aim for āfinish fastā jobs - up to 2 minutes was my goal)
Maybe your worker will consume all the memory and you need to schedule smaller batches. Maybe you need to increase memory on the underlying VM running your Sidekiq workers
For example Heroku Standard 1x Dyno has only 512MB, maybe increase it to Standard 2x Dyno (1GB could be enough), or in some cases it make sense to go Performance-M Dyno with 2,5GB. More in heroku dynos and common dyno type issues
Maybe your worker will be underutilized and therefore you can increase worker_batch_size
or number of threads for your Sidekiq worker
just be mindfull on how many active connections your PosgreSQL DB can handle. For example Herokuās Standard 7 has 500 Connection Limit. For example 30 dyno with 5 threads == 150 DB connections + you still need connections for rest of the app (webserver, other workers)
Try tweeking those numbers and for each schedule a sample of couple thousand of records.
Once you got this right you can go wild and increas number of Sidekiq workers (for example have 30, 40, 50 Heroku dynos for your worker)
Recommendation here is not to schedule all 500 M records. But try to schedule 100K see how it goes (monitor), then 1M (monitor), then 10M, 100M, ā¦
You are enqueuing a LOT of jobs. Be sure you have a way to kill those jobs if something goes wrong.
You donāt need to add any special killswitch code for exit a job.
We recommend to have separate Sidekiq worker dedicated to script jobs like this. Benefit is that if something goes wrong you can just scale these worker VMs to 0 (or 0 worker dynos on Heroku) and just delete the enqued jobs from Sidekiq Web UI.
cat config/manual_sidekiq.yml
:concurrency: <%= (ENV['MANUAL_MAX_THREADS'] || 1).to_i %>
:queues:
- [manual, 4]
note: the āMANUAL_MAX_THREADSā ENV variable, you can use this to scale the number of threads for your Sidekiq worker that would be running this script jobs. For example if you have 30 dynos for this worker you can set this to 5 and you will have 150 threads running in parallel.
If Option 1 is not possible for you, you can implement a killswitch flag in your worker code.
If you use something like Flipper you can exit a job if a flag is set, etcā¦
# app/workers/update_addresses_worker.rb
class UpdateAddressesWorker
# ...
def perform(min_id, max_id, batch_size = 1_000)
return if Flipper[:killswitch].enabled? # optional
# ...or `return if ENV['KILLSWITCH'].present?`
# ...or just deploy updated worker with `return` on beginning of this method
#...
e.g. in Heroku when you change ENV variable dyno will reinstantiate . So you can set e.g.
KILLSWITCH
ENV variable.
The service had a quite fast business logic code resulting in constructing a SQL that would update couple of fields on a table.
The process of probing different batch sizes & Sidekiq thread numbers with couple of thousands/millions records took about 5 hours. We ended up with 5 threads on 40 Standard 2x Heroku dynos.
Then the actual run of the script with rest of the half a billion records was finished by the morning (Iāve run it like 11 PM, Iāve checked 7AM next day and all was finished).
We (PostPilot) use Judoscale so the dyno number was back to 0 by the morning.
Again this is very specific to our setup. Your setup will be different. You need to monitor and adjust accordingly. Also our DB was not under heavy load during the night. If you have a lot of usage on your DB you need to be more careful.
Full credit for this solution goes to Matt Bertino who taught me this. He is a true PostgeSQL & Ruby on Rails wizard š§āāļø.
Do you want to see what we do? Check us out at postpilot.com
<!-- app/views/layouts/_navbar.html.erb -->
<header
class="bg-gray-500 sm:flex sm:justify-between sm:px-4 sm:py-1 sm:items-center"
data-controller="navbar" data-navbar-state-value="false">
<div class="flex justify-between px-4 py-1 sm:p-0 items-center">
<div class="font-bold text-xl font-mono text-gray-200">
<span class="text-orange-400">Dev</span>Prof
</div>
<div class="sm:hidden">
<button class="text-orange-400 focus:text-white focus:outline-none hover:text-white block"
type="button"
data-action="click->navbar#toggle" >
<span class="sr-only">Open main menu</span>
<svg class="h-8 w-8 fill-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path data-navbar-target="x" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" class="hidden" />
<path data-navbar-target="bars" stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
</div>
<div class="hidden sm:flex px-2 pt-2 pb-4 sm:pb-2" data-navbar-target="menu">
<a href="/" class="block text-gray-200 font-semibold hover:bg-gray-800 rounded px-2 py-1">Home</a>
<a href="/" class="block text-gray-200 font-semibold hover:bg-gray-800 rounded px-2 py-1 mt-1 sm:mt-0 sm:ml-2">Developers</a>
<a href="/" class="block text-gray-200 font-semibold hover:bg-gray-800 rounded px-2 py-1 mt-1 sm:mt-0 sm:ml-2">Cool Stuff</a>
</div>
</header>
/* app/javascript/controllers/navbar_controller.js */
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { state: Boolean }
static targets = [ "menu", "x", "bars" ]
connect() {
console.log(this.stateValue)
}
toggle() {
this.stateValue = !this.stateValue
if (this.stateValue) {
this.openMenu()
this.showX()
} else {
this.closeMenu()
this.showBars()
}
}
openMenu() {
this.menuTarget.classList.remove("hidden");
}
closeMenu() {
this.menuTarget.classList.add("hidden");
}
showBars() {
this.xTarget.classList.add("hidden")
this.barsTarget.classList.remove("hidden")
}
showX() {
this.xTarget.classList.remove("hidden")
this.barsTarget.classList.add("hidden")
}
}
November 2022 Iāve celebrated 13 years of being a Ruby on Rails developer. It just feels like a good time to get something off my chest. Hereās the story:
One project Iāve worked for was originally developed by a dude who used Ruby on Rails for the first time in his life. Donāt get me wrong, thereās nothing wrong with that, in fact I encourage everyone to choose Ruby on Rails for their personal dream or an experiment.
Problem is that this dude was hired as a super expensive contractor with tons of experience but that experience was all with different programming languages. After 6 months his contract expired and he moved on to a different project probably trying out different web development tools.
As Iāve been hired to take over the project from where he left I can truthfully testify the dude has never read a single Ruby on Rails book or design pattern book. Iām not kidding when I say some controllers were 4000 lines and the entire codebase was without a single test. The burden of his āI will learn on the jobā attitude is that the company inherited a barely functional mess.
This may not be a problem if one accepts a contract for a company that can afford to throw money left and right. But itās just evil if you do this to a fresh startup that just managed to scrape a couple of thousands dollars to make their dream real.
And this article is a call to the moral side of all fellow web-developer colleagues: Our job is not just about the money. Sometimes peopleās dreams are at stake. Sometimes people take personal loans and draw their life savings to make those projects real.
If youāre planning to accept a greenfield project honestly ask yourself: Are my skills up to the task?
I donāt want this article hanging on a plea. Here are my personal opinions what is the solution
Look, Iām not trying to bring down anyone who wants to learn. Far from it. I think everyone should explore and experiment. Technology my project uses may not be the best match for your project and vice versa. We need to broaden our horizons.
Itās more about the terms of this exploration and experimentation.
Many companies allow their employees to play around with new technologies and thatās good. They either allow side projects or dedicate separate environments for the experiment.
Many companies canāt afford it (e.g.: fresh startups)
Many companies can afford it but straight up refuse to invest in such things
There are usually some fundamental issues with those kinds of projects and good developers donāt stick around for very long or the projects fail on their own. I worked for a company where the manager was frequently telling us he would not pay us to write tests. Yeah, that project didnāt last long.
Whatever the case you need to be responsible with anything you introduce. You want to introduce a new gem or JS library? You want to try a new code design pattern? You want to split monolith into microservices? You want to implement a new DevOps tool? Ok cool, but how long are you planning to stay on the project and carry on this decision? 5 years? 5 months? 5 weeks ? Who will carry on this responsibility after you leave?
Last project I worked for I worked there as a Lead for 7 years. Iāve learned the word Responsibility with capital R.
So what if my company doesnāt allow me to experiment? Should I just experiment in my free time without anyone paying me for it ? ā¦well, yes.
Iāve heard music producer Rick Rubin talk about the creative process of Eminem:
99% of what he writes is never used. He does this just to stay engaged in the creative process of writing and finding new ways to write. He does this so that when he needs it, it just comes.
Look at my Github profile. Itās a mess! At the time of writing this article I have around 180 public repos of which 89 are sources (I have many many more private ones). I really need like 10% of them and rest are about the process.
New design pattern? Create a dummy project to test it out. Interesting deployment solution? Create a dummy project and try it out. New programming language I would like to try? Create a repo and try it out,ā¦
I create all that mess so that once Iām paid to deliver a solution I already done the drafts and itās time to bring real rhymes.
Same applies to my attitude towards OpenSource projects. Many times I fork a repo, start writing an improvement and abandon the work. Many time I realize my idea is stupid, many times I just donāt have the time to finish it to a real pull request. Finished or not finished, I win either way as I absorb new coding styles, thinking patterns, ā¦new beats.
Look if you are doing this 9 to 5 just to get paid I have no more arguments for you. Iām surprised you made it this far reading this. Iām surprised you are reading an article no one is paying you to read.
Throughout my career Iāve met true web developer artists both in Frontend and Backend. People who enjoy what they create and love the craft. I salute to all of you.
For the rest of you at least think about it.
You need to realize that some companies or teams are about consistency with their approach.
For example look at what DHH and good folks at 37 Signals (creators of Ruby on Rails) have been preaching about all these years. Monolith over microservices, limit the use of service objects, limit the use of JavaScript, ā¦
They know about code designs, SOLID principles, popular architecture opinions and all the cool JS libraries out there. However theyāve deliberately chosen to limit their toolset so that everyone using vanilla Rails is on the same page.
This is not just for some idealistic open source strategy. Theyāve decided to stick with this convention so that a junior developer creating his first Ruby on Rails project has the same tools for success as what they use on their products (Basecamp & Hey). My friends, that takes a lot of courage!
Understanding the source code behind Rails will take you to the next level. Understanding decisions behind Rails will take you to the next dimension.
So remember next time the lead engineer in your company doesnāt allow you to install a cool gem or use a new revolutionary JavaScript library: Maybe itās not because he/she is against your career āgrowthā. Maybe itās because that decision would carry a burden that the whole team will have to carry for next decade.
The convention is sometimes better than whatās cool.
7.17.5
localhost running under Ubuntu 20.04 from standard atp-get instalation (example)
` sudo vim /etc/elasticsearch/elasticsearch.yml`
# .....
# xpack.security.enabled: false # make sure this is commented
discovery.type: single-node
xpack.security.enabled: true
sudo service elasticsearch stop
sudo service elasticsearch status
sudo service elasticsearch start
sudo service elasticsearch status
to set up password:
$ sudo /usr/share/elasticsearch/bin/elasticsearch-setup-passwords interactive
Let say I hoose a pasword xxmypaswdxx
:
test
$ curl -XGET localhost:9200
{"error":{"root_cause":[{"type":"security_exception","reason":"missing authentication credentials for REST request [/]","header":{"WWW-Authenticate":"Basic realm=\"security\" charset=\"UTF-8\""}}],"type":"security_exception","reason":"missing authentication credentials for REST request [/]","header":{"WWW-Authenticate":"Basic realm=\"security\" charset=\"UTF-8\""}},"status":401
$ curl --user elastic:xxmypaswdxx -XGET localhost:9200
{
"name" : "xxxxxxxx",
"cluster_name" : "xxxxxxxx",
"cluster_uuid" : "FWhvJOvmTCmp_Nevybmb2g",
"version" : {
"number" : "7.17.5",
"build_flavor" : "default",
"build_type" : "deb",
"build_hash" : "8d61b4f7ddf931f219e3745f295ed2bbc50c8e84",
"build_date" : "2022-06-23T21:57:28.736740635Z",
"build_snapshot" : false,
"lucene_version" : "8.11.1",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
you can also base64(username:pssword)
eg and pass it as header. E.g.: base64(elastic:xxmypaswdxx) = "ZWxhc3RpYzp4eG15cGFzd2R4eA=="
$ curl -H 'Authorization: Basic ZWxhc3RpYzp4eG15cGFzd2R4eA==' -XGET localhost:9200
{
"name" : "xxxxxxxx",
...
}
$ curl -XGET http://elastic:xxmypaswdxx@localhost:9200
{
"name" : "xxxxxxxx",
...
}
Most imortant for Ruby/Rails ElasticSearch Client gem you can pass it as a host, that means in Rails you can:
# config/initializers/elasticsearch.rb
client = Elasticsearch::Client.new(url: ENV.fetch('ELASTICSEARCH_HOST') )
make sure your ENV['ELASTICSEARCH_HOST']="http://elastic:xxmypaswdxx@localhost:9200"
Rails 7 embraced the use of Import maps and they are awesome.
If you wonder how to use importmap in plain HTML here is an example:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Import maps without Rails - Local-time example</title>
<script async src="https://unpkg.com/es-module-shims@1.2.0/dist/es-module-shims.js"></script>
<script type="importmap-shim">
{
"imports": {
"local-time": "https://ga.jspm.io/npm:local-time@2.1.0/app/assets/javascripts/local-time.js"
}
}
</script>
<script type="module-shim">
import LocalTime from "local-time"
LocalTime.start()
</script>
<style>
time { color: #c11; font-size: 1.1em; }
</style>
</head>
<body>
<h1>Import maps without Rails - Local-time JS example</h1>
<p>
Last time I had chocolate was <time datetime="2022-05-08T23:00:00+02:00" data-local="time-ago">8th of May</time>
</p>
</body>
</html>
to see the example in action check this JS fiddle
Example uses importmap to loads local-time js
that converts <time>
HTML elements from UTC to the browserās local time (more info).
Looking for Hotwire Stimulus examples ?
Rails 7 is a breath of fresh air. Thanks to importmaps everything is simple again. JavaScript (JS) is easy to be implemented without the need to install node,npm,yarn,webpack,..other 150 non-Ruby tools on your Laptop
But what about CSS ?
Well there is good old Sprockets (a.k.a Rails asset pipeline) and good old gems contanining SCSS (remember those?)
Letās make life easy again
If you donāt have importmaps yet in your Rails project:
# to check if you already have importmaps
$ cat config/importmap.rb
# to install importmaps in your Rails7 project
$ rails importmap:install
To add Bootstrap 5 JS to Rails 7 project via importmaps:
$ bin/importmap pin bootstrap
ā¦this will add necessary JS (bootstrap and popperjs) to config/importmaps.rb
Then you need to just import bootstrap in your application.js
// app/javascript/application.js
// ...
import 'bootstrap'
For some reason popperjs acts broken in my Rails7 project when I load it from default
ga.jspm.io
CDN. Thatās why I recommend to load it fromunpkg.com
:
# config/importmaps.rb
# ...
pin "bootstrap", to: "https://ga.jspm.io/npm:bootstrap@5.1.3/dist/js/bootstrap.esm.js"
pin "@popperjs/core", to: "https://unpkg.com/@popperjs/core@2.11.2/dist/esm/index.js" # use unpkg.com as ga.jspm.io contains a broken popper package
# ...
To install official Bootstrap 5 Ruby gem
# Gemfile
# ...
gem 'bootstrap', '~> 5.1.3'
# ...
and bundle install
Then just edit your app/assets/stylesheets/application.scss
// app/assets/stylesheets/application.scss
// ...
@import "bootstrap";
// ...
note: be sure you replace your application.css with application.scss. That means
app/assets/stylesheets/application.css
should not exist!
If you want to change some variables:
// app/assets/stylesheets/application.scss
// ...
$primary: #c11;
@import "bootstrap";
// ...
Make sure your layout (app/views/application.html.erb
) contains:
<%# ... %>
<head>
<%# ... %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%# this loads Sprockets/Rails asset pipeline %>
<%= javascript_importmap_tags %> <%# this loads JS from importmaps %>
<%# ... %>
</head>
<!-- ... -->
rails new --css bootstrap
option but that will
require esbuild
which requires all the JS shenanigans in your laptop this article wants to
avoidābut this way you load a gem and you donāt use the JS bit of itā
So what? Like if thereās no single gem in your project you donāt use at 100%. I love āvanilla Railsā approach and love to avoid 3rd party gems as much as I can but this will save you so much hustle, especially if you are a beginner new to Rails or you are starting a sideproject (thereās always a time to refactor if you really need to)
ābut Sprockets are no longer usedā
Yes they are. There was a period of time with RoR 5.2 & 6.x where webpacker was taking over and developers were ditching Rails asset pipeline but this new importmaps approach is fresh breath to bring gems with scss back.
Basecamp (& DHH) were quite clear about it that Sprockets will not
disappear anyday soon.
update Well actually I was wrong. Sprockets will probably be replaced by Propshaft in Rails 8. source of this claim
But still Sprockets are the most convinient way how to use CSS in Rails7
what about DartSass
if you decide to configure DartSass Rails go for it.
but
--css
(esbuild) is there to replace sprockets
No itās not, same way how webpacker didnāt replace it
But what if CDN provider goes down, then my application JS will not work
Yes you and other billion websites as well. If your project is a bank then yeah sure use your own CDN or load from vendor. But if your project is startup to sell T-shirts then Iām pretty sure everyone will survive that 5 min downtime.
Photo by Pablo Arroyo via unsplash
account = Account.last
Rails.cache.fetch ['posts', account] do
# ....
end
NOTE by default caching is disabled in test enviroment (which is a good
thing). You donāt need to change default null_store
caching
# config/environments/test.rb
Rails.application.configure do
# ...
config.cache_store = :null_store # feel free to keep this as it is
What we want is enable the caching only for particular test:
# spec/any_spec.rb
module TestFileCachingHelper
def self.cache
return @file_cache if @file_cache
path = "tmp/test#{ENV['TEST_ENV_NUMBER']}/cache"
FileUtils::mkdir_p(path)
@file_cache = ActiveSupport::Cache.lookup_store(:file_store, path)
@file_cache
end
end
before do
allow(Rails).to receive(:cache).and_return(TestFileCachingHelper.cache)
Rails.cache.clear
end
it do
expect(Rails.cache.exist?('some_key')).to be(false)
Rails.cache.write('some_key', 'test')
expect(Rails.cache.exist?('some_key')).to be(true)
end
Credit for this part of article goes to Emanuel De and his article How to: Rails cache for individual rspec tests Consider this as a mirror article
we can go step further and enable cache only on tests with specific RSpec tag / filters
# spec/support/test_file_caching_helper.rb
module TestFileCachingHelper
def self.cache
return @file_cache if @file_cache
path = "tmp/test#{ENV['TEST_ENV_NUMBER']}/cache"
FileUtils::mkdir_p(path)
@file_cache = ActiveSupport::Cache.lookup_store(:file_store, path)
@file_cache
end
end
# spec/rails_helper.rb
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
RSpec.configure do |config|
# ...
# tests that use Rails cache https://blog.eq8.eu/til/how-to-test-caching-on-individual-tests-rails-rspec.html
config.before(:example, :cache_enabled) do
Rails.cache.clear
allow(Rails).to receive(:cache).and_return(TestFileCachingHelper.cache)
end
# ...
end
# spec/any_spec.rb
require 'rails_helper'
RSpec.describe 'Anything' do
it 'should behave like test without cache enabled'
# ...
end
it 'should behave like test with enabled cache', :cache_enabled
# ...
end
context 'entire section under influence of cache', :cache_enabled do
it 'should behave like test with cache enabled' do
# ...
end
it do
Rails.cache.fetch 'hello' { 123 }
expect(Rails.cache.fetch('hello').to eq 123
end
end
end
Gem db-query-matchers will help you test how many SQL calls the request has made
# Gemfile
# ...
group :test do
gem 'rspec-rails'
# ...
gem 'db-query-matchers'
# spec/controllers/accounts_controller_spec.rb
RSpec.describe AccountsController do
# ...
def trigger
get :index
end
it 'is performant', :cache_enabled do
#First call
expect { trigger }.to make_database_queries(count: 420..430)
# cache kicked in
expect { trigger }.to make_database_queries(count: 7)
end
note donāt use let(:trigger) { get :index } as that will memoize the call => second call will not trigger
has_many_attached
is a cool feature but developers may feels like itās
missing one critical feature: change order of attachments.
In this article Iāll show you one simple way how to order attachments of a simple Entry model that has many pictures.
To limit the scope of this article Iāll assume your application have a basic setup of ActiveStorage such as
bin/rails active_storage:install
Here is our Entry
model. As you can see it has_many_attached #pictures
# app/models/entry.rb
class Entry < ApplicationRecord
has_many_attached :pictures
# ...
end
We need to add a new Array field to the Entry
model that will hold ids
of attached pictures
in order. That means if attachments were uploaded
in order:
ActiveStorage::Attachment id=1
ActiveStorage::Attachment id=2
ActiveStorage::Attachment id=3
ā¦we can store the ids [1,2,3]
in any order we want see them appear in e.g.: [3,1,2]
Assuming we use PostgreSQL database lets add a json
field to our
database which defalts to an empty Array
class AddOrderedPictureIdsToEntries < ActiveRecord::Migration[6.1]
def change
add_column :entries, :ordered_picture_ids, :json, default: []
end
end
if you are not using Posgres database you can use Rails model serialize field as an Array
$ bin/rails db:migrate
$ bin/rails c
entry = Entry.new
entry.ordered_picture_ids
# => []
entry.ordered_picture_ids = [3,1,2]
entry.save!
entry.ordered_picture_ids
# => [3,1,2]
Now we will intreduce method #ordered_pictures
which will return
#pictures
ordered by the values in #ordered_picture_ids
# app/models/entry.rb
class Entry < ApplicationRecord
has_many_attached :pictures
def ordered_pictures
pictures.sort_by{ |pic| ordered_picture_ids.index(pic.id) || (pic.id*100) }
end
def ordered_picture_ids=(ids)
super(ids.map(&:to_i)) # convert any ids passed to this method to integer
# this is just for security reasons,
# you don't need to do this for the feature to work
end
end
reason why we do
|| (pic.id*100)
is so that we give default order to records without explicit order (E.g stuff that was uploaded before we start changing order). Please have a look at RSpec specs bellow to fully understand edgecases
Great now when you call entry.ordered_pictures
you will get attached
pictures in order you like:
-# app/views/entries/edit.html.slim
- @entry.ordered_pictures.each do |picture|
= image_tag(picture)
If you use some JavaScript solution (e.g drag and drop sort) that will send an Array of ids to your backend then
this is all you need. Just update your controller to allow our new #ordered_picture_ids
property in params
class EntriesController < ApplicationController
# ...
def update
entry_params = params
.require(:entry)
.permit(:title, pictures: [], ordered_picture_ids: [])
@entry.attributes = entry_params
@entry.save
# ...
end
end
Rails 7 introduced Hotwire Turbo which makes developers that prefere to write as little of JavaScript as possible (like me) extremly happy.
With this technology a really elegant solution would be to have buttons that would change order of attachments Up or Down within same turbo frame. Letās have a look how this would look like:
First letās introduce methods for moving attachement picture:
# app/models/entry.rb
class Entry < ApplicationRecord
has_many_attached :pictures
# ...
def ordered_pictures
pictures.sort_by{ |pic| ordered_picture_ids.index(pic.id) || (pic.id*100) }
end
def ordered_picture_ids=(ids)
super(ids.map(&:to_i))
end
def ordered_picture_move_up!(picture)
ordered_picture_move!(picture, :up)
end
def ordered_picture_move_down!(picture)
ordered_picture_move!(picture, :down)
end
private
def ordered_picture_move!(picture, where)
raise TypeError, "#{picture} must be a ActiveStorage::Attachment" unless picture.is_a?(ActiveStorage::Attachment)
pics = ordered_pictures.dup
case where
when :up then ArrayElementMove.up!(pics, picture)
when :down then ArrayElementMove.down!(pics, picture)
else
raise "unknown option #{where}"
end
self.ordered_picture_ids=pics.map(&:id)
self.save!
self.reload
true
end
end
Methods #ordered_pictures
and #ordered_picture_ids
didnāt change
compared to previous example.
We introduced two more methods #ordered_picture_move_up!
and #ordered_picture_move_down!
that will be our interface to move items up and down.
Both uses private method #ordered_picture_move
that will manipulate
order of pictures ids in array #ordered_picture_ids
and save new order to
this field
For details please see RSpec specs at the bottom of this article
Tricky bit here is how to move items up and down in a Ruby Array. As far
as Iām aware there is no built in feature directly in Ruby so I
created a small helper ArrayElementMove
to do that:
# lib/array_element_move.rb
module ArrayElementMove
MustBeUniqArray = Class.new(StandardError)
ItemNotInArray = Class.new(StandardError)
def self.up!(array, item)
self.check_if_uniq!(array)
return array if array.first == item
position = array.index(item) || raise(ItemNotInArray)
array.insert((position - 1), array.delete_at(position))
end
def self.down!(array, item)
self.check_if_uniq!(array)
return array if array.last == item
position = array.index(item) || raise(ItemNotInArray)
array.insert((position + 1), array.delete_at(position))
end
def self.check_if_uniq!(array)
raise MustBeUniqArray if array.size != array.uniq.size
end
end
more about this Class in this til note
Donāt forget to require this class in your Rails app:
# config/application.rb
# ...
require './lib/array_element_move'
# ...
So this will allow us to do:
a = [1,2,3]
ArrayElementMove.up!(a, 2)
a == [2,1,3]
Letās introduce seperate controller EntryPicturesController
that will
be responsible for operations related to entry pictures (so that we donāt
polute EntryController
)
# config/routes.rb
resources :entries do
resources :pictures, only: [:destroy], controller: 'entry_pictures' do
post :up, on: :member
post :down, on: :member
end
end
# app/controllers/entry_pictures_controller.rb
class EntryPicturesController < ApplicationController
before_action :find_entry
before_action :find_picture
def up
@entry.ordered_picture_move_up!(@picture)
redirect_to(edit_entry_path(@entry))
end
def down
@entry.ordered_picture_move_down!(@picture)
redirect_to(edit_entry_path(@entry))
end
# not required, just to show why it's nice to separate concerns
def destroy
@picture.purge
redirect_to(edit_entry_path(@entry))
end
private
def find_entry_id
@entry = Entry.find(params[:entry_id])
end
def find_picture
@picture = @entry.pictures.find(params[:id])
end
end
-# app/views/entries/edit.html.slim
= turbo_frame_for 'pictures' do
- @entry.ordered_pictures.each do |picture|
div.entry-picture
= image_tag(picture)
= button_to 'move left', up_entry_picture_path(@entry, picture)
= button_to 'move right', down_entry_picture_path(@entry, picture)
= button_to 'Delete', entry_picture_path(@entry, picture), method: :delete, data: {confirm: 'Delete picture?'}
Thatās it
If you are here just for technical solution you donāt have to read further. I just want to close this article with some opinions.
I donāt know.
I personally think this feature is missing from ActiveStorage by design because your application may have a different iterpretation on how to order attachments.
For example maybe within same has_many_attached your application is ordering PDFs in front of images.
So it sounds straight forward but order logic may have many meanings
So imagine we do something like:
# app/models/entry.rb
class Entry < ApplicationRecord
has_many :pictures
end
class Picture < ApplicationRecord
belongs_to :entry
has_one_attached :image
end
In this case our pictures
table can be more dynamic and have an order
field upon which we can do our re-ordering:
# db/schema.rb
# ...
create_table "pictures", force: :cascade do |t|
t.bigint "entry_id"
t.integer "order", default: 0
# ...
Yes sure this is a good solution (Iām using simmilar solutions plenty in other projects) So if it works for you go ahead. Just realize you are giving up native ActiveStorage has_many_attached features that come default in Rails (like no sweat direct upload). If thatās not a big deal for you then no problem.
# spec/model/entry_spec.rb
require 'rails_helper'
RSpec.describe Entry, type: :model do
describe 'ordered_pictures' do
let!(:entry) { create :entry, :with_pictures }
before do
@pic1, @pic2, @pic3 = entry.pictures
end
context 'when no exact order' do
it do
expect(entry.ordered_pictures).to eq([@pic1, @pic2, @pic3])
end
describe 'up' do
it do
expect(entry.ordered_pictures).to eq([@pic1, @pic2, @pic3])
entry.ordered_picture_move_up!(@pic3)
expect(entry.ordered_pictures).to eq([@pic1, @pic3, @pic2])
entry.ordered_picture_move_up!(@pic3)
expect(entry.ordered_pictures).to eq([@pic3, @pic1, @pic2])
entry.ordered_picture_move_up!(@pic3)
expect(entry.ordered_pictures).to eq([@pic3, @pic1, @pic2])
entry.ordered_picture_move_up!(@pic2)
expect(entry.ordered_pictures).to eq([@pic3, @pic2, @pic1])
end
it 'check type' do
expect { entry.ordered_picture_move_up!(@pic2.blob) }
.to raise_exception(TypeError, /ActiveStorage::Blob/)
expect(entry.ordered_pictures).to eq([@pic1, @pic2, @pic3])
end
end
describe 'down' do
it do
expect(entry.ordered_pictures).to eq([@pic1, @pic2, @pic3])
entry.ordered_picture_move_down!(@pic1)
expect(entry.ordered_pictures).to eq([@pic2, @pic1, @pic3])
entry.ordered_picture_move_down!(@pic1)
expect(entry.ordered_pictures).to eq([@pic2, @pic3, @pic1])
entry.ordered_picture_move_down!(@pic1)
expect(entry.ordered_pictures).to eq([@pic2, @pic3, @pic1])
entry.ordered_picture_move_down!(@pic2)
expect(entry.ordered_pictures).to eq([@pic3, @pic2, @pic1])
end
it 'check type' do
expect { entry.ordered_picture_move_down!(2) }
.to raise_exception(TypeError, "2 must be a ActiveStorage::Attachment")
expect(entry.ordered_pictures).to eq([@pic1, @pic2, @pic3])
end
end
end
context 'when order' do
it do
entry.ordered_picture_ids = [@pic2.id, @pic3.id, @pic1.id]
expect(entry.ordered_pictures).to eq([@pic2, @pic3, @pic1])
end
end
context 'when order with mistakes' do
it do
entry.ordered_picture_ids = [@pic2.id, nil, @pic3.id, 'poop', @pic1.id]
expect(entry.ordered_pictures).to eq([@pic2, @pic3, @pic1])
end
end
context 'when order but element missing' do
it do
entry.ordered_picture_ids = [@pic2.id, @pic1.id]
expect(entry.ordered_pictures).to eq([@pic2, @pic1, @pic3])
end
end
context 'when order but element missing' do
it do
entry.ordered_picture_ids = [@pic2.id]
expect(entry.ordered_pictures).to eq([@pic2, @pic1, @pic3])
end
end
end
end
# spec/lib/array_element_move_spec.rb
require 'rails_helper'
RSpec.describe ArrayElementMove do
let(:arr) { [1,2,3,4,5,6] }
it do
ArrayElementMove.up!(arr, 4)
expect(arr).to eq([1,2,4,3,5,6])
expect(ArrayElementMove.up!(arr, 4)).to eq([1,4,2,3,5,6])
expect(arr).to eq([1,4,2,3,5,6])
ArrayElementMove.up!(arr, 4)
expect(arr).to eq([4,1,2,3,5,6])
ArrayElementMove.up!(arr, 4)
expect(arr).to eq([4,1,2,3,5,6])
end
it do
ArrayElementMove.down!(arr, 4)
expect(arr).to eq([1,2,3,5,4,6])
expect(ArrayElementMove.down!(arr, 4)).to eq([1,2,3,5,6,4])
expect(arr).to eq([1,2,3,5,6,4])
expect(ArrayElementMove.down!(arr, 4)).to eq([1,2,3,5,6,4])
expect(arr).to eq([1,2,3,5,6,4])
end
context 'when non uniq array' do
let(:arr) { [1,4,2,3,4,5,6] }
it do
expect { ArrayElementMove.down!(arr, 3) }.to raise_exception(ArrayElementMove::MustBeUniqArray)
expect(arr).to eq([1,4,2,3,4,5,6])
end
it do
expect { ArrayElementMove.down!(arr, 3) }.to raise_exception(ArrayElementMove::MustBeUniqArray)
expect(arr).to eq([1,4,2,3,4,5,6])
end
end
context 'when non existing item' do
it do
expect { ArrayElementMove.up!(arr, 9) }.to raise_exception(ArrayElementMove::ItemNotInArray)
expect(arr).to eq([1,2,3,4,5,6])
end
it do
expect { ArrayElementMove.up!(arr, 9) }.to raise_exception(ArrayElementMove::ItemNotInArray)
expect(arr).to eq([1,2,3,4,5,6])
end
end
end