[explore-v2] setup, basic layout, control panels, v2 url (#1233)

* 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

* accommodate old and new explore urls

* move bootstrap data up in scope

* fix code climate issues

* fix long lines

* fix syntax error
This commit is contained in:
Alanna Scott 2016-10-03 22:47:39 -07:00 committed by GitHub
parent d8638dbcf3
commit e6e902e8df
20 changed files with 1292 additions and 1 deletions

View File

@ -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 };
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import { Panel } from 'react-bootstrap';
const ChartContainer = function () {
return (
<Panel header="Chart title">
chart goes here
</Panel>
);
};
export default ChartContainer;

View File

@ -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 (
<div className="panel space-1">
<div className="panel-header">Chart Options</div>
<div className="panel-body">
<h5 className="section-heading">Datasource</h5>
<div className="row">
<Select
name="select-datasource"
placeholder="Select a datasource"
options={this.props.datasources.map((d) => ({ value: d[0], label: d[1] }))}
value={this.props.datasourceId}
autosize={false}
onChange={this.changeDatasource.bind(this)}
/>
</div>
<h5 className="section-heading">Viz Type</h5>
<div className="row">
<Select
name="select-viztype"
placeholder="Select a viz type"
options={VIZ_TYPES}
value={this.props.vizType}
autosize={false}
onChange={this.changeViz.bind(this)}
/>
</div>
</div>
</div>
);
}
}
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);

View File

@ -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 (
<Panel>
<ChartControl />
<TimeFilter />
<GroupBy />
<SqlClause />
<Filters />
</Panel>
);
};
export default ControlPanelsContainer;

View File

@ -0,0 +1,26 @@
import React from 'react';
import ChartContainer from './ChartContainer';
import ControlPanelsContainer from './ControlPanelsContainer';
import QueryAndSaveButtons from './QueryAndSaveButtons';
const ExploreViewContainer = function () {
return (
<div className="container-fluid">
<div className="row">
<div className="col-sm-3">
<QueryAndSaveButtons
canAdd="True"
onQuery={() => { console.log('clicked query'); }}
/>
<br /><br />
<ControlPanelsContainer />
</div>
<div className="col-sm-9">
<ChartContainer />
</div>
</div>
</div>
);
};
export default ExploreViewContainer;

View File

@ -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) => (
<div>
<Select
className="row"
multi={false}
name="select-column"
placeholder="Select column"
options={this.props.filterColumnOpts}
value={filter.field}
autosize={false}
onChange={this.changeField.bind(this, filter)}
/>
<div className="row">
<Select
className="col-sm-3"
multi={false}
name="select-op"
placeholder="Select operator"
options={this.state.opOpts.map((o) => ({ value: o, label: o }))}
value={filter.op}
autosize={false}
onChange={this.changeOp.bind(this, filter)}
/>
<div className="col-sm-6">
<input
type="text"
onChange={this.changeValue.bind(this, filter)}
className="form-control input-sm"
placeholder="Filter value"
/>
</div>
<div className="col-sm-3">
<Button
bsStyle="primary"
onClick={this.removeFilter.bind(this, filter)}
>
<i className="fa fa-minus" />
</Button>
</div>
</div>
</div>
)
);
return (
<div className="panel space-1">
<div className="panel-header">Filters</div>
<div className="panel-body">
{filters}
<Button
bsStyle="primary"
onClick={this.addFilter.bind(this)}
>
<i className="fa fa-plus" />Add Filter
</Button>
</div>
</div>
);
}
}
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);

View File

@ -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 (
<div className="panel space-1">
<div className="panel-header">GroupBy</div>
<div className="panel-body">
<div className="row">
<h5 className="section-heading">GroupBy Column</h5>
<Select
multi
name="select-time-column"
placeholder="Select groupby columns"
options={this.props.groupByColumnOpts}
value={this.props.groupByColumns}
autosize={false}
onChange={this.changeColumns.bind(this)}
/>
</div>
<div className="row">
<h5 className="section-heading">Metrics</h5>
<Select
multi
name="select-since"
placeholder="Select metrics"
options={this.props.metricsOpts}
value={this.props.metrics}
autosize={false}
onChange={this.changeMetrics.bind(this)}
/>
</div>
</div>
</div>
);
}
}
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);

View File

@ -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 (
<div className="btn-group query-and-save">
<button type="button" className="btn btn-primary btn-sm" onClick={onQuery}>
<i className="fa fa-bolt"></i> Query
</button>
<button
type="button"
className={saveClasses}
data-target="#save_modal"
data-toggle="modal"
>
<i className="fa fa-plus-circle"></i> Save as
</button>
</div>
);
}
QueryAndSaveBtns.propTypes = propTypes;

View File

@ -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 (
<div className="panel space-1">
<div className="panel-header">SQL</div>
<div className="panel-body">
<div className="row">
<h5 className="section-heading">Where</h5>
<input
type="text"
onChange={this.changeWhere.bind(this)}
className="form-control input-sm"
placeholder="Where Clause"
/>
</div>
<div className="row">
<h5 className="section-heading">Having</h5>
<input
type="text"
onChange={this.changeHaving.bind(this)}
className="form-control input-sm"
placeholder="Having Clause"
/>
</div>
</div>
</div>
);
}
}
SqlClause.propTypes = propTypes;
function mapStateToProps() {
return {};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(actions, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(SqlClause);

View File

@ -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 (
<div className="panel space-1">
<div className="panel-header">Time Filter</div>
<div className="panel-body">
<div className="row">
<h5 className="section-heading">Time Column & Grain</h5>
<Select
className="col-sm-6"
name="select-time-column"
placeholder="Select a time column"
options={this.props.timeColumnOpts}
value={this.props.timeColumn}
autosize={false}
onChange={this.changeTimeColumn.bind(this)}
/>
<Select
className="col-sm-6"
name="select-time-grain"
placeholder="Select a time grain"
options={this.props.timeGrainOpts}
value={this.props.timeGrain}
autosize={false}
onChange={this.changeTimeGrain.bind(this)}
/>
</div>
<div className="row">
<h5 className="section-heading">Since - Until</h5>
<Select
className="col-sm-6"
name="select-since"
placeholder="Select Since Time"
options={sinceOptions.map((s) => ({ value: s, label: s }))}
value={this.props.since}
autosize={false}
onChange={this.changeSince.bind(this)}
/>
<Select
className="col-sm-6"
name="select-until"
placeholder="Select Until Time"
options={untilOptions.map((u) => ({ value: u, label: u }))}
value={this.props.until}
autosize={false}
onChange={this.changeUntil.bind(this)}
/>
</div>
</div>
</div>
);
}
}
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);

View File

@ -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'];

View File

@ -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(
<Provider store={store}>
<ExploreViewContainer
data={bootstrapData}
/>
</Provider>,
exploreViewContainer
);

View File

@ -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;
};

View File

@ -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: [],
};

View File

@ -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",

View File

@ -0,0 +1,91 @@
import { it, describe } from 'mocha';
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]);
});
});

View File

@ -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);
}

View File

@ -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',

View File

@ -0,0 +1,15 @@
{% extends "caravel/basic.html" %}
{% block body %}
<div
id="js-explore-view-container"
data-bootstrap="{{ bootstrap_data }}"
></div>
{% endblock %}
{% block tail_js %}
{{ super() }}
{% with filename="explorev2" %}
{% include "caravel/partials/_script_tag.html" %}
{% endwith %}
{% endblock %}

View File

@ -1201,6 +1201,113 @@ class Caravel(BaseCaravelView):
can_download=slice_download_perm,
userid=g.user.get_id() if g.user else '')
@has_access
@expose("/exploreV2/<datasource_type>/<datasource_id>/<slice_id>/")
@expose("/exploreV2/<datasource_type>/<datasource_id>/")
@log_this
def exploreV2(self, datasource_type, datasource_id, slice_id=None):
error_redirect = '/slicemodelview/list/'
datasource_class = SourceRegistry.sources[datasource_type]
datasources = db.session.query(datasource_class).all()
datasources = sorted(datasources, key=lambda ds: ds.full_name)
datasource = [ds for ds in datasources if int(datasource_id) == ds.id]
datasource = datasource[0] if datasource else None
if not datasource:
flash(DATASOURCE_MISSING_ERR, "alert")
return redirect(error_redirect)
if not self.datasource_access(datasource):
flash(
__(get_datasource_access_error_msg(datasource.name)), "danger")
return redirect('caravel/request_access_form/{}/{}/{}'.format(
datasource_type, datasource_id, datasource.name))
request_args_multi_dict = request.args # MultiDict
slice_id = slice_id or request_args_multi_dict.get("slice_id")
slc = None
# build viz_obj and get it's params
if slice_id:
slc = db.session.query(models.Slice).filter_by(id=slice_id).first()
try:
viz_obj = slc.get_viz(
url_params_multidict=request_args_multi_dict)
except Exception as e:
logging.exception(e)
flash(utils.error_msg_from_exception(e), "danger")
return redirect(error_redirect)
else:
viz_type = request_args_multi_dict.get("viz_type")
if not viz_type and datasource.default_endpoint:
return redirect(datasource.default_endpoint)
# default to table if no default endpoint and no viz_type
viz_type = viz_type or "table"
# validate viz params
try:
viz_obj = viz.viz_types[viz_type](
datasource, request_args_multi_dict)
except Exception as e:
logging.exception(e)
flash(utils.error_msg_from_exception(e), "danger")
return redirect(error_redirect)
slice_params_multi_dict = ImmutableMultiDict(viz_obj.orig_form_data)
# slc perms
slice_add_perm = self.can_access('can_add', 'SliceModelView')
slice_edit_perm = check_ownership(slc, raise_if_false=False)
slice_download_perm = self.can_access('can_download', 'SliceModelView')
# handle save or overwrite
action = slice_params_multi_dict.get('action')
if action in ('saveas', 'overwrite'):
return self.save_or_overwrite_slice(
slice_params_multi_dict, slc, slice_add_perm, slice_edit_perm)
# handle different endpoints
if slice_params_multi_dict.get("json") == "true":
if config.get("DEBUG"):
# Allows for nice debugger stack traces in debug mode
return Response(
viz_obj.get_json(),
status=200,
mimetype="application/json")
try:
return Response(
viz_obj.get_json(),
status=200,
mimetype="application/json")
except Exception as e:
logging.exception(e)
return json_error_response(utils.error_msg_from_exception(e))
elif slice_params_multi_dict.get("csv") == "true":
payload = viz_obj.get_csv()
return Response(
payload,
status=200,
headers=generate_download_headers("csv"),
mimetype="application/csv")
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())
}
if slice_params_multi_dict.get("standalone") == "true":
template = "caravel/standalone.html"
else:
template = "caravel/explorev2.html"
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):
"""Save or overwrite a slice"""
@ -1831,6 +1938,34 @@ class Caravel(BaseCaravelView):
'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_type = request.args.get('datasource_type')
datasource_class = SourceRegistry.sources[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)
datasource_grains = datasource.database.grains()
grain_names = [str(grain.name) for grain in datasource_grains]
form_data = {
"dttm_cols": datasource.dttm_cols,
"time_grains": grain_names,
"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/<last_updated_ms>")
@log_this