You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by GitBox <gi...@apache.org> on 2018/07/03 05:53:55 UTC

[GitHub] hughhhh closed pull request #5321: Add short URL link to dashboard

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')} />
+              }
+            />
+            &nbsp;&nbsp;
+            <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')} />
+              }
+            />
+            &nbsp;&nbsp;
+            <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:
users@infra.apache.org


With regards,
Apache Git Services

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org