turbo_form 0.9.1 → 0.10.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 +22 -5
- data/app/assets/javascripts/turbo_form.js +39 -3
- data/lib/turbo_form/form_builder.rb +7 -3
- 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: dbeda5baf3151daeee78f081393fcb7ad320a927c7c928b1a1af4940afe02580
|
|
4
|
+
data.tar.gz: 62f16acb9a560d7b7abb946c02d72c6a021a7e790ae1d8abf984a5627e44a0f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fa3df3560e87aa90ad79e86664c14cfc1a4d44cd28474e43b3bfbc66032fe676531c444d3d3716421628a3590f7b7c32e6eb3f04d77ca324f4bd223630a1b225
|
|
7
|
+
data.tar.gz: 4de2fedf514e70e5f37f5fa49e74bec3389dfed6626eb848180d90b2fa216963db372ab55bad53f0b6cedddaac9b93d3c29ed80045960ac356226311ae121996
|
data/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Dynamic forms for Rails, by convention.
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
A form that needs to change as it is filled in — a dependent dropdown, a section
|
|
6
8
|
that appears once you pick a type — usually costs you a route, a controller
|
|
7
9
|
action, a Stimulus controller and some wiring. This gem asks for two words
|
|
@@ -68,9 +70,9 @@ the route concern listens.
|
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
`true` picks the element's natural event: `change` for a select, `click` for a
|
|
71
|
-
button, `input` for everything else.
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
button, `input` for everything else. An `input` event fires on every keystroke,
|
|
74
|
+
so it waits for a 300ms pause before sending the form. Name an event when you
|
|
75
|
+
want something else, like `:blur`. Any event name works, including custom ones
|
|
74
76
|
dispatched by other Stimulus controllers — `dynamic_trigger: "autocomplete:selected"`
|
|
75
77
|
— as long as the event is dispatched on the field or bubbles up from inside it.
|
|
76
78
|
|
|
@@ -81,6 +83,20 @@ the select and date families where Rails keeps HTML attributes in a separate has
|
|
|
81
83
|
<%= f.collection_select :category_id, Category.all, :id, :name, dynamic_trigger: true %>
|
|
82
84
|
```
|
|
83
85
|
|
|
86
|
+
### `dynamic_debounce:` on a field
|
|
87
|
+
|
|
88
|
+
```erb
|
|
89
|
+
<%= f.text_field :name, dynamic_trigger: true, dynamic_debounce: 500 %> <%# wait longer %>
|
|
90
|
+
<%= f.range_field :size, dynamic_trigger: true, dynamic_debounce: false %> <%# don't wait %>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
How many milliseconds a trigger waits for its events to pause before sending
|
|
94
|
+
the form, as it stands by then. `input` events wait 300ms unless told
|
|
95
|
+
otherwise; every other event sends at once unless it names a wait.
|
|
96
|
+
|
|
97
|
+
Text typed into a field while its request is out is kept when the response
|
|
98
|
+
arrives, and a response overtaken by a later one to the same URL isn't rendered.
|
|
99
|
+
|
|
84
100
|
### `dynamic_action:` on a field
|
|
85
101
|
|
|
86
102
|
```erb
|
|
@@ -217,8 +233,9 @@ it doesn't care what the trigger changed.
|
|
|
217
233
|
|
|
218
234
|
It handles the common case well and gets out of the way otherwise.
|
|
219
235
|
|
|
220
|
-
- **No
|
|
221
|
-
|
|
236
|
+
- **No request cancellation or loading state** beyond `aria-busy`. An overtaken
|
|
237
|
+
request still runs on the server; its response is just not rendered. Write
|
|
238
|
+
the action by hand when you need more — that path is still open.
|
|
222
239
|
- **Resourceful pages only.** The form's URL comes from its model, so a page
|
|
223
240
|
whose `new` or `edit` isn't the model's own route has nowhere to send it.
|
|
224
241
|
`dynamic_action:` is the way out, at the cost of writing the action and its
|
|
@@ -33,21 +33,34 @@ class FormRefresh {
|
|
|
33
33
|
try {
|
|
34
34
|
const response = await Turbo.fetch(this.location, { method: "PATCH", body: new FormData(this.formElement), headers: { ...this.target.headers, "X-CSRF-Token": csrfToken() } })
|
|
35
35
|
|
|
36
|
-
await this.target.render(response)
|
|
36
|
+
if (!this.superseded) await this.target.render(response)
|
|
37
37
|
} finally {
|
|
38
38
|
this.requestFinished()
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
requestStarted() {
|
|
43
|
+
latestRefreshes(this.formElement).set(this.location.href, this)
|
|
43
44
|
markAsBusy(this.formElement, ...this.target.elements)
|
|
44
45
|
Turbo.navigator.formSubmissionStarted(this)
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
requestFinished() {
|
|
48
|
-
clearBusyState(this.formElement, ...this.target.elements)
|
|
49
|
+
if (!this.superseded) clearBusyState(this.formElement, ...this.target.elements)
|
|
49
50
|
Turbo.navigator.formSubmissionFinished(this)
|
|
50
51
|
}
|
|
52
|
+
|
|
53
|
+
// Responses can arrive out of order. Only the last one sent to a URL is
|
|
54
|
+
// rendered, so the page ends up answering the form as it was last sent.
|
|
55
|
+
get superseded() {
|
|
56
|
+
return latestRefreshes(this.formElement).get(this.location.href) != this
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const refreshes = new WeakMap()
|
|
61
|
+
|
|
62
|
+
function latestRefreshes(form) {
|
|
63
|
+
return refreshes.get(form) ?? refreshes.set(form, new Map).get(form)
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
class PageTarget {
|
|
@@ -146,10 +159,24 @@ class TriggerObserver {
|
|
|
146
159
|
const trigger = target.closest?.(triggerSelector)
|
|
147
160
|
const form = trigger?.form
|
|
148
161
|
|
|
149
|
-
if (form && (trigger.dataset.turboFormAction || form.dataset.turboFormUrl) && eventFor(trigger) == type) this.delegate.triggerFired(form, trigger)
|
|
162
|
+
if (form && (trigger.dataset.turboFormAction || form.dataset.turboFormUrl) && eventFor(trigger) == type) debounce(trigger, type, () => this.delegate.triggerFired(form, trigger))
|
|
150
163
|
}
|
|
151
164
|
}
|
|
152
165
|
|
|
166
|
+
const timers = new WeakMap()
|
|
167
|
+
|
|
168
|
+
// An input event, fired on every keystroke, waits for a 300ms pause before
|
|
169
|
+
// sending the form as it stands by then. data-turbo-form-debounce names a wait
|
|
170
|
+
// of its own for any event, or 0 for none. A trigger gone from the page by then,
|
|
171
|
+
// as when the user has moved on, sends nothing.
|
|
172
|
+
function debounce(trigger, type, fire) {
|
|
173
|
+
const wait = Number(trigger.dataset.turboFormDebounce ?? (type == "input" ? 300 : 0))
|
|
174
|
+
if (!wait) return fire()
|
|
175
|
+
|
|
176
|
+
clearTimeout(timers.get(trigger))
|
|
177
|
+
timers.set(trigger, setTimeout(() => trigger.isConnected && fire(), wait))
|
|
178
|
+
}
|
|
179
|
+
|
|
153
180
|
// A trigger with no event named fires on its element's natural one.
|
|
154
181
|
function eventFor(trigger) {
|
|
155
182
|
const named = trigger.dataset.turboFormTrigger
|
|
@@ -183,4 +210,13 @@ function countVisit() {
|
|
|
183
210
|
root.turboFormVisits = Number(root.turboFormVisits || 0) + 1
|
|
184
211
|
}
|
|
185
212
|
|
|
213
|
+
// A response answers the form as it was sent, which may be a keystroke or more
|
|
214
|
+
// behind the field being typed into. That field keeps what it has.
|
|
215
|
+
function keepFocusedValue(event) {
|
|
216
|
+
const { target, detail } = event
|
|
217
|
+
|
|
218
|
+
if (target == document.activeElement && target.form?.querySelector(triggerSelector) && detail.attributeName == "value") event.preventDefault()
|
|
219
|
+
}
|
|
220
|
+
|
|
186
221
|
new TriggerObserver({ triggerFired: refresh }, document).start()
|
|
222
|
+
addEventListener("turbo:before-morph-attribute", keepFocusedValue)
|
|
@@ -4,13 +4,14 @@ 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
|
# f.select :flavor, flavors, {}, dynamic_action: summary_widgets_path # stream from a URL of its own
|
|
7
|
+
# f.text_field :name, dynamic_trigger: true, dynamic_debounce: 500 # once typing pauses for 500ms
|
|
7
8
|
#
|
|
8
9
|
# `dynamic_trigger:` always has to end up as a data attribute, but where it
|
|
9
10
|
# *arrives* depends on the helper. Most treat their `options` hash as the tag's
|
|
10
11
|
# HTML attributes; the select and date families keep those in a trailing
|
|
11
12
|
# `html_options` instead, which is where these overrides earn their keep.
|
|
12
13
|
module FormBuilder
|
|
13
|
-
DYNAMIC_OPTIONS = %i[dynamic_trigger dynamic_action].freeze
|
|
14
|
+
DYNAMIC_OPTIONS = %i[dynamic_trigger dynamic_action dynamic_debounce].freeze
|
|
14
15
|
|
|
15
16
|
def select(method, choices = nil, options = {}, html_options = {}, &block)
|
|
16
17
|
super(method, choices, *hoist_trigger(options, html_options), &block)
|
|
@@ -85,17 +86,20 @@ module TurboForm
|
|
|
85
86
|
def self.absorb_trigger(attributes, template)
|
|
86
87
|
return attributes unless attributes.keys.intersect?(DYNAMIC_OPTIONS)
|
|
87
88
|
|
|
88
|
-
trigger, action = attributes.values_at(*DYNAMIC_OPTIONS)
|
|
89
|
+
trigger, action, debounce = attributes.values_at(*DYNAMIC_OPTIONS)
|
|
89
90
|
attributes = attributes.except(*DYNAMIC_OPTIONS)
|
|
90
91
|
trigger ||= action.present?
|
|
91
92
|
return attributes unless trigger
|
|
92
93
|
|
|
93
|
-
attributes.merge(data: { **attributes[:data].to_h, turbo_form_trigger: trigger_event(trigger), turbo_form_action: (template.url_for(action) if action) })
|
|
94
|
+
attributes.merge(data: { **attributes[:data].to_h, turbo_form_trigger: trigger_event(trigger), turbo_form_action: (template.url_for(action) if action), turbo_form_debounce: debounce_wait(debounce) })
|
|
94
95
|
end
|
|
95
96
|
|
|
96
97
|
# Left blank for `true`, which the script reads as the element's own
|
|
97
98
|
# default: `change` for a select, `input` for a text field, `click` for a
|
|
98
99
|
# button.
|
|
99
100
|
def self.trigger_event(trigger) = trigger == true ? '' : trigger.to_s
|
|
101
|
+
|
|
102
|
+
# The script debounces input events on its own; `false` turns that off.
|
|
103
|
+
def self.debounce_wait(debounce) = debounce == false ? 0 : debounce
|
|
100
104
|
end
|
|
101
105
|
end
|
data/lib/turbo_form/version.rb
CHANGED