turbo_form 0.4.0 → 0.5.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/README.md +71 -133
- data/app/assets/javascripts/turbo_form.js +33 -32
- data/lib/turbo_form/engine.rb +6 -5
- data/lib/turbo_form/form_builder.rb +13 -18
- data/lib/turbo_form/form_helper.rb +6 -31
- data/lib/turbo_form/reload.rb +68 -0
- data/lib/turbo_form/system_test_helper.rb +3 -13
- data/lib/turbo_form/version.rb +1 -1
- data/lib/turbo_form.rb +0 -37
- metadata +2 -4
- data/app/controllers/turbo_form/dynamic_forms_controller.rb +0 -56
- data/config/routes.rb +0 -10
- data/lib/turbo_form/signature.rb +0 -64
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c69a1768a1d65dd2bdfa4d77ea5e58ea239923d54280820b5197bec043376b91
|
|
4
|
+
data.tar.gz: 5958181f35cc5c6cfbdbd50c9a4da9eea692c5670867dfa86d55459355a07c08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76dbea8632fa0087c403cbaa95682fae7b16ed8c3370586007e873e4c7ad3277a06f73a436782834ffb8d2b81fa8302a4d7c3e61130e48adbf6dd28ebd53d6a4
|
|
7
|
+
data.tar.gz: c89bdf36b175d24708012a869ec06692217a38d8b0d3d889c1692dca95237a7ce0039d020cab93463d34491b452b0a8ce309b2cb969396d0a1662534e358861d
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# turbo_form
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Dynamic forms for Rails, by convention.
|
|
4
4
|
|
|
5
5
|
A form that needs to change as it is filled in — a dependent dropdown, a section
|
|
6
6
|
that appears once you pick a type — usually costs you a route, a controller
|
|
@@ -10,26 +10,18 @@ instead:
|
|
|
10
10
|
```erb
|
|
11
11
|
<%= form_for @widget, dynamic: true do |f| %>
|
|
12
12
|
<%= f.select :category, @widget.categories, {}, dynamic_trigger: true %>
|
|
13
|
-
|
|
14
|
-
<div id="flavor-field">
|
|
15
|
-
<%= f.select :flavor, @widget.flavors %>
|
|
16
|
-
</div>
|
|
13
|
+
<%= f.select :flavor, @widget.flavors %>
|
|
17
14
|
<% end %>
|
|
18
15
|
```
|
|
19
16
|
|
|
20
|
-
Pick a category and the
|
|
21
|
-
|
|
22
|
-
`
|
|
23
|
-
|
|
24
|
-
```erb
|
|
25
|
-
<%= fields model: @resource do |f| %>
|
|
26
|
-
<%= turbo_stream.update "flavor-field" do %>
|
|
27
|
-
<%= f.select :flavor, @resource.flavors %>
|
|
28
|
-
<% end %>
|
|
29
|
-
<% end %>
|
|
30
|
-
```
|
|
17
|
+
Pick a category and the page reloads with the form as it currently stands. The
|
|
18
|
+
form assigns what was typed to `@widget` before its fields render, so
|
|
19
|
+
`@widget.flavors` answers for the category just picked. Turbo morphs the page in
|
|
20
|
+
place, so focus, scroll and everything else typed survive the reload.
|
|
31
21
|
|
|
32
|
-
That's the whole feature. No route, no controller, no
|
|
22
|
+
That's the whole feature. No route, no controller action, no template, no
|
|
23
|
+
JavaScript. The page is rendered by its own controller, so its authentication,
|
|
24
|
+
authorization and partials are the ones it always had.
|
|
33
25
|
|
|
34
26
|
## Installation
|
|
35
27
|
|
|
@@ -37,9 +29,9 @@ That's the whole feature. No route, no controller, no JavaScript.
|
|
|
37
29
|
gem "turbo_form"
|
|
38
30
|
```
|
|
39
31
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
On a stock Rails app — Propshaft, importmap-rails, stimulus-rails — the Stimulus
|
|
33
|
+
controller registers itself and there is nothing to generate. If your app
|
|
34
|
+
bundles with esbuild, Vite, Bun or webpack:
|
|
43
35
|
|
|
44
36
|
```bash
|
|
45
37
|
rails generate turbo_form:install
|
|
@@ -51,22 +43,9 @@ See [JavaScript](#javascript) for what that does and how to do it by hand.
|
|
|
51
43
|
|
|
52
44
|
### `dynamic:` on the form
|
|
53
45
|
|
|
54
|
-
`dynamic: true` makes the form dynamic
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
- A form rendered by `WidgetsController` gets
|
|
59
|
-
`widgets/dynamic_form.turbo_stream.erb`.
|
|
60
|
-
- If that doesn't exist, lookup follows controller inheritance, ending at
|
|
61
|
-
`application/dynamic_form.turbo_stream.erb`.
|
|
62
|
-
- Partials resolve the same way, so `render "fields"` inside the template finds
|
|
63
|
-
`widgets/_fields`, just as it would in the form itself.
|
|
64
|
-
|
|
65
|
-
To render a different template, pass its name instead of `true`:
|
|
66
|
-
|
|
67
|
-
```erb
|
|
68
|
-
<%= form_for @widget, dynamic: "shared/refresh_widget" %>
|
|
69
|
-
```
|
|
46
|
+
`dynamic: true` makes the form dynamic: on a reload, it assigns the submitted
|
|
47
|
+
values under its scope to its object, via `assign_attributes`, before any field
|
|
48
|
+
renders.
|
|
70
49
|
|
|
71
50
|
### `dynamic_trigger:` on a field
|
|
72
51
|
|
|
@@ -80,30 +59,6 @@ checkbox, `input` for a text field, `click` for a button. Name an event when you
|
|
|
80
59
|
want something else — `:blur` on text fields is usually what you want, since the
|
|
81
60
|
default fires on every keystroke.
|
|
82
61
|
|
|
83
|
-
#### Sending the form somewhere else
|
|
84
|
-
|
|
85
|
-
A trigger can answer to a different endpoint than its own form, and can add to
|
|
86
|
-
what the form sends:
|
|
87
|
-
|
|
88
|
-
```erb
|
|
89
|
-
<%= f.button "Run", type: "button", dynamic_trigger: {
|
|
90
|
-
event: :click,
|
|
91
|
-
url: formula_preview_widgets_path,
|
|
92
|
-
params: { attribute: :rafter_count }
|
|
93
|
-
} %>
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
`url:` replaces the form's own endpoint for that one trigger; everything else on
|
|
97
|
-
the form keeps using the form's. `params:` are appended to the submitted form
|
|
98
|
-
data, so one endpoint can tell which of several triggers asked. Both are
|
|
99
|
-
optional, and either can be given without the other.
|
|
100
|
-
|
|
101
|
-
That endpoint is yours, not the gem's — it's an ordinary action rendering an
|
|
102
|
-
ordinary turbo_stream template, so the whole form arrives as `params` under its
|
|
103
|
-
usual scope. Use it when one form feeds several actions: a dozen fields that
|
|
104
|
-
each preview themselves, or a select that belongs to a nested form with its own
|
|
105
|
-
controller.
|
|
106
|
-
|
|
107
62
|
Works on every Rails field helper, including the select and date families where
|
|
108
63
|
Rails keeps HTML attributes in a separate hash:
|
|
109
64
|
|
|
@@ -121,85 +76,61 @@ inherits from, so it works without SimpleForm being involved at all:
|
|
|
121
76
|
= f.input :category, input_html: { dynamic_trigger: true }
|
|
122
77
|
```
|
|
123
78
|
|
|
124
|
-
## What
|
|
125
|
-
|
|
126
|
-
A `dynamic: true` form carries a signed description of itself to a single
|
|
127
|
-
endpoint the gem draws into your routes. Given a valid signature it:
|
|
128
|
-
|
|
129
|
-
1. permits the submitted parameters wholesale,
|
|
130
|
-
2. rebuilds the form's object and assigns them to it — an edit form's record is
|
|
131
|
-
found again by id, and a new form's object is rebuilt from the attributes it
|
|
132
|
-
was built with (a parent's foreign key from `@order.line_items.new`, say) before
|
|
133
|
-
the submitted ones are applied,
|
|
134
|
-
3. assigns it to `@resource`,
|
|
135
|
-
4. runs `TurboForm.before_render` inside the controller with the resource, if one is set,
|
|
136
|
-
5. renders the turbo_stream template.
|
|
137
|
-
|
|
138
|
-
Nothing is persisted. `@resource` exists to be asked what the form should now
|
|
139
|
-
look like, not to be saved — and since Active Record saves some assignments on
|
|
140
|
-
the spot (`has_many` writers, `*_ids=`), an Active Record resource is rebuilt
|
|
141
|
-
inside a transaction that is always rolled back.
|
|
142
|
-
|
|
143
|
-
The signature covers the class name, the parameter scope, the template, the view
|
|
144
|
-
paths the form was rendered under, and the record's id or starting attributes. It is signed because the endpoint
|
|
145
|
-
constantizes, loads and renders what it names; none of that may come from the
|
|
146
|
-
browser unverified. It is not encrypted, so those starting attributes are
|
|
147
|
-
readable in the page, as the form itself is.
|
|
148
|
-
|
|
149
|
-
## Configuration
|
|
79
|
+
## What a reload does
|
|
150
80
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
# The endpoint inherits from this, so your authentication applies to it. Point
|
|
155
|
-
# it at a narrower controller when that inheritance brings something the
|
|
156
|
-
# endpoint shouldn't have.
|
|
157
|
-
TurboForm.parent_controller = "ApplicationController"
|
|
81
|
+
A trigger visits the page's own URL with the form's fields in the query string,
|
|
82
|
+
leaving out the authenticity token and `_method`, and marks the request with an
|
|
83
|
+
`X-Turbo-Form` header. On the server:
|
|
158
84
|
|
|
159
|
-
|
|
160
|
-
|
|
85
|
+
1. the page's controller runs as it would for any visit, building `@widget` the
|
|
86
|
+
way it always does — found by id, built from a parent, whatever it does,
|
|
87
|
+
2. the form assigns the submitted values to that object as it starts to render,
|
|
88
|
+
3. the whole request runs inside a database transaction that is always rolled
|
|
89
|
+
back.
|
|
161
90
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
91
|
+
Only a request carrying the header is assigned anything. A plain link can't set
|
|
92
|
+
a header, and another origin can't without a CORS preflight, so a hand-crafted
|
|
93
|
+
URL with form values in it renders the page as if they weren't there.
|
|
165
94
|
|
|
166
|
-
|
|
95
|
+
The rollback is there because Active Record saves some assignments on the spot
|
|
96
|
+
(`has_many` writers, `*_ids=`). A reload exists to render, so nothing it does is
|
|
97
|
+
kept — including anything else the action writes.
|
|
167
98
|
|
|
168
|
-
|
|
169
|
-
controllers that save things. `before_render` is where you get to treat it like
|
|
170
|
-
one of your own: it runs in a `before_action`, after the signature is verified
|
|
171
|
-
and the resource is built, and is handed that resource. It runs *inside* the
|
|
172
|
-
controller, so its protected helpers — Pundit's among them — are in reach.
|
|
99
|
+
### Needing the values before the form
|
|
173
100
|
|
|
174
|
-
|
|
175
|
-
|
|
101
|
+
The form assigns as it starts to render, so everything from the form down — and
|
|
102
|
+
the layout, which Rails renders after the template — sees what was typed. The
|
|
103
|
+
controller, and anything in the template above the form, sees the object as it
|
|
104
|
+
was built. When those need it too, assign in the action:
|
|
176
105
|
|
|
177
106
|
```ruby
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
# Action Policy's verify_authorized
|
|
182
|
-
TurboForm.before_render = ->(resource) { skip_verify_authorized! }
|
|
107
|
+
def new
|
|
108
|
+
@widget = turbo_form_assign(Widget.new)
|
|
109
|
+
end
|
|
183
110
|
```
|
|
184
111
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
112
|
+
`turbo_form_assign` returns the object, assigns only on a reload, and takes
|
|
113
|
+
`scope:` when the form's scope isn't the object's param key. The form assigns
|
|
114
|
+
the same values again when it renders, which changes nothing.
|
|
188
115
|
|
|
189
|
-
|
|
190
|
-
# Pundit
|
|
191
|
-
TurboForm.before_render = ->(resource) { authorize resource, :edit? }
|
|
116
|
+
### Switching an STI subclass
|
|
192
117
|
|
|
193
|
-
|
|
194
|
-
|
|
118
|
+
When the submitted values include the inheritance column, the form switches its
|
|
119
|
+
object to the subclass it names, with `becomes`, before assigning — so a `type`
|
|
120
|
+
select can turn a `Gizmo` into a `Doohickey` and the rest of the form answers
|
|
121
|
+
as one. Give the form its base class's scope (`scope: :gadget`) so the switched
|
|
122
|
+
object reads and posts under the same name.
|
|
195
123
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
124
|
+
The switched object shares its attributes with the original, so everything
|
|
125
|
+
outside the form sees the same values. To have it be the new subclass too, keep
|
|
126
|
+
what `turbo_form_assign` returns — it switches the same way.
|
|
127
|
+
|
|
128
|
+
### After a failed save
|
|
199
129
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
130
|
+
Rendering `:new` or `:edit` with `422` after a failed save leaves the browser on
|
|
131
|
+
the form's own URL, since Turbo renders it without touching history — so a
|
|
132
|
+
trigger reloads the right page. The reload builds a fresh object that has not
|
|
133
|
+
been validated, so the error messages go away.
|
|
203
134
|
|
|
204
135
|
## JavaScript
|
|
205
136
|
|
|
@@ -238,7 +169,7 @@ To do it by hand, write that file yourself and run `rails stimulus:manifest:upda
|
|
|
238
169
|
|
|
239
170
|
### Testing against it
|
|
240
171
|
|
|
241
|
-
A
|
|
172
|
+
A reload is a round trip, so the line after a trigger is racing it. Wrap the
|
|
242
173
|
trigger and the wait comes with it:
|
|
243
174
|
|
|
244
175
|
```ruby
|
|
@@ -247,16 +178,23 @@ select "Barrel Aged", from: "Flavor"
|
|
|
247
178
|
```
|
|
248
179
|
|
|
249
180
|
`expect_dynamic_form_request` is available in system tests with nothing to
|
|
250
|
-
require or include — Minitest and RSpec both. It waits on
|
|
251
|
-
|
|
252
|
-
doesn't care what the
|
|
253
|
-
a page carries more than one dynamic form.
|
|
181
|
+
require or include — Minitest and RSpec both. It waits on a count of
|
|
182
|
+
completed reloads kept on `<html>` rather than on the fields that came back, so
|
|
183
|
+
it doesn't care what the reload changed.
|
|
254
184
|
|
|
255
185
|
## What this deliberately doesn't do
|
|
256
186
|
|
|
257
|
-
It handles the common case well and gets out of the way otherwise.
|
|
258
|
-
|
|
259
|
-
|
|
187
|
+
It handles the common case well and gets out of the way otherwise.
|
|
188
|
+
|
|
189
|
+
- **No debouncing, request cancellation or loading state.** Write the action by
|
|
190
|
+
hand when you need those — that path is still open.
|
|
191
|
+
- **The form rides in the URL.** Very large forms can hit URL length limits,
|
|
192
|
+
file inputs are left out, and what was typed lands in browser history and any
|
|
193
|
+
access log outside Rails' own parameter filtering.
|
|
194
|
+
- **Only the primary database is rolled back**, and only writes: jobs enqueued
|
|
195
|
+
or mail sent during a reload still happen. A streamed response renders after
|
|
196
|
+
the transaction has closed.
|
|
197
|
+
- **A form with Turbo turned off** reloads whatever URL a failed save left it on.
|
|
260
198
|
|
|
261
199
|
## License
|
|
262
200
|
|
|
@@ -1,44 +1,45 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import { Turbo } from "@hotwired/turbo-rails"
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// Never worth putting in a URL: the token is a secret, and the page's own
|
|
5
|
+
// verb is GET whatever the form says.
|
|
6
|
+
const UNSENT = new Set(["authenticity_token", "_method"])
|
|
7
|
+
|
|
8
|
+
// Reloads the page with the form's current state in the query string. The
|
|
9
|
+
// page's own action renders it again, and the form assigns that state to its
|
|
10
|
+
// object on the way, so the whole page answers as the user now has it. The
|
|
11
|
+
// header is what tells the server this is that reload and not an ordinary
|
|
12
|
+
// visit to the same URL.
|
|
5
13
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// change on screen.
|
|
14
|
+
// A morphing refresh keeps focus and scroll, so it reads as the form updating
|
|
15
|
+
// in place rather than as a navigation.
|
|
9
16
|
export default class extends Controller {
|
|
10
|
-
|
|
17
|
+
perform() {
|
|
18
|
+
const url = new URL(location.href)
|
|
19
|
+
const data = new FormData(this.element)
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const body = new FormData(this.element)
|
|
21
|
+
for (const name of new Set(data.keys())) url.searchParams.delete(name)
|
|
22
|
+
for (const [name, value] of data) {
|
|
23
|
+
if (typeof value === "string" && !UNSENT.has(name)) url.searchParams.append(name, value)
|
|
24
|
+
}
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
const markReload = ({ detail: { url: requested, fetchOptions } }) => {
|
|
27
|
+
if (requested.href !== url.href) return
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
body
|
|
24
|
-
})
|
|
29
|
+
fetchOptions.headers["X-Turbo-Form"] = "reload"
|
|
30
|
+
document.removeEventListener("turbo:before-fetch-request", markReload)
|
|
31
|
+
}
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
Turbo.
|
|
29
|
-
|
|
30
|
-
// Lets a system test wait on a completed round trip instead of sleeping.
|
|
31
|
-
this.requestsValue++
|
|
33
|
+
document.addEventListener("turbo:before-fetch-request", markReload)
|
|
34
|
+
document.addEventListener("turbo:load", countVisit, { once: true })
|
|
35
|
+
Turbo.visit(url, { action: "replace", shouldCacheSnapshot: false, refresh: { method: "morph", scroll: "preserve" } })
|
|
32
36
|
}
|
|
37
|
+
}
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (token) headers["X-CSRF-Token"] = token
|
|
41
|
-
|
|
42
|
-
return headers
|
|
43
|
-
}
|
|
39
|
+
// Kept on <html> because a morph rewrites <body> to what the server sent, and
|
|
40
|
+
// the server never knows the count. Lets a system test wait on a completed
|
|
41
|
+
// reload instead of sleeping.
|
|
42
|
+
function countVisit() {
|
|
43
|
+
const root = document.documentElement.dataset
|
|
44
|
+
root.turboFormVisits = Number(root.turboFormVisits || 0) + 1
|
|
44
45
|
}
|
data/lib/turbo_form/engine.rb
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
require "turbo-rails"
|
|
2
2
|
require "turbo_form/form_helper"
|
|
3
3
|
require "turbo_form/form_builder"
|
|
4
|
-
require "turbo_form/
|
|
4
|
+
require "turbo_form/reload"
|
|
5
5
|
|
|
6
6
|
module TurboForm
|
|
7
|
-
# Deliberately *not* `isolate_namespace`: the template this engine renders
|
|
8
|
-
# belongs to the host, and isolating would point its route helpers at this
|
|
9
|
-
# engine's own (empty) route set -- so `widgets_path` in a host's
|
|
10
|
-
# dynamic_form template would raise.
|
|
11
7
|
class Engine < ::Rails::Engine
|
|
12
8
|
# importmap-rails reads config.importmap.paths exactly once, in its own
|
|
13
9
|
# `importmap` initializer, and draws the host's pins last -- so appending
|
|
@@ -53,6 +49,11 @@ module TurboForm
|
|
|
53
49
|
end
|
|
54
50
|
end
|
|
55
51
|
|
|
52
|
+
initializer "turbo_form.reload" do |app|
|
|
53
|
+
app.middleware.use TurboForm::Reload::Middleware
|
|
54
|
+
ActiveSupport.on_load(:action_controller_base) { include TurboForm::Reload::Controller }
|
|
55
|
+
end
|
|
56
|
+
|
|
56
57
|
# Deliberately eager rather than `ActiveSupport.on_load(:action_view)`: that
|
|
57
58
|
# hook doesn't fire until Action View is first loaded, which in an app that
|
|
58
59
|
# isn't eager loading is partway through rendering the first view -- late
|
|
@@ -9,6 +9,15 @@ module TurboForm
|
|
|
9
9
|
# HTML attributes; the select and date families keep those in a trailing
|
|
10
10
|
# `html_options` instead, which is where these overrides earn their keep.
|
|
11
11
|
module FormBuilder
|
|
12
|
+
# A dynamic form's trigger reloads the page with the form's current state in
|
|
13
|
+
# the query string. Assigning it here, before the form's block runs, lets
|
|
14
|
+
# every field -- and every question the form asks of its object -- answer
|
|
15
|
+
# as the user now has it.
|
|
16
|
+
def initialize(object_name, object, template, options)
|
|
17
|
+
super
|
|
18
|
+
@object = TurboForm::Reload.assign(object, @template.params[object_name]) if options[:dynamic] && TurboForm::Reload.requested?(@template.try(:request))
|
|
19
|
+
end
|
|
20
|
+
|
|
12
21
|
def select(method, choices = nil, options = {}, html_options = {}, &block)
|
|
13
22
|
super(method, choices, *hoist_trigger(options, html_options), &block)
|
|
14
23
|
end
|
|
@@ -75,28 +84,14 @@ module TurboForm
|
|
|
75
84
|
attributes = attributes.except(:dynamic_trigger)
|
|
76
85
|
return attributes unless trigger
|
|
77
86
|
|
|
78
|
-
event, url, query = destructure_trigger(trigger)
|
|
79
|
-
|
|
80
87
|
data = (attributes[:data] || {}).dup
|
|
81
|
-
data[:action] = [ data[:action], stimulus_action_for(
|
|
82
|
-
data[:turbo_form_url_param] = url if url
|
|
83
|
-
data[:turbo_form_query_param] = query if query
|
|
88
|
+
data[:action] = [ data[:action], stimulus_action_for(trigger) ].compact.join(" ")
|
|
84
89
|
attributes.merge(data: data)
|
|
85
90
|
end
|
|
86
91
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
def destructure_trigger(trigger)
|
|
91
|
-
return trigger.values_at(:event, :url, :params) if trigger.is_a?(Hash)
|
|
92
|
-
|
|
93
|
-
[ trigger, nil, nil ]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# `true` -- or a hash that names no event -- leaves the event off the
|
|
97
|
-
# descriptor so Stimulus binds the element's own default: `change` for a
|
|
98
|
-
# select, `input` for a text field, `click` for a button. Anything else is
|
|
99
|
-
# taken as the event name.
|
|
92
|
+
# `true` leaves the event off the descriptor so Stimulus binds the
|
|
93
|
+
# element's own default: `change` for a select, `input` for a text field,
|
|
94
|
+
# `click` for a button. Anything else is taken as the event name.
|
|
100
95
|
def stimulus_action_for(event)
|
|
101
96
|
event.nil? || event == true ? "turbo-form#perform" : "#{event}->turbo-form#perform"
|
|
102
97
|
end
|
|
@@ -2,49 +2,24 @@ module TurboForm
|
|
|
2
2
|
# Prepended onto ActionView::Helpers::FormHelper.
|
|
3
3
|
#
|
|
4
4
|
# form_for @widget, dynamic: true
|
|
5
|
-
# form_for @widget, dynamic: "shared/refresh" # render this template instead
|
|
6
5
|
module FormHelper
|
|
7
|
-
# `form_for` ends in `form_with`, so overriding one covers both.
|
|
6
|
+
# `form_for` ends in `form_with`, so overriding one covers both. `dynamic:`
|
|
7
|
+
# stays in the options so the builder sees it; Rails slices the form tag's
|
|
8
|
+
# attributes out by name, so it never reaches the HTML.
|
|
8
9
|
def form_with(model: false, scope: nil, url: nil, format: nil, **options, &block)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
object = _object_for_form_builder(model)
|
|
13
|
-
raise ArgumentError, "form_for/form_with needs a :model to build a dynamic form from" unless object
|
|
14
|
-
|
|
15
|
-
scope ||= model_name_from_record_or_class(object).param_key
|
|
16
|
-
signature = TurboForm::Signature.new(
|
|
17
|
-
model_name: (object.class.try(:base_class) || object.class).name,
|
|
18
|
-
scope: scope.to_s,
|
|
19
|
-
template: (dynamic unless dynamic == true),
|
|
20
|
-
prefixes: lookup_context.prefixes,
|
|
21
|
-
**origin_of(object)
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
wire_dynamic_form(options, signature)
|
|
25
|
-
super(model:, scope:, url:, format:, **options, &block)
|
|
10
|
+
wire_dynamic_form(options) if options[:dynamic]
|
|
11
|
+
super
|
|
26
12
|
end
|
|
27
13
|
|
|
28
14
|
private
|
|
29
|
-
# A saved record is found again by id. An unsaved one is rebuilt from what
|
|
30
|
-
# it had already been given -- a parent's foreign key, say -- since the
|
|
31
|
-
# form may not submit that back. A plain form object with no dirty
|
|
32
|
-
# tracking has nothing to carry.
|
|
33
|
-
def origin_of(object)
|
|
34
|
-
return { id: object.id } if object.try(:persisted?)
|
|
35
|
-
|
|
36
|
-
{ seed: object.try(:changes).to_h.transform_values(&:last) }
|
|
37
|
-
end
|
|
38
|
-
|
|
39
15
|
# `form_for` funnels HTML attributes through options[:html] while `form_with`
|
|
40
16
|
# takes them at the top level. Write wherever the caller already is, and sit
|
|
41
17
|
# alongside any Stimulus controller they asked for rather than replacing it.
|
|
42
|
-
def wire_dynamic_form(options
|
|
18
|
+
def wire_dynamic_form(options)
|
|
43
19
|
attributes = options.key?(:html) ? (options[:html] ||= {}) : options
|
|
44
20
|
data = attributes[:data] ||= {}
|
|
45
21
|
|
|
46
22
|
data[:controller] = [ data[:controller], "turbo-form" ].compact.join(" ")
|
|
47
|
-
data[:turbo_form_url_value] = turbo_form_path(signature)
|
|
48
23
|
end
|
|
49
24
|
end
|
|
50
25
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module TurboForm
|
|
2
|
+
# A dynamic trigger reloads its page with this header set. Only a request
|
|
3
|
+
# carrying it has its form's state assigned, and only such a request has its
|
|
4
|
+
# writes discarded -- one predicate for both, so the one can't happen without
|
|
5
|
+
# the other. A plain link can't set a header, and a cross-origin page can't
|
|
6
|
+
# without a CORS preflight, so a crafted URL assigns nothing.
|
|
7
|
+
module Reload
|
|
8
|
+
HEADER = "X-Turbo-Form"
|
|
9
|
+
|
|
10
|
+
def self.requested?(request) = request&.headers&.key?(HEADER)
|
|
11
|
+
|
|
12
|
+
# Answers with the object the form should now render, which is a different
|
|
13
|
+
# one when the submitted `type` picks another STI subclass.
|
|
14
|
+
def self.assign(object, submitted)
|
|
15
|
+
return object unless submitted.is_a?(ActionController::Parameters)
|
|
16
|
+
|
|
17
|
+
retype(object, submitted).tap { |record| record.assign_attributes(submitted.permit!) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Let `new` pick the subclass by Active Record's own STI rules, and switch
|
|
21
|
+
# before assigning so nested records land on the object that is kept.
|
|
22
|
+
# `becomes` shares its attributes with the original, so whoever still holds
|
|
23
|
+
# that one sees the same values, if not the subclass's behaviour.
|
|
24
|
+
def self.retype(object, submitted)
|
|
25
|
+
column = object.class.try(:inheritance_column)
|
|
26
|
+
return object unless column && submitted.key?(column)
|
|
27
|
+
|
|
28
|
+
subclass = object.class.base_class.new(column => submitted[column]).class
|
|
29
|
+
object.instance_of?(subclass) ? object : object.becomes(subclass)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Included into ActionController::Base, for an action that needs the form's
|
|
33
|
+
# state before the form itself is rendered:
|
|
34
|
+
#
|
|
35
|
+
# @widget = turbo_form_assign(Widget.new)
|
|
36
|
+
#
|
|
37
|
+
# The form assigns the same state again when it renders, which changes
|
|
38
|
+
# nothing. Keep what it returns: that is the switched object when the form
|
|
39
|
+
# picked another STI subclass.
|
|
40
|
+
module Controller
|
|
41
|
+
private
|
|
42
|
+
def turbo_form_assign(object, scope: object.model_name.param_key)
|
|
43
|
+
return object unless TurboForm::Reload.requested?(request)
|
|
44
|
+
|
|
45
|
+
TurboForm::Reload.assign(object, params[scope])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Assigning to a saved record is not always inert -- Active Record saves
|
|
50
|
+
# `has_many` writers and `*_ids=` on the spot -- and a reload exists only to
|
|
51
|
+
# render, so nothing it does is kept.
|
|
52
|
+
class Middleware
|
|
53
|
+
def initialize(app) = @app = app
|
|
54
|
+
|
|
55
|
+
def call(env)
|
|
56
|
+
return @app.call(env) unless defined?(ActiveRecord::Base) && Reload.requested?(ActionDispatch::Request.new(env))
|
|
57
|
+
|
|
58
|
+
response = nil
|
|
59
|
+
ActiveRecord::Base.transaction do
|
|
60
|
+
# ponytail: a streamed body renders after this returns, outside the transaction
|
|
61
|
+
response = @app.call(env)
|
|
62
|
+
raise ActiveRecord::Rollback
|
|
63
|
+
end
|
|
64
|
+
response
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
module TurboForm::SystemTestHelper
|
|
2
|
-
# Waits for a dynamic form to finish
|
|
3
|
-
#
|
|
4
|
-
# A trigger sends the form to the server and swaps part of it out when the
|
|
5
|
-
# response lands, so anything a test does on the next line is racing that
|
|
6
|
-
# response -- and loses often enough to be flaky rather than broken. The
|
|
7
|
-
# Stimulus controller counts completed round trips; waiting on that count is
|
|
8
|
-
# both cheaper and steadier than waiting on whichever field came back.
|
|
2
|
+
# Waits for a dynamic form to finish reloading the page.
|
|
9
3
|
#
|
|
10
4
|
# expect_dynamic_form_request { select "Bourbon", from: "Category" }
|
|
11
5
|
# select "Barrel Aged", from: "Flavor"
|
|
12
|
-
#
|
|
13
|
-
# Scope it with Capybara's own `within` when a page carries more than one
|
|
14
|
-
# dynamic form.
|
|
15
6
|
def expect_dynamic_form_request
|
|
16
|
-
|
|
17
|
-
completed = form["data-turbo-form-requests-value"].to_i + 1
|
|
7
|
+
completed = page.find("html")["data-turbo-form-visits"].to_i + 1
|
|
18
8
|
|
|
19
9
|
yield
|
|
20
10
|
|
|
21
|
-
page.assert_selector "[data-turbo-form-
|
|
11
|
+
page.assert_selector "html[data-turbo-form-visits='#{completed}']"
|
|
22
12
|
end
|
|
23
13
|
end
|
data/lib/turbo_form/version.rb
CHANGED
data/lib/turbo_form.rb
CHANGED
|
@@ -1,39 +1,2 @@
|
|
|
1
|
-
require "active_support/core_ext/module/attribute_accessors"
|
|
2
1
|
require "turbo_form/version"
|
|
3
2
|
require "turbo_form/engine"
|
|
4
|
-
|
|
5
|
-
module TurboForm
|
|
6
|
-
# The controller the engine's endpoint inherits from. Defaulting to the host's
|
|
7
|
-
# own `ApplicationController` means its `before_action`s -- authentication
|
|
8
|
-
# above all -- apply to dynamic renders for free. Point it elsewhere when that
|
|
9
|
-
# inheritance brings something the endpoint shouldn't have.
|
|
10
|
-
mattr_accessor :parent_controller, default: "ApplicationController"
|
|
11
|
-
|
|
12
|
-
# Set to false to keep the engine out of the host's route set and draw its
|
|
13
|
-
# route yourself.
|
|
14
|
-
mattr_accessor :draw_routes, default: true
|
|
15
|
-
|
|
16
|
-
# Run as the body of a `before_action`, inside the endpoint, and handed the
|
|
17
|
-
# resource the form was rebuilt into. The endpoint is an inherited controller
|
|
18
|
-
# the host never wrote, so this is where it gets to treat it like one of its
|
|
19
|
-
# own: authorize the render, skip a filter the endpoint can't satisfy
|
|
20
|
-
# (`skip_authorization`), set something the template needs. Left alone, only
|
|
21
|
-
# the host's own inherited filters apply.
|
|
22
|
-
mattr_accessor :before_render
|
|
23
|
-
|
|
24
|
-
class << self
|
|
25
|
-
attr_writer :verifier
|
|
26
|
-
|
|
27
|
-
# Signs the description of a form so the endpoint can trust the class it is
|
|
28
|
-
# about to instantiate and the template it is about to render. Derived from
|
|
29
|
-
# the application's own key generator, the way SignedGlobalID is. `url_safe`
|
|
30
|
-
# so the token can live in a path segment; JSON so nothing Marshalled ever
|
|
31
|
-
# crosses the wire.
|
|
32
|
-
def verifier
|
|
33
|
-
@verifier ||= ActiveSupport::MessageVerifier.new(
|
|
34
|
-
Rails.application.key_generator.generate_key("turbo_form"),
|
|
35
|
-
url_safe: true, serializer: JSON
|
|
36
|
-
)
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: turbo_form
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Cohen
|
|
@@ -76,8 +76,6 @@ files:
|
|
|
76
76
|
- README.md
|
|
77
77
|
- Rakefile
|
|
78
78
|
- app/assets/javascripts/turbo_form.js
|
|
79
|
-
- app/controllers/turbo_form/dynamic_forms_controller.rb
|
|
80
|
-
- config/routes.rb
|
|
81
79
|
- config/turbo_form_importmap.rb
|
|
82
80
|
- lib/generators/turbo_form/install/install_generator.rb
|
|
83
81
|
- lib/generators/turbo_form/install/templates/turbo_form_controller.js
|
|
@@ -86,7 +84,7 @@ files:
|
|
|
86
84
|
- lib/turbo_form/engine.rb
|
|
87
85
|
- lib/turbo_form/form_builder.rb
|
|
88
86
|
- lib/turbo_form/form_helper.rb
|
|
89
|
-
- lib/turbo_form/
|
|
87
|
+
- lib/turbo_form/reload.rb
|
|
90
88
|
- lib/turbo_form/system_test_helper.rb
|
|
91
89
|
- lib/turbo_form/version.rb
|
|
92
90
|
homepage: https://github.com/outlawandy/turbo_form
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
module TurboForm
|
|
2
|
-
# Renders a form again from the state the browser currently has it in.
|
|
3
|
-
#
|
|
4
|
-
# Nothing here is saved. The form's object is rebuilt only so the template can
|
|
5
|
-
# ask it what the form should now look like, which is why the parameters are
|
|
6
|
-
# taken whole: narrowing them would only make the re-rendered form disagree
|
|
7
|
-
# with what the user actually typed.
|
|
8
|
-
class DynamicFormsController < TurboForm.parent_controller.constantize
|
|
9
|
-
rescue_from TurboForm::Signature::Invalid do
|
|
10
|
-
head :bad_request
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
around_action :discard_writes
|
|
14
|
-
before_action :build_resource
|
|
15
|
-
before_action { instance_exec(@resource, &TurboForm.before_render) if TurboForm.before_render }
|
|
16
|
-
|
|
17
|
-
def update
|
|
18
|
-
render signature.template || "dynamic_form", formats: :turbo_stream
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
private
|
|
22
|
-
def build_resource
|
|
23
|
-
@resource = signature.rebuild(form_params)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Assigning to a saved record is not always inert: Active Record saves
|
|
27
|
-
# `has_many` writers and `*_ids=` on the spot.
|
|
28
|
-
def discard_writes
|
|
29
|
-
return yield unless defined?(ActiveRecord::Base) && signature.model < ActiveRecord::Base
|
|
30
|
-
|
|
31
|
-
signature.model.transaction do
|
|
32
|
-
yield
|
|
33
|
-
raise ActiveRecord::Rollback
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Renders as if it were the controller that rendered the form, so the
|
|
38
|
-
# template's relative partials resolve the way the form's own did.
|
|
39
|
-
#
|
|
40
|
-
# Read while the lookup context is built, before `rescue_from` is in play,
|
|
41
|
-
# so a bad signature falls through here and is answered by the action.
|
|
42
|
-
def _prefixes
|
|
43
|
-
signature.prefixes
|
|
44
|
-
rescue TurboForm::Signature::Invalid
|
|
45
|
-
super
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def signature
|
|
49
|
-
@signature ||= TurboForm::Signature.verify(params[:signature])
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def form_params
|
|
53
|
-
params.fetch(signature.scope, {}).permit!
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
data/config/routes.rb
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Drawn straight into the host's route set so that installing the gem is the
|
|
2
|
-
# whole installation -- there is nothing to mount. Set `TurboForm.draw_routes`
|
|
3
|
-
# to false to take that over.
|
|
4
|
-
#
|
|
5
|
-
# PATCH rather than POST for two reasons: whole forms outgrow a query string,
|
|
6
|
-
# and an edit form's `_method=patch` hidden field would make Rack rewrite a POST
|
|
7
|
-
# out from under us.
|
|
8
|
-
Rails.application.routes.draw do
|
|
9
|
-
patch "/turbo_form/:signature" => "turbo_form/dynamic_forms#update", as: :turbo_form
|
|
10
|
-
end if TurboForm.draw_routes
|
data/lib/turbo_form/signature.rb
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
module TurboForm
|
|
2
|
-
# A tamper-proof description of a dynamic form, carried in the URL the browser
|
|
3
|
-
# posts back to.
|
|
4
|
-
#
|
|
5
|
-
# It is signed because the endpoint acts on every word of it: `model_name` gets
|
|
6
|
-
# constantized and instantiated, `template` gets rendered. Neither may come
|
|
7
|
-
# from the client unverified.
|
|
8
|
-
#
|
|
9
|
-
# `prefixes` are the view paths the form was rendered under, so the endpoint
|
|
10
|
-
# can find its template and partials the way the form's own controller would.
|
|
11
|
-
#
|
|
12
|
-
# It also says where the form's object came from, so the endpoint can rebuild
|
|
13
|
-
# that object rather than a blank one: the `id` of a saved record, or the
|
|
14
|
-
# `seed` of attributes an unsaved one already had -- the `tank_id` of
|
|
15
|
-
# `@tank.rings.new` -- which the form itself may never submit.
|
|
16
|
-
class Signature
|
|
17
|
-
class Invalid < StandardError; end
|
|
18
|
-
|
|
19
|
-
def self.verify(token)
|
|
20
|
-
payload = TurboForm.verifier.verified(token.to_s)
|
|
21
|
-
raise Invalid, "not a signature this application generated" unless payload
|
|
22
|
-
|
|
23
|
-
new(**payload.symbolize_keys)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
attr_reader :model_name, :scope, :template, :prefixes, :id, :seed
|
|
27
|
-
|
|
28
|
-
def initialize(model_name:, scope:, template: nil, prefixes: [], id: nil, seed: {})
|
|
29
|
-
@model_name = model_name
|
|
30
|
-
@scope = scope
|
|
31
|
-
@template = template
|
|
32
|
-
@prefixes = prefixes
|
|
33
|
-
@id = id
|
|
34
|
-
@seed = seed
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def model = model_name.constantize
|
|
38
|
-
|
|
39
|
-
# The form's object as the user now has it. A new one is built in a single
|
|
40
|
-
# step so the submitted `type` picks its STI subclass.
|
|
41
|
-
def rebuild(params)
|
|
42
|
-
return model.new(seed.merge(params.to_h)) unless id
|
|
43
|
-
|
|
44
|
-
retype(model.find(id), params).tap { |resource| resource.assign_attributes(params) }
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def to_s
|
|
48
|
-
TurboForm.verifier.generate({ model_name:, scope:, template:, prefixes:, id:, seed: })
|
|
49
|
-
end
|
|
50
|
-
alias to_param to_s
|
|
51
|
-
|
|
52
|
-
private
|
|
53
|
-
# `find` answers with the subclass that was saved, not the one just picked.
|
|
54
|
-
# Let `new` choose by Active Record's own STI rules, and switch before
|
|
55
|
-
# assigning so nested records land on the object that is kept.
|
|
56
|
-
def retype(record, params)
|
|
57
|
-
type = params.to_h.slice(model.try(:inheritance_column).to_s)
|
|
58
|
-
return record if type.empty?
|
|
59
|
-
|
|
60
|
-
subclass = model.new(type).class
|
|
61
|
-
record.instance_of?(subclass) ? record : record.becomes(subclass)
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|