diff --git a/classes/controllers/FrmEntriesAJAXSubmitController.php b/classes/controllers/FrmEntriesAJAXSubmitController.php index 00004b7c21..3ce89a3f18 100644 --- a/classes/controllers/FrmEntriesAJAXSubmitController.php +++ b/classes/controllers/FrmEntriesAJAXSubmitController.php @@ -73,13 +73,19 @@ public static function ajax_create() { } $response['errors'] = $obj; - $invalid_msg = FrmFormsHelper::get_invalid_error_message( array( 'form' => $form ) ); + $invalid_msg = FrmFormsHelper::get_invalid_error_message( + array( + 'form' => $form, + 'errors' => $errors, + ) + ); $response['error_message'] = FrmFormsHelper::get_success_message( array( 'message' => $invalid_msg, 'form' => $form, 'entry_id' => 0, 'class' => FrmFormsHelper::form_error_class(), + 'role' => 'alert', ) ); } else { diff --git a/classes/helpers/FrmFormsHelper.php b/classes/helpers/FrmFormsHelper.php index 483050f386..b0e9ca95ef 100644 --- a/classes/helpers/FrmFormsHelper.php +++ b/classes/helpers/FrmFormsHelper.php @@ -288,11 +288,184 @@ public static function get_invalid_error_message( $args ) { $settings_args['current_form'] = $args['form']->id; } - $frm_settings = FrmAppHelper::get_settings( $settings_args ); - $invalid_msg = do_shortcode( $frm_settings->invalid_msg ); + $frm_settings = FrmAppHelper::get_settings( $settings_args ); + $field_error_messages = self::get_clickable_field_error_messages( $args ); + $invalid_msg = '' . do_shortcode( $frm_settings->invalid_msg ) . ''; + + if ( $field_error_messages ) { + $invalid_msg .= ""; + } + return apply_filters( 'frm_invalid_error_message', $invalid_msg, $args ); } + /** + * Get clickable field error messages. + * + * @since x.x + * + * @param array $args + * + * @return string + */ + private static function get_clickable_field_error_messages( $args ) { + if ( empty( $args['errors'] ) ) { + return ''; + } + + // Parse each error key once into its field ID and repeater row suffix, skipping + // non-field errors like 'form' or 'spam' that have no input to link to. + $parsed_errors = array(); + $field_ids = array(); + + foreach ( $args['errors'] as $field_plus_id => $error ) { + $field_id = preg_replace( '/^field/', '', $field_plus_id ); + $row = ''; + + if ( str_contains( $field_id, '-' ) ) { + $field_id_parts = explode( '-', $field_id ); + + if ( count( $field_id_parts ) === 3 ) { + $field_id = $field_id_parts[0]; + $row = '-' . $field_id_parts[2]; + } + } + + if ( ! is_numeric( $field_id ) ) { + continue; + } + + $field_ids[] = (int) $field_id; + $parsed_errors[] = compact( 'field_id', 'row', 'error' ); + } + + if ( ! $field_ids ) { + return ''; + } + + $fields_by_id = self::get_error_fields_by_id( $args, $field_ids ); + + // Error messages are admin configured and may include shortcodes, so allow the same + // inline formatting Formidable permits elsewhere while stripping anything unsafe. Anchors + // are intentionally excluded so a message link cannot nest inside the summary link. + $allowed_tags = array( 'strong', 'b', 'em', 'i', 'u', 'span', 'code', 'br', 'sub', 'sup', 'mark', 'small' ); + $field_error_messages = ''; + + foreach ( $parsed_errors as $parsed_error ) { + $field_id = (int) $parsed_error['field_id']; + + if ( ! isset( $fields_by_id[ $field_id ] ) ) { + continue; + } + + $field = $fields_by_id[ $field_id ]; + $error = FrmAppHelper::kses( $parsed_error['error'], $allowed_tags ); + + if ( ! self::error_field_is_linkable( $field ) ) { + // The field has no focusable input on the page being shown (a hidden field, or a + // field on another page of a multi-page form), so list the error as plain text + // rather than a link that would go nowhere when clicked. + $field_error_messages .= '
  • ' . $error . '
  • '; + continue; + } + + $html_id = 'field_' . $field->field_key . $parsed_error['row']; + + if ( in_array( $field->type, array( 'checkbox', 'radio' ), true ) ) { + // Needed to focus on the first option when the error link is clicked. + $html_id .= '-0'; + } + + $field_error_messages .= '
  • ' . $error . '
  • '; + }//end foreach + + return $field_error_messages; + } + + /** + * Map the errored field IDs to field data. + * + * The fields for the form have already been loaded to render or validate the submission, + * so this reuses them (threaded down in $args, or the per-field cache warmed while + * validating the entry) and only queries for any field it still cannot resolve, keeping + * the error summary from adding a database round trip in the normal flow. + * + * @since x.x + * + * @param array $args Includes optional 'fields'. + * @param int[] $field_ids Field IDs referenced by the current errors. + * + * @return array Field objects keyed by field ID. + */ + private static function get_error_fields_by_id( $args, $field_ids ) { + $fields_by_id = array(); + + // Prefer the fields already prepared for the form being shown, threaded down in $args. + if ( ! empty( $args['fields'] ) && is_array( $args['fields'] ) ) { + foreach ( $args['fields'] as $field ) { + // Display fields arrive as arrays; normalize to the object shape used below. + $field = (object) $field; + + if ( isset( $field->id ) ) { + $fields_by_id[ (int) $field->id ] = $field; + } + } + } + + // Next, the per-field cache warmed while validating the entry (getAll caches every + // field by id), so an AJAX submit resolves its errored fields without a query. + foreach ( array_diff( $field_ids, array_keys( $fields_by_id ) ) as $field_id ) { + $cached = FrmDb::check_cache( $field_id, 'frm_field' ); + + if ( is_object( $cached ) ) { + $fields_by_id[ $field_id ] = $cached; + } + } + + // Only touch the database for fields that are still unresolved. + $missing = array_diff( $field_ids, array_keys( $fields_by_id ) ); + + if ( ! $missing ) { + return $fields_by_id; + } + + $fields = FrmDb::get_results( 'frm_fields', array( 'id' => array_values( $missing ) ), 'id,field_key,type,field_order,form_id' ); + + foreach ( $fields as $field ) { + $fields_by_id[ (int) $field->id ] = $field; + } + + return $fields_by_id; + } + + /** + * Whether an error summary should link to the field, or just list its message as plain text. + * + * A link is only useful when the field renders a focusable input that is actually on the page + * being shown. Hidden and user ID fields render as hidden inputs that cannot receive focus, and + * a field on another page of a multi-page form is not visible, so neither should be linked. + * + * @since x.x + * + * @param stdClass $field Field row with at least type, field_order and form_id. + * + * @return bool + */ + private static function error_field_is_linkable( $field ) { + if ( in_array( $field->type, array( 'hidden', 'user_id' ), true ) ) { + // Hidden inputs cannot receive focus, so there is nothing to link to. + return false; + } + + if ( ! is_callable( 'FrmProFieldsHelper::field_on_current_page' ) ) { + // Multi-page forms are a Pro feature; in Lite every field is on the only page. + return true; + } + + // On a multi-page form, do not link a field that is on a page other than the one being shown. + return FrmProFieldsHelper::field_on_current_page( $field ); + } + /** * @param array $atts { * The success message details. @@ -301,6 +474,9 @@ public static function get_invalid_error_message( $args ) { * @type stdClass $form * @type int $entry_id * @type string $class + * @type string $role Optional. ARIA live region role for the wrapper. Defaults to 'status'. + * Pass 'alert' when the message reports a validation error so it is + * announced assertively, matching the non-ajax error wrapper. * } * * @return string @@ -328,7 +504,9 @@ public static function get_success_message( $atts ) { } $message = do_shortcode( $message ); - return '
    ' . $message . '
    '; + $role = $atts['role'] ?? 'status'; + + return '
    ' . $message . '
    '; } /** diff --git a/classes/views/frm-entries/errors.php b/classes/views/frm-entries/errors.php index c8fa444c92..26dfb2b70e 100644 --- a/classes/views/frm-entries/errors.php +++ b/classes/views/frm-entries/errors.php @@ -50,7 +50,14 @@ } } - FrmFormsHelper::show_errors( compact( 'img', 'errors', 'form' ) ); + $error_args = compact( 'img', 'errors', 'form' ); + + if ( isset( $values['fields'] ) ) { + // Reuse the fields already prepared for this form so the summary needs no extra query. + $error_args['fields'] = $values['fields']; + } + + FrmFormsHelper::show_errors( $error_args ); ?> diff --git a/css/_single_theme.css.php b/css/_single_theme.css.php index 8d520cfb5c..e5ddc383c5 100644 --- a/css/_single_theme.css.php +++ b/css/_single_theme.css.php @@ -422,6 +422,26 @@ margin-bottom:var(--field-margin); } +. .frm_error_style span{ + font-weight: bold; +} + +. .frm_error_style ul{ + list-style: inside; + color: var(--error-text); + margin-bottom: 0; + margin-left: 0; + list-style-position: outside; +} + +. .frm_error_style ul li a{ + color: var(--error-text); +} + +. .frm_error_style ul li a:hover{ + text-decoration: underline; +} + . #frm_loading .progress-striped .progress-bar{ background-image:linear-gradient(45deg, 25%, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0) 50%, 50%, 75%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0)); diff --git a/stubs.php b/stubs.php index d2fbe140c7..7edadbe48d 100644 --- a/stubs.php +++ b/stubs.php @@ -316,6 +316,13 @@ public static function replace_non_standard_formidable_shortcodes( $args, &$valu */ public static function is_field_visible_to_user( $field ) { } + /** + * @param array|int|object $field + * + * @return bool + */ + public static function field_on_current_page( $field ) { + } } class FrmViewsAppHelper { /** diff --git a/tests/phpunit/forms/test_FrmFormsHelper.php b/tests/phpunit/forms/test_FrmFormsHelper.php index 41a294d59d..f9bdb81661 100644 --- a/tests/phpunit/forms/test_FrmFormsHelper.php +++ b/tests/phpunit/forms/test_FrmFormsHelper.php @@ -146,4 +146,210 @@ private function create_form_with_custom_style_value( $custom_style ) { ) ); } + + /** + * The invalid error message should include a list of links that jump to each field that failed validation. + * + * @covers FrmFormsHelper::get_invalid_error_message + */ + public function test_get_invalid_error_message_builds_clickable_field_links() { + $this->form = $this->factory->form->create_and_get(); + + $text_id = $this->create_field_with_key( 'text', 'my_text' ); + $select_id = $this->create_field_with_key( 'select', 'my_select' ); + $checkbox_id = $this->create_field_with_key( 'checkbox', 'my_checkbox' ); + $radio_id = $this->create_field_with_key( 'radio', 'my_radio' ); + + $message = FrmFormsHelper::get_invalid_error_message( + array( + 'form' => $this->form, + 'errors' => array( + 'field' . $text_id => 'Text is required', + 'field' . $select_id => 'Select is required', + 'field' . $checkbox_id => 'Checkbox is required', + 'field' . $radio_id => 'Radio is required', + ), + ) + ); + + // The base invalid message is wrapped in a span, and the links are inside a list. + $this->assertStringContainsString( '