turbo_form 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01bb2ce15b4fc755a0f4b689fa4e11d1a34627a206d95d5b2d008ad3d9e66aa3
4
- data.tar.gz: 52860d7614b4aa8cd960721ee677854704324d34f0abdcdd938f879546daf7d2
3
+ metadata.gz: e9cee9bf341b5d0a6bf59ac5334065a7c71f66c0cfe99636471af207054c6484
4
+ data.tar.gz: 6d60f46ff0e7e74e43ba80a6db207a7fc5d30cf79afb0a7baf5ca8fa3a08fad5
5
5
  SHA512:
6
- metadata.gz: cfee3dbcfb3d319e0e1265735a9dfe88d86a0479f3cfb01b53ad9879ef9f1d7b92eaf8679a9486613ffaba54a57f3f1e2965a66531aa6065557927a629ce39d1
7
- data.tar.gz: 82b3308ced5f38ace3d7e4f170b1beb77d0559f24a67cbfb91174e39de67ef9e66831a8d1a6a9fa03a2def2772459a821833aa684b45bd6f8a6141443ab36113
6
+ metadata.gz: 87318b7b8397c9e71d1754cea8b527f7d813865b6ad8d4101183494f8788815b1f77ea3a22053b0cfe7ec2418a9f644b21380bde114839ba98adf6b38d155241
7
+ data.tar.gz: b62767b018f4c05fce71f7af5060d68526d0c4bfb3f47d6b872405f89c879489f4d4320b93a09d2e55f452b3c158b3ac0af4520d8567788e6d32ee3548d5dbdd
data/README.md CHANGED
@@ -51,20 +51,23 @@ See [JavaScript](#javascript) for what that does and how to do it by hand.
51
51
 
52
52
  ### `dynamic:` on the form
53
53
 
54
- `dynamic: true` wires the form up and points it at
55
- `app/views/<resource dir>/dynamic_form.turbo_stream.*` — alongside the
56
- resource's own partial, so `widgets/_widget` gets `widgets/dynamic_form`.
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
57
 
58
- Pass a string to render something else instead:
58
+ - A form rendered by `WidgetsController` gets
59
+ `widgets/dynamic_form.turbo_stream.erb`.
60
+ - If that doesn't exist, lookup follows controller inheritance, ending at
61
+ `application/dynamic_form.turbo_stream.erb`.
62
+ - Partials resolve the same way, so `render "fields"` inside the template finds
63
+ `widgets/_fields`, just as it would in the form itself.
64
+
65
+ To render a different template, pass its name instead of `true`:
59
66
 
60
67
  ```erb
61
68
  <%= form_for @widget, dynamic: "shared/refresh_widget" %>
62
69
  ```
63
70
 
64
- Works on `form_for` and `form_with` alike. Any template engine works — the
65
- template is resolved the way every other Rails template is, so `.slim` and
66
- `.haml` are fine.
67
-
68
71
  ### `dynamic_trigger:` on a field
69
72
 
70
73
  ```erb
@@ -124,18 +127,24 @@ A `dynamic: true` form carries a signed description of itself to a single
124
127
  endpoint the gem draws into your routes. Given a valid signature it:
125
128
 
126
129
  1. permits the submitted parameters wholesale,
127
- 2. builds a **new, unsaved** instance of the form's class from them,
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,
128
134
  3. assigns it to `@resource`,
129
- 4. calls `TurboForm.before_render` with the controller and the resource, if one is set,
135
+ 4. runs `TurboForm.before_render` inside the controller with the resource, if one is set,
130
136
  5. renders the turbo_stream template.
131
137
 
132
- Nothing is persisted, and the resource is always freshly instantiated — even for
133
- an edit form. `@resource` exists to be asked what the form should now look like,
134
- not to be saved.
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.
135
142
 
136
- The signature covers the class name, the parameter scope and the template. It is
137
- signed because the endpoint constantizes and renders what it names; none of that
138
- may come from the browser unverified.
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.
139
148
 
140
149
  ## Configuration
141
150
 
@@ -151,7 +160,7 @@ TurboForm.parent_controller = "ApplicationController"
151
160
  TurboForm.draw_routes = false
152
161
 
153
162
  # Run something before the render. See below.
154
- TurboForm.before_render = ->(controller, resource) { }
163
+ TurboForm.before_render = ->(resource) { }
155
164
  ```
156
165
 
157
166
  ### `before_render`
@@ -159,17 +168,18 @@ TurboForm.before_render = ->(controller, resource) { }
159
168
  The endpoint is a controller you never wrote, inheriting filters written for
160
169
  controllers that save things. `before_render` is where you get to treat it like
161
170
  one of your own: it runs in a `before_action`, after the signature is verified
162
- and the resource is built, and is handed the controller and that resource.
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.
163
173
 
164
174
  Satisfy a filter the endpoint can't — the most common need, since nothing here
165
175
  is authorized because nothing here is saved:
166
176
 
167
177
  ```ruby
168
178
  # Pundit's after_action :verify_authorized
169
- TurboForm.before_render = ->(controller, resource) { controller.skip_authorization }
179
+ TurboForm.before_render = ->(resource) { skip_authorization }
170
180
 
171
181
  # Action Policy's verify_authorized
172
- TurboForm.before_render = ->(controller, resource) { controller.skip_verify_authorized! }
182
+ TurboForm.before_render = ->(resource) { skip_verify_authorized! }
173
183
  ```
174
184
 
175
185
  Or authorize it for real, if re-rendering a form is something you'd rather gate.
@@ -178,18 +188,18 @@ permission you mean.
178
188
 
179
189
  ```ruby
180
190
  # Pundit
181
- TurboForm.before_render = ->(controller, resource) { controller.authorize(resource, :edit?) }
191
+ TurboForm.before_render = ->(resource) { authorize resource, :edit? }
182
192
 
183
193
  # Action Policy
184
- TurboForm.before_render = ->(controller, resource) { controller.authorize!(resource, to: :edit?) }
194
+ TurboForm.before_render = ->(resource) { authorize! resource, to: :edit? }
185
195
 
186
196
  # CanCanCan -- this also satisfies `check_authorization`, which has no runtime skip
187
- TurboForm.before_render = ->(controller, resource) { controller.authorize!(:edit, resource) }
197
+ TurboForm.before_render = ->(resource) { authorize! :edit, resource }
188
198
  ```
189
199
 
190
200
  Raising works the way it does in any `before_action`: your
191
201
  `rescue_from`s catch it, since the endpoint inherits them too. Rendering or
192
- redirecting from the controller halts the chain as usual.
202
+ redirecting halts the chain as usual.
193
203
 
194
204
  ## JavaScript
195
205
 
@@ -245,9 +255,7 @@ a page carries more than one dynamic form.
245
255
  ## What this deliberately doesn't do
246
256
 
247
257
  It handles the common case well and gets out of the way otherwise. There is no
248
- debouncing, no request cancellation, no loading state, no per-element URL
249
- override, and no hook for loading an existing record instead of building a new
250
- 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,
251
259
  and this gem doesn't stand in front of it.
252
260
 
253
261
  ## License
@@ -1,25 +1,48 @@
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 resource is rebuilt only so the template can ask
5
- # it what the form should now look like, which is why the parameters are taken
6
- # whole: narrowing them would only make the re-rendered form disagree with what
7
- # the user actually typed.
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
+ around_action :discard_writes
13
14
  before_action :build_resource
14
- before_action { TurboForm.before_render&.call(self, @resource) }
15
+ before_action { instance_exec(@resource, &TurboForm.before_render) if TurboForm.before_render }
15
16
 
16
17
  def update
17
- render template: signature.template_for(@resource), formats: :turbo_stream
18
+ render signature.template || "dynamic_form", formats: :turbo_stream
18
19
  end
19
20
 
20
21
  private
21
22
  def build_resource
22
- @resource = signature.model.new(form_params)
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
23
46
  end
24
47
 
25
48
  def signature
@@ -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.
@@ -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
- # Note that this identifies a *class*, not a record. Dynamic forms are usually
10
- # editing something unsaved, which is also why GlobalID -- which requires a
11
- # persisted record -- can't express it.
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
- # By convention the template sits alongside the resource's own partial:
33
- # `widgets/_widget` gets `widgets/dynamic_form`.
34
- def template_for(resource)
35
- template || File.join(File.dirname(resource.to_partial_path), "dynamic_form")
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
@@ -1,3 +1,3 @@
1
1
  module TurboForm
2
- VERSION = '0.3.0'
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/turbo_form.rb CHANGED
@@ -13,12 +13,12 @@ module TurboForm
13
13
  # route yourself.
14
14
  mattr_accessor :draw_routes, default: true
15
15
 
16
- # Called from a `before_action` with the controller and the resource the form
17
- # was rebuilt into. The endpoint is an inherited controller the host never
18
- # wrote, so this is where it gets to treat it like one of its own: authorize
19
- # the render, skip a filter the endpoint can't satisfy
20
- # (`controller.skip_authorization`), set something the template needs. Left
21
- # alone, only the host's own inherited filters apply.
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
22
  mattr_accessor :before_render
23
23
 
24
24
  class << self
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Cohen