diff --git a/superset/assets/spec/javascripts/components/URLShortLinkModal_spec.jsx b/superset/assets/spec/javascripts/components/URLShortLinkModal_spec.jsx
new file mode 100644
index 0000000000..494d0d390a
--- /dev/null
+++ b/superset/assets/spec/javascripts/components/URLShortLinkModal_spec.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import configureStore from 'redux-mock-store';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import URLShortLinkModal from '../../../src/components/URLShortLinkModal';
+import ModalTrigger from '../../../src/components/ModalTrigger';
+
+describe('URLShortLinkModal', () => {
+ const defaultProps = {
+ url: 'mockURL',
+ emailSubject: 'Mock Subject',
+ emailContent: 'mock content',
+ };
+
+ function setup() {
+ const mockStore = configureStore([]);
+ const store = mockStore({});
+ return shallow(, { context: { store } }).dive();
+ }
+
+ it('renders ModalTrigger', () => {
+ const wrapper = setup();
+ expect(wrapper.find(ModalTrigger)).have.length(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/HeaderActionsDropdown_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/HeaderActionsDropdown_spec.jsx
new file mode 100644
index 0000000000..673118bd3b
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/HeaderActionsDropdown_spec.jsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import { shallow } from 'enzyme';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+import RefreshIntervalModal from '../../../../src/dashboard/components/RefreshIntervalModal';
+import URLShortLinkModal from '../../../../src/components/URLShortLinkModal';
+import HeaderActionsDropdown from '../../../../src/dashboard/components/HeaderActionsDropdown';
+import SaveModal from '../../../../src/dashboard/components/SaveModal';
+import CssEditor from '../../../../src/dashboard/components/CssEditor';
+
+describe('HeaderActionsDropdown', () => {
+ const props = {
+ addSuccessToast: () => {},
+ addDangerToast: () => {},
+ dashboardId: 1,
+ dashboardTitle: 'Title',
+ hasUnsavedChanges: false,
+ css: '',
+ onChange: () => {},
+ updateCss: () => {},
+ forceRefreshAllCharts: () => {},
+ startPeriodicRender: () => {},
+ editMode: false,
+ userCanEdit: false,
+ userCanSave: false,
+ layout: {},
+ filters: {},
+ expandedSlices: {},
+ onSave: () => {},
+ };
+
+ function setup(overrideProps) {
+ const wrapper = shallow(
+ ,
+ );
+ return wrapper;
+ }
+
+ describe('readonly-user', () => {
+ const overrideProps = { userCanSave: false };
+
+ it('should render the DropdownButton', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(DropdownButton)).to.have.lengthOf(1);
+ });
+
+ it('should not render the SaveModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(SaveModal)).to.have.lengthOf(0);
+ });
+
+ it('should render one MenuItem', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(MenuItem)).to.have.lengthOf(1);
+ });
+
+ it('should render the RefreshIntervalModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(RefreshIntervalModal)).to.have.lengthOf(1);
+ });
+
+ it('should render the URLShortLinkModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(URLShortLinkModal)).to.have.lengthOf(1);
+ });
+
+ it('should not render the CssEditor', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(CssEditor)).to.have.lengthOf(0);
+ });
+ });
+
+ describe('write-user', () => {
+ const overrideProps = { userCanSave: true };
+
+ it('should render the DropdownButton', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(DropdownButton)).to.have.lengthOf(1);
+ });
+
+ it('should render the SaveModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(SaveModal)).to.have.lengthOf(1);
+ });
+
+ it('should render two MenuItems', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(MenuItem)).to.have.lengthOf(2);
+ });
+
+ it('should render the RefreshIntervalModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(RefreshIntervalModal)).to.have.lengthOf(1);
+ });
+
+ it('should render the URLShortLinkModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(URLShortLinkModal)).to.have.lengthOf(1);
+ });
+
+ it('should not render the CssEditor', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(CssEditor)).to.have.lengthOf(0);
+ });
+ });
+
+ describe('write-user-with-edit-mode', () => {
+ const overrideProps = { userCanSave: true, editMode: true };
+
+ it('should render the DropdownButton', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(DropdownButton)).to.have.lengthOf(1);
+ });
+
+ it('should render the SaveModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(SaveModal)).to.have.lengthOf(1);
+ });
+
+ it('should render three MenuItems', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(MenuItem)).to.have.lengthOf(3);
+ });
+
+ it('should render the RefreshIntervalModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(RefreshIntervalModal)).to.have.lengthOf(1);
+ });
+
+ it('should render the URLShortLinkModal', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(URLShortLinkModal)).to.have.lengthOf(1);
+ });
+
+ it('should render the CssEditor', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(CssEditor)).to.have.lengthOf(1);
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx
new file mode 100644
index 0000000000..e7ecfc142c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import { shallow } from 'enzyme';
+import Header from '../../../../src/dashboard/components/Header';
+import EditableTitle from '../../../../src/components/EditableTitle';
+import FaveStar from '../../../../src/components/FaveStar';
+import HeaderActionsDropdown from '../../../../src/dashboard/components/HeaderActionsDropdown';
+import Button from '../../../../src/components/Button';
+import UndoRedoKeylisteners from '../../../../src/dashboard/components/UndoRedoKeylisteners';
+
+describe('Header', () => {
+ const props = {
+ addSuccessToast: () => {},
+ addDangerToast: () => {},
+ dashboardInfo: { id: 1, dash_edit_perm: true, dash_save_perm: true },
+ dashboardTitle: 'title',
+ charts: {},
+ layout: {},
+ filters: {},
+ expandedSlices: {},
+ css: '',
+ isStarred: false,
+ onSave: () => {},
+ onChange: () => {},
+ fetchFaveStar: () => {},
+ fetchCharts: () => {},
+ saveFaveStar: () => {},
+ startPeriodicRender: () => {},
+ updateDashboardTitle: () => {},
+ editMode: false,
+ setEditMode: () => {},
+ showBuilderPane: false,
+ toggleBuilderPane: () => {},
+ updateCss: () => {},
+ hasUnsavedChanges: false,
+ maxUndoHistoryExceeded: false,
+
+ // redux
+ onUndo: () => {},
+ onRedo: () => {},
+ undoLength: 0,
+ redoLength: 0,
+ setMaxUndoHistoryExceeded: () => {},
+ maxUndoHistoryToast: () => {},
+ };
+
+ function setup(overrideProps) {
+ const wrapper = shallow();
+ return wrapper;
+ }
+
+ describe('read-only-user', () => {
+ const overrideProps = {
+ dashboardInfo: { id: 1, dash_edit_perm: false, dash_save_perm: false },
+ };
+
+ it('should render the EditableTitle', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(EditableTitle)).to.have.lengthOf(1);
+ });
+
+ it('should render the FaveStar', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(FaveStar)).to.have.lengthOf(1);
+ });
+
+ it('should render the HeaderActionsDropdown', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(HeaderActionsDropdown)).to.have.lengthOf(1);
+ });
+
+ it('should render one Button', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(Button)).to.have.lengthOf(1);
+ });
+
+ it('should not set up undo/redo', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(UndoRedoKeylisteners)).to.have.lengthOf(0);
+ });
+ });
+
+ describe('write-user', () => {
+ const overrideProps = {
+ editMode: false,
+ dashboardInfo: { id: 1, dash_edit_perm: true, dash_save_perm: true },
+ };
+
+ it('should render the EditableTitle', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(EditableTitle)).to.have.lengthOf(1);
+ });
+
+ it('should render the FaveStar', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(FaveStar)).to.have.lengthOf(1);
+ });
+
+ it('should render the HeaderActionsDropdown', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(HeaderActionsDropdown)).to.have.lengthOf(1);
+ });
+
+ it('should render one Button', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(Button)).to.have.lengthOf(1);
+ });
+
+ it('should not set up undo/redo', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(UndoRedoKeylisteners)).to.have.lengthOf(0);
+ });
+ });
+
+ describe('write-user-with-edit-mode', () => {
+ const overrideProps = {
+ editMode: true,
+ dashboardInfo: { id: 1, dash_edit_perm: true, dash_save_perm: true },
+ };
+
+ it('should render the EditableTitle', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(EditableTitle)).to.have.lengthOf(1);
+ });
+
+ it('should render the FaveStar', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(FaveStar)).to.have.lengthOf(1);
+ });
+
+ it('should render the HeaderActionsDropdown', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(HeaderActionsDropdown)).to.have.lengthOf(1);
+ });
+
+ it('should render four Buttons', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(Button)).to.have.lengthOf(4);
+ });
+
+ it('should set up undo/redo', () => {
+ const wrapper = setup(overrideProps);
+ expect(wrapper.find(UndoRedoKeylisteners)).to.have.lengthOf(1);
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getDashboardUrl_spec.js b/superset/assets/spec/javascripts/dashboard/util/getDashboardUrl_spec.js
new file mode 100644
index 0000000000..c45e65a02a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getDashboardUrl_spec.js
@@ -0,0 +1,14 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getDashboardUrl from '../../../../src/dashboard/util/getDashboardUrl';
+
+describe('getChartIdsFromLayout', () => {
+ it('should encode filters', () => {
+ const filters = { 35: { key: ['value'] } };
+ const url = getDashboardUrl('path', filters);
+ expect(url).to.equal(
+ 'path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D',
+ );
+ });
+});
diff --git a/superset/assets/src/components/URLShortLinkButton.jsx b/superset/assets/src/components/URLShortLinkButton.jsx
index 1efd4f7122..19c77ab332 100644
--- a/superset/assets/src/components/URLShortLinkButton.jsx
+++ b/superset/assets/src/components/URLShortLinkButton.jsx
@@ -20,6 +20,7 @@ class URLShortLinkButton extends React.Component {
shortUrl: '',
};
this.onShortUrlSuccess = this.onShortUrlSuccess.bind(this);
+ this.getCopyUrl = this.getCopyUrl.bind(this);
}
onShortUrlSuccess(data) {
@@ -54,7 +55,7 @@ class URLShortLinkButton extends React.Component {
trigger="click"
rootClose
placement="left"
- onEnter={this.getCopyUrl.bind(this)}
+ onEnter={this.getCopyUrl}
overlay={this.renderPopover()}
>
diff --git a/superset/assets/src/components/URLShortLinkModal.jsx b/superset/assets/src/components/URLShortLinkModal.jsx
new file mode 100644
index 0000000000..9f7a36bce4
--- /dev/null
+++ b/superset/assets/src/components/URLShortLinkModal.jsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import CopyToClipboard from './CopyToClipboard';
+import { getShortUrl } from '../utils/common';
+import { t } from '../locales';
+import withToasts from '../messageToasts/enhancers/withToasts';
+import ModalTrigger from './ModalTrigger';
+
+const propTypes = {
+ url: PropTypes.string,
+ emailSubject: PropTypes.string,
+ emailContent: PropTypes.string,
+ addDangerToast: PropTypes.func.isRequired,
+ isMenuItem: PropTypes.bool,
+ triggerNode: PropTypes.node.isRequired,
+};
+
+class URLShortLinkModal extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ shortUrl: '',
+ };
+ this.modal = null;
+ this.setModalRef = this.setModalRef.bind(this);
+ this.onShortUrlSuccess = this.onShortUrlSuccess.bind(this);
+ this.getCopyUrl = this.getCopyUrl.bind(this);
+ }
+
+ onShortUrlSuccess(data) {
+ this.setState({
+ shortUrl: data,
+ });
+ }
+
+ setModalRef(ref) {
+ this.modal = ref;
+ }
+
+ getCopyUrl() {
+ getShortUrl(this.props.url, this.onShortUrlSuccess, this.props.addDangerToast);
+ }
+
+ render() {
+ const emailBody = t('%s%s', this.props.emailContent, this.state.shortUrl);
+ return (
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+ );
+ }
+}
+
+URLShortLinkModal.defaultProps = {
+ url: window.location.href.substring(window.location.origin.length),
+ emailSubject: '',
+ emailContent: '',
+};
+
+URLShortLinkModal.propTypes = propTypes;
+
+export default withToasts(URLShortLinkModal);
diff --git a/superset/assets/src/dashboard/components/Header.jsx b/superset/assets/src/dashboard/components/Header.jsx
index 0c1951b8d7..9f976cbab9 100644
--- a/superset/assets/src/dashboard/components/Header.jsx
+++ b/superset/assets/src/dashboard/components/Header.jsx
@@ -189,100 +189,103 @@ class Header extends React.PureComponent {
- {userCanSaveAs && (
-
- {editMode && (
+
+ {userCanSaveAs && (
+
+ {editMode && (
+
+ )}
+
+ {editMode && (
+
+ )}
+
+ {editMode && (
+
+ )}
+
+ {editMode &&
+ hasUnsavedChanges && (
+
+ )}
+
+ {editMode &&
+ !hasUnsavedChanges && (
+
+ )}
+
+ {editMode && (
+
+ )}
+
+ )}
+
+ {!editMode &&
+ !hasUnsavedChanges && (
)}
- {editMode && (
-
- )}
-
- {editMode && (
-
- )}
-
- {editMode &&
- hasUnsavedChanges && (
-
- )}
-
- {!editMode &&
- !hasUnsavedChanges && (
-
- )}
-
- {editMode &&
- !hasUnsavedChanges && (
-
- )}
-
-
-
- {editMode && (
-
- )}
-
- )}
+
+
);
}
diff --git a/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx b/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx
index dab11c382f..b5e5d022b9 100644
--- a/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx
+++ b/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx
@@ -10,6 +10,8 @@ import SaveModal from './SaveModal';
import injectCustomCss from '../util/injectCustomCss';
import { SAVE_TYPE_NEWDASHBOARD } from '../util/constants';
import { t } from '../../locales';
+import URLShortLinkModal from '../../components/URLShortLinkModal';
+import getDashboardUrl from '../util/getDashboardUrl';
const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
@@ -24,6 +26,7 @@ const propTypes = {
startPeriodicRender: PropTypes.func.isRequired,
editMode: PropTypes.bool.isRequired,
userCanEdit: PropTypes.bool.isRequired,
+ userCanSave: PropTypes.bool.isRequired,
layout: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
expandedSlices: PropTypes.object.isRequired,
@@ -82,10 +85,12 @@ class HeaderActionsDropdown extends React.PureComponent {
expandedSlices,
onSave,
userCanEdit,
+ userCanSave,
} = this.props;
- const emailBody = t('Check out this dashboard: %s', window.location.href);
- const emailLink = `mailto:?Subject=Superset%20Dashboard%20${dashboardTitle}&Body=${emailBody}`;
+ const emailTitle = t('Superset Dashboard');
+ const emailSubject = `${emailTitle} ${dashboardTitle}`;
+ const emailBody = t('Check out this dashboard: ');
return (
- {t('Save as')}}
- canOverwrite={userCanEdit}
- />
- {hasUnsavedChanges && (
-
+ {userCanSave && (
+ {t('Save as')}}
+ canOverwrite={userCanEdit}
+ />
)}
-
+ {hasUnsavedChanges &&
+ userCanSave && (
+
+
+
+ )}
+
+ {userCanSave && }
)}
- {editMode && (
-
- )}
+
+ {t('Share dashboard')}}
+ />
+
{editMode && (
{t('Edit CSS')}}
diff --git a/superset/assets/src/dashboard/util/getDashboardUrl.js b/superset/assets/src/dashboard/util/getDashboardUrl.js
new file mode 100644
index 0000000000..d26ca90330
--- /dev/null
+++ b/superset/assets/src/dashboard/util/getDashboardUrl.js
@@ -0,0 +1,6 @@
+/* eslint camelcase: 0 */
+
+export default function getDashboardUrl(pathname, filters = {}) {
+ const preselect_filters = encodeURIComponent(JSON.stringify(filters));
+ return `${pathname}?preselect_filters=${preselect_filters}`;
+}