aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
authorJay <jeremy.davis@sonarsource.com>2023-05-25 11:42:15 +0200
committersonartech <sonartech@sonarsource.com>2023-06-09 20:03:09 +0000
commit9b201e724ef777d698cf26268560d8e3d97bfb71 (patch)
tree5aa799a4e2db4a032a3b40844864f56e2f473838 /server/sonar-web/src/main
parentd9770048ef20fa6c100718129166748523db377f (diff)
downloadsonarqube-9b201e724ef777d698cf26268560d8e3d97bfb71.tar.gz
sonarqube-9b201e724ef777d698cf26268560d8e3d97bfb71.zip
SONAR-19345 New UI for issues subnavigation
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx55
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx55
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssue.tsx74
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx101
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx92
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesList.tsx54
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesListHeader.tsx47
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueItemLocationsQuantity.tsx61
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueItemType.tsx (renamed from server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueComponent.tsx)18
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx104
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocations.tsx61
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsCrossFile.tsx208
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigator.tsx145
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigatorKeyboardHint.tsx41
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx98
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssueComponentName.tsx (renamed from server/sonar-web/src/main/js/components/locations/FlowsList.css)29
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssuesList.tsx117
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssuesListHeader.tsx (renamed from server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationBadge.tsx)45
-rw-r--r--server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx (renamed from server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssues-it.tsx)154
-rw-r--r--server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx5
-rw-r--r--server/sonar-web/src/main/js/components/controls/ListFooter.tsx16
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx2
-rw-r--r--server/sonar-web/src/main/js/components/icon-mappers/IssueTypeIcon.tsx83
-rw-r--r--server/sonar-web/src/main/js/components/locations/FlowsList.tsx107
-rw-r--r--server/sonar-web/src/main/js/components/locations/__tests__/FlowsList-test.tsx76
-rw-r--r--server/sonar-web/src/main/js/helpers/issues.ts12
-rw-r--r--server/sonar-web/src/main/js/helpers/testUtils.ts2
27 files changed, 1097 insertions, 765 deletions
diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
index 324585fd0aa..f501e8d97f1 100644
--- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
@@ -583,21 +583,21 @@ describe('issues item', () => {
).toBeInTheDocument();
});
- it('should show secondary location even when no message is present', async () => {
- renderProjectIssuesApp('project/issues?issues=issue101&open=issue101&id=myproject');
-
- expect(await screen.findByRole('button', { name: '1 issue.location_x.1' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '2 issue.location_x.2' })).toBeInTheDocument();
- });
-
it('should interact with flows and locations', async () => {
const user = userEvent.setup();
renderProjectIssuesApp('project/issues?issues=issue11&open=issue11&id=myproject');
- const dataFlowButton = await screen.findByRole('button', { name: 'Backtracking 1' });
- const exectionFlowButton = screen.getByRole('button', { name: 'issue.execution_flow' });
- let dataLocation1Button = screen.getByRole('button', { name: '1 Data location 1' });
- let dataLocation2Button = screen.getByRole('button', { name: '2 Data location 2' });
+ expect(await screen.findByLabelText('list_of_issues')).toBeInTheDocument();
+
+ const dataFlowButton = await screen.findByRole('button', {
+ name: 'issue.flow.x_steps.2 Backtracking 1',
+ });
+ const exectionFlowButton = screen.getByRole('button', {
+ name: 'issue.flow.x_steps.3 issue.full_execution_flow',
+ });
+
+ let dataLocation1Button = screen.getByRole('link', { name: '1 Data location 1' });
+ let dataLocation2Button = screen.getByRole('link', { name: '2 Data location 2' });
expect(dataFlowButton).toBeInTheDocument();
expect(dataLocation1Button).toBeInTheDocument();
@@ -609,37 +609,40 @@ describe('issues item', () => {
expect(dataLocation2Button).not.toBeInTheDocument();
await user.click(exectionFlowButton);
- expect(screen.getByRole('button', { name: '1 Execution location 1' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '2 Execution location 2' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '3 Execution location 3' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: '1 Execution location 1' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: '2 Execution location 2' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: '3 Execution location 3' })).toBeInTheDocument();
// Keyboard interaction
await user.click(dataFlowButton);
- dataLocation1Button = screen.getByRole('button', { name: '1 Data location 1' });
- dataLocation2Button = screen.getByRole('button', { name: '2 Data location 2' });
+ dataLocation1Button = screen.getByRole('link', { name: '1 Data location 1' });
+ dataLocation2Button = screen.getByRole('link', { name: '2 Data location 2' });
// Location navigation
await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
- expect(dataLocation1Button).toHaveAttribute('aria-current', 'location');
+
+ expect(dataLocation1Button).toHaveAttribute('aria-current', 'true');
await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
expect(dataLocation1Button).toHaveAttribute('aria-current', 'false');
+ expect(dataLocation2Button).toHaveAttribute('aria-current', 'true');
await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
expect(dataLocation1Button).toHaveAttribute('aria-current', 'false');
expect(dataLocation2Button).toHaveAttribute('aria-current', 'false');
await user.keyboard('{Alt>}{ArrowUp}{/Alt}');
expect(dataLocation1Button).toHaveAttribute('aria-current', 'false');
- expect(dataLocation2Button).toHaveAttribute('aria-current', 'location');
+
+ expect(dataLocation2Button).toHaveAttribute('aria-current', 'true');
// Flow navigation
await user.keyboard('{Alt>}{ArrowRight}{/Alt}');
- expect(screen.getByRole('button', { name: '1 Execution location 1' })).toHaveAttribute(
+ expect(screen.getByRole('link', { name: '1 Execution location 1' })).toHaveAttribute(
'aria-current',
- 'location'
+ 'true'
);
await user.keyboard('{Alt>}{ArrowLeft}{/Alt}');
- expect(screen.getByRole('button', { name: '1 Data location 1' })).toHaveAttribute(
+ expect(screen.getByRole('link', { name: '1 Data location 1' })).toHaveAttribute(
'aria-current',
- 'location'
+ 'true'
);
});
@@ -896,8 +899,8 @@ describe('issues item', () => {
renderIssueApp();
await user.click(await ui.issueItem4.find());
- expect(screen.getByRole('button', { name: '1 location 1' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '2 location 2' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'location 1' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'location 2' })).toBeInTheDocument();
// Select the "why is this an issue" tab
await user.click(
@@ -910,7 +913,7 @@ describe('issues item', () => {
})
).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: '1 location 1' }));
+ await user.click(screen.getByRole('link', { name: 'location 1' }));
expect(
screen.getByRole('tab', {
name: `issue.tabs.${TabKeys.Code}`,
@@ -929,7 +932,7 @@ describe('issues item', () => {
})
).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: '1 location 1' }));
+ await user.click(screen.getByRole('link', { name: 'location 1' }));
expect(
screen.getByRole('tab', {
name: `issue.tabs.${TabKeys.Code}`,
diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
index 756bdc77da6..8ace857ba5d 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
@@ -77,8 +77,7 @@ import { SecurityStandard } from '../../../types/security';
import { Component, Dict, Issue, Paging, RawQuery, RuleDetails } from '../../../types/types';
import { CurrentUser, UserBase } from '../../../types/users';
import * as actions from '../actions';
-import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList';
-import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeader';
+import SubnavigationIssuesList from '../issues-subnavigation/SubnavigationIssuesList';
import Sidebar from '../sidebar/Sidebar';
import '../styles.css';
import {
@@ -957,40 +956,12 @@ export class App extends React.PureComponent<Props, State> {
);
}
- renderConciseIssuesList() {
- const { issues, loadingMore, paging, query } = this.state;
-
- return (
- <div className="layout-page-filters">
- <ConciseIssuesListHeader
- displayBackButton={query.issues.length !== 1}
- loading={this.state.loading}
- onBackClick={this.closeIssue}
- />
- <ConciseIssuesList
- issues={issues}
- onFlowSelect={this.selectFlow}
- onIssueSelect={this.openIssue}
- onLocationSelect={this.selectLocation}
- selected={this.state.selected}
- selectedFlowIndex={this.state.selectedFlowIndex}
- selectedLocationIndex={this.state.selectedLocationIndex}
- />
- {paging && paging.total > 0 && (
- <ListFooter
- count={issues.length}
- loadMore={this.fetchMoreIssues}
- loading={loadingMore}
- total={paging.total}
- />
- )}
- </div>
- );
- }
-
renderSide(openIssue: Issue | undefined) {
const { canBrowseAllChildProjects, qualifier = ComponentQualifier.Project } =
this.props.component || {};
+
+ const { issues, paging } = this.state;
+
return (
<ScreenPositionHelper className="layout-page-side-outer">
{({ top }) => (
@@ -1025,7 +996,23 @@ export class App extends React.PureComponent<Props, State> {
</div>
)}
- {openIssue ? this.renderConciseIssuesList() : this.renderFacets()}
+ {openIssue ? (
+ <SubnavigationIssuesList
+ fetchMoreIssues={this.fetchMoreIssues}
+ issues={issues}
+ loading={this.state.loading}
+ loadingMore={this.state.loadingMore}
+ onFlowSelect={this.selectFlow}
+ onIssueSelect={this.openIssue}
+ onLocationSelect={this.selectLocation}
+ paging={paging}
+ selected={this.state.selected}
+ selectedFlowIndex={this.state.selectedFlowIndex}
+ selectedLocationIndex={this.state.selectedLocationIndex}
+ />
+ ) : (
+ this.renderFacets()
+ )}
</div>
</nav>
)}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssue.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssue.tsx
deleted file mode 100644
index d59b61a0a2c..00000000000
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssue.tsx
+++ /dev/null
@@ -1,74 +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 { Issue } from '../../../types/types';
-import ConciseIssueBox from './ConciseIssueBox';
-import ConciseIssueComponent from './ConciseIssueComponent';
-
-const HALF_DIVIDER = 2;
-
-export interface ConciseIssueProps {
- issue: Issue;
- onFlowSelect: (index?: number) => void;
- onLocationSelect: (index: number) => void;
- onSelect: (issueKey: string) => void;
- previousIssue: Issue | undefined;
- selected: boolean;
- selectedFlowIndex: number | undefined;
- selectedLocationIndex: number | undefined;
-}
-
-export default function ConciseIssue(props: ConciseIssueProps) {
- const { issue, previousIssue, selected, selectedFlowIndex, selectedLocationIndex } = props;
- const element = React.useRef<HTMLLIElement>(null);
-
- const displayComponent = !previousIssue || previousIssue.component !== issue.component;
-
- React.useEffect(() => {
- if (selected && element.current) {
- const parent = document.querySelector('.layout-page-side') as HTMLMenuElement;
- const rect = parent.getBoundingClientRect();
- const offset =
- element.current.offsetTop - rect.height / HALF_DIVIDER + rect.top / HALF_DIVIDER;
- parent.scrollTo({ top: offset, behavior: 'smooth' });
- }
- }, [selected]);
-
- return (
- <>
- {displayComponent && (
- <li>
- <ConciseIssueComponent path={issue.componentLongName} />
- </li>
- )}
- <li ref={element}>
- <ConciseIssueBox
- issue={issue}
- onClick={props.onSelect}
- onFlowSelect={props.onFlowSelect}
- onLocationSelect={props.onLocationSelect}
- selected={selected}
- selectedFlowIndex={selected ? selectedFlowIndex : undefined}
- selectedLocationIndex={selected ? selectedLocationIndex : undefined}
- />
- </li>
- </>
- );
-}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx
deleted file mode 100644
index dc89b45ef5e..00000000000
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx
+++ /dev/null
@@ -1,101 +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 { ButtonPlain } from '../../../components/controls/buttons';
-import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting';
-import FlowsList from '../../../components/locations/FlowsList';
-import LocationsList from '../../../components/locations/LocationsList';
-import TypeHelper from '../../../components/shared/TypeHelper';
-import { translateWithParameters } from '../../../helpers/l10n';
-import { FlowType, Issue } from '../../../types/types';
-import { getLocations } from '../utils';
-import ConciseIssueLocations from './ConciseIssueLocations';
-
-interface Props {
- issue: Issue;
- onClick: (issueKey: string) => void;
- onFlowSelect: (index?: number) => void;
- onLocationSelect: (index: number) => void;
- selected: boolean;
- selectedFlowIndex: number | undefined;
- selectedLocationIndex: number | undefined;
-}
-
-export default function ConciseIssueBox(props: Props) {
- const { issue, selected, selectedFlowIndex, selectedLocationIndex } = props;
-
- const handleClick = () => {
- props.onClick(issue.key);
- };
-
- const locations = React.useMemo(
- () => getLocations(issue, selectedFlowIndex),
- [issue, selectedFlowIndex]
- );
-
- return (
- <div
- className={classNames('concise-issue-box', 'clearfix', { selected })}
- onClick={selected ? undefined : handleClick}
- >
- <ButtonPlain className="concise-issue-box-message" aria-current={selected}>
- <IssueMessageHighlighting
- message={issue.message}
- messageFormattings={issue.messageFormattings}
- />
- </ButtonPlain>
- <div className="concise-issue-box-attributes">
- <TypeHelper className="display-block little-spacer-right" type={issue.type} />
- {issue.flowsWithType.length > 0 ? (
- <span className="concise-issue-box-flow-indicator muted">
- {translateWithParameters(
- 'issue.x_data_flows',
- issue.flowsWithType.filter((f) => f.type === FlowType.DATA).length
- )}
- </span>
- ) : (
- <ConciseIssueLocations
- issue={issue}
- onFlowSelect={props.onFlowSelect}
- selectedFlowIndex={selectedFlowIndex}
- />
- )}
- </div>
- {selected &&
- (issue.flowsWithType.length > 0 ? (
- <FlowsList
- flows={issue.flowsWithType}
- onLocationSelect={props.onLocationSelect}
- onFlowSelect={props.onFlowSelect}
- selectedLocationIndex={selectedLocationIndex}
- selectedFlowIndex={selectedFlowIndex}
- />
- ) : (
- <LocationsList
- locations={locations}
- componentKey={issue.component}
- onLocationSelect={props.onLocationSelect}
- selectedLocationIndex={selectedLocationIndex}
- />
- ))}
- </div>
- );
-}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx
deleted file mode 100644
index e99e0beaa2e..00000000000
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx
+++ /dev/null
@@ -1,92 +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 { Button } from '../../../components/controls/buttons';
-import { Issue } from '../../../types/types';
-import ConciseIssueLocationBadge from './ConciseIssueLocationBadge';
-
-interface Props {
- issue: Pick<Issue, 'flows' | 'secondaryLocations'>;
- onFlowSelect: (index: number) => void;
- selectedFlowIndex: number | undefined;
-}
-
-interface State {
- collapsed: boolean;
-}
-
-export const COLLAPSE_LIMIT = 8;
-
-export default class ConciseIssueLocations extends React.PureComponent<Props, State> {
- state = { collapsed: true };
-
- handleExpandClick = () => {
- this.setState({ collapsed: false });
- };
-
- renderExpandButton() {
- return (
- <Button
- className="concise-issue-expand location-index link-no-underline"
- onClick={this.handleExpandClick}
- >
- ...
- </Button>
- );
- }
-
- render() {
- const { secondaryLocations, flows } = this.props.issue;
-
- const badges: JSX.Element[] = [];
-
- if (secondaryLocations.length > 0) {
- badges.push(
- <ConciseIssueLocationBadge
- count={secondaryLocations.length}
- key="-1"
- selected={!this.props.selectedFlowIndex}
- />
- );
- }
-
- flows.forEach((flow, index) => {
- badges.push(
- <ConciseIssueLocationBadge
- count={flow.length}
- key={index}
- onClick={() => this.props.onFlowSelect(index)}
- selected={index === this.props.selectedFlowIndex}
- />
- );
- });
-
- if (!this.state.collapsed || badges.length <= COLLAPSE_LIMIT) {
- return badges;
- }
-
- return (
- <>
- {badges.slice(0, COLLAPSE_LIMIT - 1)}
- {this.renderExpandButton()}
- </>
- );
- }
-}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesList.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesList.tsx
deleted file mode 100644
index 831f2c8f239..00000000000
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesList.tsx
+++ /dev/null
@@ -1,54 +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 { Issue } from '../../../types/types';
-import ConciseIssue from './ConciseIssue';
-
-export interface ConciseIssuesListProps {
- issues: Issue[];
- onFlowSelect: (index?: number) => void;
- onIssueSelect: (issueKey: string) => void;
- onLocationSelect: (index: number) => void;
- selected: string | undefined;
- selectedFlowIndex: number | undefined;
- selectedLocationIndex: number | undefined;
-}
-
-export default function ConciseIssuesList(props: ConciseIssuesListProps) {
- const { issues, selected, selectedFlowIndex, selectedLocationIndex } = props;
-
- return (
- <ul>
- {issues.map((issue, index) => (
- <ConciseIssue
- issue={issue}
- key={issue.key}
- onFlowSelect={props.onFlowSelect}
- onLocationSelect={props.onLocationSelect}
- onSelect={props.onIssueSelect}
- previousIssue={index > 0 ? issues[index - 1] : undefined}
- selected={issue.key === selected}
- selectedFlowIndex={selectedFlowIndex}
- selectedLocationIndex={selectedLocationIndex}
- />
- ))}
- </ul>
- );
-}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesListHeader.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesListHeader.tsx
deleted file mode 100644
index 88eaa5c8ba2..00000000000
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesListHeader.tsx
+++ /dev/null
@@ -1,47 +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 BackButton from '../../../components/controls/BackButton';
-import PageShortcutsTooltip from '../../../components/ui/PageShortcutsTooltip';
-import { translate } from '../../../helpers/l10n';
-
-export interface ConciseIssuesListHeaderProps {
- displayBackButton: boolean;
- loading: boolean;
- onBackClick: () => void;
-}
-
-export default function ConciseIssuesListHeader(props: ConciseIssuesListHeaderProps) {
- const { displayBackButton, loading } = props;
-
- return (
- <header className="layout-page-header-panel concise-issues-list-header">
- <div className="layout-page-header-panel-inner concise-issues-list-header-inner display-flex-center display-flex-space-between">
- {displayBackButton && <BackButton disabled={loading} onClick={props.onBackClick} />}
- <PageShortcutsTooltip
- leftLabel={translate('issues.to_navigate_back')}
- upAndDownLabel={translate('issues.to_select_issues')}
- metaModifierLabel={translate('issues.to_navigate_issue_locations')}
- />
- {loading && <i className="spinner" />}
- </div>
- </header>
- );
-}
diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueItemLocationsQuantity.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueItemLocationsQuantity.tsx
new file mode 100644
index 00000000000..112e5c575a6
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueItemLocationsQuantity.tsx
@@ -0,0 +1,61 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { ExecutionFlowIcon } from 'design-system';
+import * as React from 'react';
+import { translate } from '../../../helpers/l10n';
+import { Issue } from '../../../types/types';
+
+interface Props {
+ issue: Pick<Issue, 'flows' | 'flowsWithType' | 'secondaryLocations'>;
+}
+
+export default function IssueItemLocationsQuantity(props: Props) {
+ const { quantity, message } = getLocationsText(props.issue);
+
+ if (message) {
+ return (
+ <div className="sw-flex sw-items-center sw-justify-center sw-gap-1 sw-overflow-hidden">
+ <ExecutionFlowIcon />
+ <span className="sw-truncate" title={`${quantity} ${message}`}>
+ <span className="sw-body-sm-highlight">{quantity}</span> {message}
+ </span>
+ </div>
+ );
+ }
+
+ return null;
+}
+
+function getLocationsText(issue: Props['issue']) {
+ const { flows, flowsWithType, secondaryLocations } = issue;
+ if (flows.length === 1 || flowsWithType.length === 1) {
+ return { quantity: 1, message: translate('issues.execution_flow') };
+ } else if (flows.length > 1) {
+ return { quantity: flows.length, message: translate('issues.execution_flows') };
+ } else if (flowsWithType.length > 1) {
+ return { quantity: flowsWithType.length, message: translate('issues.execution_flows') };
+ } else if (secondaryLocations.length === 1) {
+ return { quantity: secondaryLocations.length, message: translate('issues.location') };
+ } else if (secondaryLocations.length > 1) {
+ return { quantity: secondaryLocations.length, message: translate('issues.locations') };
+ }
+
+ return {};
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueComponent.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueItemType.tsx
index 5855093481c..bb530deeb6f 100644
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueComponent.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueItemType.tsx
@@ -17,18 +17,20 @@
* 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 React from 'react';
+import IssueTypeIcon from '../../../components/icon-mappers/IssueTypeIcon';
+import { translate } from '../../../helpers/l10n';
+import { Issue } from '../../../types/types';
interface Props {
- path: string;
+ issue: Pick<Issue, 'type'>;
}
-export default function ConciseIssueComponent({ path }: Props) {
+export default function IssueItemType(props: Props) {
return (
- <div className="concise-issue-component text-ellipsis note" title={path}>
- {/* <bdi> is used to avoid some cases where the path is wrongly displayed */}
- {/* because of the parent's direction=rtl */}
- <bdi>{path}</bdi>
- </div>
+ <span className="sw-flex sw-items-center sw-gap-1">
+ <IssueTypeIcon type={props.issue.type} />
+ {translate('issue.type', props.issue.type)}
+ </span>
);
}
diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx
new file mode 100644
index 00000000000..88cee15e0b7
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx
@@ -0,0 +1,104 @@
+/*
+ * 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 classNames from 'classnames';
+import { BaseLink, LocationMarker, StyledMarker, themeColor } from 'design-system';
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+ concealed?: boolean;
+ index: number;
+ message: string | undefined;
+ onClick: (index: number) => void;
+ selected: boolean;
+}
+
+export default function IssueLocation(props: Props) {
+ const { index, message, selected, concealed, onClick } = props;
+ const node = useRef<HTMLElement | null>(null);
+ const locationType = useMemo(() => getLocationType(message), [message]);
+ const normalizedMessage = useMemo(() => message?.replace(/^(source|sink): /i, ''), [message]);
+
+ useEffect(() => {
+ if (selected && node.current) {
+ node.current.scrollIntoView({
+ block: 'center',
+ behavior: 'smooth',
+ });
+ }
+ }, [selected]);
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ onClick(index);
+ },
+ [index, onClick]
+ );
+
+ return (
+ <StyledLink aria-current={selected} onClick={handleClick} to={{}}>
+ <StyledLocation
+ className={classNames('sw-p-1 sw-rounded-1/2 sw-flex sw-gap-2 sw-body-sm', {
+ selected,
+ })}
+ ref={(n) => (node.current = n)}
+ >
+ <LocationMarker selected={selected} text={concealed ? undefined : index + 1} />
+ <span>
+ {locationType && (
+ <LocationMarker
+ className="sw-inline sw-mr-2"
+ selected={selected}
+ text={locationType.toUpperCase()}
+ />
+ )}
+ <span>{normalizedMessage ?? translate('issue.unnamed_location')}</span>
+ </span>
+ </StyledLocation>
+ </StyledLink>
+ );
+}
+
+const StyledLocation = styled.div`
+ &.selected,
+ &:hover {
+ background-color: ${themeColor('codeLineLocationSelected')};
+ }
+
+ &:hover ${StyledMarker} {
+ background-color: ${themeColor('codeLineLocationMarkerSelected')};
+ }
+`;
+
+const StyledLink = styled(BaseLink)`
+ color: ${themeColor('pageContent')};
+ border: none;
+`;
+
+function getLocationType(message?: string) {
+ if (message?.toLowerCase().startsWith('source')) {
+ return 'source';
+ } else if (message?.toLowerCase().startsWith('sink')) {
+ return 'sink';
+ }
+ return null;
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocations.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocations.tsx
new file mode 100644
index 00000000000..c70dc70bfee
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocations.tsx
@@ -0,0 +1,61 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import React, { useMemo } from 'react';
+import { FlowLocation, Issue } from '../../../types/types';
+import IssueLocation from './IssueLocation';
+import IssueLocationsCrossFile from './IssueLocationsCrossFile';
+
+interface Props {
+ concealed?: boolean;
+ issue: Pick<Issue, 'component' | 'key' | 'flows' | 'secondaryLocations' | 'type'>;
+ locations: FlowLocation[];
+ onLocationSelect: (index: number) => void;
+ selectedLocationIndex: number | undefined;
+}
+
+export default function IssueLocations(props: Props) {
+ const { concealed, issue, locations, onLocationSelect, selectedLocationIndex } = props;
+ const isCrossFile = useMemo(
+ () => locations.some((location) => location.component !== issue.component),
+ [locations, issue.component]
+ );
+
+ return isCrossFile ? (
+ <IssueLocationsCrossFile
+ issue={issue}
+ locations={locations}
+ onLocationSelect={onLocationSelect}
+ selectedLocationIndex={selectedLocationIndex}
+ />
+ ) : (
+ <div className="sw-flex sw-flex-col sw-gap-1">
+ {locations.map((location, index) => (
+ <IssueLocation
+ concealed={concealed}
+ index={index}
+ key={`${location.msg}-${index}`}
+ message={location.msg}
+ onClick={onLocationSelect}
+ selected={index === selectedLocationIndex}
+ />
+ ))}
+ </div>
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsCrossFile.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsCrossFile.tsx
new file mode 100644
index 00000000000..aa65b807957
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsCrossFile.tsx
@@ -0,0 +1,208 @@
+/*
+ * 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 { DiscreetLink, themeBorder, themeContrast } from 'design-system';
+import React, { PureComponent } from 'react';
+import { translateWithParameters } from '../../../helpers/l10n';
+import { collapsePath } from '../../../helpers/path';
+import { FlowLocation, Issue } from '../../../types/types';
+import IssueLocation from './IssueLocation';
+
+interface Props {
+ issue: Pick<Issue, 'key' | 'type'>;
+ locations: FlowLocation[];
+ onLocationSelect: (index: number) => void;
+ selectedLocationIndex: number | undefined;
+}
+
+interface State {
+ collapsed: boolean;
+}
+
+interface LocationGroup {
+ component: string | undefined;
+ componentName: string | undefined;
+ firstLocationIndex: number;
+ locations: FlowLocation[];
+}
+
+const COLLAPSE_PATH_LIMIT = 15;
+export const VISIBLE_LOCATIONS_COLLAPSE = 2;
+
+export default class IssueLocationsCrossFile extends PureComponent<Props, State> {
+ state: State = { collapsed: true };
+
+ componentDidUpdate(prevProps: Props) {
+ if (this.props.issue.key !== prevProps.issue.key) {
+ this.setState({ collapsed: true });
+ }
+
+ // expand locations list as soon as a location in the middle of the list is selected
+ const { locations: nextLocations } = this.props;
+ if (
+ this.props.selectedLocationIndex &&
+ this.props.selectedLocationIndex > 0 &&
+ nextLocations !== undefined &&
+ this.props.selectedLocationIndex < nextLocations.length - 1
+ ) {
+ this.setState({ collapsed: false });
+ }
+ }
+
+ handleMoreLocationsClick = () => {
+ this.setState({ collapsed: false });
+ };
+
+ groupByFile = (locations: FlowLocation[]) => {
+ const groups: LocationGroup[] = [];
+
+ let currentLocations: FlowLocation[] = [];
+ let currentComponent: string | undefined;
+ let currentComponentName: string | undefined;
+ let currentFirstLocationIndex = 0;
+
+ for (let index = 0; index < locations.length; index++) {
+ const location = locations[index];
+ if (location.component === currentComponent) {
+ currentLocations.push(location);
+ } else {
+ if (currentLocations.length > 0) {
+ groups.push({
+ component: currentComponent,
+ componentName: currentComponentName,
+ firstLocationIndex: currentFirstLocationIndex,
+ locations: currentLocations,
+ });
+ }
+ currentLocations = [location];
+ currentComponent = location.component;
+ currentComponentName = location.componentName;
+ currentFirstLocationIndex = index;
+ }
+ }
+
+ if (currentLocations.length > 0) {
+ groups.push({
+ component: currentComponent,
+ componentName: currentComponentName,
+ firstLocationIndex: currentFirstLocationIndex,
+ locations: currentLocations,
+ });
+ }
+
+ return groups;
+ };
+
+ renderLocation = (index: number, message: string | undefined) => {
+ return (
+ <IssueLocation
+ index={index}
+ key={index}
+ message={message}
+ onClick={this.props.onLocationSelect}
+ selected={index === this.props.selectedLocationIndex}
+ />
+ );
+ };
+
+ renderGroup = (
+ group: LocationGroup,
+ groupIndex: number,
+ { onlyFirst = false, onlyLast = false } = {}
+ ) => {
+ const { firstLocationIndex } = group;
+ const lastLocationIndex = group.locations.length - 1;
+
+ return (
+ <div key={groupIndex}>
+ <ComponentName className="sw-pb-1 sw-body-sm-highlight">
+ {collapsePath(group.componentName ?? '', COLLAPSE_PATH_LIMIT)}
+ </ComponentName>
+ {group.locations.length > 0 && (
+ <GroupBody className="sw-ml-2 sw-pl-2">
+ {onlyFirst && this.renderLocation(firstLocationIndex, group.locations[0].msg)}
+
+ {onlyLast &&
+ this.renderLocation(
+ firstLocationIndex + lastLocationIndex,
+ group.locations[lastLocationIndex].msg
+ )}
+
+ {!onlyFirst &&
+ !onlyLast &&
+ group.locations.map((location, index) =>
+ this.renderLocation(firstLocationIndex + index, location.msg)
+ )}
+ </GroupBody>
+ )}
+ </div>
+ );
+ };
+
+ render() {
+ const { locations } = this.props;
+ const groups = this.groupByFile(locations);
+
+ if (
+ locations.length > VISIBLE_LOCATIONS_COLLAPSE &&
+ groups.length > 1 &&
+ this.state.collapsed
+ ) {
+ const firstGroup = groups[0];
+ const lastGroup = groups[groups.length - 1];
+ return (
+ <div className="sw-flex sw-flex-col sw-gap-4">
+ {this.renderGroup(firstGroup, 0, { onlyFirst: true })}
+ <div>
+ <ExpandLink
+ blurAfterClick={true}
+ onClick={this.handleMoreLocationsClick}
+ preventDefault={true}
+ to={{}}
+ >
+ {translateWithParameters(
+ 'issues.show_x_more_locations',
+ locations.length - VISIBLE_LOCATIONS_COLLAPSE
+ )}
+ </ExpandLink>
+ </div>
+ {this.renderGroup(lastGroup, groups.length - 1, { onlyLast: true })}
+ </div>
+ );
+ }
+ return (
+ <div className="sw-flex sw-flex-col sw-gap-4">
+ {groups.map((group, groupIndex) => this.renderGroup(group, groupIndex))}
+ </div>
+ );
+ }
+}
+
+const GroupBody = styled.div`
+ border-left: ${themeBorder('default', 'subnavigationExecutionFlowBorder')};
+`;
+
+const ComponentName = styled.div`
+ color: ${themeContrast('subnavigation')};
+`;
+
+const ExpandLink = styled(DiscreetLink)`
+ color: ${themeContrast('subnavigationSubheading')};
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigator.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigator.tsx
new file mode 100644
index 00000000000..8cfadabd40e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigator.tsx
@@ -0,0 +1,145 @@
+/*
+ * 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 { ExecutionFlowAccordion, SubnavigationFlowSeparator } from 'design-system';
+import React, { useCallback, useRef } from 'react';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { Flow, FlowType, Issue } from '../../../types/types';
+import { getLocations } from '../utils';
+import IssueLocations from './IssueLocations';
+import IssueLocationsNavigatorKeyboardHint from './IssueLocationsNavigatorKeyboardHint';
+
+interface Props {
+ issue: Pick<
+ Issue,
+ 'component' | 'key' | 'flows' | 'secondaryLocations' | 'type' | 'flowsWithType'
+ >;
+ onFlowSelect: (index: number) => void;
+ onLocationSelect: (index: number) => void;
+ selectedFlowIndex: number | undefined;
+ selectedLocationIndex: number | undefined;
+}
+
+export default function IssueLocationsNavigator(props: Props) {
+ const { issue, onFlowSelect, onLocationSelect, selectedFlowIndex, selectedLocationIndex } = props;
+ const accordionElement = useRef<HTMLDivElement | null>(null);
+ const hasFlows =
+ issue.flows.some((f) => f.length > 0) ||
+ issue.flowsWithType.some((f) => f.locations.length > 0);
+ const hasOneFlow = issue.flows.length === 1;
+ const hasSecondaryLocations = issue.secondaryLocations.length > 0;
+
+ const handleAccordionClick = useCallback(
+ (index) => {
+ if (onFlowSelect) {
+ onFlowSelect(index === selectedFlowIndex ? undefined : index);
+ if (index !== selectedFlowIndex) {
+ accordionElement.current?.scrollIntoView({
+ block: 'center',
+ behavior: 'smooth',
+ });
+ }
+ }
+ },
+ [selectedFlowIndex, onFlowSelect]
+ );
+
+ if (!hasSecondaryLocations && !hasFlows) {
+ return null;
+ }
+
+ if (hasSecondaryLocations || hasOneFlow) {
+ const locations = getLocations(issue, selectedFlowIndex);
+
+ if (locations.every((location) => !location.msg)) {
+ return null;
+ }
+
+ return (
+ <>
+ <SubnavigationFlowSeparator className="sw-my-2" />
+ <div className="sw-flex sw-flex-col sw-gap-1">
+ <IssueLocations
+ concealed={hasSecondaryLocations}
+ issue={issue}
+ locations={locations}
+ onLocationSelect={onLocationSelect}
+ selectedLocationIndex={selectedLocationIndex}
+ />
+ </div>
+ {locations.length > 1 && <IssueLocationsNavigatorKeyboardHint showLeftRightHint={false} />}
+ </>
+ );
+ }
+
+ const hasFlowsWithType = issue.flowsWithType.length > 0;
+ const flows = hasFlowsWithType
+ ? issue.flowsWithType
+ : issue.flows.map((locations) => ({ type: FlowType.EXECUTION, locations }));
+
+ if (flows.length > 0) {
+ return (
+ <>
+ <div className="sw-flex sw-flex-col sw-gap-4 sw-mt-4">
+ {flows.map((flow, index) => (
+ <ExecutionFlowAccordion
+ expanded={index === selectedFlowIndex}
+ header={
+ <span>
+ <strong>
+ {flow.locations.length > 1
+ ? translateWithParameters('issue.flow.x_steps', flow.locations.length)
+ : translate('issue.flow.1_step')}
+ </strong>{' '}
+ {getExecutionFlowLabel(flow, hasFlowsWithType)}
+ </span>
+ }
+ id={`${issue.key}-flow-${index}`}
+ innerRef={(n) => (accordionElement.current = n)}
+ key={`${issue.key}-flow-${index}`}
+ onClick={() => {
+ handleAccordionClick(index);
+ }}
+ >
+ <IssueLocations
+ issue={issue}
+ locations={flow.locations}
+ onLocationSelect={onLocationSelect}
+ selectedLocationIndex={selectedLocationIndex}
+ />
+ </ExecutionFlowAccordion>
+ ))}
+ </div>
+ <IssueLocationsNavigatorKeyboardHint showLeftRightHint={true} />
+ </>
+ );
+ }
+
+ return null;
+}
+
+function getExecutionFlowLabel(flow: Flow, hasFlowsWithType: boolean) {
+ if (hasFlowsWithType) {
+ return flow.type === FlowType.EXECUTION
+ ? translate('issue.full_execution_flow')
+ : flow.description;
+ }
+
+ return translate('issues.execution_flow');
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigatorKeyboardHint.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigatorKeyboardHint.tsx
new file mode 100644
index 00000000000..558b3d7ed00
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigatorKeyboardHint.tsx
@@ -0,0 +1,41 @@
+/*
+ * 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 { KeyboardHint } from 'design-system';
+import * as React from 'react';
+import { KeyboardKeys } from '../../../helpers/keycodes';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+ showLeftRightHint?: boolean;
+}
+
+export default function IssueLocationsNavigatorKeyboardHint({ showLeftRightHint }: Props) {
+ const leftRightHint = showLeftRightHint
+ ? `${KeyboardKeys.LeftArrow} ${KeyboardKeys.RightArrow}`
+ : '';
+ return (
+ <div className="sw-flex sw-justify-center sw-mt-4">
+ <KeyboardHint
+ command={`${KeyboardKeys.Alt} + ${KeyboardKeys.UpArrow} ${KeyboardKeys.DownArrow} ${leftRightHint}`}
+ title={translate('issue.hint.navigate')}
+ />
+ </div>
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx
new file mode 100644
index 00000000000..e4ed91a30d3
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx
@@ -0,0 +1,98 @@
+/*
+ * 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 { BareButton, SubnavigationItem, themeColor, themeContrast } from 'design-system';
+import { noop } from 'lodash';
+import * as React from 'react';
+import { Issue } from '../../../types/types';
+import IssueItemLocationsQuantity from './IssueItemLocationsQuantity';
+import IssueItemType from './IssueItemType';
+import IssueLocationsNavigator from './IssueLocationsNavigator';
+
+const HALF_DIVIDER = 2;
+
+export interface ConciseIssueProps {
+ issue: Issue;
+ onFlowSelect: (index?: number) => void;
+ onLocationSelect: (index: number) => void;
+ onClick: (issueKey: string) => void;
+ selected: boolean;
+ selectedFlowIndex: number | undefined;
+ selectedLocationIndex: number | undefined;
+}
+
+export default function SubnavigationIssue(props: ConciseIssueProps) {
+ const { issue, selected, selectedFlowIndex, selectedLocationIndex } = props;
+ const element = React.useRef<HTMLLIElement>(null);
+
+ React.useEffect(() => {
+ if (selected && element.current) {
+ const parent = document.querySelector('.layout-page-side') as HTMLMenuElement;
+ const rect = parent.getBoundingClientRect();
+ const offset =
+ element.current.offsetTop - rect.height / HALF_DIVIDER + rect.top / HALF_DIVIDER;
+ parent.scrollTo({ top: offset, behavior: 'smooth' });
+ }
+ }, [selected]);
+
+ return (
+ <li ref={element}>
+ <SubnavigationItem
+ active={selected}
+ onClick={selected ? noop : props.onClick}
+ value={issue.key}
+ >
+ <div className="sw-w-full">
+ <StyledIssueTitle aria-current={selected} className="sw-mb-2">
+ {issue.message}
+ </StyledIssueTitle>
+ <IssueInfo className="sw-flex sw-justify-between sw-gap-2">
+ <IssueItemType issue={issue} />
+ <IssueItemLocationsQuantity issue={issue} />
+ </IssueInfo>
+ {selected && (
+ <IssueLocationsNavigator
+ issue={issue}
+ onFlowSelect={props.onFlowSelect}
+ onLocationSelect={props.onLocationSelect}
+ selectedFlowIndex={selectedFlowIndex}
+ selectedLocationIndex={selectedLocationIndex}
+ />
+ )}
+ </div>
+ </SubnavigationItem>
+ </li>
+ );
+}
+
+const IssueInfo = styled.div`
+ color: ${themeContrast('pageContentLight')};
+
+ .active &,
+ :hover & {
+ color: ${themeContrast('subnavigation')};
+ }
+`;
+
+const StyledIssueTitle = styled(BareButton)`
+ &:focus {
+ background-color: ${themeColor('subnavigationSelected')};
+ }
+`;
diff --git a/server/sonar-web/src/main/js/components/locations/FlowsList.css b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssueComponentName.tsx
index c80eb553dff..b2b5dcba662 100644
--- a/server/sonar-web/src/main/js/components/locations/FlowsList.css
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssueComponentName.tsx
@@ -17,18 +17,27 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-.issue-flows .boxed-group-header {
- padding: calc(1.5 * var(--gridSize));
-}
+import styled from '@emotion/styled';
+import { LightLabel, SubnavigationHeading, themeBorder } from 'design-system';
+import * as React from 'react';
+import { collapsePath } from '../../../helpers/path';
-.issue-flows .boxed-group-inner {
- padding: 0 var(--gridSize) var(--gridSize);
-}
+const COLLAPSE_PATH_LIMIT = 8;
-.issue-flows .boxed-group-header .location-index {
- background-color: var(--neutral600);
+interface Props {
+ path: string;
}
-.issue-flows .boxed-group-header .location-index.selected {
- background-color: var(--conciseIssueRedSelected);
+export default function SubnavigationIssueComponentName({ path }: Props) {
+ return (
+ <StyledHeading className="sw-pb-1 sw-pt-6 sw-flex sw-truncate" title={path}>
+ <LightLabel className="sw-truncate">{collapsePath(path, COLLAPSE_PATH_LIMIT)}</LightLabel>
+ </StyledHeading>
+ );
}
+
+const StyledHeading = styled(SubnavigationHeading)`
+ &:not(:last-child) {
+ border-bottom: ${themeBorder('default')};
+ }
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssuesList.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssuesList.tsx
new file mode 100644
index 00000000000..675540f90c4
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssuesList.tsx
@@ -0,0 +1,117 @@
+/*
+ * 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 { themeBorder, themeColor } from 'design-system';
+import * as React from 'react';
+import ListFooter from '../../../components/controls/ListFooter';
+import { Issue, Paging } from '../../../types/types';
+import SubnavigationIssue from './SubnavigationIssue';
+import SubnavigationIssueComponentName from './SubnavigationIssueComponentName';
+import SubnavigationIssuesListHeader from './SubnavigationIssuesListHeader';
+
+interface Props {
+ fetchMoreIssues: () => void;
+ issues: Issue[];
+ loading: boolean;
+ loadingMore: boolean;
+ onFlowSelect: (index?: number) => void;
+ onIssueSelect: (issueKey: string) => void;
+ onLocationSelect: (index: number) => void;
+ paging: Paging | undefined;
+ selected: string | undefined;
+ selectedFlowIndex: number | undefined;
+ selectedLocationIndex: number | undefined;
+}
+
+export default function SubnavigationIssuesList(props: Props) {
+ const {
+ issues,
+ loading,
+ loadingMore,
+ paging,
+ selected,
+ selectedFlowIndex,
+ selectedLocationIndex,
+ } = props;
+
+ let selectedIndex: number | undefined = issues.findIndex((issue) => issue.key === selected);
+ selectedIndex = selectedIndex === -1 ? undefined : selectedIndex;
+
+ return (
+ <StyledWrapper>
+ <SubnavigationIssuesListHeader
+ loading={loading}
+ paging={paging}
+ selectedIndex={selectedIndex}
+ />
+ <StyledList>
+ {issues.map((issue, index) => {
+ const previousIssue = index > 0 ? issues[index - 1] : undefined;
+ const displayComponentName =
+ !previousIssue || previousIssue.component !== issue.component;
+
+ return (
+ <React.Fragment key={index}>
+ {displayComponentName && (
+ <li>
+ <SubnavigationIssueComponentName path={issue.componentLongName} />
+ </li>
+ )}
+
+ <SubnavigationIssue
+ issue={issue}
+ onClick={props.onIssueSelect}
+ onFlowSelect={props.onFlowSelect}
+ onLocationSelect={props.onLocationSelect}
+ selected={issue.key === selected}
+ selectedFlowIndex={selectedFlowIndex}
+ selectedLocationIndex={selectedLocationIndex}
+ />
+ </React.Fragment>
+ );
+ })}
+ </StyledList>
+ {paging && paging.total > 0 && (
+ <StyledFooter
+ className="sw-my-0 sw-py-4"
+ count={issues.length}
+ loadMore={props.fetchMoreIssues}
+ loading={loadingMore}
+ total={paging.total}
+ useMIUIButtons={true}
+ />
+ )}
+ </StyledWrapper>
+ );
+}
+
+const StyledList = styled.ul`
+ li:not(:last-child) {
+ border-bottom: ${themeBorder('default')};
+ }
+`;
+
+const StyledFooter = styled(ListFooter)`
+ border-top: ${themeBorder('default', 'filterbarBorder')};
+`;
+
+const StyledWrapper = styled.div`
+ background-color: ${themeColor('filterbar')};
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationBadge.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssuesListHeader.tsx
index 9a8a5d8c615..bab12fa0923 100644
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationBadge.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssuesListHeader.tsx
@@ -17,35 +17,32 @@
* 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 { DeferredSpinner, SubnavigationHeading, themeBorder } from 'design-system';
import * as React from 'react';
-import LocationIndex from '../../../components/common/LocationIndex';
-import Tooltip from '../../../components/controls/Tooltip';
-import { translateWithParameters } from '../../../helpers/l10n';
-import { formatMeasure } from '../../../helpers/measures';
+import { Paging } from '../../../types/types';
+import IssuesCounter from '../components/IssuesCounter';
interface Props {
- count: number;
- flow?: boolean;
- onClick?: () => void;
- selected: boolean;
+ loading: boolean;
+ paging: Paging | undefined;
+ selectedIndex: number | undefined;
}
-export default function ConciseIssueLocationBadge(props: Props) {
- const { count, flow, selected } = props;
+export default function SubnavigationIssuesListHeader(props: Props) {
+ const { loading, paging, selectedIndex } = props;
+
return (
- <Tooltip
- mouseEnterDelay={0.5}
- overlay={translateWithParameters(
- flow
- ? 'issue.this_flow_involves_x_code_locations'
- : 'issue.this_issue_involves_x_code_locations',
- formatMeasure(count, 'INT')
- )}
- >
- <LocationIndex onClick={props.onClick} selected={selected}>
- {'+'}
- {count}
- </LocationIndex>
- </Tooltip>
+ <StyledHeader>
+ <DeferredSpinner loading={loading}>
+ {paging && <IssuesCounter current={selectedIndex} total={paging.total} />}
+ </DeferredSpinner>
+ </StyledHeader>
);
}
+
+const StyledHeader = styled(SubnavigationHeading)`
+ position: sticky;
+ top: 0;
+ border-bottom: ${themeBorder('default', 'filterbarBorder')};
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssues-it.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx
index d97e7f49f6c..17826853c37 100644
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssues-it.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx
@@ -21,12 +21,12 @@ import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { byRole, byText } from 'testing-library-selector';
-import { mockFlowLocation, mockIssue } from '../../../../helpers/testMocks';
+import { mockFlowLocation, mockIssue, mockPaging } from '../../../../helpers/testMocks';
import { renderComponent } from '../../../../helpers/testReactTestingUtils';
+import { FCProps } from '../../../../helpers/testUtils';
import { FlowType, Issue } from '../../../../types/types';
-import { COLLAPSE_LIMIT } from '../ConciseIssueLocations';
-import ConciseIssuesList, { ConciseIssuesListProps } from '../ConciseIssuesList';
-import ConciseIssuesListHeader, { ConciseIssuesListHeaderProps } from '../ConciseIssuesListHeader';
+import { VISIBLE_LOCATIONS_COLLAPSE } from '../IssueLocationsCrossFile';
+import SubnavigationIssuesList from '../SubnavigationIssuesList';
const loc = mockFlowLocation();
const issues = [
@@ -48,7 +48,7 @@ const issues = [
component: 'bar',
componentLongName: 'Long Bar',
}),
- mockIssue(true, {
+ mockIssue(false, {
key: 'issue4',
message: 'Issue 4',
component: 'foo',
@@ -80,13 +80,6 @@ beforeEach(() => {
});
describe('rendering', () => {
- it('should hide the back button', () => {
- const { ui } = getPageObject();
- renderConciseIssues(issues, {}, { displayBackButton: false });
-
- expect(ui.headerBackButton.query()).not.toBeInTheDocument();
- });
-
it('should render concise issues without duplicating component', () => {
renderConciseIssues(issues);
@@ -126,11 +119,12 @@ describe('rendering', () => {
renderConciseIssues(
[
...issues,
- mockIssue(true, {
+ mockIssue(false, {
key: 'custom',
message: 'Custom Issue',
- flows: Array.from({ length: COLLAPSE_LIMIT - 1 }).map(() => [loc]),
- secondaryLocations: [loc, loc],
+ flows: Array.from({ length: VISIBLE_LOCATIONS_COLLAPSE }).map((i) => [
+ mockFlowLocation({ component: `component-${i}` }),
+ ]),
}),
],
{
@@ -143,41 +137,6 @@ describe('rendering', () => {
});
describe('interacting', () => {
- it('should handle back button properly', async () => {
- const { ui } = getPageObject();
- const onBackClick = jest.fn();
- const { override } = renderConciseIssues(
- issues,
- {},
- {
- displayBackButton: true,
- loading: true,
- onBackClick,
- }
- );
-
- // Back button should be shown, but disabled
- expect(ui.headerBackButton.get()).toBeInTheDocument();
- await ui.clickBackButton();
- expect(onBackClick).toHaveBeenCalledTimes(0);
-
- // Re-render without loading
- override(
- issues,
- {},
- {
- displayBackButton: true,
- loading: false,
- onBackClick,
- }
- );
-
- // Back button should be shown and enabled
- expect(ui.headerBackButton.get()).toBeInTheDocument();
- await ui.clickBackButton();
- expect(onBackClick).toHaveBeenCalledTimes(1);
- });
-
it('should scroll selected issue into view', () => {
const { override } = renderConciseIssues(issues, {
selected: 'issue2',
@@ -193,27 +152,35 @@ describe('interacting', () => {
it('expand button should work correctly', async () => {
const { ui } = getPageObject();
+ const flow = Array.from({ length: VISIBLE_LOCATIONS_COLLAPSE + 1 }).map((_, i) =>
+ mockFlowLocation({
+ component: `component-${i}`,
+ index: i,
+ msg: `loc ${i}`,
+ })
+ );
+
renderConciseIssues(
[
- ...issues,
- mockIssue(true, {
+ mockIssue(false, {
key: 'custom',
+ component: 'issue-component',
message: 'Custom Issue',
- flows: Array.from({ length: COLLAPSE_LIMIT }).map(() => [loc]),
- secondaryLocations: [loc, loc],
+ flows: [flow],
}),
],
{
selected: 'custom',
+ selectedFlowIndex: 0,
}
);
expect(ui.expandBadgesButton.get()).toBeInTheDocument();
- expect(ui.boxLocationFlowBadgeText.getAll()).toHaveLength(COLLAPSE_LIMIT - 1);
+ expect(screen.getAllByText(/loc \d/)).toHaveLength(VISIBLE_LOCATIONS_COLLAPSE);
await ui.clickExpandBadgesButton();
expect(ui.expandBadgesButton.query()).not.toBeInTheDocument();
- expect(ui.boxLocationFlowBadgeText.getAll()).toHaveLength(9);
+ expect(screen.getAllByText(/loc \d/)).toHaveLength(VISIBLE_LOCATIONS_COLLAPSE + 1);
});
it('issue selection should correctly be handled', async () => {
@@ -237,7 +204,7 @@ describe('interacting', () => {
renderConciseIssues(
[
...issues,
- mockIssue(true, {
+ mockIssue(false, {
key: 'custom',
message: 'Custom Issue',
secondaryLocations: [],
@@ -246,13 +213,13 @@ describe('interacting', () => {
],
{
onFlowSelect,
- selected: 'issue4',
+ selected: 'custom',
}
);
expect(onFlowSelect).not.toHaveBeenCalled();
- await user.click(screen.getByText('+3', { exact: false }));
+ await user.click(screen.getByText('issue.flow.x_steps.3'));
expect(onFlowSelect).toHaveBeenCalledTimes(1);
expect(onFlowSelect).toHaveBeenLastCalledWith(1);
});
@@ -261,10 +228,7 @@ describe('interacting', () => {
function getPageObject() {
const selectors = {
headerBackButton: byRole('link', { name: 'issues.return_to_list' }),
- expandBadgesButton: byRole('button', { name: '...' }),
- boxLocationFlowBadgeText: byText('issue.this_issue_involves_x_code_locations', {
- exact: false,
- }),
+ expandBadgesButton: byText(/issues.show_x_more_locations.\d/),
};
const user = userEvent.setup();
const ui = {
@@ -281,19 +245,36 @@ function getPageObject() {
function renderConciseIssues(
issues: Issue[],
- listProps: Partial<ConciseIssuesListProps> = {},
- headerProps: Partial<ConciseIssuesListHeaderProps> = {}
+ listProps: Partial<FCProps<typeof SubnavigationIssuesList>> = {}
) {
const wrapper = renderComponent(
- <>
- <ConciseIssuesListHeader
- displayBackButton={false}
- loading={false}
- onBackClick={jest.fn()}
- {...headerProps}
- />
- <ConciseIssuesList
+ <SubnavigationIssuesList
+ fetchMoreIssues={jest.fn()}
+ loading={false}
+ loadingMore={false}
+ paging={mockPaging({ total: 10 })}
+ issues={issues}
+ onFlowSelect={jest.fn()}
+ onIssueSelect={jest.fn()}
+ onLocationSelect={jest.fn()}
+ selected={undefined}
+ selectedFlowIndex={undefined}
+ selectedLocationIndex={undefined}
+ {...listProps}
+ />
+ );
+
+ function override(
+ issues: Issue[],
+ listProps: Partial<FCProps<typeof SubnavigationIssuesList>> = {}
+ ) {
+ wrapper.rerender(
+ <SubnavigationIssuesList
+ fetchMoreIssues={jest.fn()}
issues={issues}
+ loading={false}
+ loadingMore={false}
+ paging={mockPaging({ total: 10 })}
onFlowSelect={jest.fn()}
onIssueSelect={jest.fn()}
onLocationSelect={jest.fn()}
@@ -302,33 +283,6 @@ function renderConciseIssues(
selectedLocationIndex={undefined}
{...listProps}
/>
- </>
- );
-
- function override(
- issues: Issue[],
- listProps: Partial<ConciseIssuesListProps> = {},
- headerProps: Partial<ConciseIssuesListHeaderProps> = {}
- ) {
- wrapper.rerender(
- <>
- <ConciseIssuesListHeader
- displayBackButton={false}
- loading={false}
- onBackClick={jest.fn()}
- {...headerProps}
- />
- <ConciseIssuesList
- issues={issues}
- onFlowSelect={jest.fn()}
- onIssueSelect={jest.fn()}
- onLocationSelect={jest.fn()}
- selected={undefined}
- selectedFlowIndex={undefined}
- selectedLocationIndex={undefined}
- {...listProps}
- />
- </>
);
}
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx
index 17fd5c8ca10..06688e90d67 100644
--- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx
+++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx
@@ -17,7 +17,6 @@
* 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 {
BareButton,
@@ -126,7 +125,7 @@ const StyledHotspotInfo = styled.div`
color: ${themeContrast('pageContentLight')};
`;
-const StyledSeparator = withTheme(styled.div`
+const StyledSeparator = styled.div`
height: 1px;
background-color: ${themeColor('subnavigationExecutionFlowBorder')};
-`);
+`;
diff --git a/server/sonar-web/src/main/js/components/controls/ListFooter.tsx b/server/sonar-web/src/main/js/components/controls/ListFooter.tsx
index 88fbe615d13..4c8ca653971 100644
--- a/server/sonar-web/src/main/js/components/controls/ListFooter.tsx
+++ b/server/sonar-web/src/main/js/components/controls/ListFooter.tsx
@@ -17,15 +17,14 @@
* 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 classNames from 'classnames';
-import { ButtonSecondary, themeColor } from 'design-system';
+import { ButtonSecondary, DeferredSpinner, themeColor } from 'design-system';
import * as React from 'react';
import { translate, translateWithParameters } from '../../helpers/l10n';
import { formatMeasure } from '../../helpers/measures';
import { MetricType } from '../../types/metrics';
-import DeferredSpinner from '../ui/DeferredSpinner';
+import LegacySpinner from '../ui/DeferredSpinner';
import { Button } from './buttons';
export interface ListFooterProps {
@@ -122,11 +121,16 @@ export default function ListFooter(props: ListFooterProps) {
: translateWithParameters('x_show', formatMeasure(count, MetricType.Integer, null))}
</span>
{button}
- {<DeferredSpinner loading={loading} className="sw-ml-2" />}
+ {/* eslint-disable local-rules/no-conditional-rendering-of-deferredspinner */}
+ {useMIUIButtons ? (
+ <DeferredSpinner loading={loading} className="sw-ml-2" />
+ ) : (
+ <LegacySpinner loading={loading} className="sw-ml-2" />
+ )}
</StyledDiv>
);
}
-const StyledDiv = withTheme(styled.div`
+const StyledDiv = styled.div`
color: ${themeColor('pageContentLight')};
-`);
+`;
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx
index 709658d78f9..c40b9543625 100644
--- a/server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx
@@ -86,7 +86,7 @@ describe('ListFooter using MIUI buttons', () => {
describe('rendering', () => {
it('should render correctly when loading', async () => {
renderListFooter({ loading: true });
- expect(await screen.findByText('loading')).toBeInTheDocument();
+ expect(await screen.findByLabelText('loading')).toBeInTheDocument();
});
it('should not render if there are neither loadmore nor reload props', () => {
diff --git a/server/sonar-web/src/main/js/components/icon-mappers/IssueTypeIcon.tsx b/server/sonar-web/src/main/js/components/icon-mappers/IssueTypeIcon.tsx
new file mode 100644
index 00000000000..d035d4aa400
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/icon-mappers/IssueTypeIcon.tsx
@@ -0,0 +1,83 @@
+/*
+ * 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 { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import {
+ BugIcon,
+ CodeSmellIcon,
+ SecurityHotspotIcon,
+ VulnerabilityIcon,
+ themeColor,
+ themeContrast,
+} from 'design-system';
+import { IconProps } from 'design-system/lib/components/icons/Icon';
+import React from 'react';
+import { IssueType } from '../../types/issues';
+
+export interface Props extends IconProps {
+ type: string | IssueType;
+}
+
+export default function IssueTypeIcon({ type, ...iconProps }: Props) {
+ switch (type.toLowerCase()) {
+ case IssueType.Bug.toLowerCase():
+ case 'bugs':
+ case 'new_bugs':
+ case IssueType.Bug:
+ return <BugIcon {...iconProps} />;
+ case IssueType.Vulnerability.toLowerCase():
+ case 'vulnerabilities':
+ case 'new_vulnerabilities':
+ case IssueType.Vulnerability:
+ return <VulnerabilityIcon {...iconProps} />;
+ case IssueType.CodeSmell.toLowerCase():
+ case 'code_smells':
+ case 'new_code_smells':
+ case IssueType.CodeSmell:
+ return <CodeSmellIcon {...iconProps} />;
+ case IssueType.SecurityHotspot.toLowerCase():
+ case 'security_hotspots':
+ case 'new_security_hotspots':
+ case IssueType.SecurityHotspot:
+ return <SecurityHotspotIcon {...iconProps} />;
+ default:
+ return null;
+ }
+}
+
+export function IssueTypeCircleIcon({ className, type, ...iconProps }: Props) {
+ const theme = useTheme();
+ return (
+ <CircleIconContainer
+ className={classNames(
+ 'sw-inline-flex sw-items-center sw-justify-center sw-shrink-0 sw-w-6 sw-h-6',
+ className
+ )}
+ >
+ <IssueTypeIcon fill={themeContrast('issueTypeIcon')({ theme })} type={type} {...iconProps} />
+ </CircleIconContainer>
+ );
+}
+
+const CircleIconContainer = styled.div`
+ background: ${themeColor('issueTypeIcon')};
+ border-radius: 100%;
+`;
diff --git a/server/sonar-web/src/main/js/components/locations/FlowsList.tsx b/server/sonar-web/src/main/js/components/locations/FlowsList.tsx
deleted file mode 100644
index b6edde15ccb..00000000000
--- a/server/sonar-web/src/main/js/components/locations/FlowsList.tsx
+++ /dev/null
@@ -1,107 +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 { uniq } from 'lodash';
-import * as React from 'react';
-import ConciseIssueLocationBadge from '../../apps/issues/conciseIssuesList/ConciseIssueLocationBadge';
-import { translate } from '../../helpers/l10n';
-import { Flow, FlowType } from '../../types/types';
-import BoxedGroupAccordion from '../controls/BoxedGroupAccordion';
-import CrossFileLocationNavigator from './CrossFileLocationNavigator';
-import './FlowsList.css';
-import SingleFileLocationNavigator from './SingleFileLocationNavigator';
-
-const FLOW_ORDER_MAP = {
- [FlowType.DATA]: 0,
- [FlowType.EXECUTION]: 1,
-};
-export interface Props {
- flows: Flow[];
- selectedLocationIndex?: number;
- selectedFlowIndex?: number;
- onFlowSelect: (index?: number) => void;
- onLocationSelect: (index: number) => void;
-}
-
-export default function FlowsList(props: Props) {
- const { flows, selectedLocationIndex, selectedFlowIndex } = props;
-
- flows.sort((f1, f2) => FLOW_ORDER_MAP[f1.type] - FLOW_ORDER_MAP[f2.type]);
-
- return (
- <div className="issue-flows little-padded-top" role="list">
- {flows.map((flow, index) => {
- const open = selectedFlowIndex === index;
-
- const locationComponents = flow.locations.map((location) => location.component);
- const isCrossFile = uniq(locationComponents).length > 1;
-
- let fileLocationNavigator;
-
- if (isCrossFile) {
- fileLocationNavigator = (
- <CrossFileLocationNavigator
- locations={flow.locations}
- onLocationSelect={props.onLocationSelect}
- selectedLocationIndex={selectedLocationIndex}
- />
- );
- } else {
- fileLocationNavigator = (
- <ul>
- {flow.locations.map((location, locIndex) => (
- // eslint-disable-next-line react/no-array-index-key
- <li className="display-flex-column" key={locIndex}>
- <SingleFileLocationNavigator
- index={locIndex}
- message={location.msg}
- messageFormattings={location.msgFormattings}
- onClick={props.onLocationSelect}
- selected={locIndex === selectedLocationIndex}
- />
- </li>
- ))}
- </ul>
- );
- }
-
- return (
- <BoxedGroupAccordion
- className="spacer-top"
- // eslint-disable-next-line react/no-array-index-key
- key={index}
- onClick={() => props.onFlowSelect(open ? undefined : index)}
- open={open}
- noBorder={flow.type === FlowType.EXECUTION}
- title={
- flow.type === FlowType.EXECUTION
- ? translate('issue.execution_flow')
- : flow.description
- }
- renderHeader={() => (
- <ConciseIssueLocationBadge count={flow.locations.length} flow selected={open} />
- )}
- >
- {fileLocationNavigator}
- </BoxedGroupAccordion>
- );
- })}
- </div>
- );
-}
diff --git a/server/sonar-web/src/main/js/components/locations/__tests__/FlowsList-test.tsx b/server/sonar-web/src/main/js/components/locations/__tests__/FlowsList-test.tsx
deleted file mode 100644
index 7b85d51b0d1..00000000000
--- a/server/sonar-web/src/main/js/components/locations/__tests__/FlowsList-test.tsx
+++ /dev/null
@@ -1,76 +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 { render, screen } from '@testing-library/react';
-import React from 'react';
-import { FlowLocation, FlowType } from '../../../types/types';
-import FlowsList, { Props } from '../FlowsList';
-
-const componentName1 = 'file1';
-const componentName2 = 'file2';
-
-const component1 = `project:dir1/dir2/${componentName1}`;
-const component2 = `project:dir1/dir2/${componentName2}`;
-
-const mockLocation: FlowLocation = {
- msg: 'location message',
- component: component1,
- textRange: { startLine: 1, startOffset: 2, endLine: 3, endOffset: 4 },
-};
-
-it('should display file names for multi-file issues', () => {
- renderComponent({
- flows: [
- {
- locations: [
- { ...mockLocation, component: component1, componentName: componentName1 },
- { ...mockLocation, component: component1, componentName: componentName1 },
- { ...mockLocation, component: component2, componentName: componentName2 },
- ],
- type: FlowType.EXECUTION,
- },
- { locations: [], type: FlowType.DATA },
- ],
- selectedFlowIndex: 1,
- });
-
- expect(screen.getByText(componentName1)).toBeInTheDocument();
- expect(screen.getByText(componentName2)).toBeInTheDocument();
-});
-
-it('should not display file names for single-file issues', () => {
- renderComponent({
- flows: [
- {
- locations: [
- { ...mockLocation, component: component1, componentName: componentName1 },
- { ...mockLocation, component: component1, componentName: componentName1 },
- ],
- type: FlowType.EXECUTION,
- },
- { locations: [], type: FlowType.DATA },
- ],
- selectedFlowIndex: 1,
- });
-
- expect(screen.queryByText(componentName1)).not.toBeInTheDocument();
-});
-
-const renderComponent = (props?: Partial<Props>) =>
- render(<FlowsList flows={[]} onFlowSelect={jest.fn()} onLocationSelect={jest.fn()} {...props} />);
diff --git a/server/sonar-web/src/main/js/helpers/issues.ts b/server/sonar-web/src/main/js/helpers/issues.ts
index 2e970d123da..7572276d6c4 100644
--- a/server/sonar-web/src/main/js/helpers/issues.ts
+++ b/server/sonar-web/src/main/js/helpers/issues.ts
@@ -21,7 +21,7 @@ import { BugIcon, CodeSmellIcon, SecurityHotspotIcon, VulnerabilityIcon } from '
import { flatten, sortBy } from 'lodash';
import { IssueType, RawIssue } from '../types/issues';
import { MetricKey } from '../types/metrics';
-import { Dict, Flow, FlowLocation, Issue, TextRange } from '../types/types';
+import { Dict, Flow, FlowLocation, FlowType, Issue, TextRange } from '../types/types';
import { UserBase } from '../types/users';
import { ISSUE_TYPES } from './constants';
@@ -95,14 +95,22 @@ function reverseLocations(locations: FlowLocation[]): FlowLocation[] {
return x;
}
+const FLOW_ORDER_MAP = {
+ [FlowType.DATA]: 0,
+ [FlowType.EXECUTION]: 1,
+};
+
function splitFlows(
issue: RawIssue,
components: Component[] = []
): { secondaryLocations: FlowLocation[]; flows: FlowLocation[][]; flowsWithType: Flow[] } {
if (issue.flows?.some((flow) => flow.type !== undefined)) {
+ const flowsWithType = issue.flows.filter((flow) => flow.type !== undefined) as Flow[];
+ flowsWithType.sort((f1, f2) => FLOW_ORDER_MAP[f1.type] - FLOW_ORDER_MAP[f2.type]);
+
return {
flows: [],
- flowsWithType: issue.flows.filter((flow) => flow.type !== undefined) as Flow[],
+ flowsWithType,
secondaryLocations: [],
};
}
diff --git a/server/sonar-web/src/main/js/helpers/testUtils.ts b/server/sonar-web/src/main/js/helpers/testUtils.ts
index 47603ba7ce2..2a7e904c232 100644
--- a/server/sonar-web/src/main/js/helpers/testUtils.ts
+++ b/server/sonar-web/src/main/js/helpers/testUtils.ts
@@ -21,6 +21,8 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme';
import { setImmediate } from 'timers';
import { KeyboardKeys } from './keycodes';
+export type FCProps<T extends React.FunctionComponent<any>> = Parameters<T>[0];
+
export function mockEvent(overrides = {}) {
return {
target: {