diff --git a/superset/assets/javascripts/SqlLab/components/DataPreviewModal.jsx b/superset/assets/javascripts/SqlLab/components/DataPreviewModal.jsx
index 79e4b5cc16..843ed4bd63 100644
--- a/superset/assets/javascripts/SqlLab/components/DataPreviewModal.jsx
+++ b/superset/assets/javascripts/SqlLab/components/DataPreviewModal.jsx
@@ -32,7 +32,13 @@ class DataPreviewModal extends React.PureComponent {
-
+
);
diff --git a/superset/assets/javascripts/SqlLab/components/QueryTable.jsx b/superset/assets/javascripts/SqlLab/components/QueryTable.jsx
index 40b2da5e34..ffacccd78b 100644
--- a/superset/assets/javascripts/SqlLab/components/QueryTable.jsx
+++ b/superset/assets/javascripts/SqlLab/components/QueryTable.jsx
@@ -134,7 +134,9 @@ class QueryTable extends React.PureComponent {
modalTitle={'Data preview'}
beforeOpen={this.openAsyncResults.bind(this, query)}
onExit={this.clearQueryResults.bind(this, query)}
- modalBody={}
+ modalBody={
+
+ }
/>
);
} else {
diff --git a/superset/assets/javascripts/SqlLab/components/ResultSet.jsx b/superset/assets/javascripts/SqlLab/components/ResultSet.jsx
index d986d9030b..52b5aa306b 100644
--- a/superset/assets/javascripts/SqlLab/components/ResultSet.jsx
+++ b/superset/assets/javascripts/SqlLab/components/ResultSet.jsx
@@ -1,42 +1,40 @@
import React from 'react';
import { Alert, Button, ButtonGroup, ProgressBar } from 'react-bootstrap';
-import { Table } from 'reactable';
import shortid from 'shortid';
import VisualizeModal from './VisualizeModal';
import HighlightedSql from './HighlightedSql';
-
-const RESULTS_CONTROLS_HEIGHT = 36;
+import FilterableTable from '../../components/FilterableTable/FilterableTable';
const propTypes = {
actions: React.PropTypes.object,
csv: React.PropTypes.bool,
query: React.PropTypes.object,
search: React.PropTypes.bool,
- searchText: React.PropTypes.string,
showSql: React.PropTypes.bool,
visualize: React.PropTypes.bool,
cache: React.PropTypes.bool,
- resultSetHeight: React.PropTypes.number,
+ height: React.PropTypes.number.isRequired,
};
const defaultProps = {
search: true,
visualize: true,
showSql: false,
csv: true,
- searchText: '',
actions: {},
cache: false,
};
+const RESULT_SET_CONTROLS_HEIGHT = 46;
-class ResultSet extends React.PureComponent {
+export default class ResultSet extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
searchText: '',
showModal: false,
data: [],
+ height: props.search ? props.height - RESULT_SET_CONTROLS_HEIGHT : props.height,
};
}
componentWillReceiveProps(nextProps) {
@@ -54,6 +52,7 @@ class ResultSet extends React.PureComponent {
this.fetchResults(nextProps.query);
}
}
+
getControls() {
if (this.props.search || this.props.visualize || this.props.csv) {
let csvButton;
@@ -132,6 +131,7 @@ class ResultSet extends React.PureComponent {
reFetchQueryResults(query) {
this.props.actions.reFetchQueryResults(query);
}
+
render() {
const query = this.props.query;
const results = query.results;
@@ -195,31 +195,12 @@ class ResultSet extends React.PureComponent {
/>
{this.getControls.bind(this)()}
{sql}
-
-
col.name)}
- sortable
- className="table table-condensed table-bordered"
- filterBy={this.state.searchText}
- filterable={results.columns.map(c => c.name)}
- hideFilterInput
- />
-
+ col.name)}
+ height={this.state.height}
+ filterText={this.state.searchText}
+ />
);
}
@@ -240,5 +221,3 @@ class ResultSet extends React.PureComponent {
}
ResultSet.propTypes = propTypes;
ResultSet.defaultProps = defaultProps;
-
-export default ResultSet;
diff --git a/superset/assets/javascripts/SqlLab/components/SouthPane.jsx b/superset/assets/javascripts/SqlLab/components/SouthPane.jsx
index d59e8df442..d08683e790 100644
--- a/superset/assets/javascripts/SqlLab/components/SouthPane.jsx
+++ b/superset/assets/javascripts/SqlLab/components/SouthPane.jsx
@@ -71,7 +71,7 @@ class SouthPane extends React.PureComponent {
search
query={latestQuery}
actions={props.actions}
- resultSetHeight={this.state.innerTabHeight}
+ height={this.state.innerTabHeight}
/>
);
} else {
@@ -90,7 +90,7 @@ class SouthPane extends React.PureComponent {
csv={false}
actions={props.actions}
cache
- resultSetHeight={this.state.innerTabHeight}
+ height={this.state.innerTabHeight}
/>
));
diff --git a/superset/assets/javascripts/SqlLab/index.jsx b/superset/assets/javascripts/SqlLab/index.jsx
index 799739603d..fc40252c23 100644
--- a/superset/assets/javascripts/SqlLab/index.jsx
+++ b/superset/assets/javascripts/SqlLab/index.jsx
@@ -9,7 +9,9 @@ import { initEnhancer } from '../reduxUtils';
import { initJQueryAjaxCSRF } from '../modules/utils';
import App from './components/App';
import { appSetup } from '../common';
-import './main.css';
+
+require('./main.css');
+require('../components/FilterableTable/FilterableTableStyles.css');
appSetup();
initJQueryAjaxCSRF();
diff --git a/superset/assets/javascripts/SqlLab/main.css b/superset/assets/javascripts/SqlLab/main.css
index e39e6ba8f5..a3ad7dbe6b 100644
--- a/superset/assets/javascripts/SqlLab/main.css
+++ b/superset/assets/javascripts/SqlLab/main.css
@@ -237,16 +237,6 @@ div.tablePopover:hover {
padding-bottom: 3px;
padding-top: 3px;
}
-.ResultSet {
- overflow: auto;
- border-bottom: 1px solid #ccc;
-}
-.ResultSet table {
- margin-bottom: 0px;
-}
-.ResultSet table tr.last {
- border-bottom: none;
-}
.ace_editor {
border: 1px solid #ccc;
margin: 0px 0px 10px 0px;
diff --git a/superset/assets/javascripts/components/FilterableTable/FilterableTable.jsx b/superset/assets/javascripts/components/FilterableTable/FilterableTable.jsx
new file mode 100644
index 0000000000..c336148ba5
--- /dev/null
+++ b/superset/assets/javascripts/components/FilterableTable/FilterableTable.jsx
@@ -0,0 +1,190 @@
+import { List } from 'immutable';
+import React, { PropTypes, PureComponent } from 'react';
+import {
+ Column,
+ Table,
+ SortDirection,
+ SortIndicator,
+} from 'react-virtualized';
+import { getTextWidth } from '../../modules/visUtils';
+
+const propTypes = {
+ orderedColumnKeys: PropTypes.array.isRequired,
+ data: PropTypes.array.isRequired,
+ height: PropTypes.number.isRequired,
+ filterText: PropTypes.string,
+ headerHeight: PropTypes.number,
+ overscanRowCount: PropTypes.number,
+ rowHeight: PropTypes.number,
+ striped: PropTypes.bool,
+};
+
+const defaultProps = {
+ filterText: '',
+ headerHeight: 32,
+ overscanRowCount: 10,
+ rowHeight: 32,
+ striped: true,
+};
+
+export default class FilterableTable extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.list = List(this.formatTableData(props.data));
+ this.headerRenderer = this.headerRenderer.bind(this);
+ this.rowClassName = this.rowClassName.bind(this);
+ this.sort = this.sort.bind(this);
+
+ this.widthsForColumnsByKey = this.getWidthsForColumns();
+ this.totalTableWidth = props.orderedColumnKeys
+ .map(key => this.widthsForColumnsByKey[key])
+ .reduce((curr, next) => curr + next);
+
+ this.state = {
+ sortBy: props.orderedColumnKeys[0],
+ sortDirection: SortDirection.ASC,
+ fitted: false,
+ };
+ }
+
+ componentDidMount() {
+ this.fitTableToWidthIfNeeded();
+ }
+
+ getDatum(list, index) {
+ return list.get(index % list.size);
+ }
+
+ getWidthsForColumns() {
+ const PADDING = 40; // accounts for cell padding and width of sorting icon
+ const widthsByColumnKey = {};
+ this.props.orderedColumnKeys.forEach((key) => {
+ const colWidths = this.list
+ .map(d => getTextWidth(d[key]) + PADDING) // get width for each value for a key
+ .push(getTextWidth(key) + PADDING); // add width of column key to end of list
+ // set max width as value for key
+ widthsByColumnKey[key] = Math.max(...colWidths);
+ });
+ return widthsByColumnKey;
+ }
+
+ fitTableToWidthIfNeeded() {
+ const containerWidth = this.container.getBoundingClientRect().width;
+ if (containerWidth > this.totalTableWidth) {
+ this.totalTableWidth = containerWidth - 2; // accomodates 1px border on container
+ }
+ this.setState({ fitted: true });
+ }
+
+ formatTableData(data) {
+ const formattedData = data.map((row) => {
+ const newRow = {};
+ for (const k in row) {
+ const val = row[k];
+ if (typeof (val) === 'string') {
+ newRow[k] = val;
+ } else {
+ newRow[k] = JSON.stringify(val);
+ }
+ }
+ return newRow;
+ });
+ return formattedData;
+ }
+
+ hasMatch(text, row) {
+ const values = [];
+ for (const key in row) {
+ if (row.hasOwnProperty(key)) {
+ values.push(row[key].toLowerCase());
+ }
+ }
+ return values.some(v => v.includes(text.toLowerCase()));
+ }
+
+ headerRenderer({ dataKey, label, sortBy, sortDirection }) {
+ return (
+
+ {label}
+ {sortBy === dataKey &&
+
+ }
+
+ );
+ }
+
+ rowClassName({ index }) {
+ let className = '';
+ if (this.props.striped) {
+ className = index % 2 === 0 ? 'even-row' : 'odd-row';
+ }
+ return className;
+ }
+
+ sort({ sortBy, sortDirection }) {
+ this.setState({ sortBy, sortDirection });
+ }
+
+ render() {
+ const { sortBy, sortDirection } = this.state;
+ const {
+ filterText,
+ headerHeight,
+ height,
+ orderedColumnKeys,
+ overscanRowCount,
+ rowHeight,
+ } = this.props;
+
+ let sortedAndFilteredList = this.list;
+ // filter list
+ if (filterText) {
+ sortedAndFilteredList = this.list.filter(row => this.hasMatch(filterText, row));
+ }
+ // sort list
+ sortedAndFilteredList = sortedAndFilteredList
+ .sortBy(item => item[sortBy])
+ .update(list => sortDirection === SortDirection.DESC ? list.reverse() : list);
+
+ const rowGetter = ({ index }) => this.getDatum(sortedAndFilteredList, index);
+
+ return (
+ { this.container = ref; }}
+ >
+ {this.state.fitted &&
+
+ {orderedColumnKeys.map(columnKey => (
+
+ ))}
+
+ }
+
+ );
+ }
+}
+
+FilterableTable.propTypes = propTypes;
+FilterableTable.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/components/FilterableTable/FilterableTableStyles.css b/superset/assets/javascripts/components/FilterableTable/FilterableTableStyles.css
new file mode 100644
index 0000000000..c0c3717b63
--- /dev/null
+++ b/superset/assets/javascripts/components/FilterableTable/FilterableTableStyles.css
@@ -0,0 +1,60 @@
+.ReactVirtualized__Table__headerRow {
+ font-weight: 700;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+.ReactVirtualized__Table__row {
+ display: flex;
+ flex-direction: row;
+}
+.ReactVirtualized__Table__headerTruncatedText {
+ display: inline-block;
+ max-width: 100%;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+.ReactVirtualized__Table__headerColumn,
+.ReactVirtualized__Table__rowColumn {
+ min-width: 0px;
+ border-right: 1px solid #ccc;
+ align-self: center;
+ padding: 12px;
+ font-size: 12px;
+}
+.ReactVirtualized__Table__headerColumn:last-of-type,
+.ReactVirtualized__Table__rowColumn:last-of-type {
+ border-right: 0px;
+}
+.ReactVirtualized__Table__headerColumn:focus,
+.ReactVirtualized__Table__Grid:focus {
+ outline: none;
+}
+.ReactVirtualized__Table__rowColumn {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.ReactVirtualized__Table__sortableHeaderColumn {
+ cursor: pointer;
+}
+.ReactVirtualized__Table__sortableHeaderIconContainer {
+ display: flex;
+ align-items: center;
+}
+.ReactVirtualized__Table__sortableHeaderIcon {
+ flex: 0 0 24px;
+ height: 1em;
+ width: 1em;
+ fill: currentColor;
+}
+.even-row { background: #f2f2f2; }
+.odd-row { background: #ffffff; }
+.even-row,
+.odd-row {
+ border: none;
+}
+.filterable-table-container {
+ overflow: auto;
+ border: 1px solid #ccc;
+}
diff --git a/superset/assets/javascripts/modules/visUtils.js b/superset/assets/javascripts/modules/visUtils.js
new file mode 100644
index 0000000000..eef2babfb8
--- /dev/null
+++ b/superset/assets/javascripts/modules/visUtils.js
@@ -0,0 +1,11 @@
+export function getTextWidth(text, fontDetails = '12px Roboto') {
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+ context.font = fontDetails;
+ const metrics = context.measureText(text);
+ return metrics.width;
+}
+
+export default {
+ getTextWidth,
+};
diff --git a/superset/assets/package.json b/superset/assets/package.json
index 35b7afaf28..e58f572a73 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -84,6 +84,7 @@
"react-select-fast-filter-options": "^0.2.1",
"react-syntax-highlighter": "^5.0.0",
"react-virtualized-select": "^2.4.0",
+ "react-virtualized": "^9.3.0",
"reactable": "^0.14.0",
"redux": "^3.5.2",
"redux-localstorage": "^0.4.1",
diff --git a/superset/assets/spec/javascripts/components/FilterableTable/FilterableTable_spec.jsx b/superset/assets/spec/javascripts/components/FilterableTable/FilterableTable_spec.jsx
new file mode 100644
index 0000000000..f0de6f7ce2
--- /dev/null
+++ b/superset/assets/spec/javascripts/components/FilterableTable/FilterableTable_spec.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import FilterableTable from '../../../../javascripts/components/FilterableTable/FilterableTable';
+
+describe('FilterableTable', () => {
+ it('is valid element', () => {
+ expect(React.isValidElement()).to.equal(true);
+ });
+});
diff --git a/superset/assets/spec/javascripts/sqllab/ResultSet_spec.jsx b/superset/assets/spec/javascripts/sqllab/ResultSet_spec.jsx
index b31433af3c..3c46aa2f9e 100644
--- a/superset/assets/spec/javascripts/sqllab/ResultSet_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/ResultSet_spec.jsx
@@ -2,8 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
-import { Table } from 'reactable';
-
+import FilterableTable from '../../../javascripts/components/FilterableTable/FilterableTable';
import ResultSet from '../../../javascripts/SqlLab/components/ResultSet';
import { queries } from './fixtures';
@@ -21,6 +20,6 @@ describe('ResultSet', () => {
});
it('renders a Table', () => {
const wrapper = shallow();
- expect(wrapper.find(Table)).to.have.length(1);
+ expect(wrapper.find(FilterableTable)).to.have.length(1);
});
});
diff --git a/superset/assets/stylesheets/react-select/select.less b/superset/assets/stylesheets/react-select/select.less
index 4cc94dc179..87ae2d488a 100644
--- a/superset/assets/stylesheets/react-select/select.less
+++ b/superset/assets/stylesheets/react-select/select.less
@@ -91,7 +91,6 @@
@import "../../node_modules/react-select/less/mixins.less";
@import "../../node_modules/react-select/less/multi.less";
@import "../../node_modules/react-select/less/spinner.less";
-@import "../../node_modules/react-virtualized/styles.css";
// importing css from "../../node_modules/react-virtualized-select/styles.css";
// so the background color of a selected option matches the other selects