turbo_form 0.3.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: 01bb2ce15b4fc755a0f4b689fa4e11d1a34627a206d95d5b2d008ad3d9e66aa3
4
- data.tar.gz: 52860d7614b4aa8cd960721ee677854704324d34f0abdcdd938f879546daf7d2
3
+ metadata.gz: c69a1768a1d65dd2bdfa4d77ea5e58ea239923d54280820b5197bec043376b91
4
+ data.tar.gz: 5958181f35cc5c6cfbdbd50c9a4da9eea692c5670867dfa86d55459355a07c08
5
5
  SHA512:
6
- metadata.gz: cfee3dbcfb3d319e0e1265735a9dfe88d86a0479f3cfb01b53ad9879ef9f1d7b92eaf8679a9486613ffaba54a57f3f1e2965a66531aa6065557927a629ce39d1
7
- data.tar.gz: 82b3308ced5f38ace3d7e4f170b1beb77d0559f24a67cbfb91174e39de67ef9e66831a8d1a6a9fa03a2def2772459a821833aa684b45bd6f8a6141443ab36113
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,19 +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` 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`.
57
-
58
- Pass a string to render something else instead:
59
-
60
- ```erb
61
- <%= form_for @widget, dynamic: "shared/refresh_widget" %>
62
- ```
63
-
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.
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.
67
49
 
68
50
  ### `dynamic_trigger:` on a field
69
51
 
@@ -77,30 +59,6 @@ checkbox, `input` for a text field, `click` for a button. Name an event when you
77
59
  want something else — `:blur` on text fields is usually what you want, since the
78
60
  default fires on every keystroke.
79
61
 
80
- #### Sending the form somewhere else
81
-
82
- A trigger can answer to a different endpoint than its own form, and can add to
83
- what the form sends:
84
-
85
- ```erb
86
- <%= f.button "Run", type: "button", dynamic_trigger: {
87
- event: :click,
88
- url: formula_preview_widgets_path,
89
- params: { attribute: :rafter_count }
90
- } %>
91
- ```
92
-
93
- `url:` replaces the form's own endpoint for that one trigger; everything else on
94
- the form keeps using the form's. `params:` are appended to the submitted form
95
- data, so one endpoint can tell which of several triggers asked. Both are
96
- optional, and either can be given without the other.
97
-
98
- That endpoint is yours, not the gem's — it's an ordinary action rendering an
99
- ordinary turbo_stream template, so the whole form arrives as `params` under its
100
- usual scope. Use it when one form feeds several actions: a dozen fields that
101
- each preview themselves, or a select that belongs to a nested form with its own
102
- controller.
103
-
104
62
  Works on every Rails field helper, including the select and date families where
105
63
  Rails keeps HTML attributes in a separate hash:
106
64
 
@@ -118,78 +76,61 @@ inherits from, so it works without SimpleForm being involved at all:
118
76
  = f.input :category, input_html: { dynamic_trigger: true }
119
77
  ```
120
78
 
121
- ## What the endpoint does
122
-
123
- A `dynamic: true` form carries a signed description of itself to a single
124
- endpoint the gem draws into your routes. Given a valid signature it:
125
-
126
- 1. permits the submitted parameters wholesale,
127
- 2. builds a **new, unsaved** instance of the form's class from them,
128
- 3. assigns it to `@resource`,
129
- 4. calls `TurboForm.before_render` with the controller and the resource, if one is set,
130
- 5. renders the turbo_stream template.
131
-
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.
135
-
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.
139
-
140
- ## Configuration
79
+ ## What a reload does
141
80
 
142
- ```ruby
143
- # config/initializers/turbo_form.rb
144
-
145
- # The endpoint inherits from this, so your authentication applies to it. Point
146
- # it at a narrower controller when that inheritance brings something the
147
- # endpoint shouldn't have.
148
- 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:
149
84
 
150
- # Draw the route yourself instead.
151
- 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.
152
90
 
153
- # Run something before the render. See below.
154
- TurboForm.before_render = ->(controller, resource) { }
155
- ```
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.
156
94
 
157
- ### `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.
158
98
 
159
- The endpoint is a controller you never wrote, inheriting filters written for
160
- controllers that save things. `before_render` is where you get to treat it like
161
- 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.
99
+ ### Needing the values before the form
163
100
 
164
- Satisfy a filter the endpoint can't — the most common need, since nothing here
165
- 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:
166
105
 
167
106
  ```ruby
168
- # Pundit's after_action :verify_authorized
169
- TurboForm.before_render = ->(controller, resource) { controller.skip_authorization }
170
-
171
- # Action Policy's verify_authorized
172
- TurboForm.before_render = ->(controller, resource) { controller.skip_verify_authorized! }
107
+ def new
108
+ @widget = turbo_form_assign(Widget.new)
109
+ end
173
110
  ```
174
111
 
175
- Or authorize it for real, if re-rendering a form is something you'd rather gate.
176
- Name the query explicitly: the endpoint's action is `update`, which is not the
177
- 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.
178
115
 
179
- ```ruby
180
- # Pundit
181
- TurboForm.before_render = ->(controller, resource) { controller.authorize(resource, :edit?) }
116
+ ### Switching an STI subclass
182
117
 
183
- # Action Policy
184
- TurboForm.before_render = ->(controller, resource) { controller.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.
185
123
 
186
- # CanCanCan -- this also satisfies `check_authorization`, which has no runtime skip
187
- TurboForm.before_render = ->(controller, resource) { controller.authorize!(:edit, resource) }
188
- ```
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
189
129
 
190
- Raising works the way it does in any `before_action`: your
191
- `rescue_from`s catch it, since the endpoint inherits them too. Rendering or
192
- redirecting from the controller 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.
193
134
 
194
135
  ## JavaScript
195
136
 
@@ -228,7 +169,7 @@ To do it by hand, write that file yourself and run `rails stimulus:manifest:upda
228
169
 
229
170
  ### Testing against it
230
171
 
231
- 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
232
173
  trigger and the wait comes with it:
233
174
 
234
175
  ```ruby
@@ -237,18 +178,23 @@ select "Barrel Aged", from: "Flavor"
237
178
  ```
238
179
 
239
180
  `expect_dynamic_form_request` is available in system tests with nothing to
240
- require or include — Minitest and RSpec both. It waits on the controller's
241
- count of completed round trips rather than on the fields that came back, so it
242
- doesn't care what the response changed. Scope it with Capybara's `within` when
243
- 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.
244
184
 
245
185
  ## What this deliberately doesn't do
246
186
 
247
- 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,
251
- 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.
252
198
 
253
199
  ## License
254
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,37 +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.name,
18
- scope: scope.to_s,
19
- template: (dynamic unless dynamic == true)
20
- )
21
-
22
- wire_dynamic_form(options, signature)
23
- super(model:, scope:, url:, format:, **options, &block)
10
+ wire_dynamic_form(options) if options[:dynamic]
11
+ super
24
12
  end
25
13
 
26
14
  private
27
15
  # `form_for` funnels HTML attributes through options[:html] while `form_with`
28
16
  # takes them at the top level. Write wherever the caller already is, and sit
29
17
  # alongside any Stimulus controller they asked for rather than replacing it.
30
- def wire_dynamic_form(options, signature)
18
+ def wire_dynamic_form(options)
31
19
  attributes = options.key?(:html) ? (options[:html] ||= {}) : options
32
20
  data = attributes[:data] ||= {}
33
21
 
34
22
  data[:controller] = [ data[:controller], "turbo-form" ].compact.join(" ")
35
- data[:turbo_form_url_value] = turbo_form_path(signature)
36
23
  end
37
24
  end
38
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.3.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
- # 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.
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.3.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,33 +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 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.
8
- class DynamicFormsController < TurboForm.parent_controller.constantize
9
- rescue_from TurboForm::Signature::Invalid do
10
- head :bad_request
11
- end
12
-
13
- before_action :build_resource
14
- before_action { TurboForm.before_render&.call(self, @resource) }
15
-
16
- def update
17
- render template: signature.template_for(@resource), formats: :turbo_stream
18
- end
19
-
20
- private
21
- def build_resource
22
- @resource = signature.model.new(form_params)
23
- end
24
-
25
- def signature
26
- @signature ||= TurboForm::Signature.verify(params[:signature])
27
- end
28
-
29
- def form_params
30
- params.fetch(signature.scope, {}).permit!
31
- end
32
- end
33
- 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,43 +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
- # 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.
12
- class Signature
13
- class Invalid < StandardError; end
14
-
15
- def self.verify(token)
16
- payload = TurboForm.verifier.verified(token.to_s)
17
- raise Invalid, "not a signature this application generated" unless payload
18
-
19
- new(**payload.symbolize_keys)
20
- end
21
-
22
- attr_reader :model_name, :scope, :template
23
-
24
- def initialize(model_name:, scope:, template: nil)
25
- @model_name = model_name
26
- @scope = scope
27
- @template = template
28
- end
29
-
30
- def model = model_name.constantize
31
-
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")
36
- end
37
-
38
- def to_s
39
- TurboForm.verifier.generate({ model_name:, scope:, template: })
40
- end
41
- alias to_param to_s
42
- end
43
- end