From 9b201e724ef777d698cf26268560d8e3d97bfb71 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 May 2023 11:42:15 +0200 Subject: [PATCH] SONAR-19345 New UI for issues subnavigation --- .../src/components/Separator.tsx | 4 + .../design-system/src/theme/light.ts | 1 + .../js/apps/issues/__tests__/IssuesApp-it.tsx | 55 ++--- .../js/apps/issues/components/IssuesApp.tsx | 55 ++--- .../issues/conciseIssuesList/ConciseIssue.tsx | 74 ------- .../conciseIssuesList/ConciseIssueBox.tsx | 101 --------- .../ConciseIssueLocations.tsx | 92 -------- .../conciseIssuesList/ConciseIssuesList.tsx | 54 ----- .../ConciseIssuesListHeader.tsx | 47 ---- .../IssueItemLocationsQuantity.tsx | 61 +++++ .../IssueItemType.tsx} | 18 +- .../issues-subnavigation/IssueLocation.tsx | 104 +++++++++ .../issues-subnavigation/IssueLocations.tsx | 61 +++++ .../IssueLocationsCrossFile.tsx | 208 ++++++++++++++++++ .../IssueLocationsNavigator.tsx | 145 ++++++++++++ .../IssueLocationsNavigatorKeyboardHint.tsx | 41 ++++ .../SubnavigationIssue.tsx | 98 +++++++++ .../SubnavigationIssueComponentName.tsx} | 29 ++- .../SubnavigationIssuesList.tsx | 117 ++++++++++ .../SubnavigationIssuesListHeader.tsx} | 45 ++-- .../__tests__/SubnavigationIssues-it.tsx} | 154 +++++-------- .../components/HotspotListItem.tsx | 5 +- .../js/components/controls/ListFooter.tsx | 16 +- .../controls/__tests__/ListFooter-test.tsx | 2 +- .../components/icon-mappers/IssueTypeIcon.tsx | 83 +++++++ .../js/components/locations/FlowsList.tsx | 107 --------- .../locations/__tests__/FlowsList-test.tsx | 76 ------- .../sonar-web/src/main/js/helpers/issues.ts | 12 +- .../src/main/js/helpers/testUtils.ts | 2 + .../resources/org/sonar/l10n/core.properties | 13 +- 30 files changed, 1114 insertions(+), 766 deletions(-) delete mode 100644 server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssue.tsx delete mode 100644 server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx delete mode 100644 server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx delete mode 100644 server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesList.tsx delete mode 100644 server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesListHeader.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueItemLocationsQuantity.tsx rename server/sonar-web/src/main/js/apps/issues/{conciseIssuesList/ConciseIssueComponent.tsx => issues-subnavigation/IssueItemType.tsx} (66%) create mode 100644 server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocations.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsCrossFile.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigator.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocationsNavigatorKeyboardHint.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx rename server/sonar-web/src/main/js/{components/locations/FlowsList.css => apps/issues/issues-subnavigation/SubnavigationIssueComponentName.tsx} (55%) create mode 100644 server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssuesList.tsx rename server/sonar-web/src/main/js/apps/issues/{conciseIssuesList/ConciseIssueLocationBadge.tsx => issues-subnavigation/SubnavigationIssuesListHeader.tsx} (52%) rename server/sonar-web/src/main/js/apps/issues/{conciseIssuesList/__tests__/ConciseIssues-it.tsx => issues-subnavigation/__tests__/SubnavigationIssues-it.tsx} (67%) create mode 100644 server/sonar-web/src/main/js/components/icon-mappers/IssueTypeIcon.tsx delete mode 100644 server/sonar-web/src/main/js/components/locations/FlowsList.tsx delete mode 100644 server/sonar-web/src/main/js/components/locations/__tests__/FlowsList-test.tsx diff --git a/server/sonar-web/design-system/src/components/Separator.tsx b/server/sonar-web/design-system/src/components/Separator.tsx index 5f9a4d6d38f..7545293987d 100644 --- a/server/sonar-web/design-system/src/components/Separator.tsx +++ b/server/sonar-web/design-system/src/components/Separator.tsx @@ -38,3 +38,7 @@ export const BlueGreySeparator = styled(BasicSeparator)` export const GreySeparator = styled(BasicSeparator)` background-color: ${themeColor('subnavigationBorder')}; `; + +export const SubnavigationFlowSeparator = styled(BasicSeparator)` + background-color: ${themeColor('subnavigationExecutionFlowSeparator')}; +`; diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index 1064d8ab0ee..ac7d1fdd012 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -432,6 +432,7 @@ export const lightTheme = { subnavigationDisabled: COLORS.blueGrey[300], subnavigationExecutionFlow: COLORS.blueGrey[25], subnavigationExecutionFlowBorder: secondary.default, + subnavigationExecutionFlowSeparator: COLORS.blueGrey[100], subnavigationExecutionFlowActive: COLORS.indigo[500], // footer 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 { ); } - renderConciseIssuesList() { - const { issues, loadingMore, paging, query } = this.state; - - return ( -
- - - {paging && paging.total > 0 && ( - - )} -
- ); - } - renderSide(openIssue: Issue | undefined) { const { canBrowseAllChildProjects, qualifier = ComponentQualifier.Project } = this.props.component || {}; + + const { issues, paging } = this.state; + return ( {({ top }) => ( @@ -1025,7 +996,23 @@ export class App extends React.PureComponent { )} - {openIssue ? this.renderConciseIssuesList() : this.renderFacets()} + {openIssue ? ( + + ) : ( + this.renderFacets() + )} )} 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(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 && ( -
  • - -
  • - )} -
  • - -
  • - - ); -} 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 ( -
    - - - -
    - - {issue.flowsWithType.length > 0 ? ( - - {translateWithParameters( - 'issue.x_data_flows', - issue.flowsWithType.filter((f) => f.type === FlowType.DATA).length - )} - - ) : ( - - )} -
    - {selected && - (issue.flowsWithType.length > 0 ? ( - - ) : ( - - ))} -
    - ); -} 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; - onFlowSelect: (index: number) => void; - selectedFlowIndex: number | undefined; -} - -interface State { - collapsed: boolean; -} - -export const COLLAPSE_LIMIT = 8; - -export default class ConciseIssueLocations extends React.PureComponent { - state = { collapsed: true }; - - handleExpandClick = () => { - this.setState({ collapsed: false }); - }; - - renderExpandButton() { - return ( - - ); - } - - render() { - const { secondaryLocations, flows } = this.props.issue; - - const badges: JSX.Element[] = []; - - if (secondaryLocations.length > 0) { - badges.push( - - ); - } - - flows.forEach((flow, index) => { - badges.push( - 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 ( -
      - {issues.map((issue, index) => ( - 0 ? issues[index - 1] : undefined} - selected={issue.key === selected} - selectedFlowIndex={selectedFlowIndex} - selectedLocationIndex={selectedLocationIndex} - /> - ))} -
    - ); -} 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 ( -
    -
    - {displayBackButton && } - - {loading && } -
    -
    - ); -} 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; +} + +export default function IssueItemLocationsQuantity(props: Props) { + const { quantity, message } = getLocationsText(props.issue); + + if (message) { + return ( +
    + + + {quantity} {message} + +
    + ); + } + + 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 similarity index 66% rename from server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueComponent.tsx rename to 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; } -export default function ConciseIssueComponent({ path }: Props) { +export default function IssueItemType(props: Props) { return ( -
    - {/* is used to avoid some cases where the path is wrongly displayed */} - {/* because of the parent's direction=rtl */} - {path} -
    + + + {translate('issue.type', props.issue.type)} + ); } 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(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) => { + event.preventDefault(); + onClick(index); + }, + [index, onClick] + ); + + return ( + + (node.current = n)} + > + + + {locationType && ( + + )} + {normalizedMessage ?? translate('issue.unnamed_location')} + + + + ); +} + +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; + 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 ? ( + + ) : ( +
    + {locations.map((location, index) => ( + + ))} +
    + ); +} 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; + 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 { + 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 ( + + ); + }; + + renderGroup = ( + group: LocationGroup, + groupIndex: number, + { onlyFirst = false, onlyLast = false } = {} + ) => { + const { firstLocationIndex } = group; + const lastLocationIndex = group.locations.length - 1; + + return ( +
    + + {collapsePath(group.componentName ?? '', COLLAPSE_PATH_LIMIT)} + + {group.locations.length > 0 && ( + + {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) + )} + + )} +
    + ); + }; + + 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 ( +
    + {this.renderGroup(firstGroup, 0, { onlyFirst: true })} +
    + + {translateWithParameters( + 'issues.show_x_more_locations', + locations.length - VISIBLE_LOCATIONS_COLLAPSE + )} + +
    + {this.renderGroup(lastGroup, groups.length - 1, { onlyLast: true })} +
    + ); + } + return ( +
    + {groups.map((group, groupIndex) => this.renderGroup(group, groupIndex))} +
    + ); + } +} + +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(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 ( + <> + +
    + +
    + {locations.length > 1 && } + + ); + } + + const hasFlowsWithType = issue.flowsWithType.length > 0; + const flows = hasFlowsWithType + ? issue.flowsWithType + : issue.flows.map((locations) => ({ type: FlowType.EXECUTION, locations })); + + if (flows.length > 0) { + return ( + <> +
    + {flows.map((flow, index) => ( + + + {flow.locations.length > 1 + ? translateWithParameters('issue.flow.x_steps', flow.locations.length) + : translate('issue.flow.1_step')} + {' '} + {getExecutionFlowLabel(flow, hasFlowsWithType)} + + } + id={`${issue.key}-flow-${index}`} + innerRef={(n) => (accordionElement.current = n)} + key={`${issue.key}-flow-${index}`} + onClick={() => { + handleAccordionClick(index); + }} + > + + + ))} +
    + + + ); + } + + 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 ( +
    + +
    + ); +} 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(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 ( +
  • + +
    + + {issue.message} + + + + + + {selected && ( + + )} +
    +
    +
  • + ); +} + +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 similarity index 55% rename from server/sonar-web/src/main/js/components/locations/FlowsList.css rename to 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 ( + + {collapsePath(path, COLLAPSE_PATH_LIMIT)} + + ); } + +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 ( + + + + {issues.map((issue, index) => { + const previousIssue = index > 0 ? issues[index - 1] : undefined; + const displayComponentName = + !previousIssue || previousIssue.component !== issue.component; + + return ( + + {displayComponentName && ( +
  • + +
  • + )} + + +
    + ); + })} +
    + {paging && paging.total > 0 && ( + + )} +
    + ); +} + +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 similarity index 52% rename from server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationBadge.tsx rename to 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 ( - - - {'+'} - {count} - - + + + {paging && } + + ); } + +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 similarity index 67% rename from server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssues-it.tsx rename to 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 = {}, - headerProps: Partial = {} + listProps: Partial> = {} ) { const wrapper = renderComponent( - <> - - + ); + + function override( + issues: Issue[], + listProps: Partial> = {} + ) { + wrapper.rerender( + - - ); - - function override( - issues: Issue[], - listProps: Partial = {}, - headerProps: Partial = {} - ) { - wrapper.rerender( - <> - - - ); } 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))} {button} - {} + {/* eslint-disable local-rules/no-conditional-rendering-of-deferredspinner */} + {useMIUIButtons ? ( + + ) : ( + + )} ); } -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 ; + case IssueType.Vulnerability.toLowerCase(): + case 'vulnerabilities': + case 'new_vulnerabilities': + case IssueType.Vulnerability: + return ; + case IssueType.CodeSmell.toLowerCase(): + case 'code_smells': + case 'new_code_smells': + case IssueType.CodeSmell: + return ; + case IssueType.SecurityHotspot.toLowerCase(): + case 'security_hotspots': + case 'new_security_hotspots': + case IssueType.SecurityHotspot: + return ; + default: + return null; + } +} + +export function IssueTypeCircleIcon({ className, type, ...iconProps }: Props) { + const theme = useTheme(); + return ( + + + + ); +} + +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 ( -
    - {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 = ( - - ); - } else { - fileLocationNavigator = ( -
      - {flow.locations.map((location, locIndex) => ( - // eslint-disable-next-line react/no-array-index-key -
    • - -
    • - ))} -
    - ); - } - - return ( - props.onFlowSelect(open ? undefined : index)} - open={open} - noBorder={flow.type === FlowType.EXECUTION} - title={ - flow.type === FlowType.EXECUTION - ? translate('issue.execution_flow') - : flow.description - } - renderHeader={() => ( - - )} - > - {fileLocationNavigator} - - ); - })} -
    - ); -} 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) => - render(); 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> = Parameters[0]; + export function mockEvent(overrides = {}) { return { target: { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 4bdb1a75409..1b751ec1e8d 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -934,7 +934,7 @@ issue.transition.resetastoreview=Reset as To Review issue.transition.resetastoreview.description=The Security Hotspot should be analyzed again issue.tabs.code=Where is the issue? issue.x_data_flows={0} data flow(s) -issue.execution_flow=Full execution flow +issue.full_execution_flow=Full execution flow issue.location_x=Location {0} issue.closed.file_level=This issue is {status}. It was detected in the file below and is no longer being detected. issue.closed.project_level=This issue is {status}. It was detected in the project below and is no longer being detected. @@ -1008,6 +1008,17 @@ issues.x_more_locations=+ {0} more locations issues.not_all_issue_show=Not all issues are included issues.not_all_issue_show_why=You do not have access to all projects in this portfolio +# ISSUES SUBNAVIGATION +issue.hint.navigate=Navigate locations +issues.execution_flows=execution flows +issues.execution_flow=execution flow +issues.locations=locations +issues.location=location +issues.show_x_more_locations=Show {0} more location(s) +issue.flow.1_step=1 step +issue.flow.x_steps={0} steps +issue.unnamed_location=Other location + #------------------------------------------------------------------------------ # # ISSUE CHANGELOG -- 2.39.5