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:
Maxime Beauchemin 2018-08-06 15:30:13 -07:00 committed by GitHub
parent aa14bac5c7
commit 68ba63fcd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1919 additions and 356 deletions

View File

@ -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);
});
});

View File

@ -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';

View File

@ -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,

View File

@ -1,4 +1,4 @@
import { datasourceId } from './mockDatasource';
import { datasourceId } from '../../../fixtures/mockDatasource';
export const sliceId = 18;

View File

@ -1,5 +1,5 @@
import { sliceId as id } from './mockChartQueries';
import { datasourceId } from './mockDatasource';
import { datasourceId } from '../../../fixtures/mockDatasource';
export const sliceId = id;

View File

@ -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 {

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});
});

View File

@ -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', () => {

View File

@ -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 = {

View File

@ -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', () => {

View File

@ -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: {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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';
}

View File

@ -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;
}

View File

@ -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;
});
}

View File

@ -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',

View File

@ -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;

View File

@ -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,

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,4 @@
.Datasource .tab-content {
height: 600px;
overflow: auto;
}

View File

@ -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());

View File

@ -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;
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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 (

View File

@ -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 }}
/>

View File

@ -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 : () => {},
}),
},

View File

@ -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,

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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,

View File

@ -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;

View File

@ -469,3 +469,6 @@ g.annotation-container {
height: 30px;
padding-left: 10px;
}
.align-right {
text-align: right;
}

View File

@ -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}

View File

@ -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)

View File

@ -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 {

View File

@ -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',

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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)

52
tests/datasource_tests.py Normal file
View File

@ -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])

View File

@ -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
tests/fixtures/__init__.py vendored Normal file
View File

148
tests/fixtures/datasource.py vendored Normal file
View File

@ -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%',
},
],
}

View File

@ -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)