diff --git a/caravel/assets/javascripts/explorev2/components/ChartControl.jsx b/caravel/assets/javascripts/explorev2/components/ChartControl.jsx
index eee6e0d6ff45..b03c2d4fab6b 100644
--- a/caravel/assets/javascripts/explorev2/components/ChartControl.jsx
+++ b/caravel/assets/javascripts/explorev2/components/ChartControl.jsx
@@ -20,7 +20,7 @@ const defaultProps = {
vizType: null,
};
-class ChartControl extends React.Component {
+export class ChartControl extends React.Component {
componentWillMount() {
if (this.props.datasourceId) {
this.props.actions.setFormOpts(this.props.datasourceId, this.props.datasourceType);
diff --git a/caravel/assets/javascripts/explorev2/components/Filters.jsx b/caravel/assets/javascripts/explorev2/components/Filters.jsx
index 49e384157ff6..072aa99cc944 100644
--- a/caravel/assets/javascripts/explorev2/components/Filters.jsx
+++ b/caravel/assets/javascripts/explorev2/components/Filters.jsx
@@ -1,5 +1,4 @@
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';
@@ -18,7 +17,7 @@ const defaultProps = {
filters: [],
};
-class Filters extends React.Component {
+export class Filters extends React.Component {
constructor(props) {
super(props);
this.state = {
diff --git a/caravel/assets/javascripts/explorev2/components/GroupBy.jsx b/caravel/assets/javascripts/explorev2/components/GroupBy.jsx
index cbf10fdfe59e..6467e73b686f 100644
--- a/caravel/assets/javascripts/explorev2/components/GroupBy.jsx
+++ b/caravel/assets/javascripts/explorev2/components/GroupBy.jsx
@@ -19,7 +19,7 @@ const defaultProps = {
groupByColumns: [],
};
-class GroupBy extends React.Component {
+export class GroupBy extends React.Component {
changeColumns(groupByColumnOpts) {
this.props.actions.setGroupByColumns(groupByColumnOpts);
}
diff --git a/caravel/assets/javascripts/explorev2/components/SqlClause.jsx b/caravel/assets/javascripts/explorev2/components/SqlClause.jsx
index ab484dfe20ab..76630474ab27 100644
--- a/caravel/assets/javascripts/explorev2/components/SqlClause.jsx
+++ b/caravel/assets/javascripts/explorev2/components/SqlClause.jsx
@@ -7,7 +7,7 @@ const propTypes = {
actions: React.PropTypes.object,
};
-class SqlClause extends React.Component {
+export class SqlClause extends React.Component {
changeWhere(whereClause) {
this.props.actions.setWhereClause(whereClause);
}
diff --git a/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx b/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx
index dfcbd4041d84..1afe89b6bd09 100644
--- a/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx
+++ b/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx
@@ -25,7 +25,7 @@ const defaultProps = {
until: null,
};
-class TimeFilter extends React.Component {
+export class TimeFilter extends React.Component {
changeTimeColumn(timeColumnOpt) {
const val = (timeColumnOpt) ? timeColumnOpt.value : null;
this.props.actions.setTimeColumn(val);
diff --git a/caravel/assets/package.json b/caravel/assets/package.json
index 07d9e65db963..5965ac1f66d1 100644
--- a/caravel/assets/package.json
+++ b/caravel/assets/package.json
@@ -110,7 +110,9 @@
"less": "^2.6.1",
"less-loader": "^2.2.2",
"mocha": "^2.4.5",
+ "nock": "^8.0.0",
"react-addons-test-utils": "^15.3.2",
+ "redux-mock-store": "^1.2.1",
"style-loader": "^0.13.0",
"transform-loader": "^0.2.3",
"url-loader": "^0.5.7",
diff --git a/caravel/assets/spec/javascripts/explore/components/ChartControl_spec.jsx b/caravel/assets/spec/javascripts/explore/components/ChartControl_spec.jsx
new file mode 100644
index 000000000000..08f2b96d86aa
--- /dev/null
+++ b/caravel/assets/spec/javascripts/explore/components/ChartControl_spec.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import Select from 'react-select';
+import { ChartControl } from '../../../../javascripts/explorev2/components/ChartControl';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+describe('QuerySearch', () => {
+ it('should render', () => {
+ expect(
+ React.isValidElement()
+ ).to.equal(true);
+ });
+
+ const wrapper = shallow();
+ it('should have two Select', () => {
+ expect(wrapper.find(Select)).to.have.length(2);
+ });
+});
+
diff --git a/caravel/assets/spec/javascripts/explore/components/Filter_spec.jsx b/caravel/assets/spec/javascripts/explore/components/Filter_spec.jsx
new file mode 100644
index 000000000000..c69bdf473244
--- /dev/null
+++ b/caravel/assets/spec/javascripts/explore/components/Filter_spec.jsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { Button } from 'react-bootstrap';
+import Select from 'react-select';
+import { Filters } from '../../../../javascripts/explorev2/components/Filters';
+import shortid from 'shortid';
+
+function setup() {
+ const props = {
+ filters: [
+ {
+ id: shortid.generate(),
+ field: null,
+ op: null,
+ value: null,
+ },
+ ]
+ }
+ const wrapper = shallow();
+ return {
+ props,
+ wrapper,
+ };
+}
+
+describe('Filters', () => {
+ it('renders', () => {
+ expect(React.isValidElement()).to.equal(true);
+ });
+
+ it('should have one button', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(Button)).to.have.length(1);
+ expect(wrapper.find(Button).contains('Add Filter')).to.eql(true);
+ });
+
+ it('should have Select and button for filters', () => {
+ const { wrapper, props } = setup();
+ expect(wrapper.find(Button)).to.have.length(2);
+ expect(wrapper.find(Select)).to.have.length(2);
+ expect(wrapper.find('input')).to.have.length(1);
+ });
+});
diff --git a/caravel/assets/spec/javascripts/explore/components/GroupBy_spec.js b/caravel/assets/spec/javascripts/explore/components/GroupBy_spec.js
new file mode 100644
index 000000000000..fc2b66d665f3
--- /dev/null
+++ b/caravel/assets/spec/javascripts/explore/components/GroupBy_spec.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import Select from 'react-select';
+import { GroupBy } from '../../../../javascripts/explorev2/components/GroupBy';
+
+describe('GroupBy', () => {
+ it('renders', () => {
+ expect(React.isValidElement()).to.equal(true);
+ });
+
+ it('should have two Select', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(Select)).to.have.length(2);
+ });
+});
diff --git a/caravel/assets/spec/javascripts/explore/components/SqlClause_spec.js b/caravel/assets/spec/javascripts/explore/components/SqlClause_spec.js
new file mode 100644
index 000000000000..b2ed94847df5
--- /dev/null
+++ b/caravel/assets/spec/javascripts/explore/components/SqlClause_spec.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { SqlClause } from '../../../../javascripts/explorev2/components/SqlClause';
+
+describe('SqlClause', () => {
+ it('renders', () => {
+ expect(React.isValidElement()).to.equal(true);
+ });
+
+ it('should have two input fields', () => {
+ const wrapper = shallow();
+ expect(wrapper.find('input')).to.have.length(2);
+ });
+});
diff --git a/caravel/assets/spec/javascripts/explore/components/TimeFilter_spec.jsx b/caravel/assets/spec/javascripts/explore/components/TimeFilter_spec.jsx
new file mode 100644
index 000000000000..e8043811c754
--- /dev/null
+++ b/caravel/assets/spec/javascripts/explore/components/TimeFilter_spec.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import Select from 'react-select';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { TimeFilter } from '../../../../javascripts/explorev2/components/TimeFilter';
+
+describe('TimeFilter', () => {
+ it('renders', () => {
+ expect(React.isValidElement()).to.equal(true);
+ });
+
+ it('should have four Select', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(Select)).to.have.length(4);
+ });
+});
diff --git a/caravel/assets/spec/javascripts/explore/components/actions_spec.js b/caravel/assets/spec/javascripts/explore/components/actions_spec.js
index 06cb77e4d9ee..a50883e0332c 100644
--- a/caravel/assets/spec/javascripts/explore/components/actions_spec.js
+++ b/caravel/assets/spec/javascripts/explore/components/actions_spec.js
@@ -1,11 +1,111 @@
import { it, describe } from 'mocha';
-import { expect } from 'chai';
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import nock from 'nock';
+import { expect, assert } from 'chai';
+import sinon from 'sinon';
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';
+const middlewares = [thunk];
+const mockStore = configureMockStore(middlewares);
+
+describe('ajax call for datasource metadata', () => {
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ it('should return a function', () => {
+ expect(actions.setFormOpts(999, 'test')).to.be.function;
+ });
+
+ it('should dispatch clearAllOpts', () => {
+ const dispatch = sinon.spy();
+ actions.setFormOpts(null, null)(dispatch);
+ assert(dispatch.withArgs(actions.clearAllOpts()).calledOnce);
+ });
+
+ it('should dispatch new opts', () => {
+ nock('/caravel')
+ .get('/fetch_datasource_metadata')
+ .query({ datasource_id: 999, datasource_type: 'test' })
+ .reply(200, {
+ datasource_class: 'SqlaTable',
+ time_columns: ['col'],
+ time_grains: [],
+ groupby_cols: [],
+ metrics: [],
+ filter_cols: [],
+ });
+
+ const store = mockStore(initialState);
+ store.dispatch = sinon.spy();
+ store.dispatch(actions.setFormOpts(999, 'test'));
+ expect(store.dispatch.callCount).to.equal(5);
+ expect(store.getState().timeColumnOpts).to.eql(['col']);
+ });
+});
+
describe('reducers', () => {
+ it('should return new state with time column options', () => {
+ const newState = exploreReducer(initialState, actions.setTimeColumnOpts(['col1', 'col2']));
+ expect(newState.timeColumnOpts).to.eql(['col1', 'col2']);
+ });
+ it('should return new state with time grain options', () => {
+ const newState = exploreReducer(initialState, actions.setTimeGrainOpts(['day', 'week']));
+ expect(newState.timeGrainOpts).to.eql(['day', 'week']);
+ });
+
+ it('should return new state with groupby column options', () => {
+ const newState = exploreReducer(initialState, actions.setGroupByColumnOpts(['col1', 'col2']));
+ expect(newState.groupByColumnOpts).to.eql(['col1', 'col2']);
+ });
+
+ it('should return new state with metrics options', () => {
+ const newState = exploreReducer(initialState, actions.setMetricsOpts(['metric1', 'metric2']));
+ expect(newState.metricsOpts).to.eql(['metric1', 'metric2']);
+ });
+
+ it('should return new state with filter column options', () => {
+ const newState = exploreReducer(initialState, actions.setFilterColumnOpts(['col1', 'col2']));
+ expect(newState.filterColumnOpts).to.eql(['col1', 'col2']);
+ });
+
+ it('should return new state with all form data reset', () => {
+ const newState = exploreReducer(initialState, actions.resetFormData());
+ expect(newState.vizType).to.not.exist;
+ expect(newState.timeColumn).to.not.exist;
+ expect(newState.timeGrain).to.not.exist;
+ expect(newState.since).to.not.exist;
+ expect(newState.until).to.not.exist;
+ expect(newState.groupByColumns).to.be.empty;
+ expect(newState.metrics).to.be.empty;
+ expect(newState.columns).to.be.empty;
+ expect(newState.orderings).to.be.empty;
+ expect(newState.timeStampFormat).to.not.exist;
+ expect(newState.rowLimit).to.not.exist;
+ expect(newState.searchBox).to.be.false;
+ expect(newState.whereClause).to.be.empty;
+ expect(newState.havingClause).to.be.empty;
+ expect(newState.filters).to.be.empty;
+ });
+
+ it('should clear all options in store', () => {
+ const newState = exploreReducer(initialState, actions.clearAllOpts());
+ expect(newState.timeColumnOpts).to.be.empty;
+ expect(newState.timeGrainOpts).to.be.empty;
+ expect(newState.groupByColumnOpts).to.be.empty;
+ expect(newState.metricsOpts).to.be.empty;
+ expect(newState.filterColumnOpts).to.be.empty;
+ });
+
+ // it('should return new state with datasource class', () => {
+ // const newState = exploreReducer(initialState, actions.setDatasourceClass('SqlaTable'));
+ // expect(newState.datasourceClass).to.equal('SqlaTable');
+ // });
+
it('should return new state with datasource id', () => {
const newState = exploreReducer(initialState, actions.setDatasource(1));
expect(newState.datasourceId).to.equal(1);
@@ -16,6 +116,36 @@ describe('reducers', () => {
expect(newState.vizType).to.equal('bar');
});
+ it('should return new state with time column', () => {
+ const newState = exploreReducer(initialState, actions.setTimeColumn('ds'));
+ expect(newState.timeColumn).to.equal('ds');
+ });
+
+ it('should return new state with time grain', () => {
+ const newState = exploreReducer(initialState, actions.setTimeGrain('day'));
+ expect(newState.timeGrain).to.equal('day');
+ });
+
+ it('should return new state with since', () => {
+ const newState = exploreReducer(initialState, actions.setSince('1 day ago'));
+ expect(newState.since).to.equal('1 day ago');
+ });
+
+ it('should return new state with until', () => {
+ const newState = exploreReducer(initialState, actions.setUntil('now'));
+ expect(newState.until).to.equal('now');
+ });
+
+ it('should return new state with groupby columns', () => {
+ const newState = exploreReducer(initialState, actions.setGroupByColumns(['col1', 'col2']));
+ expect(newState.groupByColumns).to.eql(['col1', 'col2']);
+ });
+
+ it('should return new state with metrics', () => {
+ const newState = exploreReducer(initialState, actions.setMetrics(['sum', 'count']));
+ expect(newState.metrics).to.eql(['sum', 'count']);
+ });
+
it('should return new state with added column', () => {
const newColumn = 'col';
const newState = exploreReducer(initialState, actions.addColumn(newColumn));
@@ -57,6 +187,16 @@ describe('reducers', () => {
expect(newState.searchBox).to.equal(true);
});
+ it('should return new state with where clause', () => {
+ const newState = exploreReducer(initialState, actions.setWhereClause('where'));
+ expect(newState.whereClause).to.equal('where');
+ });
+
+ it('should return new state with having clause', () => {
+ const newState = exploreReducer(initialState, actions.setHavingClause('having'));
+ expect(newState.havingClause).to.equal('having');
+ });
+
it('should return new state with added filter', () => {
const newFilter = {
id: shortid.generate(),
diff --git a/caravel/views.py b/caravel/views.py
index 18fdb32be70d..4ab411c1546e 100755
--- a/caravel/views.py
+++ b/caravel/views.py
@@ -78,6 +78,7 @@ class ListWidgetWithCheckboxes(ListWidget):
ACCESS_REQUEST_MISSING_ERR = __(
"The access requests seem to have been deleted")
USER_MISSING_ERR = __("The user seems to have been deleted")
+DATASOURCE_ACCESS_ERR = __("User does not have access to this datasource")
def get_database_access_error_msg(database_name):
diff --git a/tests/core_tests.py b/tests/core_tests.py
index 0f16b3a77d2a..7a53d1a7d9b6 100644
--- a/tests/core_tests.py
+++ b/tests/core_tests.py
@@ -307,6 +307,19 @@ def test_csv_endpoint(self):
self.assertEqual(list(expected_data), list(data))
self.logout()
+ def test_datasource_metadata_endpoint(self):
+ params = [
+ 'datasource_id=1',
+ 'datasource_type=table'
+ ]
+ resp = self.client.get('/caravel/fetch_datasource_metadata?'+'&'.join(params))
+ self.assertEquals(500, resp.status_code)
+
+ self.login('admin')
+ resp = self.client.get('/caravel/fetch_datasource_metadata?'+'&'.join(params))
+ self.assertEquals(200, resp.status_code)
+ self.logout()
+
def test_queries_endpoint(self):
resp = self.client.get('/caravel/queries/{}'.format(0))
self.assertEquals(403, resp.status_code)