From 28a8a0f6d2e079e16a511d4112dbf17e66c514f7 Mon Sep 17 00:00:00 2001 From: 7PH Date: Mon, 15 May 2023 15:48:18 +0200 Subject: [PATCH] SONAR-19236 Implement new hotspot sidebar header --- .../security-hotspots/SecurityHotspotsApp.tsx | 19 +- .../SecurityHotspotsAppRenderer.tsx | 124 +++++----- .../__tests__/SecurityHotspotsApp-it.tsx | 39 ++- .../components/FilterBar.tsx | 229 +++++++++++------- .../resources/org/sonar/l10n/core.properties | 4 +- 5 files changed, 254 insertions(+), 161 deletions(-) diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx index b39c2c0b297..0b5d3eae408 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx @@ -24,7 +24,7 @@ import { getSecurityHotspotList, getSecurityHotspots } from '../../api/security- import withBranchStatusActions from '../../app/components/branch-status/withBranchStatusActions'; import withComponentContext from '../../app/components/componentContext/withComponentContext'; import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext'; -import { Location, withRouter } from '../../components/hoc/withRouter'; +import { Location, Router, withRouter } from '../../components/hoc/withRouter'; import { getLeakValue } from '../../components/measure/utils'; import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../helpers/branch-like'; import { isInput } from '../../helpers/keyboardEventHelpers'; @@ -55,6 +55,7 @@ interface OwnProps { currentUser: CurrentUser; component: Component; location: Location; + router: Router; } type Props = DispatchProps & OwnProps; @@ -410,6 +411,20 @@ export class SecurityHotspotsApp extends React.PureComponent { ); }; + handleShowAllHotspots = () => { + this.props.router.push({ + pathname: this.props.location.pathname, + query: { + file: undefined, + fileUuid: undefined, + hotspots: [], + sinceLeakPeriod: undefined, + assignedToMe: undefined, + id: this.props.component.key, + }, + }); + }; + handleChangeStatusFilter = (status: HotspotStatusFilter) => { this.handleChangeFilters({ status }); }; @@ -519,6 +534,7 @@ export class SecurityHotspotsApp extends React.PureComponent { loadingMeasure={loadingMeasure} loadingMore={loadingMore} onChangeFilters={this.handleChangeFilters} + onShowAllHotspots={this.handleShowAllHotspots} onHotspotClick={this.handleHotspotClick} onLoadMore={this.handleLoadMore} onSwitchStatusFilter={this.handleChangeStatusFilter} @@ -526,6 +542,7 @@ export class SecurityHotspotsApp extends React.PureComponent { onLocationClick={this.handleLocationClick} securityCategories={standards[SecurityStandard.SONARSOURCE]} selectedHotspot={selectedHotspot} + selectedHotspotLocation={selectedHotspotLocationIndex} standards={standards} /> ); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx index 6ef57e100d6..f37e044ce2a 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx @@ -62,6 +62,7 @@ export interface SecurityHotspotsAppRendererProps { loadingMeasure: boolean; loadingMore: boolean; onChangeFilters: (filters: Partial) => void; + onShowAllHotspots: VoidFunction; onHotspotClick: (hotspot: RawHotspot) => void; onLocationClick: (index?: number) => void; onLoadMore: () => void; @@ -92,6 +93,8 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe selectedHotspot, selectedHotspotLocation, standards, + onChangeFilters, + onShowAllHotspots, } = props; return ( @@ -100,22 +103,60 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe -
- {!loading && - (hotspots.length === 0 || !selectedHotspot ? ( + + + {hotspots.length > 0 && selectedHotspot && ( + <> + {filterByCategory || filterByCWE || filterByFile ? ( + + ) : ( + + )} + + )} + + +
+ {hotspots.length === 0 || !selectedHotspot ? ( ) : ( - <> - - {filterByCategory || filterByCWE || filterByFile ? ( - - ) : ( - - )} - - -
- -
- - ))} + + )} +
@@ -180,9 +185,8 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe ); } -const FilterbarStyled = withTheme( +const StyledFilterbar = withTheme( styled.div` - position: sticky; box-sizing: border-box; overflow-x: hidden; overflow-y: auto; diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx index 2f4accf4d20..7564ee0962f 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx @@ -21,7 +21,6 @@ import { act, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Route } from 'react-router-dom'; -import selectEvent from 'react-select-event'; import { byDisplayValue, byRole, byTestId, byText } from 'testing-library-selector'; import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock'; import SecurityHotspotServiceMock from '../../../api/mocks/SecurityHotspotServiceMock'; @@ -52,26 +51,29 @@ const ui = { editAssigneeButton: byRole('button', { name: 'hotspots.assignee.change_user', }), - filterAssigneeToMe: byRole('button', { + filterAssigneeToMe: byRole('checkbox', { name: 'hotspot.filters.assignee.assigned_to_me', }), - filterSeeAll: byRole('button', { name: 'hotspot.filters.assignee.all' }), + clearFilters: byRole('menuitem', { name: 'hotspot.filters.clear' }), + filterDropdown: byRole('button', { name: 'hotspot.filters.title' }), + filterToReview: byRole('radio', { name: 'hotspot.filters.status.to_review' }), filterByStatus: byRole('combobox', { name: 'hotspot.filters.status' }), filterByPeriod: byRole('combobox', { name: 'hotspot.filters.period' }), + filterNewCode: byRole('checkbox', { name: 'hotspot.filters.period.since_leak_period' }), noHotspotForFilter: byText('hotspots.no_hotspots_for_filters.title'), selectStatus: byRole('button', { name: 'hotspots.status.select_status' }), toReviewStatus: byText('hotspots.status_option.TO_REVIEW'), changeStatus: byRole('button', { name: 'hotspots.status.change_status' }), hotspotTitle: (name: string | RegExp) => byRole('heading', { name }), hotspotStatus: byRole('heading', { name: 'status: hotspots.status_option.FIXED' }), - hotpostListTitle: byRole('heading', { name: 'hotspots.list_title.TO_REVIEW.4' }), + hotpostListTitle: byText('hotspots.list_title'), hotspotCommentBox: byRole('textbox', { name: 'hotspots.comment.field' }), commentSubmitButton: byRole('button', { name: 'hotspots.comment.submit' }), commentEditButton: byRole('button', { name: 'issue.comment.edit' }), commentDeleteButton: byRole('button', { name: 'issue.comment.delete' }), textboxWithText: (value: string) => byDisplayValue(value), activeAssignee: byTestId('assignee-name'), - successGlobalMessage: byRole('status'), + successGlobalMessage: byTestId('global-message__SUCCESS'), currentUserSelectionItem: byText('foo'), panel: byTestId('security-hotspot-test'), codeTab: byRole('tab', { name: 'hotspots.tabs.code' }), @@ -82,6 +84,7 @@ const ui = { vulnerabilityContent: byText('Assess'), fixTab: byRole('tab', { name: 'hotspots.tabs.fix_recommendations' }), fixContent: byText('This is how to fix'), + showAllHotspotLink: byRole('link', { name: 'hotspot.filters.show_all' }), }; const hotspotsHandler = new SecurityHotspotServiceMock(); @@ -99,6 +102,22 @@ describe('rendering', () => { ); expect(await screen.findAllByText('variant 1, variant 2')).toHaveLength(2); }); + + it('should render the simple list when a file is selected', async () => { + const user = userEvent.setup(); + renderSecurityHotspotsApp( + `security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&files=src%2Findex.js` + ); + + expect(ui.filterDropdown.query()).not.toBeInTheDocument(); + expect(ui.filterToReview.query()).not.toBeInTheDocument(); + + // Drop selection + await user.click(ui.showAllHotspotLink.get()); + + expect(ui.filterDropdown.get()).toBeInTheDocument(); + expect(ui.filterToReview.get()).toBeInTheDocument(); + }); }); it('should navigate when comming from SonarLint', async () => { @@ -292,9 +311,11 @@ it('should be able to filter the hotspot list', async () => { expect(await ui.hotpostListTitle.find()).toBeInTheDocument(); + await user.click(ui.filterDropdown.get()); await user.click(ui.filterAssigneeToMe.get()); expect(ui.noHotspotForFilter.get()).toBeInTheDocument(); - await selectEvent.select(ui.filterByStatus.get(), ['hotspot.filters.status.to_review']); + + await user.click(ui.filterToReview.get()); expect(getSecurityHotspots).toHaveBeenLastCalledWith({ inNewCodePeriod: false, @@ -306,7 +327,8 @@ it('should be able to filter the hotspot list', async () => { status: 'TO_REVIEW', }); - await selectEvent.select(ui.filterByPeriod.get(), ['hotspot.filters.period.since_leak_period']); + await user.click(ui.filterDropdown.get()); + await user.click(ui.filterNewCode.get()); expect(getSecurityHotspots).toHaveBeenLastCalledWith({ inNewCodePeriod: true, @@ -318,7 +340,8 @@ it('should be able to filter the hotspot list', async () => { status: 'TO_REVIEW', }); - await user.click(ui.filterSeeAll.get()); + await user.click(ui.filterDropdown.get()); + await user.click(ui.clearFilters.get()); expect(ui.hotpostListTitle.get()).toBeInTheDocument(); }); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/FilterBar.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/FilterBar.tsx index 3be25c9785e..869e87986c6 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/FilterBar.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/FilterBar.tsx @@ -17,18 +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 { withTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { + CoverageIndicator, + DiscreetInteractiveIcon, + DiscreetLink, + Dropdown, + FilterIcon, + HelperHintIcon, + ItemCheckbox, + ItemDangerButton, + ItemDivider, + ItemHeader, + PopupPlacement, + ToggleButton, + themeBorder, +} from 'design-system'; import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; -import Link from '../../../components/common/Link'; -import ButtonToggle from '../../../components/controls/ButtonToggle'; import HelpTooltip from '../../../components/controls/HelpTooltip'; -import Select from '../../../components/controls/Select'; import Measure from '../../../components/measure/Measure'; -import CoverageRating from '../../../components/ui/CoverageRating'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; import { translate } from '../../../helpers/l10n'; -import { getProjectSecurityHotspots } from '../../../helpers/urls'; import { ComponentQualifier } from '../../../types/component'; +import { MetricType } from '../../../types/metrics'; import { HotspotFilters, HotspotStatusFilter } from '../../../types/security-hotspots'; import { Component } from '../../../types/types'; import { CurrentUser, isLoggedIn } from '../../../types/users'; @@ -42,6 +56,7 @@ export interface FilterBarProps { loadingMeasure: boolean; onBranch: boolean; onChangeFilters: (filters: Partial) => void; + onShowAllHotspots: VoidFunction; } const statusOptions: Array<{ label: string; value: HotspotStatusFilter }> = [ @@ -54,116 +69,148 @@ const statusOptions: Array<{ label: string; value: HotspotStatusFilter }> = [ { value: HotspotStatusFilter.SAFE, label: translate('hotspot.filters.status.safe') }, ]; -const periodOptions = [ - { value: true, label: translate('hotspot.filters.period.since_leak_period') }, - { value: false, label: translate('hotspot.filters.period.overall') }, -]; - export enum AssigneeFilterOption { ALL = 'all', ME = 'me', } -const assigneeFilterOptions = [ - { value: AssigneeFilterOption.ME, label: translate('hotspot.filters.assignee.assigned_to_me') }, - { value: AssigneeFilterOption.ALL, label: translate('hotspot.filters.assignee.all') }, -]; - export function FilterBar(props: FilterBarProps) { const { currentUser, component, filters, hotspotsReviewedMeasure, - isStaticListOfHotspots, loadingMeasure, onBranch, + isStaticListOfHotspots, } = props; const isProject = component.qualifier === ComponentQualifier.Project; + const userLoggedIn = isLoggedIn(currentUser); + const filtersCount = Number(filters.assignedToMe) + Number(filters.inNewCodePeriod); + const isFiltered = Boolean(filtersCount); return ( -
-
-
- {isStaticListOfHotspots ? ( - - {translate('hotspot.filters.show_all')} - - ) : ( -
-
-

{translate('hotspot.filters.title')}

- - {isLoggedIn(currentUser) && ( - - - props.onChangeFilters({ assignedToMe: value === AssigneeFilterOption.ME }) - } - options={assigneeFilterOptions} - value={ - filters.assignedToMe ? AssigneeFilterOption.ME : AssigneeFilterOption.ALL - } - /> - +
+ {isStaticListOfHotspots ? ( + + + {translate('hotspot.filters.show_all')} + + ), + }} + defaultMessage={translate('hotspot.filters.by_file_or_list_x')} + /> + + ) : ( + <> + {isProject && ( + + + {hotspotsReviewedMeasure !== undefined && ( + )} - - {translate('status')} - - props.onChangeFilters({ inNewCodePeriod: option.value }) - } - options={periodOptions} - isSearchable={false} - value={periodOptions.find((period) => period.value === filters.inNewCodePeriod)} - /> - )} -
+ + props.onChangeFilters({ status })} + options={statusOptions} + value={statusOptions.find((status) => status.value === filters.status)?.value} + /> + {(onBranch || userLoggedIn || isFiltered) && ( + + {translate('hotspot.filters.title')} - {isProject && ( -
- - {translate('metric.security_hotspots_reviewed.name')} - - - - {hotspotsReviewedMeasure && } - - -
- )} -
- )} -
-
+ {onBranch && ( + props.onChangeFilters({ inNewCodePeriod })} + > + + {translate('hotspot.filters.period.since_leak_period')} + + + )} + + {userLoggedIn && ( + props.onChangeFilters({ assignedToMe })} + > + + {translate('hotspot.filters.assignee.assigned_to_me')} + + + )} + + {isFiltered && } + + {isFiltered && ( + + props.onChangeFilters({ + assignedToMe: false, + inNewCodePeriod: false, + }) + } + > + {translate('hotspot.filters.clear')} + + )} + + } + placement={PopupPlacement.BottomRight} + > + + {isFiltered ? filtersCount : null} + + + )} + + + )}
); } +const StyledFilterWrapper = withTheme(styled.div` + border-bottom: ${themeBorder('default')}; +`); + export default withCurrentUserContext(FilterBar); 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 c55e4399937..10a0139e4a0 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -833,6 +833,7 @@ hotspots.continue_to_next_hotspot=Continue Reviewing hotspot.filters.title=Filters hotspot.filters.assignee.assigned_to_me=Assigned to me hotspot.filters.assignee.all=All +hotspot.filters.clear=Clear filters hotspot.filters.status=Status filter hotspot.filters.status.to_review=To review hotspot.filters.status.acknowledged=Acknowledged @@ -841,7 +842,8 @@ hotspot.filters.period=Period filter hotspot.filters.period.since_leak_period=New Code hotspot.filters.period.overall=Overall code hotspot.filters.status.safe=Safe -hotspot.filters.show_all=Show all hotspots +hotspot.filters.by_file_or_list_x=Your hotspots are currently filtered, {show_all_link} +hotspot.filters.show_all=show all hotspots hotspot.section.activity=Recent activity: hotspots.reviewed.tooltip=Percentage of open Security Hotspots that have been reviewed (Acknowledged, Fixed or Safe) -- 2.39.5