hughhhh closed pull request #5321: Add short URL link to dashboard URL: https://github.com/apache/incubator-superset/pull/5321
This is a PR merged from a forked repository. As GitHub hides the original diff on merge, it is displayed below for the sake of provenance: As this is a foreign pull request (from a fork), the diff is supplied below (as it won't show otherwise due to GitHub magic): diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 519644a4af..94555b2270 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -307,7 +307,7 @@ commands are invoked. We use [Mocha](https://mochajs.org/), [Chai](http://chaijs.com/) and [Enzyme](http://airbnb.io/enzyme/) to test Javascript. Tests can be run with: - cd /superset/superset/assets/javascripts + cd /superset/assets npm i npm run test diff --git a/superset/assets/spec/javascripts/dashboard/components/URLShortLinkModal_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/URLShortLinkModal_spec.jsx new file mode 100644 index 0000000000..5deee01896 --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/components/URLShortLinkModal_spec.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import URLShortLinkModal from '../../../../src/dashboard/components/URLShortLinkModal'; + +describe('URLShortLinkModal', () => { + const mockedProps = { + triggerNode: <i className="fa fa-edit" />, + }; + it('is valid', () => { + expect( + React.isValidElement(<URLShortLinkModal {...mockedProps} />), + ).to.equal(true); + }); + it('renders the trigger node', () => { + const wrapper = mount(<URLShortLinkModal {...mockedProps} />); + expect(wrapper.find('.fa-edit')).to.have.length(1); + }); +}); diff --git a/superset/assets/spec/javascripts/dashboard/util/getDashboardLongUrl_spec.js b/superset/assets/spec/javascripts/dashboard/util/getDashboardLongUrl_spec.js new file mode 100644 index 0000000000..43b7de67c8 --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/util/getDashboardLongUrl_spec.js @@ -0,0 +1,30 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import getDashboardLongUrl from '../../../../src/dashboard/util/getDashboardLongUrl'; + +describe('getDashboardLongUrl', () => { + it('should return link to dashboard with slug preferred when available', () => { + expect(getDashboardLongUrl({ id: 1, slug: 'slugName' }, {})).to.equal( + '/superset/dashboard/slugName/?preselect_filters=%7B%7D', + ); + expect(getDashboardLongUrl({ id: 1, slug: null }, {})).to.equal( + '/superset/dashboard/1/?preselect_filters=%7B%7D', + ); + }); + + it('should include filters passed in', () => { + expect( + getDashboardLongUrl( + { id: 1, slug: 'slugName' }, + { 13: { filterName: ['value1', 'value2'] } }, + ), + ).to.equal( + '/superset/dashboard/slugName/?preselect_filters=%7B%2213%22%3A%7B%22filterName%22%3A%5B%22value1%22%2C%22value2%22%5D%7D%7D', + ); + }); + + it('should return null when no dashboard is passed in', () => { + expect(getDashboardLongUrl(null, {})).to.equal(null); + }); +}); diff --git a/superset/assets/src/dashboard/components/Header.jsx b/superset/assets/src/dashboard/components/Header.jsx index 3b1b6b1f36..a7a40bcb45 100644 --- a/superset/assets/src/dashboard/components/Header.jsx +++ b/superset/assets/src/dashboard/components/Header.jsx @@ -298,7 +298,7 @@ class Header extends React.PureComponent { <HeaderActionsDropdown addSuccessToast={this.props.addSuccessToast} addDangerToast={this.props.addDangerToast} - dashboardId={dashboardInfo.id} + dashboardInfo={dashboardInfo} dashboardTitle={dashboardTitle} layout={layout} filters={filters} diff --git a/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx b/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx index 7b8a245074..10568a87c5 100644 --- a/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx +++ b/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx @@ -7,6 +7,7 @@ import { DropdownButton, MenuItem } from 'react-bootstrap'; import CssEditor from './CssEditor'; import RefreshIntervalModal from './RefreshIntervalModal'; import SaveModal from './SaveModal'; +import URLShortLinkModal from './URLShortLinkModal'; import injectCustomCss from '../util/injectCustomCss'; import { SAVE_TYPE_NEWDASHBOARD } from '../util/constants'; import { t } from '../../locales'; @@ -14,7 +15,7 @@ import { t } from '../../locales'; const propTypes = { addSuccessToast: PropTypes.func.isRequired, addDangerToast: PropTypes.func.isRequired, - dashboardId: PropTypes.number.isRequired, + dashboardInfo: PropTypes.object.isRequired, dashboardTitle: PropTypes.string.isRequired, hasUnsavedChanges: PropTypes.bool.isRequired, css: PropTypes.string.isRequired, @@ -72,7 +73,7 @@ class HeaderActionsDropdown extends React.PureComponent { render() { const { dashboardTitle, - dashboardId, + dashboardInfo, startPeriodicRender, forceRefreshAllCharts, editMode, @@ -86,9 +87,12 @@ class HeaderActionsDropdown extends React.PureComponent { isV2Preview, } = this.props; - const emailBody = t('Check out this dashboard: %s', window.location.href); + const emailPrefix = t('Check out this dashboard:'); + const emailBody = `${emailPrefix}: ${window.location.href}`; const emailLink = `mailto:?Subject=Superset%20Dashboard%20${dashboardTitle}&Body=${emailBody}`; + const dashboardId = dashboardInfo.id; + return ( <DropdownButton title="" @@ -133,6 +137,14 @@ class HeaderActionsDropdown extends React.PureComponent { } triggerNode={<span>{t('Set auto-refresh interval')}</span>} /> + + <URLShortLinkModal + dashboard={dashboardInfo} + filters={filters} + emailPrefix={emailPrefix} + triggerNode={<span>{t('Save Short URL')}</span>} + /> + {editMode && ( <MenuItem target="_blank" diff --git a/superset/assets/src/dashboard/components/URLShortLinkModal.jsx b/superset/assets/src/dashboard/components/URLShortLinkModal.jsx new file mode 100644 index 0000000000..fcf16c3971 --- /dev/null +++ b/superset/assets/src/dashboard/components/URLShortLinkModal.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ModalTrigger from '../../components/ModalTrigger'; +import { t } from '../../locales'; +import CopyToClipboard from './../../components/CopyToClipboard'; +import { getShortUrl } from '../../utils/common'; +import getDashboardLongUrl from '../util/getDashboardLongUrl'; + +const propTypes = { + dashboard: PropTypes.object.isRequired, + triggerNode: PropTypes.node.isRequired, + filters: PropTypes.object.isRequired, + emailPrefix: PropTypes.string.isRequired, +}; + +class URLShortLinkModal extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + shortUrl: '', + }; + this.getCopyUrl = this.getCopyUrl.bind(this); + } + + onShortUrlSuccess(data) { + this.setState({ + shortUrl: data, + }); + } + + getCopyUrl() { + const longUrl = getDashboardLongUrl( + this.props.dashboard, + this.props.filters, + ); + getShortUrl(longUrl, this.onShortUrlSuccess.bind(this)); + } + + render() { + const { emailPrefix, triggerNode } = this.props; + const { shortUrl } = this.state; + + const emailBody = `${emailPrefix} ${shortUrl}`; + + return ( + <ModalTrigger + triggerNode={triggerNode} + isMenuItem + modalTitle={t('Short URL')} + beforeOpen={this.getCopyUrl} + modalBody={ + <div> + <CopyToClipboard + text={shortUrl} + copyNode={ + <i className="fa fa-clipboard" title={t('Copy to clipboard')} /> + } + /> + + <a href={`mailto:?Subject=Superset%20Slice%20&Body=${emailBody}`}> + <i className="fa fa-envelope" /> + </a> + </div> + } + /> + ); + } +} +URLShortLinkModal.propTypes = propTypes; + +export default URLShortLinkModal; diff --git a/superset/assets/src/dashboard/deprecated/v1/components/Controls.jsx b/superset/assets/src/dashboard/deprecated/v1/components/Controls.jsx index 6a6fa47bb9..48c9510fbe 100644 --- a/superset/assets/src/dashboard/deprecated/v1/components/Controls.jsx +++ b/superset/assets/src/dashboard/deprecated/v1/components/Controls.jsx @@ -8,6 +8,7 @@ import SaveModal from './SaveModal'; import SliceAdder from './SliceAdder'; import { t } from '../../../../locales'; import InfoTooltipWithTrigger from '../../../../components/InfoTooltipWithTrigger'; +import URLShortLinkModal from '../components/URLShortLinkModal'; const $ = window.$ = require('jquery'); @@ -143,6 +144,18 @@ class Controls extends React.PureComponent { /> } /> + <URLShortLinkModal + dashboard={dashboard} + filters={filters} + emailPrefix={t('Check out this dashboard:')} + triggerNode={ + <MenuItemContent + text={t('Save Short URL')} + tooltip={t('Save a shortened URL to the dashboard with filters applied')} + faIcon="link" + /> + } + /> {dashboard.dash_save_perm && <SaveModal dashboard={dashboard} diff --git a/superset/assets/src/dashboard/deprecated/v1/components/URLShortLinkModal.jsx b/superset/assets/src/dashboard/deprecated/v1/components/URLShortLinkModal.jsx new file mode 100644 index 0000000000..3ac3f07149 --- /dev/null +++ b/superset/assets/src/dashboard/deprecated/v1/components/URLShortLinkModal.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ModalTrigger from '../../../../components/ModalTrigger'; +import { t } from '../../../../locales'; +import CopyToClipboard from '../../../../components/CopyToClipboard'; +import { getShortUrl } from '../../../../utils/common'; +import getDashboardLongUrl from '../../../util/getDashboardLongUrl'; + +const propTypes = { + dashboard: PropTypes.object.isRequired, + triggerNode: PropTypes.node.isRequired, + filters: PropTypes.object.isRequired, + emailPrefix: PropTypes.string.isRequired, +}; + +class URLShortLinkModal extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + shortUrl: '', + }; + this.getCopyUrl = this.getCopyUrl.bind(this); + } + + onShortUrlSuccess(data) { + this.setState({ + shortUrl: data, + }); + } + + getCopyUrl() { + const longUrl = getDashboardLongUrl( + this.props.dashboard, + this.props.filters, + ); + getShortUrl(longUrl, this.onShortUrlSuccess.bind(this)); + } + + render() { + const { emailPrefix, triggerNode } = this.props; + const { shortUrl } = this.state; + + const emailBody = `${emailPrefix} ${shortUrl}`; + + return ( + <ModalTrigger + triggerNode={triggerNode} + isMenuItem + modalTitle={t('Short URL')} + beforeOpen={this.getCopyUrl} + modalBody={ + <div> + <CopyToClipboard + text={shortUrl} + copyNode={ + <i className="fa fa-clipboard" title={t('Copy to clipboard')} /> + } + /> + + <a href={`mailto:?Subject=Superset%20Slice%20&Body=${emailBody}`}> + <i className="fa fa-envelope" /> + </a> + </div> + } + /> + ); + } +} +URLShortLinkModal.propTypes = propTypes; + +export default URLShortLinkModal; diff --git a/superset/assets/src/dashboard/util/getDashboardLongUrl.js b/superset/assets/src/dashboard/util/getDashboardLongUrl.js new file mode 100644 index 0000000000..b45773e67f --- /dev/null +++ b/superset/assets/src/dashboard/util/getDashboardLongUrl.js @@ -0,0 +1,25 @@ +/* eslint camelcase: 0 */ +import URI from 'urijs'; + +/** + * + * @param dashboard: object with id and slug properties + * @param filters: current filter object applied to the dashboard + * @returns long link for the dashboard with the given filters applied + */ +export default function getDashboardLongUrl(dashboard, filters) { + if (!dashboard) { + return null; + } + + const uri = new URI('/'); + const dashboardId = dashboard.slug || dashboard.id; + const directory = `/superset/dashboard/${dashboardId}/`; + + const search = uri.search(true); + search.preselect_filters = JSON.stringify(filters); + return uri + .directory(directory) + .search(search) + .toString(); +} diff --git a/superset/data/__init__.py b/superset/data/__init__.py index 49df42b137..93464ead5d 100644 --- a/superset/data/__init__.py +++ b/superset/data/__init__.py @@ -452,7 +452,14 @@ def load_world_bank_health_n_pop(): dash.dashboard_title = dash_name dash.position_json = json.dumps(l, indent=4) dash.slug = slug - + dash.json_metadata = """ + { + "filter_immune_slices": [], + "timed_refresh_immune_slices": [], + "expanded_slices": {}, + "filter_immune_slice_fields": {}, + "default_filters": "{}" + }""" dash.slices = slices[:-1] db.session.merge(dash) db.session.commit() diff --git a/superset/models/core.py b/superset/models/core.py index 4e195ae41d..c4ad50e8ed 100644 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -360,6 +360,13 @@ def url(self): pass return '/superset/dashboard/{}/'.format(self.slug or self.id) + def get_dashboard_url(self, short_url_id=None): + if short_url_id: + return '/superset/dashboard/{}/?r={}'.format( + self.slug or self.id, short_url_id) + else: + return self.url + @property def datasources(self): return {slc.datasource for slc in self.slices} diff --git a/superset/views/core.py b/superset/views/core.py index 7c39f1c6d6..1c8b238681 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -2190,6 +2190,21 @@ def dashboard(**kwargs): # noqa 'slice_can_edit': slice_can_edit, }) + url_id = request.args.get('r') + if url_id: + saved_url = db.session.query(models.Url).filter_by(id=url_id).first() + if saved_url: + url_str = parse.unquote_plus( + saved_url.url.split('?')[1][18:], encoding='utf-8', errors=None) + filters = json.loads(url_str) + metadata = { + 'default_filters': json.dumps(filters), + } + if 'metadata' in dashboard_data: + dashboard_data['metadata'].update(metadata) + else: + dashboard_data['metadata'] = metadata + bootstrap_data = { 'user_id': g.user.get_id(), 'dashboard_data': dashboard_data, diff --git a/tests/base_tests.py b/tests/base_tests.py index eefd3d98b8..e756ed4eee 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -127,6 +127,15 @@ def login(self, username='admin', password='general'): data=dict(username=username, password=password)) self.assertNotIn('User confirmation needed', resp) + def get_dashboard(self, dashboard_slug, session): + slc = ( + session.query(models.Dashboard) + .filter_by(slug=dashboard_slug) + .one() + ) + session.expunge_all() + return slc + def get_slice(self, slice_name, session): slc = ( session.query(models.Slice) diff --git a/tests/core_tests.py b/tests/core_tests.py index f1a01796b7..d91256f3a7 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -16,7 +16,9 @@ import re import string import unittest +from urllib.parse import urlparse +from future.standard_library import install_aliases import pandas as pd import psycopg2 from six import text_type @@ -30,6 +32,8 @@ from superset.views.core import DatabaseView from .base_tests import SupersetTestCase +install_aliases() + class CoreTests(SupersetTestCase): @@ -697,6 +701,31 @@ def test_slice_payload_viz_markdown(self): self.assertEqual(data['status'], None) self.assertEqual(data['error'], None) + def test_dashboard_metadata_no_short_url(self): + self.login(username='admin') + dash = self.get_dashboard('world_health', db.session) + + url = dash.get_dashboard_url() + data = self.get_json_resp('{}?json=true'.format(url)) + self.assertEqual(data['dashboard_data']['metadata']['default_filters'], '{}') + + def test_dashboard_metadata_short_url(self): + self.login(username='admin') + dash = self.get_dashboard('world_health', db.session) + + filters = '{"414":{"filter1":["a","b","c"]}}' + query = 'preselect_filters={}'.format(filters) + + url = '/{}?{}'.format(dash.get_dashboard_url(), query) + short_url = self.client.post('/r/shortner/', data=dict(data=url)) + short_path = urlparse(short_url.data.decode('utf-8')).path + + redirect = self.client.get(short_path, follow_redirects=False) + dash_url = urlparse(redirect.headers['location']) + + self.assertEqual(dash.get_dashboard_url(), dash_url.path) + self.assertEqual(query, dash_url.query) + if __name__ == '__main__': unittest.main() ---------------------------------------------------------------- This is an automated message from the Apache Git Service. To respond to the message, please log on GitHub and use the URL above to go to the specific comment. For queries about this service, please contact Infrastructure at: [email protected] With regards, Apache Git Services --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
