live_cable 0.1.2 → 0.2.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 +82 -0
- data/README.md +69 -1
- data/app/assets/javascript/controllers/live_controller.js +31 -4
- data/app/assets/javascript/loading.js +162 -0
- data/app/assets/javascript/subscriptions.js +62 -2
- data/config/importmap.rb +1 -0
- data/lib/live.rb +5 -0
- data/lib/live_cable/component/broadcasting.rb +14 -3
- data/lib/live_cable/component/events.rb +49 -0
- data/lib/live_cable/component.rb +1 -0
- data/lib/live_cable/connection/broadcasting.rb +13 -1
- data/lib/live_cable/connection/messaging.rb +18 -2
- data/lib/live_cable/rendering/compiler.rb +12 -6
- data/lib/live_cable/testing/render_state.rb +52 -0
- data/lib/live_cable/testing/test_cable_connection.rb +21 -0
- data/lib/live_cable/testing/test_channel.rb +49 -0
- data/lib/live_cable/testing/test_component.rb +147 -0
- data/lib/live_cable/testing.rb +84 -0
- data/lib/live_cable/version.rb +1 -1
- data/lib/live_cable.rb +6 -4
- metadata +39 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a08ab617cc974271c9f4b2784bb52ca53d1430942fd72e761fe52cd650a86141
|
|
4
|
+
data.tar.gz: abd1b26ca97b149de015b9fb7b9b457ff6b6f21e224a61318788876115fcd9d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3b932fcbdfcf3fb5d6b939320798eb151c82d0e6bc85c61c2452ff729233855b477752e10465c8098f4601651d04f9e67449293b9635cde3eefa47bde08733c
|
|
7
|
+
data.tar.gz: 6476ba238c1960655922da93588b175ed0468294364dd1252b05ad044eb26cb0d94360254581cb791ddc6def81155940c82d310835398b7c791b8cb3daf6884c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The Ruby gem (`live_cable`) and the npm package (`@isometriks/live_cable`) are
|
|
6
|
+
released together and share a single version number. Entries below note which
|
|
7
|
+
side of the pair a change affects when it isn't both.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
The gem and npm package versions are realigned in this release. The npm package
|
|
12
|
+
jumps from 0.1.1 straight to 0.2.0, skipping 0.1.2, which was published for the
|
|
13
|
+
gem only.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Server-dispatched DOM events.** Components can queue browser events with
|
|
18
|
+
`dispatch_event`, delivered with the next broadcast and fired after the DOM has
|
|
19
|
+
been morphed, so handlers observe the updated markup. Events are bubbling
|
|
20
|
+
`CustomEvent`s dispatched from the component root (or from `window` with
|
|
21
|
+
`window: true`), so they can be wired up with plain Stimulus `data-action`
|
|
22
|
+
syntax. (`LiveCable::Component::Events`)
|
|
23
|
+
- **Loading states.** While a message is in flight, a `live-loading` attribute is
|
|
24
|
+
added to the component's root element and to the element that triggered the
|
|
25
|
+
message, so pending feedback can be styled with plain CSS. `live-disable-with`
|
|
26
|
+
on a button or submit button swaps its label and disables it for the duration
|
|
27
|
+
of the round trip; form values are serialized before anything is disabled.
|
|
28
|
+
Reactive inputs (`live-reactive`) receive `live-loading` but are never
|
|
29
|
+
disabled, so typing is not interrupted.
|
|
30
|
+
- **Component test harness.** `LiveCable::Testing` can be included in specs to
|
|
31
|
+
mount and drive components without a browser, via `live_mount`.
|
|
32
|
+
- `./loading` subpath export for the new loading module (npm).
|
|
33
|
+
- Gemspec `homepage_uri`, `documentation_uri`, and `bug_tracker_uri` metadata.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- Minimum Rails version raised from 7.0 to 7.1 (`actioncable`, `actionview`,
|
|
38
|
+
`activemodel`, `activesupport`) (gem).
|
|
39
|
+
- Herb upgraded from `~> 0.8.10` to `~> 0.10.2`, and `prism >= 1.0` added as an
|
|
40
|
+
explicit dependency (gem).
|
|
41
|
+
- Gem homepage now points at https://livecable.io rather than the RubyGems page.
|
|
42
|
+
- The `Live` namespace for user components moved out of `lib/live_cable.rb` into
|
|
43
|
+
its own `lib/live.rb`, required explicitly and ignored by the gem's Zeitwerk
|
|
44
|
+
loader (gem).
|
|
45
|
+
- Dev dependencies updated: Vitest 2.x to 4.x, happy-dom 15.x to 20.x (npm).
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Template compiler: block sentinel tokens now carry a newline value so Herb's
|
|
50
|
+
whitespace helpers (`at_line_start?`, `preceding_token_ends_with_newline?`)
|
|
51
|
+
treat them as a line boundary instead of raising on a `nil` value.
|
|
52
|
+
- Template compiler: the closing `end` of an output block is emitted as plain
|
|
53
|
+
code instead of going through Herb's paren-balancing block-end helper, since
|
|
54
|
+
escaping is delegated to Rails' output buffer.
|
|
55
|
+
|
|
56
|
+
## 0.1.2 - 2026-03-25
|
|
57
|
+
|
|
58
|
+
Gem only; no corresponding npm release.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- JavaScript assets packaged so they can be consumed from package managers as
|
|
63
|
+
well as through the asset pipeline.
|
|
64
|
+
|
|
65
|
+
## 0.1.1 - 2026-03-25
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- Component generator.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- Herb pinned to `0.8.*`, and other library versions pinned.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- `render` inside a live template.
|
|
78
|
+
- Generator no longer creates `app/live` when the directory does not exist.
|
|
79
|
+
|
|
80
|
+
## 0.1.0
|
|
81
|
+
|
|
82
|
+
Initial public release.
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
LiveCable is a Phoenix LiveView-style live component system for Ruby on Rails that tracks state server-side and allows
|
|
4
4
|
you to call actions from the frontend using Stimulus with a React style state management API.
|
|
5
5
|
|
|
6
|
-
**Full documentation: [livecable.io](https://livecable.io)**
|
|
6
|
+
**Full documentation: [livecable.io](https://livecable.io)** | **Sandbox app: [live_cable_app](https://github.com/isometriks/live_cable_app)**
|
|
7
7
|
|
|
8
8
|
## Features
|
|
9
9
|
|
|
@@ -1000,6 +1000,53 @@ The `live-key` attribute acts as a hint for the diffing algorithm to identify el
|
|
|
1000
1000
|
<% end %>
|
|
1001
1001
|
```
|
|
1002
1002
|
|
|
1003
|
+
## Loading States
|
|
1004
|
+
|
|
1005
|
+
Every interaction is a round trip to the server. While a message is in flight, LiveCable adds a `live-loading` attribute to the component's root element and to the element that triggered the message, so you can show pending feedback with plain CSS:
|
|
1006
|
+
|
|
1007
|
+
```css
|
|
1008
|
+
button[live-loading] { opacity: 0.5; cursor: wait; }
|
|
1009
|
+
|
|
1010
|
+
.spinner { display: none; }
|
|
1011
|
+
[live-loading] .spinner { display: inline-block; }
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
To prevent double-clicks, mark buttons with `live-disable-with`. The element is disabled while the message is in flight and restored when the server responds. Give the attribute a value to also swap the label:
|
|
1015
|
+
|
|
1016
|
+
```erb
|
|
1017
|
+
<button live-action="checkout" live-disable-with="Processing...">Checkout</button>
|
|
1018
|
+
|
|
1019
|
+
<form live-form="save">
|
|
1020
|
+
<input type="text" name="title">
|
|
1021
|
+
<button type="submit" live-disable-with="Saving...">Save</button>
|
|
1022
|
+
</form>
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
For forms, put `live-disable-with` on the submit button(s); form values are serialized before anything is disabled. Reactive inputs (`live-reactive`) get the `live-loading` attribute but are never disabled, so typing is not interrupted.
|
|
1026
|
+
|
|
1027
|
+
The loading state is cleared when the server responds — with a re-render, an error, or a lightweight acknowledgement when the action didn't change any state — so it never gets stuck.
|
|
1028
|
+
|
|
1029
|
+
## Server Events
|
|
1030
|
+
|
|
1031
|
+
Components can trigger DOM events on the client with `dispatch_event` — for scroll-to-bottom, closing modals, toasts, or anything else the client should do after the server finishes:
|
|
1032
|
+
|
|
1033
|
+
```ruby
|
|
1034
|
+
def send_message(params)
|
|
1035
|
+
messages << { text: params[:text] }
|
|
1036
|
+
dispatch_event('chat:message-sent')
|
|
1037
|
+
end
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
Events fire as bubbling `CustomEvent`s from the component's root element **after the DOM has been morphed**, so handlers see the updated markup. Plain Stimulus `data-action` syntax handles them — no LiveCable-specific JavaScript:
|
|
1041
|
+
|
|
1042
|
+
```erb
|
|
1043
|
+
<div data-controller="chat" data-action="chat:message-sent->chat#scrollToBottom">
|
|
1044
|
+
...
|
|
1045
|
+
</div>
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
Pass a payload (`dispatch_event('toast:show', message: 'Saved')`, available as `event.detail`) or target global listeners with `dispatch_event('analytics:tracked', window: true)`. Events work from actions, lifecycle callbacks, and `stream_from` callbacks, and are delivered exactly once, in order, whether or not the action re-rendered.
|
|
1049
|
+
|
|
1003
1050
|
## Compound Components
|
|
1004
1051
|
|
|
1005
1052
|
By default, components render the partial at `app/views/live/component_name.html.live.erb`. You can organize your templates differently by marking a component as `compound`.
|
|
@@ -1237,6 +1284,27 @@ When a broadcast is received:
|
|
|
1237
1284
|
- **Live dashboards**: Update metrics and charts in real-time
|
|
1238
1285
|
- **Presence tracking**: Show who's currently online or viewing a resource
|
|
1239
1286
|
|
|
1287
|
+
## Testing Components
|
|
1288
|
+
|
|
1289
|
+
LiveCable includes a test harness for fast, browser-free component tests. Actions go through the real message pipeline (whitelisting, parameter parsing, change tracking, re-rendering), so tests exercise what production runs:
|
|
1290
|
+
|
|
1291
|
+
```ruby
|
|
1292
|
+
RSpec.describe Live::Counter do
|
|
1293
|
+
include LiveCable::Testing
|
|
1294
|
+
|
|
1295
|
+
it 'increments by the step size' do
|
|
1296
|
+
counter = live_mount('counter', step: 2)
|
|
1297
|
+
|
|
1298
|
+
counter.perform(:increment)
|
|
1299
|
+
|
|
1300
|
+
expect(counter.count).to eq(2)
|
|
1301
|
+
expect(counter.rendered).to have_css('[data-testid="counter-value"]', text: '2')
|
|
1302
|
+
end
|
|
1303
|
+
end
|
|
1304
|
+
```
|
|
1305
|
+
|
|
1306
|
+
The harness supports client reactive updates (`set_reactive`), broadcast assertions (`broadcasts(:_refresh)`), simulated stream broadcasts (`receive_stream`), shared state across components mounted on the same connection, connection identifiers like `current_user`, and lifecycle testing via `unmount`. See the [testing guide](https://livecable.io/guide/testing) for the full API.
|
|
1307
|
+
|
|
1240
1308
|
## Error Handling
|
|
1241
1309
|
|
|
1242
1310
|
When an unhandled exception is raised inside a component action, LiveCable replaces the component in the DOM with an error message and cleans up the server-side component.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import SubscriptionManager from "@isometriks/live_cable/subscriptions"
|
|
3
|
+
import LoadingState from "@isometriks/live_cable/loading"
|
|
3
4
|
|
|
4
5
|
export default class extends Controller {
|
|
5
6
|
static values = {
|
|
@@ -12,16 +13,18 @@ export default class extends Controller {
|
|
|
12
13
|
|
|
13
14
|
#subscription
|
|
14
15
|
#debounces = new Map()
|
|
16
|
+
#loading
|
|
15
17
|
|
|
16
18
|
#callActionCallback = (event) => {
|
|
17
19
|
event.stopPropagation()
|
|
18
20
|
|
|
19
21
|
const { action, params } = event.detail
|
|
20
22
|
|
|
21
|
-
this.sendCall(action, params)
|
|
23
|
+
this.sendCall(action, params, event.target)
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
connect() {
|
|
27
|
+
this.#loading = new LoadingState(this.element)
|
|
25
28
|
this.element.addEventListener("call", this.#callActionCallback)
|
|
26
29
|
|
|
27
30
|
this.#subscription = SubscriptionManager.subscribe(
|
|
@@ -33,8 +36,8 @@ export default class extends Controller {
|
|
|
33
36
|
|
|
34
37
|
// Create callbacks for each action or form
|
|
35
38
|
this.actionsValue.forEach((action) => {
|
|
36
|
-
this[`action_$${action}`] = ({ params }) => {
|
|
37
|
-
this.sendCall(action, this.#convertKeysToSnakeCase(params))
|
|
39
|
+
this[`action_$${action}`] = ({ params, currentTarget }) => {
|
|
40
|
+
this.sendCall(action, this.#convertKeysToSnakeCase(params), currentTarget)
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
this[`form_$${action}`] = (event) => {
|
|
@@ -47,12 +50,30 @@ export default class extends Controller {
|
|
|
47
50
|
this.element.removeEventListener("call", this.#callActionCallback)
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
sendCall(action, params = {}) {
|
|
53
|
+
sendCall(action, params = {}, trigger = null) {
|
|
54
|
+
this.#loading.start(trigger)
|
|
51
55
|
this.#subscription.send(
|
|
52
56
|
this.#flushDebounced(this.#callMessage(params, action))
|
|
53
57
|
)
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
// Called by the subscription when the server answers a message
|
|
61
|
+
// (refresh, ack, or error). Restores any live-loading / live-disable-with
|
|
62
|
+
// state once all in-flight messages have been answered.
|
|
63
|
+
finishLoading() {
|
|
64
|
+
this.#loading?.finish()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Whether any message is still awaiting a server response
|
|
68
|
+
get isLoading() {
|
|
69
|
+
return this.#loading?.active ?? false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Called by the subscription when the component is being torn down.
|
|
73
|
+
resetLoading() {
|
|
74
|
+
this.#loading?.reset()
|
|
75
|
+
}
|
|
76
|
+
|
|
56
77
|
#callMessage(params, action) {
|
|
57
78
|
return {
|
|
58
79
|
_action: action,
|
|
@@ -83,6 +104,8 @@ export default class extends Controller {
|
|
|
83
104
|
|
|
84
105
|
sendReactive(target) {
|
|
85
106
|
this.#clearDebounce(target)
|
|
107
|
+
// Never disable reactive inputs while in flight - it would drop focus
|
|
108
|
+
this.#loading.start(target, { disable: false })
|
|
86
109
|
this.#subscription.send(
|
|
87
110
|
this.#flushDebounced(this.#reactiveMessage(target))
|
|
88
111
|
)
|
|
@@ -114,9 +137,13 @@ export default class extends Controller {
|
|
|
114
137
|
sendForm(action, formEl) {
|
|
115
138
|
this.#clearDebounce(formEl)
|
|
116
139
|
|
|
140
|
+
// Serialize before starting the loading state - disabled controls
|
|
141
|
+
// (live-disable-with) are excluded from FormData
|
|
117
142
|
const formData = new FormData(formEl)
|
|
118
143
|
const params = new URLSearchParams(formData).toString()
|
|
119
144
|
|
|
145
|
+
this.#loading.start(formEl)
|
|
146
|
+
|
|
120
147
|
this.#subscription.send(
|
|
121
148
|
this.#flushDebounced(this.#callMessage(params, action))
|
|
122
149
|
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading state tracking for LiveCable components.
|
|
3
|
+
*
|
|
4
|
+
* Tracks in-flight messages for a single component and reflects them in the
|
|
5
|
+
* DOM so users can style pending states with CSS:
|
|
6
|
+
*
|
|
7
|
+
* - The component root element gets a `live-loading` attribute while any
|
|
8
|
+
* message is awaiting a server response.
|
|
9
|
+
* - The element that triggered the message (button, form, input) also gets
|
|
10
|
+
* a `live-loading` attribute.
|
|
11
|
+
* - Elements with a `live-disable-with` attribute are disabled while the
|
|
12
|
+
* message is in flight. If the attribute has a value, the element's label
|
|
13
|
+
* (textContent, or value for inputs) is swapped for it.
|
|
14
|
+
*
|
|
15
|
+
* The state is cleared when the server responds with a refresh, an error,
|
|
16
|
+
* or an ack (sent when an action didn't change any reactive variables).
|
|
17
|
+
* Multiple in-flight messages are counted; the DOM is only restored once
|
|
18
|
+
* all of them have been answered.
|
|
19
|
+
*/
|
|
20
|
+
export default class LoadingState {
|
|
21
|
+
/** @type {HTMLElement} */
|
|
22
|
+
#root
|
|
23
|
+
/** @type {number} */
|
|
24
|
+
#inFlight = 0
|
|
25
|
+
/** @type {Set<Element>} - Elements marked with the live-loading attribute */
|
|
26
|
+
#markedElements = new Set()
|
|
27
|
+
/** @type {Map<Element, Object>} - Original state of disabled elements */
|
|
28
|
+
#disabledElements = new Map()
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {HTMLElement} root - The component's root element
|
|
32
|
+
*/
|
|
33
|
+
constructor(root) {
|
|
34
|
+
this.#root = root
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether any message is currently awaiting a server response.
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
get active() {
|
|
42
|
+
return this.#inFlight > 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mark the component as loading.
|
|
47
|
+
* Called right before a message is sent to the server.
|
|
48
|
+
*
|
|
49
|
+
* @param {Element|null} trigger - The element that triggered the message
|
|
50
|
+
* @param {Object} options
|
|
51
|
+
* @param {boolean} options.disable - Whether to process live-disable-with
|
|
52
|
+
* elements. Disabled for reactive inputs so typing doesn't lose focus.
|
|
53
|
+
*/
|
|
54
|
+
start(trigger = null, { disable = true } = {}) {
|
|
55
|
+
this.#inFlight++
|
|
56
|
+
|
|
57
|
+
this.#mark(this.#root)
|
|
58
|
+
|
|
59
|
+
if (trigger instanceof Element && trigger !== this.#root) {
|
|
60
|
+
this.#mark(trigger)
|
|
61
|
+
|
|
62
|
+
if (disable) {
|
|
63
|
+
this.#disableElements(trigger)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Record a server response for this component.
|
|
70
|
+
* Restores the DOM once all in-flight messages have been answered.
|
|
71
|
+
*
|
|
72
|
+
* @returns {boolean} true if the loading state was fully cleared
|
|
73
|
+
*/
|
|
74
|
+
finish() {
|
|
75
|
+
if (this.#inFlight === 0) {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.#inFlight--
|
|
80
|
+
|
|
81
|
+
if (this.#inFlight > 0) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.#restore()
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Clear all loading state immediately, regardless of in-flight count.
|
|
91
|
+
* Used when the component is being torn down (e.g. on error).
|
|
92
|
+
*/
|
|
93
|
+
reset() {
|
|
94
|
+
this.#inFlight = 0
|
|
95
|
+
this.#restore()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#mark(element) {
|
|
99
|
+
element.setAttribute('live-loading', '')
|
|
100
|
+
this.#markedElements.add(element)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Disable the trigger and/or its descendants marked with live-disable-with.
|
|
105
|
+
* For a button the attribute lives on the button itself; for a form it
|
|
106
|
+
* usually lives on the submit button(s) inside it.
|
|
107
|
+
*/
|
|
108
|
+
#disableElements(trigger) {
|
|
109
|
+
const elements = []
|
|
110
|
+
|
|
111
|
+
if (trigger.hasAttribute('live-disable-with')) {
|
|
112
|
+
elements.push(trigger)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
elements.push(...trigger.querySelectorAll('[live-disable-with]'))
|
|
116
|
+
|
|
117
|
+
elements.forEach(element => {
|
|
118
|
+
// Already disabled by an earlier in-flight message
|
|
119
|
+
if (this.#disabledElements.has(element)) {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const text = element.getAttribute('live-disable-with')
|
|
124
|
+
const isInput = element instanceof HTMLInputElement
|
|
125
|
+
|
|
126
|
+
this.#disabledElements.set(element, {
|
|
127
|
+
disabled: element.disabled,
|
|
128
|
+
content: text ? (isInput ? element.value : element.textContent) : null,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
element.disabled = true
|
|
132
|
+
|
|
133
|
+
if (text) {
|
|
134
|
+
if (isInput) {
|
|
135
|
+
element.value = text
|
|
136
|
+
} else {
|
|
137
|
+
element.textContent = text
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#restore() {
|
|
144
|
+
this.#markedElements.forEach(element => {
|
|
145
|
+
element.removeAttribute('live-loading')
|
|
146
|
+
})
|
|
147
|
+
this.#markedElements.clear()
|
|
148
|
+
|
|
149
|
+
this.#disabledElements.forEach(({ disabled, content }, element) => {
|
|
150
|
+
element.disabled = disabled
|
|
151
|
+
|
|
152
|
+
if (content !== null) {
|
|
153
|
+
if (element instanceof HTMLInputElement) {
|
|
154
|
+
element.value = content
|
|
155
|
+
} else {
|
|
156
|
+
element.textContent = content
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
this.#disabledElements.clear()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -396,6 +396,10 @@ class Subscription {
|
|
|
396
396
|
* @param {string} [data._status] - Status update (e.g., 'subscribed', 'destroy')
|
|
397
397
|
* @param {string} [data._refresh] - HTML to morph into the DOM
|
|
398
398
|
* @param {string} [data._error] - Raw error HTML to replace the component with
|
|
399
|
+
* @param {boolean} [data._ack] - Acknowledgement that a message was processed
|
|
400
|
+
* without producing a re-render; clears the loading state
|
|
401
|
+
* @param {Array} [data._events] - Events to dispatch as CustomEvents; when
|
|
402
|
+
* attached to a _refresh they fire after the DOM has been morphed
|
|
399
403
|
* @private
|
|
400
404
|
*/
|
|
401
405
|
#received = (data) => {
|
|
@@ -405,7 +409,32 @@ class Subscription {
|
|
|
405
409
|
this.#handleRefresh(data['_refresh'])
|
|
406
410
|
} else if (data['_error']) {
|
|
407
411
|
this.#handleError(data['_error'])
|
|
412
|
+
} else if (data['_ack']) {
|
|
413
|
+
this.#controller?.finishLoading()
|
|
408
414
|
}
|
|
415
|
+
|
|
416
|
+
// Dispatch after the branch above so events attached to a refresh fire
|
|
417
|
+
// once the morph has completed and handlers see the updated DOM
|
|
418
|
+
if (data['_events']) {
|
|
419
|
+
this.#dispatchEvents(data['_events'])
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Fire server-dispatched events as bubbling CustomEvents from the
|
|
425
|
+
* component's root element, or from window when the event asks for it.
|
|
426
|
+
*
|
|
427
|
+
* @param {Array<{name: string, detail: Object, window: boolean}>} events
|
|
428
|
+
* @private
|
|
429
|
+
*/
|
|
430
|
+
#dispatchEvents(events) {
|
|
431
|
+
events.forEach(({ name, detail, window: onWindow }) => {
|
|
432
|
+
const target = onWindow ? window : this.#controller?.element
|
|
433
|
+
|
|
434
|
+
if (target) {
|
|
435
|
+
target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }))
|
|
436
|
+
}
|
|
437
|
+
})
|
|
409
438
|
}
|
|
410
439
|
|
|
411
440
|
/**
|
|
@@ -420,6 +449,7 @@ class Subscription {
|
|
|
420
449
|
return
|
|
421
450
|
}
|
|
422
451
|
|
|
452
|
+
this.#controller.resetLoading()
|
|
423
453
|
this.#controller.element.outerHTML = html
|
|
424
454
|
this.unsubscribe()
|
|
425
455
|
}
|
|
@@ -458,10 +488,40 @@ class Subscription {
|
|
|
458
488
|
return
|
|
459
489
|
}
|
|
460
490
|
|
|
461
|
-
|
|
491
|
+
// Restore live-loading / live-disable-with state before morphing so the
|
|
492
|
+
// morph applies the server-rendered truth on top of the original DOM.
|
|
493
|
+
// With multiple messages in flight this only restores once the last
|
|
494
|
+
// response arrives - until then the morph below preserves the pending
|
|
495
|
+
// elements so live-disable-with buttons can't be clicked early.
|
|
496
|
+
this.#controller.finishLoading()
|
|
497
|
+
|
|
498
|
+
const rootElement = this.#controller.element
|
|
499
|
+
const stillLoading = this.#controller.isLoading
|
|
500
|
+
|
|
501
|
+
const refreshDOM = this.#buildRefreshDOM(refresh)
|
|
502
|
+
|
|
503
|
+
if (stillLoading) {
|
|
504
|
+
refreshDOM.setAttribute('live-loading', '')
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
morphdom(rootElement, refreshDOM, {
|
|
462
508
|
// Preserve elements marked with live-ignore attribute
|
|
463
509
|
onBeforeElUpdated(fromEl, toEl) {
|
|
464
|
-
|
|
510
|
+
if (!fromEl.hasAttribute) {
|
|
511
|
+
return true
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (fromEl.hasAttribute('live-ignore')) {
|
|
515
|
+
return false
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Keep elements that are still awaiting a server response untouched
|
|
519
|
+
// (the root is handled above so the rest of the tree still morphs)
|
|
520
|
+
if (stillLoading && fromEl !== rootElement && fromEl.hasAttribute('live-loading')) {
|
|
521
|
+
return false
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return true
|
|
465
525
|
},
|
|
466
526
|
// Use stable keys for better morphing performance and state preservation
|
|
467
527
|
getNodeKey(node) {
|
data/config/importmap.rb
CHANGED
|
@@ -6,6 +6,7 @@ pin 'morphdom', to: 'https://ga.jspm.io/npm:morphdom@2.7.8/dist/morphdom-esm.js'
|
|
|
6
6
|
pin '@isometriks/live_cable/controller', to: 'controllers/live_controller.js'
|
|
7
7
|
pin '@isometriks/live_cable/blessing', to: 'live_cable_blessing.js'
|
|
8
8
|
pin '@isometriks/live_cable/subscriptions', to: 'subscriptions.js'
|
|
9
|
+
pin '@isometriks/live_cable/loading', to: 'loading.js'
|
|
9
10
|
pin '@isometriks/live_cable/observer', to: 'observer.js'
|
|
10
11
|
pin '@isometriks/live_cable/dom', to: 'dom.js'
|
|
11
12
|
pin '@isometriks/live_cable', to: 'live_cable.js'
|
data/lib/live.rb
ADDED
|
@@ -13,6 +13,12 @@ module LiveCable
|
|
|
13
13
|
broadcast({ _status: 'subscribed', id: live_id })
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Sent when a received message didn't change any reactive variables,
|
|
17
|
+
# so the client can clear its loading state without a re-render.
|
|
18
|
+
def broadcast_ack
|
|
19
|
+
broadcast({ _ack: true })
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
def broadcast_destroy
|
|
17
23
|
broadcast({ _status: 'destroy' })
|
|
18
24
|
@subscribed = false
|
|
@@ -20,9 +26,14 @@ module LiveCable
|
|
|
20
26
|
|
|
21
27
|
def broadcast_render
|
|
22
28
|
run_callbacks :render do
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
data = { _refresh: render.as_json }
|
|
30
|
+
|
|
31
|
+
# Events ride along with the render so the client can fire them
|
|
32
|
+
# after the DOM has been morphed
|
|
33
|
+
events = flush_events
|
|
34
|
+
data[:_events] = events if events.any?
|
|
35
|
+
|
|
36
|
+
broadcast(data)
|
|
26
37
|
end
|
|
27
38
|
end
|
|
28
39
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
class Component
|
|
5
|
+
module Events
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# Queue a DOM event to be dispatched on the client. Events are
|
|
9
|
+
# delivered with the next broadcast for this component - attached to
|
|
10
|
+
# the render when state changed, or on their own when it didn't - and
|
|
11
|
+
# fire on the client after the DOM has been morphed, so handlers see
|
|
12
|
+
# the updated markup.
|
|
13
|
+
#
|
|
14
|
+
# On the client the event is a bubbling CustomEvent dispatched from
|
|
15
|
+
# the component's root element (or from window with window: true), so
|
|
16
|
+
# it can be handled with plain Stimulus data-action syntax:
|
|
17
|
+
#
|
|
18
|
+
# <div data-controller="chat" data-action="chat:message-sent->chat#scrollToBottom">
|
|
19
|
+
#
|
|
20
|
+
# @param name [String, Symbol] The event name (e.g. 'chat:message-sent')
|
|
21
|
+
# @param detail [Hash] JSON-serializable payload, available as event.detail.
|
|
22
|
+
# Can be passed positionally or as bare keyword arguments; use the
|
|
23
|
+
# positional form if the payload itself needs a :window key.
|
|
24
|
+
# @param window [Boolean] Dispatch on window instead of the component root
|
|
25
|
+
def dispatch_event(name, positional_detail = nil, window: false, **detail)
|
|
26
|
+
detail = positional_detail if positional_detail
|
|
27
|
+
|
|
28
|
+
pending_events << { name: name.to_s, detail: detail.as_json, window: }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Drain the queued events. Called when a broadcast is sent so each
|
|
32
|
+
# event is delivered exactly once.
|
|
33
|
+
#
|
|
34
|
+
# @return [Array<Hash>]
|
|
35
|
+
def flush_events
|
|
36
|
+
events = pending_events.dup
|
|
37
|
+
pending_events.clear
|
|
38
|
+
events
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @return [Array<Hash>]
|
|
44
|
+
def pending_events
|
|
45
|
+
@pending_events ||= []
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/live_cable/component.rb
CHANGED
|
@@ -5,6 +5,8 @@ module LiveCable
|
|
|
5
5
|
module Broadcasting
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
+
# @return [Array<LiveCable::Component>] Components that were broadcast to
|
|
9
|
+
# (rendered or errored), including children rendered by their parents
|
|
8
10
|
def broadcast_changeset
|
|
9
11
|
rendered = []
|
|
10
12
|
shared_changeset = containers[SHARED_CONTAINER]&.changeset
|
|
@@ -25,8 +27,18 @@ module LiveCable
|
|
|
25
27
|
handle_error(component, error)
|
|
26
28
|
end
|
|
27
29
|
|
|
28
|
-
rendered |= component.rendered_children
|
|
30
|
+
rendered |= [component] | component.rendered_children
|
|
29
31
|
end
|
|
32
|
+
|
|
33
|
+
# Deliver events from components that didn't broadcast a render this
|
|
34
|
+
# cycle (no state change, or rendered inline by a parent) - rendered
|
|
35
|
+
# components already flushed their events with the refresh
|
|
36
|
+
components.each_value do |component|
|
|
37
|
+
events = component.flush_events
|
|
38
|
+
component.broadcast(_events: events) if events.any?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
rendered
|
|
30
42
|
end
|
|
31
43
|
end
|
|
32
44
|
end
|
|
@@ -11,13 +11,22 @@ module LiveCable
|
|
|
11
11
|
|
|
12
12
|
return unless data['messages'].present?
|
|
13
13
|
|
|
14
|
+
# An error broadcasts an _error, which is itself the batch's one
|
|
15
|
+
# response - so a failed message must suppress the trailing _ack
|
|
16
|
+
errored = false
|
|
14
17
|
data['messages'].each do |message|
|
|
15
|
-
action(component, message)
|
|
18
|
+
errored = true unless action(component, message)
|
|
16
19
|
end
|
|
17
20
|
|
|
18
|
-
broadcast_changeset
|
|
21
|
+
rendered = broadcast_changeset
|
|
22
|
+
|
|
23
|
+
# Guarantee exactly one response per message batch so the client can
|
|
24
|
+
# clear its loading state even when nothing changed
|
|
25
|
+
component.broadcast_ack unless errored || rendered.include?(component)
|
|
19
26
|
end
|
|
20
27
|
|
|
28
|
+
# @return [Boolean] true when the message was processed, false when an
|
|
29
|
+
# error was handled (and an _error broadcast in its place)
|
|
21
30
|
def action(component, data)
|
|
22
31
|
params = parse_params(data)
|
|
23
32
|
|
|
@@ -40,18 +49,25 @@ module LiveCable
|
|
|
40
49
|
method.call
|
|
41
50
|
end
|
|
42
51
|
end
|
|
52
|
+
|
|
53
|
+
true
|
|
43
54
|
rescue StandardError => e
|
|
44
55
|
handle_error(component, e)
|
|
56
|
+
false
|
|
45
57
|
end
|
|
46
58
|
|
|
59
|
+
# @return [Boolean] true when applied, false when an error was handled
|
|
47
60
|
def reactive(component, data)
|
|
48
61
|
unless component.class.writable_reactive_variables.include?(data['name'].to_sym)
|
|
49
62
|
raise LiveCable::Error, "Non-writable reactive variable: #{data['name']}"
|
|
50
63
|
end
|
|
51
64
|
|
|
52
65
|
component.public_send("#{data['name']}=", data['value'])
|
|
66
|
+
|
|
67
|
+
true
|
|
53
68
|
rescue StandardError => e
|
|
54
69
|
handle_error(component, e)
|
|
70
|
+
false
|
|
55
71
|
end
|
|
56
72
|
|
|
57
73
|
private
|
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
module LiveCable
|
|
4
4
|
module Rendering
|
|
5
5
|
class Compiler < ::Herb::Engine::Compiler
|
|
6
|
+
# Sentinel tokens carry a "\n" value so herb's whitespace helpers
|
|
7
|
+
# (at_line_start?, preceding_token_ends_with_newline?) treat them like
|
|
8
|
+
# a line boundary instead of crashing on a nil value.
|
|
6
9
|
def visit_erb_control_node(node)
|
|
7
|
-
@tokens << [:block_start]
|
|
10
|
+
@tokens << [:block_start, "\n"]
|
|
8
11
|
super
|
|
9
|
-
@tokens << [:block_end]
|
|
12
|
+
@tokens << [:block_end, "\n"]
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def visit_erb_block_node(node)
|
|
13
|
-
@tokens << [:block_start]
|
|
16
|
+
@tokens << [:block_start, "\n"]
|
|
14
17
|
super
|
|
15
|
-
@tokens << [:block_end]
|
|
18
|
+
@tokens << [:block_end, "\n"]
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def generate_output
|
|
@@ -29,11 +32,14 @@ module LiveCable
|
|
|
29
32
|
end
|
|
30
33
|
end
|
|
31
34
|
|
|
32
|
-
def generate_for_token(type, value,
|
|
35
|
+
def generate_for_token(type, value, _context = nil, _escaped = nil)
|
|
33
36
|
case type
|
|
34
37
|
when :text
|
|
35
38
|
@engine.send(:add_text, value)
|
|
36
|
-
when :code
|
|
39
|
+
when :code, :expr_block_end
|
|
40
|
+
# Escaping is delegated to Rails' output buffer, so the closing
|
|
41
|
+
# `end` of an output block (:expr_block_end) is emitted as plain
|
|
42
|
+
# code rather than herb's paren-balancing add_expression_block_end.
|
|
37
43
|
@engine.send(:add_code, value)
|
|
38
44
|
when :expr
|
|
39
45
|
indicator = @escape ? '==' : '='
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
module Testing
|
|
5
|
+
# Reconstructs a component's rendered HTML from _refresh broadcasts,
|
|
6
|
+
# mirroring what the JavaScript client does: the first refresh carries
|
|
7
|
+
# all parts, subsequent refreshes carry only the changed parts (nil
|
|
8
|
+
# means unchanged), and child components arrive as separate results
|
|
9
|
+
# referenced by <LiveCable child-live-id="..."> placeholders.
|
|
10
|
+
class RenderState
|
|
11
|
+
CHILD_PLACEHOLDER = %r{<LiveCable child-live-id="(?<live_id>[^"]+)"></LiveCable>}
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@parts_by_template = {}
|
|
15
|
+
@last_template = nil
|
|
16
|
+
@children = Hash.new { |hash, key| hash[key] = RenderState.new }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param refresh [Hash] A _refresh payload ({ h:, p:, c: })
|
|
20
|
+
def apply(refresh)
|
|
21
|
+
refresh = refresh.as_json # Normalize symbol/string keys
|
|
22
|
+
|
|
23
|
+
template = refresh['h'] || @last_template || 'default'
|
|
24
|
+
@last_template = template
|
|
25
|
+
|
|
26
|
+
parts = refresh['p'] || []
|
|
27
|
+
|
|
28
|
+
if @parts_by_template.key?(template)
|
|
29
|
+
parts.each_with_index do |part, index|
|
|
30
|
+
@parts_by_template[template][index] = part unless part.nil?
|
|
31
|
+
end
|
|
32
|
+
else
|
|
33
|
+
@parts_by_template[template] = parts.dup
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
(refresh['c'] || {}).each do |live_id, child_refresh|
|
|
37
|
+
@children[live_id].apply(child_refresh)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String] The reconstructed HTML with child placeholders resolved
|
|
42
|
+
def html
|
|
43
|
+
parts = @parts_by_template[@last_template]
|
|
44
|
+
return '' unless parts
|
|
45
|
+
|
|
46
|
+
parts.join.gsub(CHILD_PLACEHOLDER) do
|
|
47
|
+
@children[Regexp.last_match[:live_id]].html
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
module Testing
|
|
5
|
+
# Stand-in for an ActionCable connection exposing identified_by values
|
|
6
|
+
# (e.g. current_user) to components and their templates.
|
|
7
|
+
class TestCableConnection
|
|
8
|
+
# @return [Set<Symbol>]
|
|
9
|
+
attr_reader :identifiers
|
|
10
|
+
|
|
11
|
+
def initialize(identifiers = {})
|
|
12
|
+
identifiers = identifiers.symbolize_keys
|
|
13
|
+
@identifiers = identifiers.keys.to_set
|
|
14
|
+
|
|
15
|
+
identifiers.each do |name, value|
|
|
16
|
+
define_singleton_method(name) { value }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
module Testing
|
|
5
|
+
# Stand-in for an ActionCable channel. Records streams started via
|
|
6
|
+
# stream_from so tests can trigger their callbacks with receive_stream.
|
|
7
|
+
class TestChannel
|
|
8
|
+
# @return [Hash<String, Hash>] Stream name => { coder:, callback: }
|
|
9
|
+
attr_reader :streams
|
|
10
|
+
|
|
11
|
+
# @return [LiveCable::Testing::TestCableConnection]
|
|
12
|
+
attr_reader :connection
|
|
13
|
+
|
|
14
|
+
def initialize(identifiers = {})
|
|
15
|
+
@streams = {}
|
|
16
|
+
@connection = TestCableConnection.new(identifiers)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def stream_from(name, coder: nil, &block)
|
|
20
|
+
@streams[name] = { coder:, callback: block }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stop_stream_from(name)
|
|
24
|
+
@streams.delete(name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Simulate an external broadcast arriving on a stream.
|
|
28
|
+
# The payload goes through the stream's coder round trip, so a Hash
|
|
29
|
+
# payload arrives with string keys just like a production broadcast.
|
|
30
|
+
#
|
|
31
|
+
# @param name [String] The stream name passed to stream_from
|
|
32
|
+
# @param payload [Object] The broadcast payload
|
|
33
|
+
def broadcast_to(name, payload)
|
|
34
|
+
stream = @streams.fetch(name) do
|
|
35
|
+
raise LiveCable::Error, "Component is not streaming from #{name.inspect} " \
|
|
36
|
+
"(active streams: #{@streams.keys.inspect})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
callback = stream[:callback]
|
|
40
|
+
raise LiveCable::Error, "Stream #{name.inspect} has no callback" unless callback
|
|
41
|
+
|
|
42
|
+
coder = stream[:coder]
|
|
43
|
+
payload = coder.decode(coder.encode(payload)) if coder
|
|
44
|
+
|
|
45
|
+
callback.call(payload)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'delegate'
|
|
4
|
+
|
|
5
|
+
module LiveCable
|
|
6
|
+
module Testing
|
|
7
|
+
# Wraps a mounted component for testing. Delegates unknown methods to
|
|
8
|
+
# the component itself, so reactive variables and component methods can
|
|
9
|
+
# be read directly (e.g. +counter.count+).
|
|
10
|
+
class TestComponent < SimpleDelegator
|
|
11
|
+
# @return [LiveCable::Connection]
|
|
12
|
+
attr_reader :connection
|
|
13
|
+
|
|
14
|
+
# @return [LiveCable::Testing::TestChannel]
|
|
15
|
+
attr_reader :channel
|
|
16
|
+
|
|
17
|
+
def initialize(component, connection, channel)
|
|
18
|
+
super(component)
|
|
19
|
+
@connection = connection
|
|
20
|
+
@channel = channel
|
|
21
|
+
@broadcasts = []
|
|
22
|
+
|
|
23
|
+
capture_broadcasts(component)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [LiveCable::Component] The underlying component instance
|
|
27
|
+
def component
|
|
28
|
+
__getobj__
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Dispatch an action through the real message pipeline, as if it was
|
|
32
|
+
# triggered by live-action or live-form in the browser.
|
|
33
|
+
#
|
|
34
|
+
# Params go through a query-string round trip, so values arrive as
|
|
35
|
+
# ActionController::Parameters with string values - exactly like
|
|
36
|
+
# production.
|
|
37
|
+
#
|
|
38
|
+
# @param action [Symbol, String] The action name
|
|
39
|
+
# @param params [Hash] Parameters for the action
|
|
40
|
+
def perform(action, params = {})
|
|
41
|
+
receive_message(
|
|
42
|
+
'_action' => action.to_s,
|
|
43
|
+
'params' => ::Rack::Utils.build_nested_query(params)
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Update a writable reactive variable, as if the client sent a
|
|
48
|
+
# live-reactive input update. Raises (or broadcasts an _error when
|
|
49
|
+
# mounted with raise_errors: false) for non-writable variables.
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol, String] The reactive variable name
|
|
52
|
+
# @param value [Object] The new value
|
|
53
|
+
def set_reactive(name, value)
|
|
54
|
+
receive_message(
|
|
55
|
+
'_action' => '_reactive',
|
|
56
|
+
'name' => name.to_s,
|
|
57
|
+
'value' => value
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Simulate an external ActionCable broadcast arriving on a stream the
|
|
62
|
+
# component subscribed to via stream_from.
|
|
63
|
+
#
|
|
64
|
+
# @param stream_name [String] The stream name
|
|
65
|
+
# @param payload [Object] The broadcast payload
|
|
66
|
+
def receive_stream(stream_name, payload)
|
|
67
|
+
channel.broadcast_to(stream_name, payload)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Everything the component has broadcast since mounting (renders,
|
|
71
|
+
# acks, status updates, errors), oldest first.
|
|
72
|
+
#
|
|
73
|
+
# @param key [Symbol, nil] Filter to broadcasts containing this key
|
|
74
|
+
# (e.g. :_refresh, :_ack, :_error, :_status)
|
|
75
|
+
# @return [Array<Hash>]
|
|
76
|
+
def broadcasts(key = nil)
|
|
77
|
+
return @broadcasts.dup unless key
|
|
78
|
+
|
|
79
|
+
@broadcasts.select { |broadcast| broadcast.key?(key) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Forget previously captured broadcasts. Useful after mounting, to
|
|
83
|
+
# assert on the effects of a single action.
|
|
84
|
+
def clear_broadcasts
|
|
85
|
+
@broadcasts.clear
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# All events the component has dispatched via dispatch_event, in
|
|
89
|
+
# order, whether they rode along with a render or were broadcast on
|
|
90
|
+
# their own.
|
|
91
|
+
#
|
|
92
|
+
# @return [Array<Hash>] Event hashes ({ name:, detail:, window: })
|
|
93
|
+
def dispatched_events
|
|
94
|
+
broadcasts(:_events).flat_map { |broadcast| broadcast[:_events] }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The component's current HTML, reconstructed from its _refresh
|
|
98
|
+
# broadcasts the same way the JavaScript client builds the DOM.
|
|
99
|
+
#
|
|
100
|
+
# @return [String]
|
|
101
|
+
def rendered_html
|
|
102
|
+
state = RenderState.new
|
|
103
|
+
|
|
104
|
+
broadcasts(:_refresh).each do |broadcast|
|
|
105
|
+
state.apply(broadcast[:_refresh])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
state.html
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The rendered HTML wrapped in a Capybara node, for use with matchers
|
|
112
|
+
# like have_css / have_content. Requires the capybara gem.
|
|
113
|
+
#
|
|
114
|
+
# @return [Capybara::Node::Simple]
|
|
115
|
+
def rendered
|
|
116
|
+
# ::-prefixed because Delegator subclasses can't resolve top-level
|
|
117
|
+
# constants through their BasicObject ancestry
|
|
118
|
+
unless defined?(::Capybara)
|
|
119
|
+
raise ::LiveCable::Error,
|
|
120
|
+
'Capybara is required for rendered - add it to your Gemfile or use rendered_html'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
::Capybara.string(rendered_html)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Disconnect the component, running disconnect lifecycle callbacks and
|
|
127
|
+
# cleaning up its state - like a client unsubscribing.
|
|
128
|
+
def unmount
|
|
129
|
+
component.disconnect
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def receive_message(message)
|
|
135
|
+
connection.receive(component, { 'messages' => [message] })
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def capture_broadcasts(component)
|
|
139
|
+
captured = @broadcasts
|
|
140
|
+
|
|
141
|
+
component.define_singleton_method(:broadcast) do |data|
|
|
142
|
+
captured << data
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
# Test helpers for unit testing LiveCable components without a browser
|
|
5
|
+
# or a real ActionCable connection.
|
|
6
|
+
#
|
|
7
|
+
# Include the module in your specs and use +live_mount+ to mount a
|
|
8
|
+
# component. Actions and reactive updates are dispatched through the real
|
|
9
|
+
# message pipeline, so action whitelisting, parameter parsing, writability
|
|
10
|
+
# checks, change tracking, and re-rendering are all exercised exactly as
|
|
11
|
+
# they are in production.
|
|
12
|
+
#
|
|
13
|
+
# @example RSpec
|
|
14
|
+
# RSpec.describe Live::Counter do
|
|
15
|
+
# include LiveCable::Testing
|
|
16
|
+
#
|
|
17
|
+
# it 'increments by the step size' do
|
|
18
|
+
# counter = live_mount('counter', step: 2)
|
|
19
|
+
#
|
|
20
|
+
# counter.perform(:increment)
|
|
21
|
+
#
|
|
22
|
+
# expect(counter.count).to eq(2)
|
|
23
|
+
# expect(counter.rendered).to have_css('[data-testid="counter-value"]', text: '2')
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
module Testing
|
|
27
|
+
# Mount a component for testing.
|
|
28
|
+
#
|
|
29
|
+
# Mirrors what +LiveChannel#subscribed+ does in production: the component
|
|
30
|
+
# is registered on a connection, defaults are applied, lifecycle connect
|
|
31
|
+
# callbacks run, and the initial render is broadcast.
|
|
32
|
+
#
|
|
33
|
+
# @param component [String, Class, LiveCable::Component] Component name
|
|
34
|
+
# (e.g. 'counter' or 'chat/room'), component class, or instance
|
|
35
|
+
# @param id [String] The component id (defaults to 'test')
|
|
36
|
+
# @param connection [LiveCable::Connection, nil] Mount onto an existing
|
|
37
|
+
# test connection (from another mounted component) to share state
|
|
38
|
+
# between components
|
|
39
|
+
# @param identifiers [Hash] ActionCable connection identifiers made
|
|
40
|
+
# available to the component (e.g. current_user: user)
|
|
41
|
+
# @param raise_errors [Boolean] Raise errors from actions and rendering
|
|
42
|
+
# instead of broadcasting an _error like production does (default true)
|
|
43
|
+
# @param defaults [Hash] Default values for reactive variables
|
|
44
|
+
# @return [LiveCable::Testing::TestComponent]
|
|
45
|
+
def live_mount(component, id: 'test', connection: nil, identifiers: {}, raise_errors: true, **defaults)
|
|
46
|
+
connection ||= build_test_connection(raise_errors:)
|
|
47
|
+
|
|
48
|
+
instance =
|
|
49
|
+
case component
|
|
50
|
+
when LiveCable::Component then component
|
|
51
|
+
when Class then component.new(id)
|
|
52
|
+
else LiveCable.instance_from_string(component.to_s, id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
test_component = TestComponent.new(instance, connection, TestChannel.new(identifiers))
|
|
56
|
+
|
|
57
|
+
connection.add_component(instance)
|
|
58
|
+
instance.defaults = defaults
|
|
59
|
+
instance.apply_defaults
|
|
60
|
+
instance.connect(test_component.channel)
|
|
61
|
+
instance.broadcast_render
|
|
62
|
+
|
|
63
|
+
test_component
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_test_connection(raise_errors:)
|
|
69
|
+
require 'action_dispatch/testing/test_request'
|
|
70
|
+
|
|
71
|
+
# An empty session skips the CSRF check, like a session-less request
|
|
72
|
+
request = ActionDispatch::TestRequest.create('rack.session' => {})
|
|
73
|
+
connection = LiveCable::Connection.new(request)
|
|
74
|
+
|
|
75
|
+
if raise_errors
|
|
76
|
+
def connection.handle_error(_component, error)
|
|
77
|
+
raise error
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
connection
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/live_cable/version.rb
CHANGED
data/lib/live_cable.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'herb'
|
|
|
5
5
|
|
|
6
6
|
loader = Zeitwerk::Loader.for_gem
|
|
7
7
|
loader.ignore("#{__dir__}/generators")
|
|
8
|
+
loader.ignore("#{__dir__}/live.rb")
|
|
8
9
|
loader.setup
|
|
9
10
|
|
|
10
11
|
require_relative 'live_cable/configuration'
|
|
@@ -12,6 +13,11 @@ require_relative 'live_cable/configuration'
|
|
|
12
13
|
# Require helpers explicitly (Zeitwerk doesn't autoload app/ directory)
|
|
13
14
|
require_relative '../app/helpers/live_cable_helper'
|
|
14
15
|
|
|
16
|
+
# Namespace for user components (e.g. Live::Chat); lives outside the gem's
|
|
17
|
+
# own LiveCable namespace, so it's ignored by the gem loader above and
|
|
18
|
+
# required explicitly instead.
|
|
19
|
+
require_relative 'live'
|
|
20
|
+
|
|
15
21
|
module LiveCable
|
|
16
22
|
def self.instance_from_string(string, id)
|
|
17
23
|
klass = Live
|
|
@@ -40,8 +46,4 @@ module LiveCable
|
|
|
40
46
|
end
|
|
41
47
|
end
|
|
42
48
|
|
|
43
|
-
module Live
|
|
44
|
-
# For components to live in
|
|
45
|
-
end
|
|
46
|
-
|
|
47
49
|
require 'live_cable/engine' if defined?(Rails::Engine)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: live_cable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Craig Blanchette
|
|
@@ -15,70 +15,84 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '7.
|
|
18
|
+
version: '7.1'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '7.
|
|
25
|
+
version: '7.1'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: actionview
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '7.
|
|
32
|
+
version: '7.1'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '7.
|
|
39
|
+
version: '7.1'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: activemodel
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '7.
|
|
46
|
+
version: '7.1'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '7.
|
|
53
|
+
version: '7.1'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: activesupport
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '7.
|
|
60
|
+
version: '7.1'
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '7.
|
|
67
|
+
version: '7.1'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: herb
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 0.
|
|
74
|
+
version: 0.10.2
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 0.
|
|
81
|
+
version: 0.10.2
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: prism
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '1.0'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '1.0'
|
|
82
96
|
- !ruby/object:Gem::Dependency
|
|
83
97
|
name: zeitwerk
|
|
84
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -101,12 +115,14 @@ executables: []
|
|
|
101
115
|
extensions: []
|
|
102
116
|
extra_rdoc_files: []
|
|
103
117
|
files:
|
|
118
|
+
- CHANGELOG.md
|
|
104
119
|
- LICENSE
|
|
105
120
|
- README.md
|
|
106
121
|
- app/assets/javascript/controllers/live_controller.js
|
|
107
122
|
- app/assets/javascript/dom.js
|
|
108
123
|
- app/assets/javascript/live_cable.js
|
|
109
124
|
- app/assets/javascript/live_cable_blessing.js
|
|
125
|
+
- app/assets/javascript/loading.js
|
|
110
126
|
- app/assets/javascript/observer.js
|
|
111
127
|
- app/assets/javascript/subscriptions.js
|
|
112
128
|
- app/channels/live_channel.rb
|
|
@@ -115,9 +131,11 @@ files:
|
|
|
115
131
|
- lib/generators/live_cable/component/component_generator.rb
|
|
116
132
|
- lib/generators/live_cable/component/templates/component.rb.tt
|
|
117
133
|
- lib/generators/live_cable/component/templates/view.html.live.erb.tt
|
|
134
|
+
- lib/live.rb
|
|
118
135
|
- lib/live_cable.rb
|
|
119
136
|
- lib/live_cable/component.rb
|
|
120
137
|
- lib/live_cable/component/broadcasting.rb
|
|
138
|
+
- lib/live_cable/component/events.rb
|
|
121
139
|
- lib/live_cable/component/identification.rb
|
|
122
140
|
- lib/live_cable/component/lifecycle.rb
|
|
123
141
|
- lib/live_cable/component/method_dependency_tracking.rb
|
|
@@ -155,14 +173,23 @@ files:
|
|
|
155
173
|
- lib/live_cable/rendering/partial_renderer.rb
|
|
156
174
|
- lib/live_cable/rendering/render_result.rb
|
|
157
175
|
- lib/live_cable/rendering/renderer.rb
|
|
176
|
+
- lib/live_cable/testing.rb
|
|
177
|
+
- lib/live_cable/testing/render_state.rb
|
|
178
|
+
- lib/live_cable/testing/test_cable_connection.rb
|
|
179
|
+
- lib/live_cable/testing/test_channel.rb
|
|
180
|
+
- lib/live_cable/testing/test_component.rb
|
|
158
181
|
- lib/live_cable/version.rb
|
|
159
|
-
homepage: https://
|
|
182
|
+
homepage: https://livecable.io
|
|
160
183
|
licenses:
|
|
161
184
|
- MIT
|
|
162
185
|
metadata:
|
|
163
186
|
allowed_push_host: https://rubygems.org
|
|
164
187
|
rubygems_mfa_required: 'true'
|
|
165
188
|
source_code_uri: https://github.com/isometriks/live_cable
|
|
189
|
+
homepage_uri: https://livecable.io
|
|
190
|
+
documentation_uri: https://livecable.io
|
|
191
|
+
bug_tracker_uri: https://github.com/isometriks/live_cable/issues
|
|
192
|
+
changelog_uri: https://github.com/isometriks/live_cable/blob/main/CHANGELOG.md
|
|
166
193
|
rdoc_options: []
|
|
167
194
|
require_paths:
|
|
168
195
|
- lib
|