mistercrunch closed pull request #5186: Implement a React-based table editor
URL: https://github.com/apache/incubator-superset/pull/5186
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

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 0000000000..6955688851
--- /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 e855009592..aa82f01705 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 dcd711947a..dbd70540c4 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 b5004a1e18..81ecb20351 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 03d8e30571..c7a9c7a0bd 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 514442fc00..32d81209ae 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 0000000000..137ac3956f
--- /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 0000000000..3cbc92f9ec
--- /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 68dc783c4c..d03e30b0ab 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 a87a45d608..72aebbd551 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 ac33d99ea5..08457d9858 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 c9315dcd3c..252d99ef20 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 f3c200f7a8..60240fc31d 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 16f1f8bf36..bc4957e13f 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 0000000000..4520225740
--- /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 0000000000..0fa0ad7a73
--- /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 0000000000..6c3c74e9ba
--- /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 0000000000..1f07c59e01
--- /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 0000000000..ec632a60b9
--- /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 0000000000..6de8c4b165
--- /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 b0564ae6b8..c3b2506eee 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 45fea1dcb0..5e206c91e8 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 462e00993a..3f51ee92b1 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 0000000000..12dfdc0ceb
--- /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 0000000000..cef993bc68
--- /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 0000000000..69fd97a102
--- /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 81c23e3baf..b8a883ee71 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 ff9754b030..0eac0cf0cb 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 a4138e483e..4c15c374e7 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 c8391f2bcd..5b616f1a7f 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,27 +151,17 @@ 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
@@ -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 ef8c4172b7..fc8fb3cc25 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 11c77a2cad..4a500ea307 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 9afba58e0f..7e61cf9e58 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 a2c531e710..7d4c5d5e48 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 6b9445e3a1..00d0f9d1ea 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 d0a4a45b64..e0ef112d45 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 14cf7a8ddc..0a36fdaaa2 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 fb2abf696d..bc6fef5cde 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 0e8ffad469..566a4811d9 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 213f89597e..bee7e1c32d 100644
--- a/superset/connectors/base/models.py
+++ b/superset/connectors/base/models.py
@@ -50,6 +50,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
     params = Column(String(1000))
     perm = Column(String(1000))
 
+    sql = None
+    owner = None
+    update_from_object_fields = None
+
     @declared_attr
     def slices(self):
         return relationship(
@@ -82,6 +86,10 @@ def columns_types(self):
     def main_dttm_col(self):
         return 'timestamp'
 
+    @property
+    def datasource_name(self):
+        raise NotImplementedError()
+
     @property
     def connection(self):
         """String representing the context of the Datasource"""
@@ -134,7 +142,7 @@ def add_missing_metrics(self, metrics):
     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 @@ def data(self):
             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 @@ def handle_single_value(v):
                 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 @@ def get_column(self, column_name):
             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 @@ def expression(self):
     @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 @@ def expression(self):
     @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 0977c0c6b2..0554dfab31 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 @@ def get_druid_version(self):
             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 @@ def refresh_datasources(
         """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 @@ def get_perm(self):
             '[{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 @@ def query_datasources_by_name(
             .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 4a4e024b88..e6549260d0 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 @@ def dttm_sql_literal(self, dttm):
             return s or "'{}'".format(dttm.strftime('%Y-%m-%d %H:%M:%S.%f'))
 
     def get_metrics(self):
+        # TODO deprecate, this is not needed since MetricsControl
         metrics = []
         M = SqlMetric  # noqa
         quoted = self.column_name
@@ -221,7 +225,9 @@ class SqlMetric(Model, BaseMetric):
 
     export_fields = (
         'metric_name', 'verbose_name', 'metric_type', 'table_id', 'expression',
-        'description', 'is_restricted', 'd3format')
+        'description', 'is_restricted', 'd3format', 'warning_text')
+    update_from_object_fields = list([
+        s for s in export_fields if s not in ('table_id', )])
     export_parent = 'table'
 
     @property
@@ -281,6 +287,8 @@ class SqlaTable(Model, BaseDatasource):
         'table_name', 'main_dttm_col', 'description', 'default_endpoint',
         'database_id', 'offset', 'cache_timeout', 'schema',
         'sql', 'params', 'template_params')
+    update_from_object_fields = [
+        f for f in export_fields if f not in ('table_name', 'database_id')]
     export_parent = 'database'
     export_children = ['metrics', 'columns']
 
@@ -304,6 +312,10 @@ def connection(self):
     def description_markeddown(self):
         return utils.markdown(self.description)
 
+    @property
+    def datasource_name(self):
+        return self.table_name
+
     @property
     def link(self):
         name = escape(self.name)
@@ -363,6 +375,12 @@ def html(self):
     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 5b3f151cf8..a16348aeac 100644
--- a/superset/data/__init__.py
+++ b/superset/data/__init__.py
@@ -285,7 +285,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 a02be607b9..51e11b18f3 100644
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -645,6 +645,10 @@ def data(self):
     def unique_name(self):
         return self.database_name
 
+    @property
+    def url_object(self):
+        return make_url(self.sqlalchemy_uri_decrypted)
+
     @property
     def backend(self):
         url = make_url(self.sqlalchemy_uri_decrypted)
diff --git a/superset/views/__init__.py b/superset/views/__init__.py
index ab93a55ce7..a95da8bebb 100644
--- a/superset/views/__init__.py
+++ b/superset/views/__init__.py
@@ -3,3 +3,4 @@
 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 8bcdee44f9..ec31d9b473 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -21,7 +21,8 @@
 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 09904f71d6..07617f8c53 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -39,7 +39,7 @@
 )
 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 @@
     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 @@ def msg(self):
 
 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 0000000000..e00bfbb78a
--- /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 a5af0a19cb..35ac335009 100644
--- a/tests/base_tests.py
+++ b/tests/base_tests.py
@@ -112,8 +112,12 @@ def __init__(self, *args, **kwargs):
             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 @@ def get_slice(self, slice_name, session):
         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 @@ def run_sql(self, sql, client_id, user_name=None, 
raise_on_error=False):
         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 0000000000..7da7d929f5
--- /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 426fa9a863..00df0f8ea6 100644
--- a/tests/druid_tests.py
+++ b/tests/druid_tests.py
@@ -516,6 +516,21 @@ def test_druid_time_granularities(self, PyDruid):
                 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 0000000000..e69de29bb2
diff --git a/tests/fixtures/datasource.py b/tests/fixtures/datasource.py
new file mode 100644
index 0000000000..9f0e47ec62
--- /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 4e1597bc8d..fe7564ed14 100644
--- a/tests/security_tests.py
+++ b/tests/security_tests.py
@@ -144,7 +144,7 @@ def test_is_gamma_pvm(self):
         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 @@ def test_granter_permissions(self):
 
         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)


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
[email protected]


With regards,
Apache Git Services

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to