]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19236 Move hotspot location list to new design system
author7PH <benjamin.raymond@sonarsource.com>
Tue, 23 May 2023 14:29:20 +0000 (16:29 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 24 May 2023 20:03:14 +0000 (20:03 +0000)
server/sonar-web/design-system/src/components/subnavigation/SubnavigationItem.tsx
server/sonar-web/design-system/src/components/subnavigation/__tests__/SubnavigationItem-test.tsx
server/sonar-web/design-system/src/theme/light.ts
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCategory.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx
server/sonar-web/src/main/js/components/common/LocationMessage.css [deleted file]
server/sonar-web/src/main/js/components/common/LocationMessage.tsx
server/sonar-web/src/main/js/components/locations/SingleFileLocationNavigator.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index eade58670ab0ac8e74718412bf68ddb30cf5fc7d..68bbb8593d24ab0e800276991bd3d5d317284885 100644 (file)
@@ -22,13 +22,12 @@ import classNames from 'classnames';
 import { ReactNode, useCallback } from 'react';
 import tw, { theme as twTheme } from 'twin.macro';
 import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
-import { BareButton } from '../buttons';
 
 interface Props {
   active?: boolean;
   children: ReactNode;
   className?: string;
-  innerRef?: (node: HTMLButtonElement) => void;
+  innerRef?: (node: HTMLDivElement) => void;
   onClick: (value?: string) => void;
   value?: string;
 }
@@ -39,18 +38,19 @@ export function SubnavigationItem(props: Props) {
     onClick(value);
   }, [onClick, value]);
   return (
-    <SubnavigationItemStyled
+    <StyledSubnavigationItem
       aria-current={active}
-      className={classNames('js-subnavigation-item', { active }, className)}
+      className={classNames({ active }, className)}
+      data-testid="js-subnavigation-item"
       onClick={handleClick}
       ref={innerRef}
     >
       {children}
-    </SubnavigationItemStyled>
+    </StyledSubnavigationItem>
   );
 }
 
-const SubnavigationItemStyled = styled(BareButton)`
+const StyledSubnavigationItem = styled.div`
   ${tw`sw-flex sw-items-center sw-justify-between`}
   ${tw`sw-box-border`}
   ${tw`sw-body-sm`}
index 3646eee2b706069ecd6b3154fc2f1ef58862af76..66c776e79f7a682d04d3d7373bf8899a21552a4f 100644 (file)
@@ -25,20 +25,20 @@ import { SubnavigationItem } from '../SubnavigationItem';
 it('should render correctly', () => {
   setupWithProps();
 
-  expect(screen.getByRole('button', { current: false })).toBeVisible();
+  expect(screen.getByTestId('js-subnavigation-item')).toHaveAttribute('aria-current', 'false');
 });
 
 it('should display selected', () => {
   setupWithProps({ active: true });
 
-  expect(screen.getByRole('button', { current: true })).toBeVisible();
+  expect(screen.getByTestId('js-subnavigation-item')).toHaveAttribute('aria-current', 'true');
 });
 
 it('should call onClick with value when clicked', async () => {
   const onClick = jest.fn();
   const { user } = setupWithProps({ onClick });
 
-  await user.click(screen.getByRole('button'));
+  await user.click(screen.getByTestId('js-subnavigation-item'));
   expect(onClick).toHaveBeenCalledWith('foo');
 });
 
index f8522d70256554502792b81f7a92cd52fa8efc24..084037eb6433d82660413b0ccfdf3216aa4a39c8 100644 (file)
@@ -394,7 +394,8 @@ export const lightTheme = {
 
     // subnavigation sidebar
     subnavigation: COLORS.white,
-    subnavigationHover: COLORS.indigo[50],
+    subnavigationHover: COLORS.blueGrey[50],
+    subnavigationSelected: COLORS.blueGrey[100],
     subnavigationBorder: COLORS.grey[100],
     subnavigationSeparator: COLORS.grey[50],
     subnavigationSubheading: COLORS.blueGrey[25],
index c7a6a5f9c8149cd0bcd1095699dc6ec429b4c696..4baa2e1b120650aa4b986e0f40ceae630a1493be 100644 (file)
@@ -617,22 +617,27 @@ describe('issues item', () => {
 
     // Location navigation
     await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
-    expect(dataLocation1Button).toHaveClass('selected');
+    expect(dataLocation1Button).toHaveAttribute('aria-current', 'location');
     await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
-    expect(dataLocation1Button).not.toHaveClass('selected');
-    expect(dataLocation2Button).toHaveClass('selected');
+    expect(dataLocation1Button).toHaveAttribute('aria-current', 'false');
     await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
-    expect(dataLocation1Button).not.toHaveClass('selected');
-    expect(dataLocation2Button).not.toHaveClass('selected');
+    expect(dataLocation1Button).toHaveAttribute('aria-current', 'false');
+    expect(dataLocation2Button).toHaveAttribute('aria-current', 'false');
     await user.keyboard('{Alt>}{ArrowUp}{/Alt}');
-    expect(dataLocation1Button).not.toHaveClass('selected');
-    expect(dataLocation2Button).toHaveClass('selected');
+    expect(dataLocation1Button).toHaveAttribute('aria-current', 'false');
+    expect(dataLocation2Button).toHaveAttribute('aria-current', 'location');
 
     // Flow navigation
     await user.keyboard('{Alt>}{ArrowRight}{/Alt}');
-    expect(screen.getByRole('button', { name: '1 Execution location 1' })).toHaveClass('selected');
+    expect(screen.getByRole('button', { name: '1 Execution location 1' })).toHaveAttribute(
+      'aria-current',
+      'location'
+    );
     await user.keyboard('{Alt>}{ArrowLeft}{/Alt}');
-    expect(screen.getByRole('button', { name: '1 Data location 1' })).toHaveClass('selected');
+    expect(screen.getByRole('button', { name: '1 Data location 1' })).toHaveAttribute(
+      'aria-current',
+      'location'
+    );
   });
 
   it('should show education principles', async () => {
index e34ad991cf0020c9b3cb963ac830c39d52a42799..4599551ead3a5d7ca8ac8502cfe56191542e0994 100644 (file)
@@ -70,16 +70,19 @@ export default function HotspotCategory(props: HotspotCategoryProps) {
       expanded={expanded}
       onSetExpanded={onSetExpanded}
     >
-      {hotspots.map((hotspot) => (
-        <HotspotListItem
-          hotspot={hotspot}
-          key={hotspot.key}
-          onClick={onHotspotClick}
-          selected={hotspot.key === selectedHotspot.key}
-          onLocationClick={onLocationClick}
-          selectedHotspotLocation={selectedHotspotLocation}
-        />
-      ))}
+      <ul>
+        {hotspots.map((hotspot) => (
+          <li key={hotspot.key}>
+            <HotspotListItem
+              hotspot={hotspot}
+              onClick={onHotspotClick}
+              selected={hotspot.key === selectedHotspot.key}
+              onLocationClick={onLocationClick}
+              selectedHotspotLocation={selectedHotspotLocation}
+            />
+          </li>
+        ))}
+      </ul>
     </SubnavigationAccordion>
   );
 }
index 2a4dca9105296fc7638821a772f281e27812fe17..d123cf260d7af1386f9c4da97980174730e0a229 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 { SubnavigationItem } from 'design-system';
+import { withTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import {
+  BareButton,
+  ExecutionFlowIcon,
+  SubnavigationItem,
+  themeColor,
+  themeContrast,
+} from 'design-system';
 import React, { useCallback } from 'react';
-import LocationsList from '../../../components/locations/LocationsList';
+import { FormattedMessage } from 'react-intl';
+import SingleFileLocationNavigator from '../../../components/locations/SingleFileLocationNavigator';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { RawHotspot } from '../../../types/security-hotspots';
 import { getLocations } from '../utils';
 
@@ -35,6 +45,9 @@ export default function HotspotListItem(props: HotspotListItemProps) {
   const { hotspot, selected, selectedHotspotLocation } = props;
   const locations = getLocations(hotspot.flows, undefined);
 
+  const locationMessage =
+    locations.length > 1 ? 'hotspot.location.count.plural' : 'hotspot.location.count';
+
   // Use useCallback instead of useEffect/useRef combination to be notified of the ref changes
   const itemRef = useCallback(
     (node) => {
@@ -61,16 +74,61 @@ export default function HotspotListItem(props: HotspotListItemProps) {
       onClick={handleClick}
       className="sw-flex-col sw-items-start"
     >
-      <div>{hotspot.message}</div>
-      {selected && (
-        <LocationsList
-          locations={locations}
-          showCrossFile={false} // To be removed once we support multi file location
-          componentKey={hotspot.component}
-          onLocationSelect={props.onLocationClick}
-          selectedLocationIndex={selectedHotspotLocation}
-        />
+      <StyledHotspotTitle aria-current={selected} role="button">
+        {hotspot.message}
+      </StyledHotspotTitle>
+      {locations.length > 0 && (
+        <StyledHotspotInfo className="sw-flex sw-justify-end sw-w-full">
+          <div className="sw-flex sw-mt-2 sw-items-center sw-justify-center sw-gap-1 sw-overflow-hidden">
+            <ExecutionFlowIcon />
+            <span
+              className="sw-truncate"
+              title={translateWithParameters(locationMessage, locations.length)}
+            >
+              <FormattedMessage
+                id="hotspots.location"
+                defaultMessage={translate(locationMessage)}
+                values={{
+                  0: <span className="sw-body-sm-highlight">{locations.length}</span>,
+                }}
+              />
+            </span>
+          </div>
+        </StyledHotspotInfo>
+      )}
+      {selected && locations.length > 0 && (
+        <>
+          <StyledSeparator className="sw-w-full sw-my-2" />
+          <div className="sw-flex sw-flex-col sw-gap-1 sw-my-2 sw-w-full">
+            {locations.map((location, index) => (
+              <SingleFileLocationNavigator
+                key={index}
+                index={index}
+                concealedMarker={true}
+                message={location.msg}
+                messageFormattings={location.msgFormattings}
+                onClick={props.onLocationClick}
+                selected={index === selectedHotspotLocation}
+              />
+            ))}
+          </div>
+        </>
       )}
     </SubnavigationItem>
   );
 }
+
+const StyledHotspotTitle = styled(BareButton)`
+  &:focus {
+    background-color: ${themeColor('subnavigationSelected')};
+  }
+`;
+
+const StyledHotspotInfo = styled.div`
+  color: ${themeContrast('pageContentLight')};
+`;
+
+const StyledSeparator = withTheme(styled.div`
+  height: 1px;
+  background-color: ${themeColor('subnavigationExecutionFlowBorder')};
+`);
diff --git a/server/sonar-web/src/main/js/components/common/LocationMessage.css b/server/sonar-web/src/main/js/components/common/LocationMessage.css
deleted file mode 100644 (file)
index 97ddfff..0000000
+++ /dev/null
@@ -1,36 +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.
- */
-.location-message {
-  display: inline-block;
-  vertical-align: top;
-  line-height: 16px;
-  padding: 0 6px;
-  font-size: var(--smallFontSize);
-  word-break: break-word;
-}
-
-.location-index + .location-message {
-  margin-left: 4px;
-}
-
-.source-line-code .location-message {
-  padding-top: 2px;
-  padding-bottom: 2px;
-}
index b0bfa8e9e65df10a062fedf4f3e6161129633ed8..62c4cf19e05e617f8e72eb82c634d2fcc68eb0ca 100644 (file)
@@ -18,7 +18,6 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import './LocationMessage.css';
 
 interface Props {
   children?: React.ReactNode;
index c48b381b94826a1b6eb40b7ca7b022fcf85dc3ad..45703b11791eff92dec960c5cb2c4004e7fa1f92 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 classNames from 'classnames';
+import { LocationMarker, StyledMarker, themeColor } from 'design-system';
 import * as React from 'react';
 import { translateWithParameters } from '../../helpers/l10n';
 import { MessageFormatting } from '../../types/issues';
-import LocationIndex from '../common/LocationIndex';
 import LocationMessage from '../common/LocationMessage';
-import { ButtonPlain } from '../controls/buttons';
 import { IssueMessageHighlighting } from '../issue/IssueMessageHighlighting';
 import './SingleFileLocationNavigator.css';
 
@@ -33,6 +33,7 @@ interface Props {
   messageFormattings?: MessageFormatting[];
   onClick: (index: number) => void;
   selected: boolean;
+  concealedMarker?: boolean;
 }
 
 export default class SingleFileLocationNavigator extends React.PureComponent<Props> {
@@ -63,20 +64,18 @@ export default class SingleFileLocationNavigator extends React.PureComponent<Pro
   };
 
   render() {
-    const { index, message, messageFormattings, selected } = this.props;
+    const { index, concealedMarker, message, messageFormattings, selected } = this.props;
 
     return (
-      <ButtonPlain
-        preventDefault={true}
-        stopPropagation={true}
-        aria-current={selected ? 'location' : false}
-        className={classNames('locations-navigator', { selected })}
-        innerRef={(node) => {
-          this.node = node;
-        }}
+      <StyledButton
         onClick={this.handleClick}
+        aria-current={selected ? 'location' : false}
+        className={classNames('sw-p-1 sw-flex sw-items-center sw-gap-2', {
+          selected,
+        })}
+        ref={(n) => (this.node = n)}
       >
-        <LocationIndex>{index + 1}</LocationIndex>
+        <LocationMarker selected={selected} text={concealedMarker ? undefined : index + 1} />
         <LocationMessage>
           {message ? (
             <IssueMessageHighlighting message={message} messageFormattings={messageFormattings} />
@@ -84,7 +83,25 @@ export default class SingleFileLocationNavigator extends React.PureComponent<Pro
             translateWithParameters('issue.location_x', index + 1)
           )}
         </LocationMessage>
-      </ButtonPlain>
+      </StyledButton>
     );
   }
 }
+
+const StyledButton = styled.button`
+  color: ${themeColor('pageContent')};
+  cursor: pointer;
+  outline: none;
+  border: none;
+  background: transparent;
+
+  &.selected,
+  &:hover,
+  &:focus {
+    background-color: ${themeColor('subnavigationSelected')};
+  }
+
+  &:hover ${StyledMarker} {
+    background-color: ${themeColor('codeLineLocationMarkerSelected')};
+  }
+`;
index 10cdf553426ef93e6c0deb25a50acf0eefced74c..7eeb7acf95ef8957665c9de6c4afc49d43bcec42 100644 (file)
@@ -856,6 +856,8 @@ 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=Activity
+hotspot.location.count={0} extra location
+hotspot.location.count.plural={0} extra locations
 
 hotspots.reviewed.tooltip=Percentage of open Security Hotspots that have been reviewed (Acknowledged, Fixed or Safe)
 hotspots.review_hotspot=Review Hotspot