From ae3f7c5aad171e36610c371b9ffbf725a3070a8d Mon Sep 17 00:00:00 2001 From: Jeremy Davis Date: Thu, 28 Apr 2022 18:01:25 +0200 Subject: [PATCH] SONAR-12913 Animate hotspot snippet expansion --- .../HotspotSnippetContainerRenderer.tsx | 75 ++++++++++++++++++- .../HotspotSnippetContainerRenderer-test.tsx | 52 +++++++++++++ ...spotSnippetContainerRenderer-test.tsx.snap | 1 - 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx index 7cc9c62cfe9..2c6ab3fb8d3 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx @@ -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) }; } +function scrollToFollowAnimation( + scrollableRef: React.RefObject, + 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, + expandBlock: (direction: ExpandDirection) => Promise, + direction: ExpandDirection +) { + const wrapper = scrollableRef.current?.querySelector('.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)} /> )} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx index ac4ad4baab8..b52c08df125 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx @@ -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 = { + 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) { return shallow(