Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created March 12, 2023 16:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bennadel/8da3a88917c107449f7172e6ab9b2f5d to your computer and use it in GitHub Desktop.
Save bennadel/8da3a88917c107449f7172e6ab9b2f5d to your computer and use it in GitHub Desktop.
Rendering A Fly-Out Form Panel Using Turbo Frames With Hotwire And Lucee CFML
<!---
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>
<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>
<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>
<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>
<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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment