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?: {