Explore to SQL Lab (#5101)

* WIP

* Working version

* Clean code

* Fix lint

* Fix unit test; show only for sqla

* Working on UX

* Dropdown working 66%

* Working but needs CSS

* Fixed table

* Fix lint

* Fix unit test

* Fix languages path

* Fixes

* Fix Javascript lint
This commit is contained in:
Beto Dealmeida 2018-07-16 15:00:48 -07:00 committed by Maxime Beauchemin
parent 48317fd8f9
commit c445ef8c43
15 changed files with 152 additions and 37 deletions

View File

@ -16,15 +16,17 @@ describe('DisplayQueryButton', () => {
},
chartStatus: 'success',
queryEndpoint: 'localhost',
latestQueryFormData: {
datasource: '1__table',
},
};
it('is valid', () => {
expect(React.isValidElement(<DisplayQueryButton {...defaultProps} />)).to.equal(true);
});
it('renders a button and a modal', () => {
it('renders a dropdown', () => {
const wrapper = mount(<DisplayQueryButton {...defaultProps} />);
expect(wrapper.find(ModalTrigger)).to.have.lengthOf(1);
wrapper.find('.modal-trigger').simulate('click');
expect(wrapper.find(Modal)).to.have.lengthOf(1);
expect(wrapper.find(ModalTrigger)).to.have.lengthOf(2);
expect(wrapper.find(Modal)).to.have.lengthOf(2);
});
});

View File

@ -7,6 +7,7 @@ import ExploreActionButtons from
describe('ExploreActionButtons', () => {
const defaultProps = {
actions: {},
canDownload: 'True',
latestQueryFormData: {},
queryEndpoint: 'localhost',

View File

@ -420,6 +420,25 @@ export function popSavedQuery(saveQueryId) {
});
};
}
export function popDatasourceQuery(datasourceKey, sql) {
return function (dispatch) {
$.ajax({
type: 'GET',
url: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
success: (metadata) => {
const queryEditorProps = {
title: 'Query ' + metadata.name,
dbId: metadata.database.id,
schema: metadata.schema,
autorun: sql !== undefined,
sql: sql || metadata.select_star,
};
dispatch(addQueryEditor(queryEditorProps));
},
error: () => notify.error(t("The datasource couldn't be loaded")),
});
};
}
export function createDatasourceStarted() {
return { type: CREATE_DATASOURCE_STARTED };

View File

@ -41,11 +41,13 @@ class TabbedSqlEditors extends React.PureComponent {
}
componentDidMount() {
const query = URI(window.location).search(true);
if (query.id || query.sql || query.savedQueryId) {
if (query.id || query.sql || query.savedQueryId || query.datasourceKey) {
if (query.id) {
this.props.actions.popStoredQuery(query.id);
} else if (query.savedQueryId) {
this.props.actions.popSavedQuery(query.savedQueryId);
} else if (query.datasourceKey) {
this.props.actions.popDatasourceQuery(query.datasourceKey, query.sql);
} else if (query.sql) {
let dbId = query.dbid;
if (dbId) {

View File

@ -201,6 +201,27 @@ export function runQuery(formData, force = false, timeout = 60, key) {
};
}
export function redirectSQLLab(formData) {
return function () {
const { url } = getExploreUrlAndPayload({ formData, endpointType: 'query' });
$.ajax({
type: 'GET',
url,
success: (response) => {
const redirectUrl = new URL(window.location);
redirectUrl.pathname = '/superset/sqllab';
for (const k of redirectUrl.searchParams.keys()) {
redirectUrl.searchParams.delete(k);
}
redirectUrl.searchParams.set('datasourceKey', formData.datasource);
redirectUrl.searchParams.set('sql', response.query);
window.open(redirectUrl.href, '_blank');
},
error: () => notify.error(t("The SQL couldn't be loaded")),
});
};
}
export function refreshChart(chart, force, timeout) {
return (dispatch) => {
if (!chart.latestQueryFormData || Object.keys(chart.latestQueryFormData).length === 0) {

View File

@ -6,6 +6,10 @@ import markdown from 'react-syntax-highlighter/languages/hljs/markdown';
import sql from 'react-syntax-highlighter/languages/hljs/sql';
import json from 'react-syntax-highlighter/languages/hljs/json';
import github from 'react-syntax-highlighter/styles/hljs/github';
import { DropdownButton, MenuItem } from 'react-bootstrap';
import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
import 'react-bootstrap-table/css/react-bootstrap-table.css';
import CopyToClipboard from './../../components/CopyToClipboard';
import { getExploreUrlAndPayload } from '../exploreUtils';
@ -22,6 +26,7 @@ registerLanguage('json', json);
const $ = (window.$ = require('jquery'));
const propTypes = {
onOpenInEditor: PropTypes.func,
animation: PropTypes.bool,
queryResponse: PropTypes.object,
chartStatus: PropTypes.string,
@ -37,21 +42,14 @@ export default class DisplayQueryButton extends React.PureComponent {
this.state = {
language: null,
query: null,
data: null,
isLoading: false,
error: null,
sqlSupported: props.latestQueryFormData.datasource.split('__')[1] === 'table',
};
this.beforeOpen = this.beforeOpen.bind(this);
this.fetchQuery = this.fetchQuery.bind(this);
}
setStateFromQueryResponse() {
const qr = this.props.queryResponse;
this.setState({
language: qr.language,
query: qr.query,
isLoading: false,
});
}
fetchQuery() {
beforeOpen() {
this.setState({ isLoading: true });
const { url, payload } = getExploreUrlAndPayload({
formData: this.props.latestQueryFormData,
@ -67,6 +65,7 @@ export default class DisplayQueryButton extends React.PureComponent {
this.setState({
language: data.language,
query: data.query,
data: data.data,
isLoading: false,
error: null,
});
@ -79,18 +78,10 @@ export default class DisplayQueryButton extends React.PureComponent {
},
});
}
beforeOpen() {
if (
['loading', null].indexOf(this.props.chartStatus) >= 0 ||
!this.props.queryResponse ||
!this.props.queryResponse.query
) {
this.fetchQuery();
} else {
this.setStateFromQueryResponse();
}
redirectSQLLab() {
this.props.onOpenInEditor(this.props.latestQueryFormData);
}
renderModalBody() {
renderQueryModalBody() {
if (this.state.isLoading) {
return <Loading />;
} else if (this.state.error) {
@ -115,17 +106,66 @@ export default class DisplayQueryButton extends React.PureComponent {
}
return null;
}
renderResultsModalBody() {
if (this.state.isLoading) {
return (<img
className="loading"
alt="Loading..."
src="/static/assets/images/loading.gif"
/>);
} else if (this.state.error) {
return <pre>{this.state.error}</pre>;
} else if (this.state.data) {
if (this.state.data.length === 0) {
return 'No data';
}
const headers = Object.keys(this.state.data[0]).map((k, i) => (
<TableHeaderColumn key={k} dataField={k} isKey={i === 0} dataSort>{k}</TableHeaderColumn>
));
return (
<BootstrapTable
height="auto"
data={this.state.data}
striped
hover
condensed
>
{headers}
</BootstrapTable>
);
}
return null;
}
render() {
return (
<ModalTrigger
animation={this.props.animation}
isButton
triggerNode={<span>View Query</span>}
modalTitle={t('Query')}
bsSize="large"
beforeOpen={this.beforeOpen}
modalBody={this.renderModalBody()}
/>
<DropdownButton title={t('Query')} bsSize="sm" pullRight id="query">
<ModalTrigger
isMenuItem
animation={this.props.animation}
triggerNode={<span>View query</span>}
modalTitle={t('View query')}
bsSize="large"
beforeOpen={this.beforeOpen}
modalBody={this.renderQueryModalBody()}
eventKey="1"
/>
<ModalTrigger
isMenuItem
animation={this.props.animation}
triggerNode={<span>View results</span>}
modalTitle={t('View results')}
bsSize="large"
beforeOpen={this.beforeOpen}
modalBody={this.renderResultsModalBody()}
eventKey="2"
/>
{this.state.sqlSupported && <MenuItem
eventKey="3"
onClick={this.redirectSQLLab.bind(this)}
>
Run in SQL Lab
</MenuItem>}
</DropdownButton>
);
}
}

View File

@ -8,6 +8,7 @@ import { t } from '../../locales';
import { exportChart, getExploreLongUrl } from '../exploreUtils';
const propTypes = {
actions: PropTypes.object.isRequired,
canDownload: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired,
chartStatus: PropTypes.string,
latestQueryFormData: PropTypes.object,
@ -15,7 +16,7 @@ const propTypes = {
};
export default function ExploreActionButtons({
canDownload, chartStatus, latestQueryFormData, queryResponse }) {
actions, canDownload, chartStatus, latestQueryFormData, queryResponse }) {
const exportToCSVClasses = cx('btn btn-default btn-sm', {
'disabled disabledButton': !canDownload,
});
@ -59,6 +60,7 @@ export default function ExploreActionButtons({
queryResponse={queryResponse}
latestQueryFormData={latestQueryFormData}
chartStatus={chartStatus}
onOpenInEditor={actions.redirectSQLLab}
/>
</div>
);

View File

@ -133,6 +133,7 @@ class ExploreChartHeader extends React.PureComponent {
style={{ fontSize: '10px', marginRight: '5px' }}
/>
<ExploreActionButtons
actions={this.props.actions}
slice={this.props.slice}
canDownload={this.props.can_download}
chartStatus={chartStatus}

View File

@ -271,6 +271,7 @@ class ExploreViewContainer extends React.Component {
loading={this.props.chart.chartStatus === 'loading'}
chartIsStale={this.state.chartIsStale}
errorMessage={this.renderErrorMessage()}
datasourceType={this.props.datasource_type}
/>
<br />
<ControlPanelsContainer

View File

@ -224,6 +224,19 @@ class DatasourceControl extends React.PureComponent {
/>
</a>
</OverlayTrigger>
{this.props.datasource.type === 'table' &&
<OverlayTrigger
placement="right"
overlay={
<Tooltip id={'datasource-sqllab'}>
{t('Run SQL queries against this datasource')}
</Tooltip>
}
>
<a href={'/superset/sqllab?datasourceKey=' + this.props.value}>
<i className="fa fa-flask m-r-5" />
</a>
</OverlayTrigger>}
<Collapse in={this.state.showDatasource}>{this.renderDatasource()}</Collapse>
{this.renderModal()}
</div>

View File

@ -152,6 +152,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
'creator': str(self.created_by),
}
@property
def select_star(self):
pass
@property
def data(self):
"""Data representation of the datasource sent to the frontend"""
@ -185,6 +189,8 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
'metrics': [o.data for o in self.metrics],
'columns': [o.data for o in self.columns],
'verbose_map': verbose_map,
'schema': self.schema,
'select_star': self.select_star,
}
@staticmethod

View File

@ -109,6 +109,7 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
@property
def data(self):
return {
'id': self.id,
'name': self.cluster_name,
'backend': 'druid',
}

View File

@ -369,6 +369,10 @@ class SqlaTable(Model, BaseDatasource):
'time_grains': [grain.name for grain in self.database.grains()],
}
@property
def select_star(self):
return self.database.select_star(self.name, show_cols=True)
def get_col(self, col_name):
columns = self.columns
for col in columns:

View File

@ -647,6 +647,7 @@ class Database(Model, AuditMixinNullable, ImportMixin):
@property
def data(self):
return {
'id': self.id,
'name': self.database_name,
'backend': self.backend,
'allow_multi_schema_metadata_fetch':

View File

@ -1072,7 +1072,8 @@ class Superset(BaseSupersetView):
json.dumps({
'query': query,
'language': viz_obj.datasource.query_language,
}),
'data': viz_obj.get_df().to_dict('records'),
}, default=utils.json_iso_dttm_ser),
status=200,
mimetype='application/json')