turbo_form 0.2.0 → 0.3.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: 887edabc903374a74393c5a9f2b04e70c78d421a17e893355f9b2f985404a7da
4
- data.tar.gz: 11cbdfb726a72675d6980e373001ca40de5a9258f2329b2391992779a5eebfa7
3
+ metadata.gz: 01bb2ce15b4fc755a0f4b689fa4e11d1a34627a206d95d5b2d008ad3d9e66aa3
4
+ data.tar.gz: 52860d7614b4aa8cd960721ee677854704324d34f0abdcdd938f879546daf7d2
5
5
  SHA512:
6
- metadata.gz: 0431310fcc0bb70e96b71336275a63889b7746b79d4ba4ca64fb36358beff818d56d5b7b05f50663875371771a8e33c85fdb6b1a68e60228a7fcfeaf8a0760e9
7
- data.tar.gz: 56831a247fe03fd5bc540a2a44c6fde0a8a4dc2ac64a4f8b8a9dca712ba5134fd8ac79cced5b8e12e78e93e7daa7f427bad683fa56e1729bd59fbe212eb55197
6
+ metadata.gz: cfee3dbcfb3d319e0e1265735a9dfe88d86a0479f3cfb01b53ad9879ef9f1d7b92eaf8679a9486613ffaba54a57f3f1e2965a66531aa6065557927a629ce39d1
7
+ data.tar.gz: 82b3308ced5f38ace3d7e4f170b1beb77d0559f24a67cbfb91174e39de67ef9e66831a8d1a6a9fa03a2def2772459a821833aa684b45bd6f8a6141443ab36113
data/README.md CHANGED
@@ -37,10 +37,15 @@ That's the whole feature. No route, no controller, no JavaScript.
37
37
  gem "turbo_form"
38
38
  ```
39
39
 
40
- There is nothing to mount and nothing to generate. On a stock Rails app —
41
- Propshaft, importmap-rails, stimulus-rails — the Stimulus controller registers
42
- itself. See [JavaScript](#javascript) if your app bundles with esbuild, Vite,
43
- Bun or webpack.
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:
43
+
44
+ ```bash
45
+ rails generate turbo_form:install
46
+ ```
47
+
48
+ See [JavaScript](#javascript) for what that does and how to do it by hand.
44
49
 
45
50
  ## The two options
46
51
 
@@ -72,6 +77,30 @@ checkbox, `input` for a text field, `click` for a button. Name an event when you
72
77
  want something else — `:blur` on text fields is usually what you want, since the
73
78
  default fires on every keystroke.
74
79
 
80
+ #### Sending the form somewhere else
81
+
82
+ A trigger can answer to a different endpoint than its own form, and can add to
83
+ what the form sends:
84
+
85
+ ```erb
86
+ <%= f.button "Run", type: "button", dynamic_trigger: {
87
+ event: :click,
88
+ url: formula_preview_widgets_path,
89
+ params: { attribute: :rafter_count }
90
+ } %>
91
+ ```
92
+
93
+ `url:` replaces the form's own endpoint for that one trigger; everything else on
94
+ the form keeps using the form's. `params:` are appended to the submitted form
95
+ data, so one endpoint can tell which of several triggers asked. Both are
96
+ optional, and either can be given without the other.
97
+
98
+ That endpoint is yours, not the gem's — it's an ordinary action rendering an
99
+ ordinary turbo_stream template, so the whole form arrives as `params` under its
100
+ usual scope. Use it when one form feeds several actions: a dozen fields that
101
+ each preview themselves, or a select that belongs to a nested form with its own
102
+ controller.
103
+
75
104
  Works on every Rails field helper, including the select and date families where
76
105
  Rails keeps HTML attributes in a separate hash:
77
106
 
@@ -97,7 +126,8 @@ endpoint the gem draws into your routes. Given a valid signature it:
97
126
  1. permits the submitted parameters wholesale,
98
127
  2. builds a **new, unsaved** instance of the form's class from them,
99
128
  3. assigns it to `@resource`,
100
- 4. renders the turbo_stream template.
129
+ 4. calls `TurboForm.before_render` with the controller and the resource, if one is set,
130
+ 5. renders the turbo_stream template.
101
131
 
102
132
  Nothing is persisted, and the resource is always freshly instantiated — even for
103
133
  an edit form. `@resource` exists to be asked what the form should now look like,
@@ -112,15 +142,55 @@ may come from the browser unverified.
112
142
  ```ruby
113
143
  # config/initializers/turbo_form.rb
114
144
 
115
- # The endpoint inherits from this, so your authentication applies to it. If your
116
- # ApplicationController enforces something the endpoint can't satisfy -- Pundit's
117
- # `verify_authorized`, say -- point this at a controller that doesn't.
145
+ # The endpoint inherits from this, so your authentication applies to it. Point
146
+ # it at a narrower controller when that inheritance brings something the
147
+ # endpoint shouldn't have.
118
148
  TurboForm.parent_controller = "ApplicationController"
119
149
 
120
150
  # Draw the route yourself instead.
121
151
  TurboForm.draw_routes = false
152
+
153
+ # Run something before the render. See below.
154
+ TurboForm.before_render = ->(controller, resource) { }
155
+ ```
156
+
157
+ ### `before_render`
158
+
159
+ The endpoint is a controller you never wrote, inheriting filters written for
160
+ controllers that save things. `before_render` is where you get to treat it like
161
+ one of your own: it runs in a `before_action`, after the signature is verified
162
+ and the resource is built, and is handed the controller and that resource.
163
+
164
+ Satisfy a filter the endpoint can't — the most common need, since nothing here
165
+ is authorized because nothing here is saved:
166
+
167
+ ```ruby
168
+ # Pundit's after_action :verify_authorized
169
+ TurboForm.before_render = ->(controller, resource) { controller.skip_authorization }
170
+
171
+ # Action Policy's verify_authorized
172
+ TurboForm.before_render = ->(controller, resource) { controller.skip_verify_authorized! }
173
+ ```
174
+
175
+ Or authorize it for real, if re-rendering a form is something you'd rather gate.
176
+ Name the query explicitly: the endpoint's action is `update`, which is not the
177
+ permission you mean.
178
+
179
+ ```ruby
180
+ # Pundit
181
+ TurboForm.before_render = ->(controller, resource) { controller.authorize(resource, :edit?) }
182
+
183
+ # Action Policy
184
+ TurboForm.before_render = ->(controller, resource) { controller.authorize!(resource, to: :edit?) }
185
+
186
+ # CanCanCan -- this also satisfies `check_authorization`, which has no runtime skip
187
+ TurboForm.before_render = ->(controller, resource) { controller.authorize!(:edit, resource) }
122
188
  ```
123
189
 
190
+ Raising works the way it does in any `before_action`: your
191
+ `rescue_from`s catch it, since the endpoint inherits them too. Rendering or
192
+ redirecting from the controller halts the chain as usual.
193
+
124
194
  ## JavaScript
125
195
 
126
196
  **importmap-rails** — nothing to do. The engine pins its controller as
@@ -135,17 +205,27 @@ import TurboFormController from "controllers/turbo_form_controller"
135
205
  application.register("turbo-form", TurboFormController)
136
206
  ```
137
207
 
138
- **esbuild, Vite, Bun, webpack** — install the npm package alongside the gem, at
139
- the same version, and register it:
208
+ **esbuild, Vite, Bun, webpack** — there is no sweep to pick the controller up,
209
+ so the package has to be installed alongside the gem, at the same version, and
210
+ registered. `rails generate turbo_form:install` does both: it adds the package
211
+ with whichever manager your lockfile names, and writes
140
212
 
141
- ```bash
142
- yarn add @rolemodel/turbo_form
143
- ```
144
213
  ```js
145
- import TurboFormController from "@rolemodel/turbo_form"
146
- application.register("turbo-form", TurboFormController)
214
+ // app/javascript/controllers/turbo_form_controller.js
215
+ import TurboFormController from "@rolemodel/turbo-form"
216
+
217
+ export default TurboFormController
147
218
  ```
148
219
 
220
+ Registering by *filename* rather than by appending to
221
+ `app/javascript/controllers/index.js` is deliberate. That file is regenerated
222
+ from the contents of the directory every time `rails generate stimulus` runs, so
223
+ a registration appended to it lasts until the next controller you generate. A
224
+ file named `turbo_form_controller.js` is picked up by that same regeneration and
225
+ registered as `turbo-form`, every time.
226
+
227
+ To do it by hand, write that file yourself and run `rails stimulus:manifest:update`.
228
+
149
229
  ### Testing against it
150
230
 
151
231
  A re-render is a round trip, so the line after a trigger is racing it. Wrap the
@@ -9,12 +9,18 @@ import { Turbo } from "@hotwired/turbo-rails"
9
9
  export default class extends Controller {
10
10
  static values = { url: String, requests: Number }
11
11
 
12
- async perform() {
13
- this.trigger
14
- const response = await fetch(this.urlValue, {
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, {
15
21
  method: "PATCH",
16
22
  headers: this.#headers,
17
- body: new FormData(this.element)
23
+ body
18
24
  })
19
25
 
20
26
  if (!response.ok) return
@@ -10,13 +10,18 @@ module TurboForm
10
10
  head :bad_request
11
11
  end
12
12
 
13
- def update
14
- @resource = signature.model.new(form_params)
13
+ before_action :build_resource
14
+ before_action { TurboForm.before_render&.call(self, @resource) }
15
15
 
16
+ def update
16
17
  render template: signature.template_for(@resource), formats: :turbo_stream
17
18
  end
18
19
 
19
20
  private
21
+ def build_resource
22
+ @resource = signature.model.new(form_params)
23
+ end
24
+
20
25
  def signature
21
26
  @signature ||= TurboForm::Signature.verify(params[:signature])
22
27
  end
@@ -0,0 +1,62 @@
1
+ require "rails/generators"
2
+
3
+ module TurboForm
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.
9
+ class InstallGenerator < Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ LOCKFILES = {
13
+ "yarn.lock" => "yarn add", "pnpm-lock.yaml" => "pnpm add",
14
+ "bun.lock" => "bun add", "bun.lockb" => "bun add"
15
+ }.freeze
16
+
17
+ 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
26
+ end
27
+
28
+ 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
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,4 @@
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
@@ -75,16 +75,30 @@ module TurboForm
75
75
  attributes = attributes.except(:dynamic_trigger)
76
76
  return attributes unless trigger
77
77
 
78
+ event, url, query = destructure_trigger(trigger)
79
+
78
80
  data = (attributes[:data] || {}).dup
79
- data[:action] = [ data[:action], stimulus_action_for(trigger) ].compact.join(" ")
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
80
84
  attributes.merge(data: data)
81
85
  end
82
86
 
83
- # `true` leaves the event off the descriptor so Stimulus binds the element's
84
- # own default: `change` for a select, `input` for a text field, `click` for
85
- # a button. Anything else is taken as the event name.
86
- def stimulus_action_for(trigger)
87
- trigger == true ? "turbo-form#perform" : "#{trigger}->turbo-form#perform"
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"
88
102
  end
89
103
  end
90
104
  end
@@ -1,3 +1,3 @@
1
1
  module TurboForm
2
- VERSION = "0.2.0"
2
+ VERSION = '0.3.0'
3
3
  end
data/lib/turbo_form.rb CHANGED
@@ -12,7 +12,14 @@ module TurboForm
12
12
  # Set to false to keep the engine out of the host's route set and draw its
13
13
  # route yourself.
14
14
  mattr_accessor :draw_routes, default: true
15
- # mattr_accessor :authorize, default: ->(controller) { true }
15
+
16
+ # Called from a `before_action` with the controller and the resource the form
17
+ # was rebuilt into. The endpoint is an inherited controller the host never
18
+ # wrote, so this is where it gets to treat it like one of its own: authorize
19
+ # the render, skip a filter the endpoint can't satisfy
20
+ # (`controller.skip_authorization`), set something the template needs. Left
21
+ # alone, only the host's own inherited filters apply.
22
+ mattr_accessor :before_render
16
23
 
17
24
  class << self
18
25
  attr_writer :verifier
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Cohen
@@ -79,6 +79,8 @@ files:
79
79
  - app/controllers/turbo_form/dynamic_forms_controller.rb
80
80
  - config/routes.rb
81
81
  - config/turbo_form_importmap.rb
82
+ - lib/generators/turbo_form/install/install_generator.rb
83
+ - lib/generators/turbo_form/install/templates/turbo_form_controller.js
82
84
  - lib/tasks/turbo_form_tasks.rake
83
85
  - lib/turbo_form.rb
84
86
  - lib/turbo_form/engine.rb