* 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';
} 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(
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<HotspotSearchResponse> {
- 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<HotspotSearchResponse> {
- 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<Hotspot> {
login: hotspot.assignee,
};
}
+
hotspot.authorUser = users.find((u) => 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 };
});
* 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';
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,
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]: {},
},
};
}
if (isInput(event)) {
return;
}
+
if (event.key === KeyboardKeys.Alt) {
event.preventDefault();
return;
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;
}
}
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) {
} else {
newIndex = selectedHotspotLocationIndex + 1;
}
+
this.setState({ selectedHotspotLocationIndex: newIndex });
};
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],
};
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,
if (!this.mounted) {
return;
}
+
const measure = measures && measures.length > 0 ? measures[0] : undefined;
+
const hotspotsReviewedMeasure = filters.inNewCodePeriod
? getLeakValue(measure)
: measure?.value;
});
};
+ 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;
const standard = SECURITY_STANDARDS.find(
(stnd) => stnd !== SecurityStandard.CWE && location.query[stnd] !== undefined
);
+
const filterByCategory = standard
? { standard, category: location.query[standard] }
: undefined;
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,
});
}
? 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 = () => {
({ filters }) => ({ filters: { ...filters, ...changes } }),
() => {
this.reloadSecurityHotspotList();
+
if (changes.inNewCodePeriod !== undefined) {
this.fetchSecurityHotspotsReviewed();
}
* 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 {
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;
}
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}>
<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}>
onHotspotClick={props.onHotspotClick}
onLoadMore={props.onLoadMore}
onLocationClick={props.onLocationClick}
- selectedHotspotLocation={selectedHotspotLocation}
selectedHotspot={selectedHotspot}
+ selectedHotspotLocation={selectedHotspotLocation}
standards={standards}
/>
) : (
</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}
/>
* 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';
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';
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) => {
showDialog = value;
}
});
+
jest.mocked(get).mockImplementation(() => showDialog);
beforeAll(() => {
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();
isCompressed: true,
resetScrollDownCompress: jest.fn(),
}));
+
renderSecurityHotspotsApp();
expect(await ui.reviewButton.find()).toBeInTheDocument();
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();
it('should allow to open a hotspot in an IDE', async () => {
const user = userEvent.setup();
+
renderSecurityHotspotsApp();
await user.click(await ui.openInIDEButton.find());
description: 'I use MS Paint cuz Ima boss',
},
]);
+
const user = userEvent.setup();
renderSecurityHotspotsApp();
renderSecurityHotspotsApp();
await user.click(await ui.reviewButton.find());
await user.click(ui.toReviewStatus.get());
+
await act(async () => {
await user.click(ui.changeStatus.get());
});
// 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();
});
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());
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,
}
);
--- /dev/null
+/*
+ * 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>
+ );
+}
* 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,
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
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>
)}
{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 />}
</>
}
placement={PopupPlacement.BottomRight}
- isPortal
>
<DiscreetInteractiveIcon
Icon={FilterIcon}
);
}
-export default withCurrentUserContext(HotspotSidebarHeader);
+export default withComponentContext(withCurrentUserContext(HotspotSidebarHeader));
--- /dev/null
+/*
+ * 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);
+});
--- /dev/null
+// 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>
+`;
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
#------------------------------------------------------------------------------
#