diff --git a/caravel/assets/javascripts/SqlLab/TODO.md b/caravel/assets/javascripts/SqlLab/TODO.md
new file mode 100644
index 00000000000..8a83c57c463
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/TODO.md
@@ -0,0 +1,24 @@
+# Design
+* Query Log, search, filter on active tab only
+* Where to make the limit clear?
+
+# TODO
+* collapse sql beyond 10 lines
+* add [Visualize] icon to modal
+* Security per-database
+* Overwrite workspace query
+* Async
+* Refactor timer in to its own thing
+
+
+## Cosmetic
+* use icons for datatypes
+* SqlEditor buttons
+* use react-bootstrap-prompt for query title input
+* make input:text more self-evident
+* Tab cosmetic in theme
+
+# PROJECT
+* Write Runbook
+* Confirm backups
+* merge chef branch
diff --git a/caravel/assets/javascripts/SqlLab/actions.js b/caravel/assets/javascripts/SqlLab/actions.js
new file mode 100644
index 00000000000..eb2b6d511bd
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/actions.js
@@ -0,0 +1,102 @@
+export const RESET_STATE = 'RESET_STATE';
+export const ADD_QUERY_EDITOR = 'ADD_QUERY_EDITOR';
+export const REMOVE_QUERY_EDITOR = 'REMOVE_QUERY_EDITOR';
+export const ADD_TABLE = 'ADD_TABLE';
+export const REMOVE_TABLE = 'REMOVE_TABLE';
+export const START_QUERY = 'START_QUERY';
+export const STOP_QUERY = 'STOP_QUERY';
+export const END_QUERY = 'END_QUERY';
+export const REMOVE_QUERY = 'REMOVE_QUERY';
+export const EXPAND_TABLE = 'EXPAND_TABLE';
+export const COLLAPSE_TABLE = 'COLLAPSE_TABLE';
+export const QUERY_SUCCESS = 'QUERY_SUCCESS';
+export const QUERY_FAILED = 'QUERY_FAILED';
+export const QUERY_EDITOR_SETDB = 'QUERY_EDITOR_SETDB';
+export const QUERY_EDITOR_SET_SCHEMA = 'QUERY_EDITOR_SET_SCHEMA';
+export const QUERY_EDITOR_SET_TITLE = 'QUERY_EDITOR_SET_TITLE';
+export const QUERY_EDITOR_SET_AUTORUN = 'QUERY_EDITOR_SET_AUTORUN';
+export const QUERY_EDITOR_SET_SQL = 'QUERY_EDITOR_SET_SQL';
+export const SET_WORKSPACE_DB = 'SET_WORKSPACE_DB';
+export const ADD_WORKSPACE_QUERY = 'ADD_WORKSPACE_QUERY';
+export const REMOVE_WORKSPACE_QUERY = 'REMOVE_WORKSPACE_QUERY';
+export const SET_ACTIVE_QUERY_EDITOR = 'SET_ACTIVE_QUERY_EDITOR';
+
+export function resetState() {
+ return { type: RESET_STATE };
+}
+
+export function addQueryEditor(queryEditor) {
+ return { type: ADD_QUERY_EDITOR, queryEditor };
+}
+
+export function setActiveQueryEditor(queryEditor) {
+ return { type: SET_ACTIVE_QUERY_EDITOR, queryEditor };
+}
+
+export function removeQueryEditor(queryEditor) {
+ return { type: REMOVE_QUERY_EDITOR, queryEditor };
+}
+
+export function removeQuery(query) {
+ return { type: REMOVE_QUERY, query };
+}
+
+export function queryEditorSetDb(queryEditor, dbId) {
+ return { type: QUERY_EDITOR_SETDB, queryEditor, dbId };
+}
+
+export function queryEditorSetSchema(queryEditor, schema) {
+ return { type: QUERY_EDITOR_SET_SCHEMA, queryEditor, schema };
+}
+
+export function queryEditorSetAutorun(queryEditor, autorun) {
+ return { type: QUERY_EDITOR_SET_AUTORUN, queryEditor, autorun };
+}
+
+export function queryEditorSetTitle(queryEditor, title) {
+ return { type: QUERY_EDITOR_SET_TITLE, queryEditor, title };
+}
+
+export function queryEditorSetSql(queryEditor, sql) {
+ return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql };
+}
+
+export function addTable(table) {
+ return { type: ADD_TABLE, table };
+}
+
+export function expandTable(table) {
+ return { type: EXPAND_TABLE, table };
+}
+
+export function collapseTable(table) {
+ return { type: COLLAPSE_TABLE, table };
+}
+
+export function removeTable(table) {
+ return { type: REMOVE_TABLE, table };
+}
+
+export function startQuery(query) {
+ return { type: START_QUERY, query };
+}
+
+export function stopQuery(query) {
+ return { type: STOP_QUERY, query };
+}
+
+export function querySuccess(query, results) {
+ return { type: QUERY_SUCCESS, query, results };
+}
+
+export function queryFailed(query, msg) {
+ return { type: QUERY_FAILED, query, msg };
+}
+
+export function addWorkspaceQuery(query) {
+ return { type: ADD_WORKSPACE_QUERY, query };
+}
+
+export function removeWorkspaceQuery(query) {
+ return { type: REMOVE_WORKSPACE_QUERY, query };
+}
diff --git a/caravel/assets/javascripts/SqlLab/components/ButtonWithTooltip.jsx b/caravel/assets/javascripts/SqlLab/components/ButtonWithTooltip.jsx
new file mode 100644
index 00000000000..36220e6b4ff
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/ButtonWithTooltip.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap';
+
+
+class ButtonWithTooltip extends React.Component {
+ render() {
+ let tooltip = (
+
+ {this.props.tooltip}
+
+ );
+ return (
+
+
+
+ );
+ }
+}
+ButtonWithTooltip.defaultProps = {
+ onClick: () => {},
+ disabled: false,
+ placement: 'top',
+ bsStyle: 'default',
+};
+
+ButtonWithTooltip.propTypes = {
+ bsStyle: React.PropTypes.string,
+ children: React.PropTypes.element,
+ className: React.PropTypes.string,
+ disabled: React.PropTypes.bool,
+ onClick: React.PropTypes.func,
+ placement: React.PropTypes.string,
+ tooltip: React.PropTypes.string,
+};
+
+export default ButtonWithTooltip;
diff --git a/caravel/assets/javascripts/SqlLab/components/LeftPane.jsx b/caravel/assets/javascripts/SqlLab/components/LeftPane.jsx
new file mode 100644
index 00000000000..27e441aac11
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/LeftPane.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { Alert, Button, Label } from 'react-bootstrap';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import QueryLink from './QueryLink';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class LeftPane extends React.Component {
+ render() {
+ let queryElements;
+ if (this.props.workspaceQueries.length > 0) {
+ queryElements = this.props.workspaceQueries.map((q) => );
+ } else {
+ queryElements = (
+
+ Use the save button on the SQL editor to save a query into this section for
+ future reference
+
+ );
+ }
+ return (
+
+
+
+
+ SQL Lab
+
+
+
+
+
+
+
+
+ Saved Queries
+
+
+ {queryElements}
+
+
+
+
+
+
+ );
+ }
+}
+LeftPane.propTypes = {
+ workspaceQueries: React.PropTypes.array,
+};
+LeftPane.defaultProps = {
+ workspaceQueries: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ workspaceQueries: state.workspaceQueries,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(LeftPane);
diff --git a/caravel/assets/javascripts/SqlLab/components/Link.jsx b/caravel/assets/javascripts/SqlLab/components/Link.jsx
new file mode 100644
index 00000000000..7c88b5a929c
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/Link.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { OverlayTrigger, Tooltip } from 'react-bootstrap';
+
+
+class Link extends React.Component {
+ render() {
+ let tooltip = (
+
+ {this.props.tooltip}
+
+ );
+ const link = (
+
+ {this.props.children}
+
+ );
+ if (this.props.tooltip) {
+ return (
+
+ {link}
+
+ );
+ }
+ return link;
+ }
+}
+Link.propTypes = {
+ className: React.PropTypes.string,
+ href: React.PropTypes.string,
+ onClick: React.PropTypes.func,
+ tooltip: React.PropTypes.string,
+ placement: React.PropTypes.string,
+ children: React.PropTypes.object,
+};
+Link.defaultProps = {
+ disabled: false,
+ href: '#',
+ tooltip: null,
+ placement: 'top',
+ onClick: () => {},
+};
+
+export default Link;
diff --git a/caravel/assets/javascripts/SqlLab/components/QueryLink.jsx b/caravel/assets/javascripts/SqlLab/components/QueryLink.jsx
new file mode 100644
index 00000000000..26a5d39bead
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QueryLink.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { ButtonGroup } from 'react-bootstrap';
+import Link from './Link';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import shortid from 'shortid';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class QueryLink extends React.Component {
+ popTab() {
+ const qe = {
+ id: shortid.generate(),
+ title: this.props.query.title,
+ dbId: this.props.query.dbId,
+ autorun: false,
+ sql: this.props.query.sql,
+ };
+ this.props.actions.addQueryEditor(qe);
+ }
+ render() {
+ return (
+
+ {this.props.query.title}
+
+
+
+
+
+ );
+ }
+}
+
+QueryLink.propTypes = {
+ query: React.PropTypes.object,
+ actions: React.PropTypes.object,
+};
+
+QueryLink.defaultProps = {
+};
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+export default connect(null, mapDispatchToProps)(QueryLink);
+
diff --git a/caravel/assets/javascripts/SqlLab/components/QueryLog.jsx b/caravel/assets/javascripts/SqlLab/components/QueryLog.jsx
new file mode 100644
index 00000000000..bc83a4627c6
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QueryLog.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+
+import QueryTable from './QueryTable';
+import { Alert } from 'react-bootstrap';
+
+class QueryLog extends React.Component {
+ render() {
+ const activeQeId = this.props.tabHistory[this.props.tabHistory.length - 1];
+ const queries = this.props.queries.filter((q) => (q.sqlEditorId === activeQeId));
+ if (queries.length > 0) {
+ return (
+
+ );
+ }
+ return (
+
+ No query history yet...
+
+ );
+ }
+}
+QueryLog.defaultProps = {
+ queries: [],
+};
+
+QueryLog.propTypes = {
+ queries: React.PropTypes.array,
+ tabHistory: React.PropTypes.array,
+ actions: React.PropTypes.object,
+};
+
+function mapStateToProps(state) {
+ return {
+ queries: state.queries,
+ tabHistory: state.tabHistory,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(QueryLog);
diff --git a/caravel/assets/javascripts/SqlLab/components/QuerySearch.jsx b/caravel/assets/javascripts/SqlLab/components/QuerySearch.jsx
new file mode 100644
index 00000000000..e0352f9363c
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QuerySearch.jsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import SplitPane from 'react-split-pane';
+import Select from 'react-select';
+import { Button } from 'react-bootstrap';
+
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import QueryTable from './QueryTable';
+
+class QuerySearch extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ queryText: '',
+ };
+ }
+ changeQueryText(value) {
+ this.setState({ queryText: value });
+ }
+ render() {
+ const queries = this.props.queries;
+ return (
+
+
+
+
+
+ Search Queries
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+QuerySearch.propTypes = {
+ queries: React.PropTypes.array,
+};
+QuerySearch.defaultProps = {
+ queries: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ queries: state.queries,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(QuerySearch);
diff --git a/caravel/assets/javascripts/SqlLab/components/QueryTable.jsx b/caravel/assets/javascripts/SqlLab/components/QueryTable.jsx
new file mode 100644
index 00000000000..7b06220e22e
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/QueryTable.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import { Alert, Modal } from 'react-bootstrap';
+
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+
+import Select from 'react-select';
+
+import moment from 'moment';
+import { Table } from 'reactable';
+
+import SyntaxHighlighter from 'react-syntax-highlighter';
+import { github } from 'react-syntax-highlighter/dist/styles';
+
+import Link from './Link';
+
+// TODO move to CSS
+const STATE_COLOR_MAP = {
+ failed: 'red',
+ running: 'lime',
+ success: 'green',
+};
+
+class QueryTable extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ showVisualizeModal: false,
+ activeQuery: null,
+ };
+ }
+ hideVisualizeModal() {
+ this.setState({ showVisualizeModal: false });
+ }
+ showVisualizeModal(query) {
+ this.setState({ showVisualizeModal: true });
+ this.state.activeQuery = query;
+ }
+ changeChartType(event) {
+ }
+ render() {
+ const data = this.props.queries.map((query) => {
+ const q = Object.assign({}, query);
+ const since = (q.endDttm) ? q.endDttm : new Date();
+ let duration = since.valueOf() - q.startDttm.valueOf();
+ duration = moment.utc(duration);
+ if (q.endDttm) {
+ q.duration = duration.format('HH:mm:ss.SS');
+ }
+ q.started = moment(q.startDttm).format('HH:mm:ss');
+ q.sql = {q.sql};
+ q.state = (
+
+ {q.state}
+
+ );
+ q.actions = (
+
+
+
+
+
+
+ );
+
+ return q;
+ }).reverse();
+ let visualizeModalBody;
+ if (this.state.activeQuery) {
+ const cols = this.state.activeQuery.results.columns;
+ visualizeModalBody = (
+
+
+
({
+ column: col,
+ is_dimension: ,
+ is_date: ,
+ agg_func: (
+
+ ),
+ }))}
+ />
+
+ );
+ }
+ return (
+
+
+
+ Visualize (mock)
+
+
+ Not functional - Work in progress!
+ {visualizeModalBody}
+
+
+
+
+ );
+ }
+}
+QueryTable.propTypes = {
+ columns: React.PropTypes.array,
+ actions: React.PropTypes.object,
+ queries: React.PropTypes.object,
+};
+QueryTable.defaultProps = {
+ columns: ['state', 'started', 'duration', 'rows', 'sql', 'actions'],
+ queries: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+export default connect(mapStateToProps, mapDispatchToProps)(QueryTable);
diff --git a/caravel/assets/javascripts/SqlLab/components/ResultSet.jsx b/caravel/assets/javascripts/SqlLab/components/ResultSet.jsx
new file mode 100644
index 00000000000..c3888723217
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/ResultSet.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { Alert, Button } from 'react-bootstrap';
+import { Table } from 'reactable';
+
+
+class ResultSet extends React.Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ const results = this.props.query.results;
+ let controls = ;
+ if (this.props.showControls) {
+ controls = (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ if (results.data.length > 0) {
+ return (
+
+ );
+ }
+ return (The query returned no data);
+ }
+}
+ResultSet.propTypes = {
+ query: React.PropTypes.object,
+ showControls: React.PropTypes.boolean,
+ search: React.PropTypes.boolean,
+};
+ResultSet.defaultProps = {
+ showControls: true,
+ search: true,
+};
+
+export default ResultSet;
diff --git a/caravel/assets/javascripts/SqlLab/components/SouthPane.jsx b/caravel/assets/javascripts/SqlLab/components/SouthPane.jsx
new file mode 100644
index 00000000000..d193ab9c76b
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/SouthPane.jsx
@@ -0,0 +1,44 @@
+import { Tab, Tabs } from 'react-bootstrap';
+import QueryLog from './QueryLog';
+import ResultSet from './ResultSet';
+import React from 'react';
+
+class SouthPane extends React.Component {
+ render() {
+ let results;
+ if (this.props.latestQuery) {
+ if (this.props.latestQuery.state === 'running') {
+ results = (
+
+ );
+ } else if (this.props.latestQuery.state === 'failed') {
+ results = {this.props.latestQuery.msg}
;
+ } else if (this.props.latestQuery.state === 'success') {
+ results = ;
+ }
+ } else {
+ results = Run a query to display results here
;
+ }
+ return (
+
+
+
+ {results}
+
+
+
+
+
+
+ );
+ }
+}
+
+SouthPane.propTypes = {
+ latestQuery: React.PropTypes.object,
+};
+
+SouthPane.defaultProps = {
+};
+
+export default SouthPane;
diff --git a/caravel/assets/javascripts/SqlLab/components/SqlEditor.jsx b/caravel/assets/javascripts/SqlLab/components/SqlEditor.jsx
new file mode 100644
index 00000000000..ba097a00d95
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/SqlEditor.jsx
@@ -0,0 +1,233 @@
+const $ = window.$ = require('jquery');
+import React from 'react';
+import { Button, ButtonGroup, DropdownButton, Label, MenuItem, OverlayTrigger, Tooltip } from 'react-bootstrap';
+
+import AceEditor from 'react-ace';
+import 'brace/mode/sql';
+import 'brace/theme/github';
+import 'brace/ext/language_tools';
+
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import * as Actions from '../actions';
+import shortid from 'shortid';
+import ButtonWithTooltip from './ButtonWithTooltip';
+import SouthPane from './SouthPane';
+import Timer from './Timer';
+
+import SqlEditorTopToolbar from './SqlEditorTopToolbar';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class SqlEditor extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ autorun: props.queryEditor.autorun,
+ sql: props.queryEditor.sql,
+ };
+ }
+ componentDidMount() {
+ if (this.state.autorun) {
+ this.setState({ autorun: false });
+ this.props.actions.queryEditorSetAutorun(this.props.queryEditor, false);
+ this.startQuery();
+ }
+ }
+ getTableOptions(input, callback) {
+ const url = '/tableasync/api/read?_oc_DatabaseAsync=database_name&_od_DatabaseAsync=asc';
+ $.get(url, function (data) {
+ const options = [];
+ for (let i = 0; i < data.pks.length; i++) {
+ options.push({ value: data.pks[i], label: data.result[i].table_name });
+ }
+ callback(null, {
+ options,
+ cache: false,
+ });
+ });
+ }
+ startQuery() {
+ const that = this;
+ const query = {
+ id: shortid.generate(),
+ sqlEditorId: this.props.queryEditor.id,
+ sql: this.state.sql,
+ state: 'running',
+ tab: this.props.queryEditor.title,
+ dbId: this.props.queryEditor.dbId,
+ startDttm: new Date(),
+ };
+ const url = '/caravel/sql_json/';
+ const data = {
+ sql: this.state.sql,
+ database_id: this.props.queryEditor.dbId,
+ schema: this.props.queryEditor.schema,
+ json: true,
+ };
+ this.props.actions.startQuery(query);
+ $.ajax({
+ type: 'POST',
+ dataType: 'json',
+ url,
+ data,
+ success(results) {
+ try {
+ that.props.actions.querySuccess(query, results);
+ } catch (e) {
+ that.props.actions.queryFailed(query, e);
+ }
+ },
+ error(err) {
+ let msg = '';
+ try {
+ msg = err.responseJSON.error;
+ } catch (e) {
+ msg = (err.responseText) ? err.responseText : e;
+ }
+ that.props.actions.queryFailed(query, msg);
+ },
+ });
+ }
+ stopQuery() {
+ this.props.actions.stopQuery(this.props.latestQuery);
+ }
+ textChange(text) {
+ this.setState({ sql: text })
+ this.props.actions.queryEditorSetSql(this.props.queryEditor, text);
+ }
+ notImplemented() {
+ alert('Not implemented');
+ }
+ addWorkspaceQuery() {
+ this.props.actions.addWorkspaceQuery({
+ id: shortid.generate(),
+ sql: this.state.sql,
+ dbId: this.props.queryEditor.dbId,
+ schema: this.props.queryEditor.schema,
+ title: this.props.queryEditor.title,
+ });
+ }
+ ctasChange() {}
+ visualize() {}
+ render() {
+ let runButtons = (
+
+
+
+ );
+ if (this.props.latestQuery && this.props.latestQuery.state === 'running') {
+ runButtons = (
+
+
+
+ );
+ }
+ const rightButtons = (
+
+
+
+
+ }>
+
+
+
+
+ );
+ let limitWarning = null;
+ const row_limit = 1000;
+ if (this.props.latestQuery && this.props.latestQuery.rows === row_limit) {
+ const tooltip = (
+
+ It appears that the number of rows in the query results displayed
+ was limited on the server side to the {row_limit} limit.
+
+ );
+ limitWarning = (
+
+
+
+ );
+ }
+ const editorBottomBar = (
+
+
+ {runButtons}
+
+
+
+
+
+ {limitWarning}
+
+ {rightButtons}
+
+
+ );
+ return (
+
+
+
+
+
+ {editorBottomBar}
+
+
+
+
+
+
+ );
+ }
+}
+
+SqlEditor.propTypes = {
+ queryEditor: React.PropTypes.object,
+ actions: React.PropTypes.object,
+ latestQuery: React.PropTypes.object,
+};
+
+SqlEditor.defaultProps = {
+};
+
+function mapStateToProps(state) {
+ return {
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SqlEditor);
diff --git a/caravel/assets/javascripts/SqlLab/components/SqlEditorTopToolbar.jsx b/caravel/assets/javascripts/SqlLab/components/SqlEditorTopToolbar.jsx
new file mode 100644
index 00000000000..0190241d338
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/SqlEditorTopToolbar.jsx
@@ -0,0 +1,280 @@
+const $ = window.$ = require('jquery');
+import React from 'react';
+import { Label, OverlayTrigger, Popover } from 'react-bootstrap';
+
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import * as Actions from '../actions';
+import shortid from 'shortid';
+import Select from 'react-select';
+import Link from './Link';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class SqlEditorTopToolbar extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ databaseLoading: false,
+ databaseOptions: [],
+ schemaLoading: false,
+ schemaOptions: [],
+ tableLoading: false,
+ tableOptions: [],
+ };
+ }
+ componentWillMount() {
+ this.fetchDatabaseOptions();
+ this.fetchSchemas();
+ this.fetchTables();
+ }
+ getTableOptions(input, callback) {
+ const url = '/tableasync/api/read?_oc_DatabaseAsync=database_name&_od_DatabaseAsync=asc';
+ $.get(url, function (data) {
+ const options = [];
+ for (let i = 0; i < data.pks.length; i++) {
+ options.push({ value: data.pks[i], label: data.result[i].table_name });
+ }
+ callback(null, {
+ options,
+ cache: false,
+ });
+ });
+ }
+ getSql(table) {
+ let cols = '';
+ table.columns.forEach(function (col, i) {
+ cols += col.name;
+ if (i < table.columns.length - 1) {
+ cols += ', ';
+ }
+ });
+ return `SELECT ${cols}\nFROM ${table.name}`;
+ }
+ selectStar(table) {
+ this.props.actions.queryEditorSetSql(this.props.queryEditor, this.getSql(table));
+ }
+ popTab(table) {
+ const qe = {
+ id: shortid.generate(),
+ title: table.name,
+ dbId: table.dbId,
+ schema: table.schema,
+ autorun: true,
+ sql: this.getSql(table),
+ };
+ this.props.actions.addQueryEditor(qe);
+ }
+ fetchTables(dbId, schema) {
+ const actualDbId = dbId || this.props.queryEditor.dbId;
+ if (actualDbId) {
+ const actualSchema = schema || this.props.queryEditor.schema;
+ const that = this;
+ this.setState({ tableLoading: true });
+ this.setState({ tableOptions: [] });
+ const url = `/caravel/tables/${actualDbId}/${actualSchema}`;
+ $.get(url, function (data) {
+ let tableOptions = data.tables.map((s) => ({ value: s, label: s }));
+ const views = data.views.map((s) => ({ value: s, label: '[view] ' + s }));
+ tableOptions = [...tableOptions, ...views];
+ that.setState({ tableOptions });
+ that.setState({ tableLoading: false });
+ });
+ }
+ }
+ changeSchema(schemaOpt) {
+ const schema = (schemaOpt) ? schemaOpt.value : null;
+ this.props.actions.queryEditorSetSchema(this.props.queryEditor, schema);
+ this.fetchTables(this.props.queryEditor.dbId, schema);
+ }
+ fetchSchemas(dbId) {
+ const that = this;
+ const actualDbId = dbId || this.props.queryEditor.dbId;
+ if (actualDbId) {
+ this.setState({ schemaLoading: true });
+ const url = `/databasetablesasync/api/read?_flt_0_id=${actualDbId}`;
+ $.get(url, function (data) {
+ const schemas = data.result[0].all_schema_names;
+ const schemaOptions = schemas.map((s) => ({ value: s, label: s }));
+ that.setState({ schemaOptions });
+ that.setState({ schemaLoading: false });
+ });
+ }
+ }
+ changeDb(db) {
+ const val = (db) ? db.value : null;
+ this.setState({ schemaOptions: [] });
+ this.props.actions.queryEditorSetDb(this.props.queryEditor, val);
+ if (!(db)) {
+ this.setState({ tableOptions: [] });
+ return;
+ }
+ this.fetchTables(val, this.props.queryEditor.schema);
+ this.fetchSchemas(val);
+ }
+ fetchDatabaseOptions() {
+ this.setState({ databaseLoading: true });
+ const that = this;
+ const url = '/databaseasync/api/read';
+ $.get(url, function (data) {
+ const options = data.result.map((db) => ({ value: db.id, label: db.database_name }));
+ that.setState({ databaseOptions: options });
+ that.setState({ databaseLoading: false });
+ });
+ }
+ notImplemented() {
+ alert('Not implemented');
+ }
+ closePopover(ref) {
+ this.refs[ref].hide();
+ }
+ changeTable(tableOpt) {
+ const tableName = tableOpt.value;
+ const that = this;
+ const qe = this.props.queryEditor;
+ const url = `/caravel/table/${qe.dbId}/${tableName}/${qe.schema}/`;
+ $.get(url, function (data) {
+ that.props.actions.addTable({
+ id: shortid.generate(),
+ dbId: that.props.queryEditor.dbId,
+ queryEditorId: that.props.queryEditor.id,
+ name: data.name,
+ schema: qe.schema,
+ columns: data.columns,
+ expanded: true,
+ showPopup: false,
+ });
+ });
+ }
+ render() {
+ const tables = this.props.tables.filter((t) => (t.queryEditorId === this.props.queryEditor.id));
+ const tablesEls = tables.map((table) => {
+ let cols = [];
+ if (table.columns) {
+ cols = table.columns.map((col) => (
+
+
{col.name}
+
{col.type}
+
+ ));
+ }
+ const popoverId = 'tblPopover_' + table.name;
+ const popoverTop = (
+
+ );
+ const popover = (
+
+ {cols}
+
+ );
+ return (
+
+ );
+ });
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {tablesEls}
+
+
+ );
+ }
+}
+
+SqlEditorTopToolbar.propTypes = {
+ queryEditor: React.PropTypes.object,
+ tables: React.PropTypes.array,
+ actions: React.PropTypes.object,
+};
+
+SqlEditorTopToolbar.defaultProps = {
+ tables: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ tables: state.tables,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SqlEditorTopToolbar);
diff --git a/caravel/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx b/caravel/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx
new file mode 100644
index 00000000000..76186d11e5d
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import { DropdownButton, MenuItem, Panel, Tab, Tabs } from 'react-bootstrap';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import SqlEditor from './SqlEditor';
+import shortid from 'shortid';
+
+let queryCount = 1;
+
+class QueryEditors extends React.Component {
+ renameTab(qe) {
+ const newTitle = prompt('Enter a new title for the tab');
+ if (newTitle) {
+ this.props.actions.queryEditorSetTitle(qe, newTitle);
+ }
+ }
+ newQueryEditor() {
+ queryCount++;
+ const dbId = (this.props.workspaceDatabase) ? this.props.workspaceDatabase.id : null;
+ const qe = {
+ id: shortid.generate(),
+ title: `Query ${queryCount}`,
+ dbId,
+ autorun: false,
+ sql: 'SELECT ...',
+ };
+ this.props.actions.addQueryEditor(qe);
+ }
+ handleSelect(key) {
+ if (key === 'add_tab') {
+ this.newQueryEditor();
+ } else {
+ this.props.actions.setActiveQueryEditor({ id: key });
+ }
+ }
+ render() {
+ const that = this;
+ const editors = this.props.queryEditors.map((qe, i) => {
+ let latestQuery;
+ that.props.queries.forEach((q) => {
+ if (q.id === qe.latestQueryId) {
+ latestQuery = q;
+ }
+ });
+ const state = (latestQuery) ? latestQuery.state : '';
+ const tabTitle = (
+
+
{qe.title} {' '}
+
+
+
+
+
+ );
+ return (
+
+
+
+
+ );
+ });
+ return (
+
+ {editors}
+ } eventKey="add_tab" />
+
+ );
+ }
+}
+QueryEditors.propTypes = {
+ actions: React.PropTypes.object,
+ tabHistory: React.PropTypes.array,
+ queryEditors: React.PropTypes.array,
+ workspaceDatabase: React.PropTypes.object,
+};
+QueryEditors.defaultProps = {
+ tabHistory: [],
+ queryEditors: [],
+};
+
+function mapStateToProps(state) {
+ return {
+ queryEditors: state.queryEditors,
+ queries: state.queries,
+ workspaceDatabase: state.workspaceDatabase,
+ tabHistory: state.tabHistory,
+ };
+}
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(QueryEditors);
diff --git a/caravel/assets/javascripts/SqlLab/components/TableMetadata.jsx b/caravel/assets/javascripts/SqlLab/components/TableMetadata.jsx
new file mode 100644
index 00000000000..75bbc6459fc
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/TableMetadata.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
+
+const TableMetadata = function (props) {
+ return (
+
+
+ id
+
+ Name
+ Type
+
+ );
+};
+
+TableMetadata.propTypes = {
+ table: React.PropTypes.object,
+};
+
+export default TableMetadata;
diff --git a/caravel/assets/javascripts/SqlLab/components/TableWorkspaceElement.jsx b/caravel/assets/javascripts/SqlLab/components/TableWorkspaceElement.jsx
new file mode 100644
index 00000000000..e552c8b54cd
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/TableWorkspaceElement.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { ButtonGroup } from 'react-bootstrap';
+import Link from './Link';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as Actions from '../actions';
+import shortid from 'shortid';
+
+// CSS
+import 'react-select/dist/react-select.css';
+
+class TableWorkspaceElement extends React.Component {
+ selectStar() {
+ let cols = '';
+ const that = this;
+ this.props.table.columns.forEach(function (col, i) {
+ cols += col.name;
+ if (i < that.props.table.columns.length - 1) {
+ cols += ', ';
+ }
+ });
+ const sql = `SELECT ${cols}\nFROM ${this.props.table.name}`;
+ const qe = {
+ id: shortid.generate(),
+ title: this.props.table.name,
+ dbId: this.props.table.dbId,
+ autorun: true,
+ sql,
+ };
+ this.props.actions.addQueryEditor(qe);
+ }
+ render() {
+ let metadata = null;
+ let buttonToggle;
+ if (!this.props.table.expanded) {
+ buttonToggle = (
+
+ {this.props.table.name}
+
+ );
+ metadata = this.props.table.columns.map((col) =>
+
+ {col.name}
+ {col.type}
+
+ );
+ metadata = (
+ {metadata}
+ );
+ } else {
+ buttonToggle = (
+
+ {this.props.table.name}
+
+ );
+ }
+ return (
+
+ {buttonToggle}
+
+
+
+
+ {metadata}
+
+ );
+ }
+}
+TableWorkspaceElement.propTypes = {
+ table: React.PropTypes.object,
+ actions: React.PropTypes.object,
+};
+TableWorkspaceElement.defaultProps = {
+ table: null,
+};
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(Actions, dispatch),
+ };
+}
+export default connect(null, mapDispatchToProps)(TableWorkspaceElement);
+
diff --git a/caravel/assets/javascripts/SqlLab/components/Timer.jsx b/caravel/assets/javascripts/SqlLab/components/Timer.jsx
new file mode 100644
index 00000000000..0e5c7ff1883
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/components/Timer.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import moment from 'moment';
+
+
+class Timer extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ clockStr: '',
+ };
+ }
+ componentWillMount() {
+ this.startTimer();
+ }
+ componentWillUnmount() {
+ this.stopTimer();
+ }
+ startTimer() {
+ if (!(this.timer)) {
+ this.timer = setInterval(this.stopwatch.bind(this), 30);
+ }
+ }
+ stopTimer() {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ stopwatch() {
+ if (this.props && this.props.query) {
+ let fromDttm = this.props.query.endDttm || new Date();
+ fromDttm = moment(fromDttm);
+ let duration = fromDttm - moment(this.props.query.startDttm).valueOf();
+ duration = moment.utc(duration);
+ const clockStr = duration.format('HH:mm:ss.SS');
+ this.setState({ clockStr });
+ if (this.props.query.state !== 'running') {
+ this.stopTimer();
+ }
+ }
+ }
+ render() {
+ if (this.props.query && this.props.query.state === 'running') {
+ this.startTimer();
+ }
+ let timerSpan = null;
+ if (this.props && this.props.query) {
+ timerSpan = (
+
+ {this.state.clockStr}
+
+ );
+ }
+ return timerSpan;
+ }
+}
+Timer.propTypes = {
+ query: React.PropTypes.object,
+};
+Timer.defaultProps = {
+ query: null,
+};
+
+export default Timer;
diff --git a/caravel/assets/javascripts/SqlLab/index.jsx b/caravel/assets/javascripts/SqlLab/index.jsx
new file mode 100644
index 00000000000..af948e98850
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/index.jsx
@@ -0,0 +1,52 @@
+var $ = window.$ = require('jquery');
+var jQuery = window.jQuery = $;
+require('bootstrap');
+
+import React from 'react';
+import { render } from 'react-dom';
+
+import SplitPane from 'react-split-pane';
+
+import { Label, Tab, Tabs } from 'react-bootstrap';
+
+import LeftPane from './components/LeftPane';
+import TabbedSqlEditors from './components/TabbedSqlEditors';
+
+import { compose, createStore } from 'redux';
+import { Provider } from 'react-redux';
+
+import { initialState, sqlLabReducer } from './reducers';
+import persistState from 'redux-localstorage';
+
+require('./main.css');
+
+let store = createStore(sqlLabReducer, initialState, compose(persistState(), window.devToolsExtension && window.devToolsExtension()));
+
+// jquery hack to highlight the navbar menu
+$('a[href="/caravel/sqllab"]').parent().addClass('active');
+
+const App = React.createClass({
+ render() {
+ return (
+
+ );
+ },
+});
+
+render(
+
+
+ ,
+ document.getElementById('app')
+);
diff --git a/caravel/assets/javascripts/SqlLab/main.css b/caravel/assets/javascripts/SqlLab/main.css
new file mode 100644
index 00000000000..8aa9858d983
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/main.css
@@ -0,0 +1,249 @@
+#app {
+ position: absolute;
+ top: 65;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+.inlineBlock {
+ display: inline-block;
+}
+.valignTop {
+ vertical-align: top;
+}
+.inline {
+ display: inline;
+}
+.nopadding {
+ padding: 0px;
+}
+.panel.nopadding .panel-body {
+ padding: 0px;
+}
+.panel {
+ width: 100%;
+ overflow: auto;
+ margin-bottom: 10px;
+}
+.SqlEditor .panel-heading {
+ padding: 5px;
+}
+.window.panel-heading {
+ padding: 1px 5px;
+}
+.loading {
+ width: 50px;
+ margin-top: 15px;
+}
+.pane-cell {
+ padding: 10px;
+ overflow: auto;
+ width: 100%;
+ height: 100%;
+}
+.SqlEditor .header {
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+.Workspace .btn-sm {
+ box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
+ margin-top: 2px;
+ padding: 4px;
+}
+.Workspace hr {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+div.Workspace {
+ height: 100%;
+ margin: 0px;
+}
+.SqlEditor .clock {
+ background-color: orange;
+ padding: 5px;
+}
+.sql-toolbar {
+ border-bottom: 1px solid #DDD;
+ border-top: 1px solid #DDD;
+}
+.padded {
+ padding: 10px;
+}
+.nav-pills {
+ padding-bottom: 5px;
+}
+.p-t-10 {
+ padding-top: 10px;
+}
+.p-t-5 {
+ padding-top: 5px;
+}
+.m-r-5 {
+ margin-right: 5px;
+}
+.m-r-10 {
+ margin-right: 10px;
+}
+.m-b-10 {
+ margin-bottom: 10px;
+}
+.m-t-5 {
+ margin-top: 5px;
+}
+.m-t-10 {
+ margin-top: 10px;
+}
+.p-t-10 {
+ padding-top: 10px;
+}
+.sqllab-toolbar {
+ padding-top: 5px;
+ border-bottom: 1px solid #DDD;
+}
+.no-shadow {
+ box-shadow: none;
+ background-color: transparent;
+}
+.pane-west {
+ height: 100%;
+ overflow: auto;
+}
+.ws-el .ws-el-controls { display: none; }
+.ws-el:hover .ws-el-controls { display: block; }
+.ws-el {
+ border-radius: 4px;
+ padding: 1px 6px;
+ border: 1px solid transparent;
+}
+.circle {
+ border-radius: 50%;
+ width: 10px;
+ height: 10px;
+ display: inline-block;
+ border: 1px solid #444;
+}
+.Pane2 {
+ width: 0;
+}
+.running {
+ background-color: lime;
+ color: black;
+}
+.success {
+ background-color: green;
+}
+.failed {
+ background-color: red;
+}
+.ws-el:hover { border: 1px solid #DDD; }
+
+.handle {
+ cursor: move;
+}
+.window {
+ z-index: 1000;
+ position: absolute;
+ width: 300px;
+ opacity: 0.85;
+ border: 1px solid #AAA;
+ max-height: 600px;
+ box-shadow: rgba(0, 0, 0, 0.8) 5px 5px 25px
+}
+
+.list-group-item {
+ padding: 5px 10px;
+}
+
+table {
+ font-size: 12px;
+ margin: 0px;
+}
+
+.SqlLab pre {
+ padding: 0px !important;
+ margin: 0px;
+ border: none;
+ font-size: 11px;
+ line-height: 125%;
+ background-color: transparent !important;
+}
+
+.Resizer {
+ background: #000;
+ opacity: .2;
+ z-index: 1;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+ -moz-background-clip: padding;
+ -webkit-background-clip: padding;
+ background-clip: padding-box;
+}
+
+.Resizer:hover {
+ -webkit-transition: all 2s ease;
+ transition: all 2s ease;
+}
+
+.Resizer.horizontal {
+ height: 10px;
+ margin: -5px 0;
+ border-top: 5px solid rgba(255, 255, 255, 0);
+ border-bottom: 5px solid rgba(255, 255, 255, 0);
+ cursor: row-resize;
+ width: 100%;
+ padding: 1px;
+}
+
+.Resizer.horizontal:hover {
+ border-top: 5px solid rgba(0, 0, 0, 0.5);
+ border-bottom: 5px solid rgba(0, 0, 0, 0.5);
+}
+
+.Resizer.vertical {
+ width: 9px;
+ margin: 0 -5px;
+ border-left: 5px solid rgba(255, 255, 255, 0);
+ border-right: 5px solid rgba(255, 255, 255, 0);
+ cursor: col-resize;
+}
+
+.Resizer.vertical:hover {
+ border-left: 5px solid rgba(0, 0, 0, 0.5);
+ border-right: 5px solid rgba(0, 0, 0, 0.5);
+}
+Resizer.disabled {
+ cursor: not-allowed;
+}
+Resizer.disabled:hover {
+ border-color: transparent;
+}
+
+a.Link {
+ padding: 7px 3px;
+}
+
+table .label {
+ margin-top: 5px;
+}
+.popover{
+ max-width:400px;
+}
+.Select-menu-outer {
+ z-index: 1000;
+}
+.table-label {
+ margin-top: 5px;
+ margin-right: 10px;
+ float: left;
+}
+div.tablePopover {
+ opacity: 0.7 !important;
+}
+div.tablePopover:hover {
+ opacity: 1 !important;
+}
+.ResultSetControls {
+ padding-bottom: 3px;
+ padding-top: 3px;
+}
diff --git a/caravel/assets/javascripts/SqlLab/reducers.js b/caravel/assets/javascripts/SqlLab/reducers.js
new file mode 100644
index 00000000000..b07d83b5e38
--- /dev/null
+++ b/caravel/assets/javascripts/SqlLab/reducers.js
@@ -0,0 +1,145 @@
+import moment from 'moment';
+import shortid from 'shortid';
+import * as actions from './actions';
+
+const defaultQueryEditor = {
+ id: shortid.generate(),
+ title: 'Query 1',
+ sql: 'SELECT *\nFROM\nWHERE',
+ latestQueryId: null,
+ autorun: false,
+ dbId: null,
+};
+
+export const initialState = {
+ queryEditors: [defaultQueryEditor],
+ queries: [],
+ tables: [],
+ workspaceQueries: [],
+ tabHistory: [defaultQueryEditor.id],
+};
+
+
+function alterInArr(state, arrKey, obj, alterations) {
+ // Finds an item in an array in the state and replaces it with a
+ // new object with an altered property
+ const idKey = 'id';
+ const newArr = [];
+ state[arrKey].forEach((arrItem) => {
+ if (obj[idKey] === arrItem[idKey]) {
+ newArr.push(Object.assign({}, arrItem, alterations));
+ } else {
+ newArr.push(arrItem);
+ }
+ });
+ return Object.assign({}, state, { [arrKey]: newArr });
+}
+
+function removeFromArr(state, arrKey, obj, idKey = 'id') {
+ const newArr = [];
+ state[arrKey].forEach((arrItem) => {
+ if (!(obj[idKey] === arrItem[idKey])) {
+ newArr.push(arrItem);
+ }
+ });
+ return Object.assign({}, state, { [arrKey]: newArr });
+}
+
+function addToArr(state, arrKey, obj) {
+ const newState = {};
+ newState[arrKey] = [...state[arrKey], Object.assign({}, obj)];
+ return Object.assign({}, state, newState);
+}
+
+export const sqlLabReducer = function (state, action) {
+ const actionHandlers = {
+ [actions.ADD_QUERY_EDITOR]() {
+ const tabHistory = state.tabHistory.slice();
+ tabHistory.push(action.queryEditor.id);
+ const newState = Object.assign({}, state, { tabHistory });
+ return addToArr(newState, 'queryEditors', action.queryEditor);
+ },
+ [actions.REMOVE_QUERY_EDITOR]() {
+ let newState = removeFromArr(state, 'queryEditors', action.queryEditor);
+ // List of remaining queryEditor ids
+ const qeIds = newState.queryEditors.map((qe) => qe.id);
+ let th = state.tabHistory.slice();
+ th = th.filter((id) => qeIds.includes(id));
+ newState = Object.assign({}, newState, { tabHistory: th });
+ return newState;
+ },
+ [actions.REMOVE_QUERY]() {
+ return removeFromArr(state, 'queries', action.query);
+ },
+ [actions.RESET_STATE]() {
+ return Object.assign({}, initialState);
+ },
+ [actions.ADD_TABLE]() {
+ return addToArr(state, 'tables', action.table);
+ },
+ [actions.EXPAND_TABLE]() {
+ return alterInArr(state, 'tables', action.table, { expanded: true });
+ },
+ [actions.COLLAPSE_TABLE]() {
+ return alterInArr(state, 'tables', action.table, { expanded: false });
+ },
+ [actions.REMOVE_TABLE]() {
+ return removeFromArr(state, 'tables', action.table);
+ },
+ [actions.START_QUERY]() {
+ const newState = addToArr(state, 'queries', action.query);
+ const sqlEditor = { id: action.query.sqlEditorId };
+ return alterInArr(newState, 'queryEditors', sqlEditor, { latestQueryId: action.query.id });
+ },
+ [actions.STOP_QUERY]() {
+ return alterInArr(state, 'queries', action.query, { state: 'stopped' });
+ },
+ [actions.QUERY_SUCCESS]() {
+ const alts = {
+ state: 'success',
+ results: action.results,
+ rows: action.results.data.length,
+ endDttm: moment(),
+ };
+ return alterInArr(state, 'queries', action.query, alts);
+ },
+ [actions.QUERY_FAILED]() {
+ const alts = { state: 'failed', msg: action.msg, endDttm: moment() };
+ return alterInArr(state, 'queries', action.query, alts);
+ },
+ [actions.SET_ACTIVE_QUERY_EDITOR]() {
+ const qeIds = state.queryEditors.map((qe) => qe.id);
+ if (qeIds.includes(action.queryEditor.id)) {
+ const tabHistory = state.tabHistory.slice();
+ tabHistory.push(action.queryEditor.id);
+ return Object.assign({}, state, { tabHistory });
+ }
+ return state;
+ },
+ [actions.QUERY_EDITOR_SETDB]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { dbId: action.dbId });
+ },
+ [actions.QUERY_EDITOR_SET_SCHEMA]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { schema: action.schema });
+ },
+ [actions.QUERY_EDITOR_SET_TITLE]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { title: action.title });
+ },
+ [actions.QUERY_EDITOR_SET_SQL]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { sql: action.sql });
+ },
+ [actions.QUERY_EDITOR_SET_AUTORUN]() {
+ return alterInArr(state, 'queryEditors', action.queryEditor, { autorun: action.autorun });
+ },
+ [actions.ADD_WORKSPACE_QUERY]() {
+ return addToArr(state, 'workspaceQueries', action.query);
+ },
+ [actions.REMOVE_WORKSPACE_QUERY]() {
+ return removeFromArr(state, 'workspaceQueries', action.query);
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+}
diff --git a/caravel/assets/javascripts/dashboard/Dashboard.jsx b/caravel/assets/javascripts/dashboard/Dashboard.jsx
index 99c2a1bd382..4b7fc97429c 100644
--- a/caravel/assets/javascripts/dashboard/Dashboard.jsx
+++ b/caravel/assets/javascripts/dashboard/Dashboard.jsx
@@ -50,6 +50,7 @@ function dashboardContainer(dashboardData) {
const sliceObjects = [];
const dash = this;
dashboard.slices.forEach((data) => {
+ console.log(data);
if (data.error) {
const html = '' + data.error + '
';
$('#slice_' + data.slice_id).find('.token').html(html);
diff --git a/caravel/assets/package.json b/caravel/assets/package.json
index d9d9f8b1d39..81078b72564 100644
--- a/caravel/assets/package.json
+++ b/caravel/assets/package.json
@@ -55,19 +55,30 @@
"jquery": "^2.2.1",
"jquery-ui": "1.10.5",
"mapbox-gl": "^0.20.0",
+ "moment": "^2.14.1",
+ "moments": "0.0.2",
"mustache": "^2.2.1",
"nvd3": "1.8.4",
"react": "^15.2.1",
- "react-bootstrap": "^0.28.3",
- "react-bootstrap-datetimepicker": "0.0.22",
- "react-bootstrap-table": "^2.3.7",
+ "react-ace": "^3.4.1",
+ "react-bootstrap": "^0.30.1",
+ "react-bootstrap-table": "^2.3.8",
"react-dom": "^0.14.8",
- "react-grid-layout": "^0.12.3",
+ "react-draggable": "^2.1.2",
+ "react-grid-layout": "^0.12.4",
"react-map-gl": "^1.0.0-beta-10",
+ "react-redux": "^4.4.5",
"react-resizable": "^1.3.3",
"react-select": "^1.0.0-beta14",
+ "react-split-pane": "^0.1.42",
+ "react-syntax-highlighter": "^2.1.1",
+ "reactable": "^0.13.2",
+ "redux": "^3.5.2",
+ "redux-localstorage": "^0.4.1",
"select2": "3.5",
"select2-bootstrap-css": "^1.4.6",
+ "shortid": "^2.2.6",
+ "style-loader": "^0.13.0",
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
"topojson": "^1.6.22",
"viewport-mercator-project": "^2.1.0"
diff --git a/caravel/assets/stylesheets/less/variables.less b/caravel/assets/stylesheets/less/variables.less
index 7cdfe1016a1..341f39587e4 100644
--- a/caravel/assets/stylesheets/less/variables.less
+++ b/caravel/assets/stylesheets/less/variables.less
@@ -142,9 +142,9 @@
@border-radius-small: 2px;
//** Global color for active items (e.g., navs or dropdowns).
-@component-active-color: #fff;
+@component-active-color: black;
//** Global background color for active items (e.g., navs or dropdowns).
-@component-active-bg: @brand-primary;
+@component-active-bg: #DDD;
//** Width of the `border` for generating carets that indicator dropdowns.
@caret-width-base: 4px;
diff --git a/caravel/assets/webpack.config.js b/caravel/assets/webpack.config.js
index b877d24df51..76aa7e4c1da 100644
--- a/caravel/assets/webpack.config.js
+++ b/caravel/assets/webpack.config.js
@@ -16,6 +16,7 @@ const config = {
sql: APP_DIR + '/javascripts/sql.js',
standalone: APP_DIR + '/javascripts/standalone.js',
common: APP_DIR + '/javascripts/common.js',
+ sqllab: APP_DIR + '/javascripts/SqlLab/index.jsx',
},
output: {
path: BUILD_DIR,
diff --git a/caravel/models.py b/caravel/models.py
index 5fe42729d62..32f08223a10 100644
--- a/caravel/models.py
+++ b/caravel/models.py
@@ -16,6 +16,7 @@ import humanize
import pandas as pd
import requests
import sqlalchemy as sqla
+from sqlalchemy.engine.url import make_url
import sqlparse
from dateutil.parser import parse
@@ -310,6 +311,11 @@ class Dashboard(Model, AuditMixinNullable):
else:
return {}
+ @property
+ def sqla_metadata(self):
+ metadata = MetaData(bind=self.get_sqla_engine())
+ return metadata.reflect()
+
def dashboard_link(self):
return '{obj.dashboard_title}'.format(obj=self)
@@ -382,14 +388,49 @@ class Database(Model, AuditMixinNullable):
def __repr__(self):
return self.database_name
- def get_sqla_engine(self):
+ def get_sqla_engine(self, schema=None):
extra = self.get_extra()
params = extra.get('engine_params', {})
- return create_engine(self.sqlalchemy_uri_decrypted, **params)
+ url = make_url(self.sqlalchemy_uri_decrypted)
+ backend = url.get_backend_name()
+ if backend == 'presto' and schema:
+ if '/' in url.database:
+ url.database = url.database.split('/')[0] + '/' + schema
+ else:
+ url.database += '/' + schema
+ elif schema:
+ url.database = schema
+ return create_engine(url, **params)
+
+ def get_df(self, sql, schema):
+ eng = self.get_sqla_engine(schema=schema)
+ cur = eng.execute(sql, schema=schema)
+ cols = [col[0] for col in cur.cursor.description]
+ df = pd.DataFrame(cur.fetchall(), columns=cols)
+ return df
def safe_sqlalchemy_uri(self):
return self.sqlalchemy_uri
+ @property
+ def inspector(self):
+ engine = self.get_sqla_engine()
+ return sqla.inspect(engine)
+
+ def all_table_names(self, schema=None):
+ return sorted(self.inspector.get_table_names(schema))
+
+ def all_view_names(self, schema=None):
+ views = []
+ try:
+ views = self.inspector.get_view_names(schema)
+ except Exception as e:
+ pass
+ return views
+
+ def all_schema_names(self):
+ return sorted(self.inspector.get_schema_names())
+
def grains(self):
"""Defines time granularity database-specific expressions.
@@ -508,10 +549,8 @@ class Database(Model, AuditMixinNullable):
autoload=True,
autoload_with=self.get_sqla_engine())
- def get_columns(self, table_name):
- engine = self.get_sqla_engine()
- insp = reflection.Inspector.from_engine(engine)
- return insp.get_columns(table_name)
+ def get_columns(self, table_name, schema=None):
+ return self.inspector.get_columns(table_name, schema)
@property
def sqlalchemy_uri_decrypted(self):
diff --git a/caravel/templates/caravel/basic.html b/caravel/templates/caravel/basic.html
index 07782418edc..c82a40e1c11 100644
--- a/caravel/templates/caravel/basic.html
+++ b/caravel/templates/caravel/basic.html
@@ -29,7 +29,7 @@
{% block body %}
{% include 'caravel/flash_wrapper.html' %}
- Oops! React.js is not working properly.
+
{% endblock %}
diff --git a/caravel/utils.py b/caravel/utils.py
index 5921f3f232e..de97ed0491f 100644
--- a/caravel/utils.py
+++ b/caravel/utils.py
@@ -4,7 +4,7 @@ from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
-from datetime import datetime, date
+from datetime import date, datetime
import decimal
import functools
import json
@@ -203,8 +203,11 @@ def init(caravel):
if perm.permission.name in ('datasource_access', 'database_access'):
continue
if perm.view_menu and perm.view_menu.name not in (
- 'UserDBModelView', 'RoleModelView', 'ResetPasswordView',
- 'Security'):
+ 'ResetPasswordView',
+ 'RoleModelView',
+ 'Security',
+ 'UserDBModelView',
+ 'SQL Lab'):
sm.add_permission_role(alpha, perm)
sm.add_permission_role(admin, perm)
gamma = sm.add_role("Gamma")
@@ -217,6 +220,7 @@ def init(caravel):
'ResetPasswordView',
'RoleModelView',
'UserDBModelView',
+ 'SQL Lab',
'Security') and
perm.permission.name not in (
'all_datasource_access',
@@ -304,6 +308,8 @@ def json_iso_dttm_ser(obj):
return val
if isinstance(obj, datetime):
obj = obj.isoformat()
+ if isinstance(obj, date):
+ obj = obj.isoformat()
else:
raise TypeError(
"Unserializable object {} of type {}".format(obj, type(obj))
@@ -329,6 +335,7 @@ def json_int_dttm_ser(obj):
def error_msg_from_exception(e):
"""Translate exception into error message
+
Database have different ways to handle exception. This function attempts
to make sense of the exception object and construct a human readable
sentence.
diff --git a/caravel/views.py b/caravel/views.py
index 629136a890c..d2beac85abe 100755
--- a/caravel/views.py
+++ b/caravel/views.py
@@ -452,6 +452,16 @@ appbuilder.add_view(
category_icon='fa-database',)
+class DatabaseAsync(DatabaseView):
+ list_columns = ['id', 'database_name']
+
+appbuilder.add_view_no_menu(DatabaseAsync)
+
+class DatabaseTablesAsync(DatabaseView):
+ list_columns = ['id', 'all_table_names', 'all_schema_names']
+
+appbuilder.add_view_no_menu(DatabaseTablesAsync)
+
class TableModelView(CaravelModelView, DeleteMixin): # noqa
datamodel = SQLAInterface(models.SqlaTable)
list_columns = [
@@ -911,7 +921,7 @@ class Caravel(BaseCaravelView):
form_data=request.args,
slice_=slc)
except Exception as e:
- flash(str(e), "danger")
+ flash(utils.error_msg_from_exception(e), "danger")
return redirect(error_redirect)
if request.args.get("json") == "true":
status = 200
@@ -923,7 +933,7 @@ class Caravel(BaseCaravelView):
payload = obj.get_json()
except Exception as e:
logging.exception(e)
- payload = str(e)
+ payload = utils.error_msg_from_exception(e)
status = 500
resp = Response(
payload,
@@ -953,7 +963,7 @@ class Caravel(BaseCaravelView):
if config.get("DEBUG"):
raise(e)
return Response(
- str(e),
+ utils.error_msg_from_exception(e),
status=500,
mimetype="application/json")
return resp
@@ -1083,6 +1093,25 @@ class Caravel(BaseCaravelView):
payload = {str(time.mktime(dt.timetuple())): ccount for dt, ccount in qry if dt}
return Response(json.dumps(payload), mimetype="application/json")
+ @api
+ @has_access_api
+ @expose("/tables//")
+ def tables(self, db_id, schema):
+ """endpoint to power the calendar heatmap on the welcome page"""
+ schema = None if schema == 'null' else schema
+ database = (
+ db.session
+ .query(models.Database)
+ .filter_by(id=db_id)
+ .one()
+ )
+ payload = {
+ 'tables': database.all_table_names(schema),
+ 'views': database.all_view_names(schema),
+ }
+ return Response(
+ json.dumps(payload), mimetype="application/json")
+
@api
@has_access_api
@expose("/save_dash//", methods=['GET', 'POST'])
@@ -1218,6 +1247,13 @@ class Caravel(BaseCaravelView):
@expose("/sql//")
@log_this
def sql(self, database_id):
+ if (
+ not self.can_access(
+ 'all_datasource_access', 'all_datasource_access')):
+ flash(
+ "SQL Lab requires the `all_datasource_access` "
+ "permission", "danger")
+ return redirect("/tablemodelview/list/")
mydb = db.session.query(
models.Database).filter_by(id=database_id).first()
@@ -1240,23 +1276,35 @@ class Caravel(BaseCaravelView):
db=mydb)
@has_access
- @expose("/table///")
+ @expose("/table////")
@log_this
- def table(self, database_id, table_name):
- mydb = db.session.query(
- models.Database).filter_by(id=database_id).first()
- cols = mydb.get_columns(table_name)
- df = pd.DataFrame([(c['name'], c['type']) for c in cols])
- df.columns = ['col', 'type']
- tbl_cls = (
- "dataframe table table-striped table-bordered "
- "table-condensed sql_results").split(' ')
- return self.render_template(
- "caravel/ajah.html",
- content=df.to_html(
- index=False,
- na_rep='',
- classes=tbl_cls))
+ def table(self, database_id, table_name, schema):
+ schema = None if schema == 'null' else schema
+ mydb = db.session.query(models.Database).filter_by(id=database_id).one()
+ cols = []
+ t = mydb.get_columns(table_name, schema)
+ try:
+ t = mydb.get_columns(table_name, schema)
+ except Exception as e:
+ return Response(
+ json.dumps({'error': utils.error_msg_from_exception(e)}),
+ mimetype="application/json")
+ for col in t:
+ dtype = ""
+ try:
+ dtype = '{}'.format(col['type'])
+ except:
+ pass
+ cols.append({
+ 'name': col['name'],
+ 'type': dtype.split('(')[0] if '(' in dtype else dtype,
+ 'longType': dtype,
+ })
+ tbl = {
+ 'name': table_name,
+ 'columns': cols,
+ }
+ return Response(json.dumps(tbl), mimetype="application/json")
@has_access
@expose("/select_star///")
@@ -1330,7 +1378,7 @@ class Caravel(BaseCaravelView):
content = (
''
"{}
"
- ).format(e.message)
+ ).format(utils.error_msg_from_exception(e))
session.commit()
return content
@@ -1347,6 +1395,7 @@ class Caravel(BaseCaravelView):
limit = 1000
sql = request.form.get('sql')
database_id = request.form.get('database_id')
+ schema = request.form.get('schema')
mydb = session.query(models.Database).filter_by(id=database_id).first()
if not (self.can_access(
@@ -1372,10 +1421,9 @@ class Caravel(BaseCaravelView):
sql = '{}'.format(qry.compile(
eng, compile_kwargs={"literal_binds": True}))
try:
- df = pd.read_sql_query(sql=sql, con=eng)
+ df = mydb.get_df(sql, schema)
df = df.fillna(0) # TODO make sure NULL
except Exception as e:
- logging.exception(e)
error_msg = utils.error_msg_from_exception(e)
session.commit()
@@ -1390,6 +1438,8 @@ class Caravel(BaseCaravelView):
data = {
'columns': [c for c in df.columns],
'data': df.to_dict(orient='records'),
+ 'ydata_tpe.to_dict': {
+ k: '{}'.format(v) for k, v in df.dtypes.to_dict().items()},
}
return json.dumps(
data, default=utils.json_int_dttm_ser, allow_nan=False)
@@ -1433,6 +1483,11 @@ class Caravel(BaseCaravelView):
"""Personalized welcome page"""
return self.render_template('caravel/welcome.html', utils=utils)
+ @has_access
+ @expose("/sqllab")
+ def sqlanvil(self):
+ """SQL Editor"""
+ return self.render_template('caravel/sqllab.html')
appbuilder.add_view_no_menu(Caravel)
@@ -1462,6 +1517,11 @@ appbuilder.add_view(
category_label=__("Sources"),
category_icon='')
+appbuilder.add_link(
+ "SQL Lab",
+ href='/caravel/sqllab',
+ icon="fa-flask")
+
# ---------------------------------------------------------------------
# Redirecting URL from previous names
diff --git a/tests/core_tests.py b/tests/core_tests.py
index ec03422853e..d71dcff2325 100644
--- a/tests/core_tests.py
+++ b/tests/core_tests.py
@@ -365,6 +365,10 @@ class CoreTests(CaravelTestCase):
assert 'Births' in data
# Confirm that public doesn't have access to other datasets.
+ resp = self.client.get('/slicemodelview/list/')
+ data = resp.data.decode('utf-8')
+ assert 'wb_health_population' not in data
+
resp = self.client.get('/dashboardmodelview/list/')
data = resp.data.decode('utf-8')
assert "/caravel/dashboard/world_health/" not in data