aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js
diff options
context:
space:
mode:
author7PH <benjamin.raymond@sonarsource.com>2023-05-17 11:30:23 +0200
committersonartech <sonartech@sonarsource.com>2023-05-24 20:03:14 +0000
commit31c52cad6e60801c48999cc204c71d123d22c064 (patch)
treeacaba58837dfaca9751785cf77749aff866f79fa /server/sonar-web/src/main/js
parent56e625210bff28970ce12cf8d7807ebda6c147e1 (diff)
downloadsonarqube-31c52cad6e60801c48999cc204c71d123d22c064.tar.gz
sonarqube-31c52cad6e60801c48999cc204c71d123d22c064.zip
SONAR-19236 Implement correct layout for hotspot page sidebar
Diffstat (limited to 'server/sonar-web/src/main/js')
-rw-r--r--server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx223
-rw-r--r--server/sonar-web/src/main/js/apps/security-hotspots/components/FilterBar.tsx216
-rw-r--r--server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx159
-rw-r--r--server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotStatusFilter.tsx90
-rw-r--r--server/sonar-web/src/main/js/apps/security-hotspots/components/StatusUpdateSuccessModal.tsx2
-rw-r--r--server/sonar-web/src/main/js/hooks/useFollowScroll.ts42
6 files changed, 421 insertions, 311 deletions
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 7498eb4c696..8af9b7bccfd 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,9 +17,13 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { useTheme, withTheme } from '@emotion/react';
+import { withTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
+ DeferredSpinner,
+ LAYOUT_FOOTER_HEIGHT,
+ LAYOUT_GLOBAL_NAV_HEIGHT,
+ LAYOUT_PROJECT_NAV_HEIGHT,
LargeCenteredLayout,
PageContentFontWrapper,
themeBorder,
@@ -29,18 +33,20 @@ import * as React from 'react';
import { Helmet } from 'react-helmet-async';
import A11ySkipTarget from '../../components/a11y/A11ySkipTarget';
import Suggestions from '../../components/embed-docs-modal/Suggestions';
-import DeferredSpinner from '../../components/ui/DeferredSpinner';
import { isBranch } from '../../helpers/branch-like';
import { translate } from '../../helpers/l10n';
+import useFollowScroll from '../../hooks/useFollowScroll';
import { BranchLike } from '../../types/branch-like';
+import { ComponentQualifier } from '../../types/component';
import { MetricKey } from '../../types/metrics';
import { SecurityStandard, Standards } from '../../types/security';
import { HotspotFilters, HotspotStatusFilter, RawHotspot } from '../../types/security-hotspots';
import { Component, StandardSecurityCategories } from '../../types/types';
import EmptyHotspotsPage from './components/EmptyHotspotsPage';
-import FilterBar from './components/FilterBar';
import HotspotList from './components/HotspotList';
+import HotspotSidebarHeader from './components/HotspotSidebarHeader';
import HotspotSimpleList from './components/HotspotSimpleList';
+import HotspotFilterByStatus from './components/HotspotStatusFilter';
import HotspotViewer from './components/HotspotViewer';
import './styles.css';
@@ -97,7 +103,12 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
onShowAllHotspots,
} = props;
- const theme = useTheme();
+ 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 (
<>
@@ -107,81 +118,97 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
<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} />
-
- <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 sw-pl-12">
- <StyledContentWrapper theme={theme} className="sw-h-full">
- {hotspots.length === 0 || !selectedHotspot ? (
- <EmptyHotspotsPage
- filtered={
- filters.assignedToMe ||
- (isBranch(branchLike) && filters.inNewCodePeriod) ||
- filters.status !== HotspotStatusFilter.TO_REVIEW
- }
- filterByFile={Boolean(filterByFile)}
+ <div className="sw-grid sw-grid-cols-12 sw-w-full sw-min-h-[100vh]">
+ <StyledSidebar
+ aria-label={translate('hotspots.list')}
+ className="sw--mt-8 sw-z-filterbar sw-col-span-4"
+ style={{
+ height: `calc(100vh - ${
+ LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT
+ }px - ${footerVisibleHeight}px)`,
+ top: LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT,
+ }}
+ >
+ {isProject && (
+ <StyledSidebarHeader className="sw-w-full sw-top-0 sw-px-4 sw-py-2">
+ <HotspotSidebarHeader
+ branchLike={branchLike}
+ filters={filters}
isStaticListOfHotspots={isStaticListOfHotspots}
+ hotspotsReviewedMeasure={hotspotsReviewedMeasure}
+ loadingMeasure={loadingMeasure}
+ onChangeFilters={onChangeFilters}
/>
- ) : (
- <HotspotViewer
- component={component}
- hotspotKey={selectedHotspot.key}
- onSwitchStatusFilter={props.onSwitchStatusFilter}
- onUpdateHotspot={props.onUpdateHotspot}
- onLocationClick={props.onLocationClick}
- selectedHotspotLocation={selectedHotspotLocation}
- standards={standards}
+ </StyledSidebarHeader>
+ )}
+ <StyledSidebarContent className="sw-p-4 it__hotspot-list">
+ <DeferredSpinner className="sw-mt-3" loading={loading}>
+ <HotspotFilterByStatus
+ filters={filters}
+ isStaticListOfHotspots={isStaticListOfHotspots}
+ onChangeFilters={onChangeFilters}
+ onShowAllHotspots={onShowAllHotspots}
/>
- )}
- </StyledContentWrapper>
- </main>
+ {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}
+ />
+ )}
+ </>
+ )}
+ </DeferredSpinner>
+ </StyledSidebarContent>
+ </StyledSidebar>
+ <StyledMain className="sw-col-span-8 sw-relative sw-pl-12">
+ {hotspots.length === 0 || !selectedHotspot ? (
+ <EmptyHotspotsPage
+ filtered={
+ filters.assignedToMe ||
+ (isBranch(branchLike) && filters.inNewCodePeriod) ||
+ filters.status !== HotspotStatusFilter.TO_REVIEW
+ }
+ filterByFile={Boolean(filterByFile)}
+ isStaticListOfHotspots={isStaticListOfHotspots}
+ />
+ ) : (
+ <HotspotViewer
+ component={component}
+ hotspotKey={selectedHotspot.key}
+ onSwitchStatusFilter={props.onSwitchStatusFilter}
+ onUpdateHotspot={props.onUpdateHotspot}
+ onLocationClick={props.onLocationClick}
+ selectedHotspotLocation={selectedHotspotLocation}
+ standards={standards}
+ />
+ )}
+ </StyledMain>
</div>
</PageContentFontWrapper>
</LargeCenteredLayout>
@@ -189,22 +216,30 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
);
}
-const StyledFilterbar = withTheme(
- styled.div`
- box-sizing: border-box;
- overflow-x: hidden;
- overflow-y: auto;
- background-color: ${themeColor('filterbar')};
- border-right: ${themeBorder('default', 'filterbarBorder')};
- // ToDo set proper height
- height: calc(100vh - ${'100px'});
- `
-);
+const StyledSidebar = withTheme(styled.section`
+ position: sticky;
+ box-sizing: border-box;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ background-color: ${themeColor('filterbar')};
+ border-right: ${themeBorder('default', 'filterbarBorder')};
+`);
+
+const StyledSidebarContent = styled.div`
+ position: relative;
+ box-sizing: border-box;
+ width: 100%;
+`;
+
+const StyledSidebarHeader = withTheme(styled.div`
+ position: sticky;
+ box-sizing: border-box;
+ background-color: inherit;
+ border-bottom: ${themeBorder('default')};
+ z-index: 1;
+`);
-const StyledContentWrapper = withTheme(
- styled.div`
- background-color: ${themeColor('backgroundSecondary')};
- border-right: ${themeBorder('default', 'pageBlockBorder')};
- border-left: ${themeBorder('default', 'pageBlockBorder')};
- `
-);
+const StyledMain = styled.main`
+ flex-grow: 1;
+ background-color: ${themeColor('backgroundSecondary')};
+`;
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/FilterBar.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/FilterBar.tsx
deleted file mode 100644
index 869e87986c6..00000000000
--- a/server/sonar-web/src/main/js/apps/security-hotspots/components/FilterBar.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-/*
- * 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 { 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 HelpTooltip from '../../../components/controls/HelpTooltip';
-import Measure from '../../../components/measure/Measure';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-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';
-
-export interface FilterBarProps {
- currentUser: CurrentUser;
- component: Component;
- filters: HotspotFilters;
- hotspotsReviewedMeasure?: string;
- isStaticListOfHotspots: boolean;
- loadingMeasure: boolean;
- onBranch: boolean;
- onChangeFilters: (filters: Partial<HotspotFilters>) => void;
- onShowAllHotspots: VoidFunction;
-}
-
-const statusOptions: Array<{ label: string; value: HotspotStatusFilter }> = [
- { value: HotspotStatusFilter.TO_REVIEW, label: translate('hotspot.filters.status.to_review') },
- {
- value: HotspotStatusFilter.ACKNOWLEDGED,
- label: translate('hotspot.filters.status.acknowledged'),
- },
- { value: HotspotStatusFilter.FIXED, label: translate('hotspot.filters.status.fixed') },
- { value: HotspotStatusFilter.SAFE, label: translate('hotspot.filters.status.safe') },
-];
-
-export enum AssigneeFilterOption {
- ALL = 'all',
- ME = 'me',
-}
-
-export function FilterBar(props: FilterBarProps) {
- const {
- currentUser,
- component,
- filters,
- hotspotsReviewedMeasure,
- 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="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} />
- )}
- <Measure
- className="sw-ml-2 sw-body-sm-highlight"
- metricKey={
- onBranch && !filters.inNewCodePeriod
- ? 'security_hotspots_reviewed'
- : 'new_security_hotspots_reviewed'
- }
- 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>
- )}
-
- <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>
-
- {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);
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
new file mode 100644
index 00000000000..254cb0d1d07
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx
@@ -0,0 +1,159 @@
+/*
+ * 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 {
+ CoverageIndicator,
+ DiscreetInteractiveIcon,
+ Dropdown,
+ FilterIcon,
+ HelperHintIcon,
+ ItemCheckbox,
+ ItemDangerButton,
+ ItemDivider,
+ ItemHeader,
+} from 'design-system';
+import * as React from 'react';
+import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
+import HelpTooltip from '../../../components/controls/HelpTooltip';
+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 { MetricKey, MetricType } from '../../../types/metrics';
+import { HotspotFilters } from '../../../types/security-hotspots';
+import { CurrentUser, isLoggedIn } from '../../../types/users';
+
+export interface SecurityHotspotsAppRendererProps {
+ branchLike?: BranchLike;
+ filters: HotspotFilters;
+ hotspotsReviewedMeasure?: string;
+ loadingMeasure: boolean;
+ onChangeFilters: (filters: Partial<HotspotFilters>) => void;
+ currentUser: CurrentUser;
+ isStaticListOfHotspots: boolean;
+}
+
+function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) {
+ const {
+ branchLike,
+ filters,
+ hotspotsReviewedMeasure,
+ loadingMeasure,
+ currentUser,
+ isStaticListOfHotspots,
+ } = 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">
+ <DeferredSpinner loading={loadingMeasure}>
+ {hotspotsReviewedMeasure !== undefined && (
+ <CoverageIndicator value={hotspotsReviewedMeasure} />
+ )}
+ <Measure
+ className="sw-ml-2 sw-body-sm-highlight"
+ metricKey={
+ isBranch(branchLike) && !filters.inNewCodePeriod
+ ? MetricKey.security_hotspots_reviewed
+ : MetricKey.new_security_hotspots_reviewed
+ }
+ 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>
+
+ {!isStaticListOfHotspots && (isBranch(branchLike) || userLoggedIn || isFiltered) && (
+ <div className="sw-flex-grow sw-flex sw-justify-end">
+ <Dropdown
+ allowResizing={true}
+ closeOnClick={false}
+ id="filter-hotspots-menu"
+ overlay={
+ <>
+ <ItemHeader>{translate('hotspot.filters.title')}</ItemHeader>
+
+ {isBranch(branchLike) && (
+ <ItemCheckbox
+ checked={Boolean(filters.inNewCodePeriod)}
+ onCheck={(inNewCodePeriod: boolean) =>
+ 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: boolean) => 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}
+ isPortal={true}
+ >
+ <DiscreetInteractiveIcon
+ Icon={FilterIcon}
+ aria-label={translate('hotspot.filters.title')}
+ >
+ {isFiltered ? filtersCount : null}
+ </DiscreetInteractiveIcon>
+ </Dropdown>
+ </div>
+ )}
+ </DeferredSpinner>
+ </div>
+ );
+}
+
+export default withCurrentUserContext(HotspotSidebarHeader);
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotStatusFilter.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotStatusFilter.tsx
new file mode 100644
index 00000000000..3f19a1dac5e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotStatusFilter.tsx
@@ -0,0 +1,90 @@
+/*
+ * 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 { withTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { DiscreetLink, ToggleButton, themeBorder } from 'design-system';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { translate } from '../../../helpers/l10n';
+import { HotspotFilters, HotspotStatusFilter } from '../../../types/security-hotspots';
+
+export interface FilterBarProps {
+ filters: HotspotFilters;
+ isStaticListOfHotspots: boolean;
+ onChangeFilters: (filters: Partial<HotspotFilters>) => void;
+ onShowAllHotspots: VoidFunction;
+}
+
+const statusOptions: Array<{ label: string; value: HotspotStatusFilter }> = [
+ { value: HotspotStatusFilter.TO_REVIEW, label: translate('hotspot.filters.status.to_review') },
+ {
+ value: HotspotStatusFilter.ACKNOWLEDGED,
+ label: translate('hotspot.filters.status.acknowledged'),
+ },
+ { value: HotspotStatusFilter.FIXED, label: translate('hotspot.filters.status.fixed') },
+ { value: HotspotStatusFilter.SAFE, label: translate('hotspot.filters.status.safe') },
+];
+
+export enum AssigneeFilterOption {
+ ALL = 'all',
+ ME = 'me',
+}
+
+export default function HotspotFilterByStatus(props: FilterBarProps) {
+ const { filters, isStaticListOfHotspots } = props;
+
+ return (
+ <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>
+ ) : (
+ <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}
+ />
+ </StyledFilterWrapper>
+ )}
+ </div>
+ );
+}
+
+const StyledFilterWrapper = withTheme(styled.div`
+ border-bottom: ${themeBorder('default')};
+`);
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/StatusUpdateSuccessModal.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/StatusUpdateSuccessModal.tsx
index 2a2ec0e2dec..9259575884d 100644
--- a/server/sonar-web/src/main/js/apps/security-hotspots/components/StatusUpdateSuccessModal.tsx
+++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/StatusUpdateSuccessModal.tsx
@@ -19,8 +19,8 @@
*/
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
-import { Button, ButtonLink } from '../../../components/controls/buttons';
import Modal from '../../../components/controls/Modal';
+import { Button, ButtonLink } from '../../../components/controls/buttons';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { formatMeasure } from '../../../helpers/measures';
import { HotspotStatusOption } from '../../../types/security-hotspots';
diff --git a/server/sonar-web/src/main/js/hooks/useFollowScroll.ts b/server/sonar-web/src/main/js/hooks/useFollowScroll.ts
new file mode 100644
index 00000000000..0692d5e2ab7
--- /dev/null
+++ b/server/sonar-web/src/main/js/hooks/useFollowScroll.ts
@@ -0,0 +1,42 @@
+/*
+ * 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 { throttle } from 'lodash';
+import { useEffect, useState } from 'react';
+
+const THROTTLE_DELAY = 10;
+
+export default function useFollowScroll() {
+ const [left, setLeft] = useState(0);
+ const [top, setTop] = useState(0);
+
+ useEffect(() => {
+ const followScroll = throttle(() => {
+ if (document.documentElement) {
+ setLeft(document.documentElement.scrollLeft);
+ setTop(document.documentElement.scrollTop);
+ }
+ }, THROTTLE_DELAY);
+
+ document.addEventListener('scroll', followScroll);
+ return () => document.removeEventListener('scroll', followScroll);
+ }, []);
+
+ return { left, top };
+}