turbo_form 0.7.1 → 0.8.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 +4 -4
- data/README.md +32 -4
- data/app/assets/javascripts/turbo_form.js +35 -12
- data/lib/turbo_form/controller.rb +12 -3
- data/lib/turbo_form/form_builder.rb +21 -8
- data/lib/turbo_form/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69e93adb63b8efad18d18aa5009294ecf46dff732a835677016787a498a359fe
|
|
4
|
+
data.tar.gz: 8058b55a07707691cd9bb2770339f2685ca4c13f104cd97db01aeeef184326bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 641956f04889ecb24a6744d65f9a397e75dfa97ac5fcf57847dab4c41c06bf641268b0e620f4b9e8e1cdf865e23eaf97606a286b7db8eabc1d27d31a02707381
|
|
7
|
+
data.tar.gz: 36036dda2b7c129b9a4d704695d849049bcafbe4f5fec2b3690b2709e2e98dcaaaf2ffab4ce8c438712df1b6ea78bec0d29b08d25a9024c56ccd9d98c299d18e
|
data/README.md
CHANGED
|
@@ -64,7 +64,7 @@ It also imports the script into `app/javascript/application.js`, adding the npm
|
|
|
64
64
|
package first if your app bundles with esbuild, Vite, Bun or webpack; see
|
|
65
65
|
[JavaScript](#javascript) for doing that by hand.
|
|
66
66
|
|
|
67
|
-
## The
|
|
67
|
+
## The options
|
|
68
68
|
|
|
69
69
|
### `dynamic:` on the form
|
|
70
70
|
|
|
@@ -80,19 +80,35 @@ page of its model — `edit` once the record is saved — which is where
|
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
`true` picks the element's natural event: `change` for a select, `click` for a
|
|
83
|
-
|
|
83
|
+
button, `input` for everything else. Name an event when you want
|
|
84
84
|
something else — `:blur` on text fields is usually what you want, since the
|
|
85
85
|
default fires on every keystroke. Any event name works, including custom ones
|
|
86
86
|
dispatched by other Stimulus controllers — `dynamic_trigger: "autocomplete:selected"`
|
|
87
87
|
— as long as the event is dispatched on the field or bubbles up from inside it.
|
|
88
88
|
|
|
89
|
-
Works on every Rails field helper
|
|
90
|
-
Rails keeps HTML attributes in a separate hash:
|
|
89
|
+
Works on every Rails field helper and on `f.submit` and `f.button`, including
|
|
90
|
+
the select and date families where Rails keeps HTML attributes in a separate hash:
|
|
91
91
|
|
|
92
92
|
```erb
|
|
93
93
|
<%= f.collection_select :category_id, Category.all, :id, :name, dynamic_trigger: true %>
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
+
### `dynamic_action:` on a field
|
|
97
|
+
|
|
98
|
+
```erb
|
|
99
|
+
<%= f.select :flavor, @widget.flavors, {}, dynamic_action: summary_widgets_path %>
|
|
100
|
+
<%= f.text_field :zip, dynamic_action: lookup_path(form: "widget"), dynamic_trigger: :blur %>
|
|
101
|
+
<%= f.button "Swap", type: "button", dynamic_action: [:swap, @estimate] %>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For when the page's own render isn't the answer: the trigger PATCHes the whole
|
|
105
|
+
form to that URL instead, asks for a Turbo Stream, and renders the stream it
|
|
106
|
+
gets back, whatever its status, so a `422` of errors renders too. The URL is
|
|
107
|
+
anything `url_for` takes. The action and its `.turbo_stream` template are yours. Nothing is assigned or
|
|
108
|
+
rolled back for you, and the form doesn't need `dynamic: true`. It triggers on
|
|
109
|
+
the field's default event unless `dynamic_trigger:` names one, and counts toward
|
|
110
|
+
`expect_dynamic_form_request`.
|
|
111
|
+
|
|
96
112
|
### With SimpleForm
|
|
97
113
|
|
|
98
114
|
turbo_form builds on `ActionView::Helpers::FormBuilder`, which SimpleForm
|
|
@@ -145,6 +161,16 @@ can turn a `Gizmo` into a `Doohickey` and the rest of the page answers as one.
|
|
|
145
161
|
Give the form its base class's scope (`scope: :gadget`) so the switched object
|
|
146
162
|
reads and posts under the same name, and permit `type` in the params method.
|
|
147
163
|
|
|
164
|
+
### Telling a trigger from a visit
|
|
165
|
+
|
|
166
|
+
`turbo_form_render?` is true while a trigger's request is rendering, in the
|
|
167
|
+
controller and in views, so a layout can leave alone what an ordinary visit
|
|
168
|
+
would set up — a modal that animates open on arrival, say:
|
|
169
|
+
|
|
170
|
+
```erb
|
|
171
|
+
<div data-open-on-connect="<%= !turbo_form_render? %>">
|
|
172
|
+
```
|
|
173
|
+
|
|
148
174
|
### Inside a Turbo Frame
|
|
149
175
|
|
|
150
176
|
A form inside a `<turbo-frame>`, or one naming a frame with
|
|
@@ -207,6 +233,8 @@ It handles the common case well and gets out of the way otherwise.
|
|
|
207
233
|
Write the action by hand when you need those — that path is still open.
|
|
208
234
|
- **Resourceful pages only.** The form's URL comes from its model, so a page
|
|
209
235
|
whose `new` or `edit` isn't the model's own route has nowhere to send it.
|
|
236
|
+
`dynamic_action:` is the way out, at the cost of writing the action and its
|
|
237
|
+
stream.
|
|
210
238
|
- **Not a Turbo form submission.** Turbo renders a form's response only when it
|
|
211
239
|
redirects or fails, so the trigger fetches the form itself and hands the
|
|
212
240
|
response to a Turbo visit. Turbo renders the page — its morph, render events,
|
|
@@ -10,17 +10,22 @@ import { Turbo } from "@hotwired/turbo-rails"
|
|
|
10
10
|
// handed the response, so its events, error page and progress bar all apply. A
|
|
11
11
|
// frame is morphed directly, since Turbo's frame loading wants its own
|
|
12
12
|
// unexported response object.
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
//
|
|
14
|
+
// A trigger naming a URL of its own, with data-turbo-form-action, PATCHes the
|
|
15
|
+
// form there instead and renders the Turbo Stream that comes back.
|
|
16
|
+
export function refresh(form, trigger) {
|
|
17
|
+
return new FormRefresh(form, trigger).start()
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
// Shaped like Turbo's FormSubmission, which is what its navigator expects to be
|
|
18
21
|
// told about.
|
|
19
22
|
class FormRefresh {
|
|
20
|
-
constructor(formElement) {
|
|
23
|
+
constructor(formElement, trigger) {
|
|
24
|
+
const action = trigger?.dataset.turboFormAction
|
|
25
|
+
|
|
21
26
|
this.formElement = formElement
|
|
22
|
-
this.location = new URL(formElement.dataset.turboFormUrl, location.href)
|
|
23
|
-
this.target = targetFor(formElement)
|
|
27
|
+
this.location = new URL(action || formElement.dataset.turboFormUrl, location.href)
|
|
28
|
+
this.target = action ? new StreamTarget() : targetFor(formElement)
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
async start() {
|
|
@@ -28,7 +33,7 @@ class FormRefresh {
|
|
|
28
33
|
try {
|
|
29
34
|
const response = await Turbo.fetch(this.location, { method: "PATCH", body: new FormData(this.formElement), headers: { ...this.target.headers, "X-CSRF-Token": csrfToken() } })
|
|
30
35
|
|
|
31
|
-
this.target.render(response
|
|
36
|
+
await this.target.render(response)
|
|
32
37
|
} finally {
|
|
33
38
|
this.requestFinished()
|
|
34
39
|
}
|
|
@@ -49,13 +54,15 @@ class PageTarget {
|
|
|
49
54
|
elements = []
|
|
50
55
|
headers = { Accept: "text/html" }
|
|
51
56
|
|
|
52
|
-
render(
|
|
57
|
+
async render(response) {
|
|
58
|
+
const responseHTML = await response.text()
|
|
59
|
+
|
|
53
60
|
document.addEventListener("turbo:load", countVisit, { once: true })
|
|
54
61
|
Turbo.visit(location.href, {
|
|
55
62
|
action: "replace",
|
|
56
63
|
shouldCacheSnapshot: false,
|
|
57
64
|
refresh: { method: "morph", scroll: "preserve" },
|
|
58
|
-
response: { statusCode, responseHTML }
|
|
65
|
+
response: { statusCode: response.status, responseHTML }
|
|
59
66
|
})
|
|
60
67
|
}
|
|
61
68
|
}
|
|
@@ -70,14 +77,30 @@ class FrameTarget {
|
|
|
70
77
|
return { Accept: "text/html", "Turbo-Frame": this.element.id }
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
render(
|
|
74
|
-
const frame = new DOMParser().parseFromString(
|
|
80
|
+
async render(response) {
|
|
81
|
+
const frame = new DOMParser().parseFromString(await response.text(), "text/html").getElementById(this.element.id)
|
|
75
82
|
|
|
76
83
|
Turbo.morphTurboFrameElements(this.element, frame)
|
|
77
84
|
countVisit()
|
|
78
85
|
}
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
const streamType = "text/vnd.turbo-stream.html"
|
|
89
|
+
|
|
90
|
+
// Whatever the status, as Turbo does for a form: a 422 stream of errors is
|
|
91
|
+
// rendered too. Anything else, like an HTML error page, isn't a stream to render.
|
|
92
|
+
class StreamTarget {
|
|
93
|
+
elements = []
|
|
94
|
+
headers = { Accept: streamType }
|
|
95
|
+
|
|
96
|
+
async render(response) {
|
|
97
|
+
if (!response.headers.get("Content-Type")?.startsWith(streamType)) return
|
|
98
|
+
|
|
99
|
+
Turbo.renderStreamMessage(await response.text())
|
|
100
|
+
countVisit()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
// Turbo's own targeting: the form's data-turbo-frame, then the target of the
|
|
82
105
|
// frame it sits in, then that frame itself. "_top" means the page.
|
|
83
106
|
function targetFor(form) {
|
|
@@ -123,7 +146,7 @@ class TriggerObserver {
|
|
|
123
146
|
const trigger = target.closest?.(triggerSelector)
|
|
124
147
|
const form = trigger?.form
|
|
125
148
|
|
|
126
|
-
if (form
|
|
149
|
+
if (form && (trigger.dataset.turboFormAction || form.dataset.turboFormUrl) && eventFor(trigger) == type) this.delegate.triggerFired(form, trigger)
|
|
127
150
|
}
|
|
128
151
|
}
|
|
129
152
|
|
|
@@ -132,7 +155,7 @@ function eventFor(trigger) {
|
|
|
132
155
|
const named = trigger.dataset.turboFormTrigger
|
|
133
156
|
if (named) return named
|
|
134
157
|
if (trigger.localName == "select") return "change"
|
|
135
|
-
if (trigger.type == "submit") return "click"
|
|
158
|
+
if (trigger.type == "submit" || trigger.type == "button") return "click"
|
|
136
159
|
|
|
137
160
|
return "input"
|
|
138
161
|
}
|
|
@@ -7,16 +7,26 @@ module TurboForm
|
|
|
7
7
|
# Leans on the scaffold's conventions: the action builds `@<resource>` and
|
|
8
8
|
# `<resource>_params` permits the form.
|
|
9
9
|
module Controller
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
helper_method :turbo_form_render?
|
|
14
|
+
end
|
|
15
|
+
|
|
10
16
|
# Public, as Rails' own is. ActionController::Base's public methods are never
|
|
11
17
|
# actions, so this one isn't either.
|
|
12
18
|
def render(...)
|
|
13
|
-
dynamic_form_assignment if
|
|
19
|
+
dynamic_form_assignment if turbo_form_render?
|
|
14
20
|
super
|
|
15
21
|
end
|
|
16
22
|
|
|
17
23
|
private
|
|
24
|
+
# Whether this request is a trigger's re-render rather than a visit, for a
|
|
25
|
+
# layout or template that should answer differently to one.
|
|
26
|
+
def turbo_form_render? = request.path_parameters.key?(:dynamic_form)
|
|
27
|
+
|
|
18
28
|
def process_action(...)
|
|
19
|
-
|
|
29
|
+
turbo_form_render? ? discarding_writes { super } : super
|
|
20
30
|
end
|
|
21
31
|
|
|
22
32
|
def dynamic_form_assignment
|
|
@@ -24,7 +34,6 @@ module TurboForm
|
|
|
24
34
|
instance_variable_set(:"@#{turbo_form_resource_name}", resource)
|
|
25
35
|
end
|
|
26
36
|
|
|
27
|
-
def dynamic_form? = request.path_parameters[:dynamic_form]
|
|
28
37
|
def turbo_form_resource = instance_variable_get(:"@#{turbo_form_resource_name}")
|
|
29
38
|
def turbo_form_resource_params = send(:"#{turbo_form_resource_name}_params")
|
|
30
39
|
def turbo_form_resource_name = controller_name.singularize
|
|
@@ -3,12 +3,15 @@ module TurboForm
|
|
|
3
3
|
#
|
|
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
|
+
# f.select :flavor, flavors, {}, dynamic_action: summary_widgets_path # stream from a URL of its own
|
|
6
7
|
#
|
|
7
8
|
# `dynamic_trigger:` always has to end up as a data attribute, but where it
|
|
8
9
|
# *arrives* depends on the helper. Most treat their `options` hash as the tag's
|
|
9
10
|
# HTML attributes; the select and date families keep those in a trailing
|
|
10
11
|
# `html_options` instead, which is where these overrides earn their keep.
|
|
11
12
|
module FormBuilder
|
|
13
|
+
DYNAMIC_OPTIONS = %i[dynamic_trigger dynamic_action].freeze
|
|
14
|
+
|
|
12
15
|
def select(method, choices = nil, options = {}, html_options = {}, &block)
|
|
13
16
|
super(method, choices, *hoist_trigger(options, html_options), &block)
|
|
14
17
|
end
|
|
@@ -49,6 +52,16 @@ module TurboForm
|
|
|
49
52
|
super(method, *hoist_trigger(options, html_options))
|
|
50
53
|
end
|
|
51
54
|
|
|
55
|
+
# These two hand their options straight to the tag helper, skipping
|
|
56
|
+
# `objectify_options`, and take them first when no value is given.
|
|
57
|
+
def submit(value = nil, options = {})
|
|
58
|
+
value.is_a?(Hash) ? super(absorb_trigger(value)) : super(value, absorb_trigger(options))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def button(value = nil, options = {}, &block)
|
|
62
|
+
value.is_a?(Hash) ? super(absorb_trigger(value), &block) : super(value, absorb_trigger(options), &block)
|
|
63
|
+
end
|
|
64
|
+
|
|
52
65
|
private
|
|
53
66
|
# Every other field helper -- generated and hand-written alike -- passes its
|
|
54
67
|
# attributes through here on the way to the tag.
|
|
@@ -62,20 +75,20 @@ module TurboForm
|
|
|
62
75
|
# SimpleForm arrives on the other side, via `input_html:`, so take it from
|
|
63
76
|
# either.
|
|
64
77
|
def hoist_trigger(options, html_options)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
trigger = options[:dynamic_trigger]
|
|
68
|
-
[ options.except(:dynamic_trigger), absorb_trigger(html_options.merge(dynamic_trigger: trigger)) ]
|
|
78
|
+
[ options.except(*DYNAMIC_OPTIONS), absorb_trigger(html_options.merge(options.slice(*DYNAMIC_OPTIONS))) ]
|
|
69
79
|
end
|
|
70
80
|
|
|
81
|
+
# `dynamic_action:` makes a trigger of its own, on the default event unless
|
|
82
|
+
# `dynamic_trigger:` names one. It takes whatever `url_for` does.
|
|
71
83
|
def absorb_trigger(attributes)
|
|
72
|
-
return attributes unless attributes.
|
|
84
|
+
return attributes unless attributes.keys.intersect?(DYNAMIC_OPTIONS)
|
|
73
85
|
|
|
74
|
-
trigger = attributes
|
|
75
|
-
attributes = attributes.except(
|
|
86
|
+
trigger, action = attributes.values_at(*DYNAMIC_OPTIONS)
|
|
87
|
+
attributes = attributes.except(*DYNAMIC_OPTIONS)
|
|
88
|
+
trigger ||= action.present?
|
|
76
89
|
return attributes unless trigger
|
|
77
90
|
|
|
78
|
-
attributes.merge(data: { **attributes[:data].to_h, turbo_form_trigger: trigger_event(trigger) })
|
|
91
|
+
attributes.merge(data: { **attributes[:data].to_h, turbo_form_trigger: trigger_event(trigger), turbo_form_action: (@template.url_for(action) if action) })
|
|
79
92
|
end
|
|
80
93
|
|
|
81
94
|
# Left blank for `true`, which the script reads as the element's own
|
data/lib/turbo_form/version.rb
CHANGED