]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19236 Implement new design for hotspot header
authorstanislavh <stanislav.honcharov@sonarsource.com>
Tue, 16 May 2023 16:27:46 +0000 (18:27 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 24 May 2023 20:03:14 +0000 (20:03 +0000)
32 files changed:
server/sonar-web/design-system/src/components/FormField.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/SearchSelectDropdown.tsx
server/sonar-web/design-system/src/components/SearchSelectDropdownControl.tsx
server/sonar-web/design-system/src/components/Text.tsx
server/sonar-web/design-system/src/components/__tests__/FormField-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons.tsx
server/sonar-web/design-system/src/components/icons/RequiredIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/index.ts
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/Assignee.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/components/EmptyHotspotsPage.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeaderRightSection.tsx [new file with mode: 0644]
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/assignee/Assignee.tsx [deleted file]
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeRenderer.tsx [deleted file]
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelection.css [deleted file]
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelection.tsx [deleted file]
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelectionRenderer.tsx [deleted file]
server/sonar-web/src/main/js/apps/security-hotspots/components/status/Status.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusDescription.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusSelection.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusSelectionRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/styles.css
server/sonar-web/src/main/js/components/common/FormattingTips.tsx
server/sonar-web/src/main/js/components/common/__tests__/FormattingTips-test.tsx
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/FormattingTips-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/types/misc.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/design-system/src/components/FormField.tsx b/server/sonar-web/design-system/src/components/FormField.tsx
new file mode 100644 (file)
index 0000000..a6bac58
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * 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 styled from '@emotion/styled';
+import { ReactNode } from 'react';
+import tw from 'twin.macro';
+import { Highlight, Note } from './Text';
+import { RequiredIcon } from './icons';
+
+interface Props {
+  ariaLabel?: string;
+  children: ReactNode;
+  className?: string;
+  description?: string | ReactNode;
+  help?: ReactNode;
+  htmlFor?: string;
+  id?: string;
+  label: string | ReactNode;
+  required?: boolean;
+  title?: string;
+}
+
+export function FormField({
+  children,
+  className,
+  description,
+  help,
+  id,
+  required,
+  label,
+  htmlFor,
+  title,
+  ariaLabel,
+}: Props) {
+  return (
+    <FieldWrapper className={className} id={id}>
+      <label aria-label={ariaLabel} className="sw-mb-2" htmlFor={htmlFor} title={title}>
+        <Highlight className="sw-flex sw-items-center sw-gap-2">
+          {label}
+          {required && <RequiredIcon className="sw--ml-1" />}
+          {help}
+        </Highlight>
+      </label>
+
+      {children}
+
+      {description && <Note className="sw-mt-2">{description}</Note>}
+    </FieldWrapper>
+  );
+}
+
+const FieldWrapper = styled.div`
+  ${tw`sw-flex sw-flex-col sw-w-full`}
+
+  &:not(:last-of-type) {
+    ${tw`sw-mb-6`}
+  }
+`;
index 67987e450bd85fd020d371b7bd20fdfcf851c50d..7493c146ea6173f3661e7bc590a51d3ce500af3b 100644 (file)
@@ -52,6 +52,7 @@ export interface SearchSelectDropdownProps<
   Group extends GroupBase<Option> = GroupBase<Option>
 > extends SelectProps<V, Option, IsMulti, Group>,
     AsyncProps<Option, IsMulti, Group> {
+  controlAriaLabel?: string;
   controlLabel?: React.ReactNode | string;
   isDiscreet?: boolean;
 }
@@ -62,7 +63,16 @@ export function SearchSelectDropdown<
   IsMulti extends boolean = false,
   Group extends GroupBase<Option> = GroupBase<Option>
 >(props: SearchSelectDropdownProps<V, Option, IsMulti, Group>) {
-  const { isDiscreet, value, loadOptions, controlLabel, isDisabled, minLength, ...rest } = props;
+  const {
+    isDiscreet,
+    value,
+    loadOptions,
+    controlLabel,
+    isDisabled,
+    minLength,
+    controlAriaLabel,
+    ...rest
+  } = props;
   const [open, setOpen] = React.useState(false);
   const [inputValue, setInputValue] = React.useState('');
 
@@ -112,6 +122,7 @@ export function SearchSelectDropdown<
     <DropdownToggler
       allowResizing={true}
       className="sw-overflow-visible sw-border-none"
+      isPortal={true}
       onRequestClose={() => {
         toggleDropdown(false);
       }}
@@ -140,6 +151,7 @@ export function SearchSelectDropdown<
       }
     >
       <SearchSelectDropdownControl
+        ariaLabel={controlAriaLabel}
         disabled={isDisabled}
         isDiscreet={isDiscreet}
         label={controlLabel}
index fcb802f32ff01841080ab132cf4ec0e0d86b42ed..e0b50db401c7546be6efc60222e7a5a92ac1a11d 100644 (file)
@@ -27,6 +27,7 @@ import { InputSizeKeys } from '../types/theme';
 import { ChevronDownIcon } from './icons';
 
 interface SearchSelectDropdownControlProps {
+  ariaLabel?: string;
   disabled?: boolean;
   isDiscreet?: boolean;
   label?: React.ReactNode | string;
@@ -35,9 +36,10 @@ interface SearchSelectDropdownControlProps {
 }
 
 export function SearchSelectDropdownControl(props: SearchSelectDropdownControlProps) {
-  const { disabled, label, isDiscreet, onClick, size = 'full' } = props;
+  const { disabled, label, isDiscreet, onClick, size = 'full', ariaLabel = '' } = props;
   return (
     <StyledControl
+      aria-label={ariaLabel}
       className={classNames({ 'is-discreet': isDiscreet })}
       onClick={() => {
         if (!disabled) {
index f5ac7df3a6e3df82dec6fc7f1f11aa3709e1d691..aff69978ad3a1193ca4f8a1541f89b38dac256ca 100644 (file)
@@ -105,3 +105,15 @@ export const LightPrimary = styled.span`
 export const PageContentFontWrapper = styled.div`
   color: ${themeColor('pageContent')};
 `;
+
+export const Highlight = styled.strong`
+  color: ${themeColor('pageContentDark')};
+
+  ${tw`sw-body-sm-highlight`}
+`;
+
+export const Note = styled.span`
+  color: ${themeColor('pageContentLight')};
+
+  ${tw`sw-body-sm`}
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/FormField-test.tsx b/server/sonar-web/design-system/src/components/__tests__/FormField-test.tsx
new file mode 100644 (file)
index 0000000..ee86ad3
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * 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 { screen } from '@testing-library/react';
+import { FCProps } from '~types/misc';
+import { render } from '../../helpers/testUtils';
+import { FormField } from '../FormField';
+
+it('should render correctly', () => {
+  renderFormField({}, <input id="input" />);
+  expect(screen.getByLabelText('Hello')).toBeInTheDocument();
+});
+
+it('should render with required and description', () => {
+  renderFormField({ description: 'some description', required: true }, <input id="input" />);
+  expect(screen.getByText('some description')).toBeInTheDocument();
+  expect(screen.getByText('*')).toBeInTheDocument();
+});
+
+function renderFormField(
+  props: Partial<FCProps<typeof FormField>> = {},
+  children: any = <div>Fake input</div>
+) {
+  return render(
+    <FormField htmlFor="input" label="Hello" {...props}>
+      {children}
+    </FormField>
+  );
+}
index 28650a777c0cd56d60fb867934f033d90fca16d6..c6d2ccd3ac843e640bb8b14d117e6da8e3a2a40a 100644 (file)
@@ -38,7 +38,7 @@ export interface ButtonProps extends AllowedButtonAttributes {
   disabled?: boolean;
   icon?: React.ReactNode;
   innerRef?: React.Ref<HTMLButtonElement>;
-  onClick?: (event?: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;
+  onClick?: (event?: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => unknown;
 
   preventDefault?: boolean;
   reloadDocument?: LinkProps['reloadDocument'];
diff --git a/server/sonar-web/design-system/src/components/icons/RequiredIcon.tsx b/server/sonar-web/design-system/src/components/icons/RequiredIcon.tsx
new file mode 100644 (file)
index 0000000..d39fbc6
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * 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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { themeColor } from '../../helpers/theme';
+
+export function RequiredIcon(props: React.ComponentPropsWithoutRef<'em'>) {
+  return <StyledEm {...props}>*</StyledEm>;
+}
+
+export const StyledEm = styled.em`
+  ${tw`sw-body-sm`}
+  ${tw`sw-not-italic`}
+  ${tw`sw-ml-2`}
+  color: ${themeColor('inputRequired')};
+`;
index 6f4a7a4c893874769f3b0b4936785d31d1d793fe..301e4c08af8e747d0b298701091d8e344c767ecb 100644 (file)
@@ -53,6 +53,7 @@ export { PencilIcon } from './PencilIcon';
 export { ProjectIcon } from './ProjectIcon';
 export { PullRequestIcon } from './PullRequestIcon';
 export { RefreshIcon } from './RefreshIcon';
+export { RequiredIcon } from './RequiredIcon';
 export { SecurityHotspotIcon } from './SecurityHotspotIcon';
 export { SeparatorCircleIcon } from './SeparatorCircleIcon';
 export { SeverityBlockerIcon } from './SeverityBlockerIcon';
index edff1756a63348fce6d1e00f8716cd6e4287969d..7e500593b0878b2747309adb47047d0ea304beb3 100644 (file)
@@ -39,6 +39,7 @@ export * from './FacetItem';
 export { FailedQGConditionLink } from './FailedQGConditionLink';
 export { FlagMessage } from './FlagMessage';
 export * from './FlowStep';
+export * from './FormField';
 export * from './GenericAvatar';
 export * from './HighlightedSection';
 export { HotspotRating } from './HotspotRating';
index f37e044ce2ab67658f8f1117932f008326f7690b..7498eb4c69612bbbce375e69ee2ff6b32d583185 100644 (file)
@@ -17,7 +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 { withTheme } from '@emotion/react';
+import { useTheme, withTheme } from '@emotion/react';
 import styled from '@emotion/styled';
 import {
   LargeCenteredLayout,
@@ -97,6 +97,8 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
     onShowAllHotspots,
   } = props;
 
+  const theme = useTheme();
+
   return (
     <>
       <Suggestions suggestions="security_hotspots" />
@@ -155,28 +157,30 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
               )}
             </StyledFilterbar>
 
-            <main className="sw-col-span-8">
-              {hotspots.length === 0 || !selectedHotspot ? (
-                <EmptyHotspotsPage
-                  filtered={
-                    filters.assignedToMe ||
-                    (isBranch(branchLike) && filters.inNewCodePeriod) ||
-                    filters.status !== HotspotStatusFilter.TO_REVIEW
-                  }
-                  filterByFile={Boolean(filterByFile)}
-                  isStaticListOfHotspots={isStaticListOfHotspots}
-                />
-              ) : (
-                <HotspotViewer
-                  component={component}
-                  hotspotKey={selectedHotspot.key}
-                  hotspotsReviewedMeasure={hotspotsReviewedMeasure}
-                  onSwitchStatusFilter={props.onSwitchStatusFilter}
-                  onUpdateHotspot={props.onUpdateHotspot}
-                  onLocationClick={props.onLocationClick}
-                  selectedHotspotLocation={selectedHotspotLocation}
-                />
-              )}
+            <main className="sw-col-span-8 sw-pl-12">
+              <StyledContentWrapper theme={theme} className="sw-h-full">
+                {hotspots.length === 0 || !selectedHotspot ? (
+                  <EmptyHotspotsPage
+                    filtered={
+                      filters.assignedToMe ||
+                      (isBranch(branchLike) && filters.inNewCodePeriod) ||
+                      filters.status !== HotspotStatusFilter.TO_REVIEW
+                    }
+                    filterByFile={Boolean(filterByFile)}
+                    isStaticListOfHotspots={isStaticListOfHotspots}
+                  />
+                ) : (
+                  <HotspotViewer
+                    component={component}
+                    hotspotKey={selectedHotspot.key}
+                    onSwitchStatusFilter={props.onSwitchStatusFilter}
+                    onUpdateHotspot={props.onUpdateHotspot}
+                    onLocationClick={props.onLocationClick}
+                    selectedHotspotLocation={selectedHotspotLocation}
+                    standards={standards}
+                  />
+                )}
+              </StyledContentWrapper>
             </main>
           </div>
         </PageContentFontWrapper>
@@ -196,3 +200,11 @@ const StyledFilterbar = withTheme(
     height: calc(100vh - ${'100px'});
   `
 );
+
+const StyledContentWrapper = withTheme(
+  styled.div`
+    background-color: ${themeColor('backgroundSecondary')};
+    border-right: ${themeBorder('default', 'pageBlockBorder')};
+    border-left: ${themeBorder('default', 'pageBlockBorder')};
+  `
+);
index 7564ee0962fa14c84b6d44c80bfd87bd2d3bb24d..1f3c205ebccced6ee4b15c825eeba5a66593d27c 100644 (file)
@@ -17,7 +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 { act, screen, within } from '@testing-library/react';
+import { act, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import React from 'react';
 import { Route } from 'react-router-dom';
@@ -44,13 +44,7 @@ jest.mock('../../../api/quality-profiles');
 jest.mock('../../../api/issues');
 
 const ui = {
-  inputAssignee: byRole('searchbox', { name: 'hotspots.assignee.select_user' }),
-  selectStatusButton: byRole('button', {
-    name: 'hotspots.status.select_status',
-  }),
-  editAssigneeButton: byRole('button', {
-    name: 'hotspots.assignee.change_user',
-  }),
+  inputAssignee: byRole('combobox', { name: 'search.search_for_users' }),
   filterAssigneeToMe: byRole('checkbox', {
     name: 'hotspot.filters.assignee.assigned_to_me',
   }),
@@ -61,7 +55,7 @@ const ui = {
   filterByPeriod: byRole('combobox', { name: 'hotspot.filters.period' }),
   filterNewCode: byRole('checkbox', { name: 'hotspot.filters.period.since_leak_period' }),
   noHotspotForFilter: byText('hotspots.no_hotspots_for_filters.title'),
-  selectStatus: byRole('button', { name: 'hotspots.status.select_status' }),
+  reviewButton: byRole('button', { name: 'hotspots.status.review' }),
   toReviewStatus: byText('hotspots.status_option.TO_REVIEW'),
   changeStatus: byRole('button', { name: 'hotspots.status.change_status' }),
   hotspotTitle: (name: string | RegExp) => byRole('heading', { name }),
@@ -72,7 +66,7 @@ const ui = {
   commentEditButton: byRole('button', { name: 'issue.comment.edit' }),
   commentDeleteButton: byRole('button', { name: 'issue.comment.delete' }),
   textboxWithText: (value: string) => byDisplayValue(value),
-  activeAssignee: byTestId('assignee-name'),
+  activeAssignee: byRole('combobox', { name: 'hotspots.assignee.change_user' }),
   successGlobalMessage: byTestId('global-message__SUCCESS'),
   currentUserSelectionItem: byText('foo'),
   panel: byTestId('security-hotspot-test'),
@@ -145,7 +139,7 @@ describe('CRUD', () => {
 
     expect(await ui.activeAssignee.find()).toHaveTextContent('John Doe');
 
-    await user.click(ui.editAssigneeButton.get());
+    await user.click(ui.activeAssignee.get());
     await user.click(ui.currentUserSelectionItem.get());
 
     expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.foo`);
@@ -156,13 +150,13 @@ describe('CRUD', () => {
     const user = userEvent.setup();
     renderSecurityHotspotsApp();
 
-    await user.click(await ui.editAssigneeButton.find());
+    await user.click(await ui.activeAssignee.find());
     await user.click(ui.inputAssignee.get());
 
     await user.keyboard('User');
 
     expect(searchUsers).toHaveBeenLastCalledWith({ q: 'User' });
-    await user.keyboard('{ArrowDown}{Enter}');
+    await user.keyboard('{Enter}');
     expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.User John`);
   });
 
@@ -172,9 +166,9 @@ describe('CRUD', () => {
 
     renderSecurityHotspotsApp();
 
-    expect(await ui.selectStatus.find()).toBeInTheDocument();
+    expect(await ui.reviewButton.find()).toBeInTheDocument();
 
-    await user.click(ui.selectStatus.get());
+    await user.click(ui.reviewButton.get());
     await user.click(ui.toReviewStatus.get());
 
     await user.click(screen.getByRole('textbox', { name: 'hotspots.status.add_comment' }));
@@ -196,31 +190,7 @@ describe('CRUD', () => {
   it('should not be able to change the status if does not have edit permissions', async () => {
     hotspotsHandler.setHotspotChangeStatusPermission(false);
     renderSecurityHotspotsApp();
-    expect(await ui.selectStatus.find()).toBeDisabled();
-  });
-
-  it('should remember the comment when toggling change status panel for the same security hotspot', async () => {
-    const user = userEvent.setup();
-    renderSecurityHotspotsApp();
-
-    await user.click(await ui.selectStatusButton.find());
-    const comment = 'This is a comment';
-
-    const commentSection = within(ui.panel.get()).getByRole('textbox');
-    await user.click(commentSection);
-    await user.keyboard(comment);
-
-    // Close the panel
-    await act(async () => {
-      await user.keyboard('{Escape}');
-    });
-
-    // Check panel is closed
-    expect(ui.panel.query()).not.toBeInTheDocument();
-
-    await user.click(ui.selectStatusButton.get());
-
-    expect(await screen.findByText(comment)).toBeInTheDocument();
+    expect(await ui.reviewButton.find()).toBeDisabled();
   });
 
   it('should be able to add, edit and remove own comments', async () => {
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/Assignee.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/Assignee.tsx
new file mode 100644 (file)
index 0000000..9ecf494
--- /dev/null
@@ -0,0 +1,129 @@
+/*
+ * 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 { Avatar, LabelValueSelectOption, SearchSelectDropdown } from 'design-system';
+import { noop } from 'lodash';
+import * as React from 'react';
+import { Options, SingleValue } from 'react-select';
+import { assignSecurityHotspot } from '../../../api/security-hotspots';
+import { searchUsers } from '../../../api/users';
+import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext';
+import { addGlobalSuccessMessage } from '../../../helpers/globalMessages';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { Hotspot, HotspotResolution, HotspotStatus } from '../../../types/security-hotspots';
+import { isLoggedIn, isUserActive } from '../../../types/users';
+
+interface Props {
+  hotspot: Hotspot;
+  onAssigneeChange: () => Promise<void>;
+}
+
+const minSearchLength = 2;
+
+const UNASSIGNED = { value: '', label: translate('unassigned') };
+
+const renderAvatar = (name?: string, avatar?: string) => (
+  <Avatar hash={avatar} name={name} size="xs" />
+);
+
+export default function Assignee(props: Props) {
+  const {
+    hotspot: { assigneeUser, status, resolution, key },
+  } = props;
+  const { currentUser } = React.useContext(CurrentUserContext);
+
+  const allowCurrentUserSelection =
+    isLoggedIn(currentUser) && currentUser?.login !== assigneeUser?.login;
+
+  const defaultOptions = allowCurrentUserSelection
+    ? [
+        UNASSIGNED,
+        {
+          value: currentUser.login,
+          label: currentUser.name,
+          Icon: renderAvatar(currentUser.name, currentUser.avatar),
+        },
+      ]
+    : [UNASSIGNED];
+
+  const canEdit =
+    status === HotspotStatus.TO_REVIEW || resolution === HotspotResolution.ACKNOWLEDGED;
+
+  const controlLabel = assigneeUser ? (
+    <>
+      {renderAvatar(assigneeUser?.name, assigneeUser.avatar)} {assigneeUser.name}
+    </>
+  ) : (
+    UNASSIGNED.label
+  );
+
+  const handleSearchAssignees = (
+    query: string,
+    cb: (options: Options<LabelValueSelectOption<string>>) => void
+  ) => {
+    searchUsers({ q: query })
+      .then((result) => {
+        const options: Array<LabelValueSelectOption<string>> = result.users
+          .filter(isUserActive)
+          .map((u) => ({
+            label: u.name ?? u.login,
+            value: u.login,
+            Icon: renderAvatar(u.name, u.avatar),
+          }));
+        cb(options);
+      })
+      .catch(() => {
+        cb([]);
+      });
+  };
+
+  const handleAssign = (userOption: SingleValue<LabelValueSelectOption<string>>) => {
+    if (userOption) {
+      assignSecurityHotspot(key, {
+        assignee: userOption.value,
+      })
+        .then(() => {
+          props.onAssigneeChange();
+          addGlobalSuccessMessage(
+            userOption.value
+              ? translateWithParameters('hotspots.assign.success', userOption.label)
+              : translate('hotspots.assign.unassign.success')
+          );
+        })
+        .catch(noop);
+    }
+  };
+
+  return (
+    <SearchSelectDropdown
+      size="medium"
+      isDisabled={!canEdit || !isLoggedIn(currentUser)}
+      controlAriaLabel={translate('hotspots.assignee.change_user')}
+      defaultOptions={defaultOptions}
+      onChange={handleAssign}
+      loadOptions={handleSearchAssignees}
+      minLength={minSearchLength}
+      isDiscreet={true}
+      controlLabel={controlLabel}
+      tooShortText={translateWithParameters('search.tooShort', String(minSearchLength))}
+      placeholder={translate('search.search_for_users')}
+      aria-label={translate('search.search_for_users')}
+    />
+  );
+}
index 86bbb748b2af832746ddf215f6b687e03e0524e9..5595285db67eee44e93ff2c1e810eb612fcac98d 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 { Note } from 'design-system';
 import * as React from 'react';
 import DocLink from '../../../components/common/DocLink';
 import { translate } from '../../../helpers/l10n';
@@ -43,19 +44,21 @@ export default function EmptyHotspotsPage(props: EmptyHotspotsPageProps) {
   }
 
   return (
-    <div className="display-flex-column display-flex-center huge-spacer-top">
+    <div className="sw-items-center sw-justify-center sw-flex-col sw-flex sw-pt-16">
       <img
         alt={translate('hotspots.page')}
-        className="huge-spacer-top"
+        className="sw-mt-8"
         height={100}
         src={`${getBaseUrl()}/images/${
           filtered && !filterByFile ? 'filter-large' : 'hotspot-large'
         }.svg`}
       />
-      <h1 className="huge-spacer-top">{translate(`hotspots.${translationRoot}.title`)}</h1>
-      <div className="abs-width-400 text-center big-spacer-top">
+      <h1 className="sw-mt-10 sw-body-sm-highlight">
+        {translate(`hotspots.${translationRoot}.title`)}
+      </h1>
+      <Note className="sw-w-abs-400 sw-text-center sw-mt-4">
         {translate(`hotspots.${translationRoot}.description`)}
-      </div>
+      </Note>
       {!(filtered || isStaticListOfHotspots) && (
         <DocLink className="big-spacer-top" to="/user-guide/security-hotspots/">
           {translate('hotspots.learn_more')}
index 9f1283cb2f6054a17a02d40f546b830414c916fa..12a5189b10d6bf9c22a896c8b865197350129e1e 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 { withTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import {
+  ClipboardIconButton,
+  LAYOUT_GLOBAL_NAV_HEIGHT,
+  LAYOUT_PROJECT_NAV_HEIGHT,
+  LightLabel,
+  LightPrimary,
+  Link,
+  LinkIcon,
+  StyledPageTitle,
+  themeColor,
+} from 'design-system';
 import React from 'react';
-import Link from '../../../components/common/Link';
-import Tooltip from '../../../components/controls/Tooltip';
 import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting';
-import { translate } from '../../../helpers/l10n';
-import { getRuleUrl } from '../../../helpers/urls';
+import { getBranchLikeQuery } from '../../../helpers/branch-like';
+import {
+  getComponentSecurityHotspotsUrl,
+  getPathUrlAsString,
+  getRuleUrl,
+} from '../../../helpers/urls';
+import { BranchLike } from '../../../types/branch-like';
+import { SecurityStandard, Standards } from '../../../types/security';
 import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
-import Assignee from './assignee/Assignee';
+import { Component } from '../../../types/types';
+import HotspotHeaderRightSection from './HotspotHeaderRightSection';
 import Status from './status/Status';
 
 export interface HotspotHeaderProps {
   hotspot: Hotspot;
+  component: Component;
+  branchLike?: BranchLike;
+  standards?: Standards;
   onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
 }
 
 export function HotspotHeader(props: HotspotHeaderProps) {
-  const { hotspot } = props;
-  const { message, messageFormattings, rule } = hotspot;
+  const { hotspot, component, branchLike, standards } = props;
+  const { message, messageFormattings, rule, key } = hotspot;
+
+  const permalink = getPathUrlAsString(
+    getComponentSecurityHotspotsUrl(component.key, {
+      ...getBranchLikeQuery(branchLike),
+      hotspots: key,
+    }),
+    false
+  );
+
+  const categoryStandard = standards?.[SecurityStandard.SONARSOURCE][rule.securityCategory]?.title;
+
   return (
-    <div className="huge-spacer-bottom hotspot-header">
-      <div className="display-flex-column big-spacer-bottom">
-        <h2 className="big text-bold">
-          <IssueMessageHighlighting message={message} messageFormattings={messageFormattings} />
-        </h2>
-        <div className="spacer-top">
-          <span className="note padded-right">{rule.name}</span>
-          <Link className="small" to={getRuleUrl(rule.key)} target="_blank">
-            {rule.key}
-          </Link>
-        </div>
-      </div>
-      <div className="display-flex-space-between">
-        <Status
-          hotspot={hotspot}
-          onStatusChange={(statusOption) => props.onUpdateHotspot(true, statusOption)}
-        />
-        <div className="display-flex-end display-flex-column abs-width-240">
-          {hotspot.codeVariants && hotspot.codeVariants.length > 0 && (
-            <Tooltip overlay={hotspot.codeVariants.join(', ')}>
-              <div className="spacer-bottom display-flex-center">
-                <div>{translate('issues.facet.codeVariants')}:</div>
-                <div className="text-bold spacer-left spacer-right text-ellipsis">
-                  {hotspot.codeVariants.join(', ')}
-                </div>
-              </div>
-            </Tooltip>
-          )}
-          <div className="display-flex-center it__hs-assignee">
-            <div className="big-spacer-right">{translate('assignee')}:</div>
-            <Assignee hotspot={hotspot} onAssigneeChange={props.onUpdateHotspot} />
+    <Header
+      className="sw-sticky sw--mx-6 sw--mt-6 sw-px-6 sw-pt-6 sw-z-filterbar-header"
+      style={{ top: `${LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT - 2}px` }}
+    >
+      <div className="sw-flex sw-justify-between sw-gap-8 sw-mb-4 sw-pb-4">
+        <div className="sw-flex-1">
+          <StyledPageTitle as="h2" className="sw-whitespace-normal sw-overflow-visible">
+            <LightPrimary>
+              <IssueMessageHighlighting message={message} messageFormattings={messageFormattings} />
+            </LightPrimary>
+            <ClipboardIconButton
+              Icon={LinkIcon}
+              className="sw-ml-2"
+              copyValue={permalink}
+              discreet={true}
+            />
+          </StyledPageTitle>
+          <div className="sw-mt-2 sw-mb-4 sw-body-sm">
+            <LightLabel>{rule.name}</LightLabel>
+            <Link className="sw-ml-1" to={getRuleUrl(rule.key)} target="_blank">
+              {rule.key}
+            </Link>
           </div>
+          <Status
+            hotspot={hotspot}
+            onStatusChange={(statusOption) => props.onUpdateHotspot(true, statusOption)}
+          />
+        </div>
+        <div className="sw-flex sw-flex-col sw-gap-4">
+          <HotspotHeaderRightSection
+            hotspot={hotspot}
+            categoryStandard={categoryStandard}
+            onUpdateHotspot={props.onUpdateHotspot}
+          />
         </div>
       </div>
-    </div>
+    </Header>
   );
 }
+
+const Header = withTheme(styled.div`
+  background-color: ${themeColor('pageBlock')};
+`);
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeaderRightSection.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeaderRightSection.tsx
new file mode 100644 (file)
index 0000000..a2ce547
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * 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 classNames from 'classnames';
+import { HotspotRating, LightLabel } from 'design-system';
+import React from 'react';
+import Tooltip from '../../../components/controls/Tooltip';
+import { translate } from '../../../helpers/l10n';
+import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
+import Assignee from './Assignee';
+
+interface Props {
+  hotspot: Hotspot;
+  categoryStandard?: string;
+  onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
+}
+
+export default function HotspotHeaderRightSection(props: Props) {
+  const { hotspot, categoryStandard } = props;
+  return (
+    <>
+      <HotspotHeaderInfo title={translate('hotspots.risk_exposure')}>
+        <div className="sw-flex sw-items-center">
+          <HotspotRating className="sw-mr-1" rating={hotspot.rule.vulnerabilityProbability} />
+          <LightLabel className="sw-body-sm">
+            {translate('risk_exposure', hotspot.rule.vulnerabilityProbability)}
+          </LightLabel>
+        </div>
+      </HotspotHeaderInfo>
+      <HotspotHeaderInfo title={translate('category')}>
+        <LightLabel className="sw-body-sm">{categoryStandard}</LightLabel>
+      </HotspotHeaderInfo>
+      {hotspot.codeVariants && hotspot.codeVariants.length > 0 && (
+        <HotspotHeaderInfo title={translate('issues.facet.codeVariants')} className="sw-truncate">
+          <LightLabel className="sw-body-sm">
+            <Tooltip overlay={hotspot.codeVariants.join(', ')}>
+              <span>{hotspot.codeVariants.join(', ')}</span>
+            </Tooltip>
+          </LightLabel>
+        </HotspotHeaderInfo>
+      )}
+      <HotspotHeaderInfo title={translate('assignee')}>
+        <Assignee hotspot={hotspot} onAssigneeChange={props.onUpdateHotspot} />
+      </HotspotHeaderInfo>
+    </>
+  );
+}
+
+interface HotspotHeaderInfoProps {
+  children: React.ReactNode;
+  title: string;
+  className?: string;
+}
+
+function HotspotHeaderInfo({ children, title, className }: HotspotHeaderInfoProps) {
+  return (
+    <div className={classNames('sw-min-w-abs-150 sw-max-w-abs-250', className)}>
+      <div className="sw-body-sm-highlight">{title}:</div>
+      {children}
+    </div>
+  );
+}
index f02dcb8483bf5914911231b991192deaeb462886..dac14d335d7551f0dca38ebf9a26647d41c4f591 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import { getRuleDetails } from '../../../api/rules';
 import { getSecurityHotspotDetails } from '../../../api/security-hotspots';
+import { Standards } from '../../../types/security';
 import {
   Hotspot,
   HotspotStatusFilter,
@@ -33,11 +34,11 @@ import HotspotViewerRenderer from './HotspotViewerRenderer';
 interface Props {
   component: Component;
   hotspotKey: string;
-  hotspotsReviewedMeasure?: string;
   onSwitchStatusFilter: (option: HotspotStatusFilter) => void;
   onUpdateHotspot: (hotspotKey: string) => Promise<void>;
   onLocationClick: (index: number) => void;
   selectedHotspotLocation?: number;
+  standards?: Standards;
 }
 
 interface State {
@@ -45,7 +46,6 @@ interface State {
   ruleDescriptionSections?: RuleDescriptionSection[];
   lastStatusChangedTo?: HotspotStatusOption;
   loading: boolean;
-  showStatusUpdateSuccessModal: boolean;
 }
 
 export default class HotspotViewer extends React.PureComponent<Props, State> {
@@ -56,7 +56,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
   constructor(props: Props) {
     super(props);
     this.commentTextRef = React.createRef<HTMLTextAreaElement>();
-    this.state = { loading: false, showStatusUpdateSuccessModal: false };
+    this.state = { loading: false };
   }
 
   componentDidMount() {
@@ -98,7 +98,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
     const { hotspotKey } = this.props;
 
     if (statusUpdate) {
-      this.setState({ lastStatusChangedTo: statusOption, showStatusUpdateSuccessModal: true });
+      this.setState({ lastStatusChangedTo: statusOption });
       await this.props.onUpdateHotspot(hotspotKey);
     } else {
       await this.fetchHotspot();
@@ -123,35 +123,21 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
     }
   };
 
-  handleCloseStatusUpdateSuccessModal = () => {
-    this.setState({ showStatusUpdateSuccessModal: false });
-  };
-
   render() {
-    const { component, hotspotsReviewedMeasure, selectedHotspotLocation } = this.props;
-    const {
-      hotspot,
-      ruleDescriptionSections,
-      lastStatusChangedTo,
-      loading,
-      showStatusUpdateSuccessModal,
-    } = this.state;
+    const { component, selectedHotspotLocation, standards } = this.props;
+    const { hotspot, ruleDescriptionSections, loading } = this.state;
 
     return (
       <HotspotViewerRenderer
+        standards={standards}
         component={component}
         commentTextRef={this.commentTextRef}
         hotspot={hotspot}
         ruleDescriptionSections={ruleDescriptionSections}
-        hotspotsReviewedMeasure={hotspotsReviewedMeasure}
-        lastStatusChangedTo={lastStatusChangedTo}
         loading={loading}
-        onCloseStatusUpdateSuccessModal={this.handleCloseStatusUpdateSuccessModal}
-        onSwitchFilterToStatusOfUpdatedHotspot={this.handleSwitchFilterToStatusOfUpdatedHotspot}
         onShowCommentForm={this.handleScrollToCommentForm}
         onUpdateHotspot={this.handleHotspotUpdate}
         onLocationClick={this.props.onLocationClick}
-        showStatusUpdateSuccessModal={showStatusUpdateSuccessModal}
         selectedHotspotLocation={selectedHotspotLocation}
       />
     );
index d1472ed13627033af388616b6d06d7bf4eca1d26..f5d795791839613cf83151a5871d78a5041869f6 100644 (file)
@@ -21,6 +21,7 @@ import * as React from 'react';
 import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
 import DeferredSpinner from '../../../components/ui/DeferredSpinner';
 import { fillBranchLike } from '../../../helpers/branch-like';
+import { Standards } from '../../../types/security';
 import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
 import { Component } from '../../../types/types';
 import { CurrentUser } from '../../../types/users';
@@ -30,24 +31,19 @@ import HotspotReviewHistoryAndComments from './HotspotReviewHistoryAndComments';
 import HotspotSnippetContainer from './HotspotSnippetContainer';
 import './HotspotViewer.css';
 import HotspotViewerTabs from './HotspotViewerTabs';
-import StatusUpdateSuccessModal from './StatusUpdateSuccessModal';
 
 export interface HotspotViewerRendererProps {
   component: Component;
   currentUser: CurrentUser;
   hotspot?: Hotspot;
   ruleDescriptionSections?: RuleDescriptionSection[];
-  hotspotsReviewedMeasure?: string;
-  lastStatusChangedTo?: HotspotStatusOption;
   loading: boolean;
   commentTextRef: React.RefObject<HTMLTextAreaElement>;
-  onCloseStatusUpdateSuccessModal: () => void;
   onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
   onShowCommentForm: () => void;
-  onSwitchFilterToStatusOfUpdatedHotspot: () => void;
   onLocationClick: (index: number) => void;
-  showStatusUpdateSuccessModal: boolean;
   selectedHotspotLocation?: number;
+  standards?: Standards;
 }
 
 export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
@@ -55,33 +51,30 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
     component,
     currentUser,
     hotspot,
-    hotspotsReviewedMeasure,
     loading,
-    lastStatusChangedTo,
-    showStatusUpdateSuccessModal,
     commentTextRef,
     selectedHotspotLocation,
     ruleDescriptionSections,
+    standards,
   } = props;
 
+  const branchLike = hotspot && fillBranchLike(hotspot.project.branch, hotspot.project.pullRequest);
+
   return (
     <DeferredSpinner className="big-spacer-left big-spacer-top" loading={loading}>
-      {showStatusUpdateSuccessModal && (
-        <StatusUpdateSuccessModal
-          hotspotsReviewedMeasure={hotspotsReviewedMeasure}
-          lastStatusChangedTo={lastStatusChangedTo}
-          onClose={props.onCloseStatusUpdateSuccessModal}
-          onSwitchFilterToStatusOfUpdatedHotspot={props.onSwitchFilterToStatusOfUpdatedHotspot}
-        />
-      )}
-
       {hotspot && (
-        <div className="big-padded hotspot-content">
-          <HotspotHeader hotspot={hotspot} onUpdateHotspot={props.onUpdateHotspot} />
+        <div className="sw-box-border sw-p-6">
+          <HotspotHeader
+            hotspot={hotspot}
+            component={component}
+            standards={standards}
+            onUpdateHotspot={props.onUpdateHotspot}
+            branchLike={branchLike}
+          />
           <HotspotViewerTabs
             codeTabContent={
               <HotspotSnippetContainer
-                branchLike={fillBranchLike(hotspot.project.branch, hotspot.project.pullRequest)}
+                branchLike={branchLike}
                 component={component}
                 hotspot={hotspot}
                 onCommentButtonClick={props.onShowCommentForm}
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/Assignee.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/Assignee.tsx
deleted file mode 100644 (file)
index b108ce3..0000000
+++ /dev/null
@@ -1,110 +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 { assignSecurityHotspot } from '../../../../api/security-hotspots';
-import withCurrentUserContext from '../../../../app/components/current-user/withCurrentUserContext';
-import { addGlobalSuccessMessage } from '../../../../helpers/globalMessages';
-import { translate, translateWithParameters } from '../../../../helpers/l10n';
-import { Hotspot, HotspotResolution, HotspotStatus } from '../../../../types/security-hotspots';
-import { CurrentUser, isLoggedIn, UserActive } from '../../../../types/users';
-import AssigneeRenderer from './AssigneeRenderer';
-
-interface Props {
-  currentUser: CurrentUser;
-  hotspot: Hotspot;
-
-  onAssigneeChange: () => void;
-}
-
-interface State {
-  editing: boolean;
-  loading: boolean;
-}
-
-export class Assignee extends React.PureComponent<Props, State> {
-  mounted = false;
-  state = {
-    editing: false,
-    loading: false,
-  };
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  handleEnterEditionMode = () => {
-    this.setState({ editing: true });
-  };
-
-  handleExitEditionMode = () => {
-    this.setState({ editing: false });
-  };
-
-  handleAssign = (newAssignee: UserActive) => {
-    this.setState({ loading: true });
-    assignSecurityHotspot(this.props.hotspot.key, {
-      assignee: newAssignee?.login,
-    })
-      .then(() => {
-        if (this.mounted) {
-          this.setState({ editing: false, loading: false });
-          this.props.onAssigneeChange();
-        }
-      })
-      .then(() =>
-        addGlobalSuccessMessage(
-          newAssignee.login
-            ? translateWithParameters('hotspots.assign.success', newAssignee.name)
-            : translate('hotspots.assign.unassign.success')
-        )
-      )
-      .catch(() => this.setState({ loading: false }));
-  };
-
-  render() {
-    const {
-      currentUser,
-      hotspot: { assigneeUser, status, resolution },
-    } = this.props;
-    const { editing, loading } = this.state;
-
-    const canEdit =
-      status === HotspotStatus.TO_REVIEW || resolution === HotspotResolution.ACKNOWLEDGED;
-
-    return (
-      <AssigneeRenderer
-        assignee={assigneeUser}
-        canEdit={canEdit}
-        editing={editing}
-        loading={loading}
-        loggedInUser={isLoggedIn(currentUser) ? currentUser : undefined}
-        onAssign={this.handleAssign}
-        onEnterEditionMode={this.handleEnterEditionMode}
-        onExitEditionMode={this.handleExitEditionMode}
-      />
-    );
-  }
-}
-
-export default withCurrentUserContext(Assignee);
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeRenderer.tsx
deleted file mode 100644 (file)
index 5a4f3af..0000000
+++ /dev/null
@@ -1,79 +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 { EditButton } from '../../../../components/controls/buttons';
-import EscKeydownHandler from '../../../../components/controls/EscKeydownHandler';
-import OutsideClickHandler from '../../../../components/controls/OutsideClickHandler';
-import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
-import { translate, translateWithParameters } from '../../../../helpers/l10n';
-import { LoggedInUser, UserActive, UserBase } from '../../../../types/users';
-import AssigneeSelection from './AssigneeSelection';
-
-export interface AssigneeRendererProps {
-  canEdit: boolean;
-  editing: boolean;
-  loading: boolean;
-
-  assignee?: UserBase;
-  loggedInUser?: LoggedInUser;
-
-  onAssign: (user: UserActive) => void;
-  onEnterEditionMode: () => void;
-  onExitEditionMode: () => void;
-}
-
-export default function AssigneeRenderer(props: AssigneeRendererProps) {
-  const { assignee, canEdit, loggedInUser, editing, loading } = props;
-
-  return (
-    <DeferredSpinner loading={loading}>
-      {!editing && (
-        <div className="display-flex-center">
-          <strong className="nowrap" data-testid="assignee-name">
-            {assignee &&
-              (assignee.active
-                ? assignee.name ?? assignee.login
-                : translateWithParameters('user.x_deleted', assignee.name ?? assignee.login))}
-            {!assignee && translate('unassigned')}
-          </strong>
-          {loggedInUser && canEdit && (
-            <EditButton
-              aria-label={translate('hotspots.assignee.change_user')}
-              className="spacer-left"
-              onClick={props.onEnterEditionMode}
-            />
-          )}
-        </div>
-      )}
-
-      {loggedInUser && editing && (
-        <EscKeydownHandler onKeydown={props.onExitEditionMode}>
-          <OutsideClickHandler onClickOutside={props.onExitEditionMode}>
-            <AssigneeSelection
-              allowCurrentUserSelection={loggedInUser.login !== assignee?.login}
-              loggedInUser={loggedInUser}
-              onSelect={props.onAssign}
-            />
-          </OutsideClickHandler>
-        </EscKeydownHandler>
-      )}
-    </DeferredSpinner>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelection.css b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelection.css
deleted file mode 100644 (file)
index 5fd360a..0000000
+++ /dev/null
@@ -1,31 +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.
- */
-.hotspot-assignee-search-results {
-  min-width: 300px;
-}
-
-.hotspot-assignee-search-results li {
-  cursor: pointer;
-}
-
-.hotspot-assignee-search-results li:hover,
-.hotspot-assignee-search-results li.active {
-  background-color: var(--barBackgroundColor);
-}
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelection.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelection.tsx
deleted file mode 100644 (file)
index 13eb7a0..0000000
+++ /dev/null
@@ -1,178 +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 { debounce } from 'lodash';
-import * as React from 'react';
-import { searchUsers } from '../../../../api/users';
-import { KeyboardKeys } from '../../../../helpers/keycodes';
-import { translate } from '../../../../helpers/l10n';
-import { isUserActive, LoggedInUser, UserActive } from '../../../../types/users';
-import AssigneeSelectionRenderer from './AssigneeSelectionRenderer';
-
-interface Props {
-  allowCurrentUserSelection: boolean;
-  loggedInUser: LoggedInUser;
-  onSelect: (user: UserActive) => void;
-}
-
-interface State {
-  highlighted?: UserActive;
-  loading: boolean;
-  query?: string;
-  suggestedUsers: UserActive[];
-}
-
-const UNASSIGNED: UserActive = { login: '', name: translate('unassigned') };
-
-export default class AssigneeSelection extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      loading: false,
-      suggestedUsers: props.allowCurrentUserSelection
-        ? [props.loggedInUser, UNASSIGNED]
-        : [UNASSIGNED],
-    };
-
-    this.handleSearch = debounce(this.handleSearch, 250);
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  handleSearch = (query: string) => {
-    if (this.mounted) {
-      if (query.length < 2) {
-        this.handleNoSearch(query);
-      } else {
-        this.handleActualSearch(query);
-      }
-    }
-  };
-
-  handleNoSearch = (query: string) => {
-    const { allowCurrentUserSelection, loggedInUser } = this.props;
-
-    this.setState({
-      loading: false,
-      query,
-      suggestedUsers: allowCurrentUserSelection ? [loggedInUser, UNASSIGNED] : [UNASSIGNED],
-    });
-  };
-
-  handleActualSearch = (query: string) => {
-    this.setState({ loading: true, query });
-    searchUsers({ q: query })
-      .then((result) => {
-        if (this.mounted) {
-          this.setState({
-            loading: false,
-            query,
-            suggestedUsers: (result.users.filter(isUserActive) as UserActive[]).concat(UNASSIGNED),
-          });
-        }
-      })
-      .catch(() => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
-      });
-  };
-
-  handleKeyDown = (event: React.KeyboardEvent) => {
-    switch (event.nativeEvent.key) {
-      case KeyboardKeys.Enter:
-        event.preventDefault();
-        this.selectHighlighted();
-        break;
-      case KeyboardKeys.UpArrow:
-        event.preventDefault();
-        event.nativeEvent.stopImmediatePropagation();
-        this.highlightPrevious();
-        break;
-      case KeyboardKeys.DownArrow:
-        event.preventDefault();
-        event.nativeEvent.stopImmediatePropagation();
-        this.highlightNext();
-        break;
-    }
-  };
-
-  getCurrentIndex = () => {
-    const { highlighted, suggestedUsers } = this.state;
-
-    return highlighted
-      ? suggestedUsers.findIndex((suggestion) => suggestion.login === highlighted.login)
-      : -1;
-  };
-
-  highlightIndex = (index: number) => {
-    const { suggestedUsers } = this.state;
-
-    if (suggestedUsers.length > 0) {
-      if (index < 0) {
-        index = suggestedUsers.length - 1;
-      } else if (index >= suggestedUsers.length) {
-        index = 0;
-      }
-
-      this.setState({ highlighted: suggestedUsers[index] });
-    }
-  };
-
-  highlightPrevious = () => {
-    this.highlightIndex(this.getCurrentIndex() - 1);
-  };
-
-  highlightNext = () => {
-    this.highlightIndex(this.getCurrentIndex() + 1);
-  };
-
-  selectHighlighted = () => {
-    const { highlighted } = this.state;
-
-    if (highlighted !== undefined) {
-      this.props.onSelect(highlighted);
-    }
-  };
-
-  render() {
-    const { highlighted, loading, query, suggestedUsers } = this.state;
-
-    return (
-      <AssigneeSelectionRenderer
-        highlighted={highlighted}
-        loading={loading}
-        onKeyDown={this.handleKeyDown}
-        onSearch={this.handleSearch}
-        onSelect={this.props.onSelect}
-        query={query}
-        suggestedUsers={suggestedUsers}
-      />
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelectionRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelectionRenderer.tsx
deleted file mode 100644 (file)
index 4c89557..0000000
+++ /dev/null
@@ -1,85 +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 classNames from 'classnames';
-import * as React from 'react';
-import { DropdownOverlay } from '../../../../components/controls/Dropdown';
-import SearchBox from '../../../../components/controls/SearchBox';
-import Avatar from '../../../../components/ui/Avatar';
-import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
-import { PopupPlacement } from '../../../../components/ui/popups';
-import { translate } from '../../../../helpers/l10n';
-import { UserActive } from '../../../../types/users';
-import './AssigneeSelection.css';
-
-export interface HotspotAssigneeSelectRendererProps {
-  highlighted?: UserActive;
-  loading: boolean;
-  onKeyDown: (event: React.KeyboardEvent) => void;
-  onSearch: (query: string) => void;
-  onSelect: (user?: UserActive) => void;
-  query?: string;
-  suggestedUsers?: UserActive[];
-}
-
-export default function AssigneeSelectionRenderer(props: HotspotAssigneeSelectRendererProps) {
-  const { highlighted, loading, query, suggestedUsers } = props;
-
-  return (
-    <div className="dropdown">
-      <div className="display-flex-center">
-        <SearchBox
-          autoFocus={true}
-          onChange={props.onSearch}
-          onKeyDown={props.onKeyDown}
-          placeholder={translate('hotspots.assignee.select_user')}
-          value={query}
-        />
-        {loading && <DeferredSpinner className="spacer-left" />}
-      </div>
-
-      {!loading && (
-        <DropdownOverlay noPadding={true} placement={PopupPlacement.BottomLeft}>
-          <ul className="hotspot-assignee-search-results">
-            {suggestedUsers &&
-              suggestedUsers.map((suggestion) => (
-                <li
-                  className={classNames('padded', {
-                    active: highlighted && highlighted.login === suggestion.login,
-                  })}
-                  key={suggestion.login}
-                  onClick={() => props.onSelect(suggestion)}
-                >
-                  {suggestion.login && (
-                    <Avatar
-                      className="spacer-right"
-                      hash={suggestion.avatar}
-                      name={suggestion.name}
-                      size={16}
-                    />
-                  )}
-                  {suggestion.name}
-                </li>
-              ))}
-          </ul>
-        </DropdownOverlay>
-      )}
-    </div>
-  );
-}
index e65b1038c426624948c34a69132c8849dd29d68d..faa2f4238093508fedc1c6f9fcc32e0c3f75d3a6 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 { ButtonPrimary, HighlightedSection } from 'design-system';
 import * as React from 'react';
 import withCurrentUserContext from '../../../../app/components/current-user/withCurrentUserContext';
-import { Button } from '../../../../components/controls/buttons';
-import { DropdownOverlay } from '../../../../components/controls/Dropdown';
-import Toggler from '../../../../components/controls/Toggler';
 import Tooltip from '../../../../components/controls/Tooltip';
-import DropdownIcon from '../../../../components/icons/DropdownIcon';
-import { PopupPlacement } from '../../../../components/ui/popups';
 import { translate } from '../../../../helpers/l10n';
 import { Hotspot, HotspotStatusOption } from '../../../../types/security-hotspots';
 import { CurrentUser, isLoggedIn } from '../../../../types/users';
@@ -35,7 +31,6 @@ import StatusSelection from './StatusSelection';
 export interface StatusProps {
   currentUser: CurrentUser;
   hotspot: Hotspot;
-
   onStatusChange: (statusOption: HotspotStatusOption) => Promise<void>;
 }
 
@@ -43,57 +38,30 @@ export function Status(props: StatusProps) {
   const { currentUser, hotspot } = props;
 
   const [isOpen, setIsOpen] = React.useState(false);
-  const [comment, setComment] = React.useState('');
-
-  React.useEffect(() => {
-    setComment('');
-  }, [hotspot.key]);
-
   const statusOption = getStatusOptionFromStatusAndResolution(hotspot.status, hotspot.resolution);
   const readonly = !hotspot.canChangeStatus || !isLoggedIn(currentUser);
 
   return (
-    <div className="display-flex-row display-flex-end">
-      <StatusDescription showTitle={true} statusOption={statusOption} />
-      <div className="spacer-top">
+    <>
+      <HighlightedSection className="sw-flex sw-rounded-1 sw-p-4 sw-items-center sw-justify-between sw-gap-2 sw-flex-row">
+        <StatusDescription statusOption={statusOption} />
         <Tooltip
           overlay={readonly ? translate('hotspots.status.cannot_change_status') : null}
           placement="bottom"
         >
-          <div className="dropdown">
-            <Toggler
-              closeOnClickOutside={true}
-              closeOnEscape={true}
-              onRequestClose={() => setIsOpen(false)}
-              open={isOpen}
-              overlay={
-                <DropdownOverlay noPadding={true} placement={PopupPlacement.Bottom}>
-                  <StatusSelection
-                    hotspot={hotspot}
-                    onStatusOptionChange={async (status) => {
-                      await props.onStatusChange(status);
-                      setIsOpen(false);
-                    }}
-                    comment={comment}
-                    setComment={setComment}
-                  />
-                </DropdownOverlay>
-              }
-            >
-              <Button
-                className="dropdown-toggle big-spacer-left"
-                id="status-trigger"
-                onClick={() => setIsOpen(true)}
-                disabled={readonly}
-              >
-                <span>{translate('hotspots.status.select_status')}</span>
-                <DropdownIcon className="little-spacer-left" />
-              </Button>
-            </Toggler>
-          </div>
+          <ButtonPrimary id="status-trigger" onClick={() => setIsOpen(true)} disabled={readonly}>
+            {translate('hotspots.status.review')}
+          </ButtonPrimary>
         </Tooltip>
-      </div>
-    </div>
+      </HighlightedSection>
+      {isOpen && (
+        <StatusSelection
+          hotspot={hotspot}
+          onClose={() => setIsOpen(false)}
+          onStatusOptionChange={props.onStatusChange}
+        />
+      )}
+    </>
   );
 }
 
index aaf4d55dffbc78a2e9f15e94e19951063505bc3d..4a22732c3638e0e6ac65382a9d502260b8147cb6 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import styled from '@emotion/styled';
-import classNames from 'classnames';
+import { LightLabel, LightPrimary } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../../helpers/l10n';
 import { HotspotStatusOption } from '../../../../types/security-hotspots';
 
 export interface StatusDescriptionProps {
   statusOption: HotspotStatusOption;
-  showTitle?: boolean;
-  statusInBadge?: boolean;
 }
 
 export default function StatusDescription(props: StatusDescriptionProps) {
-  const { statusOption, showTitle, statusInBadge = true } = props;
+  const { statusOption } = props;
 
   return (
-    <Container>
+    <div>
       <h3>
-        {showTitle && `${translate('status')}: `}
-        <div className={classNames({ badge: statusInBadge })}>
+        <LightPrimary className="sw-body-sm-highlight">
+          {`${translate('status')}: `}
           {translate('hotspots.status_option', statusOption)}
-        </div>
+        </LightPrimary>
       </h3>
-      <div className="little-spacer-top">
-        {translate('hotspots.status_option', statusOption, 'description')}
-      </div>
-    </Container>
+      <Description className="sw-mt-1">
+        <LightLabel className="sw-body-sm">
+          {translate('hotspots.status_option', statusOption, 'description')}
+        </LightLabel>
+      </Description>
+    </div>
   );
 }
 
-const Container = styled.div`
-  width: 350px;
+const Description = styled.div`
+  max-width: 360px;
 `;
index b1779fc2bc34a92781867b17023d34c3d49c7c8b..63098478f33ee9fe8ac210478ccb4d297a714f5b 100644 (file)
@@ -19,6 +19,8 @@
  */
 import * as React from 'react';
 import { setSecurityHotspotStatus } from '../../../../api/security-hotspots';
+import { addGlobalSuccessMessage } from '../../../../helpers/globalMessages';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
 import { Hotspot, HotspotStatusOption } from '../../../../types/security-hotspots';
 import {
   getStatusAndResolutionFromStatusOption,
@@ -29,85 +31,58 @@ import StatusSelectionRenderer from './StatusSelectionRenderer';
 interface Props {
   hotspot: Hotspot;
   onStatusOptionChange: (statusOption: HotspotStatusOption) => Promise<void>;
-  comment: string;
-  setComment: (comment: string) => void;
+  onClose: () => void;
 }
 
-interface State {
-  loading: boolean;
-  initialStatus: HotspotStatusOption;
-  selectedStatus: HotspotStatusOption;
-}
-
-export default class StatusSelection extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    const initialStatus = getStatusOptionFromStatusAndResolution(
-      props.hotspot.status,
-      props.hotspot.resolution
-    );
-
-    this.state = {
-      loading: false,
-      initialStatus,
-      selectedStatus: initialStatus,
-    };
-  }
+export default function StatusSelection(props: Props) {
+  const { hotspot } = props;
+  const initialStatus = React.useMemo(
+    () => getStatusOptionFromStatusAndResolution(hotspot.status, hotspot.resolution),
+    [hotspot]
+  );
 
-  componentDidMount() {
-    this.mounted = true;
-  }
+  const [loading, setLoading] = React.useState(false);
+  const [status, setStatus] = React.useState(initialStatus);
+  const [comment, setComment] = React.useState('');
 
-  componentWillUnmount() {
-    this.mounted = false;
-  }
+  const submitDisabled = status === initialStatus;
 
-  handleStatusChange = (selectedStatus: HotspotStatusOption) => {
-    this.setState({ selectedStatus });
-  };
-
-  handleCommentChange = (comment: string) => {
-    this.props.setComment(comment);
-  };
+  const handleSubmit = async () => {
+    const { hotspot } = props;
 
-  handleSubmit = () => {
-    const { hotspot, comment } = this.props;
-    const { initialStatus, selectedStatus } = this.state;
-
-    if (selectedStatus && selectedStatus !== initialStatus) {
-      this.setState({ loading: true });
-      setSecurityHotspotStatus(hotspot.key, {
-        ...getStatusAndResolutionFromStatusOption(selectedStatus),
-        comment: comment || undefined,
-      })
-        .then(async () => {
-          await this.props.onStatusOptionChange(selectedStatus);
-          if (this.mounted) {
-            this.setState({ loading: false });
-          }
-        })
-        .catch(() => this.setState({ loading: false }));
+    if (status !== initialStatus) {
+      setLoading(true);
+      try {
+        await setSecurityHotspotStatus(hotspot.key, {
+          ...getStatusAndResolutionFromStatusOption(status),
+          comment: comment || undefined,
+        });
+        await props.onStatusOptionChange(status);
+        addGlobalSuccessMessage(
+          translateWithParameters(
+            'hotspots.update.success',
+            translate('hotspots.status_option', status)
+          )
+        );
+        props.onClose();
+      } catch {
+        setLoading(false);
+      }
     }
   };
 
-  render() {
-    const { comment } = this.props;
-    const { initialStatus, loading, selectedStatus } = this.state;
-    const submitDisabled = selectedStatus === initialStatus;
-
-    return (
-      <StatusSelectionRenderer
-        comment={comment}
-        loading={loading}
-        onCommentChange={this.handleCommentChange}
-        onStatusChange={this.handleStatusChange}
-        onSubmit={this.handleSubmit}
-        selectedStatus={selectedStatus}
-        submitDisabled={submitDisabled}
-      />
-    );
-  }
+  return (
+    <StatusSelectionRenderer
+      comment={comment}
+      loading={loading}
+      onCommentChange={(comment) => setComment(comment)}
+      onStatusChange={(status) => {
+        setStatus(status);
+      }}
+      onSubmit={handleSubmit}
+      onCancel={props.onClose}
+      status={status}
+      submitDisabled={submitDisabled}
+    />
+  );
 }
index a0ff12a1d1b0171c925bf3c8f51c02099ec56c7f..e547eb23c7b582f4aa4084089ac533dd05e31eb1 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 styled from '@emotion/styled';
+import {
+  ButtonPrimary,
+  ButtonSecondary,
+  DeferredSpinner,
+  FormField,
+  InputTextArea,
+  LightPrimary,
+  Note,
+  SelectionCard,
+} from 'design-system';
 import * as React from 'react';
 import FormattingTips from '../../../../components/common/FormattingTips';
-import { SubmitButton } from '../../../../components/controls/buttons';
-import Radio from '../../../../components/controls/Radio';
+import Modal from '../../../../components/controls/Modal';
 import { translate } from '../../../../helpers/l10n';
 import { HotspotStatusOption } from '../../../../types/security-hotspots';
-import StatusDescription from './StatusDescription';
 
 export interface StatusSelectionRendererProps {
-  selectedStatus: HotspotStatusOption;
+  status: HotspotStatusOption;
   onStatusChange: (statusOption: HotspotStatusOption) => void;
-
   comment?: string;
   onCommentChange: (comment: string) => void;
-
-  onSubmit: () => void;
-
+  onCancel: () => void;
+  onSubmit: () => Promise<void>;
   loading: boolean;
   submitDisabled: boolean;
 }
 
 export default function StatusSelectionRenderer(props: StatusSelectionRendererProps) {
-  const { comment, loading, selectedStatus, submitDisabled } = props;
+  const { comment, loading, status, submitDisabled } = props;
 
-  const renderOption = (status: HotspotStatusOption) => {
+  const renderOption = (statusOption: HotspotStatusOption) => {
     return (
-      <Radio
-        checked={selectedStatus === status}
-        className="big-spacer-bottom status-radio"
-        alignLabel={true}
-        onCheck={props.onStatusChange}
-        value={status}
+      <SelectionCard
+        className="sw-mb-3"
+        key={statusOption}
+        onClick={() => props.onStatusChange(statusOption)}
+        selected={statusOption === status}
+        title={translate('hotspots.status_option', statusOption)}
+        vertical={true}
       >
-        <StatusDescription statusOption={status} statusInBadge={false} />
-      </Radio>
+        <Note className="sw-mt-1 sw-mr-12">
+          {translate('hotspots.status_option', statusOption, 'description')}
+        </Note>
+      </SelectionCard>
     );
   };
 
   return (
-    <div data-testid="security-hotspot-test" className="abs-width-400">
-      <div className="big-padded">
+    <Modal contentLabel={translate('hotspots.status.review_title')}>
+      <header className="sw-p-9">
+        <h1 className="sw-heading-lg sw-mb-2">{translate('hotspots.status.review_title')}</h1>
+        <LightPrimary as="p" className="sw-body-sm">
+          {translate('hotspots.status.select')}
+        </LightPrimary>
+      </header>
+      <MainStyled className="sw-px-9">
         {renderOption(HotspotStatusOption.TO_REVIEW)}
         {renderOption(HotspotStatusOption.ACKNOWLEDGED)}
         {renderOption(HotspotStatusOption.FIXED)}
         {renderOption(HotspotStatusOption.SAFE)}
-      </div>
-
-      <hr />
-      <div className="big-padded display-flex-column">
-        <label className="text-bold" htmlFor="comment-textarea">
-          {translate('hotspots.status.add_comment')}
-        </label>
-        <textarea
-          className="spacer-top form-field fixed-width spacer-bottom"
-          id="comment-textarea"
-          onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
-            props.onCommentChange(event.currentTarget.value)
-          }
-          rows={4}
-          value={comment}
-        />
-        <FormattingTips />
-
-        <div className="big-spacer-top display-flex-justify-end display-flex-center">
-          <SubmitButton disabled={submitDisabled || loading} onClick={props.onSubmit}>
-            {translate('hotspots.status.change_status')}
-          </SubmitButton>
-
-          {loading && <i className="spacer-left spinner" />}
-        </div>
-      </div>
-    </div>
+        <FormField htmlFor="comment-textarea" label={translate('hotspots.status.add_comment')}>
+          <InputTextArea
+            className="sw-mb-2 sw-resize-y"
+            id="comment-textarea"
+            onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
+              props.onCommentChange(event.currentTarget.value)
+            }
+            rows={4}
+            size="full"
+            value={comment}
+          />
+          <FormattingTips />
+        </FormField>
+      </MainStyled>
+      <footer className="sw-flex sw-justify-end sw-items-center sw-gap-3 sw-p-9">
+        <DeferredSpinner loading={loading} />
+        <ButtonPrimary disabled={submitDisabled || loading} onClick={props.onSubmit}>
+          {translate('hotspots.status.change_status')}
+        </ButtonPrimary>
+        <ButtonSecondary onClick={props.onCancel}>{translate('cancel')}</ButtonSecondary>
+      </footer>
+    </Modal>
   );
 }
+
+const MainStyled = styled.main`
+  max-height: calc(100vh - 400px);
+  overflow: auto;
+`;
index b336939bb1049355b4cea9e016129a7590b0771a..c6f9f03b14241e7e83ff70bd570adf3001a96647 100644 (file)
@@ -60,9 +60,3 @@
     margin-left: 0;
   }
 }
-
-#security_hotspots .hotspot-content {
-  max-width: 962px; /* 1280px - 300px - 18px */
-  background: white;
-  box-sizing: border-box;
-}
index b70ebf6e75c88b6b8eedfd06153b5504fa81cb59..004e00f09bc70406666cc770f1b9db30b694a3e1 100644 (file)
@@ -17,8 +17,8 @@
  * 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 * as React from 'react';
+import { Link, Note } from 'design-system';
+import React from 'react';
 import { translate } from '../../helpers/l10n';
 import { getFormattingHelpUrl } from '../../helpers/urls';
 
@@ -26,31 +26,29 @@ interface Props {
   className?: string;
 }
 
-export default class FormattingTips extends React.PureComponent<Props> {
-  handleClick(evt: React.SyntheticEvent<HTMLAnchorElement>) {
+export default function FormattingTips({ className }: Props) {
+  const handleClick = React.useCallback((evt: React.MouseEvent<HTMLAnchorElement>) => {
     evt.preventDefault();
     window.open(
       getFormattingHelpUrl(),
       'Formatting',
       'height=300,width=600,scrollbars=1,resizable=1'
     );
-  }
+  }, []);
 
-  render() {
-    return (
-      <div className={classNames('markdown-tips', this.props.className)}>
-        <a className="little-spacer-right" href="#" onClick={this.handleClick}>
-          {translate('formatting.helplink')}
-        </a>
-        {':'}
-        <span className="spacer-left">*{translate('bold')}*</span>
-        <span className="spacer-left">
-          ``
-          {translate('code')}
-          ``
-        </span>
-        <span className="spacer-left">* {translate('bulleted_point')}</span>
-      </div>
-    );
-  }
+  return (
+    <Note className={className}>
+      <Link className="sw-mr-1" onClick={handleClick} to={getFormattingHelpUrl()}>
+        {translate('formatting.helplink')}
+      </Link>
+      {':'}
+      <span className="sw-ml-2">*{translate('bold')}*</span>
+      <span className="sw-ml-2">
+        ``
+        {translate('code')}
+        ``
+      </span>
+      <span className="sw-ml-2">* {translate('bulleted_point')}</span>
+    </Note>
+  );
 }
index b4708468def90f77cacd6222fb62da406768aa9e..5cf19660533b02540f400e4046ce5e996a2c6630 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 { shallow } from 'enzyme';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import * as React from 'react';
-import { click } from '../../../helpers/testUtils';
+import { renderComponent } from '../../../helpers/testReactTestingUtils';
+import { FCProps } from '../../../types/misc';
 import FormattingTips from '../FormattingTips';
 
 const originalOpen = window.open;
@@ -38,17 +40,15 @@ afterAll(() => {
   });
 });
 
-it('should render correctly', () => {
-  expect(shallowRender()).toMatchSnapshot();
-});
-
-it('should correctly open a new window', () => {
-  const wrapper = shallowRender();
-  expect(window.open).not.toHaveBeenCalled();
-  click(wrapper.find('a'));
+it('should render correctly', async () => {
+  const user = userEvent.setup();
+  renderFormattingTips();
+  const link = screen.getByRole('link', { name: 'formatting.helplink' });
+  expect(link).toBeInTheDocument();
+  await user.click(link);
   expect(window.open).toHaveBeenCalled();
 });
 
-function shallowRender(props: Partial<FormattingTips['props']> = {}) {
-  return shallow<FormattingTips>(<FormattingTips {...props} />);
+function renderFormattingTips(props: Partial<FCProps<typeof FormattingTips>> = {}) {
+  return renderComponent(<FormattingTips {...props} />);
 }
diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/FormattingTips-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/FormattingTips-test.tsx.snap
deleted file mode 100644 (file)
index 422fb77..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<div
-  className="markdown-tips"
->
-  <a
-    className="little-spacer-right"
-    href="#"
-    onClick={[Function]}
-  >
-    formatting.helplink
-  </a>
-  :
-  <span
-    className="spacer-left"
-  >
-    *
-    bold
-    *
-  </span>
-  <span
-    className="spacer-left"
-  >
-    \`\`
-    code
-    \`\`
-  </span>
-  <span
-    className="spacer-left"
-  >
-    * 
-    bulleted_point
-  </span>
-</div>
-`;
diff --git a/server/sonar-web/src/main/js/types/misc.ts b/server/sonar-web/src/main/js/types/misc.ts
new file mode 100644 (file)
index 0000000..ea95b30
--- /dev/null
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+export type FCProps<T extends React.FunctionComponent<any>> = Parameters<T>[0];
index 10a0139e4a0bf53ea200baed183a08cd56f95db2..ba6cdf3c30737fe8f7ec510212ad844acf29590b 100644 (file)
@@ -810,9 +810,12 @@ hotspots.open_in_ide.failure=Unable to connect to your IDE to open the Security
 hotspots.assignee.select_user=Select a user...
 hotspots.assignee.change_user=Click to change assignee
 hotspots.status.cannot_change_status=Changing a hotspot's status requires permission.
-hotspots.status.select_status=Change status
+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.change_status=Change status
+
 hotspots.status_option.TO_REVIEW=To review
 hotspots.status_option.TO_REVIEW.description=This security hotspot needs to be reviewed to assess whether the code poses a risk.
 hotspots.status_option.ACKNOWLEDGED=Acknowledged
@@ -851,7 +854,7 @@ hotspots.review_hotspot=Review Hotspot
 
 hotspots.assign.success=Security Hotspot was successfully assigned to {0}
 hotspots.assign.unassign.success=Security Hotspot was successfully unassigned
-hotspots.update.success=Update successful
+hotspots.update.success=Security Hotspot status was successfully changed to {0}
 
 #------------------------------------------------------------------------------
 #
@@ -1523,6 +1526,7 @@ search.search_for_directories=Search for directories...
 search.search_for_files=Search for files...
 search.search_for_modules=Search for modules...
 search.search_for_metrics=Search for metrics...
+search.tooShort=Please enter at least {0} characters
 
 global_search.shortcut_hint=Hint: Press 'S' from anywhere to open this search bar.