turbo_form 0.4.0 → 0.6.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 +119 -161
- data/app/assets/javascripts/turbo_form.js +143 -31
- data/config/turbo_form_importmap.rb +3 -5
- data/lib/generators/turbo_form/install/install_generator.rb +42 -48
- data/lib/turbo_form/controller.rb +60 -0
- data/lib/turbo_form/engine.rb +15 -18
- data/lib/turbo_form/form_builder.rb +33 -51
- data/lib/turbo_form/form_helper.rb +15 -38
- data/lib/turbo_form/routes.rb +16 -0
- data/lib/turbo_form/system_test_helper.rb +3 -13
- data/lib/turbo_form/version.rb +1 -1
- data/lib/turbo_form.rb +2 -39
- metadata +3 -5
- data/app/controllers/turbo_form/dynamic_forms_controller.rb +0 -56
- data/config/routes.rb +0 -10
- data/lib/generators/turbo_form/install/templates/turbo_form_controller.js +0 -4
- 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: 1ea880fbc0453b32344bf09706c5bc68c095de36fdbea018f00fdf4adc7acc03
|
|
4
|
+
data.tar.gz: 49bf3d131c1a1179d8ce8b64772e23681f1c54332797bca9cd46589c4d0c90d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 300aabd3ad77727c57ddf99980ce56036e4f5fdbf6c001a31908dcef1f0f53408b4100cf21fa1b88abfd663f2adab10dd14003d90e1915136231e62576b1e2d6
|
|
7
|
+
data.tar.gz: 24ec9eabed896e9de6f14c71b70e3f6aa8bc299244fcf3858146b55bf5d78c76549e4e08acdaaa6c0f77b13b6713183fabc2c469347c3a59df7e7dd477ed6ff9
|
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,19 @@ 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 form is sent to the
|
|
21
|
-
|
|
22
|
-
`
|
|
23
|
-
|
|
24
|
-
|
|
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 form is sent to the page it is on. That page's own
|
|
18
|
+
action builds `@widget`, the form's values are assigned to it, and the page's
|
|
19
|
+
own template renders it — so `@widget.flavors` answers for the category just
|
|
20
|
+
picked. Turbo morphs the page in place, so focus, scroll and everything else
|
|
21
|
+
typed survive.
|
|
31
22
|
|
|
32
|
-
|
|
23
|
+
No new action, no template, no JavaScript to write. The page is rendered by its
|
|
24
|
+
own controller, so its authentication, authorization and partials are the ones
|
|
25
|
+
it always had.
|
|
33
26
|
|
|
34
27
|
## Installation
|
|
35
28
|
|
|
@@ -37,72 +30,61 @@ That's the whole feature. No route, no controller, no JavaScript.
|
|
|
37
30
|
gem "turbo_form"
|
|
38
31
|
```
|
|
39
32
|
|
|
40
|
-
|
|
41
|
-
stimulus-rails — the Stimulus controller registers itself and there is nothing
|
|
42
|
-
to generate either. If your app bundles with esbuild, Vite, Bun or webpack:
|
|
33
|
+
Then run the installer:
|
|
43
34
|
|
|
44
35
|
```bash
|
|
45
36
|
rails generate turbo_form:install
|
|
46
37
|
```
|
|
47
38
|
|
|
48
|
-
|
|
39
|
+
It includes `TurboForm::Controller` in your ApplicationController and declares
|
|
40
|
+
the route concern:
|
|
49
41
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
template named `dynamic_form`, looked up just as the controller that rendered
|
|
56
|
-
the form would look up one of its own views:
|
|
42
|
+
```ruby
|
|
43
|
+
class ApplicationController < ActionController::Base
|
|
44
|
+
include TurboForm::Controller
|
|
45
|
+
end
|
|
46
|
+
```
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
48
|
+
```ruby
|
|
49
|
+
concern :turbo_form, TurboForm::Routes
|
|
50
|
+
```
|
|
64
51
|
|
|
65
|
-
|
|
52
|
+
Draw the concern on each resource with a dynamic form — the installer leaves
|
|
53
|
+
which ones to you:
|
|
66
54
|
|
|
67
|
-
```
|
|
68
|
-
|
|
55
|
+
```ruby
|
|
56
|
+
resources :widgets, concerns: :turbo_form
|
|
69
57
|
```
|
|
70
58
|
|
|
71
|
-
|
|
59
|
+
It draws a PATCH beside whichever of `new` and `edit` the resource has, so
|
|
60
|
+
`resources :widgets, only: %i[new create], concerns: :turbo_form` gets only the
|
|
61
|
+
one for `new`.
|
|
72
62
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
63
|
+
It also imports the script into `app/javascript/application.js`, adding the npm
|
|
64
|
+
package first if your app bundles with esbuild, Vite, Bun or webpack; see
|
|
65
|
+
[JavaScript](#javascript) for doing that by hand.
|
|
66
|
+
|
|
67
|
+
## The two options
|
|
77
68
|
|
|
78
|
-
`
|
|
79
|
-
checkbox, `input` for a text field, `click` for a button. Name an event when you
|
|
80
|
-
want something else — `:blur` on text fields is usually what you want, since the
|
|
81
|
-
default fires on every keystroke.
|
|
69
|
+
### `dynamic:` on the form
|
|
82
70
|
|
|
83
|
-
|
|
71
|
+
`dynamic: true` makes the form dynamic: a trigger sends it to the `new` or `edit`
|
|
72
|
+
page of its model — `edit` once the record is saved — which is where
|
|
73
|
+
`TurboForm::Routes` listens.
|
|
84
74
|
|
|
85
|
-
|
|
86
|
-
what the form sends:
|
|
75
|
+
### `dynamic_trigger:` on a field
|
|
87
76
|
|
|
88
77
|
```erb
|
|
89
|
-
<%= f.
|
|
90
|
-
event
|
|
91
|
-
url: formula_preview_widgets_path,
|
|
92
|
-
params: { attribute: :rafter_count }
|
|
93
|
-
} %>
|
|
78
|
+
<%= f.text_field :name, dynamic_trigger: true %> <%# on the default event %>
|
|
79
|
+
<%= f.text_field :name, dynamic_trigger: :blur %> <%# on a named event %>
|
|
94
80
|
```
|
|
95
81
|
|
|
96
|
-
`
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
82
|
+
`true` picks the element's natural event: `change` for a select, `click` for a
|
|
83
|
+
submit button, `input` for everything else. Name an event when you want
|
|
84
|
+
something else — `:blur` on text fields is usually what you want, since the
|
|
85
|
+
default fires on every keystroke. Any event name works, including custom ones
|
|
86
|
+
dispatched by other Stimulus controllers — `dynamic_trigger: "autocomplete:selected"`
|
|
87
|
+
— as long as the event is dispatched on the field or bubbles up from inside it.
|
|
106
88
|
|
|
107
89
|
Works on every Rails field helper, including the select and date families where
|
|
108
90
|
Rails keeps HTML attributes in a separate hash:
|
|
@@ -121,124 +103,90 @@ inherits from, so it works without SimpleForm being involved at all:
|
|
|
121
103
|
= f.input :category, input_html: { dynamic_trigger: true }
|
|
122
104
|
```
|
|
123
105
|
|
|
124
|
-
## What
|
|
106
|
+
## What a trigger does
|
|
125
107
|
|
|
126
|
-
A
|
|
127
|
-
|
|
108
|
+
A trigger PATCHes the whole form to its own page — `/widgets/new` or
|
|
109
|
+
`/widgets/:id/edit` — which the route concern sends to that page's own action.
|
|
110
|
+
The request:
|
|
128
111
|
|
|
129
|
-
1.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
112
|
+
1. runs `new` or `edit` as an ordinary visit would, callbacks and all, building
|
|
113
|
+
`@widget` the way it always does — found by id, built from a parent,
|
|
114
|
+
whatever it does,
|
|
115
|
+
2. assigns `widget_params` to it,
|
|
116
|
+
3. renders the page's own template,
|
|
137
117
|
|
|
138
|
-
|
|
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.
|
|
118
|
+
all inside a database transaction that is always rolled back.
|
|
142
119
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
browser unverified. It is not encrypted, so those starting attributes are
|
|
147
|
-
readable in the page, as the form itself is.
|
|
120
|
+
Because the values are assigned in the controller, before anything renders, the
|
|
121
|
+
whole page sees what was typed — the layout, the template above the form, and
|
|
122
|
+
the form itself.
|
|
148
123
|
|
|
149
|
-
|
|
124
|
+
The rollback is there because Active Record saves some assignments on the spot
|
|
125
|
+
(`has_many` writers, `*_ids=`). A trigger exists to render, so nothing it does is
|
|
126
|
+
kept — including anything else the action or its callbacks write.
|
|
150
127
|
|
|
151
|
-
|
|
152
|
-
# config/initializers/turbo_form.rb
|
|
128
|
+
### The conventions it leans on
|
|
153
129
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
# endpoint shouldn't have.
|
|
157
|
-
TurboForm.parent_controller = "ApplicationController"
|
|
130
|
+
turbo_form knows nothing about your resource beyond its controller's name.
|
|
131
|
+
For a `WidgetsController`:
|
|
158
132
|
|
|
159
|
-
|
|
160
|
-
|
|
133
|
+
- `new` and `edit` set `@widget`,
|
|
134
|
+
- `widget_params` permits the form's fields — the same method `create` and
|
|
135
|
+
`update` already use, so a trigger assigns nothing a save wouldn't,
|
|
136
|
+
- `new` and `edit` leave rendering to Rails. The values are assigned just before
|
|
137
|
+
Rails' implicit render, so an action that calls `render` itself renders its
|
|
138
|
+
object as it built it, and one that redirects still redirects.
|
|
161
139
|
|
|
162
|
-
|
|
163
|
-
TurboForm.before_render = ->(resource) { }
|
|
164
|
-
```
|
|
140
|
+
### Switching an STI subclass
|
|
165
141
|
|
|
166
|
-
|
|
142
|
+
When the submitted values include the inheritance column, the object is switched
|
|
143
|
+
to the subclass it names, with `becomes`, before assigning — so a `type` select
|
|
144
|
+
can turn a `Gizmo` into a `Doohickey` and the rest of the page answers as one.
|
|
145
|
+
Give the form its base class's scope (`scope: :gadget`) so the switched object
|
|
146
|
+
reads and posts under the same name, and permit `type` in the params method.
|
|
167
147
|
|
|
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.
|
|
148
|
+
### Inside a Turbo Frame
|
|
173
149
|
|
|
174
|
-
|
|
175
|
-
|
|
150
|
+
A form inside a `<turbo-frame>`, or one naming a frame with
|
|
151
|
+
`data-turbo-frame`, asks for that frame alone: the request carries the
|
|
152
|
+
`Turbo-Frame` header, turbo-rails renders without the layout, and only the frame
|
|
153
|
+
is morphed. A frame's own `target` is followed, and `_top` means the whole page,
|
|
154
|
+
as they do for Turbo. While the request is out, the form and its frame are `aria-busy`, and the
|
|
155
|
+
frame `busy`, the way Turbo marks a submission.
|
|
176
156
|
|
|
177
|
-
|
|
178
|
-
# Pundit's after_action :verify_authorized
|
|
179
|
-
TurboForm.before_render = ->(resource) { skip_authorization }
|
|
180
|
-
|
|
181
|
-
# Action Policy's verify_authorized
|
|
182
|
-
TurboForm.before_render = ->(resource) { skip_verify_authorized! }
|
|
183
|
-
```
|
|
157
|
+
### After a failed save
|
|
184
158
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
```ruby
|
|
190
|
-
# Pundit
|
|
191
|
-
TurboForm.before_render = ->(resource) { authorize resource, :edit? }
|
|
192
|
-
|
|
193
|
-
# Action Policy
|
|
194
|
-
TurboForm.before_render = ->(resource) { authorize! resource, to: :edit? }
|
|
195
|
-
|
|
196
|
-
# CanCanCan -- this also satisfies `check_authorization`, which has no runtime skip
|
|
197
|
-
TurboForm.before_render = ->(resource) { authorize! :edit, resource }
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
Raising works the way it does in any `before_action`: your
|
|
201
|
-
`rescue_from`s catch it, since the endpoint inherits them too. Rendering or
|
|
202
|
-
redirecting halts the chain as usual.
|
|
159
|
+
A trigger sends the form to its model's `new` or `edit` page, not to whatever
|
|
160
|
+
URL the browser is on, so a trigger after rendering `:new` from a failed
|
|
161
|
+
`create` still reaches the right action. The object it builds is fresh and has
|
|
162
|
+
not been validated, so the error messages go away.
|
|
203
163
|
|
|
204
164
|
## JavaScript
|
|
205
165
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
`lazyLoadControllersFrom` in a stock `app/javascript/controllers/index.js`
|
|
209
|
-
registers as `turbo-form` on its own.
|
|
210
|
-
|
|
211
|
-
If you hand-wrote that file, register it yourself:
|
|
166
|
+
There is no Stimulus controller to register. The script listens on the document
|
|
167
|
+
for triggers, so it only has to be imported once:
|
|
212
168
|
|
|
213
169
|
```js
|
|
214
|
-
|
|
215
|
-
|
|
170
|
+
// app/javascript/application.js
|
|
171
|
+
import "turbo_form" // importmap-rails: the engine pins it
|
|
172
|
+
import "@rolemodel/turbo-form" // esbuild, Vite, Bun, webpack
|
|
216
173
|
```
|
|
217
174
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
175
|
+
Bundled apps install the npm package alongside the gem, at the same version.
|
|
176
|
+
`rails generate turbo_form:install` does that with whichever package manager
|
|
177
|
+
your lockfile names, and adds the import.
|
|
178
|
+
|
|
179
|
+
To send a form from your own code, import `refresh`:
|
|
222
180
|
|
|
223
181
|
```js
|
|
224
|
-
|
|
225
|
-
import TurboFormController from "@rolemodel/turbo-form"
|
|
182
|
+
import { refresh } from "turbo_form"
|
|
226
183
|
|
|
227
|
-
|
|
184
|
+
refresh(document.querySelector("form[data-turbo-form-url]"))
|
|
228
185
|
```
|
|
229
186
|
|
|
230
|
-
Registering by *filename* rather than by appending to
|
|
231
|
-
`app/javascript/controllers/index.js` is deliberate. That file is regenerated
|
|
232
|
-
from the contents of the directory every time `rails generate stimulus` runs, so
|
|
233
|
-
a registration appended to it lasts until the next controller you generate. A
|
|
234
|
-
file named `turbo_form_controller.js` is picked up by that same regeneration and
|
|
235
|
-
registered as `turbo-form`, every time.
|
|
236
|
-
|
|
237
|
-
To do it by hand, write that file yourself and run `rails stimulus:manifest:update`.
|
|
238
|
-
|
|
239
187
|
### Testing against it
|
|
240
188
|
|
|
241
|
-
A
|
|
189
|
+
A trigger is a round trip, so the line after it is racing it. Wrap the
|
|
242
190
|
trigger and the wait comes with it:
|
|
243
191
|
|
|
244
192
|
```ruby
|
|
@@ -247,16 +195,26 @@ select "Barrel Aged", from: "Flavor"
|
|
|
247
195
|
```
|
|
248
196
|
|
|
249
197
|
`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.
|
|
198
|
+
require or include — Minitest and RSpec both. It waits on a count of
|
|
199
|
+
completed triggers kept on `<html>` rather than on the fields that came back, so
|
|
200
|
+
it doesn't care what the trigger changed.
|
|
254
201
|
|
|
255
202
|
## What this deliberately doesn't do
|
|
256
203
|
|
|
257
|
-
It handles the common case well and gets out of the way otherwise.
|
|
258
|
-
|
|
259
|
-
|
|
204
|
+
It handles the common case well and gets out of the way otherwise.
|
|
205
|
+
|
|
206
|
+
- **No debouncing, request cancellation or loading state** beyond `aria-busy`.
|
|
207
|
+
Write the action by hand when you need those — that path is still open.
|
|
208
|
+
- **Resourceful pages only.** The form's URL comes from its model, so a page
|
|
209
|
+
whose `new` or `edit` isn't the model's own route has nowhere to send it.
|
|
210
|
+
- **Not a Turbo form submission.** Turbo renders a form's response only when it
|
|
211
|
+
redirects or fails, so the trigger fetches the form itself and hands the
|
|
212
|
+
response to a Turbo visit. Turbo renders the page — its morph, render events,
|
|
213
|
+
error page and progress bar — but there are no `turbo:submit-start`,
|
|
214
|
+
`turbo:submit-end` or `turbo:before-fetch-request` events. A frame is morphed
|
|
215
|
+
directly, without Turbo's frame render events.
|
|
216
|
+
- **Only the primary database is rolled back**, and only writes: jobs enqueued
|
|
217
|
+
or mail sent during a trigger still happen.
|
|
260
218
|
|
|
261
219
|
## License
|
|
262
220
|
|
|
@@ -1,44 +1,156 @@
|
|
|
1
|
-
import { Controller } from "@hotwired/stimulus"
|
|
2
1
|
import { Turbo } from "@hotwired/turbo-rails"
|
|
3
2
|
|
|
4
|
-
//
|
|
3
|
+
// PATCHes the form, as it stands, to the page it is on. The server runs that
|
|
4
|
+
// page's own action, assigns the form to its object and renders its own
|
|
5
|
+
// template, which is morphed in so focus, scroll and everything typed survive.
|
|
6
|
+
// Inside a frame, only the frame is asked for and morphed, as Turbo would.
|
|
5
7
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
8
|
+
// Fetched rather than submitted through Turbo, which renders a form response
|
|
9
|
+
// only if it redirects or fails. The page is still rendered by Turbo: a visit
|
|
10
|
+
// handed the response, so its events, error page and progress bar all apply. A
|
|
11
|
+
// frame is morphed directly, since Turbo's frame loading wants its own
|
|
12
|
+
// unexported response object.
|
|
13
|
+
export function refresh(form) {
|
|
14
|
+
return new FormRefresh(form).start()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Shaped like Turbo's FormSubmission, which is what its navigator expects to be
|
|
18
|
+
// told about.
|
|
19
|
+
class FormRefresh {
|
|
20
|
+
constructor(formElement) {
|
|
21
|
+
this.formElement = formElement
|
|
22
|
+
this.location = new URL(formElement.dataset.turboFormUrl, location.href)
|
|
23
|
+
this.target = targetFor(formElement)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async start() {
|
|
27
|
+
this.requestStarted()
|
|
28
|
+
try {
|
|
29
|
+
const response = await Turbo.fetch(this.location, { method: "PATCH", body: new FormData(this.formElement), headers: this.target.headers })
|
|
30
|
+
|
|
31
|
+
this.target.render(response.status, await response.text())
|
|
32
|
+
} finally {
|
|
33
|
+
this.requestFinished()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
requestStarted() {
|
|
38
|
+
markAsBusy(this.formElement, ...this.target.elements)
|
|
39
|
+
Turbo.navigator.formSubmissionStarted(this)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
requestFinished() {
|
|
43
|
+
clearBusyState(this.formElement, ...this.target.elements)
|
|
44
|
+
Turbo.navigator.formSubmissionFinished(this)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class PageTarget {
|
|
49
|
+
elements = []
|
|
50
|
+
headers = { Accept: "text/html" }
|
|
51
|
+
|
|
52
|
+
render(statusCode, responseHTML) {
|
|
53
|
+
document.addEventListener("turbo:load", countVisit, { once: true })
|
|
54
|
+
Turbo.visit(location.href, {
|
|
55
|
+
action: "replace",
|
|
56
|
+
shouldCacheSnapshot: false,
|
|
57
|
+
refresh: { method: "morph", scroll: "preserve" },
|
|
58
|
+
response: { statusCode, responseHTML }
|
|
24
59
|
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class FrameTarget {
|
|
64
|
+
constructor(element) {
|
|
65
|
+
this.element = element
|
|
66
|
+
this.elements = [ element ]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get headers() {
|
|
70
|
+
return { Accept: "text/html", "Turbo-Frame": this.element.id }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
render(_statusCode, responseHTML) {
|
|
74
|
+
const frame = new DOMParser().parseFromString(responseHTML, "text/html").getElementById(this.element.id)
|
|
25
75
|
|
|
26
|
-
|
|
76
|
+
Turbo.morphTurboFrameElements(this.element, frame)
|
|
77
|
+
countVisit()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Turbo's own targeting: the form's data-turbo-frame, then the target of the
|
|
82
|
+
// frame it sits in, then that frame itself. "_top" means the page.
|
|
83
|
+
function targetFor(form) {
|
|
84
|
+
const enclosing = form.closest("turbo-frame")
|
|
85
|
+
const id = form.dataset.turboFrame || enclosing?.getAttribute("target")
|
|
86
|
+
const frame = id === "_top" ? null : id ? document.getElementById(id) : enclosing
|
|
87
|
+
|
|
88
|
+
return frame ? new FrameTarget(frame) : new PageTarget()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const triggerSelector = "[data-turbo-form-trigger]"
|
|
92
|
+
|
|
93
|
+
// Shaped like Turbo's observers. One listener per event the page's triggers
|
|
94
|
+
// name, on the event target and in the capture phase, so fields rendered later
|
|
95
|
+
// are covered and events that don't bubble, like blur, still arrive. New names
|
|
96
|
+
// are picked up as triggers appear; the browser ignores a listener it already has.
|
|
97
|
+
class TriggerObserver {
|
|
98
|
+
constructor(delegate, eventTarget) {
|
|
99
|
+
this.delegate = delegate
|
|
100
|
+
this.eventTarget = eventTarget
|
|
101
|
+
this.mutationObserver = new MutationObserver(this.listen)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
start() {
|
|
105
|
+
this.listen()
|
|
106
|
+
this.mutationObserver.observe(this.eventTarget, { subtree: true, childList: true, attributeFilter: [ "data-turbo-form-trigger" ] })
|
|
107
|
+
}
|
|
27
108
|
|
|
28
|
-
|
|
109
|
+
listen = () => {
|
|
110
|
+
for (const trigger of this.eventTarget.querySelectorAll(triggerSelector)) {
|
|
111
|
+
this.eventTarget.addEventListener(eventFor(trigger), this.triggerCaptured, true)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
triggerCaptured = ({ type, target }) => {
|
|
116
|
+
const trigger = target.closest?.(triggerSelector)
|
|
117
|
+
const form = trigger?.form
|
|
29
118
|
|
|
30
|
-
|
|
31
|
-
this.requestsValue++
|
|
119
|
+
if (form?.dataset.turboFormUrl && eventFor(trigger) == type) this.delegate.triggerFired(form)
|
|
32
120
|
}
|
|
121
|
+
}
|
|
33
122
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
123
|
+
// A trigger with no event named fires on its element's natural one.
|
|
124
|
+
function eventFor(trigger) {
|
|
125
|
+
const named = trigger.dataset.turboFormTrigger
|
|
126
|
+
if (named) return named
|
|
127
|
+
if (trigger.localName == "select") return "change"
|
|
128
|
+
if (trigger.type == "submit") return "click"
|
|
129
|
+
|
|
130
|
+
return "input"
|
|
131
|
+
}
|
|
37
132
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
133
|
+
// Turbo's own busy state, which it doesn't export.
|
|
134
|
+
function markAsBusy(...elements) {
|
|
135
|
+
for (const element of elements) {
|
|
136
|
+
if (element.localName == "turbo-frame") element.setAttribute("busy", "")
|
|
137
|
+
element.setAttribute("aria-busy", "true")
|
|
138
|
+
}
|
|
139
|
+
}
|
|
41
140
|
|
|
42
|
-
|
|
141
|
+
function clearBusyState(...elements) {
|
|
142
|
+
for (const element of elements) {
|
|
143
|
+
if (element.localName == "turbo-frame") element.removeAttribute("busy")
|
|
144
|
+
element.removeAttribute("aria-busy")
|
|
43
145
|
}
|
|
44
146
|
}
|
|
147
|
+
|
|
148
|
+
// Kept on <html> because a morph rewrites <body> to what the server sent, and
|
|
149
|
+
// the server never knows the count. Lets a system test wait on a completed
|
|
150
|
+
// reload instead of sleeping.
|
|
151
|
+
function countVisit() {
|
|
152
|
+
const root = document.documentElement.dataset
|
|
153
|
+
root.turboFormVisits = Number(root.turboFormVisits || 0) + 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
new TriggerObserver({ triggerFired: refresh }, document).start()
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
|
|
4
|
-
# name is distinct so the host's own app/javascript/controllers can't shadow it.
|
|
5
|
-
pin "controllers/turbo_form_controller", to: "turbo_form.js"
|
|
1
|
+
# Imported once by the host's application.js, which starts its listeners. The
|
|
2
|
+
# asset name is distinct so nothing in the host's app/javascript can shadow it.
|
|
3
|
+
pin 'turbo_form', to: 'turbo_form.js'
|
|
@@ -1,62 +1,56 @@
|
|
|
1
|
-
require
|
|
1
|
+
require 'rails/generators'
|
|
2
2
|
|
|
3
3
|
module TurboForm
|
|
4
4
|
module Generators
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# things they'd otherwise write by hand -- the package and the registration.
|
|
5
|
+
# Every app gets TurboForm::Controller, the route concern its resources opt
|
|
6
|
+
# into and an import of the script. On importmap-rails the engine pins the
|
|
7
|
+
# script itself; bundled apps get the npm package to import instead.
|
|
9
8
|
class InstallGenerator < Rails::Generators::Base
|
|
10
|
-
source_root File.expand_path("templates", __dir__)
|
|
11
|
-
|
|
12
9
|
LOCKFILES = {
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
'yarn.lock' => 'yarn add', 'pnpm-lock.yaml' => 'pnpm add',
|
|
11
|
+
'bun.lock' => 'bun add', 'bun.lockb' => 'bun add'
|
|
15
12
|
}.freeze
|
|
16
13
|
|
|
14
|
+
ENTRYPOINT = 'app/javascript/application.js'
|
|
15
|
+
|
|
17
16
|
def install
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
install_package
|
|
24
|
-
register_controller
|
|
25
|
-
update_stimulus_manifest
|
|
17
|
+
include_controller
|
|
18
|
+
declare_route_concern
|
|
19
|
+
install_package unless importmap?
|
|
20
|
+
import_script
|
|
26
21
|
end
|
|
27
22
|
|
|
28
23
|
private
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
end
|
|
24
|
+
def include_controller
|
|
25
|
+
inject_into_class 'app/controllers/application_controller.rb', 'ApplicationController', " include TurboForm::Controller\n"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Declared once, drawn on no resource: which forms are dynamic is the
|
|
29
|
+
# app's call, one `concerns: :turbo_form` at a time.
|
|
30
|
+
def declare_route_concern
|
|
31
|
+
route 'concern :turbo_form, TurboForm::Routes'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def importmap? = Rails.root.join('config/importmap.rb').exist?
|
|
35
|
+
|
|
36
|
+
def install_package
|
|
37
|
+
run "#{package_manager} #{package}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def import_script
|
|
41
|
+
import = %(import "#{importmap? ? "turbo_form" : "@rolemodel/turbo-form"}"\n)
|
|
42
|
+
return say("Add #{import.strip} to your JavaScript entrypoint.") unless Rails.root.join(ENTRYPOINT).exist?
|
|
43
|
+
|
|
44
|
+
append_to_file ENTRYPOINT, import
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def package = "@rolemodel/turbo-form@#{TurboForm::VERSION}"
|
|
48
|
+
|
|
49
|
+
# Matched to the lockfile that is already there, so the generator doesn't
|
|
50
|
+
# introduce a second package manager to an app that settled on one.
|
|
51
|
+
def package_manager
|
|
52
|
+
LOCKFILES.find { |lockfile, _| Rails.root.join(lockfile).exist? }&.last || 'npm install'
|
|
53
|
+
end
|
|
60
54
|
end
|
|
61
55
|
end
|
|
62
56
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module TurboForm
|
|
2
|
+
# Included into the host's ApplicationController. A request that
|
|
3
|
+
# TurboForm::Routes marks runs `new` or `edit` as usual, callbacks and all,
|
|
4
|
+
# then assigns the form to what the action built just before the implicit
|
|
5
|
+
# render.
|
|
6
|
+
#
|
|
7
|
+
# Leans on the scaffold's conventions: the action builds `@<resource>`,
|
|
8
|
+
# `<resource>_params` permits the form, and the action leaves rendering to Rails.
|
|
9
|
+
module Controller
|
|
10
|
+
private
|
|
11
|
+
def process_action(...)
|
|
12
|
+
dynamic_form? ? discarding_writes { super } : super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def default_render
|
|
16
|
+
dynamic_form_assignment if dynamic_form?
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def dynamic_form_assignment
|
|
21
|
+
resource = turbo_form_resource.extend(TurboForm::Resource).dynamic_assign(turbo_form_resource_params)
|
|
22
|
+
instance_variable_set(:"@#{turbo_form_resource_name}", resource)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dynamic_form? = request.path_parameters[:dynamic_form]
|
|
26
|
+
def turbo_form_resource = instance_variable_get(:"@#{turbo_form_resource_name}")
|
|
27
|
+
def turbo_form_resource_params = send(:"#{turbo_form_resource_name}_params")
|
|
28
|
+
def turbo_form_resource_name = controller_name.singularize
|
|
29
|
+
|
|
30
|
+
# Active Record saves `has_many` writers and `*_ids=` on assignment, and
|
|
31
|
+
# this request exists only to render.
|
|
32
|
+
def discarding_writes
|
|
33
|
+
return yield unless defined?(ActiveRecord::Base)
|
|
34
|
+
|
|
35
|
+
ActiveRecord::Base.transaction do
|
|
36
|
+
yield
|
|
37
|
+
raise ActiveRecord::Rollback
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Switches to the STI subclass the submitted `type` names before assigning, so
|
|
43
|
+
# nested records land on the object that is kept -- which is returned.
|
|
44
|
+
module Resource
|
|
45
|
+
def dynamic_assign(params)
|
|
46
|
+
retype(params).tap { it.assign_attributes(params) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
def retype(params)
|
|
51
|
+
type = inheritance_column && params[inheritance_column]
|
|
52
|
+
return self if type.blank?
|
|
53
|
+
|
|
54
|
+
subclass = self.class.sti_class_for(type)
|
|
55
|
+
instance_of?(subclass) ? self : becomes!(subclass)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def inheritance_column = self.class.try(:inheritance_column)
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/turbo_form/engine.rb
CHANGED
|
@@ -1,33 +1,30 @@
|
|
|
1
|
-
require
|
|
2
|
-
require
|
|
3
|
-
require
|
|
4
|
-
require
|
|
1
|
+
require 'turbo-rails'
|
|
2
|
+
require 'turbo_form/form_helper'
|
|
3
|
+
require 'turbo_form/form_builder'
|
|
4
|
+
require 'turbo_form/routes'
|
|
5
|
+
require 'turbo_form/controller'
|
|
5
6
|
|
|
6
7
|
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
8
|
class Engine < ::Rails::Engine
|
|
12
9
|
# importmap-rails reads config.importmap.paths exactly once, in its own
|
|
13
10
|
# `importmap` initializer, and draws the host's pins last -- so appending
|
|
14
11
|
# here both registers ours and leaves the host able to override them.
|
|
15
12
|
# The guard keeps esbuild/vite/bun hosts booting; a `before:` naming an
|
|
16
13
|
# initializer that doesn't exist is itself harmless.
|
|
17
|
-
initializer
|
|
14
|
+
initializer 'turbo_form.importmap', before: 'importmap' do |app|
|
|
18
15
|
next unless app.config.respond_to?(:importmap)
|
|
19
16
|
|
|
20
|
-
app.config.importmap.paths << root.join(
|
|
21
|
-
app.config.importmap.cache_sweepers << root.join(
|
|
17
|
+
app.config.importmap.paths << root.join('config/turbo_form_importmap.rb')
|
|
18
|
+
app.config.importmap.cache_sweepers << root.join('app/assets/javascripts')
|
|
22
19
|
end
|
|
23
20
|
|
|
24
21
|
# Propshaft puts every engine's app/assets/* on the load path by itself.
|
|
25
22
|
# Sprockets additionally wants the asset named, or the pin above silently
|
|
26
23
|
# resolves to nothing.
|
|
27
|
-
initializer
|
|
24
|
+
initializer 'turbo_form.assets' do |app|
|
|
28
25
|
next unless app.config.respond_to?(:assets)
|
|
29
26
|
|
|
30
|
-
app.config.assets.precompile <<
|
|
27
|
+
app.config.assets.precompile << 'turbo_form.js'
|
|
31
28
|
end
|
|
32
29
|
|
|
33
30
|
# Minitest's system tests descend from ActionDispatch::SystemTestCase, so
|
|
@@ -35,9 +32,9 @@ module TurboForm
|
|
|
35
32
|
# assembles itself from Action Dispatch's parts instead of inheriting the
|
|
36
33
|
# case -- so they need telling separately, once RSpec is loaded but before
|
|
37
34
|
# any example group has been defined.
|
|
38
|
-
initializer
|
|
35
|
+
initializer 'turbo_form.system_test_helper' do
|
|
39
36
|
ActiveSupport.on_load(:action_dispatch_system_test_case) do
|
|
40
|
-
require
|
|
37
|
+
require 'turbo_form/system_test_helper'
|
|
41
38
|
|
|
42
39
|
include TurboForm::SystemTestHelper
|
|
43
40
|
end
|
|
@@ -46,7 +43,7 @@ module TurboForm
|
|
|
46
43
|
config.after_initialize do
|
|
47
44
|
next unless defined?(RSpec.configure)
|
|
48
45
|
|
|
49
|
-
require
|
|
46
|
+
require 'turbo_form/system_test_helper'
|
|
50
47
|
|
|
51
48
|
RSpec.configure do |config|
|
|
52
49
|
config.include TurboForm::SystemTestHelper, type: :system
|
|
@@ -57,8 +54,8 @@ module TurboForm
|
|
|
57
54
|
# hook doesn't fire until Action View is first loaded, which in an app that
|
|
58
55
|
# isn't eager loading is partway through rendering the first view -- late
|
|
59
56
|
# enough that the first form on the first request can miss the patch.
|
|
60
|
-
initializer
|
|
61
|
-
require
|
|
57
|
+
initializer 'turbo_form.form_helpers' do
|
|
58
|
+
require 'action_view'
|
|
62
59
|
|
|
63
60
|
ActionView::Helpers::FormHelper.prepend(TurboForm::FormHelper)
|
|
64
61
|
ActionView::Helpers::FormBuilder.prepend(TurboForm::FormBuilder)
|
|
@@ -4,7 +4,7 @@ module TurboForm
|
|
|
4
4
|
# f.text_field :name, dynamic_trigger: true # re-render on the default event
|
|
5
5
|
# f.select :category, categories, {}, dynamic_trigger: :blur
|
|
6
6
|
#
|
|
7
|
-
# `dynamic_trigger:` always has to end up as a
|
|
7
|
+
# `dynamic_trigger:` always has to end up as a data attribute, but where it
|
|
8
8
|
# *arrives* depends on the helper. Most treat their `options` hash as the tag's
|
|
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.
|
|
@@ -50,55 +50,37 @@ module TurboForm
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
private
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# The short forms say when to fire and nothing else. The hash form also
|
|
88
|
-
# says where to send the form and what to send with it, which is what a
|
|
89
|
-
# field needs when it answers to a different action than its own form does.
|
|
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.
|
|
100
|
-
def stimulus_action_for(event)
|
|
101
|
-
event.nil? || event == true ? "turbo-form#perform" : "#{event}->turbo-form#perform"
|
|
102
|
-
end
|
|
53
|
+
# Every other field helper -- generated and hand-written alike -- passes its
|
|
54
|
+
# attributes through here on the way to the tag.
|
|
55
|
+
def objectify_options(options)
|
|
56
|
+
super(absorb_trigger(options))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A trailing `dynamic_trigger:` binds to `options` because Ruby folds bare
|
|
60
|
+
# keywords into the first optional positional hash -- but for these helpers
|
|
61
|
+
# that hash is the *select's* options, not the tag's. Carry it across.
|
|
62
|
+
# SimpleForm arrives on the other side, via `input_html:`, so take it from
|
|
63
|
+
# either.
|
|
64
|
+
def hoist_trigger(options, html_options)
|
|
65
|
+
return [ options, absorb_trigger(html_options) ] unless options.key?(:dynamic_trigger)
|
|
66
|
+
|
|
67
|
+
trigger = options[:dynamic_trigger]
|
|
68
|
+
[ options.except(:dynamic_trigger), absorb_trigger(html_options.merge(dynamic_trigger: trigger)) ]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def absorb_trigger(attributes)
|
|
72
|
+
return attributes unless attributes.key?(:dynamic_trigger)
|
|
73
|
+
|
|
74
|
+
trigger = attributes[:dynamic_trigger]
|
|
75
|
+
attributes = attributes.except(:dynamic_trigger)
|
|
76
|
+
return attributes unless trigger
|
|
77
|
+
|
|
78
|
+
attributes.merge(data: { **attributes[:data].to_h, turbo_form_trigger: trigger_event(trigger) })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Left blank for `true`, which the script reads as the element's own
|
|
82
|
+
# default: `change` for a select, `input` for a text field, `click` for a
|
|
83
|
+
# button.
|
|
84
|
+
def trigger_event(trigger) = trigger == true ? '' : trigger.to_s
|
|
103
85
|
end
|
|
104
86
|
end
|
|
@@ -2,49 +2,26 @@ 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, model) if options[:dynamic]
|
|
11
|
+
super
|
|
26
12
|
end
|
|
27
13
|
|
|
28
14
|
private
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{ seed: object.try(:changes).to_h.transform_values(&:last) }
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# `form_for` funnels HTML attributes through options[:html] while `form_with`
|
|
40
|
-
# takes them at the top level. Write wherever the caller already is, and sit
|
|
41
|
-
# alongside any Stimulus controller they asked for rather than replacing it.
|
|
42
|
-
def wire_dynamic_form(options, signature)
|
|
43
|
-
attributes = options.key?(:html) ? (options[:html] ||= {}) : options
|
|
44
|
-
data = attributes[:data] ||= {}
|
|
15
|
+
# `form_for` funnels HTML attributes through options[:html] while `form_with`
|
|
16
|
+
# takes them at the top level. Write wherever the caller already is.
|
|
17
|
+
def wire_dynamic_form(options, model)
|
|
18
|
+
attributes = options.key?(:html) ? (options[:html] ||= {}) : options
|
|
19
|
+
(attributes[:data] ||= {})[:turbo_form_url] = namesake_path(model)
|
|
20
|
+
end
|
|
45
21
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
22
|
+
# The page the form is on, which TurboForm::Routes answers PATCH on too.
|
|
23
|
+
def namesake_path(model)
|
|
24
|
+
polymorphic_path(model, action: Array(model).last.persisted? ? :edit : :new)
|
|
25
|
+
end
|
|
49
26
|
end
|
|
50
27
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module TurboForm
|
|
2
|
+
# A callable route concern. Draws a PATCH beside whichever of a resource's `new`
|
|
3
|
+
# and `edit` it has, at the same paths and onto the same actions, marked so the
|
|
4
|
+
# controller renders them from what the form currently holds.
|
|
5
|
+
#
|
|
6
|
+
# concern :turbo_form, TurboForm::Routes
|
|
7
|
+
# resources :widgets, concerns: :turbo_form
|
|
8
|
+
module Routes
|
|
9
|
+
def self.call(mapper, _options = {})
|
|
10
|
+
actions = mapper.send(:parent_resource).actions
|
|
11
|
+
|
|
12
|
+
mapper.new { mapper.patch :new, as: nil, defaults: { dynamic_form: true } } if actions.include?(:new)
|
|
13
|
+
mapper.patch :edit, on: :member, as: nil, defaults: { dynamic_form: true } if actions.include?(:edit)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
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
|
|
2
|
-
require
|
|
3
|
-
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
|
|
1
|
+
require 'turbo_form/version'
|
|
2
|
+
require 'turbo_form/engine'
|
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Cohen
|
|
@@ -76,17 +76,15 @@ 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
|
-
- lib/generators/turbo_form/install/templates/turbo_form_controller.js
|
|
84
81
|
- lib/tasks/turbo_form_tasks.rake
|
|
85
82
|
- lib/turbo_form.rb
|
|
83
|
+
- lib/turbo_form/controller.rb
|
|
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/routes.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
|