From 8b2228e2d45416f3964296f5495ba37a1eb279a5 Mon Sep 17 00:00:00 2001 From: omarluq Date: Wed, 14 Feb 2024 22:23:39 -0600 Subject: [PATCH 1/4] Add Turbo stream morph action --- src/core/streams/stream_actions.js | 37 ++++++++++++++++++++++++++ src/tests/unit/stream_element_tests.js | 13 +++++++++ 2 files changed, 50 insertions(+) diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index 064e94ca4..a343e4333 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,4 +1,7 @@ import { session } from "../" +import { morph } from "idiomorph" +import { dispatch } from "../../util" + export const StreamActions = { after() { @@ -37,4 +40,38 @@ export const StreamActions = { refresh() { session.refresh(this.baseURI, this.requestId) } + + morph() { + this.targetElements.forEach((targetElement) => { + try { + const morphStyle = this.getAttribute("data-turbo-morph-style") || "outerHTML" + const ignoreActive = this.getAttribute("data-turbo-morph-ignore-active") || true + const ignoreActiveValue = this.getAttribute("data-turbo-morph-ignore-active-value") || true + const head = this.getAttribute("data-turbo-morph-head") || 'merge' + morph(targetElement, this.templateContent, { + morphStyle: morphStyle, + ignoreActive: ignoreActive, + ignoreActiveValue: ignoreActiveValue, + head: head, + callbacks: { + beforeNodeAdded: (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) + }, + afterNodeMorphed: (oldNode, newNode) => { + if (newNode instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target: oldNode, + detail: { + newElement: newNode + } + }) + } + } + } + }) + } catch (error) { + console.error(error) + } + }) + } } diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index 21a9ca8aa..c084c8e9f 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -196,3 +196,16 @@ test("test action=refresh discarded when matching request id", async () => { assert.ok(document.body.hasAttribute("data-modified")) }) + +test("action=morph", async () => { + const templateElement = createTemplateElement(`
Hello Turbo Morphed
`) + const element = createStreamElement("morph", "hello", templateElement) + + assert.equal(subject.find("#hello")?.textContent, "Hello Turbo") + + subject.append(element) + await nextAnimationFrame() + + assert.notOk(subject.find("#hello")?.textContent, "Hello Turbo") + assert.equal(subject.find("#hello")?.textContent, "Hello Turbo Morphed") +}) From 268dfbcef5c8046007771d93d27418ad4fa72fc8 Mon Sep 17 00:00:00 2001 From: omarluq Date: Thu, 15 Feb 2024 11:15:41 -0600 Subject: [PATCH 2/4] limit morph action data atrributes to morphStyle only and add morph lifecycle events --- src/core/streams/stream_actions.js | 84 ++++++++++++++++++-------- src/tests/unit/stream_element_tests.js | 27 +++++++-- 2 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index a343e4333..8dcac5803 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,8 +1,7 @@ import { session } from "../" -import { morph } from "idiomorph" +import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" import { dispatch } from "../../util" - export const StreamActions = { after() { this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) @@ -39,34 +38,28 @@ export const StreamActions = { refresh() { session.refresh(this.baseURI, this.requestId) - } + }, morph() { this.targetElements.forEach((targetElement) => { try { - const morphStyle = this.getAttribute("data-turbo-morph-style") || "outerHTML" - const ignoreActive = this.getAttribute("data-turbo-morph-ignore-active") || true - const ignoreActiveValue = this.getAttribute("data-turbo-morph-ignore-active-value") || true - const head = this.getAttribute("data-turbo-morph-head") || 'merge' - morph(targetElement, this.templateContent, { + const morphStyle = targetElement.getAttribute("data-turbo-morph-style") || "outerHTML" + Idiomorph.morph(targetElement, this.templateContent, { morphStyle: morphStyle, - ignoreActive: ignoreActive, - ignoreActiveValue: ignoreActiveValue, - head: head, + ignoreActiveValue: true, callbacks: { - beforeNodeAdded: (node) => { - return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) - }, - afterNodeMorphed: (oldNode, newNode) => { - if (newNode instanceof HTMLElement) { - dispatch("turbo:morph-element", { - target: oldNode, - detail: { - newElement: newNode - } - }) - } - } + beforeNodeAdded, + beforeNodeMorphed, + beforeAttributeUpdated, + beforeNodeRemoved, + afterNodeMorphed + } + }) + + dispatch("turbo:morph", { + detail: { + currentElement: targetElement, + newElement: this.templateContent } }) } catch (error) { @@ -75,3 +68,46 @@ export const StreamActions = { }) } } + +const beforeNodeAdded = (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) +} + +const beforeNodeMorphed = (target, newElement) => { + if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + detail: { + target, + newElement + } + }) + return !event.defaultPrevented + } + return false +} + +const beforeAttributeUpdated = (attributeName, target, mutationType) => { + const event = dispatch("turbo:before-morph-attribute", { + cancelable: true, + target, + detail: { + attributeName, + mutationType + } + }) + return !event.defaultPrevented +} + +const beforeNodeRemoved = beforeNodeMorphed + +const afterNodeMorphed = (target, newElement) => { + if (newElement instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target, + detail: { + newElement + } + }) + } +} diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index c084c8e9f..f717c02c6 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -198,14 +198,31 @@ test("test action=refresh discarded when matching request id", async () => { }) test("action=morph", async () => { - const templateElement = createTemplateElement(`
Hello Turbo Morphed
`) + const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) const element = createStreamElement("morph", "hello", templateElement) - - assert.equal(subject.find("#hello")?.textContent, "Hello Turbo") + + assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo") subject.append(element) await nextAnimationFrame() - assert.notOk(subject.find("#hello")?.textContent, "Hello Turbo") - assert.equal(subject.find("#hello")?.textContent, "Hello Turbo Morphed") + assert.notOk(subject.find("div#hello")) + assert.equal(subject.find("h1#hello")?.textContent, "Hello Turbo Morphed") }) + +test("action=morph with data-turbo-morph-style='innerHTML'", async () => { + const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) + const element = createStreamElement("morph", "hello", templateElement) + const target = subject.find("div#hello") + assert.equal(target?.textContent, "Hello Turbo") + target.setAttribute("data-turbo-morph-style", "innerHTML") + + subject.append(element) + + await nextAnimationFrame() + + assert.ok(subject.find("div#hello")) + assert.ok(subject.find("div#hello > h1#hello-child-element")) + assert.equal(subject.find("div#hello > h1#hello-child-element").textContent, "Hello Turbo Morphed") +}) + From 276ee38e7cf2c0d3edc6a090bb3d919dc03cbebe Mon Sep 17 00:00:00 2001 From: omarluq Date: Tue, 20 Feb 2024 07:48:15 -0600 Subject: [PATCH 3/4] extract morph action and add children-only option to stream-element --- src/core/streams/actions/morph.js | 68 +++++++++++++++++++++++ src/core/streams/stream_actions.js | 74 ++------------------------ src/tests/unit/stream_element_tests.js | 5 +- 3 files changed, 73 insertions(+), 74 deletions(-) create mode 100644 src/core/streams/actions/morph.js diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js new file mode 100644 index 000000000..ed92291e7 --- /dev/null +++ b/src/core/streams/actions/morph.js @@ -0,0 +1,68 @@ +import { Idiomorph } from "idiomorph/dist/idiomorph.esm" +import { dispatch } from "../../../util" + +export default function morph(streamElement) { + const morphStyle = streamElement.hasAttribute("children-only") ? "innerHTML" : "outerHTML" + streamElement.targetElements.forEach((element) => { + try { + Idiomorph.morph(element, streamElement.templateContent, { + morphStyle: morphStyle, + ignoreActiveValue: true, + callbacks: { + beforeNodeAdded, + beforeNodeMorphed, + beforeAttributeUpdated, + beforeNodeRemoved, + afterNodeMorphed, + }, + }) + } catch (e) { + console.error(e) + } + }) +} + +function beforeNodeAdded(node) { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) +} + +function beforeNodeRemoved(node) { + return beforeNodeAdded(node) +} + +function beforeNodeMorphed(target, newElement) { + if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + detail: { + target, + newElement, + }, + }) + return !event.defaultPrevented + } + return false +} + +function beforeAttributeUpdated(attributeName, target, mutationType) { + const event = dispatch("turbo:before-morph-attribute", { + cancelable: true, + target, + detail: { + attributeName, + mutationType, + }, + }) + return !event.defaultPrevented +} + +function afterNodeMorphed(target, newElement) { + if (newElement instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target, + detail: { + newElement, + }, + }) + } +} diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index 8dcac5803..e7fc68836 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,6 +1,5 @@ import { session } from "../" -import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" -import { dispatch } from "../../util" +import morph from "./actions/morph" export const StreamActions = { after() { @@ -41,73 +40,6 @@ export const StreamActions = { }, morph() { - this.targetElements.forEach((targetElement) => { - try { - const morphStyle = targetElement.getAttribute("data-turbo-morph-style") || "outerHTML" - Idiomorph.morph(targetElement, this.templateContent, { - morphStyle: morphStyle, - ignoreActiveValue: true, - callbacks: { - beforeNodeAdded, - beforeNodeMorphed, - beforeAttributeUpdated, - beforeNodeRemoved, - afterNodeMorphed - } - }) - - dispatch("turbo:morph", { - detail: { - currentElement: targetElement, - newElement: this.templateContent - } - }) - } catch (error) { - console.error(error) - } - }) - } -} - -const beforeNodeAdded = (node) => { - return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) -} - -const beforeNodeMorphed = (target, newElement) => { - if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - detail: { - target, - newElement - } - }) - return !event.defaultPrevented - } - return false -} - -const beforeAttributeUpdated = (attributeName, target, mutationType) => { - const event = dispatch("turbo:before-morph-attribute", { - cancelable: true, - target, - detail: { - attributeName, - mutationType - } - }) - return !event.defaultPrevented -} - -const beforeNodeRemoved = beforeNodeMorphed - -const afterNodeMorphed = (target, newElement) => { - if (newElement instanceof HTMLElement) { - dispatch("turbo:morph-element", { - target, - detail: { - newElement - } - }) - } + morph(this) + }, } diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index f717c02c6..1e3b99f92 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -210,12 +210,12 @@ test("action=morph", async () => { assert.equal(subject.find("h1#hello")?.textContent, "Hello Turbo Morphed") }) -test("action=morph with data-turbo-morph-style='innerHTML'", async () => { +test("action=morph children-only", async () => { const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) const element = createStreamElement("morph", "hello", templateElement) const target = subject.find("div#hello") assert.equal(target?.textContent, "Hello Turbo") - target.setAttribute("data-turbo-morph-style", "innerHTML") + element.setAttribute("children-only", true) subject.append(element) @@ -225,4 +225,3 @@ test("action=morph with data-turbo-morph-style='innerHTML'", async () => { assert.ok(subject.find("div#hello > h1#hello-child-element")) assert.equal(subject.find("div#hello > h1#hello-child-element").textContent, "Hello Turbo Morphed") }) - From f02bfb2e82a2bfb34c1c2e232cd2e6efe1390c40 Mon Sep 17 00:00:00 2001 From: omarluq Date: Tue, 12 Mar 2024 21:58:31 -0500 Subject: [PATCH 4/4] test morph stream action events --- src/core/streams/actions/morph.js | 39 +++++++-------- src/core/streams/stream_actions.js | 2 +- src/tests/fixtures/morph_stream_action.html | 16 +++++++ .../functional/morph_stream_action_tests.js | 48 +++++++++++++++++++ 4 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 src/tests/fixtures/morph_stream_action.html create mode 100644 src/tests/functional/morph_stream_action_tests.js diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js index ed92291e7..d177bfd01 100644 --- a/src/core/streams/actions/morph.js +++ b/src/core/streams/actions/morph.js @@ -4,21 +4,16 @@ import { dispatch } from "../../../util" export default function morph(streamElement) { const morphStyle = streamElement.hasAttribute("children-only") ? "innerHTML" : "outerHTML" streamElement.targetElements.forEach((element) => { - try { - Idiomorph.morph(element, streamElement.templateContent, { - morphStyle: morphStyle, - ignoreActiveValue: true, - callbacks: { - beforeNodeAdded, - beforeNodeMorphed, - beforeAttributeUpdated, - beforeNodeRemoved, - afterNodeMorphed, - }, - }) - } catch (e) { - console.error(e) - } + Idiomorph.morph(element, streamElement.templateContent, { + morphStyle: morphStyle, + callbacks: { + beforeNodeAdded, + beforeNodeMorphed, + beforeAttributeUpdated, + beforeNodeRemoved, + afterNodeMorphed + } + }) }) } @@ -34,10 +29,10 @@ function beforeNodeMorphed(target, newElement) { if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { const event = dispatch("turbo:before-morph-element", { cancelable: true, + target, detail: { - target, - newElement, - }, + newElement + } }) return !event.defaultPrevented } @@ -50,8 +45,8 @@ function beforeAttributeUpdated(attributeName, target, mutationType) { target, detail: { attributeName, - mutationType, - }, + mutationType + } }) return !event.defaultPrevented } @@ -61,8 +56,8 @@ function afterNodeMorphed(target, newElement) { dispatch("turbo:morph-element", { target, detail: { - newElement, - }, + newElement + } }) } } diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index e7fc68836..486dc8566 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -41,5 +41,5 @@ export const StreamActions = { morph() { morph(this) - }, + } } diff --git a/src/tests/fixtures/morph_stream_action.html b/src/tests/fixtures/morph_stream_action.html new file mode 100644 index 000000000..df91274f5 --- /dev/null +++ b/src/tests/fixtures/morph_stream_action.html @@ -0,0 +1,16 @@ + + + + + Morph Stream Action + + + + + + +
+
Morph me
+
+ + diff --git a/src/tests/functional/morph_stream_action_tests.js b/src/tests/functional/morph_stream_action_tests.js new file mode 100644 index 000000000..b4f04c9d7 --- /dev/null +++ b/src/tests/functional/morph_stream_action_tests.js @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test" +import { nextEventOnTarget, noNextEventOnTarget } from "../helpers/page" + +test("dispatches a turbo:before-morph-element & turbo:morph-element for each morph stream action", async ({ page }) => { + await page.goto("/src/tests/fixtures/morph_stream_action.html") + + await page.evaluate(() => { + window.Turbo.renderStreamMessage(` + + + + `) + }) + + await nextEventOnTarget(page, "message_1", "turbo:before-morph-element") + await nextEventOnTarget(page, "message_1", "turbo:morph-element") + await expect(page.locator("#message_1")).toHaveText("Morphed") +}) + +test("preventing a turbo:before-morph-element prevents the morph", async ({ page }) => { + await page.goto("/src/tests/fixtures/morph_stream_action.html") + + await page.evaluate(() => { + addEventListener("turbo:before-morph-element", (event) => { + event.preventDefault() + }) + }) + + await page.evaluate(() => { + window.Turbo.renderStreamMessage(` + + + + `) + }) + + await nextEventOnTarget(page, "message_1", "turbo:before-morph-element") + await noNextEventOnTarget(page, "message_1", "turbo:morph-element") + await expect(page.locator("#message_1")).toHaveText("Morph me") +})