]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19489 Add coverage/new code highlights/labels to the code viewer
authorDavid Cho-Lerat <david.cho-lerat@sonarsource.com>
Thu, 8 Jun 2023 08:21:56 +0000 (10:21 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 9 Jun 2023 20:03:10 +0000 (20:03 +0000)
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx
server/sonar-web/src/main/js/helpers/code-viewer.ts [new file with mode: 0644]

index 92f3c466f86fe60a35e8eb174f32546401e5cfb2..4b2eda9d13d830398d7cdb4a8e7a9d944d899c29 100644 (file)
@@ -17,7 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { sortBy } from 'lodash';
+import { decorateWithUnderlineFlags } from '../../../helpers/code-viewer';
 import { isDefined } from '../../../helpers/types';
 import { ComponentQualifier } from '../../../types/component';
 import { ReviewHistoryElement, ReviewHistoryType } from '../../../types/security-hotspots';
@@ -138,19 +140,6 @@ export function createSnippets(params: {
   return hasSecondaryLocations ? ranges.sort((a, b) => a.start - b.start) : ranges;
 }
 
-function decorateWithUnderlineFlags(line: SourceLine, sourcesMap: LineMap) {
-  const previousLine = sourcesMap[line.line - 1];
-  const decoratedLine = { ...line };
-  if (isDefined(line.coverageStatus)) {
-    decoratedLine.coverageBlock =
-      line.coverageStatus === previousLine?.coverageStatus ? previousLine.coverageBlock : line.line;
-  }
-  if (line.isNew) {
-    decoratedLine.newCodeBlock = previousLine?.isNew ? previousLine.newCodeBlock : line.line;
-  }
-  return decoratedLine;
-}
-
 export function linesForSnippets(snippets: Snippet[], componentLines: LineMap) {
   return snippets.reduce<Array<{ snippet: SourceLine[]; sourcesMap: LineMap }>>((acc, snippet) => {
     const snippetSources = [];
index 9988a9f1903a1c825452091ebec1fa7631ed96ab..95ccad809f27d2ba072598029964ac4aa216988f 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { SonarCodeColorizer } from 'design-system/lib';
-import { noop } from 'lodash';
+
+import { SonarCodeColorizer } from 'design-system';
 import * as React from 'react';
 import { Button } from '../../components/controls/buttons';
+import { decorateWithUnderlineFlags } from '../../helpers/code-viewer';
 import { translate } from '../../helpers/l10n';
 import { BranchLike } from '../../types/branch-like';
 import { MetricKey } from '../../types/metrics';
@@ -28,6 +29,7 @@ import {
   Duplication,
   FlowLocation,
   Issue,
+  LineMap,
   LinearIssueLocation,
   SourceLine,
 } from '../../types/types';
@@ -36,7 +38,7 @@ import LineIssuesList from './components/LineIssuesList';
 import { getSecondaryIssueLocationsForLine } from './helpers/issueLocations';
 import { optimizeHighlightedSymbols, optimizeLocationMessage } from './helpers/lines';
 
-const EMPTY_ARRAY: any[] = [];
+const EMPTY_ARRAY: unknown[] = [];
 
 const ZERO_LINE = {
   code: '',
@@ -45,6 +47,11 @@ const ZERO_LINE = {
   line: 0,
 };
 
+interface State {
+  decoratedLinesMap: LineMap;
+  hoveredLine?: SourceLine;
+}
+
 interface Props {
   branchLike: BranchLike | undefined;
   displayAllIssues?: boolean;
@@ -68,6 +75,7 @@ interface Props {
   loadingSourcesBefore: boolean;
   loadSourcesAfter: () => void;
   loadSourcesBefore: () => void;
+  metricKey?: string;
   onIssueChange: (issue: Issue) => void;
   onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void;
   onIssuesClose: (line: SourceLine) => void;
@@ -78,21 +86,42 @@ interface Props {
   onSymbolClick: (symbols: string[]) => void;
   openIssuesByLine: { [line: number]: boolean };
   renderDuplicationPopup: (index: number, line: number) => React.ReactNode;
-  metricKey?: string;
   selectedIssue: string | undefined;
   sources: SourceLine[];
   symbolsByLine: { [line: number]: string[] };
 }
 
-export default class SourceViewerCode extends React.PureComponent<Props> {
+export default class SourceViewerCode extends React.PureComponent<Props, State> {
   firstUncoveredLineFound = false;
 
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      decoratedLinesMap: this.getDecoratedLinesMap(props.sources),
+      hoveredLine: undefined,
+    };
+  }
+
   componentDidUpdate(prevProps: Props) {
     if (this.props.metricKey !== prevProps.metricKey) {
       this.firstUncoveredLineFound = false;
     }
+
+    if (this.props.sources !== prevProps.sources) {
+      this.setState({
+        decoratedLinesMap: this.getDecoratedLinesMap(this.props.sources),
+      });
+    }
   }
 
+  getDecoratedLinesMap = (sources: SourceLine[]) =>
+    sources.reduce((map: LineMap, line: SourceLine) => {
+      map[line.line] = decorateWithUnderlineFlags(line, map);
+
+      return map;
+    }, {});
+
   getDuplicationsForLine = (line: SourceLine): number[] => {
     return this.props.duplicationsByLine[line.line] || EMPTY_ARRAY;
   };
@@ -105,38 +134,61 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
     return this.props.issueLocationsByLine[line.line] || EMPTY_ARRAY;
   };
 
+  onLineMouseEnter = (hoveredLineNumber: number) =>
+    this.setState(({ decoratedLinesMap }) => ({
+      hoveredLine: decoratedLinesMap[hoveredLineNumber],
+    }));
+
+  onLineMouseLeave = (leftLineNumber: number) =>
+    this.setState(({ hoveredLine }) => ({
+      hoveredLine: hoveredLine?.line === leftLineNumber ? undefined : hoveredLine,
+    }));
+
   renderLine = ({
-    line,
-    index,
     displayCoverage,
     displayDuplications,
     displayIssues,
+    index,
+    line,
   }: {
-    line: SourceLine;
-    index: number;
     displayCoverage: boolean;
     displayDuplications: boolean;
     displayIssues: boolean;
+    index: number;
+    line: SourceLine;
   }) => {
+    const { hoveredLine } = this.state;
+
     const {
-      highlightedLocationMessage,
-      selectedIssue,
-      openIssuesByLine,
-      issueLocationsByLine,
+      branchLike,
       displayAllIssues,
+      displayIssueLocationsCount,
+      displayIssueLocationsLink,
+      displayLocationMarkers,
+      duplications,
+      highlightedLine,
+      highlightedLocationMessage,
       highlightedLocations,
+      highlightedSymbols,
+      issueLocationsByLine,
+      issuePopup,
       metricKey,
+      openIssuesByLine,
+      selectedIssue,
       sources,
+      symbolsByLine,
     } = this.props;
 
     const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, highlightedLocations);
 
-    const duplicationsCount = this.props.duplications ? this.props.duplications.length : 0;
+    const duplicationsCount = duplications?.length ?? 0;
 
     const issuesForLine = this.getIssuesForLine(line);
-    const firstLineNumber = sources && sources.length ? sources[0].line : 0;
+
+    const firstLineNumber = sources?.length ? sources[0].line : 0;
 
     let scrollToUncoveredLine = false;
+
     if (
       !this.firstUncoveredLineFound &&
       displayCoverage &&
@@ -146,62 +198,69 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
       scrollToUncoveredLine =
         (metricKey === MetricKey.new_uncovered_lines && line.isNew) ||
         metricKey === MetricKey.uncovered_lines;
+
       this.firstUncoveredLineFound = scrollToUncoveredLine;
     }
 
+    const displayCoverageUnderline = !!(
+      hoveredLine?.coverageBlock && hoveredLine.coverageBlock === line.coverageBlock
+    );
+
     return (
       <Line
-        displayAllIssues={this.props.displayAllIssues}
-        displayNewCodeUnderline={false}
-        displayCoverageUnderline={false}
-        onLineMouseEnter={noop}
-        onLineMouseLeave={noop}
+        displayAllIssues={displayAllIssues}
         displayCoverage={displayCoverage}
+        displayCoverageUnderline={displayCoverageUnderline}
         displayDuplications={displayDuplications}
         displayIssues={displayIssues}
-        displayLocationMarkers={this.props.displayLocationMarkers}
+        displayLocationMarkers={displayLocationMarkers}
+        displayNewCodeUnderline={hoveredLine?.newCodeBlock === line.line}
         displaySCM={sources.length > 0}
         duplications={this.getDuplicationsForLine(line)}
         duplicationsCount={duplicationsCount}
         firstLineNumber={firstLineNumber}
-        highlighted={line.line === this.props.highlightedLine}
+        highlighted={line.line === highlightedLine}
         highlightedLocationMessage={optimizeLocationMessage(
           highlightedLocationMessage,
           secondaryIssueLocations
         )}
         highlightedSymbols={optimizeHighlightedSymbols(
-          this.props.symbolsByLine[line.line],
-          this.props.highlightedSymbols
+          symbolsByLine[line.line],
+          highlightedSymbols
         )}
         issueLocations={this.getIssueLocationsForLine(line)}
         issues={issuesForLine}
         key={line.line || line.code}
         line={line}
         loadDuplications={this.props.loadDuplications}
-        onIssueSelect={this.props.onIssueSelect}
-        onIssueUnselect={this.props.onIssueUnselect}
         onIssuesClose={this.props.onIssuesClose}
+        onIssueSelect={this.props.onIssueSelect}
         onIssuesOpen={this.props.onIssuesOpen}
+        onIssueUnselect={this.props.onIssueUnselect}
+        onLineMouseEnter={this.onLineMouseEnter}
+        onLineMouseLeave={this.onLineMouseLeave}
         onLocationSelect={this.props.onLocationSelect}
         onSymbolClick={this.props.onSymbolClick}
-        openIssues={this.props.openIssuesByLine[line.line] || false}
+        openIssues={openIssuesByLine[line.line] || false}
         previousLine={index > 0 ? sources[index - 1] : undefined}
         renderDuplicationPopup={this.props.renderDuplicationPopup}
         scrollToUncoveredLine={scrollToUncoveredLine}
         secondaryIssueLocations={secondaryIssueLocations}
       >
         <LineIssuesList
-          displayWhyIsThisAnIssue
+          branchLike={branchLike}
           displayAllIssues={displayAllIssues}
+          displayIssueLocationsCount={displayIssueLocationsCount}
+          displayIssueLocationsLink={displayIssueLocationsLink}
+          displayWhyIsThisAnIssue
           issueLocationsByLine={issueLocationsByLine}
+          issuePopup={issuePopup}
           issuesForLine={issuesForLine}
           line={line}
-          openIssuesByLine={openIssuesByLine}
-          branchLike={this.props.branchLike}
-          issuePopup={this.props.issuePopup}
           onIssueChange={this.props.onIssueChange}
           onIssueClick={this.props.onIssueSelect}
           onIssuePopupToggle={this.props.onIssuePopupToggle}
+          openIssuesByLine={openIssuesByLine}
           selectedIssue={selectedIssue}
         />
       </Line>
@@ -209,7 +268,16 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
   };
 
   render() {
-    const { issues = [], sources } = this.props;
+    const { decoratedLinesMap } = this.state;
+
+    const {
+      hasSourcesAfter,
+      hasSourcesBefore,
+      issues = [],
+      loadingSourcesAfter,
+      loadingSourcesBefore,
+      sources,
+    } = this.props;
 
     const displayCoverage = sources.some((s) => s.coverageStatus != null);
     const displayDuplications = sources.some((s) => !!s.duplicated);
@@ -220,9 +288,9 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
     return (
       <SonarCodeColorizer>
         <div className="it__source-viewer-code">
-          {this.props.hasSourcesBefore && (
+          {hasSourcesBefore && (
             <div className="source-viewer-more-code">
-              {this.props.loadingSourcesBefore ? (
+              {loadingSourcesBefore ? (
                 <div className="js-component-viewer-loading-before">
                   <i className="spinner" />
                   <span className="note spacer-left">
@@ -244,27 +312,27 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
             <tbody>
               {hasFileIssues &&
                 this.renderLine({
-                  line: ZERO_LINE,
-                  index: -1,
                   displayCoverage,
                   displayDuplications,
                   displayIssues,
+                  index: -1,
+                  line: ZERO_LINE,
                 })}
               {sources.map((line, index) =>
                 this.renderLine({
-                  line,
-                  index,
                   displayCoverage,
                   displayDuplications,
                   displayIssues,
+                  index,
+                  line: decoratedLinesMap[line.line] || line,
                 })
               )}
             </tbody>
           </table>
 
-          {this.props.hasSourcesAfter && (
+          {hasSourcesAfter && (
             <div className="source-viewer-more-code">
-              {this.props.loadingSourcesAfter ? (
+              {loadingSourcesAfter ? (
                 <div className="js-component-viewer-loading-after">
                   <i className="spinner" />
                   <span className="note spacer-left">
index 731a881f3a2eafbff8ddcc1f50b984ced2951de1..3f11fccd9d1aa79909ee2ccc40aba6ac6524f136 100644 (file)
@@ -172,7 +172,7 @@ it('should be able to interact with issue action', async () => {
   ).toBeInTheDocument();
 });
 
-it('should load line when looking arround unloaded line', async () => {
+it('should load line when looking around unloaded line', async () => {
   const rerender = renderSourceViewer({
     aroundLine: 50,
     component: componentsHandler.getHugeFileKey(),
index bfd4403d196d3aa4d51a4b0013e6d70976cd4074..9a725c5523a0e922fa0352f1ca6d6a3768f5d96a 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import classNames from 'classnames';
 import { LineCoverage, LineMeta, LineNumber, LineWrapper } from 'design-system';
 import { times } from 'lodash';
@@ -75,7 +76,7 @@ export default function Line(props: LineProps) {
     displayAllIssues,
     displayCoverage,
     displayDuplications,
-    displayLineNumberOptions,
+    displayLineNumberOptions = true,
     displayLocationMarkers,
     highlightedLocationMessage,
     displayNewCodeUnderline,
@@ -125,9 +126,6 @@ export default function Line(props: LineProps) {
     [line.line, onLineMouseLeave]
   );
 
-  // default is true
-  const displayOptions = displayLineNumberOptions !== false;
-
   const { branchLike, file } = useSourceViewerContext();
   const permalink = getPathUrlAsString(
     getCodeUrl(file.project, branchLike, file.key, line.line),
@@ -171,7 +169,7 @@ export default function Line(props: LineProps) {
       className={classNames('it__source-line', { 'it__source-line-filtered': line.isNew })}
     >
       <LineNumber
-        displayOptions={displayOptions}
+        displayOptions={displayLineNumberOptions}
         firstLineNumber={firstLineNumber}
         lineNumber={line.line}
         ariaLabel={translateWithParameters('source_viewer.line_X', line.line)}
@@ -179,6 +177,7 @@ export default function Line(props: LineProps) {
       />
 
       {displaySCM && <LineSCM line={line} previousLine={previousLine} />}
+
       {displayIssues && !displayAllIssues ? (
         <LineIssuesIndicator
           issues={issues}
@@ -224,6 +223,7 @@ export default function Line(props: LineProps) {
           coverageStatus={line.coverageStatus}
         />
       )}
+
       <LineCode
         displayCoverageUnderline={displayCoverage && displayCoverageUnderline}
         displayLocationMarkers={displayLocationMarkers}
diff --git a/server/sonar-web/src/main/js/helpers/code-viewer.ts b/server/sonar-web/src/main/js/helpers/code-viewer.ts
new file mode 100644 (file)
index 0000000..1a4424f
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 type { LineMap, SourceLine } from '../types/types';
+
+export function decorateWithUnderlineFlags(line: SourceLine, sourcesMap: LineMap) {
+  const previousLine: SourceLine | undefined = sourcesMap[line.line - 1];
+
+  const decoratedLine = { ...line };
+
+  if (line.coverageStatus) {
+    decoratedLine.coverageBlock =
+      line.coverageStatus === previousLine?.coverageStatus
+        ? previousLine.coverageBlock ?? line.line
+        : line.line;
+  }
+
+  if (line.isNew) {
+    decoratedLine.newCodeBlock = previousLine?.isNew
+      ? previousLine.newCodeBlock ?? line.line
+      : line.line;
+  }
+
+  return decoratedLine;
+}