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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cb1af8f341d1112a85f86bc4fe28470e83f867994be6029a32fbd996da03677
4
- data.tar.gz: 97afa78d913454ea096c0f988ecb85d99f7ebe7027f02f2e19cc40af534b624e
3
+ metadata.gz: 69e93adb63b8efad18d18aa5009294ecf46dff732a835677016787a498a359fe
4
+ data.tar.gz: 8058b55a07707691cd9bb2770339f2685ca4c13f104cd97db01aeeef184326bd
5
5
  SHA512:
6
- metadata.gz: 221fbbcd5e31c6191a208cefb92089f70094ef5709547cdb63984a4bb419342d7109dfe4001135853ee7380537b63d82a8cb2fef64af550d40a1a713dfa93f20
7
- data.tar.gz: 2e7cd4ecec1194cc7605035e9a51c9dcd1542f3c45410c6497a74221e8ae6a037991650a6d928bdf230097af4cfbc0e7aaf73751326a912f291649b9e22221b4
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 two options
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
- submit button, `input` for everything else. Name an event when you want
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, including the select and date families where
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
- export function refresh(form) {
14
- return new FormRefresh(form).start()
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.status, await response.text())
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(statusCode, responseHTML) {
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(_statusCode, responseHTML) {
74
- const frame = new DOMParser().parseFromString(responseHTML, "text/html").getElementById(this.element.id)
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?.dataset.turboFormUrl && eventFor(trigger) == type) this.delegate.triggerFired(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 dynamic_form?
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
- dynamic_form? ? discarding_writes { super } : super
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
- 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)) ]
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.key?(:dynamic_trigger)
84
+ return attributes unless attributes.keys.intersect?(DYNAMIC_OPTIONS)
73
85
 
74
- trigger = attributes[:dynamic_trigger]
75
- attributes = attributes.except(:dynamic_trigger)
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
@@ -1,3 +1,3 @@
1
1
  module TurboForm
2
- VERSION = '0.7.1'
2
+ VERSION = '0.8.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.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Cohen