import withBranchStatusActions from '../../app/components/branch-status/withBranchStatusActions';
import withComponentContext from '../../app/components/componentContext/withComponentContext';
import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext';
-import { Location, withRouter } from '../../components/hoc/withRouter';
+import { Location, Router, withRouter } from '../../components/hoc/withRouter';
import { getLeakValue } from '../../components/measure/utils';
import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../helpers/branch-like';
import { isInput } from '../../helpers/keyboardEventHelpers';
currentUser: CurrentUser;
component: Component;
location: Location;
+ router: Router;
}
type Props = DispatchProps & OwnProps;
);
};
+ handleShowAllHotspots = () => {
+ this.props.router.push({
+ pathname: this.props.location.pathname,
+ query: {
+ file: undefined,
+ fileUuid: undefined,
+ hotspots: [],
+ sinceLeakPeriod: undefined,
+ assignedToMe: undefined,
+ id: this.props.component.key,
+ },
+ });
+ };
+
handleChangeStatusFilter = (status: HotspotStatusFilter) => {
this.handleChangeFilters({ status });
};
loadingMeasure={loadingMeasure}
loadingMore={loadingMore}
onChangeFilters={this.handleChangeFilters}
+ onShowAllHotspots={this.handleShowAllHotspots}
onHotspotClick={this.handleHotspotClick}
onLoadMore={this.handleLoadMore}
onSwitchStatusFilter={this.handleChangeStatusFilter}
onLocationClick={this.handleLocationClick}
securityCategories={standards[SecurityStandard.SONARSOURCE]}
selectedHotspot={selectedHotspot}
+ selectedHotspotLocation={selectedHotspotLocationIndex}
standards={standards}
/>
);
loadingMeasure: boolean;
loadingMore: boolean;
onChangeFilters: (filters: Partial<HotspotFilters>) => void;
+ onShowAllHotspots: VoidFunction;
onHotspotClick: (hotspot: RawHotspot) => void;
onLocationClick: (index?: number) => void;
onLoadMore: () => void;
selectedHotspot,
selectedHotspotLocation,
standards,
+ onChangeFilters,
+ onShowAllHotspots,
} = props;
return (
<Helmet title={translate('hotspots.page')} />
<A11ySkipTarget anchor="security_hotspots_main" />
- <FilterBar
- component={component}
- filters={filters}
- hotspotsReviewedMeasure={hotspotsReviewedMeasure}
- isStaticListOfHotspots={isStaticListOfHotspots}
- loadingMeasure={loadingMeasure}
- onBranch={isBranch(branchLike)}
- onChangeFilters={props.onChangeFilters}
- />
<LargeCenteredLayout id={MetricKey.security_hotspots}>
<PageContentFontWrapper>
<div className="sw-grid sw-grid-cols-12 sw-w-full sw-body-sm">
<DeferredSpinner className="sw-mt-3" loading={loading} />
- {!loading &&
- (hotspots.length === 0 || !selectedHotspot ? (
+ <StyledFilterbar className="sw-col-span-4 sw-rounded-t-1 sw-mt-0 sw-z-filterbar sw-p-4 it__hotspot-list">
+ <FilterBar
+ component={component}
+ filters={filters}
+ hotspotsReviewedMeasure={hotspotsReviewedMeasure}
+ isStaticListOfHotspots={isStaticListOfHotspots}
+ loadingMeasure={loadingMeasure}
+ onBranch={isBranch(branchLike)}
+ onChangeFilters={onChangeFilters}
+ onShowAllHotspots={onShowAllHotspots}
+ />
+ {hotspots.length > 0 && selectedHotspot && (
+ <>
+ {filterByCategory || filterByCWE || filterByFile ? (
+ <HotspotSimpleList
+ filterByCategory={filterByCategory}
+ filterByCWE={filterByCWE}
+ filterByFile={filterByFile}
+ hotspots={hotspots}
+ hotspotsTotal={hotspotsTotal}
+ loadingMore={loadingMore}
+ onHotspotClick={props.onHotspotClick}
+ onLoadMore={props.onLoadMore}
+ onLocationClick={props.onLocationClick}
+ selectedHotspotLocation={selectedHotspotLocation}
+ selectedHotspot={selectedHotspot}
+ standards={standards}
+ />
+ ) : (
+ <HotspotList
+ hotspots={hotspots}
+ hotspotsTotal={hotspotsTotal}
+ isStaticListOfHotspots={isStaticListOfHotspots}
+ loadingMore={loadingMore}
+ onHotspotClick={props.onHotspotClick}
+ onLoadMore={props.onLoadMore}
+ onLocationClick={props.onLocationClick}
+ securityCategories={securityCategories}
+ selectedHotspot={selectedHotspot}
+ selectedHotspotLocation={selectedHotspotLocation}
+ statusFilter={filters.status}
+ />
+ )}
+ </>
+ )}
+ </StyledFilterbar>
+
+ <main className="sw-col-span-8">
+ {hotspots.length === 0 || !selectedHotspot ? (
<EmptyHotspotsPage
filtered={
filters.assignedToMe ||
isStaticListOfHotspots={isStaticListOfHotspots}
/>
) : (
- <>
- <FilterbarStyled className="sw-col-span-4 sw-rounded-t-1 sw-mt-0 sw-z-filterbar sw-p-4 it__hotspot-list">
- {filterByCategory || filterByCWE || filterByFile ? (
- <HotspotSimpleList
- filterByCategory={filterByCategory}
- filterByCWE={filterByCWE}
- filterByFile={filterByFile}
- hotspots={hotspots}
- hotspotsTotal={hotspotsTotal}
- loadingMore={loadingMore}
- onHotspotClick={props.onHotspotClick}
- onLoadMore={props.onLoadMore}
- onLocationClick={props.onLocationClick}
- selectedHotspotLocation={selectedHotspotLocation}
- selectedHotspot={selectedHotspot}
- standards={standards}
- />
- ) : (
- <HotspotList
- hotspots={hotspots}
- hotspotsTotal={hotspotsTotal}
- isStaticListOfHotspots={isStaticListOfHotspots}
- loadingMore={loadingMore}
- onHotspotClick={props.onHotspotClick}
- onLoadMore={props.onLoadMore}
- onLocationClick={props.onLocationClick}
- securityCategories={securityCategories}
- selectedHotspot={selectedHotspot}
- selectedHotspotLocation={selectedHotspotLocation}
- statusFilter={filters.status}
- />
- )}
- </FilterbarStyled>
-
- <main className="sw-col-span-8">
- <HotspotViewer
- component={component}
- hotspotKey={selectedHotspot.key}
- hotspotsReviewedMeasure={hotspotsReviewedMeasure}
- onSwitchStatusFilter={props.onSwitchStatusFilter}
- onUpdateHotspot={props.onUpdateHotspot}
- onLocationClick={props.onLocationClick}
- selectedHotspotLocation={selectedHotspotLocation}
- />
- </main>
- </>
- ))}
+ <HotspotViewer
+ component={component}
+ hotspotKey={selectedHotspot.key}
+ hotspotsReviewedMeasure={hotspotsReviewedMeasure}
+ onSwitchStatusFilter={props.onSwitchStatusFilter}
+ onUpdateHotspot={props.onUpdateHotspot}
+ onLocationClick={props.onLocationClick}
+ selectedHotspotLocation={selectedHotspotLocation}
+ />
+ )}
+ </main>
</div>
</PageContentFontWrapper>
</LargeCenteredLayout>
);
}
-const FilterbarStyled = withTheme(
+const StyledFilterbar = withTheme(
styled.div`
- position: sticky;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Route } from 'react-router-dom';
-import selectEvent from 'react-select-event';
import { byDisplayValue, byRole, byTestId, byText } from 'testing-library-selector';
import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock';
import SecurityHotspotServiceMock from '../../../api/mocks/SecurityHotspotServiceMock';
editAssigneeButton: byRole('button', {
name: 'hotspots.assignee.change_user',
}),
- filterAssigneeToMe: byRole('button', {
+ filterAssigneeToMe: byRole('checkbox', {
name: 'hotspot.filters.assignee.assigned_to_me',
}),
- filterSeeAll: byRole('button', { name: 'hotspot.filters.assignee.all' }),
+ clearFilters: byRole('menuitem', { name: 'hotspot.filters.clear' }),
+ filterDropdown: byRole('button', { name: 'hotspot.filters.title' }),
+ filterToReview: byRole('radio', { name: 'hotspot.filters.status.to_review' }),
filterByStatus: byRole('combobox', { name: 'hotspot.filters.status' }),
filterByPeriod: byRole('combobox', { name: 'hotspot.filters.period' }),
+ filterNewCode: byRole('checkbox', { name: 'hotspot.filters.period.since_leak_period' }),
noHotspotForFilter: byText('hotspots.no_hotspots_for_filters.title'),
selectStatus: byRole('button', { name: 'hotspots.status.select_status' }),
toReviewStatus: byText('hotspots.status_option.TO_REVIEW'),
changeStatus: byRole('button', { name: 'hotspots.status.change_status' }),
hotspotTitle: (name: string | RegExp) => byRole('heading', { name }),
hotspotStatus: byRole('heading', { name: 'status: hotspots.status_option.FIXED' }),
- hotpostListTitle: byRole('heading', { name: 'hotspots.list_title.TO_REVIEW.4' }),
+ hotpostListTitle: byText('hotspots.list_title'),
hotspotCommentBox: byRole('textbox', { name: 'hotspots.comment.field' }),
commentSubmitButton: byRole('button', { name: 'hotspots.comment.submit' }),
commentEditButton: byRole('button', { name: 'issue.comment.edit' }),
commentDeleteButton: byRole('button', { name: 'issue.comment.delete' }),
textboxWithText: (value: string) => byDisplayValue(value),
activeAssignee: byTestId('assignee-name'),
- successGlobalMessage: byRole('status'),
+ successGlobalMessage: byTestId('global-message__SUCCESS'),
currentUserSelectionItem: byText('foo'),
panel: byTestId('security-hotspot-test'),
codeTab: byRole('tab', { name: 'hotspots.tabs.code' }),
vulnerabilityContent: byText('Assess'),
fixTab: byRole('tab', { name: 'hotspots.tabs.fix_recommendations' }),
fixContent: byText('This is how to fix'),
+ showAllHotspotLink: byRole('link', { name: 'hotspot.filters.show_all' }),
};
const hotspotsHandler = new SecurityHotspotServiceMock();
);
expect(await screen.findAllByText('variant 1, variant 2')).toHaveLength(2);
});
+
+ it('should render the simple list when a file is selected', async () => {
+ const user = userEvent.setup();
+ renderSecurityHotspotsApp(
+ `security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&files=src%2Findex.js`
+ );
+
+ expect(ui.filterDropdown.query()).not.toBeInTheDocument();
+ expect(ui.filterToReview.query()).not.toBeInTheDocument();
+
+ // Drop selection
+ await user.click(ui.showAllHotspotLink.get());
+
+ expect(ui.filterDropdown.get()).toBeInTheDocument();
+ expect(ui.filterToReview.get()).toBeInTheDocument();
+ });
});
it('should navigate when comming from SonarLint', async () => {
expect(await ui.hotpostListTitle.find()).toBeInTheDocument();
+ await user.click(ui.filterDropdown.get());
await user.click(ui.filterAssigneeToMe.get());
expect(ui.noHotspotForFilter.get()).toBeInTheDocument();
- await selectEvent.select(ui.filterByStatus.get(), ['hotspot.filters.status.to_review']);
+
+ await user.click(ui.filterToReview.get());
expect(getSecurityHotspots).toHaveBeenLastCalledWith({
inNewCodePeriod: false,
status: 'TO_REVIEW',
});
- await selectEvent.select(ui.filterByPeriod.get(), ['hotspot.filters.period.since_leak_period']);
+ await user.click(ui.filterDropdown.get());
+ await user.click(ui.filterNewCode.get());
expect(getSecurityHotspots).toHaveBeenLastCalledWith({
inNewCodePeriod: true,
status: 'TO_REVIEW',
});
- await user.click(ui.filterSeeAll.get());
+ await user.click(ui.filterDropdown.get());
+ await user.click(ui.clearFilters.get());
expect(ui.hotpostListTitle.get()).toBeInTheDocument();
});
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { withTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import {
+ CoverageIndicator,
+ DiscreetInteractiveIcon,
+ DiscreetLink,
+ Dropdown,
+ FilterIcon,
+ HelperHintIcon,
+ ItemCheckbox,
+ ItemDangerButton,
+ ItemDivider,
+ ItemHeader,
+ PopupPlacement,
+ ToggleButton,
+ themeBorder,
+} from 'design-system';
import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
-import Link from '../../../components/common/Link';
-import ButtonToggle from '../../../components/controls/ButtonToggle';
import HelpTooltip from '../../../components/controls/HelpTooltip';
-import Select from '../../../components/controls/Select';
import Measure from '../../../components/measure/Measure';
-import CoverageRating from '../../../components/ui/CoverageRating';
import DeferredSpinner from '../../../components/ui/DeferredSpinner';
import { translate } from '../../../helpers/l10n';
-import { getProjectSecurityHotspots } from '../../../helpers/urls';
import { ComponentQualifier } from '../../../types/component';
+import { MetricType } from '../../../types/metrics';
import { HotspotFilters, HotspotStatusFilter } from '../../../types/security-hotspots';
import { Component } from '../../../types/types';
import { CurrentUser, isLoggedIn } from '../../../types/users';
loadingMeasure: boolean;
onBranch: boolean;
onChangeFilters: (filters: Partial<HotspotFilters>) => void;
+ onShowAllHotspots: VoidFunction;
}
const statusOptions: Array<{ label: string; value: HotspotStatusFilter }> = [
{ value: HotspotStatusFilter.SAFE, label: translate('hotspot.filters.status.safe') },
];
-const periodOptions = [
- { value: true, label: translate('hotspot.filters.period.since_leak_period') },
- { value: false, label: translate('hotspot.filters.period.overall') },
-];
-
export enum AssigneeFilterOption {
ALL = 'all',
ME = 'me',
}
-const assigneeFilterOptions = [
- { value: AssigneeFilterOption.ME, label: translate('hotspot.filters.assignee.assigned_to_me') },
- { value: AssigneeFilterOption.ALL, label: translate('hotspot.filters.assignee.all') },
-];
-
export function FilterBar(props: FilterBarProps) {
const {
currentUser,
component,
filters,
hotspotsReviewedMeasure,
- isStaticListOfHotspots,
loadingMeasure,
onBranch,
+ isStaticListOfHotspots,
} = props;
const isProject = component.qualifier === ComponentQualifier.Project;
+ const userLoggedIn = isLoggedIn(currentUser);
+ const filtersCount = Number(filters.assignedToMe) + Number(filters.inNewCodePeriod);
+ const isFiltered = Boolean(filtersCount);
return (
- <div className="filter-bar-outer">
- <div className="filter-bar">
- <div className="filter-bar-inner display-flex-center">
- {isStaticListOfHotspots ? (
- <Link to={getProjectSecurityHotspots(component.key)}>
- {translate('hotspot.filters.show_all')}
- </Link>
- ) : (
- <div className="display-flex-space-between width-100">
- <div className="display-flex-center">
- <h3 className="huge-spacer-right">{translate('hotspot.filters.title')}</h3>
-
- {isLoggedIn(currentUser) && (
- <span className="huge-spacer-right">
- <ButtonToggle
- onCheck={(value: AssigneeFilterOption) =>
- props.onChangeFilters({ assignedToMe: value === AssigneeFilterOption.ME })
- }
- options={assigneeFilterOptions}
- value={
- filters.assignedToMe ? AssigneeFilterOption.ME : AssigneeFilterOption.ALL
- }
- />
- </span>
+ <div className="sw-flex sw-flex-col sw-justify-between sw-pb-4 sw-mb-3">
+ {isStaticListOfHotspots ? (
+ <StyledFilterWrapper className="sw-flex sw-px-2 sw-py-4">
+ <FormattedMessage
+ id="hotspot.filters.by_file_or_list_x"
+ values={{
+ show_all_link: (
+ <DiscreetLink
+ className="sw-ml-1"
+ onClick={props.onShowAllHotspots}
+ preventDefault={true}
+ to={{}}
+ >
+ {translate('hotspot.filters.show_all')}
+ </DiscreetLink>
+ ),
+ }}
+ defaultMessage={translate('hotspot.filters.by_file_or_list_x')}
+ />
+ </StyledFilterWrapper>
+ ) : (
+ <>
+ {isProject && (
+ <StyledFilterWrapper className="sw-flex sw-px-2 sw-py-4 sw-items-center sw-h-6">
+ <DeferredSpinner loading={loadingMeasure}>
+ {hotspotsReviewedMeasure !== undefined && (
+ <CoverageIndicator value={hotspotsReviewedMeasure} />
)}
-
- <span className="spacer-right"> {translate('status')} </span>
- <Select
- className="input-medium big-spacer-right"
- aria-label={translate('hotspot.filters.status')}
- onChange={(option: { value: HotspotStatusFilter }) =>
- props.onChangeFilters({ status: option.value })
+ <Measure
+ className="sw-ml-2 sw-body-sm-highlight"
+ metricKey={
+ onBranch && !filters.inNewCodePeriod
+ ? 'security_hotspots_reviewed'
+ : 'new_security_hotspots_reviewed'
}
- options={statusOptions}
- isSearchable={false}
- value={statusOptions.find((status) => status.value === filters.status)}
+ metricType={MetricType.Percent}
+ value={hotspotsReviewedMeasure}
/>
+ <span className="sw-ml-1 sw-body-sm">
+ {translate('metric.security_hotspots_reviewed.name')}
+ </span>
+ <HelpTooltip className="sw-ml-1" overlay={translate('hotspots.reviewed.tooltip')}>
+ <HelperHintIcon aria-label="help-tooltip" />
+ </HelpTooltip>
+ </DeferredSpinner>
+ </StyledFilterWrapper>
+ )}
- {onBranch && (
- <Select
- className="input-medium big-spacer-right"
- aria-label={translate('hotspot.filters.period')}
- onChange={(option: { value: boolean }) =>
- props.onChangeFilters({ inNewCodePeriod: option.value })
- }
- options={periodOptions}
- isSearchable={false}
- value={periodOptions.find((period) => period.value === filters.inNewCodePeriod)}
- />
- )}
- </div>
+ <StyledFilterWrapper className="sw-flex sw-px-2 sw-py-4 sw-gap-2 sw-justify-between">
+ <ToggleButton
+ aria-label={translate('hotspot.filters.status')}
+ onChange={(status: HotspotStatusFilter) => props.onChangeFilters({ status })}
+ options={statusOptions}
+ value={statusOptions.find((status) => status.value === filters.status)?.value}
+ />
+ {(onBranch || userLoggedIn || isFiltered) && (
+ <Dropdown
+ allowResizing={true}
+ closeOnClick={false}
+ id="filter-hotspots-menu"
+ overlay={
+ <>
+ <ItemHeader>{translate('hotspot.filters.title')}</ItemHeader>
- {isProject && (
- <div className="display-flex-center">
- <span className="little-spacer-right">
- {translate('metric.security_hotspots_reviewed.name')}
- </span>
- <HelpTooltip
- className="big-spacer-right"
- overlay={translate('hotspots.reviewed.tooltip')}
- />
- <DeferredSpinner loading={loadingMeasure}>
- {hotspotsReviewedMeasure && <CoverageRating value={hotspotsReviewedMeasure} />}
- <Measure
- className="spacer-left huge it__hs-review-percentage"
- metricKey={
- onBranch && !filters.inNewCodePeriod
- ? 'security_hotspots_reviewed'
- : 'new_security_hotspots_reviewed'
- }
- metricType="PERCENT"
- value={hotspotsReviewedMeasure}
- />
- </DeferredSpinner>
- </div>
- )}
- </div>
- )}
- </div>
- </div>
+ {onBranch && (
+ <ItemCheckbox
+ checked={Boolean(filters.inNewCodePeriod)}
+ onCheck={(inNewCodePeriod) => props.onChangeFilters({ inNewCodePeriod })}
+ >
+ <span className="sw-mx-2">
+ {translate('hotspot.filters.period.since_leak_period')}
+ </span>
+ </ItemCheckbox>
+ )}
+
+ {userLoggedIn && (
+ <ItemCheckbox
+ checked={Boolean(filters.assignedToMe)}
+ onCheck={(assignedToMe) => props.onChangeFilters({ assignedToMe })}
+ >
+ <span className="sw-mx-2">
+ {translate('hotspot.filters.assignee.assigned_to_me')}
+ </span>
+ </ItemCheckbox>
+ )}
+
+ {isFiltered && <ItemDivider />}
+
+ {isFiltered && (
+ <ItemDangerButton
+ onClick={() =>
+ props.onChangeFilters({
+ assignedToMe: false,
+ inNewCodePeriod: false,
+ })
+ }
+ >
+ {translate('hotspot.filters.clear')}
+ </ItemDangerButton>
+ )}
+ </>
+ }
+ placement={PopupPlacement.BottomRight}
+ >
+ <DiscreetInteractiveIcon
+ Icon={FilterIcon}
+ aria-label={translate('hotspot.filters.title')}
+ >
+ {isFiltered ? filtersCount : null}
+ </DiscreetInteractiveIcon>
+ </Dropdown>
+ )}
+ </StyledFilterWrapper>
+ </>
+ )}
</div>
);
}
+const StyledFilterWrapper = withTheme(styled.div`
+ border-bottom: ${themeBorder('default')};
+`);
+
export default withCurrentUserContext(FilterBar);
hotspot.filters.title=Filters
hotspot.filters.assignee.assigned_to_me=Assigned to me
hotspot.filters.assignee.all=All
+hotspot.filters.clear=Clear filters
hotspot.filters.status=Status filter
hotspot.filters.status.to_review=To review
hotspot.filters.status.acknowledged=Acknowledged
hotspot.filters.period.since_leak_period=New Code
hotspot.filters.period.overall=Overall code
hotspot.filters.status.safe=Safe
-hotspot.filters.show_all=Show all hotspots
+hotspot.filters.by_file_or_list_x=Your hotspots are currently filtered, {show_all_link}
+hotspot.filters.show_all=show all hotspots
hotspot.section.activity=Recent activity:
hotspots.reviewed.tooltip=Percentage of open Security Hotspots that have been reviewed (Acknowledged, Fixed or Safe)