mirror of
https://github.com/apache/superset.git
synced 2024-09-12 08:39:45 -04:00
Dashboard refactory (#3581)
Create Chart component for all chart fetching and rendering, and apply redux architecture in dashboard view.
This commit is contained in:
parent
39e502faae
commit
4fa1f0ab17
184
superset/assets/javascripts/chart/Chart.jsx
Normal file
184
superset/assets/javascripts/chart/Chart.jsx
Normal file
@ -0,0 +1,184 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Mustache from 'mustache';
|
||||
|
||||
import { d3format } from '../modules/utils';
|
||||
import ChartBody from './ChartBody';
|
||||
import Loading from '../components/Loading';
|
||||
import StackTraceMessage from '../components/StackTraceMessage';
|
||||
import visMap from '../../visualizations/main';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object,
|
||||
chartKey: PropTypes.string.isRequired,
|
||||
containerId: PropTypes.string.isRequired,
|
||||
datasource: PropTypes.object.isRequired,
|
||||
formData: PropTypes.object.isRequired,
|
||||
height: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
setControlValue: PropTypes.func,
|
||||
timeout: PropTypes.number,
|
||||
vizType: PropTypes.string.isRequired,
|
||||
// state
|
||||
chartAlert: PropTypes.string,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
chartUpdateStartTime: PropTypes.number,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
queryRequest: PropTypes.object,
|
||||
queryResponse: PropTypes.object,
|
||||
lastRendered: PropTypes.number,
|
||||
triggerQuery: PropTypes.bool,
|
||||
// dashboard callbacks
|
||||
addFilter: PropTypes.func,
|
||||
getFilters: PropTypes.func,
|
||||
clearFilter: PropTypes.func,
|
||||
removeFilter: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
addFilter: () => ({}),
|
||||
getFilters: () => ({}),
|
||||
clearFilter: () => ({}),
|
||||
removeFilter: () => ({}),
|
||||
};
|
||||
|
||||
class Chart extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// these properties are used by visualizations
|
||||
this.containerId = props.containerId;
|
||||
this.selector = `#${this.containerId}`;
|
||||
this.formData = props.formData;
|
||||
this.datasource = props.datasource;
|
||||
this.addFilter = this.addFilter.bind(this);
|
||||
this.getFilters = this.getFilters.bind(this);
|
||||
this.clearFilter = this.clearFilter.bind(this);
|
||||
this.removeFilter = this.removeFilter.bind(this);
|
||||
this.height = this.height.bind(this);
|
||||
this.width = this.width.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.runQuery();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.containerId = nextProps.containerId;
|
||||
this.selector = `#${this.containerId}`;
|
||||
this.formData = nextProps.formData;
|
||||
this.datasource = nextProps.datasource;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this.props.queryResponse &&
|
||||
this.props.chartStatus === 'success' &&
|
||||
!this.props.queryResponse.error && (
|
||||
prevProps.queryResponse !== this.props.queryResponse ||
|
||||
prevProps.height !== this.props.height ||
|
||||
prevProps.width !== this.props.width ||
|
||||
prevProps.lastRendered !== this.props.lastRendered)
|
||||
) {
|
||||
this.renderViz();
|
||||
}
|
||||
}
|
||||
|
||||
getFilters() {
|
||||
return this.props.getFilters();
|
||||
}
|
||||
|
||||
addFilter(col, vals, merge = true, refresh = true) {
|
||||
this.props.addFilter(col, vals, merge, refresh);
|
||||
}
|
||||
|
||||
clearFilter() {
|
||||
this.props.clearFilter();
|
||||
}
|
||||
|
||||
removeFilter(col, vals) {
|
||||
this.props.removeFilter(col, vals);
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.setState({
|
||||
errorMsg: null,
|
||||
});
|
||||
}
|
||||
|
||||
width() {
|
||||
return this.props.width || this.container.el.offsetWidth;
|
||||
}
|
||||
|
||||
height() {
|
||||
return this.props.height || this.container.el.offsetHeight;
|
||||
}
|
||||
|
||||
d3format(col, number) {
|
||||
const { datasource } = this.props;
|
||||
const format = (datasource.column_formats && datasource.column_formats[col]) || '0.3s';
|
||||
|
||||
return d3format(format, number);
|
||||
}
|
||||
|
||||
runQuery() {
|
||||
this.props.actions.runQuery(this.props.formData, true,
|
||||
this.props.timeout,
|
||||
this.props.chartKey,
|
||||
);
|
||||
}
|
||||
|
||||
render_template(s) {
|
||||
const context = {
|
||||
width: this.width(),
|
||||
height: this.height(),
|
||||
};
|
||||
return Mustache.render(s, context);
|
||||
}
|
||||
|
||||
renderViz() {
|
||||
const viz = visMap[this.props.vizType];
|
||||
try {
|
||||
viz(this, this.props.queryResponse, this.props.actions.setControlValue);
|
||||
} catch (e) {
|
||||
this.props.actions.chartRenderingFailed(e, this.props.chartKey);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLoading = this.props.chartStatus === 'loading';
|
||||
return (
|
||||
<div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
|
||||
{isLoading &&
|
||||
<Loading size={25} />
|
||||
}
|
||||
|
||||
{this.props.chartAlert &&
|
||||
<StackTraceMessage
|
||||
message={this.props.chartAlert}
|
||||
queryResponse={this.props.queryResponse}
|
||||
/>
|
||||
}
|
||||
|
||||
{!this.props.chartAlert &&
|
||||
<ChartBody
|
||||
containerId={this.containerId}
|
||||
vizType={this.props.formData.viz_type}
|
||||
height={this.height}
|
||||
width={this.width}
|
||||
ref={(inner) => {
|
||||
this.container = inner;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Chart.propTypes = propTypes;
|
||||
Chart.defaultProps = defaultProps;
|
||||
|
||||
export default Chart;
|
54
superset/assets/javascripts/chart/ChartBody.jsx
Normal file
54
superset/assets/javascripts/chart/ChartBody.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import $ from 'jquery';
|
||||
|
||||
const propTypes = {
|
||||
containerId: PropTypes.string.isRequired,
|
||||
vizType: PropTypes.string.isRequired,
|
||||
height: PropTypes.func.isRequired,
|
||||
width: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
class ChartBody extends React.PureComponent {
|
||||
html(data) {
|
||||
this.el.innerHTML = data;
|
||||
}
|
||||
|
||||
css(property, value) {
|
||||
this.el.style[property] = value;
|
||||
}
|
||||
|
||||
get(n) {
|
||||
return $(this.el).get(n);
|
||||
}
|
||||
|
||||
find(classname) {
|
||||
return $(this.el).find(classname);
|
||||
}
|
||||
|
||||
show() {
|
||||
return $(this.el).show();
|
||||
}
|
||||
|
||||
height() {
|
||||
return this.props.height();
|
||||
}
|
||||
|
||||
width() {
|
||||
return this.props.width();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
id={this.props.containerId}
|
||||
className={`slice_container ${this.props.vizType}`}
|
||||
ref={(el) => { this.el = el; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChartBody.propTypes = propTypes;
|
||||
|
||||
export default ChartBody;
|
28
superset/assets/javascripts/chart/ChartContainer.jsx
Normal file
28
superset/assets/javascripts/chart/ChartContainer.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import * as Actions from './chartAction';
|
||||
import Chart from './Chart';
|
||||
|
||||
function mapStateToProps({ charts }, ownProps) {
|
||||
const chart = charts[ownProps.chartKey];
|
||||
return {
|
||||
chartAlert: chart.chartAlert,
|
||||
chartStatus: chart.chartStatus,
|
||||
chartUpdateEndTime: chart.chartUpdateEndTime,
|
||||
chartUpdateStartTime: chart.chartUpdateStartTime,
|
||||
latestQueryFormData: chart.latestQueryFormData,
|
||||
queryResponse: chart.queryResponse,
|
||||
queryRequest: chart.queryRequest,
|
||||
triggerQuery: chart.triggerQuery,
|
||||
triggerRender: chart.triggerRender,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(Actions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Chart);
|
91
superset/assets/javascripts/chart/chartAction.js
Normal file
91
superset/assets/javascripts/chart/chartAction.js
Normal file
@ -0,0 +1,91 @@
|
||||
import { getExploreUrl } from '../explore/exploreUtils';
|
||||
import { t } from '../locales';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
||||
export function chartUpdateStarted(queryRequest, key) {
|
||||
return { type: CHART_UPDATE_STARTED, queryRequest, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
|
||||
export function chartUpdateSucceeded(queryResponse, key) {
|
||||
return { type: CHART_UPDATE_SUCCEEDED, queryResponse, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED';
|
||||
export function chartUpdateStopped(queryRequest, key) {
|
||||
if (queryRequest) {
|
||||
queryRequest.abort();
|
||||
}
|
||||
return { type: CHART_UPDATE_STOPPED, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_TIMEOUT = 'CHART_UPDATE_TIMEOUT';
|
||||
export function chartUpdateTimeout(statusText, timeout, key) {
|
||||
return { type: CHART_UPDATE_TIMEOUT, statusText, timeout, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
|
||||
export function chartUpdateFailed(queryResponse, key) {
|
||||
return { type: CHART_UPDATE_FAILED, queryResponse, key };
|
||||
}
|
||||
|
||||
export const CHART_RENDERING_FAILED = 'CHART_RENDERING_FAILED';
|
||||
export function chartRenderingFailed(error, key) {
|
||||
return { type: CHART_RENDERING_FAILED, error, key };
|
||||
}
|
||||
|
||||
export const REMOVE_CHART = 'REMOVE_CHART';
|
||||
export function removeChart(key) {
|
||||
return { type: REMOVE_CHART, key };
|
||||
}
|
||||
|
||||
export const TRIGGER_QUERY = 'TRIGGER_QUERY';
|
||||
export function triggerQuery(value = true, key) {
|
||||
return { type: TRIGGER_QUERY, value, key };
|
||||
}
|
||||
|
||||
// this action is used for forced re-render without fetch data
|
||||
export const RENDER_TRIGGERED = 'RENDER_TRIGGERED';
|
||||
export function renderTriggered(value, key) {
|
||||
return { type: RENDER_TRIGGERED, value, key };
|
||||
}
|
||||
|
||||
export const RUN_QUERY = 'RUN_QUERY';
|
||||
export function runQuery(formData, force = false, timeout = 60, key) {
|
||||
return (dispatch) => {
|
||||
const url = getExploreUrl(formData, 'json', force);
|
||||
const queryRequest = $.ajax({
|
||||
url,
|
||||
dataType: 'json',
|
||||
timeout: timeout * 1000,
|
||||
success: (queryResponse =>
|
||||
dispatch(chartUpdateSucceeded(queryResponse, key))
|
||||
),
|
||||
error: ((xhr) => {
|
||||
if (xhr.statusText === 'timeout') {
|
||||
dispatch(chartUpdateTimeout(xhr.statusText, timeout, key));
|
||||
} else {
|
||||
let error = '';
|
||||
if (!xhr.responseText) {
|
||||
const status = xhr.status;
|
||||
if (status === 0) {
|
||||
// This may happen when the worker in gunicorn times out
|
||||
error += (
|
||||
t('The server could not be reached. You may want to ' +
|
||||
'verify your connection and try again.'));
|
||||
} else {
|
||||
error += (t('An unknown error occurred. (Status: %s )', status));
|
||||
}
|
||||
}
|
||||
const errorResponse = Object.assign({}, xhr.responseJSON, error);
|
||||
dispatch(chartUpdateFailed(errorResponse, key));
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
dispatch(chartUpdateStarted(queryRequest, key));
|
||||
dispatch(triggerQuery(false, key));
|
||||
};
|
||||
}
|
100
superset/assets/javascripts/chart/chartReducer.js
Normal file
100
superset/assets/javascripts/chart/chartReducer.js
Normal file
@ -0,0 +1,100 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { now } from '../modules/dates';
|
||||
import * as actions from './chartAction';
|
||||
import { t } from '../locales';
|
||||
|
||||
export const chartPropType = {
|
||||
chartKey: PropTypes.string.isRequired,
|
||||
chartAlert: PropTypes.string,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
chartUpdateStartTime: PropTypes.number,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
queryResponse: PropTypes.object,
|
||||
triggerQuery: PropTypes.bool,
|
||||
lastRendered: PropTypes.number,
|
||||
};
|
||||
|
||||
export const chart = {
|
||||
chartKey: '',
|
||||
chartAlert: null,
|
||||
chartStatus: null,
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
latestQueryFormData: null,
|
||||
queryResponse: null,
|
||||
triggerQuery: true,
|
||||
lastRendered: 0,
|
||||
};
|
||||
|
||||
export default function chartReducer(charts = {}, action) {
|
||||
const actionHandlers = {
|
||||
[actions.CHART_UPDATE_SUCCEEDED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'success',
|
||||
queryResponse: action.queryResponse,
|
||||
chartUpdateEndTime: now(),
|
||||
};
|
||||
},
|
||||
[actions.CHART_UPDATE_STARTED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'loading',
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
queryRequest: action.queryRequest,
|
||||
};
|
||||
},
|
||||
[actions.CHART_UPDATE_STOPPED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'stopped',
|
||||
chartAlert: t('Updating chart was stopped'),
|
||||
};
|
||||
},
|
||||
[actions.CHART_RENDERING_FAILED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'failed',
|
||||
chartAlert: t('An error occurred while rendering the visualization: %s', action.error),
|
||||
};
|
||||
},
|
||||
[actions.CHART_UPDATE_TIMEOUT](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'failed',
|
||||
chartAlert: (
|
||||
"<strong>{t('Query timeout')}</strong> - " +
|
||||
t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
|
||||
t('Perhaps your data has grown, your database is under unusual load, ' +
|
||||
'or you are simply querying a data source that is too large ' +
|
||||
'to be processed within the timeout range. ' +
|
||||
'If that is the case, we recommend that you summarize your data further.')),
|
||||
};
|
||||
},
|
||||
[actions.CHART_UPDATE_FAILED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'failed',
|
||||
chartAlert: action.queryResponse ? action.queryResponse.error : t('Network error.'),
|
||||
chartUpdateEndTime: now(),
|
||||
queryResponse: action.queryResponse,
|
||||
};
|
||||
},
|
||||
[actions.TRIGGER_QUERY](state) {
|
||||
return { ...state, triggerQuery: action.value };
|
||||
},
|
||||
[actions.RENDER_TRIGGERED](state) {
|
||||
return { ...state, lastRendered: action.value };
|
||||
},
|
||||
};
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
if (action.type === actions.REMOVE_CHART) {
|
||||
delete charts[action.key];
|
||||
return charts;
|
||||
}
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
return { ...charts, [action.key]: actionHandlers[action.type](charts[action.key], action) };
|
||||
}
|
||||
|
||||
return charts;
|
||||
}
|
@ -5,19 +5,20 @@ import TooltipWrapper from './TooltipWrapper';
|
||||
import { t } from '../locales';
|
||||
|
||||
const propTypes = {
|
||||
sliceId: PropTypes.number.isRequired,
|
||||
actions: PropTypes.object.isRequired,
|
||||
itemId: PropTypes.number.isRequired,
|
||||
fetchFaveStar: PropTypes.func,
|
||||
saveFaveStar: PropTypes.func,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default class FaveStar extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.actions.fetchFaveStar(this.props.sliceId);
|
||||
this.props.fetchFaveStar(this.props.itemId);
|
||||
}
|
||||
|
||||
onClick(e) {
|
||||
e.preventDefault();
|
||||
this.props.actions.saveFaveStar(this.props.sliceId, this.props.isStarred);
|
||||
this.props.saveFaveStar(this.props.itemId, this.props.isStarred);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
59
superset/assets/javascripts/components/StackTraceMessage.jsx
Normal file
59
superset/assets/javascripts/components/StackTraceMessage.jsx
Normal file
@ -0,0 +1,59 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Collapse } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
message: PropTypes.string,
|
||||
queryResponse: PropTypes.object,
|
||||
showStackTrace: PropTypes.bool,
|
||||
};
|
||||
const defaultProps = {
|
||||
showStackTrace: false,
|
||||
};
|
||||
|
||||
class StackTraceMessage extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showStackTrace: props.showStackTrace,
|
||||
};
|
||||
}
|
||||
|
||||
hasTrace() {
|
||||
return this.props.queryResponse && this.props.queryResponse.stacktrace;
|
||||
}
|
||||
|
||||
render() {
|
||||
const msg = (
|
||||
<div>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{ __html: this.props.message }}
|
||||
/>
|
||||
</div>);
|
||||
|
||||
return (
|
||||
<div className={`stack-trace-container${this.hasTrace() ? ' has-trace' : ''}`}>
|
||||
<Alert
|
||||
bsStyle="warning"
|
||||
onClick={() => this.setState({ showStackTrace: !this.state.showStackTrace })}
|
||||
>
|
||||
{msg}
|
||||
</Alert>
|
||||
{this.hasTrace() &&
|
||||
<Collapse in={this.state.showStackTrace}>
|
||||
<pre>
|
||||
{this.props.queryResponse.stacktrace}
|
||||
</pre>
|
||||
</Collapse>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StackTraceMessage.propTypes = propTypes;
|
||||
StackTraceMessage.defaultProps = defaultProps;
|
||||
|
||||
export default StackTraceMessage;
|
@ -283,7 +283,7 @@ export function dashboardContainer(dashboard, datasources, userid) {
|
||||
const refreshAll = () => {
|
||||
const slices = dash.sliceObjects
|
||||
.filter(slice => immune.indexOf(slice.data.slice_id) === -1);
|
||||
dash.renderSlices(slices, true, interval * 0.2);
|
||||
dash.fetchSlices(slices, true, interval * 0.2);
|
||||
};
|
||||
const fetchAndRender = function () {
|
||||
refreshAll();
|
||||
@ -375,7 +375,7 @@ $(document).ready(() => {
|
||||
|
||||
const state = getInitialState(dashboardData);
|
||||
px = superset(state);
|
||||
const dashboard = dashboardContainer(state.dashboard, state.datasources, state.user_id);
|
||||
const dashboard = dashboardContainer(state.dashboard, state.datasources, state.userId);
|
||||
initDashboardView(dashboard);
|
||||
dashboard.init();
|
||||
});
|
||||
|
112
superset/assets/javascripts/dashboard/actions.js
Normal file
112
superset/assets/javascripts/dashboard/actions.js
Normal file
@ -0,0 +1,112 @@
|
||||
/* global notify */
|
||||
import $ from 'jquery';
|
||||
import { getExploreUrl } from '../explore/exploreUtils';
|
||||
|
||||
export const ADD_FILTER = 'ADD_FILTER';
|
||||
export function addFilter(sliceId, col, vals, merge = true, refresh = true) {
|
||||
return { type: ADD_FILTER, sliceId, col, vals, merge, refresh };
|
||||
}
|
||||
|
||||
export const CLEAR_FILTER = 'CLEAR_FILTER';
|
||||
export function clearFilter(sliceId) {
|
||||
return { type: CLEAR_FILTER, sliceId };
|
||||
}
|
||||
|
||||
export const REMOVE_FILTER = 'REMOVE_FILTER';
|
||||
export function removeFilter(sliceId, col, vals) {
|
||||
return { type: REMOVE_FILTER, sliceId, col, vals };
|
||||
}
|
||||
|
||||
export const UPDATE_DASHBOARD_LAYOUT = 'UPDATE_DASHBOARD_LAYOUT';
|
||||
export function updateDashboardLayout(layout) {
|
||||
return { type: UPDATE_DASHBOARD_LAYOUT, layout };
|
||||
}
|
||||
|
||||
export const UPDATE_DASHBOARD_TITLE = 'UPDATE_DASHBOARD_TITLE';
|
||||
export function updateDashboardTitle(title) {
|
||||
return { type: UPDATE_DASHBOARD_TITLE, title };
|
||||
}
|
||||
|
||||
export function addSlicesToDashboard(dashboardId, sliceIds) {
|
||||
return () => (
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `/superset/add_slices/${dashboardId}/`,
|
||||
data: {
|
||||
data: JSON.stringify({ slice_ids: sliceIds }),
|
||||
},
|
||||
})
|
||||
.done(() => {
|
||||
// Refresh page to allow for slices to re-render
|
||||
window.location.reload();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export const REMOVE_SLICE = 'REMOVE_SLICE';
|
||||
export function removeSlice(slice) {
|
||||
return { type: REMOVE_SLICE, slice };
|
||||
}
|
||||
|
||||
export const UPDATE_SLICE_NAME = 'UPDATE_SLICE_NAME';
|
||||
export function updateSliceName(slice, sliceName) {
|
||||
return { type: UPDATE_SLICE_NAME, slice, sliceName };
|
||||
}
|
||||
export function saveSlice(slice, sliceName) {
|
||||
const oldName = slice.slice_name;
|
||||
return (dispatch) => {
|
||||
const sliceParams = {};
|
||||
sliceParams.slice_id = slice.slice_id;
|
||||
sliceParams.action = 'overwrite';
|
||||
sliceParams.slice_name = sliceName;
|
||||
const saveUrl = getExploreUrl(slice.form_data, 'base', false, null, sliceParams);
|
||||
return $.ajax({
|
||||
url: saveUrl,
|
||||
type: 'GET',
|
||||
success: () => {
|
||||
dispatch(updateSliceName(slice, sliceName));
|
||||
notify.success('This slice name was saved successfully.');
|
||||
},
|
||||
error: () => {
|
||||
// if server-side reject the overwrite action,
|
||||
// revert to old state
|
||||
dispatch(updateSliceName(slice, oldName));
|
||||
notify.error("You don't have the rights to alter this slice");
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
|
||||
export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
|
||||
export function toggleFaveStar(isStarred) {
|
||||
return { type: TOGGLE_FAVE_STAR, isStarred };
|
||||
}
|
||||
|
||||
export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
|
||||
export function fetchFaveStar(id) {
|
||||
return function (dispatch) {
|
||||
const url = `${FAVESTAR_BASE_URL}/${id}/count`;
|
||||
return $.get(url)
|
||||
.done((data) => {
|
||||
if (data.count > 0) {
|
||||
dispatch(toggleFaveStar(true));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
|
||||
export function saveFaveStar(id, isStarred) {
|
||||
return function (dispatch) {
|
||||
const urlSuffix = isStarred ? 'unselect' : 'select';
|
||||
const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
|
||||
$.get(url);
|
||||
dispatch(toggleFaveStar(!isStarred));
|
||||
};
|
||||
}
|
||||
|
||||
export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
|
||||
export function toggleExpandSlice(slice, isExpanded) {
|
||||
return { type: TOGGLE_EXPAND_SLICE, slice, isExpanded };
|
||||
}
|
@ -14,6 +14,15 @@ const $ = window.$ = require('jquery');
|
||||
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
slices: PropTypes.array,
|
||||
userId: PropTypes.string.isRequired,
|
||||
addSlicesToDashboard: PropTypes.func,
|
||||
onSave: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
readFilters: PropTypes.func,
|
||||
renderSlices: PropTypes.func,
|
||||
serialize: PropTypes.func,
|
||||
startPeriodicRender: PropTypes.func,
|
||||
};
|
||||
|
||||
class Controls extends React.PureComponent {
|
||||
@ -36,14 +45,16 @@ class Controls extends React.PureComponent {
|
||||
}
|
||||
refresh() {
|
||||
// Force refresh all slices
|
||||
this.props.dashboard.renderSlices(this.props.dashboard.sliceObjects, true);
|
||||
this.props.renderSlices(true);
|
||||
}
|
||||
changeCss(css) {
|
||||
this.setState({ css });
|
||||
this.props.dashboard.onChange();
|
||||
this.props.onChange();
|
||||
}
|
||||
render() {
|
||||
const dashboard = this.props.dashboard;
|
||||
const { dashboard, userId,
|
||||
addSlicesToDashboard, startPeriodicRender, readFilters,
|
||||
serialize, onSave } = this.props;
|
||||
const emailBody = t('Checkout this dashboard: %s', window.location.href);
|
||||
const emailLink = 'mailto:?Subject=Superset%20Dashboard%20'
|
||||
+ `${dashboard.dashboard_title}&Body=${emailBody}`;
|
||||
@ -57,18 +68,20 @@ class Controls extends React.PureComponent {
|
||||
</Button>
|
||||
<SliceAdder
|
||||
dashboard={dashboard}
|
||||
addSlicesToDashboard={addSlicesToDashboard}
|
||||
userId={userId}
|
||||
triggerNode={
|
||||
<i className="fa fa-plus" />
|
||||
}
|
||||
/>
|
||||
<RefreshIntervalModal
|
||||
onChange={refreshInterval => dashboard.startPeriodicRender(refreshInterval * 1000)}
|
||||
onChange={refreshInterval => startPeriodicRender(refreshInterval * 1000)}
|
||||
triggerNode={
|
||||
<i className="fa fa-clock-o" />
|
||||
}
|
||||
/>
|
||||
<CodeModal
|
||||
codeCallback={dashboard.readFilters.bind(dashboard)}
|
||||
codeCallback={readFilters}
|
||||
triggerNode={<i className="fa fa-filter" />}
|
||||
/>
|
||||
<CssEditor
|
||||
@ -96,6 +109,9 @@ class Controls extends React.PureComponent {
|
||||
</Button>
|
||||
<SaveModal
|
||||
dashboard={dashboard}
|
||||
readFilters={readFilters}
|
||||
serialize={serialize}
|
||||
onSave={onSave}
|
||||
css={this.state.css}
|
||||
triggerNode={
|
||||
<Button disabled={!dashboard.dash_save_perm}>
|
||||
|
349
superset/assets/javascripts/dashboard/components/Dashboard.jsx
Normal file
349
superset/assets/javascripts/dashboard/components/Dashboard.jsx
Normal file
@ -0,0 +1,349 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import AlertsWrapper from '../../components/AlertsWrapper';
|
||||
import GridLayout from './GridLayout';
|
||||
import Header from './Header';
|
||||
import DashboardAlert from './DashboardAlert';
|
||||
import { getExploreUrl } from '../../explore/exploreUtils';
|
||||
import { areObjectsEqual } from '../../reduxUtils';
|
||||
import { t } from '../../locales';
|
||||
|
||||
import '../../../stylesheets/dashboard.css';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object,
|
||||
initMessages: PropTypes.array,
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
slices: PropTypes.object,
|
||||
datasources: PropTypes.object,
|
||||
filters: PropTypes.object,
|
||||
refresh: PropTypes.bool,
|
||||
timeout: PropTypes.number,
|
||||
userId: PropTypes.string,
|
||||
isStarred: PropTypes.bool,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
initMessages: [],
|
||||
dashboard: {},
|
||||
slices: {},
|
||||
datasources: {},
|
||||
filters: {},
|
||||
timeout: 60,
|
||||
userId: '',
|
||||
isStarred: false,
|
||||
};
|
||||
|
||||
class Dashboard extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.refreshTimer = null;
|
||||
this.firstLoad = true;
|
||||
|
||||
// alert for unsaved changes
|
||||
this.state = {
|
||||
alert: null,
|
||||
trigger: false,
|
||||
};
|
||||
|
||||
this.rerenderCharts = this.rerenderCharts.bind(this);
|
||||
this.updateDashboardTitle = this.updateDashboardTitle.bind(this);
|
||||
this.onSave = this.onSave.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.serialize = this.serialize.bind(this);
|
||||
this.readFilters = this.readFilters.bind(this);
|
||||
this.fetchAllSlices = this.fetchSlices.bind(this, this.getAllSlices());
|
||||
this.startPeriodicRender = this.startPeriodicRender.bind(this);
|
||||
this.addSlicesToDashboard = this.addSlicesToDashboard.bind(this);
|
||||
this.fetchSlice = this.fetchSlice.bind(this);
|
||||
this.getFormDataExtra = this.getFormDataExtra.bind(this);
|
||||
this.props.actions.fetchFaveStar = this.props.actions.fetchFaveStar.bind(this);
|
||||
this.props.actions.saveFaveStar = this.props.actions.saveFaveStar.bind(this);
|
||||
this.props.actions.saveSlice = this.props.actions.saveSlice.bind(this);
|
||||
this.props.actions.removeSlice = this.props.actions.removeSlice.bind(this);
|
||||
this.props.actions.removeChart = this.props.actions.removeChart.bind(this);
|
||||
this.props.actions.updateDashboardLayout = this.props.actions.updateDashboardLayout.bind(this);
|
||||
this.props.actions.toggleExpandSlice = this.props.actions.toggleExpandSlice.bind(this);
|
||||
this.props.actions.addFilter = this.props.actions.addFilter.bind(this);
|
||||
this.props.actions.clearFilter = this.props.actions.clearFilter.bind(this);
|
||||
this.props.actions.removeFilter = this.props.actions.removeFilter.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadPreSelectFilters();
|
||||
this.firstLoad = false;
|
||||
window.addEventListener('resize', this.rerenderCharts);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// check filters is changed
|
||||
if (!areObjectsEqual(nextProps.filters, this.props.filters)) {
|
||||
this.renderUnsavedChangeAlert();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!areObjectsEqual(prevProps.filters, this.props.filters) && this.props.refresh) {
|
||||
Object.keys(this.props.filters).forEach(sliceId => (this.refreshExcept(sliceId)));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.rerenderCharts);
|
||||
}
|
||||
|
||||
onBeforeUnload(hasChanged) {
|
||||
if (hasChanged) {
|
||||
window.addEventListener('beforeunload', this.unload);
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', this.unload);
|
||||
}
|
||||
}
|
||||
|
||||
onChange() {
|
||||
this.onBeforeUnload(true);
|
||||
this.renderUnsavedChangeAlert();
|
||||
}
|
||||
|
||||
onSave() {
|
||||
this.onBeforeUnload(false);
|
||||
this.setState({
|
||||
alert: '',
|
||||
});
|
||||
}
|
||||
|
||||
// return charts in array
|
||||
getAllSlices() {
|
||||
return Object.values(this.props.slices);
|
||||
}
|
||||
|
||||
getFormDataExtra(slice) {
|
||||
const formDataExtra = Object.assign({}, slice.formData);
|
||||
const extraFilters = this.effectiveExtraFilters(slice.slice_id);
|
||||
formDataExtra.filters = formDataExtra.filters.concat(extraFilters);
|
||||
return formDataExtra;
|
||||
}
|
||||
|
||||
getFilters(sliceId) {
|
||||
return this.props.filters[sliceId];
|
||||
}
|
||||
|
||||
unload() {
|
||||
const message = t('You have unsaved changes.');
|
||||
window.event.returnValue = message; // Gecko + IE
|
||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||
}
|
||||
|
||||
effectiveExtraFilters(sliceId) {
|
||||
const metadata = this.props.dashboard.metadata;
|
||||
const filters = this.props.filters;
|
||||
const f = [];
|
||||
const immuneSlices = metadata.filter_immune_slices || [];
|
||||
if (sliceId && immuneSlices.includes(sliceId)) {
|
||||
// The slice is immune to dashboard filters
|
||||
return f;
|
||||
}
|
||||
|
||||
// Building a list of fields the slice is immune to filters on
|
||||
let immuneToFields = [];
|
||||
if (
|
||||
sliceId &&
|
||||
metadata.filter_immune_slice_fields &&
|
||||
metadata.filter_immune_slice_fields[sliceId]) {
|
||||
immuneToFields = metadata.filter_immune_slice_fields[sliceId];
|
||||
}
|
||||
for (const filteringSliceId in filters) {
|
||||
if (filteringSliceId === sliceId.toString()) {
|
||||
// Filters applied by the slice don't apply to itself
|
||||
continue;
|
||||
}
|
||||
for (const field in filters[filteringSliceId]) {
|
||||
if (!immuneToFields.includes(field)) {
|
||||
f.push({
|
||||
col: field,
|
||||
op: 'in',
|
||||
val: filters[filteringSliceId][field],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
jsonEndpoint(data, force = false) {
|
||||
let endpoint = getExploreUrl(data, 'json', force);
|
||||
if (endpoint.charAt(0) !== '/') {
|
||||
// Known issue for IE <= 11:
|
||||
// https://connect.microsoft.com/IE/feedbackdetail/view/1002846/pathname-incorrect-for-out-of-document-elements
|
||||
endpoint = '/' + endpoint;
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
loadPreSelectFilters() {
|
||||
for (const key in this.props.filters) {
|
||||
for (const col in this.props.filters[key]) {
|
||||
const sliceId = parseInt(key, 10);
|
||||
this.props.actions.addFilter(sliceId, col,
|
||||
this.props.filters[key][col], false, false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshExcept(sliceId) {
|
||||
const immune = this.props.dashboard.metadata.filter_immune_slices || [];
|
||||
const slices = this.getAllSlices()
|
||||
.filter(slice => slice.slice_id !== sliceId && immune.indexOf(slice.slice_id) === -1);
|
||||
this.fetchSlices(slices);
|
||||
}
|
||||
|
||||
stopPeriodicRender() {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
startPeriodicRender(interval) {
|
||||
this.stopPeriodicRender();
|
||||
const immune = this.props.dashboard.metadata.timed_refresh_immune_slices || [];
|
||||
const refreshAll = () => {
|
||||
const affectedSlices = this.getAllSlices()
|
||||
.filter(slice => immune.indexOf(slice.slice_id) === -1);
|
||||
this.fetchSlices(affectedSlices, true, interval * 0.2);
|
||||
};
|
||||
const fetchAndRender = () => {
|
||||
refreshAll();
|
||||
if (interval > 0) {
|
||||
this.refreshTimer = setTimeout(fetchAndRender, interval);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndRender();
|
||||
}
|
||||
|
||||
readFilters() {
|
||||
// Returns a list of human readable active filters
|
||||
return JSON.stringify(this.props.filters, null, ' ');
|
||||
}
|
||||
|
||||
updateDashboardTitle(title) {
|
||||
this.props.actions.updateDashboardTitle(title);
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.props.dashboard.layout.map(reactPos => ({
|
||||
slice_id: reactPos.i,
|
||||
col: reactPos.x + 1,
|
||||
row: reactPos.y,
|
||||
size_x: reactPos.w,
|
||||
size_y: reactPos.h,
|
||||
}));
|
||||
}
|
||||
|
||||
addSlicesToDashboard(sliceIds) {
|
||||
return this.props.actions.addSlicesToDashboard(this.props.dashboard.id, sliceIds);
|
||||
}
|
||||
|
||||
fetchSlice(slice, force = false) {
|
||||
return this.props.actions.runQuery(
|
||||
this.getFormDataExtra(slice), force, this.props.timeout, slice.chartKey,
|
||||
);
|
||||
}
|
||||
|
||||
// fetch and render an list of slices
|
||||
fetchSlices(slc, force = false, interval = 0) {
|
||||
const slices = slc || this.getAllSlices();
|
||||
if (!interval) {
|
||||
slices.forEach((slice) => { this.fetchSlice(slice, force); });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = this.props.dashboard.metadata;
|
||||
const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
|
||||
if (typeof meta.stagger_refresh !== 'boolean') {
|
||||
meta.stagger_refresh = meta.stagger_refresh === undefined ?
|
||||
true : meta.stagger_refresh === 'true';
|
||||
}
|
||||
const delay = meta.stagger_refresh ? refreshTime / (slices.length - 1) : 0;
|
||||
slices.forEach((slice, i) => {
|
||||
setTimeout(() => { this.fetchSlice(slice, force); }, delay * i);
|
||||
});
|
||||
}
|
||||
|
||||
// re-render chart without fetch
|
||||
rerenderCharts() {
|
||||
this.getAllSlices().forEach((slice) => {
|
||||
setTimeout(() => {
|
||||
this.props.actions.renderTriggered(new Date().getTime(), slice.chartKey);
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
renderUnsavedChangeAlert() {
|
||||
this.setState({
|
||||
alert: (
|
||||
<span>
|
||||
<strong>{t('You have unsaved changes.')}</strong> {t('Click the')}
|
||||
<i className="fa fa-save" />
|
||||
{t('button on the top right to save your changes.')}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="dashboard-container">
|
||||
{this.state.alert && <DashboardAlert alertContent={this.state.alert} />}
|
||||
<div id="dashboard-header">
|
||||
<AlertsWrapper initMessages={this.props.initMessages} />
|
||||
<Header
|
||||
dashboard={this.props.dashboard}
|
||||
userId={this.props.userId}
|
||||
isStarred={this.props.isStarred}
|
||||
updateDashboardTitle={this.updateDashboardTitle}
|
||||
onSave={this.onSave}
|
||||
onChange={this.onChange}
|
||||
serialize={this.serialize}
|
||||
readFilters={this.readFilters}
|
||||
fetchFaveStar={this.props.actions.fetchFaveStar}
|
||||
saveFaveStar={this.props.actions.saveFaveStar}
|
||||
renderSlices={this.fetchAllSlices}
|
||||
startPeriodicRender={this.startPeriodicRender}
|
||||
addSlicesToDashboard={this.addSlicesToDashboard}
|
||||
/>
|
||||
</div>
|
||||
<div id="grid-container" className="slice-grid gridster">
|
||||
<GridLayout
|
||||
dashboard={this.props.dashboard}
|
||||
datasources={this.props.datasources}
|
||||
filters={this.props.filters}
|
||||
charts={this.props.slices}
|
||||
timeout={this.props.timeout}
|
||||
onChange={this.onChange}
|
||||
getFormDataExtra={this.getFormDataExtra}
|
||||
fetchSlice={this.fetchSlice}
|
||||
saveSlice={this.props.actions.saveSlice}
|
||||
removeSlice={this.props.actions.removeSlice}
|
||||
removeChart={this.props.actions.removeChart}
|
||||
updateDashboardLayout={this.props.actions.updateDashboardLayout}
|
||||
toggleExpandSlice={this.props.actions.toggleExpandSlice}
|
||||
addFilter={this.props.actions.addFilter}
|
||||
getFilters={this.getFilters}
|
||||
clearFilter={this.props.actions.clearFilter}
|
||||
removeFilter={this.props.actions.removeFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Dashboard.propTypes = propTypes;
|
||||
Dashboard.defaultProps = defaultProps;
|
||||
|
||||
export default Dashboard;
|
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
alertContent: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const DashboardAlert = ({ alertContent }) => (
|
||||
<div id="alert-container">
|
||||
<div className="container-fluid">
|
||||
<Alert bsStyle="warning">
|
||||
{alertContent}
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
DashboardAlert.propTypes = propTypes;
|
||||
|
||||
export default DashboardAlert;
|
@ -0,0 +1,29 @@
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as dashboardActions from '../actions';
|
||||
import * as chartActions from '../../chart/chartAction';
|
||||
import Dashboard from './Dashboard';
|
||||
|
||||
function mapStateToProps({ charts, dashboard }) {
|
||||
return {
|
||||
initMessages: dashboard.common.flash_messages,
|
||||
timeout: dashboard.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
|
||||
dashboard: dashboard.dashboard,
|
||||
slices: charts,
|
||||
datasources: dashboard.datasources,
|
||||
filters: dashboard.filters,
|
||||
refresh: dashboard.refresh,
|
||||
userId: dashboard.userId,
|
||||
isStarred: !!dashboard.isStarred,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
const actions = { ...chartActions, ...dashboardActions };
|
||||
return {
|
||||
actions: bindActionCreators(actions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Dashboard);
|
132
superset/assets/javascripts/dashboard/components/GridCell.jsx
Normal file
132
superset/assets/javascripts/dashboard/components/GridCell.jsx
Normal file
@ -0,0 +1,132 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import SliceHeader from './SliceHeader';
|
||||
import ChartContainer from '../../chart/ChartContainer';
|
||||
|
||||
import '../../../stylesheets/dashboard.css';
|
||||
|
||||
const propTypes = {
|
||||
timeout: PropTypes.number,
|
||||
datasource: PropTypes.object,
|
||||
isLoading: PropTypes.bool,
|
||||
isExpanded: PropTypes.bool,
|
||||
widgetHeight: PropTypes.number,
|
||||
widgetWidth: PropTypes.number,
|
||||
exploreChartUrl: PropTypes.string,
|
||||
exportCSVUrl: PropTypes.string,
|
||||
slice: PropTypes.object,
|
||||
chartKey: PropTypes.string,
|
||||
formData: PropTypes.object,
|
||||
filters: PropTypes.object,
|
||||
forceRefresh: PropTypes.func,
|
||||
removeSlice: PropTypes.func,
|
||||
updateSliceName: PropTypes.func,
|
||||
toggleExpandSlice: PropTypes.func,
|
||||
addFilter: PropTypes.func,
|
||||
getFilters: PropTypes.func,
|
||||
clearFilter: PropTypes.func,
|
||||
removeFilter: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
forceRefresh: () => ({}),
|
||||
removeSlice: () => ({}),
|
||||
updateSliceName: () => ({}),
|
||||
toggleExpandSlice: () => ({}),
|
||||
addFilter: () => ({}),
|
||||
getFilters: () => ({}),
|
||||
clearFilter: () => ({}),
|
||||
removeFilter: () => ({}),
|
||||
};
|
||||
|
||||
class GridCell extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const sliceId = this.props.slice.slice_id;
|
||||
this.addFilter = this.props.addFilter.bind(this, sliceId);
|
||||
this.getFilters = this.props.getFilters.bind(this, sliceId);
|
||||
this.clearFilter = this.props.clearFilter.bind(this, sliceId);
|
||||
this.removeFilter = this.props.removeFilter.bind(this, sliceId);
|
||||
}
|
||||
|
||||
getDescriptionId(slice) {
|
||||
return 'description_' + slice.slice_id;
|
||||
}
|
||||
|
||||
getHeaderId(slice) {
|
||||
return 'header_' + slice.slice_id;
|
||||
}
|
||||
|
||||
width() {
|
||||
return this.props.widgetWidth - 10;
|
||||
}
|
||||
|
||||
height(slice) {
|
||||
const widgetHeight = this.props.widgetHeight;
|
||||
const headerId = this.getHeaderId(slice);
|
||||
const descriptionId = this.getDescriptionId(slice);
|
||||
const headerHeight = this.refs[headerId] ? this.refs[headerId].offsetHeight : 30;
|
||||
let descriptionHeight = 0;
|
||||
if (this.props.isExpanded && this.refs[descriptionId]) {
|
||||
descriptionHeight = this.refs[descriptionId].offsetHeight + 10;
|
||||
}
|
||||
return widgetHeight - headerHeight - descriptionHeight;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
exploreChartUrl, exportCSVUrl, isExpanded, isLoading, removeSlice, updateSliceName,
|
||||
toggleExpandSlice, forceRefresh, chartKey, slice, datasource, formData, timeout,
|
||||
} = this.props;
|
||||
return (
|
||||
<div
|
||||
className={isLoading ? 'slice-cell-highlight' : 'slice-cell'}
|
||||
id={`${slice.slice_id}-cell`}
|
||||
>
|
||||
<div ref={this.getHeaderId(slice)}>
|
||||
<SliceHeader
|
||||
slice={slice}
|
||||
exploreChartUrl={exploreChartUrl}
|
||||
exportCSVUrl={exportCSVUrl}
|
||||
isExpanded={isExpanded}
|
||||
removeSlice={removeSlice}
|
||||
updateSliceName={updateSliceName}
|
||||
toggleExpandSlice={toggleExpandSlice}
|
||||
forceRefresh={forceRefresh}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="slice_description bs-callout bs-callout-default"
|
||||
style={isExpanded ? {} : { display: 'none' }}
|
||||
ref={this.getDescriptionId(slice)}
|
||||
dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
|
||||
/>
|
||||
<div className="row chart-container">
|
||||
<input type="hidden" value="false" />
|
||||
<ChartContainer
|
||||
containerId={`slice-container-${slice.slice_id}`}
|
||||
chartKey={chartKey}
|
||||
datasource={datasource}
|
||||
formData={formData}
|
||||
height={this.height(slice)}
|
||||
width={this.width()}
|
||||
timeout={timeout}
|
||||
vizType={slice.formData.viz_type}
|
||||
addFilter={this.addFilter}
|
||||
getFilters={this.getFilters}
|
||||
clearFilter={this.clearFilter}
|
||||
removeFilter={this.removeFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GridCell.propTypes = propTypes;
|
||||
GridCell.defaultProps = defaultProps;
|
||||
|
||||
export default GridCell;
|
@ -1,10 +1,8 @@
|
||||
/* global notify */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||
import $ from 'jquery';
|
||||
|
||||
import SliceCell from './SliceCell';
|
||||
import GridCell from './GridCell';
|
||||
import { getExploreUrl } from '../../explore/exploreUtils';
|
||||
|
||||
require('react-grid-layout/css/styles.css');
|
||||
@ -14,119 +12,127 @@ const ResponsiveReactGridLayout = WidthProvider(Responsive);
|
||||
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
datasources: PropTypes.object,
|
||||
charts: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object,
|
||||
timeout: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
getFormDataExtra: PropTypes.func,
|
||||
fetchSlice: PropTypes.func,
|
||||
saveSlice: PropTypes.func,
|
||||
removeSlice: PropTypes.func,
|
||||
removeChart: PropTypes.func,
|
||||
updateDashboardLayout: PropTypes.func,
|
||||
toggleExpandSlice: PropTypes.func,
|
||||
addFilter: PropTypes.func,
|
||||
getFilters: PropTypes.func,
|
||||
clearFilter: PropTypes.func,
|
||||
removeFilter: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onChange: () => ({}),
|
||||
getFormDataExtra: () => ({}),
|
||||
fetchSlice: () => ({}),
|
||||
saveSlice: () => ({}),
|
||||
removeSlice: () => ({}),
|
||||
removeChart: () => ({}),
|
||||
updateDashboardLayout: () => ({}),
|
||||
toggleExpandSlice: () => ({}),
|
||||
addFilter: () => ({}),
|
||||
getFilters: () => ({}),
|
||||
clearFilter: () => ({}),
|
||||
removeFilter: () => ({}),
|
||||
};
|
||||
|
||||
class GridLayout extends React.Component {
|
||||
componentWillMount() {
|
||||
const layout = [];
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.props.dashboard.slices.forEach((slice, index) => {
|
||||
const sliceId = slice.slice_id;
|
||||
let pos = this.props.dashboard.posDict[sliceId];
|
||||
if (!pos) {
|
||||
pos = {
|
||||
col: (index * 4 + 1) % 12,
|
||||
row: Math.floor((index) / 3) * 4,
|
||||
size_x: 4,
|
||||
size_y: 4,
|
||||
};
|
||||
}
|
||||
|
||||
layout.push({
|
||||
i: String(sliceId),
|
||||
x: pos.col - 1,
|
||||
y: pos.row,
|
||||
w: pos.size_x,
|
||||
minW: 2,
|
||||
h: pos.size_y,
|
||||
});
|
||||
});
|
||||
|
||||
this.setState({
|
||||
layout,
|
||||
slices: this.props.dashboard.slices,
|
||||
});
|
||||
this.onResizeStop = this.onResizeStop.bind(this);
|
||||
this.onDragStop = this.onDragStop.bind(this);
|
||||
this.forceRefresh = this.forceRefresh.bind(this);
|
||||
this.removeSlice = this.removeSlice.bind(this);
|
||||
this.updateSliceName = this.props.dashboard.dash_edit_perm ?
|
||||
this.updateSliceName.bind(this) : null;
|
||||
}
|
||||
|
||||
onResizeStop(layout, oldItem, newItem) {
|
||||
const newSlice = this.props.dashboard.getSlice(newItem.i);
|
||||
if (oldItem.w !== newItem.w || oldItem.h !== newItem.h) {
|
||||
this.setState({ layout }, () => newSlice.resize());
|
||||
}
|
||||
this.props.dashboard.onChange();
|
||||
onResizeStop(layout) {
|
||||
this.props.updateDashboardLayout(layout);
|
||||
this.props.onChange();
|
||||
}
|
||||
|
||||
onDragStop(layout) {
|
||||
this.setState({ layout });
|
||||
this.props.dashboard.onChange();
|
||||
this.props.updateDashboardLayout(layout);
|
||||
this.props.onChange();
|
||||
}
|
||||
|
||||
removeSlice(sliceId) {
|
||||
$('[data-toggle=tooltip]').tooltip('hide');
|
||||
this.setState({
|
||||
layout: this.state.layout.filter(function (reactPos) {
|
||||
return reactPos.i !== String(sliceId);
|
||||
}),
|
||||
slices: this.state.slices.filter(function (slice) {
|
||||
return slice.slice_id !== sliceId;
|
||||
}),
|
||||
});
|
||||
this.props.dashboard.onChange();
|
||||
getWidgetId(slice) {
|
||||
return 'widget_' + slice.slice_id;
|
||||
}
|
||||
|
||||
getWidgetHeight(slice) {
|
||||
const widgetId = this.getWidgetId(slice);
|
||||
if (!widgetId || !this.refs[widgetId]) {
|
||||
return 400;
|
||||
}
|
||||
return this.refs[widgetId].offsetHeight;
|
||||
}
|
||||
|
||||
getWidgetWidth(slice) {
|
||||
const widgetId = this.getWidgetId(slice);
|
||||
if (!widgetId || !this.refs[widgetId]) {
|
||||
return 400;
|
||||
}
|
||||
return this.refs[widgetId].offsetWidth;
|
||||
}
|
||||
|
||||
findSliceIndexById(sliceId) {
|
||||
return this.props.dashboard.slices
|
||||
.map(slice => (slice.slice_id)).indexOf(sliceId);
|
||||
}
|
||||
|
||||
forceRefresh(sliceId) {
|
||||
return this.props.fetchSlice(this.props.charts['slice_' + sliceId], true);
|
||||
}
|
||||
|
||||
removeSlice(slice) {
|
||||
if (!slice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove slice dashbaord and charts
|
||||
this.props.removeSlice(slice);
|
||||
this.props.removeChart(this.props.charts['slice_' + slice.slice_id].chartKey);
|
||||
this.props.onChange();
|
||||
}
|
||||
|
||||
updateSliceName(sliceId, sliceName) {
|
||||
const index = this.state.slices.map(slice => (slice.slice_id)).indexOf(sliceId);
|
||||
const index = this.findSliceIndexById(sliceId);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update slice_name first
|
||||
const oldSlices = this.state.slices;
|
||||
const currentSlice = this.state.slices[index];
|
||||
const updated = Object.assign({},
|
||||
this.state.slices[index], { slice_name: sliceName });
|
||||
const updatedSlices = this.state.slices.slice();
|
||||
updatedSlices[index] = updated;
|
||||
this.setState({ slices: updatedSlices });
|
||||
const currentSlice = this.props.dashboard.slices[index];
|
||||
if (currentSlice.slice_name === sliceName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sliceParams = {};
|
||||
sliceParams.slice_id = currentSlice.slice_id;
|
||||
sliceParams.action = 'overwrite';
|
||||
sliceParams.slice_name = sliceName;
|
||||
const saveUrl = getExploreUrl(currentSlice.form_data, 'base', false, null, sliceParams);
|
||||
|
||||
$.ajax({
|
||||
url: saveUrl,
|
||||
type: 'GET',
|
||||
success: () => {
|
||||
notify.success('This slice name was saved successfully.');
|
||||
},
|
||||
error: () => {
|
||||
// if server-side reject the overwrite action,
|
||||
// revert to old state
|
||||
this.setState({ slices: oldSlices });
|
||||
notify.error('You don\'t have the rights to alter this slice');
|
||||
},
|
||||
});
|
||||
this.props.saveSlice(currentSlice, sliceName);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.state.layout.map(reactPos => ({
|
||||
slice_id: reactPos.i,
|
||||
col: reactPos.x + 1,
|
||||
row: reactPos.y,
|
||||
size_x: reactPos.w,
|
||||
size_y: reactPos.h,
|
||||
}));
|
||||
isExpanded(slice) {
|
||||
return this.props.dashboard.metadata.expanded_slices &&
|
||||
this.props.dashboard.metadata.expanded_slices[slice.slice_id];
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ResponsiveReactGridLayout
|
||||
className="layout"
|
||||
layouts={{ lg: this.state.layout }}
|
||||
onResizeStop={this.onResizeStop.bind(this)}
|
||||
onDragStop={this.onDragStop.bind(this)}
|
||||
layouts={{ lg: this.props.dashboard.layout }}
|
||||
onResizeStop={this.onResizeStop}
|
||||
onDragStop={this.onDragStop}
|
||||
cols={{ lg: 12, md: 12, sm: 10, xs: 8, xxs: 6 }}
|
||||
rowHeight={100}
|
||||
autoSize
|
||||
@ -134,19 +140,36 @@ class GridLayout extends React.Component {
|
||||
useCSSTransforms
|
||||
draggableHandle=".drag"
|
||||
>
|
||||
{this.state.slices.map(slice => (
|
||||
{this.props.dashboard.slices.map(slice => (
|
||||
<div
|
||||
id={'slice_' + slice.slice_id}
|
||||
key={slice.slice_id}
|
||||
data-slice-id={slice.slice_id}
|
||||
className={`widget ${slice.form_data.viz_type}`}
|
||||
ref={this.getWidgetId(slice)}
|
||||
>
|
||||
<SliceCell
|
||||
<GridCell
|
||||
slice={slice}
|
||||
removeSlice={this.removeSlice.bind(this, slice.slice_id)}
|
||||
expandedSlices={this.props.dashboard.metadata.expanded_slices}
|
||||
updateSliceName={this.props.dashboard.dash_edit_perm ?
|
||||
this.updateSliceName.bind(this) : null}
|
||||
chartKey={'slice_' + slice.slice_id}
|
||||
datasource={this.props.datasources[slice.form_data.datasource]}
|
||||
filters={this.props.filters}
|
||||
formData={this.props.getFormDataExtra(slice)}
|
||||
timeout={this.props.timeout}
|
||||
widgetHeight={this.getWidgetHeight(slice)}
|
||||
widgetWidth={this.getWidgetWidth(slice)}
|
||||
exploreChartUrl={getExploreUrl(this.props.getFormDataExtra(slice))}
|
||||
exportCSVUrl={getExploreUrl(this.props.getFormDataExtra(slice), 'csv')}
|
||||
isExpanded={!!this.isExpanded(slice)}
|
||||
isLoading={[undefined, 'loading']
|
||||
.indexOf(this.props.charts['slice_' + slice.slice_id].chartStatus) !== -1}
|
||||
toggleExpandSlice={this.props.toggleExpandSlice}
|
||||
forceRefresh={this.forceRefresh}
|
||||
removeSlice={this.removeSlice}
|
||||
updateSliceName={this.updateSliceName}
|
||||
addFilter={this.props.addFilter}
|
||||
getFilters={this.props.getFilters}
|
||||
clearFilter={this.props.clearFilter}
|
||||
removeFilter={this.props.removeFilter}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -156,5 +179,6 @@ class GridLayout extends React.Component {
|
||||
}
|
||||
|
||||
GridLayout.propTypes = propTypes;
|
||||
GridLayout.defaultProps = defaultProps;
|
||||
|
||||
export default GridLayout;
|
||||
|
@ -3,22 +3,32 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import Controls from './Controls';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object,
|
||||
};
|
||||
const defaultProps = {
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
isStarred: PropTypes.bool,
|
||||
addSlicesToDashboard: PropTypes.func,
|
||||
onSave: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
fetchFaveStar: PropTypes.func,
|
||||
readFilters: PropTypes.func,
|
||||
renderSlices: PropTypes.func,
|
||||
saveFaveStar: PropTypes.func,
|
||||
serialize: PropTypes.func,
|
||||
startPeriodicRender: PropTypes.func,
|
||||
updateDashboardTitle: PropTypes.func,
|
||||
};
|
||||
|
||||
class Header extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
};
|
||||
|
||||
this.handleSaveTitle = this.handleSaveTitle.bind(this);
|
||||
}
|
||||
handleSaveTitle(title) {
|
||||
this.props.dashboard.updateDashboardTitle(title);
|
||||
this.props.updateDashboardTitle(title);
|
||||
}
|
||||
render() {
|
||||
const dashboard = this.props.dashboard;
|
||||
@ -32,12 +42,29 @@ class Header extends React.PureComponent {
|
||||
onSaveTitle={this.handleSaveTitle}
|
||||
noPermitTooltip={'You don\'t have the rights to alter this dashboard.'}
|
||||
/>
|
||||
<span is class="favstar" class_name="Dashboard" obj_id={dashboard.id} />
|
||||
<span className="favstar">
|
||||
<FaveStar
|
||||
itemId={dashboard.id}
|
||||
fetchFaveStar={this.props.fetchFaveStar}
|
||||
saveFaveStar={this.props.saveFaveStar}
|
||||
isStarred={this.props.isStarred}
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="pull-right" style={{ marginTop: '35px' }}>
|
||||
{!this.props.dashboard.standalone_mode &&
|
||||
<Controls dashboard={dashboard} />
|
||||
<Controls
|
||||
dashboard={dashboard}
|
||||
userId={this.props.userId}
|
||||
addSlicesToDashboard={this.props.addSlicesToDashboard}
|
||||
onSave={this.props.onSave}
|
||||
onChange={this.props.onChange}
|
||||
readFilters={this.props.readFilters}
|
||||
renderSlices={this.props.renderSlices}
|
||||
serialize={this.props.serialize}
|
||||
startPeriodicRender={this.props.startPeriodicRender}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div className="clearfix" />
|
||||
@ -46,6 +73,5 @@ class Header extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
Header.propTypes = propTypes;
|
||||
Header.defaultProps = defaultProps;
|
||||
|
||||
export default Header;
|
||||
|
@ -13,6 +13,9 @@ const propTypes = {
|
||||
css: PropTypes.string,
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
triggerNode: PropTypes.node.isRequired,
|
||||
readFilters: PropTypes.func,
|
||||
serialize: PropTypes.func,
|
||||
onSave: PropTypes.func,
|
||||
};
|
||||
|
||||
class SaveModal extends React.PureComponent {
|
||||
@ -45,8 +48,8 @@ class SaveModal extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
saveDashboardRequest(data, url, saveType) {
|
||||
const dashboard = this.props.dashboard;
|
||||
const saveModal = this.modal;
|
||||
const onSaveDashboard = this.props.onSave;
|
||||
Object.assign(data, { css: this.props.css });
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
@ -56,7 +59,7 @@ class SaveModal extends React.PureComponent {
|
||||
},
|
||||
success(resp) {
|
||||
saveModal.close();
|
||||
dashboard.onSave();
|
||||
onSaveDashboard();
|
||||
if (saveType === 'newDashboard') {
|
||||
window.location = `/superset/dashboard/${resp.id}/`;
|
||||
} else {
|
||||
@ -72,21 +75,13 @@ class SaveModal extends React.PureComponent {
|
||||
}
|
||||
saveDashboard(saveType, newDashboardTitle) {
|
||||
const dashboard = this.props.dashboard;
|
||||
const expandedSlices = {};
|
||||
$.each($('.slice_info'), function () {
|
||||
const widget = $(this).parents('.widget');
|
||||
const sliceDescription = widget.find('.slice_description');
|
||||
if (sliceDescription.is(':visible')) {
|
||||
expandedSlices[$(widget).attr('data-slice-id')] = true;
|
||||
}
|
||||
});
|
||||
const positions = dashboard.reactGridLayout.serialize();
|
||||
const positions = this.props.serialize();
|
||||
const data = {
|
||||
positions,
|
||||
css: this.state.css,
|
||||
expanded_slices: expandedSlices,
|
||||
expanded_slices: dashboard.metadata.expanded_slices || {},
|
||||
dashboard_title: dashboard.dashboard_title,
|
||||
default_filters: dashboard.readFilters(),
|
||||
default_filters: this.props.readFilters(),
|
||||
duplicate_slices: this.state.duplicateSlices,
|
||||
};
|
||||
let url = null;
|
||||
|
@ -11,6 +11,8 @@ require('react-bootstrap-table/css/react-bootstrap-table.css');
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
triggerNode: PropTypes.node.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
addSlicesToDashboard: PropTypes.func,
|
||||
};
|
||||
|
||||
class SliceAdder extends React.Component {
|
||||
@ -43,7 +45,7 @@ class SliceAdder extends React.Component {
|
||||
}
|
||||
|
||||
onEnterModal() {
|
||||
const uri = '/sliceaddview/api/read?_flt_0_created_by=' + this.props.dashboard.curUserId;
|
||||
const uri = `/sliceaddview/api/read?_flt_0_created_by=${this.props.userId}`;
|
||||
this.slicesRequest = $.ajax({
|
||||
url: uri,
|
||||
type: 'GET',
|
||||
@ -52,7 +54,7 @@ class SliceAdder extends React.Component {
|
||||
const slices = response.result.map(slice => ({
|
||||
id: slice.id,
|
||||
sliceName: slice.slice_name,
|
||||
vizType: slice.viz_type,
|
||||
vizType: slice.vizType,
|
||||
modified: slice.modified,
|
||||
}));
|
||||
|
||||
@ -65,14 +67,30 @@ class SliceAdder extends React.Component {
|
||||
error: (error) => {
|
||||
this.errored = true;
|
||||
this.setState({
|
||||
errorMsg: this.props.dashboard.getAjaxErrorMsg(error),
|
||||
errorMsg: t('Sorry, there was an error fetching slices to this dashboard: ') +
|
||||
this.getAjaxErrorMsg(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getAjaxErrorMsg(error) {
|
||||
const respJSON = error.responseJSON;
|
||||
return (respJSON && respJSON.message) ? respJSON.message :
|
||||
error.responseText;
|
||||
}
|
||||
|
||||
addSlices() {
|
||||
this.props.dashboard.addSlicesToDashboard(Object.keys(this.state.selectionMap));
|
||||
const adder = this;
|
||||
this.props.addSlicesToDashboard(Object.keys(this.state.selectionMap))
|
||||
// if successful, page will be reloaded.
|
||||
.fail((error) => {
|
||||
adder.errored = true;
|
||||
adder.setState({
|
||||
errorMsg: t('Sorry, there was an error adding slices to this dashboard: ') +
|
||||
this.getAjaxErrorMsg(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleSlice(slice) {
|
||||
|
@ -1,117 +0,0 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { t } from '../../locales';
|
||||
import { getExploreUrl } from '../../explore/exploreUtils';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
|
||||
const propTypes = {
|
||||
slice: PropTypes.object.isRequired,
|
||||
removeSlice: PropTypes.func.isRequired,
|
||||
updateSliceName: PropTypes.func,
|
||||
expandedSlices: PropTypes.object,
|
||||
};
|
||||
|
||||
const SliceCell = ({ expandedSlices, removeSlice, slice, updateSliceName }) => {
|
||||
const onSaveTitle = (newTitle) => {
|
||||
if (updateSliceName) {
|
||||
updateSliceName(slice.slice_id, newTitle);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="slice-cell" id={`${slice.slice_id}-cell`}>
|
||||
<div className="row chart-header">
|
||||
<div className="col-md-12">
|
||||
<div className="header">
|
||||
<EditableTitle
|
||||
title={slice.slice_name}
|
||||
canEdit={!!updateSliceName}
|
||||
onSaveTitle={onSaveTitle}
|
||||
noPermitTooltip={'You don\'t have the rights to alter this dashboard.'}
|
||||
/>
|
||||
</div>
|
||||
<div className="chart-controls">
|
||||
<div id={'controls_' + slice.slice_id} className="pull-right">
|
||||
<a title={t('Move chart')} data-toggle="tooltip">
|
||||
<i className="fa fa-arrows drag" />
|
||||
</a>
|
||||
<a className="refresh" title={t('Force refresh data')} data-toggle="tooltip">
|
||||
<i className="fa fa-repeat" />
|
||||
</a>
|
||||
{slice.description &&
|
||||
<a title={t('Toggle chart description')}>
|
||||
<i
|
||||
className="fa fa-info-circle slice_info"
|
||||
title={slice.description}
|
||||
data-toggle="tooltip"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
href={slice.edit_url}
|
||||
title={t('Edit chart')}
|
||||
data-toggle="tooltip"
|
||||
>
|
||||
<i className="fa fa-pencil" />
|
||||
</a>
|
||||
<a
|
||||
className="exportCSV"
|
||||
href={getExploreUrl(slice.form_data, 'csv')}
|
||||
title={t('Export CSV')}
|
||||
data-toggle="tooltip"
|
||||
>
|
||||
<i className="fa fa-table" />
|
||||
</a>
|
||||
<a
|
||||
className="exploreChart"
|
||||
href={getExploreUrl(slice.form_data)}
|
||||
title={t('Explore chart')}
|
||||
data-toggle="tooltip"
|
||||
>
|
||||
<i className="fa fa-share" />
|
||||
</a>
|
||||
<a
|
||||
className="remove-chart"
|
||||
title={t('Remove chart from dashboard')}
|
||||
data-toggle="tooltip"
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
onClick={() => { removeSlice(slice.slice_id); }}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="slice_description bs-callout bs-callout-default"
|
||||
style={
|
||||
expandedSlices &&
|
||||
expandedSlices[String(slice.slice_id)] ? {} : { display: 'none' }
|
||||
}
|
||||
dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
|
||||
/>
|
||||
<div className="row chart-container">
|
||||
<input type="hidden" value="false" />
|
||||
<div id={'token_' + slice.slice_id} className="token col-md-12">
|
||||
<img
|
||||
src="/static/assets/images/loading.gif"
|
||||
className="loading"
|
||||
alt="loading"
|
||||
/>
|
||||
<div
|
||||
id={'con_' + slice.slice_id}
|
||||
className={`slice_container ${slice.form_data.viz_type}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SliceCell.propTypes = propTypes;
|
||||
|
||||
export default SliceCell;
|
142
superset/assets/javascripts/dashboard/components/SliceHeader.jsx
Normal file
142
superset/assets/javascripts/dashboard/components/SliceHeader.jsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
|
||||
import { t } from '../../locales';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||
|
||||
const propTypes = {
|
||||
slice: PropTypes.object.isRequired,
|
||||
exploreChartUrl: PropTypes.string,
|
||||
exportCSVUrl: PropTypes.string,
|
||||
isExpanded: PropTypes.bool,
|
||||
formDataExtra: PropTypes.object,
|
||||
removeSlice: PropTypes.func,
|
||||
updateSliceName: PropTypes.func,
|
||||
toggleExpandSlice: PropTypes.func,
|
||||
forceRefresh: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
forceRefresh: () => ({}),
|
||||
removeSlice: () => ({}),
|
||||
updateSliceName: () => ({}),
|
||||
toggleExpandSlice: () => ({}),
|
||||
};
|
||||
|
||||
class SliceHeader extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.onSaveTitle = this.onSaveTitle.bind(this);
|
||||
}
|
||||
|
||||
onSaveTitle(newTitle) {
|
||||
if (this.props.updateSliceName) {
|
||||
this.props.updateSliceName(this.props.slice.slice_id, newTitle);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const slice = this.props.slice;
|
||||
const isCached = slice.is_cached;
|
||||
const isExpanded = !!this.props.isExpanded;
|
||||
const cachedWhen = moment.utc(slice.cached_dttm).fromNow();
|
||||
const refreshTooltip = isCached ?
|
||||
t('Served from data cached %s . Click to force refresh.', cachedWhen) :
|
||||
t('Force refresh data');
|
||||
|
||||
return (
|
||||
<div className="row chart-header">
|
||||
<div className="col-md-12">
|
||||
<div className="header">
|
||||
<EditableTitle
|
||||
title={slice.slice_name}
|
||||
canEdit={!!this.props.updateSliceName}
|
||||
onSaveTitle={this.onSaveTitle}
|
||||
noPermitTooltip={'You don\'t have the rights to alter this dashboard.'}
|
||||
/>
|
||||
</div>
|
||||
<div className="chart-controls">
|
||||
<div id={'controls_' + slice.slice_id} className="pull-right">
|
||||
<a>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="move"
|
||||
tooltip={t('Move chart')}
|
||||
>
|
||||
<i className="fa fa-arrows drag" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
<a
|
||||
className={`refresh ${isCached ? 'danger' : ''}`}
|
||||
onClick={() => (this.props.forceRefresh(slice.slice_id))}
|
||||
>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="refresh"
|
||||
tooltip={refreshTooltip}
|
||||
>
|
||||
<i className="fa fa-repeat" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
{slice.description &&
|
||||
<a onClick={() => this.props.toggleExpandSlice(slice, !isExpanded)}>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="description"
|
||||
tooltip={t('Toggle chart description')}
|
||||
>
|
||||
<i className="fa fa-info-circle slice_info" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
}
|
||||
<a href={slice.edit_url} target="_blank">
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="edit"
|
||||
tooltip={t('Edit chart')}
|
||||
>
|
||||
<i className="fa fa-pencil" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
<a className="exportCSV" href={this.props.exportCSVUrl}>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="exportCSV"
|
||||
tooltip={t('Export CSV')}
|
||||
>
|
||||
<i className="fa fa-table" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
<a className="exploreChart" href={this.props.exploreChartUrl} target="_blank">
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="exploreChart"
|
||||
tooltip={t('Explore chart')}
|
||||
>
|
||||
<i className="fa fa-share" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
<a className="remove-chart" onClick={() => (this.props.removeSlice(slice))}>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="close"
|
||||
tooltip={t('Remove chart from dashboard')}
|
||||
>
|
||||
<i className="fa fa-close" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SliceHeader.propTypes = propTypes;
|
||||
SliceHeader.defaultProps = defaultProps;
|
||||
|
||||
export default SliceHeader;
|
29
superset/assets/javascripts/dashboard/index.jsx
Normal file
29
superset/assets/javascripts/dashboard/index.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { initEnhancer } from '../reduxUtils';
|
||||
import { appSetup } from '../common';
|
||||
import { initJQueryAjax } from '../modules/utils';
|
||||
import DashboardContainer from './components/DashboardContainer';
|
||||
import rootReducer, { getInitialState } from './reducers';
|
||||
|
||||
appSetup();
|
||||
initJQueryAjax();
|
||||
|
||||
const appContainer = document.getElementById('app');
|
||||
const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
|
||||
const initState = Object.assign({}, getInitialState(bootstrapData));
|
||||
|
||||
const store = createStore(
|
||||
rootReducer, initState, compose(applyMiddleware(thunk), initEnhancer(false)));
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<DashboardContainer />
|
||||
</Provider>,
|
||||
appContainer,
|
||||
);
|
||||
|
188
superset/assets/javascripts/dashboard/reducers.js
Normal file
188
superset/assets/javascripts/dashboard/reducers.js
Normal file
@ -0,0 +1,188 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import d3 from 'd3';
|
||||
|
||||
import charts, { chart } from '../chart/chartReducer';
|
||||
import * as actions from './actions';
|
||||
import { getParam } from '../modules/utils';
|
||||
import { alterInArr, removeFromArr } from '../reduxUtils';
|
||||
import { applyDefaultFormData } from '../explore/stores/store';
|
||||
|
||||
export function getInitialState(bootstrapData) {
|
||||
const { user_id, datasources, common } = bootstrapData;
|
||||
delete common.locale;
|
||||
delete common.language_pack;
|
||||
|
||||
const dashboard = { ...bootstrapData.dashboard_data };
|
||||
const filters = {};
|
||||
try {
|
||||
// allow request parameter overwrite dashboard metadata
|
||||
const filterData = JSON.parse(getParam('preselect_filters') || dashboard.metadata.default_filters);
|
||||
for (const key in filterData) {
|
||||
const sliceId = parseInt(key, 10);
|
||||
filters[sliceId] = filterData[key];
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
|
||||
dashboard.posDict = {};
|
||||
dashboard.layout = [];
|
||||
if (dashboard.position_json) {
|
||||
dashboard.position_json.forEach((position) => {
|
||||
dashboard.posDict[position.slice_id] = position;
|
||||
});
|
||||
}
|
||||
dashboard.slices.forEach((slice, index) => {
|
||||
const sliceId = slice.slice_id;
|
||||
let pos = dashboard.posDict[sliceId];
|
||||
if (!pos) {
|
||||
pos = {
|
||||
col: (index * 4 + 1) % 12,
|
||||
row: Math.floor((index) / 3) * 4,
|
||||
size_x: 4,
|
||||
size_y: 4,
|
||||
};
|
||||
}
|
||||
|
||||
dashboard.layout.push({
|
||||
i: String(sliceId),
|
||||
x: pos.col - 1,
|
||||
y: pos.row,
|
||||
w: pos.size_x,
|
||||
minW: 2,
|
||||
h: pos.size_y,
|
||||
});
|
||||
});
|
||||
|
||||
// will use charts action/reducers to handle chart render
|
||||
const initCharts = {};
|
||||
dashboard.slices.forEach((slice) => {
|
||||
const chartKey = 'slice_' + slice.slice_id;
|
||||
initCharts[chartKey] = { ...chart,
|
||||
chartKey,
|
||||
slice_id: slice.slice_id,
|
||||
form_data: slice.form_data,
|
||||
formData: applyDefaultFormData(slice.form_data),
|
||||
};
|
||||
});
|
||||
|
||||
// also need to add formData for dashboard.slices
|
||||
dashboard.slices = dashboard.slices.map(slice =>
|
||||
({ ...slice, formData: applyDefaultFormData(slice.form_data) }),
|
||||
);
|
||||
|
||||
return {
|
||||
charts: initCharts,
|
||||
dashboard: { filters, dashboard, userId: user_id, datasources, common },
|
||||
};
|
||||
}
|
||||
|
||||
const dashboard = function (state = {}, action) {
|
||||
const actionHandlers = {
|
||||
[actions.UPDATE_DASHBOARD_TITLE]() {
|
||||
const newDashboard = { ...state.dashboard, dashboard_title: action.title };
|
||||
return { ...state, dashboard: newDashboard };
|
||||
},
|
||||
[actions.UPDATE_DASHBOARD_LAYOUT]() {
|
||||
const newDashboard = { ...state.dashboard, layout: action.layout };
|
||||
return { ...state, dashboard: newDashboard };
|
||||
},
|
||||
[actions.REMOVE_SLICE]() {
|
||||
const newLayout = state.dashboard.layout.filter(function (reactPos) {
|
||||
return reactPos.i !== String(action.slice.slice_id);
|
||||
});
|
||||
const newDashboard = removeFromArr(state.dashboard, 'slices', action.slice, 'slice_id');
|
||||
return { ...state, dashboard: { ...newDashboard, layout: newLayout } };
|
||||
},
|
||||
[actions.TOGGLE_FAVE_STAR]() {
|
||||
return { ...state, isStarred: action.isStarred };
|
||||
},
|
||||
[actions.TOGGLE_EXPAND_SLICE]() {
|
||||
const updatedExpandedSlices = { ...state.dashboard.metadata.expanded_slices };
|
||||
const sliceId = action.slice.slice_id;
|
||||
if (action.isExpanded) {
|
||||
updatedExpandedSlices[sliceId] = true;
|
||||
} else {
|
||||
delete updatedExpandedSlices[sliceId];
|
||||
}
|
||||
const metadata = { ...state.dashboard.metadata, expanded_slices: updatedExpandedSlices };
|
||||
const newDashboard = { ...state.dashboard, metadata };
|
||||
return { ...state, dashboard: newDashboard };
|
||||
},
|
||||
|
||||
// filters
|
||||
[actions.ADD_FILTER]() {
|
||||
const selectedSlice = state.dashboard.slices
|
||||
.find(slice => (slice.slice_id === action.sliceId));
|
||||
if (!selectedSlice) {
|
||||
return state;
|
||||
}
|
||||
|
||||
let filters;
|
||||
const { sliceId, col, vals, merge, refresh } = action;
|
||||
const filterKeys = ['__from', '__to', '__time_col',
|
||||
'__time_grain', '__time_origin', '__granularity'];
|
||||
if (filterKeys.indexOf(col) >= 0 ||
|
||||
selectedSlice.formData.groupby.indexOf(col) !== -1) {
|
||||
if (!(sliceId in state.filters)) {
|
||||
filters = { ...state.filters, [sliceId]: {} };
|
||||
}
|
||||
|
||||
let newFilter = {};
|
||||
if (state.filters[sliceId] && !(col in state.filters[sliceId]) || !merge) {
|
||||
newFilter = { ...state.filters[sliceId], [col]: vals };
|
||||
// d3.merge pass in array of arrays while some value form filter components
|
||||
// from and to filter box require string to be process and return
|
||||
} else if (state.filters[sliceId][col] instanceof Array) {
|
||||
newFilter = d3.merge([state.filters[sliceId][col], vals]);
|
||||
} else {
|
||||
newFilter = d3.merge([[state.filters[sliceId][col]], vals])[0] || '';
|
||||
}
|
||||
filters = { ...state.filters, [sliceId]: newFilter };
|
||||
}
|
||||
return { ...state, filters, refresh };
|
||||
},
|
||||
[actions.CLEAR_FILTER]() {
|
||||
const newFilters = { ...state.filters };
|
||||
delete newFilters[action.sliceId];
|
||||
|
||||
return { ...state.dashboard, filter: newFilters, refresh: true };
|
||||
},
|
||||
[actions.REMOVE_FILTER]() {
|
||||
const newFilters = { ...state.filters };
|
||||
const { sliceId, col, vals } = action;
|
||||
|
||||
if (sliceId in state.filters) {
|
||||
if (col in state.filters[sliceId]) {
|
||||
const a = [];
|
||||
newFilters[sliceId][col].forEach(function (v) {
|
||||
if (vals.indexOf(v) < 0) {
|
||||
a.push(v);
|
||||
}
|
||||
});
|
||||
newFilters[sliceId][col] = a;
|
||||
}
|
||||
}
|
||||
return { ...state.dashboard, filter: newFilters, refresh: true };
|
||||
},
|
||||
|
||||
// slice reducer
|
||||
[actions.UPDATE_SLICE_NAME]() {
|
||||
const newDashboard = alterInArr(
|
||||
state.dashboard, 'slices',
|
||||
action.slice, { slice_name: action.sliceName },
|
||||
'slice_id');
|
||||
return { ...state.dashboard, dashboard: newDashboard };
|
||||
},
|
||||
};
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
return actionHandlers[action.type]();
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({
|
||||
charts,
|
||||
dashboard,
|
||||
});
|
@ -1,74 +0,0 @@
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { getFormDataFromControls } from '../stores/store';
|
||||
import { triggerQuery } from './exploreActions';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
||||
export function chartUpdateStarted(queryRequest, latestQueryFormData) {
|
||||
return { type: CHART_UPDATE_STARTED, queryRequest, latestQueryFormData };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
|
||||
export function chartUpdateSucceeded(queryResponse) {
|
||||
return { type: CHART_UPDATE_SUCCEEDED, queryResponse };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED';
|
||||
export function chartUpdateStopped(queryRequest) {
|
||||
if (queryRequest) {
|
||||
queryRequest.abort();
|
||||
}
|
||||
return { type: CHART_UPDATE_STOPPED };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_TIMEOUT = 'CHART_UPDATE_TIMEOUT';
|
||||
export function chartUpdateTimeout(statusText, timeout) {
|
||||
return { type: CHART_UPDATE_TIMEOUT, statusText, timeout };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
|
||||
export function chartUpdateFailed(queryResponse) {
|
||||
return { type: CHART_UPDATE_FAILED, queryResponse };
|
||||
}
|
||||
|
||||
export const UPDATE_CHART_STATUS = 'UPDATE_CHART_STATUS';
|
||||
export function updateChartStatus(status) {
|
||||
return { type: UPDATE_CHART_STATUS, status };
|
||||
}
|
||||
|
||||
export const CHART_RENDERING_FAILED = 'CHART_RENDERING_FAILED';
|
||||
export function chartRenderingFailed(error) {
|
||||
return { type: CHART_RENDERING_FAILED, error };
|
||||
}
|
||||
|
||||
export const REMOVE_CHART_ALERT = 'REMOVE_CHART_ALERT';
|
||||
export function removeChartAlert() {
|
||||
return { type: REMOVE_CHART_ALERT };
|
||||
}
|
||||
|
||||
export const RUN_QUERY = 'RUN_QUERY';
|
||||
export function runQuery(formData, force = false, timeout = 60) {
|
||||
return function (dispatch, getState) {
|
||||
const { explore } = getState();
|
||||
const lastQueryFormData = getFormDataFromControls(explore.controls);
|
||||
const url = getExploreUrl(formData, 'json', force);
|
||||
const queryRequest = $.ajax({
|
||||
url,
|
||||
dataType: 'json',
|
||||
success(queryResponse) {
|
||||
dispatch(chartUpdateSucceeded(queryResponse));
|
||||
},
|
||||
error(err) {
|
||||
if (err.statusText === 'timeout') {
|
||||
dispatch(chartUpdateTimeout(err.statusText, timeout));
|
||||
} else if (err.statusText !== 'abort') {
|
||||
dispatch(chartUpdateFailed(err.responseJSON));
|
||||
}
|
||||
},
|
||||
timeout: timeout * 1000,
|
||||
});
|
||||
dispatch(chartUpdateStarted(queryRequest, lastQueryFormData));
|
||||
dispatch(triggerQuery(false));
|
||||
};
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import { triggerQuery } from '../../chart/chartAction';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
@ -54,11 +55,6 @@ export function resetControls() {
|
||||
return { type: RESET_FIELDS };
|
||||
}
|
||||
|
||||
export const TRIGGER_QUERY = 'TRIGGER_QUERY';
|
||||
export function triggerQuery(value = true) {
|
||||
return { type: TRIGGER_QUERY, value };
|
||||
}
|
||||
|
||||
export function fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery = false) {
|
||||
return function (dispatch) {
|
||||
dispatch(fetchDatasourceStarted());
|
||||
@ -146,11 +142,6 @@ export function updateChartTitle(slice_name) {
|
||||
return { type: UPDATE_CHART_TITLE, slice_name };
|
||||
}
|
||||
|
||||
export const RENDER_TRIGGERED = 'RENDER_TRIGGERED';
|
||||
export function renderTriggered() {
|
||||
return { type: RENDER_TRIGGERED };
|
||||
}
|
||||
|
||||
export const CREATE_NEW_SLICE = 'CREATE_NEW_SLICE';
|
||||
export function createNewSlice(can_add, can_download, can_overwrite, slice, form_data) {
|
||||
return { type: CREATE_NEW_SLICE, can_add, can_download, can_overwrite, slice, form_data };
|
||||
|
@ -1,362 +0,0 @@
|
||||
import $ from 'jquery';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Mustache from 'mustache';
|
||||
import { connect } from 'react-redux';
|
||||
import { Alert, Collapse, Panel } from 'react-bootstrap';
|
||||
import visMap from '../../../visualizations/main';
|
||||
import { d3format } from '../../modules/utils';
|
||||
import ExploreActionButtons from './ExploreActionButtons';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||
import Timer from '../../components/Timer';
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { getFormDataFromControls } from '../stores/store';
|
||||
import CachedLabel from '../../components/CachedLabel';
|
||||
import { t } from '../../locales';
|
||||
|
||||
const CHART_STATUS_MAP = {
|
||||
failed: 'danger',
|
||||
loading: 'warning',
|
||||
success: 'success',
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
alert: PropTypes.string,
|
||||
can_overwrite: PropTypes.bool.isRequired,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
chartUpdateStartTime: PropTypes.number.isRequired,
|
||||
column_formats: PropTypes.object,
|
||||
containerId: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
width: PropTypes.string.isRequired,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
slice: PropTypes.object,
|
||||
table_name: PropTypes.string,
|
||||
viz_type: PropTypes.string.isRequired,
|
||||
formData: PropTypes.object,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
queryResponse: PropTypes.object,
|
||||
triggerRender: PropTypes.bool,
|
||||
standalone: PropTypes.bool,
|
||||
datasourceType: PropTypes.string,
|
||||
datasourceId: PropTypes.number,
|
||||
timeout: PropTypes.number,
|
||||
};
|
||||
|
||||
class ChartContainer extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selector: `#${props.containerId}`,
|
||||
showStackTrace: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this.props.queryResponse &&
|
||||
(
|
||||
prevProps.queryResponse !== this.props.queryResponse ||
|
||||
prevProps.height !== this.props.height ||
|
||||
prevProps.width !== this.props.width ||
|
||||
this.props.triggerRender
|
||||
) && !this.props.queryResponse.error
|
||||
&& this.props.chartStatus !== 'failed'
|
||||
&& this.props.chartStatus !== 'stopped'
|
||||
&& this.props.chartStatus !== 'loading'
|
||||
) {
|
||||
this.renderViz();
|
||||
}
|
||||
}
|
||||
|
||||
getMockedSliceObject() {
|
||||
const props = this.props;
|
||||
const getHeight = () => {
|
||||
const headerHeight = props.standalone ? 0 : 100;
|
||||
return parseInt(props.height, 10) - headerHeight;
|
||||
};
|
||||
return {
|
||||
viewSqlQuery: props.queryResponse.query,
|
||||
containerId: props.containerId,
|
||||
datasource: props.datasource,
|
||||
selector: this.state.selector,
|
||||
formData: props.formData,
|
||||
container: {
|
||||
html: (data) => {
|
||||
// this should be a callback to clear the contents of the slice container
|
||||
$(this.state.selector).html(data);
|
||||
},
|
||||
css: (property, value) => {
|
||||
$(this.state.selector).css(property, value);
|
||||
},
|
||||
height: getHeight,
|
||||
show: () => { },
|
||||
get: n => ($(this.state.selector).get(n)),
|
||||
find: classname => ($(this.state.selector).find(classname)),
|
||||
},
|
||||
|
||||
width: () => this.chartContainerRef.getBoundingClientRect().width,
|
||||
|
||||
height: getHeight,
|
||||
|
||||
render_template: (s) => {
|
||||
const context = {
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
};
|
||||
return Mustache.render(s, context);
|
||||
},
|
||||
|
||||
setFilter: () => {},
|
||||
|
||||
getFilters: () => (
|
||||
// return filter objects from viz.formData
|
||||
{}
|
||||
),
|
||||
|
||||
addFilter: () => {},
|
||||
|
||||
removeFilter: () => {},
|
||||
|
||||
done: () => {},
|
||||
clearError: () => {
|
||||
// no need to do anything here since Alert is closable
|
||||
// query button will also remove Alert
|
||||
},
|
||||
error() {},
|
||||
|
||||
d3format: (col, number) => {
|
||||
// mock d3format function in Slice object in superset.js
|
||||
const format = props.column_formats[col];
|
||||
return d3format(format, number);
|
||||
},
|
||||
|
||||
data: {
|
||||
csv_endpoint: getExploreUrl(props.formData, 'csv'),
|
||||
json_endpoint: getExploreUrl(props.formData, 'json'),
|
||||
standalone_endpoint: getExploreUrl(props.formData, 'standalone'),
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
removeAlert() {
|
||||
this.props.actions.removeChartAlert();
|
||||
}
|
||||
|
||||
runQuery() {
|
||||
this.props.actions.runQuery(this.props.formData, true, this.props.timeout);
|
||||
}
|
||||
|
||||
updateChartTitleOrSaveSlice(newTitle) {
|
||||
const isNewSlice = !this.props.slice;
|
||||
const params = {
|
||||
slice_name: newTitle,
|
||||
action: isNewSlice ? 'saveas' : 'overwrite',
|
||||
};
|
||||
const saveUrl = getExploreUrl(this.props.formData, 'base', false, null, params);
|
||||
this.props.actions.saveSlice(saveUrl)
|
||||
.then((data) => {
|
||||
if (isNewSlice) {
|
||||
this.props.actions.createNewSlice(
|
||||
data.can_add, data.can_download, data.can_overwrite,
|
||||
data.slice, data.form_data);
|
||||
} else {
|
||||
this.props.actions.updateChartTitle(newTitle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderChartTitle() {
|
||||
let title;
|
||||
if (this.props.slice) {
|
||||
title = this.props.slice.slice_name;
|
||||
} else {
|
||||
title = t('%s - untitled', this.props.table_name);
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
renderViz() {
|
||||
this.props.actions.renderTriggered();
|
||||
const mockSlice = this.getMockedSliceObject();
|
||||
this.setState({ mockSlice });
|
||||
const viz = visMap[this.props.viz_type];
|
||||
try {
|
||||
viz(mockSlice, this.props.queryResponse, this.props.actions.setControlValue);
|
||||
} catch (e) {
|
||||
this.props.actions.chartRenderingFailed(e);
|
||||
}
|
||||
}
|
||||
|
||||
renderAlert() {
|
||||
/* eslint-disable react/no-danger */
|
||||
const msg = (
|
||||
<div>
|
||||
<i
|
||||
className="fa fa-close pull-right"
|
||||
onClick={this.removeAlert.bind(this)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{ __html: this.props.alert }}
|
||||
/>
|
||||
</div>);
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
bsStyle="warning"
|
||||
onClick={() => this.setState({ showStackTrace: !this.state.showStackTrace })}
|
||||
>
|
||||
{msg}
|
||||
</Alert>
|
||||
{this.props.queryResponse && this.props.queryResponse.stacktrace &&
|
||||
<Collapse in={this.state.showStackTrace}>
|
||||
<pre>
|
||||
{this.props.queryResponse.stacktrace}
|
||||
</pre>
|
||||
</Collapse>
|
||||
}
|
||||
</div>);
|
||||
}
|
||||
|
||||
renderChart() {
|
||||
if (this.props.alert) {
|
||||
return this.renderAlert();
|
||||
}
|
||||
const loading = this.props.chartStatus === 'loading';
|
||||
return (
|
||||
<div>
|
||||
{loading &&
|
||||
<img
|
||||
alt="loading"
|
||||
width="25"
|
||||
src="/static/assets/images/loading.gif"
|
||||
style={{ position: 'absolute' }}
|
||||
/>
|
||||
}
|
||||
<div
|
||||
id={this.props.containerId}
|
||||
ref={(ref) => { this.chartContainerRef = ref; }}
|
||||
className={this.props.viz_type}
|
||||
style={{
|
||||
opacity: loading ? '0.25' : '1',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.standalone) {
|
||||
// dom manipulation hack to get rid of the boostrap theme's body background
|
||||
$('body').addClass('background-transparent');
|
||||
return this.renderChart();
|
||||
}
|
||||
const queryResponse = this.props.queryResponse;
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<Panel
|
||||
style={{ height: this.props.height }}
|
||||
header={
|
||||
<div
|
||||
id="slice-header"
|
||||
className="clearfix panel-title-large"
|
||||
>
|
||||
<EditableTitle
|
||||
title={this.renderChartTitle()}
|
||||
canEdit={!this.props.slice || this.props.can_overwrite}
|
||||
onSaveTitle={this.updateChartTitleOrSaveSlice.bind(this)}
|
||||
/>
|
||||
|
||||
{this.props.slice &&
|
||||
<span>
|
||||
<FaveStar
|
||||
sliceId={this.props.slice.slice_id}
|
||||
actions={this.props.actions}
|
||||
isStarred={this.props.isStarred}
|
||||
/>
|
||||
|
||||
<TooltipWrapper
|
||||
label="edit-desc"
|
||||
tooltip={t('Edit slice properties')}
|
||||
>
|
||||
<a
|
||||
className="edit-desc-icon"
|
||||
href={`/slicemodelview/edit/${this.props.slice.slice_id}`}
|
||||
>
|
||||
<i className="fa fa-edit" />
|
||||
</a>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
}
|
||||
|
||||
<div className="pull-right">
|
||||
{this.props.chartStatus === 'success' &&
|
||||
this.props.queryResponse &&
|
||||
this.props.queryResponse.is_cached &&
|
||||
<CachedLabel
|
||||
onClick={this.runQuery.bind(this)}
|
||||
cachedTimestamp={queryResponse.cached_dttm}
|
||||
/>
|
||||
}
|
||||
<Timer
|
||||
startTime={this.props.chartUpdateStartTime}
|
||||
endTime={this.props.chartUpdateEndTime}
|
||||
isRunning={this.props.chartStatus === 'loading'}
|
||||
status={CHART_STATUS_MAP[this.props.chartStatus]}
|
||||
style={{ fontSize: '10px', marginRight: '5px' }}
|
||||
/>
|
||||
<ExploreActionButtons
|
||||
slice={this.state.mockSlice}
|
||||
canDownload={this.props.can_download}
|
||||
chartStatus={this.props.chartStatus}
|
||||
queryResponse={queryResponse}
|
||||
queryEndpoint={getExploreUrl(this.props.latestQueryFormData, 'query')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{this.renderChart()}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChartContainer.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps({ explore, chart }) {
|
||||
const formData = getFormDataFromControls(explore.controls);
|
||||
return {
|
||||
alert: chart.chartAlert,
|
||||
can_overwrite: !!explore.can_overwrite,
|
||||
can_download: !!explore.can_download,
|
||||
datasource: explore.datasource,
|
||||
column_formats: explore.datasource ? explore.datasource.column_formats : null,
|
||||
containerId: explore.slice ? `slice-container-${explore.slice.slice_id}` : 'slice-container',
|
||||
formData,
|
||||
isStarred: explore.isStarred,
|
||||
slice: explore.slice,
|
||||
standalone: explore.standalone,
|
||||
table_name: formData.datasource_name,
|
||||
viz_type: formData.viz_type,
|
||||
triggerRender: explore.triggerRender,
|
||||
datasourceType: explore.datasource.type,
|
||||
datasourceId: explore.datasource_id,
|
||||
chartStatus: chart.chartStatus,
|
||||
chartUpdateEndTime: chart.chartUpdateEndTime,
|
||||
chartUpdateStartTime: chart.chartUpdateStartTime,
|
||||
latestQueryFormData: chart.latestQueryFormData,
|
||||
queryResponse: chart.queryResponse,
|
||||
timeout: explore.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, () => ({}))(ChartContainer);
|
@ -73,7 +73,7 @@ export default class EmbedCodeButton extends React.Component {
|
||||
<div className="col-md-6 col-sm-12">
|
||||
<div className="form-group">
|
||||
<small>
|
||||
<label className="control-label" htmlFor="embed-height">t('Height')</label>
|
||||
<label className="control-label" htmlFor="embed-height">{t('Height')}</label>
|
||||
</small>
|
||||
<input
|
||||
className="form-control input-sm"
|
||||
@ -87,7 +87,7 @@ export default class EmbedCodeButton extends React.Component {
|
||||
<div className="col-md-6 col-sm-12">
|
||||
<div className="form-group">
|
||||
<small>
|
||||
<label className="control-label" htmlFor="embed-width">t('Width')</label>
|
||||
<label className="control-label" htmlFor="embed-width">{t('Width')}</label>
|
||||
</small>
|
||||
<input
|
||||
className="form-control input-sm"
|
||||
|
@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { chartPropType } from '../../chart/chartReducer';
|
||||
import ExploreActionButtons from './ExploreActionButtons';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||
import Timer from '../../components/Timer';
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import CachedLabel from '../../components/CachedLabel';
|
||||
import { t } from '../../locales';
|
||||
|
||||
const CHART_STATUS_MAP = {
|
||||
failed: 'danger',
|
||||
loading: 'warning',
|
||||
success: 'success',
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
can_overwrite: PropTypes.bool.isRequired,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
slice: PropTypes.object,
|
||||
table_name: PropTypes.string,
|
||||
form_data: PropTypes.object,
|
||||
timeout: PropTypes.number,
|
||||
chart: PropTypes.shape(chartPropType),
|
||||
};
|
||||
|
||||
class ExploreChartHeader extends React.PureComponent {
|
||||
runQuery() {
|
||||
this.props.actions.runQuery(this.props.form_data, true,
|
||||
this.props.timeout, this.props.chart.chartKey);
|
||||
}
|
||||
|
||||
updateChartTitleOrSaveSlice(newTitle) {
|
||||
const isNewSlice = !this.props.slice;
|
||||
const params = {
|
||||
slice_name: newTitle,
|
||||
action: isNewSlice ? 'saveas' : 'overwrite',
|
||||
};
|
||||
const saveUrl = getExploreUrl(this.props.form_data, 'base', false, null, params);
|
||||
this.props.actions.saveSlice(saveUrl)
|
||||
.then((data) => {
|
||||
if (isNewSlice) {
|
||||
this.props.actions.createNewSlice(
|
||||
data.can_add, data.can_download, data.can_overwrite,
|
||||
data.slice, data.form_data);
|
||||
} else {
|
||||
this.props.actions.updateChartTitle(newTitle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderChartTitle() {
|
||||
let title;
|
||||
if (this.props.slice) {
|
||||
title = this.props.slice.slice_name;
|
||||
} else {
|
||||
title = t('%s - untitled', this.props.table_name);
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
render() {
|
||||
const queryResponse = this.props.chart.queryResponse;
|
||||
const data = {
|
||||
csv_endpoint: getExploreUrl(this.props.form_data, 'csv'),
|
||||
json_endpoint: getExploreUrl(this.props.form_data, 'json'),
|
||||
standalone_endpoint: getExploreUrl(this.props.form_data, 'standalone'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="slice-header"
|
||||
className="clearfix panel-title-large"
|
||||
>
|
||||
<EditableTitle
|
||||
title={this.renderChartTitle()}
|
||||
canEdit={!this.props.slice || this.props.can_overwrite}
|
||||
onSaveTitle={this.updateChartTitleOrSaveSlice.bind(this)}
|
||||
/>
|
||||
|
||||
{this.props.slice &&
|
||||
<span>
|
||||
<FaveStar
|
||||
itemId={this.props.slice.slice_id}
|
||||
fetchFaveStar={this.props.actions.fetchFaveStar}
|
||||
saveFaveStar={this.props.actions.saveFaveStar}
|
||||
isStarred={this.props.isStarred}
|
||||
/>
|
||||
|
||||
<TooltipWrapper
|
||||
label="edit-desc"
|
||||
tooltip={t('Edit slice properties')}
|
||||
>
|
||||
<a
|
||||
className="edit-desc-icon"
|
||||
href={`/slicemodelview/edit/${this.props.slice.slice_id}`}
|
||||
>
|
||||
<i className="fa fa-edit" />
|
||||
</a>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
}
|
||||
|
||||
<div className="pull-right">
|
||||
{this.props.chart.chartStatus === 'success' &&
|
||||
queryResponse &&
|
||||
queryResponse.is_cached &&
|
||||
<CachedLabel
|
||||
onClick={this.runQuery.bind(this)}
|
||||
cachedTimestamp={queryResponse.cached_dttm}
|
||||
/>
|
||||
}
|
||||
<Timer
|
||||
startTime={this.props.chart.chartUpdateStartTime}
|
||||
endTime={this.props.chart.chartUpdateEndTime}
|
||||
isRunning={this.props.chart.chartStatus === 'loading'}
|
||||
status={CHART_STATUS_MAP[this.props.chart.chartStatus]}
|
||||
style={{ fontSize: '10px', marginRight: '5px' }}
|
||||
/>
|
||||
<ExploreActionButtons
|
||||
slice={Object.assign({}, this.props.slice, { data })}
|
||||
canDownload={this.props.can_download}
|
||||
chartStatus={this.props.chart.chartStatus}
|
||||
queryResponse={queryResponse}
|
||||
queryEndpoint={getExploreUrl(this.props.form_data, 'query')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ExploreChartHeader.propTypes = propTypes;
|
||||
|
||||
export default ExploreChartHeader;
|
@ -0,0 +1,79 @@
|
||||
import $ from 'jquery';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Panel } from 'react-bootstrap';
|
||||
|
||||
import { chartPropType } from '../../chart/chartReducer';
|
||||
import ChartContainer from '../../chart/ChartContainer';
|
||||
import ExploreChartHeader from './ExploreChartHeader';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
can_overwrite: PropTypes.bool.isRequired,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
datasource: PropTypes.object,
|
||||
column_formats: PropTypes.object,
|
||||
containerId: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
width: PropTypes.string.isRequired,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
slice: PropTypes.object,
|
||||
table_name: PropTypes.string,
|
||||
vizType: PropTypes.string.isRequired,
|
||||
form_data: PropTypes.object,
|
||||
standalone: PropTypes.bool,
|
||||
timeout: PropTypes.number,
|
||||
chart: PropTypes.shape(chartPropType),
|
||||
};
|
||||
|
||||
class ExploreChartPanel extends React.PureComponent {
|
||||
getHeight() {
|
||||
const headerHeight = this.props.standalone ? 0 : 100;
|
||||
return parseInt(this.props.height, 10) - headerHeight;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.standalone) {
|
||||
// dom manipulation hack to get rid of the boostrap theme's body background
|
||||
$('body').addClass('background-transparent');
|
||||
return this.renderChart();
|
||||
}
|
||||
|
||||
const header = (
|
||||
<ExploreChartHeader
|
||||
actions={this.props.actions}
|
||||
can_overwrite={this.props.can_overwrite}
|
||||
can_download={this.props.can_download}
|
||||
isStarred={this.props.isStarred}
|
||||
slice={this.props.slice}
|
||||
table_name={this.props.table_name}
|
||||
form_data={this.props.form_data}
|
||||
timeout={this.props.timeout}
|
||||
chart={this.props.chart}
|
||||
/>);
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<Panel
|
||||
style={{ height: this.props.height }}
|
||||
header={header}
|
||||
>
|
||||
<ChartContainer
|
||||
containerId={this.props.containerId}
|
||||
datasource={this.props.datasource}
|
||||
formData={this.props.form_data}
|
||||
height={this.getHeight()}
|
||||
slice={this.props.slice}
|
||||
chartKey={this.props.chart.chartKey}
|
||||
setControlValue={this.props.actions.setControlValue}
|
||||
timeout={this.props.timeout}
|
||||
vizType={this.props.vizType}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ExploreChartPanel.propTypes = propTypes;
|
||||
|
||||
export default ExploreChartPanel;
|
@ -3,27 +3,28 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import ChartContainer from './ChartContainer';
|
||||
|
||||
import ExploreChartPanel from './ExploreChartPanel';
|
||||
import ControlPanelsContainer from './ControlPanelsContainer';
|
||||
import SaveModal from './SaveModal';
|
||||
import QueryAndSaveBtns from './QueryAndSaveBtns';
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { getFormDataFromControls } from '../stores/store';
|
||||
import { chartPropType } from '../../chart/chartReducer';
|
||||
import * as exploreActions from '../actions/exploreActions';
|
||||
import * as saveModalActions from '../actions/saveModalActions';
|
||||
import * as chartActions from '../actions/chartActions';
|
||||
import * as chartActions from '../../chart/chartAction';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
datasource_type: PropTypes.string.isRequired,
|
||||
isDatasourceMetaLoading: PropTypes.bool.isRequired,
|
||||
chartStatus: PropTypes.string,
|
||||
chart: PropTypes.shape(chartPropType).isRequired,
|
||||
controls: PropTypes.object.isRequired,
|
||||
forcedHeight: PropTypes.string,
|
||||
form_data: PropTypes.object.isRequired,
|
||||
standalone: PropTypes.bool.isRequired,
|
||||
triggerQuery: PropTypes.bool.isRequired,
|
||||
queryRequest: PropTypes.object,
|
||||
timeout: PropTypes.number,
|
||||
};
|
||||
|
||||
@ -39,13 +40,12 @@ class ExploreViewContainer extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', this.handleResize.bind(this));
|
||||
this.triggerQueryIfNeeded();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(np) {
|
||||
if (np.controls.viz_type.value !== this.props.controls.viz_type.value) {
|
||||
this.props.actions.resetControls();
|
||||
this.props.actions.triggerQuery();
|
||||
this.props.actions.triggerQuery(true, this.props.chart.chartKey);
|
||||
}
|
||||
if (np.controls.datasource.value !== this.props.controls.datasource.value) {
|
||||
this.props.actions.fetchDatasourceMetadata(np.form_data.datasource, true);
|
||||
@ -63,9 +63,7 @@ class ExploreViewContainer extends React.Component {
|
||||
onQuery() {
|
||||
// remove alerts when query
|
||||
this.props.actions.removeControlPanelAlert();
|
||||
this.props.actions.removeChartAlert();
|
||||
|
||||
this.props.actions.triggerQuery();
|
||||
this.props.actions.triggerQuery(true, this.props.chart.chartKey);
|
||||
|
||||
history.pushState(
|
||||
{},
|
||||
@ -74,7 +72,7 @@ class ExploreViewContainer extends React.Component {
|
||||
}
|
||||
|
||||
onStop() {
|
||||
this.props.actions.chartUpdateStopped(this.props.queryRequest);
|
||||
this.props.actions.chartUpdateStopped(this.props.chart.queryRequest);
|
||||
}
|
||||
|
||||
getWidth() {
|
||||
@ -90,8 +88,9 @@ class ExploreViewContainer extends React.Component {
|
||||
}
|
||||
|
||||
triggerQueryIfNeeded() {
|
||||
if (this.props.triggerQuery && !this.hasErrors()) {
|
||||
this.props.actions.runQuery(this.props.form_data, false, this.props.timeout);
|
||||
if (this.props.chart.triggerQuery && !this.hasErrors()) {
|
||||
this.props.actions.runQuery(this.props.form_data, false,
|
||||
this.props.timeout, this.props.chart.chartKey);
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,10 +133,10 @@ class ExploreViewContainer extends React.Component {
|
||||
}
|
||||
renderChartContainer() {
|
||||
return (
|
||||
<ChartContainer
|
||||
actions={this.props.actions}
|
||||
<ExploreChartPanel
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
{...this.props}
|
||||
/>);
|
||||
}
|
||||
|
||||
@ -168,7 +167,7 @@ class ExploreViewContainer extends React.Component {
|
||||
onQuery={this.onQuery.bind(this)}
|
||||
onSave={this.toggleModal.bind(this)}
|
||||
onStop={this.onStop.bind(this)}
|
||||
loading={this.props.chartStatus === 'loading'}
|
||||
loading={this.props.chart.chartStatus === 'loading'}
|
||||
errorMessage={this.renderErrorMessage()}
|
||||
/>
|
||||
<br />
|
||||
@ -191,18 +190,28 @@ class ExploreViewContainer extends React.Component {
|
||||
|
||||
ExploreViewContainer.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps({ explore, chart }) {
|
||||
function mapStateToProps({ explore, charts }) {
|
||||
const form_data = getFormDataFromControls(explore.controls);
|
||||
const chartKey = Object.keys(charts)[0];
|
||||
const chart = charts[chartKey];
|
||||
return {
|
||||
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
|
||||
datasource: explore.datasource,
|
||||
datasource_type: explore.datasource.type,
|
||||
datasourceId: explore.datasource_id,
|
||||
controls: explore.controls,
|
||||
can_overwrite: !!explore.can_overwrite,
|
||||
can_download: !!explore.can_download,
|
||||
column_formats: explore.datasource ? explore.datasource.column_formats : null,
|
||||
containerId: explore.slice ? `slice-container-${explore.slice.slice_id}` : 'slice-container',
|
||||
isStarred: explore.isStarred,
|
||||
slice: explore.slice,
|
||||
form_data,
|
||||
table_name: form_data.datasource_name,
|
||||
vizType: form_data.viz_type,
|
||||
standalone: explore.standalone,
|
||||
triggerQuery: explore.triggerQuery,
|
||||
forcedHeight: explore.forced_height,
|
||||
queryRequest: chart.queryRequest,
|
||||
chartStatus: chart.chartStatus,
|
||||
chart,
|
||||
timeout: explore.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
|
||||
};
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ const propTypes = {
|
||||
onHide: PropTypes.func.isRequired,
|
||||
actions: PropTypes.object.isRequired,
|
||||
form_data: PropTypes.object,
|
||||
user_id: PropTypes.string.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
dashboards: PropTypes.array.isRequired,
|
||||
alert: PropTypes.string,
|
||||
slice: PropTypes.object,
|
||||
@ -34,7 +34,7 @@ class SaveModal extends React.Component {
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.props.actions.fetchDashboards(this.props.user_id);
|
||||
this.props.actions.fetchDashboards(this.props.userId);
|
||||
}
|
||||
onChange(name, event) {
|
||||
switch (name) {
|
||||
@ -243,7 +243,7 @@ function mapStateToProps({ explore, saveModal }) {
|
||||
datasource: explore.datasource,
|
||||
slice: explore.slice,
|
||||
can_overwrite: explore.can_overwrite,
|
||||
user_id: explore.user_id,
|
||||
userId: explore.userId,
|
||||
dashboards: saveModal.dashboards,
|
||||
alert: saveModal.saveModalAlert,
|
||||
};
|
||||
|
@ -34,19 +34,23 @@ const bootstrappedState = Object.assign(
|
||||
filterColumnOpts: [],
|
||||
isDatasourceMetaLoading: false,
|
||||
isStarred: false,
|
||||
triggerQuery: true,
|
||||
triggerRender: false,
|
||||
},
|
||||
);
|
||||
|
||||
const chartKey = bootstrappedState.slice ? ('slice_' + bootstrappedState.slice.slice_id) : 'slice';
|
||||
const initState = {
|
||||
chart: {
|
||||
chartAlert: null,
|
||||
chartStatus: null,
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
latestQueryFormData: getFormDataFromControls(controls),
|
||||
queryResponse: null,
|
||||
charts: {
|
||||
[chartKey]: {
|
||||
chartKey,
|
||||
chartAlert: null,
|
||||
chartStatus: null,
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
latestQueryFormData: getFormDataFromControls(controls),
|
||||
queryResponse: null,
|
||||
triggerQuery: true,
|
||||
triggerRender: false,
|
||||
},
|
||||
},
|
||||
saveModal: {
|
||||
dashboards: [],
|
||||
|
@ -1,80 +0,0 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import { now } from '../../modules/dates';
|
||||
import * as actions from '../actions/chartActions';
|
||||
import { t } from '../../locales';
|
||||
|
||||
export default function chartReducer(state = {}, action) {
|
||||
const actionHandlers = {
|
||||
[actions.CHART_UPDATE_SUCCEEDED]() {
|
||||
return Object.assign(
|
||||
{},
|
||||
state,
|
||||
{
|
||||
chartStatus: 'success',
|
||||
queryResponse: action.queryResponse,
|
||||
},
|
||||
);
|
||||
},
|
||||
[actions.CHART_UPDATE_STARTED]() {
|
||||
return Object.assign({}, state,
|
||||
{
|
||||
chartStatus: 'loading',
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
queryRequest: action.queryRequest,
|
||||
latestQueryFormData: action.latestQueryFormData,
|
||||
});
|
||||
},
|
||||
[actions.CHART_UPDATE_STOPPED]() {
|
||||
return Object.assign({}, state,
|
||||
{
|
||||
chartStatus: 'stopped',
|
||||
chartAlert: t('Updating chart was stopped'),
|
||||
});
|
||||
},
|
||||
[actions.CHART_RENDERING_FAILED]() {
|
||||
return Object.assign({}, state, {
|
||||
chartStatus: 'failed',
|
||||
chartAlert: t('An error occurred while rendering the visualization: %s', action.error),
|
||||
});
|
||||
},
|
||||
[actions.CHART_UPDATE_TIMEOUT]() {
|
||||
return Object.assign({}, state, {
|
||||
chartStatus: 'failed',
|
||||
chartAlert: (
|
||||
'<strong>Query timeout</strong> - visualization query are set to timeout at ' +
|
||||
`${action.timeout} seconds. ` +
|
||||
t('Perhaps your data has grown, your database is under unusual load, ' +
|
||||
'or you are simply querying a data source that is to large ' +
|
||||
'to be processed within the timeout range. ' +
|
||||
'If that is the case, we recommend that you summarize your data further.')),
|
||||
});
|
||||
},
|
||||
[actions.CHART_UPDATE_FAILED]() {
|
||||
return Object.assign({}, state, {
|
||||
chartStatus: 'failed',
|
||||
chartAlert: action.queryResponse ? action.queryResponse.error : t('Network error.'),
|
||||
chartUpdateEndTime: now(),
|
||||
queryResponse: action.queryResponse,
|
||||
});
|
||||
},
|
||||
[actions.UPDATE_CHART_STATUS]() {
|
||||
const newState = Object.assign({}, state, { chartStatus: action.status });
|
||||
if (action.status === 'success' || action.status === 'failed') {
|
||||
newState.chartUpdateEndTime = now();
|
||||
}
|
||||
return newState;
|
||||
},
|
||||
[actions.REMOVE_CHART_ALERT]() {
|
||||
if (state.chartAlert !== null) {
|
||||
return Object.assign({}, state, { chartAlert: null });
|
||||
}
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
return actionHandlers[action.type]();
|
||||
}
|
||||
return state;
|
||||
}
|
@ -56,11 +56,6 @@ export default function exploreReducer(state = {}, action) {
|
||||
}
|
||||
return Object.assign({}, state, changes);
|
||||
},
|
||||
[actions.TRIGGER_QUERY]() {
|
||||
return Object.assign({}, state, {
|
||||
triggerQuery: action.value,
|
||||
});
|
||||
},
|
||||
[actions.UPDATE_CHART_TITLE]() {
|
||||
const updatedSlice = Object.assign({}, state.slice, { slice_name: action.slice_name });
|
||||
return Object.assign({}, state, { slice: updatedSlice });
|
||||
@ -69,9 +64,6 @@ export default function exploreReducer(state = {}, action) {
|
||||
const controls = getControlsState(state, getFormDataFromControls(state.controls));
|
||||
return Object.assign({}, state, { controls });
|
||||
},
|
||||
[actions.RENDER_TRIGGERED]() {
|
||||
return Object.assign({}, state, { triggerRender: false });
|
||||
},
|
||||
[actions.CREATE_NEW_SLICE]() {
|
||||
return Object.assign({}, state, {
|
||||
slice: action.slice,
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import chart from './chartReducer';
|
||||
import charts from '../../chart/chartReducer';
|
||||
import saveModal from './saveModalReducer';
|
||||
import explore from './exploreReducer';
|
||||
|
||||
export default combineReducers({
|
||||
chart,
|
||||
charts,
|
||||
saveModal,
|
||||
explore,
|
||||
});
|
||||
|
@ -240,3 +240,11 @@ export function tryNumify(s) {
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function getParam(name) {
|
||||
/* eslint no-useless-escape: 0 */
|
||||
const formattedName = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
|
||||
const regex = new RegExp('[\\?&]' + formattedName + '=([^&#]*)');
|
||||
const results = regex.exec(location.search);
|
||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||
}
|
||||
|
@ -19,10 +19,9 @@ export function alterInObject(state, arrKey, obj, alterations) {
|
||||
return Object.assign({}, state, { [arrKey]: newObject });
|
||||
}
|
||||
|
||||
export function alterInArr(state, arrKey, obj, alterations) {
|
||||
export function alterInArr(state, arrKey, obj, alterations, idKey = 'id') {
|
||||
// Finds an item in an array in the state and replaces it with a
|
||||
// new object with an altered property
|
||||
const idKey = 'id';
|
||||
const newArr = [];
|
||||
state[arrKey].forEach((arrItem) => {
|
||||
if (obj[idKey] === arrItem[idKey]) {
|
||||
@ -96,19 +95,5 @@ export function areArraysShallowEqual(arr1, arr2) {
|
||||
}
|
||||
|
||||
export function areObjectsEqual(obj1, obj2) {
|
||||
if (!obj1 || !obj2) {
|
||||
return false;
|
||||
}
|
||||
if (!Object.keys(obj1).length !== Object.keys(obj2).length) {
|
||||
return false;
|
||||
}
|
||||
for (const id in obj1) {
|
||||
if (!obj2.hasOwnProperty(id)) {
|
||||
return false;
|
||||
}
|
||||
if (obj1[id] !== obj2[id]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return JSON.stringify(obj1) === JSON.stringify(obj2);
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { describe, it } from 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { slice } from './fixtures';
|
||||
|
||||
import SliceCell from '../../../javascripts/dashboard/components/SliceCell';
|
||||
|
||||
describe('SliceCell', () => {
|
||||
const mockedProps = {
|
||||
slice,
|
||||
removeSlice: () => {},
|
||||
expandedSlices: {},
|
||||
};
|
||||
it('is valid', () => {
|
||||
expect(
|
||||
React.isValidElement(<SliceCell {...mockedProps} />),
|
||||
).to.equal(true);
|
||||
});
|
||||
it('renders six links', () => {
|
||||
const wrapper = mount(<SliceCell {...mockedProps} />);
|
||||
expect(wrapper.find('a')).to.have.length(6);
|
||||
});
|
||||
});
|
@ -66,5 +66,5 @@ export const contextData = {
|
||||
dash_save_perm: true,
|
||||
standalone_mode: false,
|
||||
dash_edit_perm: true,
|
||||
user_id: '1',
|
||||
userId: '1',
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import { expect } from 'chai';
|
||||
import sinon from 'sinon';
|
||||
import $ from 'jquery';
|
||||
import * as exploreUtils from '../../../javascripts/explore/exploreUtils';
|
||||
import * as actions from '../../../javascripts/explore/actions/chartActions';
|
||||
import * as actions from '../../../javascripts/chart/chartAction';
|
||||
|
||||
describe('chart actions', () => {
|
||||
let dispatch;
|
||||
|
@ -24,7 +24,7 @@ describe('SaveModal', () => {
|
||||
},
|
||||
explore: {
|
||||
can_overwrite: true,
|
||||
user_id: '1',
|
||||
userId: '1',
|
||||
datasource: {},
|
||||
slice: {
|
||||
slice_id: 1,
|
||||
|
@ -3,6 +3,7 @@ import { it, describe } from 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import sinon from 'sinon';
|
||||
import $ from 'jquery';
|
||||
import * as chartActions from '../../../javascripts/chart/chartAction';
|
||||
import * as actions from '../../../javascripts/explore/actions/exploreActions';
|
||||
import { defaultState } from '../../../javascripts/explore/stores/store';
|
||||
import exploreReducer from '../../../javascripts/explore/reducers/exploreReducer';
|
||||
@ -77,7 +78,7 @@ describe('fetching actions', () => {
|
||||
ajaxStub.yieldsTo('success', { data: '' });
|
||||
makeRequest(true);
|
||||
expect(dispatch.callCount).to.equal(5);
|
||||
expect(dispatch.getCall(4).args[0].type).to.equal(actions.TRIGGER_QUERY);
|
||||
expect(dispatch.getCall(4).args[0].type).to.equal(chartActions.TRIGGER_QUERY);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -18,17 +18,26 @@ div.widget .chart-controls {
|
||||
right: 0;
|
||||
top: 5px;
|
||||
padding: 5px 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
div.widget:hover .chart-controls {
|
||||
opacity: 0.75;
|
||||
display: none;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
.slice-grid div.widget {
|
||||
border-radius: 0;
|
||||
border: 0px;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background-color: #fff;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.slice-grid .slice_container {
|
||||
background-color: #fff;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.dashboard .slice-grid .dragging,
|
||||
.dashboard .slice-grid .resizing {
|
||||
opacity: 0.5;
|
||||
@ -84,10 +93,12 @@ div.widget .chart-controls {
|
||||
.slice-cell {
|
||||
box-shadow: 0px 0px 20px 5px rgba(0,0,0,0);
|
||||
transition: box-shadow 1s ease-in;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slice-cell-highlight {
|
||||
box-shadow: 0px 0px 20px 5px rgba(0,0,0,0.2);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slice-cell .editable-title input[type="button"] {
|
||||
@ -95,7 +106,7 @@ div.widget .chart-controls {
|
||||
}
|
||||
|
||||
.dashboard .separator.widget .slice_container {
|
||||
padding: 0px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.dashboard .separator.widget .slice_container hr {
|
||||
@ -116,6 +127,8 @@ div.widget .chart-controls {
|
||||
|
||||
.dashboard .title .favstar {
|
||||
font-size: 20px;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
}
|
||||
|
||||
.chart-header .header {
|
||||
|
@ -189,8 +189,23 @@ div.widget .chart-header a {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.widget .slice_container {
|
||||
overflow: hidden;
|
||||
div.widget {
|
||||
.slice_container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stack-trace-container.has-trace {
|
||||
.alert-warning:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
.stack-trace-container,
|
||||
.slice_container {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar .alert {
|
||||
|
@ -22,7 +22,7 @@ function markupWidget(slice, payload) {
|
||||
jqdiv.html(`
|
||||
<iframe id="${iframeId}"
|
||||
frameborder="0"
|
||||
height="${slice.height()}"
|
||||
height="${slice.height() - 20}"
|
||||
sandbox="allow-same-origin allow-scripts allow-top-navigation allow-popups">
|
||||
</iframe>
|
||||
`);
|
||||
|
@ -19,7 +19,7 @@ const config = {
|
||||
common: APP_DIR + '/javascripts/common.js',
|
||||
addSlice: ['babel-polyfill', APP_DIR + '/javascripts/addSlice/index.jsx'],
|
||||
explore: ['babel-polyfill', APP_DIR + '/javascripts/explore/index.jsx'],
|
||||
dashboard: ['babel-polyfill', APP_DIR + '/javascripts/dashboard/Dashboard.jsx'],
|
||||
dashboard: ['babel-polyfill', APP_DIR + '/javascripts/dashboard/index.jsx'],
|
||||
sqllab: ['babel-polyfill', APP_DIR + '/javascripts/SqlLab/index.jsx'],
|
||||
welcome: ['babel-polyfill', APP_DIR + '/javascripts/welcome.js'],
|
||||
profile: ['babel-polyfill', APP_DIR + '/javascripts/profile/index.jsx'],
|
||||
|
@ -6,11 +6,5 @@
|
||||
class="dashboard container-fluid"
|
||||
data-bootstrap="{{ bootstrap_data }}"
|
||||
>
|
||||
<div id="alert-container"></div>
|
||||
<div id="dashboard-header"></div>
|
||||
|
||||
<!-- gridster class used for backwards compatibility -->
|
||||
<div id="grid-container" class="slice-grid gridster"></div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
Loading…
Reference in New Issue
Block a user