diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js b/superset/assets/spec/fixtures/mockDatasource.js similarity index 100% rename from superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js rename to superset/assets/spec/fixtures/mockDatasource.js diff --git a/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx b/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx new file mode 100644 index 000000000..695568885 --- /dev/null +++ b/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; + +import CollectionTable from '../../../src/CRUD/CollectionTable'; +import mockDatasource from '../../fixtures/mockDatasource'; + +const props = { + collection: mockDatasource['7__table'].columns, + tableColumns: ['column_name', 'type', 'groupby'], +}; + +describe('CollectionTable', () => { + + let wrapper; + let el; + + beforeEach(() => { + el = ; + wrapper = shallow(el); + }); + + it('is valid', () => { + expect(React.isValidElement(el)).to.equal(true); + }); + + it('renders a table', () => { + const length = mockDatasource['7__table'].columns.length; + expect(wrapper.find('table')).to.have.lengthOf(1); + expect(wrapper.find('tbody tr.row')).to.have.lengthOf(length); + }); + +}); diff --git a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx index e85500959..aa82f0170 100644 --- a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx @@ -9,7 +9,7 @@ import DashboardBuilder from '../../../../src/dashboard/containers/DashboardBuil // mock data import chartQueries, { sliceId as chartId } from '../fixtures/mockChartQueries'; -import datasources from '../fixtures/mockDatasource'; +import datasources from '../../../fixtures/mockDatasource'; import dashboardInfo from '../fixtures/mockDashboardInfo'; import { dashboardLayout } from '../fixtures/mockDashboardLayout'; import dashboardState from '../fixtures/mockDashboardState'; diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx index dcd711947..dbd70540c 100644 --- a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx @@ -8,7 +8,7 @@ import Chart from '../../../../../src/dashboard/components/gridComponents/Chart' import SliceHeader from '../../../../../src/dashboard/components/SliceHeader'; import ChartContainer from '../../../../../src/chart/ChartContainer'; -import mockDatasource from '../../fixtures/mockDatasource'; +import mockDatasource from '../../../../fixtures/mockDatasource'; import { sliceEntitiesForChart as sliceEntities, sliceId, diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js index b5004a1e1..81ecb2035 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js @@ -1,4 +1,4 @@ -import { datasourceId } from './mockDatasource'; +import { datasourceId } from '../../../fixtures/mockDatasource'; export const sliceId = 18; diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js index 03d8e3057..c7a9c7a0b 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js @@ -1,5 +1,5 @@ import { sliceId as id } from './mockChartQueries'; -import { datasourceId } from './mockDatasource'; +import { datasourceId } from '../../../fixtures/mockDatasource'; export const sliceId = id; diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js index 514442fc0..32d81209a 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js @@ -3,7 +3,7 @@ import { dashboardLayout } from './mockDashboardLayout'; import dashboardInfo from './mockDashboardInfo'; import dashboardState from './mockDashboardState'; import messageToasts from '../../messageToasts/mockMessageToasts'; -import datasources from './mockDatasource'; +import datasources from '../../../fixtures/mockDatasource'; import sliceEntities from './mockSliceEntities'; export default { diff --git a/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx b/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx new file mode 100644 index 000000000..137ac3956 --- /dev/null +++ b/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Tabs } from 'react-bootstrap'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; +import configureStore from 'redux-mock-store'; +import $ from 'jquery'; +import sinon from 'sinon'; + +import DatasourceEditor from '../../../src/datasource/DatasourceEditor'; +import mockDatasource from '../../fixtures/mockDatasource'; + +const props = { + datasource: mockDatasource['7__table'], + addSuccessToast: () => {}, + addDangerToast: () => {}, + onChange: sinon.spy(), +}; +const extraColumn = { + column_name: 'new_column', + type: 'VARCHAR(10)', + description: null, + filterable: true, + verbose_name: null, + is_dttm: false, + expression: '', + groupby: true, +}; + +describe('DatasourceEditor', () => { + const mockStore = configureStore([]); + const store = mockStore({}); + + let wrapper; + let el; + let ajaxStub; + let inst; + + beforeEach(() => { + ajaxStub = sinon.stub($, 'ajax'); + el = ; + wrapper = shallow(el, { context: { store } }).dive(); + inst = wrapper.instance(); + }); + + afterEach(() => { + ajaxStub.restore(); + }); + + it('is valid', () => { + expect(React.isValidElement(el)).to.equal(true); + }); + + it('renders Tabs', () => { + expect(wrapper.find(Tabs)).to.have.lengthOf(1); + }); + + it('makes an async request', () => { + wrapper.setState({ activeTabKey: 2 }); + const syncButton = wrapper.find('.sync-from-source'); + expect(syncButton).to.have.lengthOf(1); + syncButton.simulate('click'); + expect(ajaxStub.calledOnce).to.equal(true); + }); + + it('merges columns', () => { + const numCols = props.datasource.columns.length; + expect(inst.state.databaseColumns.length).to.equal(numCols); + inst.mergeColumns([extraColumn]); + expect(inst.state.databaseColumns.length).to.equal(numCols + 1); + }); + +}); diff --git a/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx b/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx new file mode 100644 index 000000000..3cbc92f9e --- /dev/null +++ b/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import configureStore from 'redux-mock-store'; +import { shallow } from 'enzyme'; +import $ from 'jquery'; +import sinon from 'sinon'; + +import DatasourceModal from '../../../src/datasource/DatasourceModal'; +import DatasourceEditor from '../../../src/datasource/DatasourceEditor'; +import mockDatasource from '../../fixtures/mockDatasource'; + +const props = { + datasource: mockDatasource['7__table'], + addSuccessToast: () => {}, + addDangerToast: () => {}, + onChange: sinon.spy(), + show: true, + onHide: () => {}, +}; + +describe('DatasourceModal', () => { + const mockStore = configureStore([]); + const store = mockStore({}); + + let wrapper; + let el; + let ajaxStub; + let inst; + + beforeEach(() => { + ajaxStub = sinon.stub($, 'ajax'); + el = ; + wrapper = shallow(el, { context: { store } }).dive(); + inst = wrapper.instance(); + }); + + afterEach(() => { + ajaxStub.restore(); + }); + + it('is valid', () => { + expect(React.isValidElement(el)).to.equal(true); + }); + + it('renders a Modal', () => { + expect(wrapper.find(Modal)).to.have.lengthOf(1); + }); + + it('renders a DatasourceEditor', () => { + expect(wrapper.find(DatasourceEditor)).to.have.lengthOf(1); + }); + + it('saves on confirm', () => { + inst.onConfirmSave(); + expect(ajaxStub.calledOnce).to.equal(true); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx index 68dc783c4..d03e30b0a 100644 --- a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx +++ b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx @@ -4,7 +4,7 @@ import configureStore from 'redux-mock-store'; import { expect } from 'chai'; import { describe, it } from 'mocha'; import { shallow } from 'enzyme'; -import { Modal } from 'react-bootstrap'; +import DatasourceModal from '../../../../src/datasource/DatasourceModal'; import DatasourceControl from '../../../../src/explore/components/controls/DatasourceControl'; const defaultProps = { @@ -35,6 +35,6 @@ describe('DatasourceControl', () => { it('renders a Modal', () => { const wrapper = setup(); - expect(wrapper.find(Modal)).to.have.lengthOf(1); + expect(wrapper.find(DatasourceModal)).to.have.lengthOf(1); }); }); diff --git a/superset/assets/spec/javascripts/explore/exploreActions_spec.js b/superset/assets/spec/javascripts/explore/exploreActions_spec.js index a87a45d60..72aebbd55 100644 --- a/superset/assets/spec/javascripts/explore/exploreActions_spec.js +++ b/superset/assets/spec/javascripts/explore/exploreActions_spec.js @@ -1,12 +1,9 @@ /* eslint-disable no-unused-expressions */ import { it, describe } from 'mocha'; import { expect } from 'chai'; -import sinon from 'sinon'; -import $ from 'jquery'; -import * as chartActions from '../../../src/chart/chartAction'; -import * as actions from '../../../src/explore/actions/exploreActions'; import { defaultState } from '../../../src/explore/store'; import exploreReducer from '../../../src/explore/reducers/exploreReducer'; +import * as actions from '../../../src/explore/actions/exploreActions'; describe('reducers', () => { it('sets correct control value given a key and value', () => { @@ -20,65 +17,3 @@ describe('reducers', () => { expect(newState.controls.show_legend.value).to.equal(true); }); }); - -describe('fetching actions', () => { - let dispatch; - let request; - let ajaxStub; - - beforeEach(() => { - dispatch = sinon.spy(); - ajaxStub = sinon.stub($, 'ajax'); - }); - afterEach(() => { - ajaxStub.restore(); - }); - - describe('fetchDatasourceMetadata', () => { - const datasourceKey = '1__table'; - - const makeRequest = (alsoTriggerQuery = false) => { - request = actions.fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery); - request(dispatch); - }; - - it('calls fetchDatasourceStarted', () => { - makeRequest(); - expect(dispatch.args[0][0].type).to.equal(actions.FETCH_DATASOURCE_STARTED); - }); - - it('makes the ajax request', () => { - makeRequest(); - expect(ajaxStub.calledOnce).to.be.true; - }); - - it('calls correct url', () => { - const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`; - makeRequest(); - expect(ajaxStub.getCall(0).args[0].url).to.equal(url); - }); - - it('calls correct actions on error', () => { - ajaxStub.yieldsTo('error', { responseJSON: { error: 'error text' } }); - makeRequest(); - expect(dispatch.callCount).to.equal(2); - expect(dispatch.getCall(1).args[0].type).to.equal(actions.FETCH_DATASOURCE_FAILED); - }); - - it('calls correct actions on success', () => { - ajaxStub.yieldsTo('success', { data: '' }); - makeRequest(); - expect(dispatch.callCount).to.equal(4); - expect(dispatch.getCall(1).args[0].type).to.equal(actions.SET_DATASOURCE); - expect(dispatch.getCall(2).args[0].type).to.equal(actions.FETCH_DATASOURCE_SUCCEEDED); - expect(dispatch.getCall(3).args[0].type).to.equal(actions.RESET_FIELDS); - }); - - it('triggers query if flag is set', () => { - ajaxStub.yieldsTo('success', { data: '' }); - makeRequest(true); - expect(dispatch.callCount).to.equal(5); - expect(dispatch.getCall(4).args[0].type).to.equal(chartActions.TRIGGER_QUERY); - }); - }); -}); diff --git a/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx b/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx index ac33d99ea..08457d985 100644 --- a/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx +++ b/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx @@ -4,7 +4,7 @@ import { describe, it } from 'mocha'; import { expect } from 'chai'; import { user } from './fixtures'; import CreatedContent from '../../../src/profile/components/CreatedContent'; -import TableLoader from '../../../src/profile/components/TableLoader'; +import TableLoader from '../../../src/components/TableLoader'; describe('CreatedContent', () => { diff --git a/superset/assets/spec/javascripts/profile/Favorites_spec.jsx b/superset/assets/spec/javascripts/profile/Favorites_spec.jsx index c9315dcd3..252d99ef2 100644 --- a/superset/assets/spec/javascripts/profile/Favorites_spec.jsx +++ b/superset/assets/spec/javascripts/profile/Favorites_spec.jsx @@ -5,7 +5,7 @@ import { expect } from 'chai'; import { user } from './fixtures'; import Favorites from '../../../src/profile/components/Favorites'; -import TableLoader from '../../../src/profile/components/TableLoader'; +import TableLoader from '../../../src/components/TableLoader'; describe('Favorites', () => { const mockedProps = { diff --git a/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx b/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx index f3c200f7a..60240fc31 100644 --- a/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx +++ b/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx @@ -5,7 +5,7 @@ import { expect } from 'chai'; import { user } from './fixtures'; import RecentActivity from '../../../src/profile/components/RecentActivity'; -import TableLoader from '../../../src/profile/components/TableLoader'; +import TableLoader from '../../../src/components/TableLoader'; describe('RecentActivity', () => { diff --git a/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx b/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx index 16f1f8bf3..bc4957e13 100644 --- a/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx +++ b/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx @@ -9,10 +9,6 @@ import { table, defaultQueryEditor, databases, tables } from './fixtures'; import SqlEditorLeftBar from '../../../src/SqlLab/components/SqlEditorLeftBar'; import TableElement from '../../../src/SqlLab/components/TableElement'; -global.notify = { - error: () => {}, -}; - describe('SqlEditorLeftBar', () => { const mockedProps = { actions: { diff --git a/superset/assets/src/CRUD/CollectionTable.jsx b/superset/assets/src/CRUD/CollectionTable.jsx new file mode 100644 index 000000000..452022574 --- /dev/null +++ b/superset/assets/src/CRUD/CollectionTable.jsx @@ -0,0 +1,220 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import shortid from 'shortid'; + +import Button from '../components/Button'; +import Fieldset from './Fieldset'; +import { recurseReactClone } from './utils'; +import './styles.css'; + +const propTypes = { + collection: PropTypes.array, + itemGenerator: PropTypes.func, + columnLabels: PropTypes.object, + tableColumns: PropTypes.array, + columns: PropTypes.array, + onChange: PropTypes.func, + itemRenderers: PropTypes.object, + allowDeletes: PropTypes.bool, + expandFieldset: PropTypes.node, + emptyMessage: PropTypes.node, + extraButtons: PropTypes.node, + allowAddItem: PropTypes.bool, +}; +const defaultProps = { + onChange: () => {}, + itemRenderers: {}, + columnLabels: {}, + allowDeletes: false, + emptyMessage: 'No entries', + allowAddItem: false, + itemGenerator: () => ({}), +}; +const Frame = props => ( +
+ {props.children} +
); +Frame.propTypes = { children: PropTypes.node }; + +function createKeyedCollection(arr) { + const newArr = arr.map(o => ({ + ...o, + id: o.id || shortid.generate(), + })); + const map = {}; + newArr.forEach((o) => { + map[o.id] = o; + }); + return map; +} + +export default class CRUDCollection extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + expandedColumns: {}, + collection: createKeyedCollection(props.collection), + }; + this.renderItem = this.renderItem.bind(this); + this.onAddItem = this.onAddItem.bind(this); + this.renderExpandableSection = this.renderExpandableSection.bind(this); + this.getLabel = this.getLabel.bind(this); + this.onFieldsetChange = this.onFieldsetChange.bind(this); + this.renderTableBody = this.renderTableBody.bind(this); + this.changeCollection = this.changeCollection.bind(this); + } + componentWillReceiveProps(nextProps) { + if (nextProps.collection !== this.props.collection) { + this.setState({ + collection: createKeyedCollection(nextProps.collection), + }); + } + } + onCellChange(id, col, val) { + this.changeCollection({ + ...this.state.collection, + [id]: { + ...this.state.collection[id], + [col]: val, + }, + }); + + } + onAddItem() { + let newItem = this.props.itemGenerator(); + if (!newItem.id) { + newItem = { ...newItem, id: shortid.generate() }; + } + this.changeCollection({ + ...this.state.collection, + [newItem.id]: newItem, + }); + } + onFieldsetChange(item) { + this.changeCollection({ + ...this.state.collection, + [item.id]: item, + }); + } + getLabel(col) { + const { columnLabels } = this.props; + let label = columnLabels[col] ? columnLabels[col] : col; + if (label.startsWith('__')) { + label = ''; + } + return label; + } + changeCollection(collection) { + this.setState({ collection }); + this.props.onChange(Object.keys(collection).map(k => collection[k])); + } + deleteItem(id) { + const newColl = { ...this.state.collection }; + delete newColl[id]; + this.changeCollection(newColl); + } + effectiveTableColumns() { + const { tableColumns, allowDeletes, expandFieldset } = this.props; + const cols = allowDeletes ? tableColumns.concat(['__actions']) : tableColumns; + return expandFieldset ? ['__expand'].concat(cols) : cols; + } + toggleExpand(id) { + this.onCellChange(id, '__expanded', false); + this.setState({ + expandedColumns: { + ...this.state.expandedColumns, + [id]: !this.state.expandedColumns[id], + }, + }); + } + renderHeaderRow() { + const cols = this.effectiveTableColumns(); + return ( + + + {this.props.expandFieldset && } + {cols.map(col => {this.getLabel(col)})} + {this.props.allowDeletes && } + + + ); + } + renderExpandableSection(item) { + const propsGenerator = () => ({ item, onChange: this.onFieldsetChange }); + return recurseReactClone(this.props.expandFieldset, Fieldset, propsGenerator); + } + renderCell(record, col) { + const renderer = this.props.itemRenderers[col]; + const val = record[col]; + const onChange = this.onCellChange.bind(this, record.id, col); + return renderer ? renderer(val, onChange, this.getLabel(col)) : val; + } + renderItem(record) { + const { tableColumns, allowDeletes, expandFieldset } = this.props; + /* eslint-disable no-underscore-dangle */ + const isExpanded = !!this.state.expandedColumns[record.id] || record.__expanded; + let tds = []; + if (expandFieldset) { + tds.push( + + + ); + } + tds = tds.concat(tableColumns.map(col => ( + {this.renderCell(record, col)} + ))); + if (allowDeletes) { + tds.push( + + + ); + } + const trs = [{tds}]; + if (isExpanded) { + trs.push( + + +
+ {this.renderExpandableSection(record)} +
+ + ); + } + return trs; + } + renderEmptyCell() { + return {this.props.emptyMessage}; + } + renderTableBody() { + const data = Object.keys(this.state.collection).map(k => this.state.collection[k]); + const content = data.length ? data.map(d => this.renderItem(d)) : this.renderEmptyCell(); + return {content}; + } + render() { + return ( +
+ + {this.renderHeaderRow()} + {this.renderTableBody()} +
+
+ {this.props.allowAddItem && + } + {this.props.extraButtons} +
+
+ ); + } +} +CRUDCollection.defaultProps = defaultProps; +CRUDCollection.propTypes = propTypes; diff --git a/superset/assets/src/CRUD/Field.jsx b/superset/assets/src/CRUD/Field.jsx new file mode 100644 index 000000000..0fa0ad7a7 --- /dev/null +++ b/superset/assets/src/CRUD/Field.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + FormGroup, ControlLabel, HelpBlock, FormControl, OverlayTrigger, Tooltip, +} from 'react-bootstrap'; + +import './styles.less'; + +const propTypes = { + value: PropTypes.any, + label: PropTypes.string, + descr: PropTypes.node, + fieldKey: PropTypes.string.isRequired, + control: PropTypes.node.isRequired, + onChange: PropTypes.func, + compact: PropTypes.bool, +}; +const defaultProps = { + controlProps: {}, + onChange: () => {}, + compact: false, +}; + +export default class Field extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + onChange(newValue) { + this.props.onChange(this.props.fieldKey, newValue); + } + render() { + const { compact, value, label, control, descr, fieldKey } = this.props; + const hookedControl = React.cloneElement(control, { value, onChange: this.onChange }); + return ( + + + {label || fieldKey} + {compact && descr && + {descr} + } + > + + + } + + {hookedControl} + + {!compact && descr && + {descr} + } + + ); + } +} +Field.propTypes = propTypes; +Field.defaultProps = defaultProps; diff --git a/superset/assets/src/CRUD/Fieldset.jsx b/superset/assets/src/CRUD/Fieldset.jsx new file mode 100644 index 000000000..6c3c74e9b --- /dev/null +++ b/superset/assets/src/CRUD/Fieldset.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from 'react-bootstrap'; + +import { recurseReactClone } from './utils'; +import Field from './Field'; + +const propTypes = { + children: PropTypes.node, + onChange: PropTypes.func, + item: PropTypes.object, + title: PropTypes.node, + compact: PropTypes.bool, +}; +const defaultProps = { + compact: false, +}; + +export default class Fieldset extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + onChange(fieldKey, val) { + return this.props.onChange({ + ...this.props.item, + [fieldKey]: val, + }); + } + render() { + const { title } = this.props; + const propExtender = field => ({ + onChange: this.onChange, + value: this.props.item[field.props.fieldKey], + compact: this.props.compact, + }); + return ( +
+ {title && + {title} + } + {recurseReactClone(this.props.children, Field, propExtender)} +
+ ); + } +} +Fieldset.propTypes = propTypes; +Fieldset.defaultProps = defaultProps; diff --git a/superset/assets/src/CRUD/styles.css b/superset/assets/src/CRUD/styles.css new file mode 100644 index 000000000..1f07c59e0 --- /dev/null +++ b/superset/assets/src/CRUD/styles.css @@ -0,0 +1,13 @@ +.CRUD i.fa-caret-down, i.fa-caret-up { + width: '5px'; +} +.CRUD td.expanded = { + border-top: '0px'; + padding: '0px'; +} +.CRUD .frame = { + border: '1px solid #AAA'; + border-radius: 5; + padding: 10; + background: '#F4F4F4'; +} diff --git a/superset/assets/src/CRUD/styles.less b/superset/assets/src/CRUD/styles.less new file mode 100644 index 000000000..ec632a60b --- /dev/null +++ b/superset/assets/src/CRUD/styles.less @@ -0,0 +1,14 @@ +.CRUD .text-right { + text-align: right; +} +.CRUD .empty-collection { + padding: 10px; +} +.CRUD .control-label { + font-weight: bold; + font-size: 16px; +} + +.CRUD .tiny-cell { + width: 5px; +} diff --git a/superset/assets/src/CRUD/utils.js b/superset/assets/src/CRUD/utils.js new file mode 100644 index 000000000..6de8c4b16 --- /dev/null +++ b/superset/assets/src/CRUD/utils.js @@ -0,0 +1,20 @@ +import React from 'react'; + +export function recurseReactClone(children, type, propExtender) { + /** + * Clones a React component's children, and injects new props + * where the type specified is matched. + */ + return React.Children.map(children, (child) => { + let newChild = child; + if (child && child.type === type) { + newChild = React.cloneElement(child, propExtender(child)); + } + if (newChild && newChild.props.children) { + newChild = React.cloneElement(newChild, { + children: recurseReactClone(newChild.props.children, type, propExtender), + }); + } + return newChild; + }); +} diff --git a/superset/assets/src/components/Checkbox.jsx b/superset/assets/src/components/Checkbox.jsx index b0564ae6b..c3b2506ee 100644 --- a/superset/assets/src/components/Checkbox.jsx +++ b/superset/assets/src/components/Checkbox.jsx @@ -12,7 +12,9 @@ export default function Checkbox({ checked, onChange, style }) { { + onChange(!checked); + }} style={{ border: '1px solid #aaa', borderRadius: '2px', diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx index 45fea1dcb..5e206c91e 100644 --- a/superset/assets/src/components/EditableTitle.jsx +++ b/superset/assets/src/components/EditableTitle.jsx @@ -10,14 +10,18 @@ const propTypes = { onSaveTitle: PropTypes.func, noPermitTooltip: PropTypes.string, showTooltip: PropTypes.bool, + emptyText: PropTypes.node, + style: PropTypes.object, }; const defaultProps = { title: t('Title'), canEdit: false, showTooltip: true, + onSaveTitle: () => {}, + emptyText: '', }; -class EditableTitle extends React.PureComponent { +export default class EditableTitle extends React.PureComponent { constructor(props) { super(props); this.state = { @@ -45,10 +49,7 @@ class EditableTitle extends React.PureComponent { if (!this.props.canEdit || this.state.isEditing) { return; } - - this.setState({ - isEditing: true, - }); + this.setState({ isEditing: true }); } handleBlur() { @@ -101,7 +102,6 @@ class EditableTitle extends React.PureComponent { if (!this.props.canEdit) { return; } - this.setState({ title: ev.target.value, }); @@ -110,17 +110,23 @@ class EditableTitle extends React.PureComponent { handleKeyPress(ev) { if (ev.key === 'Enter') { ev.preventDefault(); - this.handleBlur(); } } render() { - let content = ( + let value; + if (this.state.title) { + value = this.state.title; + } else if (!this.state.isEditing) { + value = this.props.emptyText; + } + let input = ( ); - if (this.props.showTooltip) { - content = ( + if (this.props.showTooltip && !this.state.isEditing) { + input = ( - {content} + {input} ); } @@ -146,13 +152,12 @@ class EditableTitle extends React.PureComponent { this.props.canEdit && 'editable-title--editable', this.state.isEditing && 'editable-title--editing', )} + style={this.props.style} > - {content} + {input} ); } } EditableTitle.propTypes = propTypes; EditableTitle.defaultProps = defaultProps; - -export default EditableTitle; diff --git a/superset/assets/src/profile/components/TableLoader.jsx b/superset/assets/src/components/TableLoader.jsx similarity index 94% rename from superset/assets/src/profile/components/TableLoader.jsx rename to superset/assets/src/components/TableLoader.jsx index 462e00993..3f51ee92b 100644 --- a/superset/assets/src/profile/components/TableLoader.jsx +++ b/superset/assets/src/components/TableLoader.jsx @@ -2,8 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Table, Tr, Td } from 'reactable'; import $ from 'jquery'; -import Loading from '../../components/Loading'; -import '../../../stylesheets/reactable-pagination.css'; +import Loading from '../components/Loading'; +import '../../stylesheets/reactable-pagination.css'; const propTypes = { dataEndpoint: PropTypes.string.isRequired, diff --git a/superset/assets/src/datasource/DatasourceEditor.jsx b/superset/assets/src/datasource/DatasourceEditor.jsx new file mode 100644 index 000000000..12dfdc0ce --- /dev/null +++ b/superset/assets/src/datasource/DatasourceEditor.jsx @@ -0,0 +1,575 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Badge, Col, Label, Tabs, Tab, Well } from 'react-bootstrap'; +import shortid from 'shortid'; +import $ from 'jquery'; + +import { t } from '../locales'; + +import Button from '../components/Button'; +import Loading from '../components/Loading'; +import CheckboxControl from '../explore/components/controls/CheckboxControl'; +import TextControl from '../explore/components/controls/TextControl'; +import SelectControl from '../explore/components/controls/SelectControl'; +import TextAreaControl from '../explore/components/controls/TextAreaControl'; +import SelectAsyncControl from '../explore/components/controls/SelectAsyncControl'; +import SpatialControl from '../explore/components/controls/SpatialControl'; +import CollectionTable from '../CRUD/CollectionTable'; +import EditableTitle from '../components/EditableTitle'; +import Fieldset from '../CRUD/Fieldset'; +import Field from '../CRUD/Field'; + +import withToasts from '../messageToasts/enhancers/withToasts'; + +import './main.css'; + +const checkboxGenerator = (d, onChange) => ; +const styleMonospace = { fontFamily: 'monospace' }; +const DATA_TYPES = ['STRING', 'NUMBER', 'DATETIME']; + +function CollectionTabTitle({ title, collection }) { + return ( +
+ {title} {collection ? collection.length : 0} +
+ ); +} +CollectionTabTitle.propTypes = { + title: PropTypes.string, + collection: PropTypes.array, +}; + +function ColumnCollectionTable({ + columns, onChange, editableColumnName, showExpression, allowAddItem, + allowEditDataType, itemGenerator, +}) { + return ( + +
+ {showExpression && + } + />} + } + /> + {allowEditDataType && + } + />} + + {t('The pattern of the timestamp format, use ')} + + {t('python datetime string pattern')} + + {t(` expression. If time is stored in epoch format, put \`epoch_s\` or + \`epoch_ms\`. Leave \`Database Expression\` + below empty if timestamp is stored in ' + String or Integer(epoch) type`)} + + } + control={} + /> + + {t(` + The database expression to cast internal datetime + constants to database date/timestamp type according to the DBAPI. + The expression should follow the pattern of + %Y-%m-%d %H:%M:%S, based on different DBAPI. + The string should be a python string formatter + \`Ex: TO_DATE('{}', 'YYYY-MM-DD HH24:MI:SS')\` for Oracle + Superset uses default expression based on DB URI if this + field is blank. + `)} + + } + control={} + /> +
+ + } + columnLabels={{ + column_name: 'Column', + type: 'Data Type', + groupby: 'Is Dimension', + is_dttm: 'Is Temporal', + filterable: 'Is Filterable', + }} + onChange={onChange} + itemRenderers={{ + column_name: (v, onItemChange) => ( + editableColumnName ? + : + v + ), + type: d => , + is_dttm: checkboxGenerator, + filterable: checkboxGenerator, + groupby: checkboxGenerator, + }} + />); +} +ColumnCollectionTable.propTypes = { + columns: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + editableColumnName: PropTypes.bool, + showExpression: PropTypes.bool, + allowAddItem: PropTypes.bool, + allowEditDataType: PropTypes.bool, + itemGenerator: PropTypes.func, +}; +ColumnCollectionTable.defaultProps = { + editableColumnName: false, + showExpression: false, + allowAddItem: false, + allowEditDataType: false, + itemGenerator: () => ({ + column_name: '', + filterable: true, + groupby: true, + }), +}; + +function StackedField({ label, formElement }) { + return ( +
+
{label}
+
{formElement}
+
+ ); +} +StackedField.propTypes = { + label: PropTypes.string, + formElement: PropTypes.node, +}; + +function FormContainer({ children }) { + return ( + + {children} + + ); +} +FormContainer.propTypes = { + children: PropTypes.node, +}; + +const propTypes = { + datasource: PropTypes.object.isRequired, + onChange: PropTypes.func, + addSuccessToast: PropTypes.func.isRequired, + addDangerToast: PropTypes.func.isRequired, +}; +const defaultProps = { + onChange: () => {}, +}; +export class DatasourceEditor extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + datasource: props.datasource, + showAlert: true, + errors: [], + isDruid: props.datasource.type === 'druid', + isSqla: props.datasource.type === 'table', + databaseColumns: props.datasource.columns.filter(col => !col.expression), + calculatedColumns: props.datasource.columns.filter(col => !!col.expression), + metadataLoading: false, + activeTabKey: 1, + }; + + this.onChange = this.onChange.bind(this); + this.onDatasourcePropChange = this.onDatasourcePropChange.bind(this); + this.onDatasourceChange = this.onDatasourceChange.bind(this); + this.hideAlert = this.hideAlert.bind(this); + this.syncMetadata = this.syncMetadata.bind(this); + this.setColumns = this.setColumns.bind(this); + this.validateAndChange = this.validateAndChange.bind(this); + this.handleTabSelect = this.handleTabSelect.bind(this); + } + onChange() { + const datasource = { + ...this.state.datasource, + columns: [...this.state.databaseColumns, ...this.state.calculatedColumns], + }; + this.props.onChange(datasource, this.state.errors); + } + onDatasourceChange(newDatasource) { + this.setState({ datasource: newDatasource }, this.validateAndChange); + } + onDatasourcePropChange(attr, value) { + const datasource = { ...this.state.datasource, [attr]: value }; + this.setState({ datasource }, this.onDatasourceChange(datasource)); + } + setColumns(obj) { + this.setState(obj, this.validateAndChange); + } + validateAndChange() { + this.validate(this.onChange); + } + mergeColumns(cols) { + let { databaseColumns } = this.state; + let hasChanged; + const currentColNames = databaseColumns.map(col => col.column_name); + cols.forEach((col) => { + if (currentColNames.indexOf(col.name) < 0) { + // Adding columns + databaseColumns = databaseColumns.concat([{ + id: shortid.generate(), + column_name: col.name, + type: col.type, + groupby: true, + filterable: true, + }]); + hasChanged = true; + } + }); + if (hasChanged) { + this.setColumns({ databaseColumns }); + } + } + syncMetadata() { + const datasource = this.state.datasource; + const url = `/datasource/external_metadata/${datasource.type}/${datasource.id}/`; + this.setState({ metadataLoading: true }); + const success = (data) => { + this.mergeColumns(data); + this.props.addSuccessToast(t('Metadata has been synced')); + this.setState({ metadataLoading: false }); + }; + const error = (err) => { + let msg = t('An error has occurred'); + if (err.responseJSON && err.responseJSON.error) { + msg = err.responseJSON.error; + } + this.props.addDangerToast(msg); + this.setState({ metadataLoading: false }); + }; + $.ajax({ + url, + type: 'GET', + success, + error, + }); + } + findDuplicates(arr, accessor) { + const seen = {}; + const dups = []; + arr.forEach((obj) => { + const item = accessor(obj); + if (item in seen) { + dups.push(item); + } else { + seen[item] = null; + } + }); + return dups; + } + validate(callback) { + let errors = []; + let dups; + const datasource = this.state.datasource; + + // Looking for duplicate column_name + dups = this.findDuplicates(datasource.columns, obj => obj.column_name); + errors = errors.concat(dups.map(name => t('Column name [%s] is duplicated', name))); + + // Looking for duplicate metric_name + dups = this.findDuplicates(datasource.metrics, obj => obj.metric_name); + errors = errors.concat(dups.map(name => t('Metric name [%s] is duplicated', name))); + + // Making sure calculatedColumns have an expression defined + const noFilterCalcCols = this.state.calculatedColumns.filter( + col => !col.expression && !col.json); + errors = errors.concat(noFilterCalcCols.map( + col => t('Calculated column [%s] requires an expression', col.column_name))); + + this.setState({ errors }, callback); + } + hideAlert() { + this.setState({ showAlert: false }); + } + handleTabSelect(activeTabKey) { + this.setState({ activeTabKey }); + } + renderSettingsFieldset() { + const datasource = this.state.datasource; + return ( +
+ } + /> + } + /> + } + /> + data.pks.map((pk, i) => ({ + value: pk, + label: `${data.result[i].first_name} ${data.result[i].last_name}`, + }))} + />} + controlProps={{ + }} + /> +
+ ); + } + renderAdvancedFieldset() { + const datasource = this.state.datasource; + return ( +
+ { this.state.isSqla && + } + /> + } + { this.state.isDruid && + + {t('The JSON metric or post aggregation definition.')} + + } + control={} + /> + } + } + /> + } + /> +
); + } + renderSpatialTab() { + const spatials = this.state.datasource.spatials; + return ( + } + eventKey={4} + > + ({ + name: '', + type: '', + config: null, + })} + collection={spatials} + allowDeletes + itemRenderers={{ + name: (d, onChange) => ( + ), + config: (v, onChange) => ( + + ), + }} + /> + ); + } + renderErrors() { + if (this.state.errors.length > 0) { + return ( + + {this.state.errors.map(err =>
{err}
)} +
); + } + return null; + } + renderMetricCollection() { + return ( + +
+ } + /> + } + /> + } + /> +
+ + } + collection={this.state.datasource.metrics} + onChange={this.onDatasourcePropChange.bind(this, 'metrics')} + itemGenerator={() => ({ + metric_name: '', + verbose_name: '', + expression: '', + })} + itemRenderers={{ + metric_name: (v, onChange) => ( + ), + verbose_name: (v, onChange) => ( + ), + expression: (v, onChange) => ( + ), + description: (v, onChange, label) => ( + } + />), + d3format: (v, onChange, label) => ( + } + />), + }} + allowDeletes + />); + } + render() { + const datasource = this.state.datasource; + return ( +
+ {this.renderErrors()} + + + {this.state.activeTabKey === 1 && +
+ + + {this.renderSettingsFieldset()} + + + + + {this.renderAdvancedFieldset()} + + +
+ } +
+ + } + eventKey={2} + > + {this.state.activeTabKey === 2 && +
+ this.setColumns({ databaseColumns })} + /> + + {this.state.metadataLoading && } +
+ } +
+ } + eventKey={3} + > + {this.state.activeTabKey === 3 && + this.setColumns({ calculatedColumns })} + editableColumnName + showExpression + allowAddItem + allowEditDataType + itemGenerator={() => ({ + column_name: '', + filterable: true, + groupby: true, + expression: '', + __expanded: true, + })} + /> + } + + } + eventKey={4} + > + {this.state.activeTabKey === 4 && this.renderMetricCollection()} + +
+
+ ); + } +} +DatasourceEditor.defaultProps = defaultProps; +DatasourceEditor.propTypes = propTypes; +export default withToasts(DatasourceEditor); diff --git a/superset/assets/src/datasource/DatasourceModal.jsx b/superset/assets/src/datasource/DatasourceModal.jsx new file mode 100644 index 000000000..cef993bc6 --- /dev/null +++ b/superset/assets/src/datasource/DatasourceModal.jsx @@ -0,0 +1,161 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Button, Modal } from 'react-bootstrap'; +import Dialog from 'react-bootstrap-dialog'; + +import { t } from '../locales'; +import DatasourceEditor from '../datasource/DatasourceEditor'; +import withToasts from '../messageToasts/enhancers/withToasts'; + +const $ = window.$ = require('jquery'); + +const propTypes = { + onChange: PropTypes.func, + datasource: PropTypes.object, + show: PropTypes.bool.isRequired, + onHide: PropTypes.func, + onDatasourceSave: PropTypes.func, + addSuccessToast: PropTypes.func.isRequired, +}; + +const defaultProps = { + onChange: () => {}, + onHide: () => {}, + onDatasourceSave: () => {}, +}; + +class DatasourceModal extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + showEditDatasource: false, + filter: '', + loading: true, + errors: [], + showDatasource: false, + datasource: props.datasource, + }; + this.toggleShowDatasource = this.toggleShowDatasource.bind(this); + this.changeSearch = this.changeSearch.bind(this); + this.setSearchRef = this.setSearchRef.bind(this); + this.onDatasourceChange = this.onDatasourceChange.bind(this); + this.onClickSave = this.onClickSave.bind(this); + this.onConfirmSave = this.onConfirmSave.bind(this); + } + onClickSave() { + this.dialog.show({ + title: 'Confirm save', + bsSize: 'medium', + actions: [ + Dialog.CancelAction(), + Dialog.OKAction(this.onConfirmSave), + ], + body: this.renderSaveDialog(), + }); + } + onConfirmSave() { + const url = '/datasource/save/'; + const that = this; + $.ajax({ + url, + type: 'POST', + data: { + data: JSON.stringify(this.state.datasource), + }, + success: (data) => { + this.props.addSuccessToast(t('The datasource has been saved')); + this.props.onDatasourceSave(data); + this.props.onHide(); + }, + error(err) { + let msg = t('An error has occurred'); + if (err.responseJSON && err.responseJSON.error) { + msg = err.responseJSON.error; + } + that.dialog.show({ + title: 'Error', + bsSize: 'medium', + bsStyle: 'danger', + actions: [ + Dialog.DefaultAction('Ok', () => {}, 'btn-danger'), + ], + body: msg, + }); + }, + }); + } + onDatasourceChange(datasource, errors) { + this.setState({ datasource, errors }); + } + setSearchRef(searchRef) { + this.searchRef = searchRef; + } + toggleShowDatasource() { + this.setState({ showDatasource: !this.state.showDatasource }); + } + changeSearch(event) { + this.setState({ filter: event.target.value }); + } + renderSaveDialog() { + return ( +
+ +
+ {' '} + {t(`The data source configuration exposed here + affects all the charts using this datasource. + Be mindful that changing settings + here may affect other charts + in undesirable ways.`)} +
+
+ {t('Are you sure you want to save and apply changes?')} +
+ ); + } + render() { + return ( + + + +
+ + {t('Datasource Editor for ')} + {this.props.datasource.name} + +
+
+
+ + {this.props.show && + } + + + + + + { this.dialog = el; }} /> + + +
); + } +} + +DatasourceModal.propTypes = propTypes; +DatasourceModal.defaultProps = defaultProps; +export default withToasts(DatasourceModal); diff --git a/superset/assets/src/datasource/main.css b/superset/assets/src/datasource/main.css new file mode 100644 index 000000000..69fd97a10 --- /dev/null +++ b/superset/assets/src/datasource/main.css @@ -0,0 +1,4 @@ +.Datasource .tab-content { + height: 600px; + overflow: auto; +} diff --git a/superset/assets/src/explore/actions/exploreActions.js b/superset/assets/src/explore/actions/exploreActions.js index 81c23e3ba..b8a883ee7 100644 --- a/superset/assets/src/explore/actions/exploreActions.js +++ b/superset/assets/src/explore/actions/exploreActions.js @@ -1,6 +1,4 @@ /* eslint camelcase: 0 */ -import { triggerQuery } from '../../chart/chartAction'; - const $ = window.$ = require('jquery'); const FAVESTAR_BASE_URL = '/superset/favstar/slice'; @@ -20,21 +18,12 @@ export function setDatasources(datasources) { return { type: SET_DATASOURCES, datasources }; } -export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED'; -export function fetchDatasourceStarted() { - return { type: FETCH_DATASOURCE_STARTED }; -} - +export const POST_DATASOURCE_STARTED = 'POST_DATASOURCE_STARTED'; export const FETCH_DATASOURCE_SUCCEEDED = 'FETCH_DATASOURCE_SUCCEEDED'; export function fetchDatasourceSucceeded() { return { type: FETCH_DATASOURCE_SUCCEEDED }; } -export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED'; -export function fetchDatasourceFailed(error) { - return { type: FETCH_DATASOURCE_FAILED, error }; -} - export const FETCH_DATASOURCES_STARTED = 'FETCH_DATASOURCES_STARTED'; export function fetchDatasourcesStarted() { return { type: FETCH_DATASOURCES_STARTED }; @@ -45,9 +34,9 @@ export function fetchDatasourcesSucceeded() { return { type: FETCH_DATASOURCES_SUCCEEDED }; } -export const FETCH_DATASOURCES_FAILED = 'FETCH_DATASOURCES_FAILED'; -export function fetchDatasourcesFailed(error) { - return { type: FETCH_DATASOURCES_FAILED, error }; +export const POST_DATASOURCES_FAILED = 'POST_DATASOURCES_FAILED'; +export function postDatasourcesFailed(error) { + return { type: POST_DATASOURCES_FAILED, error }; } export const RESET_FIELDS = 'RESET_FIELDS'; @@ -55,28 +44,6 @@ export function resetControls() { return { type: RESET_FIELDS }; } -export function fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery = false) { - return function (dispatch) { - dispatch(fetchDatasourceStarted()); - const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`; - $.ajax({ - type: 'GET', - url, - success: (data) => { - dispatch(setDatasource(data)); - dispatch(fetchDatasourceSucceeded()); - dispatch(resetControls()); - if (alsoTriggerQuery) { - dispatch(triggerQuery()); - } - }, - error(error) { - dispatch(fetchDatasourceFailed(error.responseJSON.error)); - }, - }); - }; -} - export function fetchDatasources() { return function (dispatch) { dispatch(fetchDatasourcesStarted()); diff --git a/superset/assets/src/explore/components/ControlPanelsContainer.jsx b/superset/assets/src/explore/components/ControlPanelsContainer.jsx index ff9754b03..0eac0cf0c 100644 --- a/superset/assets/src/explore/components/ControlPanelsContainer.jsx +++ b/superset/assets/src/explore/components/ControlPanelsContainer.jsx @@ -44,7 +44,7 @@ class ControlPanelsContainer extends React.Component { } // Applying mapStateToProps if needed if (mapF) { - return Object.assign({}, control, mapF(this.props.exploreState, control)); + return Object.assign({}, control, mapF(this.props.exploreState, control, this.props.actions)); } return control; } diff --git a/superset/assets/src/explore/components/controls/CheckboxControl.jsx b/superset/assets/src/explore/components/controls/CheckboxControl.jsx index a4138e483..4c15c374e 100644 --- a/superset/assets/src/explore/components/controls/CheckboxControl.jsx +++ b/superset/assets/src/explore/components/controls/CheckboxControl.jsx @@ -4,7 +4,7 @@ import ControlHeader from '../ControlHeader'; import Checkbox from '../../../components/Checkbox'; const propTypes = { - name: PropTypes.string.isRequired, + name: PropTypes.string, value: PropTypes.bool, label: PropTypes.string, description: PropTypes.string, @@ -22,21 +22,25 @@ export default class CheckboxControl extends React.Component { onChange() { this.props.onChange(!this.props.value); } - render() { + renderCheckbox() { return ( - - } - /> - ); + ); + } + render() { + if (this.props.label) { + return ( + + ); + } + return this.renderCheckbox(); } } - CheckboxControl.propTypes = propTypes; CheckboxControl.defaultProps = defaultProps; diff --git a/superset/assets/src/explore/components/controls/DatasourceControl.jsx b/superset/assets/src/explore/components/controls/DatasourceControl.jsx index c8391f2bc..5b616f1a7 100644 --- a/superset/assets/src/explore/components/controls/DatasourceControl.jsx +++ b/superset/assets/src/explore/components/controls/DatasourceControl.jsx @@ -1,56 +1,49 @@ /* eslint no-undef: 2 */ import React from 'react'; import PropTypes from 'prop-types'; -import { Table } from 'reactable'; import { - Row, Col, Collapse, Label, - FormControl, - Modal, OverlayTrigger, + Row, Tooltip, Well, } from 'react-bootstrap'; import $ from 'jquery'; import ControlHeader from '../ControlHeader'; -import Loading from '../../../components/Loading'; import { t } from '../../../locales'; +import DatasourceModal from '../../../datasource/DatasourceModal'; import ColumnOption from '../../../components/ColumnOption'; import MetricOption from '../../../components/MetricOption'; import withToasts from '../../../messageToasts/enhancers/withToasts'; const propTypes = { - description: PropTypes.string, - label: PropTypes.string, - name: PropTypes.string.isRequired, onChange: PropTypes.func, value: PropTypes.string.isRequired, - datasource: PropTypes.object, addDangerToast: PropTypes.func.isRequired, + datasource: PropTypes.object.isRequired, + onDatasourceSave: PropTypes.func, }; const defaultProps = { onChange: () => {}, + onDatasourceSave: () => {}, }; class DatasourceControl extends React.PureComponent { constructor(props) { super(props); this.state = { - showModal: false, + showEditDatasourceModal: false, filter: '', loading: true, showDatasource: false, }; this.toggleShowDatasource = this.toggleShowDatasource.bind(this); - this.onChange = this.onChange.bind(this); - this.onEnterModal = this.onEnterModal.bind(this); - this.toggleModal = this.toggleModal.bind(this); - this.changeSearch = this.changeSearch.bind(this); + this.toggleEditDatasourceModal = this.toggleEditDatasourceModal.bind(this); this.setSearchRef = this.setSearchRef.bind(this); this.selectDatasource = this.selectDatasource.bind(this); } @@ -110,47 +103,10 @@ class DatasourceControl extends React.PureComponent { this.setState({ showModal: false }); this.props.onChange(datasourceId); } + toggleEditDatasourceModal() { + this.setState({ showEditDatasourceModal: !this.state.showEditDatasourceModal }); + } renderModal() { - return ( - - - {t('Select a datasource')} - - -
- { - this.setSearchRef(ref); - }} - type="text" - bsSize="sm" - value={this.state.filter} - placeholder={t('Search / Filter')} - onChange={this.changeSearch} - /> -
- {this.state.loading && } - {this.state.datasources && ( - - )} - - - ); } renderDatasource() { const datasource = this.props.datasource; @@ -195,27 +151,17 @@ class DatasourceControl extends React.PureComponent { {t('Click to point to another datasource')} } > -