diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js b/superset/assets/spec/fixtures/mockDatasource.js
similarity index 100%
rename from superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js
rename to superset/assets/spec/fixtures/mockDatasource.js
diff --git a/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx b/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx
new file mode 100644
index 000000000..695568885
--- /dev/null
+++ b/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it, beforeEach } from 'mocha';
+import { shallow } from 'enzyme';
+
+import CollectionTable from '../../../src/CRUD/CollectionTable';
+import mockDatasource from '../../fixtures/mockDatasource';
+
+const props = {
+ collection: mockDatasource['7__table'].columns,
+ tableColumns: ['column_name', 'type', 'groupby'],
+};
+
+describe('CollectionTable', () => {
+
+ let wrapper;
+ let el;
+
+ beforeEach(() => {
+ el = ;
+ wrapper = shallow(el);
+ });
+
+ it('is valid', () => {
+ expect(React.isValidElement(el)).to.equal(true);
+ });
+
+ it('renders a table', () => {
+ const length = mockDatasource['7__table'].columns.length;
+ expect(wrapper.find('table')).to.have.lengthOf(1);
+ expect(wrapper.find('tbody tr.row')).to.have.lengthOf(length);
+ });
+
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
index e85500959..aa82f0170 100644
--- a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
@@ -9,7 +9,7 @@ import DashboardBuilder from '../../../../src/dashboard/containers/DashboardBuil
// mock data
import chartQueries, { sliceId as chartId } from '../fixtures/mockChartQueries';
-import datasources from '../fixtures/mockDatasource';
+import datasources from '../../../fixtures/mockDatasource';
import dashboardInfo from '../fixtures/mockDashboardInfo';
import { dashboardLayout } from '../fixtures/mockDashboardLayout';
import dashboardState from '../fixtures/mockDashboardState';
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
index dcd711947..dbd70540c 100644
--- a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
@@ -8,7 +8,7 @@ import Chart from '../../../../../src/dashboard/components/gridComponents/Chart'
import SliceHeader from '../../../../../src/dashboard/components/SliceHeader';
import ChartContainer from '../../../../../src/chart/ChartContainer';
-import mockDatasource from '../../fixtures/mockDatasource';
+import mockDatasource from '../../../../fixtures/mockDatasource';
import {
sliceEntitiesForChart as sliceEntities,
sliceId,
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
index b5004a1e1..81ecb2035 100644
--- a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
@@ -1,4 +1,4 @@
-import { datasourceId } from './mockDatasource';
+import { datasourceId } from '../../../fixtures/mockDatasource';
export const sliceId = 18;
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
index 03d8e3057..c7a9c7a0b 100644
--- a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
@@ -1,5 +1,5 @@
import { sliceId as id } from './mockChartQueries';
-import { datasourceId } from './mockDatasource';
+import { datasourceId } from '../../../fixtures/mockDatasource';
export const sliceId = id;
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
index 514442fc0..32d81209a 100644
--- a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
@@ -3,7 +3,7 @@ import { dashboardLayout } from './mockDashboardLayout';
import dashboardInfo from './mockDashboardInfo';
import dashboardState from './mockDashboardState';
import messageToasts from '../../messageToasts/mockMessageToasts';
-import datasources from './mockDatasource';
+import datasources from '../../../fixtures/mockDatasource';
import sliceEntities from './mockSliceEntities';
export default {
diff --git a/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx b/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx
new file mode 100644
index 000000000..137ac3956
--- /dev/null
+++ b/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { Tabs } from 'react-bootstrap';
+import { expect } from 'chai';
+import { describe, it, beforeEach } from 'mocha';
+import { shallow } from 'enzyme';
+import configureStore from 'redux-mock-store';
+import $ from 'jquery';
+import sinon from 'sinon';
+
+import DatasourceEditor from '../../../src/datasource/DatasourceEditor';
+import mockDatasource from '../../fixtures/mockDatasource';
+
+const props = {
+ datasource: mockDatasource['7__table'],
+ addSuccessToast: () => {},
+ addDangerToast: () => {},
+ onChange: sinon.spy(),
+};
+const extraColumn = {
+ column_name: 'new_column',
+ type: 'VARCHAR(10)',
+ description: null,
+ filterable: true,
+ verbose_name: null,
+ is_dttm: false,
+ expression: '',
+ groupby: true,
+};
+
+describe('DatasourceEditor', () => {
+ const mockStore = configureStore([]);
+ const store = mockStore({});
+
+ let wrapper;
+ let el;
+ let ajaxStub;
+ let inst;
+
+ beforeEach(() => {
+ ajaxStub = sinon.stub($, 'ajax');
+ el = ;
+ wrapper = shallow(el, { context: { store } }).dive();
+ inst = wrapper.instance();
+ });
+
+ afterEach(() => {
+ ajaxStub.restore();
+ });
+
+ it('is valid', () => {
+ expect(React.isValidElement(el)).to.equal(true);
+ });
+
+ it('renders Tabs', () => {
+ expect(wrapper.find(Tabs)).to.have.lengthOf(1);
+ });
+
+ it('makes an async request', () => {
+ wrapper.setState({ activeTabKey: 2 });
+ const syncButton = wrapper.find('.sync-from-source');
+ expect(syncButton).to.have.lengthOf(1);
+ syncButton.simulate('click');
+ expect(ajaxStub.calledOnce).to.equal(true);
+ });
+
+ it('merges columns', () => {
+ const numCols = props.datasource.columns.length;
+ expect(inst.state.databaseColumns.length).to.equal(numCols);
+ inst.mergeColumns([extraColumn]);
+ expect(inst.state.databaseColumns.length).to.equal(numCols + 1);
+ });
+
+});
diff --git a/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx b/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx
new file mode 100644
index 000000000..3cbc92f9e
--- /dev/null
+++ b/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { expect } from 'chai';
+import { describe, it, beforeEach } from 'mocha';
+import configureStore from 'redux-mock-store';
+import { shallow } from 'enzyme';
+import $ from 'jquery';
+import sinon from 'sinon';
+
+import DatasourceModal from '../../../src/datasource/DatasourceModal';
+import DatasourceEditor from '../../../src/datasource/DatasourceEditor';
+import mockDatasource from '../../fixtures/mockDatasource';
+
+const props = {
+ datasource: mockDatasource['7__table'],
+ addSuccessToast: () => {},
+ addDangerToast: () => {},
+ onChange: sinon.spy(),
+ show: true,
+ onHide: () => {},
+};
+
+describe('DatasourceModal', () => {
+ const mockStore = configureStore([]);
+ const store = mockStore({});
+
+ let wrapper;
+ let el;
+ let ajaxStub;
+ let inst;
+
+ beforeEach(() => {
+ ajaxStub = sinon.stub($, 'ajax');
+ el = ;
+ wrapper = shallow(el, { context: { store } }).dive();
+ inst = wrapper.instance();
+ });
+
+ afterEach(() => {
+ ajaxStub.restore();
+ });
+
+ it('is valid', () => {
+ expect(React.isValidElement(el)).to.equal(true);
+ });
+
+ it('renders a Modal', () => {
+ expect(wrapper.find(Modal)).to.have.lengthOf(1);
+ });
+
+ it('renders a DatasourceEditor', () => {
+ expect(wrapper.find(DatasourceEditor)).to.have.lengthOf(1);
+ });
+
+ it('saves on confirm', () => {
+ inst.onConfirmSave();
+ expect(ajaxStub.calledOnce).to.equal(true);
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx
index 68dc783c4..d03e30b0a 100644
--- a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx
@@ -4,7 +4,7 @@ import configureStore from 'redux-mock-store';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { shallow } from 'enzyme';
-import { Modal } from 'react-bootstrap';
+import DatasourceModal from '../../../../src/datasource/DatasourceModal';
import DatasourceControl from '../../../../src/explore/components/controls/DatasourceControl';
const defaultProps = {
@@ -35,6 +35,6 @@ describe('DatasourceControl', () => {
it('renders a Modal', () => {
const wrapper = setup();
- expect(wrapper.find(Modal)).to.have.lengthOf(1);
+ expect(wrapper.find(DatasourceModal)).to.have.lengthOf(1);
});
});
diff --git a/superset/assets/spec/javascripts/explore/exploreActions_spec.js b/superset/assets/spec/javascripts/explore/exploreActions_spec.js
index a87a45d60..72aebbd55 100644
--- a/superset/assets/spec/javascripts/explore/exploreActions_spec.js
+++ b/superset/assets/spec/javascripts/explore/exploreActions_spec.js
@@ -1,12 +1,9 @@
/* eslint-disable no-unused-expressions */
import { it, describe } from 'mocha';
import { expect } from 'chai';
-import sinon from 'sinon';
-import $ from 'jquery';
-import * as chartActions from '../../../src/chart/chartAction';
-import * as actions from '../../../src/explore/actions/exploreActions';
import { defaultState } from '../../../src/explore/store';
import exploreReducer from '../../../src/explore/reducers/exploreReducer';
+import * as actions from '../../../src/explore/actions/exploreActions';
describe('reducers', () => {
it('sets correct control value given a key and value', () => {
@@ -20,65 +17,3 @@ describe('reducers', () => {
expect(newState.controls.show_legend.value).to.equal(true);
});
});
-
-describe('fetching actions', () => {
- let dispatch;
- let request;
- let ajaxStub;
-
- beforeEach(() => {
- dispatch = sinon.spy();
- ajaxStub = sinon.stub($, 'ajax');
- });
- afterEach(() => {
- ajaxStub.restore();
- });
-
- describe('fetchDatasourceMetadata', () => {
- const datasourceKey = '1__table';
-
- const makeRequest = (alsoTriggerQuery = false) => {
- request = actions.fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery);
- request(dispatch);
- };
-
- it('calls fetchDatasourceStarted', () => {
- makeRequest();
- expect(dispatch.args[0][0].type).to.equal(actions.FETCH_DATASOURCE_STARTED);
- });
-
- it('makes the ajax request', () => {
- makeRequest();
- expect(ajaxStub.calledOnce).to.be.true;
- });
-
- it('calls correct url', () => {
- const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`;
- makeRequest();
- expect(ajaxStub.getCall(0).args[0].url).to.equal(url);
- });
-
- it('calls correct actions on error', () => {
- ajaxStub.yieldsTo('error', { responseJSON: { error: 'error text' } });
- makeRequest();
- expect(dispatch.callCount).to.equal(2);
- expect(dispatch.getCall(1).args[0].type).to.equal(actions.FETCH_DATASOURCE_FAILED);
- });
-
- it('calls correct actions on success', () => {
- ajaxStub.yieldsTo('success', { data: '' });
- makeRequest();
- expect(dispatch.callCount).to.equal(4);
- expect(dispatch.getCall(1).args[0].type).to.equal(actions.SET_DATASOURCE);
- expect(dispatch.getCall(2).args[0].type).to.equal(actions.FETCH_DATASOURCE_SUCCEEDED);
- expect(dispatch.getCall(3).args[0].type).to.equal(actions.RESET_FIELDS);
- });
-
- it('triggers query if flag is set', () => {
- ajaxStub.yieldsTo('success', { data: '' });
- makeRequest(true);
- expect(dispatch.callCount).to.equal(5);
- expect(dispatch.getCall(4).args[0].type).to.equal(chartActions.TRIGGER_QUERY);
- });
- });
-});
diff --git a/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx b/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx
index ac33d99ea..08457d985 100644
--- a/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx
+++ b/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx
@@ -4,7 +4,7 @@ import { describe, it } from 'mocha';
import { expect } from 'chai';
import { user } from './fixtures';
import CreatedContent from '../../../src/profile/components/CreatedContent';
-import TableLoader from '../../../src/profile/components/TableLoader';
+import TableLoader from '../../../src/components/TableLoader';
describe('CreatedContent', () => {
diff --git a/superset/assets/spec/javascripts/profile/Favorites_spec.jsx b/superset/assets/spec/javascripts/profile/Favorites_spec.jsx
index c9315dcd3..252d99ef2 100644
--- a/superset/assets/spec/javascripts/profile/Favorites_spec.jsx
+++ b/superset/assets/spec/javascripts/profile/Favorites_spec.jsx
@@ -5,7 +5,7 @@ import { expect } from 'chai';
import { user } from './fixtures';
import Favorites from '../../../src/profile/components/Favorites';
-import TableLoader from '../../../src/profile/components/TableLoader';
+import TableLoader from '../../../src/components/TableLoader';
describe('Favorites', () => {
const mockedProps = {
diff --git a/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx b/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx
index f3c200f7a..60240fc31 100644
--- a/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx
+++ b/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx
@@ -5,7 +5,7 @@ import { expect } from 'chai';
import { user } from './fixtures';
import RecentActivity from '../../../src/profile/components/RecentActivity';
-import TableLoader from '../../../src/profile/components/TableLoader';
+import TableLoader from '../../../src/components/TableLoader';
describe('RecentActivity', () => {
diff --git a/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx b/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx
index 16f1f8bf3..bc4957e13 100644
--- a/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx
@@ -9,10 +9,6 @@ import { table, defaultQueryEditor, databases, tables } from './fixtures';
import SqlEditorLeftBar from '../../../src/SqlLab/components/SqlEditorLeftBar';
import TableElement from '../../../src/SqlLab/components/TableElement';
-global.notify = {
- error: () => {},
-};
-
describe('SqlEditorLeftBar', () => {
const mockedProps = {
actions: {
diff --git a/superset/assets/src/CRUD/CollectionTable.jsx b/superset/assets/src/CRUD/CollectionTable.jsx
new file mode 100644
index 000000000..452022574
--- /dev/null
+++ b/superset/assets/src/CRUD/CollectionTable.jsx
@@ -0,0 +1,220 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import shortid from 'shortid';
+
+import Button from '../components/Button';
+import Fieldset from './Fieldset';
+import { recurseReactClone } from './utils';
+import './styles.css';
+
+const propTypes = {
+ collection: PropTypes.array,
+ itemGenerator: PropTypes.func,
+ columnLabels: PropTypes.object,
+ tableColumns: PropTypes.array,
+ columns: PropTypes.array,
+ onChange: PropTypes.func,
+ itemRenderers: PropTypes.object,
+ allowDeletes: PropTypes.bool,
+ expandFieldset: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ extraButtons: PropTypes.node,
+ allowAddItem: PropTypes.bool,
+};
+const defaultProps = {
+ onChange: () => {},
+ itemRenderers: {},
+ columnLabels: {},
+ allowDeletes: false,
+ emptyMessage: 'No entries',
+ allowAddItem: false,
+ itemGenerator: () => ({}),
+};
+const Frame = props => (
+
+ {props.children}
+
);
+Frame.propTypes = { children: PropTypes.node };
+
+function createKeyedCollection(arr) {
+ const newArr = arr.map(o => ({
+ ...o,
+ id: o.id || shortid.generate(),
+ }));
+ const map = {};
+ newArr.forEach((o) => {
+ map[o.id] = o;
+ });
+ return map;
+}
+
+export default class CRUDCollection extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ expandedColumns: {},
+ collection: createKeyedCollection(props.collection),
+ };
+ this.renderItem = this.renderItem.bind(this);
+ this.onAddItem = this.onAddItem.bind(this);
+ this.renderExpandableSection = this.renderExpandableSection.bind(this);
+ this.getLabel = this.getLabel.bind(this);
+ this.onFieldsetChange = this.onFieldsetChange.bind(this);
+ this.renderTableBody = this.renderTableBody.bind(this);
+ this.changeCollection = this.changeCollection.bind(this);
+ }
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.collection !== this.props.collection) {
+ this.setState({
+ collection: createKeyedCollection(nextProps.collection),
+ });
+ }
+ }
+ onCellChange(id, col, val) {
+ this.changeCollection({
+ ...this.state.collection,
+ [id]: {
+ ...this.state.collection[id],
+ [col]: val,
+ },
+ });
+
+ }
+ onAddItem() {
+ let newItem = this.props.itemGenerator();
+ if (!newItem.id) {
+ newItem = { ...newItem, id: shortid.generate() };
+ }
+ this.changeCollection({
+ ...this.state.collection,
+ [newItem.id]: newItem,
+ });
+ }
+ onFieldsetChange(item) {
+ this.changeCollection({
+ ...this.state.collection,
+ [item.id]: item,
+ });
+ }
+ getLabel(col) {
+ const { columnLabels } = this.props;
+ let label = columnLabels[col] ? columnLabels[col] : col;
+ if (label.startsWith('__')) {
+ label = '';
+ }
+ return label;
+ }
+ changeCollection(collection) {
+ this.setState({ collection });
+ this.props.onChange(Object.keys(collection).map(k => collection[k]));
+ }
+ deleteItem(id) {
+ const newColl = { ...this.state.collection };
+ delete newColl[id];
+ this.changeCollection(newColl);
+ }
+ effectiveTableColumns() {
+ const { tableColumns, allowDeletes, expandFieldset } = this.props;
+ const cols = allowDeletes ? tableColumns.concat(['__actions']) : tableColumns;
+ return expandFieldset ? ['__expand'].concat(cols) : cols;
+ }
+ toggleExpand(id) {
+ this.onCellChange(id, '__expanded', false);
+ this.setState({
+ expandedColumns: {
+ ...this.state.expandedColumns,
+ [id]: !this.state.expandedColumns[id],
+ },
+ });
+ }
+ renderHeaderRow() {
+ const cols = this.effectiveTableColumns();
+ return (
+
+
+ {this.props.expandFieldset && }
+ {cols.map(col => {this.getLabel(col)} )}
+ {this.props.allowDeletes && }
+
+
+ );
+ }
+ renderExpandableSection(item) {
+ const propsGenerator = () => ({ item, onChange: this.onFieldsetChange });
+ return recurseReactClone(this.props.expandFieldset, Fieldset, propsGenerator);
+ }
+ renderCell(record, col) {
+ const renderer = this.props.itemRenderers[col];
+ const val = record[col];
+ const onChange = this.onCellChange.bind(this, record.id, col);
+ return renderer ? renderer(val, onChange, this.getLabel(col)) : val;
+ }
+ renderItem(record) {
+ const { tableColumns, allowDeletes, expandFieldset } = this.props;
+ /* eslint-disable no-underscore-dangle */
+ const isExpanded = !!this.state.expandedColumns[record.id] || record.__expanded;
+ let tds = [];
+ if (expandFieldset) {
+ tds.push(
+
+
+ );
+ }
+ tds = tds.concat(tableColumns.map(col => (
+ {this.renderCell(record, col)}
+ )));
+ if (allowDeletes) {
+ tds.push(
+
+
+ );
+ }
+ const trs = [{tds} ];
+ if (isExpanded) {
+ trs.push(
+
+
+
+ {this.renderExpandableSection(record)}
+
+
+ );
+ }
+ return trs;
+ }
+ renderEmptyCell() {
+ return {this.props.emptyMessage} ;
+ }
+ renderTableBody() {
+ const data = Object.keys(this.state.collection).map(k => this.state.collection[k]);
+ const content = data.length ? data.map(d => this.renderItem(d)) : this.renderEmptyCell();
+ return {content} ;
+ }
+ render() {
+ return (
+
+
+ {this.renderHeaderRow()}
+ {this.renderTableBody()}
+
+
+ {this.props.allowAddItem &&
+
+ Add Item
+ }
+ {this.props.extraButtons}
+
+
+ );
+ }
+}
+CRUDCollection.defaultProps = defaultProps;
+CRUDCollection.propTypes = propTypes;
diff --git a/superset/assets/src/CRUD/Field.jsx b/superset/assets/src/CRUD/Field.jsx
new file mode 100644
index 000000000..0fa0ad7a7
--- /dev/null
+++ b/superset/assets/src/CRUD/Field.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ FormGroup, ControlLabel, HelpBlock, FormControl, OverlayTrigger, Tooltip,
+} from 'react-bootstrap';
+
+import './styles.less';
+
+const propTypes = {
+ value: PropTypes.any,
+ label: PropTypes.string,
+ descr: PropTypes.node,
+ fieldKey: PropTypes.string.isRequired,
+ control: PropTypes.node.isRequired,
+ onChange: PropTypes.func,
+ compact: PropTypes.bool,
+};
+const defaultProps = {
+ controlProps: {},
+ onChange: () => {},
+ compact: false,
+};
+
+export default class Field extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onChange = this.onChange.bind(this);
+ }
+ onChange(newValue) {
+ this.props.onChange(this.props.fieldKey, newValue);
+ }
+ render() {
+ const { compact, value, label, control, descr, fieldKey } = this.props;
+ const hookedControl = React.cloneElement(control, { value, onChange: this.onChange });
+ return (
+
+
+ {label || fieldKey}
+ {compact && descr &&
+ {descr}
+ }
+ >
+
+
+ }
+
+ {hookedControl}
+
+ {!compact && descr &&
+ {descr}
+ }
+
+ );
+ }
+}
+Field.propTypes = propTypes;
+Field.defaultProps = defaultProps;
diff --git a/superset/assets/src/CRUD/Fieldset.jsx b/superset/assets/src/CRUD/Fieldset.jsx
new file mode 100644
index 000000000..6c3c74e9b
--- /dev/null
+++ b/superset/assets/src/CRUD/Fieldset.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Form } from 'react-bootstrap';
+
+import { recurseReactClone } from './utils';
+import Field from './Field';
+
+const propTypes = {
+ children: PropTypes.node,
+ onChange: PropTypes.func,
+ item: PropTypes.object,
+ title: PropTypes.node,
+ compact: PropTypes.bool,
+};
+const defaultProps = {
+ compact: false,
+};
+
+export default class Fieldset extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onChange = this.onChange.bind(this);
+ }
+ onChange(fieldKey, val) {
+ return this.props.onChange({
+ ...this.props.item,
+ [fieldKey]: val,
+ });
+ }
+ render() {
+ const { title } = this.props;
+ const propExtender = field => ({
+ onChange: this.onChange,
+ value: this.props.item[field.props.fieldKey],
+ compact: this.props.compact,
+ });
+ return (
+
+ );
+ }
+}
+Fieldset.propTypes = propTypes;
+Fieldset.defaultProps = defaultProps;
diff --git a/superset/assets/src/CRUD/styles.css b/superset/assets/src/CRUD/styles.css
new file mode 100644
index 000000000..1f07c59e0
--- /dev/null
+++ b/superset/assets/src/CRUD/styles.css
@@ -0,0 +1,13 @@
+.CRUD i.fa-caret-down, i.fa-caret-up {
+ width: '5px';
+}
+.CRUD td.expanded = {
+ border-top: '0px';
+ padding: '0px';
+}
+.CRUD .frame = {
+ border: '1px solid #AAA';
+ border-radius: 5;
+ padding: 10;
+ background: '#F4F4F4';
+}
diff --git a/superset/assets/src/CRUD/styles.less b/superset/assets/src/CRUD/styles.less
new file mode 100644
index 000000000..ec632a60b
--- /dev/null
+++ b/superset/assets/src/CRUD/styles.less
@@ -0,0 +1,14 @@
+.CRUD .text-right {
+ text-align: right;
+}
+.CRUD .empty-collection {
+ padding: 10px;
+}
+.CRUD .control-label {
+ font-weight: bold;
+ font-size: 16px;
+}
+
+.CRUD .tiny-cell {
+ width: 5px;
+}
diff --git a/superset/assets/src/CRUD/utils.js b/superset/assets/src/CRUD/utils.js
new file mode 100644
index 000000000..6de8c4b16
--- /dev/null
+++ b/superset/assets/src/CRUD/utils.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+export function recurseReactClone(children, type, propExtender) {
+ /**
+ * Clones a React component's children, and injects new props
+ * where the type specified is matched.
+ */
+ return React.Children.map(children, (child) => {
+ let newChild = child;
+ if (child && child.type === type) {
+ newChild = React.cloneElement(child, propExtender(child));
+ }
+ if (newChild && newChild.props.children) {
+ newChild = React.cloneElement(newChild, {
+ children: recurseReactClone(newChild.props.children, type, propExtender),
+ });
+ }
+ return newChild;
+ });
+}
diff --git a/superset/assets/src/components/Checkbox.jsx b/superset/assets/src/components/Checkbox.jsx
index b0564ae6b..c3b2506ee 100644
--- a/superset/assets/src/components/Checkbox.jsx
+++ b/superset/assets/src/components/Checkbox.jsx
@@ -12,7 +12,9 @@ export default function Checkbox({ checked, onChange, style }) {
{
+ onChange(!checked);
+ }}
style={{
border: '1px solid #aaa',
borderRadius: '2px',
diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx
index 45fea1dcb..5e206c91e 100644
--- a/superset/assets/src/components/EditableTitle.jsx
+++ b/superset/assets/src/components/EditableTitle.jsx
@@ -10,14 +10,18 @@ const propTypes = {
onSaveTitle: PropTypes.func,
noPermitTooltip: PropTypes.string,
showTooltip: PropTypes.bool,
+ emptyText: PropTypes.node,
+ style: PropTypes.object,
};
const defaultProps = {
title: t('Title'),
canEdit: false,
showTooltip: true,
+ onSaveTitle: () => {},
+ emptyText: '',
};
-class EditableTitle extends React.PureComponent {
+export default class EditableTitle extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
@@ -45,10 +49,7 @@ class EditableTitle extends React.PureComponent {
if (!this.props.canEdit || this.state.isEditing) {
return;
}
-
- this.setState({
- isEditing: true,
- });
+ this.setState({ isEditing: true });
}
handleBlur() {
@@ -101,7 +102,6 @@ class EditableTitle extends React.PureComponent {
if (!this.props.canEdit) {
return;
}
-
this.setState({
title: ev.target.value,
});
@@ -110,17 +110,23 @@ class EditableTitle extends React.PureComponent {
handleKeyPress(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
-
this.handleBlur();
}
}
render() {
- let content = (
+ let value;
+ if (this.state.title) {
+ value = this.state.title;
+ } else if (!this.state.isEditing) {
+ value = this.props.emptyText;
+ }
+ let input = (
);
- if (this.props.showTooltip) {
- content = (
+ if (this.props.showTooltip && !this.state.isEditing) {
+ input = (
- {content}
+ {input}
);
}
@@ -146,13 +152,12 @@ class EditableTitle extends React.PureComponent {
this.props.canEdit && 'editable-title--editable',
this.state.isEditing && 'editable-title--editing',
)}
+ style={this.props.style}
>
- {content}
+ {input}
);
}
}
EditableTitle.propTypes = propTypes;
EditableTitle.defaultProps = defaultProps;
-
-export default EditableTitle;
diff --git a/superset/assets/src/profile/components/TableLoader.jsx b/superset/assets/src/components/TableLoader.jsx
similarity index 94%
rename from superset/assets/src/profile/components/TableLoader.jsx
rename to superset/assets/src/components/TableLoader.jsx
index 462e00993..3f51ee92b 100644
--- a/superset/assets/src/profile/components/TableLoader.jsx
+++ b/superset/assets/src/components/TableLoader.jsx
@@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Table, Tr, Td } from 'reactable';
import $ from 'jquery';
-import Loading from '../../components/Loading';
-import '../../../stylesheets/reactable-pagination.css';
+import Loading from '../components/Loading';
+import '../../stylesheets/reactable-pagination.css';
const propTypes = {
dataEndpoint: PropTypes.string.isRequired,
diff --git a/superset/assets/src/datasource/DatasourceEditor.jsx b/superset/assets/src/datasource/DatasourceEditor.jsx
new file mode 100644
index 000000000..12dfdc0ce
--- /dev/null
+++ b/superset/assets/src/datasource/DatasourceEditor.jsx
@@ -0,0 +1,575 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Alert, Badge, Col, Label, Tabs, Tab, Well } from 'react-bootstrap';
+import shortid from 'shortid';
+import $ from 'jquery';
+
+import { t } from '../locales';
+
+import Button from '../components/Button';
+import Loading from '../components/Loading';
+import CheckboxControl from '../explore/components/controls/CheckboxControl';
+import TextControl from '../explore/components/controls/TextControl';
+import SelectControl from '../explore/components/controls/SelectControl';
+import TextAreaControl from '../explore/components/controls/TextAreaControl';
+import SelectAsyncControl from '../explore/components/controls/SelectAsyncControl';
+import SpatialControl from '../explore/components/controls/SpatialControl';
+import CollectionTable from '../CRUD/CollectionTable';
+import EditableTitle from '../components/EditableTitle';
+import Fieldset from '../CRUD/Fieldset';
+import Field from '../CRUD/Field';
+
+import withToasts from '../messageToasts/enhancers/withToasts';
+
+import './main.css';
+
+const checkboxGenerator = (d, onChange) => ;
+const styleMonospace = { fontFamily: 'monospace' };
+const DATA_TYPES = ['STRING', 'NUMBER', 'DATETIME'];
+
+function CollectionTabTitle({ title, collection }) {
+ return (
+
+ {title} {collection ? collection.length : 0}
+
+ );
+}
+CollectionTabTitle.propTypes = {
+ title: PropTypes.string,
+ collection: PropTypes.array,
+};
+
+function ColumnCollectionTable({
+ columns, onChange, editableColumnName, showExpression, allowAddItem,
+ allowEditDataType, itemGenerator,
+}) {
+ return (
+
+
+ {showExpression &&
+ }
+ />}
+ }
+ />
+ {allowEditDataType &&
+ }
+ />}
+
+ {t('The pattern of the timestamp format, use ')}
+
+ {t('python datetime string pattern')}
+
+ {t(` expression. If time is stored in epoch format, put \`epoch_s\` or
+ \`epoch_ms\`. Leave \`Database Expression\`
+ below empty if timestamp is stored in '
+ String or Integer(epoch) type`)}
+
+ }
+ control={ }
+ />
+
+ {t(`
+ The database expression to cast internal datetime
+ constants to database date/timestamp type according to the DBAPI.
+ The expression should follow the pattern of
+ %Y-%m-%d %H:%M:%S, based on different DBAPI.
+ The string should be a python string formatter
+ \`Ex: TO_DATE('{}', 'YYYY-MM-DD HH24:MI:SS')\` for Oracle
+ Superset uses default expression based on DB URI if this
+ field is blank.
+ `)}
+
+ }
+ control={ }
+ />
+
+
+ }
+ columnLabels={{
+ column_name: 'Column',
+ type: 'Data Type',
+ groupby: 'Is Dimension',
+ is_dttm: 'Is Temporal',
+ filterable: 'Is Filterable',
+ }}
+ onChange={onChange}
+ itemRenderers={{
+ column_name: (v, onItemChange) => (
+ editableColumnName ?
+ :
+ v
+ ),
+ type: d => {d} ,
+ is_dttm: checkboxGenerator,
+ filterable: checkboxGenerator,
+ groupby: checkboxGenerator,
+ }}
+ />);
+}
+ColumnCollectionTable.propTypes = {
+ columns: PropTypes.array.isRequired,
+ onChange: PropTypes.func.isRequired,
+ editableColumnName: PropTypes.bool,
+ showExpression: PropTypes.bool,
+ allowAddItem: PropTypes.bool,
+ allowEditDataType: PropTypes.bool,
+ itemGenerator: PropTypes.func,
+};
+ColumnCollectionTable.defaultProps = {
+ editableColumnName: false,
+ showExpression: false,
+ allowAddItem: false,
+ allowEditDataType: false,
+ itemGenerator: () => ({
+ column_name: '',
+ filterable: true,
+ groupby: true,
+ }),
+};
+
+function StackedField({ label, formElement }) {
+ return (
+
+
{label}
+
{formElement}
+
+ );
+}
+StackedField.propTypes = {
+ label: PropTypes.string,
+ formElement: PropTypes.node,
+};
+
+function FormContainer({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+FormContainer.propTypes = {
+ children: PropTypes.node,
+};
+
+const propTypes = {
+ datasource: PropTypes.object.isRequired,
+ onChange: PropTypes.func,
+ addSuccessToast: PropTypes.func.isRequired,
+ addDangerToast: PropTypes.func.isRequired,
+};
+const defaultProps = {
+ onChange: () => {},
+};
+export class DatasourceEditor extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ datasource: props.datasource,
+ showAlert: true,
+ errors: [],
+ isDruid: props.datasource.type === 'druid',
+ isSqla: props.datasource.type === 'table',
+ databaseColumns: props.datasource.columns.filter(col => !col.expression),
+ calculatedColumns: props.datasource.columns.filter(col => !!col.expression),
+ metadataLoading: false,
+ activeTabKey: 1,
+ };
+
+ this.onChange = this.onChange.bind(this);
+ this.onDatasourcePropChange = this.onDatasourcePropChange.bind(this);
+ this.onDatasourceChange = this.onDatasourceChange.bind(this);
+ this.hideAlert = this.hideAlert.bind(this);
+ this.syncMetadata = this.syncMetadata.bind(this);
+ this.setColumns = this.setColumns.bind(this);
+ this.validateAndChange = this.validateAndChange.bind(this);
+ this.handleTabSelect = this.handleTabSelect.bind(this);
+ }
+ onChange() {
+ const datasource = {
+ ...this.state.datasource,
+ columns: [...this.state.databaseColumns, ...this.state.calculatedColumns],
+ };
+ this.props.onChange(datasource, this.state.errors);
+ }
+ onDatasourceChange(newDatasource) {
+ this.setState({ datasource: newDatasource }, this.validateAndChange);
+ }
+ onDatasourcePropChange(attr, value) {
+ const datasource = { ...this.state.datasource, [attr]: value };
+ this.setState({ datasource }, this.onDatasourceChange(datasource));
+ }
+ setColumns(obj) {
+ this.setState(obj, this.validateAndChange);
+ }
+ validateAndChange() {
+ this.validate(this.onChange);
+ }
+ mergeColumns(cols) {
+ let { databaseColumns } = this.state;
+ let hasChanged;
+ const currentColNames = databaseColumns.map(col => col.column_name);
+ cols.forEach((col) => {
+ if (currentColNames.indexOf(col.name) < 0) {
+ // Adding columns
+ databaseColumns = databaseColumns.concat([{
+ id: shortid.generate(),
+ column_name: col.name,
+ type: col.type,
+ groupby: true,
+ filterable: true,
+ }]);
+ hasChanged = true;
+ }
+ });
+ if (hasChanged) {
+ this.setColumns({ databaseColumns });
+ }
+ }
+ syncMetadata() {
+ const datasource = this.state.datasource;
+ const url = `/datasource/external_metadata/${datasource.type}/${datasource.id}/`;
+ this.setState({ metadataLoading: true });
+ const success = (data) => {
+ this.mergeColumns(data);
+ this.props.addSuccessToast(t('Metadata has been synced'));
+ this.setState({ metadataLoading: false });
+ };
+ const error = (err) => {
+ let msg = t('An error has occurred');
+ if (err.responseJSON && err.responseJSON.error) {
+ msg = err.responseJSON.error;
+ }
+ this.props.addDangerToast(msg);
+ this.setState({ metadataLoading: false });
+ };
+ $.ajax({
+ url,
+ type: 'GET',
+ success,
+ error,
+ });
+ }
+ findDuplicates(arr, accessor) {
+ const seen = {};
+ const dups = [];
+ arr.forEach((obj) => {
+ const item = accessor(obj);
+ if (item in seen) {
+ dups.push(item);
+ } else {
+ seen[item] = null;
+ }
+ });
+ return dups;
+ }
+ validate(callback) {
+ let errors = [];
+ let dups;
+ const datasource = this.state.datasource;
+
+ // Looking for duplicate column_name
+ dups = this.findDuplicates(datasource.columns, obj => obj.column_name);
+ errors = errors.concat(dups.map(name => t('Column name [%s] is duplicated', name)));
+
+ // Looking for duplicate metric_name
+ dups = this.findDuplicates(datasource.metrics, obj => obj.metric_name);
+ errors = errors.concat(dups.map(name => t('Metric name [%s] is duplicated', name)));
+
+ // Making sure calculatedColumns have an expression defined
+ const noFilterCalcCols = this.state.calculatedColumns.filter(
+ col => !col.expression && !col.json);
+ errors = errors.concat(noFilterCalcCols.map(
+ col => t('Calculated column [%s] requires an expression', col.column_name)));
+
+ this.setState({ errors }, callback);
+ }
+ hideAlert() {
+ this.setState({ showAlert: false });
+ }
+ handleTabSelect(activeTabKey) {
+ this.setState({ activeTabKey });
+ }
+ renderSettingsFieldset() {
+ const datasource = this.state.datasource;
+ return (
+
+ }
+ />
+ }
+ />
+ }
+ />
+ data.pks.map((pk, i) => ({
+ value: pk,
+ label: `${data.result[i].first_name} ${data.result[i].last_name}`,
+ }))}
+ />}
+ controlProps={{
+ }}
+ />
+
+ );
+ }
+ renderAdvancedFieldset() {
+ const datasource = this.state.datasource;
+ return (
+
+ { this.state.isSqla &&
+ }
+ />
+ }
+ { this.state.isDruid &&
+
+ {t('The JSON metric or post aggregation definition.')}
+
+ }
+ control={ }
+ />
+ }
+ }
+ />
+ }
+ />
+ );
+ }
+ renderSpatialTab() {
+ const spatials = this.state.datasource.spatials;
+ return (
+ }
+ eventKey={4}
+ >
+ ({
+ name: '',
+ type: '',
+ config: null,
+ })}
+ collection={spatials}
+ allowDeletes
+ itemRenderers={{
+ name: (d, onChange) => (
+ ),
+ config: (v, onChange) => (
+
+ ),
+ }}
+ />
+ );
+ }
+ renderErrors() {
+ if (this.state.errors.length > 0) {
+ return (
+
+ {this.state.errors.map(err => {err}
)}
+ );
+ }
+ return null;
+ }
+ renderMetricCollection() {
+ return (
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+ }
+ collection={this.state.datasource.metrics}
+ onChange={this.onDatasourcePropChange.bind(this, 'metrics')}
+ itemGenerator={() => ({
+ metric_name: '',
+ verbose_name: '',
+ expression: '',
+ })}
+ itemRenderers={{
+ metric_name: (v, onChange) => (
+ ),
+ verbose_name: (v, onChange) => (
+ ),
+ expression: (v, onChange) => (
+ ),
+ description: (v, onChange, label) => (
+ }
+ />),
+ d3format: (v, onChange, label) => (
+ }
+ />),
+ }}
+ allowDeletes
+ />);
+ }
+ render() {
+ const datasource = this.state.datasource;
+ return (
+
+ {this.renderErrors()}
+
+
+ {this.state.activeTabKey === 1 &&
+
+
+
+ {this.renderSettingsFieldset()}
+
+
+
+
+ {this.renderAdvancedFieldset()}
+
+
+
+ }
+
+
+ }
+ eventKey={2}
+ >
+ {this.state.activeTabKey === 2 &&
+
+ this.setColumns({ databaseColumns })}
+ />
+
+ {t('Sync columns from source')}
+
+ {this.state.metadataLoading && }
+
+ }
+
+ }
+ eventKey={3}
+ >
+ {this.state.activeTabKey === 3 &&
+ this.setColumns({ calculatedColumns })}
+ editableColumnName
+ showExpression
+ allowAddItem
+ allowEditDataType
+ itemGenerator={() => ({
+ column_name: '',
+ filterable: true,
+ groupby: true,
+ expression: '',
+ __expanded: true,
+ })}
+ />
+ }
+
+ }
+ eventKey={4}
+ >
+ {this.state.activeTabKey === 4 && this.renderMetricCollection()}
+
+
+
+ );
+ }
+}
+DatasourceEditor.defaultProps = defaultProps;
+DatasourceEditor.propTypes = propTypes;
+export default withToasts(DatasourceEditor);
diff --git a/superset/assets/src/datasource/DatasourceModal.jsx b/superset/assets/src/datasource/DatasourceModal.jsx
new file mode 100644
index 000000000..cef993bc6
--- /dev/null
+++ b/superset/assets/src/datasource/DatasourceModal.jsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import Dialog from 'react-bootstrap-dialog';
+
+import { t } from '../locales';
+import DatasourceEditor from '../datasource/DatasourceEditor';
+import withToasts from '../messageToasts/enhancers/withToasts';
+
+const $ = window.$ = require('jquery');
+
+const propTypes = {
+ onChange: PropTypes.func,
+ datasource: PropTypes.object,
+ show: PropTypes.bool.isRequired,
+ onHide: PropTypes.func,
+ onDatasourceSave: PropTypes.func,
+ addSuccessToast: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ onChange: () => {},
+ onHide: () => {},
+ onDatasourceSave: () => {},
+};
+
+class DatasourceModal extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ showEditDatasource: false,
+ filter: '',
+ loading: true,
+ errors: [],
+ showDatasource: false,
+ datasource: props.datasource,
+ };
+ this.toggleShowDatasource = this.toggleShowDatasource.bind(this);
+ this.changeSearch = this.changeSearch.bind(this);
+ this.setSearchRef = this.setSearchRef.bind(this);
+ this.onDatasourceChange = this.onDatasourceChange.bind(this);
+ this.onClickSave = this.onClickSave.bind(this);
+ this.onConfirmSave = this.onConfirmSave.bind(this);
+ }
+ onClickSave() {
+ this.dialog.show({
+ title: 'Confirm save',
+ bsSize: 'medium',
+ actions: [
+ Dialog.CancelAction(),
+ Dialog.OKAction(this.onConfirmSave),
+ ],
+ body: this.renderSaveDialog(),
+ });
+ }
+ onConfirmSave() {
+ const url = '/datasource/save/';
+ const that = this;
+ $.ajax({
+ url,
+ type: 'POST',
+ data: {
+ data: JSON.stringify(this.state.datasource),
+ },
+ success: (data) => {
+ this.props.addSuccessToast(t('The datasource has been saved'));
+ this.props.onDatasourceSave(data);
+ this.props.onHide();
+ },
+ error(err) {
+ let msg = t('An error has occurred');
+ if (err.responseJSON && err.responseJSON.error) {
+ msg = err.responseJSON.error;
+ }
+ that.dialog.show({
+ title: 'Error',
+ bsSize: 'medium',
+ bsStyle: 'danger',
+ actions: [
+ Dialog.DefaultAction('Ok', () => {}, 'btn-danger'),
+ ],
+ body: msg,
+ });
+ },
+ });
+ }
+ onDatasourceChange(datasource, errors) {
+ this.setState({ datasource, errors });
+ }
+ setSearchRef(searchRef) {
+ this.searchRef = searchRef;
+ }
+ toggleShowDatasource() {
+ this.setState({ showDatasource: !this.state.showDatasource });
+ }
+ changeSearch(event) {
+ this.setState({ filter: event.target.value });
+ }
+ renderSaveDialog() {
+ return (
+
+
+
+ {' '}
+ {t(`The data source configuration exposed here
+ affects all the charts using this datasource.
+ Be mindful that changing settings
+ here may affect other charts
+ in undesirable ways.`)}
+
+
+ {t('Are you sure you want to save and apply changes?')}
+
+ );
+ }
+ render() {
+ return (
+
+
+
+
+
+ {t('Datasource Editor for ')}
+ {this.props.datasource.name}
+
+
+
+
+
+ {this.props.show &&
+ }
+
+
+
+ 0}
+ >
+ {t('Save')}
+
+ {t('Cancel')}
+ { this.dialog = el; }} />
+
+
+ );
+ }
+}
+
+DatasourceModal.propTypes = propTypes;
+DatasourceModal.defaultProps = defaultProps;
+export default withToasts(DatasourceModal);
diff --git a/superset/assets/src/datasource/main.css b/superset/assets/src/datasource/main.css
new file mode 100644
index 000000000..69fd97a10
--- /dev/null
+++ b/superset/assets/src/datasource/main.css
@@ -0,0 +1,4 @@
+.Datasource .tab-content {
+ height: 600px;
+ overflow: auto;
+}
diff --git a/superset/assets/src/explore/actions/exploreActions.js b/superset/assets/src/explore/actions/exploreActions.js
index 81c23e3ba..b8a883ee7 100644
--- a/superset/assets/src/explore/actions/exploreActions.js
+++ b/superset/assets/src/explore/actions/exploreActions.js
@@ -1,6 +1,4 @@
/* eslint camelcase: 0 */
-import { triggerQuery } from '../../chart/chartAction';
-
const $ = window.$ = require('jquery');
const FAVESTAR_BASE_URL = '/superset/favstar/slice';
@@ -20,21 +18,12 @@ export function setDatasources(datasources) {
return { type: SET_DATASOURCES, datasources };
}
-export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
-export function fetchDatasourceStarted() {
- return { type: FETCH_DATASOURCE_STARTED };
-}
-
+export const POST_DATASOURCE_STARTED = 'POST_DATASOURCE_STARTED';
export const FETCH_DATASOURCE_SUCCEEDED = 'FETCH_DATASOURCE_SUCCEEDED';
export function fetchDatasourceSucceeded() {
return { type: FETCH_DATASOURCE_SUCCEEDED };
}
-export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED';
-export function fetchDatasourceFailed(error) {
- return { type: FETCH_DATASOURCE_FAILED, error };
-}
-
export const FETCH_DATASOURCES_STARTED = 'FETCH_DATASOURCES_STARTED';
export function fetchDatasourcesStarted() {
return { type: FETCH_DATASOURCES_STARTED };
@@ -45,9 +34,9 @@ export function fetchDatasourcesSucceeded() {
return { type: FETCH_DATASOURCES_SUCCEEDED };
}
-export const FETCH_DATASOURCES_FAILED = 'FETCH_DATASOURCES_FAILED';
-export function fetchDatasourcesFailed(error) {
- return { type: FETCH_DATASOURCES_FAILED, error };
+export const POST_DATASOURCES_FAILED = 'POST_DATASOURCES_FAILED';
+export function postDatasourcesFailed(error) {
+ return { type: POST_DATASOURCES_FAILED, error };
}
export const RESET_FIELDS = 'RESET_FIELDS';
@@ -55,28 +44,6 @@ export function resetControls() {
return { type: RESET_FIELDS };
}
-export function fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery = false) {
- return function (dispatch) {
- dispatch(fetchDatasourceStarted());
- const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`;
- $.ajax({
- type: 'GET',
- url,
- success: (data) => {
- dispatch(setDatasource(data));
- dispatch(fetchDatasourceSucceeded());
- dispatch(resetControls());
- if (alsoTriggerQuery) {
- dispatch(triggerQuery());
- }
- },
- error(error) {
- dispatch(fetchDatasourceFailed(error.responseJSON.error));
- },
- });
- };
-}
-
export function fetchDatasources() {
return function (dispatch) {
dispatch(fetchDatasourcesStarted());
diff --git a/superset/assets/src/explore/components/ControlPanelsContainer.jsx b/superset/assets/src/explore/components/ControlPanelsContainer.jsx
index ff9754b03..0eac0cf0c 100644
--- a/superset/assets/src/explore/components/ControlPanelsContainer.jsx
+++ b/superset/assets/src/explore/components/ControlPanelsContainer.jsx
@@ -44,7 +44,7 @@ class ControlPanelsContainer extends React.Component {
}
// Applying mapStateToProps if needed
if (mapF) {
- return Object.assign({}, control, mapF(this.props.exploreState, control));
+ return Object.assign({}, control, mapF(this.props.exploreState, control, this.props.actions));
}
return control;
}
diff --git a/superset/assets/src/explore/components/controls/CheckboxControl.jsx b/superset/assets/src/explore/components/controls/CheckboxControl.jsx
index a4138e483..4c15c374e 100644
--- a/superset/assets/src/explore/components/controls/CheckboxControl.jsx
+++ b/superset/assets/src/explore/components/controls/CheckboxControl.jsx
@@ -4,7 +4,7 @@ import ControlHeader from '../ControlHeader';
import Checkbox from '../../../components/Checkbox';
const propTypes = {
- name: PropTypes.string.isRequired,
+ name: PropTypes.string,
value: PropTypes.bool,
label: PropTypes.string,
description: PropTypes.string,
@@ -22,21 +22,25 @@ export default class CheckboxControl extends React.Component {
onChange() {
this.props.onChange(!this.props.value);
}
- render() {
+ renderCheckbox() {
return (
-
- }
- />
- );
+ );
+ }
+ render() {
+ if (this.props.label) {
+ return (
+
+ );
+ }
+ return this.renderCheckbox();
}
}
-
CheckboxControl.propTypes = propTypes;
CheckboxControl.defaultProps = defaultProps;
diff --git a/superset/assets/src/explore/components/controls/DatasourceControl.jsx b/superset/assets/src/explore/components/controls/DatasourceControl.jsx
index c8391f2bc..5b616f1a7 100644
--- a/superset/assets/src/explore/components/controls/DatasourceControl.jsx
+++ b/superset/assets/src/explore/components/controls/DatasourceControl.jsx
@@ -1,56 +1,49 @@
/* eslint no-undef: 2 */
import React from 'react';
import PropTypes from 'prop-types';
-import { Table } from 'reactable';
import {
- Row,
Col,
Collapse,
Label,
- FormControl,
- Modal,
OverlayTrigger,
+ Row,
Tooltip,
Well,
} from 'react-bootstrap';
import $ from 'jquery';
import ControlHeader from '../ControlHeader';
-import Loading from '../../../components/Loading';
import { t } from '../../../locales';
+import DatasourceModal from '../../../datasource/DatasourceModal';
import ColumnOption from '../../../components/ColumnOption';
import MetricOption from '../../../components/MetricOption';
import withToasts from '../../../messageToasts/enhancers/withToasts';
const propTypes = {
- description: PropTypes.string,
- label: PropTypes.string,
- name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string.isRequired,
- datasource: PropTypes.object,
addDangerToast: PropTypes.func.isRequired,
+ datasource: PropTypes.object.isRequired,
+ onDatasourceSave: PropTypes.func,
};
const defaultProps = {
onChange: () => {},
+ onDatasourceSave: () => {},
};
class DatasourceControl extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
- showModal: false,
+ showEditDatasourceModal: false,
filter: '',
loading: true,
showDatasource: false,
};
this.toggleShowDatasource = this.toggleShowDatasource.bind(this);
- this.onChange = this.onChange.bind(this);
- this.onEnterModal = this.onEnterModal.bind(this);
- this.toggleModal = this.toggleModal.bind(this);
- this.changeSearch = this.changeSearch.bind(this);
+ this.toggleEditDatasourceModal = this.toggleEditDatasourceModal.bind(this);
this.setSearchRef = this.setSearchRef.bind(this);
this.selectDatasource = this.selectDatasource.bind(this);
}
@@ -110,47 +103,10 @@ class DatasourceControl extends React.PureComponent {
this.setState({ showModal: false });
this.props.onChange(datasourceId);
}
+ toggleEditDatasourceModal() {
+ this.setState({ showEditDatasourceModal: !this.state.showEditDatasourceModal });
+ }
renderModal() {
- return (
-
-
- {t('Select a datasource')}
-
-
-
- {
- this.setSearchRef(ref);
- }}
- type="text"
- bsSize="sm"
- value={this.state.filter}
- placeholder={t('Search / Filter')}
- onChange={this.changeSearch}
- />
-
- {this.state.loading && }
- {this.state.datasources && (
-
- )}
-
-
- );
}
renderDatasource() {
const datasource = this.props.datasource;
@@ -195,27 +151,17 @@ class DatasourceControl extends React.PureComponent {
{t('Click to point to another datasource')}
}
>
-
+
{this.props.datasource.name}
- {t("Edit the datasource's configuration")}
+
+ {t('Expand/collapse datasource configuration')}
}
- >
-
-
-
-
- {t('Show datasource configuration')}
- }
>
}
{this.renderDatasource()}
- {this.renderModal()}
+
);
}
diff --git a/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx b/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx
index ef8c4172b..fc8fb3cc2 100644
--- a/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx
+++ b/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx
@@ -33,8 +33,15 @@ const defaultProps = {
const SelectAsyncControl = (props) => {
const { value, onChange, dataEndpoint, multi, mutator, placeholder, onAsyncErrorMessage } = props;
const onSelectionChange = (options) => {
- const optionValues = options.map(option => option.value);
- onChange(optionValues);
+ let val;
+ if (multi) {
+ val = options.map(option => option.value);
+ } else if (options) {
+ val = options.value;
+ } else {
+ val = null;
+ }
+ onChange(val);
};
return (
diff --git a/superset/assets/src/explore/components/controls/TextAreaControl.jsx b/superset/assets/src/explore/components/controls/TextAreaControl.jsx
index 11c77a2ca..4a500ea30 100644
--- a/superset/assets/src/explore/components/controls/TextAreaControl.jsx
+++ b/superset/assets/src/explore/components/controls/TextAreaControl.jsx
@@ -16,7 +16,7 @@ import ModalTrigger from '../../../components/ModalTrigger';
import { t } from '../../../locales';
const propTypes = {
- name: PropTypes.string.isRequired,
+ name: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.string,
height: PropTypes.number,
@@ -46,6 +46,7 @@ export default class TextAreaControl extends React.Component {
this.props.onChange(value);
}
renderEditor(inModal = false) {
+ const value = this.props.value || '';
if (this.props.language) {
return (
);
@@ -69,7 +70,7 @@ export default class TextAreaControl extends React.Component {
componentClass="textarea"
placeholder={t('textarea')}
onChange={this.onControlChange.bind(this)}
- value={this.props.value}
+ value={value}
disabled={this.props.readOnly}
style={{ height: this.props.height }}
/>
diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx
index 6405c4647..ddf077597 100644
--- a/superset/assets/src/explore/controls.jsx
+++ b/superset/assets/src/explore/controls.jsx
@@ -191,8 +191,9 @@ export const controls = {
label: t('Datasource'),
default: null,
description: null,
- mapStateToProps: state => ({
+ mapStateToProps: (state, control, actions) => ({
datasource: state.datasource,
+ onDatasourceSave: actions ? actions.setDatasource : () => {},
}),
},
diff --git a/superset/assets/src/explore/reducers/exploreReducer.js b/superset/assets/src/explore/reducers/exploreReducer.js
index a2c531e71..7d4c5d5e4 100644
--- a/superset/assets/src/explore/reducers/exploreReducer.js
+++ b/superset/assets/src/explore/reducers/exploreReducer.js
@@ -10,19 +10,19 @@ export default function exploreReducer(state = {}, action) {
isStarred: action.isStarred,
};
},
- [actions.FETCH_DATASOURCE_STARTED]() {
+ [actions.POST_DATASOURCE_STARTED]() {
return {
...state,
isDatasourceMetaLoading: true,
};
},
- [actions.FETCH_DATASOURCE_SUCCEEDED]() {
+ [actions.POST_DATASOURCE_SUCCEEDED]() {
return {
...state,
isDatasourceMetaLoading: false,
};
},
- [actions.FETCH_DATASOURCE_FAILED]() {
+ [actions.POST_DATASOURCE_FAILED]() {
return {
...state,
isDatasourceMetaLoading: false,
@@ -30,7 +30,6 @@ export default function exploreReducer(state = {}, action) {
};
},
[actions.SET_DATASOURCE]() {
-
return {
...state,
datasource: action.datasource,
diff --git a/superset/assets/src/profile/components/CreatedContent.jsx b/superset/assets/src/profile/components/CreatedContent.jsx
index 6b9445e3a..00d0f9d1e 100644
--- a/superset/assets/src/profile/components/CreatedContent.jsx
+++ b/superset/assets/src/profile/components/CreatedContent.jsx
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
-import TableLoader from './TableLoader';
+import TableLoader from '../../components/TableLoader';
import { t } from '../../locales';
const propTypes = {
diff --git a/superset/assets/src/profile/components/Favorites.jsx b/superset/assets/src/profile/components/Favorites.jsx
index d0a4a45b6..e0ef112d4 100644
--- a/superset/assets/src/profile/components/Favorites.jsx
+++ b/superset/assets/src/profile/components/Favorites.jsx
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
-import TableLoader from './TableLoader';
+import TableLoader from '../../components/TableLoader';
import { t } from '../../locales';
const propTypes = {
diff --git a/superset/assets/src/profile/components/RecentActivity.jsx b/superset/assets/src/profile/components/RecentActivity.jsx
index 14cf7a8dd..0a36fdaaa 100644
--- a/superset/assets/src/profile/components/RecentActivity.jsx
+++ b/superset/assets/src/profile/components/RecentActivity.jsx
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
-import TableLoader from './TableLoader';
+import TableLoader from '../../components/TableLoader';
const propTypes = {
user: PropTypes.object,
diff --git a/superset/assets/stylesheets/less/cosmo/variables.less b/superset/assets/stylesheets/less/cosmo/variables.less
index fb2abf696..bc6fef5cd 100644
--- a/superset/assets/stylesheets/less/cosmo/variables.less
+++ b/superset/assets/stylesheets/less/cosmo/variables.less
@@ -213,7 +213,7 @@
@input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
//** `.form-group` margin
-@form-group-margin-bottom: 15px;
+@form-group-margin-bottom: 10px;
@legend-color: @text-color;
@legend-border-color: #e5e5e5;
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index 0e8ffad46..566a4811d 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -469,3 +469,6 @@ g.annotation-container {
height: 30px;
padding-left: 10px;
}
+.align-right {
+ text-align: right;
+}
diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py
index 213f89597..bee7e1c32 100644
--- a/superset/connectors/base/models.py
+++ b/superset/connectors/base/models.py
@@ -50,6 +50,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
params = Column(String(1000))
perm = Column(String(1000))
+ sql = None
+ owner = None
+ update_from_object_fields = None
+
@declared_attr
def slices(self):
return relationship(
@@ -82,6 +86,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
def main_dttm_col(self):
return 'timestamp'
+ @property
+ def datasource_name(self):
+ raise NotImplementedError()
+
@property
def connection(self):
"""String representing the context of the Datasource"""
@@ -134,7 +142,7 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
def metrics_combo(self):
return sorted(
[
- (m.metric_name, m.verbose_name or m.metric_name)
+ (m.metric_name, m.verbose_name or m.metric_name or '')
for m in self.metrics],
key=lambda x: x[1])
@@ -174,22 +182,36 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
for o in self.columns
})
return {
- 'all_cols': utils.choicify(self.column_names),
+ # simple fields
+ 'id': self.id,
'column_formats': self.column_formats,
+ 'description': self.description,
'database': self.database.data, # pylint: disable=no-member
- 'edit_url': self.url,
+ 'default_endpoint': self.default_endpoint,
'filter_select': self.filter_select_enabled,
+ 'name': self.name,
+ 'datasource_name': self.datasource_name,
+ 'type': self.type,
+ 'schema': self.schema,
+ 'offset': self.offset,
+ 'cache_timeout': self.cache_timeout,
+ 'params': self.params,
+ 'perm': self.perm,
+
+ # sqla-specific
+ 'sql': self.sql,
+
+ # computed fields
+ 'all_cols': utils.choicify(self.column_names),
+ 'columns': [o.data for o in self.columns],
+ 'edit_url': self.url,
'filterable_cols': utils.choicify(self.filterable_column_names),
'gb_cols': utils.choicify(self.groupby_column_names),
- 'id': self.id,
- 'metrics_combo': self.metrics_combo,
- 'name': self.name,
- 'order_by_choices': order_by_choices,
- 'type': self.type,
'metrics': [o.data for o in self.metrics],
- 'columns': [o.data for o in self.columns],
+ 'metrics_combo': self.metrics_combo,
+ 'order_by_choices': order_by_choices,
+ 'owner': self.owner.id if self.owner else None,
'verbose_map': verbose_map,
- 'schema': self.schema,
'select_star': self.select_star,
}
@@ -222,6 +244,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
values = None
return values
+ def external_metadata(self):
+ """Returns column information from the external system"""
+ raise NotImplementedError()
+
def get_query_str(self, query_obj):
"""Returns a query as a string
@@ -253,6 +279,68 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
if col.column_name == column_name:
return col
+ def get_fk_many_from_list(
+ self, object_list, fkmany, fkmany_class, key_attr):
+ """Update ORM one-to-many list from object list
+
+ Used for syncing metrics and columns using the same code"""
+
+ object_dict = {o.get(key_attr): o for o in object_list}
+ object_keys = [o.get(key_attr) for o in object_list]
+
+ # delete fks that have been removed
+ fkmany = [o for o in fkmany if getattr(o, key_attr) in object_keys]
+
+ # sync existing fks
+ for fk in fkmany:
+ obj = object_dict.get(getattr(fk, key_attr))
+ for attr in fkmany_class.update_from_object_fields:
+ setattr(fk, attr, obj.get(attr))
+
+ # create new fks
+ new_fks = []
+ orm_keys = [getattr(o, key_attr) for o in fkmany]
+ for obj in object_list:
+ key = obj.get(key_attr)
+ if key not in orm_keys:
+ del obj['id']
+ orm_kwargs = {}
+ for k in obj:
+ if (
+ k in fkmany_class.update_from_object_fields and
+ k in obj
+ ):
+ orm_kwargs[k] = obj[k]
+ new_obj = fkmany_class(**orm_kwargs)
+ new_fks.append(new_obj)
+ fkmany += new_fks
+ return fkmany
+
+ def update_from_object(self, obj):
+ """Update datasource from a data structure
+
+ The UI's table editor crafts a complex data structure that
+ contains most of the datasource's properties as well as
+ an array of metrics and columns objects. This method
+ receives the object from the UI and syncs the datasource to
+ match it. Since the fields are different for the different
+ connectors, the implementation uses ``update_from_object_fields``
+ which can be defined for each connector and
+ defines which fields should be synced"""
+ for attr in self.update_from_object_fields:
+ setattr(self, attr, obj.get(attr))
+
+ self.user_id = obj.get('owner')
+
+ # Syncing metrics
+ metrics = self.get_fk_many_from_list(
+ obj.get('metrics'), self.metrics, self.metric_class, 'metric_name')
+ self.metrics = metrics
+
+ # Syncing columns
+ self.columns = self.get_fk_many_from_list(
+ obj.get('columns'), self.columns, self.column_class, 'column_name')
+
class BaseColumn(AuditMixinNullable, ImportMixin):
"""Interface for column"""
@@ -315,9 +403,11 @@ class BaseColumn(AuditMixinNullable, ImportMixin):
@property
def data(self):
attrs = (
- 'column_name', 'verbose_name', 'description', 'expression',
- 'filterable', 'groupby', 'is_dttm', 'type')
- return {s: getattr(self, s) for s in attrs}
+ 'id', 'column_name', 'verbose_name', 'description', 'expression',
+ 'filterable', 'groupby', 'is_dttm', 'type',
+ 'database_expression', 'python_date_format',
+ )
+ return {s: getattr(self, s) for s in attrs if hasattr(self, s)}
class BaseMetric(AuditMixinNullable, ImportMixin):
@@ -359,6 +449,6 @@ class BaseMetric(AuditMixinNullable, ImportMixin):
@property
def data(self):
attrs = (
- 'metric_name', 'verbose_name', 'description', 'expression',
- 'warning_text')
+ 'id', 'metric_name', 'verbose_name', 'description', 'expression',
+ 'warning_text', 'd3format')
return {s: getattr(self, s) for s in attrs}
diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py
index 0977c0c6b..0554dfab3 100644
--- a/superset/connectors/druid/models.py
+++ b/superset/connectors/druid/models.py
@@ -98,6 +98,7 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
export_fields = ('cluster_name', 'coordinator_host', 'coordinator_port',
'coordinator_endpoint', 'broker_host', 'broker_port',
'broker_endpoint', 'cache_timeout')
+ update_from_object_fields = export_fields
export_children = ['datasources']
def __repr__(self):
@@ -142,6 +143,11 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
self.coordinator_host, self.coordinator_port) + '/status'
return json.loads(requests.get(endpoint).text)['version']
+ @property
+ @utils.memoized
+ def druid_version(self):
+ return self.get_druid_version()
+
def refresh_datasources(
self,
datasource_name=None,
@@ -150,7 +156,6 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
"""Refresh metadata of all datasources in the cluster
If ``datasource_name`` is specified, only that datasource is updated
"""
- self.druid_version = self.get_druid_version()
ds_list = self.get_datasources()
blacklist = conf.get('DRUID_DATA_SOURCE_BLACKLIST', [])
ds_refresh = []
@@ -271,6 +276,7 @@ class DruidColumn(Model, BaseColumn):
'count_distinct', 'sum', 'avg', 'max', 'min', 'filterable',
'description', 'dimension_spec_json', 'verbose_name',
)
+ update_from_object_fields = export_fields
export_parent = 'datasource'
def __repr__(self):
@@ -413,8 +419,9 @@ class DruidMetric(Model, BaseMetric):
export_fields = (
'metric_name', 'verbose_name', 'metric_type', 'datasource_id',
- 'json', 'description', 'is_restricted', 'd3format',
+ 'json', 'description', 'is_restricted', 'd3format', 'warning_text',
)
+ update_from_object_fields = export_fields
export_parent = 'datasource'
@property
@@ -481,6 +488,7 @@ class DruidDatasource(Model, BaseDatasource):
'datasource_name', 'is_hidden', 'description', 'default_endpoint',
'cluster_name', 'offset', 'cache_timeout', 'params',
)
+ update_from_object_fields = export_fields
export_parent = 'cluster'
export_children = ['columns', 'metrics']
@@ -520,6 +528,9 @@ class DruidDatasource(Model, BaseDatasource):
'[{obj.cluster_name}].[{obj.datasource_name}]'
'(id:{obj.id})').format(obj=self)
+ def update_from_object(self, obj):
+ return NotImplementedError()
+
@property
def link(self):
name = escape(self.datasource_name)
@@ -1575,6 +1586,16 @@ class DruidDatasource(Model, BaseDatasource):
.all()
)
+ def external_metadata(self):
+ self.merge_flag = True
+ return [
+ {
+ 'name': k,
+ 'type': v.get('type'),
+ }
+ for k, v in self.latest_metadata().items()
+ ]
+
sa.event.listen(DruidDatasource, 'after_insert', set_perm)
sa.event.listen(DruidDatasource, 'after_update', set_perm)
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 4a4e024b8..e6549260d 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -93,6 +93,9 @@ class TableColumn(Model, BaseColumn):
'filterable', 'expression', 'description', 'python_date_format',
'database_expression',
)
+
+ update_from_object_fields = [
+ s for s in export_fields if s not in ('table_id',)]
export_parent = 'table'
@property
@@ -170,6 +173,7 @@ class TableColumn(Model, BaseColumn):
return s or "'{}'".format(dttm.strftime('%Y-%m-%d %H:%M:%S.%f'))
def get_metrics(self):
+ # TODO deprecate, this is not needed since MetricsControl
metrics = []
M = SqlMetric # noqa
quoted = self.column_name
@@ -221,7 +225,9 @@ class SqlMetric(Model, BaseMetric):
export_fields = (
'metric_name', 'verbose_name', 'metric_type', 'table_id', 'expression',
- 'description', 'is_restricted', 'd3format')
+ 'description', 'is_restricted', 'd3format', 'warning_text')
+ update_from_object_fields = list([
+ s for s in export_fields if s not in ('table_id', )])
export_parent = 'table'
@property
@@ -281,6 +287,8 @@ class SqlaTable(Model, BaseDatasource):
'table_name', 'main_dttm_col', 'description', 'default_endpoint',
'database_id', 'offset', 'cache_timeout', 'schema',
'sql', 'params', 'template_params')
+ update_from_object_fields = [
+ f for f in export_fields if f not in ('table_name', 'database_id')]
export_parent = 'database'
export_children = ['metrics', 'columns']
@@ -304,6 +312,10 @@ class SqlaTable(Model, BaseDatasource):
def description_markeddown(self):
return utils.markdown(self.description)
+ @property
+ def datasource_name(self):
+ return self.table_name
+
@property
def link(self):
name = escape(self.name)
@@ -363,6 +375,12 @@ class SqlaTable(Model, BaseDatasource):
def sql_url(self):
return self.database.sql_url + '?table_name=' + str(self.table_name)
+ def external_metadata(self):
+ cols = self.database.get_columns(self.table_name, schema=self.schema)
+ for col in cols:
+ col['type'] = '{}'.format(col['type'])
+ return cols
+
@property
def time_column_grains(self):
return {
diff --git a/superset/data/__init__.py b/superset/data/__init__.py
index a164111e6..6dfba43e5 100644
--- a/superset/data/__init__.py
+++ b/superset/data/__init__.py
@@ -296,7 +296,7 @@ def load_world_bank_health_n_pop():
"TUV", "IMY", "KNA", "ASM", "ADO", "AMA", "PLW",
],
"op": "not in"}],
- )),
+ )),
Slice(
slice_name="Rural Breakdown",
viz_type='sunburst',
diff --git a/superset/models/core.py b/superset/models/core.py
index a02be607b..51e11b18f 100644
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -645,6 +645,10 @@ class Database(Model, AuditMixinNullable, ImportMixin):
def unique_name(self):
return self.database_name
+ @property
+ def url_object(self):
+ return make_url(self.sqlalchemy_uri_decrypted)
+
@property
def backend(self):
url = make_url(self.sqlalchemy_uri_decrypted)
diff --git a/superset/views/__init__.py b/superset/views/__init__.py
index ab93a55ce..a95da8beb 100644
--- a/superset/views/__init__.py
+++ b/superset/views/__init__.py
@@ -3,3 +3,4 @@ from . import base # noqa
from . import core # noqa
from . import sql_lab # noqa
from . import annotations # noqa
+from . import datasource # noqa
diff --git a/superset/views/base.py b/superset/views/base.py
index 8bcdee44f..ec31d9b47 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -21,7 +21,8 @@ from flask_babel import gettext as __
from flask_babel import lazy_gettext as _
import yaml
-from superset import conf, security_manager, utils
+from superset import conf, db, security_manager, utils
+from superset.exceptions import SupersetSecurityException
from superset.translations.utils import get_language_pack
FRONTEND_CONF_KEYS = (
@@ -91,6 +92,13 @@ def get_user_roles():
class BaseSupersetView(BaseView):
+
+ def json_response(self, obj, status=200):
+ return Response(
+ json.dumps(obj, default=utils.json_int_dttm_ser),
+ status=status,
+ mimetype='application/json')
+
def common_bootsrap_payload(self):
"""Common data always sent to the client"""
messages = get_flashed_messages(with_categories=True)
@@ -268,3 +276,49 @@ class CsvResponse(Response):
Override Response to take into account csv encoding from config.py
"""
charset = conf.get('CSV_EXPORT').get('encoding', 'utf-8')
+
+
+def check_ownership(obj, raise_if_false=True):
+ """Meant to be used in `pre_update` hooks on models to enforce ownership
+
+ Admin have all access, and other users need to be referenced on either
+ the created_by field that comes with the ``AuditMixin``, or in a field
+ named ``owners`` which is expected to be a one-to-many with the User
+ model. It is meant to be used in the ModelView's pre_update hook in
+ which raising will abort the update.
+ """
+ if not obj:
+ return False
+
+ security_exception = SupersetSecurityException(
+ "You don't have the rights to alter [{}]".format(obj))
+
+ if g.user.is_anonymous():
+ if raise_if_false:
+ raise security_exception
+ return False
+ roles = [r.name for r in get_user_roles()]
+ if 'Admin' in roles:
+ return True
+ session = db.create_scoped_session()
+ orig_obj = session.query(obj.__class__).filter_by(id=obj.id).first()
+
+ # Making a list of owners that works across ORM models
+ owners = []
+ if hasattr(orig_obj, 'owners'):
+ owners += orig_obj.owners
+ if hasattr(orig_obj, 'owner'):
+ owners += [orig_obj.owner]
+ if hasattr(orig_obj, 'created_by'):
+ owners += [orig_obj.created_by]
+
+ owner_names = [o.username for o in owners]
+
+ if (
+ g.user and hasattr(g.user, 'username') and
+ g.user.username in owner_names):
+ return True
+ if raise_if_false:
+ raise security_exception
+ else:
+ return False
diff --git a/superset/views/core.py b/superset/views/core.py
index 14c546919..ab0f686d0 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -39,7 +39,7 @@ from superset import (
)
from superset.connectors.connector_registry import ConnectorRegistry
from superset.connectors.sqla.models import AnnotationDatasource, SqlaTable
-from superset.exceptions import SupersetException, SupersetSecurityException
+from superset.exceptions import SupersetException
from superset.forms import CsvToDatabaseForm
from superset.jinja_context import get_template_processor
from superset.legacy import cast_form_data, update_time_range
@@ -50,8 +50,10 @@ from superset.utils import (
merge_extra_filters, merge_request_params, QueryStatus,
)
from .base import (
- api, BaseSupersetView, CsvResponse, DeleteMixin,
- generate_download_headers, get_error_msg, get_user_roles,
+ api, BaseSupersetView,
+ check_ownership,
+ CsvResponse, DeleteMixin,
+ generate_download_headers, get_error_msg,
json_error_response, SupersetFilter, SupersetModelView, YamlExportMixin,
)
from .utils import bootstrap_user_data
@@ -92,48 +94,6 @@ def is_owner(obj, user):
return obj and user in obj.owners
-def check_ownership(obj, raise_if_false=True):
- """Meant to be used in `pre_update` hooks on models to enforce ownership
-
- Admin have all access, and other users need to be referenced on either
- the created_by field that comes with the ``AuditMixin``, or in a field
- named ``owners`` which is expected to be a one-to-many with the User
- model. It is meant to be used in the ModelView's pre_update hook in
- which raising will abort the update.
- """
- if not obj:
- return False
-
- security_exception = SupersetSecurityException(
- "You don't have the rights to alter [{}]".format(obj))
-
- if g.user.is_anonymous():
- if raise_if_false:
- raise security_exception
- return False
- roles = (r.name for r in get_user_roles())
- if 'Admin' in roles:
- return True
- session = db.create_scoped_session()
- orig_obj = session.query(obj.__class__).filter_by(id=obj.id).first()
- owner_names = (user.username for user in orig_obj.owners)
- if (
- hasattr(orig_obj, 'created_by') and
- orig_obj.created_by and
- orig_obj.created_by.username == g.user.username):
- return True
- if (
- hasattr(orig_obj, 'owners') and
- g.user and
- hasattr(g.user, 'username') and
- g.user.username in owner_names):
- return True
- if raise_if_false:
- raise security_exception
- else:
- return False
-
-
class SliceFilter(SupersetFilter):
def apply(self, query, func): # noqa
if self.has_all_datasource_access():
@@ -768,12 +728,6 @@ appbuilder.add_view_no_menu(R)
class Superset(BaseSupersetView):
"""The base views for Superset!"""
- def json_response(self, obj, status=200):
- return Response(
- json.dumps(obj, default=utils.json_int_dttm_ser),
- status=status,
- mimetype='application/json')
-
@has_access_api
@expose('/datasources/')
def datasources(self):
diff --git a/superset/views/datasource.py b/superset/views/datasource.py
new file mode 100644
index 000000000..e00bfbb78
--- /dev/null
+++ b/superset/views/datasource.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C,R,W
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+from __future__ import unicode_literals
+
+import json
+
+from flask import request
+from flask_appbuilder import expose
+from flask_babel import gettext as __
+
+from superset import appbuilder, db
+from superset.connectors.connector_registry import ConnectorRegistry
+from .base import BaseSupersetView, check_ownership, json_error_response
+
+
+class Datasource(BaseSupersetView):
+ """Datasource-related views"""
+ @expose('/save/', methods=['POST'])
+ def save(self):
+ datasource = json.loads(request.form.get('data'))
+ datasource_id = datasource.get('id')
+ datasource_type = datasource.get('type')
+ orm_datasource = ConnectorRegistry.get_datasource(
+ datasource_type, datasource_id, db.session)
+
+ if not check_ownership(orm_datasource, raise_if_false=False):
+ return json_error_response(
+ __(
+ 'You are not authorized to modify '
+ 'this data source configuration'),
+ status='401',
+ )
+ orm_datasource.update_from_object(datasource)
+ data = orm_datasource.data
+ db.session.commit()
+ return self.json_response(data)
+
+ @expose('/external_metadata///')
+ def external_metadata(self, datasource_type=None, datasource_id=None):
+ """Gets column info from the source system"""
+ orm_datasource = ConnectorRegistry.get_datasource(
+ datasource_type, datasource_id, db.session)
+ return self.json_response(orm_datasource.external_metadata())
+
+
+appbuilder.add_view_no_menu(Datasource)
diff --git a/tests/base_tests.py b/tests/base_tests.py
index a5af0a19c..35ac33500 100644
--- a/tests/base_tests.py
+++ b/tests/base_tests.py
@@ -112,8 +112,12 @@ class SupersetTestCase(unittest.TestCase):
session.commit()
def get_table(self, table_id):
- return db.session.query(SqlaTable).filter_by(
- id=table_id).first()
+ return (
+ db.session
+ .query(SqlaTable)
+ .filter_by(id=table_id)
+ .one()
+ )
def get_or_create(self, cls, criteria, session):
obj = session.query(cls).filter_by(**criteria).first()
@@ -137,8 +141,7 @@ class SupersetTestCase(unittest.TestCase):
return slc
def get_table_by_name(self, name):
- return db.session.query(SqlaTable).filter_by(
- table_name=name).first()
+ return db.session.query(SqlaTable).filter_by(table_name=name).one()
def get_druid_ds_by_name(self, name):
return db.session.query(DruidDatasource).filter_by(
@@ -215,50 +218,3 @@ class SupersetTestCase(unittest.TestCase):
if raise_on_error and 'error' in resp:
raise Exception('run_sql failed')
return resp
-
- def test_gamma_permissions(self):
- def assert_can_read(view_menu):
- self.assertIn(('can_show', view_menu), gamma_perm_set)
- self.assertIn(('can_list', view_menu), gamma_perm_set)
-
- def assert_can_write(view_menu):
- self.assertIn(('can_add', view_menu), gamma_perm_set)
- self.assertIn(('can_download', view_menu), gamma_perm_set)
- self.assertIn(('can_delete', view_menu), gamma_perm_set)
- self.assertIn(('can_edit', view_menu), gamma_perm_set)
-
- def assert_cannot_write(view_menu):
- self.assertNotIn(('can_add', view_menu), gamma_perm_set)
- self.assertNotIn(('can_download', view_menu), gamma_perm_set)
- self.assertNotIn(('can_delete', view_menu), gamma_perm_set)
- self.assertNotIn(('can_edit', view_menu), gamma_perm_set)
- self.assertNotIn(('can_save', view_menu), gamma_perm_set)
-
- def assert_can_all(view_menu):
- assert_can_read(view_menu)
- assert_can_write(view_menu)
-
- gamma_perm_set = set()
- for perm in security_manager.find_role('Gamma').permissions:
- gamma_perm_set.add((perm.permission.name, perm.view_menu.name))
-
- # check read only perms
- assert_can_read('TableModelView')
- assert_cannot_write('DruidColumnInlineView')
-
- # make sure that user can create slices and dashboards
- assert_can_all('SliceModelView')
- assert_can_all('DashboardModelView')
-
- self.assertIn(('can_add_slices', 'Superset'), gamma_perm_set)
- self.assertIn(('can_copy_dash', 'Superset'), gamma_perm_set)
- self.assertIn(('can_created_dashboards', 'Superset'), gamma_perm_set)
- self.assertIn(('can_created_slices', 'Superset'), gamma_perm_set)
- self.assertIn(('can_csv', 'Superset'), gamma_perm_set)
- self.assertIn(('can_dashboard', 'Superset'), gamma_perm_set)
- self.assertIn(('can_explore', 'Superset'), gamma_perm_set)
- self.assertIn(('can_explore_json', 'Superset'), gamma_perm_set)
- self.assertIn(('can_fave_dashboards', 'Superset'), gamma_perm_set)
- self.assertIn(('can_fave_slices', 'Superset'), gamma_perm_set)
- self.assertIn(('can_save_dash', 'Superset'), gamma_perm_set)
- self.assertIn(('can_slice', 'Superset'), gamma_perm_set)
diff --git a/tests/datasource_tests.py b/tests/datasource_tests.py
new file mode 100644
index 000000000..7da7d929f
--- /dev/null
+++ b/tests/datasource_tests.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+"""Unit tests for Superset"""
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+from __future__ import unicode_literals
+
+import json
+
+from .base_tests import SupersetTestCase
+from .fixtures.datasource import datasource_post
+
+
+class DatasourceTests(SupersetTestCase):
+
+ requires_examples = True
+
+ def __init__(self, *args, **kwargs):
+ super(DatasourceTests, self).__init__(*args, **kwargs)
+
+ def test_external_metadata(self):
+ self.login(username='admin')
+ tbl_id = self.get_table_by_name('birth_names').id
+ url = '/datasource/external_metadata/table/{}/'.format(tbl_id)
+ resp = self.get_json_resp(url)
+ col_names = {o.get('name') for o in resp}
+ self.assertEquals(
+ col_names,
+ {'sum_boys', 'num', 'gender', 'name', 'ds', 'state', 'sum_girls'},
+ )
+
+ def compare_lists(self, l1, l2, key):
+ l2_lookup = {o.get(key): o for o in l2}
+ for obj1 in l1:
+ obj2 = l2_lookup.get(obj1.get(key))
+ for k in obj1:
+ if k not in 'id' and obj1.get(k):
+ self.assertEquals(obj1.get(k), obj2.get(k))
+
+ def test_save(self):
+ self.login(username='admin')
+ tbl_id = self.get_table_by_name('birth_names').id
+ datasource_post['id'] = tbl_id
+ data = dict(data=json.dumps(datasource_post))
+ resp = self.get_json_resp('/datasource/save/', data)
+ for k in datasource_post:
+ if k == 'columns':
+ self.compare_lists(datasource_post[k], resp[k], 'column_name')
+ elif k == 'metrics':
+ self.compare_lists(datasource_post[k], resp[k], 'metric_name')
+ else:
+ self.assertEquals(resp[k], datasource_post[k])
diff --git a/tests/druid_tests.py b/tests/druid_tests.py
index 426fa9a86..00df0f8ea 100644
--- a/tests/druid_tests.py
+++ b/tests/druid_tests.py
@@ -516,6 +516,21 @@ class DruidTests(SupersetTestCase):
instance.timeseries.call_args[1]['granularity']['period'],
)
+ @patch('superset.connectors.druid.models.PyDruid')
+ def test_external_metadata(self, PyDruid):
+ self.login(username='admin')
+ self.login(username='admin')
+ cluster = self.get_cluster(PyDruid)
+ cluster.refresh_datasources()
+ datasource = cluster.datasources[0]
+ url = '/datasource/external_metadata/druid/{}/'.format(datasource.id)
+ resp = self.get_json_resp(url)
+ col_names = {o.get('name') for o in resp}
+ self.assertEquals(
+ col_names,
+ {'__time', 'dim1', 'dim2', 'metric1'},
+ )
+
if __name__ == '__main__':
unittest.main()
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/fixtures/datasource.py b/tests/fixtures/datasource.py
new file mode 100644
index 000000000..9f0e47ec6
--- /dev/null
+++ b/tests/fixtures/datasource.py
@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+"""Fixtures for test_datasource.py"""
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+from __future__ import unicode_literals
+
+datasource_post = {
+ 'id': None,
+ 'column_formats': {'ratio': '.2%'},
+ 'description': 'Adding a DESCRip',
+ 'default_endpoint': '',
+ 'filter_select': True,
+ 'name': 'birth_names',
+ 'datasource_name': 'birth_names',
+ 'type': 'table',
+ 'schema': '',
+ 'offset': 66,
+ 'cache_timeout': 55,
+ 'sql': '',
+ 'columns': [
+ {
+ 'id': 504,
+ 'column_name': 'ds',
+ 'verbose_name': '',
+ 'description': None,
+ 'expression': '',
+ 'filterable': True,
+ 'groupby': True,
+ 'is_dttm': True,
+ 'type': 'DATETIME',
+ 'database_expression': '',
+ },
+ {
+ 'id': 505,
+ 'column_name': 'gender',
+ 'verbose_name': None,
+ 'description': None,
+ 'expression': '',
+ 'filterable': True,
+ 'groupby': True,
+ 'is_dttm': False,
+ 'type': 'VARCHAR(16)',
+ 'database_expression': None,
+ },
+ {
+ 'id': 506,
+ 'column_name': 'name',
+ 'verbose_name': None,
+ 'description': None,
+ 'expression': None,
+ 'filterable': True,
+ 'groupby': True,
+ 'is_dttm': None,
+ 'type': 'VARCHAR(255)',
+ 'database_expression': None,
+ },
+ {
+ 'id': 508,
+ 'column_name': 'state',
+ 'verbose_name': None,
+ 'description': None,
+ 'expression': None,
+ 'filterable': True,
+ 'groupby': True,
+ 'is_dttm': None,
+ 'type': 'VARCHAR(10)',
+ 'database_expression': None,
+ },
+ {
+ 'id': 509,
+ 'column_name': 'sum_boys',
+ 'verbose_name': None,
+ 'description': None,
+ 'expression': None,
+ 'filterable': True,
+ 'groupby': True,
+ 'is_dttm': None,
+ 'type': 'BIGINT(20)',
+ 'database_expression': None,
+ },
+ {
+ 'id': 510,
+ 'column_name': 'sum_girls',
+ 'verbose_name': None,
+ 'description': None,
+ 'expression': '',
+ 'filterable': False,
+ 'groupby': False,
+ 'is_dttm': False,
+ 'type': 'BIGINT(20)',
+ 'database_expression': None,
+ },
+ {
+ 'id': 532,
+ 'column_name': 'num',
+ 'verbose_name': None,
+ 'description': None,
+ 'expression': None,
+ 'filterable': True,
+ 'groupby': True,
+ 'is_dttm': None,
+ 'type': 'BIGINT(20)',
+ 'database_expression': None,
+ },
+ {
+ 'id': 522,
+ 'column_name': 'num_california',
+ 'verbose_name': None,
+ 'description': None,
+ 'expression': "CASE WHEN state = 'CA' THEN num ELSE 0 END",
+ 'filterable': False,
+ 'groupby': False,
+ 'is_dttm': False,
+ 'type': 'NUMBER',
+ 'database_expression': None,
+ },
+ ],
+ 'metrics': [
+ {
+ 'id': 824,
+ 'metric_name': 'sum__num',
+ 'verbose_name': 'Babies',
+ 'description': '',
+ 'expression': 'SUM(num)',
+ 'warning_text': '',
+ 'd3format': '',
+ },
+ {
+ 'id': 836,
+ 'metric_name': 'count',
+ 'verbose_name': '',
+ 'description': None,
+ 'expression': 'count(1)',
+ 'warning_text': None,
+ 'd3format': None,
+ },
+ {
+ 'id': 843,
+ 'metric_name': 'ratio',
+ 'verbose_name': 'Ratio Boys/Girls',
+ 'description': 'This represents the ratio of boys/girls',
+ 'expression': 'sum(sum_boys) / sum(sum_girls)',
+ 'warning_text': 'no warning',
+ 'd3format': '.2%',
+ },
+ ],
+}
diff --git a/tests/security_tests.py b/tests/security_tests.py
index 4e1597bc8..fe7564ed1 100644
--- a/tests/security_tests.py
+++ b/tests/security_tests.py
@@ -144,7 +144,7 @@ class RolePermissionTests(SupersetTestCase):
self.assertTrue(security_manager.is_gamma_pvm(
security_manager.find_permission_view_menu('can_show', 'TableModelView')))
- def test_gamma_permissions(self):
+ def test_gamma_permissions_basic(self):
self.assert_can_gamma(get_perm_tuples('Gamma'))
self.assert_cannot_gamma(get_perm_tuples('Gamma'))
self.assert_cannot_alpha(get_perm_tuples('Alpha'))
@@ -175,3 +175,50 @@ class RolePermissionTests(SupersetTestCase):
self.assert_cannot_gamma(granter_set)
self.assert_cannot_alpha(granter_set)
+
+ def test_gamma_permissions(self):
+ def assert_can_read(view_menu):
+ self.assertIn(('can_show', view_menu), gamma_perm_set)
+ self.assertIn(('can_list', view_menu), gamma_perm_set)
+
+ def assert_can_write(view_menu):
+ self.assertIn(('can_add', view_menu), gamma_perm_set)
+ self.assertIn(('can_download', view_menu), gamma_perm_set)
+ self.assertIn(('can_delete', view_menu), gamma_perm_set)
+ self.assertIn(('can_edit', view_menu), gamma_perm_set)
+
+ def assert_cannot_write(view_menu):
+ self.assertNotIn(('can_add', view_menu), gamma_perm_set)
+ self.assertNotIn(('can_download', view_menu), gamma_perm_set)
+ self.assertNotIn(('can_delete', view_menu), gamma_perm_set)
+ self.assertNotIn(('can_edit', view_menu), gamma_perm_set)
+ self.assertNotIn(('can_save', view_menu), gamma_perm_set)
+
+ def assert_can_all(view_menu):
+ assert_can_read(view_menu)
+ assert_can_write(view_menu)
+
+ gamma_perm_set = set()
+ for perm in security_manager.find_role('Gamma').permissions:
+ gamma_perm_set.add((perm.permission.name, perm.view_menu.name))
+
+ # check read only perms
+ assert_can_read('TableModelView')
+ assert_cannot_write('DruidColumnInlineView')
+
+ # make sure that user can create slices and dashboards
+ assert_can_all('SliceModelView')
+ assert_can_all('DashboardModelView')
+
+ self.assertIn(('can_add_slices', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_copy_dash', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_created_dashboards', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_created_slices', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_csv', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_dashboard', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_explore', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_explore_json', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_fave_dashboards', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_fave_slices', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_save_dash', 'Superset'), gamma_perm_set)
+ self.assertIn(('can_slice', 'Superset'), gamma_perm_set)