diff --git a/.gitignore b/.gitignore
index 350e1f610d..9ab883cbd8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,6 @@ npm-debug.log
client/cypress/screenshots
client/cypress/videos
+
+client/app/assets/less/**/*.css
+client/app/visualizations/vega/vega.css
diff --git a/client/app/assets/less/inc/ant-variables.less b/client/app/assets/less/inc/ant-variables.less
index 99e379a841..7bf7e7f724 100644
--- a/client/app/assets/less/inc/ant-variables.less
+++ b/client/app/assets/less/inc/ant-variables.less
@@ -82,5 +82,6 @@
/* --------------------------------------------------------
Notification
-----------------------------------------------------------*/
+@notification-padding-vertical: 16px;
@notification-padding: @notification-padding-vertical 48px @notification-padding-vertical 17px;
@notification-width: auto;
diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less
index 4c73715ee9..27d44cd5f5 100644
--- a/client/app/assets/less/redash/query.less
+++ b/client/app/assets/less/redash/query.less
@@ -194,6 +194,8 @@ edit-in-place p.editable:hover {
}
.query__vis {
+ height: 100%;
+
table {
border: 1px solid #f0f0f0;
}
diff --git a/client/app/components/QueryEditor.jsx b/client/app/components/QueryEditor.jsx
index b2df146704..6cb82fdf44 100644
--- a/client/app/components/QueryEditor.jsx
+++ b/client/app/components/QueryEditor.jsx
@@ -4,17 +4,9 @@ import Tooltip from 'antd/lib/tooltip';
import { react2angular } from 'react2angular';
import AceEditor from 'react-ace';
-import ace from 'brace';
+import { snippetManager, langTools } from '@/components/editor';
import notification from '@/services/notification';
-import 'brace/ext/language_tools';
-import 'brace/mode/json';
-import 'brace/mode/python';
-import 'brace/mode/sql';
-import 'brace/mode/yaml';
-import 'brace/theme/textmate';
-import 'brace/ext/searchbox';
-
import { Query } from '@/services/query';
import { QuerySnippet } from '@/services/query-snippet';
import { KeyboardShortcuts } from '@/services/keyboard-shortcuts';
@@ -26,23 +18,6 @@ import { DataSource, Schema } from './proptypes';
import './QueryEditor.css';
-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');
-defineDummySnippets('yaml');
-
class QueryEditor extends React.Component {
static propTypes = {
queryText: PropTypes.string.isRequired,
@@ -160,7 +135,6 @@ class QueryEditor extends React.Component {
});
QuerySnippet.query((snippets) => {
- const snippetManager = snippetsModule.snippetManager;
const m = {
snippetText: '',
};
diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx
index 673b7ecb3d..3be3714e6d 100644
--- a/client/app/components/dynamic-form/DynamicForm.jsx
+++ b/client/app/components/dynamic-form/DynamicForm.jsx
@@ -7,22 +7,37 @@ import Checkbox from 'antd/lib/checkbox';
import Button from 'antd/lib/button';
import Upload from 'antd/lib/upload';
import Icon from 'antd/lib/icon';
-import { includes, isFunction } from 'lodash';
+import { includes, isFunction, isPlainObject, isArray } from 'lodash';
import Select from 'antd/lib/select';
import notification from '@/services/notification';
import AceEditorInput from '@/components/AceEditorInput';
import { Field, Action, AntdForm } from '../proptypes';
import helper from './dynamicFormHelper';
+const { TextArea } = Input;
+
const fieldRules = ({ type, required, minLength }) => {
const requiredRule = required;
const minLengthRule = minLength && includes(['text', 'email', 'password'], type);
const emailTypeRule = type === 'email';
+ const jsonRule = type === 'json';
return [
requiredRule && { required, message: 'This field is required.' },
- minLengthRule && { min: minLength, message: 'This field is too short.' },
- emailTypeRule && { type: 'email', message: 'This field must be a valid email.' },
+ minLengthRule && { min: minLength, message: 'This field is too short.' }, emailTypeRule && { type: 'email', message: 'This field must be a valid email.' },
+ jsonRule && {
+ type: 'object',
+ transform(x) {
+ if (x && x.trim()) {
+ try {
+ JSON.parse(x);
+ } catch {
+ return '';
+ }
+ }
+ },
+ message: 'This field must be a JSON string.',
+ },
].filter(rule => rule);
};
@@ -75,13 +90,27 @@ class DynamicForm extends React.Component {
}));
};
+ parseValues = (values) => {
+ this.props.fields.forEach((field) => {
+ if (field.type === 'json' && field.name in values) {
+ try {
+ values[field.name] = JSON.parse(values[field.name]);
+ } catch {
+ // Invalid JSON must be discarded
+ values[field.name] = null;
+ }
+ }
+ });
+ return values;
+ };
+
handleSubmit = (e) => {
this.setState({ isSubmitting: true });
e.preventDefault();
this.props.form.validateFieldsAndScroll((err, values) => {
if (!err) {
this.props.onSubmit(
- values,
+ this.parseValues(values),
(msg) => {
const { setFieldsValue, getFieldsValue } = this.props.form;
this.setState({ isSubmitting: false });
@@ -156,8 +185,13 @@ class DynamicForm extends React.Component {
renderField(field, props) {
const { getFieldDecorator } = this.props.form;
- const { name, type, initialValue } = field;
+ const { name, type } = field;
const fieldLabel = field.title || helper.toHuman(name);
+ let initialValue = field.initialValue;
+
+ if (isPlainObject(initialValue) || isArray(initialValue)) {
+ initialValue = JSON.stringify(field.initialValue);
+ }
const options = {
rules: fieldRules(field),
@@ -176,9 +210,11 @@ class DynamicForm extends React.Component {
} else if (type === 'number') {
return getFieldDecorator(name, options)();
} else if (type === 'textarea') {
- return getFieldDecorator(name, options)();
+ return getFieldDecorator(name, options)();
} else if (type === 'ace') {
return getFieldDecorator(name, options)();
+ } else if (type === 'json') {
+ return getFieldDecorator(name, options)();
}
return getFieldDecorator(name, options)();
}
@@ -190,12 +226,6 @@ class DynamicForm extends React.Component {
const fieldLabel = title || helper.toHuman(name);
const { feedbackIcons, form } = this.props;
- const formItemProps = {
- className: 'm-b-10',
- hasFeedback: type !== 'checkbox' && type !== 'file' && feedbackIcons,
- label: type === 'checkbox' ? '' : fieldLabel,
- };
-
const fieldProps = {
...field.props,
className: 'w-100',
@@ -206,6 +236,12 @@ class DynamicForm extends React.Component {
placeholder: field.placeholder,
'data-test': fieldLabel,
};
+ const formItemProps = {
+ className: 'm-b-10',
+ hasFeedback: type !== 'checkbox' && type !== 'file' && feedbackIcons,
+ label: type === 'checkbox' ? '' : fieldLabel,
+ extra: fieldProps.extra,
+ };
return (
diff --git a/client/app/components/dynamic-form/dynamicFormHelper.js b/client/app/components/dynamic-form/dynamicFormHelper.js
index c1d17997d3..f917e3ba6c 100644
--- a/client/app/components/dynamic-form/dynamicFormHelper.js
+++ b/client/app/components/dynamic-form/dynamicFormHelper.js
@@ -5,13 +5,15 @@ function orderedInputs(properties, order, targetOptions) {
const inputs = new Array(order.length);
Object.keys(properties).forEach((key) => {
const position = order.indexOf(key);
+ const field = properties[key];
const input = {
name: key,
- title: properties[key].title,
- type: properties[key].type,
- placeholder: properties[key].default && properties[key].default.toString(),
- required: properties[key].required,
+ title: field.title,
+ type: field.type,
+ placeholder: field.default && field.default.toString(),
+ required: field.required,
initialValue: targetOptions[key],
+ props: field.props,
};
if (position > -1) {
@@ -41,6 +43,10 @@ function normalizeSchema(configurationSchema) {
prop.type = 'text';
}
+ if (prop.type === 'object') {
+ prop.type = 'json';
+ }
+
prop.required = includes(configurationSchema.required, name);
});
diff --git a/client/app/components/editor.js b/client/app/components/editor.js
new file mode 100644
index 0000000000..4a7d2c7cfd
--- /dev/null
+++ b/client/app/components/editor.js
@@ -0,0 +1,35 @@
+/**
+ * Common definition of the rich text editor
+ * (currently using Ace Editor, potentially migrating to Monaco)
+ */
+import ace from 'brace';
+
+import 'brace/ext/language_tools';
+import 'brace/mode/json';
+import 'brace/mode/python';
+import 'brace/mode/sql';
+import 'brace/mode/yaml';
+import 'brace/theme/textmate';
+import 'brace/ext/searchbox';
+
+defineDummySnippets('python');
+defineDummySnippets('sql');
+defineDummySnippets('json');
+defineDummySnippets('yaml');
+
+// 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.
+export function defineDummySnippets(mode, fn) {
+ ace.define(
+ `ace/snippets/${mode}`,
+ ['require', 'exports', 'module'],
+ fn ||
+ ((require, exports) => {
+ exports.snippetText = '';
+ exports.scope = mode;
+ }),
+ );
+}
+
+export const langTools = ace.acequire('ace/ext/language_tools');
+export const snippetManager = ace.acequire('ace/snippets').snippetManager;
diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js
index a1240cf029..3bcefb86f9 100644
--- a/client/app/components/proptypes.js
+++ b/client/app/components/proptypes.js
@@ -45,11 +45,13 @@ export const Field = PropTypes.shape({
'file',
'select',
'content',
+ 'json',
]).isRequired,
initialValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool,
+ PropTypes.object,
PropTypes.arrayOf(PropTypes.string),
PropTypes.arrayOf(PropTypes.number),
]),
diff --git a/client/app/components/queries/api-key-dialog.js b/client/app/components/queries/api-key-dialog.js
index 6f4451b3b0..395fd7e020 100644
--- a/client/app/components/queries/api-key-dialog.js
+++ b/client/app/components/queries/api-key-dialog.js
@@ -1,3 +1,5 @@
+import { getQueryDataUrl } from './index';
+
const ApiKeyDialog = {
template: `