From 9cacc03e2c45628cde42ed12911db3c20986f2aa Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Tue, 18 Jul 2023 16:49:39 +0200 Subject: [PATCH] SONAR-19750 Unlock project hotspots page --- .../src/main/js/api/security-hotspots.ts | 31 ++-- .../security-hotspots/SecurityHotspotsApp.tsx | 159 +++++++++++------ .../SecurityHotspotsAppRenderer.tsx | 45 +++-- .../__tests__/SecurityHotspotsApp-it.tsx | 164 +++++++++++------- .../HotspotDisabledFilterTooltip.tsx | 48 +++++ .../components/HotspotSidebarHeader.tsx | 54 ++++-- .../HotspotDisabledFilterTooltip-test.tsx | 37 ++++ ...HotspotDisabledFilterTooltip-test.tsx.snap | 49 ++++++ .../resources/org/sonar/l10n/core.properties | 4 +- 9 files changed, 431 insertions(+), 160 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotDisabledFilterTooltip.tsx create mode 100644 server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotDisabledFilterTooltip-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotDisabledFilterTooltip-test.tsx.snap diff --git a/server/sonar-web/src/main/js/api/security-hotspots.ts b/server/sonar-web/src/main/js/api/security-hotspots.ts index 4369330003a..3b3cd10bab1 100644 --- a/server/sonar-web/src/main/js/api/security-hotspots.ts +++ b/server/sonar-web/src/main/js/api/security-hotspots.ts @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { throwGlobalError } from '../helpers/error'; import { getJSON, post } from '../helpers/request'; import { BranchParameters } from '../types/branch-like'; @@ -31,6 +32,7 @@ import { } from '../types/security-hotspots'; import { UserBase } from '../types/users'; +const HOTSPOTS_LIST_URL = '/api/hotspots/list'; const HOTSPOTS_SEARCH_URL = '/api/hotspots/search'; export function assignSecurityHotspot( @@ -70,27 +72,34 @@ export function editSecurityHotspotComment( export function getSecurityHotspots( data: { - projectKey: string; + inNewCodePeriod?: boolean; + onlyMine?: boolean; p: number; + projectKey: string; ps: number; - status?: HotspotStatus; resolution?: HotspotResolution; - onlyMine?: boolean; - inNewCodePeriod?: boolean; - } & BranchParameters + status?: HotspotStatus; + } & BranchParameters, + projectIsIndexing = false ): Promise { - return getJSON(HOTSPOTS_SEARCH_URL, data).catch(throwGlobalError); + return getJSON( + projectIsIndexing ? HOTSPOTS_LIST_URL : HOTSPOTS_SEARCH_URL, + projectIsIndexing ? { ...data, project: data.projectKey } : data + ).catch(throwGlobalError); } export function getSecurityHotspotList( hotspotKeys: string[], data: { projectKey: string; - } & BranchParameters + } & BranchParameters, + projectIsIndexing = false ): Promise { - return getJSON(HOTSPOTS_SEARCH_URL, { ...data, hotspots: hotspotKeys.join() }).catch( - throwGlobalError - ); + return getJSON(projectIsIndexing ? HOTSPOTS_LIST_URL : HOTSPOTS_SEARCH_URL, { + ...data, + hotspots: hotspotKeys.join(), + ...(projectIsIndexing ? { project: data.projectKey } : {}), + }).catch(throwGlobalError); } export function getSecurityHotspotDetails(securityHotspotKey: string): Promise { @@ -105,10 +114,12 @@ export function getSecurityHotspotDetails(securityHotspotKey: string): Promise u.login === hotspot.author) || { active: true, login: hotspot.author, }; + hotspot.comment.forEach((c) => { c.user = users.find((u) => u.login === c.login) || { active: true, login: c.login }; }); 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 3d503e44137..795d7fc2ed7 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 @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { flatMap, range } from 'lodash'; import * as React from 'react'; import { getMeasures } from '../../api/measures'; @@ -31,6 +32,7 @@ import { KeyboardKeys } from '../../helpers/keycodes'; import { getStandards } from '../../helpers/security-standard'; import { withBranchLikes } from '../../queries/branch'; import { BranchLike } from '../../types/branch-like'; +import { MetricKey } from '../../types/metrics'; import { SecurityStandard, Standards } from '../../types/security'; import { HotspotFilters, @@ -81,25 +83,25 @@ export class SecurityHotspotsApp extends React.PureComponent { super(props); this.state = { + filters: { + ...this.constructFiltersFromProps(props), + status: HotspotStatusFilter.TO_REVIEW, + }, + hotspots: [], + hotspotsPageIndex: 1, + hotspotsTotal: 0, loading: true, loadingMeasure: false, loadingMore: false, - hotspots: [], - hotspotsTotal: 0, - hotspotsPageIndex: 1, selectedHotspot: undefined, standards: { - [SecurityStandard.OWASP_TOP10]: {}, - [SecurityStandard.OWASP_TOP10_2021]: {}, - [SecurityStandard.SONARSOURCE]: {}, [SecurityStandard.CWE]: {}, + [SecurityStandard.OWASP_ASVS_4_0]: {}, + [SecurityStandard.OWASP_TOP10_2021]: {}, + [SecurityStandard.OWASP_TOP10]: {}, [SecurityStandard.PCI_DSS_3_2]: {}, [SecurityStandard.PCI_DSS_4_0]: {}, - [SecurityStandard.OWASP_ASVS_4_0]: {}, - }, - filters: { - ...this.constructFiltersFromProps(props), - status: HotspotStatusFilter.TO_REVIEW, + [SecurityStandard.SONARSOURCE]: {}, }, }; } @@ -146,6 +148,7 @@ export class SecurityHotspotsApp extends React.PureComponent { if (isInput(event)) { return; } + if (event.key === KeyboardKeys.Alt) { event.preventDefault(); return; @@ -154,20 +157,24 @@ export class SecurityHotspotsApp extends React.PureComponent { switch (event.key) { case KeyboardKeys.DownArrow: { event.preventDefault(); + if (event.altKey) { this.selectNextLocation(); } else { this.selectNeighboringHotspot(+1); } + break; } case KeyboardKeys.UpArrow: { event.preventDefault(); + if (event.altKey) { this.selectPreviousLocation(); } else { this.selectNeighboringHotspot(-1); } + break; } } @@ -175,16 +182,21 @@ export class SecurityHotspotsApp extends React.PureComponent { selectNextLocation = () => { const { selectedHotspotLocationIndex, selectedHotspot } = this.state; + if (selectedHotspot === undefined) { return; } + const locations = getLocations(selectedHotspot.flows, undefined); + if (locations.length === 0) { return; } + const lastIndex = locations.length - 1; let newIndex; + if (selectedHotspotLocationIndex === undefined) { newIndex = 0; } else if (selectedHotspotLocationIndex === lastIndex) { @@ -192,6 +204,7 @@ export class SecurityHotspotsApp extends React.PureComponent { } else { newIndex = selectedHotspotLocationIndex + 1; } + this.setState({ selectedHotspotLocationIndex: newIndex }); }; @@ -199,21 +212,25 @@ export class SecurityHotspotsApp extends React.PureComponent { const { selectedHotspotLocationIndex } = this.state; let newIndex; + if (selectedHotspotLocationIndex === 0) { newIndex = undefined; } else if (selectedHotspotLocationIndex !== undefined) { newIndex = selectedHotspotLocationIndex - 1; } + this.setState({ selectedHotspotLocationIndex: newIndex }); }; selectNeighboringHotspot = (shift: number) => { this.setState({ selectedHotspotLocationIndex: undefined }); + this.setState(({ hotspots, selectedHotspot }) => { const index = selectedHotspot && hotspots.findIndex((h) => h.key === selectedHotspot.key); if (index !== undefined && index > -1) { const newIndex = Math.max(0, Math.min(hotspots.length - 1, index + shift)); + return { selectedHotspot: hotspots[newIndex], }; @@ -272,10 +289,11 @@ export class SecurityHotspotsApp extends React.PureComponent { const { filters } = this.state; const reviewedHotspotsMetricKey = filters.inNewCodePeriod - ? 'new_security_hotspots_reviewed' - : 'security_hotspots_reviewed'; + ? MetricKey.new_security_hotspots_reviewed + : MetricKey.security_hotspots_reviewed; this.setState({ loadingMeasure: true }); + return getMeasures({ component: component.key, metricKeys: reviewedHotspotsMetricKey, @@ -285,7 +303,9 @@ export class SecurityHotspotsApp extends React.PureComponent { if (!this.mounted) { return; } + const measure = measures && measures.length > 0 ? measures[0] : undefined; + const hotspotsReviewedMeasure = filters.inNewCodePeriod ? getLeakValue(measure) : measure?.value; @@ -299,6 +319,55 @@ export class SecurityHotspotsApp extends React.PureComponent { }); }; + fetchFilteredSecurityHotspots({ + filterByCategory, + filterByCWE, + filterByFile, + page, + }: { + filterByCategory: + | { + standard: SecurityStandard; + category: string; + } + | undefined; + filterByCWE: string | undefined; + filterByFile: string | undefined; + page: number; + }) { + const { branchLike, component, location } = this.props; + const { filters } = this.state; + + const hotspotFilters: Dict = {}; + + if (filterByCategory) { + hotspotFilters[filterByCategory.standard] = filterByCategory.category; + } + + if (filterByCWE) { + hotspotFilters[SecurityStandard.CWE] = filterByCWE; + } + + if (filterByFile) { + hotspotFilters.files = filterByFile; + } + + hotspotFilters['owaspAsvsLevel'] = location.query['owaspAsvsLevel']; + + return getSecurityHotspots( + { + ...hotspotFilters, + inNewCodePeriod: filters.inNewCodePeriod && Boolean(filterByFile), // only add new code period when filtering by file + p: page, + projectKey: component.key, + ps: PAGE_SIZE, + status: HotspotStatus.TO_REVIEW, // we're only interested in unresolved hotspots + ...getBranchLikeQuery(branchLike), + }, + component.needIssueSync + ); + } + fetchSecurityHotspots(page = 1) { const { branchLike, component, location } = this.props; const { filters } = this.state; @@ -310,6 +379,7 @@ export class SecurityHotspotsApp extends React.PureComponent { const standard = SECURITY_STANDARDS.find( (stnd) => stnd !== SecurityStandard.CWE && location.query[stnd] !== undefined ); + const filterByCategory = standard ? { standard, category: location.query[standard] } : undefined; @@ -321,35 +391,22 @@ export class SecurityHotspotsApp extends React.PureComponent { this.setState({ filterByCategory, filterByCWE, filterByFile, hotspotKeys }); if (hotspotKeys && hotspotKeys.length > 0) { - return getSecurityHotspotList(hotspotKeys, { - projectKey: component.key, - ...getBranchLikeQuery(branchLike), - }); + return getSecurityHotspotList( + hotspotKeys, + { + projectKey: component.key, + ...getBranchLikeQuery(branchLike), + }, + component.needIssueSync + ); } if (filterByCategory || filterByCWE || filterByFile) { - const hotspotFilters: Dict = {}; - - if (filterByCategory) { - hotspotFilters[filterByCategory.standard] = filterByCategory.category; - } - if (filterByCWE) { - hotspotFilters[SecurityStandard.CWE] = filterByCWE; - } - if (filterByFile) { - hotspotFilters.files = filterByFile; - } - - hotspotFilters['owaspAsvsLevel'] = location.query['owaspAsvsLevel']; - - return getSecurityHotspots({ - ...hotspotFilters, - projectKey: component.key, - p: page, - ps: PAGE_SIZE, - status: HotspotStatus.TO_REVIEW, // we're only interested in unresolved hotspots - inNewCodePeriod: filters.inNewCodePeriod && Boolean(filterByFile), // only add leak period when filtering by file - ...getBranchLikeQuery(branchLike), + return this.fetchFilteredSecurityHotspots({ + filterByCategory, + filterByCWE, + filterByFile, + page, }); } @@ -363,16 +420,19 @@ export class SecurityHotspotsApp extends React.PureComponent { ? undefined : HotspotResolution[filters.status]; - return getSecurityHotspots({ - projectKey: component.key, - p: page, - ps: PAGE_SIZE, - status, - resolution, - onlyMine: filters.assignedToMe, - inNewCodePeriod: filters.inNewCodePeriod, - ...getBranchLikeQuery(branchLike), - }); + return getSecurityHotspots( + { + inNewCodePeriod: filters.inNewCodePeriod, + ...(component.needIssueSync ? {} : { onlyMine: filters.assignedToMe }), + p: page, + projectKey: component.key, + ps: PAGE_SIZE, + resolution, + status, + ...getBranchLikeQuery(branchLike), + }, + component.needIssueSync + ); } reloadSecurityHotspotList = () => { @@ -400,6 +460,7 @@ export class SecurityHotspotsApp extends React.PureComponent { ({ filters }) => ({ filters: { ...filters, ...changes } }), () => { this.reloadSecurityHotspotList(); + if (changes.inNewCodePeriod !== undefined) { this.fetchSecurityHotspotsReviewed(); } 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 2950a28090e..8253691a9fb 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 @@ -17,6 +17,7 @@ * 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 { @@ -68,15 +69,15 @@ export interface SecurityHotspotsAppRendererProps { loadingMeasure: boolean; loadingMore: boolean; onChangeFilters: (filters: Partial) => void; - onShowAllHotspots: VoidFunction; onHotspotClick: (hotspot: RawHotspot) => void; - onLocationClick: (index?: number) => void; onLoadMore: () => void; + onLocationClick: (index?: number) => void; + onShowAllHotspots: VoidFunction; onSwitchStatusFilter: (option: HotspotStatusFilter) => void; onUpdateHotspot: (hotspotKey: string) => Promise; + securityCategories: StandardSecurityCategories; selectedHotspot?: RawHotspot; selectedHotspotLocation?: number; - securityCategories: StandardSecurityCategories; standards: Standards; } @@ -97,25 +98,29 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe loading, loadingMeasure, loadingMore, + onChangeFilters, + onShowAllHotspots, securityCategories, selectedHotspot, selectedHotspotLocation, standards, - onChangeFilters, - onShowAllHotspots, } = props; const isProject = component.qualifier === ComponentQualifier.Project; const { top: topScroll } = useFollowScroll(); + const distanceFromBottom = topScroll + window.innerHeight - document.body.clientHeight; + const footerVisibleHeight = distanceFromBottom > -LAYOUT_FOOTER_HEIGHT ? LAYOUT_FOOTER_HEIGHT + distanceFromBottom : 0; return ( <> - + + + @@ -130,27 +135,28 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe )} + @@ -173,8 +179,8 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe onHotspotClick={props.onHotspotClick} onLoadMore={props.onLoadMore} onLocationClick={props.onLocationClick} - selectedHotspotLocation={selectedHotspotLocation} selectedHotspot={selectedHotspot} + selectedHotspotLocation={selectedHotspotLocation} standards={standards} /> ) : ( @@ -197,25 +203,26 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe + {hotspots.length === 0 || !selectedHotspot ? ( ) : ( 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 3806173420e..931c291c9e7 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 @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -33,6 +34,7 @@ import { mockLoggedInUser } from '../../../helpers/testMocks'; import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; import { byDisplayValue, byRole, byTestId, byText } from '../../../helpers/testSelector'; import { ComponentContextShape } from '../../../types/component'; +import { MetricKey } from '../../../types/metrics'; import SecurityHotspotsApp from '../SecurityHotspotsApp'; import useScrollDownCompress from '../hooks/useScrollDownCompress'; @@ -50,64 +52,70 @@ jest.mock('../../../helpers/sonarlint', () => ({ openHotspot: jest.fn().mockResolvedValue(null), probeSonarLintServers: jest.fn().mockResolvedValue([ { - port: 1234, - ideName: 'VIM', description: 'I use VIM', + ideName: 'VIM', + port: 1234, }, ]), })); jest.mock('.../../../helpers/storage'); const ui = { - inputAssignee: byRole('combobox', { name: 'search.search_for_users' }), + activeAssignee: byRole('combobox', { name: 'hotspots.assignee.change_user' }), + activityTab: byRole('tab', { name: /hotspots.tabs.activity/ }), + addCommentButton: byRole('button', { name: 'hotspots.status.add_comment' }), + changeStatus: byRole('button', { name: 'hotspots.status.change_status' }), + clearFilters: byRole('menuitem', { name: 'hotspot.filters.clear' }), + codeContent: byRole('table'), + codeTab: byRole('tab', { name: /hotspots.tabs.code/ }), + commentDeleteButton: byRole('button', { name: 'issue.comment.delete' }), + commentEditButton: byRole('button', { name: 'issue.comment.edit' }), + commentSubmitButton: byRole('button', { name: 'hotspots.comment.submit' }), + continueReviewingButton: byRole('button', { name: 'hotspots.continue_to_next_hotspot' }), + currentUserSelectionItem: byText('foo'), + dontShowSuccessDialogCheckbox: byRole('checkbox', { + name: 'hotspots.success_dialog.do_not_show', + }), filterAssigneeToMe: byRole('checkbox', { name: 'hotspot.filters.assignee.assigned_to_me', }), - 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' }), + filterByStatus: byRole('combobox', { name: 'hotspot.filters.status' }), + filterDropdown: byRole('button', { name: 'hotspot.filters.title' }), filterNewCode: byRole('checkbox', { name: 'hotspot.filters.period.since_leak_period' }), - noHotspotForFilter: byText('hotspots.no_hotspots_for_filters.title'), - reviewButton: byRole('button', { name: 'hotspots.status.review' }), - 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' }), + filterToReview: byRole('radio', { name: 'hotspot.filters.status.to_review' }), + fixContent: byText('This is how to fix'), + fixTab: byRole('tab', { name: /hotspots.tabs.fix_recommendations/ }), 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: byRole('combobox', { name: 'hotspots.assignee.change_user' }), - successGlobalMessage: byTestId('global-message__SUCCESS'), - currentUserSelectionItem: byText('foo'), + hotspotStatus: byRole('heading', { name: 'status: hotspots.status_option.FIXED' }), + hotspotTitle: (name: string | RegExp) => byRole('heading', { name }), + inputAssignee: byRole('combobox', { name: 'search.search_for_users' }), + noHotspotForFilter: byText('hotspots.no_hotspots_for_filters.title'), + openInIDEButton: byRole('button', { name: 'hotspots.open_in_ide.open' }), panel: byTestId('security-hotspot-test'), - codeTab: byRole('tab', { name: /hotspots.tabs.code/ }), - codeContent: byRole('table'), - riskTab: byRole('tab', { name: /hotspots.tabs.risk_description/ }), + reviewButton: byRole('button', { name: 'hotspots.status.review' }), riskContent: byText('Root cause'), - vulnerabilityTab: byRole('tab', { name: /hotspots.tabs.vulnerability_description/ }), - 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' }), - activityTab: byRole('tab', { name: /hotspots.tabs.activity/ }), - addCommentButton: byRole('button', { name: 'hotspots.status.add_comment' }), - openInIDEButton: byRole('button', { name: 'hotspots.open_in_ide.open' }), - continueReviewingButton: byRole('button', { name: 'hotspots.continue_to_next_hotspot' }), + riskTab: byRole('tab', { name: /hotspots.tabs.risk_description/ }), seeStatusHotspots: byRole('button', { name: /hotspots.see_x_hotspots/ }), - dontShowSuccessDialogCheckbox: byRole('checkbox', { - name: 'hotspots.success_dialog.do_not_show', - }), + showAllHotspotLink: byRole('link', { name: 'hotspot.filters.show_all' }), + successGlobalMessage: byTestId('global-message__SUCCESS'), + textboxWithText: (value: string) => byDisplayValue(value), + toReviewStatus: byText('hotspots.status_option.TO_REVIEW'), + vulnerabilityContent: byText('Assess'), + vulnerabilityTab: byRole('tab', { name: /hotspots.tabs.vulnerability_description/ }), }; const originalScrollTo = window.scrollTo; const hotspotsHandler = new SecurityHotspotServiceMock(); const rulesHandles = new CodingRulesServiceMock(); const branchHandler = new BranchesServiceMock(); + +const mockComponentInstance = mockComponent({ + key: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', + name: 'benflix', +}); + let showDialog = 'true'; jest.mocked(save).mockImplementation((_key: string, value?: string) => { @@ -115,6 +123,7 @@ jest.mocked(save).mockImplementation((_key: string, value?: string) => { showDialog = value; } }); + jest.mocked(get).mockImplementation(() => showDialog); beforeAll(() => { @@ -152,13 +161,15 @@ describe('rendering', () => { renderSecurityHotspotsApp( 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=test-2' ); + 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` + `security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&files=src%2Findex.js&cwe=foo&inNewCodePeriod=true` ); expect(ui.filterDropdown.query()).not.toBeInTheDocument(); @@ -177,6 +188,7 @@ describe('rendering', () => { isCompressed: true, resetScrollDownCompress: jest.fn(), })); + renderSecurityHotspotsApp(); expect(await ui.reviewButton.find()).toBeInTheDocument(); @@ -256,6 +268,7 @@ describe('CRUD', () => { saveButton: byRole('button', { name: 'hotspots.comment.submit' }), deleteButton: byRole('button', { name: 'delete' }), }; + const user = userEvent.setup(); const comment = 'This is a comment from john doe'; renderSecurityHotspotsApp(); @@ -358,6 +371,7 @@ describe('navigation', () => { it('should allow to open a hotspot in an IDE', async () => { const user = userEvent.setup(); + renderSecurityHotspotsApp(); await user.click(await ui.openInIDEButton.find()); @@ -377,6 +391,7 @@ describe('navigation', () => { description: 'I use MS Paint cuz Ima boss', }, ]); + const user = userEvent.setup(); renderSecurityHotspotsApp(); @@ -392,6 +407,7 @@ it('after status change, should be able to disable success dialog show', async ( renderSecurityHotspotsApp(); await user.click(await ui.reviewButton.find()); await user.click(ui.toReviewStatus.get()); + await act(async () => { await user.click(ui.changeStatus.get()); }); @@ -403,9 +419,11 @@ it('after status change, should be able to disable success dialog show', async ( // Repeat status change and verify that dialog is not shown await user.click(await ui.reviewButton.find()); await user.click(ui.toReviewStatus.get()); + await act(async () => { await user.click(ui.changeStatus.get()); }); + expect(ui.continueReviewingButton.query()).not.toBeInTheDocument(); }); @@ -416,33 +434,43 @@ it('should be able to filter the hotspot list', async () => { expect(await ui.hotpostListTitle.find()).toBeInTheDocument(); await user.click(ui.filterDropdown.get()); + + expect(ui.filterAssigneeToMe.get()).toBeEnabled(); + await user.click(ui.filterAssigneeToMe.get()); + expect(await ui.noHotspotForFilter.find()).toBeInTheDocument(); await user.click(ui.filterToReview.get()); - expect(getSecurityHotspots).toHaveBeenLastCalledWith({ - inNewCodePeriod: false, - onlyMine: true, - p: 1, - projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', - ps: 500, - resolution: undefined, - status: 'TO_REVIEW', - }); + expect(getSecurityHotspots).toHaveBeenLastCalledWith( + { + inNewCodePeriod: false, + onlyMine: true, + p: 1, + projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', + ps: 500, + resolution: undefined, + status: 'TO_REVIEW', + }, + undefined + ); await user.click(ui.filterDropdown.get()); await user.click(await ui.filterNewCode.find()); - expect(getSecurityHotspots).toHaveBeenLastCalledWith({ - inNewCodePeriod: true, - onlyMine: true, - p: 1, - projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', - ps: 500, - resolution: undefined, - status: 'TO_REVIEW', - }); + expect(getSecurityHotspots).toHaveBeenLastCalledWith( + { + inNewCodePeriod: true, + onlyMine: true, + p: 1, + projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', + ps: 500, + resolution: undefined, + status: 'TO_REVIEW', + }, + undefined + ); await user.click(ui.filterDropdown.get()); await user.click(ui.clearFilters.get()); @@ -450,28 +478,38 @@ it('should be able to filter the hotspot list', async () => { expect(ui.hotpostListTitle.get()).toBeInTheDocument(); }); +it('should disable the "assigned to me" filter if the project is indexing', async () => { + const user = userEvent.setup(); + + renderSecurityHotspotsApp( + 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', + { component: { ...mockComponentInstance, needIssueSync: true } } + ); + + await user.click(ui.filterDropdown.get()); + + expect(ui.filterAssigneeToMe.get()).toHaveAttribute('disabled'); +}); + function renderSecurityHotspotsApp( navigateTo?: string, component?: Partial ) { return renderAppWithComponentContext( - 'security_hotspots', - () => } />, + MetricKey.security_hotspots, + () => } />, { - navigateTo: - navigateTo ?? - 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', currentUser: mockLoggedInUser({ login: 'foo', name: 'foo', }), + navigateTo: + navigateTo ?? + 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', }, { onComponentChange: jest.fn(), - component: mockComponent({ - key: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', - name: 'benflix', - }), + component: mockComponentInstance, ...component, } ); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotDisabledFilterTooltip.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotDisabledFilterTooltip.tsx new file mode 100644 index 00000000000..70e70213450 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotDisabledFilterTooltip.tsx @@ -0,0 +1,48 @@ +/* + * 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 DocLink from '../../../components/common/DocLink'; +import { translate } from '../../../helpers/l10n'; + +export function HotspotDisabledFilterTooltip() { + return ( +
+

+ {translate('indexation.page_unavailable.description')}{' '} + {translate('indexation.filter_unavailable.description')} +

+
+ {translate('indexation.learn_more')} + { + // This tooltip content is rendered in the context of a , and + // captures the "focus out" event and closes the dropdown, preventing us from clicking + // this link. We preventDefault() to avoid this behavior. + e.preventDefault(); + }} + to="/instance-administration/reindexing/" + > + {translate('indexation.reindexing')} + +
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx index ddbbdbb44c1..56d26d44309 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { CoverageIndicator, DiscreetInteractiveIcon, @@ -29,51 +30,59 @@ import { ItemHeader, } from 'design-system'; import * as React from 'react'; +import withComponentContext from '../../../app/components/componentContext/withComponentContext'; import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; import HelpTooltip from '../../../components/controls/HelpTooltip'; +import Tooltip from '../../../components/controls/Tooltip'; import Measure from '../../../components/measure/Measure'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; import { PopupPlacement } from '../../../components/ui/popups'; import { isBranch } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; import { BranchLike } from '../../../types/branch-like'; +import { ComponentContextShape } from '../../../types/component'; import { MetricKey, MetricType } from '../../../types/metrics'; import { HotspotFilters } from '../../../types/security-hotspots'; import { CurrentUser, isLoggedIn } from '../../../types/users'; +import { HotspotDisabledFilterTooltip } from './HotspotDisabledFilterTooltip'; -export interface SecurityHotspotsAppRendererProps { +export interface SecurityHotspotsAppRendererProps extends ComponentContextShape { branchLike?: BranchLike; + currentUser: CurrentUser; filters: HotspotFilters; hotspotsReviewedMeasure?: string; + isStaticListOfHotspots: boolean; loadingMeasure: boolean; onChangeFilters: (filters: Partial) => void; - currentUser: CurrentUser; - isStaticListOfHotspots: boolean; } function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) { const { branchLike, + component, + currentUser, filters, hotspotsReviewedMeasure, - loadingMeasure, - currentUser, isStaticListOfHotspots, + loadingMeasure, } = props; const userLoggedIn = isLoggedIn(currentUser); + const filtersCount = Number(filters.assignedToMe) + Number(isBranch(branchLike) && filters.inNewCodePeriod); + const isFiltered = Boolean(filtersCount); return ( -
+
{hotspotsReviewedMeasure !== undefined && ( )} + - + + {translate('metric.security_hotspots_reviewed.name')} + {!isStaticListOfHotspots && (isBranch(branchLike) || userLoggedIn || isFiltered) && ( -
+
{translate('hotspot.filters.title')} @@ -113,14 +125,21 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) { )} {userLoggedIn && ( - props.onChangeFilters({ assignedToMe })} + } + placement="right" > - - {translate('hotspot.filters.assignee.assigned_to_me')} - - + props.onChangeFilters({ assignedToMe })} + > + + {translate('hotspot.filters.assignee.assigned_to_me')} + + + )} {isFiltered && } @@ -140,7 +159,6 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) { } placement={PopupPlacement.BottomRight} - isPortal > { + const { container } = rtlRender(); + + expect(container).toMatchSnapshot(); + + const reindexingLink = screen.getByText('indexation.reindexing'); + + const mouseDownEvent = createEvent.mouseDown(reindexingLink); + + fireEvent(reindexingLink, mouseDownEvent); + + expect(mouseDownEvent.defaultPrevented).toBe(true); +}); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotDisabledFilterTooltip-test.tsx.snap b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotDisabledFilterTooltip-test.tsx.snap new file mode 100644 index 00000000000..baba3c9fd1a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotDisabledFilterTooltip-test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly and stop event propagation 1`] = ` +
+
+

+ indexation.page_unavailable.description + + indexation.filter_unavailable.description +

+
+ + indexation.learn_more + + + + + opens_in_new_window + + + + indexation.reindexing + +
+
+`; 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 55d28157982..736bdf495c9 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -4735,7 +4735,9 @@ indexation.page_unavailable.title.portfolios=Portfolios page is temporarily unav indexation.page_unavailable.title={componentQualifier} {componentName} is temporarily unavailable indexation.page_unavailable.description=SonarQube is reindexing project data. indexation.page_unavailable.description.additional_information=This page is unavailable until this process is complete. {link} - +indexation.filter_unavailable.description=This filter is unavailable until this process is complete. +indexation.learn_more=Learn more: +indexation.reindexing=Reindexing #------------------------------------------------------------------------------ # -- 2.39.5