Repository: paularmstrong/build-tracker (ts)

Issue:
feat(app): Press on size or delta cell in ComparisonTable to show more information
# Problem

Sometimes when I'm digging into the table, I want to see more information about the artifact in the build or the delta (like all sizeKey changes, etc). There's currently no short way of doing that.

# Solution

On press (click), a modal window could open that nicely formats the full `Artifact` or `ArtifactDelta` object (depending on which cell type).


Pull request description:
feat(app): add modal dialogs to comparison table
# Problem

Sometimes when I'm digging into the table, I want to see more information about the artifact in the build or the delta (like all sizeKey changes, etc). There's currently no short way of doing that.

# Solution

On press (click), a modal window could open that nicely formats the full `Artifact` or `ArtifactDelta` object (depending on which cell type).

<img width="526" alt="Screen Shot 2020-03-28 at 12 21 08 PM" src="https://user-images.githubusercontent.com/33297/77831820-a4585080-70ee-11ea-930c-453b80805b3f.png">
<img width="373" alt="Screen Shot 2020-03-28 at 12 21 01 PM" src="https://user-images.githubusercontent.com/33297/77831821-a5897d80-70ee-11ea-88b7-4a764c28c23d.png">

Closes #183 

# TODO

- [x] 🤓 Add & update tests (always try to _increase_ test coverage)
- [ ] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [ ] 📖 Update relevant documentation


Patch:
diff --git a/src/app/src/components/BuildInfo.tsx b/src/app/src/components/BuildInfo.tsx
index cfd46c1..3c7d784 100644
--- a/src/app/src/components/BuildInfo.tsx
+++ b/src/app/src/components/BuildInfo.tsx
@@ -1,36 +1,42 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
-import * as Theme from '../theme';
+import { BuildMetaItem } from '@build-tracker/build';
 import Button from './Button';
 import CollapseIcon from '../icons/Collapse';
 import { formatSha } from '@build-tracker/formatting';
 import React from 'react';
 import RemoveIcon from '../icons/Remove';
 import { State } from '../store/types';
-import TextLink from './TextLink';
+import TabularMetadata from './TabularMetadata';
+import { View } from 'react-native';
 import { removeComparedRevision, setFocusedRevision } from '../store/actions';
-import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
-import { Table, Tbody, Td, Th, Tr } from './Table';
 import { useDispatch, useSelector } from 'react-redux';
 
 interface Props {
   focusedRevision: string;
-  style?: StyleProp<ViewStyle>;
 }
 
-const titleCase = (value: string): string => {
-  return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
-};
+const buildMetaSort = ['revision', 'timestamp'];
+
+function formatTimestamp(timestamp: number): string {
+  const dateTime = new Date(parseInt(`${timestamp}000`, 10));
+  return `${dateTime
+    .toLocaleDateString('ja', {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+    })
+    .replace(/\//g, '-')} ${dateTime.toLocaleTimeString()}`;
+}
 
 const BuildInfo = (props: Props): React.ReactElement => {
-  const { focusedRevision, style } = props;
+  const { focusedRevision } = props;
 
   const build = useSelector((state: State) =>
     state.comparator.builds.find((build) => build.getMetaValue('revision') === focusedRevision)
   );
   const revision = build.getMetaValue('revision');
-  const revisionUrl = build.getMetaUrl('revision');
 
   const dispatch = useDispatch();
 
@@ -42,74 +48,35 @@ const BuildInfo = (props: Props): React.ReactElement => {
     dispatch(removeComparedRevision(focusedRevision));
   }, [dispatch, focusedRevision]);
 
+  const tableData = Object.keys(build.meta).reduce((memo: Array<[string, BuildMetaItem]>, metaKey: string): Array<
+    [string, BuildMetaItem]
+  > => {
+    const sortIndex = buildMetaSort.indexOf(metaKey);
+    const value = metaKey === 'timestamp' ? formatTimestamp(build.meta[metaKey]) : build.meta[metaKey];
+    if (sortIndex >= 0) {
+      memo.splice(sortIndex, 0, [metaKey, value]);
+    } else {
+      memo.push([metaKey, value]);
+    }
+    return memo;
+  }, []);
+
   return (
-    <View style={[styles.root, style]}>
-      <View style={styles.header}>
-        <Text style={styles.headerText}>Build: {formatSha(revision)}</Text>
-        <Button icon={CollapseIcon} iconOnly onPress={handleClose} title="Collapse details" />
-      </View>
-      <Table>
-        <Tbody>
-          <Tr>
-            <Th>
-              <Text>Revision</Text>
-            </Th>
-            <Td style={styles.infoCell}>
-              {revisionUrl ? <TextLink href={revisionUrl} text={revision} /> : <Text>{revision}</Text>}
-            </Td>
-          </Tr>
-          <Tr>
-            <Th>
-              <Text>Date</Text>
-            </Th>
-            <Td style={styles.infoCell}>
-              <Text>{build.timestamp.toLocaleString()}</Text>
-            </Td>
-          </Tr>
-          {Object.keys(build.meta)
-            .filter((metaKey) => metaKey !== 'revision' && metaKey !== 'timestamp')
-            .map((metaKey: 'revision' | 'parentRevision' | 'branch') => {
-              const value = build.getMetaValue(metaKey);
-              const url = build.getMetaUrl(metaKey);
-              return (
-                <Tr key={metaKey}>
-                  <Th>
-                    <Text>{titleCase(metaKey)}</Text>
-                  </Th>
-                  <Td style={styles.infoCell}>{url ? <TextLink href={url} text={value} /> : <Text>{value}</Text>}</Td>
-                </Tr>
-              );
-            })}
-        </Tbody>
-      </Table>
-      <View style={styles.footer}>
-        <Button color="secondary" icon={RemoveIcon} onPress={handleRemove} title="Remove build" />
-      </View>
+    <View>
+      <TabularMetadata
+        closeButtonLabel="Collapse details"
+        data={tableData}
+        footer={
+          <View>
+            <Button color="secondary" icon={RemoveIcon} onPress={handleRemove} title="Remove build" />
+          </View>
+        }
+        icon={CollapseIcon}
+        onClose={handleClose}
+        title={`Build: ${formatSha(revision)}`}
+      />
     </View>
   );
 };
 
-const styles = StyleSheet.create({
-  root: {
-    paddingVertical: Theme.Spacing.Normal,
-    paddingHorizontal: Theme.Spacing.Large,
-  },
-  header: {
-    flexDirection: 'row',
-    justifyContent: 'space-between',
-    alignItems: 'center',
-  },
-  headerText: {
-    fontWeight: Theme.FontWeight.Bold,
-    fontSize: Theme.FontSize.Normal,
-  },
-  infoCell: {
-    textAlign: 'left',
-  },
-  footer: {
-    alignItems: 'flex-start',
-    paddingTop: Theme.Spacing.Normal,
-  },
-});
-
 export default BuildInfo;
diff --git a/src/app/src/components/ComparisonTable/DeltaCell.tsx b/src/app/src/components/ComparisonTable/DeltaCell.tsx
index dbd0daa..469065c 100644
--- a/src/app/src/components/ComparisonTable/DeltaCell.tsx
+++ b/src/app/src/components/ComparisonTable/DeltaCell.tsx
@@ -2,12 +2,16 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import { BudgetLevel } from '@build-tracker/types';
+import CloseIcon from '../../icons/Close';
 import ErrorIcon from '../../icons/Error';
 import HashIcon from '../../icons/Hash';
 import Hoverable from '../Hoverable';
 import React from 'react';
+import RelativeModal from '../RelativeModal';
 import RelativeTooltip from '../RelativeTooltip';
+import TabularMetadata from '../TabularMetadata';
 import { Td } from '../Table';
+import { TouchableOpacity } from 'react-native';
 import WarningIcon from '../../icons/Warning';
 import { DeltaCell as Cell, TotalDeltaCell as TDCell } from '@build-tracker/comparator';
 import { formatBudgetResult, formatBytes, formatUnexpectedHashChange } from '@build-tracker/formatting';
@@ -46,11 +50,14 @@ export const warning: Color = {
 export const scale = ({ red, blue, green }: Color, percentDelta: number): string =>
   `rgba(${red},${green},${blue},${Math.max(Math.min(Math.abs(percentDelta), 1), 0)})`;
 
+const formatPercentChange = (percentDelta: number): string => `${(percentDelta * 100).toFixed(3)}%`;
+
 export const DeltaCell = (props: Props): React.ReactElement => {
   const { cell, sizeKey, style } = props;
   const sizeDelta = cell.sizes[sizeKey];
   const percentDelta = cell.percents[sizeKey];
   const viewRef: React.RefObject<View> = React.useRef(null);
+  const cellRef = React.useRef();
 
   const failingBudgets = cell.failingBudgets;
   const errorBudgets = failingBudgets.filter((budget) => budget.level === BudgetLevel.ERROR);
@@ -69,7 +76,7 @@ export const DeltaCell = (props: Props): React.ReactElement => {
     backgroundColor = scale(happy, percentDelta);
   }
 
-  const stringChange = `${sizeDelta} bytes (${(percentDelta * 100).toFixed(3)}%)`;
+  const stringChange = `${sizeDelta} bytes (${formatPercentChange(percentDelta)})`;
 
   const text = formatBytes(sizeDelta);
   const hashChangedOnly = cell.hashChangeUnexpected;
@@ -79,30 +86,66 @@ export const DeltaCell = (props: Props): React.ReactElement => {
     ? formatUnexpectedHashChange(cell.name, false)
     : `"${cell.name}" changed by ${stringChange}`;
 
+  const [dialogVisible, setDialogVisible] = React.useState(false);
+
+  const handlePress = React.useCallback((): void => {
+    setDialogVisible(true);
+  }, [setDialogVisible]);
+
+  const handleDismissDialog = React.useCallback((): void => {
+    setDialogVisible(false);
+  }, [setDialogVisible]);
+
+  const tableData = Object.keys(cell.sizes).reduce((memo: Array<[string, string]>, sizeKey: string): Array<
+    [string, string]
+  > => {
+    memo.push([sizeKey, `${formatBytes(cell.sizes[sizeKey])} (${formatPercentChange(cell.percents[sizeKey])})`]);
+    return memo;
+  }, []);
+
+  const tableFooter = failingBudgets.length ? (
+    failingBudgets.map((budget, index) => <Text key={index}>{formatBudgetResult(budget, cell.name, true)}</Text>)
+  ) : hashChangedOnly ? (
+    <Text>{formatUnexpectedHashChange(cell.name, true)}</Text>
+  ) : null;
+
   return (
     <Td accessibilityLabel={tooltipText} style={[style, { backgroundColor }]}>
       {text ? (
-        <Hoverable>
-          {(isHovered) => (
-            <View ref={viewRef} style={styles.textWrapper} testID="delta">
-              {sizeDelta !== 0 || hashChangedOnly ? (
-                <Text>
-                  {errorBudgets.length ? (
-                    <ErrorIcon />
-                  ) : warningBudgets.length ? (
-                    <WarningIcon />
-                  ) : hashChangedOnly ? (
-                    <HashIcon />
-                  ) : (
-                    ''
-                  )}{' '}
-                </Text>
-              ) : null}
-              {sizeDelta !== 0 ? <Text>{text}</Text> : null}
-              {isHovered ? <RelativeTooltip relativeTo={viewRef} text={tooltipText} /> : null}
-            </View>
-          )}
-        </Hoverable>
+        <TouchableOpacity onPress={handlePress} ref={cellRef}>
+          <Hoverable>
+            {(isHovered) => (
+              <View ref={viewRef} style={styles.textWrapper} testID="delta">
+                {sizeDelta !== 0 || hashChangedOnly ? (
+                  <Text>
+                    {errorBudgets.length ? (
+                      <ErrorIcon />
+                    ) : warningBudgets.length ? (
+                      <WarningIcon />
+                    ) : hashChangedOnly ? (
+                      <HashIcon />
+                    ) : (
+                      ''
+                    )}{' '}
+                  </Text>
+                ) : null}
+                {sizeDelta !== 0 ? <Text>{text}</Text> : null}
+                {isHovered ? <RelativeTooltip relativeTo={viewRef} text={tooltipText} /> : null}
+              </View>
+            )}
+          </Hoverable>
+          {dialogVisible ? (
+            <RelativeModal onDismiss={handleDismissDialog} portalRootID="menuPortal" relativeTo={cellRef}>
+              <TabularMetadata
+                data={tableData}
+                footer={<View>{tableFooter}</View>}
+                icon={CloseIcon}
+                onClose={handleDismissDialog}
+                title={`${cell.name} delta`}
+              />
+            </RelativeModal>
+          ) : null}
+        </TouchableOpacity>
       ) : null}
     </Td>
   );
diff --git a/src/app/src/components/ComparisonTable/TotalCell.tsx b/src/app/src/components/ComparisonTable/TotalCell.tsx
index d88e6ba..731c66b 100644
--- a/src/app/src/components/ComparisonTable/TotalCell.tsx
+++ b/src/app/src/components/ComparisonTable/TotalCell.tsx
@@ -2,10 +2,13 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import { TotalCell as Cell } from '@build-tracker/comparator';
+import CloseIcon from '../../icons/Close';
 import { formatBytes } from '@build-tracker/formatting';
 import React from 'react';
-import { Td } from './../Table';
-import { StyleProp, Text, ViewStyle } from 'react-native';
+import RelativeModal from '../RelativeModal';
+import TabularMetadata from '../TabularMetadata';
+import { Td } from '../Table';
+import { StyleProp, Text, TouchableOpacity, ViewStyle } from 'react-native';
 
 interface Props {
   cell: Cell;
@@ -17,7 +20,37 @@ export const TotalCell = (props: Props): React.ReactElement => {
   const { cell, sizeKey, style } = props;
   const value = cell.sizes[sizeKey];
   const text = value === 0 ? '' : formatBytes(value);
-  return <Td style={style}>{text ? <Text>{text}</Text> : null}</Td>;
+
+  const cellRef = React.useRef();
+  const [dialogVisible, setDialogVisible] = React.useState(false);
+
+  const handlePress = React.useCallback((): void => {
+    setDialogVisible(true);
+  }, [setDialogVisible]);
+
+  const handleDismissDialog = React.useCallback((): void => {
+    setDialogVisible(false);
+  }, [setDialogVisible]);
+
+  const tableData = Object.keys(cell.sizes).reduce((memo: Array<[string, string]>, metaKey: string): Array<
+    [string, string]
+  > => {
+    memo.push([metaKey, formatBytes(cell.sizes[metaKey])]);
+    return memo;
+  }, []);
+
+  return (
+    <Td style={style}>
+      <TouchableOpacity onPress={handlePress} ref={cellRef}>
+        {text ? <Text>{text}</Text> : null}
+      </TouchableOpacity>
+      {dialogVisible ? (
+        <RelativeModal onDismiss={handleDismissDialog} portalRootID="menuPortal" relativeTo={cellRef}>
+          <TabularMetadata data={tableData} icon={CloseIcon} onClose={handleDismissDialog} title={cell.name} />
+        </RelativeModal>
+      ) : null}
+    </Td>
+  );
 };
 
 export default React.memo(TotalCell);
diff --git a/src/app/src/components/RelativeModal.tsx b/src/app/src/components/RelativeModal.tsx
index 4633b13..41a8bb0 100644
--- a/src/app/src/components/RelativeModal.tsx
+++ b/src/app/src/components/RelativeModal.tsx
@@ -61,7 +61,7 @@ const Menu = (props: Props): React.ReactElement => {
         testID="overlay"
       />
       <View
-        accessibilityRole={accessibilityRole}
+        accessibilityRole={accessibilityRole || 'dialog'}
         ref={ref}
         style={[
           styles.root,
diff --git a/src/app/src/components/TabularMetadata.tsx b/src/app/src/components/TabularMetadata.tsx
new file mode 100644
index 0000000..40f29c1
--- /dev/null
+++ b/src/app/src/components/TabularMetadata.tsx
@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as Theme from '../theme';
+import { BuildMetaItem } from '@build-tracker/build';
+import Button from './Button';
+import CloseIcon from '../icons/Close';
+import React from 'react';
+import TextLink from './TextLink';
+import { StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native';
+import { Table, Tbody, Td, Th, Tr } from './Table';
+
+interface Props {
+  closeButtonLabel?: string;
+  data: Array<[string, BuildMetaItem]>;
+  footer?: React.ReactElement;
+  icon?: React.ComponentType<{ style?: StyleProp<TextStyle> }>;
+  onClose?: () => void;
+  title: string;
+}
+
+function getMetaValue(val: string | BuildMetaItem): string {
+  // @ts-ignore
+  return typeof val === 'object' && val.hasOwnProperty('value') ? val.value : val;
+}
+
+function getMetaUrl(val: string | BuildMetaItem): string | undefined {
+  // @ts-ignore
+  return typeof val === 'object' && val.hasOwnProperty('url') ? val.url : undefined;
+}
+
+const TabularMetadata = (props: Props): React.ReactElement => {
+  const { closeButtonLabel, data, footer, icon, onClose, title } = props;
+
+  const handleClose = React.useCallback(() => {
+    onClose && onClose();
+  }, [onClose]);
+
+  return (
+    <View style={styles.root}>
+      <View style={styles.header}>
+        <Text style={styles.headerText}>{title}</Text>
+        <Button icon={icon || CloseIcon} iconOnly onPress={handleClose} title={closeButtonLabel || 'Close'} />
+      </View>
+      <Table>
+        <Tbody>
+          {data.map(([key, dataValue]) => {
+            const value = getMetaValue(dataValue);
+            const url = getMetaUrl(dataValue);
+            return (
+              <Tr key={key}>
+                <Th>
+                  <Text>{key}</Text>
+                </Th>
+                <Td style={styles.infoCell}>{url ? <TextLink href={url} text={value} /> : <Text>{value}</Text>}</Td>
+              </Tr>
+            );
+          })}
+        </Tbody>
+      </Table>
+      {footer ? <View style={styles.footer}>{footer}</View> : null}
+    </View>
+  );
+};
+
+const styles = StyleSheet.create({
+  root: {
+    paddingVertical: Theme.Spacing.Small,
+    paddingHorizontal: Theme.Spacing.Small,
+  },
+  header: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+  },
+  headerText: {
+    fontWeight: Theme.FontWeight.Bold,
+    fontSize: Theme.FontSize.Normal,
+  },
+  infoCell: {
+    textAlign: 'left',
+  },
+  footer: {
+    alignItems: 'flex-start',
+    paddingTop: Theme.Spacing.Normal,
+  },
+});
+
+export default TabularMetadata;
diff --git a/typings/react-native/index.d.ts b/typings/react-native/index.d.ts
index 94631aa..7edd260 100644
--- a/typings/react-native/index.d.ts
+++ b/typings/react-native/index.d.ts
@@ -443,6 +443,7 @@ export interface Insets {
  * @see React.DOMAtributes
  */
 export interface Touchable {
+  autoFocus?: boolean;
   onTouchStart?: (event: GestureResponderEvent) => void;
   onTouchMove?: (event: GestureResponderEvent) => void;
   onTouchEnd?: (event: GestureResponderEvent) => void;
@@ -695,7 +696,8 @@ export interface TransformsStyle {
     | TranslateXTransform
     | TranslateYTransform
     | SkewXTransform
-    | SkewYTransform)[];
+    | SkewYTransform
+  )[];
   transformMatrix?: Array<number>;
   rotation?: number;
   scaleX?: number;
@@ -1581,6 +1583,7 @@ export type AccessibilityRole =
   | 'button'
   | 'checkbox'
   | 'combobox'
+  | 'dialog'
   | 'header'
   | 'image'
   | 'imagebutton'
@@ -1644,6 +1647,8 @@ export interface ViewProps extends GestureResponderHandlers, Touchable, Accessib
 
   href?: string;
 
+  onKeyPress?: (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => void;
+
   /**
    * Invoked on mount and layout changes with
    *


Test patch:
diff --git a/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx b/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
index 1e25aa9..cb67f4f 100644
--- a/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
@@ -10,6 +10,7 @@ import { Td } from '../../Table';
 import WarningIcon from '../../../icons/Warning';
 import { BudgetLevel, BudgetType } from '@build-tracker/types';
 import { fireEvent, render } from 'react-native-testing-library';
+import { formatBudgetResult, formatUnexpectedHashChange } from '@build-tracker/formatting';
 import { StyleSheet, Text } from 'react-native';
 
 describe('DeltaCell', () => {
@@ -278,4 +279,117 @@ describe('DeltaCell', () => {
       expect(queryAllByProps({ accessibilityRole: 'tooltip' })).toHaveLength(0);
     });
   });
+
+  describe('modal dialog', () => {
+    test('shows and hides modal dialog when closed', () => {
+      const { getByProps, getByText } = render(
+        <DeltaCell
+          cell={{
+            type: CellType.DELTA,
+            budgets: [],
+            failingBudgets: [],
+            name: 'tacos',
+            percents: { gzip: 0.2, stat: 0.5 },
+            hashChanged: true,
+            hashChangeUnexpected: false,
+            sizes: { gzip: 2400, stat: 4300 },
+          }}
+          sizeKey="stat"
+        />
+      );
+      fireEvent.press(getByText('4.2 KiB'));
+      expect(getByProps({ role: 'dialog' })).not.toBeNull();
+      fireEvent.press(getByProps({ role: 'button', 'aria-label': 'Close' }));
+      expect(() => getByProps({ role: 'dialog' })).toThrow();
+    });
+
+    test('renders all stat size information in a modal dialog', () => {
+      const { getByText, queryAllByText } = render(
+        <DeltaCell
+          cell={{
+            type: CellType.DELTA,
+            budgets: [],
+            failingBudgets: [],
+            name: 'tacos',
+            percents: { gzip: 0.2, stat: 0.5 },
+            hashChanged: true,
+            hashChangeUnexpected: false,
+            sizes: { gzip: 2400, stat: 4300 },
+          }}
+          sizeKey="stat"
+        />
+      );
+      fireEvent.press(getByText('4.2 KiB'));
+      expect(queryAllByText('gzip')).toHaveLength(1);
+      expect(queryAllByText('2.34 KiB (20.000%)')).toHaveLength(1);
+      expect(queryAllByText('gzip')).toHaveLength(1);
+      expect(queryAllByText('4.2 KiB (50.000%)')).toHaveLength(1);
+    });
+
+    test('renders a footer with a text summary of failing budgets', () => {
+      const budget0 = {
+        level: BudgetLevel.ERROR,
+        type: BudgetType.SIZE,
+        sizeKey: 'stat',
+        passing: false,
+        actual: 5,
+        expected: 2,
+      };
+      const budget1 = {
+        level: BudgetLevel.WARN,
+        type: BudgetType.DELTA,
+        sizeKey: 'stat',
+        passing: false,
+        actual: 5,
+        expected: 2,
+      };
+      const budget2 = {
+        level: BudgetLevel.WARN,
+        type: BudgetType.PERCENT_DELTA,
+        sizeKey: 'stat',
+        passing: false,
+        actual: 5,
+        expected: 2,
+      };
+      const { getByText, queryAllByText } = render(
+        <DeltaCell
+          cell={{
+            type: CellType.DELTA,
+            budgets: [budget0, budget1, budget2],
+            failingBudgets: [budget0, budget1],
+            name: 'tacos',
+            percents: { gzip: 0.2, stat: 0.5 },
+            hashChanged: true,
+            hashChangeUnexpected: false,
+            sizes: { gzip: 2400, stat: 4300 },
+          }}
+          sizeKey="stat"
+        />
+      );
+      fireEvent.press(getByText('4.2 KiB'));
+      expect(queryAllByText(formatBudgetResult(budget0, 'tacos', true))).toHaveLength(1);
+      expect(queryAllByText(formatBudgetResult(budget1, 'tacos', true))).toHaveLength(1);
+      expect(queryAllByText(formatBudgetResult(budget2, 'tacos', true))).toHaveLength(0);
+    });
+
+    test('renders a footer with a text summary of unexpected hash change', () => {
+      const { getByText, queryAllByText } = render(
+        <DeltaCell
+          cell={{
+            type: CellType.DELTA,
+            budgets: [],
+            failingBudgets: [],
+            name: 'tacos',
+            percents: { gzip: 0.2, stat: 0.5 },
+            hashChanged: true,
+            hashChangeUnexpected: true,
+            sizes: { gzip: 2400, stat: 4300 },
+          }}
+          sizeKey="stat"
+        />
+      );
+      fireEvent.press(getByText('4.2 KiB'));
+      expect(queryAllByText(formatUnexpectedHashChange('tacos', true))).toHaveLength(1);
+    });
+  });
 });
diff --git a/src/app/src/components/ComparisonTable/__tests__/TotalCell.test.tsx b/src/app/src/components/ComparisonTable/__tests__/TotalCell.test.tsx
index a011097..cd766ea 100644
--- a/src/app/src/components/ComparisonTable/__tests__/TotalCell.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/TotalCell.test.tsx
@@ -3,9 +3,9 @@
  */
 import { CellType } from '@build-tracker/comparator';
 import React from 'react';
-import { render } from 'react-native-testing-library';
 import { Text } from 'react-native';
 import { TotalCell } from '../TotalCell';
+import { fireEvent, render } from 'react-native-testing-library';
 
 describe('TotalCell', () => {
   test('Displays a formatted value', () => {
@@ -21,4 +21,27 @@ describe('TotalCell', () => {
     );
     expect(queryAllByType(Text)).toHaveLength(0);
   });
+
+  describe('modal dialog', () => {
+    test('shows and hides modal dialog when closed', () => {
+      const { getByProps, getByText } = render(
+        <TotalCell cell={{ type: CellType.TOTAL, name: 'tacos', sizes: { stat: 4300, gzip: 2400 } }} sizeKey="stat" />
+      );
+      fireEvent.press(getByText('4.2 KiB'));
+      expect(getByProps({ role: 'dialog' })).not.toBeNull();
+      fireEvent.press(getByProps({ role: 'button', 'aria-label': 'Close' }));
+      expect(() => getByProps({ role: 'dialog' })).toThrow();
+    });
+
+    test('renders all stat size information in a modal dialog', () => {
+      const { getByText, queryAllByText } = render(
+        <TotalCell cell={{ type: CellType.TOTAL, name: 'tacos', sizes: { stat: 4300, gzip: 2400 } }} sizeKey="stat" />
+      );
+      fireEvent.press(getByText('4.2 KiB'));
+      expect(queryAllByText('gzip')).toHaveLength(1);
+      expect(queryAllByText('2.34 KiB')).toHaveLength(1);
+      expect(queryAllByText('stat')).toHaveLength(1);
+      expect(queryAllByText('4.2 KiB')).toHaveLength(2);
+    });
+  });
 });
diff --git a/src/app/src/components/__tests__/BuildInfo.test.tsx b/src/app/src/components/__tests__/BuildInfo.test.tsx
index 1d717f3..a10fdee 100644
--- a/src/app/src/components/__tests__/BuildInfo.test.tsx
+++ b/src/app/src/components/__tests__/BuildInfo.test.tsx
@@ -8,7 +8,6 @@ import Comparator from '@build-tracker/comparator';
 import mockStore from '../../store/mock';
 import { Provider } from 'react-redux';
 import React from 'react';
-import TextLink from '../TextLink';
 import { fireEvent, render } from 'react-native-testing-library';
 
 const build = new Build({ branch: 'master', revision: '1234565', parentRevision: 'abcdef', timestamp: 123 }, []);
@@ -25,54 +24,6 @@ describe('BuildInfo', () => {
     expect(focusRevisionSpy).toHaveBeenCalledWith(undefined);
   });
 
-  test('renders a text link if the revision has a URL', () => {
-    const buildA = new Build(
-      {
-        branch: 'master',
-        revision: {
-          value: '123456',
-          url: 'https://github.com/paularmstrong/build-tracker/commit/123456',
-        },
-        parentRevision: 'abcdef',
-        timestamp: 123,
-      },
-      []
-    );
-    const { getByType } = render(
-      <Provider store={mockStore({ comparator: new Comparator({ builds: [buildA] }) })}>
-        <BuildInfo focusedRevision="123456" />
-      </Provider>
-    );
-    expect(getByType(TextLink).props).toMatchObject({
-      href: 'https://github.com/paularmstrong/build-tracker/commit/123456',
-      text: '123456',
-    });
-  });
-
-  test('renders a text link for other keys that have a URL', () => {
-    const buildA = new Build(
-      {
-        branch: 'master',
-        revision: '123456',
-        parentRevision: {
-          value: 'abcdef',
-          url: 'https://github.com/paularmstrong/build-tracker/commit/abcdef',
-        },
-        timestamp: 123,
-      },
-      []
-    );
-    const { getByType } = render(
-      <Provider store={mockStore({ comparator: new Comparator({ builds: [buildA] }) })}>
-        <BuildInfo focusedRevision="123456" />
-      </Provider>
-    );
-    expect(getByType(TextLink).props).toMatchObject({
-      href: 'https://github.com/paularmstrong/build-tracker/commit/abcdef',
-      text: 'abcdef',
-    });
-  });
-
   test('removes the build focus on button press', () => {
     const removeComparedRevisionSpy = jest.spyOn(Actions, 'removeComparedRevision');
     const { getByProps } = render(
diff --git a/src/app/src/components/__tests__/TabularMetadata.test.tsx b/src/app/src/components/__tests__/TabularMetadata.test.tsx
new file mode 100644
index 0000000..46a8701
--- /dev/null
+++ b/src/app/src/components/__tests__/TabularMetadata.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import React from 'react';
+import TabularMetadata from '../TabularMetadata';
+import TextLink from '../TextLink';
+import { View } from 'react-native';
+import { fireEvent, render } from 'react-native-testing-library';
+
+describe('TabularMetadata', () => {
+  describe('header', () => {
+    test('renders a header with title and button', () => {
+      const { getByText } = render(<TabularMetadata data={[]} title="Tacos" />);
+      expect(getByText('Tacos')).not.toBeNull();
+    });
+
+    test('on button press, calls onClose method', () => {
+      const handleClose = jest.fn();
+      const { getByProps } = render(<TabularMetadata data={[]} onClose={handleClose} title="Tacos" />);
+      fireEvent.press(getByProps({ role: 'button', 'aria-label': 'Close' }));
+      expect(handleClose).toHaveBeenCalled();
+    });
+
+    test('does not require onClose method', () => {
+      const { getByProps } = render(<TabularMetadata data={[]} title="Tacos" />);
+      expect(() => fireEvent.press(getByProps({ role: 'button', 'aria-label': 'Close' }))).not.toThrow();
+    });
+  });
+
+  describe('table', () => {
+    test('renders rows with data', () => {
+      const { getByText } = render(
+        <TabularMetadata
+          data={[
+            ['special', 'tacos'],
+            ['regular', 'burritos'],
+          ]}
+          title="Tacos"
+        />
+      );
+      expect(getByText('special')).not.toBeNull();
+      expect(getByText('tacos')).not.toBeNull();
+      expect(getByText('regular')).not.toBeNull();
+      expect(getByText('burritos')).not.toBeNull();
+    });
+
+    test('renders links when URLs are provided', () => {
+      const { getByType } = render(
+        <TabularMetadata data={[['special', { value: 'tacos', url: '/tacos-url' }]]} title="Tacos" />
+      );
+      expect(getByType(TextLink).props).toMatchObject({
+        href: '/tacos-url',
+        text: 'tacos',
+      });
+    });
+  });
+
+  describe('footer', () => {
+    test('renders footer when provided', () => {
+      const footer = <View testID="footer" />;
+      const { getByTestId } = render(<TabularMetadata data={[]} footer={footer} title="Tacos" />);
+      expect(getByTestId('footer')).not.toBeNull();
+    });
+  });
+});