]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23098 Introduce Project Dependencies tab
authorLucas <97296331+lucas-paulger-sonarsource@users.noreply.github.com>
Tue, 15 Oct 2024 07:36:28 +0000 (10:36 +0300)
committersonartech <sonartech@sonarsource.com>
Mon, 21 Oct 2024 20:04:00 +0000 (20:04 +0000)
15 files changed:
server/sonar-web/design-system/src/components/Pill.tsx
server/sonar-web/design-system/src/theme/light.ts
server/sonar-web/src/main/js/api/dependencies.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/DependenciesServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/apps/dependencies/DependenciesApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/dependencies/__tests__/DependenciesApp-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/dependencies/components/DependencyListItem.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/dependencies/routes.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/doc-links.ts
server/sonar-web/src/main/js/queries/dependencies.ts [new file with mode: 0644]
server/sonar-web/src/main/js/sonar-aligned/types/branch-like.ts
server/sonar-web/src/main/js/types/dependencies.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 3f53c3473c57e505413eb88f5599972645b8a30a..bcd2b2f246cc7c9ac34b6e4d2f399f14be6811ce 100644 (file)
@@ -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<{
index f885de25948470b30881468db8e2c225d0a5dacf..f9c0aaa006fbc40da7a14f73a869e066100262cb 100644 (file)
@@ -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 (file)
index 0000000..9449805
--- /dev/null
@@ -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 (file)
index 0000000..15578b9
--- /dev/null
@@ -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()),
+      ),
+    });
+  };
+}
index 6b06dd7ea5b8d2fd478919804f38a94a57fc1184..092f2dd6f57ad0103a8f8597c92b56e098f7e790 100644 (file)
@@ -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()}
index df15beb3d0451579cdce61aff768d7cebc519176..605c7910ce1969ee1739e4a7259e36285548fe9b 100644 (file)
@@ -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 (file)
index 0000000..712383f
--- /dev/null
@@ -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 (file)
index 0000000..1e35263
--- /dev/null
@@ -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 (file)
index 0000000..83ae36c
--- /dev/null
@@ -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 (file)
index 0000000..27525c6
--- /dev/null
@@ -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;
index e02e3a1fafe5ead1ff9fcc85942ccbe2df5068b1..c142f5c32c4bc3f3b8b84ef577310f5de53be99a 100644 (file)
@@ -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 (file)
index 0000000..d74c3b5
--- /dev/null
@@ -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,
+        }),
+    });
+  },
+);
index 3f075d402e74efa07cfcf8f07029f9103af0be08..f71497a99921a6cc82190c75e37b97436153b102 100644 (file)
  */
 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 (file)
index 0000000..0974ce7
--- /dev/null
@@ -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>>;
index 9c8e346cb22f87dbe11d5baf2d5709661d6d1735..b0e96baa0bee7db9f543195c7fcf80f4f1aa4c1f 100644 (file)
@@ -598,6 +598,7 @@ layout.logout=Log out
 layout.measures=Measures
 layout.settings=Administration
 layout.security_hotspots=Security Hotspots
+layout.dependencies=Dependencies
 layout.settings.TRK=Project Settings
 layout.settings.APP=Application Settings
 layout.settings.VW=Portfolio Settings
@@ -773,6 +774,7 @@ quality_gates.page=Quality Gates
 issues.page=Issues
 issues.skip_to_filters=Skip to issue filters
 issues.skip_to_list=Skip to issues list
+dependencies.page=Dependencies
 view_projects.page=Projects
 portfolios.page=Portfolios
 portfolio_breakdown.page=Portfolio Breakdown
@@ -1276,6 +1278,25 @@ issue_bulk_change.select_tags=Select tags
 issue_bulk_change.selected_tags=Selected tags
 issue_bulk_change.resolution_comment=Resolution comment
 
+#------------------------------------------------------------------------------
+#
+# DEPENDENCIES PAGE
+#
+#------------------------------------------------------------------------------
+
+dependencies.list.title={count} {count, plural, one {dependency} other {dependencies}}
+dependencies.list.name_search.title={count} matching {count, plural, one {dependency} other {dependencies}}
+dependencies.empty_state.title=There are no dependencies on this project
+dependencies.empty_state.body=When you analyze 3rd party code dependencies you will see them be displayed here along with any vulnerabilities they may raise
+dependencies.empty_state.link_text=Learn more about dependency analysis
+dependencies.transitive.label=Transitive
+dependencies.direct.label=Direct
+dependencies.dependency.findings.label={0} findings
+dependencies.dependency.exploitable_findings.label={count} exploitable {count, plural, one {finding} other {findings}}
+dependencies.dependency.no_findings.label=No findings
+dependencies.dependency.version.label=version
+dependencies.dependency.fix_version.label=fix with
+
 #------------------------------------------------------------------------------
 #
 # PROJECTS PAGE
@@ -1909,6 +1930,7 @@ search.shortcut_hint=Hint: Press {shortcut} from anywhere to open this search ba
 search.show_more.hint=Press {key} to display
 search.placeholder=Search for projects...
 search.search_for_projects=Search for projects...
+search.search_for_dependencies=Search for dependencies...
 search.search_for_members=Search for members...
 search.search_for_users=Search for users...
 search.search_for_users_or_groups=Search for users or groups...