phlex-reactive 0.4.8 → 0.9.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 +924 -4
- 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 +249 -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 +160 -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 +333 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# The class-level declaration DSL (issue #115): the five registries a
|
|
7
|
+
# component declares itself through — reactive_record, reactive_state,
|
|
8
|
+
# action, reactive_collection, reactive_compute — their public readers,
|
|
9
|
+
# and from_identity (rebuilding an instance from a verified identity
|
|
10
|
+
# payload). Every registry resolves through Component::Registry, so all
|
|
11
|
+
# five share ONE inheritance semantic: resolve through the superclass at
|
|
12
|
+
# read time, memoized per class against the registry generation.
|
|
13
|
+
#
|
|
14
|
+
# The readers are PUBLIC API — the action endpoint (reactive_action/
|
|
15
|
+
# reactive_action?/reactive_actions), Response (reactive_collection_def),
|
|
16
|
+
# Streamable's default #id (reactive_record_key), doctor, and the test
|
|
17
|
+
# helpers all dispatch through them. They keep their exact signatures
|
|
18
|
+
# forever; new reader forms (reactive_compute_def) are added alongside,
|
|
19
|
+
# never instead.
|
|
20
|
+
module DSL
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
class_methods do
|
|
24
|
+
# Declare the ActiveRecord (GlobalID-able) record this component is
|
|
25
|
+
# rebuilt from. The signed token carries its GlobalID; the server
|
|
26
|
+
# re-finds it on each action. State lives in the DB.
|
|
27
|
+
def reactive_record(name)
|
|
28
|
+
Registry.write_scalar(self, :record_key, name.to_sym)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The nearest declared record key up the ancestry, or nil. Resolved
|
|
32
|
+
# through Registry (issue #115) — the same always-fresh semantics the
|
|
33
|
+
# pre-#115 live superclass walk had, now memoized against the registry
|
|
34
|
+
# generation like every other registry.
|
|
35
|
+
def reactive_record_key
|
|
36
|
+
Registry.resolve_scalar(self, :record_key, :reactive_record_key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Opt into signed STATE for record-less components only.
|
|
40
|
+
# reactive_state :count, :open
|
|
41
|
+
def reactive_state(*names)
|
|
42
|
+
Registry.append(self, :state_keys, names.map(&:to_sym))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Ancestors' keys first, own appended — declaration order. Resolved
|
|
46
|
+
# through Registry at read time (issue #115), so a parent's later
|
|
47
|
+
# reactive_state is visible here and in the identity fast path (the
|
|
48
|
+
# write sweeps the memoized ivar pairs family-wide).
|
|
49
|
+
def reactive_state_keys
|
|
50
|
+
Registry.resolve_list(self, :state_keys, :reactive_state_keys)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Declare a client-invokable action with an optional param schema.
|
|
54
|
+
# action :increment
|
|
55
|
+
# action :rename, params: { title: :string }
|
|
56
|
+
#
|
|
57
|
+
# Param types are coerced server-side; anything not in the schema is
|
|
58
|
+
# dropped before reaching your method (no mass assignment):
|
|
59
|
+
# * Scalars — :string (default), :integer, :float, :boolean
|
|
60
|
+
# * Array of scalar — wrap the type in an array: [:integer]
|
|
61
|
+
# * Array of hash (Rails nested attributes) — wrap a hash schema:
|
|
62
|
+
# action :save, params: {
|
|
63
|
+
# date: :string,
|
|
64
|
+
# bank_account_ids: [:integer],
|
|
65
|
+
# invoice_items_attributes: [
|
|
66
|
+
# { id: :integer, quantity: :float, price: :float, _destroy: :boolean }
|
|
67
|
+
# ]
|
|
68
|
+
# }
|
|
69
|
+
# Array params accept BOTH a JSON array and a Rails-style index hash
|
|
70
|
+
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
71
|
+
def action(name, params: {})
|
|
72
|
+
Registry.write_entry(
|
|
73
|
+
self, :actions, name.to_sym,
|
|
74
|
+
Action.new(name: name.to_sym, params: params, schema: Phlex::Reactive::ParamSchema.compile(params))
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The full declared-action registry, ancestors included — the
|
|
79
|
+
# default-deny source of truth the endpoint dispatches against.
|
|
80
|
+
# Resolved through Registry at read time (issue #115): a parent action
|
|
81
|
+
# declared after a subclass was first read is no longer silently
|
|
82
|
+
# invisible to the subclass (the pre-#115 snapshot-dup divergence).
|
|
83
|
+
def reactive_actions
|
|
84
|
+
Registry.resolve_hash(self, :actions, :reactive_actions)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def reactive_action(name)
|
|
88
|
+
reactive_actions[name.to_sym]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def reactive_action?(name)
|
|
92
|
+
reactive_actions.key?(name.to_sym)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Declare an add/remove-row collection (issue #35) — the list contract
|
|
96
|
+
# in one place, so actions append/prepend/remove a row WITHOUT
|
|
97
|
+
# re-deriving the container id, count, and empty-state in every action:
|
|
98
|
+
#
|
|
99
|
+
# reactive_collection :items,
|
|
100
|
+
# item: ItemRowComponent, # the per-row Streamable component
|
|
101
|
+
# container: "items-list", # the DOM id rows live in
|
|
102
|
+
# count: "items-count", # optional companion id (the size badge)
|
|
103
|
+
# empty: ItemsEmptyComponent, # optional empty-state component
|
|
104
|
+
# size: -> { @record.items.size } # resolves the live size
|
|
105
|
+
#
|
|
106
|
+
# An action then governs the reply with one call:
|
|
107
|
+
# reply.append(:items, item) # row append + count + clear empty-state
|
|
108
|
+
# reply.remove(:items, id) # row remove + count + restore empty-state
|
|
109
|
+
#
|
|
110
|
+
# count/empty/size are optional: a list with just rows omits them and the
|
|
111
|
+
# corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
|
|
112
|
+
# README "Reactive collections" section.
|
|
113
|
+
def reactive_collection(name, item:, container:, count: nil, empty: nil, size: nil)
|
|
114
|
+
Registry.write_entry(
|
|
115
|
+
self, :collections, name.to_sym,
|
|
116
|
+
CollectionDefinition.new(name: name.to_sym, item:, container:, count:, empty:, size:)
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def reactive_collections
|
|
121
|
+
Registry.resolve_hash(self, :collections, :reactive_collections)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def reactive_collection_def(name)
|
|
125
|
+
reactive_collections[name.to_sym]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reactive_collection?(name)
|
|
129
|
+
reactive_collections.key?(name.to_sym)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Declare a client-side computation, OR (called with just a name) read
|
|
133
|
+
# one back. Dual-purpose so a component reads `reactive_compute :split,
|
|
134
|
+
# inputs: …, outputs: …` and the endpoint/helpers read
|
|
135
|
+
# `reactive_compute(:split)` — mirroring how `on`/`reactive_field` keep a
|
|
136
|
+
# tight surface. `reducer:` defaults to the compute name.
|
|
137
|
+
#
|
|
138
|
+
# reactive_compute :payment_split,
|
|
139
|
+
# inputs: %i[allowance cash leasing total], # fields the JS reducer reads
|
|
140
|
+
# outputs: %i[allowance cash leasing] # fields it writes (no round trip)
|
|
141
|
+
#
|
|
142
|
+
# `inputs:` also takes a HASH to TYPE each input (issue #104): a :number is
|
|
143
|
+
# coerced through Number on the client (the array-form default), a :string
|
|
144
|
+
# is read raw — so a live text preview reads real text, not NaN→0:
|
|
145
|
+
#
|
|
146
|
+
# reactive_compute :preview,
|
|
147
|
+
# inputs: { title: :string, qty: :number },
|
|
148
|
+
# outputs: %i[title_preview char_count]
|
|
149
|
+
#
|
|
150
|
+
# An output with no matching form field writes to its reactive_text(:name)
|
|
151
|
+
# node (textContent); a declared input also mirrors into its own text node
|
|
152
|
+
# with no reducer. The array form's wire stays byte-identical.
|
|
153
|
+
#
|
|
154
|
+
# Register the matching JS once at boot. The reducer's signature is
|
|
155
|
+
# (values, meta) — values is { inputName: Number } over the declared
|
|
156
|
+
# inputs; meta is { changed }, the name of the declared input the
|
|
157
|
+
# triggering event edited (null on a direct call or an unowned/
|
|
158
|
+
# undeclared target). A one-argument reducer keeps working (it just
|
|
159
|
+
# ignores meta); `changed` is what lets ONE reducer express a
|
|
160
|
+
# multi-way/mutual rebalance (issue #75) — branch on the edited field.
|
|
161
|
+
# Because an output write dispatches a real `input` event (issue #76),
|
|
162
|
+
# a branching reducer must be CONVERGENT — the re-entrant pass must
|
|
163
|
+
# recompute the values already written so the change guard settles it
|
|
164
|
+
# (see the header of app/javascript/phlex/reactive/compute.js).
|
|
165
|
+
#
|
|
166
|
+
# import { setComputeReducer } from "phlex/reactive/compute"
|
|
167
|
+
# setComputeReducer("payment_split", ({ allowance, cash, leasing, total }, { changed }) => ({ … }))
|
|
168
|
+
def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil)
|
|
169
|
+
return reactive_compute_def(name) if inputs.nil? && outputs.nil?
|
|
170
|
+
|
|
171
|
+
input_names, input_types = normalize_compute_inputs(inputs)
|
|
172
|
+
Registry.write_entry(
|
|
173
|
+
self, :computes, name.to_sym,
|
|
174
|
+
ComputeDefinition.new(
|
|
175
|
+
name: name.to_sym, inputs: input_names, input_types:,
|
|
176
|
+
outputs: Array(outputs).map(&:to_sym), reducer: (reducer || name).to_s
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def reactive_computes
|
|
182
|
+
Registry.resolve_hash(self, :computes, :reactive_computes)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Fetch one compute definition — the reader form matching
|
|
186
|
+
# reactive_collection_def (issue #115). The bare reactive_compute(name)
|
|
187
|
+
# getter above is a PERMANENT documented alias (it shipped in the same
|
|
188
|
+
# minor series, #73); both stay, no deprecation.
|
|
189
|
+
def reactive_compute_def(name)
|
|
190
|
+
reactive_computes[name.to_sym]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def reactive_compute?(name)
|
|
194
|
+
reactive_computes.key?(name.to_sym)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Split the `inputs:` argument into [ordered names, types-or-nil] (issue
|
|
198
|
+
# #104). A HASH ({ title: :string, qty: :number }) yields typed inputs —
|
|
199
|
+
# the ordered keys plus a symbolized name→type map. Anything else (an
|
|
200
|
+
# array, a bare symbol) is the UNTYPED form — ordered names and nil types,
|
|
201
|
+
# so the array-form wire stays byte-identical and the client keeps its
|
|
202
|
+
# numeric coercion. Named after the shape it normalizes.
|
|
203
|
+
def normalize_compute_inputs(inputs)
|
|
204
|
+
if inputs.is_a?(Hash)
|
|
205
|
+
names = inputs.keys.map(&:to_sym)
|
|
206
|
+
types = inputs.transform_keys(&:to_sym).transform_values(&:to_sym)
|
|
207
|
+
[names, types]
|
|
208
|
+
else
|
|
209
|
+
[Array(inputs).map(&:to_sym), nil]
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Rebuild a component instance from a verified identity payload. Called
|
|
214
|
+
# by the action endpoint after the token signature is verified.
|
|
215
|
+
#
|
|
216
|
+
# A component may carry a record (re-found via GlobalID), signed state
|
|
217
|
+
# (instance vars listed in reactive_state), or BOTH (the inline_edit
|
|
218
|
+
# pattern: a record plus "which field / what mode"). We assemble the
|
|
219
|
+
# init kwargs from whichever identity pieces are declared.
|
|
220
|
+
def from_identity(payload)
|
|
221
|
+
kwargs = {}
|
|
222
|
+
|
|
223
|
+
if reactive_record_key
|
|
224
|
+
record = GlobalID::Locator.locate(payload.fetch("gid"))
|
|
225
|
+
raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
|
|
226
|
+
|
|
227
|
+
kwargs[reactive_record_key] = record
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
if reactive_state_keys.any?
|
|
231
|
+
state = payload.fetch("s", {})
|
|
232
|
+
reactive_state_keys.each do
|
|
233
|
+
# Use key presence, not the value: a signed `nil` (nullable state)
|
|
234
|
+
# must round-trip distinctly. Only a genuinely absent key falls
|
|
235
|
+
# back to the component's initialize default; `false` and `nil`
|
|
236
|
+
# both survive.
|
|
237
|
+
next unless state.key?(it.to_s)
|
|
238
|
+
|
|
239
|
+
kwargs[it] = state[it.to_s]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
new(**kwargs)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|