Implement a React-based table editor (#5186)
* A React table editor * addressing comments * Fix SelectAsyncControl error on clear * fix tests * more corrections * Removed <strong>
This commit is contained in:
parent
aa14bac5c7
commit
68ba63fcd9
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { datasourceId } from './mockDatasource';
|
||||
import { datasourceId } from '../../../fixtures/mockDatasource';
|
||||
|
||||
export const sliceId = 18;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { sliceId as id } from './mockChartQueries';
|
||||
import { datasourceId } from './mockDatasource';
|
||||
import { datasourceId } from '../../../fixtures/mockDatasource';
|
||||
|
||||
export const sliceId = id;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.Datasource .tab-content {
|
||||
height: 600px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 : () => {},
|
||||
}),
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -469,3 +469,6 @@ g.annotation-container {
|
|||
height: 30px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
|
|||
params = Column(String(1000))
|
||||
perm = Column(String(1000))
|
||||
|
||||
sql = None
|
||||
owner = None
|
||||
update_from_object_fields = None
|
||||
|
||||
@declared_attr
|
||||
def slices(self):
|
||||
return relationship(
|
||||
|
|
@ -82,6 +86,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
|
|||
def main_dttm_col(self):
|
||||
return 'timestamp'
|
||||
|
||||
@property
|
||||
def datasource_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def connection(self):
|
||||
"""String representing the context of the Datasource"""
|
||||
|
|
@ -134,7 +142,7 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
|
|||
def metrics_combo(self):
|
||||
return sorted(
|
||||
[
|
||||
(m.metric_name, m.verbose_name or m.metric_name)
|
||||
(m.metric_name, m.verbose_name or m.metric_name or '')
|
||||
for m in self.metrics],
|
||||
key=lambda x: x[1])
|
||||
|
||||
|
|
@ -174,22 +182,36 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
|
|||
for o in self.columns
|
||||
})
|
||||
return {
|
||||
'all_cols': utils.choicify(self.column_names),
|
||||
# simple fields
|
||||
'id': self.id,
|
||||
'column_formats': self.column_formats,
|
||||
'description': self.description,
|
||||
'database': self.database.data, # pylint: disable=no-member
|
||||
'edit_url': self.url,
|
||||
'default_endpoint': self.default_endpoint,
|
||||
'filter_select': self.filter_select_enabled,
|
||||
'name': self.name,
|
||||
'datasource_name': self.datasource_name,
|
||||
'type': self.type,
|
||||
'schema': self.schema,
|
||||
'offset': self.offset,
|
||||
'cache_timeout': self.cache_timeout,
|
||||
'params': self.params,
|
||||
'perm': self.perm,
|
||||
|
||||
# sqla-specific
|
||||
'sql': self.sql,
|
||||
|
||||
# computed fields
|
||||
'all_cols': utils.choicify(self.column_names),
|
||||
'columns': [o.data for o in self.columns],
|
||||
'edit_url': self.url,
|
||||
'filterable_cols': utils.choicify(self.filterable_column_names),
|
||||
'gb_cols': utils.choicify(self.groupby_column_names),
|
||||
'id': self.id,
|
||||
'metrics_combo': self.metrics_combo,
|
||||
'name': self.name,
|
||||
'order_by_choices': order_by_choices,
|
||||
'type': self.type,
|
||||
'metrics': [o.data for o in self.metrics],
|
||||
'columns': [o.data for o in self.columns],
|
||||
'metrics_combo': self.metrics_combo,
|
||||
'order_by_choices': order_by_choices,
|
||||
'owner': self.owner.id if self.owner else None,
|
||||
'verbose_map': verbose_map,
|
||||
'schema': self.schema,
|
||||
'select_star': self.select_star,
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +244,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
|
|||
values = None
|
||||
return values
|
||||
|
||||
def external_metadata(self):
|
||||
"""Returns column information from the external system"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_query_str(self, query_obj):
|
||||
"""Returns a query as a string
|
||||
|
||||
|
|
@ -253,6 +279,68 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
|
|||
if col.column_name == column_name:
|
||||
return col
|
||||
|
||||
def get_fk_many_from_list(
|
||||
self, object_list, fkmany, fkmany_class, key_attr):
|
||||
"""Update ORM one-to-many list from object list
|
||||
|
||||
Used for syncing metrics and columns using the same code"""
|
||||
|
||||
object_dict = {o.get(key_attr): o for o in object_list}
|
||||
object_keys = [o.get(key_attr) for o in object_list]
|
||||
|
||||
# delete fks that have been removed
|
||||
fkmany = [o for o in fkmany if getattr(o, key_attr) in object_keys]
|
||||
|
||||
# sync existing fks
|
||||
for fk in fkmany:
|
||||
obj = object_dict.get(getattr(fk, key_attr))
|
||||
for attr in fkmany_class.update_from_object_fields:
|
||||
setattr(fk, attr, obj.get(attr))
|
||||
|
||||
# create new fks
|
||||
new_fks = []
|
||||
orm_keys = [getattr(o, key_attr) for o in fkmany]
|
||||
for obj in object_list:
|
||||
key = obj.get(key_attr)
|
||||
if key not in orm_keys:
|
||||
del obj['id']
|
||||
orm_kwargs = {}
|
||||
for k in obj:
|
||||
if (
|
||||
k in fkmany_class.update_from_object_fields and
|
||||
k in obj
|
||||
):
|
||||
orm_kwargs[k] = obj[k]
|
||||
new_obj = fkmany_class(**orm_kwargs)
|
||||
new_fks.append(new_obj)
|
||||
fkmany += new_fks
|
||||
return fkmany
|
||||
|
||||
def update_from_object(self, obj):
|
||||
"""Update datasource from a data structure
|
||||
|
||||
The UI's table editor crafts a complex data structure that
|
||||
contains most of the datasource's properties as well as
|
||||
an array of metrics and columns objects. This method
|
||||
receives the object from the UI and syncs the datasource to
|
||||
match it. Since the fields are different for the different
|
||||
connectors, the implementation uses ``update_from_object_fields``
|
||||
which can be defined for each connector and
|
||||
defines which fields should be synced"""
|
||||
for attr in self.update_from_object_fields:
|
||||
setattr(self, attr, obj.get(attr))
|
||||
|
||||
self.user_id = obj.get('owner')
|
||||
|
||||
# Syncing metrics
|
||||
metrics = self.get_fk_many_from_list(
|
||||
obj.get('metrics'), self.metrics, self.metric_class, 'metric_name')
|
||||
self.metrics = metrics
|
||||
|
||||
# Syncing columns
|
||||
self.columns = self.get_fk_many_from_list(
|
||||
obj.get('columns'), self.columns, self.column_class, 'column_name')
|
||||
|
||||
|
||||
class BaseColumn(AuditMixinNullable, ImportMixin):
|
||||
"""Interface for column"""
|
||||
|
|
@ -315,9 +403,11 @@ class BaseColumn(AuditMixinNullable, ImportMixin):
|
|||
@property
|
||||
def data(self):
|
||||
attrs = (
|
||||
'column_name', 'verbose_name', 'description', 'expression',
|
||||
'filterable', 'groupby', 'is_dttm', 'type')
|
||||
return {s: getattr(self, s) for s in attrs}
|
||||
'id', 'column_name', 'verbose_name', 'description', 'expression',
|
||||
'filterable', 'groupby', 'is_dttm', 'type',
|
||||
'database_expression', 'python_date_format',
|
||||
)
|
||||
return {s: getattr(self, s) for s in attrs if hasattr(self, s)}
|
||||
|
||||
|
||||
class BaseMetric(AuditMixinNullable, ImportMixin):
|
||||
|
|
@ -359,6 +449,6 @@ class BaseMetric(AuditMixinNullable, ImportMixin):
|
|||
@property
|
||||
def data(self):
|
||||
attrs = (
|
||||
'metric_name', 'verbose_name', 'description', 'expression',
|
||||
'warning_text')
|
||||
'id', 'metric_name', 'verbose_name', 'description', 'expression',
|
||||
'warning_text', 'd3format')
|
||||
return {s: getattr(self, s) for s in attrs}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
|
|||
export_fields = ('cluster_name', 'coordinator_host', 'coordinator_port',
|
||||
'coordinator_endpoint', 'broker_host', 'broker_port',
|
||||
'broker_endpoint', 'cache_timeout')
|
||||
update_from_object_fields = export_fields
|
||||
export_children = ['datasources']
|
||||
|
||||
def __repr__(self):
|
||||
|
|
@ -142,6 +143,11 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
|
|||
self.coordinator_host, self.coordinator_port) + '/status'
|
||||
return json.loads(requests.get(endpoint).text)['version']
|
||||
|
||||
@property
|
||||
@utils.memoized
|
||||
def druid_version(self):
|
||||
return self.get_druid_version()
|
||||
|
||||
def refresh_datasources(
|
||||
self,
|
||||
datasource_name=None,
|
||||
|
|
@ -150,7 +156,6 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
|
|||
"""Refresh metadata of all datasources in the cluster
|
||||
If ``datasource_name`` is specified, only that datasource is updated
|
||||
"""
|
||||
self.druid_version = self.get_druid_version()
|
||||
ds_list = self.get_datasources()
|
||||
blacklist = conf.get('DRUID_DATA_SOURCE_BLACKLIST', [])
|
||||
ds_refresh = []
|
||||
|
|
@ -271,6 +276,7 @@ class DruidColumn(Model, BaseColumn):
|
|||
'count_distinct', 'sum', 'avg', 'max', 'min', 'filterable',
|
||||
'description', 'dimension_spec_json', 'verbose_name',
|
||||
)
|
||||
update_from_object_fields = export_fields
|
||||
export_parent = 'datasource'
|
||||
|
||||
def __repr__(self):
|
||||
|
|
@ -413,8 +419,9 @@ class DruidMetric(Model, BaseMetric):
|
|||
|
||||
export_fields = (
|
||||
'metric_name', 'verbose_name', 'metric_type', 'datasource_id',
|
||||
'json', 'description', 'is_restricted', 'd3format',
|
||||
'json', 'description', 'is_restricted', 'd3format', 'warning_text',
|
||||
)
|
||||
update_from_object_fields = export_fields
|
||||
export_parent = 'datasource'
|
||||
|
||||
@property
|
||||
|
|
@ -481,6 +488,7 @@ class DruidDatasource(Model, BaseDatasource):
|
|||
'datasource_name', 'is_hidden', 'description', 'default_endpoint',
|
||||
'cluster_name', 'offset', 'cache_timeout', 'params',
|
||||
)
|
||||
update_from_object_fields = export_fields
|
||||
|
||||
export_parent = 'cluster'
|
||||
export_children = ['columns', 'metrics']
|
||||
|
|
@ -520,6 +528,9 @@ class DruidDatasource(Model, BaseDatasource):
|
|||
'[{obj.cluster_name}].[{obj.datasource_name}]'
|
||||
'(id:{obj.id})').format(obj=self)
|
||||
|
||||
def update_from_object(self, obj):
|
||||
return NotImplementedError()
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
name = escape(self.datasource_name)
|
||||
|
|
@ -1575,6 +1586,16 @@ class DruidDatasource(Model, BaseDatasource):
|
|||
.all()
|
||||
)
|
||||
|
||||
def external_metadata(self):
|
||||
self.merge_flag = True
|
||||
return [
|
||||
{
|
||||
'name': k,
|
||||
'type': v.get('type'),
|
||||
}
|
||||
for k, v in self.latest_metadata().items()
|
||||
]
|
||||
|
||||
|
||||
sa.event.listen(DruidDatasource, 'after_insert', set_perm)
|
||||
sa.event.listen(DruidDatasource, 'after_update', set_perm)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ class TableColumn(Model, BaseColumn):
|
|||
'filterable', 'expression', 'description', 'python_date_format',
|
||||
'database_expression',
|
||||
)
|
||||
|
||||
update_from_object_fields = [
|
||||
s for s in export_fields if s not in ('table_id',)]
|
||||
export_parent = 'table'
|
||||
|
||||
@property
|
||||
|
|
@ -170,6 +173,7 @@ class TableColumn(Model, BaseColumn):
|
|||
return s or "'{}'".format(dttm.strftime('%Y-%m-%d %H:%M:%S.%f'))
|
||||
|
||||
def get_metrics(self):
|
||||
# TODO deprecate, this is not needed since MetricsControl
|
||||
metrics = []
|
||||
M = SqlMetric # noqa
|
||||
quoted = self.column_name
|
||||
|
|
@ -221,7 +225,9 @@ class SqlMetric(Model, BaseMetric):
|
|||
|
||||
export_fields = (
|
||||
'metric_name', 'verbose_name', 'metric_type', 'table_id', 'expression',
|
||||
'description', 'is_restricted', 'd3format')
|
||||
'description', 'is_restricted', 'd3format', 'warning_text')
|
||||
update_from_object_fields = list([
|
||||
s for s in export_fields if s not in ('table_id', )])
|
||||
export_parent = 'table'
|
||||
|
||||
@property
|
||||
|
|
@ -281,6 +287,8 @@ class SqlaTable(Model, BaseDatasource):
|
|||
'table_name', 'main_dttm_col', 'description', 'default_endpoint',
|
||||
'database_id', 'offset', 'cache_timeout', 'schema',
|
||||
'sql', 'params', 'template_params')
|
||||
update_from_object_fields = [
|
||||
f for f in export_fields if f not in ('table_name', 'database_id')]
|
||||
export_parent = 'database'
|
||||
export_children = ['metrics', 'columns']
|
||||
|
||||
|
|
@ -304,6 +312,10 @@ class SqlaTable(Model, BaseDatasource):
|
|||
def description_markeddown(self):
|
||||
return utils.markdown(self.description)
|
||||
|
||||
@property
|
||||
def datasource_name(self):
|
||||
return self.table_name
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
name = escape(self.name)
|
||||
|
|
@ -363,6 +375,12 @@ class SqlaTable(Model, BaseDatasource):
|
|||
def sql_url(self):
|
||||
return self.database.sql_url + '?table_name=' + str(self.table_name)
|
||||
|
||||
def external_metadata(self):
|
||||
cols = self.database.get_columns(self.table_name, schema=self.schema)
|
||||
for col in cols:
|
||||
col['type'] = '{}'.format(col['type'])
|
||||
return cols
|
||||
|
||||
@property
|
||||
def time_column_grains(self):
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ def load_world_bank_health_n_pop():
|
|||
"TUV", "IMY", "KNA", "ASM", "ADO", "AMA", "PLW",
|
||||
],
|
||||
"op": "not in"}],
|
||||
)),
|
||||
)),
|
||||
Slice(
|
||||
slice_name="Rural Breakdown",
|
||||
viz_type='sunburst',
|
||||
|
|
|
|||
|
|
@ -645,6 +645,10 @@ class Database(Model, AuditMixinNullable, ImportMixin):
|
|||
def unique_name(self):
|
||||
return self.database_name
|
||||
|
||||
@property
|
||||
def url_object(self):
|
||||
return make_url(self.sqlalchemy_uri_decrypted)
|
||||
|
||||
@property
|
||||
def backend(self):
|
||||
url = make_url(self.sqlalchemy_uri_decrypted)
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ from . import base # noqa
|
|||
from . import core # noqa
|
||||
from . import sql_lab # noqa
|
||||
from . import annotations # noqa
|
||||
from . import datasource # noqa
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ from flask_babel import gettext as __
|
|||
from flask_babel import lazy_gettext as _
|
||||
import yaml
|
||||
|
||||
from superset import conf, security_manager, utils
|
||||
from superset import conf, db, security_manager, utils
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.translations.utils import get_language_pack
|
||||
|
||||
FRONTEND_CONF_KEYS = (
|
||||
|
|
@ -91,6 +92,13 @@ def get_user_roles():
|
|||
|
||||
|
||||
class BaseSupersetView(BaseView):
|
||||
|
||||
def json_response(self, obj, status=200):
|
||||
return Response(
|
||||
json.dumps(obj, default=utils.json_int_dttm_ser),
|
||||
status=status,
|
||||
mimetype='application/json')
|
||||
|
||||
def common_bootsrap_payload(self):
|
||||
"""Common data always sent to the client"""
|
||||
messages = get_flashed_messages(with_categories=True)
|
||||
|
|
@ -268,3 +276,49 @@ class CsvResponse(Response):
|
|||
Override Response to take into account csv encoding from config.py
|
||||
"""
|
||||
charset = conf.get('CSV_EXPORT').get('encoding', 'utf-8')
|
||||
|
||||
|
||||
def check_ownership(obj, raise_if_false=True):
|
||||
"""Meant to be used in `pre_update` hooks on models to enforce ownership
|
||||
|
||||
Admin have all access, and other users need to be referenced on either
|
||||
the created_by field that comes with the ``AuditMixin``, or in a field
|
||||
named ``owners`` which is expected to be a one-to-many with the User
|
||||
model. It is meant to be used in the ModelView's pre_update hook in
|
||||
which raising will abort the update.
|
||||
"""
|
||||
if not obj:
|
||||
return False
|
||||
|
||||
security_exception = SupersetSecurityException(
|
||||
"You don't have the rights to alter [{}]".format(obj))
|
||||
|
||||
if g.user.is_anonymous():
|
||||
if raise_if_false:
|
||||
raise security_exception
|
||||
return False
|
||||
roles = [r.name for r in get_user_roles()]
|
||||
if 'Admin' in roles:
|
||||
return True
|
||||
session = db.create_scoped_session()
|
||||
orig_obj = session.query(obj.__class__).filter_by(id=obj.id).first()
|
||||
|
||||
# Making a list of owners that works across ORM models
|
||||
owners = []
|
||||
if hasattr(orig_obj, 'owners'):
|
||||
owners += orig_obj.owners
|
||||
if hasattr(orig_obj, 'owner'):
|
||||
owners += [orig_obj.owner]
|
||||
if hasattr(orig_obj, 'created_by'):
|
||||
owners += [orig_obj.created_by]
|
||||
|
||||
owner_names = [o.username for o in owners]
|
||||
|
||||
if (
|
||||
g.user and hasattr(g.user, 'username') and
|
||||
g.user.username in owner_names):
|
||||
return True
|
||||
if raise_if_false:
|
||||
raise security_exception
|
||||
else:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ from superset import (
|
|||
)
|
||||
from superset.connectors.connector_registry import ConnectorRegistry
|
||||
from superset.connectors.sqla.models import AnnotationDatasource, SqlaTable
|
||||
from superset.exceptions import SupersetException, SupersetSecurityException
|
||||
from superset.exceptions import SupersetException
|
||||
from superset.forms import CsvToDatabaseForm
|
||||
from superset.jinja_context import get_template_processor
|
||||
from superset.legacy import cast_form_data, update_time_range
|
||||
|
|
@ -50,8 +50,10 @@ from superset.utils import (
|
|||
merge_extra_filters, merge_request_params, QueryStatus,
|
||||
)
|
||||
from .base import (
|
||||
api, BaseSupersetView, CsvResponse, DeleteMixin,
|
||||
generate_download_headers, get_error_msg, get_user_roles,
|
||||
api, BaseSupersetView,
|
||||
check_ownership,
|
||||
CsvResponse, DeleteMixin,
|
||||
generate_download_headers, get_error_msg,
|
||||
json_error_response, SupersetFilter, SupersetModelView, YamlExportMixin,
|
||||
)
|
||||
from .utils import bootstrap_user_data
|
||||
|
|
@ -92,48 +94,6 @@ def is_owner(obj, user):
|
|||
return obj and user in obj.owners
|
||||
|
||||
|
||||
def check_ownership(obj, raise_if_false=True):
|
||||
"""Meant to be used in `pre_update` hooks on models to enforce ownership
|
||||
|
||||
Admin have all access, and other users need to be referenced on either
|
||||
the created_by field that comes with the ``AuditMixin``, or in a field
|
||||
named ``owners`` which is expected to be a one-to-many with the User
|
||||
model. It is meant to be used in the ModelView's pre_update hook in
|
||||
which raising will abort the update.
|
||||
"""
|
||||
if not obj:
|
||||
return False
|
||||
|
||||
security_exception = SupersetSecurityException(
|
||||
"You don't have the rights to alter [{}]".format(obj))
|
||||
|
||||
if g.user.is_anonymous():
|
||||
if raise_if_false:
|
||||
raise security_exception
|
||||
return False
|
||||
roles = (r.name for r in get_user_roles())
|
||||
if 'Admin' in roles:
|
||||
return True
|
||||
session = db.create_scoped_session()
|
||||
orig_obj = session.query(obj.__class__).filter_by(id=obj.id).first()
|
||||
owner_names = (user.username for user in orig_obj.owners)
|
||||
if (
|
||||
hasattr(orig_obj, 'created_by') and
|
||||
orig_obj.created_by and
|
||||
orig_obj.created_by.username == g.user.username):
|
||||
return True
|
||||
if (
|
||||
hasattr(orig_obj, 'owners') and
|
||||
g.user and
|
||||
hasattr(g.user, 'username') and
|
||||
g.user.username in owner_names):
|
||||
return True
|
||||
if raise_if_false:
|
||||
raise security_exception
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class SliceFilter(SupersetFilter):
|
||||
def apply(self, query, func): # noqa
|
||||
if self.has_all_datasource_access():
|
||||
|
|
@ -768,12 +728,6 @@ appbuilder.add_view_no_menu(R)
|
|||
|
||||
class Superset(BaseSupersetView):
|
||||
"""The base views for Superset!"""
|
||||
def json_response(self, obj, status=200):
|
||||
return Response(
|
||||
json.dumps(obj, default=utils.json_int_dttm_ser),
|
||||
status=status,
|
||||
mimetype='application/json')
|
||||
|
||||
@has_access_api
|
||||
@expose('/datasources/')
|
||||
def datasources(self):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -112,8 +112,12 @@ class SupersetTestCase(unittest.TestCase):
|
|||
session.commit()
|
||||
|
||||
def get_table(self, table_id):
|
||||
return db.session.query(SqlaTable).filter_by(
|
||||
id=table_id).first()
|
||||
return (
|
||||
db.session
|
||||
.query(SqlaTable)
|
||||
.filter_by(id=table_id)
|
||||
.one()
|
||||
)
|
||||
|
||||
def get_or_create(self, cls, criteria, session):
|
||||
obj = session.query(cls).filter_by(**criteria).first()
|
||||
|
|
@ -137,8 +141,7 @@ class SupersetTestCase(unittest.TestCase):
|
|||
return slc
|
||||
|
||||
def get_table_by_name(self, name):
|
||||
return db.session.query(SqlaTable).filter_by(
|
||||
table_name=name).first()
|
||||
return db.session.query(SqlaTable).filter_by(table_name=name).one()
|
||||
|
||||
def get_druid_ds_by_name(self, name):
|
||||
return db.session.query(DruidDatasource).filter_by(
|
||||
|
|
@ -215,50 +218,3 @@ class SupersetTestCase(unittest.TestCase):
|
|||
if raise_on_error and 'error' in resp:
|
||||
raise Exception('run_sql failed')
|
||||
return resp
|
||||
|
||||
def test_gamma_permissions(self):
|
||||
def assert_can_read(view_menu):
|
||||
self.assertIn(('can_show', view_menu), gamma_perm_set)
|
||||
self.assertIn(('can_list', view_menu), gamma_perm_set)
|
||||
|
||||
def assert_can_write(view_menu):
|
||||
self.assertIn(('can_add', view_menu), gamma_perm_set)
|
||||
self.assertIn(('can_download', view_menu), gamma_perm_set)
|
||||
self.assertIn(('can_delete', view_menu), gamma_perm_set)
|
||||
self.assertIn(('can_edit', view_menu), gamma_perm_set)
|
||||
|
||||
def assert_cannot_write(view_menu):
|
||||
self.assertNotIn(('can_add', view_menu), gamma_perm_set)
|
||||
self.assertNotIn(('can_download', view_menu), gamma_perm_set)
|
||||
self.assertNotIn(('can_delete', view_menu), gamma_perm_set)
|
||||
self.assertNotIn(('can_edit', view_menu), gamma_perm_set)
|
||||
self.assertNotIn(('can_save', view_menu), gamma_perm_set)
|
||||
|
||||
def assert_can_all(view_menu):
|
||||
assert_can_read(view_menu)
|
||||
assert_can_write(view_menu)
|
||||
|
||||
gamma_perm_set = set()
|
||||
for perm in security_manager.find_role('Gamma').permissions:
|
||||
gamma_perm_set.add((perm.permission.name, perm.view_menu.name))
|
||||
|
||||
# check read only perms
|
||||
assert_can_read('TableModelView')
|
||||
assert_cannot_write('DruidColumnInlineView')
|
||||
|
||||
# make sure that user can create slices and dashboards
|
||||
assert_can_all('SliceModelView')
|
||||
assert_can_all('DashboardModelView')
|
||||
|
||||
self.assertIn(('can_add_slices', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_copy_dash', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_created_dashboards', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_created_slices', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_csv', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_dashboard', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_explore', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_explore_json', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_fave_dashboards', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_fave_slices', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_save_dash', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_slice', 'Superset'), gamma_perm_set)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
@ -516,6 +516,21 @@ class DruidTests(SupersetTestCase):
|
|||
instance.timeseries.call_args[1]['granularity']['period'],
|
||||
)
|
||||
|
||||
@patch('superset.connectors.druid.models.PyDruid')
|
||||
def test_external_metadata(self, PyDruid):
|
||||
self.login(username='admin')
|
||||
self.login(username='admin')
|
||||
cluster = self.get_cluster(PyDruid)
|
||||
cluster.refresh_datasources()
|
||||
datasource = cluster.datasources[0]
|
||||
url = '/datasource/external_metadata/druid/{}/'.format(datasource.id)
|
||||
resp = self.get_json_resp(url)
|
||||
col_names = {o.get('name') for o in resp}
|
||||
self.assertEquals(
|
||||
col_names,
|
||||
{'__time', 'dim1', 'dim2', 'metric1'},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -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%',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -144,7 +144,7 @@ class RolePermissionTests(SupersetTestCase):
|
|||
self.assertTrue(security_manager.is_gamma_pvm(
|
||||
security_manager.find_permission_view_menu('can_show', 'TableModelView')))
|
||||
|
||||
def test_gamma_permissions(self):
|
||||
def test_gamma_permissions_basic(self):
|
||||
self.assert_can_gamma(get_perm_tuples('Gamma'))
|
||||
self.assert_cannot_gamma(get_perm_tuples('Gamma'))
|
||||
self.assert_cannot_alpha(get_perm_tuples('Alpha'))
|
||||
|
|
@ -175,3 +175,50 @@ class RolePermissionTests(SupersetTestCase):
|
|||
|
||||
self.assert_cannot_gamma(granter_set)
|
||||
self.assert_cannot_alpha(granter_set)
|
||||
|
||||
def test_gamma_permissions(self):
|
||||
def assert_can_read(view_menu):
|
||||
self.assertIn(('can_show', view_menu), gamma_perm_set)
|
||||
self.assertIn(('can_list', view_menu), gamma_perm_set)
|
||||
|
||||
def assert_can_write(view_menu):
|
||||
self.assertIn(('can_add', view_menu), gamma_perm_set)
|
||||
self.assertIn(('can_download', view_menu), gamma_perm_set)
|
||||
self.assertIn(('can_delete', view_menu), gamma_perm_set)
|
||||
self.assertIn(('can_edit', view_menu), gamma_perm_set)
|
||||
|
||||
def assert_cannot_write(view_menu):
|
||||
self.assertNotIn(('can_add', view_menu), gamma_perm_set)
|
||||
self.assertNotIn(('can_download', view_menu), gamma_perm_set)
|
||||
self.assertNotIn(('can_delete', view_menu), gamma_perm_set)
|
||||
self.assertNotIn(('can_edit', view_menu), gamma_perm_set)
|
||||
self.assertNotIn(('can_save', view_menu), gamma_perm_set)
|
||||
|
||||
def assert_can_all(view_menu):
|
||||
assert_can_read(view_menu)
|
||||
assert_can_write(view_menu)
|
||||
|
||||
gamma_perm_set = set()
|
||||
for perm in security_manager.find_role('Gamma').permissions:
|
||||
gamma_perm_set.add((perm.permission.name, perm.view_menu.name))
|
||||
|
||||
# check read only perms
|
||||
assert_can_read('TableModelView')
|
||||
assert_cannot_write('DruidColumnInlineView')
|
||||
|
||||
# make sure that user can create slices and dashboards
|
||||
assert_can_all('SliceModelView')
|
||||
assert_can_all('DashboardModelView')
|
||||
|
||||
self.assertIn(('can_add_slices', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_copy_dash', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_created_dashboards', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_created_slices', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_csv', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_dashboard', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_explore', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_explore_json', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_fave_dashboards', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_fave_slices', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_save_dash', 'Superset'), gamma_perm_set)
|
||||
self.assertIn(('can_slice', 'Superset'), gamma_perm_set)
|
||||
|
|
|
|||
Loading…
Reference in New Issue