diff --git a/superset/assets/src/chart/Chart.jsx b/superset/assets/src/chart/Chart.jsx
index f223174efa526..1718fc78f5236 100644
--- a/superset/assets/src/chart/Chart.jsx
+++ b/superset/assets/src/chart/Chart.jsx
@@ -7,7 +7,7 @@ import { Tooltip } from 'react-bootstrap';
import { d3format } from '../modules/utils';
import ChartBody from './ChartBody';
import Loading from '../components/Loading';
-import { Logger, LOG_ACTIONS_RENDER_EVENT } from '../logger';
+import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger';
import StackTraceMessage from '../components/StackTraceMessage';
import RefreshChartOverlay from '../components/RefreshChartOverlay';
import visMap from '../visualizations';
@@ -17,7 +17,7 @@ import './chart.css';
const propTypes = {
annotationData: PropTypes.object,
actions: PropTypes.object,
- chartKey: PropTypes.string.isRequired,
+ chartId: PropTypes.number.isRequired,
containerId: PropTypes.string.isRequired,
datasource: PropTypes.object.isRequired,
formData: PropTypes.object.isRequired,
@@ -42,8 +42,6 @@ const propTypes = {
// dashboard callbacks
addFilter: PropTypes.func,
getFilters: PropTypes.func,
- clearFilter: PropTypes.func,
- removeFilter: PropTypes.func,
onQuery: PropTypes.func,
onDismissRefreshOverlay: PropTypes.func,
};
@@ -51,8 +49,6 @@ const propTypes = {
const defaultProps = {
addFilter: () => ({}),
getFilters: () => ({}),
- clearFilter: () => ({}),
- removeFilter: () => ({}),
};
class Chart extends React.PureComponent {
@@ -67,8 +63,6 @@ class Chart extends React.PureComponent {
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.headerHeight = this.headerHeight.bind(this);
this.height = this.height.bind(this);
this.width = this.width.bind(this);
@@ -76,10 +70,11 @@ class Chart extends React.PureComponent {
componentDidMount() {
if (this.props.triggerQuery) {
- this.props.actions.runQuery(this.props.formData, false,
- this.props.timeout,
- this.props.chartKey,
- );
+ const { formData } = this.props;
+ this.props.actions.runQuery(formData, false, this.props.timeout, this.props.chartId);
+ } else {
+ // when drag/dropping in a dashboard, a chart may be unmounted/remounted but still have data
+ this.renderViz();
}
}
@@ -93,10 +88,10 @@ class Chart extends React.PureComponent {
componentDidUpdate(prevProps) {
if (
- this.props.queryResponse &&
- ['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
- !this.props.queryResponse.error && (
- prevProps.annotationData !== this.props.annotationData ||
+ this.props.queryResponse &&
+ ['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
+ !this.props.queryResponse.error &&
+ (prevProps.annotationData !== this.props.annotationData ||
prevProps.queryResponse !== this.props.queryResponse ||
prevProps.height !== this.props.height ||
prevProps.width !== this.props.width ||
@@ -118,20 +113,14 @@ class Chart extends React.PureComponent {
this.props.addFilter(col, vals, merge, refresh);
}
- clearFilter() {
- this.props.clearFilter();
- }
-
- removeFilter(col, vals, refresh = true) {
- this.props.removeFilter(col, vals, refresh);
- }
-
clearError() {
this.setState({ errorMsg: null });
}
width() {
- return this.props.width || this.container.el.offsetWidth;
+ return (
+ this.props.width || (this.container && this.container.el && this.container.el.offsetWidth)
+ );
}
headerHeight() {
@@ -139,7 +128,9 @@ class Chart extends React.PureComponent {
}
height() {
- return this.props.height || this.container.el.offsetHeight;
+ return (
+ this.props.height || (this.container && this.container.el && this.container.el.offsetHeight)
+ );
}
d3format(col, number) {
@@ -150,7 +141,7 @@ class Chart extends React.PureComponent {
}
error(e) {
- this.props.actions.chartRenderingFailed(e, this.props.chartKey);
+ this.props.actions.chartRenderingFailed(e, this.props.chartId);
}
verboseMetricName(metric) {
@@ -167,7 +158,6 @@ class Chart extends React.PureComponent {
renderTooltip() {
if (this.state.tooltip) {
- /* eslint-disable react/no-danger */
return (
);
- /* eslint-enable react/no-danger */
}
return null;
}
renderViz() {
- const viz = visMap[this.props.vizType];
- const fd = this.props.formData;
- const qr = this.props.queryResponse;
+ const { vizType, formData, queryResponse, setControlValue, chartId, chartStatus } = this.props;
+ const visRenderer = visMap[vizType];
const renderStart = Logger.getTimestamp();
try {
// Executing user-defined data mutator function
- if (fd.js_data) {
- qr.data = sandboxedEval(fd.js_data)(qr.data);
+ if (formData.js_data) {
+ queryResponse.data = sandboxedEval(formData.js_data)(queryResponse.data);
+ }
+ visRenderer(this, queryResponse, setControlValue);
+ if (chartStatus !== 'rendered') {
+ this.props.actions.chartRenderingSucceeded(chartId);
}
- // [re]rendering the visualization
- viz(this, qr, this.props.setControlValue);
- Logger.append(LOG_ACTIONS_RENDER_EVENT, {
- label: this.props.chartKey,
- vis_type: this.props.vizType,
+ Logger.append(LOG_ACTIONS_RENDER_CHART, {
+ slice_id: 'slice_' + chartId,
+ viz_type: vizType,
start_offset: renderStart,
duration: Logger.getTimestamp() - renderStart,
});
- this.props.actions.chartRenderingSucceeded(this.props.chartKey);
+ this.props.actions.chartRenderingSucceeded(chartId);
} catch (e) {
- this.props.actions.chartRenderingFailed(e, this.props.chartKey);
+ console.error(e); // eslint-disable-line no-console
+ this.props.actions.chartRenderingFailed(e, chartId);
}
}
render() {
const isLoading = this.props.chartStatus === 'loading';
+
+ // this allows
to be positioned in the middle of the chart
+ const containerStyles = isLoading ? { height: this.height(), width: this.width() } : null;
return (
-
+
{this.renderTooltip()}
- {isLoading &&
-
- }
- {this.props.chartAlert &&
-
- }
+ {isLoading && }
+ {this.props.chartAlert && (
+
+ )}
{!isLoading &&
!this.props.chartAlert &&
this.props.refreshOverlayVisible &&
!this.props.errorMessage &&
- this.container &&
-
- }
- {!isLoading && !this.props.chartAlert &&
- {
- this.container = inner;
- }}
- />
- }
+ this.container && (
+
+ )}
+
+ {!isLoading &&
+ !this.props.chartAlert && (
+ {
+ this.container = inner;
+ }}
+ />
+ )}
);
}
diff --git a/superset/assets/src/chart/ChartContainer.jsx b/superset/assets/src/chart/ChartContainer.jsx
index b731412fc5ff7..b66fe5d017b8e 100644
--- a/superset/assets/src/chart/ChartContainer.jsx
+++ b/superset/assets/src/chart/ChartContainer.jsx
@@ -1,29 +1,13 @@
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
-import * as Actions from './chartAction';
+import * as actions from './chartAction';
import Chart from './Chart';
-function mapStateToProps({ charts }, ownProps) {
- const chart = charts[ownProps.chartKey];
- return {
- annotationData: chart.annotationData,
- chartAlert: chart.chartAlert,
- chartStatus: chart.chartStatus,
- chartUpdateEndTime: chart.chartUpdateEndTime,
- chartUpdateStartTime: chart.chartUpdateStartTime,
- latestQueryFormData: chart.latestQueryFormData,
- lastRendered: chart.lastRendered,
- queryResponse: chart.queryResponse,
- queryRequest: chart.queryRequest,
- triggerQuery: chart.triggerQuery,
- };
-}
-
function mapDispatchToProps(dispatch) {
return {
- actions: bindActionCreators(Actions, dispatch),
+ actions: bindActionCreators(actions, dispatch),
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(Chart);
+export default connect(null, mapDispatchToProps)(Chart);
diff --git a/superset/assets/src/chart/chartAction.js b/superset/assets/src/chart/chartAction.js
index cb24f65f14705..82c4250a91e4b 100644
--- a/superset/assets/src/chart/chartAction.js
+++ b/superset/assets/src/chart/chartAction.js
@@ -1,10 +1,10 @@
import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
-import { Logger, LOG_ACTIONS_LOAD_EVENT } from '../logger';
+import { Logger, LOG_ACTIONS_LOAD_CHART } from '../logger';
import { COMMON_ERR_MESSAGES } from '../common';
import { t } from '../locales';
-const $ = window.$ = require('jquery');
+const $ = (window.$ = require('jquery'));
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
export function chartUpdateStarted(queryRequest, latestQueryFormData, key) {
@@ -74,11 +74,13 @@ export function runAnnotationQuery(annotation, timeout = 60, formData = null, ke
fd.time_grain_sqla = granularity;
fd.granularity = granularity;
- const sliceFormData = Object.keys(annotation.overrides)
- .reduce((d, k) => ({
+ const sliceFormData = Object.keys(annotation.overrides).reduce(
+ (d, k) => ({
...d,
[k]: annotation.overrides[k] || fd[k],
- }), {});
+ }),
+ {},
+ );
const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE;
const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative);
const queryRequest = $.ajax({
@@ -117,6 +119,11 @@ export function updateQueryFormData(value, key) {
return { type: UPDATE_QUERY_FORM_DATA, value, key };
}
+export const ADD_CHART = 'ADD_CHART';
+export function addChart(chart, key) {
+ return { type: ADD_CHART, chart, key };
+}
+
export const RUN_QUERY = 'RUN_QUERY';
export function runQuery(formData, force = false, timeout = 60, key) {
return (dispatch) => {
@@ -138,19 +145,22 @@ export function runQuery(formData, force = false, timeout = 60, key) {
const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, payload, key)))
.then(() => queryRequest)
.then((queryResponse) => {
- Logger.append(LOG_ACTIONS_LOAD_EVENT, {
- label: key,
+ Logger.append(LOG_ACTIONS_LOAD_CHART, {
+ slice_id: 'slice_' + key,
is_cached: queryResponse.is_cached,
+ force_refresh: force,
row_count: queryResponse.rowcount,
datasource: formData.datasource,
start_offset: logStart,
duration: Logger.getTimestamp() - logStart,
+ has_extra_filters: formData.extra_filters && formData.extra_filters.length > 0,
+ viz_type: formData.viz_type,
});
return dispatch(chartUpdateSucceeded(queryResponse, key));
})
.catch((err) => {
- Logger.append(LOG_ACTIONS_LOAD_EVENT, {
- label: key,
+ Logger.append(LOG_ACTIONS_LOAD_CHART, {
+ slice_id: 'slice_' + key,
has_err: true,
datasource: formData.datasource,
start_offset: logStart,
@@ -190,3 +200,7 @@ export function runQuery(formData, force = false, timeout = 60, key) {
]);
};
}
+
+export function refreshChart(chart, force, timeout) {
+ return dispatch => dispatch(runQuery(chart.latestQueryFormData, force, timeout, chart.id));
+}
diff --git a/superset/assets/src/chart/chartReducer.js b/superset/assets/src/chart/chartReducer.js
index f68a5b80eef4a..ea8de8b54d9ea 100644
--- a/superset/assets/src/chart/chartReducer.js
+++ b/superset/assets/src/chart/chartReducer.js
@@ -1,25 +1,10 @@
/* 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,
- queryRequest: PropTypes.object,
- queryResponse: PropTypes.object,
- triggerQuery: PropTypes.bool,
- lastRendered: PropTypes.number,
-};
-
export const chart = {
- chartKey: '',
+ id: 0,
chartAlert: null,
chartStatus: 'loading',
chartUpdateEndTime: null,
@@ -33,6 +18,12 @@ export const chart = {
export default function chartReducer(charts = {}, action) {
const actionHandlers = {
+ [actions.ADD_CHART]() {
+ return {
+ ...chart,
+ ...action.chart,
+ };
+ },
[actions.CHART_UPDATE_SUCCEEDED](state) {
return { ...state,
chartStatus: 'success',
@@ -70,12 +61,12 @@ export default function chartReducer(charts = {}, action) {
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.')),
+ `${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) {
@@ -151,7 +142,10 @@ export default function chartReducer(charts = {}, action) {
}
if (action.type in actionHandlers) {
- return { ...charts, [action.key]: actionHandlers[action.type](charts[action.key], action) };
+ return {
+ ...charts,
+ [action.key]: actionHandlers[action.type](charts[action.key], action),
+ };
}
return charts;
diff --git a/superset/assets/src/components/ActionMenuItem.jsx b/superset/assets/src/components/ActionMenuItem.jsx
new file mode 100644
index 0000000000000..e6c44478b655b
--- /dev/null
+++ b/superset/assets/src/components/ActionMenuItem.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { MenuItem } from 'react-bootstrap';
+
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
+
+export function MenuItemContent({ faIcon, text, tooltip, children }) {
+ return (
+
+ {faIcon && }
+ {text} {''}
+
+ {children}
+
+ );
+}
+
+MenuItemContent.propTypes = {
+ faIcon: PropTypes.string,
+ text: PropTypes.string,
+ tooltip: PropTypes.string,
+ children: PropTypes.node,
+};
+
+MenuItemContent.defaultProps = {
+ faIcon: '',
+ text: '',
+ tooltip: null,
+ children: null,
+};
+
+export function ActionMenuItem({
+ onClick,
+ href,
+ target,
+ text,
+ tooltip,
+ children,
+ faIcon,
+}) {
+ return (
+
+ );
+}
+
+ActionMenuItem.propTypes = {
+ onClick: PropTypes.func,
+ href: PropTypes.string,
+ target: PropTypes.string,
+ ...MenuItemContent.propTypes,
+};
+
+ActionMenuItem.defaultProps = {
+ onClick() {},
+ href: null,
+ target: null,
+};
diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx
index b773340846622..45fea1dcb030d 100644
--- a/superset/assets/src/components/EditableTitle.jsx
+++ b/superset/assets/src/components/EditableTitle.jsx
@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
+import cx from 'classnames';
import TooltipWrapper from './TooltipWrapper';
import { t } from '../locales';
@@ -27,8 +28,10 @@ class EditableTitle extends React.PureComponent {
this.handleClick = this.handleClick.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleChange = this.handleChange.bind(this);
+ this.handleKeyUp = this.handleKeyUp.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
}
+
componentWillReceiveProps(nextProps) {
if (nextProps.title !== this.state.title) {
this.setState({
@@ -37,8 +40,9 @@ class EditableTitle extends React.PureComponent {
});
}
}
+
handleClick() {
- if (!this.props.canEdit) {
+ if (!this.props.canEdit || this.state.isEditing) {
return;
}
@@ -46,6 +50,7 @@ class EditableTitle extends React.PureComponent {
isEditing: true,
});
}
+
handleBlur() {
if (!this.props.canEdit) {
return;
@@ -67,9 +72,31 @@ class EditableTitle extends React.PureComponent {
this.setState({
lastTitle: this.state.title,
});
+ }
+
+ if (this.props.title !== this.state.title) {
this.props.onSaveTitle(this.state.title);
}
}
+
+ handleKeyUp(ev) {
+ // this entire method exists to support using EditableTitle as the title of a
+ // react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
+ //
+ // tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
+ // clicked and is focused/active. for accessibility, when focused the Tab
intercepts
+ // the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
+ // keydown is still called so we can detect this and manually add a ' ' to the current title
+ if (ev.key === ' ') {
+ let title = ev.target.value;
+ const titleLength = (title || '').length;
+ if (title && title[titleLength - 1] !== ' ') {
+ title = `${title} `;
+ this.setState(() => ({ title }));
+ }
+ }
+ }
+
handleChange(ev) {
if (!this.props.canEdit) {
return;
@@ -79,6 +106,7 @@ class EditableTitle extends React.PureComponent {
title: ev.target.value,
});
}
+
handleKeyPress(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
@@ -86,12 +114,14 @@ class EditableTitle extends React.PureComponent {
this.handleBlur();
}
}
+
render() {
- let input = (
+ let content = (
);
if (this.props.showTooltip) {
- input = (
+ content = (
- {input}
+ {content}
);
}
return (
-
{input}
+
+ {content}
+
);
}
}
diff --git a/superset/assets/src/components/Loading.jsx b/superset/assets/src/components/Loading.jsx
index 416e7702959e1..953e702ac4c51 100644
--- a/superset/assets/src/components/Loading.jsx
+++ b/superset/assets/src/components/Loading.jsx
@@ -5,7 +5,7 @@ const propTypes = {
size: PropTypes.number,
};
const defaultProps = {
- size: 25,
+ size: 50,
};
export default function Loading(props) {
@@ -15,14 +15,18 @@ export default function Loading(props) {
alt="Loading..."
src="/static/assets/images/loading.gif"
style={{
- width: props.size,
- height: props.size,
+ width: Math.min(props.size, 50),
+ // height is auto
padding: 0,
margin: 0,
position: 'absolute',
+ left: '50%',
+ top: '50%',
+ transform: 'translate(-50%, -50%)',
}}
/>
);
}
+
Loading.propTypes = propTypes;
Loading.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/.eslintrc b/superset/assets/src/dashboard/.eslintrc
new file mode 100644
index 0000000000000..a3f86e3a17a0c
--- /dev/null
+++ b/superset/assets/src/dashboard/.eslintrc
@@ -0,0 +1,33 @@
+{
+ "extends": "prettier",
+ "plugins": ["prettier"],
+ "rules": {
+ "prefer-template": 2,
+ "new-cap": 2,
+ "no-restricted-syntax": 2,
+ "guard-for-in": 2,
+ "prefer-arrow-callback": 2,
+ "func-names": 2,
+ "react/jsx-no-bind": 2,
+ "no-confusing-arrow": 2,
+ "jsx-a11y/no-static-element-interactions": 2,
+ "jsx-a11y/anchor-has-content": 2,
+ "react/require-default-props": 2,
+ "no-plusplus": 2,
+ "no-mixed-operators": 0,
+ "no-continue": 2,
+ "no-bitwise": 2,
+ "no-undef": 2,
+ "no-multi-assign": 2,
+ "no-restricted-properties": 2,
+ "no-prototype-builtins": 2,
+ "jsx-a11y/href-no-hash": 2,
+ "class-methods-use-this": 2,
+ "import/no-named-as-default": 2,
+ "import/prefer-default-export": 2,
+ "react/no-unescaped-entities": 2,
+ "react/no-string-refs": 2,
+ "react/jsx-indent": 0,
+ "prettier/prettier": "error"
+ }
+}
diff --git a/superset/assets/src/dashboard/.prettierrc b/superset/assets/src/dashboard/.prettierrc
new file mode 100644
index 0000000000000..a20502b7f06d8
--- /dev/null
+++ b/superset/assets/src/dashboard/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all"
+}
diff --git a/superset/assets/src/dashboard/actions/dashboardLayout.js b/superset/assets/src/dashboard/actions/dashboardLayout.js
new file mode 100644
index 0000000000000..bd01146143487
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/dashboardLayout.js
@@ -0,0 +1,203 @@
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import { addInfoToast } from './messageToasts';
+import { setUnsavedChanges } from './dashboardState';
+import { TABS_TYPE, ROW_TYPE } from '../util/componentTypes';
+import {
+ DASHBOARD_ROOT_ID,
+ NEW_COMPONENTS_SOURCE_ID,
+ DASHBOARD_HEADER_ID,
+} from '../util/constants';
+import dropOverflowsParent from '../util/dropOverflowsParent';
+import findParentId from '../util/findParentId';
+
+// this is a helper that takes an action as input and dispatches
+// an additional setUnsavedChanges(true) action after the dispatch in the case
+// that dashboardState.hasUnsavedChanges is false.
+function setUnsavedChangesAfterAction(action) {
+ return (...args) => (dispatch, getState) => {
+ const result = action(...args);
+ if (typeof result === 'function') {
+ dispatch(result(dispatch, getState));
+ } else {
+ dispatch(result);
+ }
+
+ if (!getState().dashboardState.hasUnsavedChanges) {
+ dispatch(setUnsavedChanges(true));
+ }
+ };
+}
+
+// Component CRUD -------------------------------------------------------------
+export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
+
+export const updateComponents = setUnsavedChangesAfterAction(
+ nextComponents => ({
+ type: UPDATE_COMPONENTS,
+ payload: {
+ nextComponents,
+ },
+ }),
+);
+
+export function updateDashboardTitle(text) {
+ return (dispatch, getState) => {
+ const { dashboardLayout } = getState();
+ dispatch(
+ updateComponents({
+ [DASHBOARD_HEADER_ID]: {
+ ...dashboardLayout.present[DASHBOARD_HEADER_ID],
+ meta: {
+ text,
+ },
+ },
+ }),
+ );
+ };
+}
+
+export const DELETE_COMPONENT = 'DELETE_COMPONENT';
+export const deleteComponent = setUnsavedChangesAfterAction((id, parentId) => ({
+ type: DELETE_COMPONENT,
+ payload: {
+ id,
+ parentId,
+ },
+}));
+
+export const CREATE_COMPONENT = 'CREATE_COMPONENT';
+export const createComponent = setUnsavedChangesAfterAction(dropResult => ({
+ type: CREATE_COMPONENT,
+ payload: {
+ dropResult,
+ },
+}));
+
+// Tabs -----------------------------------------------------------------------
+export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
+export const createTopLevelTabs = setUnsavedChangesAfterAction(dropResult => ({
+ type: CREATE_TOP_LEVEL_TABS,
+ payload: {
+ dropResult,
+ },
+}));
+
+export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
+export const deleteTopLevelTabs = setUnsavedChangesAfterAction(() => ({
+ type: DELETE_TOP_LEVEL_TABS,
+ payload: {},
+}));
+
+// Resize ---------------------------------------------------------------------
+export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
+export function resizeComponent({ id, width, height }) {
+ return (dispatch, getState) => {
+ const { dashboardLayout: undoableLayout } = getState();
+ const { present: dashboard } = undoableLayout;
+ const component = dashboard[id];
+ const widthChanged = width && component.meta.width !== width;
+ const heightChanged = height && component.meta.height !== height;
+ if (component && (widthChanged || heightChanged)) {
+ // update the size of this component
+ const updatedComponents = {
+ [id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ width: width || component.meta.width,
+ height: height || component.meta.height,
+ },
+ },
+ };
+
+ dispatch(updateComponents(updatedComponents));
+ }
+ };
+}
+
+// Drag and drop --------------------------------------------------------------
+export const MOVE_COMPONENT = 'MOVE_COMPONENT';
+const moveComponent = setUnsavedChangesAfterAction(dropResult => ({
+ type: MOVE_COMPONENT,
+ payload: {
+ dropResult,
+ },
+}));
+
+export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
+export function handleComponentDrop(dropResult) {
+ return (dispatch, getState) => {
+ const overflowsParent = dropOverflowsParent(
+ dropResult,
+ getState().dashboardLayout.present,
+ );
+
+ if (overflowsParent) {
+ return dispatch(
+ addInfoToast(
+ `There is not enough space for this component. Try decreasing its width, or increasing the destination width.`,
+ ),
+ );
+ }
+
+ const { source, destination } = dropResult;
+ const droppedOnRoot = destination && destination.id === DASHBOARD_ROOT_ID;
+ const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+
+ if (droppedOnRoot) {
+ dispatch(createTopLevelTabs(dropResult));
+ } else if (destination && isNewComponent) {
+ dispatch(createComponent(dropResult));
+ } else if (
+ destination &&
+ source &&
+ !// ensure it has moved
+ (destination.id === source.id && destination.index === source.index)
+ ) {
+ dispatch(moveComponent(dropResult));
+ }
+
+ const { dashboardLayout: undoableLayout } = getState();
+
+ // if we moved a child from a Tab or Row parent and it was the only child, delete the parent.
+ if (!isNewComponent) {
+ const { present: layout } = undoableLayout;
+ const sourceComponent = layout[source.id];
+ if (
+ (sourceComponent.type === TABS_TYPE ||
+ sourceComponent.type === ROW_TYPE) &&
+ sourceComponent.children &&
+ sourceComponent.children.length === 0
+ ) {
+ const parentId = findParentId({
+ childId: source.id,
+ layout,
+ });
+ dispatch(deleteComponent(source.id, parentId));
+ }
+ }
+
+ return null;
+ };
+}
+
+// Undo redo ------------------------------------------------------------------
+export function undoLayoutAction() {
+ return (dispatch, getState) => {
+ dispatch(UndoActionCreators.undo());
+
+ const { dashboardLayout, dashboardState } = getState();
+
+ if (
+ dashboardLayout.past.length === 0 &&
+ !dashboardState.maxUndoHistoryExceeded
+ ) {
+ dispatch(setUnsavedChanges(false));
+ }
+ };
+}
+
+export const redoLayoutAction = setUnsavedChangesAfterAction(
+ UndoActionCreators.redo,
+);
diff --git a/superset/assets/src/dashboard/actions/dashboardState.js b/superset/assets/src/dashboard/actions/dashboardState.js
new file mode 100644
index 0000000000000..688f5b02fad0c
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/dashboardState.js
@@ -0,0 +1,254 @@
+/* eslint camelcase: 0 */
+import $ from 'jquery';
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import { addChart, removeChart, refreshChart } from '../../chart/chartAction';
+import { chart as initChart } from '../../chart/chartReducer';
+import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources';
+import { applyDefaultFormData } from '../../explore/store';
+import { getAjaxErrorMsg } from '../../modules/utils';
+import {
+ Logger,
+ LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
+ LOG_ACTIONS_REFRESH_DASHBOARD,
+} from '../../logger';
+import { SAVE_TYPE_OVERWRITE } from '../util/constants';
+import { t } from '../../locales';
+
+import {
+ addSuccessToast,
+ addWarningToast,
+ addDangerToast,
+} from './messageToasts';
+
+export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
+export function setUnsavedChanges(hasUnsavedChanges) {
+ return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } };
+}
+
+export const CHANGE_FILTER = 'CHANGE_FILTER';
+export function changeFilter(chart, col, vals, merge = true, refresh = true) {
+ Logger.append(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
+ id: chart.id,
+ column: col,
+ value_count: Array.isArray(vals) ? vals.length : (vals && 1) || 0,
+ merge,
+ refresh,
+ });
+ return { type: CHANGE_FILTER, chart, col, vals, merge, refresh };
+}
+
+export const ADD_SLICE = 'ADD_SLICE';
+export function addSlice(slice) {
+ return { type: ADD_SLICE, slice };
+}
+
+export const REMOVE_SLICE = 'REMOVE_SLICE';
+export function removeSlice(sliceId) {
+ return { type: REMOVE_SLICE, sliceId };
+}
+
+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 fetchFaveStarThunk(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 saveFaveStarThunk(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(sliceId) {
+ return { type: TOGGLE_EXPAND_SLICE, sliceId };
+}
+
+export const UPDATE_CSS = 'UPDATE_CSS';
+export function updateCss(css) {
+ return { type: UPDATE_CSS, css };
+}
+
+export const SET_EDIT_MODE = 'SET_EDIT_MODE';
+export function setEditMode(editMode) {
+ return { type: SET_EDIT_MODE, editMode };
+}
+
+export const ON_CHANGE = 'ON_CHANGE';
+export function onChange() {
+ return { type: ON_CHANGE };
+}
+
+export const ON_SAVE = 'ON_SAVE';
+export function onSave() {
+ return { type: ON_SAVE };
+}
+
+export function saveDashboardRequestSuccess() {
+ return dispatch => {
+ dispatch(onSave());
+ // clear layout undo history
+ dispatch(UndoActionCreators.clearHistory());
+ };
+}
+
+export function saveDashboardRequest(data, id, saveType) {
+ const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
+ const url = `/superset/${path}/${id}/`;
+ return dispatch =>
+ $.ajax({
+ type: 'POST',
+ url,
+ data: {
+ data: JSON.stringify(data),
+ },
+ success: () => {
+ dispatch(saveDashboardRequestSuccess());
+ dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
+ },
+ error: error => {
+ const errorMsg = getAjaxErrorMsg(error);
+ dispatch(
+ addDangerToast(
+ `${t('Sorry, there was an error saving this dashboard: ')}
+ ${errorMsg}`,
+ ),
+ );
+ },
+ });
+}
+
+export function fetchCharts(chartList = [], force = false, interval = 0) {
+ return (dispatch, getState) => {
+ Logger.append(LOG_ACTIONS_REFRESH_DASHBOARD, {
+ force,
+ interval,
+ chartCount: chartList.length,
+ });
+ const timeout = getState().dashboardInfo.common.conf
+ .SUPERSET_WEBSERVER_TIMEOUT;
+ if (!interval) {
+ chartList.forEach(chart => dispatch(refreshChart(chart, force, timeout)));
+ return;
+ }
+
+ const { metadata: meta } = getState().dashboardInfo;
+ 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 / (chartList.length - 1)
+ : 0;
+ chartList.forEach((chart, i) => {
+ setTimeout(
+ () => dispatch(refreshChart(chart, force, timeout)),
+ delay * i,
+ );
+ });
+ };
+}
+
+let refreshTimer = null;
+export function startPeriodicRender(interval) {
+ const stopPeriodicRender = () => {
+ if (refreshTimer) {
+ clearTimeout(refreshTimer);
+ refreshTimer = null;
+ }
+ };
+
+ return (dispatch, getState) => {
+ stopPeriodicRender();
+
+ const { metadata } = getState().dashboardInfo;
+ const immune = metadata.timed_refresh_immune_slices || [];
+ const refreshAll = () => {
+ const affected = Object.values(getState().charts).filter(
+ chart => immune.indexOf(chart.id) === -1,
+ );
+ return dispatch(fetchCharts(affected, true, interval * 0.2));
+ };
+ const fetchAndRender = () => {
+ refreshAll();
+ if (interval > 0) {
+ refreshTimer = setTimeout(fetchAndRender, interval);
+ }
+ };
+
+ fetchAndRender();
+ };
+}
+
+export const TOGGLE_BUILDER_PANE = 'TOGGLE_BUILDER_PANE';
+export function toggleBuilderPane() {
+ return { type: TOGGLE_BUILDER_PANE };
+}
+
+export function addSliceToDashboard(id) {
+ return (dispatch, getState) => {
+ const { sliceEntities } = getState();
+ const selectedSlice = sliceEntities.slices[id];
+ const form_data = selectedSlice.form_data;
+ const newChart = {
+ ...initChart,
+ id,
+ form_data,
+ formData: applyDefaultFormData(form_data),
+ };
+
+ return Promise.all([
+ dispatch(addChart(newChart, id)),
+ dispatch(fetchDatasourceMetadata(form_data.datasource)),
+ ]).then(() => dispatch(addSlice(selectedSlice)));
+ };
+}
+
+export function removeSliceFromDashboard(id) {
+ return dispatch => {
+ dispatch(removeSlice(id));
+ dispatch(removeChart(id));
+ };
+}
+
+// Undo history ---------------------------------------------------------------
+export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
+export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
+ return {
+ type: SET_MAX_UNDO_HISTORY_EXCEEDED,
+ payload: { maxUndoHistoryExceeded },
+ };
+}
+
+export function maxUndoHistoryToast() {
+ return (dispatch, getState) => {
+ const { dashboardLayout } = getState();
+ const historyLength = dashboardLayout.past.length;
+
+ return dispatch(
+ addWarningToast(
+ `You have used all ${historyLength} undo slots and will not be able to fully undo subsequent actions. You may save your current state to reset the history.`,
+ ),
+ );
+ };
+}
diff --git a/superset/assets/src/dashboard/actions/datasources.js b/superset/assets/src/dashboard/actions/datasources.js
new file mode 100644
index 0000000000000..d97296e9e8586
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/datasources.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+
+export const SET_DATASOURCE = 'SET_DATASOURCE';
+export function setDatasource(datasource, key) {
+ return { type: SET_DATASOURCE, datasource, key };
+}
+
+export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
+export function fetchDatasourceStarted(key) {
+ return { type: FETCH_DATASOURCE_STARTED, key };
+}
+
+export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED';
+export function fetchDatasourceFailed(error, key) {
+ return { type: FETCH_DATASOURCE_FAILED, error, key };
+}
+
+export function fetchDatasourceMetadata(key) {
+ return (dispatch, getState) => {
+ const { datasources } = getState();
+ const datasource = datasources[key];
+
+ if (datasource) {
+ return dispatch(setDatasource(datasource, key));
+ }
+
+ const url = `/superset/fetch_datasource_metadata?datasourceKey=${key}`;
+ return $.ajax({
+ type: 'GET',
+ url,
+ success: data => dispatch(setDatasource(data, key)),
+ error: error =>
+ dispatch(fetchDatasourceFailed(error.responseJSON.error, key)),
+ });
+ };
+}
diff --git a/superset/assets/src/dashboard/actions/messageToasts.js b/superset/assets/src/dashboard/actions/messageToasts.js
new file mode 100644
index 0000000000000..e5c04e6ca463a
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/messageToasts.js
@@ -0,0 +1,59 @@
+import shortid from 'shortid';
+
+import {
+ INFO_TOAST,
+ SUCCESS_TOAST,
+ WARNING_TOAST,
+ DANGER_TOAST,
+} from '../util/constants';
+
+function getToastUuid(type) {
+ return `${type}-${shortid.generate()}`;
+}
+
+export const ADD_TOAST = 'ADD_TOAST';
+export function addToast({ toastType, text, duration }) {
+ return {
+ type: ADD_TOAST,
+ payload: {
+ id: getToastUuid(toastType),
+ toastType,
+ text,
+ duration,
+ },
+ };
+}
+
+export const REMOVE_TOAST = 'REMOVE_TOAST';
+export function removeToast(id) {
+ return {
+ type: REMOVE_TOAST,
+ payload: {
+ id,
+ },
+ };
+}
+
+// Different types of toasts
+export const ADD_INFO_TOAST = 'ADD_INFO_TOAST';
+export function addInfoToast(text) {
+ return dispatch =>
+ dispatch(addToast({ text, toastType: INFO_TOAST, duration: 4000 }));
+}
+
+export const ADD_SUCCESS_TOAST = 'ADD_SUCCESS_TOAST';
+export function addSuccessToast(text) {
+ return dispatch =>
+ dispatch(addToast({ text, toastType: SUCCESS_TOAST, duration: 4000 }));
+}
+
+export const ADD_WARNING_TOAST = 'ADD_WARNING_TOAST';
+export function addWarningToast(text) {
+ return dispatch =>
+ dispatch(addToast({ text, toastType: WARNING_TOAST, duration: 4000 }));
+}
+
+export const ADD_DANGER_TOAST = 'ADD_DANGER_TOAST';
+export function addDangerToast(text) {
+ return dispatch => dispatch(addToast({ text, toastType: DANGER_TOAST }));
+}
diff --git a/superset/assets/src/dashboard/actions/sliceEntities.js b/superset/assets/src/dashboard/actions/sliceEntities.js
new file mode 100644
index 0000000000000..516a5144d1042
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/sliceEntities.js
@@ -0,0 +1,73 @@
+/* eslint camelcase: 0 */
+import $ from 'jquery';
+
+import { getDatasourceParameter } from '../../modules/utils';
+
+export const SET_ALL_SLICES = 'SET_ALL_SLICES';
+export function setAllSlices(slices) {
+ return { type: SET_ALL_SLICES, slices };
+}
+
+export const FETCH_ALL_SLICES_STARTED = 'FETCH_ALL_SLICES_STARTED';
+export function fetchAllSlicesStarted() {
+ return { type: FETCH_ALL_SLICES_STARTED };
+}
+
+export const FETCH_ALL_SLICES_FAILED = 'FETCH_ALL_SLICES_FAILED';
+export function fetchAllSlicesFailed(error) {
+ return { type: FETCH_ALL_SLICES_FAILED, error };
+}
+
+export function fetchAllSlices(userId) {
+ return (dispatch, getState) => {
+ const { sliceEntities } = getState();
+ if (sliceEntities.lastUpdated === 0) {
+ dispatch(fetchAllSlicesStarted());
+
+ const uri = `/sliceaddview/api/read?_flt_0_created_by=${userId}`;
+ return $.ajax({
+ url: uri,
+ type: 'GET',
+ success: response => {
+ const slices = {};
+ response.result.forEach(slice => {
+ let form_data = JSON.parse(slice.params);
+ let datasource = form_data.datasource;
+ if (!datasource) {
+ datasource = getDatasourceParameter(
+ slice.datasource_id,
+ slice.datasource_type,
+ );
+ form_data = {
+ ...form_data,
+ datasource,
+ };
+ }
+ if (['markup', 'separator'].indexOf(slice.viz_type) === -1) {
+ slices[slice.id] = {
+ slice_id: slice.id,
+ slice_url: slice.slice_url,
+ slice_name: slice.slice_name,
+ edit_url: slice.edit_url,
+ form_data,
+ datasource_name: slice.datasource_name_text,
+ datasource_link: slice.datasource_link,
+ changed_on: new Date(slice.changed_on).getTime(),
+ description: slice.description,
+ description_markdown: slice.description_markeddown,
+ viz_type: slice.viz_type,
+ modified: slice.modified
+ ? slice.modified.replace(/<[^>]*>/g, '')
+ : '',
+ };
+ }
+ });
+ return dispatch(setAllSlices(slices));
+ },
+ error: error => dispatch(fetchAllSlicesFailed(error)),
+ });
+ }
+
+ return dispatch(setAllSlices(sliceEntities.slices));
+ };
+}
diff --git a/superset/assets/src/dashboard/components/AddSliceCard.jsx b/superset/assets/src/dashboard/components/AddSliceCard.jsx
new file mode 100644
index 0000000000000..c8266ad162cfb
--- /dev/null
+++ b/superset/assets/src/dashboard/components/AddSliceCard.jsx
@@ -0,0 +1,61 @@
+import cx from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { t } from '../../locales';
+
+const propTypes = {
+ datasourceLink: PropTypes.string,
+ innerRef: PropTypes.func,
+ isSelected: PropTypes.bool,
+ lastModified: PropTypes.string.isRequired,
+ sliceName: PropTypes.string.isRequired,
+ style: PropTypes.object,
+ visType: PropTypes.string.isRequired,
+};
+
+const defaultProps = {
+ datasourceLink: '—',
+ innerRef: null,
+ isSelected: false,
+ style: null,
+};
+
+function AddSliceCard({
+ datasourceLink,
+ innerRef,
+ isSelected,
+ lastModified,
+ sliceName,
+ style,
+ visType,
+}) {
+ return (
+
+
+
{sliceName}
+
+
+ Modified
+ {lastModified}
+
+
+ Visualization
+ {visType}
+
+
+ Data source
+
+
+
+
+ {isSelected &&
{t('Added')}
}
+
+ );
+}
+
+AddSliceCard.propTypes = propTypes;
+AddSliceCard.defaultProps = defaultProps;
+
+export default AddSliceCard;
diff --git a/superset/assets/src/dashboard/components/BuilderComponentPane.jsx b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
new file mode 100644
index 0000000000000..aafee5dcabce1
--- /dev/null
+++ b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
@@ -0,0 +1,129 @@
+/* eslint-env browser */
+import PropTypes from 'prop-types';
+import React from 'react';
+import cx from 'classnames';
+import { StickyContainer, Sticky } from 'react-sticky';
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+
+import NewColumn from './gridComponents/new/NewColumn';
+import NewDivider from './gridComponents/new/NewDivider';
+import NewHeader from './gridComponents/new/NewHeader';
+import NewRow from './gridComponents/new/NewRow';
+import NewTabs from './gridComponents/new/NewTabs';
+import NewMarkdown from './gridComponents/new/NewMarkdown';
+import SliceAdder from '../containers/SliceAdder';
+import { t } from '../../locales';
+
+const SUPERSET_HEADER_HEIGHT = 59;
+
+const propTypes = {
+ topOffset: PropTypes.number,
+ toggleBuilderPane: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ topOffset: 0,
+};
+
+class BuilderComponentPane extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ slideDirection: 'slide-out',
+ };
+
+ this.openSlicesPane = this.slide.bind(this, 'slide-in');
+ this.closeSlicesPane = this.slide.bind(this, 'slide-out');
+ }
+
+ slide(direction) {
+ this.setState({
+ slideDirection: direction,
+ });
+ }
+
+ render() {
+ const { topOffset } = this.props;
+ return (
+
+
+ {({ height }) => (
+
+
+ {({ style, isSticky }) => (
+
+
+
+
+ {t('Insert')}
+
+
+
+
+
+ {t('Your charts & filters')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('All components')}
+
+
+
+
+
+ )}
+
+
+ )}
+
+
+ );
+ }
+}
+
+BuilderComponentPane.propTypes = propTypes;
+BuilderComponentPane.defaultProps = defaultProps;
+
+export default BuilderComponentPane;
diff --git a/superset/assets/src/dashboard/components/CodeModal.jsx b/superset/assets/src/dashboard/components/CodeModal.jsx
index 0e84ad1bab606..cc0c9f2a41fa3 100644
--- a/superset/assets/src/dashboard/components/CodeModal.jsx
+++ b/superset/assets/src/dashboard/components/CodeModal.jsx
@@ -12,13 +12,16 @@ const propTypes = {
const defaultProps = {
codeCallback: () => {},
+ code: '',
};
export default class CodeModal extends React.PureComponent {
constructor(props) {
super(props);
this.state = { code: props.code };
+ this.beforeOpen = this.beforeOpen.bind(this);
}
+
beforeOpen() {
let code = this.props.code;
if (!code && this.props.codeCallback) {
@@ -26,18 +29,17 @@ export default class CodeModal extends React.PureComponent {
}
this.setState({ code });
}
+
render() {
return (
-
- {this.state.code}
-
+ {this.state.code}
}
/>
diff --git a/superset/assets/src/dashboard/components/CssEditor.jsx b/superset/assets/src/dashboard/components/CssEditor.jsx
index 5abf5f81b55db..45ef86d63acdd 100644
--- a/superset/assets/src/dashboard/components/CssEditor.jsx
+++ b/superset/assets/src/dashboard/components/CssEditor.jsx
@@ -29,15 +29,20 @@ class CssEditor extends React.PureComponent {
css: props.initialCss,
cssTemplateOptions: [],
};
+ this.changeCss = this.changeCss.bind(this);
+ this.changeCssTemplate = this.changeCssTemplate.bind(this);
}
+
changeCss(css) {
this.setState({ css }, () => {
this.props.onChange(css);
});
}
+
changeCssTemplate(opt) {
this.changeCss(opt.css);
}
+
renderTemplateSelector() {
if (this.props.templates) {
return (
@@ -46,13 +51,14 @@ class CssEditor extends React.PureComponent {