From ef1d41c122bb25abdb35e80fa472e1b4a79475d8 Mon Sep 17 00:00:00 2001 From: Lucas <97296331+lucas-paulger-sonarsource@users.noreply.github.com> Date: Fri, 25 Oct 2024 17:35:50 +0300 Subject: [PATCH] SONAR-23469 Support Pagination of Dependencies (#12149) --- .../sonar-web/src/main/js/api/dependencies.ts | 13 +++- .../js/api/mocks/DependenciesServiceMock.ts | 17 ++++- .../js/apps/dependencies/DependenciesApp.tsx | 42 +++++++++++-- .../__tests__/DependenciesApp-it.tsx | 62 ++++++++++++++++++- .../src/main/js/queries/dependencies.ts | 29 ++++++--- 5 files changed, 144 insertions(+), 19 deletions(-) diff --git a/server/sonar-web/src/main/js/api/dependencies.ts b/server/sonar-web/src/main/js/api/dependencies.ts index a4cd1695e5e..58c3129179a 100644 --- a/server/sonar-web/src/main/js/api/dependencies.ts +++ b/server/sonar-web/src/main/js/api/dependencies.ts @@ -24,6 +24,17 @@ import { DependenciesResponse } from '../types/dependencies'; const DEPENDENCY_PATH = '/api/v2/analysis/dependencies'; -export function getDependencies(params: { projectKey: string; q?: string } & BranchLikeParameters) { +export function getDependencies({ + pageParam, + projectKey, + q, + ...branchParameters +}: { pageParam: number; projectKey: string; q?: string } & BranchLikeParameters) { + const params = { + pageIndex: pageParam, + projectKey, + q, + ...branchParameters, + }; return axios.get(DEPENDENCY_PATH, { params }); } diff --git a/server/sonar-web/src/main/js/api/mocks/DependenciesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/DependenciesServiceMock.ts index 15578b9609e..9fed3a7692e 100644 --- a/server/sonar-web/src/main/js/api/mocks/DependenciesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/DependenciesServiceMock.ts @@ -27,7 +27,7 @@ jest.mock('../dependencies'); export const DEFAULT_DEPENDENCIES_MOCK: DependenciesResponse = { page: { pageIndex: 1, - pageSize: 100, + pageSize: 50, total: 0, }, dependencies: [], @@ -49,7 +49,20 @@ export default class DependenciesServiceMock { this.#defaultDependenciesData = response; }; - handleGetDependencies = (data: { q?: string }) => { + handleGetDependencies = (data: { pageParam: number; q?: string }) => { + const { pageSize } = this.#defaultDependenciesData.page; + const totalDependencies = this.#defaultDependenciesData.dependencies.length; + + if (pageSize < totalDependencies) { + const startIndex = (data.pageParam - 1) * pageSize; + const endIndex = startIndex + pageSize; + + return Promise.resolve({ + ...this.#defaultDependenciesData, + dependencies: this.#defaultDependenciesData.dependencies.slice(startIndex, endIndex), + }); + } + return Promise.resolve({ ...this.#defaultDependenciesData, dependencies: this.#defaultDependenciesData.dependencies.filter( diff --git a/server/sonar-web/src/main/js/apps/dependencies/DependenciesApp.tsx b/server/sonar-web/src/main/js/apps/dependencies/DependenciesApp.tsx index bd7dc34513b..4f8bedf6265 100644 --- a/server/sonar-web/src/main/js/apps/dependencies/DependenciesApp.tsx +++ b/server/sonar-web/src/main/js/apps/dependencies/DependenciesApp.tsx @@ -20,12 +20,13 @@ import styled from '@emotion/styled'; import { Spinner, Text } from '@sonarsource/echoes-react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { FormattedMessage } from 'react-intl'; import { InputSearch, LargeCenteredLayout } from '~design-system'; import withComponentContext from '../../app/components/componentContext/withComponentContext'; import DocumentationLink from '../../components/common/DocumentationLink'; +import ListFooter from '../../components/controls/ListFooter'; import { DocLink } from '../../helpers/doc-links'; import { translate } from '../../helpers/l10n'; import { useCurrentBranchQuery } from '../../queries/branch'; @@ -48,7 +49,15 @@ function App(props: Readonly) { const [search, setSearch] = useState(''); - const { data: { dependencies = [] } = {}, isLoading } = useDependenciesQuery({ + const { + data: { pages, pageParams } = { + pages: [], + pageParams: [], + }, + isLoading, + fetchNextPage, + isFetchingNextPage, + } = useDependenciesQuery({ projectKey: component.key, q: search, branchParameters: getBranchLikeQuery(branchLike) as BranchLikeParameters, @@ -56,7 +65,19 @@ function App(props: Readonly) { const listName = search ? 'dependencies.list.name_search.title' : 'dependencies.list.title'; - const resultsExist = dependencies.length > 0 || search.length >= SEARCH_MIN_LENGTH; + const { resultsExist, allDependencies, totalDependencies, itemCount } = useMemo(() => { + // general paging information + const { total, pageSize } = pages[0]?.page ?? { total: 0, pageSize: 0 }; + + const resultsExist = total > 0 || search.length >= SEARCH_MIN_LENGTH; + + const allDependencies = pages.flatMap((page) => page.dependencies); + const totalDependencies = pages[0]?.page.total ?? 0; + + const itemCount = pageSize * pages.length > total ? total : pageSize * pages.length; + + return { resultsExist, allDependencies, totalDependencies, itemCount }; + }, [pages, search]); return ( @@ -77,7 +98,7 @@ function App(props: Readonly) { )} - {dependencies.length === 0 && } + {!resultsExist && } {resultsExist && (
@@ -85,17 +106,26 @@ function App(props: Readonly) { id={listName} defaultMessage={translate(listName)} values={{ - count: dependencies.length, + count: allDependencies.length, }} />
    - {dependencies.map((dependency) => ( + {allDependencies.map((dependency) => (
  • ))}
+ {pageParams.length > 0 && ( + fetchNextPage()} + loading={isFetchingNextPage} + total={totalDependencies} + /> + )}
)}
diff --git a/server/sonar-web/src/main/js/apps/dependencies/__tests__/DependenciesApp-it.tsx b/server/sonar-web/src/main/js/apps/dependencies/__tests__/DependenciesApp-it.tsx index 3eca30fb38b..9ea4b597c88 100644 --- a/server/sonar-web/src/main/js/apps/dependencies/__tests__/DependenciesApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/dependencies/__tests__/DependenciesApp-it.tsx @@ -37,7 +37,7 @@ const settingsHandler = new SettingsServiceMock(); const MOCK_RESPONSE: DependenciesResponse = { page: { pageIndex: 1, - pageSize: 100, + pageSize: 50, total: 4, }, dependencies: [ @@ -90,6 +90,48 @@ const MOCK_RESPONSE: DependenciesResponse = { ], }; +const MOCK_RESPONSE_NO_FINDINGS_PAGING: DependenciesResponse = { + page: { + pageIndex: 1, + pageSize: 2, + total: 4, + }, + dependencies: [ + { + key: '1', + name: 'jackson-databind', + longName: 'com.fasterxml.jackson.core:jackson-databind', + version: '2.10.0', + transitive: false, + project: 'project1', + }, + { + key: '2', + name: 'snappy-java', + longName: 'org.xerial.snappy:snappy-java', + version: '3.52', + transitive: true, + project: 'project1', + }, + { + key: '3', + name: 'SnakeYAML', + longName: 'org.yaml:SnakeYAML', + version: '2.10.0', + transitive: true, + project: 'project1', + }, + { + key: '4', + name: 'random-lib', + longName: 'com.random:random-lib', + version: '2.10.0', + transitive: true, + project: 'project1', + }, + ], +}; + const MOCK_RESPONSE_NO_FINDINGS: DependenciesResponse = { page: { pageIndex: 1, @@ -201,6 +243,21 @@ it('should correctly show empty results state when no dependencies are found', a expect(await ui.searchTitle.get()).toBeInTheDocument(); }); +it('should correctly fetch additional pages of dependencies', async () => { + depsHandler.setDefaultDependencies(MOCK_RESPONSE_NO_FINDINGS_PAGING); + const { ui, user } = getPageObject(); + + renderDependenciesApp(); + + expect(await ui.dependencies.findAll()).toHaveLength(2); + + expect(await ui.twoOfFour.find()).toBeInTheDocument(); + + await user.click(ui.showMore.get()); + expect(await ui.fourOfFour.find()).toBeInTheDocument(); + expect(await ui.dependencies.findAll()).toHaveLength(4); +}); + function getPageObject() { const user = userEvent.setup(); const ui = { @@ -211,6 +268,9 @@ function getPageObject() { dependencies: byRole('listitem'), searchInput: byRole('searchbox'), searchTitle: byText('dependencies.list.name_search.title0'), + showMore: byText('show_more'), + twoOfFour: byText('x_of_y_shown.2.4'), + fourOfFour: byText('x_of_y_shown.4.4'), }; return { ui, user }; } diff --git a/server/sonar-web/src/main/js/queries/dependencies.ts b/server/sonar-web/src/main/js/queries/dependencies.ts index 7186d299d11..37d2f1ca606 100644 --- a/server/sonar-web/src/main/js/queries/dependencies.ts +++ b/server/sonar-web/src/main/js/queries/dependencies.ts @@ -18,21 +18,32 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { queryOptions } from '@tanstack/react-query'; +import { infiniteQueryOptions } from '@tanstack/react-query'; import { getDependencies } from '../api/dependencies'; import { BranchLikeParameters } from '../sonar-aligned/types/branch-like'; -import { createQueryHook } from './common'; +import { createInfiniteQueryHook } from './common'; -export const useDependenciesQuery = createQueryHook( - (data: { branchParameters: BranchLikeParameters; projectKey: string; q?: string }) => { - return queryOptions({ - queryKey: ['dependencies', data.projectKey, data.branchParameters, data.q], - queryFn: () => - getDependencies({ +export const useDependenciesQuery = createInfiniteQueryHook( + (data: { + branchParameters: BranchLikeParameters; + pageIndex?: number; + projectKey: string; + q?: string; + }) => { + return infiniteQueryOptions({ + queryKey: ['dependencies', data.projectKey, data.branchParameters, data.q, data.pageIndex], + queryFn: ({ pageParam }) => { + return getDependencies({ projectKey: data.projectKey, q: data.q, ...data.branchParameters, - }), + pageParam, + }); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + return lastPage.page.pageIndex + 1; + }, }); }, ); -- 2.39.5