From b9c5d3dd1c87c1daf5468e894da699aed20f7339 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 7 Jul 2026 13:35:18 -0700 Subject: [PATCH] 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. --- craft/src/components/forms/ContactForm.tsx | 11 +---- .../forms/FormContainer.toHtml.test.ts | 27 +++++++++++ craft/src/components/forms/FormContainer.tsx | 35 +++++++++++++- craft/src/utils/form-relay-wiring.ts | 46 +++++++++++++++++++ 4 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 craft/src/components/forms/FormContainer.toHtml.test.ts create mode 100644 craft/src/utils/form-relay-wiring.ts diff --git a/craft/src/components/forms/ContactForm.tsx b/craft/src/components/forms/ContactForm.tsx index 889de8b..37ae2e3 100644 --- a/craft/src/components/forms/ContactForm.tsx +++ b/craft/src/components/forms/ContactForm.tsx @@ -1,6 +1,7 @@ import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; +import { relayFormWiring } from '../../utils/form-relay-wiring'; interface ContactFormField { type: 'text' | 'email' | 'tel' | 'textarea' | 'select'; @@ -435,15 +436,7 @@ ContactForm.craft = { alignSelf: 'flex-start', }); - const useRelay = !!props.recipientEmail; - const fid = 'F' + Math.random().toString(36).slice(2, 8); - const actionAttr = useRelay ? `__WHP_FORM_ACTION__${fid}__` : esc(props.formAction || '#'); - const honeypot = useRelay - ? `` - : ''; - const marker = useRelay - ? `` - : ''; + const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction); return { html: `${marker}
diff --git a/craft/src/components/forms/FormContainer.toHtml.test.ts b/craft/src/components/forms/FormContainer.toHtml.test.ts new file mode 100644 index 0000000..200e73f --- /dev/null +++ b/craft/src/components/forms/FormContainer.toHtml.test.ts @@ -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' }, ''); + expect(html).toMatch(//); + 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' }, ''); + expect(html).not.toContain('WHP-FORM'); + expect(html).not.toContain('_gotcha'); + expect(html).toContain('action="/legacy"'); + expect(html).toContain(''); + }); +}); diff --git a/craft/src/components/forms/FormContainer.tsx b/craft/src/components/forms/FormContainer.tsx index 95f6d43..068bfe7 100644 --- a/craft/src/components/forms/FormContainer.tsx +++ b/craft/src/components/forms/FormContainer.tsx @@ -2,10 +2,13 @@ import React, { CSSProperties } from 'react'; import { useNode, Element, UserComponent } from '@craftjs/core'; import { Container } from '../layout/Container'; import { cssPropsToString } from '../../utils/style-helpers'; +import { relayFormWiring } from '../../utils/form-relay-wiring'; interface FormContainerProps { action?: string; method?: 'GET' | 'POST'; + recipientEmail?: string; + thankYouUrl?: string; style?: CSSProperties; children?: React.ReactNode; } @@ -51,6 +54,31 @@ const FormContainerSettings: React.FC = () => { return (
+
+ + 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 }} + /> +

+ 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. +

+
+ +
+ + 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 }} + /> +
+
${childrenHtml}`, + html: `${marker}
${body}
`, }; }; diff --git a/craft/src/utils/form-relay-wiring.ts b/craft/src/utils/form-relay-wiring.ts new file mode 100644 index 0000000..3164df7 --- /dev/null +++ b/craft/src/utils/form-relay-wiring.ts @@ -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, '"'); + +export interface RelayWiring { + /** true when a recipient is set (relay path); false = legacy formAction fallback */ + useRelay: boolean; + /** the `` 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 `` 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: ``, + actionAttr: `__WHP_FORM_ACTION__${fid}__`, + honeypot: ``, + }; +}