<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="4.0.1">Jekyll</generator><link href="https://nts.strzibny.name/feed.xml" rel="self" type="application/atom+xml" /><link href="https://nts.strzibny.name/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2025-10-21T13:12:13+00:00</updated><id>https://nts.strzibny.name/feed.xml</id><title type="html">Notes to self</title><subtitle>Josef Strzibny's publication on software engineering, technical leadership, business, and bootstrapping</subtitle><author><name>Josef Strzibny</name></author><entry><title type="html">devise-otp 2.0 released</title><link href="https://nts.strzibny.name/devise-otp-2/" rel="alternate" type="text/html" title="devise-otp 2.0 released" /><published>2025-10-21T00:00:00+00:00</published><updated>2025-10-21T00:00:00+00:00</updated><id>https://nts.strzibny.name/devise-otp-200-released</id><content type="html" xml:base="https://nts.strzibny.name/devise-otp-2/">&lt;p&gt;The OTP plugin for Devise I help to maintain goes 2.0 this week. Here is what’s new and how to upgrade.&lt;/p&gt;

&lt;h2 id=&quot;whats-new&quot;&gt;What’s new&lt;/h2&gt;

&lt;p&gt;We tried to address a couple of things in &lt;a href=&quot;https://github.com/wmlele/devise-otp/releases/tag/v2.0.0&quot;&gt;this release&lt;/a&gt;, mainly support for lockable strategy, improving locale files, and fixing Hotwire and Remember me support. On top we refactored some code, cleaned up ERB views, and added Rubocop and linting.&lt;/p&gt;

&lt;p&gt;Most of these things are self-descriptive but it might be important to note that the devise-otp shares failed login counter with lockable for now. In a way, a login failure is a login failure at the end of the day.&lt;/p&gt;

&lt;p&gt;We also have some breaking changes that warrant the big version bump. Laney Stroup refactored browser persistance to be a dedicated controller and while standardizing browser persistance routes moved actions to be HTML buttons.&lt;/p&gt;

&lt;h2 id=&quot;upgrading&quot;&gt;Upgrading&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;Regenerate the controller and views if you use custom ones:&lt;/p&gt;

    &lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt; rails g devise_otp:controllers
 rails g devise_otp:views
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;

    &lt;p&gt;Note that the browser persistence controls now use &lt;code class=&quot;highlighter-rouge&quot;&gt;button_to&lt;/code&gt;, rather than &lt;code class=&quot;highlighter-rouge&quot;&gt;link_to&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Update locales file&lt;/p&gt;

    &lt;p&gt;Move any *_persistence keys from &lt;code class=&quot;highlighter-rouge&quot;&gt;devise.otp.otp_tokens&lt;/code&gt; to &lt;code class=&quot;highlighter-rouge&quot;&gt;devise.otp.otp_persistence&lt;/code&gt;&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And that should be it! Please file an issue if you found one.&lt;/p&gt;</content><author><name>Josef Strzibny</name></author><category term="ruby" /><category term="rails" /><category term="authentication" /><summary type="html">The OTP plugin for Devise I help to maintain goes 2.0 this week. Here is what’s new and how to upgrade.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">InvoicePrinter 2.5 with QR images and Ruby 3.4 support</title><link href="https://nts.strzibny.name/invoiceprinter-2.5/" rel="alternate" type="text/html" title="InvoicePrinter 2.5 with QR images and Ruby 3.4 support" /><published>2025-10-20T00:00:00+00:00</published><updated>2025-10-20T00:00:00+00:00</updated><id>https://nts.strzibny.name/invoiceprinter-25-with-qr-images-and-ruby-34-support</id><content type="html" xml:base="https://nts.strzibny.name/invoiceprinter-2.5/">&lt;p&gt;Today I released a new version of InvoicePrinter, my Ruby library for generating PDF invoices. Here’s what’s new.&lt;/p&gt;

&lt;h2 id=&quot;new-features&quot;&gt;New features&lt;/h2&gt;

&lt;p&gt;I finally implemented last feature I had in mind, QR code images. I decided not to add dependencies and keep it as a simple image, although we could consider a built-in feature later on.&lt;/p&gt;

&lt;p&gt;To add a QR code, simply point to your QR image path:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;invoice&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;InvoicePrinter&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;number: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'NO. 202500000001'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;provider_name: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'John White'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;provider_lines: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;provider_address&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;purchaser_name: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'Will Black'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;purchaser_lines: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;purchaser_address&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;issue_date: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'10/20/2025'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;due_date: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'11/03/2025'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;total: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'$ 900'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;bank_account_number: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'156546546465'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;description: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Invoice with QR image example.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;items: &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[],&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;note: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Scan the QR code for payment or details.&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;qr_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;File&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;expand_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'../qr.png'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;__FILE__&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;no&quot;&gt;InvoicePrinter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;document: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;qr: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;qr_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;file_name: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;'qr_invoice.pdf'&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The QR code will always appear bottom right, above any notes if they exist.&lt;/p&gt;

&lt;h2 id=&quot;ruby-support&quot;&gt;Ruby support&lt;/h2&gt;

&lt;p&gt;InvoicePrinter got Ruby 3.4 support. It currently supports Rubies from 3.1 to 3.4. We are staying with current Prawn version due to a &lt;a href=&quot;https://github.com/strzibny/invoice_printer/issues/92&quot;&gt;circular dependency&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;new-contributors&quot;&gt;New contributors&lt;/h2&gt;

&lt;p&gt;Simon Neutert was a new contributor in this release, thanks Simon!&lt;/p&gt;</content><author><name>Josef Strzibny</name></author><category term="ruby" /><category term="invoicing" /><category term="pdf" /><summary type="html">Today I released a new version of InvoicePrinter, my Ruby library for generating PDF invoices. Here’s what’s new.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Running JavaScript after a Turbo Stream renders</title><link href="https://nts.strzibny.name/rails-turbo-after-stream-render/" rel="alternate" type="text/html" title="Running JavaScript after a Turbo Stream renders" /><published>2025-03-24T00:00:00+00:00</published><updated>2025-03-24T00:00:00+00:00</updated><id>https://nts.strzibny.name/runing-javascript-after-a-turbo-stream-renders</id><content type="html" xml:base="https://nts.strzibny.name/rails-turbo-after-stream-render/">&lt;p&gt;Turbo comes with &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-stream-render&lt;/code&gt; but unfortunately doesn’t ship with the equivalent &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:after-stream-render&lt;/code&gt;. Here’s how to run JavaScript after the stream renders.&lt;/p&gt;

&lt;h2 id=&quot;why-we-need-this&quot;&gt;Why we need this&lt;/h2&gt;

&lt;p&gt;If you are building your application with Hotwire, your Turbo streams will likely add, remove, and replace some HTML nodes. This mostly works except when you want to add HTML that comes with some JavaScript. Like a file picker, Trix editor, and the like.&lt;/p&gt;

&lt;p&gt;Turbo itself won’t do anything about this. It’s a rather simple tool with simple purpose. JavaScript initialization should come with the HTML Turbo is about to add. Hotwire solves this with Stimulus.&lt;/p&gt;

&lt;h2 id=&quot;the-hotwire-way&quot;&gt;The Hotwire way&lt;/h2&gt;

&lt;p&gt;The Hotwire answer to the problem is Stimulus controllers. Stimulus is build on top of MutationObserver which is a browser API providing the ability to watch for changes being made to the DOM.&lt;/p&gt;

&lt;p&gt;When a Stimulus controller appears on the page, its &lt;code class=&quot;highlighter-rouge&quot;&gt;connect&lt;/code&gt; method is called automatically. If our HTML doesn’t come with a Stimulus controller, we should create a new controller and put our initialization code inside its &lt;code class=&quot;highlighter-rouge&quot;&gt;connect&lt;/code&gt; method:&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// app/javascript/controllers/my_controller.js&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Controller&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Controller&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Code to initialize something&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then we simply add the controller to an element inside our Turbo Stream:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-controller=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;my-controller&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  	Something that needs to be initialize with JavaScript.
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/turbo-stream&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;when-stimulus-is-not-an-option&quot;&gt;When Stimulus is not an option&lt;/h2&gt;

&lt;p&gt;Sometimes Stimulus might not be what we want and we would love to have a Turbo Stream event to hook into anyways.&lt;/p&gt;

&lt;p&gt;Luckily, &lt;a href=&quot;https://discuss.hotwired.dev/u/sdhull/summary&quot;&gt;Steve&lt;/a&gt; shared his little implementation of &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:after-stream-render&lt;/code&gt; in this &lt;a href=&quot;https://discuss.hotwired.dev/t/event-to-know-a-turbo-stream-has-been-rendered/1554/25&quot;&gt;thread&lt;/a&gt;:&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// application.js&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;afterRenderEvent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:after-stream-render&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:before-stream-render&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;originalRender&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;render&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;render&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;streamElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;originalRender&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;streamElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dispatchEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;afterRenderEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As you can see, the idea is quite simple. We create a new custom &lt;code class=&quot;highlighter-rouge&quot;&gt;Event&lt;/code&gt; and add an event listener for &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-stream-render&lt;/code&gt; which already exist. We then run our event after we are done with original rendering.&lt;/p&gt;

&lt;p&gt;To use it we create an event listener and paste the JavaScript that needs to run after rendering:&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:after-stream-render&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;I will run after stream render&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;</content><author><name>Josef Strzibny</name></author><category term="rails" /><summary type="html">Turbo comes with turbo:before-stream-render but unfortunately doesn’t ship with the equivalent turbo:after-stream-render. Here’s how to run JavaScript after the stream renders.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Running interactive sessions with Kamal</title><link href="https://nts.strzibny.name/interactive-session-kamal/" rel="alternate" type="text/html" title="Running interactive sessions with Kamal" /><published>2025-03-21T00:00:00+00:00</published><updated>2025-03-21T00:00:00+00:00</updated><id>https://nts.strzibny.name/running-interactive-sessions-with-kamal</id><content type="html" xml:base="https://nts.strzibny.name/interactive-session-kamal/">&lt;p&gt;How to connect to a container on a server managed by Kamal and run an interactive session?&lt;/p&gt;

&lt;h2 id=&quot;interactive-server-actions&quot;&gt;Interactive server actions&lt;/h2&gt;

&lt;p&gt;Kamal comes with a &lt;code class=&quot;highlighter-rouge&quot;&gt;kamal server exec&lt;/code&gt; to execute a single command on the server. If we pass the &lt;code class=&quot;highlighter-rouge&quot;&gt;-i&lt;/code&gt; option, we’ll start the interactive session that doesn’t cancel the connection immediatelly.&lt;/p&gt;

&lt;p&gt;Similarly, Docker comes with &lt;code class=&quot;highlighter-rouge&quot;&gt;docker exec&lt;/code&gt; command with the &lt;code class=&quot;highlighter-rouge&quot;&gt;-it&lt;/code&gt; options to run a container process interactively.&lt;/p&gt;

&lt;p&gt;If we combine both of these we’ll get what we need. A single command to run something interactively out of a single container:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;kamal server &lt;span class=&quot;nb&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;docker exec -it [CONTAINER] [COMMAND]&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;example&quot;&gt;Example&lt;/h2&gt;

&lt;p&gt;Here’s an example with kamal-proxy:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;kamal server &lt;span class=&quot;nb&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;docker exec -it kamal-proxy bash&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;After running the comment we’ll be dropped into the container running Kamal Proxy.&lt;/p&gt;

&lt;p&gt;Then we can play with the proxy without running long commands:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;kamal-proxy stop app-web &lt;span class=&quot;nt&quot;&gt;--message&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;kamal-proxy resume app-web
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;</content><author><name>Josef Strzibny</name></author><category term="kamal" /><summary type="html">How to connect to a container on a server managed by Kamal and run an interactive session?</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Predownloading embedding models in Rails with Kamal</title><link href="https://nts.strzibny.name/predownload-embed-models-kamal/" rel="alternate" type="text/html" title="Predownloading embedding models in Rails with Kamal" /><published>2025-03-10T00:00:00+00:00</published><updated>2025-03-10T00:00:00+00:00</updated><id>https://nts.strzibny.name/predownloading-embedding-models-in-rails-with-kamal</id><content type="html" xml:base="https://nts.strzibny.name/predownload-embed-models-kamal/">&lt;p&gt;If you are building AI-powered applications in Ruby on Rails, you might have come across  Informers or Transformers.rb gems for transformer inference. Here’s how to improve the production deployment of their models in Kamal setups.&lt;/p&gt;

&lt;h2 id=&quot;informers--transformersrb&quot;&gt;Informers &amp;amp; Transformers.rb&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/ankane/informers&quot;&gt;Informers&lt;/a&gt; and &lt;a href=&quot;https://github.com/ankane/transformers-ruby&quot;&gt;Transformers.rb&lt;/a&gt; are excellent gems for creating document embeddings. These embeddings can then be used to search your documents, build chatbots, and more.&lt;/p&gt;

&lt;p&gt;Here’s an example from the README:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;sentences&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;This is an example sentence&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Each sentence is converted&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Informers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;pipeline&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;embedding&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;sentence-transformers/all-MiniLM-L6-v2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;embeddings&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sentences&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As you can see, you can directly pass a model name you want to the gem. However, these models are quite big and don’t ship with these gems. Conveniently, they will be downloaded on first use.&lt;/p&gt;

&lt;p&gt;This auto-download works amazingly well for development but quickly become a major issue in production. If you are using Kamal or other Docker-based deployment you would be constantly downloading these models with every new release.&lt;/p&gt;

&lt;h2 id=&quot;production-setup&quot;&gt;Production setup&lt;/h2&gt;

&lt;p&gt;For production, we need to download these models just once to a single location, then point the gems to read the models from there.&lt;/p&gt;

&lt;p&gt;The first step is creating a location for them on the server:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# run on the server after logging in with SSH&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# or ideally make it part of server configuration&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;mkdir&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; /models&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;chmod &lt;/span&gt;700 /models&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;chown &lt;/span&gt;1000:1000 /models
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Setting up the ownership for the user &lt;code class=&quot;highlighter-rouge&quot;&gt;1000&lt;/code&gt; is important since we’ll be downloading them from the Rails containers.&lt;/p&gt;

&lt;p&gt;The next step is figure out where they get saved. After a little bit of search on GitHub I found out that both gems construct the cache path using the &lt;code class=&quot;highlighter-rouge&quot;&gt;XDG_CACHE_HOME&lt;/code&gt; environment variable.&lt;/p&gt;

&lt;p&gt;So we’ll need to set this variable to our location and make sure there are downloaded before we run the application.&lt;/p&gt;

&lt;h2 id=&quot;kamal-bits&quot;&gt;Kamal bits&lt;/h2&gt;

&lt;p&gt;To expose our &lt;code class=&quot;highlighter-rouge&quot;&gt;/models&lt;/code&gt; location we’ll add a new volume definition:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# config/deploy.yml&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;SERVICE_NAME&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;DOCKER_USERNAME&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/[SERVICE_NAME]&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;volumes&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/storage:/rails/storage&quot;&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/models:/rails/models&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Now if we read and save from &lt;code class=&quot;highlighter-rouge&quot;&gt;/rails/models&lt;/code&gt; inside the application image, we actually read from and save to a permanent location now.&lt;/p&gt;

&lt;p&gt;So now we can set &lt;code class=&quot;highlighter-rouge&quot;&gt;XDG_CACHE_HOME&lt;/code&gt; env to point to &lt;code class=&quot;highlighter-rouge&quot;&gt;/rails/models&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# config/deploy.yml&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;clear&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;XDG_CACHE_HOME&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/rails/models&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This way we make sure the downloaded models will get reused, but we should make sure they are available before we run our embedding tasks.&lt;/p&gt;

&lt;h2 id=&quot;pre-download&quot;&gt;Pre-download&lt;/h2&gt;

&lt;p&gt;We have a few options on populating the new &lt;code class=&quot;highlighter-rouge&quot;&gt;/models&lt;/code&gt; cache.&lt;/p&gt;

&lt;p&gt;We can grab the local models and &lt;code class=&quot;highlighter-rouge&quot;&gt;scp&lt;/code&gt; them to the expected location (note the full paths with the subdirectory). We can also ‘one off’ a Rails console task with &lt;code class=&quot;highlighter-rouge&quot;&gt;kamal console&lt;/code&gt; and make sure they are downloaded or create a Rake task.&lt;/p&gt;

&lt;p&gt;We can also make this a firm part of deployment by moving this task into the &lt;code class=&quot;highlighter-rouge&quot;&gt;bin/docker-entrypoint&lt;/code&gt; like this:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;${@: -1:1}&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;solid_queue:start&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;];&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;then&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Caching models into $XDG_CACHE_HOME&quot;&lt;/span&gt;

  &lt;span class=&quot;n&quot;&gt;bundle&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rails&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;runner&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;'
  begin
    model = Informers.pipeline(&quot;embedding&quot;, &quot;thenlper/gte-base&quot;)
    puts &quot;Successfully downloaded thenlper/gte-base model&quot;
  end
  '&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;fi&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Running ${@}&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;${@}&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Since I run Solid Queue, I used the &lt;code class=&quot;highlighter-rouge&quot;&gt;solid_queue:start&lt;/code&gt; as argument. You might need to adjust this to fit your background job processing system.&lt;/p&gt;

&lt;p&gt;Note that you need call all of the models you’ll actually use.&lt;/p&gt;</content><author><name>Josef Strzibny</name></author><category term="kamal" /><summary type="html">If you are building AI-powered applications in Ruby on Rails, you might have come across Informers or Transformers.rb gems for transformer inference. Here’s how to improve the production deployment of their models in Kamal setups.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Combining multiple sitemaps with a sitemap index</title><link href="https://nts.strzibny.name/multiple-sitemaps-index/" rel="alternate" type="text/html" title="Combining multiple sitemaps with a sitemap index" /><published>2025-02-17T00:00:00+00:00</published><updated>2025-02-17T00:00:00+00:00</updated><id>https://nts.strzibny.name/combining-multiple-sitemaps-with-a-sitemap-index</id><content type="html" xml:base="https://nts.strzibny.name/multiple-sitemaps-index/">&lt;p&gt;What if we need to combine multiple sitemaps for a main domain or subdomain? Here’s how to do it by creating sitemap index.&lt;/p&gt;

&lt;h2 id=&quot;sitemap-index&quot;&gt;Sitemap index&lt;/h2&gt;

&lt;p&gt;Let’s say our site has a regular &lt;code class=&quot;highlighter-rouge&quot;&gt;sitemap.xml&lt;/code&gt; and a &lt;code class=&quot;highlighter-rouge&quot;&gt;blog/sitemap.xml&lt;/code&gt;, but we want Google to crawl and index both. To combine them we need to rename the original to something else like &lt;code class=&quot;highlighter-rouge&quot;&gt;main_sitemap.xml&lt;/code&gt; and then create an index of all sitemaps we have:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;sitemapindex&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;xmlns=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;sitemap&amp;gt;&lt;/span&gt;
     &lt;span class=&quot;nt&quot;&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://deploymentfromscratch.com/main_sitemap.xml&lt;span class=&quot;nt&quot;&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
     &lt;span class=&quot;nt&quot;&gt;&amp;lt;lastmod&amp;gt;&lt;/span&gt;2004-10-01T18:23:17+00:00&lt;span class=&quot;nt&quot;&gt;&amp;lt;/lastmod&amp;gt;&lt;/span&gt;
   &lt;span class=&quot;nt&quot;&gt;&amp;lt;/sitemap&amp;gt;&lt;/span&gt;
   &lt;span class=&quot;nt&quot;&gt;&amp;lt;sitemap&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;nt&quot;&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://deploymentfromscratch.com/blog/sitemap.xml&lt;span class=&quot;nt&quot;&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
   &lt;span class=&quot;nt&quot;&gt;&amp;lt;/sitemap&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/sitemapindex&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And that’s it, both sitemaps then stay exactly same as before. Optionally we can include &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; tag for latest modification.&lt;/p&gt;</content><author><name>Josef Strzibny</name></author><summary type="html">What if we need to combine multiple sitemaps for a main domain or subdomain? Here’s how to do it by creating sitemap index.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Setting up Cloudflare R2 buckets for Active Storage</title><link href="https://nts.strzibny.name/rails-cloudflare-r2-activestorage/" rel="alternate" type="text/html" title="Setting up Cloudflare R2 buckets for Active Storage" /><published>2025-01-31T00:00:00+00:00</published><updated>2025-01-31T00:00:00+00:00</updated><id>https://nts.strzibny.name/setting-up-cloudflare-r2-buckets-for-active-storage</id><content type="html" xml:base="https://nts.strzibny.name/rails-cloudflare-r2-activestorage/">&lt;p&gt;Rails comes with a built-in support for saving and uploading files to S3 and S3-compatible storage services in Active Storage. Here’s how to set up Cloudflare R2.&lt;/p&gt;

&lt;h2 id=&quot;cloudflare-r2&quot;&gt;Cloudflare R2&lt;/h2&gt;

&lt;p&gt;To start using Cloudflare R2, select &lt;em&gt;R2 Object Storage&lt;/em&gt; from the menu on the left navbar. If you are on free plan you’ll need to subscribe first.&lt;/p&gt;

&lt;p&gt;Then you can click &lt;em&gt;+ Create bucket&lt;/em&gt; and give your bucket a name. Choose meaningful names for your buckets. I usually append the environment to the name.&lt;/p&gt;

&lt;p&gt;Cloudflare will auto assign your bucket location based on your actual location and providing a hint might not work. Not great doing this on holidays.&lt;/p&gt;

&lt;p&gt;TIP: If you are hosting in EU, you can at least choose &lt;em&gt;Specify jurisdiction&lt;/em&gt; and force it to be within EU.&lt;/p&gt;

&lt;p&gt;Then on the bucket page choose &lt;em&gt;Settings&lt;/em&gt; and include the following CORS:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;AllowedOrigins&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&quot;&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;AllowedMethods&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;PUT&quot;&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;AllowedHeaders&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&quot;&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;ExposeHeaders&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Origin&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Content-Type&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Content-MD5&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Content-Disposition&quot;&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;MaxAgeSeconds&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;3600&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Once done click on &lt;em&gt;API&lt;/em&gt; next to the &lt;em&gt;+ Create Bucket&lt;/em&gt; button. Select &lt;em&gt;Manage API tokens&lt;/em&gt; and create a new token.&lt;/p&gt;

&lt;p&gt;Note all secrets and most importantly access key, access key secret and URL.&lt;/p&gt;

&lt;h2 id=&quot;rails-configuration&quot;&gt;Rails configuration&lt;/h2&gt;

&lt;p&gt;Install Active Storage and configure &lt;code class=&quot;highlighter-rouge&quot;&gt;config/storage.yml&lt;/code&gt; similarly to the following:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;test&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Disk&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;root&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&amp;lt;%= Rails.root.join(&quot;tmp/storage&quot;) %&amp;gt;&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Disk&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;root&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&amp;lt;%= Rails.root.join(&quot;storage&quot;) %&amp;gt;&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;cloudflare_dev&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;S3&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;endpoint&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;https://[HASH].eu.r2.cloudflarestorage.com&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;access_key_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&amp;lt;%= ENV[&quot;R2_ACCESS_KEY&quot;] %&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;secret_access_key&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&amp;lt;%= ENV[&quot;R2_SECRET_ACCESS_KEY&quot;] %&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;bucket&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;app-dev&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;region&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;auto&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;cloudflare_production&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;endpoint&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;https://[HASH].eu.r2.cloudflarestorage.com&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;access_key_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&amp;lt;%= ENV[&quot;R2_ACCESS_KEY&quot;] %&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;secret_access_key&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&amp;lt;%= ENV[&quot;R2_SECRET_ACCESS_KEY&quot;] %&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;bucket&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;app&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;region&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;auto&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To connect Cloudflare we’ll need an &lt;code class=&quot;highlighter-rouge&quot;&gt;endpoint&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;access_key_id&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;secret_access_key&lt;/code&gt;, and &lt;code class=&quot;highlighter-rouge&quot;&gt;region&lt;/code&gt;. You can use environment variables or Rails Credentials.&lt;/p&gt;

&lt;p&gt;Note that unlike for Amazon S3 where we fill in the region and Digital Ocean Spaces where the region is unused, we set it to &lt;code class=&quot;highlighter-rouge&quot;&gt;auto&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I usually set up two different backends, one for development and one for production:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# config/environments/development.rb&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;active_storage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;service&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:cloudflare_dev&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# config/environments/production.rb&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;active_storage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;service&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:cloudflare_production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And that’s really it!&lt;/p&gt;</content><author><name>Josef Strzibny</name></author><category term="rails" /><summary type="html">Rails comes with a built-in support for saving and uploading files to S3 and S3-compatible storage services in Active Storage. Here’s how to set up Cloudflare R2.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Adding button loader to Turbo-powered forms</title><link href="https://nts.strzibny.name/button-loader-turbo-forms/" rel="alternate" type="text/html" title="Adding button loader to Turbo-powered forms" /><published>2025-01-15T00:00:00+00:00</published><updated>2025-01-15T00:00:00+00:00</updated><id>https://nts.strzibny.name/adding-button-loader-to-turbo-powered-forms</id><content type="html" xml:base="https://nts.strzibny.name/button-loader-turbo-forms/">&lt;p&gt;Turbo is a great way to build user interfaces, but most Turbo forms have to wait for the server response. Here’s how I am adding a small loading spinner to the submit buttons to improve the UX.&lt;/p&gt;

&lt;h2 id=&quot;submit-feedback&quot;&gt;Submit feedback&lt;/h2&gt;

&lt;p&gt;Whenever we submit a Turbo form, we are waiting for a response from the server without any big visual changes. This is especially noticable inside modals and on slow connections.&lt;/p&gt;

&lt;p&gt;To improve the situation we’ll create a small Stimulus controller that can be attached to any such form and suggests everything is working despite the wait:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/uploads/lazyform.gif&quot; alt=&quot;lazyform&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Here’s the implementation of our small controller for lazy forms:&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Controller&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Connects to data-controller=&quot;lazy-form&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Controller&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;targets&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;submit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;buttonTarget&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Preserve the button's current width and height&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;buttonWidth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;offsetWidth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;px&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;buttonHeight&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;offsetHeight&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;px&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;style&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;width&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;buttonWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;style&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;height&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;buttonHeight&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;// Change the button text and disable it&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;innerHTML&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&amp;lt;span class=&quot;small-loader&quot;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;disabled&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The controller’s only job is to update the button without any interference to the submission process. I find it best when the button’s width doesn’t change, but you can change it to whatever’s needed.&lt;/p&gt;

&lt;p&gt;You might also want to style the disabled state e.g. with &lt;code class=&quot;highlighter-rouge&quot;&gt;cursor: wait;&lt;/code&gt; at least.&lt;/p&gt;

&lt;p&gt;This can then be added to any form at hand:&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;form_tag&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;resource_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;controller&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;lazy-form&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;submit-&amp;gt;lazy-form#submit&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;
  &lt;span class=&quot;o&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;button_tag&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Submit&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;button is-primary&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;lazy-form-target&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;&amp;lt;%&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;alternatives&quot;&gt;Alternatives&lt;/h2&gt;

&lt;p&gt;There is also &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-submits-with&lt;/code&gt; which let’s you avoid writing Stimulus controller for simple cases.&lt;/p&gt;</content><author><name>Josef Strzibny</name></author><category term="rails" /><summary type="html">Turbo is a great way to build user interfaces, but most Turbo forms have to wait for the server response. Here’s how I am adding a small loading spinner to the submit buttons to improve the UX.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Auto-saving Rails forms with Turbo Streams</title><link href="https://nts.strzibny.name/rails-autosave-form-turbo-stream/" rel="alternate" type="text/html" title="Auto-saving Rails forms with Turbo Streams" /><published>2025-01-09T00:00:00+00:00</published><updated>2025-01-09T00:00:00+00:00</updated><id>https://nts.strzibny.name/auto-saving-forms-with-turbo-streams</id><content type="html" xml:base="https://nts.strzibny.name/rails-autosave-form-turbo-stream/">&lt;p&gt;Here’s how to implement autosaving for inline input fields the Hotwire way.&lt;/p&gt;

&lt;h2 id=&quot;autosaved-forms&quot;&gt;Autosaved forms&lt;/h2&gt;

&lt;p&gt;What’s autosave? Autosaving is saving a user input automatically on changes, lost focus or after an interval of no interactivity without any specific user action. Typically in inline forms.&lt;/p&gt;

&lt;p&gt;To make things straigtforward let’s say we want to save a post’s title while reusing an existing &lt;code class=&quot;highlighter-rouge&quot;&gt;update&lt;/code&gt; action that can save the title or perhaps all the post’s attributes.&lt;/p&gt;

&lt;p&gt;They are couple options to go around it, but here’s how I do it. You just need Turbo and Stimulus installed.&lt;/p&gt;

&lt;h2 id=&quot;stimulus-autosave&quot;&gt;Stimulus autosave&lt;/h2&gt;

&lt;p&gt;Since we’ll remove the usual ‘Save’ button from the form, we’ll need an auto-submission done in a different way. We can create a small Stimulus autosave controller that will be able to autosave anything by submitting the model form:&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Controller&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Controller&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;targets&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;form&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;submit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;preventDefault&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;formTarget&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;requestSubmit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The controller has one form target to determine which form to submit. You could even avoid a target and find the form element, but this makes it explicit.&lt;/p&gt;

&lt;p&gt;We need to call &lt;code class=&quot;highlighter-rouge&quot;&gt;requestSubmit&lt;/code&gt; instead of &lt;code class=&quot;highlighter-rouge&quot;&gt;submit&lt;/code&gt; otherwise we wouldn’t get a Turbo request.&lt;/p&gt;

&lt;p&gt;Tip: There is &lt;a href=&quot;https://www.stimulus-components.com/docs/stimulus-auto-submit&quot;&gt;Auto Submit&lt;/a&gt; controller as a package.&lt;/p&gt;

&lt;h2 id=&quot;the-form&quot;&gt;The form&lt;/h2&gt;

&lt;p&gt;The form to submit the action is completely same, we keep our &lt;code class=&quot;highlighter-rouge&quot;&gt;form_with&lt;/code&gt;, we keep the same &lt;code class=&quot;highlighter-rouge&quot;&gt;url&lt;/code&gt; if we specified that. We just wrap it with &lt;code class=&quot;highlighter-rouge&quot;&gt;data-controller&lt;/code&gt; set to our Stimulus controller name and provide data attributes on the form and the field in question:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-controller=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;autosave&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%=&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;form_with&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;model:&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;url:&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;post_path&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;block-target&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;form&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;form&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%=&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;form.label&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;:title&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%=&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;form.text_field&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;:title&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;&quot;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;blur-&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;autosave#submit&quot; } %&amp;gt;
      
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;title-status&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I used the &lt;code class=&quot;highlighter-rouge&quot;&gt;blur&lt;/code&gt; event to autosave on lost focus but you could also use &lt;code class=&quot;highlighter-rouge&quot;&gt;change&lt;/code&gt; to immediately save any progress (at the expense of many HTTP calls).&lt;/p&gt;

&lt;p&gt;I also prepared a small &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; with an ID that will inform the user about the saving status. The type of the element is irrelevant, it can be a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; too. The important thing is the ID.&lt;/p&gt;

&lt;p&gt;Note that Turbo could also replace the whole form or in a different approach you could even morph the whole screen.&lt;/p&gt;

&lt;h2 id=&quot;turbo-stream&quot;&gt;Turbo stream&lt;/h2&gt;

&lt;p&gt;Given that our &lt;code class=&quot;highlighter-rouge&quot;&gt;update&lt;/code&gt; action remains the same as with traditional forms, the form would already be submitted but we would get the usual redirection. Instead, we want to return a Turbo stream:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;PostsController&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;ApplicationController&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;...&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;update&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;vi&quot;&gt;@blog_post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post_params&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
      &lt;span class=&quot;n&quot;&gt;respond_to&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;turbo_stream&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;html&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;redirect_to&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;vi&quot;&gt;@post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;notice: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Post was successfully updated.&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
      &lt;span class=&quot;n&quot;&gt;respond_to&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;turbo_stream&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;html&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;render&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:edit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;status: :unprocessable_entity&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Now we have an autosave but without any feedback for the user. To add it we’ll instruct Turbo to replace our &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; with either message &lt;em&gt;Saved.&lt;/em&gt; or the error in the view:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;&amp;lt;!-- app/views/posts/update.turbo_stream.erb --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%=&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;turbo_stream.append&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;title-status&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;&quot;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;method:&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;:morph&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target:&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;:current_step&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;blog_post.errors&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;:title&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;any&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%=&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;blog_post.errors&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;:title&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;first&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;elsif&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;blog_post.title_previously_changed&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    Saved.
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Asking on &lt;code class=&quot;highlighter-rouge&quot;&gt;title_previously_changed?&lt;/code&gt; will only output the message if any actual changes happened.&lt;/p&gt;

&lt;p&gt;And that’s it! You have an inline form with autosaving.&lt;/p&gt;

&lt;h2 id=&quot;warning&quot;&gt;Warning&lt;/h2&gt;

&lt;p&gt;I spent a considerable amount of time chasing an issue with wrong field focus. If that happens to you, go through all of your forms and make sure each input field has a unique ID. If you have two forms of the same model on the page they will have a different form ID but same input IDs.&lt;/p&gt;</content><author><name>Josef Strzibny</name></author><category term="rails" /><summary type="html">Here’s how to implement autosaving for inline input fields the Hotwire way.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Business Class 2.0 with Rails 8, Pay 8, Solid, Kamal 2, and fancy generator</title><link href="https://nts.strzibny.name/businessclass-2.0/" rel="alternate" type="text/html" title="Business Class 2.0 with Rails 8, Pay 8, Solid, Kamal 2, and fancy generator" /><published>2024-12-30T00:00:00+00:00</published><updated>2024-12-30T00:00:00+00:00</updated><id>https://nts.strzibny.name/business-class-20-rails-8-pay-8-solid-kamal-2-and-fancy-generator</id><content type="html" xml:base="https://nts.strzibny.name/businessclass-2.0/">&lt;p&gt;The &lt;a href=&quot;https://businessclasskit.com&quot;&gt;Ruby on Rails template&lt;/a&gt; Business Class gets a whole new edition. Rails 8, new licencing, and improved CRUD generator.&lt;/p&gt;

&lt;h2 id=&quot;business-class-20&quot;&gt;Business Class 2.0&lt;/h2&gt;

&lt;p&gt;The new Business Class is built on top of Rails 8, Pay 8, Solid Trifecta libraries, and Kamal 2. Solid Trifecta is now the default and Redis dependency was removed, simplifyng everything. Also, Action Policy got added to finally refactor authorization. An overall update of dependencies but that’s not all!&lt;/p&gt;

&lt;h2 id=&quot;crud-generator&quot;&gt;CRUD Generator&lt;/h2&gt;

&lt;p&gt;The CRUD generator got much better. It will now give you bulk actions both for team views (as grid) and admin views (as table). All destructive actions load a confirmation HTML dialog view with Hotwire.&lt;/p&gt;

&lt;p&gt;I was also able to fix the previous limitation of simple models and you can now generate models like &lt;code class=&quot;highlighter-rouge&quot;&gt;my_shop/product&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;garage/fancy_car&lt;/code&gt; giving you a beautiful start for your well-architected domain model.&lt;/p&gt;

&lt;p&gt;The refreshed generator now also generates policy files so you literally get a whole CRUD for a team-resource without much work.&lt;/p&gt;

&lt;h2 id=&quot;kamal-2&quot;&gt;Kamal 2&lt;/h2&gt;

&lt;p&gt;Kamal configuration got updated to its second major version and to handle the new Solid libraries. Business Class now also explicitely provides a custom &lt;code class=&quot;highlighter-rouge&quot;&gt;production.conf&lt;/code&gt; for any necessary tuning and provisioning sets up unattended upgrades.&lt;/p&gt;

&lt;p&gt;Note that you can use Business Class with managed database or even without Kamal. It’s just Kamal-ready if you need it to be.&lt;/p&gt;

&lt;h2 id=&quot;design-and-demo&quot;&gt;Design and demo&lt;/h2&gt;

&lt;p&gt;The default design got improved in details and screens got much more coherent.&lt;/p&gt;

&lt;p&gt;Here’s a &lt;a href=&quot;https://youtu.be/XYD_1w2v2fM?si=3ZjhslnZglIilvNQ&quot;&gt;demo&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;licencing&quot;&gt;Licencing&lt;/h2&gt;

&lt;p&gt;The &lt;a href=&quot;https://businessclasskit.com/pricing&quot;&gt;new license&lt;/a&gt; gives you lifetime access to Business Class, no need for any kind of renewals. There is a more expensive version for agencies, but even that one is now with lifetime access.&lt;/p&gt;</content><author><name>Josef Strzibny</name></author><category term="rails" /><summary type="html">The Ruby on Rails template Business Class gets a whole new edition. Rails 8, new licencing, and improved CRUD generator.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nts.strzibny.name/og_filter" /><media:content medium="image" url="https://nts.strzibny.name/og_filter" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>