PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/field-validation.php +410 -0 1.0.0 → 1.1.0 View file →
@@ -26,8 +26,28 @@
26 26 */
27 27 public const BLOCK_CONFIG_META_KEY = '_suredonation_block_config';
28 28
29 29 /**
30 + * Key within the consolidated suredonation_options array that stores the
31 + * admin-overridden default validation messages (Global Settings → Form
32 + * Validation). Per-field messages always take precedence over these.
33 + *
34 + * @since 1.1.0
35 + */
36 + public const VALIDATION_MESSAGES_OPTION_KEY = 'validation_messages';
37 +
38 + /**
39 + * Field blocks whose values participate in field-level validation.
40 + *
41 + * @since 1.1.0
42 + */
43 + public const VALIDATABLE_BLOCKS = [
44 + 'suredonation/input',
45 + 'suredonation/email',
46 + 'suredonation/number',
47 + ];
48 +
49 + /**
30 50 * Add block configuration for form fields.
31 51 *
32 52 * This function processes blocks in a form and stores their configuration as post meta.
33 53 * It extracts payment block settings (amount type, fixed amount, minimum amount, etc.)
@@ -141,10 +161,33 @@
141 161 break;
142 162 case 'suredonation/cover-fees':
143 163 $processed_config = self::process_cover_fees_block( $block['attrs'] );
144 164 break;
165 + case 'suredonation/input':
166 + $processed_config = self::process_input_block( $block['attrs'] );
167 + break;
168 + case 'suredonation/email':
169 + $processed_config = self::process_email_block( $block['attrs'] );
170 + break;
145 171 }
146 172
173 + /**
174 + * Filter the stored validation config for a field block.
175 + *
176 + * Lets extensions contribute configuration for field blocks the
177 + * core does not handle (e.g. phone, address, url) so their rules are
178 + * persisted on save and picked up by validate_form_data(). Return a
179 + * non-empty array (including at least a 'required' flag plus any rule
180 + * values the validator needs) to store it under the block id.
181 + *
182 + * @since 1.1.0
183 + * @param array<string, mixed>|null $processed_config Config from core (null when unhandled).
184 + * @param string $block_name Block name.
185 + * @param array<string, mixed> $attrs Block attributes.
186 + * @param array<mixed> $blocks All blocks in the form.
187 + */
188 + $processed_config = apply_filters( 'suredonation_field_block_config', $processed_config, $block_name, $block['attrs'], $blocks );
189 +
147 190 // If block was processed, store its configuration.
148 191 if ( null !== $processed_config && ! empty( $processed_config ) ) {
149 192 $processed_config['block_name'] = $block_name;
150 193
@@ -344,7 +387,374 @@
344 387 if ( isset( $attrs['max'] ) ) {
345 388 $number_config['max'] = floatval( $attrs['max'] );
346 389 }
347 390
391 + // Field-level min/max value rules for client + server validation.
392 + //
393 + // These are stored under dedicated keys (read from the block's real
394 + // `minValue`/`maxValue` attributes) and are deliberately kept separate
395 + // from the amount-path `min`/`max` keys above, which are consumed by
396 + // Payment_Helper::validate_number_field_amount(). Coerced with absint to
397 + // match Number_Markup, which renders integer min/max — keeping the
398 + // rendered HTML constraints and server validation in sync. The markup
399 + // mirrors these exact defaults: min is always present (default 1) and
400 + // max only applies when greater than zero.
401 + $number_config['validation_min'] = isset( $attrs['minValue'] ) ? absint( Helper::get_string_value( $attrs['minValue'] ) ) : 1;
402 + $number_config['validation_max'] = isset( $attrs['maxValue'] ) ? absint( Helper::get_string_value( $attrs['maxValue'] ) ) : 0;
403 +
404 + // Per-field custom required message.
405 + $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
406 + if ( '' !== $error_msg ) {
407 + $number_config['error_msg'] = $error_msg;
408 + }
409 +
348 410 return $number_config;
411 + }
412 +
413 + /**
414 + * Process input (text) block configuration.
415 + *
416 + * Extracts the field-level validation rules — required, max length and the
417 + * optional per-field custom required message — for server-side enforcement.
418 + *
419 + * @param array<mixed> $attrs Block attributes.
420 + * @return array<string, mixed> Processed input block configuration.
421 + * @since 1.1.0
422 + */
423 + private static function process_input_block( $attrs ) {
424 + $input_config = [
425 + 'required' => ! empty( $attrs['required'] ),
426 + 'max_length' => isset( $attrs['maxLength'] ) ? absint( Helper::get_string_value( $attrs['maxLength'] ) ) : 100,
427 + ];
428 +
429 + $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
430 + if ( '' !== $error_msg ) {
431 + $input_config['error_msg'] = $error_msg;
432 + }
433 +
434 + return $input_config;
435 + }
436 +
437 + /**
438 + * Process email block configuration.
439 + *
440 + * Extracts required state, the optional per-field custom required message
441 + * and the per-field invalid-email message for server-side enforcement.
442 + *
443 + * @param array<mixed> $attrs Block attributes.
444 + * @return array<string, mixed> Processed email block configuration.
445 + * @since 1.1.0
446 + */
447 + private static function process_email_block( $attrs ) {
448 + $email_config = [
449 + 'required' => ! empty( $attrs['required'] ),
450 + ];
451 +
452 + $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
453 + if ( '' !== $error_msg ) {
454 + $email_config['error_msg'] = $error_msg;
455 + }
456 +
457 + $invalid_email_msg = isset( $attrs['invalidEmailMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['invalidEmailMsg'] ) ) : '';
458 + if ( '' !== $invalid_email_msg ) {
459 + $email_config['invalid_email_msg'] = $invalid_email_msg;
460 + }
461 +
462 + return $email_config;
463 + }
464 +
465 + /**
466 + * Get the block types that participate in field validation.
467 + *
468 + * Extensions register new validatable field blocks (e.g. phone, address,
469 + * url) via the filter so their values run through validate_form_data().
470 + * Pair this with the suredonation_field_block_config filter (to store the
471 + * block's rules on save) and suredonation_validate_field (to apply them).
472 + *
473 + * @return array<int, string>
474 + * @since 1.1.0
475 + */
476 + public static function get_validatable_blocks() {
477 + /**
478 + * Filter the block types that participate in field validation.
479 + *
480 + * @since 1.1.0
481 + * @param array<int, string> $blocks Validatable block names.
482 + */
483 + $blocks = apply_filters( 'suredonation_validatable_blocks', self::VALIDATABLE_BLOCKS );
484 +
485 + return is_array( $blocks ) ? $blocks : self::VALIDATABLE_BLOCKS;
486 + }
487 +
488 + /**
489 + * Validate submitted donation form field values server-side.
490 + *
491 + * This is the authoritative validation pass: it reads the immutable block
492 + * configuration stored on form save and enforces each field's rules
493 + * (required, max length, email format, number range). Per-field custom
494 + * messages take precedence over the global defaults configured under
495 + * Global Settings → Form Validation.
496 + *
497 + * @param array<string, mixed> $fields Submitted field values keyed by field slug.
498 + * @param int $form_id Donation form post ID.
499 + * @return array<string, string> Map of field slug => error message. Empty when valid.
500 + * @since 1.1.0
501 + */
502 + public static function validate_form_data( $fields, $form_id ) {
503 + $errors = [];
504 +
505 + if ( ! is_array( $fields ) ) {
506 + $fields = [];
507 + }
508 +
509 + $form_id = absint( $form_id );
510 + if ( $form_id <= 0 ) {
511 + return $errors;
512 + }
513 +
514 + $block_config = self::get_or_migrate_block_config_for_legacy_form( $form_id );
515 + if ( empty( $block_config ) || ! is_array( $block_config ) ) {
516 + return $errors;
517 + }
518 +
519 + $validatable = self::get_validatable_blocks();
520 +
521 + foreach ( $block_config as $config ) {
522 + if ( ! is_array( $config ) ) {
523 + continue;
524 + }
525 +
526 + $block_name = isset( $config['block_name'] ) && is_string( $config['block_name'] ) ? $config['block_name'] : '';
527 + $slug = isset( $config['slug'] ) && is_string( $config['slug'] ) ? $config['slug'] : '';
528 +
529 + if ( '' === $slug || ! in_array( $block_name, $validatable, true ) ) {
530 + continue;
531 + }
532 +
533 + $raw_value = array_key_exists( $slug, $fields ) ? $fields[ $slug ] : '';
534 + $value = is_scalar( $raw_value ) ? trim( (string) $raw_value ) : '';
535 +
536 + $error = self::validate_field_value( $block_name, $config, $value );
537 +
538 + /**
539 + * Filter the validation error for a single donation form field.
540 + *
541 + * Lets extensions (e.g. SureDonation Pro) add custom validators for
542 + * their own field types or rules. Return a non-empty string to flag
543 + * the field as invalid; return an empty string to pass.
544 + *
545 + * @since 1.1.0
546 + * @param string $error Current error message ('' when valid).
547 + * @param string $value Submitted, trimmed field value.
548 + * @param array<string, mixed> $config Stored block configuration for the field.
549 + * @param int $form_id Donation form ID.
550 + * @param string $block_name Block name (e.g. 'suredonation/input').
551 + */
552 + $error = apply_filters( 'suredonation_validate_field', $error, $value, $config, $form_id, $block_name );
553 +
554 + if ( is_string( $error ) && '' !== $error ) {
555 + $errors[ $slug ] = $error;
556 + }
557 + }
558 +
559 + return $errors;
560 + }
561 +
562 + /**
563 + * Get a validation message by key, preferring the admin override.
564 + *
565 + * @param string $key Message key.
566 + * @return string
567 + * @since 1.1.0
568 + */
569 + public static function get_validation_message( $key ) {
570 + $defaults = self::default_validation_messages();
571 + $stored = Helper::get_suredonation_option( self::VALIDATION_MESSAGES_OPTION_KEY, [] );
572 +
573 + if ( is_array( $stored ) && ! empty( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
574 + return $stored[ $key ];
575 + }
576 +
577 + return isset( $defaults[ $key ] ) ? $defaults[ $key ] : '';
578 + }
579 +
580 + /**
581 + * Default (fallback) validation messages, keyed by message key.
582 + *
583 + * Messages containing %s use sprintf substitution for the configured bound.
584 + *
585 + * @return array<string, string>
586 + * @since 1.1.0
587 + */
588 + public static function default_validation_messages() {
589 + $messages = [
590 + 'suredonation_input_block_required_text' => __( 'This field is required.', 'suredonation' ),
591 + 'suredonation_email_block_required_text' => __( 'This field is required.', 'suredonation' ),
592 + 'suredonation_number_block_required_text' => __( 'This field is required.', 'suredonation' ),
593 + 'suredonation_valid_email' => __( 'Please enter a valid email address.', 'suredonation' ),
594 + 'suredonation_valid_number' => __( 'Please enter a valid number.', 'suredonation' ),
595 + /* translators: %s: maximum number of characters allowed. */
596 + 'suredonation_input_max_length' => __( 'Maximum length is %s characters.', 'suredonation' ),
597 + /* translators: %s: minimum allowed value. */
598 + 'suredonation_input_min_value' => __( 'Minimum value is %s.', 'suredonation' ),
599 + /* translators: %s: maximum allowed value. */
600 + 'suredonation_input_max_value' => __( 'Maximum value is %s.', 'suredonation' ),
601 + ];
602 +
603 + /**
604 + * Filter the default validation messages.
605 + *
606 + * Extensions add message keys for their own field types here so the
607 + * messages resolve, localize and surface in the Form Validation tab
608 + * alongside the core ones. Keys containing %s use sprintf substitution.
609 + *
610 + * @since 1.1.0
611 + * @param array<string, string> $messages Default messages keyed by message key.
612 + */
613 + return apply_filters( 'suredonation_default_validation_messages', $messages );
614 + }
615 +
616 + /**
617 + * Get the fully resolved validation messages (admin overrides over defaults).
618 + *
619 + * Used to localize the messages to the frontend so client-side validation
620 + * mirrors exactly what the server enforces.
621 + *
622 + * @return array<string, string>
623 + * @since 1.1.0
624 + */
625 + public static function get_resolved_validation_messages() {
626 + $defaults = self::default_validation_messages();
627 + $stored = Helper::get_suredonation_option( self::VALIDATION_MESSAGES_OPTION_KEY, [] );
628 +
629 + if ( ! is_array( $stored ) ) {
630 + return $defaults;
631 + }
632 +
633 + $resolved = $defaults;
634 + foreach ( $defaults as $key => $default ) {
635 + if ( ! empty( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
636 + $resolved[ $key ] = $stored[ $key ];
637 + }
638 + }
639 +
640 + return $resolved;
641 + }
642 +
643 + /**
644 + * Apply the core validation rules for a single field value.
645 + *
646 + * @param string $block_name Block name.
647 + * @param array<string, mixed> $config Stored block configuration for the field.
648 + * @param string $value Submitted, trimmed field value.
649 + * @return string Error message, or '' when the value passes.
650 + * @since 1.1.0
651 + */
652 + private static function validate_field_value( $block_name, $config, $value ) {
653 + // Required check applies to every field type.
654 + if ( ! empty( $config['required'] ) && '' === $value ) {
655 + return self::resolve_required_message( $block_name, $config );
656 + }
657 +
658 + // Format/range checks are skipped for empty optional values.
659 + if ( '' === $value ) {
660 + return '';
661 + }
662 +
663 + switch ( $block_name ) {
664 + case 'suredonation/input':
665 + $max_length = isset( $config['max_length'] ) && is_numeric( $config['max_length'] ) ? (int) $config['max_length'] : 0;
666 + $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value );
667 + if ( $max_length > 0 && $length > $max_length ) {
668 + // str_replace (not sprintf) because the message is admin/translator
669 + // editable; a stray literal % would make sprintf throw on PHP 8.
670 + return str_replace( '%s', number_format_i18n( $max_length ), self::get_validation_message( 'suredonation_input_max_length' ) );
671 + }
672 + break;
673 +
674 + case 'suredonation/email':
675 + if ( ! is_email( $value ) ) {
676 + if ( ! empty( $config['invalid_email_msg'] ) && is_string( $config['invalid_email_msg'] ) ) {
677 + return $config['invalid_email_msg'];
678 + }
679 + return self::get_validation_message( 'suredonation_valid_email' );
680 + }
681 + break;
682 +
683 + case 'suredonation/number':
684 + if ( ! is_numeric( $value ) ) {
685 + return self::get_validation_message( 'suredonation_valid_number' );
686 + }
687 +
688 + $number = (float) $value;
689 +
690 + if ( isset( $config['validation_min'] ) && is_numeric( $config['validation_min'] ) && $number < (float) $config['validation_min'] ) {
691 + return str_replace( '%s', self::format_number( (float) $config['validation_min'] ), self::get_validation_message( 'suredonation_input_min_value' ) );
692 + }
693 +
694 + $validation_max = isset( $config['validation_max'] ) && is_numeric( $config['validation_max'] ) ? (float) $config['validation_max'] : 0.0;
695 + if ( $validation_max > 0 && $number > $validation_max ) {
696 + return str_replace( '%s', self::format_number( $validation_max ), self::get_validation_message( 'suredonation_input_max_value' ) );
697 + }
698 + break;
699 + }
700 +
701 + return '';
702 + }
703 +
704 + /**
705 + * Resolve the required-error message for a field.
706 + *
707 + * Resolution order: per-field custom message → global default for the field
708 + * type (Global Settings → Form Validation) → generic fallback. The message
709 + * key is derived from the block name by convention, so new field blocks need
710 + * no code change here — they only register their default message and tab
711 + * field (e.g. 'suredonation/phone' → 'suredonation_phone_block_required_text').
712 + *
713 + * @param string $block_name Block name.
714 + * @param array<string, mixed> $config Stored block configuration for the field.
715 + * @return string
716 + * @since 1.1.0
717 + */
718 + private static function resolve_required_message( $block_name, $config ) {
719 + if ( ! empty( $config['error_msg'] ) && is_string( $config['error_msg'] ) ) {
720 + return $config['error_msg'];
721 + }
722 +
723 + $message = self::get_validation_message( self::required_message_key( $block_name ) );
724 +
725 + return '' !== $message ? $message : __( 'This field is required.', 'suredonation' );
726 + }
727 +
728 + /**
729 + * Derive the required-message key for a block name.
730 + *
731 + * 'suredonation/input' => 'suredonation_input_block_required_text'.
732 + *
733 + * @param string $block_name Block name.
734 + * @return string
735 + * @since 1.1.0
736 + */
737 + public static function required_message_key( $block_name ) {
738 + $short = str_replace( 'suredonation/', '', (string) $block_name );
739 + $short = (string) preg_replace( '/[^a-z0-9_]+/', '_', strtolower( $short ) );
740 +
741 + return 'suredonation_' . $short . '_block_required_text';
742 + }
743 +
744 + /**
745 + * Format a numeric bound for display in a validation message.
746 + *
747 + * Drops the decimal portion for whole numbers (e.g. 10.0 → "10").
748 + *
749 + * @param float $number Number to format.
750 + * @return string
751 + * @since 1.1.0
752 + */
753 + private static function format_number( $number ) {
754 + if ( floor( $number ) === $number ) {
755 + return number_format_i18n( $number );
756 + }
757 +
758 + return number_format_i18n( $number, 2 );
349 759 }
350 760 }