mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Rewrite query editor to React (#2636)
This commit is contained in:
committed by
Arik Fraimovich
parent
ccac41c6d4
commit
8c478087a9
235
client/app/components/QueryEditor.jsx
Normal file
235
client/app/components/QueryEditor.jsx
Normal 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}>{{ }}</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']));
|
||||
}
|
||||
16
client/app/components/proptypes.js
Normal file
16
client/app/components/proptypes.js
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>{{ }}</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">*</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">
|
||||
|
||||
@@ -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
6612
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -28,6 +28,7 @@ const config = {
|
||||
publicPath: "/static/"
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
alias: {
|
||||
"@": appPath
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user