superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From maximebeauche...@apache.org
Subject [incubator-superset] branch master updated: [explore] add "View samples" modal to action buttons (#5770)
Date Thu, 20 Sep 2018 20:51:44 GMT
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 <maximebeauchemin@gmail.com>
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" />&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"
         />
@@ -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,


Mime
View raw message