diff --git a/superset/assets/src/explore/actions/exploreActions.js b/superset/assets/src/explore/actions/exploreActions.js index 31eb507e3..3c019de9f 100644 --- a/superset/assets/src/explore/actions/exploreActions.js +++ b/superset/assets/src/explore/actions/exploreActions.js @@ -145,3 +145,8 @@ export function createNewSlice( form_data, }; } + +export const SLICE_UPDATED = 'SLICE_UPDATED'; +export function sliceUpdated(slice) { + return { type: SLICE_UPDATED, slice }; +} diff --git a/superset/assets/src/explore/components/DisplayQueryButton.jsx b/superset/assets/src/explore/components/DisplayQueryButton.jsx index 2ac322113..0c087721c 100644 --- a/superset/assets/src/explore/components/DisplayQueryButton.jsx +++ b/superset/assets/src/explore/components/DisplayQueryButton.jsx @@ -46,6 +46,7 @@ import ModalTrigger from './../../components/ModalTrigger'; import Button from '../../components/Button'; import RowCountLabel from './RowCountLabel'; import { prepareCopyToClipboardTabularData } from '../../utils/common'; +import PropertiesModal from './PropertiesModal'; registerLanguage('markdown', markdownSyntax); registerLanguage('html', htmlSyntax); @@ -58,6 +59,7 @@ const propTypes = { queryResponse: PropTypes.object, chartStatus: PropTypes.string, latestQueryFormData: PropTypes.object.isRequired, + slice: PropTypes.object, }; const defaultProps = { animation: true, @@ -75,9 +77,12 @@ export default class DisplayQueryButton extends React.PureComponent { error: null, filterText: '', sqlSupported: datasource && datasource.split('__')[1] === 'table', + isPropertiesModalOpen: false, }; this.beforeOpen = this.beforeOpen.bind(this); this.changeFilterText = this.changeFilterText.bind(this); + this.openPropertiesModal = this.openPropertiesModal.bind(this); + this.closePropertiesModal = this.closePropertiesModal.bind(this); } beforeOpen(endpointType) { this.setState({ isLoading: true }); @@ -113,6 +118,12 @@ export default class DisplayQueryButton extends React.PureComponent { redirectSQLLab() { this.props.onOpenInEditor(this.props.latestQueryFormData); } + openPropertiesModal() { + this.setState({ isPropertiesModalOpen: true }); + } + closePropertiesModal() { + this.setState({ isPropertiesModalOpen: false }); + } renderQueryModalBody() { if (this.state.isLoading) { return ; @@ -222,6 +233,19 @@ export default class DisplayQueryButton extends React.PureComponent { pullRight id="query" > + {this.props.slice && ( + <> + + {t('Edit properties')} + + + > + )} this.beforeOpen('query')} modalBody={this.renderQueryModalBody()} - eventKey="1" /> this.beforeOpen('results')} modalBody={this.renderResultsModalBody()} - eventKey="2" /> this.beforeOpen('samples')} modalBody={this.renderSamplesModalBody()} - eventKey="2" /> {this.state.sqlSupported && ( diff --git a/superset/assets/src/explore/components/ExploreActionButtons.jsx b/superset/assets/src/explore/components/ExploreActionButtons.jsx index c7a7f20c3..c606108c3 100644 --- a/superset/assets/src/explore/components/ExploreActionButtons.jsx +++ b/superset/assets/src/explore/components/ExploreActionButtons.jsx @@ -33,6 +33,7 @@ const propTypes = { chartStatus: PropTypes.string, latestQueryFormData: PropTypes.object, queryResponse: PropTypes.object, + slice: PropTypes.object, }; export default function ExploreActionButtons({ @@ -41,6 +42,7 @@ export default function ExploreActionButtons({ chartStatus, latestQueryFormData, queryResponse, + slice, }) { const exportToCSVClasses = cx('btn btn-default btn-sm', { 'disabled disabledButton': !canDownload, @@ -89,6 +91,7 @@ export default function ExploreActionButtons({ latestQueryFormData={latestQueryFormData} chartStatus={chartStatus} onOpenInEditor={actions.redirectSQLLab} + slice={slice} /> ); diff --git a/superset/assets/src/explore/components/PropertiesModal.jsx b/superset/assets/src/explore/components/PropertiesModal.jsx new file mode 100644 index 000000000..9424fd6da --- /dev/null +++ b/superset/assets/src/explore/components/PropertiesModal.jsx @@ -0,0 +1,239 @@ +/** + * 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, { useState, useEffect, useRef } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { + Button, + Modal, + Row, + Col, + FormControl, + FormGroup, +} from 'react-bootstrap'; +import Dialog from 'react-bootstrap-dialog'; +import Select from 'react-select'; +import { t } from '@superset-ui/translation'; +import { SupersetClient } from '@superset-ui/connection'; + +import { sliceUpdated } from '../actions/exploreActions'; +import getClientErrorObject from '../../utils/getClientErrorObject'; + +function PropertiesModalWrapper({ show, onHide, animation, slice, onSave }) { + // The wrapper is a separate component so that hooks only run when the modal opens + return ( + + + + ); +} + +function PropertiesModal({ slice, onHide, onSave }) { + const [submitting, setSubmitting] = useState(false); + const errorDialog = useRef(); + const [ownerOptions, setOwnerOptions] = useState(null); + + // values of form inputs + const [name, setName] = useState(slice.slice_name || ''); + const [description, setDescription] = useState(slice.description || ''); + const [cacheTimeout, setCacheTimeout] = useState( + slice.cache_timeout != null ? slice.cache_timeout : '', + ); + const [owners, setOwners] = useState(null); + + function showError({ error, statusText }) { + errorDialog.current.show({ + title: 'Error', + bsSize: 'medium', + bsStyle: 'danger', + actions: [Dialog.DefaultAction('Ok', () => {}, 'btn-danger')], + body: error || statusText || t('An error has occurred'), + }); + } + + async function fetchOwners() { + try { + const response = await SupersetClient.get({ + endpoint: `/api/v1/chart/${slice.slice_id}`, + }); + setOwners( + response.json.result.owners.map(owner => ({ + value: owner.id, + label: owner.username, + })), + ); + } catch (response) { + const clientError = await getClientErrorObject(response); + showError(clientError); + } + } + + // get the owners of this slice + useEffect(() => { + fetchOwners(); + }, []); + + // get the list of users who can own a chart + useEffect(() => { + SupersetClient.get({ + endpoint: `/api/v1/chart/related/owners`, + }).then(res => { + setOwnerOptions( + res.json.result.map(item => ({ + value: item.value, + label: item.text, + })), + ); + }); + }, []); + + const onSubmit = async event => { + event.stopPropagation(); + event.preventDefault(); + setSubmitting(true); + const payload = { + slice_name: name || null, + description: description || null, + cache_timeout: cacheTimeout || null, + owners: owners.map(o => o.value), + }; + try { + const res = await SupersetClient.put({ + endpoint: `/api/v1/chart/${slice.slice_id}`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + // update the redux state + onSave(res.json.result); + onHide(); + } catch (res) { + const clientError = await getClientErrorObject(res); + showError(clientError); + } + setSubmitting(false); + }; + + return ( + + + Edit Chart Properties + + + + + {t('Basic Information')} + + + {t('Name')} + + setName(event.target.value)} + /> + + + + {t('Description')} + + setDescription(event.target.value)} + style={{ maxWidth: '100%' }} + /> + + {t( + 'The description can be displayed as widget headers in the dashboard view. Supports markdown.', + )} + + + + + {t('Configuration')} + + + {t('Cache Timeout')} + + + setCacheTimeout(event.target.value.replace(/[^0-9]/, '')) + } + /> + + {t( + 'Duration (in seconds) of the caching timeout for this chart. Note this defaults to the datasource/table timeout if undefined.', + )} + + + {t('Access')} + + + {t('Owners')} + + + + {t('A list of users who can alter the chart')} + + + + + + + + {t('Save')} + + + {t('Cancel')} + + + + + ); +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ onSave: sliceUpdated }, dispatch); +} + +export { PropertiesModalWrapper }; +export default connect(null, mapDispatchToProps)(PropertiesModalWrapper); diff --git a/superset/assets/src/explore/reducers/exploreReducer.js b/superset/assets/src/explore/reducers/exploreReducer.js index a0b4cab14..9d68510cd 100644 --- a/superset/assets/src/explore/reducers/exploreReducer.js +++ b/superset/assets/src/explore/reducers/exploreReducer.js @@ -158,6 +158,15 @@ export default function exploreReducer(state = {}, action) { can_overwrite: action.can_overwrite, }; }, + [actions.SLICE_UPDATED]() { + return { + ...state, + slice: { + ...state.slice, + ...action.slice, + }, + }; + }, }; if (action.type in actionHandlers) { return actionHandlers[action.type](); diff --git a/superset/models/slice.py b/superset/models/slice.py index d0fea3daf..b9a63f41c 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -148,6 +148,7 @@ class Slice( logging.exception(e) d["error"] = str(e) return { + "cache_timeout": self.cache_timeout, "datasource": self.datasource_name, "description": self.description, "description_markeddown": self.description_markeddown, diff --git a/superset/views/chart/api.py b/superset/views/chart/api.py index 47c7d7fb3..0ce8e8ee1 100644 --- a/superset/views/chart/api.py +++ b/superset/views/chart/api.py @@ -85,7 +85,7 @@ class ChartPostSchema(BaseOwnedSchema): viz_type = fields.String(allow_none=True, validate=Length(0, 250)) owners = fields.List(fields.Integer(validate=validate_owner)) params = fields.String(allow_none=True, validate=validate_json) - cache_timeout = fields.Integer() + cache_timeout = fields.Integer(allow_none=True) datasource_id = fields.Integer(required=True) datasource_type = fields.String(required=True) datasource_name = fields.String(allow_none=True) @@ -110,7 +110,7 @@ class ChartPutSchema(BaseOwnedSchema): viz_type = fields.String(allow_none=True, validate=Length(0, 250)) owners = fields.List(fields.Integer(validate=validate_owner)) params = fields.String(allow_none=True) - cache_timeout = fields.Integer() + cache_timeout = fields.Integer(allow_none=True) datasource_id = fields.Integer(allow_none=True) datasource_type = fields.String(allow_none=True) dashboards = fields.List(fields.Integer(validate=validate_dashboard))
+ {t( + 'The description can be displayed as widget headers in the dashboard view. Supports markdown.', + )} +
+ {t( + 'Duration (in seconds) of the caching timeout for this chart. Note this defaults to the datasource/table timeout if undefined.', + )} +
+ {t('A list of users who can alter the chart')} +