diff --git a/superset/assets/javascripts/chart/Chart.jsx b/superset/assets/javascripts/chart/Chart.jsx index 958d353f95540..bd7e4f80c2b7b 100644 --- a/superset/assets/javascripts/chart/Chart.jsx +++ b/superset/assets/javascripts/chart/Chart.jsx @@ -8,6 +8,7 @@ import ChartBody from './ChartBody'; import Loading from '../components/Loading'; import StackTraceMessage from '../components/StackTraceMessage'; import visMap from '../../visualizations/main'; +import sandboxedEval from '../modules/sandbox'; const propTypes = { annotationData: PropTypes.object, @@ -141,8 +142,15 @@ class Chart extends React.PureComponent { renderViz() { const viz = visMap[this.props.vizType]; + const fd = this.props.formData; + const qr = this.props.queryResponse; try { - viz(this, this.props.queryResponse, this.props.setControlValue); + // Executing user-defined data mutator function + if (fd.js_data) { + qr.data = sandboxedEval(fd.js_data)(qr.data); + } + // [re]rendering the visualization + viz(this, qr, this.props.setControlValue); } catch (e) { this.props.actions.chartRenderingFailed(e, this.props.chartKey); } diff --git a/superset/assets/javascripts/explore/components/controls/TextAreaControl.jsx b/superset/assets/javascripts/explore/components/controls/TextAreaControl.jsx index 47b5454f4ccd5..3e968546a5a3b 100644 --- a/superset/assets/javascripts/explore/components/controls/TextAreaControl.jsx +++ b/superset/assets/javascripts/explore/components/controls/TextAreaControl.jsx @@ -7,6 +7,7 @@ import 'brace/mode/sql'; import 'brace/mode/json'; import 'brace/mode/html'; import 'brace/mode/markdown'; +import 'brace/mode/javascript'; import 'brace/theme/textmate'; @@ -16,24 +17,21 @@ import { t } from '../../../locales'; const propTypes = { name: PropTypes.string.isRequired, - label: PropTypes.string, - description: PropTypes.string, onChange: PropTypes.func, value: PropTypes.string, height: PropTypes.number, - language: PropTypes.oneOf([null, 'json', 'html', 'sql', 'markdown']), minLines: PropTypes.number, maxLines: PropTypes.number, offerEditInModal: PropTypes.bool, + language: PropTypes.oneOf([null, 'json', 'html', 'sql', 'markdown', 'javascript']), + aboveEditorSection: PropTypes.node, }; const defaultProps = { - label: null, - description: null, onChange: () => {}, value: '', height: 250, - minLines: 10, + minLines: 3, maxLines: 10, offerEditInModal: true, }; @@ -73,6 +71,14 @@ export default class TextAreaControl extends React.Component { /> ); } + renderModalBody() { + return ( +
+
{this.props.aboveEditorSection}
+ {this.renderEditor(true)} +
+ ); + } render() { const controlHeader = ; return ( @@ -88,7 +94,7 @@ export default class TextAreaControl extends React.Component { {t('Edit')} {this.props.language} {t('in modal')} } - modalBody={this.renderEditor(true)} + modalBody={this.renderModalBody(true)} />} ); diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index 7117712d2100e..95d4813f15af8 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -46,6 +46,15 @@ const sortAxisChoices = [ ['value_desc', 'sum(value) descending'], ]; +const sandboxUrl = 'https://github.com/apache/incubator-superset/blob/master/superset/assets/javascripts/modules/sandbox.js'; +const sandboxedEvalInfo = ( + + {t('While this runs in a ')} + sandboxed vm + , {t('a set of')} useful objects are in context + {t('to be used where necessary.')} + ); + const groupByControl = { type: 'SelectControl', multi: true, @@ -1759,5 +1768,18 @@ export const controls = { default: false, }, + js_data: { + type: 'TextAreaControl', + label: t('Javascript data mutator'), + description: t('Define a function that receives intercepts the data objects and can mutate it'), + language: 'javascript', + default: '', + height: 100, + aboveEditorSection: ( +

+ Define a function that intercepts the data object passed to the visualization + and returns a similarly shaped object. {sandboxedEvalInfo} +

), + }, }; export default controls; diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 2c5f2a61e1889..88f6710a9daff 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -607,6 +607,7 @@ export const visTypes = { controlPanelSections: [ { label: t('Code'), + expanded: true, controlSetRows: [ ['markup_type'], ['code'], diff --git a/superset/assets/javascripts/modules/sandbox.js b/superset/assets/javascripts/modules/sandbox.js new file mode 100644 index 0000000000000..24473adb3d644 --- /dev/null +++ b/superset/assets/javascripts/modules/sandbox.js @@ -0,0 +1,25 @@ +// A safe alternative to JS's eval +import vm from 'vm'; +import _ from 'underscore'; + +// Objects exposed here should be treated like a public API +// if `underscore` had backwards incompatible changes in a future release, we'd +// have to be careful about bumping the library as those changes could break user charts +const GLOBAL_CONTEXT = { + console, + _, +}; + +// Copied/modified from https://github.com/hacksparrow/safe-eval/blob/master/index.js +export default function sandboxedEval(code, context, opts) { + const sandbox = {}; + const resultKey = 'SAFE_EVAL_' + Math.floor(Math.random() * 1000000); + sandbox[resultKey] = {}; + const codeToEval = resultKey + '=' + code; + const sandboxContext = { ...GLOBAL_CONTEXT, ...context }; + Object.keys(sandboxContext).forEach(function (key) { + sandbox[key] = sandboxContext[key]; + }); + vm.runInNewContext(codeToEval, sandbox, opts); + return sandbox[resultKey]; +} diff --git a/superset/assets/spec/javascripts/modules/sandbox_spec.jsx b/superset/assets/spec/javascripts/modules/sandbox_spec.jsx new file mode 100644 index 0000000000000..85b36472fca65 --- /dev/null +++ b/superset/assets/spec/javascripts/modules/sandbox_spec.jsx @@ -0,0 +1,17 @@ +import { it, describe } from 'mocha'; +import { expect } from 'chai'; + +import sandboxedEval from '../../../javascripts/modules/sandbox'; + +describe('sandboxedEval', () => { + it('works like a basic eval', () => { + expect(sandboxedEval('100')).to.equal(100); + expect(sandboxedEval('v => v * 2')(5)).to.equal(10); + }); + it('d3 is in context and works', () => { + expect(sandboxedEval("l => _.find(l, s => s === 'bar')")(['foo', 'bar'])).to.equal('bar'); + }); + it('passes context as expected', () => { + expect(sandboxedEval('foo', { foo: 'bar' })).to.equal('bar'); + }); +});