mirror of https://github.com/apache/superset.git
[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:
parent
f40499e550
commit
db6cd21504
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
));
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue