turbo_form 0.2.0 → 0.4.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 +121 -33
- data/app/assets/javascripts/turbo_form.js +10 -4
- data/app/controllers/turbo_form/dynamic_forms_controller.rb +35 -7
- data/lib/generators/turbo_form/install/install_generator.rb +62 -0
- data/lib/generators/turbo_form/install/templates/turbo_form_controller.js +4 -0
- data/lib/turbo_form/form_builder.rb +20 -6
- data/lib/turbo_form/form_helper.rb +14 -2
- data/lib/turbo_form/signature.rb +31 -10
- data/lib/turbo_form/version.rb +1 -1
- data/lib/turbo_form.rb +8 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9cee9bf341b5d0a6bf59ac5334065a7c71f66c0cfe99636471af207054c6484
|
|
4
|
+
data.tar.gz: 6d60f46ff0e7e74e43ba80a6db207a7fc5d30cf79afb0a7baf5ca8fa3a08fad5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87318b7b8397c9e71d1754cea8b527f7d813865b6ad8d4101183494f8788815b1f77ea3a22053b0cfe7ec2418a9f644b21380bde114839ba98adf6b38d155241
|
|
7
|
+
data.tar.gz: b62767b018f4c05fce71f7af5060d68526d0c4bfb3f47d6b872405f89c879489f4d4320b93a09d2e55f452b3c158b3ac0af4520d8567788e6d32ee3548d5dbdd
|
data/README.md
CHANGED
|
@@ -37,29 +37,37 @@ That's the whole feature. No route, no controller, no JavaScript.
|
|
|
37
37
|
gem "turbo_form"
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
There is nothing to mount
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
There is nothing to mount. On a stock Rails app — Propshaft, importmap-rails,
|
|
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:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
rails generate turbo_form:install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
See [JavaScript](#javascript) for what that does and how to do it by hand.
|
|
44
49
|
|
|
45
50
|
## The two options
|
|
46
51
|
|
|
47
52
|
### `dynamic:` on the form
|
|
48
53
|
|
|
49
|
-
`dynamic: true`
|
|
50
|
-
`
|
|
51
|
-
|
|
54
|
+
`dynamic: true` makes the form dynamic. When a trigger fires, the gem renders a
|
|
55
|
+
template named `dynamic_form`, looked up just as the controller that rendered
|
|
56
|
+
the form would look up one of its own views:
|
|
57
|
+
|
|
58
|
+
- A form rendered by `WidgetsController` gets
|
|
59
|
+
`widgets/dynamic_form.turbo_stream.erb`.
|
|
60
|
+
- If that doesn't exist, lookup follows controller inheritance, ending at
|
|
61
|
+
`application/dynamic_form.turbo_stream.erb`.
|
|
62
|
+
- Partials resolve the same way, so `render "fields"` inside the template finds
|
|
63
|
+
`widgets/_fields`, just as it would in the form itself.
|
|
52
64
|
|
|
53
|
-
|
|
65
|
+
To render a different template, pass its name instead of `true`:
|
|
54
66
|
|
|
55
67
|
```erb
|
|
56
68
|
<%= form_for @widget, dynamic: "shared/refresh_widget" %>
|
|
57
69
|
```
|
|
58
70
|
|
|
59
|
-
Works on `form_for` and `form_with` alike. Any template engine works — the
|
|
60
|
-
template is resolved the way every other Rails template is, so `.slim` and
|
|
61
|
-
`.haml` are fine.
|
|
62
|
-
|
|
63
71
|
### `dynamic_trigger:` on a field
|
|
64
72
|
|
|
65
73
|
```erb
|
|
@@ -72,6 +80,30 @@ checkbox, `input` for a text field, `click` for a button. Name an event when you
|
|
|
72
80
|
want something else — `:blur` on text fields is usually what you want, since the
|
|
73
81
|
default fires on every keystroke.
|
|
74
82
|
|
|
83
|
+
#### Sending the form somewhere else
|
|
84
|
+
|
|
85
|
+
A trigger can answer to a different endpoint than its own form, and can add to
|
|
86
|
+
what the form sends:
|
|
87
|
+
|
|
88
|
+
```erb
|
|
89
|
+
<%= f.button "Run", type: "button", dynamic_trigger: {
|
|
90
|
+
event: :click,
|
|
91
|
+
url: formula_preview_widgets_path,
|
|
92
|
+
params: { attribute: :rafter_count }
|
|
93
|
+
} %>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`url:` replaces the form's own endpoint for that one trigger; everything else on
|
|
97
|
+
the form keeps using the form's. `params:` are appended to the submitted form
|
|
98
|
+
data, so one endpoint can tell which of several triggers asked. Both are
|
|
99
|
+
optional, and either can be given without the other.
|
|
100
|
+
|
|
101
|
+
That endpoint is yours, not the gem's — it's an ordinary action rendering an
|
|
102
|
+
ordinary turbo_stream template, so the whole form arrives as `params` under its
|
|
103
|
+
usual scope. Use it when one form feeds several actions: a dozen fields that
|
|
104
|
+
each preview themselves, or a select that belongs to a nested form with its own
|
|
105
|
+
controller.
|
|
106
|
+
|
|
75
107
|
Works on every Rails field helper, including the select and date families where
|
|
76
108
|
Rails keeps HTML attributes in a separate hash:
|
|
77
109
|
|
|
@@ -95,32 +127,80 @@ A `dynamic: true` form carries a signed description of itself to a single
|
|
|
95
127
|
endpoint the gem draws into your routes. Given a valid signature it:
|
|
96
128
|
|
|
97
129
|
1. permits the submitted parameters wholesale,
|
|
98
|
-
2.
|
|
130
|
+
2. rebuilds the form's object and assigns them to it — an edit form's record is
|
|
131
|
+
found again by id, and a new form's object is rebuilt from the attributes it
|
|
132
|
+
was built with (a parent's foreign key from `@order.line_items.new`, say) before
|
|
133
|
+
the submitted ones are applied,
|
|
99
134
|
3. assigns it to `@resource`,
|
|
100
|
-
4.
|
|
135
|
+
4. runs `TurboForm.before_render` inside the controller with the resource, if one is set,
|
|
136
|
+
5. renders the turbo_stream template.
|
|
101
137
|
|
|
102
|
-
Nothing is persisted
|
|
103
|
-
|
|
104
|
-
|
|
138
|
+
Nothing is persisted. `@resource` exists to be asked what the form should now
|
|
139
|
+
look like, not to be saved — and since Active Record saves some assignments on
|
|
140
|
+
the spot (`has_many` writers, `*_ids=`), an Active Record resource is rebuilt
|
|
141
|
+
inside a transaction that is always rolled back.
|
|
105
142
|
|
|
106
|
-
The signature covers the class name, the parameter scope
|
|
107
|
-
|
|
108
|
-
may come from the
|
|
143
|
+
The signature covers the class name, the parameter scope, the template, the view
|
|
144
|
+
paths the form was rendered under, and the record's id or starting attributes. It is signed because the endpoint
|
|
145
|
+
constantizes, loads and renders what it names; none of that may come from the
|
|
146
|
+
browser unverified. It is not encrypted, so those starting attributes are
|
|
147
|
+
readable in the page, as the form itself is.
|
|
109
148
|
|
|
110
149
|
## Configuration
|
|
111
150
|
|
|
112
151
|
```ruby
|
|
113
152
|
# config/initializers/turbo_form.rb
|
|
114
153
|
|
|
115
|
-
# The endpoint inherits from this, so your authentication applies to it.
|
|
116
|
-
#
|
|
117
|
-
#
|
|
154
|
+
# The endpoint inherits from this, so your authentication applies to it. Point
|
|
155
|
+
# it at a narrower controller when that inheritance brings something the
|
|
156
|
+
# endpoint shouldn't have.
|
|
118
157
|
TurboForm.parent_controller = "ApplicationController"
|
|
119
158
|
|
|
120
159
|
# Draw the route yourself instead.
|
|
121
160
|
TurboForm.draw_routes = false
|
|
161
|
+
|
|
162
|
+
# Run something before the render. See below.
|
|
163
|
+
TurboForm.before_render = ->(resource) { }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### `before_render`
|
|
167
|
+
|
|
168
|
+
The endpoint is a controller you never wrote, inheriting filters written for
|
|
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.
|
|
173
|
+
|
|
174
|
+
Satisfy a filter the endpoint can't — the most common need, since nothing here
|
|
175
|
+
is authorized because nothing here is saved:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
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! }
|
|
122
183
|
```
|
|
123
184
|
|
|
185
|
+
Or authorize it for real, if re-rendering a form is something you'd rather gate.
|
|
186
|
+
Name the query explicitly: the endpoint's action is `update`, which is not the
|
|
187
|
+
permission you mean.
|
|
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.
|
|
203
|
+
|
|
124
204
|
## JavaScript
|
|
125
205
|
|
|
126
206
|
**importmap-rails** — nothing to do. The engine pins its controller as
|
|
@@ -135,17 +215,27 @@ import TurboFormController from "controllers/turbo_form_controller"
|
|
|
135
215
|
application.register("turbo-form", TurboFormController)
|
|
136
216
|
```
|
|
137
217
|
|
|
138
|
-
**esbuild, Vite, Bun, webpack** —
|
|
139
|
-
the same version, and
|
|
218
|
+
**esbuild, Vite, Bun, webpack** — there is no sweep to pick the controller up,
|
|
219
|
+
so the package has to be installed alongside the gem, at the same version, and
|
|
220
|
+
registered. `rails generate turbo_form:install` does both: it adds the package
|
|
221
|
+
with whichever manager your lockfile names, and writes
|
|
140
222
|
|
|
141
|
-
```bash
|
|
142
|
-
yarn add @rolemodel/turbo_form
|
|
143
|
-
```
|
|
144
223
|
```js
|
|
145
|
-
|
|
146
|
-
|
|
224
|
+
// app/javascript/controllers/turbo_form_controller.js
|
|
225
|
+
import TurboFormController from "@rolemodel/turbo-form"
|
|
226
|
+
|
|
227
|
+
export default TurboFormController
|
|
147
228
|
```
|
|
148
229
|
|
|
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
|
+
|
|
149
239
|
### Testing against it
|
|
150
240
|
|
|
151
241
|
A re-render is a round trip, so the line after a trigger is racing it. Wrap the
|
|
@@ -165,9 +255,7 @@ a page carries more than one dynamic form.
|
|
|
165
255
|
## What this deliberately doesn't do
|
|
166
256
|
|
|
167
257
|
It handles the common case well and gets out of the way otherwise. There is no
|
|
168
|
-
debouncing, no request cancellation
|
|
169
|
-
override, and no hook for loading an existing record instead of building a new
|
|
170
|
-
one. When you need those, write the action by hand — that path is still open,
|
|
258
|
+
debouncing, no request cancellation and no loading state. When you need those, write the action by hand — that path is still open,
|
|
171
259
|
and this gem doesn't stand in front of it.
|
|
172
260
|
|
|
173
261
|
## License
|
|
@@ -9,12 +9,18 @@ import { Turbo } from "@hotwired/turbo-rails"
|
|
|
9
9
|
export default class extends Controller {
|
|
10
10
|
static values = { url: String, requests: Number }
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
// A trigger can send the form somewhere other than the form's own endpoint,
|
|
13
|
+
// and can add to what it sends. Both ride in as Stimulus action params, so
|
|
14
|
+
// one form can feed several actions without needing several forms.
|
|
15
|
+
async perform({ params: { url, query } }) {
|
|
16
|
+
const body = new FormData(this.element)
|
|
17
|
+
|
|
18
|
+
if (query) Object.entries(query).forEach(([name, value]) => body.append(name, value))
|
|
19
|
+
|
|
20
|
+
const response = await fetch(url || this.urlValue, {
|
|
15
21
|
method: "PATCH",
|
|
16
22
|
headers: this.#headers,
|
|
17
|
-
body
|
|
23
|
+
body
|
|
18
24
|
})
|
|
19
25
|
|
|
20
26
|
if (!response.ok) return
|
|
@@ -1,22 +1,50 @@
|
|
|
1
1
|
module TurboForm
|
|
2
2
|
# Renders a form again from the state the browser currently has it in.
|
|
3
3
|
#
|
|
4
|
-
# Nothing here is saved. The
|
|
5
|
-
# it what the form should now look like, which is why the parameters are
|
|
6
|
-
# whole: narrowing them would only make the re-rendered form disagree
|
|
7
|
-
# the user actually typed.
|
|
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
8
|
class DynamicFormsController < TurboForm.parent_controller.constantize
|
|
9
9
|
rescue_from TurboForm::Signature::Invalid do
|
|
10
10
|
head :bad_request
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
around_action :discard_writes
|
|
14
|
+
before_action :build_resource
|
|
15
|
+
before_action { instance_exec(@resource, &TurboForm.before_render) if TurboForm.before_render }
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
def update
|
|
18
|
+
render signature.template || "dynamic_form", formats: :turbo_stream
|
|
17
19
|
end
|
|
18
20
|
|
|
19
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
|
+
|
|
20
48
|
def signature
|
|
21
49
|
@signature ||= TurboForm::Signature.verify(params[:signature])
|
|
22
50
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module TurboForm
|
|
4
|
+
module Generators
|
|
5
|
+
# There is nothing to install on importmap-rails: the engine pins its own
|
|
6
|
+
# Stimulus controller and a stock `app/javascript/controllers/index.js`
|
|
7
|
+
# registers it. Bundled apps get no such sweep, so this hands them the two
|
|
8
|
+
# things they'd otherwise write by hand -- the package and the registration.
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
LOCKFILES = {
|
|
13
|
+
"yarn.lock" => "yarn add", "pnpm-lock.yaml" => "pnpm add",
|
|
14
|
+
"bun.lock" => "bun add", "bun.lockb" => "bun add"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def install
|
|
18
|
+
if importmap?
|
|
19
|
+
say "turbo_form registers its own Stimulus controller on importmap-rails. Nothing to install."
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
install_package
|
|
24
|
+
register_controller
|
|
25
|
+
update_stimulus_manifest
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
def importmap? = Rails.root.join("config/importmap.rb").exist?
|
|
30
|
+
|
|
31
|
+
def install_package
|
|
32
|
+
run "#{package_manager} #{package}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Named for the controller it is, rather than imported into `index.js`:
|
|
36
|
+
# that file is rewritten wholesale every time `rails generate stimulus`
|
|
37
|
+
# runs, and would take a registration appended to it down with it. The
|
|
38
|
+
# manifest is generated from this directory instead, so a file named
|
|
39
|
+
# this way is picked back up every time and registered as `turbo-form`.
|
|
40
|
+
def register_controller
|
|
41
|
+
copy_file "turbo_form_controller.js", "app/javascript/controllers/turbo_form_controller.js"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Left to Stimulus' own task rather than written here, the way the
|
|
45
|
+
# `stimulus` generator does it: the task is what the app already trusts
|
|
46
|
+
# to regenerate this file, and its internals have moved between releases.
|
|
47
|
+
def update_stimulus_manifest
|
|
48
|
+
return unless Rails.root.join("app/javascript/controllers/index.js").exist?
|
|
49
|
+
|
|
50
|
+
rails_command "stimulus:manifest:update"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def package = "@rolemodel/turbo-form@#{TurboForm::VERSION}"
|
|
54
|
+
|
|
55
|
+
# Matched to the lockfile that is already there, so the generator doesn't
|
|
56
|
+
# introduce a second package manager to an app that settled on one.
|
|
57
|
+
def package_manager
|
|
58
|
+
LOCKFILES.find { |lockfile, _| Rails.root.join(lockfile).exist? }&.last || "npm install"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -75,16 +75,30 @@ module TurboForm
|
|
|
75
75
|
attributes = attributes.except(:dynamic_trigger)
|
|
76
76
|
return attributes unless trigger
|
|
77
77
|
|
|
78
|
+
event, url, query = destructure_trigger(trigger)
|
|
79
|
+
|
|
78
80
|
data = (attributes[:data] || {}).dup
|
|
79
|
-
data[:action] = [ data[:action], stimulus_action_for(
|
|
81
|
+
data[:action] = [ data[:action], stimulus_action_for(event) ].compact.join(" ")
|
|
82
|
+
data[:turbo_form_url_param] = url if url
|
|
83
|
+
data[:turbo_form_query_param] = query if query
|
|
80
84
|
attributes.merge(data: data)
|
|
81
85
|
end
|
|
82
86
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
def
|
|
87
|
-
trigger
|
|
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"
|
|
88
102
|
end
|
|
89
103
|
end
|
|
90
104
|
end
|
|
@@ -14,9 +14,11 @@ module TurboForm
|
|
|
14
14
|
|
|
15
15
|
scope ||= model_name_from_record_or_class(object).param_key
|
|
16
16
|
signature = TurboForm::Signature.new(
|
|
17
|
-
model_name: object.class.name,
|
|
17
|
+
model_name: (object.class.try(:base_class) || object.class).name,
|
|
18
18
|
scope: scope.to_s,
|
|
19
|
-
template: (dynamic unless dynamic == true)
|
|
19
|
+
template: (dynamic unless dynamic == true),
|
|
20
|
+
prefixes: lookup_context.prefixes,
|
|
21
|
+
**origin_of(object)
|
|
20
22
|
)
|
|
21
23
|
|
|
22
24
|
wire_dynamic_form(options, signature)
|
|
@@ -24,6 +26,16 @@ module TurboForm
|
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
private
|
|
29
|
+
# A saved record is found again by id. An unsaved one is rebuilt from what
|
|
30
|
+
# it had already been given -- a parent's foreign key, say -- since the
|
|
31
|
+
# form may not submit that back. A plain form object with no dirty
|
|
32
|
+
# tracking has nothing to carry.
|
|
33
|
+
def origin_of(object)
|
|
34
|
+
return { id: object.id } if object.try(:persisted?)
|
|
35
|
+
|
|
36
|
+
{ seed: object.try(:changes).to_h.transform_values(&:last) }
|
|
37
|
+
end
|
|
38
|
+
|
|
27
39
|
# `form_for` funnels HTML attributes through options[:html] while `form_with`
|
|
28
40
|
# takes them at the top level. Write wherever the caller already is, and sit
|
|
29
41
|
# alongside any Stimulus controller they asked for rather than replacing it.
|
data/lib/turbo_form/signature.rb
CHANGED
|
@@ -6,9 +6,13 @@ module TurboForm
|
|
|
6
6
|
# constantized and instantiated, `template` gets rendered. Neither may come
|
|
7
7
|
# from the client unverified.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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.
|
|
12
16
|
class Signature
|
|
13
17
|
class Invalid < StandardError; end
|
|
14
18
|
|
|
@@ -19,25 +23,42 @@ module TurboForm
|
|
|
19
23
|
new(**payload.symbolize_keys)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
|
-
attr_reader :model_name, :scope, :template
|
|
26
|
+
attr_reader :model_name, :scope, :template, :prefixes, :id, :seed
|
|
23
27
|
|
|
24
|
-
def initialize(model_name:, scope:, template: nil)
|
|
28
|
+
def initialize(model_name:, scope:, template: nil, prefixes: [], id: nil, seed: {})
|
|
25
29
|
@model_name = model_name
|
|
26
30
|
@scope = scope
|
|
27
31
|
@template = template
|
|
32
|
+
@prefixes = prefixes
|
|
33
|
+
@id = id
|
|
34
|
+
@seed = seed
|
|
28
35
|
end
|
|
29
36
|
|
|
30
37
|
def model = model_name.constantize
|
|
31
38
|
|
|
32
|
-
#
|
|
33
|
-
# `
|
|
34
|
-
def
|
|
35
|
-
|
|
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) }
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
def to_s
|
|
39
|
-
TurboForm.verifier.generate({ model_name:, scope:, template: })
|
|
48
|
+
TurboForm.verifier.generate({ model_name:, scope:, template:, prefixes:, id:, seed: })
|
|
40
49
|
end
|
|
41
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
|
|
42
63
|
end
|
|
43
64
|
end
|
data/lib/turbo_form/version.rb
CHANGED
data/lib/turbo_form.rb
CHANGED
|
@@ -12,7 +12,14 @@ module TurboForm
|
|
|
12
12
|
# Set to false to keep the engine out of the host's route set and draw its
|
|
13
13
|
# route yourself.
|
|
14
14
|
mattr_accessor :draw_routes, default: true
|
|
15
|
-
|
|
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
|
|
16
23
|
|
|
17
24
|
class << self
|
|
18
25
|
attr_writer :verifier
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Cohen
|
|
@@ -79,6 +79,8 @@ files:
|
|
|
79
79
|
- app/controllers/turbo_form/dynamic_forms_controller.rb
|
|
80
80
|
- config/routes.rb
|
|
81
81
|
- config/turbo_form_importmap.rb
|
|
82
|
+
- lib/generators/turbo_form/install/install_generator.rb
|
|
83
|
+
- lib/generators/turbo_form/install/templates/turbo_form_controller.js
|
|
82
84
|
- lib/tasks/turbo_form_tasks.rake
|
|
83
85
|
- lib/turbo_form.rb
|
|
84
86
|
- lib/turbo_form/engine.rb
|