phlex-reactive 0.4.7 → 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +970 -0
  3. data/README.md +1134 -49
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +93 -0
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1591 -56
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +37 -348
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +36 -7
  25. data/lib/phlex/reactive/js.rb +222 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +156 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +329 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +20 -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