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( '', $message );
+ $this->assertStringContainsString( '- Text is required
', $message );
+ $this->assertStringContainsString( '- Select is required
', $message );
+
+ // Checkbox and radio links target the first option so focus lands on a real input.
+ $this->assertStringContainsString( '- Checkbox is required
', $message );
+ $this->assertStringContainsString( '- Radio is required
', $message );
+ }
+
+ /**
+ * Non-field errors such as 'form' or 'spam' have no input to link to and should be skipped.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_skips_non_field_errors() {
+ $this->form = $this->factory->form->create_and_get();
+ $text_id = $this->create_field_with_key( 'text', 'linkable_text' );
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => array(
+ 'form' => 'There was a problem with your submission.',
+ 'spam' => 'Your entry appears to be spam!',
+ 'field' . $text_id => 'Text is required',
+ ),
+ )
+ );
+
+ $this->assertStringContainsString( '- Text is required
', $message );
+ $this->assertStringNotContainsString( 'appears to be spam', $message );
+ $this->assertStringNotContainsString( 'x',
+ ),
+ )
+ );
+
+ // Scripts are removed entirely.
+ $this->assertStringNotContainsString( '