From 4a135c57e611fcf44a9b29c474f9cc0649e57eab Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Fri, 2 Feb 2018 10:28:38 -0800 Subject: [PATCH 1/3] [explore] fix missing CacheLabel (#4326) Also adding a basic unit test. (cherry picked from commit 1f8fccc) --- .../explore/components/ExploreChartHeader.jsx | 21 ++++------ .../components/ExploreChartHeader_spec.jsx | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 superset/assets/spec/javascripts/explore/components/ExploreChartHeader_spec.jsx diff --git a/superset/assets/javascripts/explore/components/ExploreChartHeader.jsx b/superset/assets/javascripts/explore/components/ExploreChartHeader.jsx index 30b47994eb3bf..0faf164df4cf9 100644 --- a/superset/assets/javascripts/explore/components/ExploreChartHeader.jsx +++ b/superset/assets/javascripts/explore/components/ExploreChartHeader.jsx @@ -74,7 +74,7 @@ class ExploreChartHeader extends React.PureComponent { json_endpoint: getExploreUrl(formData, 'json'), standalone_endpoint: getExploreUrl(formData, 'standalone'), }; - + const chartSucceeded = ['success', 'rendered'].indexOf(this.props.chart.chartStatus) > 0; return (
}
- {this.props.chart.chartStatus === 'success' && queryResponse && + {chartSucceeded && queryResponse && - } - {this.props.chart.chartStatus === 'success' && - queryResponse && - queryResponse.is_cached && - - - } + />} + {chartSucceeded && queryResponse && queryResponse.is_cached && + } { + let wrapper; + beforeEach(() => { + wrapper = shallow(); + }); + + it('is valid', () => { + expect( + React.isValidElement(), + ).to.equal(true); + }); + + it('renders', () => { + expect(wrapper.find(EditableTitle)).to.have.lengthOf(1); + expect(wrapper.find(ExploreActionButtons)).to.have.lengthOf(1); + }); +}); From ff2bff98ce5fb22e05abd3379de2f25c009df22a Mon Sep 17 00:00:00 2001 From: timifasubaa <30888507+timifasubaa@users.noreply.github.com> Date: Sat, 3 Feb 2018 20:22:06 -0800 Subject: [PATCH 2/3] Refactor import csv (#4298) * move helpers to utils * make form use queryselector * refactor exception throwing and handling * update db_connection access point * nits (cherry picked from commit 6d37d97) --- superset/db_engine_specs.py | 3 +- superset/forms.py | 18 ++++++----- superset/views/core.py | 62 ++++++++++++++----------------------- tests/core_tests.py | 14 +++++---- 4 files changed, 44 insertions(+), 53 deletions(-) diff --git a/superset/db_engine_specs.py b/superset/db_engine_specs.py index d26f633bbd266..e55fa9482902a 100644 --- a/superset/db_engine_specs.py +++ b/superset/db_engine_specs.py @@ -133,13 +133,14 @@ def _allowed_file(filename): 'table': table, 'df': df, 'name': form.name.data, - 'con': create_engine(form.con.data, echo=False), + 'con': create_engine(form.con.data.sqlalchemy_uri, echo=False), 'schema': form.schema.data, 'if_exists': form.if_exists.data, 'index': form.index.data, 'index_label': form.index_label.data, 'chunksize': 10000, } + BaseEngineSpec.df_to_db(**df_to_db_kwargs) @classmethod diff --git a/superset/forms.py b/superset/forms.py index a07790440ffde..cacb9067eb81b 100644 --- a/superset/forms.py +++ b/superset/forms.py @@ -10,14 +10,20 @@ from flask_wtf.file import FileAllowed, FileField, FileRequired from wtforms import ( BooleanField, IntegerField, SelectField, StringField) +from wtforms.ext.sqlalchemy.fields import QuerySelectField from wtforms.validators import DataRequired, NumberRange, Optional -from superset import app +from superset import app, db +from superset.models import core as models config = app.config class CsvToDatabaseForm(DynamicForm): + # pylint: disable=E0211 + def all_db_items(): + return db.session.query(models.Database) + name = StringField( _('Table Name'), description=_('Name of table to be created from csv data.'), @@ -28,12 +34,9 @@ class CsvToDatabaseForm(DynamicForm): description=_('Select a CSV file to be uploaded to a database.'), validators=[ FileRequired(), FileAllowed(['csv'], _('CSV Files Only!'))]) - - con = SelectField( - _('Database'), - description=_('database in which to add above table.'), - validators=[DataRequired()], - choices=[]) + con = QuerySelectField( + query_factory=all_db_items, + get_pk=lambda a: a.id, get_label=lambda a: a.database_name) sep = StringField( _('Delimiter'), description=_('Delimiter used by CSV file (for whitespace use \s+).'), @@ -49,7 +52,6 @@ class CsvToDatabaseForm(DynamicForm): ('fail', _('Fail')), ('replace', _('Replace')), ('append', _('Append'))], validators=[DataRequired()]) - schema = StringField( _('Schema'), description=_('Specify a schema (if database flavour supports this).'), diff --git a/superset/views/core.py b/superset/views/core.py index ccf6b484bdab0..41effd9bc27ad 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -24,10 +24,11 @@ from flask_babel import gettext as __ from flask_babel import lazy_gettext as _ import pandas as pd +from six import text_type import sqlalchemy as sqla from sqlalchemy import create_engine from sqlalchemy.engine.url import make_url -from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import IntegrityError, OperationalError from unidecode import unidecode from werkzeug.routing import BaseConverter from werkzeug.utils import secure_filename @@ -163,8 +164,6 @@ def apply(self, query, func): # noqa return query - - class DatabaseView(SupersetModelView, DeleteMixin, YamlExportMixin): # noqa datamodel = SQLAInterface(models.Database) @@ -319,49 +318,36 @@ def form_get(self, form): form.infer_datetime_format.data = True form.decimal.data = '.' form.if_exists.data = 'append' - all_datasources = ( - db.session.query( - models.Database.sqlalchemy_uri, - models.Database.database_name) - .all() - ) - form.con.choices += all_datasources def form_post(self, form): - def _upload_file(csv_file): - if csv_file and csv_file.filename: - filename = secure_filename(csv_file.filename) - csv_file.save(os.path.join(config['UPLOAD_FOLDER'], filename)) - return filename - csv_file = form.csv_file.data - _upload_file(csv_file) - table = SqlaTable(table_name=form.name.data) - database = ( - db.session.query(models.Database) - .filter_by(sqlalchemy_uri=form.data.get('con')) - .one() - ) - table.database = database - table.database_id = database.id + form.csv_file.data.filename = secure_filename(form.csv_file.data.filename) + csv_filename = form.csv_file.data.filename try: - database.db_engine_spec.create_table_from_csv(form, table) + csv_file.save(os.path.join(config['UPLOAD_FOLDER'], csv_filename)) + table = SqlaTable(table_name=form.name.data) + table.database = form.data.get('con') + table.database_id = table.database.id + table.database.db_engine_spec.create_table_from_csv(form, table) except Exception as e: - os.remove(os.path.join(config['UPLOAD_FOLDER'], csv_file.filename)) - flash(e, 'error') - return redirect('/tablemodelview/list/') + try: + os.remove(os.path.join(config['UPLOAD_FOLDER'], csv_filename)) + except OSError: + pass + message = u'Table name {} already exists. Please pick another'.format( + form.name.data) if isinstance(e, IntegrityError) else text_type(e) + flash( + message, + 'danger') + return redirect('/csvtodatabaseview/form') - os.remove(os.path.join(config['UPLOAD_FOLDER'], csv_file.filename)) + os.remove(os.path.join(config['UPLOAD_FOLDER'], csv_filename)) # Go back to welcome page / splash screen - db_name = ( - db.session.query(models.Database.database_name) - .filter_by(sqlalchemy_uri=form.data.get('con')) - .one() - ) - message = _('CSV file "{0}" uploaded to table "{1}" in ' - 'database "{2}"'.format(form.csv_file.data.filename, + db_name = table.database.database_name + message = _(u'CSV file "{0}" uploaded to table "{1}" in ' + 'database "{2}"'.format(csv_filename, form.name.data, - db_name[0])) + db_name)) flash(message, 'info') return redirect('/tablemodelview/list/') diff --git a/tests/core_tests.py b/tests/core_tests.py index 8415465ae6d86..9cb48b042f3e2 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -804,20 +804,22 @@ def test_import_csv(self): test_file.write('john,1\n') test_file.write('paul,2\n') test_file.close() - main_db_uri = db.session.query( - models.Database.sqlalchemy_uri)\ - .filter_by(database_name='main').all() + main_db_uri = ( + db.session.query(models.Database) + .filter_by(database_name='main') + .all() + ) test_file = open(filename, 'rb') form_data = { 'csv_file': test_file, 'sep': ',', 'name': table_name, - 'con': main_db_uri[0][0], + 'con': main_db_uri[0].id, 'if_exists': 'append', 'index_label': 'test_label', - 'mangle_dupe_cols': False} - + 'mangle_dupe_cols': False, + } url = '/databaseview/list/' add_datasource_page = self.get_resp(url) assert 'Upload a CSV' in add_datasource_page From d2bb6cdef0ec7db7250bbde17d515d6b7b3d2bf8 Mon Sep 17 00:00:00 2001 From: Grace Guo Date: Mon, 5 Feb 2018 10:21:17 -0800 Subject: [PATCH 3/3] 1. fix check filters change logic (#4339) 2. should show chart after loading completed (cherry picked from commit e965f95) --- superset/assets/javascripts/chart/Chart.jsx | 2 +- .../assets/javascripts/components/Loading.jsx | 1 + .../dashboard/components/Dashboard.jsx | 21 +++- .../spec/javascripts/chart/Chart_spec.jsx | 22 +++- .../javascripts/dashboard/Dashboard_spec.jsx | 107 ++++++++++++++++-- .../spec/javascripts/dashboard/fixtures.jsx | 39 ++++++- .../javascripts/dashboard/reducers_spec.js | 11 +- superset/assets/stylesheets/superset.less | 1 + 8 files changed, 177 insertions(+), 27 deletions(-) diff --git a/superset/assets/javascripts/chart/Chart.jsx b/superset/assets/javascripts/chart/Chart.jsx index 2824b255540e0..a7bff52b34c6a 100644 --- a/superset/assets/javascripts/chart/Chart.jsx +++ b/superset/assets/javascripts/chart/Chart.jsx @@ -180,7 +180,7 @@ class Chart extends React.PureComponent { /> } - {!this.props.chartAlert && + {!isLoading && !this.props.chartAlert && ); diff --git a/superset/assets/javascripts/dashboard/components/Dashboard.jsx b/superset/assets/javascripts/dashboard/components/Dashboard.jsx index c99c293604ad9..bcc13778d2fe1 100644 --- a/superset/assets/javascripts/dashboard/components/Dashboard.jsx +++ b/superset/assets/javascripts/dashboard/components/Dashboard.jsx @@ -93,12 +93,21 @@ class Dashboard extends React.PureComponent { } componentDidUpdate(prevProps) { - if (!areObjectsEqual(prevProps.filters, this.props.filters) && this.props.refresh) { - const currentFilterKeys = Object.keys(this.props.filters); - if (currentFilterKeys.length) { - currentFilterKeys.forEach(key => (this.refreshExcept(key))); - } else { - this.refreshExcept(); + if (this.props.refresh) { + let changedFilterKey; + const prevFiltersKeySet = new Set(Object.keys(prevProps.filters)); + Object.keys(this.props.filters).some((key) => { + prevFiltersKeySet.delete(key); + if (prevProps.filters[key] === undefined || + !areObjectsEqual(prevProps.filters[key], this.props.filters[key])) { + changedFilterKey = key; + return true; + } + return false; + }); + // has changed filter or removed a filter? + if (!!changedFilterKey || prevFiltersKeySet.size) { + this.refreshExcept(changedFilterKey); } } } diff --git a/superset/assets/spec/javascripts/chart/Chart_spec.jsx b/superset/assets/spec/javascripts/chart/Chart_spec.jsx index f4754f477ca7d..9d45e85c905a0 100644 --- a/superset/assets/spec/javascripts/chart/Chart_spec.jsx +++ b/superset/assets/spec/javascripts/chart/Chart_spec.jsx @@ -6,6 +6,8 @@ import sinon from 'sinon'; import { chart as initChart } from '../../../javascripts/chart/chartReducer'; import Chart from '../../../javascripts/chart/Chart'; +import ChartBody from '../../../javascripts/chart/ChartBody'; +import Loading from '../../../javascripts/components/Loading'; describe('Chart', () => { const chart = { @@ -30,15 +32,20 @@ describe('Chart', () => { }, }; + let wrapper; + beforeEach(() => { + wrapper = shallow( + , + ); + }); describe('renderViz', () => { - let wrapper; let stub; beforeEach(() => { - wrapper = shallow( - , - ); stub = sinon.stub(wrapper.instance(), 'renderViz'); }); + afterEach(() => { + stub.restore(); + }); it('should not call when loading', () => { const prevProp = wrapper.props(); @@ -68,4 +75,11 @@ describe('Chart', () => { expect(stub.callCount).to.equals(1); }); }); + + describe('render', () => { + it('should render ChartBody after loading is completed', () => { + expect(wrapper.find(Loading)).to.have.length(1); + expect(wrapper.find(ChartBody)).to.have.length(0); + }); + }); }); diff --git a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx index 95f013f115080..1ac495992a630 100644 --- a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx @@ -26,7 +26,7 @@ describe('Dashboard', () => { it('should render', () => { const wrapper = shallow(); expect(wrapper.find('#dashboard-container')).to.have.length(1); - expect(wrapper.instance().getAllSlices()).to.have.length(2); + expect(wrapper.instance().getAllSlices()).to.have.length(3); }); it('should handle metadata default_filters', () => { @@ -51,10 +51,8 @@ describe('Dashboard', () => { it('should carry updated filter', () => { wrapper.setProps({ filters: { - 256: { - region: [], - country_name: ['France'], - }, + 256: { region: [] }, + 257: { country_name: ['France'] }, }, }); const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters; @@ -74,7 +72,7 @@ describe('Dashboard', () => { }); it('should not refresh filter slice', () => { - const filterKey = Object.keys(defaultFilters)[0]; + const filterKey = Object.keys(defaultFilters)[1]; wrapper.instance().refreshExcept(filterKey); expect(spy.callCount).to.equal(1); expect(spy.getCall(0).args[0].length).to.equal(1); @@ -83,7 +81,102 @@ describe('Dashboard', () => { it('should refresh all slices', () => { wrapper.instance().refreshExcept(); expect(spy.callCount).to.equal(1); - expect(spy.getCall(0).args[0].length).to.equal(2); + expect(spy.getCall(0).args[0].length).to.equal(3); + }); + }); + + describe('componentDidUpdate', () => { + let wrapper; + let refreshExceptSpy; + let fetchSlicesStub; + let prevProp; + beforeEach(() => { + wrapper = shallow(); + prevProp = wrapper.instance().props; + refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept'); + fetchSlicesStub = sinon.stub(wrapper.instance(), 'fetchSlices'); + }); + afterEach(() => { + fetchSlicesStub.restore(); + refreshExceptSpy.restore(); + }); + + describe('should check if filter has change', () => { + beforeEach(() => { + refreshExceptSpy.reset(); + }); + it('no change', () => { + wrapper.setProps({ + refresh: true, + filters: { + 256: { region: [] }, + 257: { country_name: ['United States'] }, + }, + }); + wrapper.instance().componentDidUpdate(prevProp); + expect(refreshExceptSpy.callCount).to.equal(0); + }); + + it('remove filter', () => { + wrapper.setProps({ + refresh: true, + filters: { + 256: { region: [] }, + }, + }); + wrapper.instance().componentDidUpdate(prevProp); + expect(refreshExceptSpy.callCount).to.equal(1); + }); + + it('change filter', () => { + wrapper.setProps({ + refresh: true, + filters: { + 256: { region: [] }, + 257: { country_name: ['Canada'] }, + }, + }); + wrapper.instance().componentDidUpdate(prevProp); + expect(refreshExceptSpy.callCount).to.equal(1); + }); + + it('add filter', () => { + wrapper.setProps({ + refresh: true, + filters: { + 256: { region: [] }, + 257: { country_name: ['Canada'] }, + 258: { another_filter: ['new'] }, + }, + }); + wrapper.instance().componentDidUpdate(prevProp); + expect(refreshExceptSpy.callCount).to.equal(1); + }); + }); + + it('should refresh if refresh flag is true', () => { + wrapper.setProps({ + refresh: true, + filters: { + 256: { region: ['Asian'] }, + }, + }); + wrapper.instance().componentDidUpdate(prevProp); + const fetchArgs = fetchSlicesStub.lastCall.args[0]; + expect(fetchArgs).to.have.length(2); + }); + + it('should not refresh filter_immune_slices', () => { + wrapper.setProps({ + refresh: true, + filters: { + 256: { region: [] }, + 257: { country_name: ['Canada'] }, + }, + }); + wrapper.instance().componentDidUpdate(prevProp); + const fetchArgs = fetchSlicesStub.lastCall.args[0]; + expect(fetchArgs).to.have.length(1); }); }); }); diff --git a/superset/assets/spec/javascripts/dashboard/fixtures.jsx b/superset/assets/spec/javascripts/dashboard/fixtures.jsx index 7b95e7c65533c..1b9cf21b62c1b 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx +++ b/superset/assets/spec/javascripts/dashboard/fixtures.jsx @@ -1,10 +1,8 @@ import { getInitialState } from '../../../javascripts/dashboard/reducers'; export const defaultFilters = { - 256: { - region: [], - country_name: ['United States'], - }, + 256: { region: [] }, + 257: { country_name: ['United States'] }, }; export const regionFilter = { datasource: null, @@ -39,6 +37,35 @@ export const regionFilter = { slice_name: 'Region Filters', slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20256%7D', }; +export const countryFilter = { + datasource: null, + description: null, + description_markeddown: '', + edit_url: '/slicemodelview/edit/257', + form_data: { + datasource: '2__table', + date_filter: false, + filters: [], + granularity_sqla: null, + groupby: ['country_name'], + having: '', + instant_filtering: true, + metric: 'sum__SP_POP_TOTL', + show_druid_time_granularity: false, + show_druid_time_origin: false, + show_sqla_time_column: false, + show_sqla_time_granularity: false, + since: '100 years ago', + slice_id: 257, + time_grain_sqla: null, + until: 'now', + viz_type: 'filter_box', + where: '', + }, + slice_id: 257, + slice_name: 'Country Filters', + slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20257%7D', +}; export const slice = { datasource: null, description: null, @@ -100,7 +127,7 @@ const mockDashboardData = { id: 2, metadata: { default_filters: JSON.stringify(defaultFilters), - filter_immune_slices: [], + filter_immune_slices: [256], timed_refresh_immune_slices: [], filter_immune_slice_fields: {}, expanded_slices: {}, @@ -122,7 +149,7 @@ const mockDashboardData = { }, ], slug: 'births', - slices: [regionFilter, slice], + slices: [regionFilter, slice, countryFilter], standalone_mode: false, }; export const { dashboard, charts } = getInitialState({ diff --git a/superset/assets/spec/javascripts/dashboard/reducers_spec.js b/superset/assets/spec/javascripts/dashboard/reducers_spec.js index 17b8f75df54c3..8022928ddf4b5 100644 --- a/superset/assets/spec/javascripts/dashboard/reducers_spec.js +++ b/superset/assets/spec/javascripts/dashboard/reducers_spec.js @@ -11,8 +11,10 @@ describe('Dashboard reducers', () => { type: actions.REMOVE_SLICE, slice: initState.dashboard.slices[1], }; + expect(initState.dashboard.slices).to.have.length(3); + const { dashboard, filters, refresh } = reducers(initState, action); - expect(dashboard.slices).to.have.length(1); + expect(dashboard.slices).to.have.length(2); expect(filters).to.deep.equal(defaultFilters); expect(refresh).to.equal(false); }); @@ -22,9 +24,12 @@ describe('Dashboard reducers', () => { type: actions.REMOVE_SLICE, slice: initState.dashboard.slices[0], }; + const initFilters = Object.keys(initState.filters); + expect(initFilters).to.have.length(2); + const { dashboard, filters, refresh } = reducers(initState, action); - expect(dashboard.slices).to.have.length(1); - expect(filters).to.deep.equal({}); + expect(dashboard.slices).to.have.length(2); + expect(Object.keys(filters)).to.have.length(1); expect(refresh).to.equal(true); }); }); diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less index c5a8ea7d4576f..488adebd5ed62 100644 --- a/superset/assets/stylesheets/superset.less +++ b/superset/assets/stylesheets/superset.less @@ -200,6 +200,7 @@ div.widget { .stack-trace-container, .slice_container { opacity: 0.5; + position: relative; } } }