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; }