[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
This commit is contained in:
Alanna Scott 2017-04-18 14:29:38 -07:00 committed by GitHub
parent f40499e550
commit db6cd21504
13 changed files with 302 additions and 53 deletions

View File

@ -32,7 +32,13 @@ class DataPreviewModal extends React.PureComponent {
</Modal.Title>
</Modal.Header>
<Modal.Body>
<ResultSet query={query} visualize={false} csv={false} actions={this.props.actions} />
<ResultSet
query={query}
visualize={false}
csv={false}
actions={this.props.actions}
height={400}
/>
</Modal.Body>
</Modal>
);

View File

@ -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={<ResultSet showSql query={query} actions={this.props.actions} />}
modalBody={
<ResultSet showSql query={query} actions={this.props.actions} height={400} />
}
/>
);
} else {

View File

@ -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}
<div
className="ResultSet"
style={{ height: `${this.props.resultSetHeight - RESULTS_CONTROLS_HEIGHT}px` }}
>
<Table
data={data.map(function (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;
})}
columns={results.columns.map(col => col.name)}
sortable
className="table table-condensed table-bordered"
filterBy={this.state.searchText}
filterable={results.columns.map(c => c.name)}
hideFilterInput
/>
</div>
<FilterableTable
data={data}
orderedColumnKeys={results.columns.map(col => col.name)}
height={this.state.height}
filterText={this.state.searchText}
/>
</div>
);
}
@ -240,5 +221,3 @@ class ResultSet extends React.PureComponent {
}
ResultSet.propTypes = propTypes;
ResultSet.defaultProps = defaultProps;
export default ResultSet;

View File

@ -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}
/>
</Tab>
));

View File

@ -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();

View File

@ -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;

View File

@ -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 (
<div>
{label}
{sortBy === dataKey &&
<SortIndicator sortDirection={sortDirection} />
}
</div>
);
}
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 (
<div
style={{ height }}
className="filterable-table-container"
ref={(ref) => { this.container = ref; }}
>
{this.state.fitted &&
<Table
ref="Table"
headerHeight={headerHeight}
height={height - 2}
overscanRowCount={overscanRowCount}
rowClassName={this.rowClassName}
rowHeight={rowHeight}
rowGetter={rowGetter}
rowCount={sortedAndFilteredList.size}
sort={this.sort}
sortBy={sortBy}
sortDirection={sortDirection}
width={this.totalTableWidth}
>
{orderedColumnKeys.map(columnKey => (
<Column
dataKey={columnKey}
disableSort={false}
headerRenderer={this.headerRenderer}
width={this.widthsForColumnsByKey[columnKey]}
label={columnKey}
key={columnKey}
/>
))}
</Table>
}
</div>
);
}
}
FilterableTable.propTypes = propTypes;
FilterableTable.defaultProps = defaultProps;

View File

@ -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;
}

View File

@ -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,
};

View File

@ -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",

View File

@ -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(<FilterableTable />)).to.equal(true);
});
});

View File

@ -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(<ResultSet {...mockedProps} />);
expect(wrapper.find(Table)).to.have.length(1);
expect(wrapper.find(FilterableTable)).to.have.length(1);
});
});

View File

@ -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