From c0e9d2b88c411c3957224d221fb7186381ab3602 Mon Sep 17 00:00:00 2001 From: vera-liu Date: Thu, 29 Sep 2016 14:48:13 -0700 Subject: [PATCH] Explore control panel - Chart control, TimeFilter, GroupBy, Filters (#1205) * create structure for new forked explore view (#1099) * create structure for new forked explore view * update component name * add bootstrap data pattern * remove console.log * Associate version to entry files (#1060) * Associate version to entry files * Modified path joins for configs * Made changes based on comments * Created store and reducers (#1108) * Created store and reducers * Added spec * Modifications based on comments * Explore control panel components: Chart control, Time filter, SQL, GroupBy and Filters * Modifications based on comments --- .../explorev2/actions/exploreActions.js | 192 +++++++++++ .../explorev2/components/ChartContainer.jsx | 11 + .../explorev2/components/ChartControl.jsx | 89 ++++++ .../components/ControlPanelsContainer.jsx | 20 ++ .../components/ExploreViewContainer.jsx | 26 ++ .../explorev2/components/Filters.jsx | 128 ++++++++ .../explorev2/components/GroupBy.jsx | 82 +++++ .../components/QueryAndSaveButtons.jsx | 31 ++ .../explorev2/components/SqlClause.jsx | 58 ++++ .../explorev2/components/TimeFilter.jsx | 117 +++++++ .../assets/javascripts/explorev2/constants.js | 35 ++ .../assets/javascripts/explorev2/index.jsx | 43 +++ .../explorev2/reducers/exploreReducer.js | 111 +++++++ .../javascripts/explorev2/stores/store.js | 52 +++ caravel/assets/package.json | 3 +- .../explore/components/actions_spec.js | 92 ++++++ caravel/assets/utils/common.js | 13 + caravel/assets/utils/reducerUtils.js | 53 +++ caravel/assets/webpack.config.js | 1 + caravel/templates/caravel/explore.html | 302 +----------------- caravel/views.py | 43 ++- 21 files changed, 1199 insertions(+), 303 deletions(-) create mode 100644 caravel/assets/javascripts/explorev2/actions/exploreActions.js create mode 100644 caravel/assets/javascripts/explorev2/components/ChartContainer.jsx create mode 100644 caravel/assets/javascripts/explorev2/components/ChartControl.jsx create mode 100644 caravel/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx create mode 100644 caravel/assets/javascripts/explorev2/components/ExploreViewContainer.jsx create mode 100644 caravel/assets/javascripts/explorev2/components/Filters.jsx create mode 100644 caravel/assets/javascripts/explorev2/components/GroupBy.jsx create mode 100644 caravel/assets/javascripts/explorev2/components/QueryAndSaveButtons.jsx create mode 100644 caravel/assets/javascripts/explorev2/components/SqlClause.jsx create mode 100644 caravel/assets/javascripts/explorev2/components/TimeFilter.jsx create mode 100644 caravel/assets/javascripts/explorev2/constants.js create mode 100644 caravel/assets/javascripts/explorev2/index.jsx create mode 100644 caravel/assets/javascripts/explorev2/reducers/exploreReducer.js create mode 100644 caravel/assets/javascripts/explorev2/stores/store.js create mode 100644 caravel/assets/spec/javascripts/explore/components/actions_spec.js create mode 100644 caravel/assets/utils/reducerUtils.js diff --git a/caravel/assets/javascripts/explorev2/actions/exploreActions.js b/caravel/assets/javascripts/explorev2/actions/exploreActions.js new file mode 100644 index 000000000000..e5e5c33eb4e1 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/actions/exploreActions.js @@ -0,0 +1,192 @@ +const $ = window.$ = require('jquery'); +export const SET_DATASOURCE = 'SET_DATASOURCE'; +export const SET_VIZTYPE = 'SET_VIZTYPE'; +export const SET_TIME_COLUMN_OPTS = 'SET_TIME_COLUMN_OPTS'; +export const SET_TIME_GRAIN_OPTS = 'SET_TIME_GRAIN_OPTS'; +export const SET_TIME_COLUMN = 'SET_TIME_COLUMN'; +export const SET_TIME_GRAIN = 'SET_TIME_GRAIN'; +export const SET_SINCE = 'SET_SINCE'; +export const SET_UNTIL = 'SET_UNTIL'; +export const SET_GROUPBY_COLUMNS = 'SET_GROUPBY_COLUMNS'; +export const SET_GROUPBY_COLUMN_OPTS = 'SET_GROUPBY_COLUMN_OPTS'; +export const SET_METRICS = 'SET_METRICS'; +export const SET_METRICS_OPTS = 'SET_METRICS_OPTS'; +export const ADD_COLUMN = 'ADD_COLUMN'; +export const REMOVE_COLUMN = 'REMOVE_COLUMN'; +export const ADD_ORDERING = 'ADD_ORDERING'; +export const REMOVE_ORDERING = 'REMOVE_ORDERING'; +export const SET_TIME_STAMP = 'SET_TIME_STAMP'; +export const SET_ROW_LIMIT = 'SET_ROW_LIMIT'; +export const TOGGLE_SEARCHBOX = 'TOGGLE_SEARCHBOX'; +export const SET_FILTER_COLUMN_OPTS = 'SET_FILTER_COLUMN_OPTS'; +export const SET_WHERE_CLAUSE = 'SET_WHERE_CLAUSE'; +export const SET_HAVING_CLAUSE = 'SET_HAVING_CLAUSE'; +export const ADD_FILTER = 'ADD_FILTER'; +export const SET_FILTER = 'SET_FILTER'; +export const REMOVE_FILTER = 'REMOVE_FILTER'; +export const CHANGE_FILTER_FIELD = 'CHANGE_FILTER_FIELD'; +export const CHANGE_FILTER_OP = 'CHANGE_FILTER_OP'; +export const CHANGE_FILTER_VALUE = 'CHANGE_FILTER_VALUE'; +export const RESET_FORM_DATA = 'RESET_FORM_DATA'; +export const CLEAR_ALL_OPTS = 'CLEAR_ALL_OPTS'; + +export function setTimeColumnOpts(timeColumnOpts) { + return { type: SET_TIME_COLUMN_OPTS, timeColumnOpts }; +} + +export function setTimeGrainOpts(timeGrainOpts) { + return { type: SET_TIME_GRAIN_OPTS, timeGrainOpts }; +} + +export function setGroupByColumnOpts(groupByColumnOpts) { + return { type: SET_GROUPBY_COLUMN_OPTS, groupByColumnOpts }; +} + +export function setMetricsOpts(metricsOpts) { + return { type: SET_METRICS_OPTS, metricsOpts }; +} + +export function setFilterColumnOpts(filterColumnOpts) { + return { type: SET_FILTER_COLUMN_OPTS, filterColumnOpts }; +} + +export function resetFormData() { + // Clear all form data when switching datasource + return { type: RESET_FORM_DATA }; +} + +export function clearAllOpts() { + return { type: CLEAR_ALL_OPTS }; +} + +export function setFormOpts(datasourceId, datasourceType) { + return function (dispatch) { + const timeColumnOpts = []; + const groupByColumnOpts = []; + const metricsOpts = []; + const filterColumnOpts = []; + const timeGrainOpts = []; + + if (datasourceId) { + const params = [`datasource_id=${datasourceId}`, `datasource_type=${datasourceType}`]; + const url = '/caravel/fetch_datasource_metadata?' + params.join('&'); + + $.get(url, (data, status) => { + if (status === 'success') { + data.dttm_cols.forEach((d) => { + if (d) timeColumnOpts.push({ value: d, label: d }); + }); + data.groupby_cols.forEach((d) => { + if (d) groupByColumnOpts.push({ value: d, label: d }); + }); + data.metrics.forEach((d) => { + if (d) metricsOpts.push({ value: d[1], label: d[0] }); + }); + data.filter_cols.forEach((d) => { + if (d) filterColumnOpts.push({ value: d, label: d }); + }); + data.time_grains.forEach((d) => { + if (d) timeGrainOpts.push({ value: d, label: d }); + }); + // Repopulate options for controls + dispatch(setTimeColumnOpts(timeColumnOpts)); + dispatch(setTimeGrainOpts(timeGrainOpts)); + dispatch(setGroupByColumnOpts(groupByColumnOpts)); + dispatch(setMetricsOpts(metricsOpts)); + dispatch(setFilterColumnOpts(filterColumnOpts)); + } + }); + } else { + // Clear all Select options + dispatch(clearAllOpts()); + } + }; +} + +export function setDatasource(datasourceId) { + return { type: SET_DATASOURCE, datasourceId }; +} + +export function setVizType(vizType) { + return { type: SET_VIZTYPE, vizType }; +} + +export function setTimeColumn(timeColumn) { + return { type: SET_TIME_COLUMN, timeColumn }; +} + +export function setTimeGrain(timeGrain) { + return { type: SET_TIME_GRAIN, timeGrain }; +} + +export function setSince(since) { + return { type: SET_SINCE, since }; +} + +export function setUntil(until) { + return { type: SET_UNTIL, until }; +} + +export function setGroupByColumns(groupByColumns) { + return { type: SET_GROUPBY_COLUMNS, groupByColumns }; +} + +export function setMetrics(metrics) { + return { type: SET_METRICS, metrics }; +} + +export function addColumn(column) { + return { type: ADD_COLUMN, column }; +} + +export function removeColumn(column) { + return { type: REMOVE_COLUMN, column }; +} + +export function addOrdering(ordering) { + return { type: ADD_ORDERING, ordering }; +} + +export function removeOrdering(ordering) { + return { type: REMOVE_ORDERING, ordering }; +} + +export function setTimeStamp(timeStampFormat) { + return { type: SET_TIME_STAMP, timeStampFormat }; +} + +export function setRowLimit(rowLimit) { + return { type: SET_ROW_LIMIT, rowLimit }; +} + +export function toggleSearchBox(searchBox) { + return { type: TOGGLE_SEARCHBOX, searchBox }; +} + +export function setWhereClause(whereClause) { + return { type: SET_WHERE_CLAUSE, whereClause }; +} + +export function setHavingClause(havingClause) { + return { type: SET_HAVING_CLAUSE, havingClause }; +} + +export function addFilter(filter) { + return { type: ADD_FILTER, filter }; +} + +export function removeFilter(filter) { + return { type: REMOVE_FILTER, filter }; +} + +export function changeFilterField(filter, field) { + return { type: CHANGE_FILTER_FIELD, filter, field }; +} + +export function changeFilterOp(filter, op) { + return { type: CHANGE_FILTER_OP, filter, op }; +} + +export function changeFilterValue(filter, value) { + return { type: CHANGE_FILTER_VALUE, filter, value }; +} diff --git a/caravel/assets/javascripts/explorev2/components/ChartContainer.jsx b/caravel/assets/javascripts/explorev2/components/ChartContainer.jsx new file mode 100644 index 000000000000..3666e923a201 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/ChartContainer.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Panel } from 'react-bootstrap'; + +const ChartContainer = function () { + return ( + + chart goes here + + ); +}; +export default ChartContainer; diff --git a/caravel/assets/javascripts/explorev2/components/ChartControl.jsx b/caravel/assets/javascripts/explorev2/components/ChartControl.jsx new file mode 100644 index 000000000000..eee6e0d6ff45 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/ChartControl.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import Select from 'react-select'; +import { bindActionCreators } from 'redux'; +import * as actions from '../actions/exploreActions'; +import { connect } from 'react-redux'; +import { VIZ_TYPES } from '../constants'; + +const propTypes = { + actions: React.PropTypes.object, + datasources: React.PropTypes.array, + datasourceId: React.PropTypes.number, + datasourceType: React.PropTypes.string, + vizType: React.PropTypes.string, +}; + +const defaultProps = { + datasources: [], + datasourceId: null, + datasourceType: null, + vizType: null, +}; + +class ChartControl extends React.Component { + componentWillMount() { + if (this.props.datasourceId) { + this.props.actions.setFormOpts(this.props.datasourceId, this.props.datasourceType); + } + } + changeDatasource(datasourceOpt) { + const val = (datasourceOpt) ? datasourceOpt.value : null; + this.props.actions.setDatasource(val); + this.props.actions.resetFormData(); + this.props.actions.setFormOpts(val, this.props.datasourceType); + } + changeViz(vizOpt) { + const val = (vizOpt) ? vizOpt.value : null; + this.props.actions.setVizType(val); + } + render() { + return ( +
+
Chart Options
+
+
Datasource
+
+ +
+
+
+ ); + } +} + +ChartControl.propTypes = propTypes; +ChartControl.defaultProps = defaultProps; + +function mapStateToProps(state) { + return { + datasources: state.datasources, + datasourceId: state.datasourceId, + datasourceType: state.datasourceType, + vizType: state.vizType, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch), + }; +} +export default connect(mapStateToProps, mapDispatchToProps)(ChartControl); diff --git a/caravel/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx b/caravel/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx new file mode 100644 index 000000000000..c3f70e9532a6 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Panel } from 'react-bootstrap'; +import TimeFilter from './TimeFilter'; +import ChartControl from './ChartControl'; +import GroupBy from './GroupBy'; +import SqlClause from './SqlClause'; +import Filters from './Filters'; + +const ControlPanelsContainer = function () { + return ( + + + + + + + + ); +}; +export default ControlPanelsContainer; diff --git a/caravel/assets/javascripts/explorev2/components/ExploreViewContainer.jsx b/caravel/assets/javascripts/explorev2/components/ExploreViewContainer.jsx new file mode 100644 index 000000000000..f6b6b52faae3 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/ExploreViewContainer.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import ChartContainer from './ChartContainer'; +import ControlPanelsContainer from './ControlPanelsContainer'; +import QueryAndSaveButtons from './QueryAndSaveButtons'; + +const ExploreViewContainer = function () { + return ( +
+
+
+ { console.log('clicked query'); }} + /> +

+ +
+
+ +
+
+
+ ); +}; + +export default ExploreViewContainer; diff --git a/caravel/assets/javascripts/explorev2/components/Filters.jsx b/caravel/assets/javascripts/explorev2/components/Filters.jsx new file mode 100644 index 000000000000..49e384157ff6 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/Filters.jsx @@ -0,0 +1,128 @@ +import React from 'react'; +// import { Tab, Row, Col, Nav, NavItem } from 'react-bootstrap'; +import Select from 'react-select'; +import { Button } from 'react-bootstrap'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import * as actions from '../actions/exploreActions'; +import shortid from 'shortid'; + +const propTypes = { + actions: React.PropTypes.object, + filterColumnOpts: React.PropTypes.array, + filters: React.PropTypes.array, +}; + +const defaultProps = { + filterColumnOpts: [], + filters: [], +}; + +class Filters extends React.Component { + constructor(props) { + super(props); + this.state = { + opOpts: ['in', 'not in'], + }; + } + changeField(filter, fieldOpt) { + const val = (fieldOpt) ? fieldOpt.value : null; + this.props.actions.changeFilterField(filter, val); + } + changeOp(filter, opOpt) { + const val = (opOpt) ? opOpt.value : null; + this.props.actions.changeFilterOp(filter, val); + } + changeValue(filter, value) { + this.props.actions.changeFilterValue(filter, value); + } + removeFilter(filter) { + this.props.actions.removeFilter(filter); + } + addFilter() { + this.props.actions.addFilter({ + id: shortid.generate(), + field: null, + op: null, + value: null, + }); + } + render() { + const filters = this.props.filters.map((filter) => ( +
+ ({ value: o, label: o }))} + value={filter.op} + autosize={false} + onChange={this.changeOp.bind(this, filter)} + /> +
+ +
+
+ +
+
+ + ) + ); + return ( +
+
Filters
+
+ {filters} + +
+
+ ); + } +} + +Filters.propTypes = propTypes; + +Filters.defaultProps = defaultProps; + +function mapStateToProps(state) { + return { + filterColumnOpts: state.filterColumnOpts, + filters: state.filters, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Filters); diff --git a/caravel/assets/javascripts/explorev2/components/GroupBy.jsx b/caravel/assets/javascripts/explorev2/components/GroupBy.jsx new file mode 100644 index 000000000000..cbf10fdfe59e --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/GroupBy.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import Select from 'react-select'; +import { bindActionCreators } from 'redux'; +import * as actions from '../actions/exploreActions'; +import { connect } from 'react-redux'; + +const propTypes = { + actions: React.PropTypes.object, + metricsOpts: React.PropTypes.array, + metrics: React.PropTypes.array, + groupByColumnOpts: React.PropTypes.array, + groupByColumns: React.PropTypes.array, +}; + +const defaultProps = { + metricsOpts: [], + metrics: [], + groupByColumnOpts: [], + groupByColumns: [], +}; + +class GroupBy extends React.Component { + changeColumns(groupByColumnOpts) { + this.props.actions.setGroupByColumns(groupByColumnOpts); + } + changeMetrics(metricsOpts) { + this.props.actions.setMetrics(metricsOpts); + } + render() { + return ( +
+
GroupBy
+
+
+
GroupBy Column
+ +
+
+
+ ); + } +} + +GroupBy.propTypes = propTypes; +GroupBy.defaultProps = defaultProps; + +function mapStateToProps(state) { + return { + metricsOpts: state.metricsOpts, + metrics: state.metrics, + groupByColumnOpts: state.groupByColumnOpts, + groupByColumns: state.groupByColumns, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(GroupBy); diff --git a/caravel/assets/javascripts/explorev2/components/QueryAndSaveButtons.jsx b/caravel/assets/javascripts/explorev2/components/QueryAndSaveButtons.jsx new file mode 100644 index 000000000000..1a521393402f --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/QueryAndSaveButtons.jsx @@ -0,0 +1,31 @@ +import React, { PropTypes } from 'react'; +import classnames from 'classnames'; + +const propTypes = { + canAdd: PropTypes.string.isRequired, + onQuery: PropTypes.func.isRequired, +}; + +export default function QueryAndSaveBtns({ canAdd, onQuery }) { + const saveClasses = classnames('btn btn-default btn-sm', { + 'disabled disabledButton': canAdd !== 'True', + }); + + return ( +
+ + +
+ ); +} + +QueryAndSaveBtns.propTypes = propTypes; diff --git a/caravel/assets/javascripts/explorev2/components/SqlClause.jsx b/caravel/assets/javascripts/explorev2/components/SqlClause.jsx new file mode 100644 index 000000000000..ab484dfe20ab --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/SqlClause.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { bindActionCreators } from 'redux'; +import * as actions from '../actions/exploreActions'; +import { connect } from 'react-redux'; + +const propTypes = { + actions: React.PropTypes.object, +}; + +class SqlClause extends React.Component { + changeWhere(whereClause) { + this.props.actions.setWhereClause(whereClause); + } + changeHaving(havingClause) { + this.props.actions.setHavingClause(havingClause); + } + render() { + return ( +
+
SQL
+
+
+
Where
+ +
+
+
Having
+ +
+
+
+ ); + } +} + +SqlClause.propTypes = propTypes; + +function mapStateToProps() { + return {}; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(SqlClause); diff --git a/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx b/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx new file mode 100644 index 000000000000..2cbf7afedea8 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import Select from 'react-select'; +import { bindActionCreators } from 'redux'; +import * as actions from '../actions/exploreActions'; +import { connect } from 'react-redux'; +import { sinceOptions, untilOptions } from '../constants'; + +const propTypes = { + actions: React.PropTypes.object, + timeColumnOpts: React.PropTypes.array, + timeColumn: React.PropTypes.string, + timeGrainOpts: React.PropTypes.array, + timeGrain: React.PropTypes.string, + since: React.PropTypes.string, + until: React.PropTypes.string, +}; + +const defaultProps = { + timeColumnOpts: [], + timeColumn: null, + timeGrainOpts: [], + timeGrain: null, + since: null, + until: null, +}; + +class TimeFilter extends React.Component { + changeTimeColumn(timeColumnOpt) { + const val = (timeColumnOpt) ? timeColumnOpt.value : null; + this.props.actions.setTimeColumn(val); + } + changeTimeGrain(timeGrainOpt) { + const val = (timeGrainOpt) ? timeGrainOpt.value : null; + this.props.actions.setTimeGrain(val); + } + changeSince(sinceOpt) { + const val = (sinceOpt) ? sinceOpt.value : null; + this.props.actions.setSince(val); + } + changeUntil(untilOpt) { + const val = (untilOpt) ? untilOpt.value : null; + this.props.actions.setUntil(val); + } + render() { + return ( +
+
Time Filter
+
+
+
Time Column & Grain
+ +
+
+
Since - Until
+ ({ value: u, label: u }))} + value={this.props.until} + autosize={false} + onChange={this.changeUntil.bind(this)} + /> +
+
+
+ ); + } +} + +TimeFilter.propTypes = propTypes; +TimeFilter.defaultProps = defaultProps; + +function mapStateToProps(state) { + return { + timeColumnOpts: state.timeColumnOpts, + timeColumn: state.timeColumn, + timeGrainOpts: state.timeGrainOpts, + timeGrain: state.timeGrain, + since: state.since, + until: state.until, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(TimeFilter); diff --git a/caravel/assets/javascripts/explorev2/constants.js b/caravel/assets/javascripts/explorev2/constants.js new file mode 100644 index 000000000000..2415eaa43e5b --- /dev/null +++ b/caravel/assets/javascripts/explorev2/constants.js @@ -0,0 +1,35 @@ +export const VIZ_TYPES = [ + { value: 'dist_bar', label: 'Distribution - Bar Chart', requiresTime: false }, + { value: 'pie', label: 'Pie Chart', requiresTime: false }, + { value: 'line', label: 'Time Series - Line Chart', requiresTime: true }, + { value: 'bar', label: 'Time Series - Bar Chart', requiresTime: true }, + { value: 'compare', label: 'Time Series - Percent Change', requiresTime: true }, + { value: 'area', label: 'Time Series - Stacked', requiresTime: true }, + { value: 'table', label: 'Table View', requiresTime: false }, + { value: 'markup', label: 'Markup', requiresTime: false }, + { value: 'pivot_table', label: 'Pivot Table', requiresTime: false }, + { value: 'separator', label: 'Separator', requiresTime: false }, + { value: 'word_cloud', label: 'Word Cloud', requiresTime: false }, + { value: 'treemap', label: 'Treemap', requiresTime: false }, + { value: 'cal_heatmap', label: 'Calendar Heatmap', requiresTime: true }, + { value: 'box_plot', label: 'Box Plot', requiresTime: false }, + { value: 'bubble', label: 'Bubble Chart', requiresTime: false }, + { value: 'big_number', label: 'Big Number with Trendline', requiresTime: false }, + { value: 'bubble', label: 'Bubble Chart', requiresTime: false }, + { value: 'histogram', label: 'Histogram', requiresTime: false }, + { value: 'sunburst', label: 'Sunburst', requiresTime: false }, + { value: 'sankey', label: 'Sankey', requiresTime: false }, + { value: 'directed_force', label: 'Directed Force Layout', requiresTime: false }, + { value: 'world_map', label: 'World Map', requiresTime: false }, + { value: 'filter_box', label: 'Filter Box', requiresTime: false }, + { value: 'iframe', label: 'iFrame', requiresTime: false }, + { value: 'para', label: 'Parallel Coordinates', requiresTime: false }, + { value: 'heatmap', label: 'Heatmap', requiresTime: false }, + { value: 'horizon', label: 'Horizon', requiresTime: false }, + { value: 'mapbox', label: 'Mapbox', requiresTime: false }, +]; + +export const sinceOptions = ['1 hour ago', '12 hours ago', '1 day ago', + '7 days ago', '28 days ago', '90 days ago', '1 year ago']; +export const untilOptions = ['now', '1 day ago', '7 days ago', + '28 days ago', '90 days ago', '1 year ago']; diff --git a/caravel/assets/javascripts/explorev2/index.jsx b/caravel/assets/javascripts/explorev2/index.jsx new file mode 100644 index 000000000000..e478ee5a406c --- /dev/null +++ b/caravel/assets/javascripts/explorev2/index.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import ExploreViewContainer from './components/ExploreViewContainer'; + +import { createStore, applyMiddleware, compose } from 'redux'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; + +import { initialState } from './stores/store'; + +const exploreViewContainer = document.getElementById('js-explore-view-container'); +const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstrap')); + +import { exploreReducer } from './reducers/exploreReducer'; + +const bootstrappedState = Object.assign(initialState, { + datasources: bootstrapData.datasources, + datasourceId: parseInt(bootstrapData.datasource_id, 10), + datasourceType: bootstrapData.datasource_type, + sliceName: bootstrapData.viz.form_data.slice_name, + sliceId: bootstrapData.viz.form_data.slice_id, + vizType: bootstrapData.viz.form_data.viz_type, + timeColumn: bootstrapData.viz.form_data.granularity_sqla, + timeGrain: bootstrapData.viz.form_data.time_grain_sqla, + metrics: [bootstrapData.viz.form_data.metric].map((m) => ({ value: m, label: m })), + since: bootstrapData.viz.form_data.since, + until: bootstrapData.viz.form_data.until, + havingClause: bootstrapData.viz.form_data.having, + whereClause: bootstrapData.viz.form_data.where, +}); +const store = createStore(exploreReducer, bootstrappedState, + compose(applyMiddleware(thunk)) +); + +ReactDOM.render( + + + , + exploreViewContainer +); + diff --git a/caravel/assets/javascripts/explorev2/reducers/exploreReducer.js b/caravel/assets/javascripts/explorev2/reducers/exploreReducer.js new file mode 100644 index 000000000000..ffef2dac4101 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/reducers/exploreReducer.js @@ -0,0 +1,111 @@ +import { defaultFormData, defaultOpts } from '../stores/store'; +import * as actions from '../actions/exploreActions'; +import { addToArr, removeFromArr, alterInArr } from '../../../utils/reducerUtils'; + +export const exploreReducer = function (state, action) { + const actionHandlers = { + [actions.SET_DATASOURCE]() { + return Object.assign({}, state, { datasourceId: action.datasourceId }); + }, + [actions.SET_VIZTYPE]() { + return Object.assign({}, state, { vizType: action.vizType }); + }, + [actions.SET_TIME_COLUMN_OPTS]() { + return Object.assign({}, state, { timeColumnOpts: action.timeColumnOpts }); + }, + [actions.SET_TIME_GRAIN_OPTS]() { + return Object.assign({}, state, { timeGrainOpts: action.timeGrainOpts }); + }, + [actions.SET_TIME_COLUMN]() { + return Object.assign({}, state, { timeColumn: action.timeColumn }); + }, + [actions.SET_TIME_GRAIN]() { + return Object.assign({}, state, { timeGrain: action.timeGrain }); + }, + [actions.SET_SINCE]() { + return Object.assign({}, state, { since: action.since }); + }, + [actions.SET_UNTIL]() { + return Object.assign({}, state, { until: action.until }); + }, + [actions.SET_GROUPBY_COLUMN_OPTS]() { + return Object.assign({}, state, { groupByColumnOpts: action.groupByColumnOpts }); + }, + [actions.SET_GROUPBY_COLUMNS]() { + return Object.assign({}, state, { groupByColumns: action.groupByColumns }); + }, + [actions.SET_METRICS_OPTS]() { + return Object.assign({}, state, { metricsOpts: action.metricsOpts }); + }, + [actions.SET_METRICS]() { + return Object.assign({}, state, { metrics: action.metrics }); + }, + [actions.ADD_COLUMN]() { + return Object.assign({}, state, { columns: [...state.columns, action.column] }); + }, + [actions.REMOVE_COLUMN]() { + const newColumns = []; + state.columns.forEach((c) => { + if (c !== action.column) { + newColumns.push(c); + } + }); + return Object.assign({}, state, { columns: newColumns }); + }, + [actions.ADD_ORDERING]() { + return Object.assign({}, state, { orderings: [...state.orderings, action.ordering] }); + }, + [actions.REMOVE_ORDERING]() { + const newOrderings = []; + state.orderings.forEach((o) => { + if (o !== action.ordering) { + newOrderings.push(o); + } + }); + return Object.assign({}, state, { orderings: newOrderings }); + }, + [actions.SET_TIME_STAMP]() { + return Object.assign({}, state, { timeStampFormat: action.timeStampFormat }); + }, + [actions.SET_ROW_LIMIT]() { + return Object.assign({}, state, { rowLimit: action.rowLimit }); + }, + [actions.TOGGLE_SEARCHBOX]() { + return Object.assign({}, state, { searchBox: action.searchBox }); + }, + [actions.SET_WHERE_CLAUSE]() { + return Object.assign({}, state, { whereClause: action.whereClause }); + }, + [actions.SET_HAVING_CLAUSE]() { + return Object.assign({}, state, { havingClause: action.havingClause }); + }, + [actions.SET_FILTER_COLUMN_OPTS]() { + return Object.assign({}, state, { filterColumnOpts: action.filterColumnOpts }); + }, + [actions.ADD_FILTER]() { + return addToArr(state, 'filters', action.filter); + }, + [actions.REMOVE_FILTER]() { + return removeFromArr(state, 'filters', action.filter); + }, + [actions.CHANGE_FILTER_FIELD]() { + return alterInArr(state, 'filters', action.filter, { field: action.field }); + }, + [actions.CHANGE_FILTER_OP]() { + return alterInArr(state, 'filters', action.filter, { op: action.op }); + }, + [actions.CHANGE_FILTER_VALUE]() { + return alterInArr(state, 'filters', action.filter, { value: action.value }); + }, + [actions.RESET_FORM_DATA]() { + return Object.assign({}, state, defaultFormData); + }, + [actions.CLEAR_ALL_OPTS]() { + return Object.assign({}, state, defaultOpts); + }, + }; + if (action.type in actionHandlers) { + return actionHandlers[action.type](); + } + return state; +}; diff --git a/caravel/assets/javascripts/explorev2/stores/store.js b/caravel/assets/javascripts/explorev2/stores/store.js new file mode 100644 index 000000000000..80d1bd7296c9 --- /dev/null +++ b/caravel/assets/javascripts/explorev2/stores/store.js @@ -0,0 +1,52 @@ +export const initialState = { + datasources: null, + datasourceId: null, + datasourceType: null, + vizType: null, + timeColumnOpts: [], + timeColumn: null, + timeGrainOpts: [], + timeGrain: null, + since: null, + until: null, + groupByColumnOpts: [], + groupByColumns: [], + metricsOpts: [], + metrics: [], + columns: [], + orderings: [], + timeStampFormat: null, + rowLimit: null, + searchBox: false, + whereClause: '', + havingClause: '', + filters: [], + filterColumnOpts: [], +}; + +// TODO: add datasource_type here after druid support is added +export const defaultFormData = { + vizType: null, + timeColumn: null, + timeGrain: null, + since: null, + until: null, + groupByColumns: [], + metrics: [], + columns: [], + orderings: [], + timeStampFormat: null, + rowLimit: null, + searchBox: false, + whereClause: '', + havingClause: '', + filters: [], +}; + +export const defaultOpts = { + timeColumnOpts: [], + timeGrainOpts: [], + groupByColumnOpts: [], + metricsOpts: [], + filterColumnOpts: [], +}; diff --git a/caravel/assets/package.json b/caravel/assets/package.json index fa678959aa72..f70a924eaf90 100644 --- a/caravel/assets/package.json +++ b/caravel/assets/package.json @@ -77,6 +77,7 @@ "reactable": "^0.14.0", "redux": "^3.5.2", "redux-localstorage": "^0.4.1", + "redux-thunk": "^2.1.0", "select2": "3.5", "select2-bootstrap-css": "^1.4.6", "shortid": "^2.2.6", @@ -101,9 +102,9 @@ "eslint-plugin-jsx-a11y": "^1.2.0", "eslint-plugin-react": "^5.2.2", "exports-loader": "^0.6.3", - "istanbul": "^1.0.0-alpha", "file-loader": "^0.8.5", "imports-loader": "^0.6.5", + "istanbul": "^1.0.0-alpha", "jsdom": "^8.0.1", "json-loader": "^0.5.4", "less": "^2.6.1", diff --git a/caravel/assets/spec/javascripts/explore/components/actions_spec.js b/caravel/assets/spec/javascripts/explore/components/actions_spec.js new file mode 100644 index 000000000000..c57d5305ca54 --- /dev/null +++ b/caravel/assets/spec/javascripts/explore/components/actions_spec.js @@ -0,0 +1,92 @@ +import { expect } from 'chai'; +import shortid from 'shortid'; +import * as actions from '../../../../javascripts/explorev2/actions/exploreActions'; +import { initialState } from '../../../../javascripts/explorev2/stores/store'; +import { exploreReducer } from '../../../../javascripts/explorev2/reducers/exploreReducer'; + +describe('reducers', () => { + + it('should return new state with datasource id', () => { + const newState = exploreReducer(initialState, actions.setDatasource(1)); + expect(newState.datasourceId).to.equal(1); + }); + + it('should return new state with viz type', () => { + const newState = exploreReducer(initialState, actions.setVizType('bar')); + expect(newState.vizType).to.equal('bar'); + }); + + it('should return new state with added column', () => { + const newColumn = 'col'; + const newState = exploreReducer(initialState, actions.addColumn(newColumn)); + expect(newState.columns).to.deep.equal([newColumn]); + }); + + it('should return new state with removed column', () => { + const testState = { initialState, columns: ['col1', 'col2'] }; + const remColumn = 'col1'; + const newState = exploreReducer(testState, actions.removeColumn(remColumn)); + expect(newState.columns).to.deep.equal(['col2']); + }); + + it('should return new state with added ordering', () => { + const newOrdering = 'ord'; + const newState = exploreReducer(initialState, actions.addOrdering(newOrdering)); + expect(newState.orderings).to.deep.equal(['ord']); + }); + + it('should return new state with removed ordering', () => { + const testState = { initialState, orderings: ['ord1', 'ord2'] }; + const remOrdering = 'ord1'; + const newState = exploreReducer(testState, actions.removeOrdering(remOrdering)); + expect(newState.orderings).to.deep.equal(['ord2']); + }); + + it('should return new state with time stamp', () => { + const newState = exploreReducer(initialState, actions.setTimeStamp(1)); + expect(newState.timeStampFormat).to.equal(1); + }); + + it('should return new state with row limit', () => { + const newState = exploreReducer(initialState, actions.setRowLimit(10)); + expect(newState.rowLimit).to.equal(10); + }); + + it('should return new state with search box toggled', () => { + const newState = exploreReducer(initialState, actions.toggleSearchBox(true)); + expect(newState.searchBox).to.equal(true); + }); + + it('should return new state with added filter', () => { + const newFilter = { + id: shortid.generate(), + eq: 'value', + op: 'in', + col: 'vals', + }; + const newState = exploreReducer(initialState, actions.addFilter(newFilter)); + expect(newState.filters).to.deep.equal([newFilter]); + }); + + it('should return new state with removed filter', () => { + const filter1 = { + id: shortid.generate(), + eq: 'value', + op: 'in', + col: 'vals1', + }; + const filter2 = { + id: shortid.generate(), + eq: 'value', + op: 'not in', + col: 'vals2', + }; + const testState = { + initialState, + filters: [filter1, filter2], + }; + const newState = exploreReducer(testState, actions.removeFilter(filter1)); + expect(newState.filters).to.deep.equal([filter2]); + }); + +}); diff --git a/caravel/assets/utils/common.js b/caravel/assets/utils/common.js index a6aa001e41d8..5e98b996bfb5 100644 --- a/caravel/assets/utils/common.js +++ b/caravel/assets/utils/common.js @@ -1,4 +1,7 @@ /* eslint global-require: 0 */ +import persistState from 'redux-localstorage'; +import { compose } from 'redux'; + const d3 = window.d3 || require('d3'); export const EARTH_CIRCUMFERENCE_KM = 40075.16; @@ -26,3 +29,13 @@ export function rgbLuminance(r, g, b) { // Formula: https://en.wikipedia.org/wiki/Relative_luminance return (LUMINANCE_RED_WEIGHT * r) + (LUMINANCE_GREEN_WEIGHT * g) + (LUMINANCE_BLUE_WEIGHT * b); } + +export function getDevEnhancer() { + let enhancer = compose(persistState()); + if (process.env.NODE_ENV === 'dev') { + enhancer = compose( + persistState(), window.devToolsExtension && window.devToolsExtension() + ); + } + return enhancer; +} diff --git a/caravel/assets/utils/reducerUtils.js b/caravel/assets/utils/reducerUtils.js new file mode 100644 index 000000000000..e233c2413b9a --- /dev/null +++ b/caravel/assets/utils/reducerUtils.js @@ -0,0 +1,53 @@ +import shortid from 'shortid'; + +export function addToObject(state, arrKey, obj) { + const newObject = Object.assign({}, state[arrKey]); + const copiedObject = Object.assign({}, obj); + + if (!copiedObject.id) { + copiedObject.id = shortid.generate(); + } + newObject[copiedObject.id] = copiedObject; + return Object.assign({}, state, { [arrKey]: newObject }); +} + +export function alterInObject(state, arrKey, obj, alterations) { + const newObject = Object.assign({}, state[arrKey]); + newObject[obj.id] = (Object.assign({}, newObject[obj.id], alterations)); + return Object.assign({}, state, { [arrKey]: newObject }); +} + +export 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 }); +} + +export 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 }); +} + +export function addToArr(state, arrKey, obj) { + const newObj = Object.assign({}, obj); + if (!newObj.id) { + newObj.id = shortid.generate(); + } + const newState = {}; + newState[arrKey] = [...state[arrKey], newObj]; + return Object.assign({}, state, newState); +} diff --git a/caravel/assets/webpack.config.js b/caravel/assets/webpack.config.js index 7b851e2e0e8c..1667e016f117 100644 --- a/caravel/assets/webpack.config.js +++ b/caravel/assets/webpack.config.js @@ -15,6 +15,7 @@ const config = { 'css-theme': APP_DIR + '/javascripts/css-theme.js', dashboard: APP_DIR + '/javascripts/dashboard/Dashboard.jsx', explore: APP_DIR + '/javascripts/explore/explore.jsx', + explorev2: APP_DIR + '/javascripts/explorev2/index.jsx', welcome: APP_DIR + '/javascripts/welcome.js', standalone: APP_DIR + '/javascripts/standalone.js', common: APP_DIR + '/javascripts/common.js', diff --git a/caravel/templates/caravel/explore.html b/caravel/templates/caravel/explore.html index 1ea84c5de88d..57e0c3a4c7fa 100644 --- a/caravel/templates/caravel/explore.html +++ b/caravel/templates/caravel/explore.html @@ -1,307 +1,15 @@ {% extends "caravel/basic.html" %} -{% block title %} - {% if slice %} - [slice] {{ slice.slice_name }} - {% else %} - [explore] {{ viz.datasource.table_name }} - {% endif %} -{% endblock %} - {% block body %} - {% set datasource = viz.datasource %} - {% set form = viz.form %} - - {% macro panofield(fieldname)%} -
- {% set field = form.get_field(fieldname)%} -
- {{ field.label }} - {% if field.description %} - - {% endif %} - {{ field(class_=form.field_css_classes(field.name)) }} -
-
- {% endmacro %} - -
- +
{% endblock %} {% block tail_js %} {{ super() }} - {% with filename="explore" %} + {% with filename="explorev2" %} {% include "caravel/partials/_script_tag.html" %} {% endwith %} {% endblock %} diff --git a/caravel/views.py b/caravel/views.py index 415a03571cf0..517f595a7b45 100755 --- a/caravel/views.py +++ b/caravel/views.py @@ -1205,11 +1205,18 @@ def explore(self, datasource_type, datasource_id, slice_id=None): template = "caravel/standalone.html" else: template = "caravel/explore.html" - return self.render_template( - template, viz=viz_obj, slice=slc, datasources=datasources, - can_add=slice_add_perm, can_edit=slice_edit_perm, - can_download=slice_download_perm, - userid=g.user.get_id() if g.user else '') + bootstrap_data = { + "can_add": slice_add_perm, + "can_download": slice_download_perm, + "can_edit": slice_edit_perm, + # TODO: separate endpoint for fetching datasources + "datasources": [(d.id, d.full_name) for d in datasources], + "datasource_id": datasource_id, + "datasource_type": datasource_type, + "user_id": g.user.get_id() if g.user else None, + "viz": json.loads(viz_obj.get_json()) + } + return self.render_template(template, bootstrap_data=json.dumps(bootstrap_data)) def save_or_overwrite_slice( self, args, slc, slice_add_perm, slice_edit_perm): @@ -1832,6 +1839,32 @@ def csv(self, client_id): 'attachment; filename={}.csv'.format(query.name)) return response + @has_access + @expose("/fetch_datasource_metadata") + @log_this + def fetch_datasource_metadata(self): + # TODO: check permissions + # TODO: check if datasource exits + session = db.session + datasource_class = SourceRegistry.sources[request.args.get('datasource_type')] + datasource = ( + session.query(datasource_class) + .filter_by(id=request.args.get('datasource_id')) + .first() + ) + # SUPPORT DRUID + # TODO: move this logic to the model (maybe) + grains_choices = [str(grain.name) for grain in datasource.database.grains()] + form_data = { + "dttm_cols": datasource.dttm_cols, + "time_grains": grains_choices, + "groupby_cols": datasource.groupby_column_names, + "metrics": datasource.metrics_combo, + "filter_cols": datasource.filterable_column_names, + } + return Response( + json.dumps(form_data), mimetype="application/json") + @has_access @expose("/queries/") @log_this