Rewrite query editor to React (#2636)

This commit is contained in:
Allen Short
2018-10-03 19:25:19 +00:00
committed by Arik Fraimovich
parent ccac41c6d4
commit 8c478087a9
8 changed files with 3602 additions and 3507 deletions

View File

@@ -0,0 +1,235 @@
import React from 'react';
import PropTypes from 'prop-types';
import { map } from 'lodash';
import Tooltip from 'antd/lib/tooltip';
import 'antd/lib/tooltip/style';
import { react2angular } from 'react2angular';
import AceEditor from 'react-ace';
import ace from 'brace';
import toastr from 'angular-toastr';
import 'brace/ext/language_tools';
import 'brace/mode/json';
import 'brace/mode/python';
import 'brace/mode/sql';
import 'brace/theme/textmate';
import 'brace/ext/searchbox';
import { DataSource, Schema } from './proptypes';
const langTools = ace.acequire('ace/ext/language_tools');
const snippetsModule = ace.acequire('ace/snippets');
// By default Ace will try to load snippet files for the different modes and fail.
// We don't need them, so we use these placeholders until we define our own.
function defineDummySnippets(mode) {
ace.define(`ace/snippets/${mode}`, ['require', 'exports', 'module'], (require, exports) => {
exports.snippetText = '';
exports.scope = mode;
});
}
defineDummySnippets('python');
defineDummySnippets('sql');
defineDummySnippets('json');
function buildKeywordsFromSchema(schema) {
const keywords = {};
schema.forEach((table) => {
keywords[table.name] = 'Table';
table.columns.forEach((c) => {
keywords[c] = 'Column';
keywords[`${table.name}.${c}`] = 'Column';
});
});
return map(keywords, (v, k) =>
({
name: k,
value: k,
score: 0,
meta: v,
}));
}
class QueryEditor extends React.Component {
static propTypes = {
queryText: PropTypes.string.isRequired,
schema: Schema, // eslint-disable-line react/no-unused-prop-types
addNewParameter: PropTypes.func.isRequired,
dataSources: PropTypes.arrayOf(DataSource),
dataSource: DataSource,
canEdit: PropTypes.bool.isRequired,
isDirty: PropTypes.bool.isRequired,
isQueryOwner: PropTypes.bool.isRequired,
updateDataSource: PropTypes.func.isRequired,
canExecuteQuery: PropTypes.func.isRequired,
executeQuery: PropTypes.func.isRequired,
queryExecuting: PropTypes.bool.isRequired,
saveQuery: PropTypes.func.isRequired,
updateQuery: PropTypes.func.isRequired,
listenForResize: PropTypes.func.isRequired,
listenForEditorCommand: PropTypes.func.isRequired,
}
static defaultProps = {
schema: null,
dataSource: {},
dataSources: [],
}
constructor(props) {
super(props);
this.state = {
schema: null, // eslint-disable-line react/no-unused-state
keywords: [], // eslint-disable-line react/no-unused-state
autocompleteQuery: true,
};
langTools.addCompleter({
getCompletions: (state, session, pos, prefix, callback) => {
if (prefix.length === 0) {
callback(null, []);
return;
}
callback(null, this.state.keywords);
},
});
this.onLoad = (editor) => {
// Release Cmd/Ctrl+L to the browser
editor.commands.bindKey('Cmd+L', null);
editor.commands.bindKey('Ctrl+P', null);
editor.commands.bindKey('Ctrl+L', null);
// eslint-disable-next-line react/prop-types
this.props.QuerySnippet.query((snippets) => {
const snippetManager = snippetsModule.snippetManager;
const m = {
snippetText: '',
};
m.snippets = snippetManager.parseSnippetFile(m.snippetText);
snippets.forEach((snippet) => {
m.snippets.push(snippet.getSnippet());
});
snippetManager.register(m.snippets || [], m.scope);
});
editor.focus();
this.props.listenForResize(() => editor.resize());
this.props.listenForEditorCommand((e, command, ...args) => {
switch (command) {
case 'focus': {
editor.focus();
break;
}
case 'paste': {
const [text] = args;
editor.session.doc.replace(editor.selection.getRange(), text);
const range = editor.selection.getRange();
this.props.updateQuery(editor.session.getValue());
editor.selection.setRange(range);
break;
}
default:
break;
}
});
};
this.formatQuery = () => {
// eslint-disable-next-line react/prop-types
const format = this.props.Query.format;
format(this.props.dataSource.syntax || 'sql', this.props.queryText)
.then(this.props.updateQuery)
.catch(error => toastr.error(error));
};
}
static getDerivedStateFromProps(nextProps, prevState) {
if (!nextProps.schema) {
return { keywords: [], autocompleteQuery: false };
} else if (nextProps.schema !== prevState.schema) {
return {
schema: nextProps.schema,
keywords: buildKeywordsFromSchema(nextProps.schema),
autocompleteQuery: (nextProps.schema.reduce((totalLength, table) =>
totalLength + table.columns.length, 0) <= 5000),
};
}
return null;
}
render() {
// eslint-disable-next-line react/prop-types
const modKey = this.props.KeyboardShortcuts.modKey;
return (
<section style={{ height: '100%' }}>
<div className="container p-15 m-b-10" style={{ height: '100%' }}>
<div style={{ height: 'calc(100% - 40px)', marginBottom: '0px' }} className="editor__container">
<AceEditor
ref={this.refEditor}
theme="textmate"
mode={this.props.dataSource.syntax || 'sql'}
value={this.props.queryText}
editorProps={{ $blockScrolling: Infinity }}
width="100%"
height="100%"
setOptions={{
behavioursEnabled: true,
enableSnippets: true,
enableLiveAutocompletion: this.state.autocompleteQuery,
autoScrollEditorIntoView: true,
}}
showPrintMargin={false}
wrapEnabled={false}
onLoad={this.onLoad}
onPaste={this.onPaste}
onChange={(queryText) => { this.props.updateQuery(queryText); }}
/>
</div>
<div className="editor__control">
<div className="form-inline d-flex">
<Tooltip placement="top" title={<span>Add New Parameter (<i>{modKey} + P</i>)</span>}>
<button type="button" className="btn btn-default m-r-5" onClick={this.props.addNewParameter}>&#123;&#123;&nbsp;&#125;&#125;</button>
</Tooltip>
<Tooltip placement="top" title="Format Query">
<button type="button" className="btn btn-default" onClick={this.formatQuery}>
<span className="zmdi zmdi-format-indent-increase" />
</button>
</Tooltip>
<Tooltip placement="top" title="Autocomplete">
<button className={'btn btn-default' + (this.state.autocompleteQuery ? ' active' : '')} onClick={() => this.setState({ autocompleteQuery: !this.state.autocompleteQuery })} >
<span className="fa fa-magic" />
</button>
</Tooltip>
<select className="form-control datasource-small flex-fill w-100" onChange={this.props.updateDataSource} disabled={!this.props.isQueryOwner}>
{this.props.dataSources.map(ds => <option label={ds.name} value={ds.id} key={`ds-option-${ds.id}`}>{ds.name}</option>)}
</select>
{this.props.canEdit ?
<Tooltip placement="top" title={modKey + ' + S'>}>
<button className="btn btn-default m-l-5" onClick={this.props.saveQuery} title="Save">
<span className="fa fa-floppy-o" />
<span className="hidden-xs">Save</span>
{this.props.isDirty ? '*' : null}
</button>
</Tooltip> : null }
<Tooltip placement="top" title={modKey + ' + Enter'}>
<button type="button" className="btn btn-primary m-l-5" disabled={this.props.queryExecuting || !this.props.canExecuteQuery()} onClick={this.props.executeQuery}>
<span className="zmdi zmdi-play" />
<span className="hidden-xs">Execute</span>
</button>
</Tooltip>
</div>
</div>
</div>
</section>);
}
}
export default function init(ngModule) {
ngModule.component('queryEditor', react2angular(QueryEditor, null, ['QuerySnippet', 'Query', 'KeyboardShortcuts']));
}

View File

@@ -0,0 +1,16 @@
import PropTypes from 'prop-types';
export const DataSource = PropTypes.shape({
syntax: PropTypes.string,
options: PropTypes.shape({
doc: PropTypes.string,
doc_url: PropTypes.string,
}),
type_name: PropTypes.string,
});
export const Table = PropTypes.shape({
columns: PropTypes.arrayOf(PropTypes.string).isRequired,
});
export const Schema = PropTypes.arrayOf(Table);

View File

@@ -1,166 +0,0 @@
import 'brace';
import 'brace/mode/python';
import 'brace/mode/sql';
import 'brace/mode/json';
import 'brace/ext/language_tools';
import 'brace/ext/searchbox';
import { map } from 'lodash';
// By default Ace will try to load snippet files for the different modes and fail.
// We don't need them, so we use these placeholders until we define our own.
function defineDummySnippets(mode) {
window.ace.define(`ace/snippets/${mode}`, ['require', 'exports', 'module'], (require, exports) => {
exports.snippetText = '';
exports.scope = mode;
});
}
defineDummySnippets('python');
defineDummySnippets('sql');
defineDummySnippets('json');
function queryEditor(QuerySnippet, $timeout) {
return {
restrict: 'E',
scope: {
query: '=',
schema: '=',
syntax: '=',
autoCompleteQuery: '=',
},
template: '<div ui-ace="editorOptions" ng-model="query.query"></div>',
link: {
pre($scope) {
$scope.syntax = $scope.syntax || 'sql';
$scope.editorOptions = {
mode: 'json',
// require: ['ace/ext/language_tools'],
advanced: {
behavioursEnabled: true,
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
autoScrollEditorIntoView: true,
},
onLoad(editor) {
$scope.$on('query-editor.command', ($event, command, ...args) => {
switch (command) {
case 'focus': {
editor.focus();
break;
}
case 'paste': {
const [text] = args;
editor.session.doc.replace(editor.selection.getRange(), text);
const range = editor.selection.getRange();
$scope.query.query = editor.session.getValue();
$timeout(() => {
editor.selection.setRange(range);
});
break;
}
default:
break;
}
});
// Release Cmd/Ctrl+L to the browser
editor.commands.bindKey('Cmd+L', null);
editor.commands.bindKey('Ctrl+P', null);
editor.commands.bindKey('Ctrl+L', null);
QuerySnippet.query((snippets) => {
window.ace.acequire(['ace/snippets'], (snippetsModule) => {
const snippetManager = snippetsModule.snippetManager;
const m = {
snippetText: '',
};
m.snippets = snippetManager.parseSnippetFile(m.snippetText);
snippets.forEach((snippet) => {
m.snippets.push(snippet.getSnippet());
});
snippetManager.register(m.snippets || [], m.scope);
});
});
editor.$blockScrolling = Infinity;
editor.getSession().setUseWrapMode(true);
editor.setShowPrintMargin(false);
$scope.$watch('syntax', (syntax) => {
const newMode = `ace/mode/${syntax}`;
editor.getSession().setMode(newMode);
});
$scope.$watch('schema', (newSchema, oldSchema) => {
if (newSchema !== oldSchema) {
if (newSchema === undefined) {
return;
}
const tokensCount =
newSchema.reduce((totalLength, table) => totalLength + table.columns.length, 0);
// If there are too many tokens or if it's requested via the UI
// we disable live autocomplete, as it makes typing slower.
if (tokensCount > 5000 || !$scope.$parent.autoCompleteQuery) {
editor.setOption('enableLiveAutocompletion', false);
} else {
editor.setOption('enableLiveAutocompletion', true);
}
}
});
$scope.$parent.$on('angular-resizable.resizing', () => {
editor.resize();
});
$scope.$watch('autoCompleteQuery', () => {
editor.setOption('enableLiveAutocompletion', $scope.autoCompleteQuery);
});
editor.focus();
},
};
const schemaCompleter = {
getCompletions(state, session, pos, prefix, callback) {
if (prefix.length === 0 || !$scope.schema) {
callback(null, []);
return;
}
if (!$scope.schema.keywords) {
const keywords = {};
$scope.schema.forEach((table) => {
keywords[table.name] = 'Table';
table.columns.forEach((c) => {
keywords[c] = 'Column';
keywords[`${table.name}.${c}`] = 'Column';
});
});
$scope.schema.keywords = map(keywords, (v, k) => ({
name: k,
value: k,
score: 0,
meta: v,
}));
}
callback(null, $scope.schema.keywords);
},
};
window.ace.acequire(['ace/ext/language_tools'], (langTools) => {
langTools.addCompleter(schemaCompleter);
});
},
},
};
}
export default function init(ngModule) {
ngModule.directive('queryEditor', queryEditor);
}

View File

@@ -137,50 +137,27 @@
<div class="content">
<div class="flex-fill p-relative">
<div class="p-absolute d-flex flex-column p-l-15 p-r-15" style="left: 0; top: 0; right: 0; bottom: 0;">
<div class="row editor" resizable r-directions="['bottom']" r-flex="true" resizable-toggle toggle-shortcut="Alt+D" style="min-height: 11px; max-height: 70vh;"
ng-if="sourceMode">
<section>
<div class="container p-15 m-b-10" style="height:100%;">
<p style="height:calc(100% - 40px); margin-bottom: 0px;" class="editor__container">
<query-editor query="query" schema="schema" syntax="dataSource.syntax" auto-complete-query="autoCompleteQuery"></query-editor>
</p>
<div class="editor__control">
<div class="form-inline d-flex">
<button type="button" class="btn btn-default m-r-5" ng-click="addNewParameter()" uib-tooltip-html="'Add New Parameter (<i>' + modKey + ' + P</i>)'"
tooltip-append-to-body="true">
<span ng-non-bindable>{{&nbsp;}}</span>
</button>
<button type="button" class="btn btn-default m-r-5" ng-click="formatQuery()" uib-tooltip="Format Query">
<span class="zmdi zmdi-format-indent-increase"></span>
</button>
<button type="button" class="btn btn-default m-r-5" ng-click="toggleAutoComplete()" uib-tooltip="Toggle AutoComplete" ng-class="{active: autoCompleteQuery}">
<span class="fa fa-magic"></span>
</button>
<select class="form-control datasource-small flex-fill w-100" ng-disabled="!isQueryOwner || !sourceMode" ng-model="query.data_source_id"
ng-change="updateDataSource()" ng-options="ds.id as ds.name for ds in dataSources"></select>
<button class="btn btn-default m-l-5" ng-show="canEdit" ng-click="saveQuery()" title="Save" uib-tooltip-html="modKey + ' + S'"
tooltip-append-to-body="true">
<span class="fa fa-floppy-o"></span>
<span class="hidden-xs">Save</span>
<span ng-show="isDirty">&#42;</span>
</button>
<button type="button" class="btn btn-primary m-l-5" ng-disabled="queryExecuting || !canExecuteQuery()" ng-click="executeQuery()"
uib-tooltip-html="modKey + ' + Enter'" tooltip-append-to-body="true">
<span class="zmdi zmdi-play"></span>
<span class="hidden-xs">Execute</span>
</button>
</div>
</div>
</div>
</section>
<div class="row editor" resizable r-directions="['bottom']" r-flex="true" resizable-toggle
style="min-height: 11px; max-height: 70vh;" ng-if="sourceMode">
<query-editor
style="width: 100%; height: 100%;"
query-text="query.query"
schema="schema"
syntax="dataSource.syntax"
can-edit="canEdit"
is-dirty="isDirty"
is-query-owner="isQueryOwner"
update-data-source="updateDataSource"
execute-query="executeQuery"
query-executing="queryExecuting"
can-execute-query="canExecuteQuery"
listen-for-resize="listenForResize"
listen-for-editor-command="listenForEditorCommand"
save-query="saveQuery"
update-query="updateQuery"
add-new-parameter="addNewParameter"
data-data-source="dataSource"
data-data-sources="dataSources"></query-editor>
</div>
<div class="row query-metadata__mobile">

View File

@@ -53,6 +53,10 @@ function QuerySourceCtrl(
$scope.canForkQuery = () => currentUser.hasPermission('edit_query') && !$scope.dataSource.view_only;
$scope.updateQuery = (newQueryText) => {
$scope.query.query = newQueryText;
};
// @override
$scope.saveQuery = (options, data) => {
const savePromise = saveQuery(options, data);
@@ -74,9 +78,7 @@ function QuerySourceCtrl(
$scope.formatQuery = () => {
Query.format($scope.dataSource.syntax, $scope.query.query)
.then((query) => {
$scope.query.query = query;
})
.then($scope.updateQuery)
.catch(error => toastr.error(error));
};
@@ -107,6 +109,9 @@ function QuerySourceCtrl(
});
};
$scope.listenForEditorCommand = f => $scope.$on('query-editor.command', f);
$scope.listenForResize = f => $scope.$parent.$on('angular-resizable.resizing', f);
$scope.$watch('query.query', (newQueryText) => {
$scope.isDirty = newQueryText !== queryText;
});

6612
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,7 @@
"antd": "^3.7.1",
"babel-preset-react": "^6.24.1",
"bootstrap": "^3.3.7",
"brace": "^0.10.0",
"brace": "^0.11.0",
"chroma-js": "^1.3.6",
"core-js": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz",
"cornelius": "git+https://github.com/restorando/cornelius.git",
@@ -70,6 +70,7 @@
"plotly.js": "1.30.1",
"prop-types": "^15.6.1",
"react": "^16.3.2",
"react-ace": "^6.1.0",
"react-dom": "^16.3.2",
"react2angular": "^3.2.1",
"ui-select": "^0.19.8",

View File

@@ -28,6 +28,7 @@ const config = {
publicPath: "/static/"
},
resolve: {
extensions: ['.js', '.jsx'],
alias: {
"@": appPath
}