mirror of https://github.com/apache/superset.git
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:
parent
48317fd8f9
commit
c445ef8c43
|
@ -16,15 +16,17 @@ describe('DisplayQueryButton', () => {
|
||||||
},
|
},
|
||||||
chartStatus: 'success',
|
chartStatus: 'success',
|
||||||
queryEndpoint: 'localhost',
|
queryEndpoint: 'localhost',
|
||||||
|
latestQueryFormData: {
|
||||||
|
datasource: '1__table',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('is valid', () => {
|
it('is valid', () => {
|
||||||
expect(React.isValidElement(<DisplayQueryButton {...defaultProps} />)).to.equal(true);
|
expect(React.isValidElement(<DisplayQueryButton {...defaultProps} />)).to.equal(true);
|
||||||
});
|
});
|
||||||
it('renders a button and a modal', () => {
|
it('renders a dropdown', () => {
|
||||||
const wrapper = mount(<DisplayQueryButton {...defaultProps} />);
|
const wrapper = mount(<DisplayQueryButton {...defaultProps} />);
|
||||||
expect(wrapper.find(ModalTrigger)).to.have.lengthOf(1);
|
expect(wrapper.find(ModalTrigger)).to.have.lengthOf(2);
|
||||||
wrapper.find('.modal-trigger').simulate('click');
|
expect(wrapper.find(Modal)).to.have.lengthOf(2);
|
||||||
expect(wrapper.find(Modal)).to.have.lengthOf(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,6 +7,7 @@ import ExploreActionButtons from
|
||||||
|
|
||||||
describe('ExploreActionButtons', () => {
|
describe('ExploreActionButtons', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
actions: {},
|
||||||
canDownload: 'True',
|
canDownload: 'True',
|
||||||
latestQueryFormData: {},
|
latestQueryFormData: {},
|
||||||
queryEndpoint: 'localhost',
|
queryEndpoint: 'localhost',
|
||||||
|
|
|
@ -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() {
|
export function createDatasourceStarted() {
|
||||||
return { type: CREATE_DATASOURCE_STARTED };
|
return { type: CREATE_DATASOURCE_STARTED };
|
||||||
|
|
|
@ -41,11 +41,13 @@ class TabbedSqlEditors extends React.PureComponent {
|
||||||
}
|
}
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const query = URI(window.location).search(true);
|
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) {
|
if (query.id) {
|
||||||
this.props.actions.popStoredQuery(query.id);
|
this.props.actions.popStoredQuery(query.id);
|
||||||
} else if (query.savedQueryId) {
|
} else if (query.savedQueryId) {
|
||||||
this.props.actions.popSavedQuery(query.savedQueryId);
|
this.props.actions.popSavedQuery(query.savedQueryId);
|
||||||
|
} else if (query.datasourceKey) {
|
||||||
|
this.props.actions.popDatasourceQuery(query.datasourceKey, query.sql);
|
||||||
} else if (query.sql) {
|
} else if (query.sql) {
|
||||||
let dbId = query.dbid;
|
let dbId = query.dbid;
|
||||||
if (dbId) {
|
if (dbId) {
|
||||||
|
|
|
@ -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) {
|
export function refreshChart(chart, force, timeout) {
|
||||||
return (dispatch) => {
|
return (dispatch) => {
|
||||||
if (!chart.latestQueryFormData || Object.keys(chart.latestQueryFormData).length === 0) {
|
if (!chart.latestQueryFormData || Object.keys(chart.latestQueryFormData).length === 0) {
|
||||||
|
|
|
@ -6,6 +6,10 @@ import markdown from 'react-syntax-highlighter/languages/hljs/markdown';
|
||||||
import sql from 'react-syntax-highlighter/languages/hljs/sql';
|
import sql from 'react-syntax-highlighter/languages/hljs/sql';
|
||||||
import json from 'react-syntax-highlighter/languages/hljs/json';
|
import json from 'react-syntax-highlighter/languages/hljs/json';
|
||||||
import github from 'react-syntax-highlighter/styles/hljs/github';
|
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 CopyToClipboard from './../../components/CopyToClipboard';
|
||||||
import { getExploreUrlAndPayload } from '../exploreUtils';
|
import { getExploreUrlAndPayload } from '../exploreUtils';
|
||||||
|
|
||||||
|
@ -22,6 +26,7 @@ registerLanguage('json', json);
|
||||||
const $ = (window.$ = require('jquery'));
|
const $ = (window.$ = require('jquery'));
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
onOpenInEditor: PropTypes.func,
|
||||||
animation: PropTypes.bool,
|
animation: PropTypes.bool,
|
||||||
queryResponse: PropTypes.object,
|
queryResponse: PropTypes.object,
|
||||||
chartStatus: PropTypes.string,
|
chartStatus: PropTypes.string,
|
||||||
|
@ -37,21 +42,14 @@ export default class DisplayQueryButton extends React.PureComponent {
|
||||||
this.state = {
|
this.state = {
|
||||||
language: null,
|
language: null,
|
||||||
query: null,
|
query: null,
|
||||||
|
data: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
sqlSupported: props.latestQueryFormData.datasource.split('__')[1] === 'table',
|
||||||
};
|
};
|
||||||
this.beforeOpen = this.beforeOpen.bind(this);
|
this.beforeOpen = this.beforeOpen.bind(this);
|
||||||
this.fetchQuery = this.fetchQuery.bind(this);
|
|
||||||
}
|
}
|
||||||
setStateFromQueryResponse() {
|
beforeOpen() {
|
||||||
const qr = this.props.queryResponse;
|
|
||||||
this.setState({
|
|
||||||
language: qr.language,
|
|
||||||
query: qr.query,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
fetchQuery() {
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
const { url, payload } = getExploreUrlAndPayload({
|
const { url, payload } = getExploreUrlAndPayload({
|
||||||
formData: this.props.latestQueryFormData,
|
formData: this.props.latestQueryFormData,
|
||||||
|
@ -67,6 +65,7 @@ export default class DisplayQueryButton extends React.PureComponent {
|
||||||
this.setState({
|
this.setState({
|
||||||
language: data.language,
|
language: data.language,
|
||||||
query: data.query,
|
query: data.query,
|
||||||
|
data: data.data,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
@ -79,18 +78,10 @@ export default class DisplayQueryButton extends React.PureComponent {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
beforeOpen() {
|
redirectSQLLab() {
|
||||||
if (
|
this.props.onOpenInEditor(this.props.latestQueryFormData);
|
||||||
['loading', null].indexOf(this.props.chartStatus) >= 0 ||
|
|
||||||
!this.props.queryResponse ||
|
|
||||||
!this.props.queryResponse.query
|
|
||||||
) {
|
|
||||||
this.fetchQuery();
|
|
||||||
} else {
|
|
||||||
this.setStateFromQueryResponse();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
renderModalBody() {
|
renderQueryModalBody() {
|
||||||
if (this.state.isLoading) {
|
if (this.state.isLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
} else if (this.state.error) {
|
} else if (this.state.error) {
|
||||||
|
@ -115,17 +106,66 @@ export default class DisplayQueryButton extends React.PureComponent {
|
||||||
}
|
}
|
||||||
return null;
|
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() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<ModalTrigger
|
<DropdownButton title={t('Query')} bsSize="sm" pullRight id="query">
|
||||||
animation={this.props.animation}
|
<ModalTrigger
|
||||||
isButton
|
isMenuItem
|
||||||
triggerNode={<span>View Query</span>}
|
animation={this.props.animation}
|
||||||
modalTitle={t('Query')}
|
triggerNode={<span>View query</span>}
|
||||||
bsSize="large"
|
modalTitle={t('View query')}
|
||||||
beforeOpen={this.beforeOpen}
|
bsSize="large"
|
||||||
modalBody={this.renderModalBody()}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { t } from '../../locales';
|
||||||
import { exportChart, getExploreLongUrl } from '../exploreUtils';
|
import { exportChart, getExploreLongUrl } from '../exploreUtils';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
actions: PropTypes.object.isRequired,
|
||||||
canDownload: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired,
|
canDownload: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired,
|
||||||
chartStatus: PropTypes.string,
|
chartStatus: PropTypes.string,
|
||||||
latestQueryFormData: PropTypes.object,
|
latestQueryFormData: PropTypes.object,
|
||||||
|
@ -15,7 +16,7 @@ const propTypes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExploreActionButtons({
|
export default function ExploreActionButtons({
|
||||||
canDownload, chartStatus, latestQueryFormData, queryResponse }) {
|
actions, canDownload, chartStatus, latestQueryFormData, queryResponse }) {
|
||||||
const exportToCSVClasses = cx('btn btn-default btn-sm', {
|
const exportToCSVClasses = cx('btn btn-default btn-sm', {
|
||||||
'disabled disabledButton': !canDownload,
|
'disabled disabledButton': !canDownload,
|
||||||
});
|
});
|
||||||
|
@ -59,6 +60,7 @@ export default function ExploreActionButtons({
|
||||||
queryResponse={queryResponse}
|
queryResponse={queryResponse}
|
||||||
latestQueryFormData={latestQueryFormData}
|
latestQueryFormData={latestQueryFormData}
|
||||||
chartStatus={chartStatus}
|
chartStatus={chartStatus}
|
||||||
|
onOpenInEditor={actions.redirectSQLLab}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -133,6 +133,7 @@ class ExploreChartHeader extends React.PureComponent {
|
||||||
style={{ fontSize: '10px', marginRight: '5px' }}
|
style={{ fontSize: '10px', marginRight: '5px' }}
|
||||||
/>
|
/>
|
||||||
<ExploreActionButtons
|
<ExploreActionButtons
|
||||||
|
actions={this.props.actions}
|
||||||
slice={this.props.slice}
|
slice={this.props.slice}
|
||||||
canDownload={this.props.can_download}
|
canDownload={this.props.can_download}
|
||||||
chartStatus={chartStatus}
|
chartStatus={chartStatus}
|
||||||
|
|
|
@ -271,6 +271,7 @@ class ExploreViewContainer extends React.Component {
|
||||||
loading={this.props.chart.chartStatus === 'loading'}
|
loading={this.props.chart.chartStatus === 'loading'}
|
||||||
chartIsStale={this.state.chartIsStale}
|
chartIsStale={this.state.chartIsStale}
|
||||||
errorMessage={this.renderErrorMessage()}
|
errorMessage={this.renderErrorMessage()}
|
||||||
|
datasourceType={this.props.datasource_type}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<ControlPanelsContainer
|
<ControlPanelsContainer
|
||||||
|
|
|
@ -224,6 +224,19 @@ class DatasourceControl extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</OverlayTrigger>
|
</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>
|
<Collapse in={this.state.showDatasource}>{this.renderDatasource()}</Collapse>
|
||||||
{this.renderModal()}
|
{this.renderModal()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -152,6 +152,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin):
|
||||||
'creator': str(self.created_by),
|
'creator': str(self.created_by),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def select_star(self):
|
||||||
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data(self):
|
def data(self):
|
||||||
"""Data representation of the datasource sent to the frontend"""
|
"""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],
|
'metrics': [o.data for o in self.metrics],
|
||||||
'columns': [o.data for o in self.columns],
|
'columns': [o.data for o in self.columns],
|
||||||
'verbose_map': verbose_map,
|
'verbose_map': verbose_map,
|
||||||
|
'schema': self.schema,
|
||||||
|
'select_star': self.select_star,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -109,6 +109,7 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
|
||||||
@property
|
@property
|
||||||
def data(self):
|
def data(self):
|
||||||
return {
|
return {
|
||||||
|
'id': self.id,
|
||||||
'name': self.cluster_name,
|
'name': self.cluster_name,
|
||||||
'backend': 'druid',
|
'backend': 'druid',
|
||||||
}
|
}
|
||||||
|
|
|
@ -369,6 +369,10 @@ class SqlaTable(Model, BaseDatasource):
|
||||||
'time_grains': [grain.name for grain in self.database.grains()],
|
'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):
|
def get_col(self, col_name):
|
||||||
columns = self.columns
|
columns = self.columns
|
||||||
for col in columns:
|
for col in columns:
|
||||||
|
|
|
@ -647,6 +647,7 @@ class Database(Model, AuditMixinNullable, ImportMixin):
|
||||||
@property
|
@property
|
||||||
def data(self):
|
def data(self):
|
||||||
return {
|
return {
|
||||||
|
'id': self.id,
|
||||||
'name': self.database_name,
|
'name': self.database_name,
|
||||||
'backend': self.backend,
|
'backend': self.backend,
|
||||||
'allow_multi_schema_metadata_fetch':
|
'allow_multi_schema_metadata_fetch':
|
||||||
|
|
|
@ -1072,7 +1072,8 @@ class Superset(BaseSupersetView):
|
||||||
json.dumps({
|
json.dumps({
|
||||||
'query': query,
|
'query': query,
|
||||||
'language': viz_obj.datasource.query_language,
|
'language': viz_obj.datasource.query_language,
|
||||||
}),
|
'data': viz_obj.get_df().to_dict('records'),
|
||||||
|
}, default=utils.json_iso_dttm_ser),
|
||||||
status=200,
|
status=200,
|
||||||
mimetype='application/json')
|
mimetype='application/json')
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue