diff options
author | David Cho-Lerat <david.cho-lerat@sonarsource.com> | 2023-07-18 16:49:39 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-07-19 20:03:05 +0000 |
commit | 9cacc03e2c45628cde42ed12911db3c20986f2aa (patch) | |
tree | a4eb3607435db28494cc05b9ae09c0a649989034 /server/sonar-web/src/main/js/apps | |
parent | 7c322c39eb267da94b1875aebd948e7409c6a9a2 (diff) | |
download | sonarqube-9cacc03e2c45628cde42ed12911db3c20986f2aa.tar.gz sonarqube-9cacc03e2c45628cde42ed12911db3c20986f2aa.zip |
SONAR-19750 Unlock project hotspots page
Diffstat (limited to 'server/sonar-web/src/main/js/apps')
7 files changed, 407 insertions, 149 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 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<Props, State> { 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<Props, State> { if (isInput(event)) { return; } + if (event.key === KeyboardKeys.Alt) { event.preventDefault(); return; @@ -154,20 +157,24 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> { 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<Props, State> { 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<Props, State> { } else { newIndex = selectedHotspotLocationIndex + 1; } + this.setState({ selectedHotspotLocationIndex: newIndex }); }; @@ -199,21 +212,25 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> { 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<Props, State> { 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<Props, State> { 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<Props, State> { }); }; + 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<string> = {}; + + 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<Props, State> { 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<Props, State> { 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<string> = {}; - - 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<Props, State> { ? 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<Props, State> { ({ 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<HotspotFilters>) => 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<void>; + 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 ( <> - <Suggestions suggestions="security_hotspots" /> + <Suggestions suggestions={MetricKey.security_hotspots} /> + <Helmet title={translate('hotspots.page')} /> + <A11ySkipTarget anchor="security_hotspots_main" /> <LargeCenteredLayout id={MetricKey.security_hotspots}> @@ -130,27 +135,28 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe <HotspotSidebarHeader branchLike={branchLike} filters={filters} - isStaticListOfHotspots={isStaticListOfHotspots} hotspotsReviewedMeasure={hotspotsReviewedMeasure} + isStaticListOfHotspots={isStaticListOfHotspots} loadingMeasure={loadingMeasure} onChangeFilters={onChangeFilters} /> </StyledSidebarHeader> )} + <StyledSidebarContent className="sw-p-4 it__hotspot-list" style={{ + height: `calc( + 100vh - ${ + LAYOUT_GLOBAL_NAV_HEIGHT + + LAYOUT_PROJECT_NAV_HEIGHT + + STICKY_HEADER_HEIGHT - + footerVisibleHeight + }px + )`, top: `${ LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT + STICKY_HEADER_HEIGHT }px`, - height: `calc( - 100vh - ${ - LAYOUT_GLOBAL_NAV_HEIGHT + - LAYOUT_PROJECT_NAV_HEIGHT + - STICKY_HEADER_HEIGHT - - footerVisibleHeight - }px - )`, }} > <DeferredSpinner className="sw-mt-3" loading={loading}> @@ -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 </DeferredSpinner> </StyledSidebarContent> </StyledSidebar> + <StyledMain className="sw-col-span-8 sw-relative sw-ml-12"> {hotspots.length === 0 || !selectedHotspot ? ( <EmptyHotspotsPage + filterByFile={Boolean(filterByFile)} filtered={ filters.assignedToMe || (isBranch(branchLike) && filters.inNewCodePeriod) || filters.status !== HotspotStatusFilter.TO_REVIEW } - filterByFile={Boolean(filterByFile)} isStaticListOfHotspots={isStaticListOfHotspots} /> ) : ( <HotspotViewer - hotspotsReviewedMeasure={hotspotsReviewedMeasure} component={component} hotspotKey={selectedHotspot.key} + hotspotsReviewedMeasure={hotspotsReviewedMeasure} + onLocationClick={props.onLocationClick} onSwitchStatusFilter={props.onSwitchStatusFilter} onUpdateHotspot={props.onUpdateHotspot} - onLocationClick={props.onLocationClick} selectedHotspotLocation={selectedHotspotLocation} standards={standards} /> 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<ComponentContextShape> ) { return renderAppWithComponentContext( - 'security_hotspots', - () => <Route path="security_hotspots" element={<SecurityHotspotsApp />} />, + MetricKey.security_hotspots, + () => <Route path={MetricKey.security_hotspots} element={<SecurityHotspotsApp />} />, { - 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 ( + <div className="sw-body-sm sw-w-[190px]"> + <p> + {translate('indexation.page_unavailable.description')}{' '} + {translate('indexation.filter_unavailable.description')} + </p> + <hr className="sw-mx-0 sw-my-3 sw-p-0 sw-w-full" /> + <span className="sw-body-sm-highlight">{translate('indexation.learn_more')}</span> + <DocLink + className="sw-ml-1" + onMouseDown={(e) => { + // This tooltip content is rendered in the context of a <Dropdown>, and <DropdownToggler> + // 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')} + </DocLink> + </div> + ); +} 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<HotspotFilters>) => 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 ( - <div className="sw-flex sw-py-4 sw-items-center sw-h-6 sw-px-4"> + <div className="sw-flex sw-h-6 sw-items-center sw-px-4 sw-py-4"> <DeferredSpinner loading={loadingMeasure}> {hotspotsReviewedMeasure !== undefined && ( <CoverageIndicator value={hotspotsReviewedMeasure} /> )} + <Measure - className="sw-ml-2 sw-body-sm-highlight it__hs-review-percentage" + className="it__hs-review-percentage sw-body-sm-highlight sw-ml-2" metricKey={ isBranch(branchLike) && !filters.inNewCodePeriod ? MetricKey.security_hotspots_reviewed @@ -82,19 +91,22 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) { metricType={MetricType.Percent} value={hotspotsReviewedMeasure} /> - <span className="sw-ml-1 sw-body-sm"> + + <span className="sw-body-sm sw-ml-1"> {translate('metric.security_hotspots_reviewed.name')} </span> + <HelpTooltip className="sw-ml-1" overlay={translate('hotspots.reviewed.tooltip')}> <HelperHintIcon aria-label="help-tooltip" /> </HelpTooltip> {!isStaticListOfHotspots && (isBranch(branchLike) || userLoggedIn || isFiltered) && ( - <div className="sw-flex-grow sw-flex sw-justify-end"> + <div className="sw-flex sw-flex-grow sw-justify-end"> <Dropdown allowResizing closeOnClick={false} id="filter-hotspots-menu" + isPortal overlay={ <> <ItemHeader>{translate('hotspot.filters.title')}</ItemHeader> @@ -113,14 +125,21 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) { )} {userLoggedIn && ( - <ItemCheckbox - checked={Boolean(filters.assignedToMe)} - onCheck={(assignedToMe: boolean) => props.onChangeFilters({ assignedToMe })} + <Tooltip + classNameSpace={component?.needIssueSync ? 'tooltip' : 'sw-hidden'} + overlay={<HotspotDisabledFilterTooltip />} + placement="right" > - <span className="sw-mx-2"> - {translate('hotspot.filters.assignee.assigned_to_me')} - </span> - </ItemCheckbox> + <ItemCheckbox + checked={Boolean(filters.assignedToMe)} + disabled={component?.needIssueSync} + onCheck={(assignedToMe: boolean) => props.onChangeFilters({ assignedToMe })} + > + <span className="sw-mx-2"> + {translate('hotspot.filters.assignee.assigned_to_me')} + </span> + </ItemCheckbox> + </Tooltip> )} {isFiltered && <ItemDivider />} @@ -140,7 +159,6 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) { </> } placement={PopupPlacement.BottomRight} - isPortal > <DiscreetInteractiveIcon Icon={FilterIcon} @@ -156,4 +174,4 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) { ); } -export default withCurrentUserContext(HotspotSidebarHeader); +export default withComponentContext(withCurrentUserContext(HotspotSidebarHeader)); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotDisabledFilterTooltip-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotDisabledFilterTooltip-test.tsx new file mode 100644 index 00000000000..363e3a8478e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotDisabledFilterTooltip-test.tsx @@ -0,0 +1,37 @@ +/* + * 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 { createEvent, fireEvent, render as rtlRender, screen } from '@testing-library/react'; +import * as React from 'react'; +import { HotspotDisabledFilterTooltip } from '../HotspotDisabledFilterTooltip'; + +it('should render correctly and stop event propagation', () => { + const { container } = rtlRender(<HotspotDisabledFilterTooltip />); + + 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`] = ` +<div> + <div + class="sw-body-sm sw-w-[190px]" + > + <p> + indexation.page_unavailable.description + + indexation.filter_unavailable.description + </p> + <hr + class="sw-mx-0 sw-my-3 sw-p-0 sw-w-full" + /> + <span + class="sw-body-sm-highlight" + > + indexation.learn_more + </span> + <a + class="sw-ml-1" + href="https://docs.sonarqube.org/latest/instance-administration/reindexing/" + rel="noopener noreferrer" + target="_blank" + > + <svg + class="little-spacer-right" + height="14" + style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 1.41421;" + version="1.1" + viewBox="0 0 16 16" + width="14" + xml:space="preserve" + xmlns:xlink="http://www.w3.org/1999/xlink" + > + <title> + opens_in_new_window + </title> + <path + d="M12 9.25v2.5A2.25 2.25 0 0 1 9.75 14h-6.5A2.25 2.25 0 0 1 1 11.75v-6.5A2.25 2.25 0 0 1 3.25 3h5.5c.14 0 .25.11.25.25v.5c0 .14-.11.25-.25.25h-5.5C2.562 4 2 4.563 2 5.25v6.5c0 .688.563 1.25 1.25 1.25h6.5c.688 0 1.25-.563 1.25-1.25v-2.5c0-.14.11-.25.25-.25h.5c.14 0 .25.11.25.25zm3-6.75v4c0 .273-.227.5-.5.5a.497.497 0 0 1-.352-.148l-1.375-1.375L7.68 10.57a.27.27 0 0 1-.18.078.27.27 0 0 1-.18-.078l-.89-.89a.27.27 0 0 1-.078-.18.27.27 0 0 1 .078-.18l5.093-5.093-1.375-1.375A.497.497 0 0 1 10 2.5c0-.273.227-.5.5-.5h4c.273 0 .5.227.5.5z" + style="fill: currentColor;" + /> + </svg> + indexation.reindexing + </a> + </div> +</div> +`; |