Repository: paularmstrong/build-tracker (ts)

Issue:
Add tooltips to graph
# Problem

There's no information while hovering over the graph in the app that can help verify what build/revision you're about to interact with

# Solution
Add tooltips while hovering with a mouse that includes more information

Nice to haves: 
* Build `revision`
* Build `date`
* Build `summary`
* Budget warnings & errors 



Pull request:
[app] add tooltips to graph when hovering
<!--
Thank you so much for contributing to open source and the Build Tracker project!
-->

# Problem

There's no information while hovering over the graph in the app that can help verify what build/revision you're about to interact with.

# Solution

* Refactor `Tooltip` into `Tooltip` (`left`, `top` props) and `RelativeTooltip` (`relativeTo` prop)
* Use `RelativeTooltip` where `Tooltip` was used before
* Use `Tooltip` in `HoverOverlay` to show revision value and current hovered artifact

Fixes #12

# Examples
<img width="1392" alt="hovering the middle of the graph" src="https://user-images.githubusercontent.com/33297/76707310-50d81280-66ab-11ea-8b05-cade91a7f43a.png">
<img width="1392" alt="hovering top-left of the graph" src="https://user-images.githubusercontent.com/33297/76707312-53d30300-66ab-11ea-93ff-bcc652b0d5da.png">
<img width="1392" alt="hovering deltacell" src="https://user-images.githubusercontent.com/33297/76707313-546b9980-66ab-11ea-9d55-64048244fda5.png">


# TODO

- [x] 🤓 Add & update tests (always try to _increase_ test coverage)
- [x] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [ ] 📖 Update relevant documentation


Patch:
diff --git a/src/app/src/components/ComparisonTable/ArtifactCell.tsx b/src/app/src/components/ComparisonTable/ArtifactCell.tsx
index 2273628..da6d9ee 100644
--- a/src/app/src/components/ComparisonTable/ArtifactCell.tsx
+++ b/src/app/src/components/ComparisonTable/ArtifactCell.tsx
@@ -5,8 +5,8 @@ import * as Theme from '../../theme';
 import Hoverable from '../Hoverable';
 import { hsl } from 'd3-color';
 import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
 import { Th } from '../Table';
-import Tooltip from '../Tooltip';
 import { ArtifactCell as ACell, GroupCell as GCell } from '@build-tracker/comparator';
 import { StyleProp, StyleSheet, Switch, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
 
@@ -61,7 +61,7 @@ export const ArtifactCell = (props: Props): React.ReactElement => {
                   {text}
                 </Text>
               </TouchableOpacity>
-              {isHovered ? <Tooltip relativeTo={textRef} text={`Show "${text}" only`} /> : null}
+              {isHovered ? <RelativeTooltip relativeTo={textRef} text={`Show "${text}" only`} /> : null}
             </View>
           )}
         </Hoverable>
diff --git a/src/app/src/components/ComparisonTable/DeltaCell.tsx b/src/app/src/components/ComparisonTable/DeltaCell.tsx
index ad00eaa..714a6a7 100644
--- a/src/app/src/components/ComparisonTable/DeltaCell.tsx
+++ b/src/app/src/components/ComparisonTable/DeltaCell.tsx
@@ -6,8 +6,8 @@ import ErrorIcon from '../../icons/Error';
 import HashIcon from '../../icons/Hash';
 import Hoverable from '../Hoverable';
 import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
 import { Td } from '../Table';
-import Tooltip from '../Tooltip';
 import WarningIcon from '../../icons/Warning';
 import { DeltaCell as Cell, TotalDeltaCell as TDCell } from '@build-tracker/comparator';
 import { formatBudgetResult, formatBytes, formatUnexpectedHashChange } from '@build-tracker/formatting';
@@ -99,7 +99,7 @@ export const DeltaCell = (props: Props): React.ReactElement => {
                 </Text>
               ) : null}
               {sizeDelta !== 0 ? <Text>{text}</Text> : null}
-              {isHovered ? <Tooltip relativeTo={viewRef} text={tooltipText} /> : null}
+              {isHovered ? <RelativeTooltip relativeTo={viewRef} text={tooltipText} /> : null}
             </View>
           )}
         </Hoverable>
diff --git a/src/app/src/components/ComparisonTable/GroupCell.tsx b/src/app/src/components/ComparisonTable/GroupCell.tsx
index 365891e..45d64c5 100644
--- a/src/app/src/components/ComparisonTable/GroupCell.tsx
+++ b/src/app/src/components/ComparisonTable/GroupCell.tsx
@@ -6,8 +6,8 @@ import FolderIcon from '../../icons/Folder';
 import { GroupCell as GCell } from '@build-tracker/comparator';
 import Hoverable from '../Hoverable';
 import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
 import { Th } from '../Table';
-import Tooltip from '../Tooltip';
 import { StyleProp, StyleSheet, Switch, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
 
 interface Props {
@@ -54,7 +54,7 @@ export const GroupCell = (props: Props): React.ReactElement => {
                 <Text style={isHovered && styles.hoveredText}>
                   <FolderIcon style={[styles.folder, isHovered && styles.hoveredText]} testID="groupicon" /> {text}
                 </Text>
-                {isHovered ? <Tooltip relativeTo={nameRef} text={`${artifactNames.join(', ')}`} /> : null}
+                {isHovered ? <RelativeTooltip relativeTo={nameRef} text={`${artifactNames.join(', ')}`} /> : null}
               </View>
             </TouchableOpacity>
           )}
diff --git a/src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx b/src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx
index 1279220..60fc1e3 100644
--- a/src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx
+++ b/src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx
@@ -5,8 +5,8 @@ import { RevisionDeltaCell as Cell } from '@build-tracker/comparator';
 import { formatSha } from '@build-tracker/formatting';
 import Hoverable from '../Hoverable';
 import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
 import { Th } from '../Table';
-import Tooltip from '../Tooltip';
 import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
 
 interface Props {
@@ -25,7 +25,7 @@ export const RevisionDeltaCell = (props: Props): React.ReactElement => {
           <View ref={viewRef} testID="delta">
             <Text style={styles.delta}>{`𝚫${deltaIndex}`}</Text>
             {isHovered ? (
-              <Tooltip
+              <RelativeTooltip
                 relativeTo={viewRef}
                 text={`Delta from ${formatSha(againstRevision)} to ${formatSha(revision)}`}
               />
diff --git a/src/app/src/components/Graph/HoverOverlay.tsx b/src/app/src/components/Graph/HoverOverlay.tsx
index a96b0c6..01a480b 100644
--- a/src/app/src/components/Graph/HoverOverlay.tsx
+++ b/src/app/src/components/Graph/HoverOverlay.tsx
@@ -3,11 +3,13 @@
  */
 import * as Theme from '../../theme';
 import Build from '@build-tracker/build';
+import { formatSha } from '@build-tracker/formatting';
 import memoize from 'memoize-one';
 import { Offset } from './Offset';
 import React from 'react';
 import { select } from 'd3-selection';
 import { Series } from 'd3-shape';
+import Tooltip from '../Tooltip';
 import { ScaleBand, ScaleLinear, ScalePoint } from 'd3-scale';
 
 interface Props {
@@ -75,12 +77,19 @@ const HoverOverlay = (props: Props): React.ReactElement => {
     [buildRevisionFromX, onSelectRevision, selectedRevisions]
   );
 
+  const [offsetX, setOffsetX] = React.useState(NaN);
+  const [absPosition, setAbsPosition] = React.useState({ left: 0, top: 0 });
+  const [hoveredArtifact, setHoveredArtifact] = React.useState('');
+
   const handleMouseMove = React.useCallback(
     (event: React.MouseEvent<SVGRectElement>): void => {
       const {
-        nativeEvent: { offsetX, offsetY = Offset.TOP }
+        nativeEvent: { offsetX, offsetY = Offset.TOP, pageX, pageY }
       } = event;
 
+      setOffsetX(offsetX);
+      setAbsPosition({ left: pageX, top: pageY });
+
       const revision = buildRevisionFromX(offsetX - Offset.LEFT);
       const xPos = xGetter(revision.value);
       handleMoveLine(lineRef.current, xPos);
@@ -89,6 +98,7 @@ const HoverOverlay = (props: Props): React.ReactElement => {
       const hoveredArtifact = data.find(data => {
         return data[revision.index][0] <= yValue && data[revision.index][1] >= yValue;
       });
+      hoveredArtifact && setHoveredArtifact(hoveredArtifact.key);
       onHoverArtifacts(hoveredArtifact ? [hoveredArtifact.key] : []);
     },
     [buildRevisionFromX, data, onHoverArtifacts, xGetter, yScale]
@@ -97,12 +107,18 @@ const HoverOverlay = (props: Props): React.ReactElement => {
   const handleMouseOut = React.useCallback((): void => {
     select(lineRef.current).style('opacity', 0);
     onHoverArtifacts([]);
+    setOffsetX(NaN);
   }, [onHoverArtifacts]);
 
   const handleMouseOver = React.useCallback((): void => {
     select(lineRef.current).style('opacity', 1);
   }, []);
 
+  const getTooltipText = React.useCallback((): string => {
+    const revision = buildRevisionFromX(offsetX - Offset.LEFT);
+    return `Revision: ${formatSha(revision.value)}, Artifact: ${hoveredArtifact}`;
+  }, [buildRevisionFromX, hoveredArtifact, offsetX]);
+
   return (
     <g>
       <rect
@@ -132,6 +148,7 @@ const HoverOverlay = (props: Props): React.ReactElement => {
         );
       })}
       <line data-testid="hoverline" pointerEvents="none" ref={lineRef} style={styles.hoverLine} y1={0} y2={height} />
+      {!isNaN(offsetX) ? <Tooltip left={absPosition.left} top={absPosition.top} text={getTooltipText()} /> : null}
     </g>
   );
 };
diff --git a/src/app/src/components/RelativeTooltip.tsx b/src/app/src/components/RelativeTooltip.tsx
new file mode 100644
index 0000000..ae77052
--- /dev/null
+++ b/src/app/src/components/RelativeTooltip.tsx
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import React from 'react';
+import Tooltip from './Tooltip';
+import { View } from 'react-native';
+
+interface Props {
+  relativeTo: React.RefObject<View>;
+  text: string;
+}
+
+const RelativeTooltip = (props: Props): React.ReactElement => {
+  const { relativeTo, text } = props;
+  const [position, setPosition] = React.useState({ top: -999, left: 0 });
+
+  React.useEffect(() => {
+    let mounted = true;
+    if (relativeTo.current) {
+      relativeTo.current.measureInWindow(
+        (x: number, y: number, width: number, height: number): void => {
+          if (!mounted) {
+            return;
+          }
+
+          setPosition({ left: x + Math.round(width / 2), top: y + Math.round(height / 2) });
+        }
+      );
+    }
+    return () => {
+      mounted = false;
+    };
+    /* eslint-disable react-hooks/exhaustive-deps */
+    // Tests break if you pass the ref/relativeTo higher object in and not the actual value we're using
+  }, [relativeTo.current]);
+  /* eslint-enable react-hooks/exhaustive-deps */
+
+  return <Tooltip left={position.left} top={position.top} text={text} />;
+};
+
+export default RelativeTooltip;
diff --git a/src/app/src/components/Tooltip.tsx b/src/app/src/components/Tooltip.tsx
index 58eb260..f374c97 100644
--- a/src/app/src/components/Tooltip.tsx
+++ b/src/app/src/components/Tooltip.tsx
@@ -7,61 +7,53 @@ import ReactDOM from 'react-dom';
 import { Dimensions, StyleSheet, Text, View } from 'react-native';
 
 interface Props {
-  relativeTo: React.RefObject<View>;
+  top: number;
+  left: number;
   text: string;
 }
 
-const tipSpace = 6;
+const TIP_SPACE = 6;
 
 const Tooltip = (props: Props): React.ReactElement => {
-  const { relativeTo, text } = props;
+  const { left, text, top } = props;
   const [position, setPosition] = React.useState({ top: -999, left: 0 });
   const portalRoot = document.getElementById('tooltipPortal');
   const ref: React.RefObject<View> = React.createRef();
 
   React.useEffect(() => {
     let mounted = true;
-    if (relativeTo.current) {
-      const { width: windowWidth, height: windowHeight } = Dimensions.get('window');
-      ref.current.measure(
-        (_x: number, _y: number, tipWidth: number, tipHeight: number): void => {
-          if (!mounted) {
-            return;
-          }
-          relativeTo.current.measureInWindow(
-            (x: number, y: number, width: number, height: number): void => {
-              if (!mounted) {
-                return;
-              }
-              let top = y + height + tipSpace;
-              let left = x + width / 2 - tipWidth / 2;
-              // too far right when underneath
-              if (left + tipWidth > windowWidth) {
-                left = x - tipWidth - tipSpace;
-                top = y + height / 2 - tipHeight / 2;
-              }
-              // too far left when underneath
-              else if (left < 0) {
-                left = x + width + tipSpace;
-                top = y + height / 2 - tipHeight / 2;
-              }
-              // too close to bottom
-              else if (top + tipHeight > windowHeight) {
-                top = y - tipHeight - tipSpace;
-              }
-
-              setPosition({ left, top });
-            }
-          );
+    const { width: windowWidth } = Dimensions.get('window');
+    ref.current.measure(
+      (_x: number, _y: number, tipWidth: number, tipHeight: number): void => {
+        if (!mounted) {
+          return;
+        }
+        let newTop = top - TIP_SPACE - tipHeight;
+        let newLeft = left - Math.round(tipWidth / 2);
+        // too far right
+        if (newLeft + tipWidth + TIP_SPACE > windowWidth) {
+          newLeft = left - tipWidth - TIP_SPACE;
+          newTop = top - Math.round(tipHeight / 2);
+        }
+        // too far left
+        else if (newLeft < TIP_SPACE) {
+          newLeft = left + TIP_SPACE;
+          newTop = top - Math.round(tipHeight / 2);
         }
-      );
-    }
+        // too close to top
+        else if (newTop <= TIP_SPACE) {
+          newTop = top + tipHeight + TIP_SPACE;
+        }
+
+        setPosition({ left: newLeft, top: newTop });
+      }
+    );
     return () => {
       mounted = false;
     };
     /* eslint-disable react-hooks/exhaustive-deps */
     // Tests break if you pass the ref/relativeTo higher object in and not the actual value we're using
-  }, [ref.current, relativeTo.current]);
+  }, [ref.current, left, top]);
   /* eslint-enable react-hooks/exhaustive-deps */
 
   const tooltip = (
@@ -81,7 +73,8 @@ const styles = StyleSheet.create({
   root: {
     maxWidth: 400,
     position: 'absolute',
-    backgroundColor: Theme.Color.Gray50,
+    pointerEvents: 'none',
+    backgroundColor: `${Theme.Color.Gray50}EE`,
     borderRadius: Theme.BorderRadius.Normal,
     paddingHorizontal: Theme.Spacing.Small,
     paddingVertical: Theme.Spacing.Xsmall,


Test patch:
diff --git a/src/app/src/components/__tests__/RelativeTooltip.test.tsx b/src/app/src/components/__tests__/RelativeTooltip.test.tsx
new file mode 100644
index 0000000..bc557c7
--- /dev/null
+++ b/src/app/src/components/__tests__/RelativeTooltip.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
+import { render } from '@testing-library/react';
+import { MeasureOnSuccessCallback, View } from 'react-native';
+
+describe('RelativeTooltip', () => {
+  let viewRef;
+  beforeEach(() => {
+    viewRef = React.createRef();
+    render(<View ref={viewRef} />);
+  });
+
+  test('renders a tooltip to a portal', () => {
+    const portal = document.createElement('div');
+    portal.setAttribute('id', 'tooltipPortal');
+    document.body.appendChild(portal);
+
+    jest.spyOn(View.prototype, 'measure').mockImplementation(
+      (fn: MeasureOnSuccessCallback): void => {
+        fn(0, 0, 45, 20, 0, 0);
+      }
+    );
+    jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
+      (fn: (x: number, y: number, width: number, height: number) => void): void => {
+        fn(20, 100, 40, 30);
+      }
+    );
+
+    const { getByRole, queryAllByText } = render(<RelativeTooltip relativeTo={viewRef} text="foobar" />, {
+      container: portal
+    });
+
+    expect(getByRole('tooltip').style).toMatchObject({
+      top: '89px',
+      left: '17px'
+    });
+    expect(queryAllByText('foobar')).toHaveLength(1);
+    document.body.removeChild(portal);
+  });
+
+  test('renders directly without a portal available', () => {
+    jest.spyOn(View.prototype, 'measure').mockImplementation(
+      (fn: MeasureOnSuccessCallback): void => {
+        fn(0, 0, 45, 20, 0, 0);
+      }
+    );
+
+    jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
+      (fn: (x: number, y: number, width: number, height: number) => void): void => {
+        fn(20, 100, 40, 30);
+      }
+    );
+
+    const { getByRole, queryAllByText } = render(<RelativeTooltip relativeTo={viewRef} text="foobar" />);
+
+    expect(getByRole('tooltip').style).toMatchObject({
+      top: '89px',
+      left: '17px'
+    });
+    expect(queryAllByText('foobar')).toHaveLength(1);
+  });
+});
diff --git a/src/app/src/components/__tests__/Tooltip.test.tsx b/src/app/src/components/__tests__/Tooltip.test.tsx
index 5531f45..058bfa3 100644
--- a/src/app/src/components/__tests__/Tooltip.test.tsx
+++ b/src/app/src/components/__tests__/Tooltip.test.tsx
@@ -7,12 +7,6 @@ import Tooltip from '../Tooltip';
 import { Dimensions, MeasureOnSuccessCallback, View } from 'react-native';
 
 describe('Tooltip', () => {
-  let viewRef;
-  beforeEach(() => {
-    viewRef = React.createRef();
-    render(<View ref={viewRef} />);
-  });
-
   test('renders a tooltip to a portal', () => {
     const portal = document.createElement('div');
     portal.setAttribute('id', 'tooltipPortal');
@@ -23,21 +17,17 @@ describe('Tooltip', () => {
         fn(0, 0, 45, 20, 0, 0);
       }
     );
-    jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-      (fn: (x: number, y: number, width: number, height: number) => void): void => {
-        fn(20, 100, 40, 30);
-      }
-    );
 
-    const { getByRole, queryAllByText } = render(<Tooltip relativeTo={viewRef} text="foobar" />, {
+    const { getByRole, queryAllByText } = render(<Tooltip left={20} top={100} text="foobar" />, {
       container: portal
     });
 
     expect(getByRole('tooltip').style).toMatchObject({
-      top: '136px',
-      left: '17.5px'
+      top: '90px',
+      left: '26px'
     });
     expect(queryAllByText('foobar')).toHaveLength(1);
+    document.body.removeChild(portal);
   });
 
   test('renders directly without a portal available', () => {
@@ -47,17 +37,11 @@ describe('Tooltip', () => {
       }
     );
 
-    jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-      (fn: (x: number, y: number, width: number, height: number) => void): void => {
-        fn(20, 100, 40, 30);
-      }
-    );
-
-    const { getByRole, queryAllByText } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
+    const { getByRole, queryAllByText } = render(<Tooltip left={20} top={100} text="foobar" />);
 
     expect(getByRole('tooltip').style).toMatchObject({
-      top: '136px',
-      left: '17.5px'
+      top: '90px',
+      left: '26px'
     });
     expect(queryAllByText('foobar')).toHaveLength(1);
   });
@@ -70,17 +54,12 @@ describe('Tooltip', () => {
           fn(0, 0, 45, 30, 0, 0);
         }
       );
-      jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-        (fn: (x: number, y: number, width: number, height: number) => void): void => {
-          fn(10, 200, 20, 10);
-        }
-      );
 
-      const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
+      const { getByRole } = render(<Tooltip left={10} top={200} text="foobar" />);
 
       expect(getByRole('tooltip').style).toMatchObject({
-        top: '190px',
-        left: '36px'
+        top: '185px',
+        left: '16px'
       });
     });
 
@@ -91,38 +70,27 @@ describe('Tooltip', () => {
           fn(0, 0, 45, 30, 0, 0);
         }
       );
-      jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-        (fn: (x: number, y: number, width: number, height: number) => void): void => {
-          fn(380, 200, 50, 10);
-        }
-      );
 
-      const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
+      const { getByRole } = render(<Tooltip left={380} top={200} text="foobar" />);
 
       expect(getByRole('tooltip').style).toMatchObject({
-        top: '190px',
+        top: '185px',
         left: '329px'
       });
     });
 
-    test('avoids the bottom edge', () => {
+    test('avoids the top edge', () => {
       jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 400, scale: 1, fontScale: 1 });
       jest.spyOn(View.prototype, 'measure').mockImplementation(
         (fn: MeasureOnSuccessCallback): void => {
           fn(0, 0, 45, 30, 0, 0);
         }
       );
-      jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-        (fn: (x: number, y: number, width: number, height: number) => void): void => {
-          fn(200, 380, 50, 10);
-        }
-      );
-
-      const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
+      const { getByRole } = render(<Tooltip left={200} top={0} text="foobar" />);
 
       expect(getByRole('tooltip').style).toMatchObject({
-        top: '344px',
-        left: '202.5px'
+        top: '36px',
+        left: '177px'
       });
     });
   });