diff options
author | Jay <jeremy.davis@sonarsource.com> | 2023-05-25 11:42:15 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-06-09 20:03:09 +0000 |
commit | 9b201e724ef777d698cf26268560d8e3d97bfb71 (patch) | |
tree | 5aa799a4e2db4a032a3b40844864f56e2f473838 /server/sonar-web/src/main | |
parent | d9770048ef20fa6c100718129166748523db377f (diff) | |
download | sonarqube-9b201e724ef777d698cf26268560d8e3d97bfb71.tar.gz sonarqube-9b201e724ef777d698cf26268560d8e3d97bfb71.zip |
SONAR-19345 New UI for issues subnavigation
Diffstat (limited to 'server/sonar-web/src/main')
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: { |