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 6cfb055a294..b4285110ed4 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 00000000000..bb4dc0c4f98
--- /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 10f5cda32e1..874c1cbcadb 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 ba43c147d29..7b6a2b860c8 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 f8a9c87e0e0..9d6436d98f2 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 a3b290dbff4..24bdb8fa6be 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 90b7c76e163..7ea42c57c0c 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 00000000000..763c46850db
--- /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: () => loading,
+ Block: ({ title, children }: any) => (
+
+ {title}
+ {children}
+
+ ),
+}));
+
+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[0]> = {}) => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ 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 2a23a66d3cf..c3d5ff4b627 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[] } | 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[];
+ } 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 && (
+ {
+ // the checkbox handles its own clicks
+ if ((e.target as HTMLElement).closest('.ant-checkbox-wrapper')) return;
+ handleToggleAll();
+ }}
+ >
+
+ {matchCount !== undefined ? `ALL (${matchCount})` : 'ALL'}
+
+ {selectAllLoading && }
+
+ );
+
return (
<>
@@ -216,7 +323,7 @@ export const SearchRemote = ({ mode, plugin, connectionId, config, disabledScope
}
renderLoading={() => }
renderError={() => Something Error}
- 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={() => }
- 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 00000000000..52d0b1afdca
--- /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 00000000000..fc02255db3e
--- /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 = {
+ 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 (
+ fetchPage: (page: number, pageSize: number) => Promise<{ children?: T[] | null; count?: number }>,
+ pageSize = SELECT_ALL_PAGE_SIZE,
+ maxPages = SELECT_ALL_MAX_PAGES,
+): Promise> => {
+ const items: T[] = [];
+ const seen = new Set();
+ 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 = (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 = (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 = (
+ selected: S[],
+ filtered: T[],
+ disabledIds: ID[] = [],
+): Array => {
+ 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 dc880b18c07..eb40f68e58b 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 966ce8bf5af..6ac89e50457 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 a8db35f9dd5..316ac2d29b6 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?: {