Browse Source

feat(crons): Add zooming to crons overview (#67948)

Adds the ability to zoom on monitor timelines

Looks like this


https://github.com/getsentry/sentry/assets/1421724/f4014c35-a6a0-49ff-ada3-72f2730037bc
Evan Purkhiser 11 months ago
parent
commit
838bb9301d

+ 1 - 0
static/app/views/monitors/components/cronDetailsTimeline.tsx

@@ -123,6 +123,7 @@ export function CronDetailsTimeline({monitor, organization}: Props) {
         />
       </Header>
       <StyledGridLineOverlay
+        allowZoom={!isLoading}
         showCursor={!isLoading}
         timeWindowConfig={timeWindowConfig}
         start={dates.start}

+ 41 - 9
static/app/views/monitors/components/overviewTimeline/gridLines.tsx

@@ -1,18 +1,23 @@
 import {useCallback} from 'react';
 import styled from '@emotion/styled';
+import {mergeRefs} from '@react-aria/utils';
 import moment from 'moment';
 
+import {updateDateTime} from 'sentry/actionCreators/pageFilters';
 import DateTime from 'sentry/components/dateTime';
 import {space} from 'sentry/styles/space';
+import useRouter from 'sentry/utils/useRouter';
 import type {TimeWindowConfig} from 'sentry/views/monitors/components/overviewTimeline/types';
 
 import {useTimelineCursor} from './timelineCursor';
+import {useTimelineZoom} from './timelineZoom';
 
 interface Props {
   end: Date;
   start: Date;
   timeWindowConfig: TimeWindowConfig;
   width: number;
+  allowZoom?: boolean;
   className?: string;
   showCursor?: boolean;
   stickyCursor?: boolean;
@@ -50,6 +55,7 @@ function getTimeMarkersFromConfig(
 
   const lastTimeMark = moment(end);
   alignTimeMarkersToStartOf(lastTimeMark, timeMarkerInterval);
+
   // Generate time markers which represent location of grid lines/time labels
   for (let i = 1; i < elapsedMinutes / timeMarkerInterval; i++) {
     const timeMark = moment(lastTimeMark).subtract(i * timeMarkerInterval, 'minute');
@@ -87,28 +93,54 @@ export function GridLineOverlay({
   start,
   showCursor,
   stickyCursor,
+  allowZoom,
   className,
 }: Props) {
+  const router = useRouter();
   const {dateLabelFormat} = timeWindowConfig;
 
-  const makeCursorText = useCallback(
-    (percentPosition: number) => {
-      const timeOffset = (end.getTime() - start.getTime()) * percentPosition;
+  const msPerPixel = (timeWindowConfig.elapsedMinutes * 60 * 1000) / width;
+
+  const dateFromPosition = useCallback(
+    (position: number) => moment(start.getTime() + msPerPixel * position),
+    [msPerPixel, start]
+  );
 
-      return moment(start.getTime() + timeOffset).format(dateLabelFormat);
-    },
-    [dateLabelFormat, end, start]
+  const makeCursorLabel = useCallback(
+    (position: number) => dateFromPosition(position).format(dateLabelFormat),
+    [dateFromPosition, dateLabelFormat]
   );
 
+  const handleZoom = useCallback(
+    (startX: number, endX: number) =>
+      updateDateTime(
+        {
+          start: dateFromPosition(startX).toDate(),
+          end: dateFromPosition(endX).toDate(),
+        },
+        router
+      ),
+    [dateFromPosition, router]
+  );
+
+  const {
+    selectionContainerRef,
+    timelineSelector,
+    isActive: selectionIsActive,
+  } = useTimelineZoom<HTMLDivElement>({enabled: !!allowZoom, onSelect: handleZoom});
+
   const {cursorContainerRef, timelineCursor} = useTimelineCursor<HTMLDivElement>({
-    enabled: showCursor,
+    enabled: showCursor && !selectionIsActive,
     sticky: stickyCursor,
-    labelText: makeCursorText,
+    labelText: makeCursorLabel,
   });
 
+  const overlayRef = mergeRefs(cursorContainerRef, selectionContainerRef);
+
   return (
-    <Overlay ref={cursorContainerRef} className={className}>
+    <Overlay ref={overlayRef} className={className}>
       {timelineCursor}
+      {timelineSelector}
       <GridLineContainer>
         {getTimeMarkersFromConfig(start, end, timeWindowConfig, width).map(
           ({date, position}) => (

+ 1 - 0
static/app/views/monitors/components/overviewTimeline/index.tsx

@@ -150,6 +150,7 @@ export function OverviewTimeline({monitorList}: Props) {
       </Header>
       <GridLineOverlay
         stickyCursor
+        allowZoom
         showCursor={!isLoading}
         timeWindowConfig={timeWindowConfig}
         start={dates.start}

+ 4 - 4
static/app/views/monitors/components/overviewTimeline/timelineCursor.tsx

@@ -11,10 +11,10 @@ const TOOLTIP_OFFSET = 10;
 
 interface Options {
   /**
-   * Function used to compute the text of the cursor tooltip. Recieves the %
-   * value the cursor is within the container.
+   * Function used to compute the text of the cursor tooltip. Receives the
+   * offset value within the container.
    */
-  labelText: (percentPosition: number) => string;
+  labelText: (positionX: number) => string;
   /**
    * May be set to false to disable rendering the timeline cursor
    */
@@ -71,7 +71,7 @@ function useTimelineCursor<E extends HTMLElement>({
         const offset = e.clientX - containerRect.left;
         const tooltipWidth = labelRef.current.offsetWidth;
 
-        labelRef.current.innerText = labelText(offset / containerRect.width);
+        labelRef.current.innerText = labelText(offset);
 
         containerRef.current.style.setProperty('--cursorOffset', `${offset}px`);
         containerRef.current.style.setProperty('--cursorMax', `${containerRect.width}px`);

+ 136 - 0
static/app/views/monitors/components/overviewTimeline/timelineZoom.spec.tsx

@@ -0,0 +1,136 @@
+import {fireEvent, render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {useTimelineZoom} from './timelineZoom';
+
+interface TestProps {
+  onSelect?: (startX: number, endX: number) => void;
+}
+
+function TestComponent({onSelect}: TestProps) {
+  const {isActive, timelineSelector, selectionContainerRef} =
+    useTimelineZoom<HTMLDivElement>({enabled: true, onSelect});
+
+  return (
+    <div data-test-id="body">
+      {isActive && <div>Selection Active</div>}
+      <div data-test-id="container" ref={selectionContainerRef}>
+        {timelineSelector}
+      </div>
+    </div>
+  );
+}
+
+beforeEach(() => {
+  jest
+    .spyOn(window, 'requestAnimationFrame')
+    .mockImplementation((callback: FrameRequestCallback): number => {
+      callback(0);
+      return 0;
+    });
+});
+
+afterEach(() => {
+  jest.mocked(window.requestAnimationFrame).mockRestore();
+});
+
+function setupTestComponent() {
+  const handleSelect = jest.fn();
+
+  render(<TestComponent onSelect={handleSelect} />);
+
+  const body = screen.getByTestId('body');
+  const container = screen.getByTestId('container');
+
+  container.getBoundingClientRect = jest.fn(() => ({
+    x: 10,
+    y: 10,
+    width: 100,
+    height: 100,
+    left: 10,
+    top: 10,
+    right: 110,
+    bottom: 110,
+    toJSON: jest.fn(),
+  }));
+
+  return {handleSelect, body, container};
+}
+
+describe('TimelineZoom', function () {
+  it('triggers onSelect', function () {
+    const {handleSelect, body, container} = setupTestComponent();
+
+    // Selector has not appeared
+    expect(screen.queryByRole('presentation')).not.toBeInTheDocument();
+
+    // Selection does not start when clicking outside the container
+    fireEvent.mouseDown(body, {button: 0, clientX: 0, clientY: 0});
+    expect(screen.queryByRole('presentation')).not.toBeInTheDocument();
+
+    // Move cursor into the container, selection still not present
+    fireEvent.mouseMove(body, {clientX: 20, clientY: 20});
+    expect(screen.queryByRole('presentation')).not.toBeInTheDocument();
+
+    // Left click starts selection
+    fireEvent.mouseDown(body, {button: 0, clientX: 20, clientY: 20});
+
+    const selection = screen.getByRole('presentation');
+    expect(selection).toBeInTheDocument();
+    expect(screen.getByText('Selection Active')).toBeInTheDocument();
+
+    expect(container.style.getPropertyValue('--selectionStart')).toBe('10px');
+    expect(container.style.getPropertyValue('--selectionWidth')).toBe('0px');
+
+    // Body has disabled text selection
+    expect(document.body).toHaveStyle({userSelect: 'none'});
+
+    // Move right 15px
+    fireEvent.mouseMove(body, {clientX: 35, clientY: 20});
+    expect(container.style.getPropertyValue('--selectionWidth')).toBe('15px');
+
+    // Move left 25px, at the edge of the container
+    fireEvent.mouseMove(body, {clientX: 10, clientY: 20});
+    expect(container.style.getPropertyValue('--selectionStart')).toBe('0px');
+    expect(container.style.getPropertyValue('--selectionWidth')).toBe('10px');
+
+    // Move left 5px more, selection does not move out of the container
+    fireEvent.mouseMove(body, {clientX: 5, clientY: 20});
+    expect(container.style.getPropertyValue('--selectionStart')).toBe('0px');
+    expect(container.style.getPropertyValue('--selectionWidth')).toBe('10px');
+
+    // Release to make selection
+    fireEvent.mouseUp(body, {clientX: 5, clientY: 20});
+    expect(handleSelect).toHaveBeenCalledWith(0, 10);
+  });
+
+  it('does not start selection with right click', function () {
+    const {body} = setupTestComponent();
+
+    // Move cursor into the container, selection still not present
+    fireEvent.mouseMove(body, {clientX: 20, clientY: 20});
+
+    // Right click does nothing
+    fireEvent.mouseDown(body, {button: 1, clientX: 20, clientY: 20});
+    expect(screen.queryByRole('presentation')).not.toBeInTheDocument();
+  });
+
+  it('does not select for very small regions', function () {
+    const {handleSelect, body, container} = setupTestComponent();
+
+    // Left click starts selection
+    fireEvent.mouseMove(body, {clientX: 20, clientY: 20});
+    fireEvent.mouseDown(body, {button: 0, clientX: 20, clientY: 20});
+    fireEvent.mouseMove(body, {clientX: 22, clientY: 20});
+
+    const selection = screen.getByRole('presentation');
+    expect(selection).toBeInTheDocument();
+    expect(screen.getByText('Selection Active')).toBeInTheDocument();
+
+    expect(container.style.getPropertyValue('--selectionStart')).toBe('10px');
+    expect(container.style.getPropertyValue('--selectionWidth')).toBe('2px');
+
+    // Relase does not make selection for such a small range
+    fireEvent.mouseUp(body, {clientX: 22, clientY: 20});
+    expect(handleSelect).not.toHaveBeenCalledWith(0, 10);
+  });
+});

+ 184 - 0
static/app/views/monitors/components/overviewTimeline/timelineZoom.tsx

@@ -0,0 +1,184 @@
+import {useCallback, useEffect, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+import {AnimatePresence, motion} from 'framer-motion';
+
+import testableTransition from 'sentry/utils/testableTransition';
+
+/**
+ * The minimum number in pixels which the selection should be considered valid
+ * and will fire the onSelect handler.
+ */
+const MIN_SIZE = 5;
+
+interface Options {
+  /**
+   * May be set to false to disable rendering the timeline cursor
+   */
+  enabled?: boolean;
+  /**
+   * Triggered when a selection has been made
+   */
+  onSelect?: (startX: number, endX: number) => void;
+}
+
+function useTimelineZoom<E extends HTMLElement>({enabled = true, onSelect}: Options) {
+  const rafIdRef = useRef<number | null>(null);
+
+  const containerRef = useRef<E>(null);
+
+  const [isActive, setIsActive] = useState(false);
+  const initialX = useRef(0);
+
+  const startX = useRef(0);
+  const endX = useRef(0);
+
+  const handleMouseMove = useCallback(
+    (e: MouseEvent) => {
+      if (rafIdRef.current !== null) {
+        window.cancelAnimationFrame(rafIdRef.current);
+      }
+
+      if (containerRef.current === null) {
+        return;
+      }
+
+      if (!isActive) {
+        return;
+      }
+
+      const containerRect = containerRef.current.getBoundingClientRect();
+
+      rafIdRef.current = window.requestAnimationFrame(() => {
+        if (containerRef.current === null) {
+          return;
+        }
+
+        const offset = e.clientX - containerRect.left - initialX.current;
+        const isLeft = offset < 0;
+
+        const absoluteOffset = Math.abs(offset);
+
+        const start = !isLeft
+          ? initialX.current
+          : Math.max(0, initialX.current - absoluteOffset);
+
+        const width =
+          e.clientX < containerRect.left
+            ? initialX.current
+            : Math.min(containerRect.width - start, absoluteOffset);
+
+        containerRef.current.style.setProperty('--selectionStart', `${start}px`);
+        containerRef.current.style.setProperty('--selectionWidth', `${width}px`);
+
+        startX.current = start;
+        endX.current = start + width;
+      });
+    },
+    [isActive]
+  );
+
+  const handleMouseDown = useCallback((e: MouseEvent) => {
+    if (containerRef.current === null) {
+      return;
+    }
+
+    // Only primary click activates selection
+    if (e.button !== 0) {
+      return;
+    }
+
+    const containerRect = containerRef.current.getBoundingClientRect();
+    const offset = e.clientX - containerRect.left;
+
+    // Selection is only activated when inside the container
+    const isInsideContainer =
+      e.clientX > containerRect.left &&
+      e.clientX < containerRect.right &&
+      e.clientY > containerRect.top &&
+      e.clientY < containerRect.bottom;
+
+    if (!isInsideContainer) {
+      return;
+    }
+
+    setIsActive(true);
+
+    initialX.current = offset;
+
+    document.body.style.setProperty('user-select', 'none');
+    containerRef.current.style.setProperty('--selectionStart', `${offset}px`);
+    containerRef.current.style.setProperty('--selectionWidth', '0px');
+  }, []);
+
+  const handleMouseUp = useCallback(() => {
+    if (containerRef.current === null) {
+      return;
+    }
+    if (!isActive) {
+      return;
+    }
+
+    setIsActive(false);
+    document.body.style.removeProperty('user-select');
+
+    if (endX.current - startX.current >= MIN_SIZE) {
+      onSelect?.(startX.current, endX.current);
+    }
+
+    startX.current = 0;
+    endX.current = 0;
+  }, [isActive, onSelect]);
+
+  useEffect(() => {
+    if (enabled) {
+      window.addEventListener('mousemove', handleMouseMove);
+      window.addEventListener('mousedown', handleMouseDown);
+      window.addEventListener('mouseup', handleMouseUp);
+    } else {
+      setIsActive(false);
+    }
+
+    return () => {
+      window.removeEventListener('mousemove', handleMouseMove);
+      window.removeEventListener('mousedown', handleMouseDown);
+      window.removeEventListener('mouseup', handleMouseUp);
+    };
+  }, [enabled, handleMouseMove, handleMouseDown, handleMouseUp]);
+
+  useEffect(() => {
+    return () => {};
+  });
+
+  const timelineSelector = (
+    <AnimatePresence>{isActive && <Selection role="presentation" />}</AnimatePresence>
+  );
+
+  return {selectionContainerRef: containerRef, isActive, timelineSelector};
+}
+
+const Selection = styled(motion.div)`
+  pointer-events: none;
+  background: ${p => p.theme.translucentBorder};
+  border-left: 1px solid ${p => p.theme.purple200};
+  border-right: 1px solid ${p => p.theme.purple200};
+  height: 100%;
+  position: absolute;
+  top: 0;
+  left: var(--selectionStart);
+  width: var(--selectionWidth);
+  z-index: 2;
+`;
+
+Selection.defaultProps = {
+  initial: 'initial',
+  animate: 'animate',
+  exit: 'exit',
+  transition: testableTransition({duration: 0.2}),
+  variants: {
+    initial: {opacity: 0},
+    animate: {opacity: 1},
+    exit: {opacity: 0},
+  },
+};
+
+export {useTimelineZoom};