turbo_form 0.9.1 → 0.11.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 +39 -5
- data/app/assets/javascripts/turbo_form.js +39 -3
- data/lib/turbo_form/controller.rb +10 -5
- 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: 959442a013960f1f2a4cbe72191b4fc965f15cc416be00b4d8ba2f2349dca3f9
|
|
4
|
+
data.tar.gz: 7fa3a58b24e6c22bf27bcfc994dad3aa3f121795ccf6f18211839f18cfb99045
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a62fcaf048717ca8261bac7e79a4c831bce7d02e9b0c0e6f689c3fe01d7966eeff9a4ed771977c703984b45e45b9ba1282f397ed2ba3b2a29a1f29ddb05dd7d2
|
|
7
|
+
data.tar.gz: 3a5c4756db5d7c1685733877e424198d6a338065bd51c79f2f2cb4d67c0875bb005bd9918835ab4125b65920ab50860a64e04488b14671b7d7eae41adb4e01c6
|
data/README.md
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# turbo_form
|
|
2
2
|
|
|
3
|
+
<div>
|
|
4
|
+
<img src="https://github.com/OutlawAndy/turbo_form/actions/workflows/ci.yml/badge.svg" alt="CI">
|
|
5
|
+
<img src="https://img.shields.io/gem/v/turbo_form?label=rubygems" alt="RubyGems">
|
|
6
|
+
<img src="https://img.shields.io/npm/v/@rolemodel/turbo-form?label=npm" alt="npm">
|
|
7
|
+
</div>
|
|
8
|
+
<br/>
|
|
9
|
+
|
|
3
10
|
Dynamic forms for Rails, by convention.
|
|
4
11
|
|
|
12
|
+

|
|
13
|
+
|
|
5
14
|
A form that needs to change as it is filled in — a dependent dropdown, a section
|
|
6
15
|
that appears once you pick a type — usually costs you a route, a controller
|
|
7
16
|
action, a Stimulus controller and some wiring. This gem asks for two words
|
|
@@ -68,9 +77,9 @@ the route concern listens.
|
|
|
68
77
|
```
|
|
69
78
|
|
|
70
79
|
`true` picks the element's natural event: `change` for a select, `click` for a
|
|
71
|
-
button, `input` for everything else.
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
button, `input` for everything else. An `input` event fires on every keystroke,
|
|
81
|
+
so it waits for a 300ms pause before sending the form. Name an event when you
|
|
82
|
+
want something else, like `:blur`. Any event name works, including custom ones
|
|
74
83
|
dispatched by other Stimulus controllers — `dynamic_trigger: "autocomplete:selected"`
|
|
75
84
|
— as long as the event is dispatched on the field or bubbles up from inside it.
|
|
76
85
|
|
|
@@ -81,6 +90,20 @@ the select and date families where Rails keeps HTML attributes in a separate has
|
|
|
81
90
|
<%= f.collection_select :category_id, Category.all, :id, :name, dynamic_trigger: true %>
|
|
82
91
|
```
|
|
83
92
|
|
|
93
|
+
### `dynamic_debounce:` on a field
|
|
94
|
+
|
|
95
|
+
```erb
|
|
96
|
+
<%= f.text_field :name, dynamic_trigger: true, dynamic_debounce: 500 %> <%# wait longer %>
|
|
97
|
+
<%= f.range_field :size, dynamic_trigger: true, dynamic_debounce: false %> <%# don't wait %>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
How many milliseconds a trigger waits for its events to pause before sending
|
|
101
|
+
the form, as it stands by then. `input` events wait 300ms unless told
|
|
102
|
+
otherwise; every other event sends at once unless it names a wait.
|
|
103
|
+
|
|
104
|
+
Text typed into a field while its request is out is kept when the response
|
|
105
|
+
arrives, and a response overtaken by a later one to the same URL isn't rendered.
|
|
106
|
+
|
|
84
107
|
### `dynamic_action:` on a field
|
|
85
108
|
|
|
86
109
|
```erb
|
|
@@ -141,6 +164,16 @@ For a `WidgetsController`:
|
|
|
141
164
|
themselves — `render layout: 'panel'` is fine. The values are assigned just
|
|
142
165
|
before it renders; an action that redirects still redirects.
|
|
143
166
|
|
|
167
|
+
When `new` and `edit` build the form's object under another name — a bulk form
|
|
168
|
+
object in `@form`, say, where `@widget` already means one of the records it
|
|
169
|
+
wraps — name it, and `widget_params` still permits it:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class WidgetsController < ApplicationController
|
|
173
|
+
self.turbo_form_resource_name = :form
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
144
177
|
### Switching an STI subclass
|
|
145
178
|
|
|
146
179
|
When the submitted values include the inheritance column, the object is switched
|
|
@@ -217,8 +250,9 @@ it doesn't care what the trigger changed.
|
|
|
217
250
|
|
|
218
251
|
It handles the common case well and gets out of the way otherwise.
|
|
219
252
|
|
|
220
|
-
- **No
|
|
221
|
-
|
|
253
|
+
- **No request cancellation or loading state** beyond `aria-busy`. An overtaken
|
|
254
|
+
request still runs on the server; its response is just not rendered. Write
|
|
255
|
+
the action by hand when you need more — that path is still open.
|
|
222
256
|
- **Resourceful pages only.** The form's URL comes from its model, so a page
|
|
223
257
|
whose `new` or `edit` isn't the model's own route has nowhere to send it.
|
|
224
258
|
`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)
|
|
@@ -5,12 +5,17 @@ module TurboForm
|
|
|
5
5
|
# implicitly, or with its own `render layout: 'panel'`.
|
|
6
6
|
#
|
|
7
7
|
# Leans on the scaffold's conventions: the action builds `@<resource>` and
|
|
8
|
-
# `<resource>_params` permits the form.
|
|
8
|
+
# `<resource>_params` permits the form. A controller whose form object goes by
|
|
9
|
+
# another name sets `self.turbo_form_resource_name = :form`.
|
|
9
10
|
module Controller
|
|
10
11
|
extend ActiveSupport::Concern
|
|
11
12
|
|
|
12
13
|
included do
|
|
13
14
|
helper_method :turbo_form_render?
|
|
15
|
+
|
|
16
|
+
# The instance variable `new` and `edit` build the form's object in, when
|
|
17
|
+
# it isn't the controller's namesake.
|
|
18
|
+
class_attribute :turbo_form_resource_name, instance_accessor: false
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
# Public, as Rails' own is. ActionController::Base's public methods are never
|
|
@@ -31,12 +36,12 @@ module TurboForm
|
|
|
31
36
|
|
|
32
37
|
def dynamic_form_assignment
|
|
33
38
|
resource = turbo_form_resource.extend(TurboForm::Resource).dynamic_assign(turbo_form_resource_params)
|
|
34
|
-
instance_variable_set(
|
|
39
|
+
instance_variable_set(turbo_form_ivar, resource)
|
|
35
40
|
end
|
|
36
41
|
|
|
37
|
-
def turbo_form_resource = instance_variable_get(
|
|
38
|
-
def turbo_form_resource_params = send(:"#{
|
|
39
|
-
def
|
|
42
|
+
def turbo_form_resource = instance_variable_get(turbo_form_ivar)
|
|
43
|
+
def turbo_form_resource_params = send(:"#{controller_name.singularize}_params")
|
|
44
|
+
def turbo_form_ivar = :"@#{self.class.turbo_form_resource_name || controller_name.singularize}"
|
|
40
45
|
|
|
41
46
|
# Active Record saves `has_many` writers and `*_ids=` on assignment, and
|
|
42
47
|
# this request exists only to render.
|
|
@@ -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