]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19236 Move security hotspot activity to its own tab
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Wed, 17 May 2023 08:57:49 +0000 (10:57 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 24 May 2023 20:03:14 +0000 (20:03 +0000)
20 files changed:
server/sonar-web/design-system/src/components/HtmlFormatter.tsx
server/sonar-web/design-system/src/components/Text.tsx
server/sonar-web/design-system/src/components/icons/TrashIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/index.ts
server/sonar-web/src/main/js/app/styles/style.css
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCommentModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCommentPopup.tsx [deleted file]
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotPrimaryLocationBox.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotReviewHistory.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotReviewHistoryAndComments.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusSelectionRenderer.tsx
server/sonar-web/src/main/js/components/common/FormattingTips.tsx
server/sonar-web/src/main/js/components/rules/RuleDescription.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 2142e789a9e768a3eee57c67d4e26032d0042f5f..fd44093c0e6bfdeff159c46d9e76d4cdb6a6e493 100644 (file)
@@ -22,7 +22,6 @@ import tw from 'twin.macro';
 import { themeBorder, themeColor } from '../helpers';
 
 export const HtmlFormatter = styled.div`
-  ${tw`sw-my-6`}
   ${tw`sw-body-sm`}
 
   a {
index aff69978ad3a1193ca4f8a1541f89b38dac256ca..fb6664fc0381d7dbfbc83ce7b4597bfdaf7233e8 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import styled from '@emotion/styled';
+import { ElementType } from 'react';
 import tw from 'twin.macro';
 import { themeColor, themeContrast } from '../helpers/theme';
 
@@ -50,9 +51,17 @@ export function TextMuted({ text, className }: { className?: string; text: strin
   );
 }
 
-export function PageTitle({ text, className }: { className?: string; text: string }) {
+export function PageTitle({
+  text,
+  className,
+  as = 'h1',
+}: {
+  as?: ElementType;
+  className?: string;
+  text: string;
+}) {
   return (
-    <StyledPageTitle className={className} title={text}>
+    <StyledPageTitle as={as} className={className} title={text}>
       {text}
     </StyledPageTitle>
   );
@@ -86,6 +95,7 @@ const StyledMutedText = styled(StyledText)`
 `;
 
 export const StyledPageTitle = styled(StyledText)`
+  ${tw`sw-block`};
   ${tw`sw-text-base`}
   color: ${themeColor('facetHeader')};
 `;
diff --git a/server/sonar-web/design-system/src/components/icons/TrashIcon.tsx b/server/sonar-web/design-system/src/components/icons/TrashIcon.tsx
new file mode 100644 (file)
index 0000000..f4865e6
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * 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 { TrashIcon as BaseTrashIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export const TrashIcon = OcticonHoc(BaseTrashIcon);
index 301e4c08af8e747d0b298701091d8e344c767ecb..27e7dc2d9f63d767cd8748cb159f7463f605343c 100644 (file)
@@ -66,6 +66,7 @@ export { StatusConfirmedIcon } from './StatusConfirmedIcon';
 export { StatusOpenIcon } from './StatusOpenIcon';
 export { StatusReopenedIcon } from './StatusReopenedIcon';
 export { StatusResolvedIcon } from './StatusResolvedIcon';
+export { TrashIcon } from './TrashIcon';
 export { TriangleDownIcon } from './TriangleDownIcon';
 export { TriangleLeftIcon } from './TriangleLeftIcon';
 export { TriangleRightIcon } from './TriangleRightIcon';
index 3ec343d5ad310f98bfd027d207072d806e233b8f..17c90e4e825d34a032044263af4e3449395f6559 100644 (file)
   margin: 0 1px 0 0;
 }
 
-.markdown-tips {
-  font-size: var(--smallFontSize);
-  color: var(--secondFontColor);
-}
-
 .rule-desc,
 .markdown {
   line-height: 1.5;
index 1f3c205ebccced6ee4b15c825eeba5a66593d27c..ed641478c1ef4558afb7d8d71271212e99fb096b 100644 (file)
@@ -79,6 +79,8 @@ const ui = {
   fixTab: byRole('tab', { name: 'hotspots.tabs.fix_recommendations' }),
   fixContent: byText('This is how to fix'),
   showAllHotspotLink: byRole('link', { name: 'hotspot.filters.show_all' }),
+  activityTab: byRole('tab', { name: 'hotspots.tabs.activity' }),
+  addCommentButton: byRole('button', { name: 'hotspots.status.add_comment' }),
 };
 
 const hotspotsHandler = new SecurityHotspotServiceMock();
@@ -153,7 +155,9 @@ describe('CRUD', () => {
     await user.click(await ui.activeAssignee.find());
     await user.click(ui.inputAssignee.get());
 
-    await user.keyboard('User');
+    await act(async () => {
+      await user.keyboard('User');
+    });
 
     expect(searchUsers).toHaveBeenLastCalledWith({ q: 'User' });
     await user.keyboard('{Enter}');
@@ -171,13 +175,14 @@ describe('CRUD', () => {
     await user.click(ui.reviewButton.get());
     await user.click(ui.toReviewStatus.get());
 
-    await user.click(screen.getByRole('textbox', { name: 'hotspots.status.add_comment' }));
+    await user.click(screen.getByRole('textbox', { name: 'hotspots.status.add_comment_optional' }));
     await user.keyboard(comment);
 
     await act(async () => {
       await user.click(ui.changeStatus.get());
     });
 
+    await user.click(ui.activityTab.get());
     expect(setSecurityHotspotStatus).toHaveBeenLastCalledWith('test-1', {
       comment: 'COMMENT-TEXT',
       resolution: undefined,
@@ -195,14 +200,17 @@ describe('CRUD', () => {
 
   it('should be able to add, edit and remove own comments', async () => {
     const uiComment = {
-      saveButton: byRole('button', { name: 'save' }),
+      saveButton: byRole('button', { name: 'hotspots.comment.submit' }),
       deleteButton: byRole('button', { name: 'delete' }),
     };
     const user = userEvent.setup();
     const comment = 'This is a comment from john doe';
     renderSecurityHotspotsApp();
 
-    const commentSection = await ui.hotspotCommentBox.find();
+    await user.click(await ui.activityTab.find());
+    await user.click(ui.addCommentButton.get());
+
+    const commentSection = ui.hotspotCommentBox.get();
     const submitButton = ui.commentSubmitButton.get();
 
     // Add a new comment
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCommentModal.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCommentModal.tsx
new file mode 100644 (file)
index 0000000..d8f0b5d
--- /dev/null
@@ -0,0 +1,61 @@
+/*
+ * 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 { ButtonPrimary, FormField, InputTextArea, Modal } from 'design-system';
+import * as React from 'react';
+import FormattingTips from '../../../components/common/FormattingTips';
+import { translate } from '../../../helpers/l10n';
+
+export interface HotspotCommentPopupProps {
+  value?: string;
+  onSubmit: (comment: string) => void;
+  onCancel: () => void;
+}
+
+export default function HotspotCommentModal(props: HotspotCommentPopupProps) {
+  const [comment, setComment] = React.useState(props.value ?? '');
+
+  return (
+    <Modal
+      headerTitle={translate(
+        props.value !== undefined ? 'issue.comment.edit' : 'hotspots.status.add_comment'
+      )}
+      onClose={props.onCancel}
+      body={
+        <FormField htmlFor="security-hotspot-comment" label={translate('hotspots.comment.field')}>
+          <InputTextArea
+            className="sw-mb-2 sw-resize-y"
+            id="security-hotspot-comment"
+            size="full"
+            onChange={(event) => setComment(event.target.value)}
+            rows={3}
+            value={comment}
+          />
+          <FormattingTips />
+        </FormField>
+      }
+      primaryButton={
+        <ButtonPrimary onClick={() => props.onSubmit(comment)} disabled={!comment}>
+          {translate('hotspots.comment.submit')}
+        </ButtonPrimary>
+      }
+      secondaryButtonLabel={translate('cancel')}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCommentPopup.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCommentPopup.tsx
deleted file mode 100644 (file)
index 2ec060a..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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 * as React from 'react';
-import FormattingTips from '../../../components/common/FormattingTips';
-import { Button, ResetButtonLink } from '../../../components/controls/buttons';
-import { translate } from '../../../helpers/l10n';
-
-export interface HotspotCommentPopupProps {
-  markdownComment: string;
-  onCommentEditSubmit: (comment: string) => void;
-  onCancelEdit: () => void;
-}
-
-export default function HotspotCommentPopup(props: HotspotCommentPopupProps) {
-  const [comment, setComment] = React.useState(props.markdownComment);
-
-  return (
-    <div className="issue-comment-bubble-popup">
-      <div className="issue-comment-form-text">
-        <textarea
-          autoFocus={true}
-          onChange={(event) => setComment(event.target.value)}
-          rows={2}
-          value={comment}
-        />
-      </div>
-      <div className="spacer-top display-flex-space-between">
-        <div className="issue-comment-form-tips">
-          <FormattingTips />
-        </div>
-        <div className="">
-          <Button
-            className="little-spacer-right"
-            onClick={() => props.onCommentEditSubmit(comment)}
-          >
-            {translate('save')}
-          </Button>
-          <ResetButtonLink
-            onClick={() => {
-              setComment('');
-              props.onCancelEdit();
-            }}
-          >
-            {translate('cancel')}
-          </ResetButtonLink>
-        </div>
-      </div>
-    </div>
-  );
-}
index 975fa64fb534d09712a86719f3a1e35077a59b30..6506a0bc9f7d958972ec1c6804a97297ea3a44a4 100644 (file)
  */
 import classNames from 'classnames';
 import * as React from 'react';
-import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
-import { ButtonLink } from '../../../components/controls/buttons';
 import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting';
-import { translate } from '../../../helpers/l10n';
 import { Hotspot } from '../../../types/security-hotspots';
-import { CurrentUser, isLoggedIn } from '../../../types/users';
 import './HotspotPrimaryLocationBox.css';
 
 const SCROLL_DELAY = 100;
@@ -33,13 +29,11 @@ const SCROLL_BOTTOM_OFFSET = 28; // 1 line below + margin
 
 export interface HotspotPrimaryLocationBoxProps {
   hotspot: Hotspot;
-  onCommentClick: () => void;
-  currentUser: CurrentUser;
   secondaryLocationSelected: boolean;
 }
 
-export function HotspotPrimaryLocationBox(props: HotspotPrimaryLocationBoxProps) {
-  const { hotspot, currentUser, secondaryLocationSelected } = props;
+export default function HotspotPrimaryLocationBox(props: HotspotPrimaryLocationBoxProps) {
+  const { hotspot, secondaryLocationSelected } = props;
 
   const locationRef = React.useRef<HTMLDivElement>(null);
 
@@ -74,16 +68,6 @@ export function HotspotPrimaryLocationBox(props: HotspotPrimaryLocationBoxProps)
           messageFormattings={hotspot.messageFormattings}
         />
       </div>
-      {isLoggedIn(currentUser) && (
-        <ButtonLink
-          className="nowrap big-spacer-left it__hs-add-comment"
-          onClick={props.onCommentClick}
-        >
-          {translate('hotspots.comment.open')}
-        </ButtonLink>
-      )}
     </div>
   );
 }
-
-export default withCurrentUserContext(HotspotPrimaryLocationBox);
index df68802a9e662055830317f928444b14488a8712..5bb9fccbc3c05aef065fd1dc71b33f13191ca2c8 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 classNames from 'classnames';
+import styled from '@emotion/styled';
+import {
+  DangerButtonPrimary,
+  DestructiveIcon,
+  HtmlFormatter,
+  InteractiveIcon,
+  LightLabel,
+  Modal,
+  PencilIcon,
+  TrashIcon,
+  themeBorder,
+} from 'design-system';
 import * as React from 'react';
-import { Button, ButtonLink, DeleteButton, EditButton } from '../../../components/controls/buttons';
-import Dropdown, { DropdownOverlay } from '../../../components/controls/Dropdown';
-import Toggler from '../../../components/controls/Toggler';
 import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
 import IssueChangelogDiff from '../../../components/issue/components/IssueChangelogDiff';
 import Avatar from '../../../components/ui/Avatar';
-import { PopupPlacement } from '../../../components/ui/popups';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { sanitizeUserInput } from '../../../helpers/sanitize';
 import { Hotspot, ReviewHistoryType } from '../../../types/security-hotspots';
 import { getHotspotReviewHistory } from '../utils';
-import HotspotCommentPopup from './HotspotCommentPopup';
+import HotspotCommentModal from './HotspotCommentModal';
 
 export interface HotspotReviewHistoryProps {
   hotspot: Hotspot;
   onDeleteComment: (key: string) => void;
   onEditComment: (key: string, comment: string) => void;
-  onShowFullHistory: () => void;
-  showFullHistory: boolean;
 }
 
-export const MAX_RECENT_ACTIVITY = 5;
-
 export default function HotspotReviewHistory(props: HotspotReviewHistoryProps) {
-  const { hotspot, showFullHistory } = props;
-  const fullReviewHistory = getHotspotReviewHistory(hotspot);
-  const [editedCommentKey, setEditedCommentKey] = React.useState('');
-
-  const reviewHistory = showFullHistory
-    ? fullReviewHistory
-    : fullReviewHistory.slice(0, MAX_RECENT_ACTIVITY);
+  const { hotspot } = props;
+  const history = getHotspotReviewHistory(hotspot);
+  const [editCommentKey, setEditCommentKey] = React.useState('');
+  const [deleteCommentKey, setDeleteCommentKey] = React.useState('');
 
   return (
-    <>
-      <ul>
-        {reviewHistory.map((historyElt, historyIndex) => {
-          const { user, type, diffs, date, html, key, updatable, markdown } = historyElt;
-          return (
-            <li
-              className={classNames('padded-top padded-bottom', {
-                'bordered-top': historyIndex > 0,
-              })}
-              key={historyIndex}
-            >
-              <div className="display-flex-center">
-                {user.name && (
-                  <>
-                    <Avatar
-                      className="little-spacer-right"
-                      hash={user.avatar}
-                      name={user.name}
-                      size={20}
-                    />
-                    <strong>
-                      {user.active
-                        ? user.name
-                        : translateWithParameters('user.x_deleted', user.name)}
-                    </strong>
-                    {type === ReviewHistoryType.Creation && (
-                      <span className="little-spacer-left">
-                        {translate('hotspots.review_history.created')}
-                      </span>
-                    )}
-                    {type === ReviewHistoryType.Comment && (
-                      <span className="little-spacer-left">
-                        {translate('hotspots.review_history.comment_added')}
-                      </span>
-                    )}
-                    <span className="little-spacer-left little-spacer-right">-</span>
-                  </>
-                )}
-                <DateTimeFormatter date={date} />
-              </div>
-
-              {type === ReviewHistoryType.Diff && diffs && (
-                <div className="spacer-top">
-                  {diffs.map((diff, diffIndex) => (
-                    <IssueChangelogDiff diff={diff} key={diffIndex} />
-                  ))}
+    <ul>
+      {history.map((historyElt, historyIndex) => {
+        const { user, type, diffs, date, html, key, updatable, markdown } = historyElt;
+        return (
+          <li className="sw-p-2 sw-body-sm" key={historyIndex}>
+            <div className="sw-body-sm-highlight 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={20} />
+                  <span className="sw-body-sm-highlight">
+                    {user.active ? user.name : translateWithParameters('user.x_deleted', user.name)}
+                  </span>
                 </div>
               )}
 
-              {type === ReviewHistoryType.Comment && key && html && markdown && (
-                <div className="spacer-top display-flex-space-between">
-                  <div
-                    className="markdown"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: sanitizeUserInput(html) }}
+              {type === ReviewHistoryType.Creation && translate('hotspots.review_history.created')}
+
+              {type === ReviewHistoryType.Comment &&
+                translate('hotspots.review_history.comment_added')}
+            </LightLabel>
+
+            {type === ReviewHistoryType.Diff && diffs && (
+              <div className="sw-mt-2">
+                {diffs.map((diff, diffIndex) => (
+                  <IssueChangelogDiff diff={diff} key={diffIndex} />
+                ))}
+              </div>
+            )}
+
+            {type === ReviewHistoryType.Comment && key && html && markdown && (
+              <div className="sw-mt-2 sw-flex sw-justify-between sw-h-5">
+                <CommentBox
+                  className="sw-pl-2 sw-ml-2 sw-body-sm"
+                  // eslint-disable-next-line react/no-danger
+                  dangerouslySetInnerHTML={{ __html: sanitizeUserInput(html) }}
+                />
+
+                {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 && (
+                  <HotspotCommentModal
+                    value={markdown}
+                    onCancel={() => setEditCommentKey('')}
+                    onSubmit={(comment) => {
+                      setEditCommentKey('');
+                      props.onEditComment(key, comment);
+                    }}
                   />
-                  {updatable && (
-                    <div>
-                      <div className="dropdown">
-                        <Toggler
-                          onRequestClose={() => {
-                            setEditedCommentKey('');
-                          }}
-                          open={key === editedCommentKey}
-                          overlay={
-                            <DropdownOverlay placement={PopupPlacement.BottomRight}>
-                              <HotspotCommentPopup
-                                markdownComment={markdown}
-                                onCancelEdit={() => setEditedCommentKey('')}
-                                onCommentEditSubmit={(comment) => {
-                                  setEditedCommentKey('');
-                                  props.onEditComment(key, comment);
-                                }}
-                              />
-                            </DropdownOverlay>
-                          }
-                        >
-                          <EditButton
-                            title="issue.comment.edit"
-                            className="button-small"
-                            onClick={() => setEditedCommentKey(key)}
-                          />
-                        </Toggler>
-                      </div>
-                      <Dropdown
-                        onOpen={() => setEditedCommentKey('')}
-                        overlay={
-                          <div className="padded abs-width-150">
-                            <p>{translate('issue.comment.delete_confirm_message')}</p>
-                            <Button
-                              className="button-red big-spacer-top pull-right"
-                              onClick={() => props.onDeleteComment(key)}
-                            >
-                              {translate('delete')}
-                            </Button>
-                          </div>
-                        }
-                        overlayPlacement={PopupPlacement.BottomRight}
+                )}
+
+                {deleteCommentKey && (
+                  <Modal
+                    headerTitle={translate('issue.comment.delete')}
+                    onClose={() => setDeleteCommentKey('')}
+                    body={<p>{translate('issue.comment.delete_confirm_message')}</p>}
+                    primaryButton={
+                      <DangerButtonPrimary
+                        onClick={() => {
+                          setDeleteCommentKey('');
+                          props.onDeleteComment(key);
+                        }}
                       >
-                        <DeleteButton title="issue.comment.delete" className="button-small" />
-                      </Dropdown>
-                    </div>
-                  )}
-                </div>
-              )}
-            </li>
-          );
-        })}
-      </ul>
-      {!showFullHistory && fullReviewHistory.length > MAX_RECENT_ACTIVITY && (
-        <ButtonLink className="spacer-top" onClick={props.onShowFullHistory}>
-          {translate('show_all')}
-        </ButtonLink>
-      )}
-    </>
+                        {translate('delete')}
+                      </DangerButtonPrimary>
+                    }
+                    secondaryButtonLabel={translate('cancel')}
+                  />
+                )}
+              </div>
+            )}
+          </li>
+        );
+      })}
+    </ul>
   );
 }
+
+const CommentBox = styled(HtmlFormatter)`
+  border-left: ${themeBorder('default', 'activityCommentPipe')};
+`;
index 84446352cd9e892eabce30acdbb8dff0bbc4cd2c..9562e579a348d6d0c654e15bbe194ea192413ef1 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 { ButtonSecondary, PageTitle } from 'design-system';
 import * as React from 'react';
 import {
   commentSecurityHotspot,
   deleteSecurityHotspotComment,
   editSecurityHotspotComment,
 } from '../../../api/security-hotspots';
-import FormattingTips from '../../../components/common/FormattingTips';
-import { Button } from '../../../components/controls/buttons';
 import { translate } from '../../../helpers/l10n';
 import { Hotspot } from '../../../types/security-hotspots';
 import { CurrentUser, isLoggedIn } from '../../../types/users';
+import HotspotCommentModal from './HotspotCommentModal';
 import HotspotReviewHistory from './HotspotReviewHistory';
 
 interface Props {
   currentUser: CurrentUser;
   hotspot: Hotspot;
-  commentTextRef: React.RefObject<HTMLTextAreaElement>;
   onCommentUpdate: () => void;
 }
 
 interface State {
-  comment: string;
-  showFullHistory: boolean;
+  showAddCommentModal: boolean;
 }
 
 export default class HotspotReviewHistoryAndComments extends React.PureComponent<Props, State> {
   constructor(props: Props) {
     super(props);
     this.state = {
-      comment: '',
-      showFullHistory: false,
+      showAddCommentModal: false,
     };
   }
 
-  componentDidUpdate(prevProps: Props) {
-    if (prevProps.hotspot.key !== this.props.hotspot.key) {
-      this.setState({
-        comment: '',
-        showFullHistory: false,
-      });
-    }
-  }
-
-  handleCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
-    this.setState({ comment: event.target.value });
-  };
-
-  handleSubmitComment = () => {
-    return commentSecurityHotspot(this.props.hotspot.key, this.state.comment).then(() => {
-      this.setState({ comment: '' });
+  handleSubmitComment = (comment: string) => {
+    return commentSecurityHotspot(this.props.hotspot.key, comment).then(() => {
+      this.setState({ showAddCommentModal: false });
       this.props.onCommentUpdate();
     });
   };
@@ -83,50 +67,45 @@ export default class HotspotReviewHistoryAndComments extends React.PureComponent
     });
   };
 
-  handleShowFullHistory = () => {
-    this.setState({ showFullHistory: true });
+  handleShowCommentModal = () => {
+    this.setState({ showAddCommentModal: true });
+  };
+
+  handleHideCommentModal = () => {
+    this.setState({ showAddCommentModal: false });
   };
 
   render() {
-    const { currentUser, hotspot, commentTextRef } = this.props;
-    const { comment, showFullHistory } = this.state;
+    const { currentUser, hotspot } = this.props;
+    const { showAddCommentModal } = this.state;
     return (
-      <div className="padded it__hs-review-history">
+      <div className="it__hs-review-history">
+        <PageTitle
+          as="h2"
+          className="sw-body-md-highlight"
+          text={translate('hotspot.section.activity')}
+        />
+
         {isLoggedIn(currentUser) && (
-          <>
-            <label htmlFor="security-hotspot-comment">{translate('hotspots.comment.field')}</label>
-            <textarea
-              id="security-hotspot-comment"
-              className="form-field fixed-width width-100 spacer-bottom"
-              onChange={this.handleCommentChange}
-              ref={commentTextRef}
-              rows={2}
-              value={comment}
-            />
-            <div className="display-flex-space-between display-flex-center ">
-              <FormattingTips className="huge-spacer-bottom" />
-              <div>
-                <Button
-                  className="huge-spacer-bottom"
-                  id="hotspot-comment-box-submit"
-                  onClick={this.handleSubmitComment}
-                >
-                  {translate('hotspots.comment.submit')}
-                </Button>
-              </div>
-            </div>
-          </>
+          <ButtonSecondary className="sw-mt-4 sw-mb-2" onClick={this.handleShowCommentModal}>
+            {translate('hotspots.status.add_comment')}
+          </ButtonSecondary>
         )}
 
-        <h2 className="spacer-top big-spacer-bottom">{translate('hotspot.section.activity')}</h2>
-
         <HotspotReviewHistory
           hotspot={hotspot}
           onDeleteComment={this.handleDeleteComment}
           onEditComment={this.handleEditComment}
-          onShowFullHistory={this.handleShowFullHistory}
-          showFullHistory={showFullHistory}
         />
+
+        {showAddCommentModal && (
+          <HotspotCommentModal
+            onCancel={this.handleHideCommentModal}
+            onSubmit={(comment) => {
+              this.handleSubmitComment(comment);
+            }}
+          />
+        )}
       </div>
     );
   }
index fd0e1cca5b75e9c7c150ca6aa39bad3ad8b2f8fd..285bdd7c94a2d2235d9e84245dcd3599a4690c13 100644 (file)
@@ -31,7 +31,6 @@ interface Props {
   branchLike?: BranchLike;
   component: Component;
   hotspot: Hotspot;
-  onCommentButtonClick: () => void;
   onLocationSelect: (index: number) => void;
   selectedHotspotLocation?: number;
 }
@@ -213,7 +212,6 @@ export default class HotspotSnippetContainer extends React.Component<Props, Stat
         hotspot={hotspot}
         loading={loading}
         locations={locations}
-        onCommentButtonClick={this.props.onCommentButtonClick}
         onExpandBlock={this.handleExpansion}
         onSymbolClick={this.handleSymbolClick}
         onLocationSelect={this.props.onLocationSelect}
index fbdc9e0062c35a74bacddfef9b33e406162be08d..54be3aa36c8529993a0d93321d72a7c58d932968 100644 (file)
@@ -41,7 +41,6 @@ export interface HotspotSnippetContainerRendererProps {
   loading: boolean;
   locations: { [line: number]: LinearIssueLocation[] };
   selectedHotspotLocation?: number;
-  onCommentButtonClick: () => void;
   onExpandBlock: (direction: ExpandDirection) => Promise<void>;
   onSymbolClick: (symbols: string[]) => void;
   onLocationSelect: (index: number) => void;
@@ -130,11 +129,10 @@ export default function HotspotSnippetContainerRenderer(
     () => (
       <HotspotPrimaryLocationBox
         hotspot={hotspot}
-        onCommentClick={props.onCommentButtonClick}
         secondaryLocationSelected={secondaryLocationSelected}
       />
     ),
-    [hotspot, secondaryLocationSelected, props.onCommentButtonClick]
+    [hotspot, secondaryLocationSelected]
   );
 
   const renderHotspotBoxInLine = (line: SourceLine) =>
index dac14d335d7551f0dca38ebf9a26647d41c4f591..cfb3f941efb96c9717a9b078eafb38850566913c 100644 (file)
@@ -51,11 +51,9 @@ interface State {
 export default class HotspotViewer extends React.PureComponent<Props, State> {
   mounted = false;
   state: State;
-  commentTextRef: React.RefObject<HTMLTextAreaElement>;
 
   constructor(props: Props) {
     super(props);
-    this.commentTextRef = React.createRef<HTMLTextAreaElement>();
     this.state = { loading: false };
   }
 
@@ -105,17 +103,6 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
     }
   };
 
-  handleScrollToCommentForm = () => {
-    if (this.commentTextRef.current) {
-      this.commentTextRef.current.scrollIntoView({
-        block: 'center',
-        behavior: 'smooth',
-        inline: 'center',
-      });
-      this.commentTextRef.current.focus({ preventScroll: true });
-    }
-  };
-
   handleSwitchFilterToStatusOfUpdatedHotspot = () => {
     const { lastStatusChangedTo } = this.state;
     if (lastStatusChangedTo) {
@@ -131,11 +118,9 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
       <HotspotViewerRenderer
         standards={standards}
         component={component}
-        commentTextRef={this.commentTextRef}
         hotspot={hotspot}
         ruleDescriptionSections={ruleDescriptionSections}
         loading={loading}
-        onShowCommentForm={this.handleScrollToCommentForm}
         onUpdateHotspot={this.handleHotspotUpdate}
         onLocationClick={this.props.onLocationClick}
         selectedHotspotLocation={selectedHotspotLocation}
index f5d795791839613cf83151a5871d78a5041869f6..9ed8c034d79ada1e0c43ba95e87a4d59b224cc3e 100644 (file)
@@ -38,9 +38,7 @@ export interface HotspotViewerRendererProps {
   hotspot?: Hotspot;
   ruleDescriptionSections?: RuleDescriptionSection[];
   loading: boolean;
-  commentTextRef: React.RefObject<HTMLTextAreaElement>;
   onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
-  onShowCommentForm: () => void;
   onLocationClick: (index: number) => void;
   selectedHotspotLocation?: number;
   standards?: Standards;
@@ -52,7 +50,6 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
     currentUser,
     hotspot,
     loading,
-    commentTextRef,
     selectedHotspotLocation,
     ruleDescriptionSections,
     standards,
@@ -61,7 +58,9 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
   const branchLike = hotspot && fillBranchLike(hotspot.project.branch, hotspot.project.pullRequest);
 
   return (
-    <DeferredSpinner className="big-spacer-left big-spacer-top" loading={loading}>
+    <>
+      <DeferredSpinner className="sw-ml-4 sw-mt-4" loading={loading} />
+
       {hotspot && (
         <div className="sw-box-border sw-p-6">
           <HotspotHeader
@@ -72,12 +71,18 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
             branchLike={branchLike}
           />
           <HotspotViewerTabs
+            activityTabContent={
+              <HotspotReviewHistoryAndComments
+                currentUser={currentUser}
+                hotspot={hotspot}
+                onCommentUpdate={props.onUpdateHotspot}
+              />
+            }
             codeTabContent={
               <HotspotSnippetContainer
                 branchLike={branchLike}
                 component={component}
                 hotspot={hotspot}
-                onCommentButtonClick={props.onShowCommentForm}
                 onLocationSelect={props.onLocationClick}
                 selectedHotspotLocation={selectedHotspotLocation}
               />
@@ -86,15 +91,9 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
             ruleDescriptionSections={ruleDescriptionSections}
             selectedHotspotLocation={selectedHotspotLocation}
           />
-          <HotspotReviewHistoryAndComments
-            commentTextRef={commentTextRef}
-            currentUser={currentUser}
-            hotspot={hotspot}
-            onCommentUpdate={props.onUpdateHotspot}
-          />
         </div>
       )}
-    </DeferredSpinner>
+    </>
   );
 }
 
index 0f2807eb5370d7e05f7a2b1807eb922efa432c43..8689e38abe57280f41bc88094b1003dcfa1ac4f8 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { ToggleButton, getTabId, getTabPanelId } from 'design-system';
-import { groupBy } from 'lodash';
+import { groupBy, omit } from 'lodash';
 import * as React from 'react';
 import RuleDescription from '../../../components/rules/RuleDescription';
 import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
@@ -28,6 +28,7 @@ import { Hotspot } from '../../../types/security-hotspots';
 import { RuleDescriptionSection, RuleDescriptionSections } from '../../coding-rules/rule';
 
 interface Props {
+  activityTabContent: React.ReactNode;
   codeTabContent: React.ReactNode;
   hotspot: Hotspot;
   ruleDescriptionSections?: RuleDescriptionSection[];
@@ -42,7 +43,6 @@ interface State {
 interface Tab {
   value: TabKeys;
   label: string;
-  content: React.ReactNode;
 }
 
 export enum TabKeys {
@@ -68,10 +68,7 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
   }
 
   componentDidUpdate(prevProps: Props) {
-    if (
-      this.props.hotspot.key !== prevProps.hotspot.key ||
-      prevProps.codeTabContent !== this.props.codeTabContent
-    ) {
+    if (this.props.hotspot.key !== prevProps.hotspot.key) {
       const tabs = this.computeTabs();
       this.setState({
         currentTab: tabs[0],
@@ -122,44 +119,40 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
   };
 
   computeTabs() {
-    const { ruleDescriptionSections, codeTabContent } = this.props;
+    const { ruleDescriptionSections } = this.props;
     const descriptionSectionsByKey = groupBy(ruleDescriptionSections, (section) => section.key);
-    const rootCauseDescriptionSections =
-      descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
-      descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE];
 
     return [
       {
         value: TabKeys.Code,
         label: translate('hotspots.tabs.code'),
-        content: codeTabContent,
+        show: true,
       },
       {
         value: TabKeys.RiskDescription,
         label: translate('hotspots.tabs.risk_description'),
-        content: rootCauseDescriptionSections && (
-          <RuleDescription sections={rootCauseDescriptionSections} />
-        ),
+        show:
+          descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
+          descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE],
       },
       {
         value: TabKeys.VulnerabilityDescription,
         label: translate('hotspots.tabs.vulnerability_description'),
-        content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && (
-          <RuleDescription
-            sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]}
-          />
-        ),
+        show: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] !== undefined,
       },
       {
         value: TabKeys.FixRecommendation,
         label: translate('hotspots.tabs.fix_recommendations'),
-        content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
-          <RuleDescription
-            sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
-          />
-        ),
+        show: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] !== undefined,
+      },
+      {
+        value: TabKeys.Activity,
+        label: translate('hotspots.tabs.activity'),
+        show: true,
       },
-    ].filter((tab) => tab.content);
+    ]
+      .filter((tab) => tab.show)
+      .map((tab) => omit(tab, 'show'));
   }
 
   selectNeighboringTab(shift: number) {
@@ -178,7 +171,14 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
   }
 
   render() {
+    const { ruleDescriptionSections, codeTabContent, activityTabContent } = this.props;
     const { tabs, currentTab } = this.state;
+
+    const descriptionSectionsByKey = groupBy(ruleDescriptionSections, (section) => section.key);
+    const rootCauseDescriptionSections =
+      descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
+      descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE];
+
     return (
       <>
         <ToggleButton
@@ -193,7 +193,27 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
           id={getTabPanelId(currentTab.value)}
           role="tabpanel"
         >
-          {currentTab.content}
+          {currentTab.value === TabKeys.Code && codeTabContent}
+
+          {currentTab.value === TabKeys.RiskDescription && rootCauseDescriptionSections && (
+            <RuleDescription sections={rootCauseDescriptionSections} />
+          )}
+
+          {currentTab.value === TabKeys.VulnerabilityDescription &&
+            descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && (
+              <RuleDescription
+                sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]}
+              />
+            )}
+
+          {currentTab.value === TabKeys.FixRecommendation &&
+            descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
+              <RuleDescription
+                sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
+              />
+            )}
+
+          {currentTab.value === TabKeys.Activity && activityTabContent}
         </div>
       </>
     );
index e547eb23c7b582f4aa4084089ac533dd05e31eb1..b5f6c37d6077b38005533c5b8991a72b9fbe3971 100644 (file)
@@ -78,7 +78,10 @@ export default function StatusSelectionRenderer(props: StatusSelectionRendererPr
         {renderOption(HotspotStatusOption.ACKNOWLEDGED)}
         {renderOption(HotspotStatusOption.FIXED)}
         {renderOption(HotspotStatusOption.SAFE)}
-        <FormField htmlFor="comment-textarea" label={translate('hotspots.status.add_comment')}>
+        <FormField
+          htmlFor="comment-textarea"
+          label={translate('hotspots.status.add_comment_optional')}
+        >
           <InputTextArea
             className="sw-mb-2 sw-resize-y"
             id="comment-textarea"
index 004e00f09bc70406666cc770f1b9db30b694a3e1..8405308bf1805e77e24d472d1b35c2d41a67080c 100644 (file)
@@ -22,11 +22,11 @@ import React from 'react';
 import { translate } from '../../helpers/l10n';
 import { getFormattingHelpUrl } from '../../helpers/urls';
 
-interface Props {
+export interface FormattingTipsProps {
   className?: string;
 }
 
-export default function FormattingTips({ className }: Props) {
+export default function FormattingTips({ className }: FormattingTipsProps) {
   const handleClick = React.useCallback((evt: React.MouseEvent<HTMLAnchorElement>) => {
     evt.preventDefault();
     window.open(
index 1b6763dbe69dce9b0df657abc778d300e2747d4c..4d979bd2bbc1be714910058d4158fd79b953e745 100644 (file)
@@ -181,6 +181,9 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
 }
 
 const StyledHtmlFormatter = styled(HtmlFormatter)`
+  margin-top: 1.5rem;
+  margin-bottom: 1.5rem;
+
   .code-difference-container {
     display: flex;
     flex-direction: column;
index ba6cdf3c30737fe8f7ec510212ad844acf29590b..a5d901e8c7a25b7503ada4d589986f2ef326082c 100644 (file)
@@ -798,6 +798,7 @@ hotspots.tabs.code=Where is the risk?
 hotspots.tabs.risk_description=What's the risk?
 hotspots.tabs.vulnerability_description=Assess the risk
 hotspots.tabs.fix_recommendations=How can I fix it?
+hotspots.tabs.activity=Activity
 hotspots.review_history.created=created Security Hotspot
 hotspots.review_history.comment_added=added a comment
 hotspots.comment.field=Comment:
@@ -813,7 +814,8 @@ hotspots.status.cannot_change_status=Changing a hotspot's status requires permis
 hotspots.status.review=Review
 hotspots.status.review_title=Review Security Hotspot
 hotspots.status.select=Select a status
-hotspots.status.add_comment=Add a comment (Optional)
+hotspots.status.add_comment=Add a comment
+hotspots.status.add_comment_optional=Add a comment (Optional)
 hotspots.status.change_status=Change status
 
 hotspots.status_option.TO_REVIEW=To review
@@ -847,7 +849,7 @@ hotspot.filters.period.overall=Overall code
 hotspot.filters.status.safe=Safe
 hotspot.filters.by_file_or_list_x=Your hotspots are currently filtered, {show_all_link}
 hotspot.filters.show_all=show all hotspots
-hotspot.section.activity=Recent activity:
+hotspot.section.activity=Activity
 
 hotspots.reviewed.tooltip=Percentage of open Security Hotspots that have been reviewed (Acknowledged, Fixed or Safe)
 hotspots.review_hotspot=Review Hotspot