[dashboard] Modal for editing dashboard properties & metadata (#8876)

* wip

* wip

* wip

* modal to update dashboard properties

* cleanup

* translations and flavor text

* linted

* more explanatory text in the modal
This commit is contained in:
David Aaron Suddjian 2020-01-14 11:29:59 -08:00 committed by Maxime Beauchemin
parent 50f21cb7db
commit 614f13377b
11 changed files with 437 additions and 3 deletions

View File

@ -63,6 +63,8 @@ describe('Header', () => {
redoLength: 0,
setMaxUndoHistoryExceeded: () => {},
maxUndoHistoryToast: () => {},
dashboardInfoChanged: () => {},
dashboardTitleChanged: () => {},
};
function setup(overrideProps) {

View File

@ -0,0 +1,25 @@
/**
* 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.
*/
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
// updates partially changed dashboard info
export function dashboardInfoChanged(newInfo) {
return { type: DASHBOARD_INFO_UPDATED, newInfo };
}

View File

@ -88,6 +88,16 @@ export function updateDashboardTitle(text) {
};
}
export const DASHBOARD_TITLE_CHANGED = 'DASHBOARD_TITLE_CHANGED';
// call this one when it's not an undo-able action
export function dashboardTitleChanged(text) {
return {
type: DASHBOARD_TITLE_CHANGED,
text,
};
}
export const DELETE_COMPONENT = 'DELETE_COMPONENT';
export const deleteComponent = setUnsavedChangesAfterAction((id, parentId) => ({
type: DELETE_COMPONENT,

View File

@ -44,6 +44,7 @@ import {
LOG_ACTIONS_FORCE_REFRESH_DASHBOARD,
LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD,
} from '../../logger/LogUtils';
import PropertiesModal from './PropertiesModal';
const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
@ -86,6 +87,8 @@ const propTypes = {
maxUndoHistoryToast: PropTypes.func.isRequired,
refreshFrequency: PropTypes.number.isRequired,
setRefreshFrequency: PropTypes.func.isRequired,
dashboardInfoChanged: PropTypes.func.isRequired,
dashboardTitleChanged: PropTypes.func.isRequired,
};
const defaultProps = {
@ -103,6 +106,7 @@ class Header extends React.PureComponent {
this.state = {
didNotifyMaxUndoHistoryToast: false,
emphasizeUndo: false,
showingPropertiesModal: false,
};
this.handleChangeText = this.handleChangeText.bind(this);
@ -116,6 +120,8 @@ class Header extends React.PureComponent {
this.forceRefresh = this.forceRefresh.bind(this);
this.startPeriodicRender = this.startPeriodicRender.bind(this);
this.overwriteDashboard = this.overwriteDashboard.bind(this);
this.showPropertiesModal = this.showPropertiesModal.bind(this);
this.hidePropertiesModal = this.hidePropertiesModal.bind(this);
}
componentDidMount() {
@ -256,6 +262,14 @@ class Header extends React.PureComponent {
}
}
showPropertiesModal() {
this.setState({ showingPropertiesModal: true });
}
hidePropertiesModal() {
this.setState({ showingPropertiesModal: false });
}
render() {
const {
dashboardTitle,
@ -405,6 +419,27 @@ class Header extends React.PureComponent {
</Button>
)}
{this.state.showingPropertiesModal && (
<PropertiesModal
dashboardTitle={dashboardTitle}
dashboardInfo={dashboardInfo}
show={this.state.showingPropertiesModal}
onHide={this.hidePropertiesModal}
onDashboardSave={updates => {
this.props.dashboardInfoChanged({
slug: updates.slug,
metadata: JSON.parse(updates.jsonMetadata),
});
this.props.dashboardTitleChanged(updates.title);
history.pushState(
{ event: 'dashboard_properties_changed' },
'',
`/superset/dashboard/${updates.slug}/`,
);
}}
/>
)}
<HeaderActionsDropdown
addSuccessToast={this.props.addSuccessToast}
addDangerToast={this.props.addDangerToast}
@ -427,6 +462,7 @@ class Header extends React.PureComponent {
userCanEdit={userCanEdit}
userCanSave={userCanSaveAs}
isLoading={isLoading}
showPropertiesModal={this.showPropertiesModal}
/>
</div>
</div>

View File

@ -53,6 +53,7 @@ const propTypes = {
layout: PropTypes.object.isRequired,
expandedSlices: PropTypes.object.isRequired,
onSave: PropTypes.func.isRequired,
showPropertiesModal: PropTypes.func.isRequired,
};
const defaultProps = {
@ -190,8 +191,8 @@ class HeaderActionsDropdown extends React.PureComponent {
/>
{editMode && (
<MenuItem target="_blank" href={`/dashboard/edit/${dashboardId}`}>
{t('Edit dashboard metadata')}
<MenuItem onClick={this.props.showPropertiesModal}>
{t('Edit dashboard properties')}
</MenuItem>
)}

View File

@ -0,0 +1,297 @@
/**
* 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 from 'react';
import PropTypes from 'prop-types';
import { Row, Col, Button, Modal, FormControl } 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 getClientErrorObject from '../../utils/getClientErrorObject';
import withToasts from '../../messageToasts/enhancers/withToasts';
const propTypes = {
dashboardTitle: PropTypes.string,
dashboardInfo: PropTypes.object,
owners: PropTypes.arrayOf(PropTypes.object),
show: PropTypes.bool.isRequired,
onHide: PropTypes.func,
onDashboardSave: PropTypes.func,
addSuccessToast: PropTypes.func.isRequired,
};
const defaultProps = {
dashboardInfo: {},
dashboardTitle: '[dashboard name]',
owners: [],
onHide: () => {},
onDashboardSave: () => {},
show: false,
};
class PropertiesModal extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
errors: [],
values: {
dashboard_title: this.props.dashboardTitle,
slug: this.props.dashboardInfo.slug,
owners: this.props.owners || [],
json_metadata: JSON.stringify(this.props.dashboardInfo.metadata),
},
isOwnersLoaded: false,
userOptions: null,
isAdvancedOpen: false,
};
this.onChange = this.onChange.bind(this);
this.onOwnersChange = this.onOwnersChange.bind(this);
this.save = this.save.bind(this);
this.toggleAdvanced = this.toggleAdvanced.bind(this);
}
componentDidMount() {
SupersetClient.get({ endpoint: `/users/api/read` }).then(response => {
const options = response.json.result.map((user, i) => ({
// ids are in a separate `pks` array in the results... need api v2
value: response.json.pks[i],
label: `${user.first_name} ${user.last_name}`,
}));
this.setState({
userOptions: options,
});
});
SupersetClient.get({
endpoint: `/api/v1/dashboard/${this.props.dashboardInfo.id}`,
}).then(response => {
this.setState({ isOwnersLoaded: true });
const initialSelectedValues = response.json.result.owners.map(owner => ({
value: owner.id,
label: owner.username,
}));
this.onOwnersChange(initialSelectedValues);
});
}
onOwnersChange(value) {
this.setState(state => ({
values: {
...state.values,
owners: value,
},
}));
}
onChange(e) {
const { name, value } = e.target;
this.setState(state => ({
values: {
...state.values,
[name]: value,
},
}));
}
toggleAdvanced() {
this.setState(state => ({
isAdvancedOpen: !state.isAdvancedOpen,
}));
}
save(e) {
e.preventDefault();
e.stopPropagation();
const owners = this.state.values.owners.map(o => o.value);
SupersetClient.put({
endpoint: `/api/v1/dashboard/${this.props.dashboardInfo.id}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...this.state.values,
owners,
}),
})
.then(({ json }) => {
this.props.addSuccessToast(t('The dashboard has been saved'));
this.props.onDashboardSave({
title: json.result.dashboard_title,
slug: json.result.slug,
jsonMetadata: json.result.json_metadata,
ownerIds: json.result.owners,
});
this.props.onHide();
})
.catch(response =>
getClientErrorObject(response).then(({ error, statusText }) => {
this.dialog.show({
title: 'Error',
bsSize: 'medium',
bsStyle: 'danger',
actions: [Dialog.DefaultAction('Ok', () => {}, 'btn-danger')],
body: error || statusText || t('An error has occurred'),
});
}),
);
}
render() {
const { userOptions, values, isOwnersLoaded, isAdvancedOpen } = this.state;
return (
<Modal show={this.props.show} onHide={this.props.onHide} bsSize="lg">
<form onSubmit={this.save}>
<Modal.Header closeButton>
<Modal.Title>
<div>
<span className="float-left">{t('Dashboard Properties')}</span>
</div>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Row>
<Col md={12}>
<h3>{t('Basic Information')}</h3>
</Col>
</Row>
<Row>
<Col md={6}>
<label className="control-label" htmlFor="embed-height">
{t('Title')}
</label>
<FormControl
name="dashboard_title"
type="text"
bsSize="sm"
value={values.dashboard_title}
onChange={this.onChange}
/>
</Col>
<Col md={6}>
<label className="control-label" htmlFor="embed-height">
{t('URL Slug')}
</label>
<FormControl
name="slug"
type="text"
bsSize="sm"
value={values.slug}
onChange={this.onChange}
/>
<p className="help-block">
{t('A readable URL for your dashboard')}
</p>
</Col>
</Row>
<Row>
<Col md={6}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
<label className="control-label" htmlFor="owners">
{t('Owners')}
</label>
{userOptions && (
<>
<Select
name="owners"
multi
isLoading={!userOptions}
value={values.owners}
options={userOptions || []}
onChange={this.onOwnersChange}
disabled={!isOwnersLoaded}
/>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard.',
)}
</p>
</>
)}
</Col>
</Row>
<Row>
<Col md={12}>
<h3 style={{ marginTop: '1em' }}>
<button
type="button"
className="text-button"
onClick={this.toggleAdvanced}
>
<i
className={`fa fa-angle-${
isAdvancedOpen ? 'down' : 'right'
}`}
style={{ minWidth: '1em' }}
/>
{t('Advanced')}
</button>
</h3>
{isAdvancedOpen && (
<>
<label className="control-label" htmlFor="json_metadata">
{t('JSON Metadata')}
</label>
<FormControl
componentClass="textarea"
style={{ maxWidth: '100%' }}
name="json_metadata"
type="text"
bsSize="sm"
value={values.json_metadata}
onChange={this.onChange}
/>
<p className="help-block">
{t(
'This JSON object is generated dynamically when clicking the save or overwrite button in the dashboard view. It is exposed here for reference and for power users who may want to alter specific parameters.',
)}
</p>
</>
)}
</Col>
</Row>
</Modal.Body>
<Modal.Footer>
<span className="float-right">
<Button
type="submit"
bsSize="sm"
bsStyle="primary"
className="m-r-5"
disabled={this.state.errors.length > 0}
>
{t('Save')}
</Button>
<Button type="button" bsSize="sm" onClick={this.props.onHide}>
{t('Cancel')}
</Button>
<Dialog
ref={ref => {
this.dialog = ref;
}}
/>
</span>
</Modal.Footer>
</form>
</Modal>
);
}
}
PropertiesModal.propTypes = propTypes;
PropertiesModal.defaultProps = defaultProps;
export default withToasts(PropertiesModal);

View File

@ -22,6 +22,8 @@ import { connect } from 'react-redux';
import DashboardHeader from '../components/Header';
import isDashboardLoading from '../util/isDashboardLoading';
import { dashboardInfoChanged } from '../actions/dashboardInfo';
import {
setEditMode,
showBuilderPane,
@ -42,6 +44,7 @@ import {
undoLayoutAction,
redoLayoutAction,
updateDashboardTitle,
dashboardTitleChanged,
} from '../actions/dashboardLayout';
import {
@ -107,6 +110,8 @@ function mapDispatchToProps(dispatch) {
maxUndoHistoryToast,
logEvent,
setRefreshFrequency,
dashboardInfoChanged,
dashboardTitleChanged,
},
dispatch,
);

View File

@ -0,0 +1,32 @@
/**
* 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 { DASHBOARD_INFO_UPDATED } from '../actions/dashboardInfo';
export default function dashboardStateReducer(state = {}, action) {
switch (action.type) {
case DASHBOARD_INFO_UPDATED:
return {
...state,
...action.newInfo,
};
default:
return state;
}
}

View File

@ -20,6 +20,7 @@ import {
DASHBOARD_ROOT_ID,
DASHBOARD_GRID_ID,
NEW_COMPONENTS_SOURCE_ID,
DASHBOARD_HEADER_ID,
} from '../util/constants';
import componentIsResizable from '../util/componentIsResizable';
import findParentId from '../util/findParentId';
@ -39,6 +40,7 @@ import {
MOVE_COMPONENT,
CREATE_TOP_LEVEL_TABS,
DELETE_TOP_LEVEL_TABS,
DASHBOARD_TITLE_CHANGED,
} from '../actions/dashboardLayout';
const actionHandlers = {
@ -273,6 +275,19 @@ const actionHandlers = {
...nextState,
};
},
[DASHBOARD_TITLE_CHANGED](state, action) {
return {
...state,
[DASHBOARD_HEADER_ID]: {
...state[DASHBOARD_HEADER_ID],
meta: {
...state[DASHBOARD_HEADER_ID].meta,
text: action.text,
},
},
};
},
};
export default function layoutReducer(state = {}, action) {

View File

@ -19,6 +19,7 @@
import { combineReducers } from 'redux';
import charts from '../../chart/chartReducer';
import dashboardInfo from './dashboardInfo';
import dashboardState from './dashboardState';
import dashboardFilters from './dashboardFilters';
import datasources from './datasources';
@ -26,7 +27,6 @@ import sliceEntities from './sliceEntities';
import dashboardLayout from '../reducers/undoableDashboardLayout';
import messageToasts from '../../messageToasts/reducers';
const dashboardInfo = (state = {}) => state;
const impressionId = (state = '') => state;
export default combineReducers({

View File

@ -41,3 +41,14 @@
padding-left: 8px;
font-size: @font-size-m;
}
.text-button {
outline: none;
border: none;
margin: 0;
padding: 0;
background: none;
text-decoration: none;
font-size: inherit;
font-weight: inherit;
}