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',
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ import ExploreActionButtons from
|
|||
|
||||
describe('ExploreActionButtons', () => {
|
||||
const defaultProps = {
|
||||
actions: {},
|
||||
canDownload: 'True',
|
||||
latestQueryFormData: {},
|
||||
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() {
|
||||
return { type: CREATE_DATASOURCE_STARTED };
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -109,6 +109,7 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin):
|
|||
@property
|
||||
def data(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.cluster_name,
|
||||
'backend': 'druid',
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
Loading…
Reference in New Issue