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 by providing the ability to submit them in place using AJAX and HTMX.

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 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
  • Compatible with FieldtypeFormSelect, let users choose which forms to embed, your code determines how they are rendered
  • Uses HTMX, a stable, powerful, and tiny (14kb gzipped) library, installation documentation available here

This module is BYOH (Bring Your Own HTMX) as the library is not provided with this module. This helps FormBuilderHtmx remain long-term stable by not locking it to external asset versioning. Use the version that works for you and confidently add this module to both new and existing ProcessWire applications.

CSRF protection must be disabled for forms rendered using this module.

Using this module is truly easy.

<!-- Replace the native $forms->render() method with $htmxForms->render() -->
<?php $htmxForm = $htmxForms->render('your_form_name') ?>
 
<!-- Use native ProcessWire properties and methods as usual -->
<?php
	echo $htmxForm->styles;
	echo $htmxForm->scripts;

	echo $htmxForm;
?>

Presto.

You can optionally include a helpful 'spinner' or activity animation that will be showed to users while their form request is being processed. Check out these pre-made examples you can use.

<style>
  /*
    Add these styles to your CSS, the `.htmx-request` must be present as shown here.
  	Be sure to include any CSS your 'spinner' may need, and style everything as desired
  */

  .activity-indicator {
    display: none;
  }

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

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

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

Presto (again)

Please test and check that everything works with your FormBuilder forms. Pull requests and issues filed on Github are welcome, or drop by here to get some help!

Install as a ProcessWire module

Install using Composer

Download from the FormBuilderHtmx Github repository .

Cheers!

  • Like 20
  • 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

Posted (edited)

@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

21 minutes ago, FireWire said:

If the forms only work when markup regions is not used then that's more of something to do with FormBuilder itself.

Excuse my english! What I mean, was: the form WORKS using markup regions with $form->render AND also, the form works using FormBuilderHtmx in a regular template. @FireWire

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

Thanks to @Sanyaissues for the great work and PR that makes FormBuilderHtmx work when rendering in loops of repeater field content ?. Latest version on Github has the fix merged. Available for download on the main branch.

  • Like 1
  • Thanks 2
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...