Skip to main content
Ben Nadel at Scotch On The Rocks (SOTR) 2011 (Edinburgh) with: Chandan Kumar and Sagar Ganatra
Ben Nadel at Scotch On The Rocks (SOTR) 2011 (Edinburgh) with: Chandan Kumar Sagar Ganatra ( @sagarganatra )

Rendering A Fly-Out Form Panel Using Turbo Frames With Hotwire And Lucee CFML

By on

When using Hotwire to progressively enhance "normal" ColdFusion pages, the process is quite seamless: as long as you're returning a non-200 status code on failed form submissions, everything just works! It's only when you start transcluding forms from one page into another page that things get tricky. This is doubly-true when the transcluded form is transient, such as with a modal window or a fly-out panel. To start getting comfortable with this concept, I wanted to try and render a form inside a fly-out panel in a Hotwire enhanced ColdFusion application.

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

The technique that I'm using in this post is an attempt to recreate the approach outlined by Andrew Tait in his Krystal Labs post: Lessons learned with Hotwire. I'm just applying it in a ColdFusion context with ColdFusion mechanics.

To keep things simple, this ColdFusion application allows me to create notes. There's a main page with a list of notes and then a form page for creating new notes. And, that's it - no edit page, no delete page; there's just enough functionality here to require a form with some validation that will get progressively enhanced to show in a fly-out panel.

First, let's look at our main ColdFusion page: the list of notes. In the following CFML code, there are two things of note:

  1. There is an empty Turbo Frame at the bottom of the page. The Turbo Frame uses [target="_top"] so that any navigation event or redirect within the frame will be applied to the entire page, not just to the Turbo Frame context.

  2. Our "Add note" button uses the data-turbo-frame attribute to target said Turbo Frame, transcluding the "Add note" form into the main page. This link also uses the [data-turbo-action="advance"] attribute so that the URL is updated to point to the "Add note" form page. This allows the form to be "deep linked" (in a sense).

<cfscript>

	notes = application.noteService.getNotes();

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

		<h2>
			Welcome to My Site
		</h2>

		<p>
			<!---
				We're opening our note form into a Turbo Frame AND we're advancing the
				location URL. This way, if someone were to refresh the page, they would
				still be presented with the note form (albeit no longer located within the
				Turbo Frame / Fly-out panel).
			--->
			<a
				href="create/index.htm"
				data-turbo-frame="fly-out-frame"
				data-turbo-action="advance">
				Add note
			</a>
		</p>

		<ul>
			<cfloop item="note" array="#notes#">
				<li>
					#encodeForHtml( note.text )#
				</li>
			</cfloop>
		</ul>

		<!---
			Our Fly-Out Turbo Frame will use [target="_top"] so that any navigation events
			or redirects within the frame will be applied to the top-level page. This will
			make it possible/easier to REDIRECT BACK TO THE MAIN PAGE with up-to-date note
			information after an embedded fly-out form mutates the application state.
		--->
		<turbo-frame
			id="fly-out-frame"
			target="_top">
		</turbo-frame>

	</cfoutput>
</cfmodule>

As you can see, our list of notes is relatively straightforward. Unfortunately, that's where the simplicity ends! It turns out that transcluding a form into a transient rendering is rather complex. And, I believe that this complexity is essential. Meaning, I'm not sure that it can be refactored into a more simple approach (given the requirements of the Hotwire framework).

The key to this approach - and to any Turbo Stream based approach - is to have user interface (UI) views that can be easily rendered from multiple places. Thankfully, ColdFusion makes that part simple enough through native constructs such as CFInclude and CFModule (ie, custom tags).

In this case, our main reusable view is the "add note form". And, to keep things simple, I'll be rendering our form partial using CFInclude. The benefit of using CFInclude is that there are no additional mechanics to worry about. The drawback of using CFInclude is that there is no obvious requirement for what data is needed within the view (when compared to a ColdFusion custom tag which has attributes for unidirectional data-binding). In our template, the only requirement is an errorMessage value.

Note that the content of the following form is wrapped in a DIV with attribute, [id="note-form"]. This will come into play when we need to re-render the form with an error message.

<cfoutput>

	<!---
		This wrapper DIV serves to give us a target that we can REPLACE with a Turbo
		Stream directive if / when we need to re-render the form with error messages.
	--->
	<div id="note-form">
		<h2>
			Add Note
		</h2>

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

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

	<!---
		This script tag will be executed every time the view is merged into the page,
		whether as the initial rendering or as part of a Turbo Stream action. I'm using it
		to focus the input. THe [autofocus] attribute works on the first render, but not
		on the subsequent rendering. This script tag makes up the difference.
	--->
	<script type="text/javascript">
		document.querySelector( "input[name='text']" ).focus();
	</script>

</cfoutput>

Now that we have our reusable, re-renderable _form.cfm template, let's look at how we render our form and process our form submissions inside a transient Turbo Frame.

When Hotwire progressively enhances a page, and Turbo Drive takes over navigation, any page that is going to be rendered inside a Turbo Frame is provided with a special HTTP request header: Turbo-Frame. This HTTP header contains the id identifier of the contextual frame. In our case, that Turbo-Frame header will contain the value, fly-out-frame. In this demo, I'm not going to consume that value directly; but, we will use the existence of said HTTP header to help determine how the current ColdFusion page is being consumed.

For the sake of simplicity, I'm going to include both the frame-based and the standalone rendering of the "Add note" form in the same CFML template. Ideally, this divergence would be managed in a more generalized "layout" selection. Take note that are two references to our _form.cfm template from above.

<!---
	When rendered as a top-level request, we can render the form AS-IS. However, if we're
	rendering inside a Turbo Frame (ie, we're trancluding the form into another page), we
	have to render the form inside a like-named Turbo Frame so that Hotwire can merge the
	results back into the live page.
	--
	NOTE: In a more robust architecture, this could be implemented much more seamlessly as
	a layout selection, such as a "standard" layout vs a "fly-out" layout. However, to
	keep things as simple as possible, I'm rendering both types of layouts right here in
	the same template so that we can see the mechanics at play.
--->
<cfif request.turbo.isFrame>


	<turbo-frame id="fly-out-frame">
		<div class="fly-out">
			<div class="fly-out__content">

				<!--- !!! Reused Form UI !!! --->
				<cfinclude template="_form.cfm" />

			</div>
			<a href="index.htm" class="fly-out__backdrop">
				Close
			</a>
		</div>
	</turbo-frame>


<!--- Standard page layout, non-frame version. --->
<cfelse>


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

		<!--- !!! Reused Form UI !!! --->
		<cfinclude template="_form.cfm" />

	</cfmodule>


</cfif>

Having a shared _form.cfm helps with the different layouts; but, that's not where the leverage ends. Our shared form rendering also comes into play when we need to show errors to the user.

Recall that our original <turbo-frame> tag was using the [target="_top"] attribute. What that means is that any action that takes place inside the Turbo Frame (ex link navigation, form submission) is going to apply to the entire page, not just to the parent frame. Which means, if the user submits the "Add note" form and there is an error to display, attempting to re-render the form in response is going to wipe-out (and replace) the entire page - not just the Turbo Frame.

ASIDE: Links and form submissions located inside a Turbo Frame can override the frame-based settings by including a data-turbo-frame attribute.

That is, of course, unless the response is a Turbo Stream (of type, text/vnd.turbo-stream.html). When the ColdFusion server responds with a set of Turbo Stream directives, Turbo Drive will apply those to the page instead of following any redirects or blowing away the existing page markup.

To this end, if we have an errorMessage to show to the user, we're not going to just re-render the form. Instead, we're going to respond with a Turbo Stream that tells Turbo Drive to replace the existing form rendering with a new form rendering. And, this is where our reusable _form.cfm template comes into play again:

<cfscript>

	header
		statusCode = request.template.statusCode
		statusText = request.template.statusText
	;
	content
		type = "text/vnd.turbo-stream.html; charset=utf-8"
	;

</cfscript>
<!---
	Turbo Drive is expecting our POST request to do one of the following:

	1. Redirect to another page (upon successful execution).
	2. Re-render with a non-200 response (to show error messages).
	3. Respond with a set of Turbo Stream directives (to mutate the existing DOM).

	The problem that we face here is that our parent Turbo Frame has [target="_top"].
	Which means that if we re-render our form to show the errors, it will swap out the
	entire page content, not just the fly-out content. As such, in order to maintain the
	same page layout AND show errors, we need to use a Turbo Stream directive to REPLACE
	THE ENTIRE FORM, complete with errors, in order to update the view. This is why we
	wrapped the form in a DIV[id="note-form"] - so that we could hot-swap it!
--->
<turbo-stream action="replace" target="note-form">
	<template>

		<!--- !!! Reused Form UI !!! --->
		<cfinclude template="_form.cfm" />

	</template>
</turbo-stream>

Remember how we arbitrarily wrapped our form in <div id="note-form">? This is why - it gave us something to hook into with our Turbo Stream response. This response of type [action="replace"] tells Hotwire to completely strip-out the given DOM (Document Object Model) element (targeted by id) and replace it with the contents of our <template>.

What we can see now is that a reusable _form.cfm template is critical for this kind of workflow. It is being used to:

  1. Render the "Add note" form in a standard layout.

  2. Render the "Add note" form in a Frame-based layout.

  3. Render the "Add note" form in an error response.

The logic that pulls this all together is in our Controller layer. One thing to note here is that the Turbo Stream error response is always being used to render the error, regardless of whether or not the form resides inside a Turbo Frame. Since Turbo Streams represent targeted DOM mutations, they allow us to be a bit more layout-agnostic.

<cfscript>

	param name="request.context.text" type="string" default="";

	errorMessage = "";

	// Processing the note form submission.
	if ( request.isPost ) {

		try {

			application.noteService.createNote( request.context.text.trim() );
			// NOTE: Since our Turbo Frame has [target="_top"], Turbo Drive is going to
			// apply any response - including a Location header - to the top-level page,
			// not to the Turbo Frame. That means that upon success, we can simply
			// redirect the user back to the main page in order to render the newly-
			// created note.
			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;

		}

		// We only make it this far if there are errors in the form validation (and we
		// didn't redirect the user back to the main page). If the request can support
		// consuming a Turbo Stream (ie, it's been enhanced by Hotwire), then we need to
		// update the UI using stream directives, otherwise Turbo Drive will overwrite the
		// entire page (remember, [target="_top"]) with our response.
		if ( request.turbo.isStream ) {

			include "_error.stream.cfm";
			exit;

		}

	}

	include "_create.cfm";

</cfscript>

In all rendering cases, if the form submission is successful then we redirect the user back to the main page (ie, the list of notes). This works because our Turbo Frame used [target="_top"] and does not confine the response to the Turbo Frame boundary.

If the form submission contains an error, however, then we respond with a Turbo Stream if it's supported. And, if the current request doesn't support a Turbo Stream response, we just let the _create.cfm template render, which includes the erorrMessage rendering within it (thanks to our reusable _form.cfm template).

We now have a workflow that works with just ColdFusion; and, can be progressively enhanced to work with Hotwire:

A form being transcluded into a fly-out panel in a Hotwire application using ColdFusion.

Some of the complexity in this demo is due to the fact that I have no server-side framework whatsoever - it's just CFML templates and a ColdFusion custom tag (for the page layout). But, I believe there is some essential complexity that we can't avoid, especially if we want to account for progressive enhancement (ie, what happens if the JavaScript bundles fails to load).

Refreshing the Page With a Rendered Fly-Out Panel

In this demo, when you click on the "Add note" button, I'm both opening the fly-out view and advancing the page history. What this means is that if the user refreshes the page while the fly-out panel is open, we end up rendering the "Add note" form as if the user accessed it directly, outside of the fly-out panel.

At this time, I'm OK with that. In a way, that's the whole point of the progressive enhancement. If we want to re-render the form in a fly-out, we'd have to get a lot more complex with how we handling routing and page composition. Which seems somewhat antithetical to the Hotwire mindset.

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