From db6cd215040220f3cf3636f1decb25139f395cad Mon Sep 17 00:00:00 2001 From: Alanna Scott Date: Tue, 18 Apr 2017 14:29:38 -0700 Subject: [PATCH] [sqllab] table refactor (#2587) * make react-virtualized table work use dynamic sizing for cell width enable filtering require height prop for result set component * fix tests and linting * move some state to props * move getTextWidth to visUtils * make striped rows optional * fix striped proptype * update name to FilterableTable * add basic test and fix linting * accept array of columns keys rather than an array of objects that needs to be mapped * move container div inside the component * rename styles * fit table component to width if it's smaller than parent container * move stylesheet to javascript folder otherwise it throws an error on npm run prod * move css to index.jsx * fix result set spec * fix linting and test * fix result set props * keep list immutable --- .../SqlLab/components/DataPreviewModal.jsx | 8 +- .../SqlLab/components/QueryTable.jsx | 4 +- .../SqlLab/components/ResultSet.jsx | 47 ++--- .../SqlLab/components/SouthPane.jsx | 4 +- superset/assets/javascripts/SqlLab/index.jsx | 4 +- superset/assets/javascripts/SqlLab/main.css | 10 - .../FilterableTable/FilterableTable.jsx | 190 ++++++++++++++++++ .../FilterableTable/FilterableTableStyles.css | 60 ++++++ .../assets/javascripts/modules/visUtils.js | 11 + superset/assets/package.json | 1 + .../FilterableTable/FilterableTable_spec.jsx | 10 + .../javascripts/sqllab/ResultSet_spec.jsx | 5 +- .../stylesheets/react-select/select.less | 1 - 13 files changed, 302 insertions(+), 53 deletions(-) create mode 100644 superset/assets/javascripts/components/FilterableTable/FilterableTable.jsx create mode 100644 superset/assets/javascripts/components/FilterableTable/FilterableTableStyles.css create mode 100644 superset/assets/javascripts/modules/visUtils.js create mode 100644 superset/assets/spec/javascripts/components/FilterableTable/FilterableTable_spec.jsx 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