import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils';
import { ComponentContextShape } from '../../../types/component';
import SecurityHotspotsApp from '../SecurityHotspotsApp';
+import useScrollDownCompress from '../hooks/useScrollDownCompress';
jest.mock('../../../api/measures');
jest.mock('../../../api/security-hotspots');
jest.mock('../../../api/rules');
jest.mock('../../../api/quality-profiles');
jest.mock('../../../api/issues');
+jest.mock('../hooks/useScrollDownCompress');
const ui = {
inputAssignee: byRole('combobox', { name: 'search.search_for_users' }),
successGlobalMessage: byTestId('global-message__SUCCESS'),
currentUserSelectionItem: byText('foo'),
panel: byTestId('security-hotspot-test'),
- codeTab: byRole('tab', { name: 'hotspots.tabs.code' }),
+ codeTab: byRole('tab', { name: /hotspots.tabs.code/ }),
codeContent: byRole('table'),
- riskTab: byRole('tab', { name: 'hotspots.tabs.risk_description' }),
+ riskTab: byRole('tab', { name: /hotspots.tabs.risk_description/ }),
riskContent: byText('Root cause'),
- vulnerabilityTab: byRole('tab', { name: 'hotspots.tabs.vulnerability_description' }),
+ vulnerabilityTab: byRole('tab', { name: /hotspots.tabs.vulnerability_description/ }),
vulnerabilityContent: byText('Assess'),
- fixTab: byRole('tab', { name: 'hotspots.tabs.fix_recommendations' }),
+ 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' }),
+ activityTab: byRole('tab', { name: /hotspots.tabs.activity/ }),
addCommentButton: byRole('button', { name: 'hotspots.status.add_comment' }),
};
+const originalScrollTo = window.scrollTo;
const hotspotsHandler = new SecurityHotspotServiceMock();
const rulesHandles = new CodingRulesServiceMock();
+beforeAll(() => {
+ Object.defineProperty(window, 'scrollTo', {
+ writable: true,
+ value: () => {
+ /* noop */
+ },
+ });
+});
+
+afterAll(() => {
+ Object.defineProperty(window, 'scrollTo', {
+ writable: true,
+ value: originalScrollTo,
+ });
+});
+
+beforeEach(() => {
+ jest.mocked(useScrollDownCompress).mockImplementation(() => ({
+ isScrolled: false,
+ isCompressed: false,
+ resetScrollDownCompress: jest.fn(),
+ }));
+});
+
afterEach(() => {
hotspotsHandler.reset();
rulesHandles.reset();
expect(ui.filterDropdown.get()).toBeInTheDocument();
expect(ui.filterToReview.get()).toBeInTheDocument();
});
+
+ it('should render hotspot header in sticky mode', async () => {
+ jest.mocked(useScrollDownCompress).mockImplementation(() => ({
+ isScrolled: true,
+ isCompressed: true,
+ resetScrollDownCompress: jest.fn(),
+ }));
+ renderSecurityHotspotsApp();
+
+ expect(await ui.reviewButton.find()).toBeInTheDocument();
+ expect(ui.activeAssignee.query()).not.toBeInTheDocument();
+ });
});
it('should navigate when comming from SonarLint', async () => {
expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument();
});
+ it('should be able to navigate between tabs with keyboard', async () => {
+ const user = userEvent.setup();
+ renderSecurityHotspotsApp();
+
+ await act(() => user.keyboard('{ArrowLeft}'));
+ expect(ui.codeContent.get()).toBeInTheDocument();
+
+ await act(() => user.keyboard('{ArrowRight}'));
+ expect(ui.riskContent.get()).toBeInTheDocument();
+
+ await act(() => user.keyboard('{ArrowRight}'));
+ expect(ui.vulnerabilityContent.get()).toBeInTheDocument();
+
+ await act(() => user.keyboard('{ArrowRight}'));
+ expect(ui.fixContent.get()).toBeInTheDocument();
+
+ await act(() => user.keyboard('{ArrowRight}'));
+ expect(ui.addCommentButton.get()).toBeInTheDocument();
+
+ await act(() => user.keyboard('{ArrowRight}'));
+ expect(ui.addCommentButton.get()).toBeInTheDocument();
+ });
+
it('should navigate when coming from SonarLint', async () => {
// On main branch
const rtl = renderSecurityHotspotsApp(
Link,
LinkIcon,
StyledPageTitle,
+ Theme,
themeColor,
+ themeShadow,
} from 'design-system';
import React from 'react';
import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting';
import { Component } from '../../../types/types';
import HotspotHeaderRightSection from './HotspotHeaderRightSection';
import Status from './status/Status';
+import StatusReviewButton from './status/StatusReviewButton';
export interface HotspotHeaderProps {
hotspot: Hotspot;
branchLike?: BranchLike;
standards?: Standards;
onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
+ tabs: React.ReactNode;
+ isScrolled: boolean;
+ isCompressed: boolean;
+}
+
+interface StyledHeaderProps {
+ isScrolled: boolean;
+ theme: Theme;
}
export function HotspotHeader(props: HotspotHeaderProps) {
- const { hotspot, component, branchLike, standards } = props;
+ const { hotspot, component, branchLike, standards, tabs, isCompressed, isScrolled } = props;
const { message, messageFormattings, rule, key } = hotspot;
const permalink = getPathUrlAsString(
const categoryStandard = standards?.[SecurityStandard.SONARSOURCE][rule.securityCategory]?.title;
- return (
- <Header
- className="sw-sticky sw--mx-6 sw--mt-6 sw-px-6 sw-pt-6 sw-z-filterbar-header"
- style={{ top: `${LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT - 2}px` }}
- >
- <div className="sw-flex sw-justify-between sw-gap-8 sw-mb-4 sw-pb-4">
+ const content = isCompressed ? (
+ <div className="sw-flex sw-justify-between">
+ {tabs}
+ <StatusReviewButton
+ hotspot={hotspot}
+ onStatusChange={(statusOption) => props.onUpdateHotspot(true, statusOption)}
+ />
+ </div>
+ ) : (
+ <>
+ <div className="sw-flex sw-justify-between sw-gap-8 sw-mb-4">
<div className="sw-flex-1">
<StyledPageTitle as="h2" className="sw-whitespace-normal sw-overflow-visible">
<LightPrimary>
/>
</div>
</div>
+ {tabs}
+ </>
+ );
+
+ return (
+ <Header
+ className="sw-sticky sw--mx-6 sw--mt-6 sw-px-6 sw-pt-6 sw-pb-4 sw-z-filterbar-header"
+ isScrolled={isScrolled}
+ >
+ {content}
</Header>
);
}
-const Header = withTheme(styled.div`
+const Header = withTheme(styled.div<StyledHeaderProps>`
background-color: ${themeColor('pageBlock')};
+ box-shadow: ${({ isScrolled }: StyledHeaderProps) => (isScrolled ? themeShadow('sm') : 'none')};
+ top: ${LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT - 2}px;
`);
import { Component } from '../../../types/types';
import { CurrentUser } from '../../../types/users';
import { RuleDescriptionSection } from '../../coding-rules/rule';
-import { HotspotHeader } from './HotspotHeader';
import HotspotReviewHistoryAndComments from './HotspotReviewHistoryAndComments';
import HotspotSnippetContainer from './HotspotSnippetContainer';
import './HotspotViewer.css';
{hotspot && (
<div className="sw-box-border sw-p-6">
- <HotspotHeader
- hotspot={hotspot}
- component={component}
- standards={standards}
- onUpdateHotspot={props.onUpdateHotspot}
- branchLike={branchLike}
- />
<HotspotViewerTabs
activityTabContent={
<HotspotReviewHistoryAndComments
selectedHotspotLocation={selectedHotspotLocation}
/>
}
+ component={component}
+ standards={standards}
+ onUpdateHotspot={props.onUpdateHotspot}
+ branchLike={branchLike}
hotspot={hotspot}
ruleDescriptionSections={ruleDescriptionSections}
- selectedHotspotLocation={selectedHotspotLocation}
/>
</div>
)}
import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
import { KeyboardKeys } from '../../../helpers/keycodes';
import { translate } from '../../../helpers/l10n';
-import { Hotspot } from '../../../types/security-hotspots';
+import { BranchLike } from '../../../types/branch-like';
+import { Standards } from '../../../types/security';
+import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
+import { Component } from '../../../types/types';
import { RuleDescriptionSection, RuleDescriptionSections } from '../../coding-rules/rule';
+import useScrollDownCompress from '../hooks/useScrollDownCompress';
+import { HotspotHeader } from './HotspotHeader';
interface Props {
activityTabContent: React.ReactNode;
codeTabContent: React.ReactNode;
hotspot: Hotspot;
ruleDescriptionSections?: RuleDescriptionSection[];
- selectedHotspotLocation?: number;
+ component: Component;
+ branchLike?: BranchLike;
+ onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
+ standards?: Standards;
}
-
-interface State {
- currentTab: Tab;
- tabs: Tab[];
-}
-
interface Tab {
value: TabKeys;
label: string;
+ counter?: number;
}
export enum TabKeys {
Activity = 'activity',
}
-export default class HotspotViewerTabs extends React.PureComponent<Props, State> {
- constructor(props: Props) {
- super(props);
- const tabs = this.computeTabs();
- this.state = {
- currentTab: tabs[0],
- tabs,
- };
- }
-
- componentDidMount() {
- this.registerKeyboardEvents();
- }
-
- componentDidUpdate(prevProps: Props) {
- if (this.props.hotspot.key !== prevProps.hotspot.key) {
- const tabs = this.computeTabs();
- this.setState({
- currentTab: tabs[0],
- tabs,
- });
- } else if (
- this.props.selectedHotspotLocation !== undefined &&
- this.props.selectedHotspotLocation !== prevProps.selectedHotspotLocation
- ) {
- const { tabs } = this.state;
- this.setState({
- currentTab: tabs[0],
- });
- }
- }
-
- componentWillUnmount() {
- this.unregisterKeyboardEvents();
- }
-
- handleKeyboardNavigation = (event: KeyboardEvent) => {
- if (isInput(event) || isShortcut(event)) {
- return true;
- }
- if (event.key === KeyboardKeys.LeftArrow) {
- event.preventDefault();
- this.selectNeighboringTab(-1);
- } else if (event.key === KeyboardKeys.RightArrow) {
- event.preventDefault();
- this.selectNeighboringTab(+1);
- }
- };
-
- registerKeyboardEvents() {
- document.addEventListener('keydown', this.handleKeyboardNavigation);
- }
-
- unregisterKeyboardEvents() {
- document.removeEventListener('keydown', this.handleKeyboardNavigation);
- }
-
- handleSelectTabs = (tabKey: TabKeys) => {
- const { tabs } = this.state;
- const currentTab = tabs.find((tab) => tab.value === tabKey);
- if (currentTab) {
- this.setState({ currentTab });
- }
- };
-
- computeTabs() {
- const { ruleDescriptionSections } = this.props;
+const STICKY_HEADER_SHADOW_OFFSET = 24;
+const STICKY_HEADER_COMPRESS_THRESHOLD = 200;
+
+export default function HotspotViewerTabs(props: Props) {
+ const {
+ ruleDescriptionSections,
+ codeTabContent,
+ activityTabContent,
+ hotspot,
+ component,
+ standards,
+ branchLike,
+ } = props;
+
+ const { isScrolled, isCompressed, resetScrollDownCompress } = useScrollDownCompress(
+ STICKY_HEADER_COMPRESS_THRESHOLD,
+ STICKY_HEADER_SHADOW_OFFSET
+ );
+
+ const tabs = React.useMemo(() => {
const descriptionSectionsByKey = groupBy(ruleDescriptionSections, (section) => section.key);
+ const labelSuffix = isCompressed ? '.short' : '';
return [
{
value: TabKeys.Code,
- label: translate('hotspots.tabs.code'),
+ label: translate(`hotspots.tabs.code${labelSuffix}`),
show: true,
},
{
value: TabKeys.RiskDescription,
- label: translate('hotspots.tabs.risk_description'),
+ label: translate(`hotspots.tabs.risk_description${labelSuffix}`),
show:
descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE],
},
{
value: TabKeys.VulnerabilityDescription,
- label: translate('hotspots.tabs.vulnerability_description'),
+ label: translate(`hotspots.tabs.vulnerability_description${labelSuffix}`),
show: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] !== undefined,
},
{
value: TabKeys.FixRecommendation,
- label: translate('hotspots.tabs.fix_recommendations'),
+ label: translate(`hotspots.tabs.fix_recommendations${labelSuffix}`),
show: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] !== undefined,
},
{
value: TabKeys.Activity,
- label: translate('hotspots.tabs.activity'),
+ label: translate(`hotspots.tabs.activity${labelSuffix}`),
show: true,
+ counter: hotspot.comment.length,
},
]
.filter((tab) => tab.show)
.map((tab) => omit(tab, 'show'));
- }
+ }, [isCompressed, ruleDescriptionSections, hotspot.comment]);
- selectNeighboringTab(shift: number) {
- this.setState(({ tabs, currentTab }) => {
+ const [currentTab, setCurrentTab] = React.useState<Tab>(tabs[0]);
+
+ const handleKeyboardNavigation = (event: KeyboardEvent) => {
+ if (isInput(event) || isShortcut(event)) {
+ return true;
+ }
+ if (event.key === KeyboardKeys.LeftArrow) {
+ event.preventDefault();
+ selectNeighboringTab(-1);
+ } else if (event.key === KeyboardKeys.RightArrow) {
+ event.preventDefault();
+ selectNeighboringTab(+1);
+ }
+ };
+
+ const selectNeighboringTab = (shift: number) => {
+ setCurrentTab((currentTab) => {
const index = currentTab && tabs.findIndex((tab) => tab.value === currentTab.value);
if (index !== undefined && index > -1) {
const newIndex = Math.max(0, Math.min(tabs.length - 1, index + shift));
- return {
- currentTab: tabs[newIndex],
- };
+ return tabs[newIndex];
}
- return { currentTab };
+ return currentTab;
});
- }
+ };
- render() {
- const { ruleDescriptionSections, codeTabContent, activityTabContent } = this.props;
- const { tabs, currentTab } = this.state;
+ const handleSelectTabs = (tabKey: TabKeys) => {
+ const currentTab = tabs.find((tab) => tab.value === tabKey);
+ if (currentTab) {
+ setCurrentTab(currentTab);
+ }
+ };
- const descriptionSectionsByKey = groupBy(ruleDescriptionSections, (section) => section.key);
- const rootCauseDescriptionSections =
- descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
- descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE];
-
- return (
- <>
- <ToggleButton
- role="tablist"
- value={currentTab.value}
- options={tabs}
- onChange={this.handleSelectTabs}
- />
- <div
- aria-labelledby={getTabId(currentTab.value)}
- className="sw-mt-6"
- id={getTabPanelId(currentTab.value)}
- role="tabpanel"
- >
- {currentTab.value === TabKeys.Code && codeTabContent}
-
- {currentTab.value === TabKeys.RiskDescription && rootCauseDescriptionSections && (
- <RuleDescription sections={rootCauseDescriptionSections} />
+ React.useEffect(() => {
+ document.addEventListener('keydown', handleKeyboardNavigation);
+
+ return () => document.removeEventListener('keydown', handleKeyboardNavigation);
+ }, []);
+
+ React.useEffect(() => {
+ setCurrentTab(tabs[0]);
+ }, [hotspot.key]);
+
+ React.useEffect(() => {
+ if (currentTab.value !== TabKeys.Code) {
+ window.scrollTo({ top: 0 });
+ }
+ resetScrollDownCompress();
+ }, [currentTab]);
+
+ const descriptionSectionsByKey = groupBy(ruleDescriptionSections, (section) => section.key);
+ const rootCauseDescriptionSections =
+ descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
+ descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE];
+
+ return (
+ <>
+ <HotspotHeader
+ hotspot={hotspot}
+ component={component}
+ standards={standards}
+ onUpdateHotspot={props.onUpdateHotspot}
+ branchLike={branchLike}
+ isScrolled={isScrolled}
+ isCompressed={isCompressed}
+ tabs={
+ <ToggleButton
+ role="tablist"
+ value={currentTab.value}
+ options={tabs}
+ onChange={handleSelectTabs}
+ />
+ }
+ />
+ <div
+ aria-labelledby={getTabId(currentTab.value)}
+ className="sw-mt-2"
+ id={getTabPanelId(currentTab.value)}
+ role="tabpanel"
+ >
+ {currentTab.value === TabKeys.Code && codeTabContent}
+
+ {currentTab.value === TabKeys.RiskDescription && rootCauseDescriptionSections && (
+ <RuleDescription sections={rootCauseDescriptionSections} />
+ )}
+
+ {currentTab.value === TabKeys.VulnerabilityDescription &&
+ descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && (
+ <RuleDescription
+ sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]}
+ />
+ )}
+
+ {currentTab.value === TabKeys.FixRecommendation &&
+ descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
+ <RuleDescription
+ sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
+ />
)}
- {currentTab.value === TabKeys.VulnerabilityDescription &&
- descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && (
- <RuleDescription
- sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]}
- />
- )}
-
- {currentTab.value === TabKeys.FixRecommendation &&
- descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
- <RuleDescription
- sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
- />
- )}
-
- {currentTab.value === TabKeys.Activity && activityTabContent}
- </div>
- </>
- );
- }
+ {currentTab.value === TabKeys.Activity && activityTabContent}
+ </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 { ButtonPrimary, HighlightedSection } from 'design-system';
+import { HighlightedSection } from 'design-system';
import * as React from 'react';
-import withCurrentUserContext from '../../../../app/components/current-user/withCurrentUserContext';
-import Tooltip from '../../../../components/controls/Tooltip';
-import { translate } from '../../../../helpers/l10n';
import { Hotspot, HotspotStatusOption } from '../../../../types/security-hotspots';
-import { CurrentUser, isLoggedIn } from '../../../../types/users';
import { getStatusOptionFromStatusAndResolution } from '../../utils';
import StatusDescription from './StatusDescription';
-import StatusSelection from './StatusSelection';
+import StatusReviewButton from './StatusReviewButton';
export interface StatusProps {
- currentUser: CurrentUser;
hotspot: Hotspot;
onStatusChange: (statusOption: HotspotStatusOption) => Promise<void>;
}
-export function Status(props: StatusProps) {
- const { currentUser, hotspot } = props;
+export default function Status(props: StatusProps) {
+ const { hotspot } = props;
- const [isOpen, setIsOpen] = React.useState(false);
const statusOption = getStatusOptionFromStatusAndResolution(hotspot.status, hotspot.resolution);
- const readonly = !hotspot.canChangeStatus || !isLoggedIn(currentUser);
return (
- <>
- <HighlightedSection className="sw-flex sw-rounded-1 sw-p-4 sw-items-center sw-justify-between sw-gap-2 sw-flex-row">
- <StatusDescription statusOption={statusOption} />
- <Tooltip
- overlay={readonly ? translate('hotspots.status.cannot_change_status') : null}
- placement="bottom"
- >
- <ButtonPrimary id="status-trigger" onClick={() => setIsOpen(true)} disabled={readonly}>
- {translate('hotspots.status.review')}
- </ButtonPrimary>
- </Tooltip>
- </HighlightedSection>
- {isOpen && (
- <StatusSelection
- hotspot={hotspot}
- onClose={() => setIsOpen(false)}
- onStatusOptionChange={props.onStatusChange}
- />
- )}
- </>
+ <HighlightedSection className="sw-flex sw-rounded-1 sw-p-4 sw-items-center sw-justify-between sw-gap-2 sw-flex-row">
+ <StatusDescription statusOption={statusOption} />
+ <StatusReviewButton hotspot={hotspot} onStatusChange={props.onStatusChange} />
+ </HighlightedSection>
);
}
-
-export default withCurrentUserContext(Status);
--- /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 { ButtonPrimary } from 'design-system';
+import * as React from 'react';
+import withCurrentUserContext from '../../../../app/components/current-user/withCurrentUserContext';
+import Tooltip from '../../../../components/controls/Tooltip';
+import { translate } from '../../../../helpers/l10n';
+import { Hotspot, HotspotStatusOption } from '../../../../types/security-hotspots';
+import { CurrentUser, isLoggedIn } from '../../../../types/users';
+import StatusSelection from './StatusSelection';
+
+export interface StatusProps {
+ currentUser: CurrentUser;
+ hotspot: Hotspot;
+ onStatusChange: (statusOption: HotspotStatusOption) => Promise<void>;
+}
+
+export function StatusReviewButton(props: StatusProps) {
+ const { currentUser, hotspot } = props;
+
+ const [isOpen, setIsOpen] = React.useState(false);
+ const readonly = !hotspot.canChangeStatus || !isLoggedIn(currentUser);
+
+ return (
+ <>
+ <Tooltip
+ overlay={readonly ? translate('hotspots.status.cannot_change_status') : null}
+ placement="bottom"
+ >
+ <ButtonPrimary id="status-trigger" onClick={() => setIsOpen(true)} disabled={readonly}>
+ {translate('hotspots.status.review')}
+ </ButtonPrimary>
+ </Tooltip>
+
+ {isOpen && (
+ <StatusSelection
+ hotspot={hotspot}
+ onClose={() => setIsOpen(false)}
+ onStatusOptionChange={props.onStatusChange}
+ />
+ )}
+ </>
+ );
+}
+
+export default withCurrentUserContext(StatusReviewButton);
--- /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 { fireEvent, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { renderComponent } from '../../../../helpers/testReactTestingUtils';
+import useScrollDownCompress from '../useScrollDownCompress';
+
+beforeEach(() => {
+ Object.defineProperties(window.document.documentElement, {
+ clientHeight: { value: 500, configurable: true },
+ scrollHeight: { value: 1000, configurable: true },
+ scrollTop: { value: 0, configurable: true },
+ });
+});
+
+it('set isScrolled and isCompressed to true when scrolling down', async () => {
+ renderComponent(<X />);
+
+ expect(screen.getByText('isScrolled: false')).toBeVisible();
+ expect(screen.getByText('isCompressed: false')).toBeVisible();
+
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 200, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: true')).toBeVisible();
+ expect(await screen.findByText('isCompressed: false')).toBeVisible();
+
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 250, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: true')).toBeVisible();
+ expect(await screen.findByText('isCompressed: true')).toBeVisible();
+
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 260, configurable: true },
+ scrollHeight: { value: 800, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: true')).toBeVisible();
+ expect(await screen.findByText('isCompressed: true')).toBeVisible();
+
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 5, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: true')).toBeVisible();
+ expect(await screen.findByText('isCompressed: false')).toBeVisible();
+
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 5, configurable: true },
+ scrollHeight: { value: 1000, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: false')).toBeVisible();
+ expect(await screen.findByText('isCompressed: false')).toBeVisible();
+});
+
+it('reset the scroll state', async () => {
+ const user = userEvent.setup();
+ renderComponent(<X />);
+
+ expect(screen.getByText('isScrolled: false')).toBeVisible();
+ expect(screen.getByText('isCompressed: false')).toBeVisible();
+
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 200, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 250, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: true')).toBeVisible();
+ expect(await screen.findByText('isCompressed: true')).toBeVisible();
+
+ await user.click(screen.getByText('reset Compress'));
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 300, configurable: true },
+ });
+ await fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: true')).toBeVisible();
+ expect(await screen.findByText('isCompressed: false')).toBeVisible();
+});
+
+it('keep the compressed state if scroll dont move', async () => {
+ renderComponent(<X />);
+
+ expect(screen.getByText('isScrolled: false')).toBeVisible();
+ expect(screen.getByText('isCompressed: false')).toBeVisible();
+
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 200, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ Object.defineProperties(window.document.documentElement, {
+ scrollTop: { value: 250, configurable: true },
+ });
+ fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: true')).toBeVisible();
+ expect(await screen.findByText('isCompressed: true')).toBeVisible();
+
+ fireEvent.scroll(window.document);
+ expect(await screen.findByText('isScrolled: true')).toBeVisible();
+ expect(await screen.findByText('isCompressed: true')).toBeVisible();
+});
+
+function X() {
+ const { isScrolled, isCompressed, resetScrollDownCompress } = useScrollDownCompress(100, 10);
+
+ return (
+ <div>
+ <div>isScrolled: {`${isScrolled}`}</div>
+ <div>isCompressed: {`${isCompressed}`}</div>
+ <button onClick={resetScrollDownCompress} type="button">
+ reset Compress
+ </button>
+ </div>
+ );
+}
--- /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 { throttle } from 'lodash';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+const THROTTLE_LONG_DELAY = 100;
+
+export default function useScrollDownCompress(compressThreshold: number, scrollThreshold: number) {
+ const [isCompressed, setIsCompressed] = useState(false);
+ const [isScrolled, setIsScrolled] = useState(
+ () => document?.documentElement?.scrollTop > scrollThreshold
+ );
+
+ const initialScrollHeightRef = useRef<number | undefined>(undefined);
+ const scrollTopRef = useRef<number | undefined>(undefined);
+
+ useEffect(() => {
+ const handleScroll = throttle(() => {
+ // Save the initial scrollHeight of the document
+ const scrollHeight = document?.documentElement?.scrollHeight;
+ initialScrollHeightRef.current = Math.max(initialScrollHeightRef.current ?? 0, scrollHeight);
+
+ // Compute the scrollTop value relative to the initial scrollHeight.
+ // The scrollHeight value changes when we compress the header - influencing the scrollTop value
+ const relativeScrollTop =
+ document?.documentElement?.scrollTop + (initialScrollHeightRef.current - scrollHeight);
+
+ if (
+ // First scroll means we just loaded the page or changed tab, in this case we shouldn't compress
+ scrollTopRef.current === undefined ||
+ // We also shouldn't compress if the size of the document wouldn't have a scroll after being compressed
+ initialScrollHeightRef.current - document?.documentElement?.clientHeight < compressThreshold
+ ) {
+ setIsCompressed(false);
+
+ // We shouldn't change the compressed flag if the scrollTop value didn't change
+ } else if (relativeScrollTop !== scrollTopRef.current) {
+ // Compress when scrolling in down direction and we are scrolled more than a threshold
+ setIsCompressed(
+ relativeScrollTop > scrollTopRef.current && relativeScrollTop > scrollThreshold
+ );
+ }
+
+ // Should display the shadow when we are scrolled more than a small threshold
+ setIsScrolled(relativeScrollTop > scrollThreshold);
+
+ // Save the last scroll position to compare it with the next one and infer the directions
+ scrollTopRef.current = relativeScrollTop;
+ }, THROTTLE_LONG_DELAY);
+
+ document.addEventListener('scroll', handleScroll);
+ return () => document.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ const resetScrollDownCompress = useCallback(() => {
+ initialScrollHeightRef.current = undefined;
+ scrollTopRef.current = undefined;
+ }, []);
+
+ return { isCompressed, isScrolled, resetScrollDownCompress };
+}
hotspots.tabs.vulnerability_description=Assess the risk
hotspots.tabs.fix_recommendations=How can I fix it?
hotspots.tabs.activity=Activity
+hotspots.tabs.code.short=Where
+hotspots.tabs.risk_description.short=What
+hotspots.tabs.vulnerability_description.short=Assess
+hotspots.tabs.fix_recommendations.short=How
+hotspots.tabs.activity.short=Activity
hotspots.review_history.created=created Security Hotspot
hotspots.review_history.comment_added=added a comment
hotspots.comment.field=Comment: