Forms
A Form is a kind: 'div' node — the same structural node type a plain Container is — permanently flagged sanilwb_form_enabled: '1' at creation, paired with six standalone field widgets (Text Input, Textarea, Select, Radio Group, Checkbox, Submit Button). v1 scope is deliberately narrow: submissions are stored in the plugin's own database table only — no email notification.
Files:
- admin/assets/js/src/page-builder/config/formPickerEntry.js —
FORM_PICKER_ENTRY,createFormDefaultOptions() - admin/assets/js/src/page-builder/config/widgets/structuralDialogTabs.js — Form's own Content-tab fields (
isFormbranch) - admin/assets/js/src/page-builder/config/widgets/{textInput,textarea,selectField,radioGroup,checkbox,submitButton}/ — the six field widgets
- includes/class-sanilwb-frontend-renderer.php —
render_form_open_tag(),render_root_form_wrapper_open()/_close() - includes/shortcodes/class-sanilwb-{text-input,textarea,select,radio-group,checkbox,submit-button}-shortcode.php
- includes/class-sanilwb-form-submissions-db.php —
SANILWB_Form_Submissions_DB - includes/class-sanilwb-form-ajax.php —
SANILWB_Form_Ajax - includes/class-sanilwb-form-submissions-list-table.php —
SANILWB_Form_Submissions_List_Table - admin/class-sanilwb-admin-form-submissions.php —
SANILWB_Admin_Form_Submissions - public/js/sanilwb-form.js — vanilla JS AJAX submit handler
The Form div wrapper
Follows the same pattern Off-Canvas established: a plain kind: 'div' node, permanently flagged at creation (sanilwb_form_enabled: '1'), shown as its own "Form" card in the Widget Picker's Layout group (alongside Container and Off Canvas — see WIDGET_GROUPS in WidgetPickerDialog.jsx), never a toggle buried inside a plain Container's own settings.
Identity. sanilwb_form_uid (createFormDefaultOptions()) — generated once at creation, regenerated on duplicate — is what SANILWB_Form_Submissions_DB rows are grouped by, not the div's own ephemeral id (never persisted — see Data Structure).
Contexts. ['page', 'template', 'site_layout'] — wider than Off-Canvas, which excludes template. A form embedded in a template renders identically wherever that template is used; each submission still records the real post_id it came from via a hidden field (see below), so there's no ambiguity about which page a template-embedded form's submission belongs to.
Style tab is the plain Container scheme, unchanged. Unlike Off-Canvas (its own offcanvas.styles.json, a reduced field set for a fixed-position drawer), a Form has no getDivStyleConfig() branch in structuralDialogTabs.js — it reads container.styles.json, same as an ordinary Container. Its own Content tab (below) is what's genuinely different, not its box styling.
Loop/Carousel excluded, same reasoning as Off-Canvas — structuralDialogTabs.js's guard is if ( ! isOffCanvas && ! isForm && 'page' === context ).
Content tab
| Section | Field | Notes |
|---|---|---|
| Action | Custom Action (sanilwb_form_custom_action, toggle) |
Off (default): the managed pipeline below applies. On: see Custom Action mode. |
| Action | Method (sanilwb_form_method) / Action URL (sanilwb_form_action_url) |
Only shown/used when Custom Action is on. |
| Submission | Submit via AJAX (sanilwb_form_ajax_enabled, toggle, default on) |
On: no page reload, Success/Error Message shown in place. Off: a real full-page POST, using the Redirect URLs instead. |
| On Success | Success Message (sanilwb_form_success_message) — shown when AJAX is on |
Default: "Thanks — your submission has been received." |
| On Success | Redirect URL (sanilwb_form_redirect_url) — shown when AJAX is off |
Falls back to the referring page (or the site's front page) if left empty — see resolve_redirect_target(). |
| On Error | Error Message (sanilwb_form_error_message) — shown when AJAX is on |
Default: "Something went wrong. Please try again." |
| On Error | Error Redirect URL (sanilwb_form_error_redirect_url) — shown when AJAX is off |
Same empty-value fallback as Redirect URL. |
All showIf-gated so only the relevant pair of fields is visible for the current Custom Action / AJAX combination — see structuralDialogTabs.js's own isCustomAction/isAjaxSubmit constants.
Custom Action mode
Turning Custom Action on opts a Form out of this plugin's own pipeline entirely — useful for e.g. a search box that should submit straight to WordPress's own search rather than being intercepted. render_form_open_tag() then renders a bare <form method="{Method}" action="{Action URL}"> with:
- no nonce, honeypot, or identity hidden fields,
- no
data-sanilwb-formattribute — sosanilwb-form.js's delegatedsubmitlistener (which only matches[data-sanilwb-form]) never intercepts it; the browser just navigates there directly.
None of the Submission/On Success/On Error settings apply in this mode.
Rendering
render_div()'s $is_root branch checks $is_form alongside its existing $is_carousel/$is_offcanvas checks and dispatches to render_root_form_wrapper_open()/_close() instead of the plain root-wrapper methods when true — these mirror render_root_wrapper_open()/_close() exactly, except the outer tag is built via render_form_open_tag() instead of a plain <div>. A non-root Form (nested inside another div) is handled inside render_div()'s own non-root branch the same way. Either way, both root and non-root Forms funnel through the one render_form_open_tag() method — there is no separate non-root Form code path to keep in sync.
Hidden fields (managed mode only)
Injected right after the opening <form> tag, in this order:
| Field | Purpose |
|---|---|
wp_nonce_field( SANILWB_Form_Ajax::NONCE_ACTION, 'nonce' ) |
Security — proves the request came from a page that actually rendered this form. |
action = sanilwb_form_submit |
Routing — the wp_ajax_(nopriv_)sanilwb_form_submit action name. |
sanilwb_form_uid |
Identity — which Form this submission belongs to (grouped by in the DB). |
sanilwb_form_label |
The Form div's own Admin Label at render time, so the admin screen's filter dropdown still has a readable name even if the div is later deleted. |
sanilwb_form_post_id |
The real page this submission came from (get_the_ID()), even when the Form itself lives inside a shared template. |
sanilwb_form_ajax_enabled |
Duplicated from the data-ajax-enabled attribute — a DOM attribute on the <form> tag is never part of the POSTed body, so the server needs its own transmitted copy to decide how to respond. |
sanilwb_form_redirect_url / sanilwb_form_error_redirect_url |
Same reasoning — only read when AJAX is off, but always rendered regardless of the toggle (harmless). |
Honeypot (sanilwb_form_hp, a plain text input) |
Spam protection — see below. |
The honeypot is visually hidden via inline off-screen positioning (position:absolute;left:-9999px;...), not type="hidden" or display:none — an unsophisticated bot that only skips real hidden inputs still fills it in. tabindex="-1" and autocomplete="off" keep it out of a real visitor's keyboard/autofill flow.
Custom Attributes
A Form div gets one Attributes target — "Form" (the outer <form> tag itself) — the same generic mechanism every div/widget gets; see Custom Attributes.
Field widgets
All six are context-agnostic (['page', 'template', 'site_layout']) and usable either inside a Form (for a real working submission) or standalone (for a developer's own JS to read/trigger off of — a field widget has no dependency on being inside a <form> tag). Every field's Style tab follows the own-tag pattern (like Button/Submit Button) — the whole shared scheme (Layout, Size, Typography, Background, Border, etc.) redirects to the field's own rendered tag via useSharedSections, not a separate wrapper.
| Widget | Type key | Rendered tag | Notes |
|---|---|---|---|
| Text Input | text_input |
<input type="text"> or <input type="email"> |
"Validate as Email" toggle (sanilwb_email_validation) switches the type attribute and turns on server-side is_email() re-validation. There is no separate Email Input widget type — email support was merged into Text Input via this toggle. |
| Textarea | textarea |
<textarea> |
Adds a Rows field (sanilwb_rows, default 4) — the one field with no Text Input equivalent. |
| Select | select |
<select><option>... |
Options via a repeater field (sanilwb_options, OptionRow.jsx — same component Social Icons uses), starts with 3 real options rather than an empty list. |
| Checkbox | checkbox |
Single mode: one <input type="checkbox"> + label. Group mode: N pairs inside .sanilwb-checkbox-group |
sanilwb_checkbox_mode (single/group) switches the whole shape — one widget type, not two. In Group mode, Label becomes an optional heading above the list (each option has its own label) and Required is hidden entirely — a group's "at least one checked" can't be expressed with the plain HTML required attribute. |
| Radio Group | radio_group |
N <input type="radio"> + label pairs inside .sanilwb-radio-group |
Same Options repeater as Select/Checkbox Group. |
| Submit Button | submit_button |
<button type="submit"> |
Own-tag pattern identical to the Button widget's .sanilwb-button, targeting .sanilwb-submit-btn instead — see class-sanilwb-submit-button-shortcode.php. Content tab has just one field (Button Text). |
Shared Label Position field (sanilwb_label_position: Above/Below/Inline) on Text Input/Textarea/Select — resolved via that field's own presets map in each widget's .styles.json into sanilwb_layout_type/sanilwb_direction, the same pattern Menu's sanilwb_menu_layout field uses. Checkbox/Radio Group have no single "input box" the way Text Input does — see each widget's own file docblock for how their own .sanilwb-checkbox-group/.sanilwb-radio-group wrapper plays that role instead.
Required (sanilwb_field_required) — a plain toggle on every field except Checkbox in Group mode (see above); read by the shortcode class to emit a real HTML required attribute.
The naming convention this whole feature depends on
A field's HTML name attribute is sanitize_title( admin_label ) — the widget's own Admin Label (Advanced tab), already enforced non-empty and globally unique across the whole layer tree (see Layer Item Admin Labels in the Page Builder app's own CLAUDE.md). This is deliberate and load-bearing: it means the same string doubles as both the real form field's name and the submission JSON blob's storage key, with no separate "Field Name" setting and no uid-to-label lookup anywhere — SANILWB_Form_Ajax::build_submission_fields() just reads $_POST keys directly. A Checkbox Group/Radio Group option list posts as name="{slug}[]" (an array), sanitized element-by-element and stored as a real JSON array rather than being flattened to one string.
Renaming a field's Admin Label after it's already collected submissions changes what key future submissions are stored under — past rows keep whatever key was current when they were submitted (no migration, matching this plugin's general no-backward-compatibility stance).
Submission storage
Table: wp_sanilwb_form_submissions (SANILWB_Form_Submissions_DB):
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
form_uid VARCHAR(64) NOT NULL
form_label VARCHAR(255) NOT NULL
post_id BIGINT UNSIGNED NOT NULL
submission_data LONGTEXT NOT NULL -- JSON: { admin_label_slug: value, ... }
created_at DATETIME
KEY form_uid (form_uid)
KEY created_at (created_at)
Methods: insert( array $data ) (form_uid, form_label, post_id, fields — JSON-encoded into submission_data), get_all( array $args ) (paginated, filterable by form_uid, free-text search matched with LIKE against submission_data), count(), and get_distinct_forms() (feeds the admin screen's filter dropdown). No update/trash/restore — submissions are ephemeral records, not content; only Delete exists.
AJAX submit endpoint
SANILWB_Form_Ajax is the one wp_ajax_nopriv_ handler in the plugin — every handler in class-sanilwb-ajax.php is admin-only (edit_pages-gated), documented as such in that file's own header, so this genuinely public endpoint lives in its own file rather than being folded in there.
Auth story — no current_user_can() check (a real visitor is never logged in), relying instead on:
check_ajax_referer( SANILWB_Form_Ajax::NONCE_ACTION, 'nonce' )— proves the request came from a page that actually rendered the form.- The honeypot (
SANILWB_Form_Ajax::HONEYPOT_FIELD) — if non-empty, responds with success anyway (never reveal to a bot that it was caught) but silently skips the DB insert.
RESERVED_POST_KEYS — the plumbing keys (action, nonce, _wp_http_referer, the honeypot field, sanilwb_form_post_id/_uid/_label/_ajax_enabled/_redirect_url/_error_redirect_url) excluded when building the submission's own field-value blob — everything else in $_POST is assumed to be a real field, keyed by its already-slugified name (see the naming convention above).
Sanitization — every value runs through sanitize_textarea_field() (safe for both single-line and multi-line text, strips tags without collapsing newlines) since this endpoint has no way to know which field TYPE a given POST key came from; a Checkbox Group/Radio Group array value is sanitized element-by-element recursively.
AJAX vs. non-AJAX response — respond_success()/respond_error() branch on the sanilwb_form_ajax_enabled hidden field: wp_send_json_success()/wp_send_json_error() when AJAX is on, or a real wp_safe_redirect() (to the Redirect URL / Error Redirect URL, falling back to the referring page or the site's front page) when it's off — the same one submit() method handles both cases, only the final response shape differs.
Frontend JS (public/js/sanilwb-form.js)
Vanilla JS (no jQuery), enqueued the same conditional/wp_footer-deferred/once-per-request pattern Carousel and Off-Canvas already use (render_form_open_tag()'s $form_enqueued static guard → SANILWB_Shortcode_Helpers::maybe_enqueue_form_assets()).
One delegated document-level submit listener matches any [data-sanilwb-form]. When data-ajax-enabled="0", it does nothing — the browser's own full-page POST proceeds and SANILWB_Form_Ajax::submit() redirects the response. Otherwise it preventDefault()s, disables the submit button, POSTs a plain FormData(form) to form.action (already pointing at admin-ajax.php — no separately localized ajaxUrl needed), and on the JSON response either swaps the form's children for the configured Success Message (handleSuccess()) or appends an Error Message beside the still-filled-in fields (handleError(), so the visitor can just retry without losing what they typed).
Runs identically inside the Page Builder's canvas iframe, but never actually intercepts there in practice — CanvasDiv.jsx's own onSubmit={ (e) => e.preventDefault() } stops the native submit event before it reaches this delegated listener.
Admin Submissions screen
SANILWB_Admin_Form_Submissions (admin/class-sanilwb-admin-form-submissions.php) registers a top-level admin submenu (sanilwb-form-submissions) rendering admin/templates/form-submissions/list-page.php, which hosts SANILWB_Form_Submissions_List_Table extends WP_List_Table — same shape as SANILWB_Templates_List_Table. A dropdown (populated from SANILWB_Form_Submissions_DB::get_distinct_forms()) filters to one Form; a free-text search box matches against actual submitted values (an email address, a name) via the DB's own LIKE search — the two are complementary, not redundant. Bulk actions are limited to Delete.
See also
- Custom Attributes — the generic Advanced-tab mechanism every field widget's own rendered tag (and the Form's own
<form>tag) can opt into. - Off-Canvas — the div-flag pattern Forms directly follows.
- Style Field JSON Reference — the own-tag redirect pattern every field widget's
.styles.jsonuses.