Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions assets/js/phoenix_live_view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -1122,20 +1122,19 @@ export default class View {
}
}

pushHookEvent(el, targetCtx, event, payload, onReply){
pushHookEvent(el, targetCtx, event, payload){
if(!this.isConnected()){
this.log("hook", () => ["unable to push hook event. LiveView not connected", event, payload])
return false
return Promise.reject(new Error("unable to push hook event. LiveView not connected"))
}
let [ref, els, opts] = this.putRef([{el, loading: true, lock: true}], event, "hook")
this.pushWithReply(() => [ref, els, opts], "event", {

return this.pushWithReply(() => [ref, els, opts], "event", {
type: "hook",
event: event,
value: payload,
cid: this.closestComponentID(targetCtx)
}).then(({resp: _resp, reply: hookReply}) => onReply(hookReply, ref))

return ref
}).then(({resp: _resp, reply}) => ({reply, ref}))
}

extractMeta(el, meta, value){
Expand Down
44 changes: 19 additions & 25 deletions assets/js/phoenix_live_view/view_hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,39 +253,33 @@ export default class ViewHook {
}

pushEvent(event, payload = {}, onReply){
const promise = this.__view().pushHookEvent(this.el, null, event, payload)
if(onReply === undefined){
return new Promise((resolve, reject) => {
try {
const ref = this.__view().pushHookEvent(this.el, null, event, payload, (reply, _ref) => resolve(reply))
if(ref === false){
reject(new Error("unable to push hook event. LiveView not connected"))
}
} catch (error){
reject(error)
}
})
}
return this.__view().pushHookEvent(this.el, null, event, payload, onReply)
return promise.then(({reply}) => reply)
}
promise.then(({reply, ref}) => onReply(reply, ref)).catch(() => {})
// return nothing (for now)
return
}

pushEventTo(phxTarget, event, payload = {}, onReply){
if(onReply === undefined){
return new Promise((resolve, reject) => {
try {
this.__view().withinTargets(phxTarget, (view, targetCtx) => {
const ref = view.pushHookEvent(this.el, targetCtx, event, payload, (reply, _ref) => resolve(reply))
if(ref === false){
reject(new Error("unable to push hook event. LiveView not connected"))
}
})
} catch (error){
reject(error)
}
const targetPair = []
this.__view().withinTargets(phxTarget, (view, targetCtx) => {
targetPair.push({view, targetCtx})
})
const promises = targetPair.map(({view, targetCtx}) => {
return view.pushHookEvent(this.el, targetCtx, event, payload)
})
return Promise.allSettled(promises)
}
return this.__view().withinTargets(phxTarget, (view, targetCtx) => {
return view.pushHookEvent(this.el, targetCtx, event, payload, onReply)
this.__view().withinTargets(phxTarget, (view, targetCtx) => {
return view.pushHookEvent(this.el, targetCtx, event, payload)
.then(({reply, ref}) => onReply(reply, ref))
.catch(() => {})
})
// return nothing (for now)
return
}

handleEvent(event, callback){
Expand Down
90 changes: 83 additions & 7 deletions assets/test/event_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ let stubNextChannelReply = (view, replyPayload) => {
}
}

let stubNextChannelReplyWithError = (view, reason) => {
let oldPush = view.channel.push
view.channel.push = () => {
return {
receives: [],
receive(kind, cb){
if(kind === "error"){
cb(reason)
view.channel.push = oldPush
}
return this
}
}
}
}

describe("events", () => {
let processedEvents
beforeEach(() => {
Expand Down Expand Up @@ -153,13 +169,12 @@ describe("pushEvent replies", () => {

test("reply", (done) => {
let view
let pushedRef = null
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {
Gateway: {
mounted(){
stubNextChannelReply(view, {transactionID: "1001"})
pushedRef = this.pushEvent("charge", {amount: 123}, (resp, ref) => {
this.pushEvent("charge", {amount: 123}, (resp, ref) => {
processedReplies.push({resp, ref})
view.el.dispatchEvent(new CustomEvent("replied", {detail: {resp, ref}}))
})
Expand All @@ -176,7 +191,6 @@ describe("pushEvent replies", () => {
}, [])

view.el.addEventListener("replied", () => {
expect(pushedRef).toEqual(0)
expect(processedReplies).toEqual([{resp: {transactionID: "1001"}, ref: 0}])
done()
})
Expand Down Expand Up @@ -211,15 +225,74 @@ describe("pushEvent replies", () => {
})
})

test("pushEvent without connection noops", () => {
test("rejects with error", (done) => {
let view
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {
Gateway: {
mounted(){
stubNextChannelReplyWithError(view, "error")
this.pushEvent("charge", {amount: 123}).catch((error) => {
expect(error).toEqual(expect.any(Error))
done()
})
}
}
}
})
view = simulateView(liveSocket, [], "")
view.update({
s: [`
<div id="gateway" phx-hook="Gateway">
</div>
`]
}, [])
})

test("pushEventTo - promise with multiple targets", (done) => {
let view
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {
Gateway: {
mounted(){
stubNextChannelReply(view, {transactionID: "1001"})
this.pushEventTo("[data-foo]", "charge", {amount: 123}).then((result) => {
expect(result).toEqual([
{status: "fulfilled", value: {ref: 0, reply: {transactionID: "1001"}}},
// we only stubbed one reply
{status: "rejected", reason: expect.any(Error)}
Comment on lines +261 to +263
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One difference between pushEvent and pushEventTo is that for pushEvent, we only resolve the reply, but here the fulfilled value is { ref, reply }. We could map over the values and remove the ref if we want.

])
done()
})
}
}
}
})
view = simulateView(liveSocket, [], "")
liveSocket.main = view
view.update({
s: [`
<div id="${view.id}" data-phx-session="abc123" data-phx-root-id="${view.id}">
<div id="gateway" phx-hook="Gateway">
<div data-foo="bar"></div>
<div data-foo="baz"></div>
</div>
</div>
`]
}, [])
})

test("pushEvent without connection noops", (done) => {
let view
let pushedRef = "before"
const spy = jest.fn()
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {
Gateway: {
mounted(){
stubNextChannelReply(view, {transactionID: "1001"})
pushedRef = this.pushEvent("charge", {amount: 123}, () => {})
this.pushEvent("charge", {amount: 1233433}).then(spy).catch(() => {
view.el.dispatchEvent(new CustomEvent("pushed"))
})
}
}
}
Expand All @@ -233,6 +306,9 @@ describe("pushEvent replies", () => {
`]
}, [])

expect(pushedRef).toEqual(false)
view.el.addEventListener("pushed", () => {
expect(spy).not.toHaveBeenCalled()
done()
})
})
})
8 changes: 6 additions & 2 deletions guides/client/js-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,18 @@ The above life-cycle callbacks have in-scope access to the following attributes:

* `el` - attribute referencing the bound DOM node
* `liveSocket` - the reference to the underlying `LiveSocket` instance
* `pushEvent(event, payload, (reply, ref) => ...)` - method to push an event from the client to the LiveView server
* `pushEvent(event, payload, (reply, ref) => ...)` - method to push an event from the client to the LiveView server.
If no callback function is passed, a promise that resolves to the `reply` is returned.
* `pushEventTo(selectorOrTarget, event, payload, (reply, ref) => ...)` - method to push targeted events from the client
to LiveViews and LiveComponents. It sends the event to the LiveComponent or LiveView the `selectorOrTarget` is
defined in, where its value can be either a query selector or an actual DOM element. If the query selector returns
more than one element it will send the event to all of them, even if all the elements are in the same LiveComponent
or LiveView. `pushEventTo` supports passing the node element e.g. `this.el` instead of selector e.g. `"#" + this.el.id`
as the first parameter for target.
* `handleEvent(event, (payload) => ...)` - method to handle an event pushed from the server
As there can be multiple targets, if no callback is passed, a promise is returned that matches the return value of
[`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled#return_value). Individual fulfilled values are of the format `{ reply, ref }`.
* `handleEvent(event, (payload) => ...)` - method to handle an event pushed from the server. Returns a value that can be passed to `removeHandleEvent` to remove the event handler.
* `removeHandleEvent(ref)` - method to remove an event handler added via `handleEvent`
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one was not documented, but I think it should be

* `upload(name, files)` - method to inject a list of file-like objects into an uploader.
* `uploadTo(selectorOrTarget, name, files)` - method to inject a list of file-like objects into an uploader.
The hook will send the files to the uploader with `name` defined by [`allow_upload/3`](`Phoenix.LiveView.allow_upload/3`)
Expand Down
Loading