This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new d10c07f91 feat(sonarqube): add "ALL" toggle for filtered remote scope
search (#9282)
d10c07f91 is described below
commit d10c07f91c3df7ee3b401a36de462c50b49f6d47
Author: Dirk <[email protected]>
AuthorDate: Sun Nov 1 05:23:31 2026 +0100
feat(sonarqube): add "ALL" toggle for filtered remote scope search (#9282)
* feat(sonarqube): return total count from search-remote-scopes
Add an optional, non-breaking way for plugins to report the total number
of matching scopes in the search-remote-scopes response:
- helpers: new DsSearchRemoteScopesWithCount function type and
NewDsRemoteApiScopeSearchHelperWithCount constructor. The existing
NewDsRemoteApiScopeSearchHelper wraps legacy search functions with an
unknown total (-1), so no other plugin needs to change.
- The response body contains "count" only if the plugin reports it.
- DsRemoteApiScopeList documents the optional count field.
- sonarqube: pass paging.total of api/components/search_projects through.
Signed-off-by: DoDiODev <[email protected]>
* feat(config-ui): add "ALL" toggle to the SonarQube remote scope search
When searching remote scopes, an "ALL" entry is rendered as the first row
of the result list (same placement as showSelectAll of
miller-columns-select). It (de)selects every match of the current search:
- loads all remaining pages on demand, stopping at the backend-reported
count or, for plugins without count, at the first short page
- keeps the selection outside of the filter and skips already added
(disabled) scopes
- shows the number of matches, a tri-state checkbox and a warning if the
page limit was reached
- discards results that arrive after the search query changed
The feature is opt-in via dataScope.searchSelectAll and only enabled for
SonarQube. Duplicates are removed when appending further search pages.
Signed-off-by: DoDiODev <[email protected]>
---------
Signed-off-by: DoDiODev <[email protected]>
---
.../api/ds_remote_api_scope_search_helper.go | 55 +++++--
.../api/ds_remote_api_scope_search_helper_test.go | 72 +++++++++
.../api/models/ds_remote_api_scope_list_entry.go | 2 +
backend/plugins/sonarqube/api/init.go | 2 +-
backend/plugins/sonarqube/api/remote_api.go | 8 +-
backend/plugins/sonarqube/api/remote_api_test.go | 41 ++++-
config-ui/src/api/scope/index.ts | 2 +-
.../data-scope-remote/search-remote.test.tsx | 166 +++++++++++++++++++++
.../components/data-scope-remote/search-remote.tsx | 118 ++++++++++++++-
.../data-scope-remote/select-all.test.ts | 101 +++++++++++++
.../components/data-scope-remote/select-all.ts | 105 +++++++++++++
.../plugins/components/data-scope-remote/styled.ts | 13 ++
.../src/plugins/register/sonarqube/config.tsx | 1 +
config-ui/src/types/plugin.ts | 2 +
14 files changed, 669 insertions(+), 19 deletions(-)
diff --git
a/backend/helpers/pluginhelper/api/ds_remote_api_scope_search_helper.go
b/backend/helpers/pluginhelper/api/ds_remote_api_scope_search_helper.go
index 6cfb055a2..b4285110e 100644
--- a/backend/helpers/pluginhelper/api/ds_remote_api_scope_search_helper.go
+++ b/backend/helpers/pluginhelper/api/ds_remote_api_scope_search_helper.go
@@ -27,9 +27,14 @@ import (
type DsSearchRemoteScopes[C plugin.ToolLayerApiConnection, S
plugin.ToolLayerScope] func(
apiClient plugin.ApiClient, params
*models.DsRemoteApiScopeSearchParams) (children
[]models.DsRemoteApiScopeListEntry[S], err errors.Error)
+// DsSearchRemoteScopesWithCount is like DsSearchRemoteScopes but additionally
returns the total
+// number of matching scopes reported by the remote server. A negative total
means "unknown".
+type DsSearchRemoteScopesWithCount[C plugin.ToolLayerApiConnection, S
plugin.ToolLayerScope] func(
+ apiClient plugin.ApiClient, params
*models.DsRemoteApiScopeSearchParams) (children
[]models.DsRemoteApiScopeListEntry[S], total int, err errors.Error)
+
type DsRemoteApiScopeSearchHelper[C plugin.ToolLayerApiConnection, S
plugin.ToolLayerScope] struct {
*DsRemoteApiProxyHelper[C]
- searchRemoteScopes DsSearchRemoteScopes[C, S]
+ searchRemoteScopes DsSearchRemoteScopesWithCount[C, S]
}
func NewDsRemoteApiScopeSearchHelper[
@@ -38,6 +43,25 @@ func NewDsRemoteApiScopeSearchHelper[
](
rap *DsRemoteApiProxyHelper[C],
searchRemoteScopes DsSearchRemoteScopes[C, S],
+) *DsRemoteApiScopeSearchHelper[C, S] {
+ var withCount DsSearchRemoteScopesWithCount[C, S]
+ if searchRemoteScopes != nil {
+ withCount = func(apiClient plugin.ApiClient, params
*models.DsRemoteApiScopeSearchParams) ([]models.DsRemoteApiScopeListEntry[S],
int, errors.Error) {
+ children, err := searchRemoteScopes(apiClient, params)
+ return children, -1, err
+ }
+ }
+ return NewDsRemoteApiScopeSearchHelperWithCount[C, S](rap, withCount)
+}
+
+// NewDsRemoteApiScopeSearchHelperWithCount creates a search helper whose
response additionally
+// contains the total number of matching scopes ("count"), if the search
function reports it.
+func NewDsRemoteApiScopeSearchHelperWithCount[
+ C plugin.ToolLayerApiConnection,
+ S plugin.ToolLayerScope,
+](
+ rap *DsRemoteApiProxyHelper[C],
+ searchRemoteScopes DsSearchRemoteScopesWithCount[C, S],
) *DsRemoteApiScopeSearchHelper[C, S] {
return &DsRemoteApiScopeSearchHelper[C, S]{
DsRemoteApiProxyHelper: rap,
@@ -62,10 +86,20 @@ func (rss *DsRemoteApiScopeSearchHelper[C, S]) Get(input
*plugin.ApiResourceInpu
if e := vld.Struct(params); e != nil {
return nil, errors.BadInput.Wrap(e, "invalid params")
}
- children, err := rss.searchRemoteScopes(apiClient, params)
+ children, total, err := rss.searchRemoteScopes(apiClient, params)
if err != nil {
return nil, err
}
+ return &plugin.ApiResourceOutput{
+ Body: buildDsRemoteApiScopeSearchBody(children, total, params),
+ }, nil
+}
+
+func buildDsRemoteApiScopeSearchBody[S plugin.ToolLayerScope](
+ children []models.DsRemoteApiScopeListEntry[S],
+ total int,
+ params *models.DsRemoteApiScopeSearchParams,
+) map[string]interface{} {
if children == nil {
children = []models.DsRemoteApiScopeListEntry[S]{}
}
@@ -73,11 +107,14 @@ func (rss *DsRemoteApiScopeSearchHelper[C, S]) Get(input
*plugin.ApiResourceInpu
for i := range children {
children[i].ParentId = nil
}
- return &plugin.ApiResourceOutput{
- Body: map[string]interface{}{
- "children": children,
- "page": params.Page,
- "pageSize": params.PageSize,
- },
- }, nil
+ body := map[string]interface{}{
+ "children": children,
+ "page": params.Page,
+ "pageSize": params.PageSize,
+ }
+ // only expose the total if the plugin knows it, so the config-ui can
fall back to its heuristic
+ if total >= 0 {
+ body["count"] = total
+ }
+ return body
}
diff --git
a/backend/helpers/pluginhelper/api/ds_remote_api_scope_search_helper_test.go
b/backend/helpers/pluginhelper/api/ds_remote_api_scope_search_helper_test.go
new file mode 100644
index 000000000..bb4dc0c4f
--- /dev/null
+++ b/backend/helpers/pluginhelper/api/ds_remote_api_scope_search_helper_test.go
@@ -0,0 +1,72 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "testing"
+
+ "github.com/apache/devlake/core/errors"
+ "github.com/apache/devlake/core/plugin"
+ "github.com/apache/devlake/helpers/pluginhelper/api/models"
+ "github.com/stretchr/testify/assert"
+)
+
+type searchHelperTestScope = TestFakeGithubRepo
+
+type searchHelperTestConnection struct{}
+
+func (searchHelperTestConnection) TableName() string { return
"_tool_test_connections" }
+func (searchHelperTestConnection) ConnectionId() uint64 { return 1 }
+func (searchHelperTestConnection) GetEndpoint() string { return "" }
+func (searchHelperTestConnection) GetProxy() string { return "" }
+func (searchHelperTestConnection) GetRateLimitPerHour() int { return 0 }
+
+var _ plugin.ToolLayerApiConnection = searchHelperTestConnection{}
+var _ plugin.ToolLayerScope = searchHelperTestScope{}
+
+func TestBuildDsRemoteApiScopeSearchBody(t *testing.T) {
+ params := &models.DsRemoteApiScopeSearchParams{Search: "x", Page: 2,
PageSize: 10}
+ parent := "parent"
+ children :=
[]models.DsRemoteApiScopeListEntry[searchHelperTestScope]{{Id: "1", ParentId:
&parent}}
+
+ body := buildDsRemoteApiScopeSearchBody(children, 42, params)
+ assert.Equal(t, 42, body["count"])
+ assert.Equal(t, 2, body["page"])
+ assert.Equal(t, 10, body["pageSize"])
+ assert.Nil(t,
body["children"].([]models.DsRemoteApiScopeListEntry[searchHelperTestScope])[0].ParentId)
+
+ body = buildDsRemoteApiScopeSearchBody[searchHelperTestScope](nil, -1,
params)
+ _, hasCount := body["count"]
+ assert.False(t, hasCount)
+ assert.NotNil(t, body["children"])
+ assert.Len(t, body["children"], 0)
+}
+
+func TestNewDsRemoteApiScopeSearchHelperWrapsLegacyFunction(t *testing.T) {
+ legacy := func(_ plugin.ApiClient, _
*models.DsRemoteApiScopeSearchParams)
([]models.DsRemoteApiScopeListEntry[searchHelperTestScope], errors.Error) {
+ return
[]models.DsRemoteApiScopeListEntry[searchHelperTestScope]{{Id: "1"}}, nil
+ }
+ helper := NewDsRemoteApiScopeSearchHelper[searchHelperTestConnection,
searchHelperTestScope](nil, legacy)
+
+ children, total, err := helper.searchRemoteScopes(nil,
&models.DsRemoteApiScopeSearchParams{})
+ assert.Nil(t, err)
+ assert.Len(t, children, 1)
+ assert.Equal(t, -1, total)
+
+ assert.Nil(t,
NewDsRemoteApiScopeSearchHelper[searchHelperTestConnection,
searchHelperTestScope](nil, nil).searchRemoteScopes)
+}
diff --git
a/backend/helpers/pluginhelper/api/models/ds_remote_api_scope_list_entry.go
b/backend/helpers/pluginhelper/api/models/ds_remote_api_scope_list_entry.go
index 10f5cda32..874c1cbca 100644
--- a/backend/helpers/pluginhelper/api/models/ds_remote_api_scope_list_entry.go
+++ b/backend/helpers/pluginhelper/api/models/ds_remote_api_scope_list_entry.go
@@ -30,4 +30,6 @@ type DsRemoteApiScopeListEntry[S plugin.ToolLayerScope]
struct {
type DsRemoteApiScopeList[S plugin.ToolLayerScope] struct {
Children []DsRemoteApiScopeListEntry[S] `json:"children"`
+ // Count is the total number of matching scopes (search only, omitted
if the plugin doesn't know it)
+ Count *int `json:"count,omitempty"`
}
diff --git a/backend/plugins/sonarqube/api/init.go
b/backend/plugins/sonarqube/api/init.go
index ba43c147d..7b6a2b860 100644
--- a/backend/plugins/sonarqube/api/init.go
+++ b/backend/plugins/sonarqube/api/init.go
@@ -57,5 +57,5 @@ func Init(br context.BasicRes, p plugin.PluginMeta) {
models.SonarqubeProject,
SonarqubeRemotePagination,
](raProxy, listSonarqubeRemoteScopes)
- raScopeSearch =
api.NewDsRemoteApiScopeSearchHelper[models.SonarqubeConnection,
models.SonarqubeProject](raProxy, searchSonarqubeRemoteProjects)
+ raScopeSearch =
api.NewDsRemoteApiScopeSearchHelperWithCount[models.SonarqubeConnection,
models.SonarqubeProject](raProxy, searchSonarqubeRemoteProjects)
}
diff --git a/backend/plugins/sonarqube/api/remote_api.go
b/backend/plugins/sonarqube/api/remote_api.go
index f8a9c87e0..9d6436d98 100644
--- a/backend/plugins/sonarqube/api/remote_api.go
+++ b/backend/plugins/sonarqube/api/remote_api.go
@@ -51,6 +51,7 @@ func querySonarqubeProjects(
) (
children []dsmodels.DsRemoteApiScopeListEntry[models.SonarqubeProject],
nextPage *SonarqubeRemotePagination,
+ total int,
err errors.Error,
) {
if page.PageSize == 0 {
@@ -98,6 +99,7 @@ func querySonarqubeProjects(
})
}
+ total = resBody.Paging.Total
if resBody.Paging.Total >
resBody.Paging.PageIndex*resBody.Paging.PageSize {
nextPage = &SonarqubeRemotePagination{
Page: resBody.Paging.PageIndex + 1,
@@ -118,7 +120,8 @@ func listSonarqubeRemoteScopes(
nextPage *SonarqubeRemotePagination,
err errors.Error,
) {
- return querySonarqubeProjects(apiClient, "", page)
+ children, nextPage, _, err = querySonarqubeProjects(apiClient, "", page)
+ return
}
// RemoteScopes list all available scopes on the remote server
@@ -142,6 +145,7 @@ func searchSonarqubeRemoteProjects(
params *dsmodels.DsRemoteApiScopeSearchParams,
) (
children []dsmodels.DsRemoteApiScopeListEntry[models.SonarqubeProject],
+ total int,
err errors.Error,
) {
if params.Page == 0 {
@@ -151,7 +155,7 @@ func searchSonarqubeRemoteProjects(
Page: params.Page,
PageSize: params.PageSize,
}
- children, _, err = querySonarqubeProjects(apiClient, params.Search,
page)
+ children, _, total, err = querySonarqubeProjects(apiClient,
params.Search, page)
return
}
diff --git a/backend/plugins/sonarqube/api/remote_api_test.go
b/backend/plugins/sonarqube/api/remote_api_test.go
index a3b290dbf..24bdb8fa6 100644
--- a/backend/plugins/sonarqube/api/remote_api_test.go
+++ b/backend/plugins/sonarqube/api/remote_api_test.go
@@ -17,7 +17,46 @@ limitations under the License.
package api
-import "testing"
+import (
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/apache/devlake/core/errors"
+ dsmodels "github.com/apache/devlake/helpers/pluginhelper/api/models"
+ mockplugin "github.com/apache/devlake/mocks/core/plugin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestSearchSonarqubeRemoteProjectsReturnsTotal(t *testing.T) {
+ apiClient := mockplugin.NewApiClient(t)
+ var gotQuery url.Values
+ apiClient.EXPECT().Get("components/search_projects", mock.Anything,
mock.Anything).
+ RunAndReturn(func(_ string, query url.Values, _ http.Header)
(*http.Response, errors.Error) {
+ gotQuery = query
+ body :=
`{"paging":{"pageIndex":2,"pageSize":100,"total":237},` +
+
`"components":[{"key":"k1","name":"proj-1"},{"key":"k2","name":"proj-2"}]}`
+ return &http.Response{StatusCode: http.StatusOK, Body:
io.NopCloser(strings.NewReader(body))}, nil
+ })
+
+ children, total, err := searchSonarqubeRemoteProjects(apiClient,
&dsmodels.DsRemoteApiScopeSearchParams{
+ Search: "proj",
+ Page: 2,
+ PageSize: 100,
+ })
+
+ assert.Nil(t, err)
+ assert.Equal(t, 237, total)
+ assert.Len(t, children, 2)
+ assert.Equal(t, "k1", children[0].Id)
+ assert.Equal(t, "proj-1", children[0].Name)
+ assert.Equal(t, "2", gotQuery.Get("p"))
+ assert.Equal(t, "100", gotQuery.Get("ps"))
+ assert.Equal(t, `query = "proj"`, gotQuery.Get("filter"))
+}
func TestSonarqubeSearchProjectsQueryFilter(t *testing.T) {
t.Parallel()
diff --git a/config-ui/src/api/scope/index.ts b/config-ui/src/api/scope/index.ts
index 90b7c76e1..7ea42c57c 100644
--- a/config-ui/src/api/scope/index.ts
+++ b/config-ui/src/api/scope/index.ts
@@ -91,7 +91,7 @@ export const searchRemote = (
plugin: string,
connectionId: ID,
data: SearchRemoteQuery,
-): Promise<{ children: RemoteScope[]; count: number }> =>
+): Promise<{ children: RemoteScope[]; count?: number }> =>
request(`/plugins/${plugin}/connections/${connectionId}/search-remote-scopes`, {
method: 'get',
data,
diff --git
a/config-ui/src/plugins/components/data-scope-remote/search-remote.test.tsx
b/config-ui/src/plugins/components/data-scope-remote/search-remote.test.tsx
new file mode 100644
index 000000000..763c46850
--- /dev/null
+++ b/config-ui/src/plugins/components/data-scope-remote/search-remote.test.tsx
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+
+const { remote, searchRemote } = vi.hoisted(() => ({
+ remote: vi.fn(),
+ searchRemote: vi.fn(),
+}));
+
+vi.mock('@/api', () => ({ default: { scope: { remote, searchRemote } } }));
+vi.mock('@/plugins', () => ({ getPluginScopeName: () => undefined }));
+vi.mock('@/components', () => ({
+ Loading: () => <span>loading</span>,
+ Block: ({ title, children }: any) => (
+ <div>
+ {title}
+ {children}
+ </div>
+ ),
+}));
+
+import { SearchRemote } from './search-remote';
+
+const project = (i: number) => ({ id: `p${i}`, name: `proj-${i}`, type:
'scope', parentId: null, data: { key: i } });
+
+const renderComponent = (props: Partial<Parameters<typeof SearchRemote>[0]> =
{}) => {
+ const onChange = vi.fn();
+ render(
+ <SearchRemote
+ mode="multiple"
+ plugin="sonarqube"
+ connectionId={1}
+ config={{ title: 'Repositories', searchSelectAll: true }}
+ disabledScope={[]}
+ selectedScope={[]}
+ onChange={onChange}
+ {...props}
+ />,
+ );
+ return onChange;
+};
+
+const typeSearch = (value: string) =>
fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value } });
+
+const findAllCheckbox = async () =>
+ (await screen.findByRole('checkbox', { name: /^ALL/ }, { timeout: 2000 }))
as HTMLInputElement;
+
+describe('SearchRemote "ALL" toggle', () => {
+ beforeEach(() => {
+ remote.mockReset().mockResolvedValue({ children: [], nextPageToken: '' });
+ searchRemote.mockReset();
+ });
+
+ it('is hidden without a search query', async () => {
+ renderComponent();
+ await waitFor(() => expect(remote).toHaveBeenCalled());
+ expect(screen.queryByText(/^ALL/)).toBeNull();
+ });
+
+ it('is hidden when the plugin does not enable it', async () => {
+ searchRemote.mockResolvedValue({ children: [project(1)] });
+ renderComponent({ config: { title: 'Repositories' } });
+ typeSearch('proj');
+ await screen.findByText('proj-1', undefined, { timeout: 2000 });
+ expect(screen.queryByText(/^ALL/)).toBeNull();
+ });
+
+ it('is rendered as the first entry inside the result list', async () => {
+ searchRemote.mockResolvedValueOnce({ children: [project(1), project(2)],
count: 2 });
+ renderComponent();
+ typeSearch('proj');
+
+ const checkbox = await findAllCheckbox();
+ const row = screen.getByTestId('search-select-all');
+ const column = document.getElementById('miller-columns-column-root');
+ expect(column).not.toBeNull();
+ expect(column!.contains(row)).toBe(true);
+
+ const firstItem = screen.getByText('proj-1');
+ // the "ALL" row precedes the first item in document order
+ expect(row.compareDocumentPosition(firstItem) &
Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(checkbox.closest('label')?.textContent).toBe('ALL (2)');
+ });
+
+ it('selects every filtered item, loading all remaining pages', async () => {
+ // first page from the regular (debounced) search: a full page => more
results exist
+ const firstPage = Array.from({ length: 50 }, (_, i) => project(i));
+ searchRemote.mockResolvedValueOnce({ children: firstPage });
+ // "ALL" click: loads everything with its own page size
+ searchRemote.mockResolvedValueOnce({ children: Array.from({ length: 100 },
(_, i) => project(i)) });
+ searchRemote.mockResolvedValueOnce({ children: [project(100),
project(101)] });
+
+ const onChange = renderComponent({ selectedScope: [{ id: 'existing' }] });
+ typeSearch('proj');
+
+ const checkbox = await findAllCheckbox();
+ await waitFor(() => expect(checkbox.disabled).toBe(false));
+ fireEvent.click(checkbox);
+
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
+ expect(searchRemote).toHaveBeenCalledTimes(3);
+ expect(searchRemote).toHaveBeenLastCalledWith('sonarqube', 1, { search:
'proj', page: 2, pageSize: 100 });
+
+ const selected = onChange.mock.calls[0][0];
+ expect(selected).toHaveLength(103);
+ expect(selected[0].id).toBe('existing');
+ expect(selected.map((it: any) => it.id)).toContain('p101');
+ });
+
+ it('stops loading once the backend-reported count is reached', async () => {
+ searchRemote.mockResolvedValueOnce({ children: Array.from({ length: 50 },
(_, i) => project(i)), count: 200 });
+ searchRemote.mockResolvedValueOnce({ children: Array.from({ length: 100 },
(_, i) => project(i)), count: 200 });
+ searchRemote.mockResolvedValueOnce({
+ children: Array.from({ length: 100 }, (_, i) => project(i + 100)),
+ count: 200,
+ });
+
+ const onChange = renderComponent();
+ typeSearch('proj');
+
+ const checkbox = await findAllCheckbox();
+ expect(checkbox.closest('label')?.textContent).toBe('ALL (200)');
+ await waitFor(() => expect(checkbox.disabled).toBe(false));
+ fireEvent.click(checkbox);
+
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
+ // 1 regular search + 2 pages for "ALL" (no extra request for an empty
third page)
+ expect(searchRemote).toHaveBeenCalledTimes(3);
+ expect(onChange.mock.calls[0][0]).toHaveLength(200);
+ });
+
+ it('deselects the filtered items when all are already selected and skips
disabled ones', async () => {
+ searchRemote.mockResolvedValueOnce({ children: [project(1), project(2),
project(3)] });
+
+ const onChange = renderComponent({
+ selectedScope: [{ id: 'existing' }, project(1), project(3)],
+ disabledScope: [{ id: 'p2' }],
+ });
+ typeSearch('proj');
+
+ const checkbox = await findAllCheckbox();
+ await waitFor(() => expect(checkbox.checked).toBe(true));
+ fireEvent.click(checkbox);
+
+ // the complete list was already known => no additional request
+ expect(searchRemote).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith([{ id:
'existing' }]));
+ });
+});
diff --git
a/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
b/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
index 2a23a66d3..c3d5ff4b6 100644
--- a/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
+++ b/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
@@ -16,9 +16,9 @@
*
*/
-import { useState, useEffect, useMemo } from 'react';
+import { useState, useEffect, useMemo, useRef } from 'react';
import { SearchOutlined } from '@ant-design/icons';
-import { Space, Tag, Input, message } from 'antd';
+import { Space, Tag, Input, Checkbox, Spin, message } from 'antd';
import type { McsID, McsItem, McsColumn } from 'miller-columns-select';
import MillerColumnsSelect from 'miller-columns-select';
import { useDebounce } from 'ahooks';
@@ -31,6 +31,7 @@ import { getPluginScopeName } from '@/plugins';
import * as T from './types';
import * as S from './styled';
+import { fetchAllSearchResults, getSelectAllState, toggleAllFiltered } from
'./select-all';
interface Props {
mode: 'single' | 'multiple';
@@ -155,7 +156,7 @@ export const SearchRemote = ({ mode, plugin, connectionId,
config, disabledScope
loading: false,
items: [...allItems, ...newItems],
// Accumulate results across pages so previous pages remain visible
- currentItems: s.page === 1 ? newItems : [...s.currentItems,
...newItems],
+ currentItems: s.page === 1 ? newItems : uniqBy([...s.currentItems,
...newItems], 'id'),
total,
hasMore,
}));
@@ -168,6 +169,112 @@ export const SearchRemote = ({ mode, plugin,
connectionId, config, disabledScope
searchItems();
}, [searchDebounce, search.page]);
+ // ---- "ALL" toggle for the filtered search result (opt-in via
config.searchSelectAll) ----
+ // It is rendered as the first entry of the result list (like
`showSelectAll` of miller-columns-select).
+ // The built-in `showSelectAll` can't be used here: it only covers the
already loaded page(s) and
+ // replaces the whole selection, while this toggle loads every page of the
search result and keeps
+ // the selection outside of the filter.
+ const showSelectAll = !!config.searchSelectAll && mode === 'multiple' &&
!!searchDebounce;
+ const disabledIds = useMemo(() => (disabledScope ?? []).map((it) => it.id),
[disabledScope]);
+ const [fullResult, setFullResult] = useState<{ query: string; items:
McsItem<T.ResItem>[] } | null>(null);
+ const [selectAllLoading, setSelectAllLoading] = useState(false);
+ const latestQueryRef = useRef(searchDebounce);
+ latestQueryRef.current = searchDebounce;
+
+ // A new search invalidates a previously loaded complete result.
+ useEffect(() => {
+ setFullResult(null);
+ }, [searchDebounce]);
+
+ // The complete filtered list is known if it was fully fetched or no further
pages exist.
+ const knownFilteredItems = useMemo(() => {
+ if (fullResult && fullResult.query === searchDebounce) return
fullResult.items;
+ if (!search.loading && !search.hasMore) return search.currentItems;
+ return null;
+ }, [fullResult, searchDebounce, search.loading, search.hasMore,
search.currentItems]);
+
+ // Number of matches: reported by the backend (`count`) or – once fully
loaded – the list length.
+ const matchCount = search.total > 0 ? search.total :
knownFilteredItems?.length;
+
+ const selectAllState = useMemo(() => {
+ const state = getSelectAllState(selectedScope, knownFilteredItems ??
search.currentItems, disabledIds);
+ // Without the complete list we can never claim that "all" are selected.
+ return state === 'all' && !knownFilteredItems ? 'some' : state;
+ }, [selectedScope, knownFilteredItems, search.currentItems, disabledIds]);
+
+ const selectAllDisabled = selectAllLoading || search.loading ||
knownFilteredItems?.length === 0;
+
+ const handleToggleAll = async () => {
+ if (selectAllDisabled) return;
+
+ let filtered = knownFilteredItems;
+
+ if (!filtered) {
+ const query = searchDebounce;
+ let complete = true;
+ setSelectAllLoading(true);
+ try {
+ const res = await fetchAllSearchResults((page, pageSize) =>
+ API.scope.searchRemote(plugin, connectionId, { search: query, page,
pageSize }),
+ );
+ complete = res.complete;
+ filtered = res.items.map((it) => ({
+ ...it,
+ title: getPluginScopeName(plugin, it) || it.fullName || it.name,
+ })) as McsItem<T.ResItem>[];
+ } catch (err: any) {
+ message.error(err?.response?.data?.message ?? 'Failed to load all
search results.');
+ return;
+ } finally {
+ setSelectAllLoading(false);
+ }
+
+ // The user changed the search while we were loading – discard the stale
result.
+ if (latestQueryRef.current !== query) return;
+
+ const loaded = filtered;
+ if (complete) {
+ setFullResult({ query, items: loaded });
+ } else {
+ message.warning(
+ `Only the first ${loaded.length} matches could be loaded. Please
refine the search to select the rest.`,
+ );
+ }
+ setSearch((s) => ({
+ ...s,
+ items: uniqBy([...s.items, ...loaded], 'id'),
+ currentItems: uniqBy([...s.currentItems, ...loaded], 'id'),
+ hasMore: !complete,
+ }));
+ }
+
+ onChange(toggleAllFiltered(selectedScope, filtered, disabledIds));
+ };
+
+ const renderSelectAll = () =>
+ showSelectAll &&
+ search.currentItems.length > 0 && (
+ <S.SelectAllItem
+ data-testid="search-select-all"
+ $disabled={selectAllDisabled}
+ onClick={(e) => {
+ // the checkbox handles its own clicks
+ if ((e.target as HTMLElement).closest('.ant-checkbox-wrapper'))
return;
+ handleToggleAll();
+ }}
+ >
+ <Checkbox
+ checked={selectAllState === 'all'}
+ indeterminate={selectAllState === 'some'}
+ disabled={selectAllDisabled}
+ onChange={handleToggleAll}
+ >
+ {matchCount !== undefined ? `ALL (${matchCount})` : 'ALL'}
+ </Checkbox>
+ {selectAllLoading && <Spin size="small" />}
+ </S.SelectAllItem>
+ );
+
return (
<>
<Block title={config.title} required>
@@ -216,7 +323,7 @@ export const SearchRemote = ({ mode, plugin, connectionId,
config, disabledScope
}
renderLoading={() => <Loading size={20} style={{ padding: '4px
12px' }} />}
renderError={() => <span style={{ color: 'red' }}>Something
Error</span>}
- disabledIds={(disabledScope ?? []).map((it) => it.id)}
+ disabledIds={disabledIds}
selectedIds={selectedScope.map((it) => it.id)}
onSelectItemIds={(selectedIds: ID[]) =>
onChange(allItems.filter((it) => selectedIds.includes(it.id)))}
/>
@@ -233,8 +340,9 @@ export const SearchRemote = ({ mode, plugin, connectionId,
config, disabledScope
setSearch((s) => ({ ...s, loading: true, page: s.page + 1 }));
}
}}
+ renderTitle={renderSelectAll}
renderLoading={() => <Loading size={20} style={{ padding: '4px
12px' }} />}
- disabledIds={(disabledScope ?? []).map((it) => it.id)}
+ disabledIds={disabledIds}
selectedIds={selectedScope.map((it) => it.id)}
onSelectItemIds={(selectedIds: ID[]) =>
onChange(allItems.filter((it) => selectedIds.includes(it.id)))}
/>
diff --git
a/config-ui/src/plugins/components/data-scope-remote/select-all.test.ts
b/config-ui/src/plugins/components/data-scope-remote/select-all.test.ts
new file mode 100644
index 000000000..52d0b1afd
--- /dev/null
+++ b/config-ui/src/plugins/components/data-scope-remote/select-all.test.ts
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+import { fetchAllSearchResults, getSelectAllState, toggleAllFiltered } from
'./select-all';
+
+const items = (from: number, to: number) => Array.from({ length: to - from },
(_, i) => ({ id: `p${from + i}` }));
+
+describe('fetchAllSearchResults', () => {
+ it('loads pages until a short page is returned', async () => {
+ const fetchPage = vi
+ .fn()
+ .mockResolvedValueOnce({ children: items(0, 2) })
+ .mockResolvedValueOnce({ children: items(2, 4) })
+ .mockResolvedValueOnce({ children: items(4, 5) });
+
+ const res = await fetchAllSearchResults(fetchPage, 2);
+
+ expect(res.items.map((it) => it.id)).toEqual(['p0', 'p1', 'p2', 'p3',
'p4']);
+ expect(res.complete).toBe(true);
+ expect(res.total).toBeUndefined();
+ expect(fetchPage).toHaveBeenCalledTimes(3);
+ expect(fetchPage).toHaveBeenNthCalledWith(3, 3, 2);
+ });
+
+ it('stops when the reported total is reached', async () => {
+ const fetchPage = vi.fn().mockResolvedValue({ children: items(0, 2),
count: 2 });
+ const res = await fetchAllSearchResults(fetchPage, 2);
+ expect(fetchPage).toHaveBeenCalledTimes(1);
+ expect(res).toMatchObject({ total: 2, complete: true });
+ });
+
+ it('uses the reported total to avoid an extra request for the last full
page', async () => {
+ const fetchPage = vi
+ .fn()
+ .mockResolvedValueOnce({ children: items(0, 2), count: 4 })
+ .mockResolvedValueOnce({ children: items(2, 4), count: 4 });
+ const res = await fetchAllSearchResults(fetchPage, 2);
+ expect(fetchPage).toHaveBeenCalledTimes(2);
+ expect(res.items).toHaveLength(4);
+ });
+
+ it('respects the max page limit and removes duplicates', async () => {
+ const fetchPage = vi.fn().mockResolvedValue({ children: items(0, 2) });
+ const res = await fetchAllSearchResults(fetchPage, 2, 3);
+ expect(fetchPage).toHaveBeenCalledTimes(3);
+ expect(res.items).toHaveLength(2);
+ expect(res.complete).toBe(false);
+ });
+
+ it('handles empty responses', async () => {
+ const fetchPage = vi.fn().mockResolvedValue({ children: null });
+ expect(await fetchAllSearchResults(fetchPage, 2)).toEqual({ items: [],
total: undefined, complete: true });
+ });
+});
+
+describe('toggleAllFiltered / getSelectAllState', () => {
+ const filtered = items(0, 3);
+
+ it('adds all filtered items and keeps existing selection', () => {
+ const selected = [{ id: 'other' }, { id: 'p1' }];
+ const res = toggleAllFiltered(selected, filtered);
+ expect(res.map((it) => it.id)).toEqual(['other', 'p1', 'p0', 'p2']);
+ expect(getSelectAllState(selected, filtered)).toBe('some');
+ expect(getSelectAllState(res, filtered)).toBe('all');
+ });
+
+ it('removes all filtered items when all are already selected', () => {
+ const selected = [{ id: 'other' }, ...filtered];
+ const res = toggleAllFiltered(selected, filtered);
+ expect(res.map((it) => it.id)).toEqual(['other']);
+ expect(getSelectAllState(res, filtered)).toBe('none');
+ });
+
+ it('ignores disabled items', () => {
+ const res = toggleAllFiltered([], filtered, ['p1']);
+ expect(res.map((it) => it.id)).toEqual(['p0', 'p2']);
+ expect(getSelectAllState(res, filtered, ['p1'])).toBe('all');
+ expect(toggleAllFiltered(res, filtered, ['p1'])).toEqual([]);
+ });
+
+ it('compares ids independent of their type', () => {
+ expect(getSelectAllState([{ id: 1 }], [{ id: '1' }])).toBe('all');
+ });
+});
diff --git a/config-ui/src/plugins/components/data-scope-remote/select-all.ts
b/config-ui/src/plugins/components/data-scope-remote/select-all.ts
new file mode 100644
index 000000000..fc02255db
--- /dev/null
+++ b/config-ui/src/plugins/components/data-scope-remote/select-all.ts
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+export const SELECT_ALL_PAGE_SIZE = 100;
+export const SELECT_ALL_MAX_PAGES = 100;
+
+type WithId = { id: ID };
+
+export type FetchAllResult<T> = {
+ items: T[];
+ /** total reported by the backend (`count`), undefined if the plugin doesn't
provide it */
+ total?: number;
+ /** false if loading stopped because of the page limit, i.e. there may be
more results */
+ complete: boolean;
+};
+
+/**
+ * Loads every page of a remote search. Stops as soon as the backend-reported
total (`count`) is
+ * reached or – for plugins that don't report a total – a page returns fewer
items than requested.
+ */
+export const fetchAllSearchResults = async <T extends WithId>(
+ fetchPage: (page: number, pageSize: number) => Promise<{ children?: T[] |
null; count?: number }>,
+ pageSize = SELECT_ALL_PAGE_SIZE,
+ maxPages = SELECT_ALL_MAX_PAGES,
+): Promise<FetchAllResult<T>> => {
+ const items: T[] = [];
+ const seen = new Set<string>();
+ let total: number | undefined;
+
+ for (let page = 1; page <= maxPages; page++) {
+ const res = await fetchPage(page, pageSize);
+ const children = res?.children ?? [];
+
+ children.forEach((it) => {
+ const key = String(it.id);
+ if (!seen.has(key)) {
+ seen.add(key);
+ items.push(it);
+ }
+ });
+
+ if (typeof res?.count === 'number') {
+ total = res.count;
+ }
+ if (children.length < pageSize || (total !== undefined && page * pageSize
>= total)) {
+ return { items, total, complete: true };
+ }
+ }
+
+ return { items, total, complete: false };
+};
+
+const toKeySet = (ids: ID[]) => new Set(ids.map((id) => String(id)));
+
+const getSelectable = <T extends WithId>(filtered: T[], disabledIds: ID[]) => {
+ const disabled = toKeySet(disabledIds);
+ return filtered.filter((it) => !disabled.has(String(it.id)));
+};
+
+export type SelectAllState = 'all' | 'some' | 'none';
+
+export const getSelectAllState = <T extends WithId>(selected: WithId[],
filtered: T[], disabledIds: ID[] = []) => {
+ const selectable = getSelectable(filtered, disabledIds);
+ if (!selectable.length) return 'none' as SelectAllState;
+ const selectedKeys = toKeySet(selected.map((it) => it.id));
+ const count = selectable.filter((it) =>
selectedKeys.has(String(it.id))).length;
+ if (count === 0) return 'none' as SelectAllState;
+ return (count === selectable.length ? 'all' : 'some') as SelectAllState;
+};
+
+/**
+ * Toggles all filtered (non-disabled) items: if every one of them is already
selected they get
+ * removed from the selection, otherwise the missing ones are appended.
Selected items outside of
+ * the filtered list are always kept.
+ */
+export const toggleAllFiltered = <S extends WithId, T extends WithId>(
+ selected: S[],
+ filtered: T[],
+ disabledIds: ID[] = [],
+): Array<S | T> => {
+ const selectable = getSelectable(filtered, disabledIds);
+ const selectableKeys = toKeySet(selectable.map((it) => it.id));
+
+ if (getSelectAllState(selected, filtered, disabledIds) === 'all') {
+ return selected.filter((it) => !selectableKeys.has(String(it.id)));
+ }
+
+ const selectedKeys = toKeySet(selected.map((it) => it.id));
+ return [...selected, ...selectable.filter((it) =>
!selectedKeys.has(String(it.id)))];
+};
diff --git a/config-ui/src/plugins/components/data-scope-remote/styled.ts
b/config-ui/src/plugins/components/data-scope-remote/styled.ts
index dc880b18c..eb40f68e5 100644
--- a/config-ui/src/plugins/components/data-scope-remote/styled.ts
+++ b/config-ui/src/plugins/components/data-scope-remote/styled.ts
@@ -25,6 +25,19 @@ export const ColumnTitle = styled.div`
font-weight: 600;
`;
+// Mirrors the item row of miller-columns-select so the "ALL" toggle looks
like the first list entry.
+export const SelectAllItem = styled.div<{ $disabled?: boolean }>`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 12px;
+ cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
+
+ &:hover {
+ background-color: #f5f5f7;
+ }
+`;
+
export const JobLoad = styled.div`
display: flex;
align-items: center;
diff --git a/config-ui/src/plugins/register/sonarqube/config.tsx
b/config-ui/src/plugins/register/sonarqube/config.tsx
index 966ce8bf5..6ac89e504 100644
--- a/config-ui/src/plugins/register/sonarqube/config.tsx
+++ b/config-ui/src/plugins/register/sonarqube/config.tsx
@@ -73,5 +73,6 @@ export const SonarQubeConfig: IPluginConfig = {
},
dataScope: {
title: 'Repositories',
+ searchSelectAll: true,
},
};
diff --git a/config-ui/src/types/plugin.ts b/config-ui/src/types/plugin.ts
index a8db35f9d..316ac2d29 100644
--- a/config-ui/src/types/plugin.ts
+++ b/config-ui/src/types/plugin.ts
@@ -33,6 +33,8 @@ export interface IPluginConfig {
};
dataScope: {
localSearch?: boolean;
+ /** Shows an "All" toggle to (de)select every item of the filtered remote
search result. */
+ searchSelectAll?: boolean;
searchPlaceholder?: string;
title?: string;
millerColumn?: {