feat(dashboard): direct link to single chart/tab/header in dashboard (#6964)

* direct display for pre-selected tab

* update parents

* add AnchorLink component

* add unit tests
This commit is contained in:
Grace Guo 2019-04-09 15:42:46 -07:00 committed by GitHub
parent 139f299ab3
commit c50e6bc981
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 813 additions and 26 deletions

View File

@ -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(<AnchorLink {...props} />);
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(<AnchorLink {...props} />);
expect(wrapper.find(`#${props.anchorLinkId}`)).toHaveLength(1);
expect(wrapper.find(URLShortLinkButton)).toHaveLength(0);
});
it('should render URLShortLinkButton', () => {
const wrapper = shallow(<AnchorLink {...props} showShortLinkButton />);
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);
});
});

View File

@ -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,
);
});
});
});

View File

@ -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(
<WithDragDropContext>
<Header {...props} {...overrideProps} />
</WithDragDropContext>,
<Provider store={mockStoreWithTabs}>
<WithDragDropContext>
<Header {...props} {...overrideProps} />
</WithDragDropContext>
</Provider>,
);
return wrapper;
}

View File

@ -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(<Tabs {...props} />);
// 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(<Tabs {...directLinkProps} />);
expect(wrapper.state('tabIndex')).toBe(1);
});
});

View File

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

View File

@ -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: {},
},

View File

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

View File

@ -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',
]);
});
});

View File

@ -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 (
<span
className="anchor-link-container"
id={anchorLinkId}
>
{showShortLinkButton &&
<URLShortLinkButton
url={getDashboardUrl(
window.location.pathname,
filters,
anchorLinkId,
)}
emailSubject={t('Superset Chart')}
emailContent={t('Check out this chart in dashboard:')}
placement={placement}
/>}
</span>
);
}
}
AnchorLink.propTypes = propTypes;
AnchorLink.defaultProps = defaultProps;
export default AnchorLink;

View File

@ -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: '',
};

View File

@ -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={
<div>
<CopyToClipboard

View File

@ -219,3 +219,6 @@ export function undoLayoutAction() {
export const redoLayoutAction = setUnsavedChangesAfterAction(
UndoActionCreators.redo,
);
// Update component parents list ----------------------------------------------
export const UPDATE_COMPONENTS_PARENTS_LIST = 'UPDATE_COMPONENTS_PARENTS_LIST';

View File

@ -32,6 +32,7 @@ import {
addWarningToast,
addDangerToast,
} from '../../messageToasts/actions';
import { UPDATE_COMPONENTS_PARENTS_LIST } from '../actions/dashboardLayout';
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
export function setUnsavedChanges(hasUnsavedChanges) {
@ -139,19 +140,18 @@ export function saveDashboardRequestSuccess() {
export function saveDashboardRequest(data, id, saveType) {
const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
return dispatch =>
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) {

View File

@ -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);

View File

@ -180,7 +180,11 @@ class HeaderActionsDropdown extends React.PureComponent {
)}
<URLShortLinkModal
url={getDashboardUrl(window.location.pathname, this.props.filters)}
url={getDashboardUrl(
window.location.pathname,
this.props.filters,
window.location.hash,
)}
emailSubject={emailSubject}
emailContent={emailBody}
addDangerToast={this.props.addDangerToast}

View File

@ -43,6 +43,9 @@ const propTypes = {
supersetCanExplore: PropTypes.bool,
supersetCanCSV: PropTypes.bool,
sliceCanEdit: PropTypes.bool,
componentId: PropTypes.string.isRequired,
filters: PropTypes.object.isRequired,
addDangerToast: PropTypes.func.isRequired,
};
const defaultProps = {
@ -90,6 +93,9 @@ class SliceHeader extends React.PureComponent {
updateSliceName,
annotationQuery,
annotationError,
componentId,
filters,
addDangerToast,
} = this.props;
return (
@ -138,6 +144,9 @@ class SliceHeader extends React.PureComponent {
supersetCanExplore={supersetCanExplore}
supersetCanCSV={supersetCanCSV}
sliceCanEdit={sliceCanEdit}
componentId={componentId}
filters={filters}
addDangerToast={addDangerToast}
/>
)}
</div>

View File

@ -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')}
</MenuItem>
)}
<URLShortLinkModal
url={getDashboardUrl(
window.location.pathname,
filters,
componentId,
)}
addDangerToast={addDangerToast}
isMenuItem
title={t('Share chart')}
triggerNode={<span>{t('Share chart')}</span>}
/>
</Dropdown.Menu>
</Dropdown>
);

View File

@ -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}
/>
{/*

View File

@ -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 && <AnchorLink anchorLinkId={component.id} />}
<Chart
componentId={component.id}
id={component.meta.chartId}
width={Math.floor(
widthMultiple * columnWidth +

View File

@ -23,6 +23,7 @@ import cx from 'classnames';
import DragDroppable from '../dnd/DragDroppable';
import DragHandle from '../dnd/DragHandle';
import EditableTitle from '../../../components/EditableTitle';
import AnchorLink from '../../../components/AnchorLink';
import HoverMenu from '../menu/HoverMenu';
import WithPopoverMenu from '../menu/WithPopoverMenu';
import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
@ -41,6 +42,7 @@ const propTypes = {
parentComponent: componentShape.isRequired,
index: PropTypes.number.isRequired,
editMode: PropTypes.bool.isRequired,
filters: PropTypes.object.isRequired,
// redux
handleComponentDrop: PropTypes.func.isRequired,
@ -101,6 +103,7 @@ class Header extends React.PureComponent {
index,
handleComponentDrop,
editMode,
filters,
} = this.props;
const headerStyle = headerStyleOptions.find(
@ -165,6 +168,13 @@ class Header extends React.PureComponent {
onSaveTitle={this.handleChangeText}
showTooltip={false}
/>
{!editMode && (
<AnchorLink
anchorLinkId={component.id}
filters={filters}
showShortLinkButton
/>
)}
</div>
</WithPopoverMenu>

View File

@ -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 = (
<div className="icon-button">
<span className="fa fa-trash" />
@ -238,6 +247,14 @@ export default class Tab extends React.PureComponent {
onSaveTitle={this.handleChangeText}
showTooltip={false}
/>
{!editMode && (
<AnchorLink
anchorLinkId={component.id}
filters={filters}
showShortLinkButton
placement={index >= 5 ? 'left' : 'right'}
/>
)}
</WithPopoverMenu>
{dropIndicatorProps && <div {...dropIndicatorProps} />}

View File

@ -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);

View File

@ -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,

View File

@ -31,6 +31,7 @@ function mapStateToProps({ dashboardLayout: undoableLayout, dashboardState }) {
dashboardLayout: undoableLayout.present,
editMode: dashboardState.editMode,
showBuilderPane: dashboardState.showBuilderPane,
directPathToChild: dashboardState.directPathToChild,
};
}

View File

@ -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,

View File

@ -39,6 +39,7 @@ export default {
type: DASHBOARD_GRID_TYPE,
id: DASHBOARD_GRID_ID,
children: [],
parents: [DASHBOARD_ROOT_ID],
meta: {},
},

View File

@ -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) {

View File

@ -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 || '',

View File

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

View File

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

View File

@ -36,6 +36,7 @@ export default function() {
type: DASHBOARD_GRID_TYPE,
id: DASHBOARD_GRID_ID,
children: [],
parents: [DASHBOARD_ROOT_ID],
},
};
}

View File

@ -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,
});
});
}
}

View File

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