turbo_form 0.5.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 +113 -93
- data/app/assets/javascripts/turbo_form.js +138 -27
- 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 -19
- data/lib/turbo_form/form_builder.rb +33 -46
- data/lib/turbo_form/form_helper.rb +11 -9
- data/lib/turbo_form/routes.rb +16 -0
- data/lib/turbo_form/system_test_helper.rb +1 -1
- data/lib/turbo_form/version.rb +1 -1
- data/lib/turbo_form.rb +2 -2
- metadata +3 -3
- data/lib/generators/turbo_form/install/templates/turbo_form_controller.js +0 -4
- data/lib/turbo_form/reload.rb +0 -68
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
|
@@ -14,14 +14,15 @@ instead:
|
|
|
14
14
|
<% end %>
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
Pick a category and the
|
|
18
|
-
|
|
19
|
-
`@widget.flavors` answers for the category just
|
|
20
|
-
place, so focus, scroll and everything else
|
|
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.
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
25
26
|
|
|
26
27
|
## Installation
|
|
27
28
|
|
|
@@ -29,23 +30,47 @@ authorization and partials are the ones it always had.
|
|
|
29
30
|
gem "turbo_form"
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
controller registers itself and there is nothing to generate. If your app
|
|
34
|
-
bundles with esbuild, Vite, Bun or webpack:
|
|
33
|
+
Then run the installer:
|
|
35
34
|
|
|
36
35
|
```bash
|
|
37
36
|
rails generate turbo_form:install
|
|
38
37
|
```
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
It includes `TurboForm::Controller` in your ApplicationController and declares
|
|
40
|
+
the route concern:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class ApplicationController < ActionController::Base
|
|
44
|
+
include TurboForm::Controller
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
concern :turbo_form, TurboForm::Routes
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Draw the concern on each resource with a dynamic form — the installer leaves
|
|
53
|
+
which ones to you:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
resources :widgets, concerns: :turbo_form
|
|
57
|
+
```
|
|
58
|
+
|
|
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`.
|
|
62
|
+
|
|
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.
|
|
41
66
|
|
|
42
67
|
## The two options
|
|
43
68
|
|
|
44
69
|
### `dynamic:` on the form
|
|
45
70
|
|
|
46
|
-
`dynamic: true` makes the form dynamic:
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
49
74
|
|
|
50
75
|
### `dynamic_trigger:` on a field
|
|
51
76
|
|
|
@@ -54,10 +79,12 @@ renders.
|
|
|
54
79
|
<%= f.text_field :name, dynamic_trigger: :blur %> <%# on a named event %>
|
|
55
80
|
```
|
|
56
81
|
|
|
57
|
-
`true`
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
default fires on every keystroke.
|
|
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.
|
|
61
88
|
|
|
62
89
|
Works on every Rails field helper, including the select and date families where
|
|
63
90
|
Rails keeps HTML attributes in a separate hash:
|
|
@@ -76,100 +103,90 @@ inherits from, so it works without SimpleForm being involved at all:
|
|
|
76
103
|
= f.input :category, input_html: { dynamic_trigger: true }
|
|
77
104
|
```
|
|
78
105
|
|
|
79
|
-
## What a
|
|
106
|
+
## What a trigger does
|
|
80
107
|
|
|
81
|
-
A trigger
|
|
82
|
-
|
|
83
|
-
|
|
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:
|
|
84
111
|
|
|
85
|
-
1.
|
|
86
|
-
way it always does — found by id, built from a parent,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
90
117
|
|
|
91
|
-
|
|
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.
|
|
118
|
+
all inside a database transaction that is always rolled back.
|
|
94
119
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
98
123
|
|
|
99
|
-
|
|
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.
|
|
100
127
|
|
|
101
|
-
The
|
|
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:
|
|
128
|
+
### The conventions it leans on
|
|
105
129
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
@widget = turbo_form_assign(Widget.new)
|
|
109
|
-
end
|
|
110
|
-
```
|
|
130
|
+
turbo_form knows nothing about your resource beyond its controller's name.
|
|
131
|
+
For a `WidgetsController`:
|
|
111
132
|
|
|
112
|
-
`
|
|
113
|
-
`
|
|
114
|
-
|
|
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.
|
|
115
139
|
|
|
116
140
|
### Switching an STI subclass
|
|
117
141
|
|
|
118
|
-
When the submitted values include the inheritance column, the
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
123
147
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
### Inside a Turbo Frame
|
|
149
|
+
|
|
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.
|
|
127
156
|
|
|
128
157
|
### After a failed save
|
|
129
158
|
|
|
130
|
-
|
|
131
|
-
the
|
|
132
|
-
|
|
133
|
-
been validated, so the error messages go away.
|
|
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.
|
|
134
163
|
|
|
135
164
|
## JavaScript
|
|
136
165
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
`lazyLoadControllersFrom` in a stock `app/javascript/controllers/index.js`
|
|
140
|
-
registers as `turbo-form` on its own.
|
|
141
|
-
|
|
142
|
-
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:
|
|
143
168
|
|
|
144
169
|
```js
|
|
145
|
-
|
|
146
|
-
|
|
170
|
+
// app/javascript/application.js
|
|
171
|
+
import "turbo_form" // importmap-rails: the engine pins it
|
|
172
|
+
import "@rolemodel/turbo-form" // esbuild, Vite, Bun, webpack
|
|
147
173
|
```
|
|
148
174
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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`:
|
|
153
180
|
|
|
154
181
|
```js
|
|
155
|
-
|
|
156
|
-
import TurboFormController from "@rolemodel/turbo-form"
|
|
182
|
+
import { refresh } from "turbo_form"
|
|
157
183
|
|
|
158
|
-
|
|
184
|
+
refresh(document.querySelector("form[data-turbo-form-url]"))
|
|
159
185
|
```
|
|
160
186
|
|
|
161
|
-
Registering by *filename* rather than by appending to
|
|
162
|
-
`app/javascript/controllers/index.js` is deliberate. That file is regenerated
|
|
163
|
-
from the contents of the directory every time `rails generate stimulus` runs, so
|
|
164
|
-
a registration appended to it lasts until the next controller you generate. A
|
|
165
|
-
file named `turbo_form_controller.js` is picked up by that same regeneration and
|
|
166
|
-
registered as `turbo-form`, every time.
|
|
167
|
-
|
|
168
|
-
To do it by hand, write that file yourself and run `rails stimulus:manifest:update`.
|
|
169
|
-
|
|
170
187
|
### Testing against it
|
|
171
188
|
|
|
172
|
-
A
|
|
189
|
+
A trigger is a round trip, so the line after it is racing it. Wrap the
|
|
173
190
|
trigger and the wait comes with it:
|
|
174
191
|
|
|
175
192
|
```ruby
|
|
@@ -179,22 +196,25 @@ select "Barrel Aged", from: "Flavor"
|
|
|
179
196
|
|
|
180
197
|
`expect_dynamic_form_request` is available in system tests with nothing to
|
|
181
198
|
require or include — Minitest and RSpec both. It waits on a count of
|
|
182
|
-
completed
|
|
183
|
-
it doesn't care what the
|
|
199
|
+
completed triggers kept on `<html>` rather than on the fields that came back, so
|
|
200
|
+
it doesn't care what the trigger changed.
|
|
184
201
|
|
|
185
202
|
## What this deliberately doesn't do
|
|
186
203
|
|
|
187
204
|
It handles the common case well and gets out of the way otherwise.
|
|
188
205
|
|
|
189
|
-
- **No debouncing, request cancellation or loading state
|
|
190
|
-
hand when you need those — that path is still open.
|
|
191
|
-
- **
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
194
216
|
- **Only the primary database is rolled back**, and only writes: jobs enqueued
|
|
195
|
-
or mail sent during a
|
|
196
|
-
the transaction has closed.
|
|
197
|
-
- **A form with Turbo turned off** reloads whatever URL a failed save left it on.
|
|
217
|
+
or mail sent during a trigger still happen.
|
|
198
218
|
|
|
199
219
|
## License
|
|
200
220
|
|
|
@@ -1,38 +1,147 @@
|
|
|
1
|
-
import { Controller } from "@hotwired/stimulus"
|
|
2
1
|
import { Turbo } from "@hotwired/turbo-rails"
|
|
3
2
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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.
|
|
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.
|
|
13
7
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
for (const [name, value] of data) {
|
|
23
|
-
if (typeof value === "string" && !UNSENT.has(name)) url.searchParams.append(name, value)
|
|
24
|
-
}
|
|
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
|
+
}
|
|
25
16
|
|
|
26
|
-
|
|
27
|
-
|
|
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 })
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
this.target.render(response.status, await response.text())
|
|
32
|
+
} finally {
|
|
33
|
+
this.requestFinished()
|
|
31
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" }
|
|
32
51
|
|
|
33
|
-
|
|
52
|
+
render(statusCode, responseHTML) {
|
|
34
53
|
document.addEventListener("turbo:load", countVisit, { once: true })
|
|
35
|
-
Turbo.visit(
|
|
54
|
+
Turbo.visit(location.href, {
|
|
55
|
+
action: "replace",
|
|
56
|
+
shouldCacheSnapshot: false,
|
|
57
|
+
refresh: { method: "morph", scroll: "preserve" },
|
|
58
|
+
response: { statusCode, responseHTML }
|
|
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)
|
|
75
|
+
|
|
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
|
+
}
|
|
108
|
+
|
|
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
|
|
118
|
+
|
|
119
|
+
if (form?.dataset.turboFormUrl && eventFor(trigger) == type) this.delegate.triggerFired(form)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
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
|
+
}
|
|
132
|
+
|
|
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
|
+
}
|
|
140
|
+
|
|
141
|
+
function clearBusyState(...elements) {
|
|
142
|
+
for (const element of elements) {
|
|
143
|
+
if (element.localName == "turbo-frame") element.removeAttribute("busy")
|
|
144
|
+
element.removeAttribute("aria-busy")
|
|
36
145
|
}
|
|
37
146
|
}
|
|
38
147
|
|
|
@@ -43,3 +152,5 @@ function countVisit() {
|
|
|
43
152
|
const root = document.documentElement.dataset
|
|
44
153
|
root.turboFormVisits = Number(root.turboFormVisits || 0) + 1
|
|
45
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,7 +1,8 @@
|
|
|
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
8
|
class Engine < ::Rails::Engine
|
|
@@ -10,20 +11,20 @@ module TurboForm
|
|
|
10
11
|
# here both registers ours and leaves the host able to override them.
|
|
11
12
|
# The guard keeps esbuild/vite/bun hosts booting; a `before:` naming an
|
|
12
13
|
# initializer that doesn't exist is itself harmless.
|
|
13
|
-
initializer
|
|
14
|
+
initializer 'turbo_form.importmap', before: 'importmap' do |app|
|
|
14
15
|
next unless app.config.respond_to?(:importmap)
|
|
15
16
|
|
|
16
|
-
app.config.importmap.paths << root.join(
|
|
17
|
-
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')
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
# Propshaft puts every engine's app/assets/* on the load path by itself.
|
|
21
22
|
# Sprockets additionally wants the asset named, or the pin above silently
|
|
22
23
|
# resolves to nothing.
|
|
23
|
-
initializer
|
|
24
|
+
initializer 'turbo_form.assets' do |app|
|
|
24
25
|
next unless app.config.respond_to?(:assets)
|
|
25
26
|
|
|
26
|
-
app.config.assets.precompile <<
|
|
27
|
+
app.config.assets.precompile << 'turbo_form.js'
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# Minitest's system tests descend from ActionDispatch::SystemTestCase, so
|
|
@@ -31,9 +32,9 @@ module TurboForm
|
|
|
31
32
|
# assembles itself from Action Dispatch's parts instead of inheriting the
|
|
32
33
|
# case -- so they need telling separately, once RSpec is loaded but before
|
|
33
34
|
# any example group has been defined.
|
|
34
|
-
initializer
|
|
35
|
+
initializer 'turbo_form.system_test_helper' do
|
|
35
36
|
ActiveSupport.on_load(:action_dispatch_system_test_case) do
|
|
36
|
-
require
|
|
37
|
+
require 'turbo_form/system_test_helper'
|
|
37
38
|
|
|
38
39
|
include TurboForm::SystemTestHelper
|
|
39
40
|
end
|
|
@@ -42,24 +43,19 @@ module TurboForm
|
|
|
42
43
|
config.after_initialize do
|
|
43
44
|
next unless defined?(RSpec.configure)
|
|
44
45
|
|
|
45
|
-
require
|
|
46
|
+
require 'turbo_form/system_test_helper'
|
|
46
47
|
|
|
47
48
|
RSpec.configure do |config|
|
|
48
49
|
config.include TurboForm::SystemTestHelper, type: :system
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
|
|
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
|
-
|
|
57
53
|
# Deliberately eager rather than `ActiveSupport.on_load(:action_view)`: that
|
|
58
54
|
# hook doesn't fire until Action View is first loaded, which in an app that
|
|
59
55
|
# isn't eager loading is partway through rendering the first view -- late
|
|
60
56
|
# enough that the first form on the first request can miss the patch.
|
|
61
|
-
initializer
|
|
62
|
-
require
|
|
57
|
+
initializer 'turbo_form.form_helpers' do
|
|
58
|
+
require 'action_view'
|
|
63
59
|
|
|
64
60
|
ActionView::Helpers::FormHelper.prepend(TurboForm::FormHelper)
|
|
65
61
|
ActionView::Helpers::FormBuilder.prepend(TurboForm::FormBuilder)
|
|
@@ -4,20 +4,11 @@ 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.
|
|
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
|
-
|
|
21
12
|
def select(method, choices = nil, options = {}, html_options = {}, &block)
|
|
22
13
|
super(method, choices, *hoist_trigger(options, html_options), &block)
|
|
23
14
|
end
|
|
@@ -59,41 +50,37 @@ module TurboForm
|
|
|
59
50
|
end
|
|
60
51
|
|
|
61
52
|
private
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# `click` for a button. Anything else is taken as the event name.
|
|
95
|
-
def stimulus_action_for(event)
|
|
96
|
-
event.nil? || event == true ? "turbo-form#perform" : "#{event}->turbo-form#perform"
|
|
97
|
-
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
|
|
98
85
|
end
|
|
99
86
|
end
|
|
@@ -7,19 +7,21 @@ module TurboForm
|
|
|
7
7
|
# stays in the options so the builder sees it; Rails slices the form tag's
|
|
8
8
|
# attributes out by name, so it never reaches the HTML.
|
|
9
9
|
def form_with(model: false, scope: nil, url: nil, format: nil, **options, &block)
|
|
10
|
-
wire_dynamic_form(options) if options[:dynamic]
|
|
10
|
+
wire_dynamic_form(options, model) if options[:dynamic]
|
|
11
11
|
super
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
private
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
24
26
|
end
|
|
25
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
|
|
@@ -4,7 +4,7 @@ module TurboForm::SystemTestHelper
|
|
|
4
4
|
# expect_dynamic_form_request { select "Bourbon", from: "Category" }
|
|
5
5
|
# select "Barrel Aged", from: "Flavor"
|
|
6
6
|
def expect_dynamic_form_request
|
|
7
|
-
completed = page.find(
|
|
7
|
+
completed = page.find('html')['data-turbo-form-visits'].to_i + 1
|
|
8
8
|
|
|
9
9
|
yield
|
|
10
10
|
|
data/lib/turbo_form/version.rb
CHANGED
data/lib/turbo_form.rb
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
require
|
|
2
|
-
require
|
|
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
|
|
@@ -78,13 +78,13 @@ files:
|
|
|
78
78
|
- app/assets/javascripts/turbo_form.js
|
|
79
79
|
- config/turbo_form_importmap.rb
|
|
80
80
|
- lib/generators/turbo_form/install/install_generator.rb
|
|
81
|
-
- lib/generators/turbo_form/install/templates/turbo_form_controller.js
|
|
82
81
|
- lib/tasks/turbo_form_tasks.rake
|
|
83
82
|
- lib/turbo_form.rb
|
|
83
|
+
- lib/turbo_form/controller.rb
|
|
84
84
|
- lib/turbo_form/engine.rb
|
|
85
85
|
- lib/turbo_form/form_builder.rb
|
|
86
86
|
- lib/turbo_form/form_helper.rb
|
|
87
|
-
- lib/turbo_form/
|
|
87
|
+
- lib/turbo_form/routes.rb
|
|
88
88
|
- lib/turbo_form/system_test_helper.rb
|
|
89
89
|
- lib/turbo_form/version.rb
|
|
90
90
|
homepage: https://github.com/outlawandy/turbo_form
|
data/lib/turbo_form/reload.rb
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
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
|