tag injected right before the
tag. This fires * synchronously as the page HTML is written, before any paint, so there is no flash * of visibility. wp_footer is too late for this. * * 2. CONFIG via gform_form_tag: Per-form config JSON is injected inline alongside the * pre-hide CSS so it is present for both initial page renders and GF AJAX renders * (wp_footer never fires in a GF AJAX response). The runtime JS still goes in wp_footer. * * 3. nativeVisible: Only checks data-conditional-logic="hidden" (GF's own attr). * We track fields we hide via data-bl-adv-hidden="1" so we can distinguish our * mutations from GF's own mutations and avoid evaluation loops. * * 4. data-conditional-logic: We set this on fields we hide so GF skips them on * submission (GF reads this attribute to determine whether to include a field's * value in the POST). This is intentional but means GF's native CL JS may also * react — mitigated by the MutationObserver ignoring mutations on elements we own. * * 5. readFieldValue: Handles GF radio groups (multiple inputs, same name, type=radio) * by returning a string from :checked, not an array. */ class BL_GF_AdvLogic { /** * Per-form configs accumulated during this request. * * @var array */ private static array $configs = []; /** * Form IDs that already have a gform_form_tag hook registered. * * @var array */ private static array $form_tag_hooks = []; /** * Whether footer output has been printed for this request. * * @var bool */ private static bool $footer_printed = false; // ------------------------------------------------------------------------- // Bootstrap // ------------------------------------------------------------------------- /** * Initializes the required hooks for attaching custom behavior to Gravity Forms rendering and validation processes. * * @return void */ public static function init(): void { add_filter( 'gform_pre_render', [ self::class, 'attach' ], 20 ); add_filter( 'gform_pre_validation', [ self::class, 'attach_for_validation' ], 20 ); add_action( 'gform_pre_submission', [ self::class, 'enforce_server_side' ], 20 ); add_filter( 'gform_field_validation', [ self::class, 'skip_validation_if_hidden' ], 20, 4 ); } // ------------------------------------------------------------------------- // Hooks // ------------------------------------------------------------------------- /** * Hooked to gform_pre_render. * Auto-imports native CL, extracts config, and registers pre-hide CSS + config JSON (via * gform_form_tag) and the runtime JS (via wp_footer). * * @param array $form GF form array. * @return array Modified form array. */ public static function attach( array $form ): array { // Auto-import native conditionalLogic → blAdvLogic if not already set. // Imported configs have enabled=false so they pre-populate the editor for migration // but do not activate the advanced system until the user explicitly enables them. // This intentionally sets a property on each field object so that downstream hooks // at priority > 20 can inspect blAdvLogic if needed. foreach ( $form['fields'] as &$field ) { if ( empty( self::get_field_prop( $field, 'blAdvLogic' ) ) && ! empty( self::get_field_prop( $field, 'conditionalLogic' ) ) ) { $native = self::get_field_prop( $field, 'conditionalLogic' ); self::set_field_prop( $field, 'blAdvLogic', self::import_from_native( $native ) ); } } unset( $field ); // release foreach-by-reference $form_id = intval( $form['id'] ); $config = self::extract_config( $form ); self::$configs[ $form_id ] = $config; // Compute CSS and config JSON here so closures capture plain scalars, not a class reference. // Config JSON is injected via gform_form_tag so it is present for AJAX-rendered forms too // (wp_footer never fires in GF's AJAX response). $css = self::build_prehide_css( $config ); $json = ! empty( $config['fields'] ) ? wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) : ''; if ( ( $css || $json ) && ! isset( self::$form_tag_hooks[ $form_id ] ) ) { self::$form_tag_hooks[ $form_id ] = true; $config_script = $json ? '' : ''; add_filter( 'gform_form_tag', static function ( string $form_tag, array $f ) use ( $form_id, $css, $config_script ): string { if ( intval( $f['id'] ) !== $form_id ) { return $form_tag; } return ( $css ? '' : '' ) . $config_script . $form_tag; }, 20, 2 ); } // WordPress deduplicates named static callbacks at the same priority, so calling // add_action here on every attach() invocation is safe — it only registers once. add_action( 'wp_footer', [ self::class, 'print_footer' ], 20 ); return $form; } /** * Hooked to gform_pre_validation. * Lean variant of attach(): only auto-imports native CL → blAdvLogic so that * skip_validation_if_hidden() has access to the config during field validation. * Does not rebuild CSS/JSON or re-register hooks (already done via gform_pre_render). * * @param array $form GF form array. * @return array Modified form array. */ public static function attach_for_validation( array $form ): array { foreach ( $form['fields'] as &$field ) { if ( empty( self::get_field_prop( $field, 'blAdvLogic' ) ) && ! empty( self::get_field_prop( $field, 'conditionalLogic' ) ) ) { self::set_field_prop( $field, 'blAdvLogic', self::import_from_native( self::get_field_prop( $field, 'conditionalLogic' ) ) ); } } unset( $field ); return $form; } /** * Outputs the runtime JS once in wp_footer. * Per-form config JSON is injected inline via gform_form_tag so AJAX-rendered forms also receive it. */ public static function print_footer(): void { if ( self::$footer_printed || empty( self::$configs ) ) { return; } self::$footer_printed = true; ?> $field_cfg ) { if ( 'show' !== ( $field_cfg['actionType'] ?? 'show' ) ) { continue; } $has_rules = false; foreach ( $field_cfg['groups'] ?? [] as $group ) { if ( ! empty( $group['rules'] ) ) { $has_rules = true; break; } } if ( $has_rules ) { $selectors[] = '#field_' . $form_id . '_' . intval( $field_id ); } } return $selectors ? implode( ',', $selectors ) . '{display:none!important}' : ''; } /** * Converts native GF conditionalLogic into blAdvLogic group format. * Mirrors editor import structure, with one intentional difference: * auto-import defaults enabled=false until explicitly enabled by the user. * * @param array $native Native GF conditionalLogic array. * @return array blAdvLogic config array. */ private static function import_from_native( array $native ): array { $action_type = $native['actionType'] ?? 'show'; $logic_type = $native['logicType'] ?? 'all'; $rules = $native['rules'] ?? []; $group_rules = array_map( static fn( array $r ): array => [ 'fieldId' => isset( $r['fieldId'] ) ? intval( $r['fieldId'] ) : null, 'op' => $r['operator'] ?? 'is', 'value' => $r['value'] ?? '', ], $rules ); return [ 'enabled' => false, 'actionType' => $action_type, 'groups_operator' => 'AND', 'groups' => [ [ 'operator' => ( 'any' === $logic_type ) ? 'OR' : 'AND', 'rules' => $group_rules, ], ], ]; } /** * Extracts blAdvLogic configs for all enabled fields in a form. * * @param array $form GF form array. * @return array{ formId: int, fields: array } */ private static function extract_config( array $form ): array { $out = [ 'formId' => intval( $form['id'] ), 'fields' => [], ]; foreach ( $form['fields'] as $field ) { $id = intval( self::get_field_prop( $field, 'id' ) ); $adv = self::get_field_prop( $field, 'blAdvLogic' ); // blAdvLogic may be stored as a JSON string by the editor. if ( is_string( $adv ) && '' !== $adv ) { $decoded = json_decode( $adv, true ); $adv = is_array( $decoded ) ? $decoded : null; } if ( ! empty( $adv['enabled'] ) ) { $out['fields'][ $id ] = $adv; } } return $out; } /** * Returns the value of a named property from a GF field (object or legacy array). * * @param object|array $field GF field. * @param string $key Property name. * @return mixed|null */ private static function get_field_prop( $field, string $key ) { return is_object( $field ) ? ( property_exists( $field, $key ) ? $field->$key : null ) : ( $field[ $key ] ?? null ); } /** * Sets a property on a GF field (object or legacy array), by reference. * * @param object|array &$field GF field. * @param string $key Property name. * @param mixed $value New value. */ private static function set_field_prop( &$field, string $key, $value ): void { if ( is_object( $field ) ) { $field->$key = $value; } else { $field[ $key ] = $value; } } // ------------------------------------------------------------------------- // Server-side rule evaluation — mirrors JS evalRule / evalGroup / evalAdvConfig // ------------------------------------------------------------------------- /** * Reads a submitted field value from $_POST. * Returns string for single-value fields, string[] for checkbox/multi-select, null if absent. * * @param int $field_id GF field ID. * @return string|string[]|null */ private static function get_submitted_value( int $field_id ) { $key = 'input_' . $field_id; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified upstream by GF. if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized next line $v = wp_unslash( $_POST[ $key ] ); return is_array( $v ) ? array_map( 'strval', $v ) : (string) $v; } // Checkbox sub-inputs: input_X_Y where Y is the choice index. $prefix = 'input_' . $field_id . '_'; $vals = []; // phpcs:ignore WordPress.Security.NonceVerification.Missing foreach ( $_POST as $k => $v ) { if ( '' !== $v && str_starts_with( (string) $k, $prefix ) ) { $vals[] = (string) wp_unslash( $v ); } } return $vals ?: null; } /** * Returns a values map [ fieldId => submitted value ] for every field referenced in rules. * * @param array $adv_cfg blAdvLogic config array. * @return array */ private static function collect_rule_values( array $adv_cfg ): array { $values = []; foreach ( $adv_cfg['groups'] ?? [] as $group ) { foreach ( $group['rules'] ?? [] as $rule ) { $ref_id = isset( $rule['fieldId'] ) ? intval( $rule['fieldId'] ) : 0; if ( $ref_id && ! array_key_exists( $ref_id, $values ) ) { $values[ $ref_id ] = self::get_submitted_value( $ref_id ); } } } return $values; } /** * Converts a value to float with JS parseFloat-like prefix parsing, * but returns 0 for non-numeric values to mirror frontend behavior. * * @param mixed $value Raw value. * @return float */ private static function to_comparable_float( $value ): float { if ( is_int( $value ) || is_float( $value ) ) { return (float) $value; } $str = trim( (string) $value ); if ( '' === $str ) { return 0.0; } if ( preg_match( '/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/', $str, $m ) ) { return (float) $m[0]; } return 0.0; } /** * Evaluates a single rule against an actual submitted value. * * @param array $rule Rule array with 'op', 'value', 'fieldId'. * @param string|string[]|null $actual Submitted field value. * @return bool */ private static function eval_rule( array $rule, $actual ): bool { $op = (string) ( $rule['op'] ?? 'is' ); $expected = (string) ( $rule['value'] ?? '' ); $is_arr = is_array( $actual ); if ( null === $actual ) { $actual = $is_arr ? [] : ''; } switch ( $op ) { case 'is': case 'equals': return $is_arr ? in_array( $expected, $actual, true ) : (string) $actual === $expected; case 'isnot': case 'not_equals': return $is_arr ? ! in_array( $expected, $actual, true ) : (string) $actual !== $expected; case 'contains': return $is_arr ? in_array( $expected, $actual, true ) : ( '' === $expected || str_contains( (string) $actual, $expected ) ); case 'starts_with': if ( '' === $expected ) { return true; } if ( $is_arr ) { foreach ( $actual as $v ) { if ( str_starts_with( (string) $v, $expected ) ) { return true; } } return false; } return str_starts_with( (string) $actual, $expected ); case 'ends_with': if ( '' === $expected ) { return true; } $elen = strlen( $expected ); if ( $is_arr ) { foreach ( $actual as $v ) { $s = (string) $v; if ( strlen( $s ) >= $elen && substr( $s, -$elen ) === $expected ) { return true; } } return false; } $a = (string) $actual; return strlen( $a ) >= $elen && substr( $a, -$elen ) === $expected; case '>': case 'greater_than': return ! $is_arr && self::to_comparable_float( $actual ) > self::to_comparable_float( $expected ); case '<': case 'less_than': return ! $is_arr && self::to_comparable_float( $actual ) < self::to_comparable_float( $expected ); case 'isnotempty': case 'is_not_empty': return $is_arr ? ! empty( $actual ) : '' !== trim( (string) $actual ); case 'isempty': case 'is_empty': return $is_arr ? empty( $actual ) : '' === trim( (string) $actual ); default: return false; } } /** * Evaluates all rules in a group. Empty groups always pass. * * @param array $group Group array with 'operator' and 'rules'. * @param array $values Values map [ fieldId => actual ]. * @return bool */ private static function eval_group( array $group, array $values ): bool { $op = strtoupper( $group['operator'] ?? 'AND' ); $rules = $group['rules'] ?? []; if ( empty( $rules ) ) { return true; } foreach ( $rules as $rule ) { $ref_id = isset( $rule['fieldId'] ) ? intval( $rule['fieldId'] ) : 0; $result = self::eval_rule( $rule, $values[ $ref_id ] ?? null ); if ( 'OR' === $op && $result ) { return true; } if ( 'AND' === $op && ! $result ) { return false; } } return 'AND' === $op; } /** * Evaluates a full advLogic config. Returns true if the field should be visible. * * @param array $cfg blAdvLogic config array. * @param array $values Values map from collect_rule_values(). * @return bool */ private static function eval_adv_config( array $cfg, array $values ): bool { if ( empty( $cfg['enabled'] ) ) { return true; } $groups_op = strtoupper( $cfg['groups_operator'] ?? 'AND' ); $groups = $cfg['groups'] ?? []; if ( empty( $groups ) ) { return true; } $groups_result = 'OR' !== $groups_op; foreach ( $groups as $group ) { $g = self::eval_group( $group, $values ); if ( 'OR' === $groups_op ) { if ( $g ) { $groups_result = true; break; } } elseif ( ! $g ) { $groups_result = false; break; } } return 'hide' === strtolower( $cfg['actionType'] ?? 'show' ) ? ! $groups_result : $groups_result; } /** * Clears a field's POST value so GF neither validates nor stores it. * * @param int $field_id GF field ID. */ private static function clear_post_value( int $field_id ): void { $key = 'input_' . $field_id; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $_POST[ $key ] = is_array( $_POST[ $key ] ) ? [] : ''; } $prefix = 'input_' . $field_id . '_'; // phpcs:ignore WordPress.Security.NonceVerification.Missing foreach ( array_keys( $_POST ) as $k ) { if ( str_starts_with( $k, $prefix ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $_POST[ $k ] = ''; } } } // ------------------------------------------------------------------------- // Server-side enforcement hooks // ------------------------------------------------------------------------- /** * Hooked to gform_pre_submission (action). * Clears POST values for fields hidden by advanced conditional logic so GF * neither validates nor stores those values. Mirrors JS evalAdvConfig. * * @param array $form GF form array passed by GF's action. */ public static function enforce_server_side( array $form ): void { // Auto-import native CL if not yet done — gform_pre_validation (where attach() runs) // fires after gform_pre_submission, so we replicate the import here. foreach ( $form['fields'] as &$field ) { if ( empty( self::get_field_prop( $field, 'blAdvLogic' ) ) && ! empty( self::get_field_prop( $field, 'conditionalLogic' ) ) ) { self::set_field_prop( $field, 'blAdvLogic', self::import_from_native( self::get_field_prop( $field, 'conditionalLogic' ) ) ); } } unset( $field ); $config = self::extract_config( $form ); foreach ( $config['fields'] as $field_id => $adv_cfg ) { $values = self::collect_rule_values( $adv_cfg ); if ( ! self::eval_adv_config( $adv_cfg, $values ) ) { self::clear_post_value( intval( $field_id ) ); } } } /** * Hooked to gform_field_validation (filter). * Passes validation for fields that advanced conditional logic hides, preventing * required-field errors on fields the user cannot see. * * @param array $result Validation result: [ 'is_valid' => bool, 'message' => string ]. * @param mixed $_value unused. * @param array $_form unused. * @param object|array $field GF field. * @return array */ public static function skip_validation_if_hidden( array $result, $_value, array $_form, $field ): array { $adv = self::get_field_prop( $field, 'blAdvLogic' ); if ( is_string( $adv ) && '' !== $adv ) { $decoded = json_decode( $adv, true ); $adv = is_array( $decoded ) ? $decoded : null; } if ( empty( $adv['enabled'] ) ) { return $result; } $values = self::collect_rule_values( $adv ); if ( ! self::eval_adv_config( $adv, $values ) ) { return [ 'is_valid' => true, 'message' => '', ]; } return $result; } } BL_GF_AdvLogic::init();