Skip to main content
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Jacob Beers
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Jacob Beers

Transcluding A Form Into A Turbo Frame Using Hotwire And Lucee CFML

By
Published in ,

In the Hotwire framework, we can use Turbo Frames to create small, independent, dynamic areas of a page. Turbo Frames can be used for things like lazy-loading user-specific content for better caching and including (or "transcluding") forms from one page into another page. This latter concept - transcluding forms - can unlock a lot of different user experiences. But, rendering a form inside a Turbo Frame can make post-submission redirections more complicated. Fortunately, I recently learned about creating custom Turbo Stream actions, which can help us bridge the redirection gap in our ColdFusion applications.

View this code in my ColdFusion + Hotwire Demos project on GitHub.

In a traditional ColdFusion application, when a user submits a form successfully, the ColdFusion server will usually redirect the user to another page using the location() function. This might be to forward the user onward to a newly-created piece of content; or, it might redirect the user back to a previous list.

If a form is rendered in a progressively enhanced Hotwire application, this post-submission redirect is handled seamlessly by Turbo Drive if the form is rendered outside of a Turbo Frame. If, however, the form is rendered inside a <turbo-frame> element, interactions with said form are scoped to the <turbo-frame> element. Which means, post-submission redirects are also scoped to the <turbo-frame> element.

While this might work in some cases, changing the state of the ColdFusion application through a form submission often necessitates updating the user interface (to reflect the changes). And, if a Turbo Frame is narrowing the scope of the page updates, our location() directive may not render as much new date as we'd like.

To solve this problem, people in the Hotwire world respond to form submissions with Turbo Streams. These Turbo Streams generally include directives to apply DOM (Document Object Model) manipulations. So, in the case of something like a "new comment form", an application might respond with an action="append" Turbo Stream element that renders a new comment.

While I love that Hotwire can perform these DOM manipulations, this approach strays far from the traditional redirect-based logic of a ColdFusion application. As such, I wanted to see if I could things relatively familiar by using the custom action="visit" Turbo Stream action from my previous post:

// Import core modules.
import * as Turbo from "@hotwired/turbo";
import { StreamActions } from "@hotwired/turbo";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

/**
* I support Turbo.visit() stream actions.
*/
StreamActions.visit = function() {

	var url = this.dataset.url;
	var action = ( this.dataset.action || "advance" );
	var frame = ( this.dataset.frame || undefined );

	Turbo.visit(
		url,
		{
			action: action,
			frame: frame
		}
	);

}

With this custom Turbo Stream action, our ColdFusion pages can now tell Turbo Drive to perform a frame visit, multiple frame visits, a page visit, or any combination of visits.

To explore this, I setup a small ColdFusion application that allows a user to create notes. In the default ColdFusion experience, I have a separate List page and Form page. The Form page creates a note and redirects back to the List page where the new note is rendered (along with the old notes).

The default ColdFusion page for the list of notes is here - note that we have several Turbo Frames, one around the "Add note" button and one around the list of notes:

<cfscript>

	notes = application.noteService.getNotes().reverse();

</cfscript>
<cfmodule template="./tags/page.cfm">
	<cfoutput>

		<h2>
			Notes
		</h2>

		<!---
			This Turbo Frame is LAZY LOADED and will inline the create-note form. The
			static contents of the form will be rendered as a fallback while the remote
			template is being loaded.
			--
			NOTE: The "transcluded" CSS class is included to help us hide elements of the
			form after it has been loaded and inlined by Turbo Drive.
		--->
		<turbo-frame
			id="create-frame"
			src="create.htm"
			data-turbo-permanent
			class="transcluded">
			<p>
				<a href="create.htm">Add Note</a>
			</p>
		</turbo-frame>

		<!---
			This Turbo Frame gives us a target to RELOAD after a new note has been added.
			The RELOAD functionality will be implemented as a custom Turbo Stream action.
		--->
		<turbo-frame id="notes-list">

			<ul>
				<cfloop item="note" array="#notes#">
					<li>
						#encodeForHtml( note.text )#
						&mdash;
						<a
							href="delete.htm?id=#encodeForUrl( note.id )#"
							data-turbo-method="delete"
							data-turbo-confirm="Delete this note?">
							Delete
						</a>
					</li>
				</cfloop>
			</ul>

			<cfif notes.len()>
				<p>
					<a
						href="clear.htm"
						data-turbo-method="delete"
						data-turbo-confirm="Delete all notes?">
						Clear all notes
					</a>
				</p>
			</cfif>

		</turbo-frame>

	</cfoutput>
</cfmodule>

As you can see, the first Turbo Frame has both static content and a src attribute. Before Hotwire progressively enhances the page, the static content - our "Add Note" button - will be rendered for the user. Then, once Hotwire takes over, the src attribue - create.htm - will be loaded and its remote content will be transcluded into the live page.

Once the "Add Note" form is inlined, the user can create new notes without leaving the list page. But, when the user does submit a new note, we want two things to happen:

  • The "Add Note" form needs to be reset.

  • The list of notes needs to be refreshed in order to show the new note.

Both of these things can be accomplished by refreshing the two Turbo Frames on the page. Which means, when the create.cfm form submission is processed, it needs to respond with a Turbo Stream that contains two action="visit" directives: one for each Turbo Frame.

Before we look at the create.cfm page, it's important to remember that when Turbo Drive submits a form POST, which is scoped to a Turbo Frame, two things happens:

  • Turbo Drive includes a Turbo-Frame HTTP request header that contains the id value (identifier) of the contextual frame.

  • Turbo Drive adds text/vnd.turbo-stream.html to the Accept HTTP request header (stating that the ColdFusion response can be defined as a Turbo Stream).

In my demo ColdFusion app, I'm capturing this information in the onRequestStart() event-handler within my Application.cfc ColdFusion framework component:

component
	output = false
	hint = "I define the application settings and event handlers."
	{

	// ... truncated code ... //

	/**
	* I get called once to initialize the request.
	*/
	public void function onRequestStart() {

		request.context = structNew()
			.append( url )
			.append( form )
		;
		request.isGet = ( cgi.request_method == "get" );
		request.isPost = ! request.isGet;

		request.template = {
			statusCode: 200,
			statusText: "OK"
		};

		// When Turbo Drive makes requests to the ColdFusion server, it will modify the
		// HTTP request headers in two ways:
		// 
		// 1. If the request is made in the context of a Turbo Frame, the ID of the frame
		//    will be included as the header, "Turbo-Frame".
		// 
		// 2. If the request is a FORM submission, which can accept a Turbo Stream style
		//    response, a new type will be appended to the "Accept" header.
		var headers = getHttpRequestData( false ).headers;
		var turboFrame = ( headers[ "Turbo-Frame" ] ?: "" );

		request.turbo = {
			isFrame: turboFrame.len(),
			frame: turboFrame,
			isStream: ( headers[ "Accept" ] ?: "" ).findNoCase( "text/vnd.turbo-stream.html" )
		};

	}

}

As you can see, I'm capturing the state of the Turbo Drive request in the request.turbo structure. I can then use this state in order to determine how to respond when a new note is created.

Since we are going to be transcluding the Note creation form into the main page using a Turbo Frame, our remote form has to be wrapped in a <turbo-frame> element with the same id. In this case, in an attempt to slightly decouple the two pages, I'm using the Turbo-Frame HTTP header value in order to define the id attribute. This is likely overkill, but I wanted to explore that feature.

The main thing to notice in this page is the post-submission control flow. Once the note has been created, I check to see if we're inside a framed request. If so, I respond with a Turbo Stream; otherwise, I respond with a traditional ColdFusion redirect:

<cfscript>

	param name="form.text" type="string" default="";

	errorMessage = "";

	if ( request.isPost ) {

		try {

			application.noteService.createNote( form.text.trim() );

			// If we are in Turbo Frame and can support a Turbo Stream response, then we
			// will execute the REDIRECT using our custom Turbo Stream "visit" action.
			if ( request.turbo.isFrame && request.turbo.isStream ) {

				include "./create_stream.cfm";
				exit;

			// If this is a normal top-level page action, then let's redirect back to the
			// main page as per usual.
			} else {

				location( url = "index.htm", addToken = false );

			}

		} catch ( any error ) {

			errorResponse = application.errorService.getResponse( error );

			request.template.statusCode = errorResponse.statusCode;
			request.template.statusText = errorResponse.statusText;
			errorMessage = errorResponse.message;

		}

	}

</cfscript>
<cfmodule template="./tags/page.cfm">
	<cfoutput>

		<h2>
			Add Note
		</h2>

		<!---
			In order for this note creation form to be transcluded (in to the main page)
			by Turbo Drive, we have to wrap it in a Turbo Frame that corresponds to the
			lazy-loaded frame in the main page. For that, we'll use the frame ID that was
			reported in the HTTP headers (to reduce duplication).
			--
			NOTE: We are using target="_top" in this context so that if this page were
			loaded directly (via the URL), it would operate as if it weren't in a frame.
		--->
		<turbo-frame
			id="#encodeForHtmlAttribute( request.turbo.frame )#"
			target="_top">

			<cfif errorMessage.len()>
				<p class="error-message">
					#encodeForHtml( errorMessage )#
				</p>
			</cfif>

			<form method="post" action="create.htm">
				<p>
					<input type="text" name="text" size="40" autofocus />
				</p>
				<p>
					<button type="submit">
						Add Note
					</button>

					<!---
						This cancel button will be hidden (via the CSS class) when it is
						transcluded into the main page.
					--->
					<a href="index.htm" class="hide-if-transcluded">
						Cancel
					</a>

					<!---
						If this form is being transcluded into the main page, and it has
						an error message, let's show a button to clear / reset the form
						(to remove the error message). Depending on the type of form you
						have, this may not be necessary.
					--->
					<cfif ( request.turbo.isFrame && errorMessage.len() )>

						<a href="create.htm" data-turbo-frame="_self">
							Reset
						</a>

					</cfif>
				</p>
			</form>

		</turbo-frame>

	</cfoutput>
</cfmodule>

As you can see, if the ColdFusion form is being processed inside a remote Turbo Frame, I'm responding with create_stream.cfm and then exiting out of the current template. This ColdFusion template renders the Turbo Stream with out custom action="visit" responses:

<cfcontent type="text/vnd.turbo-stream.html; charset=utf-8" />
<cfoutput>

	<!--- Refresh the create form Turbo Frame. --->
	<turbo-stream
		action="visit"
		data-url="create.htm"
		data-frame="create-frame">
	</turbo-stream>

	<!--- Refresh the list of notes Turbo Frame. --->
	<turbo-stream
		action="visit"
		data-url="index.htm"
		data-frame="notes-list">
	</turbo-stream>

</cfoutput>

As you can see, our ColdFusion response contains two Turbo Stream directives. The first one reloads the "Add Note" form (resetting it). And, the second one reloads the Turbo Frame wrapped around the list of notes, re-rendering the list which should contain the newly-created note.

ASIDE: In theory, we could have issued a single top-level page visit. The problem with this, however, is that a top-level page visit auto-scrolls the user back to the top of browser window. And, I'd like the user to stay at the current scroll-offset for a better user experience (UX).

Now, if we open this ColdFusion application and try adding some notes, you'll see that both the transcluded form and the list of notes gets refreshed:

A list of notes being generated from a transcluded form inside a Turbo Frame elmenet in ColdFusion.

Using Turbo Streams to manipulate the DOM (ex, action="append") is going to be more performant since the browser doesn't have to execute another round trip to the server. However, defining the DOM manipulation directives adds complexity. Performing Turbo-Frame-based redirects / visits, on the other hand, might incur more network activity but it reduces complexity and maps more closely to a traditional ColdFusion workflow. This might be an easier "first step" before attempting to optimize latency.

Conditionally Hiding Buttons in a Transcluded Form

You may have noticed that my "Add Note" form actually has two "cancel" buttons. Only one button shows at a time, depending on whether or not the form is being transcluded into the main page. This is a tip that I learned from a GoRails video on Inline Editing with Turbo Frames in Rails, where CSS rules can be used to conditionally hide elements based on contextual <turbo-frame> classes.

If you recall from my main page, the <turbo-frame> that wraps the transcluded form includes the CSS class, "transcluded":

<turbo-frame
	id="create-frame"
	src="create.htm"
	data-turbo-permanent
	class="transcluded">

	<!--- Static content --->
</turbo-frame>

Then, in my "Add Note" form, one of my buttons includes the CSS class, "hide-if-transcluded":

<a href="index.htm" class="hide-if-transcluded">
	Cancel
</a>

Thanks to the following LESS CSS block, I can then hide this link if - and only if - it is being transcluded:

.transcluded {
	.hide-if-transcluded {
		display: none ;
	}
}

A neat trick!

Want to use code from this post? Check out the license.

Reader Comments

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel