Edit Dashboard title and Slice title in place (#2940)
* Edit Dashboard title and Slice title in place Add EditableTitle component into Dashboard and Explore view to support edit title inline.
This commit is contained in:
parent
da0a87a735
commit
8329ea2b9b
|
|
@ -0,0 +1,84 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TooltipWrapper from './TooltipWrapper';
|
||||
|
||||
const propTypes = {
|
||||
title: PropTypes.string,
|
||||
canEdit: PropTypes.bool,
|
||||
onSaveTitle: PropTypes.func.isRequired,
|
||||
};
|
||||
const defaultProps = {
|
||||
title: 'Title',
|
||||
canEdit: false,
|
||||
};
|
||||
|
||||
class EditableTitle extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isEditing: false,
|
||||
title: this.props.title,
|
||||
lastTitle: this.props.title,
|
||||
};
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleBlur = this.handleBlur.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
handleClick() {
|
||||
if (!this.props.canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isEditing: true,
|
||||
});
|
||||
}
|
||||
handleBlur() {
|
||||
if (!this.props.canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isEditing: false,
|
||||
});
|
||||
|
||||
if (this.state.lastTitle !== this.state.title) {
|
||||
this.setState({
|
||||
lastTitle: this.state.title,
|
||||
});
|
||||
this.props.onSaveTitle(this.state.title);
|
||||
}
|
||||
}
|
||||
handleChange(ev) {
|
||||
if (!this.props.canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
title: ev.target.value,
|
||||
});
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<span className="editable-title">
|
||||
<TooltipWrapper
|
||||
label="title"
|
||||
tooltip={this.props.canEdit ? 'click to edit title' : 'You don\'t have the rights to alter this title.'}
|
||||
>
|
||||
<input
|
||||
required
|
||||
type={this.state.isEditing ? 'text' : 'button'}
|
||||
value={this.state.title}
|
||||
onChange={this.handleChange}
|
||||
onBlur={this.handleBlur}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
EditableTitle.propTypes = propTypes;
|
||||
EditableTitle.defaultProps = defaultProps;
|
||||
|
||||
export default EditableTitle;
|
||||
|
|
@ -341,6 +341,10 @@ export function dashboardContainer(dashboard, datasources, userid) {
|
|||
},
|
||||
});
|
||||
},
|
||||
updateDashboardTitle(title) {
|
||||
this.dashboard_title = title;
|
||||
this.onChange();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import Controls from './Controls';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object,
|
||||
|
|
@ -14,14 +15,22 @@ class Header extends React.PureComponent {
|
|||
super(props);
|
||||
this.state = {
|
||||
};
|
||||
this.handleSaveTitle = this.handleSaveTitle.bind(this);
|
||||
}
|
||||
handleSaveTitle(title) {
|
||||
this.props.dashboard.updateDashboardTitle(title);
|
||||
}
|
||||
render() {
|
||||
const dashboard = this.props.dashboard;
|
||||
return (
|
||||
<div className="title">
|
||||
<div className="pull-left">
|
||||
<h1>
|
||||
{dashboard.dashboard_title}
|
||||
<h1 className="outer-container">
|
||||
<EditableTitle
|
||||
title={dashboard.dashboard_title}
|
||||
canEdit={dashboard.dash_save_perm}
|
||||
onSaveTitle={this.handleSaveTitle}
|
||||
/>
|
||||
<span is class="favstar" class_name="Dashboard" obj_id={dashboard.id} />
|
||||
</h1>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class SaveModal extends React.PureComponent {
|
|||
positions,
|
||||
css: this.state.css,
|
||||
expanded_slices: expandedSlices,
|
||||
dashboard_title: dashboard.dashboard_title,
|
||||
};
|
||||
let url = null;
|
||||
if (saveType === 'overwrite') {
|
||||
|
|
|
|||
|
|
@ -212,6 +212,10 @@ export const SAVE_SLICE_FAILED = 'SAVE_SLICE_FAILED';
|
|||
export function saveSliceFailed() {
|
||||
return { type: SAVE_SLICE_FAILED };
|
||||
}
|
||||
export const SAVE_SLICE_SUCCESS = 'SAVE_SLICE_SUCCESS';
|
||||
export function saveSliceSuccess(data) {
|
||||
return { type: SAVE_SLICE_SUCCESS, data };
|
||||
}
|
||||
|
||||
export const REMOVE_SAVE_MODAL_ALERT = 'REMOVE_SAVE_MODAL_ALERT';
|
||||
export function removeSaveModalAlert() {
|
||||
|
|
@ -220,10 +224,9 @@ export function removeSaveModalAlert() {
|
|||
|
||||
export function saveSlice(url) {
|
||||
return function (dispatch) {
|
||||
$.get(url, (data, status) => {
|
||||
return $.get(url, (data, status) => {
|
||||
if (status === 'success') {
|
||||
// Go to new slice url or dashboard url
|
||||
window.location = data;
|
||||
dispatch(saveSliceSuccess(data));
|
||||
} else {
|
||||
dispatch(saveSliceFailed());
|
||||
}
|
||||
|
|
@ -231,6 +234,11 @@ export function saveSlice(url) {
|
|||
};
|
||||
}
|
||||
|
||||
export const UPDATE_CHART_TITLE = 'UPDATE_CHART_TITLE';
|
||||
export function updateChartTitle(slice_name) {
|
||||
return { type: UPDATE_CHART_TITLE, slice_name };
|
||||
}
|
||||
|
||||
export const UPDATE_CHART_STATUS = 'UPDATE_CHART_STATUS';
|
||||
export function updateChartStatus(status) {
|
||||
return { type: UPDATE_CHART_STATUS, status };
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Alert, Collapse, Panel } from 'react-bootstrap';
|
|||
import visMap from '../../../visualizations/main';
|
||||
import { d3format } from '../../modules/utils';
|
||||
import ExploreActionButtons from './ExploreActionButtons';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||
import Timer from '../../components/Timer';
|
||||
|
|
@ -23,6 +24,7 @@ const CHART_STATUS_MAP = {
|
|||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
alert: PropTypes.string,
|
||||
can_overwrite: PropTypes.bool.isRequired,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
|
|
@ -39,6 +41,8 @@ const propTypes = {
|
|||
queryResponse: PropTypes.object,
|
||||
triggerRender: PropTypes.bool,
|
||||
standalone: PropTypes.bool,
|
||||
datasourceType: PropTypes.string,
|
||||
datasourceId: PropTypes.number,
|
||||
};
|
||||
|
||||
class ChartContainer extends React.PureComponent {
|
||||
|
|
@ -145,6 +149,18 @@ class ChartContainer extends React.PureComponent {
|
|||
this.props.actions.runQuery(this.props.formData, true);
|
||||
}
|
||||
|
||||
updateChartTitle(newTitle) {
|
||||
const params = {
|
||||
slice_name: newTitle,
|
||||
action: 'overwrite',
|
||||
};
|
||||
const saveUrl = getExploreUrl(this.props.formData, 'base', false, null, params);
|
||||
this.props.actions.saveSlice(saveUrl)
|
||||
.then(() => {
|
||||
this.props.actions.updateChartTitle(newTitle);
|
||||
});
|
||||
}
|
||||
|
||||
renderChartTitle() {
|
||||
let title;
|
||||
if (this.props.slice) {
|
||||
|
|
@ -240,7 +256,11 @@ class ChartContainer extends React.PureComponent {
|
|||
id="slice-header"
|
||||
className="clearfix panel-title-large"
|
||||
>
|
||||
{this.renderChartTitle()}
|
||||
<EditableTitle
|
||||
title={this.renderChartTitle()}
|
||||
canEdit={this.props.can_overwrite}
|
||||
onSaveTitle={this.updateChartTitle.bind(this)}
|
||||
/>
|
||||
|
||||
{this.props.slice &&
|
||||
<span>
|
||||
|
|
@ -304,6 +324,7 @@ function mapStateToProps(state) {
|
|||
const formData = getFormDataFromControls(state.controls);
|
||||
return {
|
||||
alert: state.chartAlert,
|
||||
can_overwrite: state.can_overwrite,
|
||||
can_download: state.can_download,
|
||||
chartStatus: state.chartStatus,
|
||||
chartUpdateEndTime: state.chartUpdateEndTime,
|
||||
|
|
@ -320,7 +341,8 @@ function mapStateToProps(state) {
|
|||
table_name: formData.datasource_name,
|
||||
viz_type: formData.viz_type,
|
||||
triggerRender: state.triggerRender,
|
||||
datasourceType: state.datasource ? state.datasource.type : null,
|
||||
datasourceType: state.datasource_type,
|
||||
datasourceId: state.datasource_id,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,11 @@ class SaveModal extends React.Component {
|
|||
const saveUrl = `${baseUrl}?form_data=` +
|
||||
`${encodeURIComponent(JSON.stringify(this.props.form_data))}` +
|
||||
`&${$.param(sliceParams, true)}`;
|
||||
this.props.actions.saveSlice(saveUrl);
|
||||
this.props.actions.saveSlice(saveUrl)
|
||||
.then((data) => {
|
||||
// Go to new slice url or dashboard url
|
||||
window.location = data;
|
||||
});
|
||||
this.props.onHide();
|
||||
}
|
||||
removeAlert() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/* eslint camelcase: 0 */
|
||||
import URI from 'urijs';
|
||||
|
||||
export function getExploreUrl(form_data, endpointType = 'base', force = false, curUrl = null) {
|
||||
export function getExploreUrl(form_data, endpointType = 'base', force = false,
|
||||
curUrl = null, requestParams = {}) {
|
||||
if (!form_data.datasource) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -38,6 +39,14 @@ export function getExploreUrl(form_data, endpointType = 'base', force = false, c
|
|||
if (endpointType === 'query') {
|
||||
search.query = 'true';
|
||||
}
|
||||
const paramNames = Object.keys(requestParams);
|
||||
if (paramNames.length) {
|
||||
paramNames.forEach((name) => {
|
||||
if (requestParams.hasOwnProperty(name)) {
|
||||
search[name] = requestParams[name];
|
||||
}
|
||||
});
|
||||
}
|
||||
uri = uri.search(search).directory(directory);
|
||||
return uri.toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,10 @@ export const exploreReducer = function (state, action) {
|
|||
}
|
||||
return newState;
|
||||
},
|
||||
[actions.UPDATE_CHART_TITLE]() {
|
||||
const updatedSlice = Object.assign({}, state.slice, { slice_name: action.slice_name });
|
||||
return Object.assign({}, state, { slice: updatedSlice });
|
||||
},
|
||||
[actions.REMOVE_CHART_ALERT]() {
|
||||
if (state.chartAlert !== null) {
|
||||
return Object.assign({}, state, { chartAlert: null });
|
||||
|
|
@ -144,6 +148,9 @@ export const exploreReducer = function (state, action) {
|
|||
[actions.SAVE_SLICE_FAILED]() {
|
||||
return Object.assign({}, state, { saveModalAlert: 'Failed to save slice' });
|
||||
},
|
||||
[actions.SAVE_SLICE_SUCCESS](data) {
|
||||
return Object.assign({}, state, { data });
|
||||
},
|
||||
[actions.REMOVE_SAVE_MODAL_ALERT]() {
|
||||
return Object.assign({}, state, { saveModalAlert: null });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { describe, it } from 'mocha';
|
||||
import sinon from 'sinon';
|
||||
import { expect } from 'chai';
|
||||
|
||||
import EditableTable from '../../../javascripts/components/EditableTitle';
|
||||
|
||||
describe('EditableTitle', () => {
|
||||
const callback = sinon.spy();
|
||||
const mockProps = {
|
||||
title: 'my title',
|
||||
canEdit: true,
|
||||
onSaveTitle: callback,
|
||||
};
|
||||
const mockEvent = {
|
||||
target: {
|
||||
value: 'new title',
|
||||
},
|
||||
};
|
||||
const editableWrapper = shallow(<EditableTable {...mockProps} />);
|
||||
const notEditableWrapper = shallow(<EditableTable title="my title" />);
|
||||
it('is valid', () => {
|
||||
expect(
|
||||
React.isValidElement(<EditableTable {...mockProps} />),
|
||||
).to.equal(true);
|
||||
});
|
||||
it('should render title', () => {
|
||||
const titleElement = editableWrapper.find('input');
|
||||
expect(titleElement.props().value).to.equal('my title');
|
||||
expect(titleElement.props().type).to.equal('button');
|
||||
});
|
||||
|
||||
describe('should handle click', () => {
|
||||
it('should change title', () => {
|
||||
editableWrapper.find('input').simulate('click');
|
||||
expect(editableWrapper.find('input').props().type).to.equal('text');
|
||||
});
|
||||
it('should not change title', () => {
|
||||
notEditableWrapper.find('input').simulate('click');
|
||||
expect(notEditableWrapper.find('input').props().type).to.equal('button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle change', () => {
|
||||
afterEach(() => {
|
||||
editableWrapper.setState({ title: 'my title' });
|
||||
editableWrapper.setState({ lastTitle: 'my title' });
|
||||
});
|
||||
it('should change title', () => {
|
||||
editableWrapper.find('input').simulate('change', mockEvent);
|
||||
expect(editableWrapper.find('input').props().value).to.equal('new title');
|
||||
});
|
||||
it('should not change title', () => {
|
||||
notEditableWrapper.find('input').simulate('change', mockEvent);
|
||||
expect(editableWrapper.find('input').props().value).to.equal('my title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle blur', () => {
|
||||
beforeEach(() => {
|
||||
editableWrapper.find('input').simulate('click');
|
||||
expect(editableWrapper.find('input').props().type).to.equal('text');
|
||||
});
|
||||
afterEach(() => {
|
||||
callback.reset();
|
||||
editableWrapper.setState({ title: 'my title' });
|
||||
editableWrapper.setState({ lastTitle: 'my title' });
|
||||
});
|
||||
|
||||
it('should trigger callback', () => {
|
||||
editableWrapper.setState({ title: 'new title' });
|
||||
editableWrapper.find('input').simulate('blur');
|
||||
expect(editableWrapper.find('input').props().type).to.equal('button');
|
||||
expect(callback.callCount).to.equal(1);
|
||||
expect(callback.getCall(0).args[0]).to.equal('new title');
|
||||
});
|
||||
it('should not trigger callback', () => {
|
||||
editableWrapper.find('input').simulate('blur');
|
||||
expect(editableWrapper.find('input').props().type).to.equal('button');
|
||||
// no change
|
||||
expect(callback.callCount).to.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -199,7 +199,7 @@ div.widget .slice_container {
|
|||
.navbar .alert {
|
||||
padding: 5px 10px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0px
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-condensed {
|
||||
|
|
@ -209,6 +209,22 @@ div.widget .slice_container {
|
|||
.table-condensed input[type="checkbox"] {
|
||||
float: left;
|
||||
}
|
||||
.m-r-5 {
|
||||
|
||||
.editable-title input {
|
||||
padding: 2px 6px 3px 6px;
|
||||
}
|
||||
|
||||
.editable-title input[type="button"] {
|
||||
border-color: transparent;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.editable-title input[type="button"]:hover {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.editable-title input[type="button"]:focus {
|
||||
outline: none;
|
||||
}.m-r-5 {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1320,6 +1320,7 @@ class Superset(BaseSupersetView):
|
|||
dashboard.position_json = json.dumps(positions, indent=4, sort_keys=True)
|
||||
md = dashboard.params_dict
|
||||
dashboard.css = data['css']
|
||||
dashboard.dashboard_title = data['dashboard_title']
|
||||
|
||||
if 'filter_immune_slices' not in md:
|
||||
md['filter_immune_slices'] = []
|
||||
|
|
|
|||
|
|
@ -372,10 +372,46 @@ class CoreTests(SupersetTestCase):
|
|||
'css': '',
|
||||
'expanded_slices': {},
|
||||
'positions': positions,
|
||||
'dashboard_title': dash.dashboard_title
|
||||
}
|
||||
url = '/superset/save_dash/{}/'.format(dash.id)
|
||||
resp = self.client.post(url, data=dict(data=json.dumps(data)))
|
||||
assert "SUCCESS" in resp.data.decode('utf-8')
|
||||
resp = self.get_resp(url, data=dict(data=json.dumps(data)))
|
||||
self.assertIn("SUCCESS", resp)
|
||||
|
||||
def test_save_dash_with_dashboard_title(self, username='admin'):
|
||||
self.login(username=username)
|
||||
dash = (
|
||||
db.session.query(models.Dashboard)
|
||||
.filter_by(slug="births")
|
||||
.first()
|
||||
)
|
||||
origin_title = dash.dashboard_title
|
||||
positions = []
|
||||
for i, slc in enumerate(dash.slices):
|
||||
d = {
|
||||
'col': 0,
|
||||
'row': i * 4,
|
||||
'size_x': 4,
|
||||
'size_y': 4,
|
||||
'slice_id': '{}'.format(slc.id)}
|
||||
positions.append(d)
|
||||
data = {
|
||||
'css': '',
|
||||
'expanded_slices': {},
|
||||
'positions': positions,
|
||||
'dashboard_title': 'new title'
|
||||
}
|
||||
url = '/superset/save_dash/{}/'.format(dash.id)
|
||||
resp = self.get_resp(url, data=dict(data=json.dumps(data)))
|
||||
updatedDash = (
|
||||
db.session.query(models.Dashboard)
|
||||
.filter_by(slug="births")
|
||||
.first()
|
||||
)
|
||||
self.assertEqual(updatedDash.dashboard_title, 'new title')
|
||||
# # bring back dashboard original title
|
||||
data['dashboard_title'] = origin_title
|
||||
self.get_resp(url, data=dict(data=json.dumps(data)))
|
||||
|
||||
def test_copy_dash(self, username='admin'):
|
||||
self.login(username=username)
|
||||
|
|
@ -510,7 +546,8 @@ class CoreTests(SupersetTestCase):
|
|||
)
|
||||
self.grant_public_access_to_table(table)
|
||||
|
||||
dash = db.session.query(models.Dashboard).filter_by(dashboard_title="Births").first()
|
||||
dash = db.session.query(models.Dashboard).filter_by(
|
||||
slug="births").first()
|
||||
dash.owners = [appbuilder.sm.find_user('admin')]
|
||||
dash.created_by = appbuilder.sm.find_user('admin')
|
||||
db.session.merge(dash)
|
||||
|
|
@ -532,7 +569,7 @@ class CoreTests(SupersetTestCase):
|
|||
|
||||
self.logout()
|
||||
self.assertRaises(
|
||||
AssertionError, self.test_save_dash, 'alpha')
|
||||
Exception, self.test_save_dash, 'alpha')
|
||||
|
||||
alpha = appbuilder.sm.find_user('alpha')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue