Relayout SQL Editor (#6872)

* Relayout SQL Editor

- Refactor SQL editor to remove usage of bootstrap col, row and collapse to simplify the layout
- Replace the react-split-pane libraray with react-split to allow custom styling of the gutter area without sacrifice correctness of the ace editor height calculation
- Rewrite the left pane animation via plain css transition and animate it to slide in and out
- General code and css clean up

* Smooth out the visual transition during dragging

(cherry picked from commit 19f82b729c7a939f12b1c5da6022c0fd76fa3ec9)

* Adjust how the height of the south pane is computed, fixing cypress tests
This commit is contained in:
Christine Chambers 2019-02-14 18:03:43 -08:00 committed by GitHub
parent 8302b9a276
commit ec6657ab2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 273 additions and 184 deletions

View File

@ -17339,6 +17339,15 @@
"prop-types": "^15.5.7"
}
},
"react-split": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.4.tgz",
"integrity": "sha512-NBKm9MaqzG/00laMUaS8GS9RnItVSekNNwItgGLMbFTeUa9w4bIY8Co/LszNBnpza9n2am0MXIw3SmyiMnhs+w==",
"requires": {
"prop-types": "^15.5.7",
"split.js": "^1.5.9"
}
},
"react-split-pane": {
"version": "0.1.85",
"resolved": "https://registry.npmjs.org/react-split-pane/-/react-split-pane-0.1.85.tgz",
@ -17380,6 +17389,17 @@
"refractor": "^2.4.1"
}
},
"react-transition-group": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.3.tgz",
"integrity": "sha512-2DGFck6h99kLNr8pOFk+z4Soq3iISydwOFeeEVPjTN6+Y01CmvbWmnN02VuTWyFdnRtIDPe+wy2q6Ui8snBPZg==",
"requires": {
"dom-helpers": "^3.3.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2",
"react-lifecycles-compat": "^3.0.4"
}
},
"react-virtualized": {
"version": "9.19.1",
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.19.1.tgz",
@ -19391,6 +19411,11 @@
}
}
},
"split.js": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/split.js/-/split.js-1.5.10.tgz",
"integrity": "sha512-/J52X5c4ZypVwu4WAhD8E1T9uXQtNokvG6mIBHauzyA1aKH6bmETVSv3RPjBXEz6Gcc4mIThgmjGQL39LD16jQ=="
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "http://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",

View File

@ -123,9 +123,10 @@
"react-select": "1.2.1",
"react-select-fast-filter-options": "^0.2.1",
"react-sortable-hoc": "^0.8.3",
"react-split-pane": "^0.1.66",
"react-split": "^2.0.4",
"react-sticky": "^6.0.2",
"react-syntax-highlighter": "^7.0.4",
"react-transition-group": "^2.5.3",
"react-virtualized": "9.19.1",
"react-virtualized-select": "^3.1.3",
"reactable-arc": "0.14.42",

View File

@ -24,7 +24,7 @@ import { shallow } from 'enzyme';
import { STATUS_OPTIONS } from '../../../src/SqlLab/constants';
import { initialState } from './fixtures';
import SouthPane from '../../../src/SqlLab/components/SouthPane';
import SouthPaneContainer, { SouthPane } from '../../../src/SqlLab/components/SouthPane';
describe('SouthPane', () => {
const middlewares = [thunk];
@ -42,11 +42,16 @@ describe('SouthPane', () => {
};
const getWrapper = () => (
shallow(<SouthPane {...mockedProps} />, {
shallow(<SouthPaneContainer {...mockedProps} />, {
context: { store },
}).dive());
let wrapper;
beforeAll(() => {
jest.spyOn(SouthPane.prototype, 'getSouthPaneHeight').mockImplementation(() => 500);
});
it('should render offline when the state is offline', () => {
wrapper = getWrapper();
wrapper.setProps({ offline: true });

View File

@ -38,6 +38,11 @@ describe('SqlEditor', () => {
defaultQueryLimit: 1000,
maxRow: 100000,
};
beforeAll(() => {
jest.spyOn(SqlEditor.prototype, 'getSqlEditorHeight').mockImplementation(() => 500);
});
it('is valid', () => {
expect(
React.isValidElement(<SqlEditor {...mockedProps} />),

View File

@ -84,7 +84,7 @@ class App extends React.PureComponent {
content = (
<div>
<QueryAutoRefresh />
<TabbedSqlEditors getHeight={this.getHeight} />
<TabbedSqlEditors />
</div>
);
}

View File

@ -48,7 +48,24 @@ const defaultProps = {
offline: false,
};
class SouthPane extends React.PureComponent {
export class SouthPane extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
height: props.height,
};
this.southPaneRef = React.createRef();
this.getSouthPaneHeight = this.getSouthPaneHeight.bind(this);
this.switchTab = this.switchTab.bind(this);
}
componentWillReceiveProps() {
// south pane expands the entire height of the tab content on mount
this.setState({ height: this.getSouthPaneHeight() });
}
// One layer of abstraction for easy spying in unit tests
getSouthPaneHeight() {
return this.southPaneRef.current.clientHeight;
}
switchTab(id) {
this.props.actions.setActiveSouthPaneTab(id);
}
@ -59,7 +76,7 @@ class SouthPane extends React.PureComponent {
{ STATUS_OPTIONS.offline }
</Label>);
}
const innerTabHeight = this.props.height - 55;
const innerTabHeight = this.state.height - 55;
let latestQuery;
const props = this.props;
if (props.editorQueries.length > 0) {
@ -98,12 +115,12 @@ class SouthPane extends React.PureComponent {
));
return (
<div className="SouthPane">
<div className="SouthPane" ref={this.southPaneRef}>
<Tabs
bsStyle="tabs"
id={shortid.generate()}
activeKey={this.props.activeSouthPaneTab}
onSelect={this.switchTab.bind(this)}
onSelect={this.switchTab}
>
<Tab
title={t('Results')}

View File

@ -17,21 +17,18 @@
* under the License.
*/
import React from 'react';
import { CSSTransition } from 'react-transition-group';
import PropTypes from 'prop-types';
import { throttle } from 'lodash';
import {
Col,
FormGroup,
InputGroup,
Form,
FormControl,
Label,
OverlayTrigger,
Row,
Tooltip,
Collapse,
} from 'react-bootstrap';
import SplitPane from 'react-split-pane';
import Split from 'react-split';
import { t } from '@superset-ui/translation';
import Button from '../../components/Button';
@ -47,9 +44,13 @@ import AceEditorWrapper from './AceEditorWrapper';
import { STATE_BSSTYLE_MAP } from '../constants';
import RunQueryActionButton from './RunQueryActionButton';
const SQL_TOOLBAR_HEIGHT = 51;
const GUTTER_HEIGHT = 5;
const INITIAL_NORTH_PERCENT = 30;
const INITIAL_SOUTH_PERCENT = 70;
const propTypes = {
actions: PropTypes.object.isRequired,
getHeight: PropTypes.func.isRequired,
database: PropTypes.object,
latestQuery: PropTypes.object,
tables: PropTypes.array.isRequired,
@ -75,13 +76,18 @@ class SqlEditor extends React.PureComponent {
ctas: '',
sql: props.queryEditor.sql,
};
this.sqlEditorRef = React.createRef();
this.northPaneRef = React.createRef();
this.onResize = this.onResize.bind(this);
this.throttledResize = throttle(this.onResize, 250);
this.onResizeStart = this.onResizeStart.bind(this);
this.onResizeEnd = this.onResizeEnd.bind(this);
this.runQuery = this.runQuery.bind(this);
this.stopQuery = this.stopQuery.bind(this);
this.onSqlChanged = this.onSqlChanged.bind(this);
this.setQueryEditorSql = this.setQueryEditorSql.bind(this);
this.queryPane = this.queryPane.bind(this);
this.getAceEditorAndSouthPaneHeights = this.getAceEditorAndSouthPaneHeights.bind(this);
this.getSqlEditorHeight = this.getSqlEditorHeight.bind(this);
}
componentWillMount() {
if (this.state.autorun) {
@ -91,29 +97,41 @@ class SqlEditor extends React.PureComponent {
}
}
componentDidMount() {
this.onResize();
window.addEventListener('resize', this.throttledResize);
// We need to measure the height of the sql editor post render to figure the height of
// the south pane so it gets rendered properly
// eslint-disable-next-line react/no-did-mount-set-state
this.setState({ height: this.getSqlEditorHeight() });
}
componentWillUnmount() {
window.removeEventListener('resize', this.throttledResize);
onResizeStart() {
// Set the heights on the ace editor and the ace content area after drag starts
// to smooth out the visual transition to the new heights when drag ends
document.getElementById('brace-editor').style.height = `calc(100% - ${SQL_TOOLBAR_HEIGHT}px)`;
document.getElementsByClassName('ace_content')[0].style.height = '100%';
}
onResize() {
const height = this.sqlEditorHeight();
const editorPaneHeight = this.props.queryEditor.height || 200;
const splitPaneHandlerHeight = 8; // 4px of height + 4px of top-margin
this.setState({
editorPaneHeight,
southPaneHeight: height - editorPaneHeight - splitPaneHandlerHeight,
height,
});
onResizeEnd([northPercent, southPercent]) {
this.setState(this.getAceEditorAndSouthPaneHeights(
this.state.height, northPercent, southPercent));
if (this.refs.ace && this.refs.ace.clientHeight) {
this.props.actions.persistEditorHeight(this.props.queryEditor, this.refs.ace.clientHeight);
if (this.northPaneRef.current && this.northPaneRef.current.clientHeight) {
this.props.actions.persistEditorHeight(this.props.queryEditor,
this.northPaneRef.current.clientHeight);
}
}
onSqlChanged(sql) {
this.setState({ sql });
}
// One layer of abstraction for easy spying in unit tests
getSqlEditorHeight() {
return this.sqlEditorRef.current.clientHeight;
}
// Return the heights for the ace editor and the south pane as an object
// given the height of the sql editor, north pane percent and south pane percent.
getAceEditorAndSouthPaneHeights(height, northPercent, southPercent) {
return {
aceEditorHeight: height * northPercent / 100 - SQL_TOOLBAR_HEIGHT - GUTTER_HEIGHT / 2,
southPaneHeight: height * southPercent / 100,
};
}
getHotkeyConfig() {
return [
{
@ -187,9 +205,42 @@ class SqlEditor extends React.PureComponent {
ctasChanged(event) {
this.setState({ ctas: event.target.value });
}
sqlEditorHeight() {
const horizontalScrollbarHeight = 25;
return parseInt(this.props.getHeight(), 10) - horizontalScrollbarHeight;
queryPane() {
const hotkeys = this.getHotkeyConfig();
const { aceEditorHeight, southPaneHeight } = this.getAceEditorAndSouthPaneHeights(
this.state.height, INITIAL_NORTH_PERCENT, INITIAL_SOUTH_PERCENT);
return (
<div className="queryPane">
<Split
sizes={[INITIAL_NORTH_PERCENT, INITIAL_SOUTH_PERCENT]}
minSize={200}
direction="vertical"
gutterSize={GUTTER_HEIGHT}
onDragStart={this.onResizeStart}
onDragEnd={this.onResizeEnd}
>
<div ref={this.northPaneRef}>
<AceEditorWrapper
actions={this.props.actions}
onBlur={this.setQueryEditorSql}
onChange={this.onSqlChanged}
queryEditor={this.props.queryEditor}
sql={this.props.queryEditor.sql}
tables={this.props.tables}
height={`${this.state.aceEditorHeight || aceEditorHeight}px`}
hotkeys={hotkeys}
/>
{this.renderEditorBottomBar(hotkeys)}
</div>
<SouthPane
editorQueries={this.props.editorQueries}
dataPreviewQueries={this.props.dataPreviewQueries}
actions={this.props.actions}
height={this.state.southPaneHeight || southPaneHeight}
/>
</Split>
</div>
);
}
renderEditorBottomBar(hotkeys) {
let ctasControls;
@ -305,74 +356,23 @@ class SqlEditor extends React.PureComponent {
);
}
render() {
const height = this.sqlEditorHeight();
const defaultNorthHeight = this.props.queryEditor.height || 200;
const hotkeys = this.getHotkeyConfig();
return (
<div
className="SqlEditor"
style={{
height: height + 'px',
}}
>
<Row>
<Collapse
in={!this.props.hideLeftBar}
>
<Col
xs={6}
sm={5}
md={4}
lg={3}
>
<SqlEditorLeftBar
height={height}
database={this.props.database}
queryEditor={this.props.queryEditor}
tables={this.props.tables}
actions={this.props.actions}
/>
</Col>
</Collapse>
<Col
xs={this.props.hideLeftBar ? 12 : 6}
sm={this.props.hideLeftBar ? 12 : 7}
md={this.props.hideLeftBar ? 12 : 8}
lg={this.props.hideLeftBar ? 12 : 9}
style={{ height: this.state.height }}
>
<SplitPane
split="horizontal"
defaultSize={defaultNorthHeight}
minSize={100}
onChange={this.onResize}
>
<div ref="ace" style={{ width: '100%' }}>
<div>
<AceEditorWrapper
actions={this.props.actions}
onBlur={this.setQueryEditorSql}
onChange={this.onSqlChanged}
queryEditor={this.props.queryEditor}
sql={this.props.queryEditor.sql}
tables={this.props.tables}
height={((this.state.editorPaneHeight || defaultNorthHeight) - 50) + 'px'}
hotkeys={hotkeys}
/>
{this.renderEditorBottomBar(hotkeys)}
</div>
</div>
<div ref="south">
<SouthPane
editorQueries={this.props.editorQueries}
dataPreviewQueries={this.props.dataPreviewQueries}
actions={this.props.actions}
height={this.state.southPaneHeight || 0}
/>
</div>
</SplitPane>
</Col>
</Row>
<div ref={this.sqlEditorRef} className="SqlEditor">
<CSSTransition
classNames="schemaPane"
in={!this.props.hideLeftBar}
timeout={300}
>
<div className="schemaPane">
<SqlEditorLeftBar
database={this.props.database}
queryEditor={this.props.queryEditor}
tables={this.props.tables}
actions={this.props.actions}
/>
</div>
</CSSTransition>
{this.queryPane()}
</div>
);
}

View File

@ -20,7 +20,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import TableElement from './TableElement';
import TableSelector from '../../components/TableSelector';
@ -106,7 +105,7 @@ export default class SqlEditorLeftBar extends React.PureComponent {
const tableMetaDataHeight = this.props.height - 130; // 130 is the height of the selects above
const qe = this.props.queryEditor;
return (
<div className="clearfix">
<div className="sqlEditorLeftBar">
<TableSelector
dbId={qe.dbId}
schema={qe.schema}

View File

@ -39,7 +39,6 @@ const propTypes = {
queryEditors: PropTypes.array,
tabHistory: PropTypes.array.isRequired,
tables: PropTypes.array.isRequired,
getHeight: PropTypes.func.isRequired,
offline: PropTypes.bool,
};
const defaultProps = {
@ -238,7 +237,6 @@ class TabbedSqlEditors extends React.PureComponent {
<div className="panel-body">
{isSelected && (
<SqlEditor
getHeight={this.props.getHeight}
tables={this.props.tables.filter(xt => xt.queryEditorId === qe.id)}
queryEditor={qe}
editorQueries={this.state.queriesArray}

View File

@ -135,7 +135,8 @@ div.Workspace {
background-color: #e8e8e8;
display: flex;
justify-content: space-between;
border-bottom: 2px solid #ccc;
border: 1px solid #ccc;
border-top: 0;
form {
margin-block-end: 0;
@ -193,21 +194,67 @@ div.Workspace {
background-color: transparent !important;
}
.SqlEditor {
.Resizer {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
.SqlLab {
.tab-content {
height: 100%;
}
.Resizer.horizontal {
height: 4px;
#brace-editor {
height: calc(100% - 51px);
}
.ace_content {
height: 100%;
}
.SouthPane {
height: 100%;
}
}
.SqlEditor {
display: flex;
flex-direction: row;
height: 100%;
.schemaPane {
flex-grow: 1;
transition: all .3s ease-in-out;
}
.schemaPane-enter-done, .schemaPane-exit {
transform: translateX(0);
}
.schemaPane-enter-active, .schemaPane-exit-active {
transform: translateX(-50%);
}
.schemaPane-enter, .schemaPane-exit-done {
transform: translateX(-100%);
max-width: 0;
overflow: hidden;
}
.queryPane {
flex-grow: 8;
position: relative;
margin-left: 15px;
}
.schemaPane-exit-done + .queryPane {
margin-left: 0;
}
.gutter {
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
width: 3%;
margin: 3px 47%;
}
.gutter.gutter-vertical {
cursor: row-resize;
width: 4%;
margin-top: 4px;
margin-left: 47%;
}
}
@ -298,9 +345,6 @@ a.Link {
.tooltip-inner {
max-width: 500px;
}
.SplitPane.horizontal {
padding-right: 4px;
}
.SouthPane {
margin-top: 10px;
position: absolute;

View File

@ -83,17 +83,15 @@ class AsyncSelect extends React.PureComponent {
render() {
return (
<div>
<Select
placeholder={this.props.placeholder}
options={this.state.options}
value={this.props.value}
isLoading={this.state.isLoading}
onChange={this.onChange}
valueRenderer={this.props.valueRenderer}
{...this.props}
/>
</div>
<Select
placeholder={this.props.placeholder}
options={this.state.options}
value={this.props.value}
isLoading={this.state.isLoading}
onChange={this.onChange}
valueRenderer={this.props.valueRenderer}
{...this.props}
/>
);
}
}

View File

@ -17,8 +17,22 @@
* under the License.
*/
.TableSelector .fa-refresh {
padding-top: 7px
padding-left: 9px;
}
.TableSelector .refresh-col {
padding-left: 0px;
display: flex;
align-items: center;
width: 30px;
}
.TableSelector .section {
padding-bottom: 5px;
display: flex;
flex-direction: row;
}
.TableSelector .select {
flex-grow: 1;
}
.TableSelector .divider {
border-bottom: 1px solid #f2f2f2;
margin: 10px 0;
}

View File

@ -20,7 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Select from 'react-virtualized-select';
import createFilterOptions from 'react-select-fast-filter-options';
import { ControlLabel, Col, Label, Row } from 'react-bootstrap';
import { ControlLabel, Label } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import { SupersetClient } from '@superset-ui/connection';
@ -38,7 +38,6 @@ const propTypes = {
tableNameSticky: PropTypes.bool,
tableName: PropTypes.string,
database: PropTypes.object,
horizontal: PropTypes.bool,
sqlLabMode: PropTypes.bool,
onChange: PropTypes.func,
clearable: PropTypes.bool,
@ -52,7 +51,6 @@ const defaultProps = {
onTableChange: () => {},
onChange: () => {},
tableNameSticky: true,
horizontal: false,
sqlLabMode: true,
clearable: true,
};
@ -199,10 +197,10 @@ export default class TableSelector extends React.PureComponent {
}
renderSelectRow(select, refreshBtn) {
return (
<Row>
<Col md={11}>{select}</Col>
<Col md={1} className="refresh-col">{refreshBtn}</Col>
</Row>
<div className="section">
<span className="select">{select}</span>
<span className="refresh-col">{refreshBtn}</span>
</div>
);
}
renderDatabaseSelect() {
@ -232,29 +230,25 @@ export default class TableSelector extends React.PureComponent {
/>);
}
renderSchema() {
return (
<div className="m-t-5">
{this.renderSelectRow(
<Select
name="select-schema"
placeholder={t('Select a schema (%s)', this.state.schemaOptions.length)}
options={this.state.schemaOptions}
value={this.props.schema}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
)}
isLoading={this.state.schemaLoading}
autosize={false}
onChange={this.changeSchema}
/>,
<RefreshLabel
onClick={() => this.onDatabaseChange({ id: this.props.dbId }, true)}
tooltipContent={t('Force refresh schema list')}
/>,
return this.renderSelectRow(
<Select
name="select-schema"
placeholder={t('Select a schema (%s)', this.state.schemaOptions.length)}
options={this.state.schemaOptions}
value={this.props.schema}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
)}
</div>
isLoading={this.state.schemaLoading}
autosize={false}
onChange={this.changeSchema}
/>,
<RefreshLabel
onClick={() => this.onDatabaseChange({ id: this.props.dbId }, true)}
tooltipContent={t('Force refresh schema list')}
/>,
);
}
renderTable() {
@ -290,20 +284,16 @@ export default class TableSelector extends React.PureComponent {
value={this.state.tableName}
loadOptions={this.getTableNamesBySubStr}
/>);
return (
<div className="m-t-5">
{this.renderSelectRow(
select,
<RefreshLabel
onClick={() => this.changeSchema({ value: this.props.schema }, true)}
tooltipContent={t('Force refresh table list')}
/>)}
</div>);
return this.renderSelectRow(
select,
<RefreshLabel
onClick={() => this.changeSchema({ value: this.props.schema }, true)}
tooltipContent={t('Force refresh table list')}
/>);
}
renderSeeTableLabel() {
return (
<div>
<hr />
<div className="section">
<ControlLabel>
{t('See table schema')}{' '}
<small>
@ -319,18 +309,11 @@ export default class TableSelector extends React.PureComponent {
render() {
return (
<div className="TableSelector">
{this.props.horizontal ?
<div>
<Col md={4}>{this.renderDatabaseSelect()}</Col>
<Col md={4}>{this.renderSchema()}</Col>
<Col md={4}>{this.renderTable()}</Col>
</div> :
<div>
<div>{this.renderDatabaseSelect()}</div>
<div className="m-t-5">{this.renderSchema()}</div>
{this.props.sqlLabMode && this.renderSeeTableLabel()}
<div className="m-t-5">{this.renderTable()}</div>
</div>}
{this.renderDatabaseSelect()}
{this.renderSchema()}
<div className="divider" />
{this.props.sqlLabMode && this.renderSeeTableLabel()}
{this.renderTable()}
</div>
);
}