]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20496 Make smooth ecperience of security hotspots header when scroll
authorstanislavh <stanislav.honcharov@sonarsource.com>
Tue, 19 Sep 2023 09:40:30 +0000 (11:40 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 20 Sep 2023 20:02:50 +0000 (20:02 +0000)
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx
server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useScrollDownCompress-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useStickyDetection-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/hooks/useScrollDownCompress.ts [deleted file]
server/sonar-web/src/main/js/apps/security-hotspots/hooks/useStickyDetection.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/testUtils.ts

index 37f9c91ee5ab8f07e453a012b3408be7c2047382..a7fbd4f325e9a3e87ed2363e02b1f06a9ec44173 100644 (file)
@@ -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);
   });
 });
 
index 0d3d6d013bb9535632758f8932c6f07967ac2b9f..0579f88f06b099885a81ab9d7207eb96314e1217 100644 (file)
  * 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<void>;
-  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 ? (
-    <span>
-      <div className="sw-flex sw-justify-between">
-        {tabs}
-
-        <StatusReviewButton hotspot={hotspot} onStatusChange={handleStatusChange} />
-      </div>
-
-      {isCodeTab && (
-        <HotspotSnippetHeader hotspot={hotspot} component={component} branchLike={branchLike} />
-      )}
-    </span>
-  ) : (
-    <>
-      <div className="sw-flex sw-justify-between sw-gap-8 sw-mb-4">
+  return (
+    <div>
+      <div className="sw-flex sw-justify-between sw-gap-8 hotspot-header">
         <div className="sw-flex-1">
           <StyledPageTitle as="h2" className="sw-whitespace-normal sw-overflow-visible">
             <LightPrimary>
@@ -134,26 +103,6 @@ export function HotspotHeader(props: HotspotHeaderProps) {
           />
         </div>
       </div>
-      {tabs}
-
-      {isCodeTab && (
-        <HotspotSnippetHeader hotspot={hotspot} component={component} branchLike={branchLike} />
-      )}
-    </>
-  );
-
-  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>
+    </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}px;
-`);
index 4edfc35fe77b003e097d53bd29278cd943c55a92..1512711b0dd2f5659b08e28f55e2c47a7ba1b80c 100644 (file)
@@ -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 && (
         <div className="sw-box-border sw-p-6">
+          <HotspotHeader
+            branchLike={branchLike}
+            component={component}
+            hotspot={hotspot}
+            onUpdateHotspot={props.onUpdateHotspot}
+            standards={standards}
+          />
           <HotspotViewerTabs
             activityTabContent={
               <HotspotReviewHistoryAndComments
@@ -92,6 +101,7 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
               />
             }
             branchLike={branchLike}
+            component={component}
             codeTabContent={
               <HotspotSnippetContainer
                 branchLike={branchLike}
@@ -101,12 +111,10 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
                 selectedHotspotLocation={selectedHotspotLocation}
               />
             }
-            component={component}
             hotspot={hotspot}
             onUpdateHotspot={props.onUpdateHotspot}
             ruleDescriptionSections={ruleDescriptionSections}
             ruleLanguage={ruleLanguage}
-            standards={standards}
           />
         </div>
       )}
index a28ac8bff97826ba78724a76e07135c032cbea93..7594a22dfaf458ff31768c84cafabe79e9de5cd1 100644 (file)
  * 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<void>;
   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<Tab>(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 (
     <>
-      <HotspotHeader
-        branchLike={branchLike}
-        component={component}
-        hotspot={hotspot}
-        isCodeTab={currentTab.value === TabKeys.Code}
-        isCompressed={isCompressed}
-        isScrolled={isScrolled}
-        onUpdateHotspot={props.onUpdateHotspot}
-        standards={standards}
-        tabs={
+      <StickyTabs
+        isSticky={isSticky}
+        top={TABS_OFFSET}
+        className="sw-sticky sw-py-4 sw--mx-6 sw-px-6 sw-z-filterbar-header hotspot-tabs"
+      >
+        <div className="sw-flex sw-justify-between">
           <ToggleButton
             role="tablist"
             value={currentTab.value}
             options={tabs}
             onChange={handleSelectTabs}
           />
-        }
-      />
+          {isSticky && <StatusReviewButton hotspot={hotspot} onStatusChange={handleStatusChange} />}
+        </div>
+        {currentTab.value === TabKeys.Code && codeTabContent && (
+          <HotspotSnippetHeader hotspot={hotspot} component={component} branchLike={branchLike} />
+        )}
+      </StickyTabs>
       <div
         aria-labelledby={getTabId(currentTab.value)}
         className="sw-mt-2"
@@ -233,3 +244,9 @@ export default function HotspotViewerTabs(props: Props) {
     </>
   );
 }
+
+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 (file)
index ed999b5..0000000
+++ /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(<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>
-  );
-}
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 (file)
index 0000000..7fe95e0
--- /dev/null
@@ -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(<StickyComponent />);
+
+  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 (
+    <div className="target">
+      <h1>{isSticky ? 'sticky' : 'static'}</h1>
+    </div>
+  );
+}
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 (file)
index 8d96cd6..0000000
+++ /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<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 };
-}
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 (file)
index 0000000..be728a4
--- /dev/null
@@ -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;
+}
index cb196db2fb0f787e96225274b62efefca9ada783..3c4cf33479fd457c750f18d960f6ee8916f037e7 100644 (file)
@@ -158,3 +158,30 @@ export async function waitAndUpdate(wrapper: ShallowWrapper<any, any> | 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]);
+}