]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17386 Improve UI of closed issues
authorJeremy Davis <jeremy.davis@sonarsource.com>
Wed, 2 Nov 2022 11:10:36 +0000 (12:10 +0100)
committersonartech <sonartech@sonarsource.com>
Fri, 4 Nov 2022 20:03:11 +0000 (20:03 +0000)
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetGroupViewer-test.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewer-test.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetGroupViewer-test.tsx.snap
server/sonar-web/src/main/js/types/issues.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 1209fa02ba1c65c5eefd708027162b87741e7b39..d0ec15e8d2aebecea5e4eed403cc2280fdb75b44 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
 import { getSources } from '../../../api/components';
 import IssueMessageBox from '../../../components/issue/IssueMessageBox';
 import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
+import { Alert } from '../../../components/ui/Alert';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
+import { translate } from '../../../helpers/l10n';
 import { BranchLike } from '../../../types/branch-like';
-import { isFile } from '../../../types/component';
+import { ComponentQualifier, isFile } from '../../../types/component';
+import { IssueStatus } from '../../../types/issues';
 import {
   Dict,
   Duplication,
@@ -265,28 +269,55 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
     const isFlow = issue.secondaryLocations.length === 0;
     const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
 
+    const issueIsClosed = issue.status === IssueStatus.Closed;
+    const issueIsFileLevel = issue.componentQualifier === ComponentQualifier.File;
+    const closedIssueMessageKey = issueIsFileLevel
+      ? 'issue.closed.file_level'
+      : 'issue.closed.project_level';
+
     return (
       <>
+        {issueIsClosed && (
+          <Alert variant="success">
+            <FormattedMessage
+              id={closedIssueMessageKey}
+              defaultMessage={translate(closedIssueMessageKey)}
+              values={{
+                status: (
+                  <strong>
+                    {translate('issue.status', issue.status)} (
+                    {issue.resolution ? translate('issue.resolution', issue.resolution) : '-'})
+                  </strong>
+                ),
+              }}
+            />
+          </Alert>
+        )}
+
         <IssueSourceViewerHeader
           branchLike={branchLike}
+          className={issueIsClosed && !issueIsFileLevel ? 'null-spacer-bottom' : ''}
           expandable={!fullyShown && isFile(snippetGroup.component.q)}
           loading={loading}
           onExpand={this.expandComponent}
           sourceViewerFile={snippetGroup.component}
         />
 
-        {issue.component === snippetGroup.component.key && issue.textRange === undefined && (
-          <IssueSourceViewerScrollContext.Consumer>
-            {(ctx) => (
-              <IssueMessageBox
-                selected={true}
-                issue={issue}
-                onClick={this.props.onIssueSelect}
-                ref={ctx?.registerPrimaryLocationRef}
-              />
-            )}
-          </IssueSourceViewerScrollContext.Consumer>
-        )}
+        {issue.component === snippetGroup.component.key &&
+          issue.textRange === undefined &&
+          !issueIsClosed && (
+            <IssueSourceViewerScrollContext.Consumer>
+              {(ctx) => (
+                <IssueMessageBox
+                  selected={true}
+                  issue={issue}
+                  onClick={this.props.onIssueSelect}
+                  ref={ctx?.registerPrimaryLocationRef}
+                />
+              )}
+            </IssueSourceViewerScrollContext.Consumer>
+          )}
+
         {snippetLines.map((snippet, index) => (
           <SnippetViewer
             key={snippets[index].index}
index 395342ee5ef6cfecd9d8c4e1d65bd6795b7ff1ba..dc3d78c0026b6721f3f6d170d41e7c292dfdd049 100644 (file)
@@ -41,6 +41,7 @@ import { translate } from '../../../helpers/l10n';
 import { HttpStatus } from '../../../helpers/request';
 import { BranchLike } from '../../../types/branch-like';
 import { isFile } from '../../../types/component';
+import { IssueStatus } from '../../../types/issues';
 import {
   Dict,
   DuplicatedFile,
@@ -122,7 +123,8 @@ export default class CrossComponentSourceViewer extends React.PureComponent<Prop
     this.setState({ loading: true });
 
     try {
-      const components = await getIssueFlowSnippets(issue.key);
+      const components =
+        issue.status === IssueStatus.Closed ? {} : await getIssueFlowSnippets(issue.key);
       if (components[issue.component] === undefined) {
         const issueComponent = await getComponentForSourceViewer({
           component: issue.component,
@@ -139,6 +141,7 @@ export default class CrossComponentSourceViewer extends React.PureComponent<Prop
           components[issue.component].sources = sources;
         }
       }
+
       if (this.mounted) {
         this.setState({
           components,
index b8457c447b2baca97e5044bb2f7fefb66a5c79fd..f0277a53e5f567bd99bbd18e7c1c30492a0711f1 100644 (file)
@@ -36,6 +36,7 @@ import './IssueSourceViewerHeader.css';
 
 export interface Props {
   branchLike: BranchLike | undefined;
+  className?: string;
   expandable?: boolean;
   displayProjectName?: boolean;
   linkToProject?: boolean;
@@ -47,6 +48,7 @@ export interface Props {
 export default function IssueSourceViewerHeader(props: Props) {
   const {
     branchLike,
+    className,
     expandable,
     displayProjectName = true,
     linkToProject = true,
@@ -66,7 +68,10 @@ export default function IssueSourceViewerHeader(props: Props) {
 
   return (
     <div
-      className="issue-source-viewer-header display-flex-row display-flex-space-between"
+      className={classNames(
+        'issue-source-viewer-header display-flex-row display-flex-space-between',
+        className
+      )}
       role="separator"
       aria-label={sourceViewerFile.path}
     >
index 9a4cf4441bb465505a32fc5cdf357f5769952464..089f87488357d887d75277ebdcb40e02d7155e21 100644 (file)
@@ -20,6 +20,7 @@
 import { shallow } from 'enzyme';
 import { range, times } from 'lodash';
 import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
 import { getSources } from '../../../../api/components';
 import IssueMessageBox from '../../../../components/issue/IssueMessageBox';
 import { mockBranch, mockMainBranch } from '../../../../helpers/mocks/branch-like';
@@ -30,6 +31,8 @@ import {
 } from '../../../../helpers/mocks/sources';
 import { mockFlowLocation, mockIssue } from '../../../../helpers/testMocks';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
+import { ComponentQualifier } from '../../../../types/component';
+import { IssueStatus } from '../../../../types/issues';
 import { SnippetGroup } from '../../../../types/types';
 import ComponentSourceSnippetGroupViewer from '../ComponentSourceSnippetGroupViewer';
 import SnippetViewer from '../SnippetViewer';
@@ -147,6 +150,36 @@ it('should render file-level issue correctly', () => {
   expect(wrapper.find('ContextConsumer').dive().find(IssueMessageBox).exists()).toBe(true);
 });
 
+it.each([
+  ['file-level', ComponentQualifier.File, 'issue.closed.file_level'],
+  ['project-level', ComponentQualifier.Project, 'issue.closed.project_level'],
+])(
+  'should render a closed %s issue correctly',
+  async (_level, componentQualifier, expectedLabel) => {
+    // issue with secondary locations and no primary location
+    const issue = mockIssue(true, {
+      component: 'project:main.js',
+      componentQualifier,
+      flows: [],
+      textRange: undefined,
+      status: IssueStatus.Closed,
+    });
+
+    const wrapper = shallowRender({
+      issue,
+      snippetGroup: {
+        locations: [],
+        ...mockSnippetsByComponent('main.js', 'project', range(1, 10)),
+      },
+    });
+
+    await waitAndUpdate(wrapper);
+
+    expect(wrapper.find<FormattedMessage>(FormattedMessage).prop('id')).toEqual(expectedLabel);
+    expect(wrapper.find('ContextConsumer').exists()).toBe(false);
+  }
+);
+
 it('should expand block', async () => {
   (getSources as jest.Mock).mockResolvedValueOnce(
     Object.values(mockSnippetsByComponent('a', 'project', range(6, 59)).sources)
index 711cf415e54f266d777cce316006d5dc13edd631..c5735a79fb3ae05e0aee52127f0237bf7df346f4 100644 (file)
@@ -28,6 +28,7 @@ import {
 } from '../../../../helpers/mocks/sources';
 import { mockFlowLocation, mockIssue } from '../../../../helpers/testMocks';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
+import { IssueStatus } from '../../../../types/issues';
 import ComponentSourceSnippetGroupViewer from '../ComponentSourceSnippetGroupViewer';
 import CrossComponentSourceViewer from '../CrossComponentSourceViewer';
 
@@ -74,6 +75,13 @@ it('Should fetch data', async () => {
   expect(getIssueFlowSnippets).toHaveBeenCalledWith('foo');
 });
 
+it('Should handle a closed issue', async () => {
+  const wrapper = shallowRender({ issue: mockIssue(true, { status: IssueStatus.Closed }) });
+  wrapper.instance().fetchIssueFlowSnippets();
+  await waitAndUpdate(wrapper);
+  expect(getIssueFlowSnippets).not.toHaveBeenCalled();
+});
+
 it('Should handle no access rights', async () => {
   (getIssueFlowSnippets as jest.Mock).mockRejectedValueOnce({ status: 403 });
 
index d94ab952f2d378cc69f894071705b74ccb61a971..0e8c55bdb00c49461d76a4e9474e9cf433095ac6 100644 (file)
@@ -32,6 +32,14 @@ export enum IssueScope {
   Test = 'TEST',
 }
 
+export enum IssueStatus {
+  Open = 'OPEN',
+  Confirmed = 'CONFIRMED',
+  Reopened = 'REOPENED',
+  Resolved = 'RESOLVED',
+  Closed = 'CLOSED',
+}
+
 interface Comment {
   createdAt: string;
   htmlText: string;
index 2acd0079329e4c084095545b2f37c7aebc6c2848..ae40c64092df04d7f6a84f76ae68a972260a45d9 100644 (file)
@@ -876,6 +876,8 @@ issue.transition.resetastoreview.description=The Security Hotspot should be anal
 issue.tabs.code=Where is the issue?
 issue.x_data_flows={0} data flow(s)
 issue.execution_flow=Full execution flow
+issue.closed.file_level=This issue is {status}. It was detected in the file below and is no longer being detected.
+issue.closed.project_level=This issue is {status}. It was detected in the project below and is no longer being detected.
 
 issues.action_select=Select issue
 issues.action_select.label=Select issue {0}