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:


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-form attribute — so sanilwb-form.js's delegated submit listener (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 anywhereSANILWB_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:

  1. check_ajax_referer( SANILWB_Form_Ajax::NONCE_ACTION, 'nonce' ) — proves the request came from a page that actually rendered the form.
  2. 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 responserespond_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.json uses.