phlex-reactive 0.4.8 → 0.9.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/CHANGELOG.md +868 -3
- data/README.md +986 -32
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +52 -7
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +30 -442
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +27 -9
- data/lib/phlex/reactive/js.rb +222 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +156 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +329 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -1
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# The view-side helper surface of Component (issue #115): the reply/js
|
|
7
|
+
# builders, the root-element attribute helpers (reactive_attrs/
|
|
8
|
+
# reactive_root), the trigger builders (on/on_client) with their hint
|
|
9
|
+
# vocabularies, the form-binding helpers (reactive_field/input/select/
|
|
10
|
+
# text/busy_on/reactive_compute_attrs), and the nested-attributes
|
|
11
|
+
# helpers. Everything a view_template spreads or calls — no registries,
|
|
12
|
+
# no signing (those live in DSL and Identity).
|
|
13
|
+
module Helpers
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
# The acting client's SSE connection id during the current action (nil
|
|
17
|
+
# outside an action, or when the client isn't subscribed to a stream).
|
|
18
|
+
# Pass it as `exclude:` when broadcasting from an action so the actor
|
|
19
|
+
# doesn't receive the echo of its own change — it already gets the
|
|
20
|
+
# action's HTTP response:
|
|
21
|
+
#
|
|
22
|
+
# def send_message(body:)
|
|
23
|
+
# msg = ChatMessage.create!(room: @room, body:)
|
|
24
|
+
# ChatMessage::Item.broadcast_append_to("chat", @room,
|
|
25
|
+
# target: "messages", model: msg, exclude: reactive_connection_id)
|
|
26
|
+
# end
|
|
27
|
+
def reactive_connection_id
|
|
28
|
+
Phlex::Reactive.current_connection_id
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Subject-bound reply builder — the preferred way to control an action's
|
|
32
|
+
# reply. `reply.replace.flash(:error, msg)` reads cleaner than
|
|
33
|
+
# `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
|
|
34
|
+
# component is the implicit subject (no `self` to thread) and there's no
|
|
35
|
+
# constant to qualify (reply is a method, so a namespaced component needs
|
|
36
|
+
# no `Response = …` alias). It returns the same immutable Response the
|
|
37
|
+
# endpoint reads, so chaining and the legacy return-value contract are
|
|
38
|
+
# unchanged. See Phlex::Reactive::Reply.
|
|
39
|
+
#
|
|
40
|
+
# def archive = reply.remove
|
|
41
|
+
# def go_home = reply.redirect("/todos")
|
|
42
|
+
# def update(name:) = (@account.update!(name:); reply.morph)
|
|
43
|
+
def reply
|
|
44
|
+
Phlex::Reactive::Reply.new(self)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# An empty client-side op chain (issue #95) — the starting point for
|
|
48
|
+
# on_client's DOM commands, mirroring how `reply` starts a Response chain:
|
|
49
|
+
# button(**on_client(:click, js.toggle("#menu"))) { "Menu" }
|
|
50
|
+
# Immutable: each verb returns a new chain, so reuse never leaks ops.
|
|
51
|
+
def js
|
|
52
|
+
Phlex::Reactive::JS.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Root-element attributes: marks the element reactive and carries the
|
|
56
|
+
# signed identity token. Spread onto the root:
|
|
57
|
+
# div(id:, **reactive_attrs) { ... }
|
|
58
|
+
def reactive_attrs
|
|
59
|
+
data = {
|
|
60
|
+
controller: "reactive",
|
|
61
|
+
reactive_token_value: reactive_token
|
|
62
|
+
}
|
|
63
|
+
# Client debug mode (issue #108): stamp the flag so the generic controller
|
|
64
|
+
# console.groups every dispatch. STRING "true", not boolean — Phlex renders
|
|
65
|
+
# a boolean-true attr VALUELESS, which getAttribute reads as "" (falsy in
|
|
66
|
+
# JS), so the client's attr check would never fire (the on()/warn_unsaved
|
|
67
|
+
# precedent). Off by default → no key, no string, zero client surface.
|
|
68
|
+
data[:reactive_debug] = "true" if Phlex::Reactive.debug
|
|
69
|
+
{ data: }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The WHOLE reactive root in one spread (issue #48). reactive_attrs alone
|
|
73
|
+
# doesn't emit `id:`, so `id:` and `data-controller="reactive"` can land on
|
|
74
|
+
# DIFFERENT elements — putting `id:` on a child leaves the controller root's
|
|
75
|
+
# `id` empty, which silently breaks token threading (the client self-matches
|
|
76
|
+
# its next token by `this.element.id`) and 403s on the next action.
|
|
77
|
+
#
|
|
78
|
+
# reactive_root binds the id to the SAME element as reactive_attrs, so the
|
|
79
|
+
# footgun is unbuildable:
|
|
80
|
+
# div(**reactive_root) { ... } # id + controller + token
|
|
81
|
+
# div(**reactive_root(class: "card")) { ... } # add your own attrs
|
|
82
|
+
#
|
|
83
|
+
# mix deep-merges, so overrides add `class:`/`data:` without clobbering the
|
|
84
|
+
# controller/token data: (a bare data: would). The id is resolved separately
|
|
85
|
+
# (an explicit override wins as a clean replace, not a `mix` string-concat —
|
|
86
|
+
# mix would join two String ids into "default override").
|
|
87
|
+
#
|
|
88
|
+
# `track_dirty:`/`warn_unsaved:` (issue #103) are CONSUMED here — deleted
|
|
89
|
+
# from overrides BEFORE the mix — because reactive_root treats every leftover
|
|
90
|
+
# kwarg as a literal HTML attribute override (only :id is special-cased), so
|
|
91
|
+
# an unconsumed `track_dirty: true` would render a bogus `track-dirty="true"`
|
|
92
|
+
# attribute. track_dirty mixes the trackDirty descriptor onto the root's
|
|
93
|
+
# data-action (mix token-joins, so a caller's own data-action survives);
|
|
94
|
+
# warn_unsaved emits the marker the client reads to arm the navigate-away
|
|
95
|
+
# guard (STRING "true" — a boolean-true attr renders valueless, which the
|
|
96
|
+
# client's param reader sees as "" → falsy).
|
|
97
|
+
def reactive_root(**overrides)
|
|
98
|
+
root_id = overrides.delete(:id) || id
|
|
99
|
+
track_dirty = overrides.delete(:track_dirty)
|
|
100
|
+
warn_unsaved = overrides.delete(:warn_unsaved)
|
|
101
|
+
|
|
102
|
+
attrs = mix({ **reactive_attrs }, overrides, { id: root_id })
|
|
103
|
+
attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if track_dirty
|
|
104
|
+
attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if warn_unsaved
|
|
105
|
+
attrs
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Attributes for an element that triggers an action.
|
|
109
|
+
# button(**on(:toggle)) { "○" }
|
|
110
|
+
# form(**on(:save, event: "submit")) { ... }
|
|
111
|
+
# input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
|
|
112
|
+
#
|
|
113
|
+
# Extra keyword args become explicit params merged over collected form
|
|
114
|
+
# fields. For click triggers we force type="button" so a bare button
|
|
115
|
+
# inside a <form> can't submit it and cause a full-page navigation.
|
|
116
|
+
#
|
|
117
|
+
# `debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an
|
|
118
|
+
# "input" trigger) into ONE round trip fired after the quiet period — so
|
|
119
|
+
# live-update-as-you-type doesn't POST per keystroke. A blur flushes a
|
|
120
|
+
# pending dispatch so the last edit is never dropped. Omit it for the
|
|
121
|
+
# immediate-dispatch default.
|
|
122
|
+
#
|
|
123
|
+
# `confirm:` (a message string) gates the action behind a confirmation
|
|
124
|
+
# prompt (issue #52). Destructive reactive triggers can't use Hotwire's
|
|
125
|
+
# `data-turbo-confirm` — the reactive controller calls preventDefault and
|
|
126
|
+
# enqueues the POST itself, so Turbo's confirm handling never runs. The
|
|
127
|
+
# client shows window.confirm(message) FIRST and bails before any
|
|
128
|
+
# enqueue/debounce if the user declines (and prevents the native default so
|
|
129
|
+
# a `submit` trigger can't navigate on cancel). Omit it for no prompt.
|
|
130
|
+
# button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }
|
|
131
|
+
#
|
|
132
|
+
# `event:` is interpolated verbatim into the Stimulus action descriptor
|
|
133
|
+
# (`#{event}->reactive#dispatch`), so any Stimulus event string works —
|
|
134
|
+
# including its native KEYBOARD FILTERS. Pass `event: "keydown.enter"` for
|
|
135
|
+
# Enter-to-submit or `event: "keydown.esc"` for Escape-to-cancel, and the
|
|
136
|
+
# action fires only on that key — no separate option, no client code, and
|
|
137
|
+
# `key` stays free as an ordinary action-param name (on(:switch, key: …)):
|
|
138
|
+
# input(**on(:add, event: "keydown.enter")) # Enter submits
|
|
139
|
+
# button(**on(:cancel, event: "keydown.esc")) # Escape cancels
|
|
140
|
+
#
|
|
141
|
+
# `listnav:` (a CSS selector for the option elements) adds keyboard list
|
|
142
|
+
# navigation to a search/combobox trigger (issue #72). It appends Stimulus
|
|
143
|
+
# keyboard filters to the SAME element's data-action so Arrow Up/Down move a
|
|
144
|
+
# client-side highlight among the options, Enter picks the highlighted one
|
|
145
|
+
# (clicking its own reactive trigger — so selection stays a signed action),
|
|
146
|
+
# and Escape clears — all with NO server round trip for the highlight (the
|
|
147
|
+
# controller's listnav* handlers, like #recompute). Omit it for no nav.
|
|
148
|
+
# input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]"))
|
|
149
|
+
# The verbatim JSON for an empty explicit-params payload. The common
|
|
150
|
+
# trigger (on(:increment), no params) hits this on EVERY render — skipping
|
|
151
|
+
# params.to_json (which re-serializes {} to the same "{}" each time) avoids
|
|
152
|
+
# a per-render allocation while keeping the wire format byte-identical.
|
|
153
|
+
EMPTY_PARAMS_JSON = "{}"
|
|
154
|
+
|
|
155
|
+
# The keyboard filters appended to a listnav trigger's data-action. Each is
|
|
156
|
+
# a client-only handler (no POST) except Enter, which clicks the highlighted
|
|
157
|
+
# option's own reactive trigger. Stimulus binds these natively.
|
|
158
|
+
LISTNAV_ACTIONS = [
|
|
159
|
+
"keydown.down->reactive#listnavNext",
|
|
160
|
+
"keydown.up->reactive#listnavPrev",
|
|
161
|
+
"keydown.enter->reactive#listnavPick",
|
|
162
|
+
"keydown.esc->reactive#listnavClose"
|
|
163
|
+
].freeze
|
|
164
|
+
|
|
165
|
+
# Event modifiers (issue #80) — window:, once:, outside:, throttle: are
|
|
166
|
+
# RESERVED keyword names on on() (no longer usable as free action params):
|
|
167
|
+
#
|
|
168
|
+
# `window: true` binds the trigger to the window (Stimulus's native
|
|
169
|
+
# `@window` descriptor suffix) — for page-level events like scroll/resize.
|
|
170
|
+
# `once: true` appends Stimulus's `:once` option, so the trigger fires at
|
|
171
|
+
# most one round trip and then unbinds. Both are pure descriptor
|
|
172
|
+
# composition. A window-bound trigger is NOT preventDefault-ed by the
|
|
173
|
+
# client (it would kill every native click/submit on the page), and it
|
|
174
|
+
# skips the forced type="button" (it isn't an in-form button trigger).
|
|
175
|
+
#
|
|
176
|
+
# `outside: true` fires the action only for events OUTSIDE this
|
|
177
|
+
# component's ROOT (containment against the reactive root element) — the
|
|
178
|
+
# close-a-dropdown-on-outside-click pattern. It implies `window: true`;
|
|
179
|
+
# an event inside the root is a complete client-side no-op:
|
|
180
|
+
# div(**mix(reactive_root, on(:close_menu, outside: true))) { ... }
|
|
181
|
+
#
|
|
182
|
+
# `throttle:` (milliseconds) rate-limits a hot trigger LEADING-EDGE: the
|
|
183
|
+
# first event fires immediately, further events are suppressed until the
|
|
184
|
+
# window elapses (scroll/mousemove). Mutually exclusive with `debounce:`
|
|
185
|
+
# (trailing-edge) — passing both raises ArgumentError.
|
|
186
|
+
# div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))
|
|
187
|
+
#
|
|
188
|
+
# `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
|
|
189
|
+
# COSMETIC hints the client applies the instant the trigger fires and
|
|
190
|
+
# REVERTS if the round trip fails, so a click/toggle gives instant feedback
|
|
191
|
+
# instead of waiting a full round trip. Hints are visual only — never data,
|
|
192
|
+
# never computed values (that would be client state). Supported ops in the
|
|
193
|
+
# hint hash:
|
|
194
|
+
# * toggle_class:/add_class:/remove_class: — a class string or array,
|
|
195
|
+
# applied to the TRIGGER (default) or to a `to:` selector scoped to the
|
|
196
|
+
# root (`to: :root` targets the root element itself).
|
|
197
|
+
# * checked: :keep — for a click-bound checkbox/radio, the client SKIPS
|
|
198
|
+
# its unconditional preventDefault so the native flip happens now
|
|
199
|
+
# (today the morph never even lets it flip). On failure, the flip is
|
|
200
|
+
# reverted.
|
|
201
|
+
# * hide: true — hides the target immediately (the `hide: true` + a
|
|
202
|
+
# `reply.remove` action is the instant delete-a-row recipe: the hint
|
|
203
|
+
# hides it, the reply removes it; a failure snaps it back).
|
|
204
|
+
# Success does NO cleanup: a reply that re-renders the root overwrites the
|
|
205
|
+
# hint with server truth; a reply that does NOT re-render the root
|
|
206
|
+
# (reply.remove / streams-only) LEAVES the hint standing — that's the
|
|
207
|
+
# instant-delete working as intended.
|
|
208
|
+
# input(type: "checkbox", checked: @todo.done,
|
|
209
|
+
# **mix(on(:toggle, event: "change", optimistic: { checked: :keep }), name: "done"))
|
|
210
|
+
# button(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" }
|
|
211
|
+
# `loading:` / `disable_with:` (issue #99) — declarative per-trigger loading
|
|
212
|
+
# states, Livewire's wire:loading + phx-disable-with without a Stimulus
|
|
213
|
+
# controller. The moment the request is ENQUEUED (covering the queue wait,
|
|
214
|
+
# not just the fetch), the trigger gets `data-reactive-busy="<action>"`, an
|
|
215
|
+
# optional loading class, an optional disabled + text swap; all revert in a
|
|
216
|
+
# guarded finally. `loading:` is a hash:
|
|
217
|
+
# * disable: true — disable the trigger while pending
|
|
218
|
+
# * class: "…" / [ … ] — a loading class string/array on the trigger
|
|
219
|
+
# (or a `to:` selector scoped to the root)
|
|
220
|
+
# * text: "Saving…" — swap the trigger's textContent while pending
|
|
221
|
+
# * to: :root / "sel" — target the class/text at the root or a selector
|
|
222
|
+
# `disable_with: "Saving…"` is the shorthand for
|
|
223
|
+
# `{ disable: true, text: "Saving…" }` and merges over an explicit `loading:`
|
|
224
|
+
# (its text/disable win). Both become RESERVED on() kwargs (CHANGELOG note,
|
|
225
|
+
# like #80's four and #98's optimistic:) — no longer free action-param names.
|
|
226
|
+
# The trigger/root also always carry `data-reactive-busy` for the whole
|
|
227
|
+
# pending window regardless of these hints, so an app styles a spinner with
|
|
228
|
+
# `[data-reactive-busy] .spinner { display: block }` and zero Ruby; see
|
|
229
|
+
# busy_on for scoped indicators.
|
|
230
|
+
# button(**on(:save, disable_with: "Saving…")) { "Save" }
|
|
231
|
+
# button(**on(:destroy, confirm: "Sure?", loading: { class: "opacity-50" })) { "Delete" }
|
|
232
|
+
def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil, listnav: nil,
|
|
233
|
+
window: false, once: false, outside: false, optimistic: nil, loading: nil, disable_with: nil,
|
|
234
|
+
**params)
|
|
235
|
+
# A typo'd or forgotten action renders fine and only surfaces as an
|
|
236
|
+
# opaque 403 at CLICK time (the endpoint's default-deny). Under
|
|
237
|
+
# verbose_errors (dev + test), fail loudly at RENDER time instead —
|
|
238
|
+
# listing the declared actions — the same courtesy reactive_compute_attrs
|
|
239
|
+
# gives an undeclared compute (issue #105). Placed FIRST, before any attr
|
|
240
|
+
# building. Production (flag off) keeps the permissive emit: a stale page
|
|
241
|
+
# after a deploy that removed an action must not 500 on render. This is a
|
|
242
|
+
# dev-time aid, NOT the security boundary — default-deny stays the
|
|
243
|
+
# SERVER's enforcement. on_client triggers are not declared actions (no
|
|
244
|
+
# registry), so they are never checked here.
|
|
245
|
+
#
|
|
246
|
+
# The check applies ONLY to a component that declares actions of its own.
|
|
247
|
+
# A component with an EMPTY registry is a cross-component dispatch helper
|
|
248
|
+
# — a child row that renders a trigger for its CONTAINER's action and
|
|
249
|
+
# sends the container's token (e.g. NotificationRowComponent → the list's
|
|
250
|
+
# :dismiss). It can't self-validate against a registry it doesn't own, so
|
|
251
|
+
# the guard would false-positive; skipping the empty case keeps the
|
|
252
|
+
# pattern working while still catching a typo in a component that DOES
|
|
253
|
+
# declare actions (the issue's target — on(:togle) where :toggle exists).
|
|
254
|
+
# verbose_errors is checked FIRST so production (flag off) short-circuits
|
|
255
|
+
# before touching the registry — zero added cost on the hot path.
|
|
256
|
+
if Phlex::Reactive.verbose_errors &&
|
|
257
|
+
(actions = self.class.reactive_actions).any? && !actions.key?(action_name.to_sym)
|
|
258
|
+
raise Phlex::Reactive::Error,
|
|
259
|
+
"#{self.class} has no declared action #{action_name.to_sym.inspect} " \
|
|
260
|
+
"(declared: #{actions.keys.inspect})"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if debounce && throttle
|
|
264
|
+
raise ArgumentError,
|
|
265
|
+
"on(#{action_name.inspect}) got both debounce: and throttle: — they are mutually " \
|
|
266
|
+
"exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
window_bound = window || outside
|
|
270
|
+
action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
|
|
271
|
+
action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
|
|
272
|
+
attrs = {
|
|
273
|
+
data: {
|
|
274
|
+
action:,
|
|
275
|
+
reactive_action_param: action_name.to_s,
|
|
276
|
+
reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
attrs[:data][:reactive_debounce_param] = debounce if debounce
|
|
280
|
+
attrs[:data][:reactive_throttle_param] = throttle if throttle
|
|
281
|
+
attrs[:data][:reactive_confirm_param] = confirm if confirm
|
|
282
|
+
attrs[:data][:reactive_listnav_option_param] = listnav if listnav
|
|
283
|
+
attrs[:data][:reactive_optimistic_param] = optimistic_hint_json(optimistic, action_name) if optimistic
|
|
284
|
+
if (loading_hint = loading_hint_json(loading, disable_with, action_name))
|
|
285
|
+
attrs[:data][:reactive_loading_param] = loading_hint
|
|
286
|
+
end
|
|
287
|
+
# STRING "true", not boolean: Phlex renders a `true` attribute VALUELESS
|
|
288
|
+
# (data-reactive-outside-param), which Stimulus's param reader sees as ""
|
|
289
|
+
# — falsy in JS, so the guard silently never fires. The explicit ="true"
|
|
290
|
+
# typecasts to a real boolean on the client.
|
|
291
|
+
attrs[:data][:reactive_outside_param] = "true" if outside
|
|
292
|
+
# The client decides preventDefault behavior from event.params (never by
|
|
293
|
+
# sniffing the descriptor), so EVERY window binding flags the param.
|
|
294
|
+
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
295
|
+
# Force type="button" for click triggers so a bare button inside a <form>
|
|
296
|
+
# can't submit it — EXCEPT when checked: :keep is declared: that hint's
|
|
297
|
+
# whole point is to let a click-bound checkbox/radio flip natively, and a
|
|
298
|
+
# forced type="button" would destroy the very control being toggled
|
|
299
|
+
# (issue #98). The caller supplies the real type="checkbox"/"radio".
|
|
300
|
+
attrs[:type] = "button" if event == "click" && !window_bound && !optimistic_keeps_native?(optimistic)
|
|
301
|
+
attrs
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Attributes for a CLIENT-ONLY trigger (issue #95): binds a DOM event to a
|
|
305
|
+
# chain of declarative DOM ops (Phlex::Reactive::JS) that the generic
|
|
306
|
+
# controller's runOps action applies in the browser — NO token, NO params,
|
|
307
|
+
# NO POST, ever. The zero-round-trip sibling of on():
|
|
308
|
+
#
|
|
309
|
+
# button(**on_client(:click, js.toggle("#menu"))) { "Menu" }
|
|
310
|
+
# # tabs, one line per tab, no Stimulus controller:
|
|
311
|
+
# button(**on_client(:click, js.hide(".panel").show("#panel-2")))
|
|
312
|
+
#
|
|
313
|
+
# `window:`, `once:`, and `outside:` compose exactly like on()'s event
|
|
314
|
+
# modifiers (#80): outside-click-to-close a dropdown is
|
|
315
|
+
# div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true)))
|
|
316
|
+
# Window-bound triggers are never preventDefault-ed by the client and skip
|
|
317
|
+
# the forced type="button".
|
|
318
|
+
#
|
|
319
|
+
# Ops are EPHEMERAL UI: any server re-render of the component (an action
|
|
320
|
+
# reply, a broadcast, a morph) rebuilds from server state and resets
|
|
321
|
+
# whatever they toggled — by design (the LiveView JS-commands caveat). Use
|
|
322
|
+
# a signed action for state that must survive re-renders.
|
|
323
|
+
#
|
|
324
|
+
# Validation is loud: only a non-empty Phlex::Reactive::JS chain is
|
|
325
|
+
# accepted — a dead trigger should fail at render, not no-op in the
|
|
326
|
+
# browser.
|
|
327
|
+
def on_client(event, ops, window: false, once: false, outside: false)
|
|
328
|
+
unless ops.is_a?(Phlex::Reactive::JS)
|
|
329
|
+
raise ArgumentError,
|
|
330
|
+
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
331
|
+
"got #{ops.class}"
|
|
332
|
+
end
|
|
333
|
+
raise ArgumentError, "on_client(#{event.inspect}) got no ops — a dead trigger" if ops.empty?
|
|
334
|
+
|
|
335
|
+
event = event.to_s
|
|
336
|
+
window_bound = window || outside
|
|
337
|
+
attrs = {
|
|
338
|
+
data: {
|
|
339
|
+
action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
|
|
340
|
+
reactive_ops_param: ops.to_json
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
# STRING "true", not boolean — same Phlex valueless-attribute trap as
|
|
344
|
+
# on()'s flags above.
|
|
345
|
+
attrs[:data][:reactive_outside_param] = "true" if outside
|
|
346
|
+
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
347
|
+
attrs[:type] = "button" if event == "click" && !window_bound
|
|
348
|
+
attrs
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Bind a form control's `name` to an action param so its value travels with
|
|
352
|
+
# the action — instead of hand-writing the magic `name: "value"` on every
|
|
353
|
+
# input and silently getting no params when you forget it (issue #23).
|
|
354
|
+
# Returns a Phlex attributes hash to spread onto any control:
|
|
355
|
+
# input(**reactive_field(:value, value: @record.name))
|
|
356
|
+
# select(**reactive_field(:status)) { ... }
|
|
357
|
+
# Extra attrs merge over the binding; an explicit name: still wins (escape
|
|
358
|
+
# hatch). The trigger (on(:save)) stays on the button, not the field — so
|
|
359
|
+
# focusing the input doesn't dispatch and collapse edit mode.
|
|
360
|
+
#
|
|
361
|
+
# `dirty: true` (issue #103) wires the field to the generic controller's
|
|
362
|
+
# trackDirty action, so a change re-scans this reactive root's owned fields
|
|
363
|
+
# and marks the changed ones `data-reactive-dirty` (and the root with a
|
|
364
|
+
# count). NO client state is shipped — the baseline is the DOM's own
|
|
365
|
+
# `defaultValue`/`defaultChecked`/`defaultSelected`, i.e. the attributes from
|
|
366
|
+
# the last server render; dirty = current ≠ default. It deep-merges the
|
|
367
|
+
# descriptor via mix, so a caller's own data-action is token-joined, not
|
|
368
|
+
# clobbered (CLAUDE.md Never-Do #8 — combining with other data:/on() still
|
|
369
|
+
# needs mix at the call site).
|
|
370
|
+
def reactive_field(param, dirty: false, **attrs)
|
|
371
|
+
binding_attrs = { name: param.to_s, **attrs }
|
|
372
|
+
return binding_attrs unless dirty
|
|
373
|
+
|
|
374
|
+
mix(binding_attrs, { data: { action: "input->reactive#trackDirty" } })
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Render an <input> already bound to an action param (issue #23). Sugar for
|
|
378
|
+
# input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
|
|
379
|
+
# reactive_input(:value, value: @record.name, type: "text")
|
|
380
|
+
def reactive_input(param, **attrs)
|
|
381
|
+
input(**reactive_field(param, **attrs))
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Mirror a compute output (or a declared input) into a TEXT NODE — a live
|
|
385
|
+
# preview heading, a character counter, a "Hello, {name}" greeting (issue
|
|
386
|
+
# #104). The text sibling of reactive_field: reactive_field binds a FORM
|
|
387
|
+
# CONTROL; reactive_text binds a plain span the client writes via
|
|
388
|
+
# textContent (XSS-safe by construction — never innerHTML).
|
|
389
|
+
#
|
|
390
|
+
# h2 { reactive_text(:title_preview, @post.title) }
|
|
391
|
+
# small { reactive_text(:char_count) }
|
|
392
|
+
#
|
|
393
|
+
# The span carries data-reactive-text=<name> and NO `name` attribute, so
|
|
394
|
+
# #collectFields never sweeps it into the POSTed params. `initial` seeds the
|
|
395
|
+
# first paint — the SERVER render must seed the same derived value the
|
|
396
|
+
# reducer would, or a morph repaints stale text (same reconcile contract
|
|
397
|
+
# reactive_compute documents). Extra attrs merge over the binding.
|
|
398
|
+
def reactive_text(name, initial = nil, **attrs)
|
|
399
|
+
span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Scoped busy indicator (issue #99). Marks an element so the generic
|
|
403
|
+
# controller toggles `data-reactive-busy` on it ONLY while `action` is in
|
|
404
|
+
# flight — the scoped sibling of the always-on `data-reactive-busy` the
|
|
405
|
+
# trigger and root carry. Spread onto any element inside the reactive root;
|
|
406
|
+
# style it with `[data-reactive-busy] { … }` and zero Ruby:
|
|
407
|
+
# span(**busy_on(:save), class: "spinner hidden")
|
|
408
|
+
def busy_on(action)
|
|
409
|
+
{ data: { reactive_busy_on: action.to_s } }
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Data attributes declaring a client-side compute for the root element.
|
|
413
|
+
# Spread ALONGSIDE reactive_root so the generic controller can find the
|
|
414
|
+
# reducer and the named input/output fields inside this root:
|
|
415
|
+
# div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
|
|
416
|
+
#
|
|
417
|
+
# It emits the reducer key plus the input/output field names as JSON so the
|
|
418
|
+
# client runs the reducer on `input`, writes the outputs with no round trip,
|
|
419
|
+
# then the debounced POST reconciles from the server reply. Raises for an
|
|
420
|
+
# undeclared compute — a silent no-op would leave the field wiring dead.
|
|
421
|
+
def reactive_compute_attrs(name)
|
|
422
|
+
definition = self.class.reactive_compute_def(name)
|
|
423
|
+
raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
|
|
424
|
+
|
|
425
|
+
{
|
|
426
|
+
data: {
|
|
427
|
+
reactive_compute_reducer_param: definition.reducer,
|
|
428
|
+
reactive_compute_inputs_param: compute_inputs_param(definition),
|
|
429
|
+
reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# The inputs param wire (issue #104). Untyped (array form) → a JSON ARRAY of
|
|
435
|
+
# names, byte-identical to the shipped wire so the client keeps its numeric
|
|
436
|
+
# coercion. Typed (hash form) → a JSON OBJECT of name→type
|
|
437
|
+
# ({"title":"string","qty":"number"}) so the client reads a :string raw and
|
|
438
|
+
# coerces a :number through Number.
|
|
439
|
+
def compute_inputs_param(definition)
|
|
440
|
+
types = definition.input_types
|
|
441
|
+
return definition.inputs.map(&:to_s).to_json if types.nil?
|
|
442
|
+
|
|
443
|
+
definition.inputs.to_h { [it.to_s, types[it].to_s] }.to_json
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Render a <select> bound to an action param (issue #23). The options block
|
|
447
|
+
# is the element's content, so the awkward FormBuilder positional split
|
|
448
|
+
# (where name: lands after the options/html-options args) goes away:
|
|
449
|
+
# reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
|
|
450
|
+
def reactive_select(param, **attrs, &)
|
|
451
|
+
select(**reactive_field(param, **attrs), &)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Map a declared nested param onto Rails' <assoc>_attributes, carrying the
|
|
455
|
+
# existing associated record's id so accepts_nested_attributes_for matches
|
|
456
|
+
# it IN PLACE instead of building a second one (issue #24). Returns the
|
|
457
|
+
# update hash; pass it to update!:
|
|
458
|
+
# def save(address:) = nested_update!(:address, address)
|
|
459
|
+
# The id is only added when the association already exists, so the first
|
|
460
|
+
# save (no associated record yet) creates one cleanly. The given attrs are
|
|
461
|
+
# not mutated.
|
|
462
|
+
def nested_attributes(association, attrs)
|
|
463
|
+
merged = attrs.dup
|
|
464
|
+
existing = reactive_record_for_nested.public_send(association)
|
|
465
|
+
merged[:id] = existing.id if existing
|
|
466
|
+
|
|
467
|
+
{ "#{association}_attributes": merged }
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Map a nested param onto <assoc>_attributes (with id preservation) AND
|
|
471
|
+
# apply it to the component's record in one call (issue #24). Extra keyword
|
|
472
|
+
# attributes update alongside the association.
|
|
473
|
+
# def save(address:, name:) = nested_update!(:address, address, name:)
|
|
474
|
+
def nested_update!(association, attrs, **extra)
|
|
475
|
+
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# The declared optimistic-hint class ops (issue #98): the cosmetic class
|
|
479
|
+
# vocabulary the client applies instantly and reverts on failure. Enforced
|
|
480
|
+
# at build time in optimistic_hint_json (default-deny — a dead hint fails
|
|
481
|
+
# at render, not silently in the browser). Each carries a class string or
|
|
482
|
+
# array; hide/checked are flags with a fixed shape.
|
|
483
|
+
OPTIMISTIC_CLASS_OPS = %w[toggle_class add_class remove_class].freeze
|
|
484
|
+
|
|
485
|
+
private
|
|
486
|
+
|
|
487
|
+
# True when the hint declares checked: :keep — the click-bound
|
|
488
|
+
# checkbox/radio case that must SKIP the forced type="button" so the native
|
|
489
|
+
# control (and its native flip) survives (issue #98). Accepts symbol or
|
|
490
|
+
# string keys/values; nil-safe for the no-hint hot path.
|
|
491
|
+
def optimistic_keeps_native?(optimistic)
|
|
492
|
+
return false unless optimistic.is_a?(Hash)
|
|
493
|
+
|
|
494
|
+
value = optimistic[:checked] || optimistic["checked"]
|
|
495
|
+
value.to_s == "keep"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Normalize + validate the optimistic hint hash, returning its JSON wire
|
|
499
|
+
# form (data-reactive-optimistic-param). `to: :root` becomes the same
|
|
500
|
+
# "@root" sentinel the js op builder uses so the client resolves it
|
|
501
|
+
# uniformly. Unknown keys, a bad `checked:` value, or a non-hash raise —
|
|
502
|
+
# a hint that can't apply must fail loudly at render.
|
|
503
|
+
def optimistic_hint_json(optimistic, action_name)
|
|
504
|
+
unless optimistic.is_a?(Hash)
|
|
505
|
+
raise ArgumentError,
|
|
506
|
+
"on(#{action_name.inspect}) optimistic: must be a Hash of visual hints " \
|
|
507
|
+
"(e.g. { checked: :keep } or { hide: true, to: :root }), got #{optimistic.class}"
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
hint = {}
|
|
511
|
+
optimistic.each do |key, value|
|
|
512
|
+
case key.to_s
|
|
513
|
+
when *OPTIMISTIC_CLASS_OPS
|
|
514
|
+
hint[key.to_s] = Array(value).map(&:to_s)
|
|
515
|
+
when "hide"
|
|
516
|
+
hint["hide"] = value ? true : false
|
|
517
|
+
when "checked"
|
|
518
|
+
unless value.to_s == "keep"
|
|
519
|
+
raise ArgumentError,
|
|
520
|
+
"on(#{action_name.inspect}) optimistic checked: only supports :keep " \
|
|
521
|
+
"(flip the native control, revert on failure), got #{value.inspect}"
|
|
522
|
+
end
|
|
523
|
+
hint["checked"] = "keep"
|
|
524
|
+
when "to"
|
|
525
|
+
hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
|
|
526
|
+
else
|
|
527
|
+
raise ArgumentError,
|
|
528
|
+
"on(#{action_name.inspect}) got an unknown optimistic hint #{key.inspect} — " \
|
|
529
|
+
"supported: toggle_class/add_class/remove_class, checked: :keep, hide: true, to:"
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
hint.to_json
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Normalize + validate the loading hint (issue #99), returning its JSON wire
|
|
537
|
+
# form (data-reactive-loading-param) or nil when neither loading: nor
|
|
538
|
+
# disable_with: is given (the bare on() hot path stays untouched).
|
|
539
|
+
# disable_with: "Saving…" expands to { disable: true, text: … } and MERGES
|
|
540
|
+
# over an explicit loading: hash — its text/disable win. `to: :root` becomes
|
|
541
|
+
# the "@root" sentinel the js op builder uses so the client resolves targets
|
|
542
|
+
# uniformly. An unknown key or a non-hash loading: raises — a dead hint must
|
|
543
|
+
# fail loudly at render, not silently in the browser (default-deny).
|
|
544
|
+
def loading_hint_json(loading, disable_with, action_name)
|
|
545
|
+
return nil if loading.nil? && disable_with.nil?
|
|
546
|
+
|
|
547
|
+
source = loading || {}
|
|
548
|
+
unless source.is_a?(Hash)
|
|
549
|
+
raise ArgumentError,
|
|
550
|
+
"on(#{action_name.inspect}) loading: must be a Hash of loading hints " \
|
|
551
|
+
"(e.g. { disable: true, class: \"opacity-50\", text: \"Saving…\" }), got #{loading.class}"
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
hint = {}
|
|
555
|
+
source.each do |key, value|
|
|
556
|
+
case key.to_s
|
|
557
|
+
when "class"
|
|
558
|
+
hint["class"] = Array(value).map(&:to_s)
|
|
559
|
+
when "disable"
|
|
560
|
+
hint["disable"] = value ? true : false
|
|
561
|
+
when "text"
|
|
562
|
+
hint["text"] = value.to_s
|
|
563
|
+
when "to"
|
|
564
|
+
hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
|
|
565
|
+
else
|
|
566
|
+
raise ArgumentError,
|
|
567
|
+
"on(#{action_name.inspect}) got an unknown loading hint #{key.inspect} — " \
|
|
568
|
+
"supported: disable:, class:, text:, to:"
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# disable_with: "Saving…" ≡ { disable: true, text: … }, applied AFTER the
|
|
573
|
+
# explicit loading: hash so it wins on both keys (the shorthand is the
|
|
574
|
+
# more specific intent when a caller passes both).
|
|
575
|
+
if disable_with
|
|
576
|
+
hint["disable"] = true
|
|
577
|
+
hint["text"] = disable_with.to_s
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
hint.to_json
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# The component's record, for the nested-attributes helpers. Requires a
|
|
584
|
+
# declared reactive_record (the nested helper only makes sense for a
|
|
585
|
+
# record-backed component).
|
|
586
|
+
def reactive_record_for_nested
|
|
587
|
+
key = self.class.reactive_record_key
|
|
588
|
+
raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
|
|
589
|
+
|
|
590
|
+
instance_variable_get(:"@#{key}")
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|