mirror of
https://github.com/apache/superset.git
synced 2024-09-12 00:29:39 -04:00
Save modal component for explore v2 (#1612)
* Added specs for SaveModal * Move datasource_id and datasource_name to form_data * Add comments * Deleted redundant fetchDashboard * Replcae has_key for python3 * More react and less jquery * Added alert for save slice * Small changes based on comments * Use react bootstrap
This commit is contained in:
parent
dc25bc6f4d
commit
38e94b9e43
@ -4,9 +4,14 @@ import classnames from 'classnames';
|
||||
const propTypes = {
|
||||
canAdd: PropTypes.string.isRequired,
|
||||
onQuery: PropTypes.func.isRequired,
|
||||
onSave: PropTypes.func,
|
||||
};
|
||||
|
||||
export default function QueryAndSaveBtns({ canAdd, onQuery }) {
|
||||
const defaultProps = {
|
||||
onSave: () => {},
|
||||
};
|
||||
|
||||
export default function QueryAndSaveBtns({ canAdd, onQuery, onSave }) {
|
||||
const saveClasses = classnames('btn btn-default btn-sm', {
|
||||
'disabled disabledButton': canAdd !== 'True',
|
||||
});
|
||||
@ -21,6 +26,7 @@ export default function QueryAndSaveBtns({ canAdd, onQuery }) {
|
||||
className={saveClasses}
|
||||
data-target="#save_modal"
|
||||
data-toggle="modal"
|
||||
onClick={onSave}
|
||||
>
|
||||
<i className="fa fa-plus-circle"></i> Save as
|
||||
</button>
|
||||
@ -29,3 +35,4 @@ export default function QueryAndSaveBtns({ canAdd, onQuery }) {
|
||||
}
|
||||
|
||||
QueryAndSaveBtns.propTypes = propTypes;
|
||||
QueryAndSaveBtns.defaultProps = defaultProps;
|
||||
|
@ -155,3 +155,53 @@ export const REMOVE_CHART_ALERT = 'REMOVE_CHART_ALERT';
|
||||
export function removeChartAlert() {
|
||||
return { type: REMOVE_CHART_ALERT };
|
||||
}
|
||||
|
||||
export const FETCH_DASHBOARDS_SUCCEEDED = 'FETCH_DASHBOARDS_SUCCEEDED';
|
||||
export function fetchDashboardsSucceeded(choices) {
|
||||
return { type: FETCH_DASHBOARDS_SUCCEEDED, choices };
|
||||
}
|
||||
|
||||
export const FETCH_DASHBOARDS_FAILED = 'FETCH_DASHBOARDS_FAILED';
|
||||
export function fetchDashboardsFailed(userId) {
|
||||
return { type: FETCH_FAILED, userId };
|
||||
}
|
||||
|
||||
export function fetchDashboards(userId) {
|
||||
return function (dispatch) {
|
||||
const url = '/dashboardmodelviewasync/api/read?_flt_0_owners=' + userId;
|
||||
$.get(url, function (data, status) {
|
||||
if (status === 'success') {
|
||||
const choices = [];
|
||||
for (let i = 0; i < data.pks.length; i++) {
|
||||
choices.push({ value: data.pks[i], label: data.result[i].dashboard_title });
|
||||
}
|
||||
dispatch(fetchDashboardsSucceeded(choices));
|
||||
} else {
|
||||
dispatch(fetchDashboardsFailed(userId));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const SAVE_SLICE_FAILED = 'SAVE_SLICE_FAILED';
|
||||
export function saveSliceFailed() {
|
||||
return { type: SAVE_SLICE_FAILED };
|
||||
}
|
||||
|
||||
export const REMOVE_SAVE_MODAL_ALERT = 'REMOVE_SAVE_MODAL_ALERT';
|
||||
export function removeSaveModalAlert() {
|
||||
return { type: REMOVE_SAVE_MODAL_ALERT };
|
||||
}
|
||||
|
||||
export function saveSlice(url) {
|
||||
return function (dispatch) {
|
||||
$.get(url, (data, status) => {
|
||||
if (status === 'success') {
|
||||
// Go to new slice url or dashboard url
|
||||
window.location = data;
|
||||
} else {
|
||||
dispatch(saveSliceFailed());
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -215,8 +215,4 @@ function mapStateToProps(state) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps() {
|
||||
return {};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ChartContainer);
|
||||
export default connect(mapStateToProps, () => ({}))(ChartContainer);
|
||||
|
@ -108,8 +108,6 @@ function mapStateToProps(state) {
|
||||
alert: state.controlPanelAlert,
|
||||
isDatasourceMetaLoading: state.isDatasourceMetaLoading,
|
||||
fields: state.fields,
|
||||
datasource_type: state.datasource_type,
|
||||
form_data: state.viz.form_data,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ import * as actions from '../actions/exploreActions';
|
||||
import { connect } from 'react-redux';
|
||||
import ChartContainer from './ChartContainer';
|
||||
import ControlPanelsContainer from './ControlPanelsContainer';
|
||||
import SaveModal from './SaveModal';
|
||||
import QueryAndSaveBtns from '../../explore/components/QueryAndSaveBtns';
|
||||
const $ = require('jquery');
|
||||
|
||||
@ -20,6 +21,7 @@ class ExploreViewContainer extends React.Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
height: this.getHeight(),
|
||||
showModal: false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -63,6 +65,10 @@ class ExploreViewContainer extends React.Component {
|
||||
this.props.datasource_type, this.props.form_data.datasource, data);
|
||||
}
|
||||
|
||||
toggleModal() {
|
||||
this.setState({ showModal: !this.state.showModal });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
@ -72,16 +78,26 @@ class ExploreViewContainer extends React.Component {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{this.state.showModal &&
|
||||
<SaveModal
|
||||
onHide={this.toggleModal.bind(this)}
|
||||
actions={this.props.actions}
|
||||
form_data={this.props.form_data}
|
||||
datasource_type={this.props.datasource_type}
|
||||
/>
|
||||
}
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
<QueryAndSaveBtns
|
||||
canAdd="True"
|
||||
onQuery={this.onQuery.bind(this)}
|
||||
onSave={this.toggleModal.bind(this)}
|
||||
/>
|
||||
<br /><br />
|
||||
<ControlPanelsContainer
|
||||
actions={this.props.actions}
|
||||
form_data={this.props.form_data}
|
||||
datasource_type={this.props.datasource_type}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-8">
|
||||
@ -112,6 +128,5 @@ function mapDispatchToProps(dispatch) {
|
||||
};
|
||||
}
|
||||
|
||||
export { ControlPanelsContainer };
|
||||
|
||||
export { ExploreViewContainer };
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ExploreViewContainer);
|
||||
|
@ -14,7 +14,11 @@ const propTypes = {
|
||||
places: PropTypes.number,
|
||||
validators: PropTypes.any,
|
||||
onChange: React.PropTypes.func,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.array]).isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.bool,
|
||||
PropTypes.array]).isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
|
241
superset/assets/javascripts/explorev2/components/SaveModal.js
Normal file
241
superset/assets/javascripts/explorev2/components/SaveModal.js
Normal file
@ -0,0 +1,241 @@
|
||||
/* eslint camel-case: 0 */
|
||||
import React, { PropTypes } from 'react';
|
||||
import $ from 'jquery';
|
||||
import { Modal, Alert, Button, Radio } from 'react-bootstrap';
|
||||
import Select from 'react-select';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const propTypes = {
|
||||
can_edit: PropTypes.bool,
|
||||
onHide: PropTypes.func.isRequired,
|
||||
actions: PropTypes.object.isRequired,
|
||||
form_data: PropTypes.object,
|
||||
datasource_type: PropTypes.string.isRequired,
|
||||
user_id: PropTypes.string.isRequired,
|
||||
dashboards: PropTypes.array.isRequired,
|
||||
alert: PropTypes.string,
|
||||
};
|
||||
|
||||
class SaveModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
saveToDashboardId: null,
|
||||
newDashboardName: '',
|
||||
newSliceName: '',
|
||||
dashboards: [],
|
||||
alert: null,
|
||||
action: 'overwrite',
|
||||
addToDash: 'noSave',
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.props.actions.fetchDashboards(this.props.user_id);
|
||||
}
|
||||
onChange(name, event) {
|
||||
switch (name) {
|
||||
case 'newSliceName':
|
||||
this.setState({ newSliceName: event.target.value });
|
||||
break;
|
||||
case 'saveToDashboardId':
|
||||
this.setState({ saveToDashboardId: event.value });
|
||||
this.changeDash('existing');
|
||||
break;
|
||||
case 'newDashboardName':
|
||||
this.setState({ newDashboardName: event.target.value });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
changeAction(action) {
|
||||
this.setState({ action });
|
||||
}
|
||||
changeDash(dash) {
|
||||
this.setState({ addToDash: dash });
|
||||
}
|
||||
saveOrOverwrite(gotodash) {
|
||||
this.setState({ alert: null });
|
||||
this.props.actions.removeSaveModalAlert();
|
||||
const params = {};
|
||||
const sliceParams = {};
|
||||
params.datasource_id = this.props.form_data.datasource;
|
||||
params.datasource_type = this.props.datasource_type;
|
||||
params.datasource_name = this.props.form_data.datasource_name;
|
||||
|
||||
let sliceName = null;
|
||||
sliceParams.action = this.state.action;
|
||||
if (sliceParams.action === 'saveas') {
|
||||
sliceName = this.state.newSliceName;
|
||||
if (sliceName === '') {
|
||||
this.setState({ alert: 'Please enter a slice name' });
|
||||
return;
|
||||
}
|
||||
sliceParams.slice_name = sliceName;
|
||||
} else {
|
||||
sliceParams.slice_name = this.props.form_data.slice_name;
|
||||
}
|
||||
|
||||
Object.keys(this.props.form_data).forEach((field) => {
|
||||
if (this.props.form_data[field] !== null && field !== 'slice_name') {
|
||||
params[field] = this.props.form_data[field];
|
||||
}
|
||||
});
|
||||
|
||||
const addToDash = this.state.addToDash;
|
||||
sliceParams.add_to_dash = addToDash;
|
||||
let dashboard = null;
|
||||
switch (addToDash) {
|
||||
case ('existing'):
|
||||
dashboard = this.state.saveToDashboardId;
|
||||
if (!dashboard) {
|
||||
this.setState({ alert: 'Please select a dashboard' });
|
||||
return;
|
||||
}
|
||||
sliceParams.save_to_dashboard_id = dashboard;
|
||||
break;
|
||||
case ('new'):
|
||||
dashboard = this.state.newDashboardName;
|
||||
if (dashboard === '') {
|
||||
this.setState({ alert: 'Please enter a dashboard name' });
|
||||
return;
|
||||
}
|
||||
sliceParams.new_dashboard_name = dashboard;
|
||||
break;
|
||||
default:
|
||||
dashboard = null;
|
||||
}
|
||||
params.V2 = true;
|
||||
sliceParams.goto_dash = gotodash;
|
||||
const baseUrl = '/superset/explore/' +
|
||||
`${this.props.datasource_type}/${this.props.form_data.datasource}/`;
|
||||
const saveUrl = `${baseUrl}?${$.param(params, true)}&${$.param(sliceParams, true)}`;
|
||||
this.props.actions.saveSlice(saveUrl);
|
||||
this.props.onHide();
|
||||
}
|
||||
removeAlert() {
|
||||
if (this.props.alert) {
|
||||
this.props.actions.removeSaveModalAlert();
|
||||
}
|
||||
this.setState({ alert: null });
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
show
|
||||
onHide={this.props.onHide}
|
||||
bsStyle="large"
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Save A Slice
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{(this.state.alert || this.props.alert) &&
|
||||
<Alert>
|
||||
{this.state.alert ? this.state.alert : this.props.alert}
|
||||
<i
|
||||
className="fa fa-close pull-right"
|
||||
onClick={this.removeAlert.bind(this)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Alert>
|
||||
}
|
||||
<Radio
|
||||
disabled={!this.props.can_edit}
|
||||
checked={this.state.action === 'overwrite'}
|
||||
onChange={this.changeAction.bind(this, 'overwrite')}
|
||||
>
|
||||
{`Overwrite slice ${this.props.form_data.slice_name}`}
|
||||
</Radio>
|
||||
|
||||
<Radio
|
||||
inline
|
||||
checked={this.state.action === 'saveas'}
|
||||
onChange={this.changeAction.bind(this, 'saveas')}
|
||||
> Save as
|
||||
</Radio>
|
||||
<input
|
||||
name="new_slice_name"
|
||||
placeholder="[slice name]"
|
||||
onChange={this.onChange.bind(this, 'newSliceName')}
|
||||
onFocus={this.changeAction.bind(this, 'saveas')}
|
||||
/>
|
||||
|
||||
|
||||
<br />
|
||||
<hr />
|
||||
|
||||
<Radio
|
||||
checked={this.state.addToDash === 'noSave'}
|
||||
onChange={this.changeDash.bind(this, 'noSave')}
|
||||
>
|
||||
Do not add to a dashboard
|
||||
</Radio>
|
||||
|
||||
<Radio
|
||||
inline
|
||||
checked={this.state.addToDash === 'existing'}
|
||||
onChange={this.changeDash.bind(this, 'existing')}
|
||||
>
|
||||
Add slice to existing dashboard
|
||||
</Radio>
|
||||
<Select
|
||||
options={this.props.dashboards}
|
||||
onChange={this.onChange.bind(this, 'saveToDashboardId')}
|
||||
autoSize={false}
|
||||
value={this.state.saveToDashboardId}
|
||||
/>
|
||||
|
||||
<Radio
|
||||
inline
|
||||
checked={this.state.addToDash === 'new'}
|
||||
onChange={this.changeDash.bind(this, 'new')}
|
||||
>
|
||||
Add to new dashboard
|
||||
</Radio>
|
||||
<input
|
||||
onChange={this.onChange.bind(this, 'newDashboardName')}
|
||||
onFocus={this.changeDash.bind(this, 'new')}
|
||||
placeholder="[dashboard name]"
|
||||
/>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
id="btn_modal_save"
|
||||
className="btn pull-left"
|
||||
onClick={this.saveOrOverwrite.bind(this)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
id="btn_modal_save_goto_dash"
|
||||
className="btn btn-primary pull-left gotodash"
|
||||
disabled={this.state.addToDash === 'noSave'}
|
||||
onClick={this.saveOrOverwrite.bind(this, true)}
|
||||
>
|
||||
Save & go to dashboard
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SaveModal.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
can_edit: state.can_edit,
|
||||
user_id: state.user_id,
|
||||
dashboards: state.dashboards,
|
||||
alert: state.saveModalAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export { SaveModal };
|
||||
export default connect(mapStateToProps, () => ({}))(SaveModal);
|
@ -13,10 +13,12 @@ const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstr
|
||||
import { exploreReducer } from './reducers/exploreReducer';
|
||||
|
||||
const bootstrappedState = Object.assign(initialState(bootstrapData.viz.form_data.viz_type), {
|
||||
can_edit: bootstrapData.can_edit,
|
||||
can_download: bootstrapData.can_download,
|
||||
datasources: bootstrapData.datasources,
|
||||
datasource_type: bootstrapData.datasource_type,
|
||||
viz: bootstrapData.viz,
|
||||
user_id: bootstrapData.user_id,
|
||||
});
|
||||
bootstrappedState.viz.form_data.datasource = parseInt(bootstrapData.datasource_id, 10);
|
||||
bootstrappedState.viz.form_data.datasource_name = bootstrapData.datasource_name;
|
||||
|
@ -27,6 +27,16 @@ export const exploreReducer = function (state, action) {
|
||||
[actions.REMOVE_CONTROL_PANEL_ALERT]() {
|
||||
return Object.assign({}, state, { controlPanelAlert: null });
|
||||
},
|
||||
|
||||
[actions.FETCH_DASHBOARDS_SUCCEEDED]() {
|
||||
return Object.assign({}, state, { dashboards: action.choices });
|
||||
},
|
||||
|
||||
[actions.FETCH_DASHBOARDS_FAILED]() {
|
||||
return Object.assign({}, state,
|
||||
{ saveModalAlert: `fetching dashboards failed for ${action.userId}` });
|
||||
},
|
||||
|
||||
[actions.SET_FIELD_OPTIONS]() {
|
||||
const newState = Object.assign({}, state);
|
||||
const optionsByFieldName = action.options;
|
||||
@ -66,6 +76,9 @@ export const exploreReducer = function (state, action) {
|
||||
newFormData.slice_name = state.viz.form_data.slice_name;
|
||||
newFormData.viz_type = state.viz.form_data.viz_type;
|
||||
}
|
||||
if (action.key === 'viz_type') {
|
||||
newFormData.previous_viz_type = state.viz.form_data.viz_type;
|
||||
}
|
||||
newFormData[action.key] = action.value ? action.value : (!state.viz.form_data[action.key]);
|
||||
return Object.assign(
|
||||
{},
|
||||
@ -99,6 +112,12 @@ export const exploreReducer = function (state, action) {
|
||||
[actions.REMOVE_CHART_ALERT]() {
|
||||
return Object.assign({}, state, { chartAlert: null });
|
||||
},
|
||||
[actions.SAVE_SLICE_FAILED]() {
|
||||
return Object.assign({}, state, { saveModalAlert: 'Failed to save slice' });
|
||||
},
|
||||
[actions.REMOVE_SAVE_MODAL_ALERT]() {
|
||||
return Object.assign({}, state, { saveModalAlert: null });
|
||||
},
|
||||
};
|
||||
if (action.type in actionHandlers) {
|
||||
return actionHandlers[action.type]();
|
||||
|
@ -750,7 +750,7 @@ export const fields = {
|
||||
type: 'SelectMultipleSortableField',
|
||||
label: 'Metrics',
|
||||
choices: [],
|
||||
default: null,
|
||||
default: [],
|
||||
description: 'One or many metrics to display',
|
||||
},
|
||||
|
||||
@ -758,6 +758,7 @@ export const fields = {
|
||||
type: 'SelectMultipleSortableField',
|
||||
label: 'Ordering',
|
||||
choices: [],
|
||||
default: [],
|
||||
description: 'One or many metrics to display',
|
||||
},
|
||||
|
||||
@ -931,6 +932,7 @@ export const fields = {
|
||||
type: 'SelectMultipleSortableField',
|
||||
label: 'Group by',
|
||||
choices: [],
|
||||
default: [],
|
||||
description: 'One or many fields to group by',
|
||||
},
|
||||
|
||||
@ -938,6 +940,7 @@ export const fields = {
|
||||
type: 'SelectMultipleSortableField',
|
||||
label: 'Columns',
|
||||
choices: [],
|
||||
default: [],
|
||||
description: 'One or many fields to pivot as columns',
|
||||
},
|
||||
|
||||
@ -945,6 +948,7 @@ export const fields = {
|
||||
type: 'SelectMultipleSortableField',
|
||||
label: 'Columns',
|
||||
choices: [],
|
||||
default: [],
|
||||
description: 'Columns to display',
|
||||
},
|
||||
|
||||
@ -1552,6 +1556,7 @@ export const fields = {
|
||||
type: 'SelectMultipleSortableField',
|
||||
label: 'label',
|
||||
choices: [],
|
||||
default: [],
|
||||
description: '`count` is COUNT(*) if a group by is used. ' +
|
||||
'Numerical columns will be aggregated with the aggregator. ' +
|
||||
'Non-numerical columns will be used to label points. ' +
|
||||
@ -1704,6 +1709,7 @@ export function defaultViz(vizType) {
|
||||
|
||||
export function initialState(vizType = 'table') {
|
||||
return {
|
||||
dashboards: [],
|
||||
isDatasourceMetaLoading: false,
|
||||
datasources: null,
|
||||
datasource_type: null,
|
||||
|
@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it, beforeEach } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
import { defaultFormData } from '../../../../javascripts/explorev2/stores/store';
|
||||
import { SaveModal } from '../../../../javascripts/explorev2/components/SaveModal';
|
||||
import { Modal, Button, Radio } from 'react-bootstrap';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const defaultProps = {
|
||||
can_edit: true,
|
||||
onHide: () => ({}),
|
||||
actions: {
|
||||
saveSlice: sinon.spy(),
|
||||
},
|
||||
form_data: defaultFormData,
|
||||
datasource_id: 1,
|
||||
datasource_name: 'birth_names',
|
||||
datasource_type: 'table',
|
||||
user_id: 1,
|
||||
};
|
||||
|
||||
describe('SaveModal', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<SaveModal {...defaultProps} />);
|
||||
});
|
||||
|
||||
it('renders a Modal with 7 inputs and 2 buttons', () => {
|
||||
expect(wrapper.find(Modal)).to.have.lengthOf(1);
|
||||
expect(wrapper.find('input')).to.have.lengthOf(2);
|
||||
expect(wrapper.find(Button)).to.have.lengthOf(2);
|
||||
expect(wrapper.find(Radio)).to.have.lengthOf(5);
|
||||
});
|
||||
});
|
@ -1456,7 +1456,8 @@ class Superset(BaseSupersetView):
|
||||
# TODO use form processing form wtforms
|
||||
d = args.to_dict(flat=False)
|
||||
del d['action']
|
||||
del d['previous_viz_type']
|
||||
if 'previous_viz_type' in d:
|
||||
del d['previous_viz_type']
|
||||
|
||||
as_list = ('metrics', 'groupby', 'columns', 'all_columns',
|
||||
'mapbox_label', 'order_by_cols')
|
||||
@ -1515,8 +1516,12 @@ class Superset(BaseSupersetView):
|
||||
db.session.commit()
|
||||
|
||||
if request.args.get('goto_dash') == 'true':
|
||||
if request.args.get('V2') == 'true':
|
||||
return dash.url
|
||||
return redirect(dash.url)
|
||||
else:
|
||||
if request.args.get('V2') == 'true':
|
||||
return slc.slice_url
|
||||
return redirect(slc.slice_url)
|
||||
|
||||
def save_slice(self, slc):
|
||||
|
Loading…
Reference in New Issue
Block a user