]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23531 Display severities in issue activity log based on current mode
authorIsmail Cherri <ismail.cherri@sonarsource.com>
Tue, 5 Nov 2024 16:11:15 +0000 (17:11 +0100)
committersonartech <sonartech@sonarsource.com>
Mon, 11 Nov 2024 20:02:44 +0000 (20:02 +0000)
server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesAppActivity-it.tsx
server/sonar-web/src/main/js/apps/issues/components/IssueReviewHistory.tsx
server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx
server/sonar-web/src/main/js/components/issue/components/IssueTransition.tsx
server/sonar-web/src/main/js/queries/issues.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 55c970a84e94710167bce975bf77be0fef083ca1..d6f19cfa92b6208813a7722b1ac43fe7f39f208d 100644 (file)
@@ -36,6 +36,7 @@ import {
   ASSIGNEE_ME,
   IssueDeprecatedStatus,
   IssueResolution,
+  IssueSeverity,
   IssueStatus,
   IssueTransition,
   IssueType,
@@ -689,6 +690,26 @@ export default class IssuesServiceMock {
             },
           ],
         }),
+        mockIssueChangelog({
+          creationDate: '2018-12-01',
+          diffs: [
+            {
+              key: 'severity',
+              newValue: IssueSeverity.Blocker,
+              oldValue: IssueSeverity.Major,
+            },
+          ],
+        }),
+        mockIssueChangelog({
+          creationDate: '2018-12-02',
+          diffs: [
+            {
+              key: 'impactSeverity',
+              newValue: `${SoftwareQuality.Maintainability}:${SoftwareImpactSeverity.Blocker}`,
+              oldValue: `${SoftwareQuality.Maintainability}:${SoftwareImpactSeverity.High}`,
+            },
+          ],
+        }),
       ],
     });
   };
index d3b7f186f71f57b56dbeb8efba87d1c1b8a150d9..adb6c9a2cd8febeb18dbfdb544ec937fdb6e3c5c 100644 (file)
@@ -22,11 +22,13 @@ import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import React from 'react';
 import { mockRestUser } from '../../../helpers/testMocks';
+import { SettingsKey } from '../../../types/settings';
 import {
   branchHandler,
   componentsHandler,
   issuesHandler,
   renderIssueApp,
+  settingsHandler,
   ui,
   usersHandler,
 } from '../test-utils';
@@ -62,6 +64,7 @@ beforeEach(() => {
   componentsHandler.reset();
   branchHandler.reset();
   usersHandler.reset();
+  settingsHandler.reset();
   usersHandler.users = [
     mockRestUser({
       login: 'bob.marley',
@@ -114,7 +117,15 @@ it('should be able to add or update comment', async () => {
   expect(screen.queryByText('activity comment new')).not.toBeInTheDocument();
 });
 
-it('should be able to show changelog', async () => {
+it.each([
+  ['MQR mode', 'true', 'issue.changelog.impactSeverity.MAINTAINABILITY.BLOCKER.HIGH'],
+  [
+    'Standard mode',
+    'false',
+    'issue.changelog.changed_to.issue.changelog.field.severity.BLOCKER (issue.changelog.was.MAJOR)',
+  ],
+])('should be able to show changelog in %s', async (_, mode, message) => {
+  settingsHandler.set(SettingsKey.MQRMode, mode);
   const user = userEvent.setup();
   issuesHandler.setIsAdmin(true);
   renderIssueApp();
@@ -139,6 +150,8 @@ it('should be able to show changelog', async () => {
       'issue.changelog.changed_to.issue.changelog.field.issueStatus.ACCEPTED (issue.changelog.was.OPEN)',
     ),
   ).toBeInTheDocument();
+  // Severities and Software Quality Severities
+  expect(screen.getByText(message)).toBeInTheDocument();
   expect(
     screen.queryByText(
       'issue.changelog.changed_to.issue.changelog.field.status.RESOLVED (issue.changelog.was.REOPENED)',
index 28bcc0adb64bffbff3246f5e968cce9d0beb046e..bc3ac902a2ca4d52b26b6c5b482beb8a1db5f260 100644 (file)
  */
 
 import styled from '@emotion/styled';
+import { Button, ButtonVariety, Spinner, Text } from '@sonarsource/echoes-react';
 import * as React from 'react';
 import {
-  DangerButtonPrimary,
   DestructiveIcon,
   HtmlFormatter,
   InteractiveIcon,
-  LightLabel,
   Modal,
   PencilIcon,
   SafeHTMLInjection,
@@ -33,11 +32,12 @@ import {
   TrashIcon,
   themeBorder,
 } from '~design-system';
-import { getIssueChangelog } from '../../../api/issues';
 import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
 import IssueChangelogDiff from '../../../components/issue/components/IssueChangelogDiff';
 import Avatar from '../../../components/ui/Avatar';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { useIssueChangelogQuery } from '../../../queries/issues';
+import { useStandardExperienceMode } from '../../../queries/settings';
 import { ReviewHistoryType } from '../../../types/security-hotspots';
 import { Issue, IssueChangelog } from '../../../types/types';
 import HotspotCommentModal from '../../security-hotspots/components/HotspotCommentModal';
@@ -49,133 +49,139 @@ export interface HotspotReviewHistoryProps {
   onEditComment: (key: string, comment: string) => void;
 }
 
-const getUpdatedChangelog = ({ changelog }: { changelog: IssueChangelog[] }) =>
+const getUpdatedChangelog = (
+  { changelog }: { changelog: IssueChangelog[] },
+  isStandardMode = false,
+): IssueChangelog[] =>
   changelog.map((changelogItem) => {
     const diffHasIssueStatusChange = changelogItem.diffs.some((diff) => diff.key === 'issueStatus');
 
+    const filteredDiffs = changelogItem.diffs.filter((diff) => {
+      if (diffHasIssueStatusChange && ['resolution', 'status'].includes(diff.key)) {
+        return false;
+      }
+      return isStandardMode ? diff.key !== 'impactSeverity' : diff.key !== 'severity';
+    });
+
     return {
       ...changelogItem,
-      // If the diff is an issue status change, we remove deprecated status and resolution diffs
-      diffs: changelogItem.diffs.filter(
-        (diff) => !(diffHasIssueStatusChange && ['resolution', 'status'].includes(diff.key)),
-      ),
+      diffs: filteredDiffs,
     };
   });
 
 export default function IssueReviewHistory(props: Readonly<HotspotReviewHistoryProps>) {
   const { issue } = props;
-  const [changeLog, setChangeLog] = React.useState<IssueChangelog[]>([]);
-  const history = useGetIssueReviewHistory(issue, changeLog);
+  const { data: isStandardMode } = useStandardExperienceMode();
   const [editCommentKey, setEditCommentKey] = React.useState('');
   const [deleteCommentKey, setDeleteCommentKey] = React.useState('');
-
-  React.useEffect(() => {
-    getIssueChangelog(issue.key).then(
-      ({ changelog }) => {
-        const updatedChangelog = getUpdatedChangelog({ changelog });
-
-        setChangeLog(updatedChangelog);
-      },
-      () => {},
-    );
-  }, [issue]);
+  const { data: changelog = [], isLoading } = useIssueChangelogQuery(issue.key, {
+    select: (data) => getUpdatedChangelog(data, isStandardMode),
+  });
+  const history = useGetIssueReviewHistory(issue, changelog);
 
   return (
-    <ul>
-      {history.map((historyElt, historyIndex) => {
-        const { user, type, diffs, date, html, key, updatable, markdown } = historyElt;
-        return (
-          <li className="sw-p-2 sw-typo-default" key={historyIndex}>
-            <div className="sw-typo-semibold sw-mb-1">
-              <DateTimeFormatter date={date} />
-            </div>
-
-            <LightLabel as="div" className="sw-flex sw-gap-2">
-              {user.name && (
-                <div className="sw-flex sw-items-center sw-gap-1">
-                  <Avatar hash={user.avatar} name={user.name} size="xs" />
-                  <span className="sw-typo-semibold">
-                    {user.active ? user.name : translateWithParameters('user.x_deleted', user.name)}
-                  </span>
-                </div>
-              )}
-
-              {type === ReviewHistoryType.Creation &&
-                translate('issue.activity.review_history.created')}
-
-              {type === ReviewHistoryType.Comment &&
-                translate('issue.activity.review_history.comment_added')}
-            </LightLabel>
-
-            {type === ReviewHistoryType.Diff && diffs && (
-              <div className="sw-mt-2">
-                {diffs.map((diff, diffIndex) => (
-                  <IssueChangelogDiff diff={diff} key={diffIndex} />
-                ))}
+    <Spinner isLoading={isLoading}>
+      <ul>
+        {history.map(({ user, type, diffs, date, html, key, updatable, markdown }) => {
+          return (
+            <li className="sw-p-2 sw-typo-default" key={`${user.name}${type}${date}`}>
+              <div className="sw-typo-semibold sw-mb-1">
+                <DateTimeFormatter date={date} />
               </div>
-            )}
-
-            {type === ReviewHistoryType.Comment && key && html && markdown && (
-              <div className="sw-mt-2 sw-flex sw-justify-between">
-                <SafeHTMLInjection htmlAsString={html} sanitizeLevel={SanitizeLevel.USER_INPUT}>
-                  <CommentBox className="sw-pl-2 sw-ml-2 sw-typo-default" />
-                </SafeHTMLInjection>
-
-                {updatable && (
-                  <div className="sw-flex sw-gap-6">
-                    <InteractiveIcon
-                      Icon={PencilIcon}
-                      aria-label={translate('issue.comment.edit')}
-                      onClick={() => setEditCommentKey(key)}
-                      size="small"
-                      stopPropagation={false}
-                    />
 
-                    <DestructiveIcon
-                      Icon={TrashIcon}
-                      aria-label={translate('issue.comment.delete')}
-                      onClick={() => setDeleteCommentKey(key)}
-                      size="small"
-                      stopPropagation={false}
-                    />
+              <Text isSubdued as="div" className="sw-mb-1">
+                {user.name !== undefined && (
+                  <div className="sw-flex sw-items-center sw-gap-1">
+                    <Avatar hash={user.avatar} name={user.name} size="xs" />
+                    <span className="sw-typo-semibold">
+                      {user.active
+                        ? user.name
+                        : translateWithParameters('user.x_deleted', user.name)}
+                    </span>
                   </div>
                 )}
 
-                {editCommentKey === key && (
-                  <HotspotCommentModal
-                    value={markdown}
-                    onCancel={() => setEditCommentKey('')}
-                    onSubmit={(comment) => {
-                      setEditCommentKey('');
-                      props.onEditComment(key, comment);
-                    }}
-                  />
-                )}
+                {type === ReviewHistoryType.Creation &&
+                  translate('issue.activity.review_history.created')}
 
-                {deleteCommentKey === key && (
-                  <Modal
-                    headerTitle={translate('issue.comment.delete')}
-                    onClose={() => setDeleteCommentKey('')}
-                    body={<p>{translate('issue.comment.delete_confirm_message')}</p>}
-                    primaryButton={
-                      <DangerButtonPrimary
-                        onClick={() => {
-                          setDeleteCommentKey('');
-                          props.onDeleteComment(key);
-                        }}
-                      >
-                        {translate('delete')}
-                      </DangerButtonPrimary>
-                    }
-                    secondaryButtonLabel={translate('cancel')}
-                  />
-                )}
-              </div>
-            )}
-          </li>
-        );
-      })}
-    </ul>
+                {type === ReviewHistoryType.Comment &&
+                  translate('issue.activity.review_history.comment_added')}
+              </Text>
+
+              {type === ReviewHistoryType.Diff && diffs && (
+                <div className="sw-mt-2">
+                  {diffs.map((diff) => (
+                    <IssueChangelogDiff
+                      diff={diff}
+                      key={date + diff.key + diff.newValue + diff.oldValue}
+                    />
+                  ))}
+                </div>
+              )}
+
+              {type === ReviewHistoryType.Comment && key && html && markdown && (
+                <div className="sw-mt-2 sw-flex sw-justify-between">
+                  <SafeHTMLInjection htmlAsString={html} sanitizeLevel={SanitizeLevel.USER_INPUT}>
+                    <CommentBox className="sw-pl-2 sw-ml-2 sw-typo-default" />
+                  </SafeHTMLInjection>
+
+                  {updatable && (
+                    <div className="sw-flex sw-gap-6">
+                      <InteractiveIcon
+                        Icon={PencilIcon}
+                        aria-label={translate('issue.comment.edit')}
+                        onClick={() => setEditCommentKey(key)}
+                        size="small"
+                        stopPropagation={false}
+                      />
+
+                      <DestructiveIcon
+                        Icon={TrashIcon}
+                        aria-label={translate('issue.comment.delete')}
+                        onClick={() => setDeleteCommentKey(key)}
+                        size="small"
+                        stopPropagation={false}
+                      />
+                    </div>
+                  )}
+
+                  {editCommentKey === key && (
+                    <HotspotCommentModal
+                      value={markdown}
+                      onCancel={() => setEditCommentKey('')}
+                      onSubmit={(comment) => {
+                        setEditCommentKey('');
+                        props.onEditComment(key, comment);
+                      }}
+                    />
+                  )}
+
+                  {deleteCommentKey === key && (
+                    <Modal
+                      headerTitle={translate('issue.comment.delete')}
+                      onClose={() => setDeleteCommentKey('')}
+                      body={<p>{translate('issue.comment.delete_confirm_message')}</p>}
+                      primaryButton={
+                        <Button
+                          variety={ButtonVariety.Danger}
+                          onClick={() => {
+                            setDeleteCommentKey('');
+                            props.onDeleteComment(key);
+                          }}
+                        >
+                          {translate('delete')}
+                        </Button>
+                      }
+                      secondaryButtonLabel={translate('cancel')}
+                    />
+                  )}
+                </div>
+              )}
+            </li>
+          );
+        })}
+      </ul>
+    </Spinner>
   );
 }
 
index d4af6b8303b1071bf2742c05d76ff81a1ba3255d..56626b5401fb12e98260994b7c62b9a10c77ed17 100644 (file)
@@ -26,9 +26,7 @@ export interface IssueChangelogDiffProps {
   diff: TypeIssueChangelogDiff;
 }
 
-export default function IssueChangelogDiff(props: Readonly<IssueChangelogDiffProps>) {
-  const { diff } = props;
-
+export default function IssueChangelogDiff({ diff }: Readonly<IssueChangelogDiffProps>) {
   const diffComputedValues = {
     newValue: diff.newValue ?? '',
     oldValue: diff.oldValue ?? '',
@@ -79,6 +77,21 @@ export default function IssueChangelogDiff(props: Readonly<IssueChangelogDiffPro
     );
   }
 
+  if (diff.key === 'impactSeverity') {
+    const [softwareQuality, newSeverity] = diffComputedValues.newValue.split(':');
+    const [_, oldSeverity] = diffComputedValues.oldValue.split(':');
+    return (
+      <p>
+        {translateWithParameters(
+          'issue.changelog.impactSeverity',
+          softwareQuality,
+          newSeverity,
+          oldSeverity,
+        )}
+      </p>
+    );
+  }
+
   if (diff.key === 'effort') {
     diffComputedValues.newValue = formatMeasure(diff.newValue, 'WORK_DUR');
     diffComputedValues.oldValue = formatMeasure(diff.oldValue, 'WORK_DUR');
index b839b72ce70175a51db2b9ca7b5c425d5e525900..bfb03626961a78aa0971ee97fca18f374c5d1c12 100644 (file)
@@ -28,9 +28,9 @@ import {
   PopupZLevel,
   SearchSelectDropdownControl,
 } from '~design-system';
-import { addIssueComment, setIssueTransition } from '../../../api/issues';
 import { SESSION_STORAGE_TRANSITION_GUIDE_KEY } from '../../../apps/issues/components/IssueNewStatusAndTransitionGuide';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { useIssueCommentMutation, useIssueTransitionMutation } from '../../../queries/issues';
 import { Issue } from '../../../types/types';
 import StatusHelper from '../../shared/StatusHelper';
 import { updateIssue } from '../actions';
@@ -49,6 +49,8 @@ export default function IssueTransition(props: Readonly<Props>) {
   const guideStepIndex = +(sessionStorage.getItem(SESSION_STORAGE_TRANSITION_GUIDE_KEY) ?? 0);
   const guideIsRunning = sessionStorage.getItem(SESSION_STORAGE_TRANSITION_GUIDE_KEY) !== null;
   const [transitioning, setTransitioning] = React.useState(false);
+  const { mutateAsync: setIssueTransition } = useIssueTransitionMutation();
+  const { mutateAsync: addIssueComment } = useIssueCommentMutation();
 
   async function handleSetTransition(transition: string, comment?: string) {
     setTransitioning(true);
diff --git a/server/sonar-web/src/main/js/queries/issues.ts b/server/sonar-web/src/main/js/queries/issues.ts
new file mode 100644 (file)
index 0000000..ceda443
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * 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 { QueryClient, queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
+import { addIssueComment, getIssueChangelog, setIssueTransition } from '../api/issues';
+import { createQueryHook } from './common';
+
+const issuesQuery = {
+  changelog: (issueKey: string) => ['issue', issueKey, 'changelog'] as const,
+};
+
+export const useIssueChangelogQuery = createQueryHook((issueKey: string) => {
+  return queryOptions({
+    queryKey: issuesQuery.changelog(issueKey),
+    queryFn: () => getIssueChangelog(issueKey),
+  });
+});
+
+export function useIssueTransitionMutation() {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: (data: { issue: string; transition: string }) => setIssueTransition(data),
+    onSuccess: ({ issue }) => {
+      invalidateIssueChangelog(issue.key, queryClient);
+    },
+  });
+}
+
+export function useIssueCommentMutation() {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: (data: { issue: string; text: string }) => addIssueComment(data),
+    onSuccess: ({ issue }) => {
+      invalidateIssueChangelog(issue.key, queryClient);
+    },
+  });
+}
+
+function invalidateIssueChangelog(issueKey: string, queryClient: QueryClient) {
+  queryClient.invalidateQueries({ queryKey: issuesQuery.changelog(issueKey) });
+}
index 2dbc5b2fcf2a21c0e03332e42bad836c4e5297bd..8c9d4bb4903c2a78eec9f298fc0552e4bfccae91 100644 (file)
@@ -1222,6 +1222,7 @@ issue.changelog.field.code_variants=Code Variants
 issue.changelog.field.type=Type
 issue.changelog.field.file=File
 issue.changelog.field.cleanCodeAttribute=Clean Code Attribute
+issue.changelog.impactSeverity={0} severity changed to {1} (was {2})
 
 #------------------------------------------------------------------------------
 #