turbo_form 0.3.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 -125
- 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 -19
- 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 -33
- data/config/routes.rb +0 -10
- data/lib/turbo_form/signature.rb +0 -43
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,19 +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`
|
|
55
|
-
`
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Pass a string to render something else instead:
|
|
59
|
-
|
|
60
|
-
```erb
|
|
61
|
-
<%= form_for @widget, dynamic: "shared/refresh_widget" %>
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Works on `form_for` and `form_with` alike. Any template engine works — the
|
|
65
|
-
template is resolved the way every other Rails template is, so `.slim` and
|
|
66
|
-
`.haml` are fine.
|
|
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.
|
|
67
49
|
|
|
68
50
|
### `dynamic_trigger:` on a field
|
|
69
51
|
|
|
@@ -77,30 +59,6 @@ checkbox, `input` for a text field, `click` for a button. Name an event when you
|
|
|
77
59
|
want something else — `:blur` on text fields is usually what you want, since the
|
|
78
60
|
default fires on every keystroke.
|
|
79
61
|
|
|
80
|
-
#### Sending the form somewhere else
|
|
81
|
-
|
|
82
|
-
A trigger can answer to a different endpoint than its own form, and can add to
|
|
83
|
-
what the form sends:
|
|
84
|
-
|
|
85
|
-
```erb
|
|
86
|
-
<%= f.button "Run", type: "button", dynamic_trigger: {
|
|
87
|
-
event: :click,
|
|
88
|
-
url: formula_preview_widgets_path,
|
|
89
|
-
params: { attribute: :rafter_count }
|
|
90
|
-
} %>
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
`url:` replaces the form's own endpoint for that one trigger; everything else on
|
|
94
|
-
the form keeps using the form's. `params:` are appended to the submitted form
|
|
95
|
-
data, so one endpoint can tell which of several triggers asked. Both are
|
|
96
|
-
optional, and either can be given without the other.
|
|
97
|
-
|
|
98
|
-
That endpoint is yours, not the gem's — it's an ordinary action rendering an
|
|
99
|
-
ordinary turbo_stream template, so the whole form arrives as `params` under its
|
|
100
|
-
usual scope. Use it when one form feeds several actions: a dozen fields that
|
|
101
|
-
each preview themselves, or a select that belongs to a nested form with its own
|
|
102
|
-
controller.
|
|
103
|
-
|
|
104
62
|
Works on every Rails field helper, including the select and date families where
|
|
105
63
|
Rails keeps HTML attributes in a separate hash:
|
|
106
64
|
|
|
@@ -118,78 +76,61 @@ inherits from, so it works without SimpleForm being involved at all:
|
|
|
118
76
|
= f.input :category, input_html: { dynamic_trigger: true }
|
|
119
77
|
```
|
|
120
78
|
|
|
121
|
-
## What
|
|
122
|
-
|
|
123
|
-
A `dynamic: true` form carries a signed description of itself to a single
|
|
124
|
-
endpoint the gem draws into your routes. Given a valid signature it:
|
|
125
|
-
|
|
126
|
-
1. permits the submitted parameters wholesale,
|
|
127
|
-
2. builds a **new, unsaved** instance of the form's class from them,
|
|
128
|
-
3. assigns it to `@resource`,
|
|
129
|
-
4. calls `TurboForm.before_render` with the controller and the resource, if one is set,
|
|
130
|
-
5. renders the turbo_stream template.
|
|
131
|
-
|
|
132
|
-
Nothing is persisted, and the resource is always freshly instantiated — even for
|
|
133
|
-
an edit form. `@resource` exists to be asked what the form should now look like,
|
|
134
|
-
not to be saved.
|
|
135
|
-
|
|
136
|
-
The signature covers the class name, the parameter scope and the template. It is
|
|
137
|
-
signed because the endpoint constantizes and renders what it names; none of that
|
|
138
|
-
may come from the browser unverified.
|
|
139
|
-
|
|
140
|
-
## Configuration
|
|
79
|
+
## What a reload does
|
|
141
80
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
# The endpoint inherits from this, so your authentication applies to it. Point
|
|
146
|
-
# it at a narrower controller when that inheritance brings something the
|
|
147
|
-
# endpoint shouldn't have.
|
|
148
|
-
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:
|
|
149
84
|
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
152
90
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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.
|
|
156
94
|
|
|
157
|
-
|
|
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.
|
|
158
98
|
|
|
159
|
-
|
|
160
|
-
controllers that save things. `before_render` is where you get to treat it like
|
|
161
|
-
one of your own: it runs in a `before_action`, after the signature is verified
|
|
162
|
-
and the resource is built, and is handed the controller and that resource.
|
|
99
|
+
### Needing the values before the form
|
|
163
100
|
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
166
105
|
|
|
167
106
|
```ruby
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
# Action Policy's verify_authorized
|
|
172
|
-
TurboForm.before_render = ->(controller, resource) { controller.skip_verify_authorized! }
|
|
107
|
+
def new
|
|
108
|
+
@widget = turbo_form_assign(Widget.new)
|
|
109
|
+
end
|
|
173
110
|
```
|
|
174
111
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
178
115
|
|
|
179
|
-
|
|
180
|
-
# Pundit
|
|
181
|
-
TurboForm.before_render = ->(controller, resource) { controller.authorize(resource, :edit?) }
|
|
116
|
+
### Switching an STI subclass
|
|
182
117
|
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
185
123
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
189
129
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
193
134
|
|
|
194
135
|
## JavaScript
|
|
195
136
|
|
|
@@ -228,7 +169,7 @@ To do it by hand, write that file yourself and run `rails stimulus:manifest:upda
|
|
|
228
169
|
|
|
229
170
|
### Testing against it
|
|
230
171
|
|
|
231
|
-
A
|
|
172
|
+
A reload is a round trip, so the line after a trigger is racing it. Wrap the
|
|
232
173
|
trigger and the wait comes with it:
|
|
233
174
|
|
|
234
175
|
```ruby
|
|
@@ -237,18 +178,23 @@ select "Barrel Aged", from: "Flavor"
|
|
|
237
178
|
```
|
|
238
179
|
|
|
239
180
|
`expect_dynamic_form_request` is available in system tests with nothing to
|
|
240
|
-
require or include — Minitest and RSpec both. It waits on
|
|
241
|
-
|
|
242
|
-
doesn't care what the
|
|
243
|
-
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.
|
|
244
184
|
|
|
245
185
|
## What this deliberately doesn't do
|
|
246
186
|
|
|
247
|
-
It handles the common case well and gets out of the way otherwise.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
252
198
|
|
|
253
199
|
## License
|
|
254
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,37 +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.name,
|
|
18
|
-
scope: scope.to_s,
|
|
19
|
-
template: (dynamic unless dynamic == true)
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
wire_dynamic_form(options, signature)
|
|
23
|
-
super(model:, scope:, url:, format:, **options, &block)
|
|
10
|
+
wire_dynamic_form(options) if options[:dynamic]
|
|
11
|
+
super
|
|
24
12
|
end
|
|
25
13
|
|
|
26
14
|
private
|
|
27
15
|
# `form_for` funnels HTML attributes through options[:html] while `form_with`
|
|
28
16
|
# takes them at the top level. Write wherever the caller already is, and sit
|
|
29
17
|
# alongside any Stimulus controller they asked for rather than replacing it.
|
|
30
|
-
def wire_dynamic_form(options
|
|
18
|
+
def wire_dynamic_form(options)
|
|
31
19
|
attributes = options.key?(:html) ? (options[:html] ||= {}) : options
|
|
32
20
|
data = attributes[:data] ||= {}
|
|
33
21
|
|
|
34
22
|
data[:controller] = [ data[:controller], "turbo-form" ].compact.join(" ")
|
|
35
|
-
data[:turbo_form_url_value] = turbo_form_path(signature)
|
|
36
23
|
end
|
|
37
24
|
end
|
|
38
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
|
-
# Called from a `before_action` with the controller and the resource the form
|
|
17
|
-
# was rebuilt into. The endpoint is an inherited controller the host never
|
|
18
|
-
# wrote, so this is where it gets to treat it like one of its own: authorize
|
|
19
|
-
# the render, skip a filter the endpoint can't satisfy
|
|
20
|
-
# (`controller.skip_authorization`), set something the template needs. Left
|
|
21
|
-
# alone, only 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,33 +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 resource is rebuilt only so the template can ask
|
|
5
|
-
# it what the form should now look like, which is why the parameters are taken
|
|
6
|
-
# whole: narrowing them would only make the re-rendered form disagree with what
|
|
7
|
-
# 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
|
-
before_action :build_resource
|
|
14
|
-
before_action { TurboForm.before_render&.call(self, @resource) }
|
|
15
|
-
|
|
16
|
-
def update
|
|
17
|
-
render template: signature.template_for(@resource), formats: :turbo_stream
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
def build_resource
|
|
22
|
-
@resource = signature.model.new(form_params)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def signature
|
|
26
|
-
@signature ||= TurboForm::Signature.verify(params[:signature])
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def form_params
|
|
30
|
-
params.fetch(signature.scope, {}).permit!
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
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,43 +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
|
-
# Note that this identifies a *class*, not a record. Dynamic forms are usually
|
|
10
|
-
# editing something unsaved, which is also why GlobalID -- which requires a
|
|
11
|
-
# persisted record -- can't express it.
|
|
12
|
-
class Signature
|
|
13
|
-
class Invalid < StandardError; end
|
|
14
|
-
|
|
15
|
-
def self.verify(token)
|
|
16
|
-
payload = TurboForm.verifier.verified(token.to_s)
|
|
17
|
-
raise Invalid, "not a signature this application generated" unless payload
|
|
18
|
-
|
|
19
|
-
new(**payload.symbolize_keys)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
attr_reader :model_name, :scope, :template
|
|
23
|
-
|
|
24
|
-
def initialize(model_name:, scope:, template: nil)
|
|
25
|
-
@model_name = model_name
|
|
26
|
-
@scope = scope
|
|
27
|
-
@template = template
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def model = model_name.constantize
|
|
31
|
-
|
|
32
|
-
# By convention the template sits alongside the resource's own partial:
|
|
33
|
-
# `widgets/_widget` gets `widgets/dynamic_form`.
|
|
34
|
-
def template_for(resource)
|
|
35
|
-
template || File.join(File.dirname(resource.to_partial_path), "dynamic_form")
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def to_s
|
|
39
|
-
TurboForm.verifier.generate({ model_name:, scope:, template: })
|
|
40
|
-
end
|
|
41
|
-
alias to_param to_s
|
|
42
|
-
end
|
|
43
|
-
end
|