]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19236 Add sticky header
authorstanislavh <stanislav.honcharov@sonarsource.com>
Tue, 23 May 2023 08:27:11 +0000 (10:27 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 24 May 2023 20:03:14 +0000 (20:03 +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/components/status/Status.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusReviewButton.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/hooks/__tests__/useScrollDownCompress-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/hooks/useScrollDownCompress.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index ed641478c1ef4558afb7d8d71271212e99fb096b..ab588ac859ec442af157fb90c0111a77e457838f 100644 (file)
@@ -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(
index 12a5189b10d6bf9c22a896c8b865197350129e1e..68a89666f6bedb3e9b7bae58ca71d910d50e3498 100644 (file)
@@ -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<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(
@@ -67,12 +78,17 @@ export function HotspotHeader(props: HotspotHeaderProps) {
 
   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>
@@ -104,10 +120,22 @@ export function HotspotHeader(props: HotspotHeaderProps) {
           />
         </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;
 `);
index 9ed8c034d79ada1e0c43ba95e87a4d59b224cc3e..e1db5420cf327c8e1daf5645518786554693688e 100644 (file)
@@ -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 && (
         <div className="sw-box-border sw-p-6">
-          <HotspotHeader
-            hotspot={hotspot}
-            component={component}
-            standards={standards}
-            onUpdateHotspot={props.onUpdateHotspot}
-            branchLike={branchLike}
-          />
           <HotspotViewerTabs
             activityTabContent={
               <HotspotReviewHistoryAndComments
@@ -87,9 +79,12 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
                 selectedHotspotLocation={selectedHotspotLocation}
               />
             }
+            component={component}
+            standards={standards}
+            onUpdateHotspot={props.onUpdateHotspot}
+            branchLike={branchLike}
             hotspot={hotspot}
             ruleDescriptionSections={ruleDescriptionSections}
-            selectedHotspotLocation={selectedHotspotLocation}
           />
         </div>
       )}
index 8689e38abe57280f41bc88094b1003dcfa1ac4f8..23e62039ee820b7de2a3bd0ab3c9226c2d54da95 100644 (file)
@@ -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<void>;
+  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<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>
+    </>
+  );
 }
index faa2f4238093508fedc1c6f9fcc32e0c3f75d3a6..e12ed8f1f719d3f61d2a9e9bb760e2a9a232bef3 100644 (file)
  * 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);
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 (file)
index 0000000..059add3
--- /dev/null
@@ -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<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);
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 (file)
index 0000000..ed999b5
--- /dev/null
@@ -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(<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/useScrollDownCompress.ts b/server/sonar-web/src/main/js/apps/security-hotspots/hooks/useScrollDownCompress.ts
new file mode 100644 (file)
index 0000000..12ce05b
--- /dev/null
@@ -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<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 };
+}
index 32fc9b56d69ca8e7c2e7ac02d5f131d0614306de..f9438870a58a63707977d6cfdf03b4119e994f92 100644 (file)
@@ -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: