turbo_form 0.0.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +173 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/turbo_form.js +37 -0
- data/app/controllers/turbo_form/dynamic_forms_controller.rb +28 -0
- data/config/routes.rb +10 -0
- data/config/turbo_form_importmap.rb +5 -0
- data/lib/tasks/turbo_form_tasks.rake +4 -0
- data/lib/turbo_form/engine.rb +44 -0
- data/lib/turbo_form/form_builder.rb +90 -0
- data/lib/turbo_form/form_helper.rb +38 -0
- data/lib/turbo_form/signature.rb +43 -0
- data/lib/turbo_form/version.rb +3 -0
- data/lib/turbo_form.rb +31 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f7a0e172eb5fd1fceae6c061a577eb424636d03b818b4ac5468e2d917e40955b
|
|
4
|
+
data.tar.gz: 82872425eec773e4ed97db3e91c8935a6c125eeca95fd6f6214303ce86ddb0dd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ede619403754616875c64b0cd57f3a56b6bea8f4d2f4a4844ebecacef86fdce3ae16ea2f21022e0ea146c2f092ba69b43783a39ec0c8cba3f25e393b5dad97cd
|
|
7
|
+
data.tar.gz: 2a236bdc2ce28e7fbb0f7005b65b1472954d90a2581619bb311b2bf1b059e7d595afdcdbf2eefe38d3bb79cf4429104b994f6c0ded268499f86897ffa9cf4b17
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Andy Cohen
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# turbo_form
|
|
2
|
+
|
|
3
|
+
Turbo-Stream backed dynamic forms for Rails, by convention.
|
|
4
|
+
|
|
5
|
+
A form that needs to change as it is filled in — a dependent dropdown, a section
|
|
6
|
+
that appears once you pick a type — usually costs you a route, a controller
|
|
7
|
+
action, a Stimulus controller and some wiring. This gem asks for two words
|
|
8
|
+
instead:
|
|
9
|
+
|
|
10
|
+
```erb
|
|
11
|
+
<%= form_for @widget, dynamic: true do |f| %>
|
|
12
|
+
<%= f.select :category, @widget.categories, {}, dynamic_trigger: true %>
|
|
13
|
+
|
|
14
|
+
<div id="flavor-field">
|
|
15
|
+
<%= f.select :flavor, @widget.flavors %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
```
|
|
19
|
+
|
|
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
|
+
```
|
|
31
|
+
|
|
32
|
+
That's the whole feature. No route, no controller, no JavaScript.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem "turbo_form"
|
|
38
|
+
```
|
|
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.
|
|
44
|
+
|
|
45
|
+
## The two options
|
|
46
|
+
|
|
47
|
+
### `dynamic:` on the form
|
|
48
|
+
|
|
49
|
+
`dynamic: true` wires the form up and points it at
|
|
50
|
+
`app/views/<resource dir>/dynamic_form.turbo_stream.*` — alongside the
|
|
51
|
+
resource's own partial, so `widgets/_widget` gets `widgets/dynamic_form`.
|
|
52
|
+
|
|
53
|
+
Pass a string to render something else instead:
|
|
54
|
+
|
|
55
|
+
```erb
|
|
56
|
+
<%= form_for @widget, dynamic: "shared/refresh_widget" %>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Works on `form_for` and `form_with` alike. Any template engine works — the
|
|
60
|
+
template is resolved the way every other Rails template is, so `.slim` and
|
|
61
|
+
`.haml` are fine.
|
|
62
|
+
|
|
63
|
+
### `dynamic_trigger:` on a field
|
|
64
|
+
|
|
65
|
+
```erb
|
|
66
|
+
<%= f.text_field :name, dynamic_trigger: true %> <%# on the default event %>
|
|
67
|
+
<%= f.text_field :name, dynamic_trigger: :blur %> <%# on a named event %>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`true` lets Stimulus pick the element's natural event: `change` for a select or
|
|
71
|
+
checkbox, `input` for a text field, `click` for a button. Name an event when you
|
|
72
|
+
want something else — `:blur` on text fields is usually what you want, since the
|
|
73
|
+
default fires on every keystroke.
|
|
74
|
+
|
|
75
|
+
Works on every Rails field helper, including the select and date families where
|
|
76
|
+
Rails keeps HTML attributes in a separate hash:
|
|
77
|
+
|
|
78
|
+
```erb
|
|
79
|
+
<%= f.collection_select :category_id, Category.all, :id, :name, dynamic_trigger: true %>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### With SimpleForm
|
|
83
|
+
|
|
84
|
+
turbo_form builds on `ActionView::Helpers::FormBuilder`, which SimpleForm
|
|
85
|
+
inherits from, so it works without SimpleForm being involved at all:
|
|
86
|
+
|
|
87
|
+
```slim
|
|
88
|
+
= simple_form_for @widget, dynamic: true do |f|
|
|
89
|
+
= f.input :category, input_html: { dynamic_trigger: true }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## What the endpoint does
|
|
93
|
+
|
|
94
|
+
A `dynamic: true` form carries a signed description of itself to a single
|
|
95
|
+
endpoint the gem draws into your routes. Given a valid signature it:
|
|
96
|
+
|
|
97
|
+
1. permits the submitted parameters wholesale,
|
|
98
|
+
2. builds a **new, unsaved** instance of the form's class from them,
|
|
99
|
+
3. assigns it to `@resource`,
|
|
100
|
+
4. renders the turbo_stream template.
|
|
101
|
+
|
|
102
|
+
Nothing is persisted, and the resource is always freshly instantiated — even for
|
|
103
|
+
an edit form. `@resource` exists to be asked what the form should now look like,
|
|
104
|
+
not to be saved.
|
|
105
|
+
|
|
106
|
+
The signature covers the class name, the parameter scope and the template. It is
|
|
107
|
+
signed because the endpoint constantizes and renders what it names; none of that
|
|
108
|
+
may come from the browser unverified.
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# config/initializers/turbo_form.rb
|
|
114
|
+
|
|
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.
|
|
118
|
+
TurboForm.parent_controller = "ApplicationController"
|
|
119
|
+
|
|
120
|
+
# Draw the route yourself instead.
|
|
121
|
+
TurboForm.draw_routes = false
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## JavaScript
|
|
125
|
+
|
|
126
|
+
**importmap-rails** — nothing to do. The engine pins its controller as
|
|
127
|
+
`controllers/turbo_form_controller`, which the `eagerLoadControllersFrom` /
|
|
128
|
+
`lazyLoadControllersFrom` in a stock `app/javascript/controllers/index.js`
|
|
129
|
+
registers as `turbo-form` on its own.
|
|
130
|
+
|
|
131
|
+
If you hand-wrote that file, register it yourself:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
import TurboFormController from "controllers/turbo_form_controller"
|
|
135
|
+
application.register("turbo-form", TurboFormController)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**esbuild, Vite, Bun, webpack** — install the npm package alongside the gem, at
|
|
139
|
+
the same version, and register it:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
yarn add turbo_form
|
|
143
|
+
```
|
|
144
|
+
```js
|
|
145
|
+
import TurboFormController from "turbo_form"
|
|
146
|
+
application.register("turbo-form", TurboFormController)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Testing against it
|
|
150
|
+
|
|
151
|
+
The controller counts completed round trips, so a system test can wait on one
|
|
152
|
+
instead of sleeping:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
def expect_dynamic_form_request
|
|
156
|
+
form = find("[data-controller~='turbo-form']")
|
|
157
|
+
before = form["data-turbo-form-requests-value"].to_i
|
|
158
|
+
yield
|
|
159
|
+
assert_selector "[data-turbo-form-requests-value='#{before + 1}']"
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## What this deliberately doesn't do
|
|
164
|
+
|
|
165
|
+
It handles the common case well and gets out of the way otherwise. There is no
|
|
166
|
+
debouncing, no request cancellation, no loading state, no per-element URL
|
|
167
|
+
override, and no hook for loading an existing record instead of building a new
|
|
168
|
+
one. When you need those, write the action by hand — that path is still open,
|
|
169
|
+
and this gem doesn't stand in front of it.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { Turbo } from "@hotwired/turbo-rails"
|
|
3
|
+
|
|
4
|
+
// Re-renders the form it is attached to, from the server, as a Turbo Stream.
|
|
5
|
+
//
|
|
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
|
+
async submit() {
|
|
13
|
+
const response = await fetch(this.urlValue, {
|
|
14
|
+
method: "PATCH",
|
|
15
|
+
headers: this.#headers,
|
|
16
|
+
body: new FormData(this.element)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
if (!response.ok) return
|
|
20
|
+
|
|
21
|
+
Turbo.renderStreamMessage(await response.text())
|
|
22
|
+
|
|
23
|
+
// Lets a system test wait on a completed round trip instead of sleeping.
|
|
24
|
+
this.requestsValue++
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get #headers() {
|
|
28
|
+
const headers = { Accept: "text/vnd.turbo-stream.html" }
|
|
29
|
+
const token = document.querySelector("meta[name=csrf-token]")?.content
|
|
30
|
+
|
|
31
|
+
// The form's own authenticity_token rides along in the body; this covers
|
|
32
|
+
// the forms that don't carry one.
|
|
33
|
+
if (token) headers["X-CSRF-Token"] = token
|
|
34
|
+
|
|
35
|
+
return headers
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module TurboForm
|
|
2
|
+
# Renders a form again from the state the browser currently has it in.
|
|
3
|
+
#
|
|
4
|
+
# Nothing here is saved. The resource is rebuilt only so the template can ask
|
|
5
|
+
# it what the form should now look like, which is why the parameters are taken
|
|
6
|
+
# whole: narrowing them would only make the re-rendered form disagree with what
|
|
7
|
+
# the user actually typed.
|
|
8
|
+
class DynamicFormsController < TurboForm.parent_controller.constantize
|
|
9
|
+
rescue_from TurboForm::Signature::Invalid do
|
|
10
|
+
head :bad_request
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update
|
|
14
|
+
@resource = signature.model.new(form_params)
|
|
15
|
+
|
|
16
|
+
render template: signature.template_for(@resource), formats: :turbo_stream
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
def signature
|
|
21
|
+
@signature ||= TurboForm::Signature.verify(params[:signature])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def form_params
|
|
25
|
+
params.fetch(signature.scope, {}).permit!
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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
|
|
@@ -0,0 +1,5 @@
|
|
|
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"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "turbo-rails"
|
|
2
|
+
require "turbo_form/form_helper"
|
|
3
|
+
require "turbo_form/form_builder"
|
|
4
|
+
require "turbo_form/signature"
|
|
5
|
+
|
|
6
|
+
module TurboForm
|
|
7
|
+
# Deliberately *not* `isolate_namespace`: the template this engine renders
|
|
8
|
+
# belongs to the host, and isolating would point its route helpers at this
|
|
9
|
+
# engine's own (empty) route set -- so `widgets_path` in a host's
|
|
10
|
+
# dynamic_form template would raise.
|
|
11
|
+
class Engine < ::Rails::Engine
|
|
12
|
+
# importmap-rails reads config.importmap.paths exactly once, in its own
|
|
13
|
+
# `importmap` initializer, and draws the host's pins last -- so appending
|
|
14
|
+
# here both registers ours and leaves the host able to override them.
|
|
15
|
+
# The guard keeps esbuild/vite/bun hosts booting; a `before:` naming an
|
|
16
|
+
# initializer that doesn't exist is itself harmless.
|
|
17
|
+
initializer "turbo_form.importmap", before: "importmap" do |app|
|
|
18
|
+
next unless app.config.respond_to?(:importmap)
|
|
19
|
+
|
|
20
|
+
app.config.importmap.paths << root.join("config/turbo_form_importmap.rb")
|
|
21
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Propshaft puts every engine's app/assets/* on the load path by itself.
|
|
25
|
+
# Sprockets additionally wants the asset named, or the pin above silently
|
|
26
|
+
# resolves to nothing.
|
|
27
|
+
initializer "turbo_form.assets" do |app|
|
|
28
|
+
next unless app.config.respond_to?(:assets)
|
|
29
|
+
|
|
30
|
+
app.config.assets.precompile << "turbo_form.js"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deliberately eager rather than `ActiveSupport.on_load(:action_view)`: that
|
|
34
|
+
# hook doesn't fire until Action View is first loaded, which in an app that
|
|
35
|
+
# isn't eager loading is partway through rendering the first view -- late
|
|
36
|
+
# enough that the first form on the first request can miss the patch.
|
|
37
|
+
initializer "turbo_form.form_helpers" do
|
|
38
|
+
require "action_view"
|
|
39
|
+
|
|
40
|
+
ActionView::Helpers::FormHelper.prepend(TurboForm::FormHelper)
|
|
41
|
+
ActionView::Helpers::FormBuilder.prepend(TurboForm::FormBuilder)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module TurboForm
|
|
2
|
+
# Prepended onto ActionView::Helpers::FormBuilder.
|
|
3
|
+
#
|
|
4
|
+
# f.text_field :name, dynamic_trigger: true # re-render on the default event
|
|
5
|
+
# f.select :category, categories, {}, dynamic_trigger: :blur
|
|
6
|
+
#
|
|
7
|
+
# `dynamic_trigger:` always has to end up as a `data-action`, but where it
|
|
8
|
+
# *arrives* depends on the helper. Most treat their `options` hash as the tag's
|
|
9
|
+
# HTML attributes; the select and date families keep those in a trailing
|
|
10
|
+
# `html_options` instead, which is where these overrides earn their keep.
|
|
11
|
+
module FormBuilder
|
|
12
|
+
def select(method, choices = nil, options = {}, html_options = {}, &block)
|
|
13
|
+
super(method, choices, *hoist_trigger(options, html_options), &block)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
|
17
|
+
super(method, collection, value_method, text_method, *hoist_trigger(options, html_options))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def grouped_collection_select(method, collection, group_method, group_label_method, option_key_method, option_value_method, options = {}, html_options = {})
|
|
21
|
+
super(method, collection, group_method, group_label_method, option_key_method, option_value_method, *hoist_trigger(options, html_options))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def collection_checkboxes(method, collection, value_method, text_method, options = {}, html_options = {}, &block)
|
|
25
|
+
super(method, collection, value_method, text_method, *hoist_trigger(options, html_options), &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def collection_radio_buttons(method, collection, value_method, text_method, options = {}, html_options = {}, &block)
|
|
29
|
+
super(method, collection, value_method, text_method, *hoist_trigger(options, html_options), &block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def time_zone_select(method, priority_zones = nil, options = {}, html_options = {})
|
|
33
|
+
super(method, priority_zones, *hoist_trigger(options, html_options))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def weekday_select(method, options = {}, html_options = {})
|
|
37
|
+
super(method, *hoist_trigger(options, html_options))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def date_select(method, options = {}, html_options = {})
|
|
41
|
+
super(method, *hoist_trigger(options, html_options))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def time_select(method, options = {}, html_options = {})
|
|
45
|
+
super(method, *hoist_trigger(options, html_options))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def datetime_select(method, options = {}, html_options = {})
|
|
49
|
+
super(method, *hoist_trigger(options, html_options))
|
|
50
|
+
end
|
|
51
|
+
|
|
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
|
+
data = (attributes[:data] || {}).dup
|
|
79
|
+
data[:action] = [ data[:action], stimulus_action_for(trigger) ].compact.join(" ")
|
|
80
|
+
attributes.merge(data: data)
|
|
81
|
+
end
|
|
82
|
+
|
|
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#submit" : "#{trigger}->turbo-form#submit"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module TurboForm
|
|
2
|
+
# Prepended onto ActionView::Helpers::FormHelper.
|
|
3
|
+
#
|
|
4
|
+
# form_for @widget, dynamic: true
|
|
5
|
+
# form_for @widget, dynamic: "shared/refresh" # render this template instead
|
|
6
|
+
module FormHelper
|
|
7
|
+
# `form_for` ends in `form_with`, so overriding one covers both.
|
|
8
|
+
def form_with(model: false, scope: nil, url: nil, format: nil, **options, &block)
|
|
9
|
+
dynamic = options.delete(:dynamic)
|
|
10
|
+
return super unless dynamic
|
|
11
|
+
|
|
12
|
+
object = _object_for_form_builder(model)
|
|
13
|
+
raise ArgumentError, "form_for/form_with needs a :model to build a dynamic form from" unless object
|
|
14
|
+
|
|
15
|
+
scope ||= model_name_from_record_or_class(object).param_key
|
|
16
|
+
signature = TurboForm::Signature.new(
|
|
17
|
+
model_name: object.class.name,
|
|
18
|
+
scope: scope.to_s,
|
|
19
|
+
template: (dynamic unless dynamic == true)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
wire_dynamic_form(options, signature)
|
|
23
|
+
super(model:, scope:, url:, format:, **options, &block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
# `form_for` funnels HTML attributes through options[:html] while `form_with`
|
|
28
|
+
# takes them at the top level. Write wherever the caller already is, and sit
|
|
29
|
+
# alongside any Stimulus controller they asked for rather than replacing it.
|
|
30
|
+
def wire_dynamic_form(options, signature)
|
|
31
|
+
attributes = options.key?(:html) ? (options[:html] ||= {}) : options
|
|
32
|
+
data = attributes[:data] ||= {}
|
|
33
|
+
|
|
34
|
+
data[:controller] = [ data[:controller], "turbo-form" ].compact.join(" ")
|
|
35
|
+
data[:turbo_form_url_value] = turbo_form_path(signature)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module TurboForm
|
|
2
|
+
# A tamper-proof description of a dynamic form, carried in the URL the browser
|
|
3
|
+
# posts back to.
|
|
4
|
+
#
|
|
5
|
+
# It is signed because the endpoint acts on every word of it: `model_name` gets
|
|
6
|
+
# constantized and instantiated, `template` gets rendered. Neither may come
|
|
7
|
+
# from the client unverified.
|
|
8
|
+
#
|
|
9
|
+
# Note that this identifies a *class*, not a record. Dynamic forms are usually
|
|
10
|
+
# editing something unsaved, which is also why GlobalID -- which requires a
|
|
11
|
+
# persisted record -- can't express it.
|
|
12
|
+
class Signature
|
|
13
|
+
class Invalid < StandardError; end
|
|
14
|
+
|
|
15
|
+
def self.verify(token)
|
|
16
|
+
payload = TurboForm.verifier.verified(token.to_s)
|
|
17
|
+
raise Invalid, "not a signature this application generated" unless payload
|
|
18
|
+
|
|
19
|
+
new(**payload.symbolize_keys)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :model_name, :scope, :template
|
|
23
|
+
|
|
24
|
+
def initialize(model_name:, scope:, template: nil)
|
|
25
|
+
@model_name = model_name
|
|
26
|
+
@scope = scope
|
|
27
|
+
@template = template
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def model = model_name.constantize
|
|
31
|
+
|
|
32
|
+
# By convention the template sits alongside the resource's own partial:
|
|
33
|
+
# `widgets/_widget` gets `widgets/dynamic_form`.
|
|
34
|
+
def template_for(resource)
|
|
35
|
+
template || File.join(File.dirname(resource.to_partial_path), "dynamic_form")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
TurboForm.verifier.generate({ model_name:, scope:, template: })
|
|
40
|
+
end
|
|
41
|
+
alias to_param to_s
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/turbo_form.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
class << self
|
|
17
|
+
attr_writer :verifier
|
|
18
|
+
|
|
19
|
+
# Signs the description of a form so the endpoint can trust the class it is
|
|
20
|
+
# about to instantiate and the template it is about to render. Derived from
|
|
21
|
+
# the application's own key generator, the way SignedGlobalID is. `url_safe`
|
|
22
|
+
# so the token can live in a path segment; JSON so nothing Marshalled ever
|
|
23
|
+
# crosses the wire.
|
|
24
|
+
def verifier
|
|
25
|
+
@verifier ||= ActiveSupport::MessageVerifier.new(
|
|
26
|
+
Rails.application.key_generator.generate_key("turbo_form"),
|
|
27
|
+
url_safe: true, serializer: JSON
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: turbo_form
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andy Cohen
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionview
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 7.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 7.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 7.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 7.1.0
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: railties
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 7.1.0
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 7.1.0
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: turbo-rails
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 2.0.0
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 2.0.0
|
|
68
|
+
description: Zero-configuration Turbo-Stream backed dynamic forms for Rails.
|
|
69
|
+
email:
|
|
70
|
+
- outlawandy@gmail.com
|
|
71
|
+
executables: []
|
|
72
|
+
extensions: []
|
|
73
|
+
extra_rdoc_files: []
|
|
74
|
+
files:
|
|
75
|
+
- MIT-LICENSE
|
|
76
|
+
- README.md
|
|
77
|
+
- Rakefile
|
|
78
|
+
- app/assets/javascripts/turbo_form.js
|
|
79
|
+
- app/controllers/turbo_form/dynamic_forms_controller.rb
|
|
80
|
+
- config/routes.rb
|
|
81
|
+
- config/turbo_form_importmap.rb
|
|
82
|
+
- lib/tasks/turbo_form_tasks.rake
|
|
83
|
+
- lib/turbo_form.rb
|
|
84
|
+
- lib/turbo_form/engine.rb
|
|
85
|
+
- lib/turbo_form/form_builder.rb
|
|
86
|
+
- lib/turbo_form/form_helper.rb
|
|
87
|
+
- lib/turbo_form/signature.rb
|
|
88
|
+
- lib/turbo_form/version.rb
|
|
89
|
+
homepage: https://github.com/outlawandy/turbo_form
|
|
90
|
+
licenses:
|
|
91
|
+
- MIT
|
|
92
|
+
metadata:
|
|
93
|
+
homepage_uri: https://github.com/outlawandy/turbo_form
|
|
94
|
+
source_code_uri: https://github.com/outlawandy/turbo_form
|
|
95
|
+
changelog_uri: https://github.com/outlawandy/turbo_form/blob/main/CHANGELOG.md
|
|
96
|
+
rdoc_options: []
|
|
97
|
+
require_paths:
|
|
98
|
+
- lib
|
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '3.1'
|
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
requirements: []
|
|
110
|
+
rubygems_version: 4.0.20
|
|
111
|
+
specification_version: 4
|
|
112
|
+
summary: Turbo-Stream backed dynamic forms, by convention.
|
|
113
|
+
test_files: []
|