turbo_form 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9cee9bf341b5d0a6bf59ac5334065a7c71f66c0cfe99636471af207054c6484
4
- data.tar.gz: 6d60f46ff0e7e74e43ba80a6db207a7fc5d30cf79afb0a7baf5ca8fa3a08fad5
3
+ metadata.gz: c69a1768a1d65dd2bdfa4d77ea5e58ea239923d54280820b5197bec043376b91
4
+ data.tar.gz: 5958181f35cc5c6cfbdbd50c9a4da9eea692c5670867dfa86d55459355a07c08
5
5
  SHA512:
6
- metadata.gz: 87318b7b8397c9e71d1754cea8b527f7d813865b6ad8d4101183494f8788815b1f77ea3a22053b0cfe7ec2418a9f644b21380bde114839ba98adf6b38d155241
7
- data.tar.gz: b62767b018f4c05fce71f7af5060d68526d0c4bfb3f47d6b872405f89c879489f4d4320b93a09d2e55f452b3c158b3ac0af4520d8567788e6d32ee3548d5dbdd
6
+ metadata.gz: 76dbea8632fa0087c403cbaa95682fae7b16ed8c3370586007e873e4c7ad3277a06f73a436782834ffb8d2b81fa8302a4d7c3e61130e48adbf6dd28ebd53d6a4
7
+ data.tar.gz: c89bdf36b175d24708012a869ec06692217a38d8b0d3d889c1692dca95237a7ce0039d020cab93463d34491b452b0a8ce309b2cb969396d0a1662534e358861d
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # turbo_form
2
2
 
3
- Turbo-Stream backed dynamic forms for Rails, by convention.
3
+ Dynamic forms for Rails, by convention.
4
4
 
5
5
  A form that needs to change as it is filled in — a dependent dropdown, a section
6
6
  that appears once you pick a type — usually costs you a route, a controller
@@ -10,26 +10,18 @@ instead:
10
10
  ```erb
11
11
  <%= form_for @widget, dynamic: true do |f| %>
12
12
  <%= f.select :category, @widget.categories, {}, dynamic_trigger: true %>
13
-
14
- <div id="flavor-field">
15
- <%= f.select :flavor, @widget.flavors %>
16
- </div>
13
+ <%= f.select :flavor, @widget.flavors %>
17
14
  <% end %>
18
15
  ```
19
16
 
20
- Pick a category and the form is sent to the server as it currently stands. The
21
- server rebuilds `@resource` from what you typed and renders
22
- `app/views/widgets/dynamic_form.turbo_stream.erb`:
23
-
24
- ```erb
25
- <%= fields model: @resource do |f| %>
26
- <%= turbo_stream.update "flavor-field" do %>
27
- <%= f.select :flavor, @resource.flavors %>
28
- <% end %>
29
- <% end %>
30
- ```
17
+ Pick a category and the page reloads with the form as it currently stands. The
18
+ form assigns what was typed to `@widget` before its fields render, so
19
+ `@widget.flavors` answers for the category just picked. Turbo morphs the page in
20
+ place, so focus, scroll and everything else typed survive the reload.
31
21
 
32
- That's the whole feature. No route, no controller, no JavaScript.
22
+ That's the whole feature. No route, no controller action, no template, no
23
+ JavaScript. The page is rendered by its own controller, so its authentication,
24
+ authorization and partials are the ones it always had.
33
25
 
34
26
  ## Installation
35
27
 
@@ -37,9 +29,9 @@ That's the whole feature. No route, no controller, no JavaScript.
37
29
  gem "turbo_form"
38
30
  ```
39
31
 
40
- 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:
32
+ On a stock Rails app — Propshaft, importmap-rails, stimulus-rails — the Stimulus
33
+ controller registers itself and there is nothing to generate. If your app
34
+ bundles with esbuild, Vite, Bun or webpack:
43
35
 
44
36
  ```bash
45
37
  rails generate turbo_form:install
@@ -51,22 +43,9 @@ See [JavaScript](#javascript) for what that does and how to do it by hand.
51
43
 
52
44
  ### `dynamic:` on the form
53
45
 
54
- `dynamic: true` 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.
64
-
65
- To render a different template, pass its name instead of `true`:
66
-
67
- ```erb
68
- <%= form_for @widget, dynamic: "shared/refresh_widget" %>
69
- ```
46
+ `dynamic: true` makes the form dynamic: on a reload, it assigns the submitted
47
+ values under its scope to its object, via `assign_attributes`, before any field
48
+ renders.
70
49
 
71
50
  ### `dynamic_trigger:` on a field
72
51
 
@@ -80,30 +59,6 @@ checkbox, `input` for a text field, `click` for a button. Name an event when you
80
59
  want something else — `:blur` on text fields is usually what you want, since the
81
60
  default fires on every keystroke.
82
61
 
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
-
107
62
  Works on every Rails field helper, including the select and date families where
108
63
  Rails keeps HTML attributes in a separate hash:
109
64
 
@@ -121,85 +76,61 @@ inherits from, so it works without SimpleForm being involved at all:
121
76
  = f.input :category, input_html: { dynamic_trigger: true }
122
77
  ```
123
78
 
124
- ## What the endpoint does
125
-
126
- A `dynamic: true` form carries a signed description of itself to a single
127
- endpoint the gem draws into your routes. Given a valid signature it:
128
-
129
- 1. permits the submitted parameters wholesale,
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,
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.
137
-
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.
142
-
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.
148
-
149
- ## Configuration
79
+ ## What a reload does
150
80
 
151
- ```ruby
152
- # config/initializers/turbo_form.rb
153
-
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.
157
- TurboForm.parent_controller = "ApplicationController"
81
+ A trigger visits the page's own URL with the form's fields in the query string,
82
+ leaving out the authenticity token and `_method`, and marks the request with an
83
+ `X-Turbo-Form` header. On the server:
158
84
 
159
- # Draw the route yourself instead.
160
- TurboForm.draw_routes = false
85
+ 1. the page's controller runs as it would for any visit, building `@widget` the
86
+ way it always does — found by id, built from a parent, whatever it does,
87
+ 2. the form assigns the submitted values to that object as it starts to render,
88
+ 3. the whole request runs inside a database transaction that is always rolled
89
+ back.
161
90
 
162
- # Run something before the render. See below.
163
- TurboForm.before_render = ->(resource) { }
164
- ```
91
+ Only a request carrying the header is assigned anything. A plain link can't set
92
+ a header, and another origin can't without a CORS preflight, so a hand-crafted
93
+ URL with form values in it renders the page as if they weren't there.
165
94
 
166
- ### `before_render`
95
+ The rollback is there because Active Record saves some assignments on the spot
96
+ (`has_many` writers, `*_ids=`). A reload exists to render, so nothing it does is
97
+ kept — including anything else the action writes.
167
98
 
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.
99
+ ### Needing the values before the form
173
100
 
174
- Satisfy a filter the endpoint can't — the most common need, since nothing here
175
- is authorized because nothing here is saved:
101
+ The form assigns as it starts to render, so everything from the form down — and
102
+ the layout, which Rails renders after the template — sees what was typed. The
103
+ controller, and anything in the template above the form, sees the object as it
104
+ was built. When those need it too, assign in the action:
176
105
 
177
106
  ```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! }
107
+ def new
108
+ @widget = turbo_form_assign(Widget.new)
109
+ end
183
110
  ```
184
111
 
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.
112
+ `turbo_form_assign` returns the object, assigns only on a reload, and takes
113
+ `scope:` when the form's scope isn't the object's param key. The form assigns
114
+ the same values again when it renders, which changes nothing.
188
115
 
189
- ```ruby
190
- # Pundit
191
- TurboForm.before_render = ->(resource) { authorize resource, :edit? }
116
+ ### Switching an STI subclass
192
117
 
193
- # Action Policy
194
- TurboForm.before_render = ->(resource) { authorize! resource, to: :edit? }
118
+ When the submitted values include the inheritance column, the form switches its
119
+ object to the subclass it names, with `becomes`, before assigning — so a `type`
120
+ select can turn a `Gizmo` into a `Doohickey` and the rest of the form answers
121
+ as one. Give the form its base class's scope (`scope: :gadget`) so the switched
122
+ object reads and posts under the same name.
195
123
 
196
- # CanCanCan -- this also satisfies `check_authorization`, which has no runtime skip
197
- TurboForm.before_render = ->(resource) { authorize! :edit, resource }
198
- ```
124
+ The switched object shares its attributes with the original, so everything
125
+ outside the form sees the same values. To have it be the new subclass too, keep
126
+ what `turbo_form_assign` returns — it switches the same way.
127
+
128
+ ### After a failed save
199
129
 
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.
130
+ Rendering `:new` or `:edit` with `422` after a failed save leaves the browser on
131
+ the form's own URL, since Turbo renders it without touching history — so a
132
+ trigger reloads the right page. The reload builds a fresh object that has not
133
+ been validated, so the error messages go away.
203
134
 
204
135
  ## JavaScript
205
136
 
@@ -238,7 +169,7 @@ To do it by hand, write that file yourself and run `rails stimulus:manifest:upda
238
169
 
239
170
  ### Testing against it
240
171
 
241
- A re-render is a round trip, so the line after a trigger is racing it. Wrap the
172
+ A reload is a round trip, so the line after a trigger is racing it. Wrap the
242
173
  trigger and the wait comes with it:
243
174
 
244
175
  ```ruby
@@ -247,16 +178,23 @@ select "Barrel Aged", from: "Flavor"
247
178
  ```
248
179
 
249
180
  `expect_dynamic_form_request` is available in system tests with nothing to
250
- require or include — Minitest and RSpec both. It waits on the controller's
251
- count of completed round trips rather than on the fields that came back, so it
252
- doesn't care what the response changed. Scope it with Capybara's `within` when
253
- a page carries more than one dynamic form.
181
+ require or include — Minitest and RSpec both. It waits on a count of
182
+ completed reloads kept on `<html>` rather than on the fields that came back, so
183
+ it doesn't care what the reload changed.
254
184
 
255
185
  ## What this deliberately doesn't do
256
186
 
257
- It handles the common case well and gets out of the way otherwise. There is no
258
- debouncing, no request cancellation and no loading state. When you need those, write the action by hand — that path is still open,
259
- and this gem doesn't stand in front of it.
187
+ It handles the common case well and gets out of the way otherwise.
188
+
189
+ - **No debouncing, request cancellation or loading state.** Write the action by
190
+ hand when you need those — that path is still open.
191
+ - **The form rides in the URL.** Very large forms can hit URL length limits,
192
+ file inputs are left out, and what was typed lands in browser history and any
193
+ access log outside Rails' own parameter filtering.
194
+ - **Only the primary database is rolled back**, and only writes: jobs enqueued
195
+ or mail sent during a reload still happen. A streamed response renders after
196
+ the transaction has closed.
197
+ - **A form with Turbo turned off** reloads whatever URL a failed save left it on.
260
198
 
261
199
  ## License
262
200
 
@@ -1,44 +1,45 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { Turbo } from "@hotwired/turbo-rails"
3
3
 
4
- // Re-renders the form it is attached to, from the server, as a Turbo Stream.
4
+ // Never worth putting in a URL: the token is a secret, and the page's own
5
+ // verb is GET whatever the form says.
6
+ const UNSENT = new Set(["authenticity_token", "_method"])
7
+
8
+ // Reloads the page with the form's current state in the query string. The
9
+ // page's own action renders it again, and the form assigns that state to its
10
+ // object on the way, so the whole page answers as the user now has it. The
11
+ // header is what tells the server this is that reload and not an ordinary
12
+ // visit to the same URL.
5
13
  //
6
- // The whole form is submitted so the server sees exactly the state the user is
7
- // looking at; nothing is saved, and the response only describes what should
8
- // change on screen.
14
+ // A morphing refresh keeps focus and scroll, so it reads as the form updating
15
+ // in place rather than as a navigation.
9
16
  export default class extends Controller {
10
- static values = { url: String, requests: Number }
17
+ perform() {
18
+ const url = new URL(location.href)
19
+ const data = new FormData(this.element)
11
20
 
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)
21
+ for (const name of new Set(data.keys())) url.searchParams.delete(name)
22
+ for (const [name, value] of data) {
23
+ if (typeof value === "string" && !UNSENT.has(name)) url.searchParams.append(name, value)
24
+ }
17
25
 
18
- if (query) Object.entries(query).forEach(([name, value]) => body.append(name, value))
26
+ const markReload = ({ detail: { url: requested, fetchOptions } }) => {
27
+ if (requested.href !== url.href) return
19
28
 
20
- const response = await fetch(url || this.urlValue, {
21
- method: "PATCH",
22
- headers: this.#headers,
23
- body
24
- })
29
+ fetchOptions.headers["X-Turbo-Form"] = "reload"
30
+ document.removeEventListener("turbo:before-fetch-request", markReload)
31
+ }
25
32
 
26
- if (!response.ok) return
27
-
28
- Turbo.renderStreamMessage(await response.text())
29
-
30
- // Lets a system test wait on a completed round trip instead of sleeping.
31
- this.requestsValue++
33
+ document.addEventListener("turbo:before-fetch-request", markReload)
34
+ document.addEventListener("turbo:load", countVisit, { once: true })
35
+ Turbo.visit(url, { action: "replace", shouldCacheSnapshot: false, refresh: { method: "morph", scroll: "preserve" } })
32
36
  }
37
+ }
33
38
 
34
- get #headers() {
35
- const headers = { Accept: "text/vnd.turbo-stream.html" }
36
- const token = document.querySelector("meta[name=csrf-token]")?.content
37
-
38
- // The form's own authenticity_token rides along in the body; this covers
39
- // the forms that don't carry one.
40
- if (token) headers["X-CSRF-Token"] = token
41
-
42
- return headers
43
- }
39
+ // Kept on <html> because a morph rewrites <body> to what the server sent, and
40
+ // the server never knows the count. Lets a system test wait on a completed
41
+ // reload instead of sleeping.
42
+ function countVisit() {
43
+ const root = document.documentElement.dataset
44
+ root.turboFormVisits = Number(root.turboFormVisits || 0) + 1
44
45
  }
@@ -1,13 +1,9 @@
1
1
  require "turbo-rails"
2
2
  require "turbo_form/form_helper"
3
3
  require "turbo_form/form_builder"
4
- require "turbo_form/signature"
4
+ require "turbo_form/reload"
5
5
 
6
6
  module TurboForm
7
- # Deliberately *not* `isolate_namespace`: the template this engine renders
8
- # belongs to the host, and isolating would point its route helpers at this
9
- # engine's own (empty) route set -- so `widgets_path` in a host's
10
- # dynamic_form template would raise.
11
7
  class Engine < ::Rails::Engine
12
8
  # importmap-rails reads config.importmap.paths exactly once, in its own
13
9
  # `importmap` initializer, and draws the host's pins last -- so appending
@@ -53,6 +49,11 @@ module TurboForm
53
49
  end
54
50
  end
55
51
 
52
+ initializer "turbo_form.reload" do |app|
53
+ app.middleware.use TurboForm::Reload::Middleware
54
+ ActiveSupport.on_load(:action_controller_base) { include TurboForm::Reload::Controller }
55
+ end
56
+
56
57
  # Deliberately eager rather than `ActiveSupport.on_load(:action_view)`: that
57
58
  # hook doesn't fire until Action View is first loaded, which in an app that
58
59
  # isn't eager loading is partway through rendering the first view -- late
@@ -9,6 +9,15 @@ module TurboForm
9
9
  # HTML attributes; the select and date families keep those in a trailing
10
10
  # `html_options` instead, which is where these overrides earn their keep.
11
11
  module FormBuilder
12
+ # A dynamic form's trigger reloads the page with the form's current state in
13
+ # the query string. Assigning it here, before the form's block runs, lets
14
+ # every field -- and every question the form asks of its object -- answer
15
+ # as the user now has it.
16
+ def initialize(object_name, object, template, options)
17
+ super
18
+ @object = TurboForm::Reload.assign(object, @template.params[object_name]) if options[:dynamic] && TurboForm::Reload.requested?(@template.try(:request))
19
+ end
20
+
12
21
  def select(method, choices = nil, options = {}, html_options = {}, &block)
13
22
  super(method, choices, *hoist_trigger(options, html_options), &block)
14
23
  end
@@ -75,28 +84,14 @@ module TurboForm
75
84
  attributes = attributes.except(:dynamic_trigger)
76
85
  return attributes unless trigger
77
86
 
78
- event, url, query = destructure_trigger(trigger)
79
-
80
87
  data = (attributes[:data] || {}).dup
81
- data[:action] = [ data[:action], stimulus_action_for(event) ].compact.join(" ")
82
- data[:turbo_form_url_param] = url if url
83
- data[:turbo_form_query_param] = query if query
88
+ data[:action] = [ data[:action], stimulus_action_for(trigger) ].compact.join(" ")
84
89
  attributes.merge(data: data)
85
90
  end
86
91
 
87
- # 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.
92
+ # `true` leaves the event off the descriptor so Stimulus binds the
93
+ # element's own default: `change` for a select, `input` for a text field,
94
+ # `click` for a button. Anything else is taken as the event name.
100
95
  def stimulus_action_for(event)
101
96
  event.nil? || event == true ? "turbo-form#perform" : "#{event}->turbo-form#perform"
102
97
  end
@@ -2,49 +2,24 @@ module TurboForm
2
2
  # Prepended onto ActionView::Helpers::FormHelper.
3
3
  #
4
4
  # form_for @widget, dynamic: true
5
- # form_for @widget, dynamic: "shared/refresh" # render this template instead
6
5
  module FormHelper
7
- # `form_for` ends in `form_with`, so overriding one covers both.
6
+ # `form_for` ends in `form_with`, so overriding one covers both. `dynamic:`
7
+ # stays in the options so the builder sees it; Rails slices the form tag's
8
+ # attributes out by name, so it never reaches the HTML.
8
9
  def form_with(model: false, scope: nil, url: nil, format: nil, **options, &block)
9
- dynamic = options.delete(:dynamic)
10
- return super unless dynamic
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) if options[:dynamic]
11
+ super
26
12
  end
27
13
 
28
14
  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
-
39
15
  # `form_for` funnels HTML attributes through options[:html] while `form_with`
40
16
  # takes them at the top level. Write wherever the caller already is, and sit
41
17
  # alongside any Stimulus controller they asked for rather than replacing it.
42
- def wire_dynamic_form(options, signature)
18
+ def wire_dynamic_form(options)
43
19
  attributes = options.key?(:html) ? (options[:html] ||= {}) : options
44
20
  data = attributes[:data] ||= {}
45
21
 
46
22
  data[:controller] = [ data[:controller], "turbo-form" ].compact.join(" ")
47
- data[:turbo_form_url_value] = turbo_form_path(signature)
48
23
  end
49
24
  end
50
25
  end
@@ -0,0 +1,68 @@
1
+ module TurboForm
2
+ # A dynamic trigger reloads its page with this header set. Only a request
3
+ # carrying it has its form's state assigned, and only such a request has its
4
+ # writes discarded -- one predicate for both, so the one can't happen without
5
+ # the other. A plain link can't set a header, and a cross-origin page can't
6
+ # without a CORS preflight, so a crafted URL assigns nothing.
7
+ module Reload
8
+ HEADER = "X-Turbo-Form"
9
+
10
+ def self.requested?(request) = request&.headers&.key?(HEADER)
11
+
12
+ # Answers with the object the form should now render, which is a different
13
+ # one when the submitted `type` picks another STI subclass.
14
+ def self.assign(object, submitted)
15
+ return object unless submitted.is_a?(ActionController::Parameters)
16
+
17
+ retype(object, submitted).tap { |record| record.assign_attributes(submitted.permit!) }
18
+ end
19
+
20
+ # Let `new` pick the subclass by Active Record's own STI rules, and switch
21
+ # before assigning so nested records land on the object that is kept.
22
+ # `becomes` shares its attributes with the original, so whoever still holds
23
+ # that one sees the same values, if not the subclass's behaviour.
24
+ def self.retype(object, submitted)
25
+ column = object.class.try(:inheritance_column)
26
+ return object unless column && submitted.key?(column)
27
+
28
+ subclass = object.class.base_class.new(column => submitted[column]).class
29
+ object.instance_of?(subclass) ? object : object.becomes(subclass)
30
+ end
31
+
32
+ # Included into ActionController::Base, for an action that needs the form's
33
+ # state before the form itself is rendered:
34
+ #
35
+ # @widget = turbo_form_assign(Widget.new)
36
+ #
37
+ # The form assigns the same state again when it renders, which changes
38
+ # nothing. Keep what it returns: that is the switched object when the form
39
+ # picked another STI subclass.
40
+ module Controller
41
+ private
42
+ def turbo_form_assign(object, scope: object.model_name.param_key)
43
+ return object unless TurboForm::Reload.requested?(request)
44
+
45
+ TurboForm::Reload.assign(object, params[scope])
46
+ end
47
+ end
48
+
49
+ # Assigning to a saved record is not always inert -- Active Record saves
50
+ # `has_many` writers and `*_ids=` on the spot -- and a reload exists only to
51
+ # render, so nothing it does is kept.
52
+ class Middleware
53
+ def initialize(app) = @app = app
54
+
55
+ def call(env)
56
+ return @app.call(env) unless defined?(ActiveRecord::Base) && Reload.requested?(ActionDispatch::Request.new(env))
57
+
58
+ response = nil
59
+ ActiveRecord::Base.transaction do
60
+ # ponytail: a streamed body renders after this returns, outside the transaction
61
+ response = @app.call(env)
62
+ raise ActiveRecord::Rollback
63
+ end
64
+ response
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,23 +1,13 @@
1
1
  module TurboForm::SystemTestHelper
2
- # Waits for a dynamic form to finish re-rendering itself.
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
- form = page.find("[data-controller~='turbo-form']")
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-requests-value='#{completed}']"
11
+ page.assert_selector "html[data-turbo-form-visits='#{completed}']"
22
12
  end
23
13
  end
@@ -1,3 +1,3 @@
1
1
  module TurboForm
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
data/lib/turbo_form.rb CHANGED
@@ -1,39 +1,2 @@
1
- require "active_support/core_ext/module/attribute_accessors"
2
1
  require "turbo_form/version"
3
2
  require "turbo_form/engine"
4
-
5
- module TurboForm
6
- # The controller the engine's endpoint inherits from. Defaulting to the host's
7
- # own `ApplicationController` means its `before_action`s -- authentication
8
- # above all -- apply to dynamic renders for free. Point it elsewhere when that
9
- # inheritance brings something the endpoint shouldn't have.
10
- mattr_accessor :parent_controller, default: "ApplicationController"
11
-
12
- # Set to false to keep the engine out of the host's route set and draw its
13
- # route yourself.
14
- mattr_accessor :draw_routes, default: true
15
-
16
- # 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
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.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Cohen
@@ -76,8 +76,6 @@ files:
76
76
  - README.md
77
77
  - Rakefile
78
78
  - app/assets/javascripts/turbo_form.js
79
- - app/controllers/turbo_form/dynamic_forms_controller.rb
80
- - config/routes.rb
81
79
  - config/turbo_form_importmap.rb
82
80
  - lib/generators/turbo_form/install/install_generator.rb
83
81
  - lib/generators/turbo_form/install/templates/turbo_form_controller.js
@@ -86,7 +84,7 @@ files:
86
84
  - lib/turbo_form/engine.rb
87
85
  - lib/turbo_form/form_builder.rb
88
86
  - lib/turbo_form/form_helper.rb
89
- - lib/turbo_form/signature.rb
87
+ - lib/turbo_form/reload.rb
90
88
  - lib/turbo_form/system_test_helper.rb
91
89
  - lib/turbo_form/version.rb
92
90
  homepage: https://github.com/outlawandy/turbo_form
@@ -1,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
@@ -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