From e6d38288aab5cca455c32bdc2f621169986ab484 Mon Sep 17 00:00:00 2001 From: Grace Guo Date: Fri, 9 Jun 2017 15:01:52 -0700 Subject: [PATCH 01/14] Edit Dashboard title and Slice title in place Add EditableTitle component into Dashboard and Explore view to support edit title inline. --- .../javascripts/components/EditableTitle.jsx | 81 ++++++++++++++++++ .../javascripts/dashboard/Dashboard.jsx | 4 + .../dashboard/components/Header.jsx | 13 ++- .../dashboard/components/SaveModal.jsx | 1 + .../explore/actions/exploreActions.js | 14 ++- .../explore/components/ChartContainer.jsx | 28 +++++- .../explore/components/SaveModal.jsx | 6 +- .../explore/reducers/exploreReducer.js | 7 ++ .../profile/EditableTitle_spec.jsx | 85 +++++++++++++++++++ superset/assets/stylesheets/superset.css | 20 ++++- superset/assets/utils/common.js | 28 ++++++ superset/views/core.py | 1 + 12 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 superset/assets/javascripts/components/EditableTitle.jsx create mode 100644 superset/assets/spec/javascripts/profile/EditableTitle_spec.jsx diff --git a/superset/assets/javascripts/components/EditableTitle.jsx b/superset/assets/javascripts/components/EditableTitle.jsx new file mode 100644 index 0000000000000..43ce2f1825754 --- /dev/null +++ b/superset/assets/javascripts/components/EditableTitle.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import TooltipWrapper from './TooltipWrapper'; + +class EditableTitle extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + isEditing: false, + title: this.props.title, + lastTitle: this.props.title, + }; + this.handleClick = this.handleClick.bind(this); + this.handleBlur = this.handleBlur.bind(this); + this.handleChange = this.handleChange.bind(this); + } + handleClick() { + if (!this.props.canEdit) { + return; + } + + this.setState({ + isEditing: true, + }); + } + handleBlur() { + if (!this.props.canEdit) { + return; + } + + this.setState({ + isEditing: false, + }); + + if (this.state.lastTitle !== this.state.title) { + this.setState({ + lastTitle: this.state.title, + }); + this.props.onSaveTitle(this.state.title); + } + } + handleChange(ev) { + if (!this.props.canEdit) { + return; + } + + this.setState({ + title: ev.target.value, + }); + } + render() { + return ( + + + + + + ); + } +} +EditableTitle.propTypes = { + title: PropTypes.string, + canEdit: PropTypes.bool, + onSaveTitle: PropTypes.func.isRequired, +}; +EditableTitle.defaultProps = { + title: 'Title', + canEdit: false, +}; + +export default EditableTitle; diff --git a/superset/assets/javascripts/dashboard/Dashboard.jsx b/superset/assets/javascripts/dashboard/Dashboard.jsx index 3abde441c16ad..483749c03e174 100644 --- a/superset/assets/javascripts/dashboard/Dashboard.jsx +++ b/superset/assets/javascripts/dashboard/Dashboard.jsx @@ -337,6 +337,10 @@ export function dashboardContainer(dashboard, datasources, userid) { }, }); }, + updateDashboardTitle(title) { + this.dashboard_title = title; + this.onChange(); + }, }); } diff --git a/superset/assets/javascripts/dashboard/components/Header.jsx b/superset/assets/javascripts/dashboard/components/Header.jsx index 44259b928a0e5..740b102f1df9d 100644 --- a/superset/assets/javascripts/dashboard/components/Header.jsx +++ b/superset/assets/javascripts/dashboard/components/Header.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Controls from './Controls'; +import EditableTitle from '../../components/EditableTitle'; const propTypes = { dashboard: PropTypes.object, @@ -14,14 +15,22 @@ class Header extends React.PureComponent { super(props); this.state = { }; + this.handleSaveTitle = this.handleSaveTitle.bind(this); + } + handleSaveTitle(title) { + this.props.dashboard.updateDashboardTitle(title); } render() { const dashboard = this.props.dashboard; return (
-

- {dashboard.dashboard_title}   +

+

diff --git a/superset/assets/javascripts/dashboard/components/SaveModal.jsx b/superset/assets/javascripts/dashboard/components/SaveModal.jsx index a414308a08a34..7fb4e86b3464c 100644 --- a/superset/assets/javascripts/dashboard/components/SaveModal.jsx +++ b/superset/assets/javascripts/dashboard/components/SaveModal.jsx @@ -84,6 +84,7 @@ class SaveModal extends React.PureComponent { positions, css: this.state.css, expanded_slices: expandedSlices, + dashboard_title: dashboard.dashboard_title, }; let url = null; if (saveType === 'overwrite') { diff --git a/superset/assets/javascripts/explore/actions/exploreActions.js b/superset/assets/javascripts/explore/actions/exploreActions.js index 3f683243fb1a0..c8611443ddab6 100644 --- a/superset/assets/javascripts/explore/actions/exploreActions.js +++ b/superset/assets/javascripts/explore/actions/exploreActions.js @@ -212,6 +212,10 @@ export const SAVE_SLICE_FAILED = 'SAVE_SLICE_FAILED'; export function saveSliceFailed() { return { type: SAVE_SLICE_FAILED }; } +export const SAVE_SLICE_SUCCESS = 'SAVE_SLICE_SUCCESS'; +export function saveSliceSuccess(data) { + return { type: SAVE_SLICE_SUCCESS, data }; +} export const REMOVE_SAVE_MODAL_ALERT = 'REMOVE_SAVE_MODAL_ALERT'; export function removeSaveModalAlert() { @@ -220,10 +224,9 @@ export function removeSaveModalAlert() { export function saveSlice(url) { return function (dispatch) { - $.get(url, (data, status) => { + return $.get(url, (data, status) => { if (status === 'success') { - // Go to new slice url or dashboard url - window.location = data; + dispatch(saveSliceSuccess(data)); } else { dispatch(saveSliceFailed()); } @@ -231,6 +234,11 @@ export function saveSlice(url) { }; } +export const UPDATE_CHART_TITLE = 'UPDATE_CHART_TITLE'; +export function updateChartTitle(slice_name) { + return { type: UPDATE_CHART_TITLE, slice_name }; +} + export const UPDATE_CHART_STATUS = 'UPDATE_CHART_STATUS'; export function updateChartStatus(status) { return { type: UPDATE_CHART_STATUS, status }; diff --git a/superset/assets/javascripts/explore/components/ChartContainer.jsx b/superset/assets/javascripts/explore/components/ChartContainer.jsx index 2070b3db8b212..d22a9296554d2 100644 --- a/superset/assets/javascripts/explore/components/ChartContainer.jsx +++ b/superset/assets/javascripts/explore/components/ChartContainer.jsx @@ -7,11 +7,13 @@ import { Alert, Collapse, Panel } from 'react-bootstrap'; import visMap from '../../../visualizations/main'; import { d3format } from '../../modules/utils'; import ExploreActionButtons from './ExploreActionButtons'; +import EditableTitle from '../../components/EditableTitle'; import FaveStar from '../../components/FaveStar'; import TooltipWrapper from '../../components/TooltipWrapper'; import Timer from '../../components/Timer'; import { getExploreUrl } from '../exploreUtils'; import { getFormDataFromControls } from '../stores/store'; +import { serialize } from '../../../utils/common'; import CachedLabel from '../../components/CachedLabel'; const CHART_STATUS_MAP = { @@ -23,6 +25,7 @@ const CHART_STATUS_MAP = { const propTypes = { actions: PropTypes.object.isRequired, alert: PropTypes.string, + can_overwrite: PropTypes.bool.isRequired, can_download: PropTypes.bool.isRequired, chartStatus: PropTypes.string, chartUpdateEndTime: PropTypes.number, @@ -39,6 +42,8 @@ const propTypes = { queryResponse: PropTypes.object, triggerRender: PropTypes.bool, standalone: PropTypes.bool, + datasourceType: PropTypes.string, + datasourceId: PropTypes.number, }; class ChartContainer extends React.PureComponent { @@ -145,6 +150,19 @@ class ChartContainer extends React.PureComponent { this.props.actions.runQuery(this.props.formData, true); } + updateChartTitle(newTitle) { + const params = {}; + params.slice_name = newTitle; + params.action = 'overwrite'; + params.form_data = this.props.formData; + const saveUrl = '/superset/explore/' + + `${this.props.datasourceType}/${this.props.datasourceId}/?${serialize(params)}`; + this.props.actions.saveSlice(saveUrl) + .then(() => { + this.props.actions.updateChartTitle(newTitle); + }); + } + renderChartTitle() { let title; if (this.props.slice) { @@ -240,7 +258,11 @@ class ChartContainer extends React.PureComponent { id="slice-header" className="clearfix panel-title-large" > - {this.renderChartTitle()} + {this.props.slice && @@ -304,6 +326,7 @@ function mapStateToProps(state) { const formData = getFormDataFromControls(state.controls); return { alert: state.chartAlert, + can_overwrite: state.can_overwrite, can_download: state.can_download, chartStatus: state.chartStatus, chartUpdateEndTime: state.chartUpdateEndTime, @@ -320,7 +343,8 @@ function mapStateToProps(state) { table_name: formData.datasource_name, viz_type: formData.viz_type, triggerRender: state.triggerRender, - datasourceType: state.datasource ? state.datasource.type : null, + datasourceType: state.datasource_type, + datasourceId: state.datasource_id, }; } diff --git a/superset/assets/javascripts/explore/components/SaveModal.jsx b/superset/assets/javascripts/explore/components/SaveModal.jsx index 35ad0c98ad519..4dbff36acfea6 100644 --- a/superset/assets/javascripts/explore/components/SaveModal.jsx +++ b/superset/assets/javascripts/explore/components/SaveModal.jsx @@ -108,7 +108,11 @@ class SaveModal extends React.Component { const saveUrl = `${baseUrl}?form_data=` + `${encodeURIComponent(JSON.stringify(this.props.form_data))}` + `&${$.param(sliceParams, true)}`; - this.props.actions.saveSlice(saveUrl); + this.props.actions.saveSlice(saveUrl) + .then((data) => { + // Go to new slice url or dashboard url + window.location = data; + }); this.props.onHide(); } removeAlert() { diff --git a/superset/assets/javascripts/explore/reducers/exploreReducer.js b/superset/assets/javascripts/explore/reducers/exploreReducer.js index f58d21a531fe2..cc214581c6b4a 100644 --- a/superset/assets/javascripts/explore/reducers/exploreReducer.js +++ b/superset/assets/javascripts/explore/reducers/exploreReducer.js @@ -135,6 +135,10 @@ export const exploreReducer = function (state, action) { } return newState; }, + [actions.UPDATE_CHART_TITLE]() { + const updatedSlice = Object.assign({}, state.slice, { slice_name: action.slice_name }); + return Object.assign({}, state, { slice: updatedSlice }); + }, [actions.REMOVE_CHART_ALERT]() { if (state.chartAlert !== null) { return Object.assign({}, state, { chartAlert: null }); @@ -144,6 +148,9 @@ export const exploreReducer = function (state, action) { [actions.SAVE_SLICE_FAILED]() { return Object.assign({}, state, { saveModalAlert: 'Failed to save slice' }); }, + [actions.SAVE_SLICE_SUCCESS](data) { + return Object.assign({}, state, { data }); + }, [actions.REMOVE_SAVE_MODAL_ALERT]() { return Object.assign({}, state, { saveModalAlert: null }); }, diff --git a/superset/assets/spec/javascripts/profile/EditableTitle_spec.jsx b/superset/assets/spec/javascripts/profile/EditableTitle_spec.jsx new file mode 100644 index 0000000000000..edce86a0245a9 --- /dev/null +++ b/superset/assets/spec/javascripts/profile/EditableTitle_spec.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { describe, it } from 'mocha'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +import EditableTable from '../../../javascripts/components/EditableTitle'; + +describe('EditableTitle', () => { + const callback = sinon.spy(); + const mockProps = { + title: 'my title', + canEdit: true, + onSaveTitle: callback, + }; + const mockEvent = { + target: { + value: 'new title', + }, + }; + const editableWrapper = shallow(); + const notEditableWrapper = shallow(); + it('is valid', () => { + expect( + React.isValidElement(), + ).to.equal(true); + }); + it('should render title', () => { + const titleElement = editableWrapper.find('input'); + expect(titleElement.props().value).to.equal('my title'); + expect(titleElement.props().type).to.equal('button'); + }); + + describe('should handle click', () => { + it('should change title', () => { + editableWrapper.find('input').simulate('click'); + expect(editableWrapper.find('input').props().type).to.equal('text'); + }); + it('should not change title', () => { + notEditableWrapper.find('input').simulate('click'); + expect(notEditableWrapper.find('input').props().type).to.equal('button'); + }); + }); + + describe('should handle change', () => { + afterEach(() => { + editableWrapper.setState({ title: 'my title' }); + editableWrapper.setState({ lastTitle: 'my title' }); + }); + it('should change title', () => { + editableWrapper.find('input').simulate('change', mockEvent); + expect(editableWrapper.find('input').props().value).to.equal('new title'); + }); + it('should not change title', () => { + notEditableWrapper.find('input').simulate('change', mockEvent); + expect(editableWrapper.find('input').props().value).to.equal('my title'); + }); + }); + + describe('should handle blur', () => { + beforeEach(() => { + editableWrapper.find('input').simulate('click'); + expect(editableWrapper.find('input').props().type).to.equal('text'); + }); + afterEach(() => { + callback.reset(); + editableWrapper.setState({ title: 'my title' }); + editableWrapper.setState({ lastTitle: 'my title' }); + }); + + it('should trigger callback', () => { + editableWrapper.setState({ title: 'new title' }); + editableWrapper.find('input').simulate('blur'); + expect(editableWrapper.find('input').props().type).to.equal('button'); + expect(callback.callCount).to.equal(1); + expect(callback.getCall(0).args[0]).to.equal('new title'); + }); + it('should not trigger callback', () => { + editableWrapper.find('input').simulate('blur'); + expect(editableWrapper.find('input').props().type).to.equal('button'); + // no change + expect(callback.callCount).to.equal(0); + }); + }); +}); diff --git a/superset/assets/stylesheets/superset.css b/superset/assets/stylesheets/superset.css index 9db4ffde6a9e0..d0b16cad2885b 100644 --- a/superset/assets/stylesheets/superset.css +++ b/superset/assets/stylesheets/superset.css @@ -199,7 +199,7 @@ div.widget .slice_container { .navbar .alert { padding: 5px 10px; margin-top: 8px; - margin-bottom: 0px + margin-bottom: 0; } .table-condensed { @@ -209,6 +209,22 @@ div.widget .slice_container { .table-condensed input[type="checkbox"] { float: left; } -.m-r-5 { + +.editable-title input { + padding: 2px 6px 3px 6px; +} + +.editable-title input[type="button"] { + border-color: transparent; + background: inherit; +} + +.editable-title input[type="button"]:hover { + cursor: text; +} + +.editable-title input[type="button"]:focus { + outline: none; +}.m-r-5 { margin-right: 5px; } diff --git a/superset/assets/utils/common.js b/superset/assets/utils/common.js index 2e143a12c811e..a15fa00bd4670 100644 --- a/superset/assets/utils/common.js +++ b/superset/assets/utils/common.js @@ -86,3 +86,31 @@ export function getShortUrl(longUrl, callback) { }, }); } + +export function serialize(obj, props) { + const parts = []; + const addParamParts = (propName, propValue) => { + const type = typeof propValue; + switch (type) { + case 'object': + parts.push(propName + '=' + encodeURIComponent(JSON.stringify(propValue))); + break; + case 'boolean': + case 'number': + case 'string': + parts.push(propName + '=' + encodeURIComponent(propValue)); + break; + default: + parts.push(propName + '='); + } + }; + + props.forEach((prop) => { + if (Array.isArray(obj[prop])) { + obj[prop].forEach(value => addParamParts(prop, value)); + } else { + addParamParts(prop, obj[prop]); + } + }); + return parts.join('&'); +} diff --git a/superset/views/core.py b/superset/views/core.py index b85f01306c833..22a107ae5e002 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -1320,6 +1320,7 @@ def _set_dash_metadata(dashboard, data): dashboard.position_json = json.dumps(positions, indent=4, sort_keys=True) md = dashboard.params_dict dashboard.css = data['css'] + dashboard.dashboard_title = data['dashboard_title'] if 'filter_immune_slices' not in md: md['filter_immune_slices'] = [] From 52bc767a807a6f0f6af757551018130922c34cbc Mon Sep 17 00:00:00 2001 From: Grace Guo Date: Fri, 9 Jun 2017 15:01:52 -0700 Subject: [PATCH 02/14] Edit Dashboard title and Slice title in place Add EditableTitle component into Dashboard and Explore view to support edit title inline. --- superset/assets/utils/common.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/superset/assets/utils/common.js b/superset/assets/utils/common.js index a15fa00bd4670..07a463e1fa3b8 100644 --- a/superset/assets/utils/common.js +++ b/superset/assets/utils/common.js @@ -87,7 +87,7 @@ export function getShortUrl(longUrl, callback) { }); } -export function serialize(obj, props) { +export function serialize(obj) { const parts = []; const addParamParts = (propName, propValue) => { const type = typeof propValue; @@ -105,12 +105,14 @@ export function serialize(obj, props) { } }; - props.forEach((prop) => { - if (Array.isArray(obj[prop])) { - obj[prop].forEach(value => addParamParts(prop, value)); - } else { - addParamParts(prop, obj[prop]); - } - }); + Object.keys(obj) + .filter(key => obj.hasOwnProperty(key)) + .forEach((prop) => { + if (Array.isArray(obj[prop])) { + obj[prop].forEach(value => addParamParts(prop, value)); + } else { + addParamParts(prop, obj[prop]); + } + }); return parts.join('&'); } From 0c7bcb8fe26f4235c948b2c83ead7cba63b9d015 Mon Sep 17 00:00:00 2001 From: Grace Guo Date: Fri, 9 Jun 2017 15:01:52 -0700 Subject: [PATCH 03/14] Edit Dashboard title and Slice title in place Add EditableTitle component into Dashboard and Explore view to support edit title inline. --- tests/core_tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core_tests.py b/tests/core_tests.py index 437511d6520f5..2b2a4b0bb7c2f 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -365,6 +365,7 @@ def test_save_dash(self, username='admin'): 'css': '', 'expanded_slices': {}, 'positions': positions, + 'dashboard_title': 'new title' } url = '/superset/save_dash/{}/'.format(dash.id) resp = self.client.post(url, data=dict(data=json.dumps(data))) From 6aadc17f25c827a6ca0a4f5e58524cb49c114327 Mon Sep 17 00:00:00 2001 From: Grace Guo Date: Fri, 9 Jun 2017 15:01:52 -0700 Subject: [PATCH 04/14] Edit Dashboard title and Slice title in place Add EditableTitle component into Dashboard and Explore view to support edit title inline. --- tests/core_tests.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/core_tests.py b/tests/core_tests.py index 2b2a4b0bb7c2f..89b141bd6a544 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -353,6 +353,30 @@ def test_save_dash(self, username='admin'): dash = db.session.query(models.Dashboard).filter_by( slug="births").first() positions = [] + for i, slc in enumerate(dash.slices): + d = { + 'col': 0, + 'row': i * 4, + 'size_x': 4, + 'size_y': 4, + 'slice_id': '{}'.format(slc.id)} + positions.append(d) + data = { + 'css': '', + 'expanded_slices': {}, + 'positions': positions, + 'dashboard_title': dash.dashboard_title + } + url = '/superset/save_dash/{}/'.format(dash.id) + resp = self.get_resp(url, data=dict(data=json.dumps(data))) + self.assertIn("SUCCESS", resp) + + def test_save_dash_with_dashboard_title(self, username='admin'): + self.login(username=username) + dash = db.session.query(models.Dashboard).filter_by( + slug="births").first() + origin_title = dash.dashboard_title + positions = [] for i, slc in enumerate(dash.slices): d = { 'col': 0, @@ -368,8 +392,13 @@ def test_save_dash(self, username='admin'): 'dashboard_title': 'new title' } url = '/superset/save_dash/{}/'.format(dash.id) - resp = self.client.post(url, data=dict(data=json.dumps(data))) - assert "SUCCESS" in resp.data.decode('utf-8') + resp = self.get_resp(url, data=dict(data=json.dumps(data))) + updatedDash = db.session.query(models.Dashboard).filter_by( + slug="births").first() + self.assertEqual(updatedDash.dashboard_title, 'new title') + # # bring back dashboard original title + data['dashboard_title'] = origin_title + self.get_resp(url, data=dict(data=json.dumps(data))) def test_copy_dash(self, username='admin'): self.login(username=username) @@ -526,7 +555,7 @@ def test_only_owners_can_save(self): self.logout() self.assertRaises( - AssertionError, self.test_save_dash, 'alpha') + Exception, self.test_save_dash, 'alpha') alpha = appbuilder.sm.find_user('alpha') From 74315dc9e613d4b32e91723800a6cf3d49e2eb09 Mon Sep 17 00:00:00 2001 From: Grace Guo Date: Fri, 9 Jun 2017 15:01:52 -0700 Subject: [PATCH 05/14] Edit Dashboard title and Slice title in place Add EditableTitle component into Dashboard and Explore view to support edit title inline. --- tests/core_tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/core_tests.py b/tests/core_tests.py index 89b141bd6a544..b8400d54311e8 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -533,7 +533,8 @@ def test_dashboard_with_created_by_can_be_accessed_by_public_users(self): ) self.grant_public_access_to_table(table) - dash = db.session.query(models.Dashboard).filter_by(dashboard_title="Births").first() + dash = db.session.query(models.Dashboard).filter_by( + slug="births").first() dash.owners = [appbuilder.sm.find_user('admin')] dash.created_by = appbuilder.sm.find_user('admin') db.session.merge(dash) From 5691f323f0f33ec0721460b2f8e108d6faf77d38 Mon Sep 17 00:00:00 2001 From: timfeirg Date: Tue, 13 Jun 2017 04:17:59 +0800 Subject: [PATCH 06/14] remove reference for CSRF_ENABLED, and use WTF_CSRF_ENABLED instead (#2946) --- docs/installation.rst | 2 +- tests/superset_test_config.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index dc4775810c4ee..10aa7d77bbcc0 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -156,7 +156,7 @@ of the parameters you can copy / paste in that configuration module: :: SQLALCHEMY_DATABASE_URI = 'sqlite:////path/to/superset.db' # Flask-WTF flag for CSRF - CSRF_ENABLED = True + WTF_CSRF_ENABLED = True # Set this API key to enable Mapbox visualizations MAPBOX_API_KEY = '' diff --git a/tests/superset_test_config.py b/tests/superset_test_config.py index d98159b0c1cc0..89b2c40c4c3eb 100644 --- a/tests/superset_test_config.py +++ b/tests/superset_test_config.py @@ -16,7 +16,6 @@ SQL_MAX_ROW = 666 TESTING = True -CSRF_ENABLED = False SECRET_KEY = 'thisismyscretkey' WTF_CSRF_ENABLED = False PUBLIC_ROLE_LIKE_GAMMA = True From 497485ced5dc654183b494cce0d47f6089fe0161 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Mon, 12 Jun 2017 13:21:14 -0700 Subject: [PATCH 07/14] Bumping some dependencies (#2945) Most notably Flask AppBuilder to 1.9.0 --- setup.py | 6 ++--- .../javascripts/components/ModalTrigger.jsx | 1 + superset/assets/package.json | 22 +++++++++---------- .../javascripts/sqllab/QuerySearch_spec.jsx | 11 ++++++---- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/setup.py b/setup.py index 327c1615eff73..dc4f42d22d01d 100644 --- a/setup.py +++ b/setup.py @@ -45,11 +45,11 @@ def get_git_sha(): 'boto3==1.4.4', 'celery==3.1.23', 'cryptography==1.7.2', - 'flask-appbuilder==1.8.1', + 'flask-appbuilder==1.9.0', 'flask-cache==0.13.1', 'flask-migrate==2.0.3', 'flask-script==2.0.5', - 'flask-sqlalchemy==2.0', + 'flask-sqlalchemy==2.1', 'flask-testing==0.6.2', 'flask-wtf==0.14.2', 'future>=0.16.0, <0.17', @@ -61,7 +61,7 @@ def get_git_sha(): 'pydruid==0.3.1', 'PyHive>=0.3.0', 'python-dateutil==2.6.0', - 'requests==2.13.0', + 'requests==2.17.3', 'simplejson==3.10.0', 'six==1.10.0', 'sqlalchemy==1.1.9', diff --git a/superset/assets/javascripts/components/ModalTrigger.jsx b/superset/assets/javascripts/components/ModalTrigger.jsx index 628f0067bd49f..315a75354d97e 100644 --- a/superset/assets/javascripts/components/ModalTrigger.jsx +++ b/superset/assets/javascripts/components/ModalTrigger.jsx @@ -87,6 +87,7 @@ export default class ModalTrigger extends React.Component { ); } + /* eslint-disable jsx-a11y/interactive-supports-focus */ return ( {this.props.triggerNode} diff --git a/superset/assets/package.json b/superset/assets/package.json index 80b1b6401d047..c466dac9cf7af 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -47,7 +47,7 @@ "classnames": "^2.2.5", "d3": "^3.5.15", "d3-cloud": "^1.2.1", - "d3-sankey": "^0.4.1", + "d3-sankey": "^0.4.2", "d3-scale": "^1.0.3", "d3-tip": "^0.6.7", "datamaps": "^0.5.8", @@ -67,11 +67,11 @@ "nvd3": "1.8.5", "prop-types": "^15.5.8", "react": "^15.5.1", - "react-ace": "^4.1.5", + "react-ace": "^5.0.1", "react-addons-css-transition-group": "^15.4.2", "react-addons-shallow-compare": "^15.4.2", "react-alert": "^2.0.1", - "react-bootstrap": "^0.30.3", + "react-bootstrap": "^0.31.0", "react-bootstrap-table": "^3.1.7", "react-dom": "^15.5.1", "react-draggable": "^2.1.2", @@ -93,28 +93,28 @@ "supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40", "topojson": "^1.6.22", "urijs": "^1.18.10", - "victory": "^0.18.4", + "victory": "^0.21.0", "viewport-mercator-project": "^2.1.0" }, "devDependencies": { "babel-cli": "^6.14.0", "babel-core": "^6.10.4", "babel-istanbul": "^0.12.2", - "babel-loader": "^6.2.4", + "babel-loader": "^7.0.0", "babel-plugin-css-modules-transform": "^1.1.0", "babel-polyfill": "^6.23.0", "babel-preset-airbnb": "^2.1.1", "babel-preset-es2015": "^6.14.0", "babel-preset-react": "^6.11.1", - "chai": "^3.5.0", - "codeclimate-test-reporter": "^0.4.1", + "chai": "^4.0.2", + "codeclimate-test-reporter": "^0.5.0", "css-loader": "^0.28.0", "enzyme": "^2.0.0", "eslint": "^3.19.0", - "eslint-config-airbnb": "^14.1.0", + "eslint-config-airbnb": "^15.0.1", "eslint-plugin-import": "^2.2.0", - "eslint-plugin-jsx-a11y": "^4.0.0", - "eslint-plugin-react": "^6.10.3", + "eslint-plugin-jsx-a11y": "^5.0.3", + "eslint-plugin-react": "^7.0.1", "exports-loader": "^0.6.3", "file-loader": "^0.11.1", "github-changes": "^1.0.4", @@ -130,7 +130,7 @@ "react-test-renderer": "^15.5.1", "redux-mock-store": "^1.2.3", "sinon": "^2.1.0", - "style-loader": "^0.16.1", + "style-loader": "^0.18.2", "transform-loader": "^0.2.3", "url-loader": "^0.5.7", "webpack": "^2.3.3", diff --git a/superset/assets/spec/javascripts/sqllab/QuerySearch_spec.jsx b/superset/assets/spec/javascripts/sqllab/QuerySearch_spec.jsx index e68ebd062226d..349a4b19a873a 100644 --- a/superset/assets/spec/javascripts/sqllab/QuerySearch_spec.jsx +++ b/superset/assets/spec/javascripts/sqllab/QuerySearch_spec.jsx @@ -18,7 +18,10 @@ describe('QuerySearch', () => { React.isValidElement(), ).to.equal(true); }); - const wrapper = shallow(); + let wrapper; + beforeEach(() => { + wrapper = shallow(); + }); it('should have three Select', () => { expect(wrapper.find(Select)).to.have.length(3); @@ -56,9 +59,9 @@ describe('QuerySearch', () => { }); it('refreshes queries when clicked', () => { - const search = sinon.spy(QuerySearch.prototype, 'refreshQueries'); + const spy = sinon.spy(QuerySearch.prototype, 'refreshQueries'); + wrapper = shallow(); wrapper.find(Button).simulate('click'); - /* eslint-disable no-unused-expressions */ - expect(search).to.have.been.called; + expect(spy.called).to.equal(true); }); }); From 280a55c74b926737d4c3d1455e35867e3cef2cbb Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Mon, 12 Jun 2017 13:35:00 -0700 Subject: [PATCH 08/14] [table viz] get metrics to right-align (#2943) Moved the histogram to be rooted on the right side as well fixes https://github.com/airbnb/superset/issues/2933 --- superset/assets/visualizations/table.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/superset/assets/visualizations/table.js b/superset/assets/visualizations/table.js index dcbcd9ac740f0..be1a82c6234d7 100644 --- a/superset/assets/visualizations/table.js +++ b/superset/assets/visualizations/table.js @@ -94,12 +94,13 @@ function tableVis(slice, payload) { // The 0.01 to 0.001 is a workaround for what appears to be a // CSS rendering bug on flat, transparent colors return ( - `linear-gradient(to right, rgba(0,0,0,0.2), rgba(0,0,0,0.2) ${perc}%, ` + + `linear-gradient(to left, rgba(0,0,0,0.2), rgba(0,0,0,0.2) ${perc}%, ` + `rgba(0,0,0,0.01) ${perc}%, rgba(0,0,0,0.001) 100%)` ); } return null; }) + .classed('text-right', d => d.isMetric) .attr('title', (d) => { if (!isNaN(d.val)) { return fC(d.val); From 59e3027f0a8e1778f32ccedac287ad67105efa91 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Mon, 12 Jun 2017 13:46:14 -0700 Subject: [PATCH 09/14] Fix handling of Chunked requests (#1742) * Fix handling of Chunked requests Add fix for handling chunk encoding requests. If ENABLE_CHUNK_ENCODING is set to true, for requests with transfer encoding set to true. It will set wsgi.input_terminated to true which tells werkzeug to ignore content-length and read the stream till the end. break comment in multiple lines * remove debug print logging --- superset/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/superset/__init__.py b/superset/__init__.py index 71cd4549f8261..daf9e6b0fc7de 100644 --- a/superset/__init__.py +++ b/superset/__init__.py @@ -80,6 +80,20 @@ if app.config.get('ENABLE_PROXY_FIX'): app.wsgi_app = ProxyFix(app.wsgi_app) +if app.config.get('ENABLE_CHUNK_ENCODING'): + class ChunkedEncodingFix(object): + + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + # Setting wsgi.input_terminated tells werkzeug.wsgi to ignore + # content-length and read the stream till the end. + if 'chunked' == environ.get('HTTP_TRANSFER_ENCODING', '').lower(): + environ['wsgi.input_terminated'] = True + return self.app(environ, start_response) + app.wsgi_app = ChunkedEncodingFix(app.wsgi_app) + if app.config.get('UPLOAD_FOLDER'): try: os.makedirs(app.config.get('UPLOAD_FOLDER')) From cfbbc016ad457c9750cef3e0f68d2bab347f1968 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 13 Jun 2017 00:03:33 +0000 Subject: [PATCH 10/14] [hotfix] bumping pandas version to 0.20.2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index dc4f42d22d01d..b8f48abf80385 100644 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def get_git_sha(): 'humanize==0.5.1', 'gunicorn==19.7.1', 'markdown==2.6.8', - 'pandas==0.19.2', + 'pandas==0.20.2', 'parsedatetime==2.0.0', 'pydruid==0.3.1', 'PyHive>=0.3.0', From 6d3cc34a37eaafdc870e5c6c24aaa1ba1bc6a22a Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Mon, 12 Jun 2017 21:04:46 -0700 Subject: [PATCH 11/14] [dashboard] notify instead of modal onSave (#2941) * [dashboard] notify instead of modal onSave * Addressing comments --- superset/assets/javascripts/dashboard/Dashboard.jsx | 8 ++++++-- .../javascripts/dashboard/components/SaveModal.jsx | 13 ++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/superset/assets/javascripts/dashboard/Dashboard.jsx b/superset/assets/javascripts/dashboard/Dashboard.jsx index 483749c03e174..8b0a0e1c69114 100644 --- a/superset/assets/javascripts/dashboard/Dashboard.jsx +++ b/superset/assets/javascripts/dashboard/Dashboard.jsx @@ -7,6 +7,7 @@ import moment from 'moment'; import GridLayout from './components/GridLayout'; import Header from './components/Header'; import { appSetup } from '../common'; +import AlertsWrapper from '../components/AlertsWrapper'; import '../../stylesheets/dashboard.css'; @@ -60,7 +61,10 @@ function renderAlert() { function initDashboardView(dashboard) { render( -
, +
+ +
+
, document.getElementById('dashboard-header'), ); // eslint-disable-next-line no-param-reassign @@ -332,7 +336,7 @@ export function dashboardContainer(dashboard, datasources, userid) { const errorMsg = getAjaxErrorMsg(error); utils.showModal({ title: 'Error', - body: 'Sorry, there was an error adding slices to this dashboard: ' + errorMsg, + body: 'Sorry, there was an error adding slices to this dashboard: ' + errorMsg, }); }, }); diff --git a/superset/assets/javascripts/dashboard/components/SaveModal.jsx b/superset/assets/javascripts/dashboard/components/SaveModal.jsx index 7fb4e86b3464c..a22f4ac7725e0 100644 --- a/superset/assets/javascripts/dashboard/components/SaveModal.jsx +++ b/superset/assets/javascripts/dashboard/components/SaveModal.jsx @@ -1,7 +1,8 @@ +/* global notify */ import React from 'react'; import PropTypes from 'prop-types'; import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap'; -import { getAjaxErrorMsg, showModal } from '../../modules/utils'; +import { getAjaxErrorMsg } from '../../modules/utils'; import ModalTrigger from '../../components/ModalTrigger'; const $ = window.$ = require('jquery'); @@ -53,19 +54,13 @@ class SaveModal extends React.PureComponent { if (saveType === 'newDashboard') { window.location = '/superset/dashboard/' + resp.id + '/'; } else { - showModal({ - title: 'Success', - body: 'This dashboard was saved successfully.', - }); + notify.success('This dashboard was saved successfully.'); } }, error(error) { saveModal.close(); const errorMsg = getAjaxErrorMsg(error); - showModal({ - title: 'Error', - body: 'Sorry, there was an error saving this dashboard: ' + errorMsg, - }); + notify.error('Sorry, there was an error saving this dashboard: ' + errorMsg); }, }); } From 1e613de573d912fb7e431e90d40ddc488cf2714c Mon Sep 17 00:00:00 2001 From: Alanna Scott Date: Tue, 13 Jun 2017 09:44:00 -0700 Subject: [PATCH 12/14] [js] version js file names using webpack chunkhash (#2951) * get compiled js file names * make manifest available as template var * use script src directly to avoid flash of unstyled content in the case of csstheme.js * linting * attempt to fix tests * exception * print the path when no manifest file found * handle case when manifest.json is not present for some reason, or in the case of tests --- superset/__init__.py | 15 +++++++++++++++ superset/assets/package.json | 2 ++ superset/assets/webpack.config.js | 10 ++++++---- .../templates/superset/partials/_script_tag.html | 5 +++-- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/superset/__init__.py b/superset/__init__.py index daf9e6b0fc7de..fceff86159ff8 100644 --- a/superset/__init__.py +++ b/superset/__init__.py @@ -32,6 +32,21 @@ app.config.from_object(CONFIG_MODULE) conf = app.config + +@app.context_processor +def get_js_manifest(): + manifest = {} + try: + with open(APP_DIR + '/static/assets/dist/manifest.json', 'r') as f: + manifest = json.load(f) + except Exception as e: + print( + "no manifest file found at " + + APP_DIR + "/static/assets/dist/manifest.json" + ) + return dict(js_manifest=manifest) + + for bp in conf.get('BLUEPRINTS'): try: print("Registering blueprint: '{}'".format(bp.name)) diff --git a/superset/assets/package.json b/superset/assets/package.json index c466dac9cf7af..b93210582c59e 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -107,6 +107,7 @@ "babel-preset-es2015": "^6.14.0", "babel-preset-react": "^6.11.1", "chai": "^4.0.2", + "clean-webpack-plugin": "^0.1.16", "codeclimate-test-reporter": "^0.5.0", "css-loader": "^0.28.0", "enzyme": "^2.0.0", @@ -134,6 +135,7 @@ "transform-loader": "^0.2.3", "url-loader": "^0.5.7", "webpack": "^2.3.3", + "webpack-manifest-plugin": "1.1.0", "webworkify-webpack": "2.0.4" } } diff --git a/superset/assets/webpack.config.js b/superset/assets/webpack.config.js index 2f51c8c5150e3..d3d44fbed2554 100644 --- a/superset/assets/webpack.config.js +++ b/superset/assets/webpack.config.js @@ -1,6 +1,7 @@ const webpack = require('webpack'); const path = require('path'); -const fs = require('fs'); +const ManifestPlugin = require('webpack-manifest-plugin'); +const CleanWebpackPlugin = require('clean-webpack-plugin'); // input dir const APP_DIR = path.resolve(__dirname, './'); @@ -8,8 +9,6 @@ const APP_DIR = path.resolve(__dirname, './'); // output dir const BUILD_DIR = path.resolve(__dirname, './dist'); -const VERSION_STRING = JSON.parse(fs.readFileSync('package.json')).version; - const config = { entry: { 'css-theme': APP_DIR + '/javascripts/css-theme.js', @@ -23,7 +22,8 @@ const config = { }, output: { path: BUILD_DIR, - filename: `[name].${VERSION_STRING}.entry.js`, + filename: '[name].[chunkhash].entry.js', + chunkFilename: '[name].[chunkhash].entry.js', }, resolve: { extensions: [ @@ -115,6 +115,8 @@ const config = { 'react/lib/ReactContext': true, }, plugins: [ + new ManifestPlugin(), + new CleanWebpackPlugin(['dist']), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify(process.env.NODE_ENV), diff --git a/superset/templates/superset/partials/_script_tag.html b/superset/templates/superset/partials/_script_tag.html index 5afd264182136..e7bee3fede0f3 100644 --- a/superset/templates/superset/partials/_script_tag.html +++ b/superset/templates/superset/partials/_script_tag.html @@ -1,4 +1,5 @@ -{% set VERSION_STRING = appbuilder.get_app.config.get("VERSION_STRING") %} {% block tail_js %} - + {% endblock %} From 1422f261b9c6c2162ce91b54e970c481cc534a46 Mon Sep 17 00:00:00 2001 From: Alanna Scott Date: Tue, 13 Jun 2017 09:44:26 -0700 Subject: [PATCH 13/14] add new slice test (#2939) * sort explicitly on label * add simple test for /slicemodelview/add endpoint * make comments and method names more clear * fix test name * be more explicit, test status_code --- superset/views/core.py | 2 +- tests/core_tests.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/superset/views/core.py b/superset/views/core.py index 22a107ae5e002..1740cb0ae8df7 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -380,7 +380,7 @@ def add(self): return self.render_template( "superset/add_slice.html", bootstrap_data=json.dumps({ - 'datasources': sorted(datasources), + 'datasources': sorted(datasources, key=lambda d: d["label"]), }), ) diff --git a/tests/core_tests.py b/tests/core_tests.py index b8400d54311e8..9317800d56f28 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -216,17 +216,24 @@ def test_slices(self): logging.info("[{name}]/[{method}]: {url}".format(**locals())) self.client.get(url) - def test_add_slice(self): + def test_tablemodelview_list(self): self.login(username='admin') - # Click on the + to add a slice url = '/tablemodelview/list/' resp = self.get_resp(url) + # assert that a table is listed table = db.session.query(SqlaTable).first() assert table.name in resp assert '/superset/explore/table/{}'.format(table.id) in resp + def test_add_slice(self): + self.login(username='admin') + # assert that /slicemodelview/add responds with 200 + url = '/slicemodelview/add' + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + def test_slices_V2(self): # Add explore-v2-beta role to admin user # Test all slice urls as user with with explore-v2-beta role From 2a42cee605fc6d78eec913528b5a43faead76ba8 Mon Sep 17 00:00:00 2001 From: Grace Guo Date: Tue, 13 Jun 2017 15:07:39 -0700 Subject: [PATCH 14/14] add fixes for code review --- .../javascripts/components/EditableTitle.jsx | 21 +++++++------ .../explore/components/ChartContainer.jsx | 12 ++++---- .../javascripts/explore/exploreUtils.js | 11 ++++++- superset/assets/utils/common.js | 30 ------------------- tests/core_tests.py | 14 ++++++--- 5 files changed, 37 insertions(+), 51 deletions(-) diff --git a/superset/assets/javascripts/components/EditableTitle.jsx b/superset/assets/javascripts/components/EditableTitle.jsx index 43ce2f1825754..70046f87ca1e2 100644 --- a/superset/assets/javascripts/components/EditableTitle.jsx +++ b/superset/assets/javascripts/components/EditableTitle.jsx @@ -2,6 +2,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import TooltipWrapper from './TooltipWrapper'; +const propTypes = { + title: PropTypes.string, + canEdit: PropTypes.bool, + onSaveTitle: PropTypes.func.isRequired, +}; +const defaultProps = { + title: 'Title', + canEdit: false, +}; + class EditableTitle extends React.PureComponent { constructor(props) { super(props); @@ -68,14 +78,7 @@ class EditableTitle extends React.PureComponent { ); } } -EditableTitle.propTypes = { - title: PropTypes.string, - canEdit: PropTypes.bool, - onSaveTitle: PropTypes.func.isRequired, -}; -EditableTitle.defaultProps = { - title: 'Title', - canEdit: false, -}; +EditableTitle.propTypes = propTypes; +EditableTitle.defaultProps = defaultProps; export default EditableTitle; diff --git a/superset/assets/javascripts/explore/components/ChartContainer.jsx b/superset/assets/javascripts/explore/components/ChartContainer.jsx index d22a9296554d2..f6da538843eef 100644 --- a/superset/assets/javascripts/explore/components/ChartContainer.jsx +++ b/superset/assets/javascripts/explore/components/ChartContainer.jsx @@ -13,7 +13,6 @@ import TooltipWrapper from '../../components/TooltipWrapper'; import Timer from '../../components/Timer'; import { getExploreUrl } from '../exploreUtils'; import { getFormDataFromControls } from '../stores/store'; -import { serialize } from '../../../utils/common'; import CachedLabel from '../../components/CachedLabel'; const CHART_STATUS_MAP = { @@ -151,12 +150,11 @@ class ChartContainer extends React.PureComponent { } updateChartTitle(newTitle) { - const params = {}; - params.slice_name = newTitle; - params.action = 'overwrite'; - params.form_data = this.props.formData; - const saveUrl = '/superset/explore/' + - `${this.props.datasourceType}/${this.props.datasourceId}/?${serialize(params)}`; + const params = { + slice_name: newTitle, + action: 'overwrite', + }; + const saveUrl = getExploreUrl(this.props.formData, 'base', false, null, params); this.props.actions.saveSlice(saveUrl) .then(() => { this.props.actions.updateChartTitle(newTitle); diff --git a/superset/assets/javascripts/explore/exploreUtils.js b/superset/assets/javascripts/explore/exploreUtils.js index 5951ff61a2e6c..2356da5eb6256 100644 --- a/superset/assets/javascripts/explore/exploreUtils.js +++ b/superset/assets/javascripts/explore/exploreUtils.js @@ -1,7 +1,8 @@ /* eslint camelcase: 0 */ import URI from 'urijs'; -export function getExploreUrl(form_data, endpointType = 'base', force = false, curUrl = null) { +export function getExploreUrl(form_data, endpointType = 'base', force = false, + curUrl = null, requestParams = {}) { if (!form_data.datasource) { return null; } @@ -38,6 +39,14 @@ export function getExploreUrl(form_data, endpointType = 'base', force = false, c if (endpointType === 'query') { search.query = 'true'; } + const paramNames = Object.keys(requestParams); + if (paramNames.length) { + paramNames.forEach((name) => { + if (requestParams.hasOwnProperty(name)) { + search[name] = requestParams[name]; + } + }); + } uri = uri.search(search).directory(directory); return uri.toString(); } diff --git a/superset/assets/utils/common.js b/superset/assets/utils/common.js index 07a463e1fa3b8..2e143a12c811e 100644 --- a/superset/assets/utils/common.js +++ b/superset/assets/utils/common.js @@ -86,33 +86,3 @@ export function getShortUrl(longUrl, callback) { }, }); } - -export function serialize(obj) { - const parts = []; - const addParamParts = (propName, propValue) => { - const type = typeof propValue; - switch (type) { - case 'object': - parts.push(propName + '=' + encodeURIComponent(JSON.stringify(propValue))); - break; - case 'boolean': - case 'number': - case 'string': - parts.push(propName + '=' + encodeURIComponent(propValue)); - break; - default: - parts.push(propName + '='); - } - }; - - Object.keys(obj) - .filter(key => obj.hasOwnProperty(key)) - .forEach((prop) => { - if (Array.isArray(obj[prop])) { - obj[prop].forEach(value => addParamParts(prop, value)); - } else { - addParamParts(prop, obj[prop]); - } - }); - return parts.join('&'); -} diff --git a/tests/core_tests.py b/tests/core_tests.py index 9317800d56f28..7ff48a9081a8c 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -380,8 +380,11 @@ def test_save_dash(self, username='admin'): def test_save_dash_with_dashboard_title(self, username='admin'): self.login(username=username) - dash = db.session.query(models.Dashboard).filter_by( - slug="births").first() + dash = ( + db.session.query(models.Dashboard) + .filter_by(slug="births") + .first() + ) origin_title = dash.dashboard_title positions = [] for i, slc in enumerate(dash.slices): @@ -400,8 +403,11 @@ def test_save_dash_with_dashboard_title(self, username='admin'): } url = '/superset/save_dash/{}/'.format(dash.id) resp = self.get_resp(url, data=dict(data=json.dumps(data))) - updatedDash = db.session.query(models.Dashboard).filter_by( - slug="births").first() + updatedDash = ( + db.session.query(models.Dashboard) + .filter_by(slug="births") + .first() + ) self.assertEqual(updatedDash.dashboard_title, 'new title') # # bring back dashboard original title data['dashboard_title'] = origin_title