Dashboard refactory (#3581)

Create Chart component for all chart fetching and rendering, and apply redux architecture in dashboard view.
This commit is contained in:
Grace Guo 2017-11-08 10:46:21 -08:00 committed by GitHub
parent 39e502faae
commit 4fa1f0ab17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 2048 additions and 876 deletions

View 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;

View 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;

View 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);

View 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));
};
}

View 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;
}

View File

@ -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() {

View 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;

View File

@ -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();
});

View 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 };
}

View File

@ -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}>

View 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')} &nbsp;
<i className="fa fa-save" />&nbsp;
{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;

View File

@ -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;

View File

@ -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);

View 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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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) {

View File

@ -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;

View 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;

View 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,
);

View 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,
});

View File

@ -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));
};
}

View File

@ -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 };

View File

@ -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);

View File

@ -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"

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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,
};

View File

@ -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: [],

View File

@ -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;
}

View File

@ -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,

View File

@ -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,
});

View File

@ -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, ' '));
}

View File

@ -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);
}

View File

@ -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);
});
});

View File

@ -66,5 +66,5 @@ export const contextData = {
dash_save_perm: true,
standalone_mode: false,
dash_edit_perm: true,
user_id: '1',
userId: '1',
};

View File

@ -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;

View File

@ -24,7 +24,7 @@ describe('SaveModal', () => {
},
explore: {
can_overwrite: true,
user_id: '1',
userId: '1',
datasource: {},
slice: {
slice_id: 1,

View File

@ -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);
});
});
});

View File

@ -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 {

View File

@ -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 {

View File

@ -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>
`);

View File

@ -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'],

View File

@ -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 %}