mirror of
https://github.com/apache/superset.git
synced 2024-09-12 00:29:39 -04:00
[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:
parent
3b4cd812ae
commit
e53f3032bb
24
superset/assets/javascripts/components/Checkbox.jsx
Normal file
24
superset/assets/javascripts/components/Checkbox.jsx
Normal 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;
|
@ -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={
|
||||||
|
@ -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' }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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):
|
||||||
|
@ -1295,12 +1295,27 @@ 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 = (
|
||||||
|
session
|
||||||
.query(models.Dashboard)
|
.query(models.Dashboard)
|
||||||
.filter_by(id=dashboard_id).first())
|
.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']
|
||||||
|
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.slices = original_dash.slices
|
||||||
dash.params = original_dash.params
|
dash.params = original_dash.params
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
Loading…
Reference in New Issue
Block a user