From 68d0434aa5b003da75550933c6f9ad813bd465b5 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:31:28 -0400 Subject: [PATCH 1/5] feat(logging): add a search bar to the All Logs page Adds the standard WP_List_Table search box to the logs list. The term is matched with a LIKE against the message column and the top-level query in the context JSON, and composes with the date and level filters. The filter form now posts the filter nonce so that searches and filters are redirected to GET, which keeps them in the pagination and sorting links. --- .../src/Admin/View/List/ListTable.php | 20 ++++- .../View/Templates/WPGraphQLLoggerFilters.php | 9 ++- .../View/Templates/WPGraphQLLoggerList.php | 1 + .../src/Admin/ViewLogsPage.php | 46 ++++++++++++ .../src/Logger/Api/LogServiceInterface.php | 11 +++ .../Database/WordPressDatabaseLogService.php | 41 ++++++++++ .../wpunit/Admin/View/List/ListTableTest.php | 58 +++++++++++++++ .../wpunit/Admin/View/ViewLogsPageTest.php | 48 ++++++++++++ .../WordPressDatabaseLogServiceTest.php | 74 +++++++++++++++++++ 9 files changed, 304 insertions(+), 4 deletions(-) diff --git a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php index cd7e9028d..a84d5f8b4 100644 --- a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php +++ b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php @@ -36,6 +36,13 @@ class ListTable extends WP_List_Table { */ public const DEFAULT_PER_PAGE = 20; + /** + * Maximum length of the search term. + * + * @var int + */ + public const MAX_SEARCH_LENGTH = 200; + /** * Constructor. * @@ -164,7 +171,7 @@ public function process_bulk_action(): void { } $preserved_filters = []; - $filter_keys = [ 'level_filter', 'start_date', 'end_date' ]; + $filter_keys = [ 'level_filter', 'start_date', 'end_date', 's' ]; foreach ( $filter_keys as $key ) { $value = isset( $_REQUEST[ $key ] ) && is_string( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) : null; @@ -467,6 +474,17 @@ protected function process_where(array $request): array { ]; } + if ( isset( $request['s'] ) && is_string( $request['s'] ) ) { + $search = mb_substr( sanitize_text_field( wp_unslash( $request['s'] ) ), 0, self::MAX_SEARCH_LENGTH ); + if ( '' !== $search ) { + $where_clauses[] = [ + 'column' => LogServiceInterface::SEARCH_COLUMN, + 'operator' => 'LIKE', + 'value' => $search, + ]; + } + } + // Allow developers to modify the where clauses. return apply_filters( 'wpgraphql_logging_logs_table_where_clauses', $where_clauses, $request ); } diff --git a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php index 25fb6a7fd..b466176ac 100644 --- a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php +++ b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php @@ -13,9 +13,10 @@ exit; } -$wpgraphql_logging_current_level = isset( $_POST['level_filter'] ) ? sanitize_text_field( wp_unslash( $_POST['level_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -$wpgraphql_logging_current_start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -$wpgraphql_logging_current_end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing +// Filters are submitted by POST and redirected to GET, so read them from the request. +$wpgraphql_logging_current_level = isset( $_REQUEST['level_filter'] ) && is_string( $_REQUEST['level_filter'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['level_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended +$wpgraphql_logging_current_start_date = isset( $_REQUEST['start_date'] ) && is_string( $_REQUEST['start_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['start_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended +$wpgraphql_logging_current_end_date = isset( $_REQUEST['end_date'] ) && is_string( $_REQUEST['end_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['end_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended // Currently only use info and error. $wpgraphql_logging_log_levels = [ 'info', 'error' ]; @@ -23,6 +24,8 @@ ?>
+ + prepare_items(); ?> + search_box( __( 'Search Logs', 'wpgraphql-logging' ), 'wpgraphql-logging' ); ?> display(); ?>
diff --git a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php index 78c48c4b0..dd72aab0c 100644 --- a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php +++ b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php @@ -229,6 +229,11 @@ public function process_filters_redirect(): void { return; } + // The filters share a form with the bulk actions, which ListTable handles itself. + if ( $this->is_bulk_action_request() ) { + return; + } + $redirect_url = $this->get_redirect_url(); wp_safe_redirect( $redirect_url ); @@ -247,6 +252,7 @@ public function get_redirect_url(): string { 'start_date', 'end_date', 'level_filter', + 's', 'orderby', 'order', ]; @@ -258,6 +264,12 @@ public function get_redirect_url(): string { } } + // Keep the typed page number only when the search and filters did not change. + $paged = $this->get_post_value( 'paged' ); + if ( null !== $paged && absint( $paged ) > 1 && ! $this->filters_changed( $filters ) ) { + $filters['paged'] = (string) absint( $paged ); + } + $redirect_url = add_query_arg( array_filter( $filters, static function ( $value ) { return '' !== $value; } ), $redirect_url ); @@ -265,6 +277,40 @@ public function get_redirect_url(): string { return (string) apply_filters( 'wpgraphql_logging_filter_redirect_url', $redirect_url, $filters ); } + /** + * Whether the submitted search or filters differ from the ones in the current URL. + * + * @param array $filters The submitted filters. + * + * @phpcs:disable WordPress.Security.NonceVerification.Recommended + * + * @return bool True if any search or filter value changed. + */ + protected function filters_changed(array $filters): bool { + foreach ( [ 'start_date', 'end_date', 'level_filter', 's' ] as $key ) { + $current = isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; + if ( ( $filters[ $key ] ?? '' ) !== $current ) { + return true; + } + } + return false; + } + + /** + * Whether the submitted form is requesting a bulk action. + * + * @return bool True if a bulk action other than the "-1" placeholder was selected. + */ + protected function is_bulk_action_request(): bool { + foreach ( [ 'action', 'action2' ] as $key ) { + $value = $this->get_post_value( $key ); + if ( null !== $value && '-1' !== $value ) { + return true; + } + } + return false; + } + /** * Retrieves and sanitizes a value from the $_POST superglobal. * diff --git a/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php b/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php index cd636a842..1e881bec7 100644 --- a/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php +++ b/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php @@ -14,6 +14,17 @@ * @since 0.0.1 */ interface LogServiceInterface { + /** + * Pseudo-column used in where conditions for a free-text search. + * + * A condition of `[ 'column' => self::SEARCH_COLUMN, 'operator' => 'LIKE', 'value' => $term ]` + * asks the service to match `$term` against whichever fields it considers searchable. + * Services that do not support search may ignore it. + * + * @var string + */ + public const SEARCH_COLUMN = 'search'; + /** * Activates the log service. */ diff --git a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php index b5feca4a8..d7c0b516d 100644 --- a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php +++ b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php @@ -250,6 +250,10 @@ protected function prepare_sql(string $sql, array $where_conditions): string { if ( '' === $column ) { continue; } + if ( self::SEARCH_COLUMN === $column ) { + $where_clauses[] = $this->prepare_search_clause( $condition ); + continue; + } if ( ! in_array( $column, $allowed_columns, true ) ) { continue; } @@ -264,6 +268,7 @@ protected function prepare_sql(string $sql, array $where_conditions): string { $this->where_values[] = $value; } + $where_clauses = array_filter( $where_clauses, static fn( string $clause ): bool => '' !== $clause ); if ( ! empty( $where_clauses ) ) { $sql .= ' WHERE ' . implode( ' AND ', $where_clauses ); } @@ -271,6 +276,42 @@ protected function prepare_sql(string $sql, array $where_conditions): string { return $sql; } + /** + * Builds the where clause for a free-text search condition. + * + * Only the message column and the top-level `query` key of the context JSON are searched. + * A leading-wildcard LIKE cannot use an index, so the set is kept small on purpose: the + * rest of the context (variables, request, response) can be large and would match noise. + * Filtering by date or level narrows the rows first via the existing indexes. + * + * @param array{column: string, operator: string, value: string} $condition The search condition. + * + * @return string The where clause, or an empty string if the condition is invalid. + */ + protected function prepare_search_clause(array $condition): string { + global $wpdb; + + if ( 'LIKE' !== $condition['operator'] || ! is_string( $condition['value'] ) ) { + return ''; + } + + $term = trim( $condition['value'] ); + if ( '' === $term ) { + return ''; + } + + $like = '%' . $wpdb->esc_like( $term ) . '%'; + + // JSON_UNQUOTE() returns a binary collation on MySQL, so compare lowercase to stay case-insensitive. + $this->where_values[] = 'message'; + $this->where_values[] = $like; + $this->where_values[] = 'context'; + $this->where_values[] = '$.query'; + $this->where_values[] = mb_strtolower( $like ); + + return '(%i LIKE %s OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(%i, %s))) LIKE %s)'; + } + /** * The safe operators. * diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php index 9e9403c04..d1627db4a 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php @@ -6,6 +6,7 @@ use WPGraphQL\Logging\Admin\View\List\ListTable; +use WPGraphQL\Logging\Logger\Api\LogServiceInterface; use WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity; use WPGraphQL\Logging\Logger\Database\WordPressDatabaseLogService; use Codeception\TestCase\WPTestCase; @@ -303,6 +304,63 @@ public function test_process_where_handles_date_filters(): void { ], $result); } + public function test_process_where_handles_search(): void { + $reflection = new \ReflectionClass($this->list_table); + $method = $reflection->getMethod('process_where'); + $method->setAccessible(true); + + $request = [ + 'level_filter' => 'error', + 's' => ' GetPosts ', + ]; + $result = $method->invoke($this->list_table, $request); + + $this->assertSame([ + [ + 'column' => 'level_name', + 'operator' => '=', + 'value' => 'error', + ], + [ + 'column' => LogServiceInterface::SEARCH_COLUMN, + 'operator' => 'LIKE', + 'value' => 'GetPosts', + ], + ], $result); + } + + public function test_process_where_ignores_empty_and_caps_long_search(): void { + $reflection = new \ReflectionClass($this->list_table); + $method = $reflection->getMethod('process_where'); + $method->setAccessible(true); + + $this->assertSame([], $method->invoke($this->list_table, ['s' => ' '])); + $this->assertSame([], $method->invoke($this->list_table, ['s' => ['array']])); + + $result = $method->invoke($this->list_table, ['s' => str_repeat('a', ListTable::MAX_SEARCH_LENGTH + 50)]); + $this->assertSame(ListTable::MAX_SEARCH_LENGTH, strlen($result[0]['value'])); + } + + public function test_prepare_items_passes_search_to_count_and_find(): void { + $expected = [ + [ + 'column' => LogServiceInterface::SEARCH_COLUMN, + 'operator' => 'LIKE', + 'value' => 'GetPosts', + ], + ]; + $this->log_service->shouldReceive('count_entities_by_where')->once()->with($expected)->andReturn(1); + $this->log_service->shouldReceive('find_entities_by_where')->once()->with(Mockery::on(static function ($args) use ($expected) { + return $args['where'] === $expected; + }))->andReturn([]); + + $_REQUEST = ['s' => 'GetPosts']; + $this->list_table->prepare_items(); + $_REQUEST = []; + + $this->assertSame([], $this->list_table->items); + } + public function test_prepare_items_sets_pagination_args(): void { $this->log_service->shouldReceive('count_entities_by_where')->andReturn(50); $this->log_service->shouldReceive('find_entities_by_where')->andReturn([]); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php index a509575d8..4272553bb 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php @@ -194,6 +194,54 @@ public function test_get_redirect_url_constructs_correct_url(): void { ); } + public function test_get_redirect_url_includes_search(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + + $_POST['s'] = 'GetPosts'; + $_POST['level_filter'] = 'error'; + + $url = $instance->get_redirect_url(); + $this->assertEquals( + menu_page_url(ViewLogsPage::ADMIN_PAGE_SLUG, false) . '&level_filter=error&s=GetPosts', + $url + ); + + unset($_POST['s'], $_POST['level_filter']); + } + + public function test_get_redirect_url_keeps_page_only_when_filters_unchanged(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + + // Typing a page number keeps the current search. + $_GET['s'] = 'GetPosts'; + $_POST['s'] = 'GetPosts'; + $_POST['paged'] = '3'; + $this->assertStringContainsString('paged=3', $instance->get_redirect_url()); + + // A new search starts from the first page. + $_POST['s'] = 'GetMenus'; + $this->assertStringNotContainsString('paged', $instance->get_redirect_url()); + + unset($_GET['s'], $_POST['s'], $_POST['paged']); + } + + public function test_process_filters_redirect_skips_bulk_actions(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + + $_POST['wpgraphql_logging_nonce'] = wp_create_nonce('wpgraphql_logging_filter'); + $_POST['action'] = 'delete'; + $_POST['action2'] = '-1'; + + // Would redirect and exit if the bulk action was not detected. + $instance->process_filters_redirect(); + $this->assertTrue(true); + + unset($_POST['wpgraphql_logging_nonce'], $_POST['action'], $_POST['action2']); + } + public function test_process_log_download_dies_without_nonce(): void { $this->set_as_admin(); $instance = ViewLogsPage::init(); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php index 92ef409de..e24e8a97a 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php @@ -8,6 +8,7 @@ use WPGraphQL\Logging\Logger\Database\WordPressDatabaseLogService; use WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity; use WPGraphQL\Logging\Logger\Api\LogEntityInterface; +use WPGraphQL\Logging\Logger\Api\LogServiceInterface; /** * Test for the WordPressDatabaseLogService @@ -184,4 +185,77 @@ public function test_delete_log_older_than(): void $logs = $this->log_service->find_entities_by_where(); $this->assertCount(0, $logs); } + + public function insert_search_data(): void + { + $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'WPGraphQL Response', ['query' => 'query GetPosts { posts { nodes { title } } }']); + $this->log_service->create_log_entity('wpgraphql_logging', 400, 'ERROR', 'WPGraphQL Response with Errors', ['query' => 'query GetMenus { menus { nodes { name } } }']); + $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'Discount 100% applied', ['variables' => ['name' => 'GetPosts']]); + } + + private function search(string $term, array $where = []): array + { + $where[] = [ + 'column' => LogServiceInterface::SEARCH_COLUMN, + 'operator' => 'LIKE', + 'value' => $term, + ]; + return $this->log_service->find_entities_by_where(['where' => $where]); + } + + public function test_search_matches_message(): void + { + $this->insert_search_data(); + $logs = $this->search('with Errors'); + $this->assertCount(1, $logs); + $this->assertEquals('WPGraphQL Response with Errors', $logs[0]->get_message()); + } + + public function test_search_matches_query_case_insensitively(): void + { + $this->insert_search_data(); + $logs = $this->search('getposts'); + $this->assertCount(1, $logs, 'Only the query should match, not variables in the context.'); + $this->assertEquals('WPGraphQL Response', $logs[0]->get_message()); + } + + public function test_search_escapes_like_wildcards(): void + { + $this->insert_search_data(); + $this->assertCount(1, $this->search('100%')); + $this->assertCount(0, $this->search('_iscount')); + } + + public function test_search_composes_with_other_conditions_and_count(): void + { + $this->insert_search_data(); + $level = [ + 'column' => 'level_name', + 'operator' => '=', + 'value' => 'ERROR', + ]; + $this->assertCount(2, $this->search('WPGraphQL Response')); + $logs = $this->search('WPGraphQL Response', [$level]); + $this->assertCount(1, $logs); + $this->assertEquals('ERROR', $logs[0]->get_level_name()); + + $where = [ + $level, + ['column' => LogServiceInterface::SEARCH_COLUMN, 'operator' => 'LIKE', 'value' => 'menus'], + ]; + $this->assertEquals(1, $this->log_service->count_entities_by_where($where)); + $where[1]['value'] = 'GetPosts'; + $this->assertEquals(0, $this->log_service->count_entities_by_where($where)); + } + + public function test_search_ignores_empty_term_and_unsupported_operator(): void + { + $this->insert_search_data(); + $this->assertCount(3, $this->search(' ')); + + $logs = $this->log_service->find_entities_by_where(['where' => [ + ['column' => LogServiceInterface::SEARCH_COLUMN, 'operator' => '=', 'value' => 'GetPosts'], + ]]); + $this->assertCount(3, $logs); + } } From a2df70456b71e9256426a6a46d325998c08389ed Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:33:40 -0400 Subject: [PATCH 2/5] fix(logging): encode filter values in the logs redirect URLs add_query_arg() does not encode values, so a search term with "&", "#" or "+" split into extra query args, and wp_safe_redirect() stripped characters such as "$" and "{" that appear in GraphQL queries. Encode the filter and search values in both the filter redirect and the bulk action redirect. --- .../src/Admin/View/List/ListTable.php | 2 +- .../src/Admin/ViewLogsPage.php | 5 +++-- .../wpunit/Admin/View/ViewLogsPageTest.php | 17 ++++++++++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php index a84d5f8b4..5b1c4aabe 100644 --- a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php +++ b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php @@ -176,7 +176,7 @@ public function process_bulk_action(): void { foreach ( $filter_keys as $key ) { $value = isset( $_REQUEST[ $key ] ) && is_string( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) : null; if ( null !== $value && '' !== $value ) { - $preserved_filters[ $key ] = $value; + $preserved_filters[ $key ] = rawurlencode( $value ); } } diff --git a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php index dd72aab0c..56ae016b4 100644 --- a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php +++ b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php @@ -270,9 +270,10 @@ public function get_redirect_url(): string { $filters['paged'] = (string) absint( $paged ); } - $redirect_url = add_query_arg( array_filter( $filters, static function ( $value ) { + // add_query_arg() does not encode values, and a search term can contain "&", "#" or "{". + $redirect_url = add_query_arg( array_map( 'rawurlencode', array_filter( $filters, static function ( $value ) { return '' !== $value; - } ), $redirect_url ); + } ) ), $redirect_url ); return (string) apply_filters( 'wpgraphql_logging_filter_redirect_url', $redirect_url, $filters ); } diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php index 4272553bb..957c11f80 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php @@ -189,7 +189,7 @@ public function test_get_redirect_url_constructs_correct_url(): void { $this->assertStringNotContainsString('level_filter', $url); $this->assertEquals( menu_page_url(ViewLogsPage::ADMIN_PAGE_SLUG, false) . - '&start_date=2025-01-01 00:00:00&end_date=2025-12-31 23:59:59&orderby=id&order=ASC', + '&start_date=2025-01-01%2000%3A00%3A00&end_date=2025-12-31%2023%3A59%3A59&orderby=id&order=ASC', $url ); } @@ -210,6 +210,21 @@ public function test_get_redirect_url_includes_search(): void { unset($_POST['s'], $_POST['level_filter']); } + public function test_get_redirect_url_encodes_search(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + + $_POST['s'] = 'query GetPost($id: ID!) { post(id: $id) { title } } & #more'; + + $url = $instance->get_redirect_url(); + $query = []; + parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query); + $this->assertSame($_POST['s'], $query['s']); + $this->assertSame($url, wp_sanitize_redirect($url), 'The URL should survive wp_safe_redirect() unchanged.'); + + unset($_POST['s']); + } + public function test_get_redirect_url_keeps_page_only_when_filters_unchanged(): void { $this->set_as_admin(); $instance = ViewLogsPage::init(); From c629b4c1ed56e7cd4c4dd70b679233ea3effa942 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:35:20 -0400 Subject: [PATCH 3/5] test(logging): cover search escaping, bulk action detection and the UI Adds wpunit coverage for quotes and backslashes in searched queries and for telling bulk actions apart from filter submits, plus a Playwright spec for searching the logs list and combining search with the level filter. --- .../tests/e2e/specs/search-logs.spec.js | 80 +++++++++++++++++++ .../wpunit/Admin/View/ViewLogsPageTest.php | 19 +++++ .../WordPressDatabaseLogServiceTest.php | 10 +++ 3 files changed, 109 insertions(+) create mode 100644 plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js diff --git a/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js b/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js new file mode 100644 index 000000000..35b6a4a5b --- /dev/null +++ b/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js @@ -0,0 +1,80 @@ +import { expect, test } from "@wordpress/e2e-test-utils-playwright"; +import { + goToLoggingSettingsPage, + goToLogsListPage, + configureLogging, + executeGraphQLQuery, + resetPluginSettings, +} from "../utils"; +import { GET_POSTS_QUERY } from "../constants"; + +const GET_SETTINGS_QUERY = ` + query GetSiteSettings { + generalSettings { + title + } + } +`; + +test.describe("Search Logs", () => { + test.beforeEach(async ({ admin, page, request }) => { + await resetPluginSettings(admin); + + await goToLoggingSettingsPage(admin); + await configureLogging(page, { + enabled: true, + dataSampling: "100", + eventLogSelection: ["graphql_request_results"], + }); + + expect((await executeGraphQLQuery(request, GET_POSTS_QUERY)).ok()).toBeTruthy(); + expect((await executeGraphQLQuery(request, GET_SETTINGS_QUERY)).ok()).toBeTruthy(); + }); + + test("finds logs by query text", async ({ page, admin }) => { + await goToLogsListPage(admin); + + const rows = page.locator("#the-list tr"); + await expect(rows.filter({ hasText: "GetPosts" })).toHaveCount(1); + await expect(rows.filter({ hasText: "GetSiteSettings" })).toHaveCount(1); + + const searchInput = page.locator("#wpgraphql-logging-search-input"); + await searchInput.fill("getsitesettings"); + await searchInput.press("Enter"); + + // The search is redirected to GET so it stays in the pagination and sort links. + await expect(page).toHaveURL(/[?&]s=getsitesettings(&|$)/); + await expect(searchInput).toHaveValue("getsitesettings"); + await expect(rows.filter({ hasText: "GetSiteSettings" })).toHaveCount(1); + await expect(rows.filter({ hasText: "GetPosts" })).toHaveCount(0); + }); + + test("combines search with the level filter", async ({ page, admin }) => { + await goToLogsListPage(admin); + + await page.locator("#wpgraphql-logging-search-input").fill("GetPosts"); + await page.locator('select[name="level_filter"]').selectOption("error"); + await page.getByRole("button", { name: "Filter", exact: true }).click(); + + await expect(page).toHaveURL(/level_filter=error/); + await expect(page).toHaveURL(/[?&]s=GetPosts(&|$)/); + await expect(page.locator('select[name="level_filter"]')).toHaveValue("error"); + await expect( + page.locator('td.colspanchange:has-text("No items found.")') + ).toBeVisible(); + }); + + test("shows no results for an unmatched search", async ({ page, admin }) => { + await goToLogsListPage(admin); + + const searchInput = page.locator("#wpgraphql-logging-search-input"); + await searchInput.fill("NoSuchOperation"); + await searchInput.press("Enter"); + + await expect( + page.locator('td.colspanchange:has-text("No items found.")') + ).toBeVisible(); + // The search box stays visible so the search can be changed. + await expect(searchInput).toHaveValue("NoSuchOperation"); + }); +}); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php index 957c11f80..dc2cd6a55 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php @@ -257,6 +257,25 @@ public function test_process_filters_redirect_skips_bulk_actions(): void { unset($_POST['wpgraphql_logging_nonce'], $_POST['action'], $_POST['action2']); } + public function test_is_bulk_action_request(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + $method = (new \ReflectionClass($instance))->getMethod('is_bulk_action_request'); + $method->setAccessible(true); + + // Both dropdowns left on the placeholder, as when filtering or searching. + $_POST['action'] = '-1'; + $_POST['action2'] = '-1'; + $this->assertFalse($method->invoke($instance)); + + // The bottom dropdown is used. + $_POST['action2'] = 'delete_all'; + $this->assertTrue($method->invoke($instance)); + + unset($_POST['action'], $_POST['action2']); + $this->assertFalse($method->invoke($instance)); + } + public function test_process_log_download_dies_without_nonce(): void { $this->set_as_admin(); $instance = ViewLogsPage::init(); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php index e24e8a97a..4f7398765 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php @@ -226,6 +226,16 @@ public function test_search_escapes_like_wildcards(): void $this->assertCount(0, $this->search('_iscount')); } + public function test_search_matches_quotes_and_backslashes_in_query(): void + { + $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'WPGraphQL Response', ['query' => 'query { posts(where: {search: "it\'s a \\\\ test"}) { nodes { id } } }']); + + // The query is unquoted from the JSON before matching, so its quotes and backslashes are not JSON-escaped. + $this->assertCount(1, $this->search('search: "it\'s')); + $this->assertCount(1, $this->search('a \\\\ test')); + $this->assertCount(0, $this->search('a \\\\\\\\ test')); + } + public function test_search_composes_with_other_conditions_and_count(): void { $this->insert_search_data(); From 541ab6b91b3b57cc7001c688d9aee897f838d6fe Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:36:22 -0400 Subject: [PATCH 4/5] refactor(logging): share the logs filter keys and document search Moves the search and filter request keys to ViewLogsPage::FILTER_KEYS, so the filter redirect, the page-number check and the bulk action redirect use the same list. Documents the search box in the admin guide, and the search where condition in the custom log store guide. --- .../how-to/update-log-store-service/index.md | 14 ++++++++++++++ docs/plugins/wpgraphql-logging/index.md | 10 +++++++++- .../src/Admin/View/List/ListTable.php | 3 +-- .../src/Admin/ViewLogsPage.php | 18 +++++++++--------- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md index 20128e19d..8d002ccb4 100644 --- a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md +++ b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md @@ -104,6 +104,20 @@ add_action( 'plugins_loaded', function() { With this in place, all logs from WPGraphQL Logging will be routed through your `MyFileLogService` and saved to a file instead of the database. +## Supporting search in the admin + +The search box on GraphQL Logs -> All Logs passes the search term to `find_entities_by_where()` and `count_entities_by_where()` as a where condition with the pseudo-column `LogServiceInterface::SEARCH_COLUMN`: + +```php +[ + 'column' => LogServiceInterface::SEARCH_COLUMN, // 'search' + 'operator' => 'LIKE', + 'value' => 'GetPosts', // The raw search term, not a LIKE pattern. +] +``` + +Your service decides which fields to match the term against. If it doesn't support search, it can ignore this condition. + ## Contributing We welcome and appreciate contributions from the community. If you'd like to help improve this documentation, please check out our [Contributing Guide](https://github.com/wpengine/hwptoolkit/blob/main/CONTRIBUTING.md) for more details on how to get started. diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md index 0231ad998..62e9c9a34 100644 --- a/docs/plugins/wpgraphql-logging/index.md +++ b/docs/plugins/wpgraphql-logging/index.md @@ -38,7 +38,7 @@ The plugin provides comprehensive data management capabilities, allowing you to ### Admin UI -The plugin delivers a admin UI with some filtering of logs around dates or level and the ability to download logs in CSV format. +The plugin delivers a admin UI with a search box, some filtering of logs around dates or level and the ability to download logs in CSV format. *** @@ -142,6 +142,14 @@ ID,Date,Level,"Level Name",Message,Channel,Query,Context,Extra 5293,"2025-10-06 15:41:34",200,INFO,"WPGraphQL Response",wpgraphql_logging,"{ posts(first: 10) ...""memory_peak_usage"":""18 MB""}" ``` +### Searching Logs + +Use the search box to find logs by their message (for example `WPGraphQL Response with Errors`) or by the text of the GraphQL query, such as an operation name like `GetPosts`. The search is case-insensitive, and it works together with the filters, sorting and pagination. + +To stay fast on large log tables, the search only looks at the message and the `query` in the log context, not the variables, request or response. A `LIKE '%term%'` search can't use an index, so combine it with a date or level filter when you have many logs. + +Developers can change or remove the search condition with the `wpgraphql_logging_logs_table_where_clauses` filter. + ### Filtering Logs You can also filter logs with the following filters: diff --git a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php index 5b1c4aabe..9b6afbb13 100644 --- a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php +++ b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php @@ -171,9 +171,8 @@ public function process_bulk_action(): void { } $preserved_filters = []; - $filter_keys = [ 'level_filter', 'start_date', 'end_date', 's' ]; - foreach ( $filter_keys as $key ) { + foreach ( ViewLogsPage::FILTER_KEYS as $key ) { $value = isset( $_REQUEST[ $key ] ) && is_string( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) : null; if ( null !== $value && '' !== $value ) { $preserved_filters[ $key ] = rawurlencode( $value ); diff --git a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php index 56ae016b4..93f9f26cc 100644 --- a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php +++ b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php @@ -38,6 +38,13 @@ class ViewLogsPage { */ public const ADMIN_PAGE_DOWNLOAD_NONCE = 'wp_graphql_logging_download'; + /** + * The request keys for the search and filters of the logs list. + * + * @var array + */ + public const FILTER_KEYS = [ 'start_date', 'end_date', 'level_filter', 's' ]; + /** * The hook suffix for the admin page. * @@ -248,14 +255,7 @@ public function process_filters_redirect(): void { public function get_redirect_url(): string { $redirect_url = menu_page_url( self::ADMIN_PAGE_SLUG, false ); - $possible_filters = [ - 'start_date', - 'end_date', - 'level_filter', - 's', - 'orderby', - 'order', - ]; + $possible_filters = [ ...self::FILTER_KEYS, 'orderby', 'order' ]; $filters = []; foreach ( $possible_filters as $key ) { $value = $this->get_post_value( $key ); @@ -288,7 +288,7 @@ public function get_redirect_url(): string { * @return bool True if any search or filter value changed. */ protected function filters_changed(array $filters): bool { - foreach ( [ 'start_date', 'end_date', 'level_filter', 's' ] as $key ) { + foreach ( self::FILTER_KEYS as $key ) { $current = isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; if ( ( $filters[ $key ] ?? '' ) !== $current ) { return true; From e60c7531e8c978dee28b7bf7c818315d90b2c5b0 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 2 Nov 2026 16:30:36 -0500 Subject: [PATCH 5/5] Potential fix for pull request finding 'Avoid undeclared mb_strtolower dependency in log search' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/Logger/Database/WordPressDatabaseLogService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php index d7c0b516d..12d833e90 100644 --- a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php +++ b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php @@ -307,9 +307,9 @@ protected function prepare_search_clause(array $condition): string { $this->where_values[] = $like; $this->where_values[] = 'context'; $this->where_values[] = '$.query'; - $this->where_values[] = mb_strtolower( $like ); + $this->where_values[] = $like; - return '(%i LIKE %s OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(%i, %s))) LIKE %s)'; + return '(%i LIKE %s OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(%i, %s))) LIKE LOWER(%s))'; } /**