From d9621d9bc030d9e5784e0b684db47f7d00a56af7 Mon Sep 17 00:00:00 2001 From: "vivi-the-going-merry[bot]" <308115520+vivi-the-going-merry[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:19:03 -0600 Subject: [PATCH 1/4] Fix empty row-title link in Entries table's Form column When the Form column is the Entries list's action column (ID/Entry Key columns hidden, as on the "all forms" view), its value was wrapped in a second, nested pointing at the form itself. Browsers implicitly close the outer row-title anchor as soon as they hit that nested tag, leaving it with no accessible name -- an axe link-name violation on every row, and doubly so when the entry's form no longer exists (the inner link was empty too). Use the plain-text form label instead of the linked version whenever this column is acting as the row's own action column, matching the existing non-editor fallback path. The label already handles a missing/unnamed form with "(no title)" text. --- classes/helpers/FrmEntriesListHelper.php | 20 ++++--- .../entries/test_FrmEntriesListHelper.php | 52 ++++++++++++++++++- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/classes/helpers/FrmEntriesListHelper.php b/classes/helpers/FrmEntriesListHelper.php index 7ef19028eb..3c8678f5db 100644 --- a/classes/helpers/FrmEntriesListHelper.php +++ b/classes/helpers/FrmEntriesListHelper.php @@ -390,11 +390,12 @@ public function single_row( $item, $style = '' ) { if ( $this->column_name === 'cb' ) { $r .= "$checkbox"; } else { - $val = in_array( $column_name, $hidden, true ) ? '' : $this->column_value( $item ); - $r .= ""; - // phpcs:ignore Universal.Operators.StrictComparisons - if ( $column_name == $action_col ) { + $is_action_col = $column_name == $action_col; + $val = in_array( $column_name, $hidden, true ) ? '' : $this->column_value( $item, $is_action_col ); + $r .= ""; + + if ( $is_action_col ) { $edit_link = admin_url( 'admin.php?page=formidable-entries&frm_action=edit&id=' . $item->id ); $r .= '' . $val . ' '; $r .= $action_links; @@ -420,10 +421,12 @@ private function get_action_columns() { /** * @param object $item + * @param bool $is_action_col Whether this column is the row's primary/action column + * (wrapped in the row-title link by the caller). * * @return mixed */ - private function column_value( $item ) { + private function column_value( $item, $is_action_col = false ) { $col_name = $this->maybe_fix_column_name( $this->column_name ); switch ( $col_name ) { @@ -452,7 +455,12 @@ private function column_value( $item ) { $form_id = $item->form_id; $user_can_edit_forms = false === FrmAppHelper::permission_nonce_error( 'frm_edit_forms' ); - if ( $user_can_edit_forms ) { + if ( $user_can_edit_forms && ! $is_action_col ) { + // When this column is the row's own action column, the caller already + // wraps it in a row-title link to the entry -- nesting a second here + // (to the form itself) produces invalid, empty-named markup once the + // browser closes the outer anchor early. Fall back to the plain label, + // same as when the user can't edit forms. $val = FrmFormsHelper::edit_form_link( $form_id ); } else { $val = FrmFormsHelper::edit_form_link_label( $form_id ); diff --git a/tests/phpunit/entries/test_FrmEntriesListHelper.php b/tests/phpunit/entries/test_FrmEntriesListHelper.php index 248f56a561..21aae933d3 100644 --- a/tests/phpunit/entries/test_FrmEntriesListHelper.php +++ b/tests/phpunit/entries/test_FrmEntriesListHelper.php @@ -43,13 +43,61 @@ public function test_column_value() { $this->assertSame( 'My entry name', $column_value ); } + /** + * The Form column's own value is wrapped in a second, nested row-title + * link when it's the row's action column (e.g. Entry Key/ID columns + * hidden on the "all forms" Entries screen) -- browsers implicitly close + * the outer row-title as soon as they hit that nested one, leaving it + * with no accessible name. When acting as the action column, the form + * value must be plain text instead. + * + * @covers FrmEntriesListHelper::column_value + */ + public function test_column_value_form_id_as_action_col_has_no_nested_link() { + FrmAppHelper::set_current_screen_and_hook_suffix(); + wp_set_current_user( 1 ); + + $item = new stdClass(); + $item->form_id = $this->factory->form->create( array( 'name' => 'My Form' ) ); + + // Not the action column: existing behavior, links to the form itself. + $column_value = $this->column_value( $item, 'form_id', false ); + $this->assertStringContainsString( 'assertStringContainsString( 'My Form', $column_value ); + + // As the action column: no nested , plain label only. + $column_value = $this->column_value( $item, 'form_id', true ); + $this->assertStringNotContainsString( 'assertSame( 'My Form', $column_value ); + } + + /** + * An entry whose form no longer exists (form_id doesn't resolve to a + * row) must still render non-empty fallback text as the action column, + * not an empty row-title link. + * + * @covers FrmEntriesListHelper::column_value + */ + public function test_column_value_form_id_as_action_col_missing_form_falls_back() { + FrmAppHelper::set_current_screen_and_hook_suffix(); + wp_set_current_user( 1 ); + + $item = new stdClass(); + $item->form_id = 999999999; + + $column_value = $this->column_value( $item, 'form_id', true ); + $this->assertNotEmpty( $column_value ); + $this->assertStringNotContainsString( 'set_private_property( $list_helper, 'column_name', $column_name ); - return $this->run_private_method( array( $list_helper, 'column_value' ), array( $item ) ); + return $this->run_private_method( array( $list_helper, 'column_value' ), array( $item, $is_action_col ) ); } } From 8aef52bc2b9c18917b5cc7ab1ed0b12e51b5fb7a Mon Sep 17 00:00:00 2001 From: "vivi-the-going-merry[bot]" <308115520+vivi-the-going-merry[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:24:50 -0600 Subject: [PATCH 2/4] Also fix the same nested-anchor bug on the Post column Self-review turned up the identical defect on the 'post_id' ("Post") column: post_edit_link() builds its own , which breaks the outer row-title link the same way edit_form_link() did when this column ends up as the row's action column. Same fix, same fallback text for a deleted post. --- classes/helpers/FrmEntriesListHelper.php | 10 ++++- .../entries/test_FrmEntriesListHelper.php | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/classes/helpers/FrmEntriesListHelper.php b/classes/helpers/FrmEntriesListHelper.php index 3c8678f5db..c211d78c2c 100644 --- a/classes/helpers/FrmEntriesListHelper.php +++ b/classes/helpers/FrmEntriesListHelper.php @@ -467,7 +467,15 @@ private function column_value( $item, $is_action_col = false ) { } break; case 'post_id': - $val = FrmAppHelper::post_edit_link( $item->post_id ); + if ( $is_action_col ) { + // Same reasoning as the form_id case above: post_edit_link() builds its + // own nested , which breaks the outer row-title link when this column + // is the action column. + $post = get_post( $item->post_id ); + $val = $post ? FrmAppHelper::truncate( $post->post_title, 50 ) : FrmFormsHelper::get_no_title_text(); + } else { + $val = FrmAppHelper::post_edit_link( $item->post_id ); + } break; case 'user_id': $user = get_userdata( $item->user_id ); diff --git a/tests/phpunit/entries/test_FrmEntriesListHelper.php b/tests/phpunit/entries/test_FrmEntriesListHelper.php index 21aae933d3..b9d4bca647 100644 --- a/tests/phpunit/entries/test_FrmEntriesListHelper.php +++ b/tests/phpunit/entries/test_FrmEntriesListHelper.php @@ -90,6 +90,49 @@ public function test_column_value_form_id_as_action_col_missing_form_falls_back( $this->assertStringNotContainsString( ', which must be dropped in favor of a plain label when this + * column is the row's action column. + * + * @covers FrmEntriesListHelper::column_value + */ + public function test_column_value_post_id_as_action_col_has_no_nested_link() { + FrmAppHelper::set_current_screen_and_hook_suffix(); + + $item = new stdClass(); + $item->post_id = $this->factory->post->create( array( 'post_title' => 'My Post' ) ); + + // Not the action column: existing behavior, links to the post itself. + $column_value = $this->column_value( $item, 'post_id', false ); + $this->assertStringContainsString( 'assertStringContainsString( 'My Post', $column_value ); + + // As the action column: no nested , plain label only. + $column_value = $this->column_value( $item, 'post_id', true ); + $this->assertStringNotContainsString( 'assertSame( 'My Post', $column_value ); + } + + /** + * An entry whose linked post no longer exists must still render + * non-empty fallback text as the action column, not an empty + * row-title link. + * + * @covers FrmEntriesListHelper::column_value + */ + public function test_column_value_post_id_as_action_col_missing_post_falls_back() { + FrmAppHelper::set_current_screen_and_hook_suffix(); + + $item = new stdClass(); + $item->post_id = 999999999; + + $column_value = $this->column_value( $item, 'post_id', true ); + $this->assertNotEmpty( $column_value ); + $this->assertStringNotContainsString( ' Date: Sat, 19 Sep 2026 21:42:30 -0600 Subject: [PATCH 3/4] Note the $is_action_col generalization gap on custom entries columns Franky's non-blocking review note: filter-based custom columns (frm_entries_{col}_column / frm_entries_column_value) don't receive $is_action_col, so a third-party column building its own could hit the same nested-anchor bug as form_id/post_id if it ever became the row's action column. No known add-on does this today, so documenting the gap rather than generalizing blind. --- classes/helpers/FrmEntriesListHelper.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/classes/helpers/FrmEntriesListHelper.php b/classes/helpers/FrmEntriesListHelper.php index c211d78c2c..f30f7e90b3 100644 --- a/classes/helpers/FrmEntriesListHelper.php +++ b/classes/helpers/FrmEntriesListHelper.php @@ -485,6 +485,9 @@ private function column_value( $item, $is_action_col = false ) { $val = $item->parent_item_id; break; default: + // Note: $is_action_col isn't passed to these filters, so a custom column that + // builds its own here can hit the same nested-anchor bug as form_id/post_id + // above if it ever becomes the row's action column. $val = apply_filters( 'frm_entries_' . $col_name . '_column', false, compact( 'item' ) ); if ( $val === false ) { From 018ee4a997a72a38b123a38937689ba5245fcf98 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Thu, 29 Oct 2026 21:33:00 -0300 Subject: [PATCH 4/4] fix(entries): strip nested links from any row-title column (#6662) Strip tags from the action column value in single_row(), and add a screen-reader "Entry N" label when the value is empty. This covers field and custom filter columns too, not only Form and Post. Move the Post column value into get_post_column_value() so column_value() stays under the phpcs complexity limit after master added the spam_reason case. https://github.com/Strategy11/formidable-pro/issues/6662 Co-Authored-By: Claude Opus 5.5 --- classes/helpers/FrmEntriesListHelper.php | 63 +++++++++++++++---- .../entries/test_FrmEntriesListHelper.php | 50 +++++++++++++++ 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/classes/helpers/FrmEntriesListHelper.php b/classes/helpers/FrmEntriesListHelper.php index 7427d6f547..a9ff715440 100644 --- a/classes/helpers/FrmEntriesListHelper.php +++ b/classes/helpers/FrmEntriesListHelper.php @@ -421,7 +421,8 @@ public function single_row( $item, $style = '' ) { if ( $is_action_col ) { $edit_link = admin_url( 'admin.php?page=formidable-entries&frm_action=edit&id=' . $item->id ); - $r .= '' . $val . ' '; + $link_text = self::get_row_title_text( $val, $item ); + $r .= '' . $link_text . ' '; $r .= $action_links; } else { $r .= $val; @@ -434,6 +435,29 @@ public function single_row( $item, $style = '' ) { return $r . ''; } + /** + * Get the content of the row title link, so the link is never empty for screen readers. + * + * A link nested inside the row title link (like a field or custom column value with its own link) is invalid markup, + * and browsers close the row title link before it, leaving the row title link with no text. + * + * @since x.x + * + * @param mixed $val The column value. + * @param stdClass $item The entry. + * + * @return string + */ + private static function get_row_title_text( $val, $item ) { + $val = preg_replace( '/<\/?a\b[^>]*>/i', '', (string) $val ); + + if ( '' === trim( wp_strip_all_tags( $val ) ) ) { + $val .= '' . esc_html( self::get_entry_label( $item ) ) . ''; + } + + return $val; + } + /** * Get the column names that the logged in user can action on * @@ -499,15 +523,7 @@ private function column_value( $item, $is_action_col = false ) { } break; case 'post_id': - if ( $is_action_col ) { - // Same reasoning as the form_id case above: post_edit_link() builds its - // own nested , which breaks the outer row-title link when this column - // is the action column. - $post = get_post( $item->post_id ); - $val = $post ? FrmAppHelper::truncate( $post->post_title, 50 ) : FrmFormsHelper::get_no_title_text(); - } else { - $val = FrmAppHelper::post_edit_link( $item->post_id ); - } + $val = $this->get_post_column_value( $item, $is_action_col ); break; case 'user_id': $user = get_userdata( $item->user_id ); @@ -517,9 +533,8 @@ private function column_value( $item, $is_action_col = false ) { $val = $item->parent_item_id; break; default: - // Note: $is_action_col isn't passed to these filters, so a custom column that - // builds its own here can hit the same nested-anchor bug as form_id/post_id - // above if it ever becomes the row's action column. + // $is_action_col isn't passed to these filters. If a custom column value with its own + // becomes the row's action column, single_row() strips the nested link in get_row_title_text(). $val = apply_filters( 'frm_entries_' . $col_name . '_column', false, compact( 'item' ) ); if ( $val === false ) { @@ -540,6 +555,28 @@ private function column_value( $item, $is_action_col = false ) { return $val; } + /** + * Get the Post column value. + * + * @since x.x + * + * @param object $item The entry. + * @param bool $is_action_col Whether this column is the row's primary/action column. + * + * @return string + */ + private function get_post_column_value( $item, $is_action_col ) { + if ( ! $is_action_col ) { + return FrmAppHelper::post_edit_link( $item->post_id ); + } + + // Same reasoning as the form_id case in column_value(): post_edit_link() builds its + // own nested , which breaks the outer row-title link when this column + // is the action column. + $post = get_post( $item->post_id ); + return $post ? FrmAppHelper::truncate( $post->post_title, 50 ) : FrmFormsHelper::get_no_title_text(); + } + /** * When a form has entries with the 0 item meta value, the values do not appear properly in the entries list. * diff --git a/tests/phpunit/entries/test_FrmEntriesListHelper.php b/tests/phpunit/entries/test_FrmEntriesListHelper.php index b30a889c52..dbe07388c7 100644 --- a/tests/phpunit/entries/test_FrmEntriesListHelper.php +++ b/tests/phpunit/entries/test_FrmEntriesListHelper.php @@ -154,6 +154,56 @@ public function test_spam_reason_column() { } } + /** + * The row title link must never contain a nested link or be left without text, + * whichever column ends up as the row title column. + * + * @see https://github.com/Strategy11/formidable-pro/issues/6662 + * + * @covers FrmEntriesListHelper::single_row + */ + public function test_single_row_title_link_has_no_nested_link() { + FrmAppHelper::set_current_screen_and_hook_suffix(); + wp_set_current_user( 1 ); + + $item = (object) array( + 'id' => 5, + 'name' => '', + 'ip' => '', + 'form_id' => $this->factory->form->create( array( 'name' => 'My Form' ) ), + ); + + $row = $this->single_row( $item, array( '0_form_id' => 'Form' ) ); + $this->assertMatchesRegularExpression( '/]*class="row-title" >My Form<\/a>/', $row ); + $this->assertSame( 1, substr_count( $row, '>My Form<' ) ); + + // A custom column value with its own link is reduced to its text. + $custom_column = static function () { + return 'Custom value'; + }; + add_filter( 'frm_entries_custom_column', $custom_column ); + $row = $this->single_row( $item, array( '0_custom' => 'Custom' ) ); + remove_filter( 'frm_entries_custom_column', $custom_column ); + $this->assertStringContainsString( 'class="row-title" >Custom value', $row ); + $this->assertStringNotContainsString( 'example.com', $row ); + + // An empty value still gives the link an accessible name. + $row = $this->single_row( $item, array( '0_ip' => 'IP' ) ); + $this->assertStringContainsString( 'class="row-title" >Entry 5', $row ); + } + + /** + * @param stdClass $item + * @param array $columns + * + * @return string + */ + private function single_row( $item, $columns ) { + $list_helper = new FrmEntriesListHelper( array( 'params' => array( 'form' => 0 ) ) ); + $this->set_private_property( $list_helper, '_column_headers', array( $columns, array(), array(), key( $columns ) ) ); + return $list_helper->single_row( $item ); + } + /** * @param stdClass $item * @param string $column_name