Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add phx-custom-events to allow custom events to be handled by LiveView #3438

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions assets/js/phoenix_live_view/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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"
Expand Down
5 changes: 4 additions & 1 deletion assets/js/phoenix_live_view/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,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"))
Expand All @@ -322,6 +322,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){
Expand Down
6 changes: 4 additions & 2 deletions assets/js/phoenix_live_view/dom_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
PHX_STREAM_REF,
PHX_VIEWPORT_TOP,
PHX_VIEWPORT_BOTTOM,
PHX_CUSTOM_EVENTS,
} from "./constants"

import {
Expand Down Expand Up @@ -96,6 +97,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 = []
Expand Down Expand Up @@ -142,7 +144,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
Expand Down Expand Up @@ -195,7 +197,7 @@ export default class DOMPatch {
},
onBeforeElUpdated: (fromEl, 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
Expand Down
51 changes: 51 additions & 0 deletions assets/js/phoenix_live_view/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions assets/js/phoenix_live_view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PHX_DISABLED,
PHX_LOADING_CLASS,
PHX_EVENT_CLASSES,
PHX_CUSTOM_EVENTS,
PHX_ERROR_CLASS,
PHX_CLIENT_ERROR_CLASS,
PHX_SERVER_ERROR_CLASS,
Expand Down Expand Up @@ -390,9 +391,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)
}
})
Expand Down Expand Up @@ -464,7 +467,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) }
})
Expand Down
38 changes: 38 additions & 0 deletions assets/test/view_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,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: ["<div id=\"one\" phx-custom-events=\"my_event\"></div>"],
fingerprint: 123
},
liveview_version: require("../package.json").version
})

expect(view.el.outerHTML).toContain("Phoenix.CustomEvent")
});

});
describe("View Hooks", function(){
beforeEach(() => {
global.document.body.innerHTML = liveViewDOM().outerHTML
Expand Down
48 changes: 48 additions & 0 deletions test/e2e/support/custom_events_live.ex
Original file line number Diff line number Diff line change
@@ -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"""
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<script src="/assets/phoenix/phoenix.min.js">
</script>
<script type="module">
class MyButton extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<button>Do it!</button>`;
this.shadowRoot.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('my_event', {detail: {foo: 'bar'}}));
});
}
}
window.customElements.define('my-button', MyButton);

import {LiveSocket} from "/assets/phoenix_live_view/phoenix_live_view.esm.js"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", window.Phoenix.Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
window.liveSocket = liveSocket
</script>
<%= @inner_content %>
"""
end

def mount(_params, _session, socket) do
{:ok, socket |> assign(:foo, nil)}
end

@impl Phoenix.LiveView
def render(assigns) do
~H"""
<my-button id="mybutton" phx-custom-events="my_event"></my-button>
<div id="foo"><%= @foo %></div>
"""
end

def handle_event("my_event", %{"foo" => foo}, socket) do
{:noreply, socket |> assign(:foo, foo)}
end
end
1 change: 1 addition & 0 deletions test/e2e/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ defmodule Phoenix.LiveViewTest.E2E.Router do
pipe_through(:browser)

live "/form/feedback", FormFeedbackLive
live "/custom-events", CustomEventsLive

scope "/issues" do
live "/2965", Issue2965Live
Expand Down
9 changes: 9 additions & 0 deletions test/e2e/tests/custom_events.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading