JetFormBuilder: register a custom Form Action
SkillAI & modelsRegisters a custom JetFormBuilder Form Action — a server-side
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the JetFormBuilder: register a custom Form Action skill
What this skill tells your AI
The instructions your AI receives, as published by lonsdale201/wp-agent-skills in jetformbuilder/jfb-form-action/SKILL.md and read by ahel’s review.
A Form Action is a server-side handler that fires after a JFB form is submitted: send email, subscribe to a CRM, append to a Google Sheet, post to Slack, charge a card, etc. Each form can have multiple actions in a chain; each action sees the request data and the chain context, can write back to the response, and can halt the chain by throwing.
This skill covers the official end-to-end API as observed in JFB 3.5.x and three production companion plugins. It includes both field-mapping patterns plugins use in the wild (dynamic "Add row" and fixed-key), plus the multi-select / token-field pattern.
API stability note
The Base action class, the 'jet-form-builder/actions/register' PHP hook, the persistence model (_jf_actions post meta), and the Action_Exception error pattern have been stable across JFB 3.x. The JS-side window.JetFBActions.addAction() and window.jfb.blocksToActions.useFields() are also stable in the source observed. The category and docHref action editor properties are a community convention (used by Fluent CRM and others) that JFB's action picker UI consumes, but they are not part of the PHP API — keep them in JS only.
When to use this skill
- Building a JFB companion plugin that needs to do something on form submit.
- The user mentions an integration target (CRM, payment, webhook, file storage, ticketing).
- The diff/files contain
extends Base(action namespace),action_attributes,do_action,JetFBActions.addAction,useFields,Action_Exception, or'jet-form-builder/actions/register'.
Architecture in one paragraph
JFB's action manager fires 'jet-form-builder/actions/register' at init priority 99 with a Manager instance. Each plugin calls $manager->register_action_type( new MyAction() ) with a class extending Jet_Form_Builder\Actions\Types\Base. The class declares its settings shape in action_attributes(), exposes user-facing labels via editor_labels() / editor_labels_help(), and implements do_action( array $request, Action_Handler $handler ) which runs at submit time. On the JS side, the action editor is React-based: the plugin enqueues a script on 'jet-form-builder/editor-assets/before' and calls window.JetFBActions.addAction( id, Component, config ) to register the editor UI. The component receives props.settings and props.onChangeSetting, uses window.jfb.blocksToActions.useFields() to query the form's current fields, and renders standard @wordpress/components. The user's configuration persists as part of the form's _jf_actions post meta (a JSON array of all actions on the form). Errors during execution are surfaced by throwing Action_Exception( $code, $message ).
Step 1 — PHP: extend the Base action class
<?php
namespace MyPlugin\Actions;
use Jet_Form_Builder\Actions\Types\Base;
use Jet_Form_Builder\Actions\Action_Handler;
use Jet_Form_Builder\Exceptions\Action_Exception;
class SubscribeAction extends Base {
public function get_id() {
return 'myplugin_subscribe';
}
public function get_name() {
return __( 'My CRM Subscribe', 'myplugin' );
}
public function self_script_name() {
// Used as the localize handle in JS — must be unique.
return 'MyPluginSubscribeData';
}
public function action_attributes() {
return array(
'list_id' => array( 'default' => array() ), // multi-select target
'tag_ids' => array( 'default' => array() ), // multi-select tags
'fields_map' => array( 'default' => array( // fixed-key mapping
'email' => '',
'first_name' => '',
'last_name' => '',
) ),
'double_optin' => array( 'default' => true ),
'duplicate_message' => array( 'default' => '' ),
);
}
public function editor_labels() {
return array(
'list_id' => __( 'Lists', 'myplugin' ),
'tag_ids' => __( 'Tags', 'myplugin' ),
'fields_map' => __( 'Field map', 'myplugin' ),
'email' => __( 'Email field', 'myplugin' ),
'first_name' => __( 'First name', 'myplugin' ),
'last_name' => __( 'Last name', 'myplugin' ),
);
}
public function editor_labels_help() {
return array(
'email' => __( 'The form field that holds the subscriber email address.', 'myplugin' ),
);
}
public function action_data() {
// Pushed to JS as MyPluginSubscribeData (or whatever self_script_name returns).
return array(
'lists' => $this->get_remote_lists(),
'tags' => $this->get_remote_tags(),
'field_map' => array(
array( 'key' => 'email', 'label' => __( 'Email field', 'myplugin' ), 'required' => true ),
array( 'key' => 'first_name', 'label' => __( 'First name field', 'myplugin' ) ),
array( 'key' => 'last_name', 'label' => __( 'Last name field', 'myplugin' ) ),
),
);
}
public function do_action( array $request, Action_Handler $handler ) {
$email = $this->get_mapped_value( 'email', $request );
if ( ! is_email( $email ) ) {
throw new Action_Exception( 'failed', __( 'A valid email is required.', 'myplugin' ) );
}
$payload = array(
'email' => $email,
'first_name' => $this->get_mapped_value( 'first_name', $request ),
'last_name' => $this->get_mapped_value( 'last_name', $request ),
'lists' => (array) ( $this->settings['list_id'] ?? array() ),
'tags' => (array) ( $this->settings['tag_ids'] ?? array() ),
);
$result = $this->call_remote_api( $payload );
if ( is_wp_error( $result ) ) {
throw new Action_Exception( 'failed', $result->get_error_message() );
}
// Hand off to later actions if useful.
$handler->add_context_once( array(
'subscriber_id' => $result['id'] ?? '',
) );
}
private function get_mapped_value( string $key, array $request ): string {
$map = (array) ( $this->settings['fields_map'] ?? array() );
$field_id = (string) ( $map[ $key ] ?? '' );
if ( '' === $field_id ) {
return '';
}
$value = $request[ $field_id ] ?? '';
if ( is_array( $value ) ) {
$value = reset( $value ) ?: '';
}
return is_scalar( $value ) ? (string) $value : '';
}
}
Required methods: get_id, get_name, do_action. Strongly recommended: action_attributes, editor_labels, self_script_name. Optional: action_data, editor_labels_help, dependence, unsupported_events.
get_id() MUST be a stable, unique slug — changing it after release strands all existing user configurations. Treat it as a public identifier.
Step 2 — PHP: register the action
add_action(
'jet-form-builder/actions/register',
function ( $manager ) {
$manager->register_action_type( new \MyPlugin\Actions\SubscribeAction() );
}
);
The hook fires once per request at init priority 99 with a Jet_Form_Builder\Actions\Manager instance. Don't store the action somewhere else — register it here and let JFB own the lifecycle.
Step 3 — PHP: enqueue the action editor JS
add_action(
'jet-form-builder/editor-assets/before',
function () {
$handle = 'myplugin-action-editor';
$dependencies = array(
'jet-fb-components', // exposes window.jfb.components and window.jfb.blocksToActions
'wp-element', // React via @wordpress/element
'wp-components', // SelectControl, TextControl, ToggleControl, etc.
'wp-i18n',
);
// JFB v2 action editor handles. Conditionally added — they are
// only registered when the modern action editor is active.
// Without these, your script can race the modern API and run
// before window.jfb.actions is populated, causing dual-registration
// failures and editor crashes (the JSON.parse "[object Object]"
// error in form.builder.js's _jf_gateways meta state is a known
// symptom).
foreach ( array( 'jet-fb-actions-v2', 'jet-fb-blocks-v2-to-actions-v2' ) as $maybe_dep ) {
if ( wp_script_is( $maybe_dep, 'registered' ) ) {
$dependencies[] = $maybe_dep;
}
}
wp_enqueue_script(
$handle,
plugins_url( 'assets/js/action-editor.js', __FILE__ ),
$dependencies,
'1.0.0',
true
);
if ( function_exists( 'wp_set_script_translations' ) ) {
wp_set_script_translations( $handle, 'myplugin' );
}
}
);
jet-fb-components is the script handle that exposes window.jfb.blocksToActions.useFields(), window.jfb.components.*, and window.JetFBActions.addAction(). Always declare it.
jet-fb-actions-v2 and jet-fb-blocks-v2-to-actions-v2 are the modern action editor v2 handles. They are only registered on JFB versions that ship the v2 editor. Add them conditionally with wp_script_is( $handle, 'registered' ) — never declare them unconditionally, because on older JFB versions they don't exist and the dependency will silently break script loading.
Step 4 — JS: register the action editor
JFB has two action registration APIs in the wild and both can be present at the same time:
- Modern:
window.jfb.actions.registerAction({ type, edit, ...config })— the v2 action editor. - Legacy:
window.JetFBActions.addAction( type, component, config )— the older wp.data-store-driven flow, pluswp.data.dispatch('jet-forms/actions').registerAction(...)for category/docHref metadata.
Critical rule: register through ONE path only — never both. Calling addAction() AND wp.data.dispatch('jet-forms/actions').registerAction(...) AND jfb.actions.registerAction(...) together corrupts the editor's data store and produces baffling failures (the JSON.parse "[object Object]" crash on _jf_gateways meta in form.builder.js is a known symptom of dual registration on JFB versions where both APIs co-exist).
The recommended pattern is a wrapper that prefers modern, falls back to legacy:
( function registerMyPluginAction( wp, JetFBActions, actionData, jfb ) {
if ( ! wp ) {
return;
}
const hasModernAction = jfb?.actions && typeof jfb.actions.registerAction === 'function';
const hasLegacyAction = JetFBActions && typeof JetFBActions.addAction === 'function';
if ( ! hasModernAction && ! hasLegacyAction ) {
// Neither API available — JFB editor not loaded. Fail silently.
return;
}
const addAction = hasLegacyAction
? JetFBActions.addAction.bind( JetFBActions )
: () => {};
const { Fragment, createElement } = wp.element;
const { __ } = wp.i18n;
const { TextControl, ToggleControl, Button, Notice } = wp.components;
const useFields = jfb?.blocksToActions?.useFields;
const jfbComponents = jfb?.components || {};
// ONE entry point that picks the right API at runtime.
const registerAction = ( type, component, config ) => {
if ( hasModernAction ) {
jfb.actions.registerAction( {
type,
edit: component,
...config,
} );
return;
}
addAction( type, component, config );
};
function MyPluginSubscribeEdit( props ) {
const { settings = {}, onChangeSetting, label } = props;
const data = actionData || {};
const fieldOpts = useFields ? useFields( { withInner: false, placeholder: '--' } ) : [];
// ... field mapping, multi-select, etc. (Step 5)
return createElement( Fragment, null /* children */ );
}
registerAction(
'myplugin_subscribe', // MUST match get_id() in PHP
MyPluginSubscribeEdit,
{
category: 'communication',
docHref: 'https://example.com/docs/myplugin-subscribe',
provideEvents: () => [ 'MYPLUGIN.SUCCESS', 'MYPLUGIN.FAILURE' ], // see jfb-action-events
}
);
}( window.wp || false, window.JetFBActions || false, window.MyPluginSubscribeData || {}, window.jfb || {} ) );
Notes on the wrapper:
- The IIFE pattern (immediate-invocation with
window.*arguments) keeps the module self-contained and lets any of the dependencies be missing without throwing — important becausewindow.jfbandwindow.JetFBActionsmay be undefined on older JFB versions or if the editor failed to load. hasModernActionandhasLegacyActionare checked once; the wrapper picks one path and never switches mid-call.- The modern API wraps the component in
edit, the legacy passes it positionally — abstract this away in the wrapper so the rest of your code is API-agnostic. - Don't add a separate
wp.data.dispatch('jet-forms/actions').registerAction(...)call alongside this. Older skills suggested it as a "data store mirror" — that advice is obsolete and creates the dual-registration bug. The modern API handles store registration internally; the legacy path'saddActionis sufficient on its own.
If neither API is available (! hasModernAction && ! hasLegacyAction), your script is loaded but the JFB editor isn't — bail silently. Don't render an error UI.
Step 5 — Field mapping: pick the right pattern
Two patterns are common and serve different needs.
5a. Fixed-key mapping (preferred when target schema is known)
Use when your integration has a fixed set of target fields (CRM with first_name/last_name/email/phone). The user maps form fields to these named slots.
const fieldMapDef = data.field_map || []; // from PHP action_data()
const currentMap = settings.fields_map || {};
const handleMap = ( key, value ) => {
onChangeSetting( { ...currentMap, [ key ]: value }, 'fields_map' );
};
// JFB ships an optional table layout — check if available, fall back to plain controls.
const Table = jfbComponents.TableListContainer;
const Head = jfbComponents.TableListHead;
const Row = jfbComponents.TableListRow;
const renderRows = fieldMapDef.map( ( def ) =>
el( Row, {
key: def.key,
tag: def.key,
label: def.label,
help: def.help,
isRequired: !! def.required,
},
( ) => el( StyledSelect, {
value: currentMap[ def.key ] || '',
options: fieldOpts,
onChange: ( v ) => handleMap( def.key, v ),
} )
)
);
const fixedMapUI = ( Table && Head && Row )
? el( Table, null,
el( Head, { columns: [ __( 'Target field', 'myplugin' ), __( 'Form field', 'myplugin' ) ] } ),
renderRows
)
: el( Fragment, null,
fieldMapDef.map( ( def ) =>
el( StyledSelect || 'select', {
key: def.key,
label: def.label,
value: currentMap[ def.key ] || '',
options: fieldOpts,
onChange: ( v ) => handleMap( def.key, v ),
} )
)
);
PHP shape: 'fields_map' => [ 'email' => 'form_email_id', 'first_name' => 'form_fname_id', ... ].
5b. Dynamic "Add row" mapping (use when the target schema is open-ended)
Use when the target system has arbitrary columns / properties and the user picks both sides — e.g. Google Sheets where the user types arbitrary column names.
const rows = Array.isArray( settings.field_map ) ? settings.field_map : [];
const updateRow = ( i, key, value ) => {
onChangeSetting(
rows.map( ( r, idx ) => idx === i ? { ...r, [ key ]: value } : r ),
'field_map'
);
};
const addRow = () => onChangeSetting(
rows.concat( [ { column_header: '', form_field: '' } ] ),
'field_map'
);
const removeRow = ( i ) => onChangeSetting(
rows.filter( ( _, idx ) => idx !== i ),
'field_map'
);
const dynamicMapUI = el( 'div', { className: 'myplugin-mapping' },
rows.map( ( row, i ) => el( 'div', { key: i, className: 'myplugin-mapping-row' },
el( TextControl, {
label: __( 'Column', 'myplugin' ),
value: row.column_header,
onChange: ( v ) => updateRow( i, 'column_header', v ),
} ),
el( StyledSelect || 'select', {
label: __( 'Form field', 'myplugin' ),
value: row.form_field,
options: fieldOpts,
onChange: ( v ) => updateRow( i, 'form_field', v ),
} ),
el( Button, {
isDestructive: true,
isSmall: true,
onClick: () => removeRow( i ),
}, __( 'Remove', 'myplugin' ) )
) ),
el( Button, { variant: 'secondary', onClick: addRow },
__( 'Add row', 'myplugin' )
)
);
PHP shape: 'field_map' => [ [ 'column_header' => 'Email', 'form_field' => 'email_field_id' ], ... ].
5c. Multi-select (lists, tags, channels)
For multi-value selects (e.g. CRM lists or tags), use JFB's FormLabeledTokenField if available, fall back to SelectControl with multiple: true.
const lists = data.lists || []; // [{ value, label }]
const selectedListIds = ( settings.list_id || [] ).map( String );
// FormLabeledTokenField (JFB-shipped) handles value<->label translation
// internally: pass IDs as `value`, pass {value, label} objects as
// `suggestions`, and onChange receives IDs back. This is unlike the
// standard @wordpress/components FormTokenField, which is string-only.
const handleListTokens = ( tokens ) => {
onChangeSetting( ( tokens || [] ).map( String ), 'list_id' );
};
const listsUI = FormLabeledTokenField
? el( FormLabeledTokenField, {
label: __( 'Lists', 'myplugin' ),
value: selectedListIds,
suggestions: lists, // array of { value, label }
onChange: handleListTokens,
__experimentalExpandOnFocus: true,
} )
: el( StyledSelect || 'select', {
label: __( 'Lists', 'myplugin' ),
value: selectedListIds,
options: lists,
multiple: true,
onChange: ( v ) => onChangeSetting( ( v || [] ).map( String ), 'list_id' ),
} );
FormLabeledTokenField is a token (chip) input that feels native in Gutenberg admin. Note its API differs from the standard @wordpress/components/FormTokenField: the labeled variant accepts { value, label } suggestions and translates between displayed labels and stored IDs internally — you store IDs, the user sees labels. The fallback path uses the plain string-based SelectControl. Always include the fallback; the FormLabeledTokenField component is shipped by JFB and not guaranteed across versions.
useFields — the form fields hook
window.jfb.blocksToActions.useFields( opts ) is a React hook returning the form's current fields as [ { value, label }, ... ], suitable for any select control. Common options:
withInner(boolean) — include nested fields (repeater children, etc.). Default: depends on JFB version; pass explicitly.placeholder(string) — first option label, e.g.'--'. If passed, prepends{ value: '', label: placeholder }.
There is no REST call here; it's local React state derived from the editor's current form blocks. The hook re-runs when fields change, so the dropdown reflects edits in real time.
provideEvents — advertise custom events the action dispatches
If your action dispatches custom JFB events (defined per the jfb-action-events skill via the 'jet-form-builder/event-types' filter), you MUST advertise them in the JS registration. Otherwise the events exist in the global registry but are invisible in the per-action event picker — meaning no admin can wire any action to them.
Pass provideEvents in the same config object you give the registerAction wrapper (Step 4):
registerAction(
'myplugin_subscribe',
Component,
{
category: 'communication',
docHref: 'https://example.com/docs/subscribe',
provideEvents: ( settings ) => [
'MYPLUGIN.SUCCESS',
'MYPLUGIN.ALREADY_EXISTS',
],
}
);
The callback receives the action's current settings — return a different list per configuration if needed (e.g. only advertise 'MYPLUGIN.FAILURE' when error notifications are enabled). The wrapper passes this through to whichever underlying API is active (jfb.actions.registerAction or JetFBActions.addAction); both honour provideEvents. See jfb-action-events for the full visibility model (always / gateway / dynamic / provided).
Category and doc link — community convention, not PHP API
The category and docHref properties passed in the registration config are JS-only conventions consumed by JFB's action picker UI for grouping and rendering a help icon. They are not exposed by the PHP Base class — there is no get_category() or get_doc_link() method. The Fluent CRM plugin pioneered this; if you follow the same shape, your action will group and link consistently with theirs.
registerAction( 'myplugin_subscribe', Component, {
category: 'communication', // free-form; common values: 'communication', 'integration', 'advanced', 'utility'
docHref: 'https://example.com/docs/...',
} );
Do NOT add a separate wp.data.dispatch('jet-forms/actions').registerAction(...) call alongside this. Older versions of this skill recommended that pattern as a "data store mirror", but on JFB versions that ship the modern jfb.actions.registerAction API, the dual call corrupts the editor's data store and triggers JSON.parse "[object Object]" failures in form.builder.js. The wrapper from Step 4 already routes to whichever API is available, and that single call is enough — both APIs internally write to the data store.
Don't push category/docHref into PHP action_data() expecting JFB to render it — there's no consumer there. Keep it JS-side.
Native components and JFB-shipped helpers
Use these directly:
| Source | Component | Purpose |
|---|---|---|
@wordpress/components | TextControl, TextareaControl, ToggleControl, CheckboxControl, SelectControl, RadioControl, RangeControl, Button, Notice, PanelBody, Spinner | Standard Gutenberg controls — the same set as jfb-form-sidebar-panel. |
window.jfb.components | StyledSelect | Styled wrapper around SelectControl matching JFB action editor look. |
window.jfb.components | FormLabeledTokenField | Token / chip input for multi-value selects. |
window.jfb.components | TableListContainer, TableListHead, TableListRow, TableListStyle | Table layout helpers for fixed-key mapping. |
window.jfb.blocksToActions | useFields | React hook returning the form's current fields. |
window.jfb.actions | registerAction({ type, edit, ...config }) | Modern action registration API. Single object argument with edit for the component. |
window.JetFBActions | addAction( type, component, config ) | Legacy action registration API. Positional args. Use the wrapper from Step 4 to bridge both. |
Always check existence before using JFB-shipped components — they are not part of a documented stable contract, and a future JFB version may move them. A graceful fallback to wp.components keeps the editor usable across versions.
Error handling: Action_Exception
Throw from do_action() to halt the action chain and surface a message to the user:
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 22
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
jfb-form-action- Source
- github.com/lonsdale201/wp-agent-skills