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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1637d6cbac8d370efc21308cc1ace27f278e2153fde411829fa4031d5980a54
4
- data.tar.gz: 0247d756f8f6217149c1bd24e30acca602b0fa9a7af69acc7d07d0228a1a727c
3
+ metadata.gz: 959442a013960f1f2a4cbe72191b4fc965f15cc416be00b4d8ba2f2349dca3f9
4
+ data.tar.gz: 7fa3a58b24e6c22bf27bcfc994dad3aa3f121795ccf6f18211839f18cfb99045
5
5
  SHA512:
6
- metadata.gz: b53a713122f8443e248f787953724cc0204aca67ae53b994122838fabaf7c9bf3300c720557f8a7818dba2154495d5f1ec98c289458f5456245c7f8550120349
7
- data.tar.gz: fab34492c6b7b1b7f01efea76aa25a157d96c7ccaaed682c9e15f424e3e4512cc37543119caa9dcc381c52208cb891c73ee727d6a1c54c1c435610a0b8e9c8b4
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
+ ![title image](title_image.png)
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. Name an event when you want
72
- something else — `:blur` on text fields is usually what you want, since the
73
- default fires on every keystroke. Any event name works, including custom ones
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 debouncing, request cancellation or loading state** beyond `aria-busy`.
221
- Write the action by hand when you need those — that path is still open.
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(:"@#{turbo_form_resource_name}", resource)
39
+ instance_variable_set(turbo_form_ivar, resource)
35
40
  end
36
41
 
37
- def turbo_form_resource = instance_variable_get(:"@#{turbo_form_resource_name}")
38
- def turbo_form_resource_params = send(:"#{turbo_form_resource_name}_params")
39
- def turbo_form_resource_name = controller_name.singularize
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
@@ -1,3 +1,3 @@
1
1
  module TurboForm
2
- VERSION = '0.9.1'
2
+ VERSION = '0.11.0'
3
3
  end
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.9.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Cohen