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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +30 -12
- data/app/helpers/hotwire_club/toolbox/optimistic_form_builder.rb +4 -42
- data/app/helpers/hotwire_club/toolbox/optimistic_form_building.rb +58 -0
- data/app/helpers/hotwire_club/toolbox/optimistic_form_helper.rb +19 -6
- data/app/javascript/hotwire_club/toolbox/optimistic_form_controller.js +37 -4
- data/docs/optimistic-form.md +41 -2
- data/lib/hotwire_club/toolbox/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ef4fb1e166b6dfb0c498a89f9dbdec29c95af1228d45d126a080ee4328ca44f
|
|
4
|
+
data.tar.gz: 67c0d7880e2e4a9749632ebd5a59c84575046cf88589ca79e8ef0919062ce661
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
bin/rails generate hotwire_club:toolbox:optimistic_form:install
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
-
|
|
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:
|
|
15
|
-
options[:builder] =
|
|
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:
|
|
24
|
-
options[:builder] =
|
|
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
|
-
!
|
|
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
|
|
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).
|
|
6
|
-
//
|
|
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.
|
|
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>',
|
data/docs/optimistic-form.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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-
|
|
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
|