Jump to content

FormBuilderHtmx - A zero configuration Pro FormBuilder companion module to enable AJAX form submissions


Recommended Posts

Hey all!

This is a module to enhance forms built using the Pro FormBuilder module that converts the submission/response loop to AJAX via HTMX.

Some noteworthy features:

  • Zero configuration, install and render AJAX powered FormBuilder forms immediately
  • Unintrusive, can be used alongside FormBuilder's native rendering methods and does not modify core module behavior
  • Perfect for forms embedded in popups and modals, does not use iframes
  • Does not conflict with styling and other JavaScript already in-place, only handles the form submission/response loop
  • Automatically disables the `Submit` button on submission to prevent duplicate requests
  • Provides the ability to add a custom 'spinner' shown when a form is being processed
  • Uses HTMX, a stable, powerful, and tiny (14kb gzipped) library

Note- this module is BYOH (Bring Your Own HTMX) as the library is not provided with FormBuilderHtmx. Read the HTMX documentation for more information and installation instructions. It's a pretty nifty library with a lot of great features that you may want to use on other parts of your site or app.

You may also have to disable CSRF protection for a form rendered using this module.

Usage is cake.

<!--
  Replace the native $forms->render() method with $htmxForms->render()
-->
<?= $htmxForms->render('your_form_name') ?>

Presto.

You can optionally include a 'spinner' or activity animation that will be showed to the user while your form request is in flight. Highly recommended.

<style>
  /*
    Include this in your CSS stylesheets, note the 'activity-indicator` class name on the 'spinner'
    element below. That class name is your choice, however `.htmx-request` must be unchanged. Also include any
    CSS your 'spinner' may need
  */

  .activity-indicator {
    display: none;
  }

  .htmx-request .activity-indicator {
    display: inline;
  }

  .htmx-request.activity-indicator {
    display: inline;
  }
</style>

<!--
  Optional second argument matches that of the $forms->render() method for pre-populated values
  Optional third argument is a CSS selector matching your 'spinner' element
-->
<?= $htmxForms->render('your_form_name', [], '#indicator-for-the-form') ?>

<!-- The 'spinner' element -->
<div id="indicator-for-the-form" class="activity-indicator">
  <span class="spinner"></span>
</div>

Presto (again)

This is an alpha release as I wrote it while working on a current project, that is to say the testing has worked for the forms I have in place. Please test and check that everything works with your FormBuilder forms. Pull requests on Github are welcome. When there has been more testing and usage I'll add it to the modules directory if it proves useful.

Download from the FormBuilderHtmx Github repository .

Cheers!

 

Would you look at that- this module is compatible with FieldtypeFormSelect which allows users to choose which form to embed using a select field when editing a page. Neat stuff.

  • Like 19
  • Thanks 3
Link to comment
Share on other sites

  • FireWire changed the title to FormBuilderHtmx - A zero configuration Pro FormBuilder companion module to enable AJAX form submissions

I was just playing around with your module and wanted to export my playground profile so I can use it in another PW instance and then this happened:

Spoiler

Dangit… Fatal Error: Uncaught TypeError: FormBuilderHtmx::renderAjaxResponseMarkup(): Argument #2 ($submitKey) must be of type string, null given, called in site/modules/FormBuilderHtmx/FormBuilderHtmx.module.php on line 68 and defined in site/modules/FormBuilderHtmx/FormBuilderHtmx.module.php:117

#0 site/modules/FormBuilderHtmx/FormBuilderHtmx.module.php(68): FormBuilderHtmx->renderAjaxResponseMarkup('InputfieldForm2', NULL, '<!DOCTYPE html>...')
#1 wire/core/WireHooks.php (1085): FormBuilderHtmx->{closure}(Object(HookEvent))
#2 wire/core/Wire.php (484): WireHooks->runHooks(Object(Page), 'render', Array)
#3 wire/modules/Process/ProcessPageView.module (184): Wire->__call('render', Array)
#4 wire/modules/Process/ProcessPageView.module (114): ProcessPageView->renderPage(Object(Page), Object(PagesRequest))
#5 wire/core/Wire.php (416): ProcessPageView->___execute(true)
#6 wire/core/WireHooks.php (968): Wire->_callMethod('___execute', Array)
#7 wire/core/Wire.php (484): WireHooks->runHooks(Object(ProcessPageView), 'execute', Array)
#8 index.php (55): Wire->__call('execute', Array)
#9 {main}
thrown (line 117 of site/modules/FormBuilderHtmx/FormBuilderHtmx.module.php)

This error message was shown because: you are logged in as a Superuser. Error has been logged.

Tried to export everything with ProcessExportProfile in it's latest version on ProcessWire 3.0.238.

  • Thanks 1
Link to comment
Share on other sites

On 5/2/2024 at 10:50 PM, FireWire said:

Note- this module is BYOH (Bring Your Own HTMX) as the library is not provided with FormBuilderHtmx

You could do a check for existance of window.htmx and then conditionally include it from CDN.

Sth along these lines

if (typeof window.htmx === 'undefined') {
    var script = document.createElement('script');
    script.src = "https://unpkg.com/htmx.org@latest";
    script.integrity = "sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2"; // Not sure if this works reliably with @latest
    script.crossOrigin = "anonymous";
    document.head.appendChild(script);
    script.onload = function() {
        console.log("htmx loaded successfully!");
    };
    script.onerror = function() {
        console.error("Failed to load htmx!");
    };
} else {
    console.log("htmx is already loaded.");
}

 

Link to comment
Share on other sites

7 hours ago, gebeer said:
Not sure if this works reliably with @latest

Yeah, the subresource key is a hash of the file contents so that will break at some point on @latest. Alternatively, I don't want to introduce external resource versioning since it would mean the module has to be updated to keep up with HTMX.

Biggest deal for me is that, as an online privacy advocate, I can't recommend something that I wouldn't use myself.

7 hours ago, gebeer said:

htmx is awesome and absolutely the right tool for such things.

Agreed! Great way to add SPA features without fundamentally changing ProcessWire rendering. I'd argue that it's the way that interactive UI should be built to begin with.

  • Like 3
Link to comment
Share on other sites

I was playing around with this today. A few things I noticed.

  • ->styles and ->scripts return null, but I could pull the original form output and use the extractions from there.
  • I get an AJAX response and the submission works, but the parsing of the whole page object returned didn't seem to work (the whole page replaced the original form on the page).

I don't have any PR yet, just going to tinker now, but thought I'd report my findings.

I should comment - I am using latte templates for output. I may need to hook into output markup somehow.

  • Thanks 1
Link to comment
Share on other sites

23 hours ago, gornycreative said:

arsing of the whole page object returned didn't seem to work (the whole page replaced the original form on the page)

Can you double check that CSRF is disabled for the form?
I was able to replicate the issue, working on it.

23 hours ago, gornycreative said:

->styles and ->scripts return null, but I could pull the original form output and use the extractions from there.

This is a good point... I haven't been using either of those since I include my own styles. So I guess only the render method is "drop in". I'll look into this.

Link to comment
Share on other sites

2 hours ago, FireWire said:

I haven't been using either of those since I include my own styles.

Yeah and I've been debating rolling my own styles and template also since I tend to try to use update UIKit or TW or whatever I am using and I have only started messing with FormBuilder, but I figure it may still be worth covering? Not sure.

Link to comment
Share on other sites

14 hours ago, gornycreative said:

but I figure it may still be worth covering?

It is. Next revision will truly be passthrough in that it adds HTMX and then returns the FormBuilder instance so you can continue to use all of the built in methods, including rendering FormBuilder scripts and styles- so a true drop-in replacement. Appreciate your feedback and help with making this module better!

  • Like 2
Link to comment
Share on other sites

New version pushed. This resolves several issues and also adds expected features.

  • The $htmxForms->render() method is now a true drop-in replacement. All methods/properties available on $forms->render() are available. The $htmxForms render method returns the original object expected from $forms->render(), so FormBuilder scripts and styles can be used
  • The same form can be used multiple times on the same page when all are rendered using FormBuilderHtmx, each separately tracks state/errors/submissions
  • Has more thorough checking for form submissions to detect HTMX form submissions vs. traditional form submissions
  • Resolves issue where the full page markup was appearing in place of the form in some instances when submitting

A note on multiple instances of the same form on one page- if one is rendered using FormBuilder and a submission has errors, all instances of that form will show those errors regardless if rendered using FormBuilder or FormBuilderHtmx. This is a core behavior of FormBuilder AFAIK and is not something introduced by FormBuilderHtmx. A benefit to rendering multiple instances of the same form using FormBuilderHtmx is that each form is individually processed.

Have done a lot of testing with great results, but as always, community testing and feedback is appreciated!

Available for download via the Github repo.

@gornycreative & @Sanyaissues let me know if this fixes the issues you're seeing.

  • Like 1
  • Thanks 1
Link to comment
Share on other sites

 

@FireWire if the form is rendered inside a template using Markup regions, there's an error after submission:

TypeError

ProcessWire\FormBuilderHtmx::renderHtmxResponse(): Return value must be of type string, null returned search
File: .../modules/FormBuilderHtmx/FormBuilderHtmx.module.php:169

159:      /**
160:       * Finds the form that has been submitted and extracts the markup to return what HTMX expects
161:       * @param  string $renderedPageMarkup Rendered full page markup
162:       */
163:      private function renderHtmxResponse(string $renderedPageMarkup): string
164:      {
165:        $pattern = "/<div id=[\"']{$this->htmxFormId()}[\"']((.|\n|\r|\t)*)<!--\/.FormBuilder-->/U";
166:    
167:        preg_match($pattern, $renderedPageMarkup, $matches);
168:    
169:        return $matches[0];

170:      }
171:    }

 

Link to comment
Share on other sites

@Sanyaissues I've never used markup regions before. Knew they existed but haven't been part of my workflow.

From the looks of it, that error indicates that when the form is submitted, the return markup doesn't contain the form or doesn't contain the identifying markup FormBuilderHtmx adds to a form that identifies it. With markup regions adding a step where it handles replacement of markup in the document, I'm not sure how that affects form rendering. This error is intentional as it helps identify where forms aren't rendering in a way that FormBuilderHtmx expects.

Need to see your code and how you're embedding your forms.

If you embed a form using markup regions without FormBuilderHtmx using $forms->render(), what happens?

Link to comment
Share on other sites

@FireWire using $forms->render with Markup Regions or using FormBuilderHtmx, without the markup regions, everything works.

Regarding my template with the markup regions the setup is kind of simple:

Spoiler
// /site/config.php

$config->useMarkupRegions = true;
$config->appendTemplateFile = '_main.php';
// /site/templates/_main.php

<?php namespace ProcessWire; ?>

<!doctype html>
<html lang="en">

    <head>
        <link rel="stylesheet" href="<?= urls()->templates ?>styles/tailwind/tailwind.css">
    </head>

    <body>
        <main data-pw-id="content"></main> <!-- Heres my markup region -->
    </body>

    <script src="<?= urls()->templates ?>scripts/vendors/htmx/htmx.min.js"></script>
    

</html>
// /site/templates/mytemplate.php

<?php namespace ProcessWire; ?>

<!-- This main will replace the one in _main.php -->
<main pw-replace="content" class="max-w-screen-sm mx-auto>	

	<?= $htmxForms->render(page()->form_selector, [], '#indicator') ?>

	<div class="activity-indicator hidden" id="indicator">
		
		<div class="flex justify-center items-center absolute w-full h-full bg-white/50 border top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
			<span class="loader w-8	h-8 border-4 border-gray-300 border-b-orange-500 rounded-[50%] inline-block box-border"></span>
		</div>
	
	</div>

</main>

 


Question aside: It's possible to return to htmx only the form using if($config->ajax) or something else?

Edited by Sanyaissues
Improving my explanation for future reference
Link to comment
Share on other sites

6 minutes ago, Sanyaissues said:

 using $forms->render or using FormBuilderHtmx, without markup regions, everything works.

If the forms only work when markup regions is not used then that's more of something to do with FormBuilder itself. FormBuilderHtmx just hooks into the FormBuilder module so if FormBuilder doesn't work, this module won't either. It may be better to look into markup regions in the FormBuilder support threads.

8 minutes ago, Sanyaissues said:


Question aside: It's possible to return to htmx only the form using if($config->ajax) or something else?

Not sure why that would be beneficial? Htmx sends headers to indicate that the request is from htmx (via ajax), and the module adds identifying markup to tell htmx where to insert the form results. So while ajax is used, detecting it separately wouldn't be useful. The module does more than just return the form, if it just returned the form then ajax submissions would stop working for it altogether. There is a lot more that goes on behind the scenes to make this work in harmony with FormBuilder.

 

Link to comment
Share on other sites

The error persist @FireWire
 

Spoiler
TypeError
ProcessWire\FormBuilderHtmx::renderHtmxResponse(): Return value must be of type string, null returned search

File: .../modules/FormBuilderHtmx/FormBuilderHtmx.module.php:175

165:      /**
166:       * Finds the form that has been submitted and extracts the markup to return what HTMX expects
167:       * @param  string $renderedPageMarkup Rendered full page markup
168:       */
169:      private function renderHtmxResponse(string $renderedPageMarkup): string
170:      {
171:        $pattern = "/\n?<div id=[\"']{$this->htmxFormId()}[\"']((.|\n|\r|\t)*)(<!--\/.FormBuilder-->|<span data-formbuilder-htmx-end><\/span>)/U";
172:    
173:        preg_match($pattern, $renderedPageMarkup, $matches);
174:    
175:        return $matches[0];

176:      }
177:    }

Update 1:  I created a new template that only renders the form (loaded with Markup Regions), and it is working.
However, in another template with a repeater field that I render with <?= page()->render('banners') ?>, I see the error above.
The repeater isn't related to the form, but there is something in the way I'm rendering the content inside my repeater that is breaking FBhtmx.

I will try to find out why the error occurs and keep you updated.


Update 2: I found the issue!
Imagine a template with a Page Reference field, where the value is a PageArray allowing users to select multiple values. The field is rendered using  <?= page()->render('myField') ?> and the field template is:

// /site/templates/fields/myfield.php

<?php namespace ProcessWire; 
  foreach($value as $item){
      echo $item->render();
  }
?>

When our page with the form and myField is rendered, the hook after Page::render will be executed for each value of myField as well as for the page itself.
If $this->renderHtmxResponse($e->return) is executed with the pages of myField, a null value will be returned.

My temporal fix:

private function addPostFormProcessingHook(): void
{
  $this->wire->addHookAfter(
    'Page::render',
    fn ($e) => !empty($e->return) && ( 
	    $this->isHtmxRequest() && $e->return = $this->renderHtmxResponse($e->return)
  )
);
}

private function renderHtmxResponse(string $renderedPageMarkup): string
{

	$pattern = "/\n?<div id=[\"']{$this->htmxFormId()}[\"']((.|\n|\r|\t)*)(<!--\/.FormBuilder-->|<span data-formbuilder-htmx-end><\/span>)/U";

  	preg_match($pattern, $renderedPageMarkup, $matches);

  	return $matches[0] ?? '';
}



 

  • Thanks 1
Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
 Share

×
×
  • Create New...