[sql lab] Make sql editor resizable (#3242)

* Update to the version of react-ace with the fixed sizing issues

* Make ace editor resizable

* Use small util method for offset calculation instead of $

* Test ResizableAceEditor

* Make the right pane of the Sql Lab scrollable

* Add default and min height to the ResizableAceEditor

* Implement SplitPane

* Make Splitter fullscreen

* React on resize of the window

* Implement min and max

* Get rid of a magic number + add margin

* Handle resize event with delay + cleanup the code

* Make ResultSet adjustable

* Make QueryHistory adjustable

* Remove ResizableAceEditor

* Make linter happy

* Test SplitPane

* Init sizes properly
This commit is contained in:
Dmitry Goryunov 2017-08-19 02:15:25 +02:00 committed by Maxime Beauchemin
parent 6fc837db51
commit 75e69f02e8
10 changed files with 359 additions and 40 deletions

View File

@ -32,6 +32,7 @@ const propTypes = {
sql: PropTypes.string.isRequired, sql: PropTypes.string.isRequired,
tables: PropTypes.array, tables: PropTypes.array,
queryEditor: PropTypes.object.isRequired, queryEditor: PropTypes.object.isRequired,
height: PropTypes.string,
}; };
const defaultProps = { const defaultProps = {
@ -119,14 +120,15 @@ class AceEditorWrapper extends React.PureComponent {
this.setState({ sql: text }); this.setState({ sql: text });
} }
render() { render() {
const { height } = this.props;
return ( return (
<AceEditor <AceEditor
ref="editor"
mode="sql" mode="sql"
theme="github" theme="github"
onLoad={this.onEditorLoad.bind(this)} onLoad={this.onEditorLoad.bind(this)}
onBlur={this.onBlur.bind(this)} onBlur={this.onBlur.bind(this)}
minLines={12} height={height}
maxLines={12}
onChange={this.textChange.bind(this)} onChange={this.textChange.bind(this)}
width="100%" width="100%"
editorProps={{ $blockScrolling: true }} editorProps={{ $blockScrolling: true }}

View File

@ -36,7 +36,6 @@ export default class ResultSet extends React.PureComponent {
searchText: '', searchText: '',
showModal: false, showModal: false,
data: null, data: null,
height: props.search ? props.height - RESULT_SET_CONTROLS_HEIGHT : props.height,
}; };
} }
componentDidMount() { componentDidMount() {
@ -143,7 +142,8 @@ export default class ResultSet extends React.PureComponent {
} }
} }
render() { render() {
const query = this.props.query; const { query, search, height } = this.props;
const tableHeight = search ? height - RESULT_SET_CONTROLS_HEIGHT : height;
let sql; let sql;
if (this.props.showSql) { if (this.props.showSql) {
@ -190,7 +190,7 @@ export default class ResultSet extends React.PureComponent {
<FilterableTable <FilterableTable
data={data} data={data}
orderedColumnKeys={results.columns.map(col => col.name)} orderedColumnKeys={results.columns.map(col => col.name)}
height={this.state.height} height={tableHeight}
filterText={this.state.searchText} filterText={this.state.searchText}
/> />
</div> </div>

View File

@ -4,6 +4,7 @@ import shortid from 'shortid';
import { Alert, Tab, Tabs } from 'react-bootstrap'; import { Alert, Tab, Tabs } from 'react-bootstrap';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { AutoSizer } from 'react-virtualized';
import * as Actions from '../actions'; import * as Actions from '../actions';
import QueryHistory from './QueryHistory'; import QueryHistory from './QueryHistory';
@ -55,6 +56,7 @@ class SouthPane extends React.PureComponent {
return window.innerHeight - sum - 95; return window.innerHeight - sum - 95;
} }
switchTab(id) { switchTab(id) {
this.props.actions.setActiveSouthPaneTab(id); this.props.actions.setActiveSouthPaneTab(id);
} }
@ -67,13 +69,28 @@ class SouthPane extends React.PureComponent {
let results; let results;
if (latestQuery) { if (latestQuery) {
results = ( results = (
<ResultSet <AutoSizer
showControls disableWidth
search >
query={latestQuery} {({ height }) => {
actions={props.actions} /*
height={this.state.innerTabHeight} checking of the height probably won't be necessary
/> after release of react-virtualized v10
*/
if (height !== 0) {
return (
<ResultSet
showControls
search
query={latestQuery}
actions={props.actions}
height={height}
/>
);
}
return <div />;
}}
</AutoSizer>
); );
} else { } else {
results = <Alert bsStyle="info">Run a query to display results here</Alert>; results = <Alert bsStyle="info">Run a query to display results here</Alert>;
@ -85,20 +102,36 @@ class SouthPane extends React.PureComponent {
eventKey={query.id} eventKey={query.id}
key={query.id} key={query.id}
> >
<ResultSet <AutoSizer
query={query} disableWidth
visualize={false} >
csv={false} {({ height }) => {
actions={props.actions} /*
cache checking of the height probably won't be necessary
height={this.state.innerTabHeight} after release of react-virtualized v10
/> */
if (height !== 0) {
return (
<ResultSet
query={query}
visualize={false}
csv={false}
actions={props.actions}
cache
height={height}
/>
);
}
return <div />;
}}
</AutoSizer>
</Tab> </Tab>
)); ));
return ( return (
<div className="SouthPane"> <div className="SouthPane">
<Tabs <Tabs
className="Tabs"
bsStyle="tabs" bsStyle="tabs"
id={shortid.generate()} id={shortid.generate()}
activeKey={this.props.activeSouthPaneTab} activeKey={this.props.activeSouthPaneTab}
@ -114,7 +147,7 @@ class SouthPane extends React.PureComponent {
title="Query History" title="Query History"
eventKey="History" eventKey="History"
> >
<div style={{ height: `${this.state.innerTabHeight}px`, overflow: 'scroll' }}> <div className="QueryHistoryWrapper">
<QueryHistory queries={props.editorQueries} actions={props.actions} /> <QueryHistory queries={props.editorQueries} actions={props.actions} />
</div> </div>
</Tab> </Tab>

View File

@ -0,0 +1,146 @@
import React from 'react';
import PropTypes from 'prop-types';
import delay from 'lodash.delay';
import { getTopOffset } from '../../../utils/common';
const propTypes = {
north: PropTypes.object.isRequired,
south: PropTypes.object.isRequired,
minHeight: PropTypes.number,
onSizeChange: PropTypes.func,
};
class SplitPane extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
dragging: false,
};
this.handleDraggingStart = this.handleDraggingStart.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleResize = this.handleResize.bind(this);
}
componentDidMount() {
window.addEventListener('mouseup', this.handleMouseUp);
window.addEventListener('mousemove', this.handleMouseMove);
window.addEventListener('resize', this.handleResize);
this.initSize();
}
componentWillUnmount() {
window.removeEventListener('mouseup', this.handleMouseUp);
window.removeEventListener('mousemove', this.handleMouseMove);
window.removeEventListener('resize', this.handleResize);
}
setSize(northInPercent, southInPercent) {
const totalHeight = this.refs.splitter.clientHeight - this.refs.dragBar.clientHeight;
const heightNorthInPixels = northInPercent * totalHeight / 100;
const heightSouthInPixels = southInPercent * totalHeight / 100;
if (this.props.onSizeChange) {
this.props.onSizeChange({
north: heightNorthInPixels,
south: heightSouthInPixels,
});
}
}
initSize() {
const totalHeight = this.refs.splitter.clientHeight;
const dragBarHeight = this.refs.dragBar.clientHeight;
const heightInPixels = (totalHeight - dragBarHeight) / 2;
const heightInPercent = heightInPixels * 100 / totalHeight;
this.setState({
...this.state,
heightNorth: heightInPercent,
heightSouth: heightInPercent,
});
this.setSize(heightInPercent, heightInPercent);
}
handleMouseMove(e) {
if (!this.state.dragging) {
return;
}
const minHeight = this.props.minHeight || 0;
const offset = getTopOffset(this.refs.splitter);
const totalHeight = this.refs.splitter.clientHeight;
const dragBarHeight = this.refs.dragBar.clientHeight;
const heightNorthInPixels = e.pageY - offset;
const heightSouthInPixels = totalHeight - heightNorthInPixels - dragBarHeight;
const heightNorthInPercent = 100 * heightNorthInPixels / totalHeight;
const heightSouthInPercent = 100 * heightSouthInPixels / totalHeight;
if (heightNorthInPercent >= minHeight
&& heightSouthInPercent >= minHeight) {
this.setState({
...this.state,
heightNorth: heightNorthInPercent,
heightSouth: heightSouthInPercent,
});
this.setSize(heightNorthInPercent, heightSouthInPercent);
}
}
handleDraggingStart() {
this.setState({ ...this.state, dragging: true });
}
handleResize() {
const { heightNorth, heightSouth } = this.state;
/*
The `delay` is needed since some events like 'onresize' happen before rendering.
That means that we can't calculate the sizes right.
*/
delay(() => {
this.setSize(heightNorth, heightSouth);
}, 100);
}
handleMouseUp() {
if (this.state.dragging) {
this.setState({ ...this.state, dragging: false });
}
}
render() {
return (
<div ref="splitter" className="Splitter">
<div
style={{ height: this.state.heightNorth + '%' }}
>
{this.props.north}
</div>
<div
ref="dragBar"
className="DragBar"
onMouseDown={this.handleDraggingStart}
>
<div className="DragBarVisible" />
</div>
<div
style={{ height: this.state.heightSouth + '%' }}
>
{this.props.south}
</div>
</div>
);
}
}
SplitPane.propTypes = propTypes;
export default SplitPane;

View File

@ -15,14 +15,16 @@ import {
import Button from '../../components/Button'; import Button from '../../components/Button';
import AceEditorWrapper from './AceEditorWrapper';
import SouthPane from './SouthPane'; import SouthPane from './SouthPane';
import SplitPane from './SplitPane';
import SaveQuery from './SaveQuery'; import SaveQuery from './SaveQuery';
import Timer from '../../components/Timer'; import Timer from '../../components/Timer';
import SqlEditorLeftBar from './SqlEditorLeftBar'; import SqlEditorLeftBar from './SqlEditorLeftBar';
import AceEditorWrapper from './AceEditorWrapper';
import { STATE_BSSTYLE_MAP } from '../constants'; import { STATE_BSSTYLE_MAP } from '../constants';
import RunQueryActionButton from './RunQueryActionButton'; import RunQueryActionButton from './RunQueryActionButton';
const propTypes = { const propTypes = {
actions: PropTypes.object.isRequired, actions: PropTypes.object.isRequired,
height: PropTypes.string.isRequired, height: PropTypes.string.isRequired,
@ -49,6 +51,7 @@ class SqlEditor extends React.PureComponent {
autorun: props.queryEditor.autorun, autorun: props.queryEditor.autorun,
ctas: '', ctas: '',
}; };
this.onSizeChange = this.onSizeChange.bind(this);
} }
componentDidMount() { componentDidMount() {
this.onMount(); this.onMount();
@ -60,6 +63,13 @@ class SqlEditor extends React.PureComponent {
this.startQuery(); this.startQuery();
} }
} }
onSizeChange(newSizes) {
const bottomBarHeight = this.refs.editorBottomBar.clientHeight;
this.setState({
...this.state,
editorHeight: newSizes.north - bottomBarHeight,
});
}
setQueryEditorSql(sql) { setQueryEditorSql(sql) {
this.props.actions.queryEditorSetSql(this.props.queryEditor, sql); this.props.actions.queryEditorSetSql(this.props.queryEditor, sql);
} }
@ -147,7 +157,7 @@ class SqlEditor extends React.PureComponent {
); );
} }
const editorBottomBar = ( const editorBottomBar = (
<div className="sql-toolbar clearfix" id="js-sql-toolbar"> <div ref="editorBottomBar" className="sql-toolbar clearfix" id="js-sql-toolbar">
<div className="pull-left"> <div className="pull-left">
<Form inline> <Form inline>
<RunQueryActionButton <RunQueryActionButton
@ -212,21 +222,34 @@ class SqlEditor extends React.PureComponent {
sm={this.props.hideLeftBar ? 12 : 7} sm={this.props.hideLeftBar ? 12 : 7}
md={this.props.hideLeftBar ? 12 : 8} md={this.props.hideLeftBar ? 12 : 8}
lg={this.props.hideLeftBar ? 12 : 9} lg={this.props.hideLeftBar ? 12 : 9}
style={{
height: this.sqlEditorHeight(),
}}
> >
<AceEditorWrapper <SplitPane
actions={this.props.actions} onSizeChange={this.onSizeChange}
onBlur={this.setQueryEditorSql.bind(this)} minHeight={25}
queryEditor={this.props.queryEditor} north={
onAltEnter={this.runQuery.bind(this)} <div>
sql={this.props.queryEditor.sql} <AceEditorWrapper
tables={this.props.tables} actions={this.props.actions}
/> onBlur={this.setQueryEditorSql.bind(this)}
{editorBottomBar} queryEditor={this.props.queryEditor}
<br /> onAltEnter={this.runQuery.bind(this)}
<SouthPane sql={this.props.queryEditor.sql}
editorQueries={this.props.editorQueries} tables={this.props.tables}
dataPreviewQueries={this.props.dataPreviewQueries} height={this.state.editorHeight + 'px'}
actions={this.props.actions} />
{editorBottomBar}
</div>
}
south={
<SouthPane
editorQueries={this.props.editorQueries}
dataPreviewQueries={this.props.dataPreviewQueries}
actions={this.props.actions}
/>
}
/> />
</Col> </Col>
</Row> </Row>

View File

@ -31,6 +31,40 @@ body {
padding-top: 5px; padding-top: 5px;
padding-bottom: 5px; padding-bottom: 5px;
} }
.DragBar {
padding: 10px 0;
text-align: center;
width: 100%;
}
.DragBarVisible {
width: 100%;
height: 3px;
background-color: #ccc;
cursor: row-resize;
}
.Splitter {
height: 100%;
}
.SqlEditor .SouthPane{
height:100%;
}
.SqlEditor .SouthPane .Tabs{
height:100%;
display: flex;
flex-direction: column;
}
.SqlEditor .SouthPane .Tabs .tab-content{
height:100%;
display: flex;
flex: 1 1;
}
.SqlEditor .SouthPane .Tabs .tab-pane{
width:100%;
}
.SqlEditor .QueryHistoryWrapper{
height: 100%;
overflow: scroll;
}
.scrollbar-container { .scrollbar-container {
position: relative; position: relative;
@ -237,8 +271,8 @@ div.tablePopover:hover {
padding-top: 3px; padding-top: 3px;
} }
.ace_editor { .ace_editor {
border: 1px solid #ccc; border: 1px solid #ccc;
margin: 0px 0px 10px 0px; margin: 0px 0px 10px 0px;
} }
.Select-menu-outer { .Select-menu-outer {
@ -253,6 +287,10 @@ div.tablePopover:hover {
padding-top: 10px; padding-top: 10px;
} }
.NorthPane {
width: 100%;
}
.TableElement { .TableElement {
margin-right: 10px; margin-right: 10px;
} }

View File

@ -55,13 +55,14 @@
"datatables.net-bs": "^1.10.12", "datatables.net-bs": "^1.10.12",
"immutable": "^3.8.1", "immutable": "^3.8.1",
"jquery": "^3.2.1", "jquery": "^3.2.1",
"lodash.delay": "^4.1.1",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"moment": "^2.14.1", "moment": "^2.14.1",
"mustache": "^2.2.1", "mustache": "^2.2.1",
"nvd3": "1.8.5", "nvd3": "1.8.5",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"react": "^15.5.1", "react": "^15.5.1",
"react-ace": "^5.0.1", "react-ace": "^5.1.2",
"react-addons-css-transition-group": "^15.6.0", "react-addons-css-transition-group": "^15.6.0",
"react-addons-shallow-compare": "^15.4.2", "react-addons-shallow-compare": "^15.4.2",
"react-alert": "^1.0.14", "react-alert": "^1.0.14",

View File

@ -0,0 +1,66 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import SplitPane from '../../../javascripts/SqlLab/components/SplitPane';
function simulateWindowEvent(eventName, customProps) {
const evt = document.createEvent('Event');
evt.initEvent(eventName, true, true);
global.window.dispatchEvent(Object.assign(evt, customProps));
}
const TestComponent = () => (<div className="test" />);
describe('ResizableAceEditor', () => {
const mockedProps = {
north: () => <div className="stub north" />,
south: () => <div className="stub south" />,
};
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
it('is valid', () => {
expect(
React.isValidElement(<SplitPane {...mockedProps} />),
).to.equal(true);
});
it('renders what you provide in north', () => {
const wrapper = shallow(<SplitPane {...mockedProps} north={<TestComponent />} />);
expect(wrapper.find(TestComponent)).to.have.length(1);
});
it('renders what you provide in south', () => {
const wrapper = shallow(<SplitPane {...mockedProps} south={<TestComponent />} />);
expect(wrapper.find(TestComponent)).to.have.length(1);
});
it('render a DragBar', () => {
const wrapper = shallow(<SplitPane {...mockedProps} />);
expect(wrapper.find('.DragBar')).to.have.length(1);
});
it('has dragging set to false by default', () => {
const wrapper = shallow(<SplitPane {...mockedProps} />);
expect(wrapper.state().dragging).to.be.equal(false);
});
it('has dragging set to true when dragged', () => {
const wrapper = shallow(<SplitPane {...mockedProps} />);
const dragbar = wrapper.find('.DragBar');
dragbar.simulate('mouseDown');
expect(wrapper.state().dragging).to.be.equal(true);
});
it('has dragging set to false when dropped', () => {
const wrapper = mount(<SplitPane {...mockedProps} />);
const dragbar = wrapper.find('.DragBar');
dragbar.simulate('mouseDown');
simulateWindowEvent('mouseup');
expect(wrapper.state().dragging).to.be.equal(false);
});
});

View File

@ -6,6 +6,7 @@ import { expect } from 'chai';
import { initialState, queries, table } from './fixtures'; import { initialState, queries, table } from './fixtures';
import SqlEditor from '../../../javascripts/SqlLab/components/SqlEditor'; import SqlEditor from '../../../javascripts/SqlLab/components/SqlEditor';
import SqlEditorLeftBar from '../../../javascripts/SqlLab/components/SqlEditorLeftBar'; import SqlEditorLeftBar from '../../../javascripts/SqlLab/components/SqlEditorLeftBar';
import SplitPane from '../../../javascripts/SqlLab/components/SplitPane';
describe('SqlEditor', () => { describe('SqlEditor', () => {
const mockedProps = { const mockedProps = {
@ -28,4 +29,8 @@ describe('SqlEditor', () => {
const wrapper = shallow(<SqlEditor {...mockedProps} />); const wrapper = shallow(<SqlEditor {...mockedProps} />);
expect(wrapper.find(SqlEditorLeftBar)).to.have.length(1); expect(wrapper.find(SqlEditorLeftBar)).to.have.length(1);
}); });
it('render a SplitPane', () => {
const wrapper = shallow(<SqlEditor {...mockedProps} />);
expect(wrapper.find(SplitPane)).to.have.length(1);
});
}); });

View File

@ -86,3 +86,8 @@ export function getShortUrl(longUrl, callback) {
}, },
}); });
} }
export function getTopOffset(element) {
const box = element.getBoundingClientRect();
return box.top + window.pageYOffset - document.documentElement.clientTop;
}