settings = new Admin\Inc\Form\Settings();
$this->entry_settings = new Admin\Inc\Entry\Settings();
$this->builder = new Admin\Inc\Form\Builder();
}
$this->csrf_token_manager = new CsrfTokenManager();
$this->actions();
$this->filters();
\add_shortcode('form', [$this, 'shortcode']);
}
public function shortcode($atts)
{
$atts = shortcode_atts(
array(
'name' => ''
),
$atts,
'ajaxy-forms'
);
$name = $atts['name'] ?? '';
if ('' === $name) {
return '';
} else {
$form = Data::parse_form($name);
if ($form) {
$parsed_form = $this->create_form($form, $form->get_initial_data());
if (is_wp_error($parsed_form)) {
return \sprintf(__('Error creating form for %1$s: %2$s', "ajaxy-forms"), $name, $parsed_form->get_error_message());
}
$this->on_submit($name, $parsed_form);
return $this->render($name, $parsed_form, $form->get_theme());
} else {
return \sprintf(__('Form %s not found', "ajaxy-forms"), $name);
}
}
}
public function create_form(Form $form, $initial_data = null)
{
// Set up the Validator component
$validator = Validation::createValidator();
// Set up the Form component, the registry is kept so that the options
// a type actually accepts can be looked up before handing them over.
$this->form_registry = new FormRegistry(
[
new CoreExtension(),
new CsrfExtension($this->csrf_token_manager),
new ValidatorExtension($validator),
],
new ResolvedFormTypeFactory()
);
$formFactory = new FormFactory($this->form_registry);
$options = \array_replace([
'csrf_protection' => false,
'csrf_message' => __('It appears you\'ve already submitted this form, or it may have timed out. Please refresh the page and try again.', "ajaxy-forms"),
'csrf_token_id' => 'form_intention_' . $form->get_name(),
'allow_extra_fields' => true,
'required' => false,
], $form->get_options());
unset($options['messages']);
if (isset($options['attr']['class'])) {
$options['attr']['class'] .= ' ajaxy-form';
} else {
$options['attr']['class'] = 'ajaxy-form';
}
$options['action'] = $form->is_ajax() ? get_rest_url(null, sprintf('ajaxy-forms/v1/form/%s', $form->get_name())) : '';
$builder = $formFactory->createNamedBuilder($form->get_name(), Type\FormType::class, $initial_data, $options);
$start_repeater = '';
$repeater = [];
foreach ($form->get_fields() as $field) {
$field_options = $field;
$field['constraints'] = $field['constraints'] ?? [];
if (isset($field['required']) && $field['required'] == "1") {
$required_message = $field_options['invalid_message'] ?? __('This field is required.');
// An unticked checkbox holds the string "0", which NotBlank
// accepts, so what has to be asserted is that it is ticked.
$required_constraint = \in_array($field['type'], ['checkbox', 'radio'], true) ? 'is_true' : 'not_blank';
$field['constraints'][] = ['type' => $required_constraint, 'message' => $required_message];
$field['required'] = '';
if ($field['type'] == 'email') {
$check_constraint = \array_filter($field['constraints'], function ($constraint) {
return $constraint['type'] == 'email';
});
if (count($check_constraint) == 0) {
$field['constraints'][] = ['type' => 'email', 'message' => $required_message];
}
}
}
if (!empty($field['constraints'])) {
$field_options['constraints'] = \array_filter(\array_map(function ($constraint) {
return Constraints::getInstance()->create_constraint($constraint);
}, $field['constraints'] ?? []), function ($constraint) {
return !is_null($constraint);
});
} else {
unset($field_options['constraints']);
}
if (!empty($field['attr']) || !empty($field['attributes'])) {
$field_options['attr'] = $field['attr'] ?? $field['attributes'];
}
if (isset($field_options['rounding_mode'])) {
$field_options['rounding_mode'] = intval($field_options['rounding_mode']);
}
if (isset($field_options['html5'])) {
$field_options['html5'] = \boolval($field_options['html5']);
}
if ($field['type'] == 'number') {
$field_options['input'] = 'string';
$field_options['data'] = '0';
}
if ($field['type'] == 'checkbox') {
if (isset($field_options['checked'])) {
if ($field_options['checked'] == "1") {
$field_options['data'] = true;
}
unset($field_options['checked']);
}
}
if (in_array($field['type'], ['choice', 'radio_group', 'checkbox_group'])) {
foreach (['multiple', 'expanded'] as $flag) {
if (isset($field_options[$flag])) {
$field_options[$flag] = \boolval($field_options[$flag]);
}
}
if (isset($field_options['choices']) && is_array($field_options['choices'])) {
array_walk($field_options['choices'], function (&$choice) {
if (!isset($choice['value']) || $choice['value'] == '') {
$choice['value'] = \sanitize_text_field($choice['label']);
}
});
$selected = array_values(\array_filter($field_options['choices'], function ($choice) {
return $choice['selected'] ?? false;
}));
if (count($selected) > 0) {
$field_options['data'] = $field_options['multiple'] ?? false
? \array_column($selected, 'value')
: $selected[0]['value'];
}
// Symfony keys the choices by their label and holds the
// submitted value, not the other way around.
$field_options['choices'] = \array_combine(\array_column($field_options['choices'], 'label'), \array_column($field_options['choices'], 'value'));
}
}
if ($field['type'] == 'time') {
$field_options['input'] = 'array';
// $field_options['html5'] = false;
}
if ($field['type'] == 'date' || $field['type'] == 'time' || $field['type'] == 'datetime' || $field['type'] == 'birthday') {
if (!isset($field_options['widget']) || !$field_options['widget'] || $field_options['widget'] == 'single_text') {
$field_options['input'] = 'string';
$field_options['widget'] = 'single_text';
} else {
$field_options['input'] = 'array';
}
}
unset($field_options['name']);
unset($field_options['type']);
$sanitize_html = !empty($field_options['sanitize_html']);
unset($field_options['sanitize_html']);
if (isset($field_options['field_type'])) {
$field_options['type'] = $field_options['field_type'];
unset($field_options['field_type']);
}
$field_options = $this->filter_options($field['type'], $field_options);
try {
if ($field['type'] == 'repeater_start') {
$builder->add($field['name'] . '-start', $this->get_field('repeater_start'), $field_options);
$start_repeater = $field['name'];
continue;
}
if ($start_repeater && $start_repeater != '') {
if ($field['type'] == 'repeater_end') {
$builder->add($start_repeater, $this->get_field('repeater_end'), $field_options + [
'name' => $start_repeater,
'fields' => $repeater
]);
$start_repeater = '';
$repeater = [];
} else {
$repeater[] = [
'name' => $field['name'],
'type' => $field['type'],
'class' => $this->get_field($field['type']),
'options' => $field_options
];
}
continue;
}
if ($field['type'] == 'file') {
Helper::create_file_field($builder, $field, $field_options);
continue;
}
$builder->add($field['name'], $this->get_field($field['type']), $field_options);
if ($sanitize_html) {
$builder->get($field['name'])->addModelTransformer(new CallbackTransformer(
function ($value) {
return $value;
},
function ($value) {
return Helper::sanitize_html($value);
}
));
}
if ($field['type'] == 'checkbox' || $field['type'] == 'radio') {
$builder->get($field['name'])->addModelTransformer(new CallbackTransformer(
function ($activeAsString) {
return (bool) (int) $activeAsString;
},
function ($activeAsBoolean) {
return (string) (int) $activeAsBoolean;
}
));
}
} catch (\Throwable $e) {
\error_log($e->getMessage());
}
}
$builder->add('_message', $this->get_field('html'), [
'html' => '
'
]);
try {
return $builder->getForm();
} catch (\Throwable $e) {
// \var_dump($e);
return new WP_Error('form_error', $e->getMessage());
}
}
/**
* Render the form
*
* @date 2024-04-11
*
* @param string $name
* @param \Isolated\Symfony\Component\Form\Form $form
* @param string $theme
*
* @return void
*/
public function render($name, $form, $theme = null)
{
$twig = new \Isolated\Twig\Environment(new \Isolated\Twig\Loader\FilesystemLoader(array(
AJAXY_FORMS_PLUGIN_DIR . 'inc/themes',
AJAXY_FORMS_PLUGIN_DIR . 'vendor/symfony/twig-bridge/Resources/views/Form',
)), [
'debug' => WP_DEBUG,
'cache' => false
]);
// Set up the Translation component
$translator = new Translator('en');
$translator->addLoader('xlf', new XliffFileLoader());
$translator->addResource('xlf', AJAXY_FORMS_PLUGIN_DIR . 'vendor/symfony/form/Resources/translations/validators.en.xlf', 'en', 'validators');
$translator->addResource('xlf', AJAXY_FORMS_PLUGIN_DIR . 'vendor/symfony/validator/Resources/translations/validators.en.xlf', 'en', 'validators');
$twig->addExtension(new TranslationExtension($translator));
$twig->addExtension(
new FormExtension($translator)
);
$twig->addExtension(new \Isolated\Twig\Extension\DebugExtension());
$theme = $theme ?? 'tailwind_2_layout.html.twig';
$twig->addRuntimeLoader(new \Isolated\Twig\RuntimeLoader\FactoryRuntimeLoader(
array(
FormRenderer::class => function () use ($twig, $theme) {
return new FormRenderer(new TwigRendererEngine(array($theme), $twig));
}
)
));
if ($this->on_submit($name, $form)) {
$registered_form = Data::parse_form($name);
$messages = array_replace([
'success' => __('Form submitted successfully', "ajaxy-forms"),
'error' => __('Form failed to submit, Please try again', "ajaxy-forms"),
], $registered_form->get_option('messages', []));
return sprintf('', $messages['success'] ?? '');
}
try {
$output = $twig->render($twig->createTemplate('{{ form(form) }}'), ['form' => $form->createView()]);
return $output;
} catch (\Throwable $e) {
\error_log($e->getMessage());
return sprintf('', __('Error rendering form: ' . $e->getMessage(), "ajaxy-forms"));
}
return '';
}
/**
* On Form Submit
*
* @date 2024-04-10
*
* @param \Isolated\Symfony\Component\Form\FormInterface $form
*
* @return boolean
*/
public function on_submit($form_name, $form)
{
$errors = [];
$this->handle_request($form);
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
$data = Helper::handle_files($data);
$registered_form = Data::parse_form($form_name);
$actions = $registered_form->get_actions();
foreach ($actions as $action) {
try {
\call_user_func($action, $data, $registered_form);
} catch (\Throwable $e) {
$errors[] = $e->getMessage();
continue;
}
}
if (count($errors) > 0) {
throw new \Exception(implode(', ', $errors));
}
$this->csrf_token_manager->refreshToken('form_intention_' . $registered_form->get_name());
return true;
}
return false;
}
/**
* Handle the request for the form
*
* WordPress slashes $_GET/$_POST through wp_magic_quotes(), while the form
* is built from unslashed data stored in the database. The native request
* handler reads the superglobals directly, so submitted values containing
* quotes (O'Brien, "I'd like to cancel") would never match the registered
* choices and would be stored escaped. Unslash them for the handover only.
*
* @date 2026-10-18
*
* @param \Isolated\Symfony\Component\Form\FormInterface $form
*
* @return void
*/
private function handle_request($form)
{
$post = $_POST;
$get = $_GET;
$_POST = \wp_unslash($_POST);
$_GET = \wp_unslash($_GET);
try {
$form->handleRequest();
} finally {
$_POST = $post;
$_GET = $get;
}
}
public function action_links($links, $file)
{
if ($file != AJAXY_FORMS_BASENAME) {
return $links;
}
if (!current_user_can('manage_options')) {
return $links;
}
$settings_link = sprintf(
'%s',
menu_page_url('ajaxy-forms-settings', false),
__('Settings', "ajaxy-forms")
);
array_unshift($links, $settings_link);
return $links;
}
public function filters()
{
// add_filter('plugin_action_links', [$this, 'action_links'], 10, 2);
}
public function actions()
{
add_action('rest_api_init', function () {
register_rest_route("ajaxy-forms" . '/v1', '/form/(?P.+)', array(
'methods' => 'GET,POST',
'permission_callback' => '__return_true',
'callback' => [$this, 'submit'],
));
register_rest_route("ajaxy-forms" . '/v1', '/form-data/', array(
'methods' => 'GET,POST',
'permission_callback' => '__return_true',
'callback' => [$this, 'get_data'],
));
});
add_action('wp_enqueue_scripts', array(&$this, 'scripts'));
}
public function scripts()
{
wp_enqueue_style("ajaxy-forms" . "-style", AJAXY_FORMS_PLUGIN_URL . '/assets/css/styles.css', [], AJAXY_FORMS_VERSION);
wp_enqueue_script("ajaxy-forms" . '-script', AJAXY_FORMS_PLUGIN_URL . '/assets/js/script.js', array(), AJAXY_FORMS_VERSION, true);
wp_localize_script("ajaxy-forms" . '-script', 'ajaxyFormsSettings', array(
'nonce' => wp_create_nonce('wp_rest'),
'dataUrl' => get_rest_url(null, sprintf('ajaxy-forms/v1/form-data/'))
));
}
/**
* Drop the options a form type does not accept
*
* The builder offers settings that belong to the plugin rather than to
* Symfony, and a field carried over from an older version may hold
* settings its type has since dropped. Either one would otherwise take
* the whole form down with an UndefinedOptionsException.
*
* @date 2026-10-18
*
* @param string $type The field type as named by the builder
* @param array $options
*
* @return array
*/
private function filter_options($type, $options)
{
if (!$this->form_registry) {
return $options;
}
try {
$defined = $this->form_registry->getType($this->get_field($type))->getOptionsResolver()->getDefinedOptions();
} catch (\Throwable $e) {
return $options;
}
$unsupported = \array_diff(\array_keys($options), $defined);
if (empty($unsupported)) {
return $options;
}
\error_log(\sprintf(
'Ajaxy Forms: the %s field does not support the %s setting, it was ignored.',
$type,
\implode(', ', $unsupported)
));
return \array_diff_key($options, \array_flip($unsupported));
}
public function get_field($type)
{
$data = preg_split('/(?=[A-Z])/', $type);
$type = \strtolower(implode('_', $data));
$field = Inc\Fields::getInstance()->get($type);
return $field['class'];
}
public function register($name, $fields, $options = [], $actions = [], $initial_data = null)
{
Data::register_form($name, $fields, $options, $actions, $initial_data);
}
/**
* Register a form action
*
* @date 2024-04-10
*
* @param string $form_name
* @param string $action_name
* @param array|callable $optionsOrCallable
*
* @return void
*/
public function register_action($form_name, $action_name, $optionsOrCallable)
{
$form = Data::parse_form($form_name);
if (!$form) {
throw new \Exception('Please register the form first before registering the action');
}
$form->add_action($action_name, $optionsOrCallable);
}
public function register_action_type($type, $options)
{
Inc\Actions::getInstance()->register($type, $options);
}
public function get_data(\WP_REST_Request $request)
{
$type = $request->get_param('type') ?? null;
switch ($type) {
case 'posts_by_term':
// This endpoint answers to anyone, so the query it builds is
// kept to published posts of a public type, a bounded number
// of them, and no meta lookups.
$term_id = \absint($request->get_param('term_id'));
$taxonomy = \sanitize_key($request->get_param('taxonomy') ?? 'category');
$post_type = \sanitize_key($request->get_param('post_type') ?? 'post');
$orderby = $request->get_param('orderby') ?? 'date';
$order = \strtoupper($request->get_param('order') ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$exclude = $request->get_param('exclude') ?? '';
$include = $request->get_param('include') ?? '';
if (!$term_id || !\taxonomy_exists($taxonomy)) {
return new \WP_REST_Response(['status' => 'error', 'message' => 'Unknown taxonomy']);
}
$post_type_object = \get_post_type_object($post_type);
if (!$post_type_object || empty($post_type_object->public)) {
return new \WP_REST_Response(['status' => 'error', 'message' => 'Unknown post type']);
}
if (!\in_array($orderby, ['date', 'title', 'menu_order', 'ID', 'rand'], true)) {
$orderby = 'date';
}
/**
* How many posts the field may list at once.
*
* @param int $limit
*/
$limit = (int)\apply_filters('ajaxy_forms_posts_by_term_limit', 200);
$posts = get_posts(array(
'post_type' => $post_type,
'order' => $order,
'orderby' => $orderby,
'exclude' => \array_map('absint', \array_filter(explode(',', $exclude))),
'include' => \array_map('absint', \array_filter(explode(',', $include))),
'post_status' => 'publish',
'posts_per_page' => $limit,
'numberposts' => $limit,
'tax_query' => array(
array(
'taxonomy' => $taxonomy,
'field' => 'term_id',
'terms' => $term_id,
),
),
));
$response = array_map(function ($post) {
return array(
'id' => $post->ID,
'title' => $post->post_title
);
}, $posts);
return new \WP_REST_Response([
'data' => $response,
'status' => 'success'
]);
default:
return new \WP_REST_Response(['status' => 'error', 'message' => 'Unsupported data type']);
}
}
public function submit(\WP_REST_Request $request)
{
$form_name = $request->get_param('name') ?? null;
if (!$form_name || trim($form_name) == "") {
return new \WP_REST_Response(['status' => 'error', 'message' => 'Form not found']);
}
$form = Data::parse_form($form_name);
if (!$form) {
return new \WP_REST_Response(['status' => 'error', 'message' => 'Form not found']);
}
$messages = array_replace([
'success' => __('Form submitted successfully', "ajaxy-forms"),
'error' => __('Form failed to submit, Please try again', "ajaxy-forms"),
'wp_error' => __('Error submitting form: %s', "ajaxy-forms"),
], $form->get_option('messages', []));
$data = $request->get_body_params()[$form_name] ?? [];
$submitted_form = $this->create_form($form, $data);
if (\is_wp_error($submitted_form)) {
return new \WP_REST_Response([
'status' => 'error',
'message' => \sprintf($messages['wp_error'], $submitted_form->get_error_message()),
'_token' => $this->csrf_token_manager->getToken('form_intention_' . $form->get_name())->getValue()
]);
}
$valid = false;
try {
$valid = $this->on_submit($form_name, $submitted_form);
} catch (\Exception $e) {
return new \WP_REST_Response([
'status' => 'error',
'message' => \sprintf($messages['wp_error'], $e->getMessage()),
'_token' => $this->csrf_token_manager->getToken('form_intention_' . $form->get_name())->getValue()
]);
}
if ($valid) {
$message = $form->get_message('success');
return new \WP_REST_Response([
'status' => 'success',
'message' => $message ? $message : $messages['success'],
'_token' => $this->csrf_token_manager->getToken('form_intention_' . $form->get_name())->getValue()
]);
}
$message = $form->get_message('error');
$errors = $this->get_form_error_messages($submitted_form);
if (count($errors) > 0) {
$message .= implode('
', $errors);
}
return new \WP_REST_Response([
'status' => 'error',
'message' => $message ? $message : $messages['error'],
'fields' => $this->get_fields_error_messages($submitted_form)
]);
}
private function get_fields_error_messages(FormInterface $form)
{
$errors = array();
if ($form->count() && $form->isSubmitted()) {
/** @var \Isolated\Symfony\Component\Form\Form */
foreach ($form as $child) {
if (!$child->isValid()) {
$children = $child->all();
$subError = false;
if (\count($children) > 0) {
foreach ($children as $sub_child) {
if (!$sub_child->isValid()) {
$errors[$child->getName()][$sub_child->getName()] = $this->get_form_error_messages($sub_child);
$subError = true;
}
}
}
if (!$subError) {
$errors[$child->getName()] = implode(', ', $this->get_form_error_messages($child));
}
}
}
}
return $errors;
}
private function get_form_error_messages(FormInterface $form)
{
$errors = array();
foreach ($form->getErrors() as $key => $error) {
$template = $error->getMessageTemplate();
$parameters = $error->getMessageParameters();
foreach ($parameters as $var => $value) {
$template = str_replace($var, $value, $template);
}
$errors[] = $template;
}
return $errors;
}
public function register_field($type, $options)
{
Inc\Fields::getInstance()->register($type, $options);
}
}
register_activation_hook(__FILE__, function () {
Inc\Data::install();
});
\Ajaxy\Forms\Plugin::init();
\register_form_field('html', [
"label" => "HTML",
"class" => Inc\Types\HtmlType::class,
"docs" => false,
"disable_constraints" => true,
"inherited" => [],
"properties" => [
[
"section" => "basic",
"order" => 1,
"label" => "Label",
"type" => "text",
"name" => "label",
"help" => "Enter the label for the field, keep empty to hide it",
],
[
"section" => "basic",
"order" => 1,
"label" => "Html",
"type" => "textarea",
"name" => "html",
"help" => "Enter the HTML to display in the form",
]
],
"order" => 50,
"keywords" => "html,custom",
"common" => false
]);
\register_form_action_type('storage', [
"label" => "Storage",
"class" => Inc\Actions\Storage::class,
"docs" => false,
"properties" => Inc\Actions\Storage::get_properties()
]);
\register_form_action_type('email', [
"label" => "Email",
"class" => Inc\Actions\Email::class,
"docs" => false,
"properties" => Inc\Actions\Email::get_properties()
]);
\register_form_action_type('sms', [
"label" => "SMS",
"class" => Inc\Actions\Sms::class,
"docs" => false,
"properties" => Inc\Actions\Sms::get_properties()
]);
\register_form_action_type('whatsapp', [
"label" => "Whatsapp",
"class" => Inc\Actions\Whatsapp::class,
"docs" => false,
"properties" => Inc\Actions\Whatsapp::get_properties()
]);
\register_form_action_type('webhook', [
"label" => "Webhook",
"class" => Inc\Actions\Webhook::class,
"docs" => false,
"properties" => Inc\Actions\Webhook::get_properties()
]);
\register_form_action_type('email_responder', [
"label" => "Auto Responder Email",
"class" => Inc\Actions\Email::class,
"docs" => false,
"properties" => Inc\Actions\Email::get_properties()
]);