Skip to content

Commit dc3debe

Browse files
committed
fix(sonarqube): use filter parameter for project search
SonarQube's components/search_projects endpoint does not accept a bare q parameter for text search; it is silently ignored. The correct approach is the filter language: filter=query = "term". Sending q=term caused DevLake to return the full unfiltered project list regardless of the search input. Also fix three related pagination bugs in the remote scope search UI: - results were replaced instead of accumulated on page 2+ - getHasMore was inverted (tested loading state instead of hasMore) - onScroll could trigger duplicate requests while one was in flight - typing a new query did not reset page and hasMore state Fixes #8805 Signed-off-by: Joshua Smith <jbsmith7741@gmail.com>
1 parent a6335ae commit dc3debe

4 files changed

Lines changed: 102 additions & 25 deletions

File tree

backend/plugins/sonarqube/api/blueprint_v200.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ func GetApiProject(
130130
Data []models.SonarqubeApiProject `json:"components"`
131131
}
132132
query := url.Values{}
133-
query.Set("q", projectKey)
133+
query.Set("p", "1")
134+
query.Set("ps", "100")
135+
query.Set("filter", sonarqubeSearchProjectsQueryFilter(projectKey))
134136
// Use components/search_projects for consistency and normal-token (Browse) support.
135137
res, err := apiClient.Get("components/search_projects", query, nil)
136138
if err != nil {
@@ -143,6 +145,11 @@ func GetApiProject(
143145
if err != nil {
144146
return nil, err
145147
}
148+
for i := range resData.Data {
149+
if resData.Data[i].ProjectKey == projectKey {
150+
return &resData.Data[i], nil
151+
}
152+
}
146153
if len(resData.Data) > 0 {
147154
return &resData.Data[0], nil
148155
}

backend/plugins/sonarqube/api/remote_api.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package api
2020
import (
2121
"fmt"
2222
"net/url"
23+
"strings"
2324

2425
"github.com/apache/incubator-devlake/core/errors"
2526
"github.com/apache/incubator-devlake/core/plugin"
@@ -33,6 +34,16 @@ type SonarqubeRemotePagination struct {
3334
PageSize int `json:"ps"`
3435
}
3536

37+
// sonarqubeSearchProjectsQueryFilter builds the "filter" query value for
38+
// api/components/search_projects. SonarQube expects text search via the filter
39+
// language (e.g. filter=query = "term"), not a bare "q" parameter; see
40+
// SearchProjectsAction in SonarQube.
41+
func sonarqubeSearchProjectsQueryFilter(term string) string {
42+
term = strings.TrimSpace(term)
43+
term = strings.ReplaceAll(term, `"`, "")
44+
return fmt.Sprintf(`query = "%s"`, term)
45+
}
46+
3647
func querySonarqubeProjects(
3748
apiClient plugin.ApiClient,
3849
keyword string,
@@ -49,11 +60,15 @@ func querySonarqubeProjects(
4960
page.Page = 1
5061
}
5162
// Use components/search_projects so non-admin (Browse) tokens can list projects.
52-
res, err := apiClient.Get("components/search_projects", url.Values{
63+
q := url.Values{
5364
"p": {fmt.Sprintf("%v", page.Page)},
5465
"ps": {fmt.Sprintf("%v", page.PageSize)},
55-
"q": {keyword},
56-
}, nil)
66+
}
67+
keyword = strings.TrimSpace(keyword)
68+
if keyword != "" {
69+
q.Set("filter", sonarqubeSearchProjectsQueryFilter(keyword))
70+
}
71+
res, err := apiClient.Get("components/search_projects", q, nil)
5772
if err != nil {
5873
return
5974
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import "testing"
21+
22+
func TestSonarqubeSearchProjectsQueryFilter(t *testing.T) {
23+
t.Parallel()
24+
if g, w := sonarqubeSearchProjectsQueryFilter("iap"), `query = "iap"`; g != w {
25+
t.Fatalf("got %q want %q", g, w)
26+
}
27+
if g, w := sonarqubeSearchProjectsQueryFilter(" my term "), `query = "my term"`; g != w {
28+
t.Fatalf("got %q want %q", g, w)
29+
}
30+
// double quotes are stripped to avoid breaking the filter string
31+
if g, w := sonarqubeSearchProjectsQueryFilter(`a"x`), `query = "ax"`; g != w {
32+
t.Fatalf("got %q want %q", g, w)
33+
}
34+
}

config-ui/src/plugins/components/data-scope-remote/search-remote.tsx

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,24 @@ export const SearchRemote = ({ mode, plugin, connectionId, config, disabledScope
5454
nextTokenMap: {},
5555
});
5656

57+
const PAGE_SIZE = 50;
58+
5759
const [search, setSearch] = useState<{
5860
loading: boolean;
5961
items: McsItem<T.ResItem>[];
6062
currentItems: McsItem<T.ResItem>[];
6163
query: string;
6264
page: number;
6365
total: number;
66+
hasMore: boolean;
6467
}>({
6568
loading: true,
6669
items: [],
6770
currentItems: [],
6871
query: '',
6972
page: 1,
7073
total: 0,
74+
hasMore: false,
7175
});
7276

7377
const searchDebounce = useDebounce(search.query, { wait: 500 });
@@ -129,24 +133,35 @@ export const SearchRemote = ({ mode, plugin, connectionId, config, disabledScope
129133
const searchItems = async () => {
130134
if (!searchDebounce) return;
131135

132-
const res = await API.scope.searchRemote(plugin, connectionId, {
133-
search: searchDebounce,
134-
page: search.page,
135-
pageSize: 20,
136-
});
137-
138-
const newItems = (res.children ?? []).map((it) => ({
139-
...it,
140-
title: getPluginScopeName(plugin, it) || it.fullName || it.name,
141-
}));
142-
143-
setSearch((s) => ({
144-
...s,
145-
loading: false,
146-
items: [...allItems, ...newItems],
147-
currentItems: newItems,
148-
total: res.count,
149-
}));
136+
try {
137+
const res = await API.scope.searchRemote(plugin, connectionId, {
138+
search: searchDebounce,
139+
page: search.page,
140+
pageSize: PAGE_SIZE,
141+
});
142+
143+
const newItems = (res.children ?? []).map((it) => ({
144+
...it,
145+
title: getPluginScopeName(plugin, it) || it.fullName || it.name,
146+
}));
147+
148+
const total = res.count ?? 0;
149+
// If the backend returns a real total, use it; otherwise fall back to
150+
// the heuristic: a full page means there are likely more results.
151+
const hasMore = total > 0 ? search.page * PAGE_SIZE < total : newItems.length >= PAGE_SIZE;
152+
153+
setSearch((s) => ({
154+
...s,
155+
loading: false,
156+
items: [...allItems, ...newItems],
157+
// Accumulate results across pages so previous pages remain visible
158+
currentItems: s.page === 1 ? newItems : [...s.currentItems, ...newItems],
159+
total,
160+
hasMore,
161+
}));
162+
} catch {
163+
setSearch((s) => ({ ...s, loading: false, hasMore: false }));
164+
}
150165
};
151166

152167
useEffect(() => {
@@ -178,7 +193,9 @@ export const SearchRemote = ({ mode, plugin, connectionId, config, disabledScope
178193
prefix={<SearchOutlined />}
179194
placeholder={config.searchPlaceholder ?? 'Search'}
180195
value={search.query}
181-
onChange={(e) => setSearch({ ...search, query: e.target.value, loading: true, currentItems: [] })}
196+
onChange={(e) =>
197+
setSearch({ ...search, query: e.target.value, loading: true, currentItems: [], page: 1, hasMore: false })
198+
}
182199
/>
183200
{!searchDebounce ? (
184201
<MillerColumnsSelect
@@ -210,8 +227,12 @@ export const SearchRemote = ({ mode, plugin, connectionId, config, disabledScope
210227
columnCount={1}
211228
columnHeight={300}
212229
getCanExpand={() => false}
213-
getHasMore={() => search.loading}
214-
onScroll={() => setSearch({ ...search, page: search.page + 1 })}
230+
getHasMore={() => search.hasMore}
231+
onScroll={() => {
232+
if (!search.loading && search.hasMore) {
233+
setSearch((s) => ({ ...s, loading: true, page: s.page + 1 }));
234+
}
235+
}}
215236
renderLoading={() => <Loading size={20} style={{ padding: '4px 12px' }} />}
216237
disabledIds={(disabledScope ?? []).map((it) => it.id)}
217238
selectedIds={selectedScope.map((it) => it.id)}

0 commit comments

Comments
 (0)