phlex-reactive 0.11.7 → 0.12.1
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 +74 -0
- data/README.md +90 -6
- data/app/assets/stylesheets/phlex/reactive/effects.css +106 -0
- data/app/javascript/phlex/reactive/confirm.js +8 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.js +341 -5
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +66 -0
- data/lib/phlex/reactive/component/helpers.rb +24 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/effects.rb +169 -0
- data/lib/phlex/reactive/engine.rb +5 -0
- data/lib/phlex/reactive/reply.rb +20 -15
- data/lib/phlex/reactive/response.rb +28 -19
- data/lib/phlex/reactive/streamable.rb +69 -32
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +30 -2
- metadata +3 -1
|
@@ -122,6 +122,72 @@ module Phlex
|
|
|
122
122
|
Registry.resolve_scalar(self, :dirty, :reactive_dirty_config)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
# Effects (issue #215): declare how THIS component animates when a
|
|
126
|
+
# stream renders it — enter (append/prepend), exit (remove), update
|
|
127
|
+
# (replace/update, plain or morph). Refines the global
|
|
128
|
+
# Phlex::Reactive.effects set per hook (most specific wins); works
|
|
129
|
+
# standalone too — declaring on a component IS that component's
|
|
130
|
+
# opt-in, no global switch required.
|
|
131
|
+
#
|
|
132
|
+
# reactive_effects enter: :slide, exit: :fade # built-ins (shipped CSS)
|
|
133
|
+
# reactive_effects update: false # disable one hook
|
|
134
|
+
# reactive_effects false # opt out entirely
|
|
135
|
+
# reactive_effects enter: :random # random built-in per application
|
|
136
|
+
# reactive_effects enter: { during: %w[transition-all], from: %w[opacity-0],
|
|
137
|
+
# to: %w[opacity-100] } # custom #186 legs
|
|
138
|
+
#
|
|
139
|
+
# Validated HERE (class load), not at render time — a typo'd effect
|
|
140
|
+
# name raises immediately. Stored as pre-compiled wire strings.
|
|
141
|
+
def reactive_effects(opt_out = nil, **hooks)
|
|
142
|
+
if opt_out == false
|
|
143
|
+
unless hooks.empty?
|
|
144
|
+
raise ArgumentError,
|
|
145
|
+
"reactive_effects false opts the component out entirely — it takes no hooks " \
|
|
146
|
+
"(got #{hooks.keys.inspect}); use per-hook false to disable one: update: false"
|
|
147
|
+
end
|
|
148
|
+
return Registry.write_scalar(self, :effects, false)
|
|
149
|
+
end
|
|
150
|
+
unless opt_out.nil?
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"reactive_effects takes hook keywords (enter:/exit:/update:) or false — got #{opt_out.inspect}"
|
|
153
|
+
end
|
|
154
|
+
if hooks.empty?
|
|
155
|
+
raise ArgumentError,
|
|
156
|
+
"reactive_effects needs at least one hook (enter:/exit:/update:) or false to opt out"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Registry.write_scalar(self, :effects, Phlex::Reactive::Effects.normalize!(hooks))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The raw declaration ({ hook => wire | false }, false for a full
|
|
163
|
+
# opt-out, or nil when undeclared), inherited through the Registry
|
|
164
|
+
# like every other scalar.
|
|
165
|
+
def reactive_effects_config
|
|
166
|
+
Registry.resolve_scalar(self, :effects, :reactive_effects_config)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The RESOLVED root effect attrs (global ⊕ component, false hooks
|
|
170
|
+
# dropped): a frozen { reactive_effect_enter: "fade", … } fragment
|
|
171
|
+
# reactive_attrs merges into the root's data:, or nil when off.
|
|
172
|
+
# Memoized per class against BOTH inputs that can change the answer
|
|
173
|
+
# — the effects config generation (a Phlex::Reactive.effects= write)
|
|
174
|
+
# and the registry generation (any declaration) — as two integer
|
|
175
|
+
# compares, because reactive_attrs is the token-signing hot path.
|
|
176
|
+
# The race on a concurrent first call is benign (idempotent rebuild).
|
|
177
|
+
def reactive_effect_attrs
|
|
178
|
+
effects_gen = Phlex::Reactive.effects_generation
|
|
179
|
+
registry_gen = Registry.generation
|
|
180
|
+
if @reactive_effect_attrs_effects_gen == effects_gen &&
|
|
181
|
+
@reactive_effect_attrs_registry_gen == registry_gen
|
|
182
|
+
return @reactive_effect_attrs
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@reactive_effect_attrs = Phlex::Reactive::Effects.root_attrs_for(self)
|
|
186
|
+
@reactive_effect_attrs_effects_gen = effects_gen
|
|
187
|
+
@reactive_effect_attrs_registry_gen = registry_gen
|
|
188
|
+
@reactive_effect_attrs
|
|
189
|
+
end
|
|
190
|
+
|
|
125
191
|
# Opt into signed STATE for record-less components only.
|
|
126
192
|
# reactive_state :count, :open
|
|
127
193
|
def reactive_state(*names)
|
|
@@ -91,6 +91,13 @@ module Phlex
|
|
|
91
91
|
if self.class.respond_to?(:reactive_scope) && (scope = self.class.reactive_scope)
|
|
92
92
|
data[:reactive_scope] = scope.to_s
|
|
93
93
|
end
|
|
94
|
+
# Effects (issue #215): the resolved global ⊕ component hooks ride the
|
|
95
|
+
# root as data-reactive-effect-<hook> so the client's stream interceptor
|
|
96
|
+
# knows how to animate this component. nil when off — no keys, byte-
|
|
97
|
+
# stable wire. The fragment is a per-class memo (two integer compares).
|
|
98
|
+
if self.class.respond_to?(:reactive_effect_attrs) && (effects = self.class.reactive_effect_attrs)
|
|
99
|
+
data.merge!(effects)
|
|
100
|
+
end
|
|
94
101
|
{ data: }
|
|
95
102
|
end
|
|
96
103
|
|
|
@@ -897,8 +904,23 @@ module Phlex
|
|
|
897
904
|
# A row's remove trigger — client-only. Draft rows leave the DOM;
|
|
898
905
|
# persisted rows (a hidden [_destroy] input present) are marked and
|
|
899
906
|
# hidden instead, so Rails destroys them on save.
|
|
900
|
-
|
|
901
|
-
|
|
907
|
+
#
|
|
908
|
+
# CONFIRM (issue #218). Removing a row can be a real loss (line items
|
|
909
|
+
# with entered amounts), so this trigger accepts the SAME confirm: the
|
|
910
|
+
# other triggers (on/on_client) do — routed through the shared
|
|
911
|
+
# apply_confirm!, so the client gates the remove behind the overridable
|
|
912
|
+
# confirmResolver (the styled-modal seam, #52/#55/#178). A String is
|
|
913
|
+
# the static message; a Hash is the CONDITIONAL form (#179) — prompt
|
|
914
|
+
# only when { when: … } matches or a { predicate: … } fires. Per-row
|
|
915
|
+
# messages come for free: the app builds a different string per row at
|
|
916
|
+
# render time. No confirm: → the plain immediate-remove fast path,
|
|
917
|
+
# unchanged.
|
|
918
|
+
#
|
|
919
|
+
# button(**reactive_nested_remove(confirm: "Really delete #{row.name}?")) { "×" }
|
|
920
|
+
def reactive_nested_remove(confirm: nil)
|
|
921
|
+
data = { action: "click->reactive#nestedRemove" }
|
|
922
|
+
apply_confirm!(data, confirm) if confirm
|
|
923
|
+
{ type: "button", data: }
|
|
902
924
|
end
|
|
903
925
|
|
|
904
926
|
# CROSS-ROOT value-conditional visibility (issue #164) — the visibility
|
|
@@ -57,6 +57,8 @@ module Phlex
|
|
|
57
57
|
dirty: :@reactive_own_dirty,
|
|
58
58
|
# Deferred reply segments — reactive_lazy (issue #165).
|
|
59
59
|
lazy: :@reactive_own_lazy,
|
|
60
|
+
# Enter/exit/update effects — reactive_effects (issue #215).
|
|
61
|
+
effects: :@reactive_own_effects,
|
|
60
62
|
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|
|
61
63
|
# WHOLE component) plus a list of named actions.
|
|
62
64
|
skip_all: :@reactive_own_skip_all,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The effects vocabulary (issue #215): animate a component's ENTER
|
|
6
|
+
# (append/prepend), EXIT (remove), and UPDATE (replace/update, plain or
|
|
7
|
+
# morph) when a Turbo Stream renders. Strictly OPT-IN at three levels,
|
|
8
|
+
# most specific wins:
|
|
9
|
+
#
|
|
10
|
+
# Phlex::Reactive.effects = true # global default set
|
|
11
|
+
# reactive_effects enter: :slide, update: false # per component
|
|
12
|
+
# reply.remove(effect: :shake) # per call
|
|
13
|
+
#
|
|
14
|
+
# An effect value is a BUILT-IN name (:fade/:slide/:scale/:highlight/
|
|
15
|
+
# :shake — shipped as CSS in app/assets/stylesheets/phlex/reactive/
|
|
16
|
+
# effects.css), :random (the client picks a built-in per application),
|
|
17
|
+
# false (disable the hook), or custom NAMED LEGS { during:, from:, to: }
|
|
18
|
+
# — the issue #186 class-list vocabulary, compiled to the same
|
|
19
|
+
# [during, from, to] wire array the client's runTransition consumes.
|
|
20
|
+
#
|
|
21
|
+
# This module owns normalization (declare-time validation — a typo'd
|
|
22
|
+
# effect raises at class load / config time, not at click time) and the
|
|
23
|
+
# WIRE forms:
|
|
24
|
+
# * root attrs: data-reactive-effect-enter="fade" (emitted by
|
|
25
|
+
# reactive_attrs; resolved global ⊕ component)
|
|
26
|
+
# * per call: data-reactive-effect="shake" on the <turbo-stream>
|
|
27
|
+
# element itself ("off" suppresses; beats the root attrs)
|
|
28
|
+
# Values are either a name or the legs JSON array — the client
|
|
29
|
+
# distinguishes by the leading "[".
|
|
30
|
+
module Effects
|
|
31
|
+
HOOKS = %i[enter exit update].freeze
|
|
32
|
+
BUILT_INS = %w[fade slide scale highlight shake].freeze
|
|
33
|
+
RANDOM = "random"
|
|
34
|
+
OFF = "off"
|
|
35
|
+
LEG_KEYS = %i[during from to].freeze
|
|
36
|
+
|
|
37
|
+
# The `effects = true` sugar — pre-normalized wire strings.
|
|
38
|
+
DEFAULTS = { enter: "fade", exit: "fade", update: "highlight" }.freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Normalize the Phlex::Reactive.effects= value: nil/false → off (nil),
|
|
42
|
+
# true → the default set, a Hash → validated per-hook wire strings.
|
|
43
|
+
def normalize_config(value)
|
|
44
|
+
case value
|
|
45
|
+
when nil, false then nil
|
|
46
|
+
when true then DEFAULTS
|
|
47
|
+
when Hash then normalize!(value)
|
|
48
|
+
else
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"Phlex::Reactive.effects takes nil/false (off), true (the default set), or a " \
|
|
51
|
+
"{ enter:/exit:/update: } hash — got #{value.inspect}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Validate + compile a per-hook declaration hash to its stored form:
|
|
56
|
+
# { hook => wire-string | false }. `false` survives normalization so a
|
|
57
|
+
# component-level `update: false` can OVERRIDE a global update effect
|
|
58
|
+
# at resolve time (the merge keeps it; attr emission skips it).
|
|
59
|
+
def normalize!(hooks)
|
|
60
|
+
hooks.each_with_object({}) do |(hook, value), out|
|
|
61
|
+
key = hook.to_sym
|
|
62
|
+
unless HOOKS.include?(key)
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"unknown effects hook #{hook.inspect} — valid hooks: #{HOOKS.join(", ")}"
|
|
65
|
+
end
|
|
66
|
+
out[key] = value == false ? false : wire_value(value)
|
|
67
|
+
end.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The RESOLVED root attrs for a component class: global ⊕ component
|
|
71
|
+
# (Hash#merge — the component's per-hook values win), false hooks
|
|
72
|
+
# dropped at emission (they exist to cancel a global hook), the whole
|
|
73
|
+
# thing nil when everything is off. `reactive_effects false` opts the
|
|
74
|
+
# class out regardless of the global set. Returns a frozen
|
|
75
|
+
# { reactive_effect_<hook>: wire } fragment ready for data.merge!.
|
|
76
|
+
def root_attrs_for(klass)
|
|
77
|
+
component = klass.respond_to?(:reactive_effects_config) ? klass.reactive_effects_config : nil
|
|
78
|
+
return nil if component == false
|
|
79
|
+
|
|
80
|
+
global = Phlex::Reactive.effects
|
|
81
|
+
resolved =
|
|
82
|
+
if global && component then global.merge(component)
|
|
83
|
+
else global || component
|
|
84
|
+
end
|
|
85
|
+
return nil if resolved.nil? || resolved.empty?
|
|
86
|
+
|
|
87
|
+
attrs = {}
|
|
88
|
+
resolved.each do |hook, wire|
|
|
89
|
+
attrs[:"reactive_effect_#{hook}"] = wire unless wire == false
|
|
90
|
+
end
|
|
91
|
+
attrs.empty? ? nil : attrs.freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Stamp a built turbo-stream tag with the PER-CALL effect override:
|
|
95
|
+
# `data-reactive-effect` on the <turbo-stream> element itself (it beats
|
|
96
|
+
# the root-declared attrs on the client; "off" suppresses them). String
|
|
97
|
+
# injection into the opening tag — turbo-rails' TagBuilder only takes
|
|
98
|
+
# `method:` as a tag attribute, and hand-built escaped attrs are the
|
|
99
|
+
# to_stream_token precedent. `.sub` hits the FIRST "<turbo-stream",
|
|
100
|
+
# which is always the opening tag (template content is escaped or
|
|
101
|
+
# nested later). nil = not given → the input passes through UNTOUCHED
|
|
102
|
+
# (byte-identical wire). Value validated + escaped — a legs JSON's
|
|
103
|
+
# quotes land as ".
|
|
104
|
+
def annotate(html, effect)
|
|
105
|
+
return html if effect.nil?
|
|
106
|
+
|
|
107
|
+
attr = %( data-reactive-effect="#{ERB::Util.html_escape(wire_value(effect))}")
|
|
108
|
+
html.to_s.sub("<turbo-stream", "<turbo-stream#{attr}").html_safe
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ONE effect value → its wire string: a built-in/random name, "off"
|
|
112
|
+
# (per-call suppression), or the legs JSON array.
|
|
113
|
+
def wire_value(value)
|
|
114
|
+
case value
|
|
115
|
+
when false then OFF
|
|
116
|
+
when Symbol, String then named_wire(value.to_s)
|
|
117
|
+
when Hash then legs_wire(value)
|
|
118
|
+
when Array then raise_positional_legs(value)
|
|
119
|
+
else
|
|
120
|
+
raise ArgumentError,
|
|
121
|
+
"effect must be a built-in (#{BUILT_INS.join(", ")}), :random, false, or " \
|
|
122
|
+
"{ during:, from:, to: } legs — got #{value.inspect}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def named_wire(name)
|
|
129
|
+
return name if name == RANDOM || BUILT_INS.include?(name)
|
|
130
|
+
|
|
131
|
+
raise ArgumentError,
|
|
132
|
+
"unknown effect #{name.inspect} — built-ins: #{BUILT_INS.join(", ")}, :random, " \
|
|
133
|
+
"or { during:, from:, to: } legs"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Named legs (issue #186 vocabulary) → the [during, from, to] wire
|
|
137
|
+
# array runTransition consumes. Class lists may be a String or an
|
|
138
|
+
# Array of strings. A SINGLE blank leg is legitimate (an element whose
|
|
139
|
+
# own CSS carries the `transition` property needs no during: utilities
|
|
140
|
+
# — the same tolerance js.rb's normalize_transition gives), but ALL
|
|
141
|
+
# legs blank is a dead effect with nothing to animate — fail loudly,
|
|
142
|
+
# like the empty-ops-chain guard in js_ops_json.
|
|
143
|
+
def legs_wire(legs)
|
|
144
|
+
unless LEG_KEYS.all? { legs.key?(it) }
|
|
145
|
+
raise ArgumentError,
|
|
146
|
+
"effect legs must name during:, from:, to: class lists — got #{legs.inspect}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
compiled = LEG_KEYS.map { Array(legs[it]).join(" ") }
|
|
150
|
+
if compiled.all? { it.strip.empty? }
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"effect legs are all blank — a dead effect with no classes to animate; got #{legs.inspect}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
JSON.generate(compiled)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The old positional triple — reject with the named-legs rewrite, the
|
|
159
|
+
# same guidance js.rb's normalize_transition gives (issue #186).
|
|
160
|
+
def raise_positional_legs(value)
|
|
161
|
+
d, f, t = value
|
|
162
|
+
raise ArgumentError,
|
|
163
|
+
"effect legs take named keys (issue #186 vocabulary) — " \
|
|
164
|
+
"{ during: #{d.inspect}, from: #{f.inspect}, to: #{t.inspect} }"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -30,6 +30,10 @@ module Phlex
|
|
|
30
30
|
initializer "phlex_reactive.assets" do
|
|
31
31
|
if it.config.respond_to?(:assets)
|
|
32
32
|
it.config.assets.paths << root.join("app/javascript").to_s
|
|
33
|
+
# The opt-in effects stylesheet (issue #215): built-in enter/exit/
|
|
34
|
+
# update animations, linked by the app with
|
|
35
|
+
# stylesheet_link_tag "phlex/reactive/effects".
|
|
36
|
+
it.config.assets.paths << root.join("app/assets/stylesheets").to_s
|
|
33
37
|
it.config.assets.precompile += %w[
|
|
34
38
|
phlex/reactive/reactive_controller.min.js
|
|
35
39
|
phlex/reactive/reactive_controller.min.js.map
|
|
@@ -41,6 +45,7 @@ module Phlex
|
|
|
41
45
|
phlex/reactive/compute.min.js.map
|
|
42
46
|
phlex/reactive/inspect.min.js
|
|
43
47
|
phlex/reactive/inspect.min.js.map
|
|
48
|
+
phlex/reactive/effects.css
|
|
44
49
|
]
|
|
45
50
|
end
|
|
46
51
|
end
|
data/lib/phlex/reactive/reply.rb
CHANGED
|
@@ -29,20 +29,23 @@ module Phlex
|
|
|
29
29
|
|
|
30
30
|
# Re-render in place. `morph: true` morphs the subtree (preserves the
|
|
31
31
|
# focused input + caret) instead of an outerHTML swap — see #morph.
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
# `effect:` (issue #215) — the per-call effect override for this one
|
|
33
|
+
# stream (a built-in name, :random, false to suppress, or #186 legs);
|
|
34
|
+
# same kwarg on every verb below.
|
|
35
|
+
def replace(morph: false, effect: nil)
|
|
36
|
+
Response.build_replace(@component, morph:, effect:)
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
# Re-render in place via Idiomorph (method="morph") — keeps focus + caret.
|
|
37
|
-
def morph
|
|
38
|
-
Response.build_morph(@component)
|
|
40
|
+
def morph(effect: nil)
|
|
41
|
+
Response.build_morph(@component, effect:)
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
# Update only inner HTML (preserves the root element + its token attr).
|
|
42
45
|
# `morph: true` morphs the inner HTML in place (issue #113) so a
|
|
43
46
|
# cross-tab/per-field update keeps a focused input's caret.
|
|
44
|
-
def update(morph: false)
|
|
45
|
-
Response.build_update(@component, morph:)
|
|
47
|
+
def update(morph: false, effect: nil)
|
|
48
|
+
Response.build_update(@component, morph:, effect:)
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
# Reactive collections (issue #35) — add/remove a row in a declared
|
|
@@ -59,12 +62,14 @@ module Phlex
|
|
|
59
62
|
# Extra kwargs (issue #186) thread to the row component's new(model:, **row_kwargs)
|
|
60
63
|
# — e.g. reply.append(item, to: :items, autofocus: true) → ItemRow.new(item:,
|
|
61
64
|
# autofocus: true). Additive: a no-kwarg call is byte-identical to before.
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
# `effect:` (issue #215) is reserved as the row's per-call effect — it is
|
|
66
|
+
# NOT forwarded to the row component's init like the other row_kwargs.
|
|
67
|
+
def append(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, effect: nil, **row_kwargs)
|
|
68
|
+
collection_build!(:build_collection_append, :append, model, legacy_model, to, effect, row_kwargs)
|
|
64
69
|
end
|
|
65
70
|
|
|
66
|
-
def prepend(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, **row_kwargs)
|
|
67
|
-
collection_build!(:build_collection_prepend, :prepend, model, legacy_model, to, row_kwargs)
|
|
71
|
+
def prepend(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, effect: nil, **row_kwargs)
|
|
72
|
+
collection_build!(:build_collection_prepend, :prepend, model, legacy_model, to, effect, row_kwargs)
|
|
68
73
|
end
|
|
69
74
|
|
|
70
75
|
# Two forms, dispatched on the PRESENCE of `from:` (issue #182 — no sentinel
|
|
@@ -74,17 +79,17 @@ module Phlex
|
|
|
74
79
|
#
|
|
75
80
|
# reply.remove # remove the bound component's element
|
|
76
81
|
# reply.remove(model, from: :items) # remove a collection row
|
|
77
|
-
def remove(model = UNSET_ARG, legacy_model = UNSET_ARG, from: UNSET_ARG)
|
|
82
|
+
def remove(model = UNSET_ARG, legacy_model = UNSET_ARG, from: UNSET_ARG, effect: nil)
|
|
78
83
|
reject_legacy_form!(:remove, model, legacy_model)
|
|
79
84
|
# Bare `reply.remove` (no positional, no from:) removes self.
|
|
80
|
-
return Response.build_remove(@component) if from.equal?(UNSET_ARG) && model.equal?(UNSET_ARG)
|
|
85
|
+
return Response.build_remove(@component, effect:) if from.equal?(UNSET_ARG) && model.equal?(UNSET_ARG)
|
|
81
86
|
|
|
82
87
|
if from.equal?(UNSET_ARG)
|
|
83
88
|
raise ArgumentError,
|
|
84
89
|
"reply.remove(model, …) needs a source collection — reply.remove(model, from: :name)"
|
|
85
90
|
end
|
|
86
91
|
reject_symbol_first!(:remove, model, :from, from)
|
|
87
|
-
Response.build_collection_remove(@component, from, resolve_row_arg(model))
|
|
92
|
+
Response.build_collection_remove(@component, from, resolve_row_arg(model), effect:)
|
|
88
93
|
end
|
|
89
94
|
|
|
90
95
|
# Subject-free builders — pass straight through so they read naturally.
|
|
@@ -136,7 +141,7 @@ module Phlex
|
|
|
136
141
|
# Shared append/prepend build: catch the old two-positional form, require the
|
|
137
142
|
# `to:` keyword, reject a Symbol-first model, then delegate to the Response
|
|
138
143
|
# builder (name first, model second — its internal order).
|
|
139
|
-
def collection_build!(builder, verb, model, legacy_model, to, row_kwargs)
|
|
144
|
+
def collection_build!(builder, verb, model, legacy_model, to, effect, row_kwargs)
|
|
140
145
|
# Old form `reply.append(:name, model)` passes a SECOND positional — the
|
|
141
146
|
# arity would just error, so intercept it with the guided rewrite.
|
|
142
147
|
reject_legacy_form!(verb, model, legacy_model)
|
|
@@ -145,7 +150,7 @@ module Phlex
|
|
|
145
150
|
"reply.#{verb} needs a target collection — reply.#{verb}(model, to: :name)"
|
|
146
151
|
end
|
|
147
152
|
reject_symbol_first!(verb, model, :to, to)
|
|
148
|
-
Response.public_send(builder, @component, to, resolve_row_arg(model), **row_kwargs)
|
|
153
|
+
Response.public_send(builder, @component, to, resolve_row_arg(model), effect:, **row_kwargs)
|
|
149
154
|
end
|
|
150
155
|
|
|
151
156
|
# The pre-#182 call `reply.<verb>(:name, model)` — a Symbol name first, the
|
|
@@ -58,8 +58,10 @@ module Phlex
|
|
|
58
58
|
# Re-render the component in place (explicit form of today's default).
|
|
59
59
|
# `morph: true` morphs the subtree (preserves the focused input + caret)
|
|
60
60
|
# instead of an outerHTML swap — see .build_morph (issue #28).
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
# `effect:` (issue #215) stamps the per-call effect override on the
|
|
62
|
+
# emitted stream — same on every self-targeting builder below.
|
|
63
|
+
def build_replace(component, morph: false, effect: nil)
|
|
64
|
+
new(streams: [component.to_stream_replace(morph:, effect:)],
|
|
63
65
|
subject_component: component)
|
|
64
66
|
end
|
|
65
67
|
|
|
@@ -69,19 +71,23 @@ module Phlex
|
|
|
69
71
|
# per-field reactive editing (a "spreadsheet" grid where a debounced save
|
|
70
72
|
# fires while the user is still typing/tabbing). The morphed root still
|
|
71
73
|
# carries the fresh signed token, so the next action verifies.
|
|
72
|
-
def build_morph(component
|
|
74
|
+
def build_morph(component, effect: nil)
|
|
75
|
+
new(streams: [component.to_stream_replace(morph: true, effect:)], subject_component: component)
|
|
76
|
+
end
|
|
73
77
|
|
|
74
78
|
# Update only inner HTML (preserves the root element + its token attr).
|
|
75
79
|
# `morph: true` morphs the inner HTML in place (issue #113) instead of
|
|
76
80
|
# replacing it, so a cross-tab update keeps a peer's focus/caret.
|
|
77
|
-
def build_update(component, morph: false)
|
|
78
|
-
new(streams: [component.to_stream_update(morph:)], subject_component: component)
|
|
81
|
+
def build_update(component, morph: false, effect: nil)
|
|
82
|
+
new(streams: [component.to_stream_update(morph:, effect:)], subject_component: component)
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
# Remove the component's element from the DOM. Uses the instance
|
|
82
86
|
# to_stream_remove (the component already knows its own #id — no
|
|
83
87
|
# class-builder reconstruction; works for record- and state-backed).
|
|
84
|
-
def build_remove(component
|
|
88
|
+
def build_remove(component, effect: nil)
|
|
89
|
+
new(streams: [component.to_stream_remove(effect:)], render_self: false)
|
|
90
|
+
end
|
|
85
91
|
|
|
86
92
|
# Client-side full navigation (Turbo.visit). Use when the current URL
|
|
87
93
|
# is dead (slug rename) or the outcome belongs on another page. Pass a
|
|
@@ -110,21 +116,24 @@ module Phlex
|
|
|
110
116
|
# container owns the add/remove trigger, so the endpoint appends its inert
|
|
111
117
|
# `reactive:token` stream (the same #30 machinery reply.streams uses) to roll
|
|
112
118
|
# the token forward without re-rendering the rows.
|
|
113
|
-
|
|
119
|
+
# `effect:` (issue #215) stamps the ROW stream only — the count
|
|
120
|
+
# companion and the empty-state toggle are bookkeeping, not the thing
|
|
121
|
+
# entering/leaving.
|
|
122
|
+
def build_collection_append(component, name, model, effect: nil, **row_kwargs)
|
|
114
123
|
definition = collection_def!(component, name)
|
|
115
|
-
new(streams: collection_add_streams(definition, component, model, :append, row_kwargs),
|
|
124
|
+
new(streams: collection_add_streams(definition, component, model, :append, row_kwargs, effect:),
|
|
116
125
|
render_self: false, token_component: component)
|
|
117
126
|
end
|
|
118
127
|
|
|
119
|
-
def build_collection_prepend(component, name, model, **row_kwargs)
|
|
128
|
+
def build_collection_prepend(component, name, model, effect: nil, **row_kwargs)
|
|
120
129
|
definition = collection_def!(component, name)
|
|
121
|
-
new(streams: collection_add_streams(definition, component, model, :prepend, row_kwargs),
|
|
130
|
+
new(streams: collection_add_streams(definition, component, model, :prepend, row_kwargs, effect:),
|
|
122
131
|
render_self: false, token_component: component)
|
|
123
132
|
end
|
|
124
133
|
|
|
125
|
-
def build_collection_remove(component, name, model)
|
|
134
|
+
def build_collection_remove(component, name, model, effect: nil)
|
|
126
135
|
definition = collection_def!(component, name)
|
|
127
|
-
new(streams: collection_remove_streams(definition, component, model),
|
|
136
|
+
new(streams: collection_remove_streams(definition, component, model, effect:),
|
|
128
137
|
render_self: false, token_component: component)
|
|
129
138
|
end
|
|
130
139
|
|
|
@@ -141,10 +150,10 @@ module Phlex
|
|
|
141
150
|
# Row add (append/prepend) + count + empty-state clear. The empty-state is
|
|
142
151
|
# removed only when the list just crossed 0->1 (size == 1) — appending to
|
|
143
152
|
# an already-populated list leaves it untouched.
|
|
144
|
-
def collection_add_streams(definition, component, model, action, row_kwargs = {})
|
|
153
|
+
def collection_add_streams(definition, component, model, action, row_kwargs = {}, effect: nil)
|
|
145
154
|
# row_kwargs (issue #186) thread to the row component's init via the class
|
|
146
155
|
# stream builder's **options passthrough (ItemRow.new(model:, **row_kwargs)).
|
|
147
|
-
streams = [definition.item.public_send(action, target: definition.container, model:, **row_kwargs)]
|
|
156
|
+
streams = [definition.item.public_send(action, target: definition.container, model:, effect:, **row_kwargs)]
|
|
148
157
|
append_count_stream(streams, definition, component)
|
|
149
158
|
|
|
150
159
|
size = definition.size_for(component)
|
|
@@ -154,8 +163,8 @@ module Phlex
|
|
|
154
163
|
|
|
155
164
|
# Row remove + count + empty-state restore. The empty-state is appended
|
|
156
165
|
# back into the container only when the list just emptied (size == 0).
|
|
157
|
-
def collection_remove_streams(definition, component, model)
|
|
158
|
-
streams = [collection_row_remove(definition, model)]
|
|
166
|
+
def collection_remove_streams(definition, component, model, effect: nil)
|
|
167
|
+
streams = [collection_row_remove(definition, model, effect)]
|
|
159
168
|
append_count_stream(streams, definition, component)
|
|
160
169
|
|
|
161
170
|
size = definition.size_for(component)
|
|
@@ -170,11 +179,11 @@ module Phlex
|
|
|
170
179
|
|
|
171
180
|
# Remove the row by its DOM id. Accepts the record (so dom_id is derived)
|
|
172
181
|
# or an already-built dom-id string (e.g. the value the row used as #id).
|
|
173
|
-
def collection_row_remove(definition, model)
|
|
182
|
+
def collection_row_remove(definition, model, effect = nil)
|
|
174
183
|
if model.is_a?(String)
|
|
175
|
-
Phlex::Reactive.stream_builder.remove(model)
|
|
184
|
+
Phlex::Reactive::Effects.annotate(Phlex::Reactive.stream_builder.remove(model), effect)
|
|
176
185
|
else
|
|
177
|
-
definition.item.remove(model)
|
|
186
|
+
definition.item.remove(model, effect:)
|
|
178
187
|
end
|
|
179
188
|
end
|
|
180
189
|
|