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;
}
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`}
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');
});
// 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],
// 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 () => {
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>
);
}
* 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';
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) => {
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')};
+`);
+++ /dev/null
-/*
- * 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;
-}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
-import './LocationMessage.css';
interface Props {
children?: React.ReactNode;
* 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';
messageFormattings?: MessageFormatting[];
onClick: (index: number) => void;
selected: boolean;
+ concealedMarker?: boolean;
}
export default class SingleFileLocationNavigator extends React.PureComponent<Props> {
};
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} />
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')};
+ }
+`;
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