Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created April 18, 2023 11:49
Show Gist options
  • Save bennadel/55e1539ed20ec18c0e0c6c5f1997a5ac to your computer and use it in GitHub Desktop.
Save bennadel/55e1539ed20ec18c0e0c6c5f1997a5ac to your computer and use it in GitHub Desktop.
Selecting Portions Of A Turbo Stream Template With Custom Actions
<cfmodule template="./tags/page.cfm">
<cfoutput>
<div id="demo-container">
<p class="original">
To be changed via Turbo Streams.
</p>
</div>
<!---
Each of the following forms is going to POST to an end-point that returns a
Turbo Stream element with a CUSTOM ACTION. These custom actions are named for
the native Turbo Stream actions along with the suffix "With" (for example,
"replace" becomes "repalceWith"). The custom actions allow for an additional
attribute, "selector", which determines which elements within the TEMPLATE
will be used in the DOM manipulation. For the sake of the demo, I'm providing
these attributes via hidden inputs:
--->
<form method="post" action="stream.htm">
<input type="hidden" name="action" value="prependWith" />
<input type="hidden" name="selector" value=".prepend-with" />
<button type="submit">
Prepend With
</button>
</form>
<form method="post" action="stream.htm">
<input type="hidden" name="action" value="appendWith" />
<input type="hidden" name="selector" value=".append-with" />
<button type="submit">
Append With
</button>
</form>
<form method="post" action="stream.htm">
<input type="hidden" name="action" value="updateWith" />
<input type="hidden" name="selector" value=".update-with" />
<button type="submit">
Update With
</button>
</form>
<form method="post" action="stream.htm">
<input type="hidden" name="action" value="beforeWith" />
<input type="hidden" name="selector" value=".before-with" />
<button type="submit">
Before With
</button>
</form>
<form method="post" action="stream.htm">
<input type="hidden" name="action" value="afterWith" />
<input type="hidden" name="selector" value=".after-with" />
<button type="submit">
After With
</button>
</form>
<form method="post" action="stream.htm">
<input type="hidden" name="action" value="replaceWith" />
<input type="hidden" name="selector" value=".replace-with" />
<button type="submit">
Replace With
</button>
</form>
</cfoutput>
</cfmodule>
// Import core modules.
import * as Turbo from "@hotwired/turbo";
import { StreamActions } from "@hotwired/turbo";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
/**
* I get the DocumentFragment to use as the Turbo Stream payload for the given Turbo Stream
* element. If no selector is provided, the original template is returned. If a selector is
* provided, a new fragment will be generated using the selector and returned.
*/
function getFragmentUsingSelector( turboStreamElement ) {
var originalFragment = turboStreamElement.templateContent;
var selector = turboStreamElement.getAttribute( "selector" );
// If no selector is provided, use the entire template - this is the same behavior as
// the relevant native action.
if ( ! selector ) {
return( originalFragment );
}
// Locate the desired sub-nodes within the template. In the vast majority of cases,
// this will likely be a SINGLE root node. But, I'm using querySelectorAll() in order
// to make the Stream action a bit more flexible.
var nodes = originalFragment.querySelectorAll( selector );
// Construct a new document fragment using the selected sub-nodes.
var fragment = document.createDocumentFragment();
fragment.append( ...nodes );
return( fragment );
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// In the following custom Turbo Stream actions, I basically went into the Turbo source
// code for StreamActions:
// --
// https://github.com/hotwired/turbo/blob/main/src/core/streams/stream_actions.ts
// --
// ... copied the logic, and replaced the raw .templateContent references with a call to
// extract the sub-tree of the template using the SELECTOR (and the Function above).
StreamActions.replaceWith = function() {
this.targetElements.forEach(
( targetElement ) => {
targetElement.replaceWith( getFragmentUsingSelector( this ) );
}
);
}
StreamActions.updateWith = function() {
this.targetElements.forEach(
( targetElement ) => {
targetElement.innerHTML = "";
targetElement.append( getFragmentUsingSelector( this ) );
}
);
}
StreamActions.afterWith = function() {
this.targetElements.forEach(
( targetElement ) => {
targetElement.parentElement?.insertBefore(
getFragmentUsingSelector( this ),
targetElement.nextSibling
);
}
);
}
StreamActions.appendWith = function() {
this.removeDuplicateTargetChildren();
this.targetElements.forEach(
( targetElement ) => {
targetElement.append( getFragmentUsingSelector( this ) );
}
);
}
StreamActions.beforeWith = function() {
this.targetElements.forEach(
( targetElement ) => {
targetElement.parentElement?.insertBefore(
getFragmentUsingSelector( this ),
targetElement
);
}
);
}
StreamActions.prependWith = function() {
this.removeDuplicateTargetChildren();
this.targetElements.forEach(
( targetElement ) => {
targetElement.prepend( getFragmentUsingSelector( this ) );
}
);
}
/**
* I get the DocumentFragment to use as the Turbo Stream payload for the given Turbo Stream
* element. If no selector is provided, the original template is returned. If a selector is
* provided, a new fragment will be generated using the selector and returned.
*/
function getFragmentUsingSelector( turboStreamElement ) {
var originalFragment = turboStreamElement.templateContent;
var selector = turboStreamElement.getAttribute( "selector" );
// If no selector is provided, use the entire template - this is the same behavior as
// the relevant native action.
if ( ! selector ) {
return( originalFragment );
}
// Locate the desired sub-nodes within the template. In the vast majority of cases,
// this will likely be a SINGLE root node. But, I'm using querySelectorAll() in order
// to make the Stream action a bit more flexible.
var nodes = originalFragment.querySelectorAll( selector );
// Construct a new document fragment using the selected sub-nodes.
var fragment = document.createDocumentFragment();
fragment.append( ...nodes );
return( fragment );
}
<cfscript>
param name="request.context.action" type="string";
param name="request.context.selector" type="string";
</cfscript>
<cfcontent type="text/vnd.turbo-stream.html; charset=utf-8" />
<cfoutput>
<!---
Note that this CUSTOM Turbo-Stream action is accepting a **SELECTOR** attribute.
The SELECTOR attribute allows us to target portions of the TEMPLATE element such
that we can more flexibly reuse existing interfaces (by including them, but
selecting only a portion of the interface as the Turbo-Stream payload).
--->
<turbo-stream
action="#request.context.action#"
selector="#request.context.selector#"
target="demo-container">
<template>
<p class="new replace-with">
Selected via 'replaceWith'
</p>
<p class="new update-with">
Selected via 'updateWith'
</p>
<p class="new prepend-with">
Selected via 'prependWith'
</p>
<p class="new append-with">
Selected via 'appendWith'
</p>
<p class="new before-with">
Selected via 'beforeWith'
</p>
<p class="new after-with">
Selected via 'afterWith'
</p>
</template>
</turbo-stream>
</cfoutput>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment