diff --git a/assets/js/phoenix_live_view/constants.js b/assets/js/phoenix_live_view/constants.js index e0feb4575..56c999e7b 100644 --- a/assets/js/phoenix_live_view/constants.js +++ b/assets/js/phoenix_live_view/constants.js @@ -49,6 +49,7 @@ export const PHX_DISABLED = "data-phx-disabled" export const PHX_DISABLE_WITH = "disable-with" export const PHX_DISABLE_WITH_RESTORE = "data-phx-disable-with-restore" export const PHX_HOOK = "hook" +export const PHX_CUSTOM_EVENTS = "custom-events" export const PHX_DEBOUNCE = "debounce" export const PHX_THROTTLE = "throttle" export const PHX_UPDATE = "update" diff --git a/assets/js/phoenix_live_view/dom.js b/assets/js/phoenix_live_view/dom.js index 7cb4255e3..b5bd0abf6 100644 --- a/assets/js/phoenix_live_view/dom.js +++ b/assets/js/phoenix_live_view/dom.js @@ -315,7 +315,7 @@ let DOM = { // maintains or adds privately used hook information // fromEl and toEl can be the same element in the case of a newly added node // fromEl and toEl can be any HTML node type, so we need to check if it's an element node - maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom){ + maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom, phxCustomEvents){ // maintain the hooks created with createHook if(fromEl.hasAttribute && fromEl.hasAttribute("data-phx-hook") && !toEl.hasAttribute("data-phx-hook")){ toEl.setAttribute("data-phx-hook", fromEl.getAttribute("data-phx-hook")) @@ -324,6 +324,9 @@ let DOM = { if(toEl.hasAttribute && (toEl.hasAttribute(phxViewportTop) || toEl.hasAttribute(phxViewportBottom))){ toEl.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll") } + if(toEl.hasAttribute && toEl.hasAttribute(phxCustomEvents)){ + toEl.setAttribute("data-phx-hook", "Phoenix.CustomEvents") + } }, putCustomElHook(el, hook){ diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index 664e9c836..d65d53e1b 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -14,6 +14,7 @@ import { PHX_STREAM_REF, PHX_VIEWPORT_TOP, PHX_VIEWPORT_BOTTOM, + PHX_CUSTOM_EVENTS, } from "./constants" import { @@ -109,6 +110,7 @@ export default class DOMPatch { let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP) let phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM) let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION) + let phxCustomEvents = liveSocket.binding(PHX_CUSTOM_EVENTS) let added = [] let updates = [] let appendPrependUpdates = [] @@ -155,7 +157,7 @@ export default class DOMPatch { } }, onBeforeNodeAdded: (el) => { - DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom) + DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom, phxCustomEvents) this.trackBefore("added", el) let morphedEl = el @@ -215,7 +217,7 @@ export default class DOMPatch { return morphCallbacks.onNodeAdded(toEl) } DOM.syncPendingAttrs(fromEl, toEl) - DOM.maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom) + DOM.maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom, phxCustomEvents) DOM.cleanChildNodes(toEl, phxUpdate) if(this.skipCIDSibling(toEl)){ // if this is a live component used in a stream, we may need to reorder it diff --git a/assets/js/phoenix_live_view/hooks.js b/assets/js/phoenix_live_view/hooks.js index bda38cd62..3b1fbebeb 100644 --- a/assets/js/phoenix_live_view/hooks.js +++ b/assets/js/phoenix_live_view/hooks.js @@ -186,7 +186,7 @@ Hooks.InfiniteScroll = { window.addEventListener("scroll", this.onScroll) } }, - + destroyed(){ if(this.scrollContainer){ this.scrollContainer.removeEventListener("scroll", this.onScroll) @@ -220,4 +220,55 @@ Hooks.InfiniteScroll = { } } } + +const serializeEvent = (event) => { + const {detail, target: {dataset}} = event + return {...detail, ...dataset} +} + +Hooks.CustomEvents = { + + listeners: [], + + pushCustomEvent(eventName, phxEvent){ + const attrs = this.el.attributes + const phxTarget = attrs["phx-target"] && attrs["phx-target"].value + const pushEvent = phxTarget + ? (event, payload, callback) => + this.pushEventTo(phxTarget, event, payload, callback) + : (event, payload, callback) => this.pushEvent(event, payload, callback) + const listener = (evt) => { + const payload = serializeEvent(evt) + this.el.dispatchEvent(new CustomEvent("phx-event-start", {detail: {name: eventName, payload}})) + pushEvent(phxEvent, payload, _e => { + this.el.dispatchEvent(new CustomEvent("phx-event-complete", {detail: {name: eventName, payload}})) + }) + } + this.el.addEventListener(eventName, listener) + this.listeners.push({eventName, listener}) + }, + + mounted(){ + const attrs = this.el.attributes + for(var i = 0; i < attrs.length; i++){ + if(/^phx-custom-event-/.test(attrs[i].name)){ + const eventName = attrs[i].name.replace("phx-custom-event-", "") + const phxEvent = attrs[i].value + this.pushCustomEvent(eventName, phxEvent) + } + } + + if(this.el.getAttribute("phx-custom-events")){ + const eventsToSend = this.el.getAttribute("phx-custom-events").split(",") + eventsToSend.forEach((eventName) => this.pushCustomEvent(eventName, eventName)) + } + }, + + destroyed(){ + this.listeners.forEach(({eventName, listener}) => { + this.el.removeEventListener(eventName, listener) + }) + } +} + export default Hooks diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index 5514471ab..f740dafd3 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -9,6 +9,7 @@ import { PHX_DISABLE_WITH_RESTORE, PHX_DISABLED, PHX_LOADING_CLASS, + PHX_CUSTOM_EVENTS, PHX_ERROR_CLASS, PHX_CLIENT_ERROR_CLASS, PHX_SERVER_ERROR_CLASS, @@ -394,9 +395,11 @@ export default class View { execNewMounted(parent = this.el){ let phxViewportTop = this.binding(PHX_VIEWPORT_TOP) let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) + let phxCustomEvents = this.binding(PHX_CUSTOM_EVENTS) + DOM.all(parent, `[${phxViewportTop}], [${phxViewportBottom}]`, hookEl => { if(this.ownsElement(hookEl)){ - DOM.maintainPrivateHooks(hookEl, hookEl, phxViewportTop, phxViewportBottom) + DOM.maintainPrivateHooks(hookEl, hookEl, phxViewportTop, phxViewportBottom, phxCustomEvents) this.maybeAddNewHook(hookEl) } }) @@ -468,7 +471,8 @@ export default class View { this.liveSocket.triggerDOM("onNodeAdded", [el]) let phxViewportTop = this.binding(PHX_VIEWPORT_TOP) let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) - DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom) + let phxCustomEvents = this.binding(PHX_CUSTOM_EVENTS) + DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom, phxCustomEvents) this.maybeAddNewHook(el) if(el.getAttribute){ this.maybeMounted(el) } }) diff --git a/assets/test/view_test.js b/assets/test/view_test.js index eb912de51..dd7df3d7f 100644 --- a/assets/test/view_test.js +++ b/assets/test/view_test.js @@ -763,6 +763,44 @@ describe("View", function(){ }) }) +describe("Custom Event Hook", function(){ + beforeEach(() => { + global.document.body.innerHTML = liveViewDOM().outerHTML + }) + + afterAll(() => { + global.document.body.innerHTML = "" + }) + + test("custom event hook gets added", async () => { + let liveSocket = new LiveSocket("/live", Socket, {}) + let el = liveViewDOM() + + let view = simulateJoinedView(el, liveSocket) + let channelStub = { + push(_evt, payload, _timeout){ + expect(payload.event).toEqual("my_event") + expect(payload.value).toEqual({"value": "2"}) + return { + receive(){ return this } + } + } + } + + view.channel = channelStub + + view.onJoin({ + rendered: { + s: ["
"], + fingerprint: 123 + }, + liveview_version + }) + + expect(view.el.outerHTML).toContain("Phoenix.CustomEvent") + }) + +}) describe("View Hooks", function(){ beforeEach(() => { global.document.body.innerHTML = liveViewDOM().outerHTML diff --git a/test/e2e/support/custom_events_live.ex b/test/e2e/support/custom_events_live.ex new file mode 100644 index 000000000..8ada84ee4 --- /dev/null +++ b/test/e2e/support/custom_events_live.ex @@ -0,0 +1,48 @@ +defmodule Phoenix.LiveViewTest.E2E.CustomEventsLive do + use Phoenix.LiveView, layout: {__MODULE__, :live} + + @impl Phoenix.LiveView + def render("live.html", assigns) do + ~H""" + + + + {@inner_content} + """ + end + + def mount(_params, _session, socket) do + {:ok, socket |> assign(:foo, nil)} + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" + +
{@foo}
+ """ + end + + def handle_event("my_event", %{"foo" => foo}, socket) do + {:noreply, socket |> assign(:foo, foo)} + end +end diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index 1e4449f7b..7df7ab7b6 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -170,6 +170,7 @@ defmodule Phoenix.LiveViewTest.E2E.Router do pipe_through(:browser) live "/form/feedback", FormFeedbackLive + live "/custom-events", CustomEventsLive live "/errors", ErrorLive scope "/issues" do diff --git a/test/e2e/tests/custom_events.spec.js b/test/e2e/tests/custom_events.spec.js new file mode 100644 index 000000000..b45ff347e --- /dev/null +++ b/test/e2e/tests/custom_events.spec.js @@ -0,0 +1,9 @@ +const {test, expect} = require("../test-fixtures") +const {syncLV} = require("../utils") + +test("sending custom events", async ({page}) => { + await page.goto("/custom-events") + await syncLV(page) + await page.locator("my-button").click() + await expect(page.locator("#foo")).toHaveText("bar") +})