turbo_form 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9cee9bf341b5d0a6bf59ac5334065a7c71f66c0cfe99636471af207054c6484
4
- data.tar.gz: 6d60f46ff0e7e74e43ba80a6db207a7fc5d30cf79afb0a7baf5ca8fa3a08fad5
3
+ metadata.gz: 1ea880fbc0453b32344bf09706c5bc68c095de36fdbea018f00fdf4adc7acc03
4
+ data.tar.gz: 49bf3d131c1a1179d8ce8b64772e23681f1c54332797bca9cd46589c4d0c90d3
5
5
  SHA512:
6
- metadata.gz: 87318b7b8397c9e71d1754cea8b527f7d813865b6ad8d4101183494f8788815b1f77ea3a22053b0cfe7ec2418a9f644b21380bde114839ba98adf6b38d155241
7
- data.tar.gz: b62767b018f4c05fce71f7af5060d68526d0c4bfb3f47d6b872405f89c879489f4d4320b93a09d2e55f452b3c158b3ac0af4520d8567788e6d32ee3548d5dbdd
6
+ metadata.gz: 300aabd3ad77727c57ddf99980ce56036e4f5fdbf6c001a31908dcef1f0f53408b4100cf21fa1b88abfd663f2adab10dd14003d90e1915136231e62576b1e2d6
7
+ data.tar.gz: 24ec9eabed896e9de6f14c71b70e3f6aa8bc299244fcf3858146b55bf5d78c76549e4e08acdaaa6c0f77b13b6713183fabc2c469347c3a59df7e7dd477ed6ff9
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,19 @@ 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 form is sent to the page it is on. That page's own
18
+ action builds `@widget`, the form's values are assigned to it, and the page's
19
+ own template renders it — so `@widget.flavors` answers for the category just
20
+ picked. Turbo morphs the page in place, so focus, scroll and everything else
21
+ typed survive.
31
22
 
32
- That's the whole feature. No route, no controller, no JavaScript.
23
+ No new action, no template, no JavaScript to write. The page is rendered by its
24
+ own controller, so its authentication, authorization and partials are the ones
25
+ it always had.
33
26
 
34
27
  ## Installation
35
28
 
@@ -37,72 +30,61 @@ That's the whole feature. No route, no controller, no JavaScript.
37
30
  gem "turbo_form"
38
31
  ```
39
32
 
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:
33
+ Then run the installer:
43
34
 
44
35
  ```bash
45
36
  rails generate turbo_form:install
46
37
  ```
47
38
 
48
- See [JavaScript](#javascript) for what that does and how to do it by hand.
39
+ It includes `TurboForm::Controller` in your ApplicationController and declares
40
+ the route concern:
49
41
 
50
- ## The two options
51
-
52
- ### `dynamic:` on the form
53
-
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:
42
+ ```ruby
43
+ class ApplicationController < ActionController::Base
44
+ include TurboForm::Controller
45
+ end
46
+ ```
57
47
 
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.
48
+ ```ruby
49
+ concern :turbo_form, TurboForm::Routes
50
+ ```
64
51
 
65
- To render a different template, pass its name instead of `true`:
52
+ Draw the concern on each resource with a dynamic form — the installer leaves
53
+ which ones to you:
66
54
 
67
- ```erb
68
- <%= form_for @widget, dynamic: "shared/refresh_widget" %>
55
+ ```ruby
56
+ resources :widgets, concerns: :turbo_form
69
57
  ```
70
58
 
71
- ### `dynamic_trigger:` on a field
59
+ It draws a PATCH beside whichever of `new` and `edit` the resource has, so
60
+ `resources :widgets, only: %i[new create], concerns: :turbo_form` gets only the
61
+ one for `new`.
72
62
 
73
- ```erb
74
- <%= f.text_field :name, dynamic_trigger: true %> <%# on the default event %>
75
- <%= f.text_field :name, dynamic_trigger: :blur %> <%# on a named event %>
76
- ```
63
+ It also imports the script into `app/javascript/application.js`, adding the npm
64
+ package first if your app bundles with esbuild, Vite, Bun or webpack; see
65
+ [JavaScript](#javascript) for doing that by hand.
66
+
67
+ ## The two options
77
68
 
78
- `true` lets Stimulus pick the element's natural event: `change` for a select or
79
- checkbox, `input` for a text field, `click` for a button. Name an event when you
80
- want something else — `:blur` on text fields is usually what you want, since the
81
- default fires on every keystroke.
69
+ ### `dynamic:` on the form
82
70
 
83
- #### Sending the form somewhere else
71
+ `dynamic: true` makes the form dynamic: a trigger sends it to the `new` or `edit`
72
+ page of its model — `edit` once the record is saved — which is where
73
+ `TurboForm::Routes` listens.
84
74
 
85
- A trigger can answer to a different endpoint than its own form, and can add to
86
- what the form sends:
75
+ ### `dynamic_trigger:` on a field
87
76
 
88
77
  ```erb
89
- <%= f.button "Run", type: "button", dynamic_trigger: {
90
- event: :click,
91
- url: formula_preview_widgets_path,
92
- params: { attribute: :rafter_count }
93
- } %>
78
+ <%= f.text_field :name, dynamic_trigger: true %> <%# on the default event %>
79
+ <%= f.text_field :name, dynamic_trigger: :blur %> <%# on a named event %>
94
80
  ```
95
81
 
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.
82
+ `true` picks the element's natural event: `change` for a select, `click` for a
83
+ submit button, `input` for everything else. Name an event when you want
84
+ something else — `:blur` on text fields is usually what you want, since the
85
+ default fires on every keystroke. Any event name works, including custom ones
86
+ dispatched by other Stimulus controllers — `dynamic_trigger: "autocomplete:selected"`
87
+ — as long as the event is dispatched on the field or bubbles up from inside it.
106
88
 
107
89
  Works on every Rails field helper, including the select and date families where
108
90
  Rails keeps HTML attributes in a separate hash:
@@ -121,124 +103,90 @@ inherits from, so it works without SimpleForm being involved at all:
121
103
  = f.input :category, input_html: { dynamic_trigger: true }
122
104
  ```
123
105
 
124
- ## What the endpoint does
106
+ ## What a trigger does
125
107
 
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:
108
+ A trigger PATCHes the whole form to its own page — `/widgets/new` or
109
+ `/widgets/:id/edit` — which the route concern sends to that page's own action.
110
+ The request:
128
111
 
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.
112
+ 1. runs `new` or `edit` as an ordinary visit would, callbacks and all, building
113
+ `@widget` the way it always does — found by id, built from a parent,
114
+ whatever it does,
115
+ 2. assigns `widget_params` to it,
116
+ 3. renders the page's own template,
137
117
 
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.
118
+ all inside a database transaction that is always rolled back.
142
119
 
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.
120
+ Because the values are assigned in the controller, before anything renders, the
121
+ whole page sees what was typed — the layout, the template above the form, and
122
+ the form itself.
148
123
 
149
- ## Configuration
124
+ The rollback is there because Active Record saves some assignments on the spot
125
+ (`has_many` writers, `*_ids=`). A trigger exists to render, so nothing it does is
126
+ kept — including anything else the action or its callbacks write.
150
127
 
151
- ```ruby
152
- # config/initializers/turbo_form.rb
128
+ ### The conventions it leans on
153
129
 
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"
130
+ turbo_form knows nothing about your resource beyond its controller's name.
131
+ For a `WidgetsController`:
158
132
 
159
- # Draw the route yourself instead.
160
- TurboForm.draw_routes = false
133
+ - `new` and `edit` set `@widget`,
134
+ - `widget_params` permits the form's fields — the same method `create` and
135
+ `update` already use, so a trigger assigns nothing a save wouldn't,
136
+ - `new` and `edit` leave rendering to Rails. The values are assigned just before
137
+ Rails' implicit render, so an action that calls `render` itself renders its
138
+ object as it built it, and one that redirects still redirects.
161
139
 
162
- # Run something before the render. See below.
163
- TurboForm.before_render = ->(resource) { }
164
- ```
140
+ ### Switching an STI subclass
165
141
 
166
- ### `before_render`
142
+ When the submitted values include the inheritance column, the object is switched
143
+ to the subclass it names, with `becomes`, before assigning — so a `type` select
144
+ can turn a `Gizmo` into a `Doohickey` and the rest of the page answers as one.
145
+ Give the form its base class's scope (`scope: :gadget`) so the switched object
146
+ reads and posts under the same name, and permit `type` in the params method.
167
147
 
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.
148
+ ### Inside a Turbo Frame
173
149
 
174
- Satisfy a filter the endpoint can't — the most common need, since nothing here
175
- is authorized because nothing here is saved:
150
+ A form inside a `<turbo-frame>`, or one naming a frame with
151
+ `data-turbo-frame`, asks for that frame alone: the request carries the
152
+ `Turbo-Frame` header, turbo-rails renders without the layout, and only the frame
153
+ is morphed. A frame's own `target` is followed, and `_top` means the whole page,
154
+ as they do for Turbo. While the request is out, the form and its frame are `aria-busy`, and the
155
+ frame `busy`, the way Turbo marks a submission.
176
156
 
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! }
183
- ```
157
+ ### After a failed save
184
158
 
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.
159
+ A trigger sends the form to its model's `new` or `edit` page, not to whatever
160
+ URL the browser is on, so a trigger after rendering `:new` from a failed
161
+ `create` still reaches the right action. The object it builds is fresh and has
162
+ not been validated, so the error messages go away.
203
163
 
204
164
  ## JavaScript
205
165
 
206
- **importmap-rails** — nothing to do. The engine pins its controller as
207
- `controllers/turbo_form_controller`, which the `eagerLoadControllersFrom` /
208
- `lazyLoadControllersFrom` in a stock `app/javascript/controllers/index.js`
209
- registers as `turbo-form` on its own.
210
-
211
- If you hand-wrote that file, register it yourself:
166
+ There is no Stimulus controller to register. The script listens on the document
167
+ for triggers, so it only has to be imported once:
212
168
 
213
169
  ```js
214
- import TurboFormController from "controllers/turbo_form_controller"
215
- application.register("turbo-form", TurboFormController)
170
+ // app/javascript/application.js
171
+ import "turbo_form" // importmap-rails: the engine pins it
172
+ import "@rolemodel/turbo-form" // esbuild, Vite, Bun, webpack
216
173
  ```
217
174
 
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
175
+ Bundled apps install the npm package alongside the gem, at the same version.
176
+ `rails generate turbo_form:install` does that with whichever package manager
177
+ your lockfile names, and adds the import.
178
+
179
+ To send a form from your own code, import `refresh`:
222
180
 
223
181
  ```js
224
- // app/javascript/controllers/turbo_form_controller.js
225
- import TurboFormController from "@rolemodel/turbo-form"
182
+ import { refresh } from "turbo_form"
226
183
 
227
- export default TurboFormController
184
+ refresh(document.querySelector("form[data-turbo-form-url]"))
228
185
  ```
229
186
 
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
-
239
187
  ### Testing against it
240
188
 
241
- A re-render is a round trip, so the line after a trigger is racing it. Wrap the
189
+ A trigger is a round trip, so the line after it is racing it. Wrap the
242
190
  trigger and the wait comes with it:
243
191
 
244
192
  ```ruby
@@ -247,16 +195,26 @@ select "Barrel Aged", from: "Flavor"
247
195
  ```
248
196
 
249
197
  `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.
198
+ require or include — Minitest and RSpec both. It waits on a count of
199
+ completed triggers kept on `<html>` rather than on the fields that came back, so
200
+ it doesn't care what the trigger changed.
254
201
 
255
202
  ## What this deliberately doesn't do
256
203
 
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.
204
+ It handles the common case well and gets out of the way otherwise.
205
+
206
+ - **No debouncing, request cancellation or loading state** beyond `aria-busy`.
207
+ Write the action by hand when you need those — that path is still open.
208
+ - **Resourceful pages only.** The form's URL comes from its model, so a page
209
+ whose `new` or `edit` isn't the model's own route has nowhere to send it.
210
+ - **Not a Turbo form submission.** Turbo renders a form's response only when it
211
+ redirects or fails, so the trigger fetches the form itself and hands the
212
+ response to a Turbo visit. Turbo renders the page — its morph, render events,
213
+ error page and progress bar — but there are no `turbo:submit-start`,
214
+ `turbo:submit-end` or `turbo:before-fetch-request` events. A frame is morphed
215
+ directly, without Turbo's frame render events.
216
+ - **Only the primary database is rolled back**, and only writes: jobs enqueued
217
+ or mail sent during a trigger still happen.
260
218
 
261
219
  ## License
262
220
 
@@ -1,44 +1,156 @@
1
- import { Controller } from "@hotwired/stimulus"
2
1
  import { Turbo } from "@hotwired/turbo-rails"
3
2
 
4
- // Re-renders the form it is attached to, from the server, as a Turbo Stream.
3
+ // PATCHes the form, as it stands, to the page it is on. The server runs that
4
+ // page's own action, assigns the form to its object and renders its own
5
+ // template, which is morphed in so focus, scroll and everything typed survive.
6
+ // Inside a frame, only the frame is asked for and morphed, as Turbo would.
5
7
  //
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.
9
- export default class extends Controller {
10
- static values = { url: String, requests: Number }
11
-
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, {
21
- method: "PATCH",
22
- headers: this.#headers,
23
- body
8
+ // Fetched rather than submitted through Turbo, which renders a form response
9
+ // only if it redirects or fails. The page is still rendered by Turbo: a visit
10
+ // handed the response, so its events, error page and progress bar all apply. A
11
+ // frame is morphed directly, since Turbo's frame loading wants its own
12
+ // unexported response object.
13
+ export function refresh(form) {
14
+ return new FormRefresh(form).start()
15
+ }
16
+
17
+ // Shaped like Turbo's FormSubmission, which is what its navigator expects to be
18
+ // told about.
19
+ class FormRefresh {
20
+ constructor(formElement) {
21
+ this.formElement = formElement
22
+ this.location = new URL(formElement.dataset.turboFormUrl, location.href)
23
+ this.target = targetFor(formElement)
24
+ }
25
+
26
+ async start() {
27
+ this.requestStarted()
28
+ try {
29
+ const response = await Turbo.fetch(this.location, { method: "PATCH", body: new FormData(this.formElement), headers: this.target.headers })
30
+
31
+ this.target.render(response.status, await response.text())
32
+ } finally {
33
+ this.requestFinished()
34
+ }
35
+ }
36
+
37
+ requestStarted() {
38
+ markAsBusy(this.formElement, ...this.target.elements)
39
+ Turbo.navigator.formSubmissionStarted(this)
40
+ }
41
+
42
+ requestFinished() {
43
+ clearBusyState(this.formElement, ...this.target.elements)
44
+ Turbo.navigator.formSubmissionFinished(this)
45
+ }
46
+ }
47
+
48
+ class PageTarget {
49
+ elements = []
50
+ headers = { Accept: "text/html" }
51
+
52
+ render(statusCode, responseHTML) {
53
+ document.addEventListener("turbo:load", countVisit, { once: true })
54
+ Turbo.visit(location.href, {
55
+ action: "replace",
56
+ shouldCacheSnapshot: false,
57
+ refresh: { method: "morph", scroll: "preserve" },
58
+ response: { statusCode, responseHTML }
24
59
  })
60
+ }
61
+ }
62
+
63
+ class FrameTarget {
64
+ constructor(element) {
65
+ this.element = element
66
+ this.elements = [ element ]
67
+ }
68
+
69
+ get headers() {
70
+ return { Accept: "text/html", "Turbo-Frame": this.element.id }
71
+ }
72
+
73
+ render(_statusCode, responseHTML) {
74
+ const frame = new DOMParser().parseFromString(responseHTML, "text/html").getElementById(this.element.id)
25
75
 
26
- if (!response.ok) return
76
+ Turbo.morphTurboFrameElements(this.element, frame)
77
+ countVisit()
78
+ }
79
+ }
80
+
81
+ // Turbo's own targeting: the form's data-turbo-frame, then the target of the
82
+ // frame it sits in, then that frame itself. "_top" means the page.
83
+ function targetFor(form) {
84
+ const enclosing = form.closest("turbo-frame")
85
+ const id = form.dataset.turboFrame || enclosing?.getAttribute("target")
86
+ const frame = id === "_top" ? null : id ? document.getElementById(id) : enclosing
87
+
88
+ return frame ? new FrameTarget(frame) : new PageTarget()
89
+ }
90
+
91
+ const triggerSelector = "[data-turbo-form-trigger]"
92
+
93
+ // Shaped like Turbo's observers. One listener per event the page's triggers
94
+ // name, on the event target and in the capture phase, so fields rendered later
95
+ // are covered and events that don't bubble, like blur, still arrive. New names
96
+ // are picked up as triggers appear; the browser ignores a listener it already has.
97
+ class TriggerObserver {
98
+ constructor(delegate, eventTarget) {
99
+ this.delegate = delegate
100
+ this.eventTarget = eventTarget
101
+ this.mutationObserver = new MutationObserver(this.listen)
102
+ }
103
+
104
+ start() {
105
+ this.listen()
106
+ this.mutationObserver.observe(this.eventTarget, { subtree: true, childList: true, attributeFilter: [ "data-turbo-form-trigger" ] })
107
+ }
27
108
 
28
- Turbo.renderStreamMessage(await response.text())
109
+ listen = () => {
110
+ for (const trigger of this.eventTarget.querySelectorAll(triggerSelector)) {
111
+ this.eventTarget.addEventListener(eventFor(trigger), this.triggerCaptured, true)
112
+ }
113
+ }
114
+
115
+ triggerCaptured = ({ type, target }) => {
116
+ const trigger = target.closest?.(triggerSelector)
117
+ const form = trigger?.form
29
118
 
30
- // Lets a system test wait on a completed round trip instead of sleeping.
31
- this.requestsValue++
119
+ if (form?.dataset.turboFormUrl && eventFor(trigger) == type) this.delegate.triggerFired(form)
32
120
  }
121
+ }
33
122
 
34
- get #headers() {
35
- const headers = { Accept: "text/vnd.turbo-stream.html" }
36
- const token = document.querySelector("meta[name=csrf-token]")?.content
123
+ // A trigger with no event named fires on its element's natural one.
124
+ function eventFor(trigger) {
125
+ const named = trigger.dataset.turboFormTrigger
126
+ if (named) return named
127
+ if (trigger.localName == "select") return "change"
128
+ if (trigger.type == "submit") return "click"
129
+
130
+ return "input"
131
+ }
37
132
 
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
133
+ // Turbo's own busy state, which it doesn't export.
134
+ function markAsBusy(...elements) {
135
+ for (const element of elements) {
136
+ if (element.localName == "turbo-frame") element.setAttribute("busy", "")
137
+ element.setAttribute("aria-busy", "true")
138
+ }
139
+ }
41
140
 
42
- return headers
141
+ function clearBusyState(...elements) {
142
+ for (const element of elements) {
143
+ if (element.localName == "turbo-frame") element.removeAttribute("busy")
144
+ element.removeAttribute("aria-busy")
43
145
  }
44
146
  }
147
+
148
+ // Kept on <html> because a morph rewrites <body> to what the server sent, and
149
+ // the server never knows the count. Lets a system test wait on a completed
150
+ // reload instead of sleeping.
151
+ function countVisit() {
152
+ const root = document.documentElement.dataset
153
+ root.turboFormVisits = Number(root.turboFormVisits || 0) + 1
154
+ }
155
+
156
+ new TriggerObserver({ triggerFired: refresh }, document).start()
@@ -1,5 +1,3 @@
1
- # Keyed as `controllers/..._controller` so the stock
2
- # `eagerLoadControllersFrom("controllers", application)` in a Rails app finds it
3
- # and registers it as `turbo-form`, with nothing asked of the host. The asset
4
- # name is distinct so the host's own app/javascript/controllers can't shadow it.
5
- pin "controllers/turbo_form_controller", to: "turbo_form.js"
1
+ # Imported once by the host's application.js, which starts its listeners. The
2
+ # asset name is distinct so nothing in the host's app/javascript can shadow it.
3
+ pin 'turbo_form', to: 'turbo_form.js'
@@ -1,62 +1,56 @@
1
- require "rails/generators"
1
+ require 'rails/generators'
2
2
 
3
3
  module TurboForm
4
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.
5
+ # Every app gets TurboForm::Controller, the route concern its resources opt
6
+ # into and an import of the script. On importmap-rails the engine pins the
7
+ # script itself; bundled apps get the npm package to import instead.
9
8
  class InstallGenerator < Rails::Generators::Base
10
- source_root File.expand_path("templates", __dir__)
11
-
12
9
  LOCKFILES = {
13
- "yarn.lock" => "yarn add", "pnpm-lock.yaml" => "pnpm add",
14
- "bun.lock" => "bun add", "bun.lockb" => "bun add"
10
+ 'yarn.lock' => 'yarn add', 'pnpm-lock.yaml' => 'pnpm add',
11
+ 'bun.lock' => 'bun add', 'bun.lockb' => 'bun add'
15
12
  }.freeze
16
13
 
14
+ ENTRYPOINT = 'app/javascript/application.js'
15
+
17
16
  def install
18
- 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
17
+ include_controller
18
+ declare_route_concern
19
+ install_package unless importmap?
20
+ import_script
26
21
  end
27
22
 
28
23
  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
24
+ def include_controller
25
+ inject_into_class 'app/controllers/application_controller.rb', 'ApplicationController', " include TurboForm::Controller\n"
26
+ end
27
+
28
+ # Declared once, drawn on no resource: which forms are dynamic is the
29
+ # app's call, one `concerns: :turbo_form` at a time.
30
+ def declare_route_concern
31
+ route 'concern :turbo_form, TurboForm::Routes'
32
+ end
33
+
34
+ def importmap? = Rails.root.join('config/importmap.rb').exist?
35
+
36
+ def install_package
37
+ run "#{package_manager} #{package}"
38
+ end
39
+
40
+ def import_script
41
+ import = %(import "#{importmap? ? "turbo_form" : "@rolemodel/turbo-form"}"\n)
42
+ return say("Add #{import.strip} to your JavaScript entrypoint.") unless Rails.root.join(ENTRYPOINT).exist?
43
+
44
+ append_to_file ENTRYPOINT, import
45
+ end
46
+
47
+ def package = "@rolemodel/turbo-form@#{TurboForm::VERSION}"
48
+
49
+ # Matched to the lockfile that is already there, so the generator doesn't
50
+ # introduce a second package manager to an app that settled on one.
51
+ def package_manager
52
+ LOCKFILES.find { |lockfile, _| Rails.root.join(lockfile).exist? }&.last || 'npm install'
53
+ end
60
54
  end
61
55
  end
62
56
  end
@@ -0,0 +1,60 @@
1
+ module TurboForm
2
+ # Included into the host's ApplicationController. A request that
3
+ # TurboForm::Routes marks runs `new` or `edit` as usual, callbacks and all,
4
+ # then assigns the form to what the action built just before the implicit
5
+ # render.
6
+ #
7
+ # Leans on the scaffold's conventions: the action builds `@<resource>`,
8
+ # `<resource>_params` permits the form, and the action leaves rendering to Rails.
9
+ module Controller
10
+ private
11
+ def process_action(...)
12
+ dynamic_form? ? discarding_writes { super } : super
13
+ end
14
+
15
+ def default_render
16
+ dynamic_form_assignment if dynamic_form?
17
+ super
18
+ end
19
+
20
+ def dynamic_form_assignment
21
+ resource = turbo_form_resource.extend(TurboForm::Resource).dynamic_assign(turbo_form_resource_params)
22
+ instance_variable_set(:"@#{turbo_form_resource_name}", resource)
23
+ end
24
+
25
+ def dynamic_form? = request.path_parameters[:dynamic_form]
26
+ def turbo_form_resource = instance_variable_get(:"@#{turbo_form_resource_name}")
27
+ def turbo_form_resource_params = send(:"#{turbo_form_resource_name}_params")
28
+ def turbo_form_resource_name = controller_name.singularize
29
+
30
+ # Active Record saves `has_many` writers and `*_ids=` on assignment, and
31
+ # this request exists only to render.
32
+ def discarding_writes
33
+ return yield unless defined?(ActiveRecord::Base)
34
+
35
+ ActiveRecord::Base.transaction do
36
+ yield
37
+ raise ActiveRecord::Rollback
38
+ end
39
+ end
40
+ end
41
+
42
+ # Switches to the STI subclass the submitted `type` names before assigning, so
43
+ # nested records land on the object that is kept -- which is returned.
44
+ module Resource
45
+ def dynamic_assign(params)
46
+ retype(params).tap { it.assign_attributes(params) }
47
+ end
48
+
49
+ private
50
+ def retype(params)
51
+ type = inheritance_column && params[inheritance_column]
52
+ return self if type.blank?
53
+
54
+ subclass = self.class.sti_class_for(type)
55
+ instance_of?(subclass) ? self : becomes!(subclass)
56
+ end
57
+
58
+ def inheritance_column = self.class.try(:inheritance_column)
59
+ end
60
+ end
@@ -1,33 +1,30 @@
1
- require "turbo-rails"
2
- require "turbo_form/form_helper"
3
- require "turbo_form/form_builder"
4
- require "turbo_form/signature"
1
+ require 'turbo-rails'
2
+ require 'turbo_form/form_helper'
3
+ require 'turbo_form/form_builder'
4
+ require 'turbo_form/routes'
5
+ require 'turbo_form/controller'
5
6
 
6
7
  module TurboForm
7
- # 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
8
  class Engine < ::Rails::Engine
12
9
  # importmap-rails reads config.importmap.paths exactly once, in its own
13
10
  # `importmap` initializer, and draws the host's pins last -- so appending
14
11
  # here both registers ours and leaves the host able to override them.
15
12
  # The guard keeps esbuild/vite/bun hosts booting; a `before:` naming an
16
13
  # initializer that doesn't exist is itself harmless.
17
- initializer "turbo_form.importmap", before: "importmap" do |app|
14
+ initializer 'turbo_form.importmap', before: 'importmap' do |app|
18
15
  next unless app.config.respond_to?(:importmap)
19
16
 
20
- app.config.importmap.paths << root.join("config/turbo_form_importmap.rb")
21
- app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
17
+ app.config.importmap.paths << root.join('config/turbo_form_importmap.rb')
18
+ app.config.importmap.cache_sweepers << root.join('app/assets/javascripts')
22
19
  end
23
20
 
24
21
  # Propshaft puts every engine's app/assets/* on the load path by itself.
25
22
  # Sprockets additionally wants the asset named, or the pin above silently
26
23
  # resolves to nothing.
27
- initializer "turbo_form.assets" do |app|
24
+ initializer 'turbo_form.assets' do |app|
28
25
  next unless app.config.respond_to?(:assets)
29
26
 
30
- app.config.assets.precompile << "turbo_form.js"
27
+ app.config.assets.precompile << 'turbo_form.js'
31
28
  end
32
29
 
33
30
  # Minitest's system tests descend from ActionDispatch::SystemTestCase, so
@@ -35,9 +32,9 @@ module TurboForm
35
32
  # assembles itself from Action Dispatch's parts instead of inheriting the
36
33
  # case -- so they need telling separately, once RSpec is loaded but before
37
34
  # any example group has been defined.
38
- initializer "turbo_form.system_test_helper" do
35
+ initializer 'turbo_form.system_test_helper' do
39
36
  ActiveSupport.on_load(:action_dispatch_system_test_case) do
40
- require "turbo_form/system_test_helper"
37
+ require 'turbo_form/system_test_helper'
41
38
 
42
39
  include TurboForm::SystemTestHelper
43
40
  end
@@ -46,7 +43,7 @@ module TurboForm
46
43
  config.after_initialize do
47
44
  next unless defined?(RSpec.configure)
48
45
 
49
- require "turbo_form/system_test_helper"
46
+ require 'turbo_form/system_test_helper'
50
47
 
51
48
  RSpec.configure do |config|
52
49
  config.include TurboForm::SystemTestHelper, type: :system
@@ -57,8 +54,8 @@ module TurboForm
57
54
  # hook doesn't fire until Action View is first loaded, which in an app that
58
55
  # isn't eager loading is partway through rendering the first view -- late
59
56
  # enough that the first form on the first request can miss the patch.
60
- initializer "turbo_form.form_helpers" do
61
- require "action_view"
57
+ initializer 'turbo_form.form_helpers' do
58
+ require 'action_view'
62
59
 
63
60
  ActionView::Helpers::FormHelper.prepend(TurboForm::FormHelper)
64
61
  ActionView::Helpers::FormBuilder.prepend(TurboForm::FormBuilder)
@@ -4,7 +4,7 @@ module TurboForm
4
4
  # f.text_field :name, dynamic_trigger: true # re-render on the default event
5
5
  # f.select :category, categories, {}, dynamic_trigger: :blur
6
6
  #
7
- # `dynamic_trigger:` always has to end up as a `data-action`, but where it
7
+ # `dynamic_trigger:` always has to end up as a data attribute, but where it
8
8
  # *arrives* depends on the helper. Most treat their `options` hash as the tag's
9
9
  # HTML attributes; the select and date families keep those in a trailing
10
10
  # `html_options` instead, which is where these overrides earn their keep.
@@ -50,55 +50,37 @@ module TurboForm
50
50
  end
51
51
 
52
52
  private
53
- # Every other field helper -- generated and hand-written alike -- passes its
54
- # attributes through here on the way to the tag.
55
- def objectify_options(options)
56
- super(absorb_trigger(options))
57
- end
58
-
59
- # A trailing `dynamic_trigger:` binds to `options` because Ruby folds bare
60
- # keywords into the first optional positional hash -- but for these helpers
61
- # that hash is the *select's* options, not the tag's. Carry it across.
62
- # SimpleForm arrives on the other side, via `input_html:`, so take it from
63
- # either.
64
- def hoist_trigger(options, html_options)
65
- return [ options, absorb_trigger(html_options) ] unless options.key?(:dynamic_trigger)
66
-
67
- trigger = options[:dynamic_trigger]
68
- [ options.except(:dynamic_trigger), absorb_trigger(html_options.merge(dynamic_trigger: trigger)) ]
69
- end
70
-
71
- def absorb_trigger(attributes)
72
- return attributes unless attributes.key?(:dynamic_trigger)
73
-
74
- trigger = attributes[:dynamic_trigger]
75
- attributes = attributes.except(:dynamic_trigger)
76
- return attributes unless trigger
77
-
78
- event, url, query = destructure_trigger(trigger)
79
-
80
- 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
84
- attributes.merge(data: data)
85
- end
86
-
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"
102
- end
53
+ # Every other field helper -- generated and hand-written alike -- passes its
54
+ # attributes through here on the way to the tag.
55
+ def objectify_options(options)
56
+ super(absorb_trigger(options))
57
+ end
58
+
59
+ # A trailing `dynamic_trigger:` binds to `options` because Ruby folds bare
60
+ # keywords into the first optional positional hash -- but for these helpers
61
+ # that hash is the *select's* options, not the tag's. Carry it across.
62
+ # SimpleForm arrives on the other side, via `input_html:`, so take it from
63
+ # either.
64
+ def hoist_trigger(options, html_options)
65
+ return [ options, absorb_trigger(html_options) ] unless options.key?(:dynamic_trigger)
66
+
67
+ trigger = options[:dynamic_trigger]
68
+ [ options.except(:dynamic_trigger), absorb_trigger(html_options.merge(dynamic_trigger: trigger)) ]
69
+ end
70
+
71
+ def absorb_trigger(attributes)
72
+ return attributes unless attributes.key?(:dynamic_trigger)
73
+
74
+ trigger = attributes[:dynamic_trigger]
75
+ attributes = attributes.except(:dynamic_trigger)
76
+ return attributes unless trigger
77
+
78
+ attributes.merge(data: { **attributes[:data].to_h, turbo_form_trigger: trigger_event(trigger) })
79
+ end
80
+
81
+ # Left blank for `true`, which the script reads as the element's own
82
+ # default: `change` for a select, `input` for a text field, `click` for a
83
+ # button.
84
+ def trigger_event(trigger) = trigger == true ? '' : trigger.to_s
103
85
  end
104
86
  end
@@ -2,49 +2,26 @@ 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, model) 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
- # `form_for` funnels HTML attributes through options[:html] while `form_with`
40
- # takes them at the top level. Write wherever the caller already is, and sit
41
- # alongside any Stimulus controller they asked for rather than replacing it.
42
- def wire_dynamic_form(options, signature)
43
- attributes = options.key?(:html) ? (options[:html] ||= {}) : options
44
- data = attributes[:data] ||= {}
15
+ # `form_for` funnels HTML attributes through options[:html] while `form_with`
16
+ # takes them at the top level. Write wherever the caller already is.
17
+ def wire_dynamic_form(options, model)
18
+ attributes = options.key?(:html) ? (options[:html] ||= {}) : options
19
+ (attributes[:data] ||= {})[:turbo_form_url] = namesake_path(model)
20
+ end
45
21
 
46
- data[:controller] = [ data[:controller], "turbo-form" ].compact.join(" ")
47
- data[:turbo_form_url_value] = turbo_form_path(signature)
48
- end
22
+ # The page the form is on, which TurboForm::Routes answers PATCH on too.
23
+ def namesake_path(model)
24
+ polymorphic_path(model, action: Array(model).last.persisted? ? :edit : :new)
25
+ end
49
26
  end
50
27
  end
@@ -0,0 +1,16 @@
1
+ module TurboForm
2
+ # A callable route concern. Draws a PATCH beside whichever of a resource's `new`
3
+ # and `edit` it has, at the same paths and onto the same actions, marked so the
4
+ # controller renders them from what the form currently holds.
5
+ #
6
+ # concern :turbo_form, TurboForm::Routes
7
+ # resources :widgets, concerns: :turbo_form
8
+ module Routes
9
+ def self.call(mapper, _options = {})
10
+ actions = mapper.send(:parent_resource).actions
11
+
12
+ mapper.new { mapper.patch :new, as: nil, defaults: { dynamic_form: true } } if actions.include?(:new)
13
+ mapper.patch :edit, on: :member, as: nil, defaults: { dynamic_form: true } if actions.include?(:edit)
14
+ end
15
+ end
16
+ end
@@ -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.6.0'
3
3
  end
data/lib/turbo_form.rb CHANGED
@@ -1,39 +1,2 @@
1
- require "active_support/core_ext/module/attribute_accessors"
2
- require "turbo_form/version"
3
- 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
1
+ require 'turbo_form/version'
2
+ require 'turbo_form/engine'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbo_form
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Cohen
@@ -76,17 +76,15 @@ 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
- - lib/generators/turbo_form/install/templates/turbo_form_controller.js
84
81
  - lib/tasks/turbo_form_tasks.rake
85
82
  - lib/turbo_form.rb
83
+ - lib/turbo_form/controller.rb
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/routes.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,4 +0,0 @@
1
- // Registered as `turbo-form` by the Stimulus manifest, which reads this filename.
2
- import TurboFormController from "@rolemodel/turbo-form"
3
-
4
- export default TurboFormController
@@ -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