diff --git a/superset/assets/spec/javascripts/components/AnchorLink_spec.jsx b/superset/assets/spec/javascripts/components/AnchorLink_spec.jsx
new file mode 100644
index 0000000000000..42811094592df
--- /dev/null
+++ b/superset/assets/spec/javascripts/components/AnchorLink_spec.jsx
@@ -0,0 +1,63 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+import sinon from 'sinon';
+
+import AnchorLink from '../../../src/components/AnchorLink';
+import URLShortLinkButton from '../../../src/components/URLShortLinkButton';
+
+describe('AnchorLink', () => {
+ const props = {
+ anchorLinkId: 'CHART-123',
+ };
+
+ it('should scroll the AnchorLink into view upon mount', () => {
+ const callback = sinon.spy();
+ const clock = sinon.useFakeTimers();
+ const stub = sinon.stub(document, 'getElementById').returns({
+ scrollIntoView: callback,
+ });
+
+ const wrapper = shallow();
+ wrapper.instance().getLocationHash = () => (props.anchorLinkId);
+ wrapper.update();
+
+ wrapper.instance().componentDidMount();
+ clock.tick(2000);
+ expect(callback.callCount).toEqual(1);
+ stub.restore();
+ });
+
+ it('should render anchor link with id', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(`#${props.anchorLinkId}`)).toHaveLength(1);
+ expect(wrapper.find(URLShortLinkButton)).toHaveLength(0);
+ });
+
+ it('should render URLShortLinkButton', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(URLShortLinkButton)).toHaveLength(1);
+ expect(wrapper.find(URLShortLinkButton).prop('placement')).toBe('right');
+
+ const targetUrl = wrapper.find(URLShortLinkButton).prop('url');
+ const hash = targetUrl.slice(targetUrl.indexOf('#') + 1);
+ expect(hash).toBe(props.anchorLinkId);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/actions/dashboardState_spec.js b/superset/assets/spec/javascripts/dashboard/actions/dashboardState_spec.js
new file mode 100644
index 0000000000000..8ba499e32107c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/actions/dashboardState_spec.js
@@ -0,0 +1,100 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import sinon from 'sinon';
+import { SupersetClient } from '@superset-ui/connection';
+
+import { saveDashboardRequest } from '../../../../src/dashboard/actions/dashboardState';
+import { UPDATE_COMPONENTS_PARENTS_LIST } from '../../../../src/dashboard/actions/dashboardLayout';
+import mockDashboardData from '../fixtures/mockDashboardData';
+import { DASHBOARD_GRID_ID } from '../../../../src/dashboard/util/constants';
+
+describe('dashboardState actions', () => {
+ const mockState = {
+ dashboardState: {
+ hasUnsavedChanges: true,
+ },
+ dashboardInfo: {},
+ dashboardLayout: {
+ past: [],
+ present: mockDashboardData.positions,
+ future: {},
+ },
+ };
+ const newDashboardData = mockDashboardData;
+
+ let postStub;
+ beforeEach(() => {
+ postStub = sinon
+ .stub(SupersetClient, 'post')
+ .resolves('the value you want to return');
+ });
+ afterEach(() => {
+ postStub.restore();
+ });
+
+ function setup(stateOverrides) {
+ const state = { ...mockState, ...stateOverrides };
+ const getState = sinon.spy(() => state);
+ const dispatch = sinon.stub();
+ return { getState, dispatch, state };
+ }
+
+ describe('saveDashboardRequest', () => {
+ it('should dispatch UPDATE_COMPONENTS_PARENTS_LIST action', () => {
+ const { getState, dispatch } = setup({
+ dashboardState: { hasUnsavedChanges: false },
+ });
+ const thunk = saveDashboardRequest(newDashboardData, 1, 'save_dash');
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).toBe(1);
+ expect(dispatch.getCall(0).args[0].type).toBe(
+ UPDATE_COMPONENTS_PARENTS_LIST,
+ );
+ });
+
+ it('should post dashboard data with updated redux state', () => {
+ const { getState, dispatch } = setup({
+ dashboardState: { hasUnsavedChanges: false },
+ });
+
+ // start with mockDashboardData, it didn't have parents attr
+ expect(
+ newDashboardData.positions[DASHBOARD_GRID_ID].parents,
+ ).not.toBeDefined();
+
+ // mock redux work: dispatch an event, cause modify redux state
+ const mockParentsList = ['ROOT_ID'];
+ dispatch.callsFake(() => {
+ mockState.dashboardLayout.present[
+ DASHBOARD_GRID_ID
+ ].parents = mockParentsList;
+ });
+
+ // call saveDashboardRequest, it should post dashboard data with updated
+ // layout object (with parents attribute)
+ const thunk = saveDashboardRequest(newDashboardData, 1, 'save_dash');
+ thunk(dispatch, getState);
+ expect(postStub.callCount).toBe(1);
+ const postPayload = postStub.getCall(0).args[0].postPayload;
+ expect(postPayload.data.positions[DASHBOARD_GRID_ID].parents).toBe(
+ mockParentsList,
+ );
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
index b206184529ac0..30121f28a791c 100644
--- a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
@@ -17,6 +17,7 @@
* under the License.
*/
import React from 'react';
+import { Provider } from 'react-redux';
import { mount } from 'enzyme';
import sinon from 'sinon';
@@ -33,6 +34,7 @@ import {
} from '../../../../../src/dashboard/util/componentTypes';
import WithDragDropContext from '../../helpers/WithDragDropContext';
+import { mockStoreWithTabs } from '../../fixtures/mockStore';
describe('Header', () => {
const props = {
@@ -43,6 +45,7 @@ describe('Header', () => {
parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
index: 0,
editMode: false,
+ filters: {},
handleComponentDrop() {},
deleteComponent() {},
updateComponents() {},
@@ -52,9 +55,11 @@ describe('Header', () => {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
-
-
- ,
+
+
+
+
+ ,
);
return wrapper;
}
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
index 59b067c67217e..72d3a03600a88 100644
--- a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
@@ -18,7 +18,7 @@
*/
import { Provider } from 'react-redux';
import React from 'react';
-import { mount } from 'enzyme';
+import { mount, shallow } from 'enzyme';
import sinon from 'sinon';
import { Tabs as BootstrapTabs, Tab as BootstrapTab } from 'react-bootstrap';
@@ -154,4 +154,20 @@ describe('Tabs', () => {
expect(deleteComponent.callCount).toBe(1);
});
+
+ it('should direct display direct-link tab', () => {
+ let wrapper = shallow();
+ // default show first tab child
+ expect(wrapper.state('tabIndex')).toBe(0);
+
+ // display child in directPathToChild list
+ const directPathToChild = dashboardLayoutWithTabs.present.ROW_ID2.parents.slice();
+ const directLinkProps = {
+ ...props,
+ directPathToChild,
+ };
+
+ wrapper = shallow();
+ expect(wrapper.state('tabIndex')).toBe(1);
+ });
});
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardData.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardData.js
new file mode 100644
index 0000000000000..8fcc4d57c8c4c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardData.js
@@ -0,0 +1,28 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { dashboardLayout } from './mockDashboardLayout';
+
+// mock the object to be posted to save_dash or copy_dash API
+export default {
+ css: '',
+ dashboard_title: 'Test 1',
+ default_filters: {},
+ expanded_slices: {},
+ positions: dashboardLayout.present,
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
index e4187b9265552..f2f965ca085bb 100644
--- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
@@ -108,12 +108,14 @@ export const dashboardLayoutWithTabs = {
id: 'TABS_ID',
type: TABS_TYPE,
children: ['TAB_ID', 'TAB_ID2'],
+ parents: ['ROOT_ID'],
},
TAB_ID: {
id: 'TAB_ID',
type: TAB_TYPE,
children: ['ROW_ID'],
+ parents: ['ROOT_ID', 'TABS_ID'],
meta: {
text: 'tab1',
},
@@ -122,7 +124,8 @@ export const dashboardLayoutWithTabs = {
TAB_ID2: {
id: 'TAB_ID2',
type: TAB_TYPE,
- children: [],
+ children: ['ROW_ID2'],
+ parents: ['ROOT_ID', 'TABS_ID'],
meta: {
text: 'tab2',
},
@@ -131,6 +134,7 @@ export const dashboardLayoutWithTabs = {
CHART_ID: {
...newComponentFactory(CHART_TYPE),
id: 'CHART_ID',
+ parents: ['ROOT_ID', 'TABS_ID', 'TAB_ID', 'ROW_ID'],
meta: {
chartId,
width: 3,
@@ -143,12 +147,33 @@ export const dashboardLayoutWithTabs = {
...newComponentFactory(ROW_TYPE),
id: 'ROW_ID',
children: ['CHART_ID'],
+ parents: ['ROOT_ID', 'TABS_ID', 'TAB_ID'],
+ },
+
+ CHART_ID2: {
+ ...newComponentFactory(CHART_TYPE),
+ id: 'CHART_ID2',
+ parents: ['ROOT_ID', 'TABS_ID', 'TAB_ID2', 'ROW_ID2'],
+ meta: {
+ chartId,
+ width: 3,
+ height: 10,
+ chartName: 'Mock chart name 2',
+ },
+ },
+
+ ROW_ID2: {
+ ...newComponentFactory(ROW_TYPE),
+ id: 'ROW_ID2',
+ children: ['CHART_ID2'],
+ parents: ['ROOT_ID', 'TABS_ID', 'TAB_ID2'],
},
[DASHBOARD_GRID_ID]: {
type: DASHBOARD_GRID_TYPE,
id: DASHBOARD_GRID_ID,
children: [],
+ parents: ['ROOT_ID'],
meta: {},
},
diff --git a/superset/assets/spec/javascripts/dashboard/util/findTabIndexByComponentId_spec.js b/superset/assets/spec/javascripts/dashboard/util/findTabIndexByComponentId_spec.js
new file mode 100644
index 0000000000000..3e3d0f7b9d648
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/findTabIndexByComponentId_spec.js
@@ -0,0 +1,85 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * 'License'); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import findTabIndexByComponentId from '../../../../src/dashboard/util/findTabIndexByComponentId';
+
+describe('findTabIndexByComponentId', () => {
+ const topLevelTabsComponent = {
+ children: ['TAB-0g-5l347I2', 'TAB-qrwN_9VB5'],
+ id: 'TABS-MNQQSW-kyd',
+ meta: {},
+ parents: ['ROOT_ID'],
+ type: 'TABS',
+ };
+ const rowLevelTabsComponent = {
+ children: [
+ 'TAB-TwyUUGp2Bg',
+ 'TAB-Zl1BQAUvN',
+ 'TAB-P0DllxzTU',
+ 'TAB---e53RNei',
+ ],
+ id: 'TABS-Oduxop1L7I',
+ meta: {},
+ parents: ['ROOT_ID', 'TABS-MNQQSW-kyd', 'TAB-qrwN_9VB5'],
+ type: 'TABS',
+ };
+ const goodPathToChild = [
+ 'ROOT_ID',
+ 'TABS-MNQQSW-kyd',
+ 'TAB-qrwN_9VB5',
+ 'TABS-Oduxop1L7I',
+ 'TAB-P0DllxzTU',
+ 'ROW-JXhrFnVP8',
+ 'CHART-dUIVg-ENq6',
+ ];
+ const badPath = ['ROOT_ID', 'TABS-MNQQSW-kyd', 'TAB-ABC', 'TABS-Oduxop1L7I'];
+
+ it('should return 0 if no directPathToChild', () => {
+ expect(
+ findTabIndexByComponentId({
+ currentComponent: topLevelTabsComponent,
+ directPathToChild: [],
+ }),
+ ).toBe(0);
+ });
+
+ it('should return 0 if not found tab id', () => {
+ expect(
+ findTabIndexByComponentId({
+ currentComponent: topLevelTabsComponent,
+ directPathToChild: badPath,
+ }),
+ ).toBe(0);
+ });
+
+ it('should return children index if matched an id in the path', () => {
+ expect(
+ findTabIndexByComponentId({
+ currentComponent: topLevelTabsComponent,
+ directPathToChild: goodPathToChild,
+ }),
+ ).toBe(1);
+
+ expect(
+ findTabIndexByComponentId({
+ currentComponent: rowLevelTabsComponent,
+ directPathToChild: goodPathToChild,
+ }),
+ ).toBe(2);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/updateComponentParentsList_spec.js b/superset/assets/spec/javascripts/dashboard/util/updateComponentParentsList_spec.js
new file mode 100644
index 0000000000000..d435f0df5ac20
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/updateComponentParentsList_spec.js
@@ -0,0 +1,97 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * 'License'); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import updateComponentParentsList from '../../../../src/dashboard/util/updateComponentParentsList';
+import { DASHBOARD_ROOT_ID } from '../../../../src/dashboard/util/constants';
+import {
+ dashboardLayout,
+ dashboardLayoutWithTabs,
+} from '../fixtures/mockDashboardLayout';
+
+describe('updateComponentParentsList', () => {
+ const emptyLayout = {
+ DASHBOARD_VERSION_KEY: 'v2',
+ GRID_ID: {
+ children: [],
+ id: 'GRID_ID',
+ type: 'GRID',
+ },
+ ROOT_ID: {
+ children: ['GRID_ID'],
+ id: 'ROOT_ID',
+ type: 'ROOT',
+ },
+ };
+ const gridLayout = {
+ ...dashboardLayout.present,
+ };
+ const tabsLayout = {
+ ...dashboardLayoutWithTabs.present,
+ };
+
+ it('should handle empty layout', () => {
+ const nextState = {
+ ...emptyLayout,
+ };
+
+ updateComponentParentsList({
+ currentComponent: nextState[DASHBOARD_ROOT_ID],
+ layout: nextState,
+ });
+
+ expect(nextState.GRID_ID.parents).toEqual(['ROOT_ID']);
+ });
+
+ it('should handle grid layout', () => {
+ const nextState = {
+ ...gridLayout,
+ };
+
+ updateComponentParentsList({
+ currentComponent: nextState[DASHBOARD_ROOT_ID],
+ layout: nextState,
+ });
+
+ expect(nextState.GRID_ID.parents).toEqual(['ROOT_ID']);
+ expect(nextState.CHART_ID.parents).toEqual([
+ 'ROOT_ID',
+ 'GRID_ID',
+ 'ROW_ID',
+ 'COLUMN_ID',
+ ]);
+ });
+
+ it('should handle root level tabs', () => {
+ const nextState = {
+ ...tabsLayout,
+ };
+
+ updateComponentParentsList({
+ currentComponent: nextState[DASHBOARD_ROOT_ID],
+ layout: nextState,
+ });
+
+ expect(nextState.GRID_ID.parents).toEqual(['ROOT_ID']);
+ expect(nextState.CHART_ID2.parents).toEqual([
+ 'ROOT_ID',
+ 'TABS_ID',
+ 'TAB_ID2',
+ 'ROW_ID2',
+ ]);
+ });
+});
diff --git a/superset/assets/src/components/AnchorLink.jsx b/superset/assets/src/components/AnchorLink.jsx
new file mode 100644
index 0000000000000..b33ee605cd444
--- /dev/null
+++ b/superset/assets/src/components/AnchorLink.jsx
@@ -0,0 +1,99 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { t } from '@superset-ui/translation';
+
+import URLShortLinkButton from './URLShortLinkButton';
+import getDashboardUrl from '../dashboard/util/getDashboardUrl';
+
+const propTypes = {
+ anchorLinkId: PropTypes.string.isRequired,
+ filters: PropTypes.object,
+ showShortLinkButton: PropTypes.bool,
+ placement: PropTypes.oneOf(['right', 'left', 'top', 'bottom']),
+};
+
+const defaultProps = {
+ showShortLinkButton: false,
+ placement: 'right',
+ filters: {},
+};
+
+
+class AnchorLink extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.handleClickAnchorLink = this.handleClickAnchorLink.bind(this);
+ }
+
+ componentDidMount() {
+ const hash = this.getLocationHash();
+ const { anchorLinkId } = this.props;
+
+ if (hash && anchorLinkId === hash) {
+ const directLinkComponent = document.getElementById(anchorLinkId);
+ if (directLinkComponent) {
+ setTimeout(() => {
+ directLinkComponent.scrollIntoView({
+ block: 'center',
+ behavior: 'smooth',
+ });
+ }, 1000);
+ }
+ }
+ }
+
+ getLocationHash() {
+ return (window.location.hash || '').substring(1);
+ }
+
+ handleClickAnchorLink(ev) {
+ ev.preventDefault();
+ history.pushState(null, null, `#${this.props.anchorLinkId}`);
+ }
+
+ render() {
+ const { anchorLinkId, filters, showShortLinkButton, placement } = this.props;
+ return (
+
+ {showShortLinkButton &&
+ }
+
+ );
+ }
+}
+
+AnchorLink.propTypes = propTypes;
+AnchorLink.defaultProps = defaultProps;
+
+export default AnchorLink;
diff --git a/superset/assets/src/components/URLShortLinkButton.jsx b/superset/assets/src/components/URLShortLinkButton.jsx
index 681992a58a092..d6d6c160d363d 100644
--- a/superset/assets/src/components/URLShortLinkButton.jsx
+++ b/superset/assets/src/components/URLShortLinkButton.jsx
@@ -29,6 +29,7 @@ const propTypes = {
emailSubject: PropTypes.string,
emailContent: PropTypes.string,
addDangerToast: PropTypes.func.isRequired,
+ placement: PropTypes.oneOf(['right', 'left', 'top', 'bottom']),
};
class URLShortLinkButton extends React.Component {
@@ -73,7 +74,7 @@ class URLShortLinkButton extends React.Component {
trigger="click"
rootClose
shouldUpdatePosition
- placement="left"
+ placement={this.props.placement}
onEnter={this.getCopyUrl}
overlay={this.renderPopover()}
>
@@ -87,6 +88,7 @@ class URLShortLinkButton extends React.Component {
URLShortLinkButton.defaultProps = {
url: window.location.href.substring(window.location.origin.length),
+ placement: 'left',
emailSubject: '',
emailContent: '',
};
diff --git a/superset/assets/src/components/URLShortLinkModal.jsx b/superset/assets/src/components/URLShortLinkModal.jsx
index 2fc01c60eddeb..d6bf3f67224ec 100644
--- a/superset/assets/src/components/URLShortLinkModal.jsx
+++ b/superset/assets/src/components/URLShortLinkModal.jsx
@@ -30,6 +30,7 @@ const propTypes = {
emailContent: PropTypes.string,
addDangerToast: PropTypes.func.isRequired,
isMenuItem: PropTypes.bool,
+ title: PropTypes.string,
triggerNode: PropTypes.node.isRequired,
};
@@ -65,7 +66,7 @@ class URLShortLinkModal extends React.Component {
isMenuItem={this.props.isMenuItem}
triggerNode={this.props.triggerNode}
beforeOpen={this.getCopyUrl}
- modalTitle={t('Share Dashboard')}
+ modalTitle={this.props.title || t('Share Dashboard')}
modalBody={
- SupersetClient.post({
+ return dispatch => {
+ dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST });
+
+ return SupersetClient.post({
endpoint: `/superset/${path}/${id}/`,
postPayload: { data },
})
- .then(response =>
- Promise.all([
- dispatch(saveDashboardRequestSuccess()),
- dispatch(
- addSuccessToast(t('This dashboard was saved successfully.')),
- ),
- ]).then(() => Promise.resolve(response)),
- )
+ .then(response => {
+ dispatch(saveDashboardRequestSuccess());
+ dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
+ return response;
+ })
.catch(response =>
getClientErrorObject(response).then(({ error }) =>
dispatch(
@@ -163,6 +163,7 @@ export function saveDashboardRequest(data, id, saveType) {
),
),
);
+ };
}
export function fetchCharts(chartList = [], force = false, interval = 0) {
diff --git a/superset/assets/src/dashboard/components/DashboardBuilder.jsx b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
index e635f902ddd8a..345807dceb579 100644
--- a/superset/assets/src/dashboard/components/DashboardBuilder.jsx
+++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
@@ -36,6 +36,7 @@ import ToastPresenter from '../../messageToasts/containers/ToastPresenter';
import WithPopoverMenu from './menu/WithPopoverMenu';
import getDragDropManager from '../util/getDragDropManager';
+import findTabIndexByComponentId from '../util/findTabIndexByComponentId';
import {
DASHBOARD_GRID_ID,
@@ -54,10 +55,12 @@ const propTypes = {
showBuilderPane: PropTypes.bool,
handleComponentDrop: PropTypes.func.isRequired,
toggleBuilderPane: PropTypes.func.isRequired,
+ directPathToChild: PropTypes.arrayOf(PropTypes.string),
};
const defaultProps = {
showBuilderPane: false,
+ directPathToChild: [],
};
class DashboardBuilder extends React.Component {
@@ -72,8 +75,19 @@ class DashboardBuilder extends React.Component {
constructor(props) {
super(props);
+
+ const { dashboardLayout, directPathToChild } = props;
+ const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
+ const rootChildId = dashboardRoot.children[0];
+ const topLevelTabs =
+ rootChildId !== DASHBOARD_GRID_ID && dashboardLayout[rootChildId];
+ const tabIndex = findTabIndexByComponentId({
+ currentComponent: topLevelTabs || dashboardLayout[DASHBOARD_ROOT_ID],
+ directPathToChild,
+ });
+
this.state = {
- tabIndex: 0, // top-level tabs
+ tabIndex,
};
this.handleChangeTab = this.handleChangeTab.bind(this);
this.handleDeleteTopLevelTabs = this.handleDeleteTopLevelTabs.bind(this);
diff --git a/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx b/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx
index 4c36d9a3c6120..7e10d4c760f09 100644
--- a/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx
+++ b/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx
@@ -180,7 +180,11 @@ class HeaderActionsDropdown extends React.PureComponent {
)}
)}
diff --git a/superset/assets/src/dashboard/components/SliceHeaderControls.jsx b/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
index 18a92071f9d1a..65565f07cb29d 100644
--- a/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
+++ b/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
@@ -21,9 +21,14 @@ import PropTypes from 'prop-types';
import moment from 'moment';
import { Dropdown, MenuItem } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
+import URLShortLinkModal from '../../components/URLShortLinkModal';
+import getDashboardUrl from '../util/getDashboardUrl';
const propTypes = {
slice: PropTypes.object.isRequired,
+ componentId: PropTypes.string.isRequired,
+ filters: PropTypes.object.isRequired,
+ addDangerToast: PropTypes.func.isRequired,
isCached: PropTypes.bool,
isExpanded: PropTypes.bool,
cachedDttm: PropTypes.string,
@@ -97,7 +102,15 @@ class SliceHeaderControls extends React.PureComponent {
}
render() {
- const { slice, isCached, cachedDttm, updatedDttm } = this.props;
+ const {
+ slice,
+ isCached,
+ cachedDttm,
+ updatedDttm,
+ filters,
+ componentId,
+ addDangerToast,
+ } = this.props;
const cachedWhen = moment.utc(cachedDttm).fromNow();
const updatedWhen = updatedDttm ? moment.utc(updatedDttm).fromNow() : '';
const refreshTooltip = isCached
@@ -145,6 +158,18 @@ class SliceHeaderControls extends React.PureComponent {
{t('Explore chart')}
)}
+
+ {t('Share chart')}}
+ />
);
diff --git a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
index d8b01d011beab..ff1120835534e 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
@@ -33,6 +33,7 @@ import {
const propTypes = {
id: PropTypes.number.isRequired,
+ componentId: PropTypes.string.isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
updateSliceName: PropTypes.func.isRequired,
@@ -55,6 +56,7 @@ const propTypes = {
supersetCanExplore: PropTypes.bool.isRequired,
supersetCanCSV: PropTypes.bool.isRequired,
sliceCanEdit: PropTypes.bool.isRequired,
+ addDangerToast: PropTypes.func.isRequired,
};
const defaultProps = {
@@ -184,6 +186,7 @@ class Chart extends React.Component {
render() {
const {
id,
+ componentId,
chart,
slice,
datasource,
@@ -198,6 +201,7 @@ class Chart extends React.Component {
supersetCanExplore,
supersetCanCSV,
sliceCanEdit,
+ addDangerToast,
} = this.props;
const { width } = this.state;
@@ -233,6 +237,9 @@ class Chart extends React.Component {
supersetCanExplore={supersetCanExplore}
supersetCanCSV={supersetCanCSV}
sliceCanEdit={sliceCanEdit}
+ componentId={componentId}
+ filters={filters}
+ addDangerToast={addDangerToast}
/>
{/*
diff --git a/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
index 56e2918b35833..836c0e726d5de 100644
--- a/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
@@ -20,6 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Chart from '../../containers/Chart';
+import AnchorLink from '../../../components/AnchorLink';
import DeleteComponentButton from '../DeleteComponentButton';
import DragDroppable from '../dnd/DragDroppable';
import HoverMenu from '../menu/HoverMenu';
@@ -148,7 +149,9 @@ class ChartHolder extends React.Component {
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
>
+ {!editMode && }
+ {!editMode && (
+
+ )}
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tab.jsx b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
index 764702d06b548..49a0f187fb5d7 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
@@ -22,6 +22,7 @@ import PropTypes from 'prop-types';
import DashboardComponent from '../../containers/DashboardComponent';
import DragDroppable from '../dnd/DragDroppable';
import EditableTitle from '../../../components/EditableTitle';
+import AnchorLink from '../../../components/AnchorLink';
import DeleteComponentModal from '../DeleteComponentModal';
import WithPopoverMenu from '../menu/WithPopoverMenu';
import { componentShape } from '../../util/propShapes';
@@ -41,6 +42,7 @@ const propTypes = {
onDropOnTab: PropTypes.func,
onDeleteTab: PropTypes.func,
editMode: PropTypes.bool.isRequired,
+ filters: PropTypes.object.isRequired,
// grid related
availableColumnCount: PropTypes.number,
@@ -195,7 +197,14 @@ export default class Tab extends React.PureComponent {
renderTab() {
const { isFocused } = this.state;
- const { component, parentComponent, index, depth, editMode } = this.props;
+ const {
+ component,
+ parentComponent,
+ index,
+ depth,
+ editMode,
+ filters,
+ } = this.props;
const deleteTabIcon = (
@@ -238,6 +247,14 @@ export default class Tab extends React.PureComponent {
onSaveTitle={this.handleChangeText}
showTooltip={false}
/>
+ {!editMode && (
+
= 5 ? 'left' : 'right'}
+ />
+ )}
{dropIndicatorProps && }
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
index dc9d59939a0db..2b8934e9f668c 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
@@ -25,6 +25,7 @@ import DragHandle from '../dnd/DragHandle';
import DashboardComponent from '../../containers/DashboardComponent';
import DeleteComponentButton from '../DeleteComponentButton';
import HoverMenu from '../menu/HoverMenu';
+import findTabIndexByComponentId from '../../util/findTabIndexByComponentId';
import { componentShape } from '../../util/propShapes';
import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
@@ -45,6 +46,7 @@ const propTypes = {
editMode: PropTypes.bool.isRequired,
renderHoverMenu: PropTypes.bool,
logEvent: PropTypes.func.isRequired,
+ directPathToChild: PropTypes.arrayOf(PropTypes.string),
// grid related
availableColumnCount: PropTypes.number,
@@ -67,6 +69,7 @@ const defaultProps = {
renderHoverMenu: true,
availableColumnCount: 0,
columnWidth: 0,
+ directPathToChild: [],
onChangeTab() {},
onResizeStart() {},
onResize() {},
@@ -76,8 +79,13 @@ const defaultProps = {
class Tabs extends React.PureComponent {
constructor(props) {
super(props);
+ const tabIndex = findTabIndexByComponentId({
+ currentComponent: props.component,
+ directPathToChild: props.directPathToChild,
+ });
+
this.state = {
- tabIndex: 0,
+ tabIndex,
};
this.handleClickTab = this.handleClickTab.bind(this);
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
diff --git a/superset/assets/src/dashboard/containers/Chart.jsx b/superset/assets/src/dashboard/containers/Chart.jsx
index 1e0e64c602479..5b27b13d31e26 100644
--- a/superset/assets/src/dashboard/containers/Chart.jsx
+++ b/superset/assets/src/dashboard/containers/Chart.jsx
@@ -23,10 +23,11 @@ import {
changeFilter as addFilter,
toggleExpandSlice,
} from '../actions/dashboardState';
+import { updateComponents } from '../actions/dashboardLayout';
+import { addDangerToast } from '../../messageToasts/actions';
import { refreshChart } from '../../chart/chartAction';
import { logEvent } from '../../logger/actions';
import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
-import { updateComponents } from '../actions/dashboardLayout';
import Chart from '../components/gridComponents/Chart';
const EMPTY_FILTERS = {};
@@ -72,6 +73,7 @@ function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
updateComponents,
+ addDangerToast,
toggleExpandSlice,
addFilter,
refreshChart,
diff --git a/superset/assets/src/dashboard/containers/DashboardBuilder.jsx b/superset/assets/src/dashboard/containers/DashboardBuilder.jsx
index 3ca70431a436d..9e1804f52a95a 100644
--- a/superset/assets/src/dashboard/containers/DashboardBuilder.jsx
+++ b/superset/assets/src/dashboard/containers/DashboardBuilder.jsx
@@ -31,6 +31,7 @@ function mapStateToProps({ dashboardLayout: undoableLayout, dashboardState }) {
dashboardLayout: undoableLayout.present,
editMode: dashboardState.editMode,
showBuilderPane: dashboardState.showBuilderPane,
+ directPathToChild: dashboardState.directPathToChild,
};
}
diff --git a/superset/assets/src/dashboard/containers/DashboardComponent.jsx b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
index 180fcd3c93709..a1a1c375e957c 100644
--- a/superset/assets/src/dashboard/containers/DashboardComponent.jsx
+++ b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
@@ -43,6 +43,11 @@ const propTypes = {
updateComponents: PropTypes.func.isRequired,
handleComponentDrop: PropTypes.func.isRequired,
logEvent: PropTypes.func.isRequired,
+ directPathToChild: PropTypes.arrayOf(PropTypes.string),
+};
+
+const defaultProps = {
+ directPathToChild: [],
};
function mapStateToProps(
@@ -56,6 +61,8 @@ function mapStateToProps(
component,
parentComponent: dashboardLayout[parentId],
editMode: dashboardState.editMode,
+ filters: dashboardState.filters,
+ directPathToChild: dashboardState.directPathToChild,
};
// rows and columns need more data about their child dimensions
@@ -98,6 +105,7 @@ class DashboardComponent extends React.PureComponent {
}
DashboardComponent.propTypes = propTypes;
+DashboardComponent.defaultProps = defaultProps;
export default connect(
mapStateToProps,
diff --git a/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
index f5799b0171f14..d693aca878c17 100644
--- a/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
+++ b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
@@ -39,6 +39,7 @@ export default {
type: DASHBOARD_GRID_TYPE,
id: DASHBOARD_GRID_ID,
children: [],
+ parents: [DASHBOARD_ROOT_ID],
meta: {},
},
diff --git a/superset/assets/src/dashboard/reducers/dashboardLayout.js b/superset/assets/src/dashboard/reducers/dashboardLayout.js
index c880a24c78659..23459e029f87d 100644
--- a/superset/assets/src/dashboard/reducers/dashboardLayout.js
+++ b/superset/assets/src/dashboard/reducers/dashboardLayout.js
@@ -24,6 +24,7 @@ import {
import componentIsResizable from '../util/componentIsResizable';
import findParentId from '../util/findParentId';
import getComponentWidthFromDrop from '../util/getComponentWidthFromDrop';
+import updateComponentParentsList from '../util/updateComponentParentsList';
import newComponentFactory from '../util/newComponentFactory';
import newEntitiesFromDrop from '../util/newEntitiesFromDrop';
import reorderItem from '../util/dnd-reorder';
@@ -32,6 +33,7 @@ import { ROW_TYPE, TAB_TYPE, TABS_TYPE } from '../util/componentTypes';
import {
UPDATE_COMPONENTS,
+ UPDATE_COMPONENTS_PARENTS_LIST,
DELETE_COMPONENT,
CREATE_COMPONENT,
MOVE_COMPONENT,
@@ -255,6 +257,21 @@ const actionHandlers = {
return nextEntities;
},
+
+ [UPDATE_COMPONENTS_PARENTS_LIST](state) {
+ const nextState = {
+ ...state,
+ };
+
+ updateComponentParentsList({
+ currentComponent: nextState[DASHBOARD_ROOT_ID],
+ layout: nextState,
+ });
+
+ return {
+ ...nextState,
+ };
+ },
};
export default function layoutReducer(state = {}, action) {
diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js
index a9c4d8f95f514..44a491ccb030d 100644
--- a/superset/assets/src/dashboard/reducers/getInitialState.js
+++ b/superset/assets/src/dashboard/reducers/getInitialState.js
@@ -63,7 +63,11 @@ export default function(bootstrapData) {
// dashboard layout
const { position_json: positionJson } = dashboard;
- const layout = positionJson || getEmptyLayout();
+ // new dash: positionJson could be {} or null
+ const layout =
+ positionJson && Object.keys(positionJson).length > 0
+ ? positionJson
+ : getEmptyLayout();
// create a lookup to sync layout names with slice names
const chartIdToLayoutId = {};
@@ -155,6 +159,14 @@ export default function(bootstrapData) {
future: [],
};
+ // find direct link component and path from root
+ const directLinkComponentId = (window.location.hash || '#').substring(1);
+ let directPathToChild = [];
+ if (layout[directLinkComponentId]) {
+ directPathToChild = (layout[directLinkComponentId].parents || []).slice();
+ directPathToChild.push(directLinkComponentId);
+ }
+
return {
datasources,
sliceEntities: { ...initSliceEntities, slices, isLoading: false },
@@ -185,6 +197,7 @@ export default function(bootstrapData) {
sliceIds: Array.from(sliceIds),
refresh: false,
filters,
+ directPathToChild,
expandedSlices: dashboard.metadata.expanded_slices || {},
refreshFrequency: dashboard.metadata.refresh_frequency || 0,
css: dashboard.css || '',
diff --git a/superset/assets/src/dashboard/util/findTabIndexByComponentId.js b/superset/assets/src/dashboard/util/findTabIndexByComponentId.js
new file mode 100644
index 0000000000000..eed141e44f585
--- /dev/null
+++ b/superset/assets/src/dashboard/util/findTabIndexByComponentId.js
@@ -0,0 +1,41 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export default function findTabIndexByComponentId({
+ currentComponent,
+ directPathToChild = [],
+}) {
+ if (
+ !currentComponent ||
+ directPathToChild.length === 0 ||
+ directPathToChild.indexOf(currentComponent.id) === -1
+ ) {
+ return 0;
+ }
+
+ const currentComponentIdx = directPathToChild.findIndex(
+ id => id === currentComponent.id,
+ );
+ const nextParentId = directPathToChild[currentComponentIdx + 1];
+ if (currentComponent.children.indexOf(nextParentId) >= 0) {
+ return currentComponent.children.findIndex(
+ childId => childId === nextParentId,
+ );
+ }
+ return 0;
+}
diff --git a/superset/assets/src/dashboard/util/getDashboardUrl.js b/superset/assets/src/dashboard/util/getDashboardUrl.js
index 9b8cada1d386f..243c8cbe65f81 100644
--- a/superset/assets/src/dashboard/util/getDashboardUrl.js
+++ b/superset/assets/src/dashboard/util/getDashboardUrl.js
@@ -18,7 +18,8 @@
*/
/* eslint camelcase: 0 */
-export default function getDashboardUrl(pathname, filters = {}) {
+export default function getDashboardUrl(pathname, filters = {}, hash = '') {
const preselect_filters = encodeURIComponent(JSON.stringify(filters));
- return `${pathname}?preselect_filters=${preselect_filters}`;
+ const hashSection = hash ? `#${hash}` : '';
+ return `${pathname}?preselect_filters=${preselect_filters}${hashSection}`;
}
diff --git a/superset/assets/src/dashboard/util/getEmptyLayout.js b/superset/assets/src/dashboard/util/getEmptyLayout.js
index 28d3187c806c1..a58866cd350a0 100644
--- a/superset/assets/src/dashboard/util/getEmptyLayout.js
+++ b/superset/assets/src/dashboard/util/getEmptyLayout.js
@@ -36,6 +36,7 @@ export default function() {
type: DASHBOARD_GRID_TYPE,
id: DASHBOARD_GRID_ID,
children: [],
+ parents: [DASHBOARD_ROOT_ID],
},
};
}
diff --git a/superset/assets/src/dashboard/util/updateComponentParentsList.js b/superset/assets/src/dashboard/util/updateComponentParentsList.js
new file mode 100644
index 0000000000000..48f01e20a61d5
--- /dev/null
+++ b/superset/assets/src/dashboard/util/updateComponentParentsList.js
@@ -0,0 +1,35 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export default function updateComponentParentsList({
+ currentComponent,
+ layout = {},
+}) {
+ if (currentComponent && layout[currentComponent.id]) {
+ const parentsList = (currentComponent.parents || []).slice();
+ parentsList.push(currentComponent.id);
+
+ currentComponent.children.forEach(childId => {
+ layout[childId].parents = parentsList; // eslint-disable-line no-param-reassign
+ updateComponentParentsList({
+ currentComponent: layout[childId],
+ layout,
+ });
+ });
+ }
+}
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index 587851ea00e8d..c23d7110a5bff 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -291,6 +291,51 @@ table.table-no-hover tr:hover {
cursor: text;
}
+.anchor-link-container {
+ position: absolute;
+ z-index: 5;
+
+ .btn.btn-sm, .btn.btn-sm:active {
+ border: none;
+ padding-top: 0;
+ padding-bottom: 0;
+ background: none;
+ box-shadow: none;
+ }
+
+ .fa.fa-link {
+ position: relative;
+ top: 2px;
+ right: 0;
+ visibility: hidden;
+ font-size: 11px;
+ text-align: center;
+ vertical-align: middle;
+ }
+}
+
+.nav.nav-tabs li .anchor-link-container {
+ top: 0;
+ right: -32px;
+}
+
+.dashboard-component.dashboard-component-header .anchor-link-container {
+ .fa.fa-link {
+ font-size: 16px;
+ }
+}
+
+.nav.nav-tabs li:hover,
+.dashboard-component.dashboard-component-header:hover {
+ .anchor-link-container {
+ cursor: pointer;
+
+ .fa.fa-link {
+ visibility: visible;
+ }
+ }
+}
+
.m-r-5 {
margin-right: 5px;
}