turbo_form 0.5.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: c69a1768a1d65dd2bdfa4d77ea5e58ea239923d54280820b5197bec043376b91
4
- data.tar.gz: 5958181f35cc5c6cfbdbd50c9a4da9eea692c5670867dfa86d55459355a07c08
3
+ metadata.gz: 1ea880fbc0453b32344bf09706c5bc68c095de36fdbea018f00fdf4adc7acc03
4
+ data.tar.gz: 49bf3d131c1a1179d8ce8b64772e23681f1c54332797bca9cd46589c4d0c90d3
5
5
  SHA512:
6
- metadata.gz: 76dbea8632fa0087c403cbaa95682fae7b16ed8c3370586007e873e4c7ad3277a06f73a436782834ffb8d2b81fa8302a4d7c3e61130e48adbf6dd28ebd53d6a4
7
- data.tar.gz: c89bdf36b175d24708012a869ec06692217a38d8b0d3d889c1692dca95237a7ce0039d020cab93463d34491b452b0a8ce309b2cb969396d0a1662534e358861d
6
+ metadata.gz: 300aabd3ad77727c57ddf99980ce56036e4f5fdbf6c001a31908dcef1f0f53408b4100cf21fa1b88abfd663f2adab10dd14003d90e1915136231e62576b1e2d6
7
+ data.tar.gz: 24ec9eabed896e9de6f14c71b70e3f6aa8bc299244fcf3858146b55bf5d78c76549e4e08acdaaa6c0f77b13b6713183fabc2c469347c3a59df7e7dd477ed6ff9
data/README.md CHANGED
@@ -14,14 +14,15 @@ instead:
14
14
  <% end %>
15
15
  ```
16
16
 
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.
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.
21
22
 
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.
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.
25
26
 
26
27
  ## Installation
27
28
 
@@ -29,23 +30,47 @@ authorization and partials are the ones it always had.
29
30
  gem "turbo_form"
30
31
  ```
31
32
 
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:
33
+ Then run the installer:
35
34
 
36
35
  ```bash
37
36
  rails generate turbo_form:install
38
37
  ```
39
38
 
40
- 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:
41
+
42
+ ```ruby
43
+ class ApplicationController < ActionController::Base
44
+ include TurboForm::Controller
45
+ end
46
+ ```
47
+
48
+ ```ruby
49
+ concern :turbo_form, TurboForm::Routes
50
+ ```
51
+
52
+ Draw the concern on each resource with a dynamic form — the installer leaves
53
+ which ones to you:
54
+
55
+ ```ruby
56
+ resources :widgets, concerns: :turbo_form
57
+ ```
58
+
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`.
62
+
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.
41
66
 
42
67
  ## The two options
43
68
 
44
69
  ### `dynamic:` on the form
45
70
 
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.
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.
49
74
 
50
75
  ### `dynamic_trigger:` on a field
51
76
 
@@ -54,10 +79,12 @@ renders.
54
79
  <%= f.text_field :name, dynamic_trigger: :blur %> <%# on a named event %>
55
80
  ```
56
81
 
57
- `true` lets Stimulus pick the element's natural event: `change` for a select or
58
- checkbox, `input` for a text field, `click` for a button. Name an event when you
59
- want something else — `:blur` on text fields is usually what you want, since the
60
- default fires on every keystroke.
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.
61
88
 
62
89
  Works on every Rails field helper, including the select and date families where
63
90
  Rails keeps HTML attributes in a separate hash:
@@ -76,100 +103,90 @@ inherits from, so it works without SimpleForm being involved at all:
76
103
  = f.input :category, input_html: { dynamic_trigger: true }
77
104
  ```
78
105
 
79
- ## What a reload does
106
+ ## What a trigger does
80
107
 
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:
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:
84
111
 
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.
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,
90
117
 
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.
118
+ all inside a database transaction that is always rolled back.
94
119
 
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.
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.
98
123
 
99
- ### Needing the values before the form
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.
100
127
 
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:
128
+ ### The conventions it leans on
105
129
 
106
- ```ruby
107
- def new
108
- @widget = turbo_form_assign(Widget.new)
109
- end
110
- ```
130
+ turbo_form knows nothing about your resource beyond its controller's name.
131
+ For a `WidgetsController`:
111
132
 
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.
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.
115
139
 
116
140
  ### Switching an STI subclass
117
141
 
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.
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.
123
147
 
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.
148
+ ### Inside a Turbo Frame
149
+
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.
127
156
 
128
157
  ### After a failed save
129
158
 
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.
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.
134
163
 
135
164
  ## JavaScript
136
165
 
137
- **importmap-rails** — nothing to do. The engine pins its controller as
138
- `controllers/turbo_form_controller`, which the `eagerLoadControllersFrom` /
139
- `lazyLoadControllersFrom` in a stock `app/javascript/controllers/index.js`
140
- registers as `turbo-form` on its own.
141
-
142
- 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:
143
168
 
144
169
  ```js
145
- import TurboFormController from "controllers/turbo_form_controller"
146
- 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
147
173
  ```
148
174
 
149
- **esbuild, Vite, Bun, webpack** — there is no sweep to pick the controller up,
150
- so the package has to be installed alongside the gem, at the same version, and
151
- registered. `rails generate turbo_form:install` does both: it adds the package
152
- 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`:
153
180
 
154
181
  ```js
155
- // app/javascript/controllers/turbo_form_controller.js
156
- import TurboFormController from "@rolemodel/turbo-form"
182
+ import { refresh } from "turbo_form"
157
183
 
158
- export default TurboFormController
184
+ refresh(document.querySelector("form[data-turbo-form-url]"))
159
185
  ```
160
186
 
161
- Registering by *filename* rather than by appending to
162
- `app/javascript/controllers/index.js` is deliberate. That file is regenerated
163
- from the contents of the directory every time `rails generate stimulus` runs, so
164
- a registration appended to it lasts until the next controller you generate. A
165
- file named `turbo_form_controller.js` is picked up by that same regeneration and
166
- registered as `turbo-form`, every time.
167
-
168
- To do it by hand, write that file yourself and run `rails stimulus:manifest:update`.
169
-
170
187
  ### Testing against it
171
188
 
172
- A reload 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
173
190
  trigger and the wait comes with it:
174
191
 
175
192
  ```ruby
@@ -179,22 +196,25 @@ select "Barrel Aged", from: "Flavor"
179
196
 
180
197
  `expect_dynamic_form_request` is available in system tests with nothing to
181
198
  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.
199
+ completed triggers kept on `<html>` rather than on the fields that came back, so
200
+ it doesn't care what the trigger changed.
184
201
 
185
202
  ## What this deliberately doesn't do
186
203
 
187
204
  It handles the common case well and gets out of the way otherwise.
188
205
 
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.
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.
194
216
  - **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.
217
+ or mail sent during a trigger still happen.
198
218
 
199
219
  ## License
200
220
 
@@ -1,38 +1,147 @@
1
- import { Controller } from "@hotwired/stimulus"
2
1
  import { Turbo } from "@hotwired/turbo-rails"
3
2
 
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.
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.
13
7
  //
14
- // A morphing refresh keeps focus and scroll, so it reads as the form updating
15
- // in place rather than as a navigation.
16
- export default class extends Controller {
17
- perform() {
18
- const url = new URL(location.href)
19
- const data = new FormData(this.element)
20
-
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
- }
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
+ }
25
16
 
26
- const markReload = ({ detail: { url: requested, fetchOptions } }) => {
27
- if (requested.href !== url.href) return
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 })
28
30
 
29
- fetchOptions.headers["X-Turbo-Form"] = "reload"
30
- document.removeEventListener("turbo:before-fetch-request", markReload)
31
+ this.target.render(response.status, await response.text())
32
+ } finally {
33
+ this.requestFinished()
31
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" }
32
51
 
33
- document.addEventListener("turbo:before-fetch-request", markReload)
52
+ render(statusCode, responseHTML) {
34
53
  document.addEventListener("turbo:load", countVisit, { once: true })
35
- Turbo.visit(url, { action: "replace", shouldCacheSnapshot: false, refresh: { method: "morph", scroll: "preserve" } })
54
+ Turbo.visit(location.href, {
55
+ action: "replace",
56
+ shouldCacheSnapshot: false,
57
+ refresh: { method: "morph", scroll: "preserve" },
58
+ response: { statusCode, responseHTML }
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)
75
+
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
+ }
108
+
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
118
+
119
+ if (form?.dataset.turboFormUrl && eventFor(trigger) == type) this.delegate.triggerFired(form)
120
+ }
121
+ }
122
+
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
+ }
132
+
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
+ }
140
+
141
+ function clearBusyState(...elements) {
142
+ for (const element of elements) {
143
+ if (element.localName == "turbo-frame") element.removeAttribute("busy")
144
+ element.removeAttribute("aria-busy")
36
145
  }
37
146
  }
38
147
 
@@ -43,3 +152,5 @@ function countVisit() {
43
152
  const root = document.documentElement.dataset
44
153
  root.turboFormVisits = Number(root.turboFormVisits || 0) + 1
45
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,7 +1,8 @@
1
- require "turbo-rails"
2
- require "turbo_form/form_helper"
3
- require "turbo_form/form_builder"
4
- require "turbo_form/reload"
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
8
  class Engine < ::Rails::Engine
@@ -10,20 +11,20 @@ module TurboForm
10
11
  # here both registers ours and leaves the host able to override them.
11
12
  # The guard keeps esbuild/vite/bun hosts booting; a `before:` naming an
12
13
  # initializer that doesn't exist is itself harmless.
13
- initializer "turbo_form.importmap", before: "importmap" do |app|
14
+ initializer 'turbo_form.importmap', before: 'importmap' do |app|
14
15
  next unless app.config.respond_to?(:importmap)
15
16
 
16
- app.config.importmap.paths << root.join("config/turbo_form_importmap.rb")
17
- 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')
18
19
  end
19
20
 
20
21
  # Propshaft puts every engine's app/assets/* on the load path by itself.
21
22
  # Sprockets additionally wants the asset named, or the pin above silently
22
23
  # resolves to nothing.
23
- initializer "turbo_form.assets" do |app|
24
+ initializer 'turbo_form.assets' do |app|
24
25
  next unless app.config.respond_to?(:assets)
25
26
 
26
- app.config.assets.precompile << "turbo_form.js"
27
+ app.config.assets.precompile << 'turbo_form.js'
27
28
  end
28
29
 
29
30
  # Minitest's system tests descend from ActionDispatch::SystemTestCase, so
@@ -31,9 +32,9 @@ module TurboForm
31
32
  # assembles itself from Action Dispatch's parts instead of inheriting the
32
33
  # case -- so they need telling separately, once RSpec is loaded but before
33
34
  # any example group has been defined.
34
- initializer "turbo_form.system_test_helper" do
35
+ initializer 'turbo_form.system_test_helper' do
35
36
  ActiveSupport.on_load(:action_dispatch_system_test_case) do
36
- require "turbo_form/system_test_helper"
37
+ require 'turbo_form/system_test_helper'
37
38
 
38
39
  include TurboForm::SystemTestHelper
39
40
  end
@@ -42,24 +43,19 @@ module TurboForm
42
43
  config.after_initialize do
43
44
  next unless defined?(RSpec.configure)
44
45
 
45
- require "turbo_form/system_test_helper"
46
+ require 'turbo_form/system_test_helper'
46
47
 
47
48
  RSpec.configure do |config|
48
49
  config.include TurboForm::SystemTestHelper, type: :system
49
50
  end
50
51
  end
51
52
 
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
-
57
53
  # Deliberately eager rather than `ActiveSupport.on_load(:action_view)`: that
58
54
  # hook doesn't fire until Action View is first loaded, which in an app that
59
55
  # isn't eager loading is partway through rendering the first view -- late
60
56
  # enough that the first form on the first request can miss the patch.
61
- initializer "turbo_form.form_helpers" do
62
- require "action_view"
57
+ initializer 'turbo_form.form_helpers' do
58
+ require 'action_view'
63
59
 
64
60
  ActionView::Helpers::FormHelper.prepend(TurboForm::FormHelper)
65
61
  ActionView::Helpers::FormBuilder.prepend(TurboForm::FormBuilder)
@@ -4,20 +4,11 @@ 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.
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
-
21
12
  def select(method, choices = nil, options = {}, html_options = {}, &block)
22
13
  super(method, choices, *hoist_trigger(options, html_options), &block)
23
14
  end
@@ -59,41 +50,37 @@ module TurboForm
59
50
  end
60
51
 
61
52
  private
62
- # Every other field helper -- generated and hand-written alike -- passes its
63
- # attributes through here on the way to the tag.
64
- def objectify_options(options)
65
- super(absorb_trigger(options))
66
- end
67
-
68
- # A trailing `dynamic_trigger:` binds to `options` because Ruby folds bare
69
- # keywords into the first optional positional hash -- but for these helpers
70
- # that hash is the *select's* options, not the tag's. Carry it across.
71
- # SimpleForm arrives on the other side, via `input_html:`, so take it from
72
- # either.
73
- def hoist_trigger(options, html_options)
74
- return [ options, absorb_trigger(html_options) ] unless options.key?(:dynamic_trigger)
75
-
76
- trigger = options[:dynamic_trigger]
77
- [ options.except(:dynamic_trigger), absorb_trigger(html_options.merge(dynamic_trigger: trigger)) ]
78
- end
79
-
80
- def absorb_trigger(attributes)
81
- return attributes unless attributes.key?(:dynamic_trigger)
82
-
83
- trigger = attributes[:dynamic_trigger]
84
- attributes = attributes.except(:dynamic_trigger)
85
- return attributes unless trigger
86
-
87
- data = (attributes[:data] || {}).dup
88
- data[:action] = [ data[:action], stimulus_action_for(trigger) ].compact.join(" ")
89
- attributes.merge(data: data)
90
- end
91
-
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.
95
- def stimulus_action_for(event)
96
- event.nil? || event == true ? "turbo-form#perform" : "#{event}->turbo-form#perform"
97
- 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
98
85
  end
99
86
  end
@@ -7,19 +7,21 @@ module TurboForm
7
7
  # stays in the options so the builder sees it; Rails slices the form tag's
8
8
  # attributes out by name, so it never reaches the HTML.
9
9
  def form_with(model: false, scope: nil, url: nil, format: nil, **options, &block)
10
- wire_dynamic_form(options) if options[:dynamic]
10
+ wire_dynamic_form(options, model) if options[:dynamic]
11
11
  super
12
12
  end
13
13
 
14
14
  private
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, and sit
17
- # alongside any Stimulus controller they asked for rather than replacing it.
18
- def wire_dynamic_form(options)
19
- attributes = options.key?(:html) ? (options[:html] ||= {}) : options
20
- 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
21
21
 
22
- data[:controller] = [ data[:controller], "turbo-form" ].compact.join(" ")
23
- 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
24
26
  end
25
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
@@ -4,7 +4,7 @@ module TurboForm::SystemTestHelper
4
4
  # expect_dynamic_form_request { select "Bourbon", from: "Category" }
5
5
  # select "Barrel Aged", from: "Flavor"
6
6
  def expect_dynamic_form_request
7
- completed = page.find("html")["data-turbo-form-visits"].to_i + 1
7
+ completed = page.find('html')['data-turbo-form-visits'].to_i + 1
8
8
 
9
9
  yield
10
10
 
@@ -1,3 +1,3 @@
1
1
  module TurboForm
2
- VERSION = "0.5.0"
2
+ VERSION = '0.6.0'
3
3
  end
data/lib/turbo_form.rb CHANGED
@@ -1,2 +1,2 @@
1
- require "turbo_form/version"
2
- require "turbo_form/engine"
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Cohen
@@ -78,13 +78,13 @@ files:
78
78
  - app/assets/javascripts/turbo_form.js
79
79
  - config/turbo_form_importmap.rb
80
80
  - lib/generators/turbo_form/install/install_generator.rb
81
- - lib/generators/turbo_form/install/templates/turbo_form_controller.js
82
81
  - lib/tasks/turbo_form_tasks.rake
83
82
  - lib/turbo_form.rb
83
+ - lib/turbo_form/controller.rb
84
84
  - lib/turbo_form/engine.rb
85
85
  - lib/turbo_form/form_builder.rb
86
86
  - lib/turbo_form/form_helper.rb
87
- - lib/turbo_form/reload.rb
87
+ - lib/turbo_form/routes.rb
88
88
  - lib/turbo_form/system_test_helper.rb
89
89
  - lib/turbo_form/version.rb
90
90
  homepage: https://github.com/outlawandy/turbo_form
@@ -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,68 +0,0 @@
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