[table editor] allow selecting physical table (#6046)

* [table editor] allow selecting physical table

* Using classes for padding
This commit is contained in:
Maxime Beauchemin 2019-01-15 08:53:24 -08:00 committed by GitHub
parent 4667f0c288
commit da813b7ee3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 628 additions and 472 deletions

View File

@ -0,0 +1,201 @@
import React from 'react';
import configureStore from 'redux-mock-store';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import fetchMock from 'fetch-mock';
import thunk from 'redux-thunk';
import { table, defaultQueryEditor, initialState, tables } from '../sqllab/fixtures';
import TableSelector from '../../../src/components/TableSelector';
describe('TableSelector', () => {
let mockedProps;
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const store = mockStore(initialState);
let wrapper;
let inst;
beforeEach(() => {
mockedProps = {
dbId: 1,
schema: 'main',
onSchemaChange: sinon.stub(),
onDbChange: sinon.stub(),
getDbList: sinon.stub(),
onTableChange: sinon.stub(),
onChange: sinon.stub(),
tableNameSticky: true,
tableName: '',
database: { id: 1, database_name: 'main' },
horizontal: false,
sqlLabMode: true,
clearable: false,
handleError: sinon.stub(),
};
wrapper = shallow(<TableSelector {...mockedProps} />, {
context: { store },
});
inst = wrapper.instance();
});
it('is valid', () => {
expect(React.isValidElement(<TableSelector {...mockedProps} />)).toBe(true);
});
describe('onDatabaseChange', () => {
it('should fetch schemas', () => {
sinon.stub(inst, 'fetchSchemas');
inst.onDatabaseChange({ id: 1 });
expect(inst.fetchSchemas.getCall(0).args[0]).toBe(1);
inst.fetchSchemas.restore();
});
it('should clear tableOptions', () => {
inst.onDatabaseChange();
expect(wrapper.state().tableOptions).toEqual([]);
});
});
describe('getTableNamesBySubStr', () => {
const GET_TABLE_NAMES_GLOB = 'glob:*/superset/tables/1/main/*';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should handle empty', () =>
inst
.getTableNamesBySubStr('')
.then((data) => {
expect(data).toEqual({ options: [] });
}));
it('should handle table name', () => {
const queryEditor = {
...defaultQueryEditor,
dbId: 1,
schema: 'main',
};
const mockTableOptions = { options: [table] };
wrapper.setProps({ queryEditor });
fetchMock.get(GET_TABLE_NAMES_GLOB, mockTableOptions, { overwriteRoutes: true });
wrapper
.instance()
.getTableNamesBySubStr('my table')
.then((data) => {
expect(fetchMock.calls(GET_TABLE_NAMES_GLOB)).toHaveLength(1);
expect(data).toEqual(mockTableOptions);
});
});
});
describe('fetchTables', () => {
const FETCH_TABLES_GLOB = 'glob:*/superset/tables/1/main/*/*/';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should clear table options', () => {
inst.fetchTables(true);
expect(wrapper.state().tableOptions).toEqual([]);
expect(wrapper.state().filterOptions).toBeNull();
});
it('should fetch table options', () => {
fetchMock.get(FETCH_TABLES_GLOB, tables, { overwriteRoutes: true });
inst
.fetchTables(true, 'birth_names')
.then(() => {
expect(wrapper.state().tableOptions).toHaveLength(3);
});
});
it('should dispatch a danger toast on error', () => {
fetchMock.get(FETCH_TABLES_GLOB, { throws: 'error' }, { overwriteRoutes: true });
wrapper
.instance()
.fetchTables(true, 'birth_names')
.then(() => {
expect(wrapper.state().tableOptions).toEqual([]);
expect(wrapper.state().tableOptions).toHaveLength(0);
expect(mockedProps.handleError.callCount).toBe(1);
});
});
});
describe('fetchSchemas', () => {
const FETCH_SCHEMAS_GLOB = 'glob:*/superset/schemas/*/*/';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should fetch schema options', () => {
const schemaOptions = {
schemas: ['main', 'erf', 'superset'],
};
fetchMock.get(FETCH_SCHEMAS_GLOB, schemaOptions, { overwriteRoutes: true });
wrapper
.instance()
.fetchSchemas(1)
.then(() => {
expect(fetchMock.calls(FETCH_SCHEMAS_GLOB)).toHaveLength(1);
expect(wrapper.state().schemaOptions).toHaveLength(3);
});
});
it('should dispatch a danger toast on error', () => {
const handleErrors = sinon.stub();
expect(handleErrors.callCount).toBe(0);
wrapper.setProps({ handleErrors });
fetchMock.get(FETCH_SCHEMAS_GLOB, { throws: new Error('Bad kitty') }, { overwriteRoutes: true });
wrapper
.instance()
.fetchSchemas(123)
.then(() => {
expect(wrapper.state().schemaOptions).toEqual([]);
expect(handleErrors.callCount).toBe(1);
});
});
});
describe('changeTable', () => {
beforeEach(() => {
sinon.stub(wrapper.instance(), 'fetchTables');
});
afterEach(() => {
wrapper.instance().fetchTables.restore();
});
it('test 1', () => {
wrapper.instance().changeTable({
value: 'birth_names',
label: 'birth_names',
});
expect(wrapper.state().tableName).toBe('birth_names');
});
it('test 2', () => {
wrapper.instance().changeTable({
value: 'main.my_table',
label: 'my_table',
});
expect(mockedProps.onTableChange.getCall(0).args[0]).toBe('my_table');
expect(mockedProps.onTableChange.getCall(0).args[1]).toBe('main');
});
});
it('changeSchema', () => {
sinon.stub(wrapper.instance(), 'fetchTables');
wrapper.instance().changeSchema({ label: 'main', value: 'main' });
expect(wrapper.instance().fetchTables.callCount).toBe(1);
expect(mockedProps.onChange.callCount).toBe(1);
wrapper.instance().changeSchema();
expect(wrapper.instance().fetchTables.callCount).toBe(2);
expect(mockedProps.onChange.callCount).toBe(2);
wrapper.instance().fetchTables.restore();
});
});

View File

@ -2,10 +2,9 @@ import React from 'react';
import configureStore from 'redux-mock-store';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import fetchMock from 'fetch-mock';
import thunk from 'redux-thunk';
import { table, defaultQueryEditor, databases, initialState, tables } from './fixtures';
import { table, defaultQueryEditor, initialState } from './fixtures';
import SqlEditorLeftBar from '../../../src/SqlLab/components/SqlEditorLeftBar';
import TableElement from '../../../src/SqlLab/components/TableElement';
@ -32,7 +31,7 @@ describe('SqlEditorLeftBar', () => {
beforeEach(() => {
wrapper = shallow(<SqlEditorLeftBar {...mockedProps} />, {
context: { store },
}).dive();
});
});
it('is valid', () => {
@ -43,189 +42,4 @@ describe('SqlEditorLeftBar', () => {
expect(wrapper.find(TableElement)).toHaveLength(1);
});
describe('onDatabaseChange', () => {
it('should fetch schemas', () => {
sinon.stub(wrapper.instance(), 'fetchSchemas');
wrapper.instance().onDatabaseChange({ value: 1, label: 'main' });
expect(wrapper.instance().fetchSchemas.getCall(0).args[0]).toBe(1);
wrapper.instance().fetchSchemas.restore();
});
it('should clear tableOptions', () => {
wrapper.instance().onDatabaseChange();
expect(wrapper.state().tableOptions).toEqual([]);
});
});
describe('getTableNamesBySubStr', () => {
const GET_TABLE_NAMES_GLOB = 'glob:*/superset/tables/1/main/*';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should handle empty', () =>
wrapper
.instance()
.getTableNamesBySubStr('')
.then((data) => {
expect(data).toEqual({ options: [] });
}));
it('should handle table name', () => {
const queryEditor = {
...defaultQueryEditor,
dbId: 1,
schema: 'main',
};
const mockTableOptions = { options: [table] };
wrapper.setProps({ queryEditor });
fetchMock.get(GET_TABLE_NAMES_GLOB, mockTableOptions, { overwriteRoutes: true });
return wrapper
.instance()
.getTableNamesBySubStr('my table')
.then((data) => {
expect(fetchMock.calls(GET_TABLE_NAMES_GLOB)).toHaveLength(1);
expect(data).toEqual(mockTableOptions);
});
});
});
it('dbMutator should build databases options', () => {
const options = wrapper.instance().dbMutator(databases);
expect(options).toEqual([
{ value: 1, label: 'main' },
{ value: 208, label: 'Presto - Gold' },
]);
});
describe('fetchTables', () => {
const FETCH_TABLES_GLOB = 'glob:*/superset/tables/1/main/birth_names/true/';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should clear table options', () => {
wrapper.instance().fetchTables(1);
expect(wrapper.state().tableOptions).toEqual([]);
expect(wrapper.state().filterOptions).toBeNull();
});
it('should fetch table options', () => {
expect.assertions(2);
fetchMock.get(FETCH_TABLES_GLOB, tables, { overwriteRoutes: true });
return wrapper
.instance()
.fetchTables(1, 'main', true, 'birth_names')
.then(() => {
expect(fetchMock.calls(FETCH_TABLES_GLOB)).toHaveLength(1);
expect(wrapper.state().tableLength).toBe(3);
});
});
it('should dispatch a danger toast on error', () => {
const dangerToastSpy = sinon.spy();
wrapper.setProps({
actions: {
addDangerToast: dangerToastSpy,
},
});
expect.assertions(4);
fetchMock.get(FETCH_TABLES_GLOB, { throws: 'error' }, { overwriteRoutes: true });
return wrapper
.instance()
.fetchTables(1, 'main', true, 'birth_names')
.then(() => {
expect(fetchMock.calls(FETCH_TABLES_GLOB)).toHaveLength(1);
expect(wrapper.state().tableOptions).toEqual([]);
expect(wrapper.state().tableLength).toBe(0);
expect(dangerToastSpy.callCount).toBe(1);
});
});
});
describe('fetchSchemas', () => {
const FETCH_SCHEMAS_GLOB = 'glob:*/superset/schemas/*';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should fetch schema options', () => {
expect.assertions(2);
const schemaOptions = {
schemas: ['main', 'erf', 'superset'],
};
fetchMock.get(FETCH_SCHEMAS_GLOB, schemaOptions, { overwriteRoutes: true });
return wrapper
.instance()
.fetchSchemas(1)
.then(() => {
expect(fetchMock.calls(FETCH_SCHEMAS_GLOB)).toHaveLength(1);
expect(wrapper.state().schemaOptions).toHaveLength(3);
});
});
it('should dispatch a danger toast on error', () => {
const dangerToastSpy = sinon.spy();
wrapper.setProps({
actions: {
addDangerToast: dangerToastSpy,
},
});
expect.assertions(3);
fetchMock.get(FETCH_SCHEMAS_GLOB, { throws: 'error' }, { overwriteRoutes: true });
return wrapper
.instance()
.fetchSchemas(123)
.then(() => {
expect(fetchMock.calls(FETCH_SCHEMAS_GLOB)).toHaveLength(1);
expect(wrapper.state().schemaOptions).toEqual([]);
expect(dangerToastSpy.callCount).toBe(1);
});
});
});
describe('changeTable', () => {
beforeEach(() => {
sinon.stub(wrapper.instance(), 'fetchTables');
});
afterEach(() => {
wrapper.instance().fetchTables.restore();
});
it('test 1', () => {
wrapper.instance().changeTable({
value: 'birth_names',
label: 'birth_names',
});
expect(wrapper.state().tableName).toBe('birth_names');
});
it('test 2', () => {
wrapper.instance().changeTable({
value: 'main.my_table',
label: 'my_table',
});
expect(wrapper.instance().fetchTables.getCall(0).args[1]).toBe('main');
});
});
it('changeSchema', () => {
sinon.stub(wrapper.instance(), 'fetchTables');
wrapper.instance().changeSchema({ label: 'main', value: 'main' });
expect(wrapper.instance().fetchTables.getCall(0).args[1]).toBe('main');
wrapper.instance().changeSchema();
expect(wrapper.instance().fetchTables.getCall(1).args[1]).toBeNull();
wrapper.instance().fetchTables.restore();
});
});

View File

@ -309,7 +309,6 @@ export const databases = {
],
};
export const tables = {
tableLength: 3,
options: [
{
value: 'birth_names',

View File

@ -1,15 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ControlLabel, Button } from 'react-bootstrap';
import { connect } from 'react-redux';
import Select from 'react-virtualized-select';
import createFilterOptions from 'react-select-fast-filter-options';
import { Button } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import { SupersetClient } from '@superset-ui/connection';
import TableElement from './TableElement';
import AsyncSelect from '../../components/AsyncSelect';
import RefreshLabel from '../../components/RefreshLabel';
import TableSelector from '../../components/TableSelector';
const propTypes = {
queryEditor: PropTypes.object.isRequired,
@ -26,7 +21,7 @@ const defaultProps = {
offline: false,
};
class SqlEditorLeftBar extends React.PureComponent {
export default class SqlEditorLeftBar extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
@ -35,33 +30,23 @@ class SqlEditorLeftBar extends React.PureComponent {
tableLoading: false,
tableOptions: [],
};
this.resetState = this.resetState.bind(this);
this.onSchemaChange = this.onSchemaChange.bind(this);
this.onDbChange = this.onDbChange.bind(this);
this.getDbList = this.getDbList.bind(this);
this.onTableChange = this.onTableChange.bind(this);
}
componentWillMount() {
this.fetchSchemas(this.props.queryEditor.dbId);
this.fetchTables(this.props.queryEditor.dbId, this.props.queryEditor.schema);
onSchemaChange(schema) {
this.props.actions.queryEditorSetSchema(this.props.queryEditor, schema);
}
onDatabaseChange(db, force) {
const val = db ? db.value : null;
this.setState(() => ({ schemaOptions: [], tableOptions: [] }));
this.props.actions.queryEditorSetSchema(this.props.queryEditor, null);
this.props.actions.queryEditorSetDb(this.props.queryEditor, val);
if (db) {
this.fetchSchemas(val, force || false);
}
onDbChange(db) {
this.props.actions.queryEditorSetDb(this.props.queryEditor, db.id);
}
getTableNamesBySubStr(input) {
if (this.props.offline || !this.props.queryEditor.dbId || !input) {
return Promise.resolve({ options: [] });
}
return SupersetClient.get({
endpoint: `/superset/tables/${this.props.queryEditor.dbId}/${
this.props.queryEditor.schema
}/${input}`,
}).then(({ json }) => ({ options: json.options }));
onTableChange(tableName, schemaName) {
this.props.actions.addTable(this.props.queryEditor, tableName, schemaName);
}
getDbList(dbs) {
this.props.actions.setDatabases(dbs);
}
dbMutator(data) {
@ -76,34 +61,6 @@ class SqlEditorLeftBar extends React.PureComponent {
resetState() {
this.props.actions.resetState();
}
fetchTables(dbId, schema, force, substr) {
// This can be large so it shouldn't be put in the Redux store
const forceRefresh = force || false;
if (!this.props.offline && dbId && schema) {
this.setState(() => ({ tableLoading: true, tableOptions: [] }));
const endpoint = `/superset/tables/${dbId}/${schema}/${substr}/${forceRefresh}/`;
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const filterOptions = createFilterOptions({ options: json.options });
this.setState(() => ({
filterOptions,
tableLoading: false,
tableOptions: json.options,
tableLength: json.tableLength,
}));
})
.catch(() => {
this.setState(() => ({ tableLoading: false, tableOptions: [], tableLength: 0 }));
this.props.actions.addDangerToast(t('Error while fetching table list'));
});
}
this.setState(() => ({ tableLoading: false, tableOptions: [], filterOptions: null }));
return Promise.resolve();
}
changeTable(tableOpt) {
if (!tableOpt) {
this.setState({ tableName: '' });
@ -119,156 +76,30 @@ class SqlEditorLeftBar extends React.PureComponent {
tableName = namePieces[1];
this.setState({ tableName });
this.props.actions.queryEditorSetSchema(this.props.queryEditor, schemaName);
this.fetchTables(this.props.queryEditor.dbId, schemaName);
}
this.props.actions.addTable(this.props.queryEditor, tableName, schemaName);
}
changeSchema(schemaOpt, force) {
const schema = schemaOpt ? schemaOpt.value : null;
this.props.actions.queryEditorSetSchema(this.props.queryEditor, schema);
this.fetchTables(this.props.queryEditor.dbId, schema, force);
}
fetchSchemas(dbId, force) {
const actualDbId = dbId || this.props.queryEditor.dbId;
const forceRefresh = force || false;
if (!this.props.offline && actualDbId) {
this.setState({ schemaLoading: true });
const endpoint = `/superset/schemas/${actualDbId}/${forceRefresh}/`;
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const schemaOptions = json.schemas.map(s => ({ value: s, label: s }));
this.setState({ schemaOptions, schemaLoading: false });
})
.catch(() => {
this.setState({ schemaLoading: false, schemaOptions: [] });
this.props.actions.addDangerToast(t('Error while fetching schema list'));
});
}
return Promise.resolve();
}
closePopover(ref) {
this.refs[ref].hide();
}
render() {
const shouldShowReset = window.location.search === '?reset=1';
const tableMetaDataHeight = this.props.height - 130; // 130 is the height of the selects above
let tableSelectPlaceholder;
let tableSelectDisabled = false;
if (this.props.database && this.props.database.allow_multi_schema_metadata_fetch) {
tableSelectPlaceholder = t('Type to search ...');
} else {
tableSelectPlaceholder = t('Select table ');
tableSelectDisabled = true;
}
const database = this.props.database || {};
const qe = this.props.queryEditor;
return (
<div className="clearfix">
<div>
<AsyncSelect
dataEndpoint={
'/databaseasync/api/' +
'read?_flt_0_expose_in_sqllab=1&' +
'_oc_DatabaseAsync=database_name&' +
'_od_DatabaseAsync=asc'
}
onChange={this.onDatabaseChange.bind(this)}
onAsyncError={() => {
this.props.actions.addDangerToast(t('Error while fetching database list'));
}}
value={this.props.queryEditor.dbId}
databaseId={this.props.queryEditor.dbId}
actions={this.props.actions}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Database:')}</span> {o.label}
</div>
)}
mutator={this.dbMutator.bind(this)}
placeholder={t('Select a database')}
autoSelect
/>
</div>
<div className="m-t-5">
<div className="row">
<div className="col-md-11 col-xs-11" style={{ paddingRight: '2px' }}>
<Select
name="select-schema"
placeholder={t('Select a schema (%s)', this.state.schemaOptions.length)}
options={this.state.schemaOptions}
value={this.props.queryEditor.schema}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
)}
isLoading={this.state.schemaLoading}
autosize={false}
onChange={this.changeSchema.bind(this)}
/>
</div>
<div className="col-md-1 col-xs-1" style={{ paddingTop: '8px', paddingLeft: '0px' }}>
<RefreshLabel
onClick={this.onDatabaseChange.bind(
this, { value: database.id }, true)}
tooltipContent={t('force refresh schema list')}
/>
</div>
</div>
</div>
<hr />
<div className="m-t-5">
<ControlLabel>
{t('See table schema')}
&nbsp;
<small>
({this.state.tableOptions.length}
&nbsp;
{t('in')}
&nbsp;
<i>{this.props.queryEditor.schema}</i>)
</small>
</ControlLabel>
<div className="row">
<div className="col-md-11 col-xs-11" style={{ paddingRight: '2px' }}>
{this.props.queryEditor.schema ? (
<Select
name="select-table"
ref="selectTable"
isLoading={this.state.tableLoading}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={this.changeTable.bind(this)}
filterOptions={this.state.filterOptions}
options={this.state.tableOptions}
/>
) : (
<Select
async
name="async-select-table"
ref="selectTable"
placeholder={tableSelectPlaceholder}
disabled={tableSelectDisabled}
autosize={false}
onChange={this.changeTable.bind(this)}
loadOptions={this.getTableNamesBySubStr.bind(this)}
/>
)}
</div>
<div className="col-md-1 col-xs-1" style={{ paddingTop: '8px', paddingLeft: '0px' }}>
<RefreshLabel
onClick={this.changeSchema.bind(
this, { value: this.props.queryEditor.schema }, true)}
tooltipContent={t('force refresh table list')}
/>
</div>
</div>
</div>
<TableSelector
dbId={qe.dbId}
schema={qe.schema}
onDbChange={this.onDbChange}
onSchemaChange={this.onSchemaChange}
getDbList={this.getDbList}
onTableChange={this.onTableChange}
tableNameSticky={false}
database={this.props.database}
handleError={this.props.actions.addDangerToast}
/>
<hr />
<div className="m-t-5">
<div className="scrollbar-container">
@ -279,23 +110,14 @@ class SqlEditorLeftBar extends React.PureComponent {
</div>
</div>
</div>
{shouldShowReset && (
<Button bsSize="small" bsStyle="danger" onClick={this.resetState.bind(this)}>
{shouldShowReset &&
<Button bsSize="small" bsStyle="danger" onClick={this.resetState}>
<i className="fa fa-bomb" /> {t('Reset State')}
</Button>
)}
</Button>}
</div>
);
}
}
function mapStateToProps({ sqlLab }) {
return {
offline: sqlLab.offline,
};
}
SqlEditorLeftBar.propTypes = propTypes;
SqlEditorLeftBar.defaultProps = defaultProps;
export default connect(mapStateToProps)(SqlEditorLeftBar);

View File

@ -14,14 +14,12 @@ const propTypes = {
PropTypes.number,
PropTypes.arrayOf(PropTypes.number),
]),
valueRenderer: PropTypes.func,
placeholder: PropTypes.string,
autoSelect: PropTypes.bool,
};
const defaultProps = {
placeholder: t('Select ...'),
valueRenderer: o => (<div>{o.label}</div>),
onAsyncError: () => {},
};

View File

@ -0,0 +1,321 @@
import React from 'react';
import PropTypes from 'prop-types';
import Select from 'react-virtualized-select';
import createFilterOptions from 'react-select-fast-filter-options';
import { ControlLabel, Col, Label } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import { SupersetClient } from '@superset-ui/connection';
import AsyncSelect from './AsyncSelect';
import RefreshLabel from './RefreshLabel';
const propTypes = {
dbId: PropTypes.number.isRequired,
schema: PropTypes.string,
onSchemaChange: PropTypes.func,
onDbChange: PropTypes.func,
getDbList: PropTypes.func,
onTableChange: PropTypes.func,
tableNameSticky: PropTypes.bool,
tableName: PropTypes.string,
database: PropTypes.object,
horizontal: PropTypes.bool,
sqlLabMode: PropTypes.bool,
onChange: PropTypes.func,
clearable: PropTypes.bool,
handleError: PropTypes.func.isRequired,
};
const defaultProps = {
onDbChange: () => {},
onSchemaChange: () => {},
getDbList: () => {},
onTableChange: () => {},
onChange: () => {},
tableNameSticky: true,
horizontal: false,
sqlLabMode: true,
clearable: true,
};
export default class TableSelector extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
schemaLoading: false,
schemaOptions: [],
tableLoading: false,
tableOptions: [],
dbId: props.dbId,
schema: props.schema,
tableName: props.tableName,
filterOptions: null,
};
this.changeSchema = this.changeSchema.bind(this);
this.changeTable = this.changeTable.bind(this);
this.dbMutator = this.dbMutator.bind(this);
this.getTableNamesBySubStr = this.getTableNamesBySubStr.bind(this);
this.onChange = this.onChange.bind(this);
this.onDatabaseChange = this.onDatabaseChange.bind(this);
}
componentDidMount() {
this.fetchSchemas(this.state.dbId);
this.fetchTables();
}
onDatabaseChange(db, force = false) {
const dbId = db ? db.id : null;
this.setState({ schemaOptions: [] });
this.props.onSchemaChange(null);
this.props.onDbChange(db);
this.fetchSchemas(dbId, force);
this.setState({ dbId, schema: null, tableOptions: [] }, this.onChange);
}
onChange() {
this.props.onChange({
dbId: this.state.dbId,
shema: this.state.schema,
tableName: this.state.tableName,
});
}
getTableNamesBySubStr(input) {
const { tableName } = this.state;
if (!this.props.dbId || !input) {
const options = this.addOptionIfMissing([], tableName);
return Promise.resolve({ options });
}
return SupersetClient.get({
endpoint: (
`/superset/tables/${this.props.dbId}/` +
`${this.props.schema}/${input}`),
}).then(({ json }) => ({ options: this.addOptionIfMissing(json.options, tableName) }));
}
dbMutator(data) {
this.props.getDbList(data.result);
if (data.result.length === 0) {
this.props.handleError(t("It seems you don't have access to any database"));
}
return data.result;
}
fetchTables(force, substr) {
// This can be large so it shouldn't be put in the Redux store
const forceRefresh = force || false;
const { dbId, schema } = this.props;
if (dbId && schema) {
this.setState(() => ({ tableLoading: true, tableOptions: [] }));
const endpoint = `/superset/tables/${dbId}/${schema}/${substr}/${forceRefresh}/`;
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const filterOptions = createFilterOptions({ options: json.options });
this.setState(() => ({
filterOptions,
tableLoading: false,
tableOptions: json.options,
}));
})
.catch(() => {
this.setState(() => ({ tableLoading: false, tableOptions: [] }));
this.props.handleError(t('Error while fetching table list'));
});
}
this.setState(() => ({ tableLoading: false, tableOptions: [], filterOptions: null }));
return Promise.resolve();
}
fetchSchemas(dbId, force) {
const actualDbId = dbId || this.props.dbId;
const forceRefresh = force || false;
if (actualDbId) {
this.setState({ schemaLoading: true });
const endpoint = `/superset/schemas/${actualDbId}/${forceRefresh}/`;
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const schemaOptions = json.schemas.map(s => ({ value: s, label: s }));
this.setState({ schemaOptions, schemaLoading: false });
})
.catch(() => {
this.setState({ schemaLoading: false, schemaOptions: [] });
this.props.handleError(t('Error while fetching schema list'));
});
}
return Promise.resolve();
}
changeTable(tableOpt) {
if (!tableOpt) {
this.setState({ tableName: '' });
return;
}
const namePieces = tableOpt.value.split('.');
let tableName = namePieces[0];
let schemaName = this.props.schema;
if (namePieces.length > 1) {
schemaName = namePieces[0];
tableName = namePieces[1];
}
if (this.props.tableNameSticky) {
this.setState({ tableName }, this.onChange);
}
this.props.onTableChange(tableName, schemaName);
}
changeSchema(schemaOpt) {
const schema = schemaOpt ? schemaOpt.value : null;
this.props.onSchemaChange(schema);
this.setState({ schema }, () => {
this.fetchTables();
this.onChange();
});
}
addOptionIfMissing(options, value) {
if (options.filter(o => o.value === this.state.tableName).length === 0 && value) {
return [...options, { value, label: value }];
}
return options;
}
renderDatabaseOption(db) {
return (
<span>
<Label bsStyle="default" className="m-r-5">{db.backend}</Label>
{db.database_name}
</span>);
}
renderDatabaseSelect() {
return (
<AsyncSelect
dataEndpoint={
'/databaseasync/api/' +
'read?_flt_0_expose_in_sqllab=1&' +
'_oc_DatabaseAsync=database_name&' +
'_od_DatabaseAsync=asc'
}
onChange={this.onDatabaseChange}
onAsyncError={() => this.props.handleError(t('Error while fetching database list'))}
clearable={false}
value={this.state.dbId}
valueKey="id"
valueRenderer={db => (
<div>
<span className="text-muted m-r-5">{t('Database:')}</span>
{this.renderDatabaseOption(db)}
</div>
)}
optionRenderer={this.renderDatabaseOption}
mutator={this.dbMutator}
placeholder={t('Select a database')}
autoSelect
/>);
}
renderSchema() {
return (
<div className="m-t-5">
<div className="row">
<div className="col-md-11 col-xs-11 p-r-2">
<Select
name="select-schema"
placeholder={t('Select a schema (%s)', this.state.schemaOptions.length)}
options={this.state.schemaOptions}
value={this.props.schema}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
)}
isLoading={this.state.schemaLoading}
autosize={false}
onChange={this.changeSchema}
/>
</div>
<div className="col-md-1 col-xs-1 p-l-0 p-t-8">
<RefreshLabel
onClick={() => this.onDatabaseChange({ id: this.props.dbId }, true)}
tooltipContent={t('force refresh schema list')}
/>
</div>
</div>
</div>
);
}
renderTable() {
let tableSelectPlaceholder;
let tableSelectDisabled = false;
if (this.props.database && this.props.database.allow_multi_schema_metadata_fetch) {
tableSelectPlaceholder = t('Type to search ...');
} else {
tableSelectPlaceholder = t('Select table ');
tableSelectDisabled = true;
}
const options = this.addOptionIfMissing(this.state.tableOptions, this.state.tableName);
return (
<div className="m-t-5">
<div className="row">
<div className="col-md-11 col-xs-11 p-r-2">
{this.props.schema ? (
<Select
name="select-table"
ref="selectTable"
isLoading={this.state.tableLoading}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={this.changeTable}
filterOptions={this.state.filterOptions}
options={options}
value={this.state.tableName}
/>
) : (
<Select
async
name="async-select-table"
ref="selectTable"
placeholder={tableSelectPlaceholder}
disabled={tableSelectDisabled}
autosize={false}
onChange={this.changeTable}
value={this.state.tableName}
loadOptions={this.getTableNamesBySubStr}
/>
)}
</div>
<div className="col-md-1 col-xs-1 p-l-0 p-t-8">
<RefreshLabel
onClick={() => this.changeSchema({ value: this.props.schema }, true)}
tooltipContent={t('force refresh table list')}
/>
</div>
</div>
</div>);
}
renderSeeTableLabel() {
return (
<div>
<hr />
<ControlLabel>
{t('See table schema')}{' '}
<small>
({this.state.tableOptions.length}
{' '}{t('in')}{' '}
<i>
{this.props.schema}
</i>)
</small>
</ControlLabel>
</div>);
}
render() {
if (this.props.horizontal) {
return (
<div>
<Col md={4}>{this.renderDatabaseSelect()}</Col>
<Col md={4}>{this.renderSchema()}</Col>
<Col md={4}>{this.renderTable()}</Col>
</div>);
}
return (
<div>
<div>{this.renderDatabaseSelect()}</div>
<div className="m-t-5">{this.renderSchema()}</div>
{this.props.sqlLabMode && this.renderSeeTableLabel()}
<div className="m-t-5">{this.renderTable()}</div>
</div>);
}
}
TableSelector.propTypes = propTypes;
TableSelector.defaultProps = defaultProps;

View File

@ -8,6 +8,7 @@ import getClientErrorObject from '../utils/getClientErrorObject';
import Button from '../components/Button';
import Loading from '../components/Loading';
import TableSelector from '../components/TableSelector';
import CheckboxControl from '../explore/components/controls/CheckboxControl';
import TextControl from '../explore/components/controls/TextControl';
import SelectControl from '../explore/components/controls/SelectControl';
@ -219,9 +220,8 @@ export class DatasourceEditor extends React.PureComponent {
};
this.props.onChange(datasource, this.state.errors);
}
onDatasourceChange(newDatasource) {
this.setState({ datasource: newDatasource }, this.validateAndChange);
onDatasourceChange(datasource) {
this.setState({ datasource }, this.validateAndChange);
}
onDatasourcePropChange(attr, value) {
@ -260,11 +260,15 @@ export class DatasourceEditor extends React.PureComponent {
}
syncMetadata() {
const { datasource } = this.state;
const endpoint = (
`/datasource/external_metadata/${datasource.type}/${datasource.id}/` +
`?db_id=${datasource.database.id}` +
`&schema=${datasource.schema}` +
`&table_name=${datasource.datasource_name}`
);
this.setState({ metadataLoading: true });
SupersetClient.get({
endpoint: `/datasource/external_metadata/${datasource.type}/${datasource.id}/`,
}).then(({ json }) => {
SupersetClient.get({ endpoint }).then(({ json }) => {
this.mergeColumns(json);
this.props.addSuccessToast(t('Metadata has been synced'));
this.setState({ metadataLoading: false });
@ -319,6 +323,27 @@ export class DatasourceEditor extends React.PureComponent {
const datasource = this.state.datasource;
return (
<Fieldset title={t('Basic')} item={datasource} onChange={this.onDatasourceChange}>
{this.state.isSqla &&
<Field
fieldKey="tableSelector"
label={t('Physical Table')}
control={
<TableSelector
dbId={datasource.database.id}
schema={datasource.schema}
tableName={datasource.datasource_name}
onSchemaChange={schema => this.onDatasourcePropChange('schema', schema)}
onDbChange={database => this.onDatasourcePropChange('database', database)}
onTableChange={table => this.onDatasourcePropChange('datasource_name', table)}
sqlLabMode={false}
clearable={false}
handleError={this.props.addDangerToast}
/>}
descr={t(
'The pointer to a physical table. Keep in mind that the chart is ' +
'associated to this Superset logical table, and this logical table points ' +
'the physical table referenced here.')}
/>}
<Field
fieldKey="description"
label={t('Description')}

View File

@ -1,24 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Col,
Collapse,
Label,
OverlayTrigger,
Row,
Tooltip,
Well,
} from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import ControlHeader from '../ControlHeader';
import DatasourceModal from '../../../datasource/DatasourceModal';
import ColumnOption from '../../../components/ColumnOption';
import MetricOption from '../../../components/MetricOption';
const propTypes = {
onChange: PropTypes.func,
value: PropTypes.string.isRequired,
value: PropTypes.string,
datasource: PropTypes.object.isRequired,
onDatasourceSave: PropTypes.func,
};
@ -26,6 +20,7 @@ const propTypes = {
const defaultProps = {
onChange: () => {},
onDatasourceSave: () => {},
value: null,
};
class DatasourceControl extends React.PureComponent {
@ -58,41 +53,6 @@ class DatasourceControl extends React.PureComponent {
showEditDatasourceModal: !showEditDatasourceModal,
}));
}
renderDatasource() {
const datasource = this.props.datasource;
return (
<div className="m-t-10">
<Well className="m-t-0">
<div className="m-b-10">
<Label>
<i className="fa fa-database" /> {datasource.database.backend}
</Label>
{` ${datasource.database.name} `}
</div>
<Row className="datasource-container">
<Col md={6}>
<strong>Columns</strong>
{datasource.columns.map(col => (
<div key={col.column_name}>
<ColumnOption showType column={col} />
</div>
))}
</Col>
<Col md={6}>
<strong>Metrics</strong>
{datasource.metrics.map(m => (
<div key={m.metric_name}>
<MetricOption metric={m} showType />
</div>
))}
</Col>
</Row>
</Well>
</div>
);
}
render() {
return (
<div>
@ -107,21 +67,6 @@ class DatasourceControl extends React.PureComponent {
{this.props.datasource.name}
</Label>
</OverlayTrigger>
<OverlayTrigger
placement="right"
overlay={
<Tooltip id={'toggle-datasource-tooltip'}>
{t('Expand/collapse datasource configuration')}
</Tooltip>
}
>
<a href="#">
<i
className={`fa fa-${this.state.showDatasource ? 'minus' : 'plus'}-square m-r-5`}
onClick={this.toggleShowDatasource}
/>
</a>
</OverlayTrigger>
{this.props.datasource.type === 'table' &&
<OverlayTrigger
placement="right"
@ -139,7 +84,6 @@ class DatasourceControl extends React.PureComponent {
<i className="fa fa-flask m-r-5" />
</a>
</OverlayTrigger>}
<Collapse in={this.state.showDatasource}>{this.renderDatasource()}</Collapse>
<DatasourceModal
datasource={this.props.datasource}
show={this.state.showEditDatasourceModal}

View File

@ -53,7 +53,7 @@ class DashboardTable extends React.PureComponent {
{this.state.dashboards.map(o => (
<Tr key={o.id}>
<Td column="dashboard" value={o.dashboard_title}>
<a href={o.url}>{o.dashboard_title}</a>
{o.dashboard_title}
</Td>
<Td column="creator" value={o.changed_by_name}>
{unsafe(o.creator)}

View File

@ -278,6 +278,16 @@ table.table-no-hover tr:hover {
.m-l-25 {
margin-left: 25px;
}
.p-l-0 {
padding-left: 0;
}
.p-t-8 {
padding-top: 8;
}
.p-r-2 {
padding-right: 2;
}
.Select-menu-outer {
z-index: 10 !important;
}

View File

@ -311,7 +311,7 @@ class DatabaseAsync(DatabaseView):
'expose_in_sqllab', 'allow_ctas', 'force_ctas_schema',
'allow_run_async', 'allow_dml',
'allow_multi_schema_metadata_fetch', 'allow_csv_upload',
'allows_subquery',
'allows_subquery', 'backend',
]

View File

@ -8,6 +8,7 @@ from flask_babel import gettext as __
from superset import appbuilder, db
from superset.connectors.connector_registry import ConnectorRegistry
from superset.models.core import Database
from .base import BaseSupersetView, check_ownership, json_error_response
@ -42,9 +43,24 @@ class Datasource(BaseSupersetView):
@has_access_api
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())
if datasource_type == 'druid':
datasource = ConnectorRegistry.get_datasource(
datasource_type, datasource_id, db.session)
elif datasource_type == 'table':
database = (
db.session
.query(Database)
.filter_by(id=request.args.get('db_id'))
.one()
)
Table = ConnectorRegistry.sources['table']
datasource = Table(
database=database,
table_name=request.args.get('table_name'),
schema=request.args.get('schema') or None,
)
external_metadata = datasource.external_metadata()
return self.json_response(external_metadata)
appbuilder.add_view_no_menu(Datasource)

View File

@ -12,8 +12,14 @@ class DatasourceTests(SupersetTestCase):
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)
tbl = self.get_table_by_name('birth_names')
schema = tbl.schema or ''
url = (
f'/datasource/external_metadata/table/{tbl.id}/?'
f'db_id={tbl.database.id}&'
f'table_name={tbl.table_name}&'
f'schema={schema}&'
)
resp = self.get_json_resp(url)
col_names = {o.get('name') for o in resp}
self.assertEquals(