This commit is contained in:
proelements
2026-05-04 15:07:06 +03:00
parent 872bc6fb57
commit 741540b767
148 changed files with 11063 additions and 1016 deletions
@@ -0,0 +1,75 @@
<?php
namespace ElementorPro\Modules\AtomicForm\Actions;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
abstract class Action_Base {
/**
* Get the action type identifier.
*
* @return string Action type (e.g., 'email', 'webhook', 'collect-submissions')
*/
abstract public function get_type(): string;
/**
* Execute the action with the provided form data and widget settings.
*
* @param array $form_data Sanitized form data submitted by the user.
* Example: ['name' => 'John', 'email' => 'john@example.com']
* @param array $widget_settings Full widget settings - action extracts what it needs.
* Example: ['email_to' => 'admin@site.com', 'email_subject' => 'New form', ...]
* @param array $context Additional context (post_id, form_id, form_name).
* Example: ['post_id' => 123, 'form_id' => 'contact', 'form_name' => 'Contact Form']
* @return array Result array with 'status' and optional data.
* Success: ['status' => 'success', 'message' => '...', ...]
* Failure: ['status' => 'failed', 'error' => '...', ...]
*/
abstract public function execute( array $form_data, array $widget_settings, array $context ): array;
/**
* Validate widget settings for this action.
*
* @param array $widget_settings Widget settings to validate.
* @return bool|\WP_Error True if valid, WP_Error otherwise.
*/
protected function validate_settings( array $widget_settings ) {
return true;
}
/**
* Format a success result.
*
* @param string $message Success message.
* @param array $additional_data Additional data to include.
* @return array
*/
protected function success( string $message, array $additional_data = [] ): array {
return array_merge(
[
'status' => 'success',
'message' => $message,
],
$additional_data
);
}
/**
* Format a failure result.
*
* @param string $error Error message.
* @param array $additional_data Additional data to include.
* @return array
*/
protected function failure( string $error, array $additional_data = [] ): array {
return array_merge(
[
'status' => 'failed',
'error' => $error,
],
$additional_data
);
}
}
@@ -0,0 +1,136 @@
<?php
namespace ElementorPro\Modules\AtomicForm\Actions;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Action_Runner {
/**
* Registered actions.
*
* @var Action_Base[]
*/
private static $actions = [];
/**
* Register an action.
*
* @param Action_Base $action Action instance to register.
* @return void
*/
public static function register_action( Action_Base $action ): void {
self::$actions[ $action->get_type() ] = $action;
}
/**
* Create an action instance by type.
*
* @param string $type Action type.
* @return Action_Base|null Action instance or null if not found.
*/
public static function create_action( string $type ): ?Action_Base {
if ( ! isset( self::$actions[ $type ] ) ) {
return null;
}
return self::$actions[ $type ];
}
/**
* Get all registered actions.
*
* @return Action_Base[] Array of registered actions.
*/
public static function get_registered_actions(): array {
return self::$actions;
}
/**
* Check if an action type is registered.
*
* @param string $type Action type.
* @return bool
*/
public static function has_action( string $type ): bool {
return isset( self::$actions[ $type ] );
}
/**
* Execute multiple actions and gather results.
*
* @param string[] $actions Array of action type strings.
* @param array $form_data Sanitized form data.
* @param array $widget_settings Full widget settings for actions to extract what they need.
* @param array $context Form context (post_id, form_id, form_name).
* @return array Results containing actionResults, allActionsSucceeded, failedActions, and optional submissionId.
*/
public static function execute_actions( array $actions, array $form_data, array $widget_settings, array $context ): array {
$action_results = [];
$failed_actions = [];
foreach ( $actions as $action_type ) {
if ( ! Action_Type::is_valid( $action_type ) ) {
$action_results[] = [
'type' => $action_type,
'status' => 'failed',
'error' => sprintf( __( 'Invalid action type: %s', 'elementor-pro' ), $action_type ),
];
$failed_actions[] = $action_type;
continue;
}
try {
$action = self::create_action( $action_type );
if ( ! $action ) {
throw new \Exception( sprintf( __( 'Could not create action: %s', 'elementor-pro' ), $action_type ) );
}
$result = $action->execute( $form_data, $widget_settings, $context );
$action_results[] = array_merge(
[ 'type' => $action_type ],
$result
);
} catch ( \Exception $e ) {
$action_results[] = [
'type' => $action_type,
'status' => 'failed',
'error' => $e->getMessage(),
];
$failed_actions[] = $action_type;
}
}
$all_actions_succeeded = empty( $failed_actions );
$response = [
'actionResults' => $action_results,
'allActionsSucceeded' => $all_actions_succeeded,
'failedActions' => $failed_actions,
];
return $response;
}
/**
* Initialize default actions.
*
* @return void
*/
public static function init(): void {
self::register_action( new Email_Action() );
self::register_action( new Collect_Submissions_Action() );
self::register_action( new Webhook_Action() );
/**
* Allow registering custom actions.
*
* @param Action_Factory $factory The action factory instance.
*/
do_action( 'elementor_pro/atomic_forms/actions/register', __CLASS__ );
}
}
@@ -0,0 +1,35 @@
<?php
namespace ElementorPro\Modules\AtomicForm\Actions;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Action_Type {
const EMAIL = 'email';
const COLLECT_SUBMISSIONS = 'collect-submissions';
const WEBHOOK = 'webhook';
/**
* Get all registered action types.
*
* @return array
*/
public static function get_all_types(): array {
return [
self::EMAIL,
self::COLLECT_SUBMISSIONS,
self::WEBHOOK,
];
}
/**
* Check if an action type is valid.
*
* @param string $type Action type to validate.
* @return bool
*/
public static function is_valid( string $type ): bool {
return in_array( $type, self::get_all_types(), true );
}
}
@@ -0,0 +1,151 @@
<?php
namespace ElementorPro\Modules\AtomicForm\Actions;
use ElementorPro\Core\Utils;
use ElementorPro\Modules\Forms\Submissions\Database\Query;
use ElementorPro\Modules\Forms\Submissions\Database\Repositories\Form_Snapshot_Repository;
use Elementor\Utils as ElementorUtils;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Collect_Submissions_Action extends Action_Base {
public function get_type(): string {
return Action_Type::COLLECT_SUBMISSIONS;
}
public function execute( array $form_data, array $widget_settings, array $context ): array {
$metadata_keys = $this->normalize_metadata_keys( $widget_settings['submissions_metadata'] ?? [] );
$field_metadata = $context['field_metadata'] ?? [];
$fields = $this->prepare_fields( $form_data, $field_metadata );
$submission_id = Query::get_instance()->add_submission(
[
'main_meta_id' => 0,
'post_id' => $context['post_id'],
'referer' => $this->get_referer(),
'referer_title' => $this->get_referer_title(),
'element_id' => $context['form_id'],
'form_name' => $context['form_name'],
'campaign_id' => 0,
'user_id' => get_current_user_id(),
'user_ip' => in_array( 'remote_ip', $metadata_keys, true ) ? Utils::get_client_ip() : '',
'user_agent' => in_array( 'user_agent', $metadata_keys, true ) ? $this->get_user_agent() : '',
'actions_count' => 0,
'actions_succeeded_count' => 0,
'meta' => wp_json_encode( [] ),
],
$fields
);
if ( ! $submission_id ) {
return $this->failure( __( 'Failed to save submission to database', 'elementor-pro' ) );
}
$this->store_form_snapshot( $context, $fields );
return $this->success(
__( 'Submission saved successfully', 'elementor-pro' ),
[ 'submissionId' => $submission_id ]
);
}
private function normalize_metadata_keys( array $raw ): array {
$allowed = [ 'remote_ip', 'user_agent' ];
return array_values( array_intersect( $raw, $allowed ) );
}
private function prepare_fields( array $form_data, array $field_metadata = [] ): array {
$fields = [];
foreach ( $form_data as $key => $value ) {
$meta = $field_metadata[ $key ] ?? [];
$label = ! empty( $meta['label'] ) ? $meta['label'] : ucwords( str_replace( [ '_', '-' ], ' ', $key ) );
$type = ! empty( $meta['type'] ) ? $meta['type'] : $this->guess_field_type( $key, $value );
$fields[] = [
'id' => $key,
'type' => $type,
'label' => $label,
'value' => is_array( $value ) ? implode( ', ', $value ) : $value,
];
}
return $fields;
}
private function guess_field_type( string $key, $value ): string {
$key_lower = strtolower( $key );
if ( strpos( $key_lower, 'email' ) !== false ) {
return 'email';
}
if ( strpos( $key_lower, 'phone' ) !== false || strpos( $key_lower, 'tel' ) !== false ) {
return 'tel';
}
if ( is_array( $value ) ) {
return 'checkbox';
}
if ( strpos( $key_lower, 'message' ) !== false || ( is_string( $value ) && strlen( $value ) > 100 ) ) {
return 'textarea';
}
if ( strpos( $key_lower, 'url' ) !== false || strpos( $key_lower, 'website' ) !== false ) {
return 'url';
}
return 'text';
}
private function store_form_snapshot( array $context, array $fields ): void {
$snapshot_fields = array_map(
function ( $field ) {
return [
'id' => $field['id'],
'type' => $field['type'],
'label' => $field['label'],
];
},
$fields
);
Form_Snapshot_Repository::instance()->create_or_update(
$context['post_id'],
$context['form_id'],
[
'name' => $context['form_name'],
'fields' => $snapshot_fields,
]
);
}
private function get_referer(): string {
$referer = ElementorUtils::get_super_global_value( $_SERVER, 'HTTP_REFERER' );
if ( $referer ) {
return esc_url_raw( wp_unslash( $referer ) );
}
return '';
}
private function get_referer_title(): string {
// For now, return empty as we don't have access to the frontend page title
return '';
}
private function get_user_agent(): string {
$user_agent = ElementorUtils::get_super_global_value( $_SERVER, 'HTTP_USER_AGENT' );
if ( $user_agent ) {
return sanitize_textarea_field( wp_unslash( $user_agent ) );
}
return '';
}
}
@@ -0,0 +1,152 @@
<?php
namespace ElementorPro\Modules\AtomicForm\Actions;
use ElementorPro\Modules\AtomicForm\Actions\Email_Settings;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Email_Action extends Action_Base {
public function get_type(): string {
return Action_Type::EMAIL;
}
public function execute( array $form_data, array $widget_settings, array $context ): array {
$validation = $this->validate_settings( $widget_settings );
if ( is_wp_error( $validation ) ) {
return $this->failure( $validation->get_error_message() );
}
$email_settings = new Email_Settings( $widget_settings );
$to = $email_settings->to();
$from = $email_settings->from();
$from_name = $email_settings->from_name();
$message = $email_settings->message();
$subject = $email_settings->subject();
$reply_to = $email_settings->reply_to();
$cc = $email_settings->cc();
$bcc = $email_settings->bcc();
$content_type = $email_settings->content_type();
$field_metadata = $context['field_metadata'] ?? [];
$message = $this->replace_shortcodes( $message, $form_data, 'html' === $content_type, $field_metadata );
$headers = [];
$headers[] = sprintf( 'From: %s <%s>', $from_name, $from );
$headers[] = sprintf( 'Reply-To: %s', $reply_to );
if ( 'html' === $content_type ) {
$headers[] = 'Content-Type: text/html; charset=UTF-8';
}
if ( ! empty( $cc ) ) {
$headers[] = sprintf( 'Cc: %s', $cc );
}
if ( ! empty( $bcc ) ) {
$headers[] = sprintf( 'Bcc: %s', $bcc );
}
/**
* Filter email headers for atomic forms.
*
* @param array $headers Email headers.
* @param array $form_data Form data.
* @param array $widget_settings Widget settings.
*/
$headers = apply_filters( 'elementor_pro/atomic_forms/email_headers', $headers, $form_data, $widget_settings );
/**
* Filter email message for atomic forms.
*
* @param string $message Email message.
* @param array $form_data Form data.
* @param array $widget_settings Widget settings.
*/
$message = apply_filters( 'elementor_pro/atomic_forms/email_message', $message, $form_data, $widget_settings );
$email_sent = wp_mail( $to, $subject, $message, $headers );
if ( ! $email_sent ) {
return $this->failure( __( 'Failed to send email', 'elementor-pro' ) );
}
return $this->success( __( 'Email sent successfully', 'elementor-pro' ) );
}
protected function validate_settings( array $widget_settings ) {
$email_settings = new Email_Settings( $widget_settings );
$email_to = $email_settings->to();
if ( ! empty( $email_to ) && ! is_email( $email_to ) ) {
$emails = array_map( 'trim', explode( ',', $email_to ) );
foreach ( $emails as $email ) {
if ( ! is_email( $email ) ) {
return new \WP_Error(
'invalid_email',
sprintf(
/* translators: %s: Invalid email address. */
__( 'Invalid email address: %s', 'elementor-pro' ),
$email
)
);
}
}
}
return true;
}
private function replace_shortcodes( string $message, array $form_data, bool $is_html, array $field_metadata = [] ): string {
$line_break = $is_html ? '<br>' : "\n";
if ( strpos( $message, '[all-fields]' ) !== false ) {
$all_fields_text = '';
foreach ( $form_data as $key => $value ) {
$meta = $field_metadata[ $key ] ?? [];
$formatted_key = ! empty( $meta['label'] ) ? $meta['label'] : ucwords( str_replace( [ '_', '-' ], ' ', $key ) );
$formatted_value = is_array( $value ) ? implode( ', ', $value ) : $value;
if ( $is_html ) {
$formatted_key = esc_html( $formatted_key );
if ( is_string( $formatted_value ) ) {
$formatted_value = nl2br( esc_html( $formatted_value ) );
}
}
$all_fields_text .= sprintf(
'%s: %s%s',
$formatted_key,
$formatted_value,
$line_break
);
}
$message = str_replace( '[all-fields]', $all_fields_text, $message );
}
$message = preg_replace_callback(
'/\[field[^\]]*id=["\']([^"\']+)["\'][^\]]*\]/',
function ( $matches ) use ( $form_data ) {
$field_id = $matches[1];
if ( isset( $form_data[ $field_id ] ) ) {
$value = $form_data[ $field_id ];
return is_array( $value ) ? implode( ', ', $value ) : $value;
}
return '';
},
$message
);
return $message;
}
}
@@ -0,0 +1,56 @@
<?php
namespace ElementorPro\Modules\AtomicForm\Actions;
use ElementorPro\Core\Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Email_Settings {
private $email_settings;
public function __construct( array $widget_settings ) {
$this->email_settings = $widget_settings['email'] ?? [];
}
public function to() {
return $this->email_settings['to'] ?? get_option( 'admin_email' );
}
public function from() {
return $this->email_settings['from'] ?? 'noreply@' . Utils::get_site_domain();
}
public function from_name() {
return $this->email_settings['from-name'] ?? get_bloginfo( 'name' );
}
public function subject() {
return $this->email_settings['subject'] ?? sprintf(
/* translators: %s: Site title. */
__( 'New message from "%s"', 'elementor-pro' ),
get_bloginfo( 'name' )
);
}
public function message() {
return $this->email_settings['message'] ?? '[all-fields]';
}
public function reply_to() {
return $this->email_settings['reply-to'] ?? $this->from();
}
public function cc() {
return $this->email_settings['cc'] ?? '';
}
public function bcc() {
return $this->email_settings['bcc'] ?? '';
}
public function content_type() {
return $this->email_settings['send-as'] ?? 'html';
}
}
@@ -0,0 +1,128 @@
<?php
namespace ElementorPro\Modules\AtomicForm\Actions;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Webhook_Action extends Action_Base {
public function get_type(): string {
return Action_Type::WEBHOOK;
}
public function execute( array $form_data, array $widget_settings, array $context ): array {
$validation = $this->validate_settings( $widget_settings );
if ( is_wp_error( $validation ) ) {
return $this->failure( $validation->get_error_message() );
}
$url = $widget_settings['webhook_url'];
$method = strtoupper( $widget_settings['webhook_method'] ?? 'POST' );
$timeout = isset( $widget_settings['webhook_timeout'] ) ? absint( $widget_settings['webhook_timeout'] ) : 30;
$payload = [
'formData' => $form_data,
'postId' => $context['post_id'],
'formId' => $context['form_id'],
'formName' => $context['form_name'],
'timestamp' => current_time( 'mysql' ),
'siteUrl' => get_site_url(),
];
/**
* Filter webhook payload for atomic forms.
*
* @param array $payload Webhook payload.
* @param array $form_data Form data.
* @param array $widget_settings Widget settings.
* @param array $context Form context.
*/
$payload = apply_filters(
'elementor_pro/atomic_forms/webhook_payload',
$payload,
$form_data,
$widget_settings,
$context
);
$args = [
'method' => $method,
'timeout' => $timeout,
'headers' => [
'Content-Type' => 'application/json',
'User-Agent' => 'Elementor Pro Atomic Forms/' . ELEMENTOR_PRO_VERSION,
],
'body' => wp_json_encode( $payload ),
];
if ( ! empty( $widget_settings['webhook_headers'] ) && is_array( $widget_settings['webhook_headers'] ) ) {
$args['headers'] = array_merge( $args['headers'], $widget_settings['webhook_headers'] );
}
$response = wp_remote_request( $url, $args );
if ( is_wp_error( $response ) ) {
return $this->failure(
sprintf(
/* translators: %s: Error message. */
__( 'Webhook request failed: %s', 'elementor-pro' ),
$response->get_error_message()
)
);
}
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = wp_remote_retrieve_body( $response );
if ( $response_code >= 200 && $response_code < 300 ) {
return $this->success(
__( 'Webhook delivered successfully', 'elementor-pro' ),
[
'responseCode' => $response_code,
'responseBody' => $response_body,
]
);
}
return $this->failure(
sprintf(
/* translators: %d: HTTP status code. */
__( 'Webhook returned error status code: %d', 'elementor-pro' ),
$response_code
),
[
'responseCode' => $response_code,
'responseBody' => $response_body,
]
);
}
protected function validate_settings( array $widget_settings ) {
if ( empty( $widget_settings['webhook_url'] ) ) {
return new \WP_Error(
'missing_url',
__( 'Webhook URL is required', 'elementor-pro' )
);
}
if ( ! filter_var( $widget_settings['webhook_url'], FILTER_VALIDATE_URL ) ) {
return new \WP_Error(
'invalid_url',
__( 'Invalid webhook URL', 'elementor-pro' )
);
}
if ( isset( $widget_settings['webhook_method'] ) ) {
$allowed_methods = [ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ];
if ( ! in_array( strtoupper( $widget_settings['webhook_method'] ), $allowed_methods, true ) ) {
return new \WP_Error(
'invalid_method',
__( 'Invalid HTTP method', 'elementor-pro' )
);
}
}
return true;
}
}