Skip to content

Commit 75ba0c5

Browse files
committed
Bugfixes, add ability to insert additional HTML attributes to form element on render
1 parent 6a604bd commit 75ba0c5

File tree

3 files changed

+239
-180
lines changed

3 files changed

+239
-180
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# FormBuilder HTMX Changelog
22

3+
## 1.0.1 2024-09-05
4+
5+
### Bugfixes, new features, recommended for all users
6+
7+
- Fix issue where multiple forms on the same page may not submit correctly
8+
- Add feature to pass additional HTML attributes that will be added to the `<form>` element when
9+
rendered on the page. Example added to README.md
10+
- Simplified identifying and returning form markup upon submission
11+
312
## 1.0.0 2024-06-12
413

514
- Update readme

FormBuilderHtmx.module.php

Lines changed: 205 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -1,179 +1,206 @@
1-
<?php namespace ProcessWire;
2-
3-
class FormBuilderHtmx extends Wire implements Module {
4-
5-
/**
6-
* Names of request headers to perist data during per-form request/response
7-
*/
8-
private const ID_REQUEST_HEADER = 'Fb-Htmx-Id';
9-
private const INDICATOR_REQUEST_HEADER = 'Fb-Htmx-Indicator';
10-
11-
public static function getModuleInfo()
12-
{
13-
return [
14-
'title' => 'FormBuilder HTMX',
15-
'summary' => __('Render HTMX ready FormBuilder forms submitted via AJAX', __FILE__),
16-
'version' => '100',
17-
'href' => 'https://processwire.com/talk/topic/29964-formbuilderhtmx-a-zero-configuration-pro-formbuilder-companion-module-to-enable-ajax-form-submissions/',
18-
'icon' => 'code',
19-
'autoload' => true,
20-
'singular' => true,
21-
'requires' => [
22-
'FormBuilder',
23-
'ProcessWire>=300',
24-
'PHP>=8.1'
25-
]
26-
];
27-
}
28-
29-
/**
30-
* {@inheritdoc}
31-
*/
32-
public function init()
33-
{
34-
$this->wire->set('htmxForms', $this);
35-
}
36-
37-
/**
38-
* {@inheritdoc}
39-
*/
40-
public function ready()
41-
{
42-
$this->addPostFormProcessingHook();
43-
}
44-
45-
/**
46-
* Provides a drop-in $htmxForms->render() replacement method for $forms->render() where an HTMX
47-
* powered form is desired
48-
* @param string $formName Name of form to render
49-
* @param FormBuilderRender $indicator CSS selector for the target activity element
50-
* @param array|Page $vars Value passed to FormBuilder::render()
51-
*/
52-
public function ___render(
53-
string $formName,
54-
array|Page $vars = [],
55-
?string $indicator = null
56-
): FormBuilderRender {
57-
// Hooks only this method call on initial render to only target HTMX forms
58-
$this->wire->addHookAfter('FormBuilderProcessor::render', function($e) use ($indicator) {
59-
$e->return = $this->renderHtmxFormMarkup($e->return, $indicator);
60-
61-
$e->removeHook(null);
62-
});
63-
64-
return wire('forms')->render($formName, $vars);
65-
}
66-
67-
/**
68-
* Add hooks to handle FormBuilder HTMX submissions after page render
69-
*/
70-
private function addPostFormProcessingHook(): void
71-
{
72-
$this->wire->addHookAfter(
73-
'Page::render',
74-
fn ($e) => $this->isHtmxRequest() && (
75-
!empty($e->return) && $e->return = $this->renderHtmxResponse($e->return)
76-
)
77-
);
78-
}
79-
80-
/**
81-
* Looks for FormBuilder request signatures to determine if the current request is both a
82-
* FormBuilder submission and an HTMX request
83-
*/
84-
private function isHtmxRequest(): bool
85-
{
86-
$input = wire('input');
87-
88-
$requestHeaders = getallheaders() + ['Hx-Request' => false, self::ID_REQUEST_HEADER => null];
89-
90-
return $input->requestMethod('post') &&
91-
$input->_submitKey &&
92-
$input->_InputfieldForm &&
93-
!!$requestHeaders[self::ID_REQUEST_HEADER] &&
94-
$requestHeaders['Hx-Request'] === 'true';
95-
}
96-
97-
/**
98-
* Handles modifying the form markup when initially rendered to the page
99-
* - Creates a unique ID to identify each form individually
100-
* - Adds wrapper with unique ID for HTMX response swap
101-
* - Adds HTMX attributes to form
102-
* - Adds request headers to persist data between rendering/processing/response loop
103-
*
104-
* @param string $renderedForm FormBuilder form markup
105-
* @param string $indicator HTML "loading" indicator element, falls back to pulling from
106-
* request headers if not present
107-
*/
108-
private function renderHtmxFormMarkup(string $renderedForm, ?string $indicator = null): string
109-
{
110-
$indicator = $this->indicatorSelector($indicator);
111-
$id = $this->htmxFormId();
112-
$renderedForm = $this->addFormBuilderHtmxContainer($renderedForm, $id);
113-
114-
// Headers are used to persist data between page render->submission->HTMX response
115-
$headers = json_encode([
116-
self::ID_REQUEST_HEADER => $id,
117-
self::INDICATOR_REQUEST_HEADER => $indicator
118-
]);
119-
120-
$htmxAttributes = array_filter([
121-
'hx-post',
122-
"hx-headers='{$headers}'",
123-
"hx-disabled-elt='button[type=submit]'",
124-
"hx-target='#{$id}'",
125-
"hx-swap='outerHTML'",
126-
$indicator ? "hx-indicator='{$indicator}'" : null,
127-
]);
128-
129-
return preg_replace('/(method="post")/i', implode(' ', $htmxAttributes), $renderedForm);
130-
}
131-
132-
/**
133-
* Inserts
134-
* @param string $renderedForm Rendered form markup
135-
* @param string|null $id Optional ID, otherwise will be pulled from headers, or generated
136-
*/
137-
private function addFormBuilderHtmxContainer(string $renderedForm, ?string $id = null): string
138-
{
139-
$id ??= $this->htmxFormId();
140-
141-
$markup = "<div id='{$id}' data-formbuilder-htmx>{$renderedForm}</div>";
142-
143-
// Markup regions removes comments before inserting content so this tag must be added to
144-
// indicate the end of the markup for a target form in place of the end FormBuilder comment
145-
$this->config->useMarkupRegions && $markup .= '<span data-formbuilder-htmx-end></span>';
146-
147-
return $markup;
148-
}
149-
150-
/**
151-
* Gets an existing ID from request headers, or creates a new one for rendering
152-
*/
153-
private function htmxFormId(): string
154-
{
155-
return getallheaders()[self::ID_REQUEST_HEADER] ?? 'fb-htmx-' . (new WireRandom)->alphanumeric(10);
156-
}
157-
158-
/**
159-
* Gets a selector for an indicator element from headers if it exists, or returns fallback param
160-
* @param string|null $indicator Optional fallback indicator
161-
*/
162-
private function indicatorSelector(?string $indicator = null): ?string
163-
{
164-
return getallheaders()[self::INDICATOR_REQUEST_HEADER] ?? $indicator;
165-
}
166-
167-
/**
168-
* Finds the form that has been submitted and extracts the markup to return what HTMX expects
169-
* @param string $renderedPageMarkup Rendered full page markup
170-
*/
171-
private function renderHtmxResponse(string $renderedPageMarkup): string
172-
{
173-
$pattern = "/\n?<div id=[\"']{$this->htmxFormId()}[\"']((.|\n|\r|\t)*)(<!--\/.FormBuilder-->|<span data-formbuilder-htmx-end><\/span>)/U";
174-
175-
preg_match($pattern, $renderedPageMarkup, $matches);
176-
177-
return $matches[0] ?? '';
178-
}
1+
<?php
2+
3+
namespace ProcessWire;
4+
5+
class FormBuilderHtmx extends Wire implements Module
6+
{
7+
8+
/**
9+
* Names of request headers to perist data during per-form request/response
10+
*/
11+
private const ID_REQUEST_HEADER = 'Fb-Htmx-Id';
12+
private const INDICATOR_REQUEST_HEADER = 'Fb-Htmx-Indicator';
13+
14+
/**
15+
* Holds the markup for a submitted form if it exists
16+
* @var string
17+
*/
18+
private string $processedForm = '';
19+
20+
public static function getModuleInfo()
21+
{
22+
return [
23+
'title' => 'FormBuilder HTMX',
24+
'summary' => __('Render HTMX ready FormBuilder forms submitted via AJAX', __FILE__),
25+
'version' => '101',
26+
'href' => 'https://processwire.com/talk/topic/29964-formbuilderhtmx-a-zero-configuration-pro-formbuilder-companion-module-to-enable-ajax-form-submissions/',
27+
'icon' => 'code',
28+
'autoload' => true,
29+
'singular' => true,
30+
'requires' => [
31+
'FormBuilder',
32+
'ProcessWire>=300',
33+
'PHP>=8.1'
34+
]
35+
];
36+
}
37+
38+
/**
39+
* {@inheritdoc}
40+
*/
41+
public function init()
42+
{
43+
$this->wire->set('htmxForms', $this);
44+
}
45+
46+
/**
47+
* {@inheritdoc}
48+
*/
49+
public function ready()
50+
{
51+
$this->addPostFormProcessingHook();
52+
}
53+
54+
/**
55+
* Provides a drop-in $htmxForms->render() replacement method for $forms->render() where an HTMX
56+
* powered form is desired
57+
* @param string $formName Name of form to render
58+
* @param array|Page $vars Value passed to FormBuilder::render()
59+
* @param string|null $indicator Optional CSS selector for the activity indicator shown
60+
* when the form is submitted
61+
* @param array $formAttributes Array of strings added to the <form> element on render
62+
*/
63+
public function ___render(
64+
string $formName,
65+
array|Page $vars = [],
66+
?string $indicator = null,
67+
array $formAttributes = []
68+
): FormBuilderRender {
69+
$this->wire->addHookAfter('FormBuilderProcessor::render', function($e) use ($indicator, $formAttributes) {
70+
// Check if this is a form submitted using FormBuilderHtmx
71+
// If so, tore markup and return just the form instead of the entire page on page render
72+
if (!$this->processedForm && $this->isHtmxSubmittedForm($e->return)) {
73+
$this->processedForm = $this->renderHtmxFormMarkup(
74+
$e->return,
75+
$indicator,
76+
$formAttributes
77+
);
78+
}
79+
80+
$e->return = $this->renderHtmxFormMarkup($e->return, $indicator, $formAttributes);
81+
82+
$e->removeHook(null);
83+
});
84+
85+
return wire('forms')->render($formName, $vars);
86+
}
87+
88+
/**
89+
* Add hooks to handle FormBuilder HTMX submissions after page render
90+
*/
91+
private function addPostFormProcessingHook(): void
92+
{
93+
$this->wire->addHookAfter(
94+
'Page::render',
95+
fn (HookEvent $e) => $this->isHtmxRequest() && $e->return = $this->processedForm
96+
);
97+
}
98+
99+
/**
100+
* Looks for FormBuilder request signatures to determine if the current request is both a
101+
* FormBuilder submission and an HTMX request
102+
*/
103+
private function isHtmxRequest(): bool
104+
{
105+
$input = wire('input');
106+
107+
$requestHeaders = getallheaders() + ['Hx-Request' => false, self::ID_REQUEST_HEADER => null];
108+
109+
return $input->requestMethod('post') &&
110+
$input->_submitKey &&
111+
$input->_InputfieldForm &&
112+
!!$requestHeaders[self::ID_REQUEST_HEADER] &&
113+
$requestHeaders['Hx-Request'] === 'true';
114+
}
115+
116+
/**
117+
* Determines if the rendered form markup was the form that was submitted
118+
* @param string $formMarkup Markup from FormBuilderProcessor::render hook event
119+
*/
120+
private function isHtmxSubmittedForm(string $formMarkup): bool
121+
{
122+
if (!$this->isHtmxRequest()) {
123+
return false;
124+
}
125+
126+
$submittedFormName = wire('input')->_InputfieldForm;
127+
128+
return preg_match(
129+
"/<div id=[\"']FormBuilderSubmitted[\"']\sdata-name=[\"']{$submittedFormName}[\"']>/",
130+
$formMarkup
131+
);
132+
133+
return $match;
134+
}
135+
136+
/**
137+
* Handles modifying the form markup when initially rendered to the page
138+
* - Creates a unique ID to identify each form individually
139+
* - Adds wrapper with unique ID for HTMX response swap
140+
* - Adds HTMX attributes to form
141+
* - Adds request headers to persist data between rendering/processing/response loop
142+
*
143+
* @param string $renderedForm FormBuilder form markup
144+
* @param string $indicator HTML "loading" indicator element, falls back to pulling from
145+
* request headers if not present
146+
* @param array $formAttributes Additional form attributes to be added to the <form> element
147+
*/
148+
private function renderHtmxFormMarkup(
149+
string $renderedForm,
150+
?string $indicator = null,
151+
array $formAttributes = []
152+
): string {
153+
$indicator = $this->indicatorSelector($indicator);
154+
$id = $this->htmxFormId();
155+
$renderedForm = $this->addFormBuilderHtmxContainer($renderedForm, $id);
156+
157+
// Headers are used to persist data between page render->submission->HTMX response
158+
$headers = json_encode([
159+
self::ID_REQUEST_HEADER => $id,
160+
self::INDICATOR_REQUEST_HEADER => $indicator
161+
]);
162+
163+
$htmxAttributes = array_filter([
164+
'hx-post',
165+
"hx-headers='{$headers}'",
166+
"hx-disabled-elt='button[type=submit]'",
167+
"hx-target='#{$id}'",
168+
"hx-swap='innerHTML'",
169+
$indicator ? "hx-indicator='{$indicator}'" : null,
170+
...$formAttributes
171+
]);
172+
173+
$htmxAttributes = array_unique($htmxAttributes);
174+
175+
return preg_replace('/(method="post")/i', implode(' ', $htmxAttributes), $renderedForm);
176+
}
177+
178+
/**
179+
* Inserts
180+
* @param string $renderedForm Rendered form markup
181+
* @param string|null $id Optional ID, otherwise will be pulled from headers, or generated
182+
*/
183+
private function addFormBuilderHtmxContainer(string $renderedForm, ?string $id = null): string
184+
{
185+
$id ??= $this->htmxFormId();
186+
187+
return "<div id='{$id}' data-formbuilder-htmx>{$renderedForm}</div>";
188+
}
189+
190+
/**
191+
* Gets an existing ID from request headers, or creates a new one for rendering
192+
*/
193+
private function htmxFormId(): string
194+
{
195+
return getallheaders()[self::ID_REQUEST_HEADER] ?? 'fb-htmx-' . (new WireRandom)->alphanumeric(10);
196+
}
197+
198+
/**
199+
* Gets a selector for an indicator element from headers if it exists, or returns fallback param
200+
* @param string|null $indicator Optional fallback indicator
201+
*/
202+
private function indicatorSelector(?string $indicator = null): ?string
203+
{
204+
return getallheaders()[self::INDICATOR_REQUEST_HEADER] ?? $indicator;
205+
}
179206
}

0 commit comments

Comments
 (0)