reactive_component 0.8.2 → 0.8.4
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 +48 -0
- data/app/channels/reactive_component/channel.rb +20 -17
- data/app/javascript/reactive_component/controllers/reactive_renderer_controller.js +20 -6
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +8 -8
- data/lib/reactive_component/broadcastable.rb +12 -2
- data/lib/reactive_component/engine.rb +5 -0
- data/lib/reactive_component/entity.rb +68 -6
- data/lib/reactive_component/version.rb +1 -1
- data/lib/reactive_component/wrapper.rb +17 -1
- data/lib/reactive_component.rb +5 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77acde881234cd7b6013ab8618efd2cbeb1ffd1a4c6232199517a9b14f4aa9ce
|
|
4
|
+
data.tar.gz: 1854830f220dd61bb49625a1a2a69e29d4e42ce96a40cf371586d3dd898bcbfc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2721d5f6252603a600a4b297276a460cece0d4216303d40b6daa193b9d141a745c7024c347f6109463016f0f21d2beef01a083fa11d4fc3d294125973fa98630
|
|
7
|
+
data.tar.gz: 78a474176edff971af3979ca7c0b02d667c5be59e89f19b2dda28b770184f651799f0220eb2c65f5dfb47e938c4307253b52ad0bdf3df09a16a9679039bb832a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.4] - 2026-09-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `key :company_id, :user_id` on a derived entity, for an entity keyed on a
|
|
7
|
+
tuple of values instead of one root record. It defines the readers, the
|
|
8
|
+
keyword initializer, a joined `id`, and `find` / `find_by(id:)`. A key of
|
|
9
|
+
the wrong arity resolves to nil instead of raising. The readers, the
|
|
10
|
+
initializer and `from_key` are defaults: an entity built from records
|
|
11
|
+
rather than ids replaces them, and `find` rebuilds it through `from_key`.
|
|
12
|
+
- `rebuilds_on ..., entities: ->(record) { ... }` fans one commit out to
|
|
13
|
+
every entity it affects, for when the entity is not reachable through a
|
|
14
|
+
single foreign key. Mutually exclusive with `via:`.
|
|
15
|
+
- Entities are `GlobalID::Identification`, so an entity used as a streamable
|
|
16
|
+
names its stream through `to_gid_param` like a model does.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- A notify component names its record to the server with a signed global id
|
|
20
|
+
instead of a raw one, and the channel resolves it with `locate_signed`
|
|
21
|
+
scoped to this gem. The stream check is unchanged. Upgrading re-renders
|
|
22
|
+
every page, so no client keeps the old payload.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- The README linked to `.html` documentation URLs the site does not serve.
|
|
26
|
+
|
|
27
|
+
## [0.8.3] - 2026-09-15
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Notify mode guide. A notify component re-renders from the server instead of
|
|
31
|
+
the broadcast payload, and `filter_callback` can remove it from a filtered
|
|
32
|
+
list. See Notify Mode.
|
|
33
|
+
- Notify wrappers fill in the component name and record id, so
|
|
34
|
+
`live_wrapper_options` only needs `strategy: :notify`.
|
|
35
|
+
- `ReactiveComponent.skip_own_broadcasts`: the component that ran a
|
|
36
|
+
`live_action` ignores the broadcast it caused and keeps its optimistic
|
|
37
|
+
update. Off by default; override per component with `live_wrapper_options`.
|
|
38
|
+
- `subscribes_to ..., fields:` limits a component's update broadcasts to
|
|
39
|
+
changes in the listed columns, like `rebuilds_on ..., fields:` on entities.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
- Notify components only react to changes to their own record. Before, every
|
|
43
|
+
notify component on a shared stream requested a re-render on any change.
|
|
44
|
+
- A destroyed record's notify component is removed instead of staying on the
|
|
45
|
+
page.
|
|
46
|
+
- `filter_callback` runs on every notify request, not only for components
|
|
47
|
+
that declare client state.
|
|
48
|
+
- The configuration docs set `Channel` options inside `to_prepare`; the
|
|
49
|
+
previous initializer example raised `NameError`.
|
|
50
|
+
|
|
3
51
|
## [0.8.2] - 2026-09-15
|
|
4
52
|
|
|
5
53
|
### Added
|
|
@@ -21,34 +21,28 @@ module ReactiveComponent
|
|
|
21
21
|
|
|
22
22
|
def request_update(data)
|
|
23
23
|
params = data['params'] || {}
|
|
24
|
-
component_class, record = subscribed_component_and_record(data
|
|
24
|
+
component_class, record = subscribed_component_and_record(data)
|
|
25
25
|
return unless record
|
|
26
26
|
|
|
27
|
-
if
|
|
28
|
-
if record_matches?(record, params)
|
|
29
|
-
transmit({ 'action' => 'render', 'data' => component_class.build_data(record) })
|
|
30
|
-
else
|
|
31
|
-
transmit({ 'action' => 'remove', 'dom_id' => data['dom_id'] })
|
|
32
|
-
end
|
|
33
|
-
else
|
|
27
|
+
if record_matches?(record, params)
|
|
34
28
|
client_state = params.slice(*component_class._client_state_fields.keys.map(&:to_s))
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
transmit({ 'action' => 'render', 'data' => component_class.build_data(record, **client_state.symbolize_keys) })
|
|
30
|
+
else
|
|
31
|
+
transmit({ 'action' => 'remove', 'dom_id' => data['dom_id'] })
|
|
37
32
|
end
|
|
38
33
|
end
|
|
39
34
|
|
|
40
35
|
private
|
|
41
36
|
|
|
42
|
-
# The component must be a reactive component
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
def subscribed_component_and_record(data, params)
|
|
37
|
+
# The component must be a reactive component, the record must come from a
|
|
38
|
+
# signed id this gem minted, and it must broadcast to the stream this
|
|
39
|
+
# subscriber verified: the same stream the wrapper signed into the page.
|
|
40
|
+
def subscribed_component_and_record(data)
|
|
47
41
|
component_class = data['component'].to_s.safe_constantize
|
|
48
42
|
return unless component_class.is_a?(Class) && component_class.include?(ReactiveComponent)
|
|
49
43
|
|
|
50
|
-
record =
|
|
51
|
-
return unless record
|
|
44
|
+
record = locate_signed(data['sgid'])
|
|
45
|
+
return unless record.is_a?(component_class.live_model_class)
|
|
52
46
|
|
|
53
47
|
stream = ReactiveComponent::Wrapper.find_stream_for(component_class, record)
|
|
54
48
|
signed = Turbo::StreamsChannel.signed_stream_name(stream)
|
|
@@ -57,6 +51,14 @@ module ReactiveComponent
|
|
|
57
51
|
[component_class, record]
|
|
58
52
|
end
|
|
59
53
|
|
|
54
|
+
# An id we did not sign, one signed for another purpose, an expired one,
|
|
55
|
+
# or one pointing at a row that is gone: all of them are a miss.
|
|
56
|
+
def locate_signed(sgid)
|
|
57
|
+
GlobalID::Locator.locate_signed(sgid, for: ReactiveComponent::Wrapper::SGID_PURPOSE)
|
|
58
|
+
rescue ActiveRecord::RecordNotFound
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
60
62
|
def record_matches?(record, params)
|
|
61
63
|
return true unless self.class.filter_callback
|
|
62
64
|
|
|
@@ -75,6 +77,7 @@ module ReactiveComponent
|
|
|
75
77
|
stream_name = Turbo::StreamsChannel.verified_stream_name(signed)
|
|
76
78
|
|
|
77
79
|
payload = { action: action, data: data }
|
|
80
|
+
payload[:request_id] = Turbo.current_request_id if Turbo.current_request_id
|
|
78
81
|
|
|
79
82
|
if compress
|
|
80
83
|
json = ActiveSupport::JSON.encode(payload)
|
|
@@ -68,8 +68,10 @@ export default class extends Controller {
|
|
|
68
68
|
data: { type: Object, default: {} },
|
|
69
69
|
strategy: { type: String, default: "push" },
|
|
70
70
|
component: { type: String, default: "" },
|
|
71
|
+
sgid: { type: String, default: "" },
|
|
71
72
|
params: { type: Object, default: {} },
|
|
72
|
-
fieldMap: { type: Object, default: {} }
|
|
73
|
+
fieldMap: { type: Object, default: {} },
|
|
74
|
+
skipOwnBroadcasts: { type: Boolean, default: false }
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
connect() {
|
|
@@ -123,7 +125,7 @@ export default class extends Controller {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
handleMessage(message) {
|
|
126
|
-
const route = routeMessage(message, this.element.id, this.strategyValue)
|
|
128
|
+
const route = routeMessage(message, this.element.id, this.strategyValue, this.ownRequestIds)
|
|
127
129
|
|
|
128
130
|
switch (route.type) {
|
|
129
131
|
case "render":
|
|
@@ -164,7 +166,7 @@ export default class extends Controller {
|
|
|
164
166
|
|
|
165
167
|
sub.perform("request_update", {
|
|
166
168
|
component: this.componentValue,
|
|
167
|
-
|
|
169
|
+
sgid: this.sgidValue,
|
|
168
170
|
dom_id: this.element.id,
|
|
169
171
|
params: this.paramsValue
|
|
170
172
|
})
|
|
@@ -205,9 +207,7 @@ export default class extends Controller {
|
|
|
205
207
|
|
|
206
208
|
fetch(this.actionUrlValue, {
|
|
207
209
|
method: "POST",
|
|
208
|
-
headers:
|
|
209
|
-
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
|
|
210
|
-
},
|
|
210
|
+
headers: this.actionHeaders(),
|
|
211
211
|
body
|
|
212
212
|
}).then(response => {
|
|
213
213
|
if (!response.ok && rollbackData) {
|
|
@@ -229,6 +229,20 @@ export default class extends Controller {
|
|
|
229
229
|
})
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// With skipOwnBroadcasts, tags the request so the broadcast it causes can be
|
|
233
|
+
// recognised and ignored. Turbo's header name, so turbo-rails tracks the id.
|
|
234
|
+
actionHeaders() {
|
|
235
|
+
const headers = { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }
|
|
236
|
+
if (!this.skipOwnBroadcastsValue) return headers
|
|
237
|
+
|
|
238
|
+
const requestId = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)
|
|
239
|
+
this.ownRequestIds ??= new Set()
|
|
240
|
+
this.ownRequestIds.add(requestId)
|
|
241
|
+
if (this.ownRequestIds.size > 20) this.ownRequestIds.delete(this.ownRequestIds.values().next().value)
|
|
242
|
+
headers["X-Turbo-Request-Id"] = requestId
|
|
243
|
+
return headers
|
|
244
|
+
}
|
|
245
|
+
|
|
232
246
|
setState(event) {
|
|
233
247
|
const updates = { ...event.params }
|
|
234
248
|
delete updates.action
|
|
@@ -78,29 +78,29 @@ export function buildActionBody(actionName, actionToken, stimulusParams, formDat
|
|
|
78
78
|
return { body, redirect }
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export function routeMessage(message, elementId, strategy) {
|
|
81
|
+
export function routeMessage(message, elementId, strategy, ownRequestIds) {
|
|
82
82
|
const { action, data } = message
|
|
83
83
|
|
|
84
|
+
if (action === "update" && ownRequestIds?.has(message.request_id)) {
|
|
85
|
+
return { type: "ignore" }
|
|
86
|
+
}
|
|
87
|
+
|
|
84
88
|
if (action === "render" && data?.dom_id === elementId) {
|
|
85
89
|
return { type: "render", data }
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
if (
|
|
89
|
-
return { type: "
|
|
92
|
+
if (action === "destroy" && data?.dom_id === elementId) {
|
|
93
|
+
return { type: "destroy" }
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
if (action === "update" && data?.dom_id === elementId) {
|
|
93
|
-
return { type: "update", data }
|
|
97
|
+
return strategy === "notify" ? { type: "request_update" } : { type: "update", data }
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
if (action === "remove" && (message.dom_id || data?.dom_id) === elementId) {
|
|
97
101
|
return { type: "remove" }
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
if (action === "destroy" && data?.dom_id === elementId) {
|
|
101
|
-
return { type: "destroy" }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
104
|
return { type: "ignore" }
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -36,8 +36,18 @@ module ReactiveComponent
|
|
|
36
36
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
|
-
def _broadcast_reactive_create
|
|
40
|
-
|
|
39
|
+
def _broadcast_reactive_create = broadcast_reactive(:create)
|
|
40
|
+
|
|
41
|
+
# Skips components subscribed to `fields:` when none of them changed.
|
|
42
|
+
def _broadcast_reactive_update
|
|
43
|
+
reactive_component_classes.each do |klass|
|
|
44
|
+
fields = klass._subscribed_fields
|
|
45
|
+
next if fields && !saved_changes.keys.intersect?(fields)
|
|
46
|
+
|
|
47
|
+
ReactiveComponent.broadcast_for(klass, self, action: :update)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
41
51
|
def _broadcast_reactive_destroy = broadcast_reactive(:destroy)
|
|
42
52
|
end
|
|
43
53
|
end
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# This file should be required from the main lib/reactive_component.rb module file.
|
|
4
4
|
|
|
5
|
+
# Entities and signed stream ids are GlobalIDs. ActiveJob pulls this railtie in
|
|
6
|
+
# for its own arguments; an app without ActiveJob would otherwise have no
|
|
7
|
+
# `GlobalID.app` and could not create one.
|
|
8
|
+
require 'global_id/railtie'
|
|
9
|
+
|
|
5
10
|
module ReactiveComponent
|
|
6
11
|
class Engine < ::Rails::Engine
|
|
7
12
|
isolate_namespace ReactiveComponent
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'active_model'
|
|
4
|
+
require 'global_id'
|
|
4
5
|
|
|
5
6
|
module ReactiveComponent
|
|
6
7
|
# A derived entity: a plain object built on top of several ActiveRecord
|
|
@@ -19,6 +20,16 @@ module ReactiveComponent
|
|
|
19
20
|
# def total = order.payments.sum(:amount)
|
|
20
21
|
# end
|
|
21
22
|
#
|
|
23
|
+
# An entity that is not keyed on one record uses `key` instead of `root`:
|
|
24
|
+
#
|
|
25
|
+
# class DueCount
|
|
26
|
+
# include ReactiveComponent::Entity
|
|
27
|
+
#
|
|
28
|
+
# key :company_id, :user_id
|
|
29
|
+
# rebuilds_on Task, fields: %i[due_on],
|
|
30
|
+
# entities: ->(task) { new(company_id: task.company_id, user_id: task.assignee_id) }
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
22
33
|
# class OrderSummaryComponent < ApplicationComponent
|
|
23
34
|
# include ReactiveComponent
|
|
24
35
|
# subscribes_to :summary, class_name: "OrderSummary"
|
|
@@ -27,6 +38,7 @@ module ReactiveComponent
|
|
|
27
38
|
extend ActiveSupport::Concern
|
|
28
39
|
include ActiveModel::Model
|
|
29
40
|
include Broadcastable
|
|
41
|
+
include GlobalID::Identification
|
|
30
42
|
|
|
31
43
|
included do
|
|
32
44
|
class_attribute :root_name, instance_writer: false
|
|
@@ -34,8 +46,9 @@ module ReactiveComponent
|
|
|
34
46
|
|
|
35
47
|
def persisted? = true
|
|
36
48
|
|
|
37
|
-
#
|
|
38
|
-
#
|
|
49
|
+
# Turbo's `stream_name_from` prefers `to_gid_param`, so an entity names its
|
|
50
|
+
# own stream. `to_param` stays as the fallback for when `GlobalID.app`
|
|
51
|
+
# isn't set: a bare id would collide with every other entity sharing it.
|
|
39
52
|
def to_param = "#{self.class.model_name.param_key}/#{id}"
|
|
40
53
|
|
|
41
54
|
class_methods do
|
|
@@ -53,20 +66,65 @@ module ReactiveComponent
|
|
|
53
66
|
define_singleton_method(:find_by) { |id:| (record = class_name.constantize.find_by(id: id)) && new(name => record) }
|
|
54
67
|
end
|
|
55
68
|
|
|
69
|
+
# An entity keyed on plain values instead of a record. Defines an `id`
|
|
70
|
+
# that joins the values the way Rails joins a composite primary key, the
|
|
71
|
+
# `find` / `find_by(id:)` the channel and actions controller need, and,
|
|
72
|
+
# as defaults you can replace, the readers, `initialize(company_id:,
|
|
73
|
+
# user_id:)` and `from_key`. A key with the wrong arity resolves to nil
|
|
74
|
+
# rather than raising.
|
|
75
|
+
#
|
|
76
|
+
# `find` turns an id back into an entity through `from_key`, so an
|
|
77
|
+
# entity that would rather be built from records than from ids defines
|
|
78
|
+
# its own `initialize` and its own `from_key` to match:
|
|
79
|
+
#
|
|
80
|
+
# key :company_id, :user_id
|
|
81
|
+
#
|
|
82
|
+
# def initialize(company, user)
|
|
83
|
+
# @company = company
|
|
84
|
+
# @user = user
|
|
85
|
+
# end
|
|
86
|
+
#
|
|
87
|
+
# delegate :id, to: :company, prefix: true
|
|
88
|
+
# delegate :id, to: :user, prefix: true
|
|
89
|
+
#
|
|
90
|
+
# def self.from_key(company_id:, user_id:)
|
|
91
|
+
# new(Company.find(company_id), User.find(user_id))
|
|
92
|
+
# end
|
|
93
|
+
def key(*names)
|
|
94
|
+
names = names.map(&:to_sym)
|
|
95
|
+
attr_reader(*names)
|
|
96
|
+
|
|
97
|
+
define_method(:initialize) { |**kwargs| names.each { |n| instance_variable_set(:"@#{n}", kwargs.fetch(n)) } }
|
|
98
|
+
define_method(:id) { names.map { |n| public_send(n) }.join('-') }
|
|
99
|
+
|
|
100
|
+
define_singleton_method(:from_key) { |**values| new(**values) }
|
|
101
|
+
define_singleton_method(:find_by) do |id:|
|
|
102
|
+
values = id.to_s.split('-')
|
|
103
|
+
from_key(**names.zip(values).to_h) if values.size == names.size
|
|
104
|
+
end
|
|
105
|
+
define_singleton_method(:find) { |id| find_by(id: id) }
|
|
106
|
+
end
|
|
107
|
+
|
|
56
108
|
# Rebroadcast the entity after `model` commits. `via:` is the foreign key
|
|
57
109
|
# on `model` pointing at the root; omit it when `model` is the root.
|
|
58
110
|
# `fields:` narrows updates to the listed columns; create and destroy
|
|
59
111
|
# always count.
|
|
60
|
-
|
|
112
|
+
# `entities:` is the alternative to `via:` when one commit touches more
|
|
113
|
+
# than one entity, or when the entity is not reachable through a single
|
|
114
|
+
# foreign key: it takes the record and returns the entities to rebuild.
|
|
115
|
+
def rebuilds_on(model, via: nil, fields: nil, entities: nil)
|
|
116
|
+
raise ArgumentError, 'rebuilds_on takes either via: or entities:, not both' if via && entities
|
|
117
|
+
|
|
61
118
|
entity = self
|
|
62
119
|
fields = fields&.map(&:to_s)
|
|
63
120
|
|
|
64
|
-
model.after_commit { entity.rebuild_from(self, via: via, fields: fields) }
|
|
121
|
+
model.after_commit { entity.rebuild_from(self, via: via, fields: fields, entities: entities) }
|
|
65
122
|
end
|
|
66
123
|
|
|
67
|
-
def rebuild_from(record, via:, fields:)
|
|
124
|
+
def rebuild_from(record, via:, fields:, entities: nil)
|
|
68
125
|
action = commit_action(record)
|
|
69
|
-
return if action
|
|
126
|
+
return if unlisted_change?(record, action, fields)
|
|
127
|
+
return Array(entities.call(record)).each { |e| e.broadcast_reactive(:update) } if entities
|
|
70
128
|
return find_by(id: record.public_send(via))&.broadcast_reactive(:update) if via
|
|
71
129
|
|
|
72
130
|
new(root_name => record).broadcast_reactive(action)
|
|
@@ -74,6 +132,10 @@ module ReactiveComponent
|
|
|
74
132
|
|
|
75
133
|
private
|
|
76
134
|
|
|
135
|
+
def unlisted_change?(record, action, fields)
|
|
136
|
+
action == :update && fields && !record.saved_changes.keys.intersect?(fields)
|
|
137
|
+
end
|
|
138
|
+
|
|
77
139
|
def commit_action(record)
|
|
78
140
|
return :destroy if record.destroyed?
|
|
79
141
|
return :create if record.previously_new_record?
|
|
@@ -2,12 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module ReactiveComponent
|
|
4
4
|
module Wrapper
|
|
5
|
+
# Scopes the signed ids a notify wrapper puts on the page: a signed id
|
|
6
|
+
# minted elsewhere in the app cannot be replayed at our channel.
|
|
7
|
+
SGID_PURPOSE = 'reactive_component'
|
|
8
|
+
|
|
5
9
|
module_function
|
|
6
10
|
|
|
7
11
|
def wrap(component_class, record, inner_html, stream: nil, client_state: nil, strategy: nil, component_name: nil,
|
|
8
|
-
params: nil, template_id: nil)
|
|
12
|
+
params: nil, template_id: nil, skip_own_broadcasts: ReactiveComponent.skip_own_broadcasts)
|
|
9
13
|
dom_id_val = component_class.dom_id_for(record)
|
|
10
14
|
|
|
15
|
+
# A notify component asks the server to re-render it, so it carries a
|
|
16
|
+
# signed id of the record it is allowed to ask about. The raw id stays
|
|
17
|
+
# out of it: the client already has one in its data.
|
|
18
|
+
if strategy.to_s == 'notify'
|
|
19
|
+
component_name ||= component_class.name
|
|
20
|
+
sgid = record.to_sgid_param(for: SGID_PURPOSE)
|
|
21
|
+
end
|
|
22
|
+
|
|
11
23
|
attrs = [
|
|
12
24
|
%(id="#{dom_id_val}"),
|
|
13
25
|
%(data-controller="reactive-renderer"),
|
|
@@ -33,8 +45,12 @@ module ReactiveComponent
|
|
|
33
45
|
|
|
34
46
|
attrs << %(data-reactive-renderer-strategy-value="#{strategy}") if strategy
|
|
35
47
|
|
|
48
|
+
attrs << %(data-reactive-renderer-skip-own-broadcasts-value="true") if skip_own_broadcasts
|
|
49
|
+
|
|
36
50
|
attrs << %(data-reactive-renderer-component-value="#{component_name}") if component_name
|
|
37
51
|
|
|
52
|
+
attrs << %(data-reactive-renderer-sgid-value="#{sgid}") if sgid
|
|
53
|
+
|
|
38
54
|
attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}") if params
|
|
39
55
|
|
|
40
56
|
if ReactiveComponent.debug
|
data/lib/reactive_component.rb
CHANGED
|
@@ -18,6 +18,8 @@ module ReactiveComponent
|
|
|
18
18
|
mattr_accessor :renderer, default: nil
|
|
19
19
|
# How long a live_action token minted into a page stays valid.
|
|
20
20
|
mattr_accessor :action_token_ttl, default: 1.day
|
|
21
|
+
# Whether a component ignores the broadcast caused by its own live_action.
|
|
22
|
+
mattr_accessor :skip_own_broadcasts, default: false
|
|
21
23
|
|
|
22
24
|
class Error < StandardError; end
|
|
23
25
|
|
|
@@ -34,6 +36,7 @@ module ReactiveComponent
|
|
|
34
36
|
class_attribute :_broadcast_config, instance_writer: false
|
|
35
37
|
class_attribute :_client_state_fields, instance_writer: false, default: {}
|
|
36
38
|
class_attribute :_subscribed_events, instance_writer: false, default: %i[create update destroy]
|
|
39
|
+
class_attribute :_subscribed_fields, instance_writer: false, default: nil
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def render_in(view_context, &)
|
|
@@ -141,10 +144,11 @@ module ReactiveComponent
|
|
|
141
144
|
end
|
|
142
145
|
|
|
143
146
|
class_methods do
|
|
144
|
-
def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy])
|
|
147
|
+
def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy], fields: nil)
|
|
145
148
|
self._live_model_attr = attr_name.to_sym
|
|
146
149
|
self._live_model_class_name = class_name || attr_name.to_s.classify
|
|
147
150
|
self._subscribed_events = Array(only).map(&:to_sym)
|
|
151
|
+
self._subscribed_fields = fields && Array(fields).map(&:to_s)
|
|
148
152
|
|
|
149
153
|
component_class = self
|
|
150
154
|
|