From 6024c23b228c979338bf11fecf9d0c660ebf3df8 Mon Sep 17 00:00:00 2001 From: Wieland Lindenthal Date: Wed, 22 Apr 2026 06:42:57 +0200 Subject: [PATCH 1/3] Support multi-substring search in project selector --- app/models/queries/projects/filters/name_filter.rb | 4 +++- .../header-project-select/header-project-select.component.ts | 3 ++- spec/features/projects/project_autocomplete_spec.rb | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/queries/projects/filters/name_filter.rb b/app/models/queries/projects/filters/name_filter.rb index 91c8f0c5d3e4..de8f3cee6039 100644 --- a/app/models/queries/projects/filters/name_filter.rb +++ b/app/models/queries/projects/filters/name_filter.rb @@ -40,7 +40,9 @@ def where when "!" ["LOWER(projects.name) NOT IN (?)", sql_value] when "~", "**" - ["LOWER(projects.name) LIKE ?", "%#{sql_value}%"] + terms = values.first.downcase.split + conditions = Array.new(terms.size, "LOWER(projects.name) LIKE ?").join(" AND ") + [conditions, *terms.map { |t| "%#{t}%" }] when "!~" ["LOWER(projects.name) NOT LIKE ?", "%#{sql_value}%"] end diff --git a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts b/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts index ef6b27a2ff1b..c01fc2de5479 100644 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts +++ b/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts @@ -81,7 +81,8 @@ export class OpHeaderProjectSelectComponent extends UntilDestroyedMixin implemen (project) => { const searchText = this.searchableProjectListService.searchText; if (searchText.length) { - const matches = project.name.toLowerCase().includes(searchText.toLowerCase()); + const terms = searchText.toLowerCase().split(/\s+/).filter((t) => t.length > 0); + const matches = terms.every((term) => project.name.toLowerCase().includes(term)); if (!matches) { return false; diff --git a/spec/features/projects/project_autocomplete_spec.rb b/spec/features/projects/project_autocomplete_spec.rb index f9a349a152f8..4ad93e8079f8 100644 --- a/spec/features/projects/project_autocomplete_spec.rb +++ b/spec/features/projects/project_autocomplete_spec.rb @@ -78,6 +78,7 @@ create(:project, name:, identifier:, members: { user => role }) end end + shared_let(:non_member_project) { create(:project) } shared_let(:public_project) { create(:public_project) } @@ -110,10 +111,10 @@ expect(page).to have_no_css("strong") end - # Expect fuzzy matches for plain + # Expect fuzzy matches for multiple substrings top_menu.search "Plain pr" top_menu.expect_result "Plain project" - top_menu.expect_no_result "Plain other project" + top_menu.expect_result "Plain other project" # Expect search to match names only and not the identifier top_menu.clear_search From 62ac6e8b741fa29e8cb51d765ac92ee4483c3f76 Mon Sep 17 00:00:00 2001 From: Wieland Lindenthal Date: Wed, 22 Apr 2026 16:17:17 +0200 Subject: [PATCH 2/3] Use TypeaheadFilter with ** operator for project selector search Separate the ~ (contains) and ** (everywhere/typeahead) operator paths in NameFilter: ~ keeps single-substring LIKE behavior for explicit user-facing filters; ** now splits on whitespace and ANDs all tokens, enabling multi-term search. Switch SearchableProjectListService from ['name', '~', ...] to ['typeahead', '**', ...] so the project selector routes through TypeaheadFilter, which inherits the multi-term ** behavior automatically. --- app/models/queries/projects/filters/name_filter.rb | 4 +++- .../searchable-project-list.service.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/models/queries/projects/filters/name_filter.rb b/app/models/queries/projects/filters/name_filter.rb index de8f3cee6039..96aa957334a6 100644 --- a/app/models/queries/projects/filters/name_filter.rb +++ b/app/models/queries/projects/filters/name_filter.rb @@ -39,7 +39,9 @@ def where ["LOWER(projects.name) IN (?)", sql_value] when "!" ["LOWER(projects.name) NOT IN (?)", sql_value] - when "~", "**" + when "~" + ["LOWER(projects.name) LIKE ?", "%#{sql_value}%"] + when "**" terms = values.first.downcase.split conditions = Array.new(terms.size, "LOWER(projects.name) LIKE ?").join(" AND ") [conditions, *terms.map { |t| "%#{t}%" }] diff --git a/frontend/src/app/shared/components/searchable-project-list/searchable-project-list.service.ts b/frontend/src/app/shared/components/searchable-project-list/searchable-project-list.service.ts index 561cc9d4c9a7..e83ce6f29149 100644 --- a/frontend/src/app/shared/components/searchable-project-list/searchable-project-list.service.ts +++ b/frontend/src/app/shared/components/searchable-project-list/searchable-project-list.service.ts @@ -84,12 +84,12 @@ export class SearchableProjectListService { return of([[] as IProject[], searchText, loadingEnabled as boolean, favoriteIds]); } - const nameFilter:ApiV3ListFilter[] = []; + const searchFilter:ApiV3ListFilter[] = []; if (searchText.length > 0) { - nameFilter.push(['name', '~', [searchText]]); + searchFilter.push(['typeahead', '**', [searchText]]); } - return this.fetchProjects(nameFilter) + return this.fetchProjects(searchFilter) .pipe(map((collection) => [collection._embedded.elements, searchText, loadingEnabled as boolean, favoriteIds])); }), switchMap(([projects, searchText, loadingEnabled, favoriteIds]:[IProject[],string,boolean,string[]]) => { From 8279a6d24b9cca1597dd38321cf78a6ab64a8d89 Mon Sep 17 00:00:00 2001 From: Wieland Lindenthal Date: Wed, 22 Apr 2026 16:40:19 +0200 Subject: [PATCH 3/3] harden the feature spec to also check that the filter still excludes --- spec/features/projects/project_autocomplete_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/projects/project_autocomplete_spec.rb b/spec/features/projects/project_autocomplete_spec.rb index 4ad93e8079f8..7af5b299cff0 100644 --- a/spec/features/projects/project_autocomplete_spec.rb +++ b/spec/features/projects/project_autocomplete_spec.rb @@ -78,7 +78,6 @@ create(:project, name:, identifier:, members: { user => role }) end end - shared_let(:non_member_project) { create(:project) } shared_let(:public_project) { create(:public_project) } @@ -115,6 +114,7 @@ top_menu.search "Plain pr" top_menu.expect_result "Plain project" top_menu.expect_result "Plain other project" + top_menu.expect_no_result "Project with different name and identifier" # Expect search to match names only and not the identifier top_menu.clear_search