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('
%s
', $messages['success'] ?? ''); } try { $output = $twig->render($twig->createTemplate('{{ form(form) }}'), ['form' => $form->createView()]); return $output; } catch (\Throwable $e) { \error_log($e->getMessage()); return sprintf('
%s
', __('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() ]);