[dashboard] adding an option to duplicate slices when "Saving AS" (#3391)

* [dashboard] adding an option to duplicate slices when "Saving AS"

* Fix tests
This commit is contained in:
Maxime Beauchemin 2017-08-30 14:09:29 -07:00 committed by GitHub
parent 3b4cd812ae
commit e53f3032bb
10 changed files with 135 additions and 20 deletions

View File

@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
checked: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
style: PropTypes.object,
};
export default function Checkbox({ checked, onChange, style }) {
return (
<span style={style}>
<i
className={`fa fa-check ${checked ? 'text-primary' : 'text-transparent'}`}
onClick={onChange.bind(!checked)}
style={{
border: '1px solid #aaa',
borderRadius: '2px',
cursor: 'pointer',
}}
/>
</span>);
}
Checkbox.propTypes = propTypes;

View File

@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap'; import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap';
import { getAjaxErrorMsg } from '../../modules/utils'; import { getAjaxErrorMsg } from '../../modules/utils';
import ModalTrigger from '../../components/ModalTrigger'; import ModalTrigger from '../../components/ModalTrigger';
import Checkbox from '../../components/Checkbox';
const $ = window.$ = require('jquery'); const $ = window.$ = require('jquery');
@ -20,13 +21,17 @@ class SaveModal extends React.PureComponent {
dashboard: props.dashboard, dashboard: props.dashboard,
css: props.css, css: props.css,
saveType: 'overwrite', saveType: 'overwrite',
newDashName: '', newDashName: props.dashboard.dashboard_title + ' [copy]',
duplicateSlices: false,
}; };
this.modal = null; this.modal = null;
this.handleSaveTypeChange = this.handleSaveTypeChange.bind(this); this.handleSaveTypeChange = this.handleSaveTypeChange.bind(this);
this.handleNameChange = this.handleNameChange.bind(this); this.handleNameChange = this.handleNameChange.bind(this);
this.saveDashboard = this.saveDashboard.bind(this); this.saveDashboard = this.saveDashboard.bind(this);
} }
toggleDuplicateSlices() {
this.setState({ duplicateSlices: !this.state.duplicateSlices });
}
handleSaveTypeChange(event) { handleSaveTypeChange(event) {
this.setState({ this.setState({
saveType: event.target.value, saveType: event.target.value,
@ -52,7 +57,7 @@ class SaveModal extends React.PureComponent {
saveModal.close(); saveModal.close();
dashboard.onSave(); dashboard.onSave();
if (saveType === 'newDashboard') { if (saveType === 'newDashboard') {
window.location = '/superset/dashboard/' + resp.id + '/'; window.location = `/superset/dashboard/${resp.id}/`;
} else { } else {
notify.success('This dashboard was saved successfully.'); notify.success('This dashboard was saved successfully.');
} }
@ -81,10 +86,11 @@ class SaveModal extends React.PureComponent {
expanded_slices: expandedSlices, expanded_slices: expandedSlices,
dashboard_title: dashboard.dashboard_title, dashboard_title: dashboard.dashboard_title,
default_filters: dashboard.readFilters(), default_filters: dashboard.readFilters(),
duplicate_slices: this.state.duplicateSlices,
}; };
let url = null; let url = null;
if (saveType === 'overwrite') { if (saveType === 'overwrite') {
url = '/superset/save_dash/' + dashboard.id + '/'; url = `/superset/save_dash/${dashboard.id}/`;
this.saveDashboardRequest(data, url, saveType); this.saveDashboardRequest(data, url, saveType);
} else if (saveType === 'newDashboard') { } else if (saveType === 'newDashboard') {
if (!newDashboardTitle) { if (!newDashboardTitle) {
@ -95,7 +101,7 @@ class SaveModal extends React.PureComponent {
}); });
} else { } else {
data.dashboard_title = newDashboardTitle; data.dashboard_title = newDashboardTitle;
url = '/superset/copy_dash/' + dashboard.id + '/'; url = `/superset/copy_dash/${dashboard.id}/`;
this.saveDashboardRequest(data, url, saveType); this.saveDashboardRequest(data, url, saveType);
} }
} }
@ -115,6 +121,7 @@ class SaveModal extends React.PureComponent {
> >
Overwrite Dashboard [{this.props.dashboard.dashboard_title}] Overwrite Dashboard [{this.props.dashboard.dashboard_title}]
</Radio> </Radio>
<hr />
<Radio <Radio
value="newDashboard" value="newDashboard"
onChange={this.handleSaveTypeChange} onChange={this.handleSaveTypeChange}
@ -125,9 +132,17 @@ class SaveModal extends React.PureComponent {
<FormControl <FormControl
type="text" type="text"
placeholder="[dashboard name]" placeholder="[dashboard name]"
value={this.state.newDashName}
onFocus={this.handleNameChange} onFocus={this.handleNameChange}
onChange={this.handleNameChange} onChange={this.handleNameChange}
/> />
<div className="m-l-25 m-t-5">
<Checkbox
checked={this.state.duplicateSlices}
onChange={this.toggleDuplicateSlices.bind(this)}
/>
<span className="m-l-5">also copy (duplicate) slices</span>
</div>
</FormGroup> </FormGroup>
} }
modalFooter={ modalFooter={

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ControlHeader from '../ControlHeader'; import ControlHeader from '../ControlHeader';
import Checkbox from '../../../components/Checkbox';
const propTypes = { const propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
@ -15,24 +16,22 @@ const defaultProps = {
onChange: () => {}, onChange: () => {},
}; };
const checkboxStyle = { paddingRight: '5px' };
export default class CheckboxControl extends React.Component { export default class CheckboxControl extends React.Component {
onToggle() { onChange(checked) {
this.props.onChange(!this.props.value); this.props.onChange(checked);
} }
render() { render() {
return ( return (
<ControlHeader <ControlHeader
{...this.props} {...this.props}
onClick={this.onToggle.bind(this)}
leftNode={ leftNode={
<span> <Checkbox
<i onChange={this.onChange.bind(this)}
className={`fa fa-check ${this.props.value ? 'text-primary' : 'text-transparent'}`} style={checkboxStyle}
onClick={this.onToggle.bind(this)} checked={!!this.props.value}
style={{ border: '1px solid #aaa', borderRadius: '2px', cursor: 'pointer' }} />
/>
&nbsp;&nbsp;
</span>
} }
/> />
); );

View File

@ -0,0 +1,39 @@
import React from 'react';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import Checkbox from '../../../javascripts/components/Checkbox';
describe('Checkbox', () => {
const defaultProps = {
checked: true,
onChange: sinon.spy(),
};
let wrapper;
const factory = (o) => {
const props = Object.assign({}, defaultProps, o);
return shallow(<Checkbox {...props} />);
};
beforeEach(() => {
wrapper = factory({});
});
it('is a valid element', () => {
expect(React.isValidElement(<Checkbox {...defaultProps} />)).to.equal(true);
});
it('inits checked when checked', () => {
expect(wrapper.find('i.fa-check.text-primary')).to.have.length(1);
});
it('inits unchecked when not checked', () => {
const el = factory({ checked: false });
expect(el.find('i.fa-check.text-primary')).to.have.length(0);
expect(el.find('i.fa-check.text-transparent')).to.have.length(1);
});
it('unchecks when clicked', () => {
expect(wrapper.find('i.fa-check.text-transparent')).to.have.length(0);
wrapper.find('i').first().simulate('click');
expect(defaultProps.onChange.calledOnce).to.equal(true);
});
});

View File

@ -7,6 +7,7 @@ import { shallow } from 'enzyme';
import CheckboxControl from '../../../../javascripts/explore/components/controls/CheckboxControl'; import CheckboxControl from '../../../../javascripts/explore/components/controls/CheckboxControl';
import ControlHeader from '../../../../javascripts/explore/components/ControlHeader'; import ControlHeader from '../../../../javascripts/explore/components/ControlHeader';
import Checkbox from '../../../../javascripts/components/Checkbox';
const defaultProps = { const defaultProps = {
name: 'show_legend', name: 'show_legend',
@ -27,6 +28,6 @@ describe('CheckboxControl', () => {
expect(controlHeader).to.have.lengthOf(1); expect(controlHeader).to.have.lengthOf(1);
const headerWrapper = controlHeader.shallow(); const headerWrapper = controlHeader.shallow();
expect(headerWrapper.find('i.fa-check')).to.have.length(1); expect(headerWrapper.find(Checkbox)).to.have.length(1);
}); });
}); });

View File

@ -228,6 +228,12 @@ div.widget .slice_container {
.m-t-5 { .m-t-5 {
margin-top: 5px; margin-top: 5px;
} }
.m-l-5 {
margin-left: 5px;
}
.m-l-25 {
margin-left: 25px;
}
.Select-menu-outer { .Select-menu-outer {
z-index: 10 !important; z-index: 10 !important;
} }

View File

@ -27,3 +27,7 @@ table.table thead th.sorting:after, table.table thead th.sorting_asc:after, tabl
.like-pre { .like-pre {
white-space: pre-wrap; white-space: pre-wrap;
} }
.widget.table thead tr {
height: 25px;
}

View File

@ -120,6 +120,17 @@ class Slice(Model, AuditMixinNullable, ImportMixin):
def datasource(self): def datasource(self):
return self.get_datasource return self.get_datasource
def clone(self):
return Slice(
slice_name=self.slice_name,
datasource_id=self.datasource_id,
datasource_type=self.datasource_type,
datasource_name=self.datasource_name,
viz_type=self.viz_type,
params=self.params,
description=self.description,
cache_timeout=self.cache_timeout)
@datasource.getter @datasource.getter
@utils.memoized @utils.memoized
def get_datasource(self): def get_datasource(self):

View File

@ -1295,13 +1295,28 @@ class Superset(BaseSupersetView):
session = db.session() session = db.session()
data = json.loads(request.form.get('data')) data = json.loads(request.form.get('data'))
dash = models.Dashboard() dash = models.Dashboard()
original_dash = (session original_dash = (
.query(models.Dashboard) session
.filter_by(id=dashboard_id).first()) .query(models.Dashboard)
.filter_by(id=dashboard_id).first())
dash.owners = [g.user] if g.user else [] dash.owners = [g.user] if g.user else []
dash.dashboard_title = data['dashboard_title'] dash.dashboard_title = data['dashboard_title']
dash.slices = original_dash.slices if data['duplicate_slices']:
# Duplicating slices as well, mapping old ids to new ones
old_to_new_sliceids = {}
for slc in original_dash.slices:
new_slice = slc.clone()
new_slice.owners = [g.user] if g.user else []
session.add(new_slice)
session.flush()
new_slice.dashboards.append(dash)
old_to_new_sliceids['{}'.format(slc.id)] =\
'{}'.format(new_slice.id)
for d in data['positions']:
d['slice_id'] = old_to_new_sliceids[d['slice_id']]
else:
dash.slices = original_dash.slices
dash.params = original_dash.params dash.params = original_dash.params
self._set_dash_metadata(dash, data) self._set_dash_metadata(dash, data)

View File

@ -467,6 +467,7 @@ class CoreTests(SupersetTestCase):
positions.append(d) positions.append(d)
data = { data = {
'css': '', 'css': '',
'duplicate_slices': False,
'expanded_slices': {}, 'expanded_slices': {},
'positions': positions, 'positions': positions,
'dashboard_title': 'Copy Of Births', 'dashboard_title': 'Copy Of Births',