You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2017/10/04 17:17:35 UTC

[incubator-superset] branch master updated: New "Time Series - Table" visualization (#3543)

This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new bb0f69d  New "Time Series - Table" visualization (#3543)
bb0f69d is described below

commit bb0f69d074286185609cae06436ab04b70978d5d
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Wed Oct 4 10:17:33 2017 -0700

    New "Time Series - Table" visualization (#3543)
    
    * [WiP] adding a new "Time Series - Table" viz
    
    * Adding drag-n-drop to collection
    
    * Using keys in arrays
    
    * tests
---
 .../assets/images/viz_thumbnails/time_table.png    | Bin 0 -> 65153 bytes
 .../components/InfoTooltipWithTrigger.jsx          |  20 +-
 .../assets/javascripts/components/MetricOption.jsx |  21 +-
 .../javascripts/explore/components/Control.jsx     |  27 +--
 .../explore/components/controls/BoundsControl.jsx  |   5 -
 .../components/controls/CollectionControl.jsx      | 119 +++++++++++
 .../controls/TimeSeriesColumnControl.jsx           | 223 +++++++++++++++++++++
 .../explore/components/controls/index.jsx          |  33 +++
 superset/assets/javascripts/explore/main.css       |   5 +-
 .../assets/javascripts/explore/stores/controls.jsx |   8 +
 .../assets/javascripts/explore/stores/visTypes.js  |  19 ++
 superset/assets/javascripts/modules/colors.js      |   2 +
 superset/assets/js_build.sh                        |   1 -
 superset/assets/package.json                       |   6 +-
 .../components/TimeSeriesColumnControl_spec.jsx    |  33 +++
 superset/assets/stylesheets/superset.less          |   7 +
 superset/assets/visualizations/main.js             |   1 +
 superset/assets/visualizations/time_table.css      |   3 +
 superset/assets/visualizations/time_table.jsx      | 173 ++++++++++++++++
 superset/viz.py                                    |  94 +++++----
 20 files changed, 710 insertions(+), 90 deletions(-)

diff --git a/superset/assets/images/viz_thumbnails/time_table.png b/superset/assets/images/viz_thumbnails/time_table.png
new file mode 100644
index 0000000..5eba0c2
Binary files /dev/null and b/superset/assets/images/viz_thumbnails/time_table.png differ
diff --git a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
index 09632cd..d86d051 100644
--- a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
+++ b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
@@ -5,7 +5,7 @@ import { slugify } from '../modules/utils';
 
 const propTypes = {
   label: PropTypes.string.isRequired,
-  tooltip: PropTypes.string.isRequired,
+  tooltip: PropTypes.string,
   icon: PropTypes.string,
   className: PropTypes.string,
   onClick: PropTypes.func,
@@ -17,11 +17,21 @@ const defaultProps = {
   className: 'text-muted',
   placement: 'right',
 };
+const tooltipStyle = { wordWrap: 'break-word' };
 
 export default function InfoTooltipWithTrigger({
     label, tooltip, icon, className, onClick, placement, bsStyle }) {
   const iconClass = `fa fa-${icon} ${className} ${bsStyle ? 'text-' + bsStyle : ''}`;
-  const tooltipStyle = { wordWrap: 'break-word' };
+  const iconEl = (
+    <i
+      className={iconClass}
+      onClick={onClick}
+      style={{ cursor: onClick ? 'pointer' : null }}
+    />
+  );
+  if (!tooltip) {
+    return iconEl;
+  }
   return (
     <OverlayTrigger
       placement={placement}
@@ -31,11 +41,7 @@ export default function InfoTooltipWithTrigger({
         </Tooltip>
       }
     >
-      <i
-        className={iconClass}
-        onClick={onClick}
-        style={{ cursor: onClick ? 'pointer' : null }}
-      />
+      {iconEl}
     </OverlayTrigger>
   );
 }
diff --git a/superset/assets/javascripts/components/MetricOption.jsx b/superset/assets/javascripts/components/MetricOption.jsx
index b190434..f099414 100644
--- a/superset/assets/javascripts/components/MetricOption.jsx
+++ b/superset/assets/javascripts/components/MetricOption.jsx
@@ -5,9 +5,13 @@ import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
 
 const propTypes = {
   metric: PropTypes.object.isRequired,
+  showFormula: PropTypes.bool,
+};
+const defaultProps = {
+  showFormula: true,
 };
 
-export default function MetricOption({ metric }) {
+export default function MetricOption({ metric, showFormula }) {
   return (
     <div>
       <span className="m-r-5 option-label">
@@ -21,12 +25,14 @@ export default function MetricOption({ metric }) {
           label={`descr-${metric.metric_name}`}
         />
       }
-      <InfoTooltipWithTrigger
-        className="m-r-5 text-muted"
-        icon="question-circle-o"
-        tooltip={metric.expression}
-        label={`expr-${metric.metric_name}`}
-      />
+      {showFormula &&
+        <InfoTooltipWithTrigger
+          className="m-r-5 text-muted"
+          icon="question-circle-o"
+          tooltip={metric.expression}
+          label={`expr-${metric.metric_name}`}
+        />
+      }
       {metric.warning_text &&
         <InfoTooltipWithTrigger
           className="m-r-5 text-danger"
@@ -38,3 +44,4 @@ export default function MetricOption({ metric }) {
     </div>);
 }
 MetricOption.propTypes = propTypes;
+MetricOption.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/explore/components/Control.jsx b/superset/assets/javascripts/explore/components/Control.jsx
index 972ff0d..ed7ea97 100644
--- a/superset/assets/javascripts/explore/components/Control.jsx
+++ b/superset/assets/javascripts/explore/components/Control.jsx
@@ -1,33 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import BoundsControl from './controls/BoundsControl';
-import CheckboxControl from './controls/CheckboxControl';
-import ColorSchemeControl from './controls/ColorSchemeControl';
-import DatasourceControl from './controls/DatasourceControl';
-import DateFilterControl from './controls/DateFilterControl';
-import FilterControl from './controls/FilterControl';
-import HiddenControl from './controls/HiddenControl';
-import SelectAsyncControl from './controls/SelectAsyncControl';
-import SelectControl from './controls/SelectControl';
-import TextAreaControl from './controls/TextAreaControl';
-import TextControl from './controls/TextControl';
-import VizTypeControl from './controls/VizTypeControl';
+import controlMap from './controls';
 
-const controlMap = {
-  BoundsControl,
-  CheckboxControl,
-  DatasourceControl,
-  DateFilterControl,
-  FilterControl,
-  HiddenControl,
-  SelectControl,
-  TextAreaControl,
-  TextControl,
-  VizTypeControl,
-  ColorSchemeControl,
-  SelectAsyncControl,
-};
 const controlTypes = Object.keys(controlMap);
 
 const propTypes = {
diff --git a/superset/assets/javascripts/explore/components/controls/BoundsControl.jsx b/superset/assets/javascripts/explore/components/controls/BoundsControl.jsx
index 776f7a4..803a539 100644
--- a/superset/assets/javascripts/explore/components/controls/BoundsControl.jsx
+++ b/superset/assets/javascripts/explore/components/controls/BoundsControl.jsx
@@ -5,16 +5,11 @@ import ControlHeader from '../ControlHeader';
 import { t } from '../../../locales';
 
 const propTypes = {
-  name: PropTypes.string.isRequired,
-  label: PropTypes.string,
-  description: PropTypes.string,
   onChange: PropTypes.func,
   value: PropTypes.array,
 };
 
 const defaultProps = {
-  label: null,
-  description: null,
   onChange: () => {},
   value: [null, null],
 };
diff --git a/superset/assets/javascripts/explore/components/controls/CollectionControl.jsx b/superset/assets/javascripts/explore/components/controls/CollectionControl.jsx
new file mode 100644
index 0000000..74f0d9b
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/controls/CollectionControl.jsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { ListGroup, ListGroupItem } from 'react-bootstrap';
+import shortid from 'shortid';
+import {
+  SortableContainer, SortableHandle, SortableElement, arrayMove,
+} from 'react-sortable-hoc';
+
+import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger';
+import ControlHeader from '../ControlHeader';
+
+const propTypes = {
+  name: PropTypes.string.isRequired,
+  label: PropTypes.string,
+  description: PropTypes.string,
+  placeholder: PropTypes.string,
+  addTooltip: PropTypes.string,
+  itemGenerator: PropTypes.func,
+  keyAccessor: PropTypes.func,
+  onChange: PropTypes.func,
+  value: PropTypes.oneOfType([
+    PropTypes.array,
+  ]),
+  isFloat: PropTypes.bool,
+  isInt: PropTypes.bool,
+  control: PropTypes.func,
+};
+
+const defaultProps = {
+  label: null,
+  description: null,
+  onChange: () => {},
+  placeholder: 'Empty collection',
+  itemGenerator: () => ({ key: shortid.generate() }),
+  keyAccessor: o => o.key,
+  value: [],
+  addTooltip: 'Add an item',
+};
+const SortableListGroupItem = SortableElement(ListGroupItem);
+const SortableListGroup = SortableContainer(ListGroup);
+const SortableDragger = SortableHandle(() => (
+  <i className="fa fa-bars text-primary" style={{ cursor: 'ns-resize' }} />));
+
+export default class CollectionControl extends React.Component {
+  constructor(props) {
+    super(props);
+    this.onAdd = this.onAdd.bind(this);
+  }
+  onChange(i, value) {
+    Object.assign(this.props.value[i], value);
+    this.props.onChange(this.props.value);
+  }
+  onAdd() {
+    this.props.onChange(this.props.value.concat([this.props.itemGenerator()]));
+  }
+  onSortEnd({ oldIndex, newIndex }) {
+    this.props.onChange(arrayMove(this.props.value, oldIndex, newIndex));
+  }
+  removeItem(i) {
+    this.props.onChange(this.props.value.filter((o, ix) => i !== ix));
+  }
+  renderList() {
+    if (this.props.value.length === 0) {
+      return <div className="text-muted">{this.props.placeholder}</div>;
+    }
+    return (
+      <SortableListGroup
+        useDragHandle
+        lockAxis="y"
+        onSortEnd={this.onSortEnd.bind(this)}
+      >
+        {this.props.value.map((o, i) => (
+          <SortableListGroupItem
+            className="clearfix"
+            key={this.props.keyAccessor(o)}
+            index={i}
+          >
+            <div className="pull-left m-r-5">
+              <SortableDragger />
+            </div>
+            <div className="pull-left">
+              <this.props.control
+                {...o}
+                onChange={this.onChange.bind(this, i)}
+              />
+            </div>
+            <div className="pull-right">
+              <InfoTooltipWithTrigger
+                icon="times"
+                label="remove-item"
+                tooltip="remove item"
+                bsStyle="primary"
+                onClick={this.removeItem.bind(this, i)}
+              />
+            </div>
+          </SortableListGroupItem>))}
+      </SortableListGroup>
+    );
+  }
+  render() {
+    return (
+      <div>
+        <ControlHeader {...this.props} />
+        {this.renderList()}
+        <InfoTooltipWithTrigger
+          icon="plus-circle"
+          label="add-item"
+          tooltip={this.props.addTooltip}
+          bsStyle="primary"
+          className="fa-lg"
+          onClick={this.onAdd}
+        />
+      </div>
+    );
+  }
+}
+
+CollectionControl.propTypes = propTypes;
+CollectionControl.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/explore/components/controls/TimeSeriesColumnControl.jsx b/superset/assets/javascripts/explore/components/controls/TimeSeriesColumnControl.jsx
new file mode 100644
index 0000000..cd8ec98
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/controls/TimeSeriesColumnControl.jsx
@@ -0,0 +1,223 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  Row, Col, FormControl, OverlayTrigger, Popover,
+} from 'react-bootstrap';
+import Select from 'react-select';
+
+import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger';
+import BoundsControl from './BoundsControl';
+
+const propTypes = {
+  onChange: PropTypes.func,
+};
+
+const defaultProps = {
+  onChange: () => {},
+};
+
+const comparisonTypeOptions = [
+  { value: 'value', label: 'Actual value' },
+  { value: 'diff', label: 'Difference' },
+  { value: 'perc', label: 'Percentage' },
+  { value: 'perc_change', label: 'Percentage Change' },
+];
+
+const colTypeOptions = [
+  { value: 'time', label: 'Time Comparison' },
+  { value: 'contrib', label: 'Contribution' },
+  { value: 'spark', label: 'Sparkline' },
+  { value: 'avg', label: 'Period Average' },
+];
+
+export default class TimeSeriesColumnControl extends React.Component {
+  constructor(props) {
+    super(props);
+    const state = Object.assign({}, props);
+    delete state.onChange;
+    this.state = state;
+    this.onChange = this.onChange.bind(this);
+  }
+  onChange() {
+    this.props.onChange(this.state);
+  }
+  onSelectChange(attr, opt) {
+    this.setState({ [attr]: opt.value }, this.onChange);
+  }
+  onTextInputChange(attr, event) {
+    this.setState({ [attr]: event.target.value }, this.onChange);
+  }
+  onBoundsChange(bounds) {
+    this.setState({ bounds }, this.onChange);
+  }
+  setType() {
+  }
+  textSummary() {
+    return `${this.state.label}`;
+  }
+  edit() {
+  }
+  formRow(label, tooltip, ttLabel, control) {
+    return (
+      <Row style={{ marginTop: '5px' }}>
+        <Col md={5}>
+          {label}{' '}
+          <InfoTooltipWithTrigger
+            placement="top"
+            tooltip={tooltip}
+            label={ttLabel}
+          />
+        </Col>
+        <Col md={7}>{control}</Col>
+      </Row>
+    );
+  }
+  renderPopover() {
+    return (
+      <Popover id="ts-col-popo" title="Column Configuration">
+        <div style={{ width: '280px' }}>
+          {this.formRow(
+            'Label',
+            'The column header label',
+            'time-lag',
+            <FormControl
+              value={this.state.label}
+              onChange={this.onTextInputChange.bind(this, 'label')}
+              bsSize="small"
+              placeholder="Label"
+            />,
+          )}
+          {this.formRow(
+            'Tooltip',
+            'Column header tooltip',
+            'col-tooltip',
+            <FormControl
+              value={this.state.tooltip}
+              onChange={this.onTextInputChange.bind(this, 'tooltip')}
+              bsSize="small"
+              placeholder="Tooltip"
+            />,
+          )}
+          {this.formRow(
+            'Type',
+            'Type of comparison, value difference or percentage',
+            'col-type',
+            <Select
+              value={this.state.colType}
+              clearable={false}
+              onChange={this.onSelectChange.bind(this, 'colType')}
+              options={colTypeOptions}
+            />,
+          )}
+          <hr />
+          {this.state.colType === 'spark' && this.formRow(
+            'Width',
+            'Width of the sparkline',
+            'spark-width',
+            <FormControl
+              value={this.state.width}
+              onChange={this.onTextInputChange.bind(this, 'width')}
+              bsSize="small"
+              placeholder="Width"
+            />,
+          )}
+          {this.state.colType === 'spark' && this.formRow(
+            'Height',
+            'Height of the sparkline',
+            'spark-width',
+            <FormControl
+              value={this.state.height}
+              onChange={this.onTextInputChange.bind(this, 'height')}
+              bsSize="small"
+              placeholder="height"
+            />,
+          )}
+          {['time', 'avg'].indexOf(this.state.colType) >= 0 && this.formRow(
+            'Time Lag',
+            'Number of periods to compare against',
+            'time-lag',
+            <FormControl
+              value={this.state.timeLag}
+              onChange={this.onTextInputChange.bind(this, 'timeLag')}
+              bsSize="small"
+              placeholder="Time Lag"
+            />,
+          )}
+          {['spark'].indexOf(this.state.colType) >= 0 && this.formRow(
+            'Time Ratio',
+            'Number of periods to ratio against',
+            'time-ratio',
+            <FormControl
+              value={this.state.timeRatio}
+              onChange={this.onTextInputChange.bind(this, 'timeRatio')}
+              bsSize="small"
+              placeholder="Time Lag"
+            />,
+          )}
+          {this.state.colType === 'time' && this.formRow(
+            'Type',
+            'Type of comparison, value difference or percentage',
+            'comp-type',
+            <Select
+              value={this.state.comparisonType}
+              clearable={false}
+              onChange={this.onSelectChange.bind(this, 'comparisonType')}
+              options={comparisonTypeOptions}
+            />,
+          )}
+          {this.state.colType !== 'spark' && this.formRow(
+            'Bounds',
+            (
+              'Number bounds used for color coding from red to green. ' +
+              'Reverse the number for green to red. To get boolean ' +
+              'red or green without spectrum, you can use either only ' +
+              'min, or max, depending on whether small or big should be ' +
+              'green or red.'
+            ),
+            'bounds',
+            <BoundsControl
+              value={this.state.bounds}
+              onChange={this.onBoundsChange.bind(this)}
+            />,
+          )}
+          {this.formRow(
+            'D3 format',
+            'D3 format string',
+            'd3-format',
+            <FormControl
+              value={this.state.d3format}
+              onChange={this.onTextInputChange.bind(this, 'd3format')}
+              bsSize="small"
+              placeholder="D3 format string"
+            />,
+          )}
+        </div>
+      </Popover>
+    );
+  }
+  render() {
+    return (
+      <span>
+        {this.textSummary()}{' '}
+        <OverlayTrigger
+          container={document.body}
+          trigger="click"
+          rootClose
+          ref="trigger"
+          placement="right"
+          overlay={this.renderPopover()}
+        >
+          <InfoTooltipWithTrigger
+            icon="edit"
+            className="text-primary"
+            onClick={this.edit.bind(this)}
+            label="edit-ts-column"
+          />
+        </OverlayTrigger>
+      </span>
+    );
+  }
+}
+
+TimeSeriesColumnControl.propTypes = propTypes;
+TimeSeriesColumnControl.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/explore/components/controls/index.jsx b/superset/assets/javascripts/explore/components/controls/index.jsx
new file mode 100644
index 0000000..499e605
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/controls/index.jsx
@@ -0,0 +1,33 @@
+import BoundsControl from './BoundsControl';
+import CheckboxControl from './CheckboxControl';
+import CollectionControl from './CollectionControl';
+import ColorSchemeControl from './ColorSchemeControl';
+import DatasourceControl from './DatasourceControl';
+import DateFilterControl from './DateFilterControl';
+import FilterControl from './FilterControl';
+import HiddenControl from './HiddenControl';
+import SelectAsyncControl from './SelectAsyncControl';
+import SelectControl from './SelectControl';
+import TextAreaControl from './TextAreaControl';
+import TextControl from './TextControl';
+import TimeSeriesColumnControl from './TimeSeriesColumnControl';
+import VizTypeControl from './VizTypeControl';
+
+const controlMap = {
+  BoundsControl,
+  CheckboxControl,
+  CollectionControl,
+  ColorSchemeControl,
+  DatasourceControl,
+  DateFilterControl,
+  FilterControl,
+  HiddenControl,
+  SelectAsyncControl,
+  SelectControl,
+  TextAreaControl,
+  TextControl,
+  TimeSeriesColumnControl,
+  VizTypeControl,
+};
+
+export default controlMap;
diff --git a/superset/assets/javascripts/explore/main.css b/superset/assets/javascripts/explore/main.css
index 684fdf0..bc67249 100644
--- a/superset/assets/javascripts/explore/main.css
+++ b/superset/assets/javascripts/explore/main.css
@@ -109,4 +109,7 @@
 }
 .save-modal-selector {
   margin: 10px 0;
-}
\ No newline at end of file
+}
+.list-group {
+  margin-bottom: 10px;
+}
diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx
index 92faad1..57222a6 100644
--- a/superset/assets/javascripts/explore/stores/controls.jsx
+++ b/superset/assets/javascripts/explore/stores/controls.jsx
@@ -5,6 +5,7 @@ import { ALL_COLOR_SCHEMES, spectrums } from '../../modules/colors';
 import MetricOption from '../../components/MetricOption';
 import ColumnOption from '../../components/ColumnOption';
 import { t } from '../../locales';
+import controlMap from '../components/controls';
 
 const D3_FORMAT_DOCS = 'D3 format syntax: https://github.com/d3/d3-format';
 
@@ -1410,5 +1411,12 @@ export const controls = {
     default: 4,
     description: 'Number of decimal places with which to display lift values',
   },
+  column_collection: {
+    type: 'CollectionControl',
+    label: t('Time Series Columns'),
+    validators: [v.nonEmpty],
+    control: controlMap.TimeSeriesColumnControl,
+  },
+
 };
 export default controls;
diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js
index 5648f4b..1d4d79b 100644
--- a/superset/assets/javascripts/explore/stores/visTypes.js
+++ b/superset/assets/javascripts/explore/stores/visTypes.js
@@ -369,6 +369,25 @@ export const visTypes = {
     },
   },
 
+  time_table: {
+    label: t('Time Series Table'),
+    controlPanelSections: [
+      {
+        label: t('Query'),
+        expanded: true,
+        controlSetRows: [
+          ['groupby', 'metrics'],
+          ['column_collection'],
+        ],
+      },
+    ],
+    controlOverrides: {
+      groupby: {
+        multiple: false,
+      },
+    },
+  },
+
   markup: {
     label: t('Markup'),
     controlPanelSections: [
diff --git a/superset/assets/javascripts/modules/colors.js b/superset/assets/javascripts/modules/colors.js
index 7fd585f..8e3e521 100644
--- a/superset/assets/javascripts/modules/colors.js
+++ b/superset/assets/javascripts/modules/colors.js
@@ -1,5 +1,7 @@
 import d3 from 'd3';
 
+export const brandColor = '#00A699';
+
 // Color related utility functions go in this object
 const bnbColors = [
   '#ff5a5f', // rausch
diff --git a/superset/assets/js_build.sh b/superset/assets/js_build.sh
index 7e48caa..c717398 100755
--- a/superset/assets/js_build.sh
+++ b/superset/assets/js_build.sh
@@ -5,7 +5,6 @@ npm --version
 node --version
 npm install -g yarn
 yarn
-npm run sync-backend
 npm run lint
 npm run test
 npm run build
diff --git a/superset/assets/package.json b/superset/assets/package.json
index c74f66d..15cfff2 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -40,6 +40,7 @@
   "homepage": "http://superset.apache.org/",
   "dependencies": {
     "@data-ui/event-flow": "0.0.8",
+    "@data-ui/sparkline": "0.0.1",
     "babel-register": "^6.24.1",
     "bootstrap": "^3.3.6",
     "brace": "^0.10.0",
@@ -56,12 +57,12 @@
     "distributions": "^1.0.0",
     "immutable": "^3.8.2",
     "jed": "^1.1.1",
-    "po2json": "^0.4.5",
     "jquery": "3.1.1",
     "lodash.throttle": "^4.1.1",
     "moment": "^2.14.1",
     "mustache": "^2.2.1",
     "nvd3": "1.8.6",
+    "po2json": "^0.4.5",
     "prop-types": "^15.6.0",
     "react": "^15.6.2",
     "react-ace": "^5.0.1",
@@ -70,8 +71,8 @@
     "react-alert": "^1.0.14",
     "react-bootstrap": "^0.31.2",
     "react-bootstrap-table": "^4.0.2",
-    "react-datetime": "^2.9.0",
     "react-dom": "^15.6.2",
+    "react-datetime": "2.9.0",
     "react-gravatar": "^2.6.1",
     "react-grid-layout": "^0.14.4",
     "react-map-gl": "^3.0.4",
@@ -79,6 +80,7 @@
     "react-resizable": "^1.3.3",
     "react-select": "1.0.0-rc.3",
     "react-select-fast-filter-options": "^0.2.1",
+    "react-sortable-hoc": "^0.6.7",
     "react-split-pane": "^0.1.66",
     "react-syntax-highlighter": "^5.7.0",
     "react-virtualized": "^9.3.0",
diff --git a/superset/assets/spec/javascripts/explore/components/TimeSeriesColumnControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/TimeSeriesColumnControl_spec.jsx
new file mode 100644
index 0000000..2f8cd6b
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/TimeSeriesColumnControl_spec.jsx
@@ -0,0 +1,33 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import { FormControl, OverlayTrigger } from 'react-bootstrap';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it, beforeEach } from 'mocha';
+import { shallow } from 'enzyme';
+
+import TimeSeriesColumnControl from '../../../../javascripts/explore/components/controls/TimeSeriesColumnControl';
+
+const defaultProps = {
+  name: 'x_axis_label',
+  label: 'X Axis Label',
+  onChange: sinon.spy(),
+};
+
+describe('SelectControl', () => {
+  let wrapper;
+  let inst;
+  beforeEach(() => {
+    wrapper = shallow(<TimeSeriesColumnControl {...defaultProps} />);
+    inst = wrapper.instance();
+  });
+
+  it('renders an OverlayTrigger', () => {
+    expect(wrapper.find(OverlayTrigger)).to.have.lengthOf(1);
+  });
+
+  it('renders an Popover', () => {
+    const popOver = shallow(inst.renderPopover());
+    expect(popOver.find(FormControl)).to.have.lengthOf(3);
+  });
+});
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index a4bb70a..78f26bb 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -1,4 +1,5 @@
 @import './less/index.less';
+@import "./less/cosmo/variables.less";
 
 body {
     margin: 0px !important;
@@ -364,6 +365,9 @@ iframe {
 .PopoverSection {
   padding-bottom: 10px;
 }
+.popover {
+  max-width: 500px !important;
+}
 .float-left {
   float: left;
 }
@@ -382,3 +386,6 @@ g.annotation-container {
     stroke-width: 1;
   }
 }
+.stroke-primary {
+  stroke: @brand-primary;
+}
diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js
index d5c3abb..dc5ee30 100644
--- a/superset/assets/visualizations/main.js
+++ b/superset/assets/visualizations/main.js
@@ -27,6 +27,7 @@ const vizMap = {
   separator: require('./markup.js'),
   sunburst: require('./sunburst.js'),
   table: require('./table.js'),
+  time_table: require('./time_table.jsx'),
   treemap: require('./treemap.js'),
   country_map: require('./country_map.js'),
   word_cloud: require('./word_cloud.js'),
diff --git a/superset/assets/visualizations/time_table.css b/superset/assets/visualizations/time_table.css
new file mode 100644
index 0000000..5f8a41b
--- /dev/null
+++ b/superset/assets/visualizations/time_table.css
@@ -0,0 +1,3 @@
+.time-table {
+  overflow: auto;
+}
diff --git a/superset/assets/visualizations/time_table.jsx b/superset/assets/visualizations/time_table.jsx
new file mode 100644
index 0000000..928352c
--- /dev/null
+++ b/superset/assets/visualizations/time_table.jsx
@@ -0,0 +1,173 @@
+import ReactDOM from 'react-dom';
+import React from 'react';
+import propTypes from 'prop-types';
+import { Table, Thead, Th } from 'reactable';
+import d3 from 'd3';
+import { Sparkline, LineSeries, PointSeries } from '@data-ui/sparkline';
+
+import MetricOption from '../javascripts/components/MetricOption';
+import TooltipWrapper from '../javascripts/components/TooltipWrapper';
+import { d3format, brandColor } from '../javascripts/modules/utils';
+import InfoTooltipWithTrigger from '../javascripts/components/InfoTooltipWithTrigger';
+import './time_table.css';
+
+const SPARK_MARGIN = 3;
+const ACCESSIBLE_COLOR_BOUNDS = ['#ca0020', '#0571b0'];
+
+function FormattedNumber({ num, format }) {
+  if (format) {
+    return (
+      <span title={num}>{d3format(format, num)}</span>
+    );
+  }
+  return <span>{num}</span>;
+}
+FormattedNumber.propTypes = {
+  num: propTypes.number.isRequired,
+  format: propTypes.string.isRequired,
+};
+
+function viz(slice, payload) {
+  slice.container.css('overflow', 'auto');
+  slice.container.css('height', slice.height());
+  const recs = payload.data.records;
+  const fd = payload.form_data;
+  const data = Object.keys(recs).sort().map((iso) => {
+    const o = recs[iso];
+    return o;
+  });
+  const reversedData = data.slice();
+  reversedData.reverse();
+  const metricMap = {};
+  slice.datasource.metrics.forEach((m) => {
+    metricMap[m.metric_name] = m;
+  });
+
+  let metrics;
+  if (payload.data.is_group_by) {
+    // Sorting by first column desc
+    metrics = payload.data.columns.sort((m1, m2) => (
+      reversedData[0][m1] > reversedData[0][m2] ? -1 : 1
+    ));
+  } else {
+    // Using ordering specified in Metrics dropdown
+    metrics = payload.data.columns;
+  }
+  const tableData = metrics.map((metric) => {
+    let leftCell;
+    if (!payload.data.is_group_by) {
+      leftCell = <MetricOption metric={metricMap[metric]} showFormula={false} />;
+    } else {
+      leftCell = metric;
+    }
+    const row = { metric: leftCell };
+    fd.column_collection.forEach((c) => {
+      if (c.colType === 'spark') {
+        let sparkData;
+        if (!c.timeRatio) {
+          sparkData = data.map(d => d[metric]);
+        } else {
+          // Period ratio sparkline
+          sparkData = [];
+          for (let i = c.timeRatio; i < data.length; i++) {
+            sparkData.push(data[i][metric] / data[i - c.timeRatio][metric]);
+          }
+        }
+        const extent = d3.extent(data, d => d[metric]);
+        const tooltip = `min: ${extent[0]}, max: ${extent[1]}`;
+        row[c.key] = (
+          <TooltipWrapper label="tt-spark" tooltip={tooltip}>
+            <div>
+              <Sparkline
+                ariaLabel={`spark-${metric}`}
+                width={parseInt(c.width, 10) || 300}
+                height={parseInt(c.height, 10) || 50}
+                margin={{
+                  top: SPARK_MARGIN,
+                  bottom: SPARK_MARGIN,
+                  left: SPARK_MARGIN,
+                  right: SPARK_MARGIN,
+                }}
+                data={sparkData}
+              >
+                <LineSeries
+                  showArea={false}
+                  stroke={brandColor}
+                />
+                <PointSeries
+                  points={['min', 'max', 'last']}
+                  fill={brandColor}
+                />
+              </Sparkline>
+            </div>
+          </TooltipWrapper>);
+      } else {
+        const recent = reversedData[0][metric];
+        let v;
+        if (c.colType === 'time') {
+          // Time lag ratio
+          v = reversedData[parseInt(c.timeLag, 10)][metric];
+          if (c.comparisonType === 'diff') {
+            v -= recent;
+          } else if (c.comparisonType === 'perc') {
+            v /= recent;
+          } else if (c.comparisonType === 'perc_change') {
+            v = (v / recent) - 1;
+          }
+        } else if (c.colType === 'contrib') {
+          // contribution to column total
+          v = recent / Object.keys(reversedData[0])
+          .map(k => reversedData[0][k])
+          .reduce((a, b) => a + b);
+        } else if (c.colType === 'avg') {
+          // Average over the last {timeLag}
+          v = reversedData
+          .map((k, i) => i < c.timeLag ? k[metric] : 0)
+          .reduce((a, b) => a + b) / c.timeLag;
+        }
+        let color;
+        if (c.bounds && c.bounds[0] !== null && c.bounds[1] !== null) {
+          const scaler = d3.scale.linear()
+            .domain([
+              c.bounds[0],
+              c.bounds[0] + ((c.bounds[1] - c.bounds[0]) / 2),
+              c.bounds[1]])
+            .range([ACCESSIBLE_COLOR_BOUNDS[0], 'grey', ACCESSIBLE_COLOR_BOUNDS[1]]);
+          color = scaler(v);
+        } else if (c.bounds && c.bounds[0] !== null) {
+          color = v >= c.bounds[0] ? ACCESSIBLE_COLOR_BOUNDS[1] : ACCESSIBLE_COLOR_BOUNDS[0];
+        } else if (c.bounds && c.bounds[1] !== null) {
+          color = v < c.bounds[1] ? ACCESSIBLE_COLOR_BOUNDS[1] : ACCESSIBLE_COLOR_BOUNDS[0];
+        }
+        row[c.key] = (
+          <span style={{ color }}>
+            <FormattedNumber num={v} format={c.d3format} />
+          </span>);
+      }
+    });
+    return row;
+  });
+  ReactDOM.render(
+    <Table
+      className="table table-condensed"
+      data={tableData}
+    >
+      <Thead>
+        <Th column="metric">Metric</Th>
+        {fd.column_collection.map((c, i) => (
+          <Th column={c.key} key={c.key} width={c.colType === 'spark' ? '1%' : null}>
+            {c.label} {c.tooltip && (
+              <InfoTooltipWithTrigger
+                tooltip={c.tooltip}
+                label={`tt-col-${i}`}
+                placement="top"
+              />
+            )}
+          </Th>))}
+      </Thead>
+    </Table>,
+    document.getElementById(slice.containerId),
+  );
+}
+
+module.exports = viz;
diff --git a/superset/viz.py b/superset/viz.py
index 22d2ea9..2283e8d 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -10,12 +10,13 @@ from __future__ import unicode_literals
 
 import copy
 import hashlib
+import inspect
 import logging
 import traceback
 import uuid
 import zlib
 
-from collections import OrderedDict, defaultdict
+from collections import defaultdict
 from itertools import product
 from datetime import datetime, timedelta
 
@@ -422,6 +423,48 @@ class TableViz(BaseViz):
             return super(TableViz, self).json_dumps(obj)
 
 
+class TimeTableViz(BaseViz):
+
+    """A data table with rich time-series related columns"""
+
+    viz_type = "time_table"
+    verbose_name = _("Time Table View")
+    credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original'
+    is_timeseries = True
+
+    def query_obj(self):
+        d = super(TimeTableViz, self).query_obj()
+        fd = self.form_data
+
+        if not fd.get('metrics'):
+            raise Exception(_("Pick at least one metric"))
+
+        if fd.get('groupby') and len(fd.get('metrics')) > 1:
+            raise Exception(_(
+                "When using 'Group By' you are limited to use "
+                "a single metric"))
+        return d
+
+    def get_data(self, df):
+        fd = self.form_data
+        values = self.metrics
+        columns = None
+        if fd.get('groupby'):
+            values = self.metrics[0]
+            columns = fd.get('groupby')
+        pt = df.pivot_table(
+            index=DTTM_ALIAS,
+            columns=columns,
+            values=values)
+        pt.index = pt.index.map(str)
+        pt = pt.sort_index()
+        return dict(
+            records=pt.to_dict(orient='index'),
+            columns=list(pt.columns),
+            is_group_by=len(fd.get('groupby')) > 0,
+        )
+
+
 class PivotTableViz(BaseViz):
 
     """A pivot table view, define your rows, columns and metrics"""
@@ -1669,6 +1712,7 @@ class MapboxViz(BaseViz):
             "color": fd.get("mapbox_color"),
         }
 
+
 class EventFlowViz(BaseViz):
     """A visualization to explore patterns in event sequences"""
 
@@ -1684,7 +1728,8 @@ class EventFlowViz(BaseViz):
         event_key = form_data.get('all_columns_x')
         entity_key = form_data.get('entity')
         meta_keys = [
-            col for col in form_data.get('all_columns') if col != event_key and col != entity_key
+            col for col in form_data.get('all_columns')
+            if col != event_key and col != entity_key
         ]
 
         query['columns'] = [event_key, entity_key] + meta_keys
@@ -1758,42 +1803,9 @@ class PairedTTestViz(BaseViz):
         return data
 
 
-viz_types_list = [
-    TableViz,
-    PivotTableViz,
-    NVD3TimeSeriesViz,
-    NVD3DualLineViz,
-    NVD3CompareTimeSeriesViz,
-    NVD3TimeSeriesStackedViz,
-    NVD3TimeSeriesBarViz,
-    DistributionBarViz,
-    DistributionPieViz,
-    BubbleViz,
-    BulletViz,
-    MarkupViz,
-    WordCloudViz,
-    BigNumberViz,
-    BigNumberTotalViz,
-    SunburstViz,
-    DirectedForceViz,
-    SankeyViz,
-    CountryMapViz,
-    ChordViz,
-    WorldMapViz,
-    FilterBoxViz,
-    IFrameViz,
-    ParallelCoordinatesViz,
-    HeatmapViz,
-    BoxPlotViz,
-    TreemapViz,
-    CalHeatmapViz,
-    HorizonViz,
-    MapboxViz,
-    HistogramViz,
-    SeparatorViz,
-    EventFlowViz,
-    PairedTTestViz,
-]
-
-viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list
-                         if v.viz_type not in config.get('VIZ_TYPE_BLACKLIST')])
+viz_types = {
+    o.viz_type: o for o in globals().values()
+    if (
+        inspect.isclass(o) and
+        issubclass(o, BaseViz) and
+        o.viz_type not in config.get('VIZ_TYPE_BLACKLIST'))}

-- 
To stop receiving notification emails like this one, please contact
['"commits@superset.apache.org" <co...@superset.apache.org>'].