From 34634622b2061350fd56bd59f75d0e3a7d20dd3d Mon Sep 17 00:00:00 2001 From: stanislavh Date: Tue, 19 Sep 2023 11:40:30 +0200 Subject: [PATCH] SONAR-20496 Make smooth ecperience of security hotspots header when scroll --- .../__tests__/SecurityHotspotsApp-it.tsx | 19 +-- .../components/HotspotHeader.tsx | 61 +------- .../components/HotspotViewerRenderer.tsx | 12 +- .../components/HotspotViewerTabs.tsx | 79 ++++++---- .../__tests__/useScrollDownCompress-test.tsx | 139 ------------------ .../__tests__/useStickyDetection-test.tsx | 53 +++++++ .../hooks/useScrollDownCompress.ts | 78 ---------- .../hooks/useStickyDetection.ts | 71 +++++++++ .../src/main/js/helpers/testUtils.ts | 27 ++++ 9 files changed, 219 insertions(+), 320 deletions(-) delete mode 100644 server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useScrollDownCompress-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useStickyDetection-test.tsx delete mode 100644 server/sonar-web/src/main/js/apps/security-hotspots/hooks/useScrollDownCompress.ts create mode 100644 server/sonar-web/src/main/js/apps/security-hotspots/hooks/useStickyDetection.ts 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 37f9c91ee5a..a7fbd4f325e 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 @@ -36,7 +36,7 @@ import { byDisplayValue, byRole, byTestId, byText } from '../../../helpers/testS import { ComponentContextShape } from '../../../types/component'; import { MetricKey } from '../../../types/metrics'; import SecurityHotspotsApp from '../SecurityHotspotsApp'; -import useScrollDownCompress from '../hooks/useScrollDownCompress'; +import useStickyDetection from '../hooks/useStickyDetection'; jest.mock('../../../api/measures'); jest.mock('../../../api/security-hotspots'); @@ -47,7 +47,7 @@ jest.mock('../../../api/users'); jest.mock('../../../api/rules'); jest.mock('../../../api/quality-profiles'); jest.mock('../../../api/issues'); -jest.mock('../hooks/useScrollDownCompress'); +jest.mock('../hooks/useStickyDetection'); jest.mock('../../../helpers/sonarlint', () => ({ openHotspot: jest.fn().mockResolvedValue(null), probeSonarLintServers: jest.fn().mockResolvedValue([ @@ -143,11 +143,7 @@ afterAll(() => { }); beforeEach(() => { - jest.mocked(useScrollDownCompress).mockImplementation(() => ({ - isScrolled: false, - isCompressed: false, - resetScrollDownCompress: jest.fn(), - })); + jest.mocked(useStickyDetection).mockImplementation(() => false); }); afterEach(() => { @@ -186,16 +182,11 @@ describe('rendering', () => { }); it('should render hotspot header in sticky mode', async () => { - jest.mocked(useScrollDownCompress).mockImplementation(() => ({ - isScrolled: true, - isCompressed: true, - resetScrollDownCompress: jest.fn(), - })); + jest.mocked(useStickyDetection).mockImplementation(() => true); renderSecurityHotspotsApp(); - expect(await ui.reviewButton.find()).toBeInTheDocument(); - expect(ui.activeAssignee.query()).not.toBeInTheDocument(); + expect(await ui.reviewButton.findAll()).toHaveLength(2); }); }); 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 0d3d6d013bb..0579f88f06b 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 @@ -18,21 +18,14 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { withTheme } from '@emotion/react'; -import styled from '@emotion/styled'; import { ClipboardIconButton, IssueMessageHighlighting, - LAYOUT_GLOBAL_NAV_HEIGHT, - LAYOUT_PROJECT_NAV_HEIGHT, LightLabel, LightPrimary, Link, LinkIcon, StyledPageTitle, - Theme, - themeColor, - themeShadow, } from 'design-system'; import React from 'react'; import { getBranchLikeQuery } from '../../../helpers/branch-like'; @@ -48,30 +41,18 @@ import { SecurityStandard, Standards } from '../../../types/security'; import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots'; import { Component } from '../../../types/types'; import HotspotHeaderRightSection from './HotspotHeaderRightSection'; -import HotspotSnippetHeader from './HotspotSnippetHeader'; import Status from './status/Status'; -import StatusReviewButton from './status/StatusReviewButton'; export interface HotspotHeaderProps { hotspot: Hotspot; component: Component; branchLike?: BranchLike; - isCodeTab?: boolean; 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 { branchLike, component, hotspot, isCodeTab, isCompressed, isScrolled, standards, tabs } = - props; + const { branchLike, component, hotspot, standards } = props; const { message, messageFormattings, rule, key } = hotspot; const refrechBranchStatus = useRefreshBranchStatus(); @@ -89,21 +70,9 @@ export function HotspotHeader(props: HotspotHeaderProps) { refrechBranchStatus(); }; - const content = isCompressed ? ( - -
- {tabs} - - -
- - {isCodeTab && ( - - )} -
- ) : ( - <> -
+ return ( +
+
@@ -134,26 +103,6 @@ export function HotspotHeader(props: HotspotHeaderProps) { />
- {tabs} - - {isCodeTab && ( - - )} - - ); - - return ( -
- {content} -
+
); } - -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}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 4edfc35fe77..1512711b0dd 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 @@ -25,6 +25,8 @@ import { fillBranchLike } from '../../../helpers/branch-like'; import { Standards } from '../../../types/security'; import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots'; import { Component } from '../../../types/types'; +import { HotspotHeader } from './HotspotHeader'; + import { CurrentUser } from '../../../types/users'; import { RuleDescriptionSection } from '../../coding-rules/rule'; import HotspotReviewHistoryAndComments from './HotspotReviewHistoryAndComments'; @@ -83,6 +85,13 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) { {hotspot && (
+ } branchLike={branchLike} + component={component} codeTabContent={ } - component={component} hotspot={hotspot} onUpdateHotspot={props.onUpdateHotspot} ruleDescriptionSections={ruleDescriptionSections} ruleLanguage={ruleLanguage} - standards={standards} />
)} 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 a28ac8bff97..7594a22dfaf 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 @@ -18,31 +18,40 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { ToggleButton, getTabId, getTabPanelId } from 'design-system'; +import styled from '@emotion/styled'; +import { + LAYOUT_GLOBAL_NAV_HEIGHT, + LAYOUT_PROJECT_NAV_HEIGHT, + ToggleButton, + getTabId, + getTabPanelId, + themeColor, + themeShadow, +} from 'design-system'; import { groupBy, omit } from 'lodash'; import * as React from 'react'; import RuleDescription from '../../../components/rules/RuleDescription'; import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; import { KeyboardKeys } from '../../../helpers/keycodes'; import { translate } from '../../../helpers/l10n'; +import { useRefreshBranchStatus } from '../../../queries/branch'; 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'; +import useStickyDetection from '../hooks/useStickyDetection'; +import HotspotSnippetHeader from './HotspotSnippetHeader'; +import StatusReviewButton from './status/StatusReviewButton'; interface Props { activityTabContent: React.ReactNode; - branchLike?: BranchLike; codeTabContent: React.ReactNode; - component: Component; hotspot: Hotspot; onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise; ruleDescriptionSections?: RuleDescriptionSection[]; ruleLanguage?: string; - standards?: Standards; + component: Component; + branchLike?: BranchLike; } interface Tab { @@ -59,29 +68,27 @@ export enum TabKeys { Activity = 'activity', } -const STICKY_HEADER_SHADOW_OFFSET = 24; -const STICKY_HEADER_COMPRESS_THRESHOLD = 200; +const TABS_OFFSET = LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT; export default function HotspotViewerTabs(props: Props) { const { activityTabContent, - branchLike, codeTabContent, - component, hotspot, ruleDescriptionSections, ruleLanguage, - standards, + component, + branchLike, } = props; - const { isScrolled, isCompressed, resetScrollDownCompress } = useScrollDownCompress( - STICKY_HEADER_COMPRESS_THRESHOLD, - STICKY_HEADER_SHADOW_OFFSET, - ); + const refrechBranchStatus = useRefreshBranchStatus(); + const isSticky = useStickyDetection('.hotspot-tabs', { + offset: TABS_OFFSET, + }); const tabs = React.useMemo(() => { const descriptionSectionsByKey = groupBy(ruleDescriptionSections, (section) => section.key); - const labelSuffix = isCompressed ? '.short' : ''; + const labelSuffix = isSticky ? '.short' : ''; return [ { @@ -115,7 +122,7 @@ export default function HotspotViewerTabs(props: Props) { ] .filter((tab) => tab.show) .map((tab) => omit(tab, 'show')); - }, [isCompressed, ruleDescriptionSections, hotspot.comment]); + }, [isSticky, ruleDescriptionSections, hotspot.comment]); const [currentTab, setCurrentTab] = React.useState(tabs[0]); @@ -154,6 +161,11 @@ export default function HotspotViewerTabs(props: Props) { } }; + const handleStatusChange = async (statusOption: HotspotStatusOption) => { + await props.onUpdateHotspot(true, statusOption); + refrechBranchStatus(); + }; + React.useEffect(() => { document.addEventListener('keydown', handleKeyboardNavigation); @@ -170,7 +182,6 @@ export default function HotspotViewerTabs(props: Props) { if (currentTab.value !== TabKeys.Code) { window.scrollTo({ top: 0 }); } - resetScrollDownCompress(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTab]); @@ -182,24 +193,24 @@ export default function HotspotViewerTabs(props: Props) { return ( <> - +
- } - /> + {isSticky && } +
+ {currentTab.value === TabKeys.Code && codeTabContent && ( + + )} +
); } + +const StickyTabs = styled.div<{ top: number; isSticky: boolean }>` + background-color: ${themeColor('pageBlock')}; + box-shadow: ${({ isSticky }) => (isSticky ? themeShadow('sm') : 'none')}; + top: ${({ top }) => top}px; +`; 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 deleted file mode 100644 index ed999b5888e..00000000000 --- a/server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useScrollDownCompress-test.tsx +++ /dev/null @@ -1,139 +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 { 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/__tests__/useStickyDetection-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useStickyDetection-test.tsx new file mode 100644 index 00000000000..7fe95e012e8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useStickyDetection-test.tsx @@ -0,0 +1,53 @@ +/* + * 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 { act } from '@testing-library/react'; +import React from 'react'; +import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { byRole } from '../../../../helpers/testSelector'; +import { mockIntersectionObserver } from '../../../../helpers/testUtils'; +import useStickyDetection from '../useStickyDetection'; + +it('should render correctly based on intersection callback', () => { + const intersect = mockIntersectionObserver(); + renderComponent(); + + expect(byRole('heading', { name: 'static' }).get()).toBeInTheDocument(); + + act(() => { + intersect({ + isIntersecting: false, + intersectionRatio: 0.99, + boundingClientRect: { top: 1 }, + intersectionRect: { top: 0 }, + }); + }); + + expect(byRole('heading', { name: 'sticky' }).get()).toBeInTheDocument(); +}); + +function StickyComponent() { + const isSticky = useStickyDetection('.target', { offset: 0 }); + + return ( +
+

{isSticky ? 'sticky' : 'static'}

+
+ ); +} 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 deleted file mode 100644 index 8d96cd68d0a..00000000000 --- a/server/sonar-web/src/main/js/apps/security-hotspots/hooks/useScrollDownCompress.ts +++ /dev/null @@ -1,78 +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 { 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/server/sonar-web/src/main/js/apps/security-hotspots/hooks/useStickyDetection.ts b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/useStickyDetection.ts new file mode 100644 index 00000000000..be728a429b7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/useStickyDetection.ts @@ -0,0 +1,71 @@ +/* + * 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 { useEffect, useState } from 'react'; + +interface Options { + offset: number; + direction?: 'HORIZONTAL' | 'VERTICAL'; +} + +/* + * Detects if sticky element is out of viewport + */ +export default function useStickyDetection(target: string, options: Options) { + const { offset, direction = 'VERTICAL' } = options; + const [isSticky, setIsSticky] = useState(false); + + useEffect(() => { + const rootMargin = + direction === 'VERTICAL' ? `${-offset - 1}px 0px 0px 0px` : `0px 0px 0px ${-offset - 1}px`; + + const observer = new IntersectionObserver( + ([e]) => { + setIsSticky(e.intersectionRatio < 1 && elementIntersectedByDirection(e, direction)); + }, + // -1px moves viewport by direction that allows to detect when element became sticky and + // fully visible in viewport + { threshold: [1], rootMargin }, + ); + + const element = document.querySelector(target); + + if (element) { + observer.observe(element); + } + + return () => { + if (element) { + observer.unobserve(element); + } + }; + }, [target, setIsSticky, direction, offset]); + + return isSticky; +} + +function elementIntersectedByDirection( + e: IntersectionObserverEntry, + direction: 'VERTICAL' | 'HORIZONTAL', +) { + const { boundingClientRect, intersectionRect } = e; + const prop = direction === 'VERTICAL' ? 'top' : 'right'; + + return boundingClientRect[prop] - intersectionRect[prop] !== 0; +} diff --git a/server/sonar-web/src/main/js/helpers/testUtils.ts b/server/sonar-web/src/main/js/helpers/testUtils.ts index cb196db2fb0..3c4cf33479f 100644 --- a/server/sonar-web/src/main/js/helpers/testUtils.ts +++ b/server/sonar-web/src/main/js/helpers/testUtils.ts @@ -158,3 +158,30 @@ export async function waitAndUpdate(wrapper: ShallowWrapper | ReactWra await new Promise(setImmediate); wrapper.update(); } + +export function mockIntersectionObserver(): Function { + let callback: Function; + + // @ts-ignore + global.IntersectionObserver = jest.fn((cb: Function) => { + const instance = { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + }; + + callback = cb; + + callback([ + { + isIntersecting: true, + intersectionRatio: 1, + boundingClientRect: { top: 0 }, + intersectionRect: { top: 0 }, + }, + ]); + return instance; + }); + + return (entry: IntersectionObserverEntry) => callback([entry]); +} -- 2.39.5