diff options
Diffstat (limited to 'server')
14 files changed, 743 insertions, 5 deletions
diff --git a/server/sonar-web/design-system/src/components/Pill.tsx b/server/sonar-web/design-system/src/components/Pill.tsx index 3f53c3473c5..bcd2b2f246c 100644 --- a/server/sonar-web/design-system/src/components/Pill.tsx +++ b/server/sonar-web/design-system/src/components/Pill.tsx @@ -31,6 +31,13 @@ export enum PillVariant { Caution = 'caution', Info = 'info', Accent = 'accent', + Success = 'success', + Neutral = 'neutral', +} + +export enum PillHighlight { + Medium = 'medium', + Low = 'low', } const variantThemeColors: Record<PillVariant, ThemeColors> = { @@ -40,6 +47,8 @@ const variantThemeColors: Record<PillVariant, ThemeColors> = { [PillVariant.Caution]: 'pillCaution', [PillVariant.Info]: 'pillInfo', [PillVariant.Accent]: 'pillAccent', + [PillVariant.Success]: 'pillSuccess', + [PillVariant.Neutral]: 'pillNeutral', }; const variantThemeBorderColors: Record<PillVariant, ThemeColors> = { @@ -49,6 +58,8 @@ const variantThemeBorderColors: Record<PillVariant, ThemeColors> = { [PillVariant.Caution]: 'pillCautionBorder', [PillVariant.Info]: 'pillInfoBorder', [PillVariant.Accent]: 'pillAccentBorder', + [PillVariant.Success]: 'pillSuccessBorder', + [PillVariant.Neutral]: 'pillNeutralBorder', }; const variantThemeHoverColors: Record<PillVariant, ThemeColors> = { @@ -58,12 +69,15 @@ const variantThemeHoverColors: Record<PillVariant, ThemeColors> = { [PillVariant.Caution]: 'pillCautionHover', [PillVariant.Info]: 'pillInfoHover', [PillVariant.Accent]: 'pillAccentHover', + [PillVariant.Success]: 'pillSuccessHover', + [PillVariant.Neutral]: 'pillNeutralHover', }; interface PillProps { ['aria-label']?: string; children: ReactNode; className?: string; + highlight?: PillHighlight; // If pill is wrapped with Tooltip, it will have onClick prop overriden. // So to avoid hover effect, we add additional prop to disable hover effect even with onClick. notClickable?: boolean; @@ -73,13 +87,13 @@ interface PillProps { // eslint-disable-next-line react/display-name export const Pill = forwardRef<HTMLButtonElement, Readonly<PillProps>>( - ({ children, variant, onClick, notClickable, ...rest }, ref) => { + ({ children, variant, highlight = PillHighlight.Low, onClick, notClickable, ...rest }, ref) => { return onClick && !notClickable ? ( <StyledPillButton onClick={onClick} ref={ref} variant={variant} {...rest}> {children} </StyledPillButton> ) : ( - <StyledPill ref={ref} variant={variant} {...rest}> + <StyledPill highlight={highlight} ref={ref} variant={variant} {...rest}> {children} </StyledPill> ); @@ -101,14 +115,17 @@ const reusedStyles = css` `; const StyledPill = styled.span<{ + highlight: PillHighlight; variant: PillVariant; }>` ${reusedStyles}; - background-color: ${({ variant }) => themeColor(variantThemeColors[variant])}; + background-color: ${({ variant, highlight }) => + highlight === PillHighlight.Medium && themeColor(variantThemeColors[variant])}; color: ${({ variant }) => themeContrast(variantThemeColors[variant])}; - border-style: ${({ variant }) => (variant === PillVariant.Accent ? 'hidden' : 'solid')}; - border-color: ${({ variant }) => themeColor(variantThemeBorderColors[variant])}; + border-style: ${({ highlight }) => (highlight === PillHighlight.Medium ? 'hidden' : 'solid')}; + border-color: ${({ variant, highlight }) => + highlight === PillHighlight.Low && themeColor(variantThemeBorderColors[variant])}; `; const StyledPillButton = styled.button<{ diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index f885de25948..f9c0aaa006f 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -345,6 +345,12 @@ export const lightTheme = { pillAccent: COLORS.indigo[50], pillAccentBorder: 'transparent', pillAccentHover: COLORS.indigo[100], + pillSuccess: COLORS.green[100], + pillSuccessBorder: COLORS.green[600], + pillSuccessHover: COLORS.green[200], + pillNeutral: COLORS.blueGrey[50], + pillNeutralBorder: COLORS.blueGrey[400], + pillNeutralHover: COLORS.blueGrey[100], // input select selectOptionSelected: secondary.light, @@ -781,6 +787,8 @@ export const lightTheme = { pillCaution: COLORS.yellow[800], pillInfo: COLORS.blue[800], pillAccent: COLORS.indigo[500], + pillSuccess: COLORS.green[800], + pillNeutral: COLORS.blueGrey[500], // project cards overviewCardDefaultIcon: COLORS.blueGrey[500], diff --git a/server/sonar-web/src/main/js/api/dependencies.ts b/server/sonar-web/src/main/js/api/dependencies.ts new file mode 100644 index 00000000000..94498055268 --- /dev/null +++ b/server/sonar-web/src/main/js/api/dependencies.ts @@ -0,0 +1,28 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import axios from 'axios'; +import { BranchLikeParameters } from '../sonar-aligned/types/branch-like'; +import { DependenciesResponse } from '../types/dependencies'; + +const DEPENDENCY_PATH = '/api/v2/analysis/dependencies'; + +export function getDependencies(params: { projectKey: string; q?: string } & BranchLikeParameters) { + return axios.get<DependenciesResponse>(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 new file mode 100644 index 00000000000..15578b9609e --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/DependenciesServiceMock.ts @@ -0,0 +1,62 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { cloneDeep } from 'lodash'; +import { DependenciesResponse } from '../../types/dependencies'; +import { getDependencies } from '../dependencies'; + +jest.mock('../dependencies'); + +export const DEFAULT_DEPENDENCIES_MOCK: DependenciesResponse = { + page: { + pageIndex: 1, + pageSize: 100, + total: 0, + }, + dependencies: [], +}; + +export default class DependenciesServiceMock { + #defaultDependenciesData: DependenciesResponse = DEFAULT_DEPENDENCIES_MOCK; + + constructor() { + jest.mocked(getDependencies).mockImplementation(this.handleGetDependencies); + } + + reset = () => { + this.#defaultDependenciesData = cloneDeep(DEFAULT_DEPENDENCIES_MOCK); + return this; + }; + + setDefaultDependencies = (response: DependenciesResponse) => { + this.#defaultDependenciesData = response; + }; + + handleGetDependencies = (data: { q?: string }) => { + return Promise.resolve({ + ...this.#defaultDependenciesData, + dependencies: this.#defaultDependenciesData.dependencies.filter( + (dependency) => + typeof data.q !== 'string' || + dependency.name.toLowerCase().includes(data.q.toLowerCase()), + ), + }); + }; +} diff --git a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx index 6b06dd7ea5b..092f2dd6f57 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx @@ -209,6 +209,17 @@ export function Menu(props: Readonly<Props>) { ); }; + const renderDependenciesLink = () => { + const isPortfolio = isPortfolioLike(qualifier); + return ( + !isPortfolio && + renderMenuLink({ + label: translate('layout.dependencies'), + pathname: '/dependencies', + }) + ); + }; + const renderSecurityReports = () => { if (isPullRequest(branchLike)) { return null; @@ -551,6 +562,7 @@ export function Menu(props: Readonly<Props>) { {renderBreakdownLink()} {renderIssuesLink()} {renderSecurityHotspotsLink()} + {renderDependenciesLink()} {renderSecurityReports()} {renderComponentMeasuresLink()} {renderCodeLink()} diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx index df15beb3d04..605c7910ce1 100644 --- a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx +++ b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx @@ -40,6 +40,7 @@ import ChangeAdminPasswordApp from '../../apps/change-admin-password/ChangeAdmin import codeRoutes from '../../apps/code/routes'; import codingRulesRoutes from '../../apps/coding-rules/routes'; import componentMeasuresRoutes from '../../apps/component-measures/routes'; +import { dependenciesRoutes } from '../../apps/dependencies/routes'; import groupsRoutes from '../../apps/groups/routes'; import { globalIssuesRoutes, projectIssuesRoutes } from '../../apps/issues/routes'; import maintenanceRoutes from '../../apps/maintenance/routes'; @@ -122,6 +123,7 @@ function renderComponentRoutes() { element={<ProjectPageExtension />} /> {projectIssuesRoutes()} + {dependenciesRoutes()} <Route path="security_hotspots" element={<SecurityHotspotsApp />} /> {projectQualityGateRoutes()} {projectQualityProfilesRoutes()} diff --git a/server/sonar-web/src/main/js/apps/dependencies/DependenciesApp.tsx b/server/sonar-web/src/main/js/apps/dependencies/DependenciesApp.tsx new file mode 100644 index 00000000000..712383f65c2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/dependencies/DependenciesApp.tsx @@ -0,0 +1,128 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import styled from '@emotion/styled'; +import { Spinner, Text } from '@sonarsource/echoes-react'; +import { InputSearch, LargeCenteredLayout } from 'design-system'; +import React, { useState } from 'react'; +import { Helmet } from 'react-helmet-async'; +import { FormattedMessage } from 'react-intl'; +import withComponentContext from '../../app/components/componentContext/withComponentContext'; +import DocumentationLink from '../../components/common/DocumentationLink'; +import { DocLink } from '../../helpers/doc-links'; +import { translate } from '../../helpers/l10n'; +import { useCurrentBranchQuery } from '../../queries/branch'; +import { useDependenciesQuery } from '../../queries/dependencies'; +import { withRouter } from '../../sonar-aligned/components/hoc/withRouter'; +import { getBranchLikeQuery } from '../../sonar-aligned/helpers/branch-like'; +import { BranchLikeParameters } from '../../sonar-aligned/types/branch-like'; +import { Component } from '../../types/types'; +import DependencyListItem from './components/DependencyListItem'; + +const SEARCH_MIN_LENGTH = 3; + +interface Props { + component: Component; +} + +function App(props: Readonly<Props>) { + const { component } = props; + const { data: branchLike } = useCurrentBranchQuery(component); + + const [search, setSearch] = useState(''); + + const { data: { dependencies = [] } = {}, isLoading } = useDependenciesQuery({ + projectKey: component.key, + q: search, + branchParameters: getBranchLikeQuery(branchLike) as BranchLikeParameters, + }); + + const listName = search ? 'dependencies.list.name_search.title' : 'dependencies.list.title'; + + const resultsExist = dependencies.length > 0 || search.length >= SEARCH_MIN_LENGTH; + + return ( + <LargeCenteredLayout className="sw-py-8 sw-typo-lg sw-h-full" id="dependencies-page"> + <Helmet defer={false} title={translate('dependencies.page')} /> + <main className="sw-relative sw-flex-1 sw-min-w-0 sw-h-full"> + {resultsExist && ( + <div className="sw-flex sw-justify-between"> + <InputSearch + className="sw-mb-4" + searchInputAriaLabel={translate('search.search_for_dependencies')} + minLength={SEARCH_MIN_LENGTH} + value={search} + onChange={(value) => setSearch(value.toLowerCase())} + placeholder={translate('search.search_for_dependencies')} + size="large" + /> + </div> + )} + + <Spinner isLoading={isLoading}> + {dependencies.length === 0 && <EmptyState />} + {resultsExist && ( + <div className="sw-overflow-auto"> + <Text> + <FormattedMessage + id={listName} + defaultMessage={translate(listName)} + values={{ + count: dependencies.length, + }} + /> + </Text> + <ul className="sw-py-4"> + {dependencies.map((dependency) => ( + <li key={dependency.key}> + <DependencyListItem dependency={dependency} /> + </li> + ))} + </ul> + </div> + )} + </Spinner> + </main> + </LargeCenteredLayout> + ); +} + +function EmptyState() { + return ( + <CenteredDiv className="sw-w-[450px] sw-mt-[185px] sw-flex sw-flex-col sw-gap-4 sw-text-center sw-mx-auto"> + <Text isHighlighted>{translate('dependencies.empty_state.title')}</Text> + <Text>{translate('dependencies.empty_state.body')}</Text> + <Text> + <DocumentationLink + to={DocLink.Dependencies} + shouldOpenInNewTab + className="sw-font-semibold" + > + {translate('dependencies.empty_state.link_text')} + </DocumentationLink> + </Text> + </CenteredDiv> + ); +} + +const CenteredDiv = styled('div')` + height: 50vh; +`; + +export default withRouter(withComponentContext(App)); 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 new file mode 100644 index 00000000000..1e3526328d1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/dependencies/__tests__/DependenciesApp-it.tsx @@ -0,0 +1,224 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { byRole, byText } from '../../../sonar-aligned/helpers/testSelector'; + +import userEvent from '@testing-library/user-event'; +import { DEBOUNCE_DELAY } from 'design-system'; +import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; +import DependenciesServiceMock from '../../../api/mocks/DependenciesServiceMock'; +import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; +import { mockComponent } from '../../../helpers/mocks/component'; +import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; +import { DependenciesResponse } from '../../../types/dependencies'; +import { Component } from '../../../types/types'; +import routes from '../routes'; + +const depsHandler = new DependenciesServiceMock(); +const branchesHandler = new BranchesServiceMock(); +const settingsHandler = new SettingsServiceMock(); +const MOCK_RESPONSE: DependenciesResponse = { + page: { + pageIndex: 1, + pageSize: 100, + total: 4, + }, + dependencies: [ + { + key: '1', + name: 'jackson-databind', + longName: 'com.fasterxml.jackson.core:jackson-databind', + version: '2.10.0', + fixVersion: '2.12.13', + transitive: false, + findingsCount: 16, + findingsSeverities: { BLOCKER: 1, HIGH: 2, MEDIUM: 2, LOW: 2, INFO: 9 }, + findingsExploitableCount: 1, + project: 'project1', + }, + { + key: '2', + name: 'snappy-java', + longName: 'org.xerial.snappy:snappy-java', + version: '3.52', + fixVersion: '4.6.1', + transitive: true, + findingsCount: 2, + findingsSeverities: { LOW: 2 }, + findingsExploitableCount: 0, + project: 'project1', + }, + { + key: '3', + name: 'SnakeYAML', + longName: 'org.yaml:SnakeYAML', + version: '2.10.0', + transitive: true, + findingsCount: 3, + findingsSeverities: { INFO: 3 }, + findingsExploitableCount: 0, + project: 'project1', + }, + { + key: '4', + name: 'random-lib', + longName: 'com.random:random-lib', + version: '2.10.0', + transitive: true, + findingsCount: 0, + findingsSeverities: {}, + findingsExploitableCount: 0, + project: 'project1', + }, + ], +}; + +const MOCK_RESPONSE_NO_FINDINGS: DependenciesResponse = { + page: { + pageIndex: 1, + pageSize: 100, + 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', + }, + ], +}; + +beforeEach(() => { + branchesHandler.reset(); + depsHandler.reset(); + settingsHandler.reset(); +}); + +it('should correctly show an empty state', async () => { + const { ui } = getPageObject(); + renderDependenciesApp(); + + expect(await ui.emptyStateTitle.find()).toBeInTheDocument(); + expect(await ui.emptyStateLink.find()).toBeInTheDocument(); +}); + +it('should correctly render dependencies with findings', async () => { + depsHandler.setDefaultDependencies(MOCK_RESPONSE); + const { ui } = getPageObject(); + + renderDependenciesApp(); + + expect(await ui.dependencies.findAll()).toHaveLength(4); +}); + +it('should correctly render dependencies when no finding information is available', async () => { + depsHandler.setDefaultDependencies(MOCK_RESPONSE_NO_FINDINGS); + const { ui } = getPageObject(); + + renderDependenciesApp(); + + expect(await ui.dependencies.findAll()).toHaveLength(4); + expect(byText('dependencies.dependency.no_findings.label').query()).not.toBeInTheDocument(); +}); + +it('should correctly search for dependencies', async () => { + depsHandler.setDefaultDependencies(MOCK_RESPONSE_NO_FINDINGS); + const { ui, user } = getPageObject(); + + renderDependenciesApp(); + + expect(await ui.dependencies.findAll()).toHaveLength(4); + + user.type(ui.searchInput.get(), 'jackson'); + + // Wait for input debounce + await new Promise((resolve) => { + setTimeout(resolve, DEBOUNCE_DELAY); + }); + + expect(await ui.dependencies.findAll()).toHaveLength(1); +}); + +it('should correctly show empty results state when no dependencies are found', async () => { + depsHandler.setDefaultDependencies(MOCK_RESPONSE_NO_FINDINGS); + const { ui, user } = getPageObject(); + + renderDependenciesApp(); + + expect(await ui.dependencies.findAll()).toHaveLength(4); + + user.type(ui.searchInput.get(), 'asd'); + + // Wait for input debounce + await new Promise((resolve) => { + setTimeout(resolve, DEBOUNCE_DELAY); + }); + + expect(await ui.searchTitle.get()).toBeInTheDocument(); +}); + +function getPageObject() { + const user = userEvent.setup(); + const ui = { + emptyStateTitle: byText('dependencies.empty_state.title'), + emptyStateLink: byRole('link', { + name: /dependencies.empty_state.link_text/, + }), + dependencies: byRole('listitem'), + searchInput: byRole('searchbox'), + searchTitle: byText('dependencies.list.name_search.title0'), + }; + return { ui, user }; +} + +function renderDependenciesApp( + { navigateTo, component }: { component: Component; navigateTo?: string } = { + component: mockComponent(), + }, +) { + return renderAppWithComponentContext('dependencies', routes, { navigateTo }, { component }); +} diff --git a/server/sonar-web/src/main/js/apps/dependencies/components/DependencyListItem.tsx b/server/sonar-web/src/main/js/apps/dependencies/components/DependencyListItem.tsx new file mode 100644 index 00000000000..83ae36c7d84 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/dependencies/components/DependencyListItem.tsx @@ -0,0 +1,144 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { IconArrowRight, LinkStandalone, Text } from '@sonarsource/echoes-react'; +import { Card, Pill, PillHighlight, PillVariant } from 'design-system'; +import React from 'react'; + +import { FormattedMessage } from 'react-intl'; +import SoftwareImpactSeverityIcon from '../../../components/icon-mappers/SoftwareImpactSeverityIcon'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { isDefined } from '../../../helpers/types'; +import { Dependency } from '../../../types/dependencies'; + +function DependencyListItem({ dependency }: Readonly<{ dependency: Dependency }>) { + //TODO: Clean up once the response returns these values always + const findingsExist = isDefined(dependency.findingsCount); + const findingsCount = isDefined(dependency.findingsCount) ? dependency.findingsCount : 0; + const findingsExploitableCount = dependency.findingsExploitableCount ?? 0; + + const hasFindings = findingsCount > 0; + + return ( + <Card className="sw-p-3 sw-mb-4"> + <div className="sw-flex sw-justify-between sw-items-center"> + <div className="sw-flex sw-items-center"> + <span className="sw-w-[305px] sw-inline-flex sw-overflow-hidden"> + <span className="sw-flex-shrink sw-overflow-hidden sw-text-ellipsis sw-whitespace-nowrap"> + {hasFindings ? ( + <LinkStandalone + to={`/dependencies/${dependency.key}`} + className="sw-mr-2 sw-text-sm" + > + {dependency.name} + </LinkStandalone> + ) : ( + <Text isHighlighted isSubdued className="sw-mr-2"> + {dependency.name} + </Text> + )} + </span> + <Pill + variant={PillVariant.Accent} + highlight={PillHighlight.Medium} + className="sw-flex-shrink-0 sw-mr-2" + > + {dependency.transitive + ? translate('dependencies.direct.label') + : translate('dependencies.transitive.label')} + </Pill> + </span> + {findingsExist && ( + <span className="sw-flex"> + {hasFindings ? ( + <> + <Text isHighlighted className="sw-mr-2"> + {translateWithParameters( + 'dependencies.dependency.findings.label', + findingsCount, + )} + </Text> + {Object.entries(dependency.findingsSeverities || {}).map(([severity, count]) => ( + <span key={severity} className="sw-flex sw-items-center sw-mr-1"> + <SoftwareImpactSeverityIcon + severity={severity} + className="sw-mr-1" + width={16} + height={16} + /> + <Text>{count}</Text> + </span> + ))} + {findingsExploitableCount > 0 && ( + <Pill + variant={PillVariant.Danger} + highlight={PillHighlight.Medium} + className="sw-ml-2" + > + <FormattedMessage + id="dependencies.dependency.exploitable_findings.label" + defaultMessage={translate( + 'dependencies.dependency.exploitable_findings.label', + )} + values={{ + count: findingsExploitableCount, + }} + /> + </Pill> + )} + </> + ) : ( + <Text isSubdued>{translate('dependencies.dependency.no_findings.label')}</Text> + )} + </span> + )} + </div> + <div className="sw-flex sw-items-center"> + {isDefined(dependency.fixVersion) ? ( + <> + <Text className="sw-mr-1">{translate('dependencies.dependency.version.label')}</Text> + <Pill variant={PillVariant.Caution} highlight={PillHighlight.Medium}> + {dependency.version} + </Pill> + </> + ) : ( + <Pill variant={PillVariant.Neutral} highlight={PillHighlight.Medium}> + {dependency.version} + </Pill> + )} + + {isDefined(dependency.fixVersion) && ( + <> + <IconArrowRight /> + <Text className="sw-mr-1"> + {translate('dependencies.dependency.fix_version.label')} + </Text> + <Pill variant={PillVariant.Success} highlight={PillHighlight.Medium}> + {dependency.fixVersion} + </Pill> + </> + )} + </div> + </div> + </Card> + ); +} + +export default DependencyListItem; diff --git a/server/sonar-web/src/main/js/apps/dependencies/routes.tsx b/server/sonar-web/src/main/js/apps/dependencies/routes.tsx new file mode 100644 index 00000000000..27525c61107 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/dependencies/routes.tsx @@ -0,0 +1,27 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import React from 'react'; +import { Route } from 'react-router-dom'; +import DependenciesApp from './DependenciesApp'; + +export const dependenciesRoutes = () => <Route path="dependencies" element={<DependenciesApp />} />; + +export default dependenciesRoutes; diff --git a/server/sonar-web/src/main/js/helpers/doc-links.ts b/server/sonar-web/src/main/js/helpers/doc-links.ts index e02e3a1fafe..c142f5c32c4 100644 --- a/server/sonar-web/src/main/js/helpers/doc-links.ts +++ b/server/sonar-web/src/main/js/helpers/doc-links.ts @@ -89,6 +89,7 @@ export enum DocLink { SonarScannerMaven = '/analyzing-source-code/scanners/sonarscanner-for-maven/', SonarWayQualityGate = '/user-guide/quality-gates/#using-sonar-way-the-recommended-quality-gate', // to be confirmed Webhooks = '/project-administration/webhooks/', + Dependencies = '/project-administration/managing-dependencies/', } export const DocTitle = { diff --git a/server/sonar-web/src/main/js/queries/dependencies.ts b/server/sonar-web/src/main/js/queries/dependencies.ts new file mode 100644 index 00000000000..d74c3b57d4c --- /dev/null +++ b/server/sonar-web/src/main/js/queries/dependencies.ts @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { queryOptions } from '@tanstack/react-query'; +import { getDependencies } from '../api/dependencies'; +import { BranchLikeParameters } from '../sonar-aligned/types/branch-like'; +import { createQueryHook } 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({ + projectKey: data.projectKey, + q: data.q, + ...data.branchParameters, + }), + }); + }, +); diff --git a/server/sonar-web/src/main/js/sonar-aligned/types/branch-like.ts b/server/sonar-web/src/main/js/sonar-aligned/types/branch-like.ts index 3f075d402e7..f71497a9992 100644 --- a/server/sonar-web/src/main/js/sonar-aligned/types/branch-like.ts +++ b/server/sonar-web/src/main/js/sonar-aligned/types/branch-like.ts @@ -19,8 +19,13 @@ */ import { Status } from './common'; +/** + * For Web API V2, use BranchLikeParameters instead + */ export type BranchParameters = { branch?: string } | { pullRequest?: string }; +export type BranchLikeParameters = { branchKey?: string } | { pullRequestKey?: string }; + export type BranchLikeBase = BranchBase | PullRequestBase; export interface BranchBase { diff --git a/server/sonar-web/src/main/js/types/dependencies.ts b/server/sonar-web/src/main/js/types/dependencies.ts new file mode 100644 index 00000000000..0974ce79754 --- /dev/null +++ b/server/sonar-web/src/main/js/types/dependencies.ts @@ -0,0 +1,43 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { SoftwareImpactSeverity } from './clean-code-taxonomy'; +import { Paging } from './types'; + +export interface Dependency { + description?: string; + //TODO: Remove optional flag when findings are implemented + findingsCount?: number; + findingsExploitableCount?: number; + findingsSeverities?: FindingsSeverities; + fixVersion?: string; + key: string; + longName: string; + name: string; + project: string; + transitive: boolean; + version?: string; +} + +export interface DependenciesResponse { + dependencies: Dependency[]; + page: Paging; +} + +type FindingsSeverities = Partial<Record<SoftwareImpactSeverity, number>>; |