site-builder: relay wiring on FormContainer (template forms) + shared helper
The recipient field was only on the ContactForm block; templates build forms from FormContainer + InputField, so template-based contact forms had no way to set a target address. Add 'Send submissions to' + thank-you fields to FormContainer, and extract the marker/placeholder/honeypot into a shared form-relay-wiring helper so ContactForm and FormContainer can't drift.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||||
|
|
||||||
interface ContactFormField {
|
interface ContactFormField {
|
||||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||||
@@ -435,15 +436,7 @@ ContactForm.craft = {
|
|||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
});
|
});
|
||||||
|
|
||||||
const useRelay = !!props.recipientEmail;
|
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction);
|
||||||
const fid = 'F' + Math.random().toString(36).slice(2, 8);
|
|
||||||
const actionAttr = useRelay ? `__WHP_FORM_ACTION__${fid}__` : esc(props.formAction || '#');
|
|
||||||
const honeypot = useRelay
|
|
||||||
? `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`
|
|
||||||
: '';
|
|
||||||
const marker = useRelay
|
|
||||||
? `<!--WHP-FORM id="${fid}" recipient="${esc(props.recipientEmail)}" thankyou="${esc(props.thankYouUrl || '')}"-->`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
html: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { FormContainer } from './FormContainer';
|
||||||
|
|
||||||
|
const toHtml = (FormContainer as any).toHtml;
|
||||||
|
|
||||||
|
describe('FormContainer.toHtml relay wiring', () => {
|
||||||
|
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
|
||||||
|
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
|
||||||
|
expect(html).toMatch(/<!--WHP-FORM id="F[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||||
|
expect(html).toMatch(/action="__WHP_FORM_ACTION__F[0-9a-z]+__"/);
|
||||||
|
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
|
||||||
|
expect(html).toContain('name="_gotcha"');
|
||||||
|
// honeypot precedes the form's children
|
||||||
|
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
|
||||||
|
// marker id === action id
|
||||||
|
const mid = html.match(/id="(F[0-9a-z]+)"/)![1];
|
||||||
|
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without recipientEmail: legacy action/method, no marker or honeypot', () => {
|
||||||
|
const { html } = toHtml({ action: '/legacy', method: 'POST' }, '<input name="email">');
|
||||||
|
expect(html).not.toContain('WHP-FORM');
|
||||||
|
expect(html).not.toContain('_gotcha');
|
||||||
|
expect(html).toContain('action="/legacy"');
|
||||||
|
expect(html).toContain('<input name="email">');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,13 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from '../layout/Container';
|
import { Container } from '../layout/Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||||
|
|
||||||
interface FormContainerProps {
|
interface FormContainerProps {
|
||||||
action?: string;
|
action?: string;
|
||||||
method?: 'GET' | 'POST';
|
method?: 'GET' | 'POST';
|
||||||
|
recipientEmail?: string;
|
||||||
|
thankYouUrl?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -51,6 +54,31 @@ const FormContainerSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Send submissions to (email)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={props.recipientEmail || ''}
|
||||||
|
onChange={(e) => setProp((p: FormContainerProps) => { p.recipientEmail = e.target.value; })}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
|
||||||
|
Delivered via the site's contact-form relay. Requires the relay to be enabled on this server. Leave blank to use the Form Action URL below instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thank-you page URL (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={props.thankYouUrl || ''}
|
||||||
|
onChange={(e) => setProp((p: FormContainerProps) => { p.thankYouUrl = e.target.value; })}
|
||||||
|
placeholder="/thank-you (blank = hosted page)"
|
||||||
|
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
||||||
<input
|
<input
|
||||||
@@ -110,6 +138,8 @@ FormContainer.craft = {
|
|||||||
props: {
|
props: {
|
||||||
action: '#',
|
action: '#',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
recipientEmail: '',
|
||||||
|
thankYouUrl: '',
|
||||||
style: {
|
style: {
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
@@ -134,7 +164,10 @@ FormContainer.craft = {
|
|||||||
padding: '24px',
|
padding: '24px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
|
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action);
|
||||||
|
const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST
|
||||||
|
const body = honeypot + childrenHtml; // honeypot as first child
|
||||||
return {
|
return {
|
||||||
html: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`,
|
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Shared contact-form relay wiring for HTML export.
|
||||||
|
*
|
||||||
|
* Any form component that wants to deliver submissions by email (ContactForm,
|
||||||
|
* FormContainer, ...) emits the SAME marker/placeholder/honeypot shape so the
|
||||||
|
* WHP publish step (`fs_rewrite_contact_forms`) can provision a token, rewrite
|
||||||
|
* the action, and strip the marker. Keeping this in one place means the two
|
||||||
|
* components can't drift apart (a drift would leak the recipient into published
|
||||||
|
* HTML — see PR #47 review).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const esc = (s: unknown): string =>
|
||||||
|
String(s ?? '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
export interface RelayWiring {
|
||||||
|
/** true when a recipient is set (relay path); false = legacy formAction fallback */
|
||||||
|
useRelay: boolean;
|
||||||
|
/** the `<!--WHP-FORM ...-->` marker (stripped at publish); '' when not relay */
|
||||||
|
marker: string;
|
||||||
|
/** value for the form's `action` attribute (placeholder when relay, else the escaped fallback) */
|
||||||
|
actionAttr: string;
|
||||||
|
/** hidden honeypot `<input>` to render as the form's first child; '' when not relay */
|
||||||
|
honeypot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param recipientEmail the "Send submissions to" address (empty/undefined = no relay)
|
||||||
|
* @param thankYouUrl optional post-submit redirect (blank = hosted thank-you page)
|
||||||
|
* @param fallbackAction the form's existing action to use when no recipient is set
|
||||||
|
*/
|
||||||
|
export function relayFormWiring(
|
||||||
|
recipientEmail: string | undefined,
|
||||||
|
thankYouUrl: string | undefined,
|
||||||
|
fallbackAction: string | undefined,
|
||||||
|
): RelayWiring {
|
||||||
|
if (!recipientEmail) {
|
||||||
|
return { useRelay: false, marker: '', actionAttr: esc(fallbackAction || '#'), honeypot: '' };
|
||||||
|
}
|
||||||
|
const fid = 'F' + Math.random().toString(36).slice(2, 8);
|
||||||
|
return {
|
||||||
|
useRelay: true,
|
||||||
|
marker: `<!--WHP-FORM id="${fid}" recipient="${esc(recipientEmail)}" thankyou="${esc(thankYouUrl || '')}"-->`,
|
||||||
|
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
|
||||||
|
honeypot: `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user