initial
This commit is contained in:
@@ -0,0 +1,789 @@
|
||||
<?php
|
||||
/**
|
||||
* Form submission processor.
|
||||
*
|
||||
* Reads form config from the `gblocks_form` post meta and derives the field
|
||||
* contract from the form post's saved block content at submit time. Running
|
||||
* through a single authoritative record by form post ID means template
|
||||
* parts, widgets, reusable blocks, and FSE templates all submit identically
|
||||
* with no host-page ambiguity or cached schema drift.
|
||||
*
|
||||
* Order:
|
||||
* 1. Load config by $form_id (post ID).
|
||||
* 2. Derive the field schema from saved form content.
|
||||
* 3. Enforce server-side Turnstile requirement if configured.
|
||||
* 4. Run conditional-visibility convergence against the derived schema.
|
||||
* 5. Sanitize fields through the server-derived schema.
|
||||
* 6. Store a bounded submission record when the form opts in.
|
||||
* 7. Run registered actions and update the stored submission status.
|
||||
*
|
||||
* @package GenerateBlocksPro
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form processor class.
|
||||
*/
|
||||
class GenerateBlocks_Pro_Form_Processor {
|
||||
|
||||
/**
|
||||
* Process a form submission.
|
||||
*
|
||||
* @param int $form_id gblocks_form post ID.
|
||||
* @param int $post_id Host page ID where the form was submitted.
|
||||
* @param array $raw_data Raw submitted field data (field_name => value).
|
||||
* @param array $context Request context (post_id, page_url, turnstile_token?).
|
||||
* @return true|WP_Error True on success, WP_Error on failure.
|
||||
*/
|
||||
public static function process( $form_id, $post_id, $raw_data, $context = [] ) {
|
||||
$form_id = absint( $form_id );
|
||||
|
||||
if ( ! $form_id ) {
|
||||
return new WP_Error( 'invalid_form_id', 'Invalid form ID.' );
|
||||
}
|
||||
|
||||
$form = get_post( $form_id );
|
||||
|
||||
if (
|
||||
! $form
|
||||
|| GenerateBlocks_Pro_Form_Post_Type::POST_TYPE !== $form->post_type
|
||||
|| 'publish' !== $form->post_status
|
||||
) {
|
||||
return new WP_Error( 'form_not_found', 'Form not found.' );
|
||||
}
|
||||
|
||||
$meta = get_post_meta( $form_id, GenerateBlocks_Pro_Form_Post_Type::META_KEY, true );
|
||||
$meta = is_array( $meta ) ? $meta : [];
|
||||
|
||||
$form_settings = wp_parse_args(
|
||||
isset( $meta['config'] ) && is_array( $meta['config'] )
|
||||
? $meta['config']
|
||||
: [],
|
||||
GenerateBlocks_Pro_Form_Post_Type::default_form_config()
|
||||
);
|
||||
$actions = isset( $form_settings['actions'] ) && is_array( $form_settings['actions'] )
|
||||
? $form_settings['actions']
|
||||
: [];
|
||||
|
||||
if ( empty( $actions ) ) {
|
||||
$public_message = self::incomplete_configuration_public_message();
|
||||
|
||||
return new WP_Error(
|
||||
'form_actions_empty',
|
||||
$public_message,
|
||||
[
|
||||
'public_message' => $public_message,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'GenerateBlocks_Pro_Form_Schema_Builder' ) ) {
|
||||
return new WP_Error( 'form_schema_unavailable', 'Form schema unavailable.' );
|
||||
}
|
||||
|
||||
$field_schema_list = GenerateBlocks_Pro_Form_Schema_Builder::get_instance()->build_schema_from_content( $form->post_content );
|
||||
|
||||
if ( is_wp_error( $field_schema_list ) ) {
|
||||
return $field_schema_list;
|
||||
}
|
||||
|
||||
if ( empty( $field_schema_list ) ) {
|
||||
return new WP_Error( 'form_schema_empty', 'Form has no fields.' );
|
||||
}
|
||||
|
||||
// Server-side Turnstile enforcement: require a token only when the
|
||||
// integration is fully configured (both secret and site key present).
|
||||
// A secret-only configuration can't render the widget, so the token
|
||||
// would never arrive and every submission would be rejected — the
|
||||
// editor UI explicitly promises fail-open until both keys are set.
|
||||
if (
|
||||
! empty( $form_settings['useTurnstile'] )
|
||||
&& '' !== GenerateBlocks_Pro_Form_Turnstile::get_secret_key()
|
||||
&& '' !== GenerateBlocks_Pro_Form_Turnstile::get_site_key()
|
||||
&& ! array_key_exists( 'turnstile_token', $context )
|
||||
) {
|
||||
return new WP_Error( 'turnstile_required', 'Turnstile verification required.' );
|
||||
}
|
||||
|
||||
// Convert the indexed schema list (order-preserving) into the
|
||||
// associative map the downstream convergence/sanitizer expect.
|
||||
$field_schema = [];
|
||||
|
||||
foreach ( $field_schema_list as $entry ) {
|
||||
if ( ! is_array( $entry ) || empty( $entry['name'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field_schema[ $entry['name'] ] = self::schema_entry_to_map( $entry );
|
||||
}
|
||||
|
||||
// Evaluate conditional visibility server-side (see resolve_hidden_fields
|
||||
// for the convergence contract). Fields that remain hidden after
|
||||
// convergence are stripped from both the submitted data and the schema
|
||||
// so the sanitizer never sees them.
|
||||
$hidden_fields = self::resolve_hidden_fields( $field_schema, $raw_data );
|
||||
|
||||
foreach ( $hidden_fields as $name ) {
|
||||
unset( $raw_data[ $name ] );
|
||||
unset( $field_schema[ $name ] );
|
||||
}
|
||||
|
||||
$sanitized = GenerateBlocks_Pro_Form_Sanitizer::sanitize_submission( $raw_data, $field_schema );
|
||||
|
||||
if ( is_wp_error( $sanitized ) ) {
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to validate sanitized form data before actions run.
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param array $sanitized Sanitized field data.
|
||||
* @param int $form_id Form post ID.
|
||||
* @param array $form_settings Form config from meta.
|
||||
* @param array $context Request context.
|
||||
*/
|
||||
$sanitized = apply_filters(
|
||||
'generateblocks_form_validate_submission',
|
||||
$sanitized,
|
||||
$form_id,
|
||||
$form_settings,
|
||||
$context
|
||||
);
|
||||
|
||||
if ( is_wp_error( $sanitized ) ) {
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
$actions = self::resolve_active_actions( $actions, $form_settings, $field_schema, $sanitized );
|
||||
|
||||
$submission_id = false;
|
||||
|
||||
if ( ! empty( $form_settings['storeSubmissions'] ) ) {
|
||||
$submission_id = GenerateBlocks_Pro_Form_Submissions::insert_received(
|
||||
$form_id,
|
||||
$post_id,
|
||||
$sanitized
|
||||
);
|
||||
}
|
||||
|
||||
$preflight_errors = self::validate_actions_before_execution(
|
||||
$actions,
|
||||
$form_settings,
|
||||
$field_schema_list,
|
||||
$sanitized
|
||||
);
|
||||
|
||||
if ( ! empty( $preflight_errors ) ) {
|
||||
return self::handle_action_failures( $form_id, $post_id, $sanitized, $preflight_errors, $submission_id );
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$has_blocking_action = self::has_blocking_actions( $actions );
|
||||
|
||||
foreach ( $actions as $action_name ) {
|
||||
if ( ! is_string( $action_name ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$callback = GenerateBlocks_Pro_Form_Action_Registry::get( $action_name );
|
||||
|
||||
if ( ! $callback ) {
|
||||
if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown/deactivated action: treat as failure so a stored
|
||||
// submission is marked failed. Silent skip would hide data loss.
|
||||
$errors[] = 'Unknown action: ' . sanitize_key( $action_name );
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log(
|
||||
'GenerateBlocks Form: unknown action "' . sanitize_key( $action_name )
|
||||
. '" in form ' . $form_id
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = call_user_func( $callback, $sanitized, $form_settings, $context );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$errors[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return self::handle_action_failures( $form_id, $post_id, $sanitized, $errors, $submission_id );
|
||||
}
|
||||
|
||||
if ( $submission_id ) {
|
||||
GenerateBlocks_Pro_Form_Submissions::update_status( $submission_id, 'processed', null, $form_id );
|
||||
}
|
||||
|
||||
if ( ! empty( $actions ) ) {
|
||||
GenerateBlocks_Pro_Form_Post_Type::record_delivery_success( $form_id );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate built-in action configuration before any action can run.
|
||||
*
|
||||
* This prevents partial delivery when an earlier action has a side effect
|
||||
* and a later action is guaranteed to fail from missing configuration.
|
||||
*
|
||||
* @param array $actions Action names from form config.
|
||||
* @param array $form_settings Form config from meta.
|
||||
* @param array $field_schema_list Derived field schema list.
|
||||
* @param array $sanitized_data Sanitized submitted data.
|
||||
* @return array Error messages or WP_Error instances.
|
||||
*/
|
||||
private static function validate_actions_before_execution( $actions, $form_settings, $field_schema_list, $sanitized_data ) {
|
||||
$errors = [];
|
||||
$has_blocking_action = self::has_blocking_actions( $actions );
|
||||
|
||||
foreach ( $actions as $action_name ) {
|
||||
if ( ! is_string( $action_name ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$action_name = sanitize_key( $action_name );
|
||||
$callback = GenerateBlocks_Pro_Form_Action_Registry::get( $action_name );
|
||||
|
||||
if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! $callback ) {
|
||||
$errors[] = 'Unknown action: ' . $action_name;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$validation = self::validate_builtin_action_before_execution(
|
||||
$action_name,
|
||||
$form_settings,
|
||||
$field_schema_list,
|
||||
$sanitized_data
|
||||
);
|
||||
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
$errors[] = $validation;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter configured actions down to actions active for this submission.
|
||||
*
|
||||
* The built-in email signup action can be gated by a single checkbox field
|
||||
* in contact + signup forms. Other actions always stay active.
|
||||
*
|
||||
* @param array $actions Configured action names.
|
||||
* @param array $form_settings Form config from meta.
|
||||
* @param array $field_schema Active field schema map.
|
||||
* @param array $sanitized_data Sanitized submitted data.
|
||||
* @return array Active action names.
|
||||
*/
|
||||
private static function resolve_active_actions( $actions, $form_settings, $field_schema, $sanitized_data ) {
|
||||
if ( ! is_array( $actions ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$active = [];
|
||||
$action_keys = array_map(
|
||||
static function ( $action_name ) {
|
||||
return is_string( $action_name ) ? sanitize_key( $action_name ) : '';
|
||||
},
|
||||
$actions
|
||||
);
|
||||
$is_contact_email_signup = in_array( 'email', $action_keys, true ) && in_array( 'email-signup', $action_keys, true );
|
||||
|
||||
foreach ( $actions as $action_name ) {
|
||||
if ( ! is_string( $action_name ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
$is_contact_email_signup &&
|
||||
'email-signup' === sanitize_key( $action_name ) &&
|
||||
! self::email_signup_opt_in_allows_action( $form_settings, $field_schema, $sanitized_data )
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$active[] = $action_name;
|
||||
}
|
||||
|
||||
return $active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an email signup opt-in field allows signup to run.
|
||||
*
|
||||
* Missing opt-in config means the action runs normally. When configured, it
|
||||
* must point at a visible checkbox field whose sanitized submitted value
|
||||
* matches that field's checked value.
|
||||
*
|
||||
* @param array $form_settings Form config from meta.
|
||||
* @param array $field_schema Active field schema map.
|
||||
* @param array $sanitized_data Sanitized submitted data.
|
||||
* @return bool Whether signup can run.
|
||||
*/
|
||||
private static function email_signup_opt_in_allows_action( $form_settings, $field_schema, $sanitized_data ) {
|
||||
$action_settings = $form_settings['actionSettings']['email-signup'] ?? null;
|
||||
|
||||
if ( ! is_array( $action_settings ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$raw_opt_in_field = $action_settings['optInField'] ?? '';
|
||||
$opt_in_field = is_scalar( $raw_opt_in_field ) ? sanitize_key( (string) $raw_opt_in_field ) : '';
|
||||
|
||||
if ( '' === $opt_in_field ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( 'checkbox' !== ( $field_schema[ $opt_in_field ]['type'] ?? '' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$field = $sanitized_data[ $opt_in_field ] ?? null;
|
||||
|
||||
if ( ! is_array( $field ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actual = sanitize_text_field( (string) ( $field['value'] ?? '' ) );
|
||||
$expected = sanitize_text_field( (string) ( $field_schema[ $opt_in_field ]['checkedValue'] ?? 'yes' ) );
|
||||
|
||||
return '' !== $actual && $actual === $expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration/data for built-in actions with known prerequisites.
|
||||
*
|
||||
* Third-party actions still run through the normal callback result handling.
|
||||
*
|
||||
* @param string $action_name Sanitized action name.
|
||||
* @param array $form_settings Form config from meta.
|
||||
* @param array $field_schema_list Derived field schema list.
|
||||
* @param array $sanitized_data Sanitized submitted data.
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
private static function validate_builtin_action_before_execution( $action_name, $form_settings, $field_schema_list, $sanitized_data ) {
|
||||
switch ( $action_name ) {
|
||||
case 'email':
|
||||
if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Email' ) ) {
|
||||
return GenerateBlocks_Pro_Form_Action_Email::validate_settings( $form_settings );
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'webhook':
|
||||
if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Webhook' ) ) {
|
||||
return GenerateBlocks_Pro_Form_Action_Webhook::validate_settings( $form_settings );
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'confirmation-email':
|
||||
if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Confirmation_Email' ) ) {
|
||||
$settings_check = GenerateBlocks_Pro_Form_Action_Confirmation_Email::validate_settings(
|
||||
$form_settings,
|
||||
$field_schema_list
|
||||
);
|
||||
|
||||
if ( is_wp_error( $settings_check ) ) {
|
||||
return $settings_check;
|
||||
}
|
||||
|
||||
return GenerateBlocks_Pro_Form_Action_Confirmation_Email::validate_submission(
|
||||
$sanitized_data,
|
||||
$form_settings
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'email-signup':
|
||||
if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Email_Signup' ) ) {
|
||||
$settings = isset( $form_settings['actionSettings'][ GenerateBlocks_Pro_Form_Action_Email_Signup::ACTION ] )
|
||||
? $form_settings['actionSettings'][ GenerateBlocks_Pro_Form_Action_Email_Signup::ACTION ]
|
||||
: [];
|
||||
|
||||
$settings_check = GenerateBlocks_Pro_Form_Action_Email_Signup::validate_settings(
|
||||
$settings,
|
||||
$field_schema_list
|
||||
);
|
||||
|
||||
if ( is_wp_error( $settings_check ) ) {
|
||||
return self::with_public_failure_message(
|
||||
$settings_check,
|
||||
self::incomplete_configuration_public_message()
|
||||
);
|
||||
}
|
||||
|
||||
return GenerateBlocks_Pro_Form_Action_Email_Signup::validate_submission(
|
||||
$sanitized_data,
|
||||
$settings
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an action is secondary and should not fail other delivery.
|
||||
*
|
||||
* @param string $action_name Action name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_non_blocking_action( $action_name ) {
|
||||
return 'confirmation-email' === sanitize_key( (string) $action_name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the action list includes at least one primary delivery action.
|
||||
*
|
||||
* @param array $actions Action names.
|
||||
* @return bool
|
||||
*/
|
||||
private static function has_blocking_actions( $actions ) {
|
||||
foreach ( $actions as $action_name ) {
|
||||
if ( is_string( $action_name ) && ! self::is_non_blocking_action( $action_name ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist failed action delivery and return a public-safe error.
|
||||
*
|
||||
* @param int $form_id gblocks_form post ID.
|
||||
* @param int $post_id Host page ID.
|
||||
* @param array $sanitized_data Sanitized submitted data.
|
||||
* @param array $errors Action error messages or WP_Error instances.
|
||||
* @param int $submission_id Stored submission ID, if storage succeeded.
|
||||
* @return WP_Error
|
||||
*/
|
||||
private static function handle_action_failures( $form_id, $post_id, $sanitized_data, $errors, $submission_id = 0 ) {
|
||||
$error_messages = self::normalize_failure_messages( $errors );
|
||||
unset( $post_id, $sanitized_data );
|
||||
|
||||
if ( $submission_id ) {
|
||||
GenerateBlocks_Pro_Form_Submissions::update_status( $submission_id, 'failed', $error_messages, $form_id );
|
||||
}
|
||||
|
||||
GenerateBlocks_Pro_Form_Post_Type::record_delivery_failure( $form_id, $errors );
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log(
|
||||
'GenerateBlocks Form Error [' . $form_id . ']: ' . implode( '; ', $error_messages )
|
||||
);
|
||||
|
||||
$public_message = self::get_public_failure_message( $errors );
|
||||
|
||||
if ( '' !== $public_message ) {
|
||||
return new WP_Error(
|
||||
'action_failed',
|
||||
$public_message,
|
||||
[
|
||||
'public_message' => $public_message,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_Error( 'action_failed', 'One or more actions failed.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-safe message for forms that are published before setup is complete.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function incomplete_configuration_public_message() {
|
||||
return __( 'This form is not fully configured. Please contact the site administrator.', 'generateblocks-pro' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a public-safe message while preserving the admin-facing error.
|
||||
*
|
||||
* @param WP_Error $error Original error.
|
||||
* @param string $public_message Message safe to show to submitters.
|
||||
* @return WP_Error
|
||||
*/
|
||||
private static function with_public_failure_message( WP_Error $error, $public_message ) {
|
||||
$existing_data = $error->get_error_data();
|
||||
$data = is_array( $existing_data ) ? $existing_data : [];
|
||||
|
||||
if ( null !== $existing_data && ! is_array( $existing_data ) ) {
|
||||
$data['private_data'] = $existing_data;
|
||||
}
|
||||
|
||||
$data['public_message'] = sanitize_text_field( (string) $public_message );
|
||||
|
||||
$error->add_data( $data, $error->get_error_code() );
|
||||
|
||||
return $error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert mixed action failures to admin-facing strings.
|
||||
*
|
||||
* @param array $errors Action error messages or WP_Error instances.
|
||||
* @return array
|
||||
*/
|
||||
private static function normalize_failure_messages( $errors ) {
|
||||
$messages = [];
|
||||
|
||||
foreach ( $errors as $error ) {
|
||||
if ( is_wp_error( $error ) ) {
|
||||
$error = $error->get_error_message();
|
||||
}
|
||||
|
||||
if ( is_string( $error ) && '' !== $error ) {
|
||||
$messages[] = $error;
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first explicitly public-safe failure message.
|
||||
*
|
||||
* @param array $errors Action error messages or WP_Error instances.
|
||||
* @return string
|
||||
*/
|
||||
private static function get_public_failure_message( $errors ) {
|
||||
foreach ( $errors as $error ) {
|
||||
if ( ! is_wp_error( $error ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $error->get_error_data();
|
||||
|
||||
if ( ! is_array( $data ) || empty( $data['public_message'] ) || ! is_string( $data['public_message'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return sanitize_text_field( $data['public_message'] );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which fields remain hidden after conditional-visibility convergence.
|
||||
*
|
||||
* Contract: every field with non-empty `conditions` starts hidden. On each
|
||||
* pass we build an effective data map where currently-hidden fields
|
||||
* contribute empty values, then calculate the next visibility state for all
|
||||
* conditional fields from that snapshot. Applying changes after the full
|
||||
* pass matches the frontend's fixed-point model and avoids DOM/schema order
|
||||
* deciding which fields submit.
|
||||
*
|
||||
* Matching the frontend's start-hidden model here means cascading
|
||||
* conditions (A reveals B reveals C) resolve the same way regardless
|
||||
* of schema order, and a browser that disables JS submits exactly the
|
||||
* fields the server expects.
|
||||
*
|
||||
* Public so unit tests can exercise the convergence in isolation.
|
||||
*
|
||||
* @param array $field_schema Associative field_name => config map.
|
||||
* @param array $raw_data Raw user-submitted data.
|
||||
* @return array Names of fields that remain hidden after convergence.
|
||||
*/
|
||||
public static function resolve_hidden_fields( $field_schema, $raw_data ) {
|
||||
$visibility = [];
|
||||
|
||||
foreach ( $field_schema as $name => $config ) {
|
||||
$visibility[ $name ] = empty( $config['conditions'] );
|
||||
}
|
||||
|
||||
$passes = 0;
|
||||
$max_passes = max( 10, count( $visibility ) + 1 );
|
||||
|
||||
do {
|
||||
$changed = false;
|
||||
$effective_data = $raw_data;
|
||||
|
||||
foreach ( $visibility as $field_name => $is_visible ) {
|
||||
if ( ! $is_visible ) {
|
||||
$effective_data[ $field_name ] = '';
|
||||
}
|
||||
}
|
||||
|
||||
$next_visibility = $visibility;
|
||||
|
||||
foreach ( $field_schema as $name => $config ) {
|
||||
if ( empty( $config['conditions'] ) ) {
|
||||
$next_visibility[ $name ] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$next_visibility[ $name ] = self::evaluate_conditions( $config['conditions'], $field_schema, $effective_data );
|
||||
}
|
||||
|
||||
foreach ( $next_visibility as $name => $is_visible ) {
|
||||
if ( ( $visibility[ $name ] ?? false ) !== $is_visible ) {
|
||||
$changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$visibility = $next_visibility;
|
||||
|
||||
$passes++;
|
||||
} while ( $changed && $passes < $max_passes );
|
||||
|
||||
// Hitting the cap with state still changing means a circular or
|
||||
// pathologically deep condition graph. Log so an admin debugging "field
|
||||
// never reveals" has a signal — conditional fields fail closed, which is
|
||||
// the safe default for submission validation.
|
||||
if ( $changed ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log(
|
||||
'GenerateBlocks Form: conditional visibility did not converge in '
|
||||
. $max_passes . ' passes; conditional fields failed closed.'
|
||||
);
|
||||
|
||||
foreach ( $field_schema as $name => $config ) {
|
||||
if ( ! empty( $config['conditions'] ) ) {
|
||||
$visibility[ $name ] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$hidden_fields = [];
|
||||
|
||||
foreach ( $visibility as $name => $is_visible ) {
|
||||
if ( ! $is_visible ) {
|
||||
$hidden_fields[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $hidden_fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a schema-list entry into the map shape the sanitizer expects.
|
||||
*
|
||||
* Schema list uses `options` as array-of-{value,label}; the sanitizer
|
||||
* wants `allowedValues` as a flat array of strings.
|
||||
*
|
||||
* @param array $entry Schema entry.
|
||||
* @return array
|
||||
*/
|
||||
private static function schema_entry_to_map( $entry ) {
|
||||
$map = [
|
||||
'type' => $entry['type'] ?? 'text',
|
||||
'required' => ! empty( $entry['required'] ),
|
||||
'label' => $entry['label'] ?? $entry['name'] ?? '',
|
||||
];
|
||||
|
||||
if ( isset( $entry['options'] ) && is_array( $entry['options'] ) ) {
|
||||
$values = [];
|
||||
|
||||
foreach ( $entry['options'] as $option ) {
|
||||
if ( isset( $option['value'] ) && '' !== $option['value'] ) {
|
||||
$values[] = (string) $option['value'];
|
||||
}
|
||||
}
|
||||
|
||||
$map['allowedValues'] = $values;
|
||||
}
|
||||
|
||||
if ( isset( $entry['checkedValue'] ) ) {
|
||||
$map['checkedValue'] = $entry['checkedValue'];
|
||||
}
|
||||
|
||||
if ( isset( $entry['conditions'] ) && is_array( $entry['conditions'] ) ) {
|
||||
$map['conditions'] = $entry['conditions'];
|
||||
}
|
||||
|
||||
if ( isset( $entry['matchesField'] ) && is_string( $entry['matchesField'] ) ) {
|
||||
$map['matchesField'] = $entry['matchesField'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate conditions for a field against submitted data.
|
||||
*
|
||||
* All conditions must be met (AND logic). If any trigger field is missing
|
||||
* from the schema, the condition is treated as not met.
|
||||
*
|
||||
* @param array $conditions Conditions.
|
||||
* @param array $field_schema Full schema.
|
||||
* @param array $raw_data Raw data.
|
||||
* @return bool
|
||||
*/
|
||||
private static function evaluate_conditions( $conditions, $field_schema, $raw_data ) {
|
||||
foreach ( $conditions as $condition ) {
|
||||
$trigger_name = $condition['field'] ?? '';
|
||||
$operator = $condition['operator'] ?? 'is';
|
||||
$expected = $condition['value'] ?? '';
|
||||
|
||||
if ( ! $trigger_name || ! isset( $field_schema[ $trigger_name ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actual = $raw_data[ $trigger_name ] ?? '';
|
||||
$is_arr = is_array( $actual );
|
||||
|
||||
if ( ! $is_arr ) {
|
||||
$actual = sanitize_text_field( (string) $actual );
|
||||
}
|
||||
|
||||
switch ( $operator ) {
|
||||
case 'is':
|
||||
if ( $is_arr ? ! in_array( $expected, $actual, true ) : $actual !== $expected ) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'isnot':
|
||||
if ( $is_arr ? in_array( $expected, $actual, true ) : $actual === $expected ) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'isempty':
|
||||
if ( $is_arr ? ! empty( $actual ) : '' !== trim( $actual ) ) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'isnotempty':
|
||||
if ( $is_arr ? empty( $actual ) : '' === trim( $actual ) ) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user