This is an automated email from the ASF dual-hosted git repository.
maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new 73d1e45 [explore] add "View samples" modal to action buttons (#5770)
73d1e45 is described below
commit 73d1e4596de21ac3c3be63e44d1afa656a7ac460
Author: Maxime Beauchemin <[email protected]>
AuthorDate: Thu Sep 20 13:51:39 2018 -0700
[explore] add "View samples" modal to action buttons (#5770)
* [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
---
.../explore/components/DisplayQueryButton_spec.jsx | 2 +-
.../spec/javascripts/sqllab/TableElement_spec.jsx | 1 +
.../src/explore/components/DisplayQueryButton.jsx | 103 ++++++++++++++++-----
.../src/explore/components/RowCountLabel.jsx | 7 +-
superset/assets/src/explore/exploreUtils.js | 8 +-
superset/assets/stylesheets/superset.less | 7 +-
superset/views/base.py | 6 +-
superset/views/core.py | 69 ++++++++++----
superset/viz.py | 29 ++++--
9 files changed, 170 insertions(+), 62 deletions(-)
diff --git
a/superset/assets/spec/javascripts/explore/components/DisplayQueryButton_spec.jsx
b/superset/assets/spec/javascripts/explore/components/DisplayQueryButton_spec.jsx
index 8bca871..68c9c41 100644
---
a/superset/assets/spec/javascripts/explore/components/DisplayQueryButton_spec.jsx
+++
b/superset/assets/spec/javascripts/explore/components/DisplayQueryButton_spec.jsx
@@ -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);
});
});
diff --git a/superset/assets/spec/javascripts/sqllab/TableElement_spec.jsx
b/superset/assets/spec/javascripts/sqllab/TableElement_spec.jsx
index 6d683d3..69adf09 100644
--- a/superset/assets/spec/javascripts/sqllab/TableElement_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/TableElement_spec.jsx
@@ -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);
});
});
diff --git a/superset/assets/src/explore/components/DisplayQueryButton.jsx
b/superset/assets/src/explore/components/DisplayQueryButton.jsx
index a7295db..45325bd 100644
--- a/superset/assets/src/explore/components/DisplayQueryButton.jsx
+++ b/superset/assets/src/explore/components/DisplayQueryButton.jsx
@@ -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';
@@ -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,
@@ -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',
@@ -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);
}
@@ -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) {
@@ -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" />
+ </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"
/>
@@ -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)}
diff --git a/superset/assets/src/explore/components/RowCountLabel.jsx
b/superset/assets/src/explore/components/RowCountLabel.jsx
index 1b29a03..3367d13 100644
--- a/superset/assets/src/explore/components/RowCountLabel.jsx
+++ b/superset/assets/src/explore/components/RowCountLabel.jsx
@@ -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);
@@ -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>
);
diff --git a/superset/assets/src/explore/exploreUtils.js
b/superset/assets/src/explore/exploreUtils.js
index fcab33f..dcf562d 100644
--- a/superset/assets/src/explore/exploreUtils.js
+++ b/superset/assets/src/explore/exploreUtils.js
@@ -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;
@@ -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) => {
diff --git a/superset/assets/stylesheets/superset.less
b/superset/assets/stylesheets/superset.less
index ef72957..6c52282 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -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{
@@ -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;
diff --git a/superset/views/base.py b/superset/views/base.py
index f249820..2f56ae7 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -7,7 +7,6 @@ from __future__ import unicode_literals
from datetime import datetime
import functools
-import json
import logging
import traceback
@@ -19,6 +18,7 @@ from flask_appbuilder.widgets import ListWidget
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
@@ -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')
@@ -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')
diff --git a/superset/views/core.py b/superset/views/core.py
index 6cb93ea..f811fd8 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -1075,17 +1075,26 @@ class Superset(BaseSupersetView):
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,
@@ -1115,6 +1124,12 @@ class Superset(BaseSupersetView):
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:
@@ -1181,10 +1196,22 @@ class Superset(BaseSupersetView):
@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)
@@ -1193,12 +1220,16 @@ class Superset(BaseSupersetView):
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
diff --git a/superset/viz.py b/superset/viz.py
index d0158c0..679c4d8 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -185,6 +185,17 @@ class BaseViz(object):
}
return fillna
+ def get_samples(self):
+ query_obj = self.query_obj()
+ query_obj.update({
+ 'groupby': [],
+ 'metrics': [],
+ 'row_limit': 1000,
+ 'columns': [o.column_name for o in self.datasource.columns],
+ })
+ df = self.get_df(query_obj)
+ return df.to_dict(orient='records')
+
def get_df(self, query_obj=None):
"""Returns a pandas dataframe based on the query object"""
if not query_obj:
@@ -238,9 +249,15 @@ class BaseViz(object):
if dtype.type == np.object_ and col in metrics:
df[col] = pd.to_numeric(df[col], errors='coerce')
+ def process_query_filters(self):
+ utils.convert_legacy_filters_into_adhoc(self.form_data)
+ merge_extra_filters(self.form_data)
+ utils.split_adhoc_filters_into_base_filters(self.form_data)
+
def query_obj(self):
"""Building a query object"""
form_data = self.form_data
+ self.process_query_filters()
gb = form_data.get('groupby') or []
metrics = self.all_metrics or []
columns = form_data.get('columns') or []
@@ -254,12 +271,6 @@ class BaseViz(object):
groupby.remove(DTTM_ALIAS)
is_timeseries = True
- # extras are used to query elements specific to a datasource type
- # for instance the extra where clause that applies only to Tables
-
- utils.convert_legacy_filters_into_adhoc(form_data)
- merge_extra_filters(form_data)
- utils.split_adhoc_filters_into_base_filters(form_data)
granularity = (
form_data.get('granularity') or
form_data.get('granularity_sqla')
@@ -282,8 +293,8 @@ class BaseViz(object):
self.from_dttm = from_dttm
self.to_dttm = to_dttm
- filters = form_data.get('filters', [])
-
+ # extras are used to query elements specific to a datasource type
+ # for instance the extra where clause that applies only to Tables
extras = {
'where': form_data.get('where', ''),
'having': form_data.get('having', ''),
@@ -300,7 +311,7 @@ class BaseViz(object):
'groupby': groupby,
'metrics': metrics,
'row_limit': row_limit,
- 'filter': filters,
+ 'filter': self.form_data.get('filters', []),
'timeseries_limit': limit,
'extras': extras,
'timeseries_limit_metric': timeseries_limit_metric,