hotwire_club-toolbox 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/MIT-LICENSE +20 -0
- data/README.md +34 -0
- data/Rakefile +6 -0
- data/app/helpers/hotwire_club/toolbox/optimistic_form_builder.rb +48 -0
- data/app/helpers/hotwire_club/toolbox/optimistic_form_helper.rb +65 -0
- data/app/javascript/hotwire_club/toolbox/helpers/timing_helpers.js +29 -0
- data/app/javascript/hotwire_club/toolbox/optimistic_form_controller.js +34 -0
- data/config/importmap.rb +2 -0
- data/config/routes.rb +2 -0
- data/docs/optimistic-form.md +155 -0
- data/lib/generators/hotwire_club/toolbox/optimistic_form/install_generator.rb +127 -0
- data/lib/hotwire_club/toolbox/engine.rb +28 -0
- data/lib/hotwire_club/toolbox/version.rb +5 -0
- data/lib/hotwire_club/toolbox.rb +9 -0
- data/lib/tasks/hotwire_club/toolbox_tasks.rake +4 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '0950bbb3f4ecf3bcf29bffd1b773e3292226a24710d4022412c33d7d5a31ade3'
|
|
4
|
+
data.tar.gz: 17433199837db374317a62fccf830caade02cf4e8a7a6c94a2d5288952a21433
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ec93a18015f5ef402f5b163f71bb49d772b84a141bf97d5f1f1d7de95dc945d4ada812b16b46959070709b5bba3308a7b811afab8a66b2f53d8558a8a50a54a7
|
|
7
|
+
data.tar.gz: a1f4e48daeb95574133cef520d10ff346ac560fddb225fc7a2646a7f08bc280881cd3942208fd699fd04ac341b0c0b93b5351e3f87d8c1b39ab9f85fcdf1db1b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.1.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Initial release.
|
|
9
|
+
- **Optimistic Form** tool: an optimistic-UI form builder for Turbo that paints
|
|
10
|
+
a predicted result on submit and reconciles with the server only on failure.
|
|
11
|
+
Includes a Stimulus controller, an install generator (importmap / jsbundling /
|
|
12
|
+
vite), and documentation in `docs/optimistic-form.md`.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Julian Rubisch
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# HotwireClub::Toolbox
|
|
2
|
+
Short description and motivation.
|
|
3
|
+
|
|
4
|
+
## Tools
|
|
5
|
+
|
|
6
|
+
A collection of loosely connected Hotwire/Rails tools and techniques. Each has its own usage guide under [`docs/`](docs/).
|
|
7
|
+
|
|
8
|
+
- **[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
|
+
## Usage
|
|
11
|
+
How to use my plugin.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
Add this line to your application's Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "hotwire_club-toolbox"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
And then execute:
|
|
21
|
+
```bash
|
|
22
|
+
$ bundle
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install it yourself as:
|
|
26
|
+
```bash
|
|
27
|
+
$ gem install hotwire_club-toolbox
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Contributing
|
|
31
|
+
Contribution directions go here.
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module HotwireClub
|
|
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.
|
|
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
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module HotwireClub
|
|
2
|
+
module Toolbox
|
|
3
|
+
# View helpers that wrap `form_with` / `form_for` with the optimistic-UI
|
|
4
|
+
# form builder and Stimulus wiring.
|
|
5
|
+
#
|
|
6
|
+
# optimistic_form_for photo, attribute_name: :favorite, value: !photo.favorite do |form|
|
|
7
|
+
# form.optimistic_template dom_id(photo, "favorite-button"), favorite_button_icon(!photo.favorite)
|
|
8
|
+
# form.button { favorite_button_icon(photo.favorite) }
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Pass +attribute_name:+/+value:+ to have a hidden field injected
|
|
12
|
+
# automatically, or call +form.optimistic_hidden_field+ yourself to place it.
|
|
13
|
+
module OptimisticFormHelper
|
|
14
|
+
def optimistic_form_with(attribute_name: nil, value: OptimisticFormBuilder::UNSET, **options, &block)
|
|
15
|
+
options[:builder] = OptimisticFormBuilder
|
|
16
|
+
enrich_form_with_optimistic_options(options)
|
|
17
|
+
|
|
18
|
+
form_with(**options) do |form|
|
|
19
|
+
optimistic_form_body(form, attribute_name, value, &block)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def optimistic_form_for(record, attribute_name: nil, value: OptimisticFormBuilder::UNSET, **options, &block)
|
|
24
|
+
options[:builder] = OptimisticFormBuilder
|
|
25
|
+
enrich_form_with_optimistic_options(options)
|
|
26
|
+
|
|
27
|
+
form_for(record, **options) do |form|
|
|
28
|
+
optimistic_form_body(form, attribute_name, value, &block)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Capture the block first so an explicit `form.optimistic_hidden_field`
|
|
35
|
+
# call inside it wins; only auto-inject when the caller supplied
|
|
36
|
+
# attribute_name/value and didn't render the field themselves.
|
|
37
|
+
def optimistic_form_body(form, attribute_name, value, &block)
|
|
38
|
+
body = capture(form, &block)
|
|
39
|
+
|
|
40
|
+
prefix = if auto_inject_hidden_field?(form, attribute_name, value)
|
|
41
|
+
form.optimistic_hidden_field(attribute_name, value: value)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
safe_join([ prefix, body ].compact)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def auto_inject_hidden_field?(form, attribute_name, value)
|
|
48
|
+
attribute_name.present? &&
|
|
49
|
+
!value.nil? &&
|
|
50
|
+
!OptimisticFormBuilder::UNSET.equal?(value) &&
|
|
51
|
+
!form.optimistic_hidden_field_rendered?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def enrich_form_with_optimistic_options(options)
|
|
55
|
+
options[:html] ||= {}
|
|
56
|
+
options[:html][:data] ||= {}
|
|
57
|
+
|
|
58
|
+
options[:html][:data][:controller] =
|
|
59
|
+
[ options[:html][:data][:controller], "optimistic-form" ].compact.join(" ")
|
|
60
|
+
options[:html][:data][:action] =
|
|
61
|
+
[ options[:html][:data][:action], "turbo:submit-start->optimistic-form#apply turbo:submit-end->optimistic-form#refresh" ].compact.join(" ")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Timing helpers for Stimulus controllers.
|
|
2
|
+
|
|
3
|
+
export function throttle(fn, delay = 1000) {
|
|
4
|
+
let timeoutId = null;
|
|
5
|
+
|
|
6
|
+
return (...args) => {
|
|
7
|
+
if (!timeoutId) {
|
|
8
|
+
fn.apply(this, args);
|
|
9
|
+
timeoutId = setTimeout(() => (timeoutId = null), delay);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function debounce(fn, delay = 1000) {
|
|
15
|
+
let timeoutId = null;
|
|
16
|
+
|
|
17
|
+
return (...args) => {
|
|
18
|
+
clearTimeout(timeoutId);
|
|
19
|
+
timeoutId = setTimeout(() => fn.apply(this, args), delay);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function nextFrame() {
|
|
24
|
+
return new Promise(requestAnimationFrame);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function delay(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
import { throttle } from "hotwire_club/toolbox/helpers/timing_helpers";
|
|
3
|
+
|
|
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.
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static targets = ["template"];
|
|
9
|
+
|
|
10
|
+
initialize() {
|
|
11
|
+
// Throttle so the paint fires immediately, but a burst of rapid submits on
|
|
12
|
+
// the same form can't stack duplicate clones.
|
|
13
|
+
this.apply = throttle(this.apply.bind(this), 200);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
apply() {
|
|
17
|
+
if (!this.hasTemplateTarget) return;
|
|
18
|
+
|
|
19
|
+
this.templateTargets.forEach((template) => {
|
|
20
|
+
document.body.appendChild(template.content.cloneNode(true));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
refresh(event) {
|
|
25
|
+
// On success the optimistic paint already reflects the new state; only
|
|
26
|
+
// refresh to reconcile when the server rejected the submission.
|
|
27
|
+
if (event.detail?.success) return;
|
|
28
|
+
|
|
29
|
+
document.body.insertAdjacentHTML(
|
|
30
|
+
"beforeend",
|
|
31
|
+
'<turbo-stream action="refresh"></turbo-stream>',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
data/config/importmap.rb
ADDED
data/config/routes.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Optimistic Form
|
|
2
|
+
|
|
3
|
+
Optimistic UI for Turbo forms. 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.
|
|
4
|
+
|
|
5
|
+
Because the optimistic update and the server response speak the same Turbo Stream vocabulary, there is one mental model and no bespoke DOM patching.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
The tool ships with the `hotwire_club-toolbox` engine. Add the gem, then run the installer:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bin/rails generate hotwire_club:toolbox:optimistic_form:install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The generator detects your JavaScript setup and wires the Stimulus controller:
|
|
16
|
+
|
|
17
|
+
- **importmap** (the module is pinned by the engine): adds an import and `application.register("optimistic-form", …)` to `app/javascript/controllers/index.js`.
|
|
18
|
+
- **jsbundling (esbuild/bun/rollup) or vite**: copies `optimistic_form_controller.js` and `helpers/timing_helpers.js` into `app/javascript/`, rewriting the import to a relative path, and registers the controller.
|
|
19
|
+
|
|
20
|
+
It also ensures the Turbo morph refresh meta tags (see Prerequisites) are present in your layout.
|
|
21
|
+
|
|
22
|
+
### Prerequisites
|
|
23
|
+
|
|
24
|
+
- **Turbo and Stimulus** in the host app.
|
|
25
|
+
- **Turbo morph page refreshes.** Reconciliation on failure uses a Turbo refresh, which must morph (not hard reload) to be seamless. The generator adds these to your layout `<head>`:
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<meta name="turbo-refresh-method" content="morph">
|
|
29
|
+
<meta name="turbo-refresh-scroll" content="preserve">
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Basic usage
|
|
33
|
+
|
|
34
|
+
A favorite toggle. The `optimistic_template` predicts the toggled state; the button shows the current one:
|
|
35
|
+
|
|
36
|
+
```erb
|
|
37
|
+
<%= optimistic_form_for photo, attribute_name: :favorite, value: !photo.favorite do |form| %>
|
|
38
|
+
<%= form.optimistic_template dom_id(photo, "favorite-button-icon"), favorite_button_icon(!photo.favorite) %>
|
|
39
|
+
<%= form.button do %><%= favorite_button_icon(photo.favorite) %><% end %>
|
|
40
|
+
<% end %>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
An add-to-cart button that bumps a counter elsewhere on the page:
|
|
44
|
+
|
|
45
|
+
```erb
|
|
46
|
+
<%= optimistic_form_with url: carts_update_path, method: :patch, attribute_name: :photo_id, value: photo.id do |form| %>
|
|
47
|
+
<%= form.optimistic_template "cart-items-count", (@cart_items_count + 1) %>
|
|
48
|
+
<%= form.button "Add to cart" %>
|
|
49
|
+
<% end %>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`attribute_name:`/`value:` inject a hidden field carrying the submitted value (see [Hidden field](#hidden-field)).
|
|
53
|
+
|
|
54
|
+
## The reconciliation model
|
|
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.
|
|
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
|
+
|
|
59
|
+
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
|
+
|
|
61
|
+
## Server contract
|
|
62
|
+
|
|
63
|
+
Because success trusts the optimistic paint and failure triggers a client refresh, your controller must respond accordingly:
|
|
64
|
+
|
|
65
|
+
- **Success:** `head :no_content` (204), or a targeted Turbo Stream (see below). **Do not redirect.** A redirect combined with morph refreshes is itself a full reload, which defeats the optimism.
|
|
66
|
+
- **Failure:** respond `4xx`/`422` so `event.detail.success` is false and the client reconciles. Set a flash if you want it surfaced after the refresh.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
def update
|
|
70
|
+
@photo = Photo.find(params[:id])
|
|
71
|
+
|
|
72
|
+
if @photo.update(photo_params)
|
|
73
|
+
head :no_content
|
|
74
|
+
else
|
|
75
|
+
flash[:alert] = "Your changes could not be saved."
|
|
76
|
+
head :unprocessable_entity
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Authoritative correction on success (opt-in)
|
|
82
|
+
|
|
83
|
+
If the true value can differ from your prediction under contention (for example a shared counter), return a targeted Turbo Stream on success. Turbo applies it over the optimistic guess; no client change is needed. To keep the prediction and the truth from drifting, render the reconcilable fragment from a **single partial** used in both places:
|
|
84
|
+
|
|
85
|
+
```erb
|
|
86
|
+
<%# app/views/photos/_favorite_button.html.erb (single source of truth) %>
|
|
87
|
+
<span id="<%= dom_id(photo, "favorite-button-icon") %>"><%= favorite_button_icon(photo.favorite) %></span>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```erb
|
|
91
|
+
<%# the optimistic prediction %>
|
|
92
|
+
<%= form.optimistic_template dom_id(photo, "favorite-button-icon") do %>
|
|
93
|
+
<%= turbo_stream.update dom_id(photo, "favorite-button-icon") do %>
|
|
94
|
+
<%= favorite_button_icon(!photo.favorite) %>
|
|
95
|
+
<% end %>
|
|
96
|
+
<% end %>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# the authoritative response
|
|
101
|
+
render turbo_stream: turbo_stream.update(
|
|
102
|
+
ActionView::RecordIdentifier.dom_id(@photo, "favorite-button-icon"),
|
|
103
|
+
partial: "photos/favorite_button", locals: { photo: @photo }
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Multiple templates in one form
|
|
108
|
+
|
|
109
|
+
Declare more than one `optimistic_template` to paint several regions from a single submit (for example a counter and a button). All of them are applied:
|
|
110
|
+
|
|
111
|
+
```erb
|
|
112
|
+
<%= optimistic_form_with url: carts_update_path, method: :patch, attribute_name: :photo_id, value: photo.id do |form| %>
|
|
113
|
+
<%= form.optimistic_template "cart-items-count", (@cart_items_count + 1) %>
|
|
114
|
+
<%= form.optimistic_template dom_id(photo, "favorite-button-icon"), favorite_button_icon(true) %>
|
|
115
|
+
<%= form.button "Add and favorite" %>
|
|
116
|
+
<% end %>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Hidden field
|
|
120
|
+
|
|
121
|
+
Two ways to submit the toggled value, pick one per form:
|
|
122
|
+
|
|
123
|
+
- **Automatic:** pass `attribute_name:`/`value:` to the form helper and a hidden field is injected for you.
|
|
124
|
+
- **Explicit:** call `form.optimistic_hidden_field :favorite, value: !photo.favorite` where you want it. This suppresses the automatic injection.
|
|
125
|
+
|
|
126
|
+
`value: false` is preserved (a favorite toggle legitimately submits `false`). Only `nil` or an omitted value suppresses the field.
|
|
127
|
+
|
|
128
|
+
## Block form
|
|
129
|
+
|
|
130
|
+
Instead of the positional `target, template`, pass a block to author the stream(s) yourself:
|
|
131
|
+
|
|
132
|
+
```erb
|
|
133
|
+
<%= form.optimistic_template do %>
|
|
134
|
+
<%= turbo_stream.update("cart-items-count") { @cart_items_count + 1 } %>
|
|
135
|
+
<% end %>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Notes
|
|
139
|
+
|
|
140
|
+
- **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.
|
|
141
|
+
- **Throttling.** `apply` is throttled per form so a burst of rapid submits cannot stack duplicate clones. The first paint is still immediate.
|
|
142
|
+
|
|
143
|
+
## API reference
|
|
144
|
+
|
|
145
|
+
Helpers (available in all views):
|
|
146
|
+
|
|
147
|
+
- `optimistic_form_with(attribute_name: nil, value: nil, **options, &block)`
|
|
148
|
+
- `optimistic_form_for(record, attribute_name: nil, value: nil, **options, &block)`
|
|
149
|
+
|
|
150
|
+
Both set `options[:builder]` to `OptimisticFormBuilder`; a `builder:` you pass is overridden (the tool's builder methods depend on it).
|
|
151
|
+
|
|
152
|
+
Builder methods (yielded `form`):
|
|
153
|
+
|
|
154
|
+
- `optimistic_template(target = nil, template = nil, &block)` - wraps a `turbo_stream.update` (positional) or your block in the tracked `<template>`.
|
|
155
|
+
- `optimistic_hidden_field(attribute_name, value:)` - renders the hidden field explicitly and suppresses auto-injection.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module HotwireClub
|
|
4
|
+
module Toolbox
|
|
5
|
+
module OptimisticForm
|
|
6
|
+
# Wires the optimistic-form Stimulus controller into the host app,
|
|
7
|
+
# adapting to its JavaScript setup (importmap, jsbundling/esbuild/bun, or
|
|
8
|
+
# vite), and ensures the Turbo morph refresh meta tags are present.
|
|
9
|
+
#
|
|
10
|
+
# bin/rails generate hotwire_club:toolbox:optimistic_form:install
|
|
11
|
+
class InstallGenerator < Rails::Generators::Base
|
|
12
|
+
CONTROLLER_MODULE = "hotwire_club/toolbox/optimistic_form_controller".freeze
|
|
13
|
+
CONTROLLERS_INDEX = "app/javascript/controllers/index.js".freeze
|
|
14
|
+
LAYOUT = "app/views/layouts/application.html.erb".freeze
|
|
15
|
+
|
|
16
|
+
MORPH_META = <<~HTML
|
|
17
|
+
<meta name="turbo-refresh-method" content="morph">
|
|
18
|
+
<meta name="turbo-refresh-scroll" content="preserve">
|
|
19
|
+
HTML
|
|
20
|
+
|
|
21
|
+
def wire_javascript
|
|
22
|
+
if importmap?
|
|
23
|
+
register_for_importmap
|
|
24
|
+
elsif bundler?
|
|
25
|
+
register_for_bundler
|
|
26
|
+
else
|
|
27
|
+
say_unknown_js_setup
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ensure_morph_meta_tags
|
|
32
|
+
unless File.exist?(layout_path)
|
|
33
|
+
say "Could not find #{LAYOUT}; add the Turbo morph refresh meta tags to your layout <head>:", :yellow
|
|
34
|
+
say MORPH_META
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if File.read(layout_path).include?("turbo-refresh-method")
|
|
39
|
+
say_status :identical, "morph refresh meta tags", :blue
|
|
40
|
+
else
|
|
41
|
+
inject_into_file layout_path, indent(MORPH_META), after: /<head>\n/
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def print_server_contract
|
|
46
|
+
say ""
|
|
47
|
+
say "Optimistic Form installed. Remember the server contract:", :green
|
|
48
|
+
say " • Success -> head :no_content (204), or a targeted turbo_stream. Do not redirect."
|
|
49
|
+
say " • Failure -> 4xx/422 so the client reconciles."
|
|
50
|
+
say "See docs/optimistic-form.md for details."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def importmap?
|
|
56
|
+
File.exist?(File.join(destination_root, "config/importmap.rb"))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def bundler?
|
|
60
|
+
gem_in_bundle?("jsbundling-rails") || gem_in_bundle?("vite_rails") ||
|
|
61
|
+
File.exist?(File.join(destination_root, "package.json"))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def register_for_importmap
|
|
65
|
+
register_controller(import: %(import OptimisticFormController from "#{CONTROLLER_MODULE}"))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def register_for_bundler
|
|
69
|
+
copy_controller_sources
|
|
70
|
+
register_controller(import: %(import OptimisticFormController from "./optimistic_form_controller"))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Copy the controller + timing helpers into the host tree, rewriting the
|
|
74
|
+
# bare-specifier import to a relative one bundlers can resolve.
|
|
75
|
+
def copy_controller_sources
|
|
76
|
+
controller = engine_js("optimistic_form_controller.js")
|
|
77
|
+
.sub(%r{from "hotwire_club/toolbox/helpers/timing_helpers"}, %(from "../helpers/timing_helpers"))
|
|
78
|
+
|
|
79
|
+
create_file "app/javascript/controllers/optimistic_form_controller.js", controller
|
|
80
|
+
create_file "app/javascript/helpers/timing_helpers.js", engine_js("helpers/timing_helpers.js")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def register_controller(import:)
|
|
84
|
+
registration = <<~JS
|
|
85
|
+
#{import}
|
|
86
|
+
application.register("optimistic-form", OptimisticFormController)
|
|
87
|
+
JS
|
|
88
|
+
|
|
89
|
+
index = File.join(destination_root, CONTROLLERS_INDEX)
|
|
90
|
+
if File.exist?(index)
|
|
91
|
+
if File.read(index).include?("optimistic-form")
|
|
92
|
+
say_status :identical, "optimistic-form registration", :blue
|
|
93
|
+
else
|
|
94
|
+
append_to_file index, "\n#{registration}"
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
say "Could not find #{CONTROLLERS_INDEX}; register the controller yourself:", :yellow
|
|
98
|
+
say registration
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def say_unknown_js_setup
|
|
103
|
+
say "Could not detect your JavaScript setup (importmap / jsbundling / vite).", :yellow
|
|
104
|
+
say "Import and register the controller manually, e.g.:", :yellow
|
|
105
|
+
say %( import OptimisticFormController from "#{CONTROLLER_MODULE}")
|
|
106
|
+
say %( application.register("optimistic-form", OptimisticFormController))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def gem_in_bundle?(name)
|
|
110
|
+
Gem.loaded_specs.key?(name)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def engine_js(relative_path)
|
|
114
|
+
File.read(HotwireClub::Toolbox::Engine.root.join("app/javascript/hotwire_club/toolbox", relative_path))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def layout_path
|
|
118
|
+
File.join(destination_root, LAYOUT)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def indent(text)
|
|
122
|
+
text.gsub(/^/, " ")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module HotwireClub
|
|
2
|
+
module Toolbox
|
|
3
|
+
class Engine < ::Rails::Engine
|
|
4
|
+
# Make the engine's view helpers available in the host app's views.
|
|
5
|
+
# (The engine intentionally does not isolate its namespace.)
|
|
6
|
+
initializer "hotwire_club.toolbox.helpers" do
|
|
7
|
+
ActiveSupport.on_load(:action_view) do
|
|
8
|
+
include HotwireClub::Toolbox::OptimisticFormHelper
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Serve the engine's JavaScript through the asset pipeline (Propshaft).
|
|
13
|
+
initializer "hotwire_club.toolbox.assets" do |app|
|
|
14
|
+
if app.config.respond_to?(:assets)
|
|
15
|
+
app.config.assets.paths << root.join("app/javascript")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Contribute the engine's importmap pins to host apps using importmap-rails.
|
|
20
|
+
initializer "hotwire_club.toolbox.importmap", before: "importmap" do |app|
|
|
21
|
+
if app.config.respond_to?(:importmap)
|
|
22
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
23
|
+
app.config.importmap.cache_sweepers << root.join("app/javascript")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hotwire_club-toolbox
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Julian Rubisch
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-07-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 8.1.3
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 8.1.3
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: turbo-rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
description: HotwireClub::Toolbox is a Rails engine that packages a collection of
|
|
41
|
+
loosely connected Hotwire and Rails tools and techniques, starting with an optimistic-UI
|
|
42
|
+
form builder for Turbo.
|
|
43
|
+
email:
|
|
44
|
+
- julian@julianrubisch.at
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- MIT-LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- Rakefile
|
|
53
|
+
- app/helpers/hotwire_club/toolbox/optimistic_form_builder.rb
|
|
54
|
+
- app/helpers/hotwire_club/toolbox/optimistic_form_helper.rb
|
|
55
|
+
- app/javascript/hotwire_club/toolbox/helpers/timing_helpers.js
|
|
56
|
+
- app/javascript/hotwire_club/toolbox/optimistic_form_controller.js
|
|
57
|
+
- config/importmap.rb
|
|
58
|
+
- config/routes.rb
|
|
59
|
+
- docs/optimistic-form.md
|
|
60
|
+
- lib/generators/hotwire_club/toolbox/optimistic_form/install_generator.rb
|
|
61
|
+
- lib/hotwire_club/toolbox.rb
|
|
62
|
+
- lib/hotwire_club/toolbox/engine.rb
|
|
63
|
+
- lib/hotwire_club/toolbox/version.rb
|
|
64
|
+
- lib/tasks/hotwire_club/toolbox_tasks.rake
|
|
65
|
+
homepage: https://github.com/TheHotwireClub/hotwire_club-toolbox
|
|
66
|
+
licenses:
|
|
67
|
+
- MIT
|
|
68
|
+
metadata:
|
|
69
|
+
homepage_uri: https://github.com/TheHotwireClub/hotwire_club-toolbox
|
|
70
|
+
source_code_uri: https://github.com/TheHotwireClub/hotwire_club-toolbox
|
|
71
|
+
changelog_uri: https://github.com/TheHotwireClub/hotwire_club-toolbox/blob/main/CHANGELOG.md
|
|
72
|
+
rubygems_mfa_required: 'true'
|
|
73
|
+
rdoc_options: []
|
|
74
|
+
require_paths:
|
|
75
|
+
- lib
|
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: 3.2.0
|
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '0'
|
|
86
|
+
requirements: []
|
|
87
|
+
rubygems_version: 3.6.2
|
|
88
|
+
specification_version: 4
|
|
89
|
+
summary: A collection of loosely connected Hotwire and Rails tools and techniques.
|
|
90
|
+
test_files: []
|