From: stanislavh Date: Tue, 23 May 2023 08:27:11 +0000 (+0200) Subject: SONAR-19236 Add sticky header X-Git-Tag: 10.1.0.73491~222 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=b3d5921c2a12af2f260f5887a347ba9ca4e74898;p=sonarqube.git SONAR-19236 Add sticky header --- diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx index ed641478c1e..ab588ac859e 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx @@ -32,6 +32,7 @@ import { mockLoggedInUser } from '../../../helpers/testMocks'; 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'); @@ -42,6 +43,7 @@ jest.mock('../../../api/users'); 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' }), @@ -70,22 +72,47 @@ const ui = { 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(); @@ -114,6 +141,18 @@ describe('rendering', () => { 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 () => { @@ -264,6 +303,29 @@ describe('navigation', () => { 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( diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx index 12a5189b10d..68a89666f6b 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx @@ -28,7 +28,9 @@ import { Link, LinkIcon, StyledPageTitle, + Theme, themeColor, + themeShadow, } from 'design-system'; import React from 'react'; import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting'; @@ -44,6 +46,7 @@ import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots'; import { Component } from '../../../types/types'; import HotspotHeaderRightSection from './HotspotHeaderRightSection'; import Status from './status/Status'; +import StatusReviewButton from './status/StatusReviewButton'; export interface HotspotHeaderProps { hotspot: Hotspot; @@ -51,10 +54,18 @@ export interface HotspotHeaderProps { branchLike?: BranchLike; standards?: Standards; onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise; + 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( @@ -67,12 +78,17 @@ export function HotspotHeader(props: HotspotHeaderProps) { const categoryStandard = standards?.[SecurityStandard.SONARSOURCE][rule.securityCategory]?.title; - return ( -
-
+ const content = isCompressed ? ( +
+ {tabs} + props.onUpdateHotspot(true, statusOption)} + /> +
+ ) : ( + <> +
@@ -104,10 +120,22 @@ export function HotspotHeader(props: HotspotHeaderProps) { />
+ {tabs} + + ); + + return ( +
+ {content}
); } -const Header = withTheme(styled.div` +const Header = withTheme(styled.div` background-color: ${themeColor('pageBlock')}; + box-shadow: ${({ isScrolled }: StyledHeaderProps) => (isScrolled ? themeShadow('sm') : 'none')}; + top: ${LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT - 2}px; `); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx index 9ed8c034d79..e1db5420cf3 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx @@ -26,7 +26,6 @@ import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots'; 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'; @@ -63,13 +62,6 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) { {hotspot && (
- } + component={component} + standards={standards} + onUpdateHotspot={props.onUpdateHotspot} + branchLike={branchLike} hotspot={hotspot} ruleDescriptionSections={ruleDescriptionSections} - selectedHotspotLocation={selectedHotspotLocation} />
)} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx index 8689e38abe5..23e62039ee8 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx @@ -24,25 +24,28 @@ import RuleDescription from '../../../components/rules/RuleDescription'; 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; + standards?: Standards; } - -interface State { - currentTab: Tab; - tabs: Tab[]; -} - interface Tab { value: TabKeys; label: string; + counter?: number; } export enum TabKeys { @@ -53,169 +56,167 @@ export enum TabKeys { Activity = 'activity', } -export default class HotspotViewerTabs extends React.PureComponent { - 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(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 ( - <> - -
- {currentTab.value === TabKeys.Code && codeTabContent} - - {currentTab.value === TabKeys.RiskDescription && 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 ( + <> + + } + /> +
+ {currentTab.value === TabKeys.Code && codeTabContent} + + {currentTab.value === TabKeys.RiskDescription && rootCauseDescriptionSections && ( + + )} + + {currentTab.value === TabKeys.VulnerabilityDescription && + descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( + + )} + + {currentTab.value === TabKeys.FixRecommendation && + descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( + )} - {currentTab.value === TabKeys.VulnerabilityDescription && - descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( - - )} - - {currentTab.value === TabKeys.FixRecommendation && - descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( - - )} - - {currentTab.value === TabKeys.Activity && activityTabContent} -
- - ); - } + {currentTab.value === TabKeys.Activity && activityTabContent} +
+ + ); } diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/status/Status.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/status/Status.tsx index faa2f423809..e12ed8f1f71 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/status/Status.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/status/Status.tsx @@ -17,52 +17,27 @@ * 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; } -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 ( - <> - - - - setIsOpen(true)} disabled={readonly}> - {translate('hotspots.status.review')} - - - - {isOpen && ( - setIsOpen(false)} - onStatusOptionChange={props.onStatusChange} - /> - )} - + + + + ); } - -export default withCurrentUserContext(Status); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusReviewButton.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusReviewButton.tsx new file mode 100644 index 00000000000..059add332b1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusReviewButton.tsx @@ -0,0 +1,64 @@ +/* + * 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; +} + +export function StatusReviewButton(props: StatusProps) { + const { currentUser, hotspot } = props; + + const [isOpen, setIsOpen] = React.useState(false); + const readonly = !hotspot.canChangeStatus || !isLoggedIn(currentUser); + + return ( + <> + + setIsOpen(true)} disabled={readonly}> + {translate('hotspots.status.review')} + + + + {isOpen && ( + setIsOpen(false)} + onStatusOptionChange={props.onStatusChange} + /> + )} + + ); +} + +export default withCurrentUserContext(StatusReviewButton); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useScrollDownCompress-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useScrollDownCompress-test.tsx new file mode 100644 index 00000000000..ed999b5888e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useScrollDownCompress-test.tsx @@ -0,0 +1,139 @@ +/* + * 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(); + + 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(); + + 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(); + + 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 ( +
+
isScrolled: {`${isScrolled}`}
+
isCompressed: {`${isCompressed}`}
+ +
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/hooks/useScrollDownCompress.ts b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/useScrollDownCompress.ts new file mode 100644 index 00000000000..12ce05b1705 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/useScrollDownCompress.ts @@ -0,0 +1,78 @@ +/* + * 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(undefined); + const scrollTopRef = useRef(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 }; +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 32fc9b56d69..f9438870a58 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -800,6 +800,11 @@ hotspots.tabs.risk_description=What's the risk? 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: