Skip to content

Commit

Permalink
[explore] add "View samples" modal to action buttons (#5770)
Browse files Browse the repository at this point in the history
* [explore] add "View samples" modal to action buttons

Also broke down the `View query` and `View results` as different
request so that viewing the query does not require fetching the results
anymore

* fix js tests

* lint
  • Loading branch information
mistercrunch authored Sep 20, 2018
1 parent 896c260 commit 73d1e45
Show file tree
Hide file tree
Showing 9 changed files with 170 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ describe('DisplayQueryButton', () => {
});
it('renders a dropdown', () => {
const wrapper = mount(<DisplayQueryButton {...defaultProps} />);
expect(wrapper.find(ModalTrigger)).to.have.lengthOf(2);
expect(wrapper.find(ModalTrigger)).to.have.lengthOf(3);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,6 @@ describe('TableElement', () => {
wrapper.find('.table-remove').simulate('click');
expect(wrapper.state().expanded).to.equal(false);
expect(mockedActions.removeDataPreview.called).to.equal(true);
expect(mockedActions.removeTable.called).to.equal(true);
});
});
103 changes: 78 additions & 25 deletions superset/assets/src/explore/components/DisplayQueryButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ 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 { DropdownButton, MenuItem, Row, Col, FormControl } from 'react-bootstrap';
import { Table } from 'reactable';
import $ from 'jquery';

import CopyToClipboard from './../../components/CopyToClipboard';
import { getExploreUrlAndPayload } from '../exploreUtils';
Expand All @@ -17,14 +17,13 @@ import Loading from '../../components/Loading';
import ModalTrigger from './../../components/ModalTrigger';
import Button from '../../components/Button';
import { t } from '../../locales';
import RowCountLabel from './RowCountLabel';

registerLanguage('markdown', markdown);
registerLanguage('html', html);
registerLanguage('sql', sql);
registerLanguage('json', json);

const $ = (window.$ = require('jquery'));

const propTypes = {
onOpenInEditor: PropTypes.func,
animation: PropTypes.bool,
Expand All @@ -46,15 +45,17 @@ export default class DisplayQueryButton extends React.PureComponent {
data: null,
isLoading: false,
error: null,
filterText: '',
sqlSupported: datasource && datasource.split('__')[1] === 'table',
};
this.beforeOpen = this.beforeOpen.bind(this);
this.changeFilterText = this.changeFilterText.bind(this);
}
beforeOpen() {
beforeOpen(endpointType) {
this.setState({ isLoading: true });
const { url, payload } = getExploreUrlAndPayload({
formData: this.props.latestQueryFormData,
endpointType: 'query',
endpointType,
});
$.ajax({
type: 'POST',
Expand All @@ -79,6 +80,9 @@ export default class DisplayQueryButton extends React.PureComponent {
},
});
}
changeFilterText(event) {
this.setState({ filterText: event.target.value });
}
redirectSQLLab() {
this.props.onOpenInEditor(this.props.latestQueryFormData);
}
Expand Down Expand Up @@ -111,7 +115,7 @@ export default class DisplayQueryButton extends React.PureComponent {
if (this.state.isLoading) {
return (<img
className="loading"
alt="Loading..."
alt={t('Loading...')}
src="/static/assets/images/loading.gif"
/>);
} else if (this.state.error) {
Expand All @@ -120,33 +124,72 @@ export default class DisplayQueryButton extends React.PureComponent {
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 this.renderDataTable(this.state.data);
}
return null;
}
renderDataTable(data) {
return (
<div style={{ overflow: 'auto' }}>
<Row>
<Col md={9}>
<RowCountLabel rowcount={data.length} suffix={t('rows retrieved')} />
</Col>
<Col md={3}>
<FormControl
placeholder={t('Search')}
bsSize="sm"
value={this.state.filterText}
onChange={this.changeFilterText}
style={{ paddingBottom: '5px' }}
/>
</Col>
</Row>
<Table
className="table table-condensed"
sortable
data={data}
hideFilterInput
filterBy={this.state.filterText}
filterable={data.length ? Object.keys(data[0]) : null}
noDataText={t('No data')}
/>
</div>
);
}
renderSamplesModalBody() {
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) {
return this.renderDataTable(this.state.data);
}
return null;
}
render() {
return (
<DropdownButton title={t('Query')} bsSize="sm" pullRight id="query">
<DropdownButton
noCaret
title={
<span>
<i className="fa fa-bars" />&nbsp;
</span>}
bsSize="sm"
pullRight
id="query"
>
<ModalTrigger
isMenuItem
animation={this.props.animation}
triggerNode={<span>{t('View query')}</span>}
modalTitle={t('View query')}
bsSize="large"
beforeOpen={this.beforeOpen}
beforeOpen={() => this.beforeOpen('query')}
modalBody={this.renderQueryModalBody()}
eventKey="1"
/>
Expand All @@ -156,10 +199,20 @@ export default class DisplayQueryButton extends React.PureComponent {
triggerNode={<span>{t('View results')}</span>}
modalTitle={t('View results')}
bsSize="large"
beforeOpen={this.beforeOpen}
beforeOpen={() => this.beforeOpen('results')}
modalBody={this.renderResultsModalBody()}
eventKey="2"
/>
<ModalTrigger
isMenuItem
animation={this.props.animation}
triggerNode={<span>{t('View samples')}</span>}
modalTitle={t('View samples')}
bsSize="large"
beforeOpen={() => this.beforeOpen('samples')}
modalBody={this.renderSamplesModalBody()}
eventKey="2"
/>
{this.state.sqlSupported && <MenuItem
eventKey="3"
onClick={this.redirectSQLLab.bind(this)}
Expand Down
7 changes: 5 additions & 2 deletions superset/assets/src/explore/components/RowCountLabel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ import TooltipWrapper from '../../components/TooltipWrapper';
const propTypes = {
rowcount: PropTypes.number,
limit: PropTypes.number,
rows: PropTypes.string,
suffix: PropTypes.string,
};

const defaultProps = {
suffix: t('rows'),
};

export default function RowCountLabel({ rowcount, limit }) {
export default function RowCountLabel({ rowcount, limit, suffix }) {
const limitReached = rowcount === limit;
const bsStyle = (limitReached || rowcount === 0) ? 'warning' : 'default';
const formattedRowCount = defaultNumberFormatter(rowcount);
Expand All @@ -32,7 +35,7 @@ export default function RowCountLabel({ rowcount, limit }) {
bsStyle={bsStyle}
style={{ fontSize: '10px', marginRight: '5px', cursor: 'pointer' }}
>
{formattedRowCount} rows
{formattedRowCount}{' '}{suffix}
</Label>
</TooltipWrapper>
);
Expand Down
8 changes: 7 additions & 1 deletion superset/assets/src/explore/exploreUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function getAnnotationJsonUrl(slice_id, form_data, isNative) {
export function getURIDirectory(formData, endpointType = 'base') {
// Building the directory part of the URI
let directory = '/superset/explore/';
if (['json', 'csv', 'query'].indexOf(endpointType) >= 0) {
if (['json', 'csv', 'query', 'results', 'samples'].indexOf(endpointType) >= 0) {
directory = '/superset/explore_json/';
}
return directory;
Expand Down Expand Up @@ -81,6 +81,12 @@ export function getExploreUrlAndPayload({
if (endpointType === 'query') {
search.query = 'true';
}
if (endpointType === 'results') {
search.results = 'true';
}
if (endpointType === 'samples') {
search.samples = 'true';
}
const paramNames = Object.keys(requestParams);
if (paramNames.length) {
paramNames.forEach((name) => {
Expand Down
7 changes: 5 additions & 2 deletions superset/assets/stylesheets/superset.less
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,8 @@ g.annotation-container {
font: normal normal normal 14px/1 FontAwesome;
content: "\f0dc";
position: absolute;
top: 17px;
right: 15px;
top: 6px;
right: 5px;
color: @brand-primary;
}
.reactable-header-sort-asc::before{
Expand All @@ -437,6 +437,9 @@ g.annotation-container {
content: "\f0dd";
color: @brand-primary;
}
tr.reactable-column-header th.reactable-header-sortable {
padding-right: 17px;
}

.explore-chart-overlay {
position: absolute;
Expand Down
6 changes: 3 additions & 3 deletions superset/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from datetime import datetime
import functools
import json
import logging
import traceback

Expand All @@ -19,6 +18,7 @@
from flask_babel import get_locale
from flask_babel import gettext as __
from flask_babel import lazy_gettext as _
import simplejson as json
import yaml

from superset import conf, db, security_manager, utils
Expand Down Expand Up @@ -52,7 +52,7 @@ def json_error_response(msg=None, status=500, stacktrace=None, payload=None, lin
payload['link'] = link

return Response(
json.dumps(payload, default=utils.json_iso_dttm_ser),
json.dumps(payload, default=utils.json_iso_dttm_ser, ignore_nan=True),
status=status, mimetype='application/json')


Expand Down Expand Up @@ -95,7 +95,7 @@ class BaseSupersetView(BaseView):

def json_response(self, obj, status=200):
return Response(
json.dumps(obj, default=utils.json_int_dttm_ser),
json.dumps(obj, default=utils.json_int_dttm_ser, ignore_nan=True),
status=status,
mimetype='application/json')

Expand Down
69 changes: 50 additions & 19 deletions superset/views/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,17 +1075,26 @@ def get_query_string_response(self, viz_obj):
else:
query = 'No query.'

return Response(
json.dumps({
'query': query,
'language': viz_obj.datasource.query_language,
'data': viz_obj.get_df().to_dict('records'), # TODO, split into endpoint
}, default=utils.json_iso_dttm_ser),
status=200,
mimetype='application/json')
return self.json_response({
'query': query,
'language': viz_obj.datasource.query_language,
})

def get_raw_results(self, viz_obj):
return self.json_response({
'data': viz_obj.get_df().to_dict('records'),
})

def get_samples(self, viz_obj):
return self.json_response({
'data': viz_obj.get_samples(),
})

def generate_json(self, datasource_type, datasource_id, form_data,
csv=False, query=False, force=False):
def generate_json(
self, datasource_type, datasource_id, form_data,
csv=False, query=False, force=False, results=False,
samples=False,
):
try:
viz_obj = self.get_viz(
datasource_type=datasource_type,
Expand Down Expand Up @@ -1115,6 +1124,12 @@ def generate_json(self, datasource_type, datasource_id, form_data,
if query:
return self.get_query_string_response(viz_obj)

if results:
return self.get_raw_results(viz_obj)

if samples:
return self.get_samples(viz_obj)

try:
payload = viz_obj.get_payload()
except SupersetException as se:
Expand Down Expand Up @@ -1181,10 +1196,22 @@ def annotation_json(self, layer_id):
@expose('/explore_json/<datasource_type>/<datasource_id>/', methods=['GET', 'POST'])
@expose('/explore_json/', methods=['GET', 'POST'])
def explore_json(self, datasource_type=None, datasource_id=None):
"""Serves all request that GET or POST form_data
This endpoint evolved to be the entry point of many different
requests that GETs or POSTs a form_data.
`self.generate_json` receives this input and returns different
payloads based on the request args in the first block
TODO: break into one endpoint for each return shape"""
csv = request.args.get('csv') == 'true'
query = request.args.get('query') == 'true'
results = request.args.get('results') == 'true'
samples = request.args.get('samples') == 'true'
force = request.args.get('force') == 'true'

try:
csv = request.args.get('csv') == 'true'
query = request.args.get('query') == 'true'
force = request.args.get('force') == 'true'
form_data = self.get_form_data()[0]
datasource_id, datasource_type = self.datasource_info(
datasource_id, datasource_type, form_data)
Expand All @@ -1193,12 +1220,16 @@ def explore_json(self, datasource_type=None, datasource_id=None):
return json_error_response(
utils.error_msg_from_exception(e),
stacktrace=traceback.format_exc())
return self.generate_json(datasource_type=datasource_type,
datasource_id=datasource_id,
form_data=form_data,
csv=csv,
query=query,
force=force)
return self.generate_json(
datasource_type=datasource_type,
datasource_id=datasource_id,
form_data=form_data,
csv=csv,
query=query,
results=results,
force=force,
samples=samples,
)

@log_this
@has_access
Expand Down
Loading

0 comments on commit 73d1e45

Please sign in to comment.