diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index 33e63a1b8c..77579e1092 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -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){ diff --git a/assets/js/phoenix_live_view/view_hook.js b/assets/js/phoenix_live_view/view_hook.js index e145ec950d..2e53f8699d 100644 --- a/assets/js/phoenix_live_view/view_hook.js +++ b/assets/js/phoenix_live_view/view_hook.js @@ -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){ diff --git a/assets/test/event_test.js b/assets/test/event_test.js index 0f4c7fa3b1..bf0ae46395 100644 --- a/assets/test/event_test.js +++ b/assets/test/event_test.js @@ -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(() => { @@ -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}})) }) @@ -176,7 +191,6 @@ describe("pushEvent replies", () => { }, []) view.el.addEventListener("replied", () => { - expect(pushedRef).toEqual(0) expect(processedReplies).toEqual([{resp: {transactionID: "1001"}, ref: 0}]) done() }) @@ -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: [` +
+
+ `] + }, []) + }) + + 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)} + ]) + done() + }) + } + } + } + }) + view = simulateView(liveSocket, [], "") + liveSocket.main = view + view.update({ + s: [` +
+
+
+
+
+
+ `] + }, []) + }) + + 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")) + }) } } } @@ -233,6 +306,9 @@ describe("pushEvent replies", () => { `] }, []) - expect(pushedRef).toEqual(false) + view.el.addEventListener("pushed", () => { + expect(spy).not.toHaveBeenCalled() + done() + }) }) }) diff --git a/guides/client/js-interop.md b/guides/client/js-interop.md index 9e2bed868c..b1e9adb2ef 100644 --- a/guides/client/js-interop.md +++ b/guides/client/js-interop.md @@ -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` * `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`)