]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12913 Animate hotspot snippet expansion
authorJeremy Davis <jeremy.davis@sonarsource.com>
Thu, 28 Apr 2022 16:01:25 +0000 (18:01 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 2 May 2022 20:02:50 +0000 (20:02 +0000)
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap

index 7cc9c62cfe9bd5d63ad85f228522ccd8effb36ab..2c6ab3fb8d3a34ca80c68eaa9bc5ff9315a21a92 100644 (file)
@@ -53,7 +53,10 @@ export interface HotspotSnippetContainerRendererProps {
 }
 
 const noop = () => undefined;
+const SCROLL_INTERVAL_DELAY = 10;
 const SCROLL_DELAY = 100;
+const EXPAND_ANIMATION_SPEED = 200;
+
 const TOP_OFFSET = 100; // 5 lines above
 const BOTTOM_OFFSET = 28; // 1 line below + margin
 
@@ -75,6 +78,73 @@ export function getScrollHandler(scrollableRef: React.RefObject<HTMLDivElement>)
   };
 }
 
+function scrollToFollowAnimation(
+  scrollableRef: React.RefObject<HTMLDivElement>,
+  targetHeight: number
+) {
+  const scrollable = scrollableRef.current;
+  if (scrollable) {
+    const handler = setInterval(() => {
+      scrollable.scrollTo({ top: targetHeight, behavior: 'smooth' });
+    }, SCROLL_INTERVAL_DELAY);
+    setTimeout(() => {
+      clearInterval(handler);
+    }, EXPAND_ANIMATION_SPEED);
+  }
+}
+
+/* Exported for testing */
+export async function animateExpansion(
+  scrollableRef: React.RefObject<HTMLDivElement>,
+  expandBlock: (direction: ExpandDirection) => Promise<void>,
+  direction: ExpandDirection
+) {
+  const wrapper = scrollableRef.current?.querySelector<HTMLElement>('.snippet');
+  const table = wrapper?.firstChild as HTMLElement;
+
+  if (!wrapper || !table) {
+    return;
+  }
+
+  // lock the wrapper's height before adding the additional rows
+  const startHeight = table.getBoundingClientRect().height;
+  wrapper.style.maxHeight = `${startHeight}px`;
+
+  await expandBlock(direction);
+
+  const targetHeight = table.getBoundingClientRect().height;
+
+  if (direction === 'up') {
+    /*
+     * Add a negative margin to keep the original alignment
+     * Remove the transition to do so instantaneously
+     */
+    table.style.transition = 'none';
+    table.style.marginTop = `${startHeight - targetHeight}px`;
+
+    setTimeout(() => {
+      /*
+       * Reset the transition to the default
+       * transition the margin back to 0 at the same time as the maxheight
+       */
+      table.style.transition = '';
+      table.style.marginTop = '0px';
+      wrapper.style.maxHeight = `${targetHeight}px`;
+    }, 0);
+  } else {
+    // False positive:
+    // eslint-disable-next-line require-atomic-updates
+    wrapper.style.maxHeight = `${targetHeight}px`;
+    scrollToFollowAnimation(scrollableRef, targetHeight);
+  }
+
+  // after the animation is done, clear the applied styles
+  setTimeout(() => {
+    table.style.marginTop = '';
+    wrapper.style.maxHeight = '';
+  }, EXPAND_ANIMATION_SPEED);
+}
+
 export default function HotspotSnippetContainerRenderer(
   props: HotspotSnippetContainerRendererProps
 ) {
@@ -129,7 +199,9 @@ export default function HotspotSnippetContainerRenderer(
               component={sourceViewerFile}
               displayLineNumberOptions={false}
               displaySCM={false}
-              expandBlock={(_i, direction) => props.onExpandBlock(direction)}
+              expandBlock={(_i, direction) =>
+                animateExpansion(scrollableRef, props.onExpandBlock, direction)
+              }
               handleCloseIssues={noop}
               handleOpenIssues={noop}
               handleSymbolClick={props.onSymbolClick}
@@ -146,7 +218,6 @@ export default function HotspotSnippetContainerRenderer(
               renderAdditionalChildInLine={renderHotspotBoxInLine}
               renderDuplicationPopup={noop}
               snippet={sourceLines}
-              scroll={getScrollHandler(scrollableRef)}
             />
           )}
         </DeferredSpinner>
index ac4ad4baab8c1cbc117e439b3bdf726c8a3070fc..b52c08df12521007d7b7ad62cad301bad4059975 100644 (file)
@@ -26,6 +26,7 @@ import { scrollToElement } from '../../../../helpers/scrolling';
 import { mockSourceLine, mockSourceViewerFile } from '../../../../helpers/testMocks';
 import SnippetViewer from '../../../issues/crossComponentSourceViewer/SnippetViewer';
 import HotspotSnippetContainerRenderer, {
+  animateExpansion,
   getScrollHandler,
   HotspotSnippetContainerRendererProps
 } from '../HotspotSnippetContainerRenderer';
@@ -106,6 +107,57 @@ describe('scrolling', () => {
   });
 });
 
+describe('expand', () => {
+  it('should work as expected', async () => {
+    jest.useFakeTimers();
+    const onExpandBlock = jest.fn().mockResolvedValue({});
+
+    const scrollableNode = document.createElement('div');
+    scrollableNode.scrollTo = jest.fn();
+    const ref: RefObject<HTMLDivElement> = {
+      current: scrollableNode
+    };
+
+    jest.spyOn(React, 'useRef').mockReturnValue(ref);
+
+    const snippet = document.createElement('div');
+    const table = document.createElement('table');
+    snippet.appendChild(table);
+    scrollableNode.querySelector = jest.fn().mockReturnValue(snippet);
+
+    jest
+      .spyOn(table, 'getBoundingClientRect')
+      .mockReturnValueOnce({ height: 42 } as DOMRect)
+      .mockReturnValueOnce({ height: 99 } as DOMRect)
+      .mockReturnValueOnce({ height: 99 } as DOMRect)
+      .mockReturnValueOnce({ height: 112 } as DOMRect);
+
+    await animateExpansion(ref, onExpandBlock, 'up');
+    expect(onExpandBlock).toBeCalledWith('up');
+
+    expect(snippet.style.maxHeight).toBe('42px');
+    expect(table.style.marginTop).toBe('-57px');
+
+    jest.advanceTimersByTime(100);
+
+    expect(snippet.style.maxHeight).toBe('99px');
+    expect(table.style.marginTop).toBe('0px');
+
+    expect(scrollableNode.scrollTo).not.toBeCalled();
+
+    jest.runAllTimers();
+
+    await animateExpansion(ref, onExpandBlock, 'down');
+    expect(onExpandBlock).toBeCalledWith('down');
+    expect(snippet.style.maxHeight).toBe('112px');
+
+    jest.advanceTimersByTime(250);
+    expect(scrollableNode.scrollTo).toBeCalled();
+
+    jest.useRealTimers();
+  });
+});
+
 function shallowRender(props?: Partial<HotspotSnippetContainerRendererProps>) {
   return shallow(
     <HotspotSnippetContainerRenderer
index be77b3e2008f823cf27ac10f864c0e3a172f4141..2a46900c6adb664cc8ae0cc7f734765148a6e912 100644 (file)
@@ -518,7 +518,6 @@ exports[`should render correctly: with sourcelines 1`] = `
         openIssuesByLine={Object {}}
         renderAdditionalChildInLine={[Function]}
         renderDuplicationPopup={[Function]}
-        scroll={[Function]}
         snippet={
           Array [
             Object {