This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 68ba63f  Implement a React-based table editor (#5186)
68ba63f is described below

commit 68ba63fcd9e426f2ff43a14fc259afef4a272e38
Author: Maxime Beauchemin <maximebeauche...@gmail.com>
AuthorDate: Mon Aug 6 15:30:13 2018 -0700

    Implement a React-based table editor (#5186)
    
    * A React table editor
    
    * addressing comments
    
    * Fix SelectAsyncControl error on clear
    
    * fix tests
    
    * more corrections
    
    * Removed <strong>
---
 .../dashboard => }/fixtures/mockDatasource.js      |   0
 .../spec/javascripts/CRUD/CollectionTable_spec.jsx |  34 ++
 .../dashboard/components/Dashboard_spec.jsx        |   2 +-
 .../components/gridComponents/Chart_spec.jsx       |   2 +-
 .../dashboard/fixtures/mockChartQueries.js         |   2 +-
 .../dashboard/fixtures/mockSliceEntities.js        |   2 +-
 .../javascripts/dashboard/fixtures/mockState.js    |   2 +-
 .../datasource/DatasourceEditor_spec.jsx           |  73 +++
 .../datasource/DatasourceModal_spec.jsx            |  59 +++
 .../explore/components/DatasourceControl_spec.jsx  |   4 +-
 .../javascripts/explore/exploreActions_spec.js     |  67 +--
 .../javascripts/profile/CreatedContent_spec.jsx    |   2 +-
 .../spec/javascripts/profile/Favorites_spec.jsx    |   2 +-
 .../javascripts/profile/RecentActivity_spec.jsx    |   2 +-
 .../javascripts/sqllab/SqlEditorLeftBar_spec.jsx   |   4 -
 superset/assets/src/CRUD/CollectionTable.jsx       | 220 ++++++++
 superset/assets/src/CRUD/Field.jsx                 |  62 +++
 superset/assets/src/CRUD/Fieldset.jsx              |  48 ++
 superset/assets/src/CRUD/styles.css                |  13 +
 superset/assets/src/CRUD/styles.less               |  14 +
 superset/assets/src/CRUD/utils.js                  |  20 +
 superset/assets/src/components/Checkbox.jsx        |   4 +-
 superset/assets/src/components/EditableTitle.jsx   |  37 +-
 .../src/{profile => }/components/TableLoader.jsx   |   4 +-
 .../assets/src/datasource/DatasourceEditor.jsx     | 575 +++++++++++++++++++++
 superset/assets/src/datasource/DatasourceModal.jsx | 161 ++++++
 superset/assets/src/datasource/main.css            |   4 +
 .../assets/src/explore/actions/exploreActions.js   |  41 +-
 .../explore/components/ControlPanelsContainer.jsx  |   2 +-
 .../components/controls/CheckboxControl.jsx        |  32 +-
 .../components/controls/DatasourceControl.jsx      |  87 +---
 .../components/controls/SelectAsyncControl.jsx     |  11 +-
 .../components/controls/TextAreaControl.jsx        |   7 +-
 superset/assets/src/explore/controls.jsx           |   3 +-
 .../assets/src/explore/reducers/exploreReducer.js  |   7 +-
 .../src/profile/components/CreatedContent.jsx      |   2 +-
 .../assets/src/profile/components/Favorites.jsx    |   2 +-
 .../src/profile/components/RecentActivity.jsx      |   2 +-
 .../assets/stylesheets/less/cosmo/variables.less   |   2 +-
 superset/assets/stylesheets/superset.less          |   3 +
 superset/connectors/base/models.py                 | 118 ++++-
 superset/connectors/druid/models.py                |  25 +-
 superset/connectors/sqla/models.py                 |  20 +-
 superset/data/__init__.py                          |   2 +-
 superset/models/core.py                            |   4 +
 superset/views/__init__.py                         |   1 +
 superset/views/base.py                             |  56 +-
 superset/views/core.py                             |  56 +-
 superset/views/datasource.py                       |  49 ++
 tests/base_tests.py                                |  58 +--
 tests/datasource_tests.py                          |  52 ++
 tests/druid_tests.py                               |  15 +
 tests/fixtures/__init__.py                         |   0
 tests/fixtures/datasource.py                       | 148 ++++++
 tests/security_tests.py                            |  49 +-
 55 files changed, 1918 insertions(+), 355 deletions(-)

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 0000000..6955688
--- /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 = <CollectionTable {...props} />;
+    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 e855009..aa82f01 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 dcd7119..dbd7054 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 b5004a1..81ecb20 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 03d8e30..c7a9c7a 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 514442f..32d8120 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 0000000..137ac39
--- /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 = <DatasourceEditor {...props} />;
+    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 0000000..3cbc92f
--- /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 = <DatasourceModal {...props} />;
+    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 68dc783..d03e30b 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 a87a45d..72aebbd 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 ac33d99..08457d9 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 c9315dc..252d99e 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 f3c200f..60240fc 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 16f1f8b..bc4957e 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 0000000..4520225
--- /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 => (
+  <div className="frame">
+    {props.children}
+  </div>);
+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 (
+      <thead>
+        <tr>
+          {this.props.expandFieldset && <th className="tiny-cell" />}
+          {cols.map(col => <th key={col}>{this.getLabel(col)}</th>)}
+          {this.props.allowDeletes && <th className="tiny-cell" />}
+        </tr>
+      </thead>
+    );
+  }
+  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(
+        <td key="__expand" className="expand">
+          <i
+            className={`fa fa-caret-${isExpanded ? 'down' : 'right'} 
text-primary pointer`}
+            onClick={this.toggleExpand.bind(this, record.id)}
+          />
+        </td>);
+    }
+    tds = tds.concat(tableColumns.map(col => (
+      <td key={col}>{this.renderCell(record, col)}</td>
+    )));
+    if (allowDeletes) {
+      tds.push(
+        <td key="__actions">
+          <i
+            className="fa fa-close text-primary pointer"
+            onClick={this.deleteItem.bind(this, record.id)}
+          />
+        </td>);
+    }
+    const trs = [<tr className="row" key={record.id}>{tds}</tr>];
+    if (isExpanded) {
+      trs.push(
+        <tr className="exp" key={'exp__' + record.id}>
+          <td colSpan={this.effectiveTableColumns().length} 
className="expanded">
+            <div>
+              {this.renderExpandableSection(record)}
+            </div>
+          </td>
+        </tr>);
+    }
+    return trs;
+  }
+  renderEmptyCell() {
+    return <tr><td 
className="empty-collection">{this.props.emptyMessage}</td></tr>;
+  }
+  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 <tbody>{content}</tbody>;
+  }
+  render() {
+    return (
+      <div className="CRUD">
+        <table
+          className="table"
+        >
+          {this.renderHeaderRow()}
+          {this.renderTableBody()}
+        </table>
+        <div>
+          {this.props.allowAddItem &&
+            <Button bsStyle="primary" onClick={this.onAddItem}>
+              <i className="fa fa-plus" /> Add Item
+            </Button>}
+          {this.props.extraButtons}
+        </div>
+      </div>
+    );
+  }
+}
+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 0000000..0fa0ad7
--- /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 (
+      <FormGroup
+        controlId={fieldKey}
+      >
+        <ControlLabel className="m-r-5">
+          {label || fieldKey}
+          {compact && descr &&
+            <OverlayTrigger
+              placement="right"
+              overlay={
+                <Tooltip id="field-descr" bsSize="lg">{descr}</Tooltip>
+                }
+            >
+              <i className="fa fa-info-circle m-l-5" />
+            </OverlayTrigger>
+          }
+        </ControlLabel>
+        {hookedControl}
+        <FormControl.Feedback />
+        {!compact && descr &&
+          <HelpBlock>{descr}</HelpBlock>
+        }
+      </FormGroup>
+    );
+  }
+}
+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 0000000..6c3c74e
--- /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 (
+      <Form componentClass="fieldset" className="CRUD">
+        {title &&
+          <legend>{title}</legend>
+        }
+        {recurseReactClone(this.props.children, Field, propExtender)}
+      </Form>
+    );
+  }
+}
+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 0000000..1f07c59
--- /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 0000000..ec632a6
--- /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 0000000..6de8c4b
--- /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 b0564ae..c3b2506 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 
}) {
     <span style={style}>
       <i
         className={`fa fa-check ${checked ? 'text-primary' : 
'text-transparent'}`}
-        onClick={onChange.bind(!checked)}
+        onClick={() => {
+          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 45fea1d..5e206c9 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: '<empty>',
 };
 
-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 = (
       <input
         required
         type={this.state.isEditing ? 'text' : 'button'}
-        value={this.state.title}
+        value={value}
+        className={!this.state.title ? 'text-muted' : null}
         onKeyUp={this.handleKeyUp}
         onChange={this.handleChange}
         onBlur={this.handleBlur}
@@ -128,14 +134,14 @@ class EditableTitle extends React.PureComponent {
         onKeyPress={this.handleKeyPress}
       />
     );
-    if (this.props.showTooltip) {
-      content = (
+    if (this.props.showTooltip && !this.state.isEditing) {
+      input = (
         <TooltipWrapper
           label="title"
-          tooltip={this.props.canEdit ? t('click to edit title') :
+          tooltip={this.props.canEdit ? t('click to edit') :
               this.props.noPermitTooltip || t('You don\'t have the rights to 
alter this title.')}
         >
-          {content}
+          {input}
         </TooltipWrapper>
       );
     }
@@ -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}
       </span>
     );
   }
 }
 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 462e009..3f51ee9 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 0000000..12dfdc0
--- /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) => <CheckboxControl value={d} 
onChange={onChange} />;
+const styleMonospace = { fontFamily: 'monospace' };
+const DATA_TYPES = ['STRING', 'NUMBER', 'DATETIME'];
+
+function CollectionTabTitle({ title, collection }) {
+  return (
+    <div>
+      {title} <Badge>{collection ? collection.length : 0}</Badge>
+    </div>
+  );
+}
+CollectionTabTitle.propTypes = {
+  title: PropTypes.string,
+  collection: PropTypes.array,
+};
+
+function ColumnCollectionTable({
+  columns, onChange, editableColumnName, showExpression, allowAddItem,
+  allowEditDataType, itemGenerator,
+}) {
+  return (
+    <CollectionTable
+      collection={columns}
+      tableColumns={['column_name', 'type', 'is_dttm', 'filterable', 
'groupby']}
+      allowDeletes
+      allowAddItem={allowAddItem}
+      itemGenerator={itemGenerator}
+      expandFieldset={
+        <FormContainer>
+          <Fieldset compact>
+            {showExpression &&
+              <Field
+                fieldKey="expression"
+                label="SQL Expression"
+                control={<TextControl />}
+              />}
+            <Field
+              fieldKey="verbose_name"
+              label={t('Label')}
+              control={<TextControl />}
+            />
+            {allowEditDataType &&
+              <Field
+                fieldKey="type"
+                label={t('Data Type')}
+                control={<SelectControl choices={DATA_TYPES} name="type" />}
+              />}
+            <Field
+              fieldKey="python_date_format"
+              label="Datetime Format"
+              descr={
+                <div>
+                  {t('The pattern of the timestamp format, use ')}
+                  <a 
href="https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior";>
+                    {t('python datetime string pattern')}
+                  </a>
+                  {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`)}
+                </div>
+              }
+              control={<TextControl />}
+            />
+            <Field
+              fieldKey="database_expression"
+              label="Database Expression"
+              descr={
+                <div>
+                  {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.
+                  `)}
+                </div>
+              }
+              control={<TextControl />}
+            />
+          </Fieldset>
+        </FormContainer>
+      }
+      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 ?
+            <EditableTitle canEdit title={v} onSaveTitle={onItemChange} /> :
+            v
+        ),
+        type: d => <Label style={{ fontSize: '75%' }}>{d}</Label>,
+        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: '<new column>',
+    filterable: true,
+    groupby: true,
+  }),
+};
+
+function StackedField({ label, formElement }) {
+  return (
+    <div>
+      <div><strong>{label}</strong></div>
+      <div>{formElement}</div>
+    </div>
+  );
+}
+StackedField.propTypes = {
+  label: PropTypes.string,
+  formElement: PropTypes.node,
+};
+
+function FormContainer({ children }) {
+  return (
+    <Well style={{ marginTop: 20 }}>
+      {children}
+    </Well>
+  );
+}
+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 (
+      <Fieldset title="Basic" item={datasource} 
onChange={this.onDatasourceChange}>
+        <Field
+          fieldKey="description"
+          label={t('Description')}
+          control={<TextAreaControl language="markdown" 
offerEditInModal={false} />}
+        />
+        <Field
+          fieldKey="default_endpoint"
+          label={t('Default URL')}
+          descr={t('Default URL to redirect to when accessing from the 
datasource list page')}
+          control={<TextControl />}
+        />
+        <Field
+          fieldKey="filter_select"
+          label={t('Autocomplete filters')}
+          descr={t('Whether to populate autocomplete filters options')}
+          control={<CheckboxControl />}
+        />
+        <Field
+          fieldKey="owner"
+          label={t('Owner')}
+          descr={t('Owner of the datasource')}
+          control={
+            <SelectAsyncControl
+              dataEndpoint="/users/api/read"
+              multi={false}
+              mutator={data => data.pks.map((pk, i) => ({
+                value: pk,
+                label: `${data.result[i].first_name} 
${data.result[i].last_name}`,
+              }))}
+            />}
+          controlProps={{
+          }}
+        />
+      </Fieldset>
+    );
+  }
+  renderAdvancedFieldset() {
+    const datasource = this.state.datasource;
+    return (
+      <Fieldset title="Advanced" item={datasource} 
onChange={this.onDatasourceChange}>
+        { this.state.isSqla &&
+          <Field
+            fieldKey="sql"
+            label={t('SQL')}
+            descr={t(
+              'When specifying SQL, the datasource acts as a view. ' +
+              'Superset will use this statement as a subquery while grouping 
and filtering ' +
+              'on the generated parent queries.')}
+            control={<TextAreaControl language="sql" offerEditInModal={false} 
/>}
+          />
+        }
+        { this.state.isDruid &&
+          <Field
+            fieldKey="json"
+            label={t('JSON')}
+            descr={
+              <div>
+                {t('The JSON metric or post aggregation definition.')}
+              </div>
+            }
+            control={<TextAreaControl language="json" offerEditInModal={false} 
/>}
+          />
+        }
+        <Field
+          fieldKey="cache_timeout"
+          label={t('Cache Timeout')}
+          descr={t('The duration of time in seconds before the cache is 
invalidated')}
+          control={<TextControl />}
+        />
+        <Field
+          fieldKey="offset"
+          label={t('Hours offset')}
+          control={<TextControl />}
+        />
+      </Fieldset>);
+  }
+  renderSpatialTab() {
+    const spatials = this.state.datasource.spatials;
+    return (
+      <Tab
+        title={<CollectionTabTitle collection={spatials} title={t('Spatial')} 
/>}
+        eventKey={4}
+      >
+        <CollectionTable
+          tableColumns={['name', 'config']}
+          onChange={this.onDatasourcePropChange.bind(this, 'spatials')}
+          itemGenerator={() => ({
+            name: '<new spatial>',
+            type: '<no type>',
+            config: null,
+          })}
+          collection={spatials}
+          allowDeletes
+          itemRenderers={{
+            name: (d, onChange) => (
+              <EditableTitle canEdit title={d} onSaveTitle={onChange} />),
+            config: (v, onChange) => (
+              <SpatialControl value={v} onChange={onChange} 
choices={datasource.all_cols} />
+            ),
+          }}
+        />
+      </Tab>);
+  }
+  renderErrors() {
+    if (this.state.errors.length > 0) {
+      return (
+        <Alert bsStyle="danger">
+          {this.state.errors.map(err => <div key={err}>{err}</div>)}
+        </Alert>);
+    }
+    return null;
+  }
+  renderMetricCollection() {
+    return (
+      <CollectionTable
+        tableColumns={['metric_name', 'verbose_name', 'expression']}
+        expandFieldset={
+          <FormContainer>
+            <Fieldset>
+              <Field
+                fieldKey="description"
+                label={t('Description')}
+                control={<TextControl />}
+              />
+              <Field
+                fieldKey="d3format"
+                label={t('D3 Format')}
+                control={<TextControl />}
+              />
+              <Field
+                label={t('Warning Message')}
+                fieldKey="warning_text"
+                description={t('Warning message to display in the metric 
selector')}
+                control={<TextControl />}
+              />
+            </Fieldset>
+          </FormContainer>
+        }
+        collection={this.state.datasource.metrics}
+        onChange={this.onDatasourcePropChange.bind(this, 'metrics')}
+        itemGenerator={() => ({
+          metric_name: '<new metric>',
+          verbose_name: '',
+          expression: '',
+        })}
+        itemRenderers={{
+          metric_name: (v, onChange) => (
+            <EditableTitle canEdit title={v} onSaveTitle={onChange} />),
+          verbose_name: (v, onChange) => (
+            <EditableTitle canEdit title={v} onSaveTitle={onChange} />),
+          expression: (v, onChange) => (
+            <EditableTitle
+              canEdit
+              title={v}
+              onSaveTitle={onChange}
+              style={styleMonospace}
+            />),
+          description: (v, onChange, label) => (
+            <StackedField
+              label={label}
+              formElement={<TextControl value={v} onChange={onChange} />}
+            />),
+          d3format: (v, onChange, label) => (
+            <StackedField
+              label={label}
+              formElement={<TextControl value={v} onChange={onChange} />}
+            />),
+        }}
+        allowDeletes
+      />);
+  }
+  render() {
+    const datasource = this.state.datasource;
+    return (
+      <div className="Datasource">
+        {this.renderErrors()}
+        <Tabs
+          id="table-tabs"
+          onSelect={this.handleTabSelect}
+          defaultActiveKey={1}
+        >
+          <Tab eventKey={1} title={t('Settings')}>
+            {this.state.activeTabKey === 1 &&
+              <div>
+                <Col md={6}>
+                  <FormContainer>
+                    {this.renderSettingsFieldset()}
+                  </FormContainer>
+                </Col>
+                <Col md={6}>
+                  <FormContainer>
+                    {this.renderAdvancedFieldset()}
+                  </FormContainer>
+                </Col>
+              </div>
+            }
+          </Tab>
+          <Tab
+            title={
+              <CollectionTabTitle collection={this.state.databaseColumns} 
title={t('Columns')} />
+            }
+            eventKey={2}
+          >
+            {this.state.activeTabKey === 2 &&
+              <div>
+                <ColumnCollectionTable
+                  columns={this.state.databaseColumns}
+                  onChange={databaseColumns => this.setColumns({ 
databaseColumns })}
+                />
+                <Button bsStyle="primary" onClick={this.syncMetadata} 
className="sync-from-source">
+                  {t('Sync columns from source')}
+                </Button>
+                {this.state.metadataLoading && <Loading />}
+              </div>
+            }
+          </Tab>
+          <Tab
+            title={
+              <CollectionTabTitle
+                collection={this.state.calculatedColumns}
+                title={t('Calculated Columns')}
+              />}
+            eventKey={3}
+          >
+            {this.state.activeTabKey === 3 &&
+              <ColumnCollectionTable
+                columns={this.state.calculatedColumns}
+                onChange={calculatedColumns => this.setColumns({ 
calculatedColumns })}
+                editableColumnName
+                showExpression
+                allowAddItem
+                allowEditDataType
+                itemGenerator={() => ({
+                  column_name: '<new column>',
+                  filterable: true,
+                  groupby: true,
+                  expression: '<enter SQL expression here>',
+                  __expanded: true,
+                })}
+              />
+            }
+          </Tab>
+          <Tab
+            title={<CollectionTabTitle collection={datasource.metrics} 
title={t('Metrics')} />}
+            eventKey={4}
+          >
+            {this.state.activeTabKey === 4 && this.renderMetricCollection()}
+          </Tab>
+        </Tabs>
+      </div>
+    );
+  }
+}
+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 0000000..cef993b
--- /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 (
+      <div>
+        <Alert bsStyle="warning" className="pointer" onClick={this.hideAlert}>
+          <div>
+            <i className="fa fa-exclamation-triangle" />{' '}
+            {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.`)}
+          </div>
+        </Alert>
+        {t('Are you sure you want to save and apply changes?')}
+      </div>
+    );
+  }
+  render() {
+    return (
+      <Modal
+        show={this.props.show}
+        onHide={this.props.onHide}
+        bsSize="lg"
+      >
+        <Modal.Header closeButton>
+          <Modal.Title>
+            <div>
+              <span className="float-left">
+                {t('Datasource Editor for ')}
+                <strong>{this.props.datasource.name}</strong>
+              </span>
+            </div>
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          {this.props.show &&
+            <DatasourceEditor
+              datasource={this.props.datasource}
+              onChange={this.onDatasourceChange}
+            />}
+        </Modal.Body>
+        <Modal.Footer>
+          <span className="float-right">
+            <Button
+              bsSize="sm"
+              bsStyle="primary"
+              className="m-r-5"
+              onClick={this.onClickSave}
+              disabled={this.state.errors.length > 0}
+            >
+              {t('Save')}
+            </Button>
+            <Button bsSize="sm" 
onClick={this.props.onHide}>{t('Cancel')}</Button>
+            <Dialog ref={(el) => { this.dialog = el; }} />
+          </span>
+        </Modal.Footer>
+      </Modal>);
+  }
+}
+
+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 0000000..69fd97a
--- /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 81c23e3..b8a883e 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 ff9754b..0eac0cf 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 a4138e4..4c15c37 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 (
-      <ControlHeader
-        {...this.props}
-        leftNode={
-          <Checkbox
-            onChange={this.onChange.bind(this)}
-            style={checkboxStyle}
-            checked={!!this.props.value}
-          />
-        }
-      />
-    );
+      <Checkbox
+        onChange={this.onChange.bind(this)}
+        style={checkboxStyle}
+        checked={!!this.props.value}
+      />);
+  }
+  render() {
+    if (this.props.label) {
+      return (
+        <ControlHeader
+          {...this.props}
+          leftNode={this.renderCheckbox()}
+        />
+      );
+    }
+    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 c8391f2..5b616f1 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 (
-      <Modal
-        show={this.state.showModal}
-        onHide={this.toggleModal}
-        onEnter={this.onEnterModal}
-        onExit={this.setSearchRef}
-        bsSize="lg"
-      >
-        <Modal.Header closeButton>
-          <Modal.Title>{t('Select a datasource')}</Modal.Title>
-        </Modal.Header>
-        <Modal.Body>
-          <div>
-            <FormControl
-              id="formControlsText"
-              inputRef={(ref) => {
-                this.setSearchRef(ref);
-              }}
-              type="text"
-              bsSize="sm"
-              value={this.state.filter}
-              placeholder={t('Search / Filter')}
-              onChange={this.changeSearch}
-            />
-          </div>
-          {this.state.loading && <Loading />}
-          {this.state.datasources && (
-            <Table
-              columns={['name', 'type', 'schema', 'connection', 'creator']}
-              className="table table-condensed"
-              data={this.state.datasources}
-              itemsPerPage={20}
-              filterable={['rawName', 'type', 'connection', 'schema', 
'creator']}
-              filterBy={this.state.filter}
-              hideFilterInput
-            />
-          )}
-        </Modal.Body>
-      </Modal>
-    );
   }
   renderDatasource() {
     const datasource = this.props.datasource;
@@ -195,28 +151,18 @@ class DatasourceControl extends React.PureComponent {
             <Tooltip id={'error-tooltip'}>{t('Click to point to another 
datasource')}</Tooltip>
           }
         >
-          <Label onClick={this.toggleModal} style={{ cursor: 'pointer' }} 
className="m-r-5">
+          <Label onClick={this.toggleEditDatasourceModal} style={{ cursor: 
'pointer' }} className="m-r-5">
             {this.props.datasource.name}
           </Label>
         </OverlayTrigger>
         <OverlayTrigger
           placement="right"
           overlay={
-            <Tooltip id={'edit-datasource-tooltip'}>
-              {t("Edit the datasource's configuration")}
+            <Tooltip id={'toggle-datasource-tooltip'}>
+              {t('Expand/collapse datasource configuration')}
             </Tooltip>
           }
         >
-          <a href={this.props.datasource.edit_url}>
-            <i className="fa fa-edit m-r-5" />
-          </a>
-        </OverlayTrigger>
-        <OverlayTrigger
-          placement="right"
-          overlay={
-            <Tooltip id={'toggle-datasource-tooltip'}>{t('Show datasource 
configuration')}</Tooltip>
-          }
-        >
           <a href="#">
             <i
               className={`fa fa-${this.state.showDatasource ? 'minus' : 
'plus'}-square m-r-5`}
@@ -238,7 +184,12 @@ class DatasourceControl extends React.PureComponent {
             </a>
           </OverlayTrigger>}
         <Collapse 
in={this.state.showDatasource}>{this.renderDatasource()}</Collapse>
-        {this.renderModal()}
+        <DatasourceModal
+          datasource={this.props.datasource}
+          show={this.state.showEditDatasourceModal}
+          onDatasourceSave={this.props.onDatasourceSave}
+          onHide={this.toggleEditDatasourceModal}
+        />
       </div>
     );
   }
diff --git 
a/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx 
b/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx
index ef8c417..fc8fb3c 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 11c77a2..4a500ea 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 (
         <AceEditor
@@ -58,7 +59,7 @@ export default class TextAreaControl extends React.Component {
           width="100%"
           editorProps={{ $blockScrolling: true }}
           enableLiveAutocompletion
-          value={this.props.value}
+          value={value}
           readOnly={this.props.readOnly}
         />
       );
@@ -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 6405c46..ddf0775 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 a2c531e..7d4c5d5 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 6b9445e..00d0f9d 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 d0a4a45..e0ef112 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 14cf7a8..0a36fda 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 fb2abf6..bc6fef5 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 0e8ffad..566a481 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 213f895..bee7e1c 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(
@@ -83,6 +87,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
         return 'timestamp'
 
     @property
+    def datasource_name(self):
+        raise NotImplementedError()
+
+    @property
     def connection(self):
         """String representing the context of the Datasource"""
         return None
@@ -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': [o.data for o in self.metrics],
             '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],
+            '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 0977c0c..0554dfa 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 4a4e024..e654926 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']
 
@@ -305,6 +313,10 @@ class SqlaTable(Model, BaseDatasource):
         return utils.markdown(self.description)
 
     @property
+    def datasource_name(self):
+        return self.table_name
+
+    @property
     def link(self):
         name = escape(self.name)
         anchor = '<a target="_blank" href="{self.explore_url}">{name}</a>'
@@ -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 a164111..6dfba43 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 a02be60..51e11b1 100644
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -646,6 +646,10 @@ class Database(Model, AuditMixinNullable, ImportMixin):
         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)
         return url.get_backend_name()
diff --git a/superset/views/__init__.py b/superset/views/__init__.py
index ab93a55..a95da8b 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 8bcdee4..ec31d9b 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 14c5469..ab0f686 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 0000000..e00bfbb
--- /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/<datasource_type>/<datasource_id>/')
+    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 a5af0a1..35ac335 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 0000000..7da7d92
--- /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 426fa9a..00df0f8 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 0000000..e69de29
diff --git a/tests/fixtures/datasource.py b/tests/fixtures/datasource.py
new file mode 100644
index 0000000..9f0e47e
--- /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 4e1597b..fe7564e 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)

Reply via email to