diff --git a/superset/assets/javascripts/chart/Chart.jsx b/superset/assets/javascripts/chart/Chart.jsx new file mode 100644 index 0000000000..f775e8900f --- /dev/null +++ b/superset/assets/javascripts/chart/Chart.jsx @@ -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 ( +
+ {isLoading && + + } + + {this.props.chartAlert && + + } + + {!this.props.chartAlert && + { + this.container = inner; + }} + /> + } +
+ ); + } +} + +Chart.propTypes = propTypes; +Chart.defaultProps = defaultProps; + +export default Chart; diff --git a/superset/assets/javascripts/chart/ChartBody.jsx b/superset/assets/javascripts/chart/ChartBody.jsx new file mode 100644 index 0000000000..89352f58b3 --- /dev/null +++ b/superset/assets/javascripts/chart/ChartBody.jsx @@ -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 ( +
{ this.el = el; }} + /> + ); + } +} + +ChartBody.propTypes = propTypes; + +export default ChartBody; diff --git a/superset/assets/javascripts/chart/ChartContainer.jsx b/superset/assets/javascripts/chart/ChartContainer.jsx new file mode 100644 index 0000000000..11c432221f --- /dev/null +++ b/superset/assets/javascripts/chart/ChartContainer.jsx @@ -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); diff --git a/superset/assets/javascripts/chart/chartAction.js b/superset/assets/javascripts/chart/chartAction.js new file mode 100644 index 0000000000..17205a41a3 --- /dev/null +++ b/superset/assets/javascripts/chart/chartAction.js @@ -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)); + }; +} diff --git a/superset/assets/javascripts/chart/chartReducer.js b/superset/assets/javascripts/chart/chartReducer.js new file mode 100644 index 0000000000..2adb904687 --- /dev/null +++ b/superset/assets/javascripts/chart/chartReducer.js @@ -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: ( + "{t('Query timeout')} - " + + 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; +} diff --git a/superset/assets/javascripts/components/FaveStar.jsx b/superset/assets/javascripts/components/FaveStar.jsx index e6332474d3..60de9d1439 100644 --- a/superset/assets/javascripts/components/FaveStar.jsx +++ b/superset/assets/javascripts/components/FaveStar.jsx @@ -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() { diff --git a/superset/assets/javascripts/components/StackTraceMessage.jsx b/superset/assets/javascripts/components/StackTraceMessage.jsx new file mode 100644 index 0000000000..a950c39c17 --- /dev/null +++ b/superset/assets/javascripts/components/StackTraceMessage.jsx @@ -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 = ( +
+

+

); + + return ( +
+ this.setState({ showStackTrace: !this.state.showStackTrace })} + > + {msg} + + {this.hasTrace() && + +
+              {this.props.queryResponse.stacktrace}
+            
+
+ } +
+ ); + } +} + +StackTraceMessage.propTypes = propTypes; +StackTraceMessage.defaultProps = defaultProps; + +export default StackTraceMessage; diff --git a/superset/assets/javascripts/dashboard/Dashboard.jsx b/superset/assets/javascripts/dashboard/Dashboard.jsx index f1334245d8..9e67647258 100644 --- a/superset/assets/javascripts/dashboard/Dashboard.jsx +++ b/superset/assets/javascripts/dashboard/Dashboard.jsx @@ -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(); }); diff --git a/superset/assets/javascripts/dashboard/actions.js b/superset/assets/javascripts/dashboard/actions.js new file mode 100644 index 0000000000..6e88ca6404 --- /dev/null +++ b/superset/assets/javascripts/dashboard/actions.js @@ -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 }; +} diff --git a/superset/assets/javascripts/dashboard/components/Controls.jsx b/superset/assets/javascripts/dashboard/components/Controls.jsx index 5d2405578e..ecbc907ef0 100644 --- a/superset/assets/javascripts/dashboard/components/Controls.jsx +++ b/superset/assets/javascripts/dashboard/components/Controls.jsx @@ -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 { } /> dashboard.startPeriodicRender(refreshInterval * 1000)} + onChange={refreshInterval => startPeriodicRender(refreshInterval * 1000)} triggerNode={ } /> } /> diff --git a/superset/assets/javascripts/dashboard/components/Dashboard.jsx b/superset/assets/javascripts/dashboard/components/Dashboard.jsx new file mode 100644 index 0000000000..2415e3692d --- /dev/null +++ b/superset/assets/javascripts/dashboard/components/Dashboard.jsx @@ -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: ( + + {t('You have unsaved changes.')} {t('Click the')}   +   + {t('button on the top right to save your changes.')} + + ), + }); + } + + render() { + return ( +
+ {this.state.alert && } +
+ +
+
+
+ +
+
+ ); + } +} + +Dashboard.propTypes = propTypes; +Dashboard.defaultProps = defaultProps; + +export default Dashboard; diff --git a/superset/assets/javascripts/dashboard/components/DashboardAlert.jsx b/superset/assets/javascripts/dashboard/components/DashboardAlert.jsx new file mode 100644 index 0000000000..4579ce880d --- /dev/null +++ b/superset/assets/javascripts/dashboard/components/DashboardAlert.jsx @@ -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 }) => ( +
+
+ + {alertContent} + +
+
+); + +DashboardAlert.propTypes = propTypes; + +export default DashboardAlert; diff --git a/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx b/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx new file mode 100644 index 0000000000..24127aaa67 --- /dev/null +++ b/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx @@ -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); diff --git a/superset/assets/javascripts/dashboard/components/GridCell.jsx b/superset/assets/javascripts/dashboard/components/GridCell.jsx new file mode 100644 index 0000000000..1a59a92dd6 --- /dev/null +++ b/superset/assets/javascripts/dashboard/components/GridCell.jsx @@ -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 ( +
+
+ +
+
+
+ + +
+
+ ); + } +} + +GridCell.propTypes = propTypes; +GridCell.defaultProps = defaultProps; + +export default GridCell; diff --git a/superset/assets/javascripts/dashboard/components/GridLayout.jsx b/superset/assets/javascripts/dashboard/components/GridLayout.jsx index dc995035aa..22d4b59c44 100644 --- a/superset/assets/javascripts/dashboard/components/GridLayout.jsx +++ b/superset/assets/javascripts/dashboard/components/GridLayout.jsx @@ -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 ( - {this.state.slices.map(slice => ( + {this.props.dashboard.slices.map(slice => (
-
))} @@ -156,5 +179,6 @@ class GridLayout extends React.Component { } GridLayout.propTypes = propTypes; +GridLayout.defaultProps = defaultProps; export default GridLayout; diff --git a/superset/assets/javascripts/dashboard/components/Header.jsx b/superset/assets/javascripts/dashboard/components/Header.jsx index a1ab0e8e55..dfba7e86f1 100644 --- a/superset/assets/javascripts/dashboard/components/Header.jsx +++ b/superset/assets/javascripts/dashboard/components/Header.jsx @@ -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.'} /> - + + +
{!this.props.dashboard.standalone_mode && - + }
@@ -46,6 +73,5 @@ class Header extends React.PureComponent { } } Header.propTypes = propTypes; -Header.defaultProps = defaultProps; export default Header; diff --git a/superset/assets/javascripts/dashboard/components/SaveModal.jsx b/superset/assets/javascripts/dashboard/components/SaveModal.jsx index f35eb636fe..cc91daee4c 100644 --- a/superset/assets/javascripts/dashboard/components/SaveModal.jsx +++ b/superset/assets/javascripts/dashboard/components/SaveModal.jsx @@ -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; diff --git a/superset/assets/javascripts/dashboard/components/SliceAdder.jsx b/superset/assets/javascripts/dashboard/components/SliceAdder.jsx index 4c5f462a02..03e0cb80c7 100644 --- a/superset/assets/javascripts/dashboard/components/SliceAdder.jsx +++ b/superset/assets/javascripts/dashboard/components/SliceAdder.jsx @@ -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) { diff --git a/superset/assets/javascripts/dashboard/components/SliceCell.jsx b/superset/assets/javascripts/dashboard/components/SliceCell.jsx deleted file mode 100644 index 2fbdff31ba..0000000000 --- a/superset/assets/javascripts/dashboard/components/SliceCell.jsx +++ /dev/null @@ -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 ( -
-
-
-
- -
- -
-
-
-
- -
- loading -
-
-
-
- ); -}; - -SliceCell.propTypes = propTypes; - -export default SliceCell; diff --git a/superset/assets/javascripts/dashboard/components/SliceHeader.jsx b/superset/assets/javascripts/dashboard/components/SliceHeader.jsx new file mode 100644 index 0000000000..d1a2d9ec94 --- /dev/null +++ b/superset/assets/javascripts/dashboard/components/SliceHeader.jsx @@ -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 ( + + ); + } +} + +SliceHeader.propTypes = propTypes; +SliceHeader.defaultProps = defaultProps; + +export default SliceHeader; diff --git a/superset/assets/javascripts/dashboard/index.jsx b/superset/assets/javascripts/dashboard/index.jsx new file mode 100644 index 0000000000..774e07101f --- /dev/null +++ b/superset/assets/javascripts/dashboard/index.jsx @@ -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( + + + , + appContainer, +); + diff --git a/superset/assets/javascripts/dashboard/reducers.js b/superset/assets/javascripts/dashboard/reducers.js new file mode 100644 index 0000000000..8d7b7f483d --- /dev/null +++ b/superset/assets/javascripts/dashboard/reducers.js @@ -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, +}); diff --git a/superset/assets/javascripts/explore/actions/chartActions.js b/superset/assets/javascripts/explore/actions/chartActions.js deleted file mode 100644 index beca6ef8a2..0000000000 --- a/superset/assets/javascripts/explore/actions/chartActions.js +++ /dev/null @@ -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)); - }; -} diff --git a/superset/assets/javascripts/explore/actions/exploreActions.js b/superset/assets/javascripts/explore/actions/exploreActions.js index dbba7b7fbe..b5be4d351e 100644 --- a/superset/assets/javascripts/explore/actions/exploreActions.js +++ b/superset/assets/javascripts/explore/actions/exploreActions.js @@ -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 }; diff --git a/superset/assets/javascripts/explore/components/ChartContainer.jsx b/superset/assets/javascripts/explore/components/ChartContainer.jsx deleted file mode 100644 index f3c660adf6..0000000000 --- a/superset/assets/javascripts/explore/components/ChartContainer.jsx +++ /dev/null @@ -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 = ( -
- -

-

); - return ( -
- this.setState({ showStackTrace: !this.state.showStackTrace })} - > - {msg} - - {this.props.queryResponse && this.props.queryResponse.stacktrace && - -
-              {this.props.queryResponse.stacktrace}
-            
-
- } -
); - } - - renderChart() { - if (this.props.alert) { - return this.renderAlert(); - } - const loading = this.props.chartStatus === 'loading'; - return ( -
- {loading && - loading - } -
{ this.chartContainerRef = ref; }} - className={this.props.viz_type} - style={{ - opacity: loading ? '0.25' : '1', - }} - /> -
- ); - } - - 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 ( -
- - - - {this.props.slice && - - - - - - - - - - } - -
- {this.props.chartStatus === 'success' && - this.props.queryResponse && - this.props.queryResponse.is_cached && - - } - - -
-
- } - > - {this.renderChart()} - -
- ); - } -} - -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); diff --git a/superset/assets/javascripts/explore/components/EmbedCodeButton.jsx b/superset/assets/javascripts/explore/components/EmbedCodeButton.jsx index c5615dc25a..01d59e1e2a 100644 --- a/superset/assets/javascripts/explore/components/EmbedCodeButton.jsx +++ b/superset/assets/javascripts/explore/components/EmbedCodeButton.jsx @@ -73,7 +73,7 @@ export default class EmbedCodeButton extends React.Component {
- +
- + { + 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 ( +
+ + + {this.props.slice && + + + + + + + + + + } + +
+ {this.props.chart.chartStatus === 'success' && + queryResponse && + queryResponse.is_cached && + + } + + +
+
+ ); + } +} + +ExploreChartHeader.propTypes = propTypes; + +export default ExploreChartHeader; diff --git a/superset/assets/javascripts/explore/components/ExploreChartPanel.jsx b/superset/assets/javascripts/explore/components/ExploreChartPanel.jsx new file mode 100644 index 0000000000..7834787c78 --- /dev/null +++ b/superset/assets/javascripts/explore/components/ExploreChartPanel.jsx @@ -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 = ( + ); + return ( +
+ + + +
+ ); + } +} + +ExploreChartPanel.propTypes = propTypes; + +export default ExploreChartPanel; diff --git a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx index f696ed6445..e3ea7f2a73 100644 --- a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx +++ b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx @@ -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 ( - ); } @@ -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()} />
@@ -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, }; } diff --git a/superset/assets/javascripts/explore/components/SaveModal.jsx b/superset/assets/javascripts/explore/components/SaveModal.jsx index 2939f2ef66..7b375c2a0a 100644 --- a/superset/assets/javascripts/explore/components/SaveModal.jsx +++ b/superset/assets/javascripts/explore/components/SaveModal.jsx @@ -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, }; diff --git a/superset/assets/javascripts/explore/index.jsx b/superset/assets/javascripts/explore/index.jsx index 049e731fe8..2247019f08 100644 --- a/superset/assets/javascripts/explore/index.jsx +++ b/superset/assets/javascripts/explore/index.jsx @@ -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: [], diff --git a/superset/assets/javascripts/explore/reducers/chartReducer.js b/superset/assets/javascripts/explore/reducers/chartReducer.js deleted file mode 100644 index 808d884071..0000000000 --- a/superset/assets/javascripts/explore/reducers/chartReducer.js +++ /dev/null @@ -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: ( - 'Query timeout - 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; -} diff --git a/superset/assets/javascripts/explore/reducers/exploreReducer.js b/superset/assets/javascripts/explore/reducers/exploreReducer.js index e37df6eb9f..7b55748800 100644 --- a/superset/assets/javascripts/explore/reducers/exploreReducer.js +++ b/superset/assets/javascripts/explore/reducers/exploreReducer.js @@ -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, diff --git a/superset/assets/javascripts/explore/reducers/index.js b/superset/assets/javascripts/explore/reducers/index.js index 0d5acb04c7..22f7e8f303 100644 --- a/superset/assets/javascripts/explore/reducers/index.js +++ b/superset/assets/javascripts/explore/reducers/index.js @@ -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, }); diff --git a/superset/assets/javascripts/modules/utils.js b/superset/assets/javascripts/modules/utils.js index 83ec2c0bbf..e7757d4b06 100644 --- a/superset/assets/javascripts/modules/utils.js +++ b/superset/assets/javascripts/modules/utils.js @@ -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, ' ')); +} diff --git a/superset/assets/javascripts/reduxUtils.js b/superset/assets/javascripts/reduxUtils.js index fc42083d56..abe2d7f231 100644 --- a/superset/assets/javascripts/reduxUtils.js +++ b/superset/assets/javascripts/reduxUtils.js @@ -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); } diff --git a/superset/assets/spec/javascripts/dashboard/SliceCell_spec.jsx b/superset/assets/spec/javascripts/dashboard/SliceCell_spec.jsx deleted file mode 100644 index 8dbf661d20..0000000000 --- a/superset/assets/spec/javascripts/dashboard/SliceCell_spec.jsx +++ /dev/null @@ -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(), - ).to.equal(true); - }); - it('renders six links', () => { - const wrapper = mount(); - expect(wrapper.find('a')).to.have.length(6); - }); -}); diff --git a/superset/assets/spec/javascripts/dashboard/fixtures.jsx b/superset/assets/spec/javascripts/dashboard/fixtures.jsx index 7c822d78f9..be515e39a8 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx +++ b/superset/assets/spec/javascripts/dashboard/fixtures.jsx @@ -66,5 +66,5 @@ export const contextData = { dash_save_perm: true, standalone_mode: false, dash_edit_perm: true, - user_id: '1', + userId: '1', }; diff --git a/superset/assets/spec/javascripts/explore/chartActions_spec.js b/superset/assets/spec/javascripts/explore/chartActions_spec.js index b2e069ab97..f88de8f955 100644 --- a/superset/assets/spec/javascripts/explore/chartActions_spec.js +++ b/superset/assets/spec/javascripts/explore/chartActions_spec.js @@ -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; diff --git a/superset/assets/spec/javascripts/explore/components/ChartContainer_spec.js b/superset/assets/spec/javascripts/explore/components/ExploreChartPanel_spec.js similarity index 100% rename from superset/assets/spec/javascripts/explore/components/ChartContainer_spec.js rename to superset/assets/spec/javascripts/explore/components/ExploreChartPanel_spec.js diff --git a/superset/assets/spec/javascripts/explore/components/SaveModal_spec.jsx b/superset/assets/spec/javascripts/explore/components/SaveModal_spec.jsx index e548d21a60..346fedac4b 100644 --- a/superset/assets/spec/javascripts/explore/components/SaveModal_spec.jsx +++ b/superset/assets/spec/javascripts/explore/components/SaveModal_spec.jsx @@ -24,7 +24,7 @@ describe('SaveModal', () => { }, explore: { can_overwrite: true, - user_id: '1', + userId: '1', datasource: {}, slice: { slice_id: 1, diff --git a/superset/assets/spec/javascripts/explore/exploreActions_spec.js b/superset/assets/spec/javascripts/explore/exploreActions_spec.js index 5d2926de2e..d37fc46dcc 100644 --- a/superset/assets/spec/javascripts/explore/exploreActions_spec.js +++ b/superset/assets/spec/javascripts/explore/exploreActions_spec.js @@ -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); }); }); }); diff --git a/superset/assets/stylesheets/dashboard.css b/superset/assets/stylesheets/dashboard.css index 289ead3852..b110311046 100644 --- a/superset/assets/stylesheets/dashboard.css +++ b/superset/assets/stylesheets/dashboard.css @@ -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 { diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less index ea43e54b7e..a42c8ba565 100644 --- a/superset/assets/stylesheets/superset.less +++ b/superset/assets/stylesheets/superset.less @@ -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 { diff --git a/superset/assets/visualizations/markup.js b/superset/assets/visualizations/markup.js index 739e4510d9..8b437162e2 100644 --- a/superset/assets/visualizations/markup.js +++ b/superset/assets/visualizations/markup.js @@ -22,7 +22,7 @@ function markupWidget(slice, payload) { jqdiv.html(` `); diff --git a/superset/assets/webpack.config.js b/superset/assets/webpack.config.js index bb1729c144..ca1465e703 100644 --- a/superset/assets/webpack.config.js +++ b/superset/assets/webpack.config.js @@ -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'], diff --git a/superset/templates/superset/dashboard.html b/superset/templates/superset/dashboard.html index bb0b97cf6f..1a158d92a7 100644 --- a/superset/templates/superset/dashboard.html +++ b/superset/templates/superset/dashboard.html @@ -6,11 +6,5 @@ class="dashboard container-fluid" data-bootstrap="{{ bootstrap_data }}" > -
-
- - -
-
{% endblock %}