hotwire_club-toolbox 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0950bbb3f4ecf3bcf29bffd1b773e3292226a24710d4022412c33d7d5a31ade3'
4
- data.tar.gz: 17433199837db374317a62fccf830caade02cf4e8a7a6c94a2d5288952a21433
3
+ metadata.gz: 3ef4fb1e166b6dfb0c498a89f9dbdec29c95af1228d45d126a080ee4328ca44f
4
+ data.tar.gz: 67c0d7880e2e4a9749632ebd5a59c84575046cf88589ca79e8ef0919062ce661
5
5
  SHA512:
6
- metadata.gz: ec93a18015f5ef402f5b163f71bb49d772b84a141bf97d5f1f1d7de95dc945d4ada812b16b46959070709b5bba3308a7b811afab8a66b2f53d8558a8a50a54a7
7
- data.tar.gz: a1f4e48daeb95574133cef520d10ff346ac560fddb225fc7a2646a7f08bc280881cd3942208fd699fd04ac341b0c0b93b5351e3f87d8c1b39ab9f85fcdf1db1b
6
+ metadata.gz: 9ace48512f23f6dd4fff86676542564f50354baa4e41520157bbca198ffedf01647798041ac1f6ed9cdd0b2b42933ae6a3cb20fd774a1153c3add0bd5cb00cfb
7
+ data.tar.gz: 63c0130410564e5bee90f918521ab146caca70aeb266c5ba0e781a4e4e01290c149d1dc16f0e1ba5ef6029ec30a201ada01dff066418b73b7627f44956bc87bc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [0.2.0]
6
+
7
+ ### Changed
8
+ - **Optimistic Form**: reconciliation moved from the form to the document. The
9
+ controller follows each submission it saw on `turbo:submit-start` and
10
+ refreshes on its `turbo:submit-end` when it failed, wherever that event is
11
+ dispatched. `optimistic_form_with` / `optimistic_form_for` now wire only
12
+ `turbo:submit-start->optimistic-form#apply`; the
13
+ `turbo:submit-end->optimistic-form#refresh` action is gone. A form that
14
+ still carries it (hand-wired, or rendered by a cached fragment) keeps
15
+ working: `refresh` runs once per submission.
16
+ - **Upgrading**: apps that copied `optimistic_form_controller.js` (jsbundling,
17
+ vite) must copy the new one before rendering with the 0.2.0 helpers, since
18
+ the old controller only reconciled through the removed action.
19
+
20
+ ### Fixed
21
+ - **Optimistic Form**: a failed submission is reconciled when the optimistic
22
+ paint removed the form itself, as a predicted row removal does. Turbo
23
+ dispatches `turbo:submit-end` on `<html>` once the form has left the page,
24
+ so the form-bound action never ran and the removed row stayed gone.
25
+
26
+ ## [0.1.1]
27
+
28
+ ### Added
29
+ - **Optimistic Form**: the builder methods (`optimistic_template`,
30
+ `optimistic_hidden_field`) now live in the `OptimisticFormBuilding` mixin, so
31
+ an app can include them in its own form builder. `optimistic_form_with` /
32
+ `optimistic_form_for` keep a `builder:` that includes the mixin and raise
33
+ `ArgumentError` for one that does not, instead of silently replacing it.
34
+ `OptimisticFormBuilder` is unchanged in behaviour (a plain `FormBuilder` with
35
+ the mixin). Documented under "Using your own form builder".
36
+
5
37
  ## [0.1.0]
6
38
 
7
39
  ### Added
data/README.md CHANGED
@@ -1,34 +1,52 @@
1
1
  # HotwireClub::Toolbox
2
- Short description and motivation.
2
+
3
+ A Rails engine that bundles loosely-connected, production-ready Hotwire and Rails patterns distilled from [The Hotwire Club](https://hotwire.club) challenge series. It's a growing collection, and each tool is independent: pull in the whole engine, use only the pieces you need. There's no shared runtime coupling between them beyond living in the same gem.
3
4
 
4
5
  ## Tools
5
6
 
6
- A collection of loosely connected Hotwire/Rails tools and techniques. Each has its own usage guide under [`docs/`](docs/).
7
+ Each tool has its own usage guide under [`docs/`](docs/).
7
8
 
8
9
  - **[Optimistic Form](docs/optimistic-form.md)** - optimistic UI for Turbo forms: paint the predicted result instantly on submit, reconcile with the server only on failure.
9
10
 
10
- ## Usage
11
- How to use my plugin.
12
-
13
11
  ## Installation
14
- Add this line to your application's Gemfile:
12
+
13
+ Add the gem to your application's Gemfile:
15
14
 
16
15
  ```ruby
17
16
  gem "hotwire_club-toolbox"
18
17
  ```
19
18
 
20
- And then execute:
19
+ Then run `bundle`.
20
+
21
+ Each tool ships with its own generator, so you install only what you use. For Optimistic Form:
22
+
21
23
  ```bash
22
- $ bundle
24
+ bin/rails generate hotwire_club:toolbox:optimistic_form:install
23
25
  ```
24
26
 
25
- Or install it yourself as:
26
- ```bash
27
- $ gem install hotwire_club-toolbox
27
+ ## Requirements
28
+
29
+ - Ruby >= 3.2.0
30
+ - Rails >= 8.1.3
31
+ - turbo-rails ~> 2.0
32
+
33
+ ## Optimistic Form at a glance
34
+
35
+ You author the optimistic update as a Turbo Stream inside a `<template>`. On submit, a Stimulus controller clones it into the DOM so Turbo paints it instantly, then reconciles against the server only if the request fails. Here's a favorite toggle: the `optimistic_template` predicts the toggled state, and the button shows the current one.
36
+
37
+ ```erb
38
+ <%= optimistic_form_for photo, attribute_name: :favorite, value: !photo.favorite do |form| %>
39
+ <%= form.optimistic_template dom_id(photo, "favorite-button-icon"), favorite_button_icon(!photo.favorite) %>
40
+ <%= form.button do %><%= favorite_button_icon(photo.favorite) %><% end %>
41
+ <% end %>
28
42
  ```
29
43
 
44
+ Full guide → [docs/optimistic-form.md](docs/optimistic-form.md).
45
+
30
46
  ## Contributing
31
- Contribution directions go here.
47
+
48
+ Issues and pull requests are welcome. See the [CHANGELOG](CHANGELOG.md) for what's shipped.
32
49
 
33
50
  ## License
51
+
34
52
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -1,48 +1,10 @@
1
1
  module HotwireClub
2
2
  module Toolbox
3
- # Form builder that renders the optimistic-UI scaffolding: a hidden
4
- # <template> holding the turbo-stream(s) the Stimulus controller paints on
5
- # submit, and (optionally) a hidden field carrying the value being toggled.
3
+ # The default builder for `optimistic_form_with` / `optimistic_form_for`:
4
+ # Rails' FormBuilder plus the optimistic-UI methods. Apps with a builder of
5
+ # their own include `OptimisticFormBuilding` in it instead.
6
6
  class OptimisticFormBuilder < ActionView::Helpers::FormBuilder
7
- # Sentinel used by OptimisticFormHelper's form defaults to distinguish
8
- # "no value given" from a real nil/false value.
9
- UNSET = Object.new
10
-
11
- # Wraps the optimistic markup in a <template> the Stimulus controller
12
- # clones into the DOM on `turbo:submit-start`.
13
- #
14
- # form.optimistic_template "cart-count", @count + 1
15
- # form.optimistic_template { turbo_stream.update("cart-count") { ... } }
16
- #
17
- # Positional form builds a `turbo_stream.update` for you; block form lets
18
- # you author the stream(s) yourself and ignores +target+/+template+.
19
- def optimistic_template(target = nil, template = nil, &block)
20
- content = if block
21
- @template.capture(&block)
22
- else
23
- @template.turbo_stream.turbo_stream_action_tag(:update, target: target, template: template)
24
- end
25
-
26
- @template.content_tag("template", data: { optimistic_form_target: "template" }) do
27
- content
28
- end
29
- end
30
-
31
- # Explicit hidden field for the value being submitted. Calling this marks
32
- # the field as rendered so the helper skips its automatic injection.
33
- #
34
- # form.optimistic_hidden_field :favorite, value: !photo.favorite
35
- #
36
- # +value:+ is required (a value-less field would be meaningless); pass
37
- # +false+ freely, only +nil+ renders an empty value.
38
- def optimistic_hidden_field(attribute_name, value:)
39
- @optimistic_hidden_field_rendered = true
40
- hidden_field(attribute_name, value: value)
41
- end
42
-
43
- def optimistic_hidden_field_rendered?
44
- !!@optimistic_hidden_field_rendered
45
- end
7
+ include OptimisticFormBuilding
46
8
  end
47
9
  end
48
10
  end
@@ -0,0 +1,58 @@
1
+ module HotwireClub
2
+ module Toolbox
3
+ # The optimistic-UI builder methods, as a mixin so an app can keep its own
4
+ # form builder (Web Awesome, Tailwind, a design-system builder) and still
5
+ # author optimistic templates:
6
+ #
7
+ # class MyFormBuilder < ActionView::Helpers::FormBuilder
8
+ # include HotwireClub::Toolbox::OptimisticFormBuilding
9
+ # end
10
+ #
11
+ # optimistic_form_with url: cart_path, builder: MyFormBuilder do |form|
12
+ # form.optimistic_template "cart-count", @count + 1
13
+ # end
14
+ #
15
+ # `OptimisticFormBuilder` is this module on a plain `FormBuilder`.
16
+ module OptimisticFormBuilding
17
+ # Sentinel used by OptimisticFormHelper's form defaults to distinguish
18
+ # "no value given" from a real nil/false value.
19
+ UNSET = Object.new
20
+
21
+ # Wraps the optimistic markup in a <template> the Stimulus controller
22
+ # clones into the DOM on `turbo:submit-start`.
23
+ #
24
+ # form.optimistic_template "cart-count", @count + 1
25
+ # form.optimistic_template { turbo_stream.update("cart-count") { ... } }
26
+ #
27
+ # Positional form builds a `turbo_stream.update` for you; block form lets
28
+ # you author the stream(s) yourself and ignores +target+/+template+.
29
+ def optimistic_template(target = nil, template = nil, &block)
30
+ content = if block
31
+ @template.capture(&block)
32
+ else
33
+ @template.turbo_stream.turbo_stream_action_tag(:update, target: target, template: template)
34
+ end
35
+
36
+ @template.content_tag("template", data: { optimistic_form_target: "template" }) do
37
+ content
38
+ end
39
+ end
40
+
41
+ # Explicit hidden field for the value being submitted. Calling this marks
42
+ # the field as rendered so the helper skips its automatic injection.
43
+ #
44
+ # form.optimistic_hidden_field :favorite, value: !photo.favorite
45
+ #
46
+ # +value:+ is required (a value-less field would be meaningless); pass
47
+ # +false+ freely, only +nil+ renders an empty value.
48
+ def optimistic_hidden_field(attribute_name, value:)
49
+ @optimistic_hidden_field_rendered = true
50
+ hidden_field(attribute_name, value: value)
51
+ end
52
+
53
+ def optimistic_hidden_field_rendered?
54
+ !!@optimistic_hidden_field_rendered
55
+ end
56
+ end
57
+ end
58
+ end
@@ -10,9 +10,12 @@ module HotwireClub
10
10
  #
11
11
  # Pass +attribute_name:+/+value:+ to have a hidden field injected
12
12
  # automatically, or call +form.optimistic_hidden_field+ yourself to place it.
13
+ #
14
+ # A +builder:+ option is honoured when that builder includes
15
+ # `OptimisticFormBuilding`; without one, `OptimisticFormBuilder` is used.
13
16
  module OptimisticFormHelper
14
- def optimistic_form_with(attribute_name: nil, value: OptimisticFormBuilder::UNSET, **options, &block)
15
- options[:builder] = OptimisticFormBuilder
17
+ def optimistic_form_with(attribute_name: nil, value: OptimisticFormBuilding::UNSET, **options, &block)
18
+ options[:builder] = optimistic_form_builder(options[:builder])
16
19
  enrich_form_with_optimistic_options(options)
17
20
 
18
21
  form_with(**options) do |form|
@@ -20,8 +23,8 @@ module HotwireClub
20
23
  end
21
24
  end
22
25
 
23
- def optimistic_form_for(record, attribute_name: nil, value: OptimisticFormBuilder::UNSET, **options, &block)
24
- options[:builder] = OptimisticFormBuilder
26
+ def optimistic_form_for(record, attribute_name: nil, value: OptimisticFormBuilding::UNSET, **options, &block)
27
+ options[:builder] = optimistic_form_builder(options[:builder])
25
28
  enrich_form_with_optimistic_options(options)
26
29
 
27
30
  form_for(record, **options) do |form|
@@ -31,6 +34,16 @@ module HotwireClub
31
34
 
32
35
  private
33
36
 
37
+ # The app's own builder is kept when it carries the optimistic methods.
38
+ # One without them fails loudly rather than being swapped out: the caller
39
+ # asked for that builder and would otherwise silently lose its methods.
40
+ def optimistic_form_builder(builder)
41
+ return OptimisticFormBuilder if builder.nil?
42
+ return builder if builder <= OptimisticFormBuilding
43
+
44
+ raise ArgumentError, "#{builder} must include HotwireClub::Toolbox::OptimisticFormBuilding to build an optimistic form"
45
+ end
46
+
34
47
  # Capture the block first so an explicit `form.optimistic_hidden_field`
35
48
  # call inside it wins; only auto-inject when the caller supplied
36
49
  # attribute_name/value and didn't render the field themselves.
@@ -47,7 +60,7 @@ module HotwireClub
47
60
  def auto_inject_hidden_field?(form, attribute_name, value)
48
61
  attribute_name.present? &&
49
62
  !value.nil? &&
50
- !OptimisticFormBuilder::UNSET.equal?(value) &&
63
+ !OptimisticFormBuilding::UNSET.equal?(value) &&
51
64
  !form.optimistic_hidden_field_rendered?
52
65
  end
53
66
 
@@ -58,7 +71,7 @@ module HotwireClub
58
71
  options[:html][:data][:controller] =
59
72
  [ options[:html][:data][:controller], "optimistic-form" ].compact.join(" ")
60
73
  options[:html][:data][:action] =
61
- [ options[:html][:data][:action], "turbo:submit-start->optimistic-form#apply turbo:submit-end->optimistic-form#refresh" ].compact.join(" ")
74
+ [ options[:html][:data][:action], "turbo:submit-start->optimistic-form#apply" ].compact.join(" ")
62
75
  end
63
76
  end
64
77
  end
@@ -2,18 +2,25 @@ import { Controller } from "@hotwired/stimulus";
2
2
  import { throttle } from "hotwire_club/toolbox/helpers/timing_helpers";
3
3
 
4
4
  // Applies optimistic UI on form submit by cloning the form's <template>(s)
5
- // into the DOM (Turbo then processes the contained turbo-stream). On submit-end
6
- // it reconciles against the server only when the submission failed.
5
+ // into the DOM (Turbo then processes the contained turbo-stream). Each
6
+ // submission is followed from the document until its submit-end, and the page
7
+ // is refreshed to reconcile against the server only when the submission failed.
7
8
  export default class extends Controller {
8
9
  static targets = ["template"];
9
10
 
10
11
  initialize() {
11
12
  // Throttle so the paint fires immediately, but a burst of rapid submits on
12
13
  // the same form can't stack duplicate clones.
13
- this.apply = throttle(this.apply.bind(this), 200);
14
+ this.paint = throttle(this.paint.bind(this), 200);
15
+ this.reconciled = new WeakSet();
14
16
  }
15
17
 
16
- apply() {
18
+ apply(event) {
19
+ this.follow(event?.detail?.formSubmission);
20
+ this.paint();
21
+ }
22
+
23
+ paint() {
17
24
  if (!this.hasTemplateTarget) return;
18
25
 
19
26
  this.templateTargets.forEach((template) => {
@@ -21,11 +28,37 @@ export default class extends Controller {
21
28
  });
22
29
  }
23
30
 
31
+ // Listened for on the document, not the form: a prediction may remove the
32
+ // form itself (a deleted row), and Turbo then dispatches turbo:submit-end on
33
+ // <html>, where a listener on the form never hears it.
34
+ follow(formSubmission) {
35
+ if (!formSubmission) return;
36
+
37
+ // Not `once`: with several submissions in flight, another form's
38
+ // submit-end may come first, and that one must not consume the listener.
39
+ const onSubmitEnd = (event) => {
40
+ if (event.detail.formSubmission !== formSubmission) return;
41
+
42
+ document.removeEventListener("turbo:submit-end", onSubmitEnd);
43
+ this.refresh(event);
44
+ };
45
+
46
+ document.addEventListener("turbo:submit-end", onSubmitEnd);
47
+ }
48
+
24
49
  refresh(event) {
25
50
  // On success the optimistic paint already reflects the new state; only
26
51
  // refresh to reconcile when the server rejected the submission.
27
52
  if (event.detail?.success) return;
28
53
 
54
+ // Once per submission, so a form that still carries the pre-0.2 submit-end
55
+ // action does not refresh a second time.
56
+ const { formSubmission } = event.detail ?? {};
57
+ if (formSubmission) {
58
+ if (this.reconciled.has(formSubmission)) return;
59
+ this.reconciled.add(formSubmission);
60
+ }
61
+
29
62
  document.body.insertAdjacentHTML(
30
63
  "beforeend",
31
64
  '<turbo-stream action="refresh"></turbo-stream>',
@@ -53,11 +53,30 @@ An add-to-cart button that bumps a counter elsewhere on the page:
53
53
 
54
54
  ## The reconciliation model
55
55
 
56
- - **On submit-start**, the controller clones the form's `<template>`(s) into the document. Turbo processes the contained stream and paints the optimistic state.
56
+ - **On submit-start**, the controller clones the form's `<template>`(s) into the document. Turbo processes the contained stream and paints the optimistic state. The controller also starts following that submission from the document.
57
57
  - **On submit-end**, the controller reconciles **only if the submission failed** (`event.detail.success === false`). On success the optimistic paint already reflects the new state, so nothing happens.
58
58
 
59
+ The helpers wire a single action onto the form, `turbo:submit-start->optimistic-form#apply`. Submit-end is not an action on the form: the controller listens for it on the document, matched to the submission it painted, so it is heard even when the form has left the page by then (see below).
60
+
59
61
  This keeps the happy path free of extra requests. A full page refresh only ever runs when the server rejects the change, which is exactly when you want authoritative truth.
60
62
 
63
+ ### Predictions that remove the form
64
+
65
+ A prediction may take its own form out of the page: a delete button whose template removes the row it sits in, or one that replaces a card around it.
66
+
67
+ ```erb
68
+ <%= optimistic_form_with url: photo_path(photo), method: :delete do |form| %>
69
+ <%= form.optimistic_template { turbo_stream.remove(dom_id(photo)) } %>
70
+ <%= form.button "Remove" %>
71
+ <% end %>
72
+ ```
73
+
74
+ Turbo dispatches `turbo:submit-end` on `<html>` once the form is no longer connected, so a listener on the form would never hear it. Because the controller follows the submission from the document, a rejected removal refreshes the page and brings the row back like any other failure.
75
+
76
+ ### Upgrading from 0.1
77
+
78
+ Before 0.2.0 the helpers also wired `turbo:submit-end->optimistic-form#refresh` onto the form, which is why a removed form was never reconciled. If your app copied `optimistic_form_controller.js` (jsbundling, vite), copy the 0.2.0 one before upgrading the gem: the old controller only reconciles through that action, which the 0.2.0 helpers no longer render. A form that still carries the action is harmless with the new controller, which refreshes once per submission.
79
+
61
80
  ## Server contract
62
81
 
63
82
  Because success trusts the optimistic paint and failure triggers a client refresh, your controller must respond accordingly:
@@ -135,6 +154,26 @@ Instead of the positional `target, template`, pass a block to author the stream(
135
154
  <% end %>
136
155
  ```
137
156
 
157
+ ## Using your own form builder
158
+
159
+ Apps often have a builder of their own (a design-system builder, a Tailwind builder). The optimistic methods are a mixin, so such a builder can carry them instead of being replaced:
160
+
161
+ ```ruby
162
+ class WebAwesomeFormBuilder < ActionView::Helpers::FormBuilder
163
+ include HotwireClub::Toolbox::OptimisticFormBuilding
164
+ # ...
165
+ end
166
+ ```
167
+
168
+ ```erb
169
+ <%= optimistic_form_with url: cart_path, builder: WebAwesomeFormBuilder do |form| %>
170
+ <%= form.optimistic_template "cart-items-count", @cart_items_count + 1 %>
171
+ <%= form.submit_button "Add to cart" %> <%# a method of your builder %>
172
+ <% end %>
173
+ ```
174
+
175
+ `OptimisticFormBuilder` itself is just `ActionView::Helpers::FormBuilder` with the module included. A `builder:` that lacks the module raises `ArgumentError` at render time, so a missing `include` shows up immediately rather than as a `NoMethodError` on `optimistic_template`.
176
+
138
177
  ## Notes
139
178
 
140
179
  - **Escaping.** Template content is treated as trusted developer markup, the same as any `turbo_stream.update` body (this is what lets you inject SVG icons). Do not pass unescaped user input; sanitize at the call site if you must.
@@ -147,7 +186,7 @@ Helpers (available in all views):
147
186
  - `optimistic_form_with(attribute_name: nil, value: nil, **options, &block)`
148
187
  - `optimistic_form_for(record, attribute_name: nil, value: nil, **options, &block)`
149
188
 
150
- Both set `options[:builder]` to `OptimisticFormBuilder`; a `builder:` you pass is overridden (the tool's builder methods depend on it).
189
+ Both default `options[:builder]` to `OptimisticFormBuilder`. A `builder:` you pass is kept when it includes `HotwireClub::Toolbox::OptimisticFormBuilding` (see [Using your own form builder](#using-your-own-form-builder)); one without it raises `ArgumentError` rather than being silently replaced.
151
190
 
152
191
  Builder methods (yielded `form`):
153
192
 
@@ -1,5 +1,5 @@
1
1
  module HotwireClub
2
2
  module Toolbox
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_club-toolbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Rubisch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-07-02 00:00:00.000000000 Z
10
+ date: 2026-10-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -51,6 +51,7 @@ files:
51
51
  - README.md
52
52
  - Rakefile
53
53
  - app/helpers/hotwire_club/toolbox/optimistic_form_builder.rb
54
+ - app/helpers/hotwire_club/toolbox/optimistic_form_building.rb
54
55
  - app/helpers/hotwire_club/toolbox/optimistic_form_helper.rb
55
56
  - app/javascript/hotwire_club/toolbox/helpers/timing_helpers.js
56
57
  - app/javascript/hotwire_club/toolbox/optimistic_form_controller.js