diff --git a/superset/assets/images/favicon.png b/superset/assets/images/favicon.png
index 55316fa7c5e58..f03cd5c7325e2 100644
Binary files a/superset/assets/images/favicon.png and b/superset/assets/images/favicon.png differ
diff --git a/superset/assets/images/superset-logo@2x.png b/superset/assets/images/superset-logo@2x.png
deleted file mode 100644
index 839f61798d5ee..0000000000000
Binary files a/superset/assets/images/superset-logo@2x.png and /dev/null differ
diff --git a/superset/assets/javascripts/components/EditableTitle.jsx b/superset/assets/javascripts/components/EditableTitle.jsx
index b773340846622..14976766975ac 100644
--- a/superset/assets/javascripts/components/EditableTitle.jsx
+++ b/superset/assets/javascripts/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.handleKeyDown = this.handleKeyDown.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);
}
}
+
+ handleKeyDown(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 = (
{input}
+
+ {input}
+
);
}
}
diff --git a/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx b/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx
index dbec7907c8bd5..6779746e636da 100644
--- a/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx
+++ b/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx
@@ -7,16 +7,16 @@ import Dashboard from '../v2/components/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,
- editMode: dashboard.editMode,
+ // 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,
+ // editMode: dashboard.editMode,
};
}
diff --git a/superset/assets/javascripts/dashboard/index.jsx b/superset/assets/javascripts/dashboard/index.jsx
index 774e07101f461..c9236bd24feea 100644
--- a/superset/assets/javascripts/dashboard/index.jsx
+++ b/superset/assets/javascripts/dashboard/index.jsx
@@ -8,17 +8,29 @@ import { initEnhancer } from '../reduxUtils';
import { appSetup } from '../common';
import { initJQueryAjax } from '../modules/utils';
import DashboardContainer from './components/DashboardContainer';
-import rootReducer, { getInitialState } from './reducers';
+// import rootReducer, { getInitialState } from './reducers';
+
+import testLayout from './v2/fixtures/testLayout';
+import rootReducer from './v2/reducers/';
appSetup();
initJQueryAjax();
const appContainer = document.getElementById('app');
-const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
-const initState = Object.assign({}, getInitialState(bootstrapData));
+// const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
+// const initState = Object.assign({}, getInitialState(bootstrapData));
+const initState = {
+ dashboard: testLayout,
+};
const store = createStore(
- rootReducer, initState, compose(applyMiddleware(thunk), initEnhancer(false)));
+ rootReducer,
+ initState,
+ compose(
+ applyMiddleware(thunk),
+ initEnhancer(false),
+ ),
+);
ReactDOM.render(
@@ -26,4 +38,3 @@ ReactDOM.render(
,
appContainer,
);
-
diff --git a/superset/assets/javascripts/dashboard/v2/.eslintrc b/superset/assets/javascripts/dashboard/v2/.eslintrc
new file mode 100644
index 0000000000000..70efc15a3ab35
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/.eslintrc
@@ -0,0 +1,29 @@
+{
+ "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": 2,
+ "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,
+ }
+}
diff --git a/superset/assets/javascripts/dashboard/v2/actions/index.js b/superset/assets/javascripts/dashboard/v2/actions/index.js
new file mode 100644
index 0000000000000..005a77e5dccd4
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/actions/index.js
@@ -0,0 +1,69 @@
+export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
+export function updateComponents(nextComponents) {
+ return {
+ type: UPDATE_COMPONENTS,
+ payload: {
+ nextComponents,
+ },
+ };
+}
+
+export const DELETE_COMPONENT = 'DELETE_COMPONENT';
+export function deleteComponent(id, parentId) {
+ return {
+ type: DELETE_COMPONENT,
+ payload: {
+ id,
+ parentId,
+ },
+ };
+}
+
+export const CREATE_COMPONENT = 'CREATE_COMPONENT';
+export function createComponent(dropResult) {
+ return {
+ type: CREATE_COMPONENT,
+ payload: {
+ dropResult,
+ },
+ };
+}
+
+
+// Drag and drop --------------------------------------------------------------
+export const MOVE_COMPONENT = 'MOVE_COMPONENT';
+export function moveComponent(dropResult) {
+ return {
+ type: MOVE_COMPONENT,
+ payload: {
+ dropResult,
+ },
+ };
+}
+
+export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
+export function handleComponentDrop(dropResult) {
+ return (dispatch) => {
+ if (
+ dropResult.destination
+ && dropResult.source
+ && !( // ensure it has moved
+ dropResult.destination.droppableId === dropResult.source.droppableId
+ && dropResult.destination.index === dropResult.source.index
+ )
+ ) {
+ return dispatch(moveComponent(dropResult));
+
+ // new components don't have a source
+ } else if (dropResult.destination && !dropResult.source) {
+ return dispatch(createComponent(dropResult));
+ }
+ return null;
+ };
+}
+
+// Resize ---------------------------------------------------------------------
+
+// export function dashboardComponentResizeStart() {}
+// export function dashboardComponentResize() {}
+// export function dashboardComponentResizeStop() {}
diff --git a/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx b/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx
index 3938a51a5fec3..86f3788bae6ae 100644
--- a/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx
@@ -1,46 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { Droppable } from 'react-beautiful-dnd';
-import DraggableNewChart from './dnd/DraggableNewChart';
-import DraggableNewDivider from './dnd/DraggableNewDivider';
-import DraggableNewHeader from './dnd/DraggableNewHeader';
-import DraggableNewRow from './dnd/DraggableNewRow';
-
-import { DROPPABLE_NEW_COMPONENT } from '../util/constants';
+import NewChart from './gridComponents/new/NewChart';
+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 NewSpacer from './gridComponents/new/NewSpacer';
+import NewTabs from './gridComponents/new/NewTabs';
const propTypes = {
editMode: PropTypes.bool,
};
-class BuilderComponentPane extends React.Component {
+class BuilderComponentPane extends React.PureComponent {
render() {
return (
-
+
Insert components
-
- {provided => (
-
-
-
-
-
- {provided.placeholder}
-
- )}
-
+
+
+
+
+
+
+
+
+
);
}
diff --git a/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx b/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
index 0de4e8b6fbdbd..5936006c8550f 100644
--- a/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import DashboardBuilder from './DashboardBuilder';
import StaticDashboard from './StaticDashboard';
-import Header from './Header';
+import DashboardHeader from './DashboardHeader';
import '../../../../stylesheets/dashboard-v2.css';
@@ -15,19 +15,23 @@ const propTypes = {
editMode: PropTypes.bool,
};
+const defaultProps = {
+ editMode: true,
+};
+
class Dashboard extends React.Component {
render() {
const { editMode, actions } = this.props;
const { setEditMode, updateDashboardTitle } = actions;
return (
-
- {editMode ?
+ {true ?
: }
);
@@ -35,5 +39,6 @@ class Dashboard extends React.Component {
}
Dashboard.propTypes = propTypes;
+Dashboard.defaultProps = defaultProps;
export default Dashboard;
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
index 62a65339a5093..94069b79dafaa 100644
--- a/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
@@ -1,109 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
+import HTML5Backend from 'react-dnd-html5-backend';
+import { DragDropContext } from 'react-dnd';
+import cx from 'classnames';
-import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import BuilderComponentPane from './BuilderComponentPane';
-import { reorder, reorderRows } from '../util/dnd-reorder';
+import DashboardGrid from '../containers/DashboardGrid';
-import { DROPPABLE_DASHBOARD_ROOT, DRAGGABLE_ROW_TYPE } from '../util/constants';
+import './dnd/dnd.css';
const propTypes = {
editMode: PropTypes.bool,
};
+const defaultProps = {
+ editMode: true,
+};
+
class DashboardBuilder extends React.Component {
constructor(props) {
super(props);
- this.state = {
- rows: [],
- entities: {},
- };
-
- this.handleDragEnd = this.handleDragEnd.bind(this);
- this.handleDragStart = this.handleDragStart.bind(this);
- this.handleReorder = this.handleReorder.bind(this);
- this.handleNewEntity = this.handleNewEntity.bind(this);
- }
-
- handleDragEnd(dropResult) {
- console.log('drag end', dropResult);
- if (dropResult.destination) {
- // if (isNewEntity(dropResult.draggableId)) {
- // this.handleNewEntity(dropResult);
- // } else {
- // this.handleReorder(dropResult);
- // }
- }
- }
-
- handleDragStart(obj) {
- console.log('drag start', obj);
- }
-
- handleNewEntity() {
-
- }
-
- handleReorder({ source, destination, draggableId }) {
- // this.setState(({ rows, entities }) => {
- // const { type } = entities[draggableId];
- //
- // if (isRowType(type)) { // re-ordering rows
- // const nextRows = reorder(
- // rows,
- // source.index,
- // destination.index,
- // );
- // return { rows: nextRows };
- // }
- //
- // // moving items between rows
- // const nextEntities = reorderRows({
- // entitiesMap: entities,
- // source,
- // destination,
- // });
- //
- // return { entities: nextEntities };
- // });
+ // this component might control the state of the side pane etc. in the future
+ this.state = {};
}
render() {
return (
-
-
-
-
- {(provided, snapshot) => (
-
- {this.state.rows.map(id => this.renderRow(id))}
- {provided.placeholder}
-
- )}
-
-
-
-
-
+
+
+
+
);
}
}
DashboardBuilder.propTypes = propTypes;
+DashboardBuilder.defaultProps = defaultProps;
-export default DashboardBuilder;
+export default DragDropContext(HTML5Backend)(DashboardBuilder);
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
new file mode 100644
index 0000000000000..884fc899bf71c
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
@@ -0,0 +1,169 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import cx from 'classnames';
+
+import DragDroppable from './dnd/DragDroppable';
+import DashboardComponent from '../containers/DashboardComponent';
+
+import {
+ DASHBOARD_ROOT_ID,
+ GRID_GUTTER_SIZE,
+ GRID_COLUMN_COUNT,
+} from '../util/constants';
+
+import './gridComponents/grid.css';
+
+const propTypes = {
+ dashboard: PropTypes.object.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+};
+
+class DashboardGrid extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isResizing: false,
+ rowGuideTop: null,
+ };
+
+ this.handleResizeStart = this.handleResizeStart.bind(this);
+ this.handleResize = this.handleResize.bind(this);
+ this.handleResizeStop = this.handleResizeStop.bind(this);
+ this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
+ }
+
+ getRowGuidePosition(resizeRef) {
+ if (resizeRef && this.grid) {
+ return resizeRef.getBoundingClientRect().bottom - this.grid.getBoundingClientRect().top - 1;
+ }
+ return null;
+ }
+
+ handleResizeStart({ ref, direction }) {
+ let rowGuideTop = null;
+ if (direction === 'bottom' || direction === 'bottomRight') {
+ rowGuideTop = this.getRowGuidePosition(ref);
+ }
+
+ this.setState(() => ({
+ isResizing: true,
+ rowGuideTop,
+ }));
+ }
+
+ handleResize({ ref, direction }) {
+ if (direction === 'bottom' || direction === 'bottomRight') {
+ this.setState(() => ({ rowGuideTop: this.getRowGuidePosition(ref) }));
+ }
+ }
+
+ handleResizeStop({ id, widthMultiple, heightMultiple }) {
+ const { dashboard: components, updateComponents } = this.props;
+ const component = components[id];
+ if (
+ component &&
+ (component.meta.width !== widthMultiple || component.meta.height !== heightMultiple)
+ ) {
+ updateComponents({
+ [id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ width: widthMultiple || component.meta.width,
+ height: heightMultiple || component.meta.height,
+ },
+ },
+ });
+ }
+ this.setState(() => ({
+ isResizing: false,
+ rowGuideTop: null,
+ }));
+ }
+
+ render() {
+ const { dashboard: components, handleComponentDrop } = this.props;
+ const { isResizing, rowGuideTop } = this.state;
+ const rootComponent = components[DASHBOARD_ROOT_ID];
+
+ return (
+
{ this.grid = ref; }}
+ className={cx('grid-container', isResizing && 'grid-container--resizing')}
+ >
+
+ {({ width }) => {
+ // account for (COLUMN_COUNT - 1) gutters
+ const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
+ const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
+
+ return width < 50 ? null : (
+
+ {(rootComponent.children || []).map((id, index) => (
+
+ ))}
+
+ {rootComponent.children.length === 0 &&
+
+ {({ dropIndicatorProps }) => (
+
+ {dropIndicatorProps &&
}
+
+ )}
+ }
+
+ {isResizing && Array(GRID_COLUMN_COUNT).fill(null).map((_, i) => (
+
+ ))}
+
+ {isResizing && rowGuideTop &&
+
}
+
+ );
+ }}
+
+
+ );
+ }
+}
+
+DashboardGrid.propTypes = propTypes;
+DashboardGrid.defaultProps = defaultProps;
+
+export default DashboardGrid;
diff --git a/superset/assets/javascripts/dashboard/v2/components/Header.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
similarity index 100%
rename from superset/assets/javascripts/dashboard/v2/components/Header.jsx
rename to superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
diff --git a/superset/assets/javascripts/dashboard/v2/components/DeleteComponentButton.jsx b/superset/assets/javascripts/dashboard/v2/components/DeleteComponentButton.jsx
new file mode 100644
index 0000000000000..18efff43ac4f9
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DeleteComponentButton.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import IconButton from './IconButton';
+
+const propTypes = {
+ onDelete: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+};
+
+export default class DeleteComponentButton extends React.PureComponent {
+ render() {
+ const { onDelete } = this.props;
+ return (
+
+ );
+ }
+}
+
+DeleteComponentButton.propTypes = propTypes;
+DeleteComponentButton.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx b/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx
new file mode 100644
index 0000000000000..98044c92029be
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+ onClick: PropTypes.func.isRequired,
+ className: PropTypes.string,
+};
+
+const defaultProps = {
+ className: null,
+};
+
+export default class IconButton extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick(event) {
+ event.preventDefault();
+ const { onClick } = this.props;
+ onClick(event);
+ }
+
+ render() {
+ const { className } = this.props;
+ return (
+
+ );
+ }
+}
+
+IconButton.propTypes = propTypes;
+IconButton.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/StaticDashboard.jsx b/superset/assets/javascripts/dashboard/v2/components/StaticDashboard.jsx
index 1d0b356893b06..4fd239779d7bd 100644
--- a/superset/assets/javascripts/dashboard/v2/components/StaticDashboard.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/StaticDashboard.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import PropTypes from 'prop-types';
+// import PropTypes from 'prop-types';
const propTypes = {
};
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
new file mode 100644
index 0000000000000..ba87e5709ea19
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DragSource, DropTarget } from 'react-dnd';
+import cx from 'classnames';
+import throttle from 'lodash.throttle';
+
+import isValidChild from '../../util/isValidChild';
+import handleHover from './handleHover';
+import handleDrop from './handleDrop';
+import { dragConfig, dropConfig } from './dragDroppableConfig';
+import { componentShape } from '../../util/propShapes';
+
+const HOVER_THROTTLE_MS = 200;
+
+const propTypes = {
+ children: PropTypes.func,
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ disableDragDrop: PropTypes.bool,
+ orientation: PropTypes.oneOf(['row', 'column']),
+ index: PropTypes.number.isRequired,
+ parentId: PropTypes.string,
+
+ // from react-dnd
+ isDragging: PropTypes.bool.isRequired,
+ isDraggingOver: PropTypes.bool.isRequired,
+ isDraggingOverShallow: PropTypes.bool.isRequired,
+ droppableRef: PropTypes.func.isRequired,
+ dragSourceRef: PropTypes.func.isRequired,
+ dragPreviewRef: PropTypes.func.isRequired,
+
+ // from redux (@TODO)
+ handleHover: PropTypes.func,
+ handleDrop: PropTypes.func,
+ onDrop: PropTypes.func, // @TODO rename broadcastDrop?
+ isValidChild: PropTypes.func,
+ isValidSibling: PropTypes.func,
+};
+
+const defaultProps = {
+ disableDragDrop: false,
+ children() {},
+ onDrop() {},
+ parentId: null,
+ orientation: 'row',
+ handleDrop,
+ handleHover,
+ isValidChild,
+ isValidSibling({ parentType, siblingType: childType }) {
+ return isValidChild({ parentType, childType });
+ },
+};
+
+class DragDroppable extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ dropIndicator: null,
+ };
+ this.handleHover = throttle(this.hover.bind(this), HOVER_THROTTLE_MS).bind(this);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ hover(props, monitor, Component) {
+ if (this.mounted) this.props.handleHover(props, monitor, Component);
+ }
+
+ render() {
+ const {
+ children,
+ orientation,
+ droppableRef,
+ dragSourceRef,
+ dragPreviewRef,
+ isDragging,
+ isDraggingOver,
+ } = this.props;
+
+ const { dropIndicator } = this.state;
+
+ return (
+
{
+ this.ref = ref;
+ dragPreviewRef(ref);
+ droppableRef(ref);
+ }}
+ className={cx(
+ 'dragdroppable',
+ orientation === 'row' && 'dragdroppable-row',
+ orientation === 'column' && 'dragdroppable-column',
+ isDragging && 'dragdroppable--dragging',
+ )}
+ >
+ {children({
+ dragSourceRef,
+ dropIndicatorProps: isDraggingOver && dropIndicator && {
+ className: 'drop-indicator',
+ style: dropIndicator,
+ },
+ })}
+
+ );
+ }
+}
+
+DragDroppable.propTypes = propTypes;
+DragDroppable.defaultProps = defaultProps;
+
+// note that the composition order here determines using
+// component.method() vs decoratedComponentInstance.method() in the drag/drop config
+export default DropTarget(...dropConfig)(
+ DragSource(...dragConfig)(DragDroppable),
+);
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DragHandle.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DragHandle.jsx
new file mode 100644
index 0000000000000..36d1e6beff521
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/DragHandle.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+ position: PropTypes.oneOf(['left', 'top']),
+ innerRef: PropTypes.func,
+ dotCount: PropTypes.number,
+};
+
+const defaultProps = {
+ position: 'left',
+ innerRef: null,
+ dotCount: 8,
+};
+
+export default class DragHandle extends React.PureComponent {
+ render() {
+ const { innerRef, position, dotCount } = this.props;
+ return (
+
+ {Array(dotCount).fill(null).map((_, i) => (
+
+ ))}
+
+ );
+ }
+}
+
+DragHandle.propTypes = propTypes;
+DragHandle.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewChart.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewChart.jsx
deleted file mode 100644
index 63171557ce5f3..0000000000000
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewChart.jsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { DRAGGABLE_NEW_CHART } from '../../util/constants';
-import DraggableNewComponent from './DraggableNewComponent';
-
-const propTypes = {
- index: PropTypes.number.isRequired,
-};
-
-export default class DraggableNewChart extends React.Component {
- render() {
- const { index } = this.props;
- return
;
- }
-}
-
-DraggableNewChart.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewComponent.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewComponent.jsx
deleted file mode 100644
index a7a4b1caddb34..0000000000000
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewComponent.jsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import cx from 'classnames';
-import { Draggable } from 'react-beautiful-dnd';
-
-const propTypes = {
- id: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired,
- index: PropTypes.number.isRequired,
- draggableType: PropTypes.string,
-};
-
-export default class DraggableNewComponent extends React.Component {
- render() {
- const { id, label, index, draggableType = undefined} = this.props;
- if (!id) console.warn(`no 'id' provided for NewComponent ${type}`);
- if (typeof index === 'undefined') console.warn(`no 'index' provided for NewComponent ${type}`);
-
- return (
-
- {(provided, snapshot) => (
-
-
- {provided.placeholder}
-
- )}
-
- );
- }
-}
-
-DraggableNewComponent.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewDivider.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewDivider.jsx
deleted file mode 100644
index 1aaf31bd98708..0000000000000
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewDivider.jsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { DRAGGABLE_NEW_DIVIDER } from '../../util/constants';
-import DraggableNewComponent from './DraggableNewComponent';
-
-const propTypes = {
- index: PropTypes.number.isRequired,
-};
-
-export default class DraggableNewDivider extends React.Component {
- render() {
- const { index } = this.props;
- return (
-
- );
- }
-}
-
-DraggableNewDivider.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewHeader.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewHeader.jsx
deleted file mode 100644
index 004693ecc8eb5..0000000000000
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewHeader.jsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { DRAGGABLE_NEW_HEADER } from '../../util/constants';
-import DraggableNewComponent from './DraggableNewComponent';
-
-const propTypes = {
- index: PropTypes.number.isRequired,
-};
-
-export default class DraggableNewHeader extends React.Component {
- render() {
- const { index } = this.props;
- return (
-
- );
- }
-}
-
-DraggableNewHeader.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewRow.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewRow.jsx
deleted file mode 100644
index dda588ec810d0..0000000000000
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/DraggableNewRow.jsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { DRAGGABLE_NEW_ROW, DRAGGABLE_ROW_TYPE } from '../../util/constants';
-import DraggableNewComponent from './DraggableNewComponent';
-
-const propTypes = {
- index: PropTypes.number.isRequired,
-};
-
-export default class DraggableNewRow extends React.Component {
- render() {
- const { index } = this.props;
- return (
-
- );
- }
-}
-
-DraggableNewRow.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/dnd.css b/superset/assets/javascripts/dashboard/v2/components/dnd/dnd.css
new file mode 100644
index 0000000000000..fb010e08a82bc
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/dnd.css
@@ -0,0 +1,60 @@
+.dragdroppable {
+ position: relative;
+}
+
+.dragdroppable--dragging {
+ opacity: 0.25;
+}
+
+.dragdroppable-row {
+ width: 100%;
+}
+
+.grid-container .dragdroppable-row:after,
+.grid-container .dragdroppable-column:after {
+ border: 1px dashed transparent;
+ content: "";
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 1px;
+ left: 0;
+ z-index: 1;
+ pointer-events: none;
+}
+
+ .grid-container .dragdroppable-row:hover:after,
+ .grid-container .dragdroppable-column:hover:after {
+ border: 1px dashed #aaa;
+ }
+
+/* Drag handle */
+.drag-handle {
+ overflow: hidden;
+ width: 16px;
+ cursor: move;
+}
+
+.drag-handle--left {
+ width: 8px;
+}
+
+.drag-handle--top {
+ /*margin: 10px auto;*/
+}
+
+.drag-handle-dot {
+ float: left;
+ height: 2px;
+ margin: 1px;
+ width: 2px
+}
+
+.drag-handle-dot:after {
+ content: "";
+ background: #aaa;
+ float: left;
+ height: 2px;
+ margin: -1px;
+ width: 2px;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js b/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
new file mode 100644
index 0000000000000..6eb37e90f393e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
@@ -0,0 +1,59 @@
+// note: the 'type' hook is not useful for us as dropping is contigent on other properties
+const TYPE = 'DRAG_DROPPABLE';
+
+export const dragConfig = [
+ TYPE,
+ {
+ canDrag(props) {
+ return !props.disableDragDrop;
+ },
+ beginDrag(props /* , monitor, component */) {
+ const { component, index, parentId } = props;
+ return { draggableId: component.id, index, parentId, type: component.type };
+ },
+ },
+ function dragStateToProps(connect, monitor) {
+ return {
+ dragSourceRef: connect.dragSource(),
+ dragPreviewRef: connect.dragPreview(),
+ isDragging: monitor.isDragging(),
+ };
+ },
+];
+
+export const dropConfig = [
+ TYPE,
+ {
+ hover(props, monitor, component) {
+ if (
+ component
+ && component.decoratedComponentInstance
+ && component.decoratedComponentInstance.handleHover
+ ) { // use the component instance so we can throttle calls
+ component.decoratedComponentInstance.handleHover(
+ props,
+ monitor,
+ component.decoratedComponentInstance,
+ );
+ }
+ },
+ // note:
+ // the react-dnd api requires that the drop() method return a result or undefined
+ // monitor.didDrop() cannot be used because it returns true only for the most-nested target
+ drop(props, monitor, component) {
+ const Component = component.decoratedComponentInstance;
+ const dropResult = monitor.getDropResult();
+ if ((!dropResult || !dropResult.destination) && Component.props.handleDrop) {
+ return Component.props.handleDrop(props, monitor, Component);
+ }
+ return undefined;
+ },
+ },
+ function dropStateToProps(connect, monitor) {
+ return {
+ droppableRef: connect.dropTarget(),
+ isDraggingOver: monitor.isOver(),
+ isDraggingOverShallow: monitor.isOver({ shallow: true }),
+ };
+ },
+];
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js b/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
new file mode 100644
index 0000000000000..de7ea854abc72
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
@@ -0,0 +1,83 @@
+export default function handleDrop(props, monitor, Component) {
+ Component.setState(() => ({ dropIndicator: null }));
+ const {
+ components,
+ component,
+ parentId,
+ index: componentIndex,
+ onDrop,
+ orientation,
+ isValidChild,
+ isValidSibling,
+ } = Component.props;
+
+ const draggingItem = monitor.getItem();
+
+ // if dropped self on self, do nothing
+ if (!draggingItem || draggingItem.draggableId === component.id) {
+ console.log(draggingItem ? 'drop self' : 'drop no item');
+ return undefined;
+ }
+
+ // append to self, or parent
+ const validChild = isValidChild({
+ parentType: component.type,
+ childType: draggingItem.type,
+ });
+
+ const validSibling = isValidSibling({
+ parentType: components[parentId] && components[parentId].type,
+ siblingType: draggingItem.type,
+ });
+
+ if (!validChild && !validSibling) {
+ console.log('not valid drop child or sibling')
+ return undefined;
+ }
+
+ const dropResult = {
+ source: draggingItem.parentId ? {
+ droppableId: draggingItem.parentId,
+ index: draggingItem.index,
+ } : null,
+ draggableId: draggingItem.draggableId,
+ };
+
+ if (validChild) { // append it to component.children
+ dropResult.destination = {
+ droppableId: component.id,
+ index: component.children.length,
+ };
+ } else { // insert as sibling
+ // if the item is in the same list with a smaller index, you must account for the
+ // "missing" index upon movement within the list
+ const sameList = draggingItem.parentId && draggingItem.parentId === parentId;
+ const sameListLowerIndex = sameList && draggingItem.index < componentIndex;
+
+ let nextIndex = sameListLowerIndex ? componentIndex - 1 : componentIndex;
+ const refBoundingRect = Component.ref.getBoundingClientRect();
+ const clientOffset = monitor.getClientOffset();
+
+
+ if (clientOffset) {
+ if (orientation === 'row') {
+ const refMiddleY =
+ refBoundingRect.top + ((refBoundingRect.bottom - refBoundingRect.top) / 2);
+ nextIndex += clientOffset.y >= refMiddleY ? 1 : 0;
+ } else {
+ const refMiddleX =
+ refBoundingRect.left + ((refBoundingRect.right - refBoundingRect.left) / 2);
+ nextIndex += clientOffset.x >= refMiddleX ? 1 : 0;
+ }
+ }
+
+ dropResult.destination = {
+ droppableId: parentId,
+ index: nextIndex,
+ };
+ }
+
+ onDrop(dropResult);
+
+ return dropResult;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js b/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js
new file mode 100644
index 0000000000000..80d0ef9a6a14b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js
@@ -0,0 +1,82 @@
+export default function handleHover(props, monitor, Component) {
+ const {
+ component,
+ components,
+ parentId,
+ orientation,
+ isDraggingOverShallow,
+ isValidChild,
+ isValidSibling,
+ } = Component.props;
+
+ const draggingItem = monitor.getItem();
+
+ if (!draggingItem || draggingItem.draggableId === component.id) {
+ Component.setState(() => ({ dropIndicator: null }));
+ return;
+ }
+
+ const validChild = isValidChild({
+ parentType: component.type,
+ childType: draggingItem.type,
+ });
+
+ const validSibling = isValidSibling({
+ parentType: components[parentId] && components[parentId].type,
+ siblingType: draggingItem.type,
+ });
+
+ if (validChild && !isDraggingOverShallow) {
+ Component.setState(() => ({ dropIndicator: null }));
+ return;
+ }
+
+ if (validChild) { // indicate drop in container
+ console.log('valid child', component.type, draggingItem.type);
+ const indicatorOrientation = orientation === 'row' ? 'column' : 'row';
+
+ Component.setState(() => ({
+ dropIndicator: {
+ top: 0,
+ right: component.children.length ? 8 : null,
+ height: indicatorOrientation === 'column' ? '100%' : 3,
+ width: indicatorOrientation === 'column' ? 3 : '100%',
+ minHeight: indicatorOrientation === 'column' ? 16 : null,
+ minWidth: indicatorOrientation === 'column' ? null : 16,
+ margin: 'auto',
+ backgroundColor: '#44C0FF',
+ position: 'absolute',
+ zIndex: 10,
+ },
+ }));
+ } else if (validSibling) { // indicate drop near parent
+ console.log('valid sibling', components[parentId].type, draggingItem.type);
+ const refBoundingRect = Component.ref.getBoundingClientRect();
+ const clientOffset = monitor.getClientOffset();
+
+ if (clientOffset) {
+ let dropOffset;
+ if (orientation === 'row') {
+ const refMiddleY =
+ refBoundingRect.top + ((refBoundingRect.bottom - refBoundingRect.top) / 2);
+ dropOffset = clientOffset.y < refMiddleY ? 0 : refBoundingRect.height;
+ } else {
+ const refMiddleX =
+ refBoundingRect.left + ((refBoundingRect.right - refBoundingRect.left) / 2);
+ dropOffset = clientOffset.x < refMiddleX ? 0 : refBoundingRect.width;
+ }
+
+ Component.setState(() => ({
+ dropIndicator: {
+ top: orientation === 'column' ? 0 : dropOffset,
+ left: orientation === 'column' ? dropOffset : 0,
+ height: orientation === 'column' ? '100%' : 3,
+ width: orientation === 'column' ? 3 : '100%',
+ backgroundColor: '#44C0FF',
+ position: 'absolute',
+ zIndex: 10,
+ },
+ }));
+ }
+ }
+}
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
new file mode 100644
index 0000000000000..a8a90f65fa1bf
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
@@ -0,0 +1,130 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import HoverMenu from '../menu/HoverMenu';
+import ResizableContainer from '../resizable/ResizableContainer';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import { componentShape } from '../../util/propShapes';
+
+import {
+ GRID_MIN_COLUMN_COUNT,
+ GRID_MIN_ROW_UNITS,
+} from '../../util/constants';
+
+const propTypes = {
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ parentId: PropTypes.string.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ rowHeight: PropTypes.number,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ deleteComponent: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ rowHeight: null,
+};
+
+class Chart extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ };
+
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ }
+
+ handleChangeFocus(nextFocus) {
+ this.setState(() => ({ isFocused: nextFocus }));
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, component, parentId } = this.props;
+ deleteComponent(component.id, parentId);
+ }
+
+ render() {
+ const { isFocused } = this.state;
+
+ const {
+ component,
+ components,
+ index,
+ depth,
+ parentId,
+ availableColumnCount,
+ columnWidth,
+ rowHeight,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ } = this.props;
+ console.log('chart depth', depth)
+ return (
+
+ {({ dropIndicatorProps, dragSourceRef }) => (
+
+
+
+
+
+ ,
+ ]}
+ >
+
+
+ {dropIndicatorProps &&
}
+
+
+ )}
+
+ );
+ }
+}
+
+Chart.propTypes = propTypes;
+Chart.defaultProps = defaultProps;
+
+export default Chart;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
new file mode 100644
index 0000000000000..c38a4618a891b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import HoverMenu from '../menu/HoverMenu';
+import ResizableContainer from '../resizable/ResizableContainer';
+import { componentShape } from '../../util/propShapes';
+
+import { GRID_GUTTER_SIZE, GRID_MIN_COLUMN_COUNT } from '../../util/constants';
+
+const propTypes = {
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ parentId: PropTypes.string.isRequired,
+ rowHeight: PropTypes.number,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ deleteComponent: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ rowHeight: null,
+};
+
+class Column extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, component, parentId } = this.props;
+ deleteComponent(component.id, parentId);
+ }
+
+ render() {
+ const {
+ component: columnComponent,
+ components,
+ index,
+ parentId,
+ availableColumnCount,
+ columnWidth,
+ rowHeight,
+ depth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ } = this.props;
+
+ const columnItems = [];
+
+ (columnComponent.children || []).forEach((id, childIndex) => {
+ const component = components[id];
+ columnItems.push(component);
+ if (childIndex < columnComponent.children.length - 1) {
+ columnItems.push(`gutter-${childIndex}`);
+ }
+ });
+
+ return (
+
+ {({ dropIndicatorProps, dragSourceRef }) => (
+
+
+
+
+
+
+
+ {columnItems.map((component, itemIndex) => {
+ if (!component.id) {
+ return
;
+ }
+
+ return (
+
+ );
+ })}
+ {dropIndicatorProps &&
}
+
+
+ )}
+
+
+ );
+ }
+}
+
+Column.propTypes = propTypes;
+Column.defaultProps = defaultProps;
+
+export default Column;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
new file mode 100644
index 0000000000000..4aeee058cc7e4
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DragDroppable from '../dnd/DragDroppable';
+import HoverMenu from '../menu/HoverMenu';
+import DeleteComponentButton from '../DeleteComponentButton';
+import { componentShape } from '../../util/propShapes';
+
+const propTypes = {
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ index: PropTypes.number.isRequired,
+ parentId: PropTypes.string.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+};
+
+class Divider extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, component, parentId } = this.props;
+ deleteComponent(component.id, parentId);
+ }
+
+ render() {
+ const {
+ component,
+ components,
+ index,
+ parentId,
+ handleComponentDrop,
+ } = this.props;
+
+ return (
+
+ {({ dropIndicatorProps, dragSourceRef }) => (
+
+
+
+
+
+
+
+ {dropIndicatorProps &&
}
+
+ )}
+
+ );
+ }
+}
+
+Divider.propTypes = propTypes;
+
+export default Divider;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
new file mode 100644
index 0000000000000..4826688d3848d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
@@ -0,0 +1,150 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import EditableTitle from '../../../../components/EditableTitle';
+import HoverMenu from '../menu/HoverMenu';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import RowStyleDropdown from '../menu/RowStyleDropdown';
+import DeleteComponentButton from '../DeleteComponentButton';
+import PopoverDropdown from '../menu/PopoverDropdown';
+import headerStyleOptions from '../../util/headerStyleOptions';
+import rowStyleOptions from '../../util/rowStyleOptions';
+import { componentShape } from '../../util/propShapes';
+import { SMALL_HEADER, ROW_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ index: PropTypes.number.isRequired,
+ parentId: PropTypes.string.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+};
+
+class Header extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ };
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
+ this.handleChangeSize = this.handleUpdateMeta.bind(this, 'headerSize');
+ this.handleChangeRowStyle = this.handleUpdateMeta.bind(this, 'rowStyle');
+ this.handleChangeText = this.handleUpdateMeta.bind(this, 'text');
+ }
+
+ handleChangeFocus(nextFocus) {
+ this.setState(() => ({ isFocused: nextFocus }));
+ }
+
+ handleUpdateMeta(metaKey, nextValue) {
+ const { updateComponents, component } = this.props;
+ if (nextValue && component.meta[metaKey] !== nextValue) {
+ updateComponents({
+ [component.id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ [metaKey]: nextValue,
+ },
+ },
+ });
+ }
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, component, parentId } = this.props;
+ deleteComponent(component.id, parentId);
+ }
+
+ render() {
+ const { isFocused } = this.state;
+
+ const {
+ component,
+ components,
+ index,
+ parentId,
+ handleComponentDrop,
+ } = this.props;
+
+ const headerStyle = headerStyleOptions.find(
+ opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
+ );
+
+ const rowStyle = rowStyleOptions.find(
+ opt => opt.value === (component.meta.rowStyle || ROW_TRANSPARENT),
+ );
+
+ return (
+
+ {({ dropIndicatorProps, dragSourceRef }) => (
+
+
+
+
+
+
+
+ {dropIndicatorProps &&
}
+
+ )}
+
+ );
+ }
+}
+
+Header.propTypes = propTypes;
+Header.defaultProps = defaultProps;
+
+export default Header;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
new file mode 100644
index 0000000000000..3743534d9b24e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
@@ -0,0 +1,188 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import HoverMenu from '../menu/HoverMenu';
+import IconButton from '../IconButton';
+import RowStyleDropdown from '../menu/RowStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import { componentShape } from '../../util/propShapes';
+import rowStyleOptions from '../../util/rowStyleOptions';
+import { GRID_GUTTER_SIZE, ROW_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ parentId: PropTypes.string.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ rowHeight: PropTypes.number,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ handleComponentDrop: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ rowHeight: null,
+};
+
+class Row extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ };
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
+ this.handleChangeRowStyle = this.handleUpdateMeta.bind(this, 'rowStyle');
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ }
+
+ handleChangeFocus(nextFocus) {
+ this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+ }
+
+ handleUpdateMeta(metaKey, nextValue) {
+ const { updateComponents, component } = this.props;
+ if (nextValue && component.meta[metaKey] !== nextValue) {
+ updateComponents({
+ [component.id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ [metaKey]: nextValue,
+ },
+ },
+ });
+ }
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, component, parentId } = this.props;
+ deleteComponent(component.id, parentId);
+ }
+
+ render() {
+ const {
+ component: rowComponent,
+ components,
+ index,
+ parentId,
+ availableColumnCount,
+ columnWidth,
+ depth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ } = this.props;
+
+ let occupiedColumnCount = 0;
+ let rowHeight = 0; // row items without height require this
+ const rowItems = [];
+
+ // this adds a gutter between each child in the row.
+ (rowComponent.children || []).forEach((id, childIndex) => {
+ const component = components[id];
+ occupiedColumnCount += (component.meta || {}).width || 0;
+ rowItems.push(component);
+ if (childIndex < rowComponent.children.length - 1) {
+ rowItems.push(`gutter-${childIndex}`);
+ }
+ if ((component.meta || {}).height) {
+ rowHeight = Math.max(rowHeight, component.meta.height);
+ }
+ });
+
+ const rowStyle = rowStyleOptions.find(
+ opt => opt.value === (rowComponent.meta.rowStyle || ROW_TRANSPARENT),
+ );
+
+ return (
+
+ {({ dropIndicatorProps, dragSourceRef }) => (
+ ,
+ ]}
+ >
+
+
+
+
+
+
+
+
+ {rowItems.map((component, itemIndex) => {
+ if (!component.id) {
+ return
;
+ }
+
+ return (
+
+ );
+ })}
+
+ {dropIndicatorProps &&
}
+
+
+ )}
+
+ );
+ }
+}
+
+Row.propTypes = propTypes;
+Row.defaultProps = defaultProps;
+
+export default Row;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
new file mode 100644
index 0000000000000..80d85e622b9be
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
@@ -0,0 +1,114 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import HoverMenu from '../menu/HoverMenu';
+import ResizableContainer from '../resizable/ResizableContainer';
+import { componentShape } from '../../util/propShapes';
+
+import {
+// GRID_MIN_COLUMN_COUNT,
+ GRID_MIN_ROW_UNITS,
+} from '../../util/constants';
+
+const propTypes = {
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ parentId: PropTypes.string.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ rowHeight: PropTypes.number,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ deleteComponent: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ rowHeight: null,
+};
+
+class Spacer extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, component, parentId } = this.props;
+ deleteComponent(component.id, parentId);
+ }
+
+ render() {
+ const {
+ component,
+ components,
+ index,
+ depth,
+ parentId,
+ availableColumnCount,
+ columnWidth,
+ rowHeight,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ } = this.props;
+
+ const orientation = depth % 2 === 0 ? 'row' : 'column';
+ const hoverMenuPosition = orientation === 'row' ? 'left' : 'top';
+ const adjustableWidth = orientation === 'column';
+ const adjustableHeight = orientation === 'row';
+
+ return (
+
+ {({ dropIndicatorProps, dragSourceRef }) => (
+
+
+
+
+
+
+
+ {dropIndicatorProps &&
}
+
+ )}
+
+ );
+ }
+}
+
+Spacer.propTypes = propTypes;
+Spacer.defaultProps = defaultProps;
+
+export default Spacer;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
new file mode 100644
index 0000000000000..d2fbd946be6ed
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
@@ -0,0 +1,281 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Tabs as BootstrapTabs, Tab } from 'react-bootstrap';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import DashboardComponent from '../../containers/DashboardComponent';
+import EditableTitle from '../../../../components/EditableTitle';
+import DeleteComponentButton from '../DeleteComponentButton';
+import HoverMenu from '../menu/HoverMenu';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { TAB_TYPE } from '../../util/componentTypes';
+
+const NEW_TAB_INDEX = -1;
+const MAX_TAB_COUNT = 5;
+
+const propTypes = {
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ parentId: PropTypes.string.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ createComponent: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+ onChangeTab: PropTypes.func,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ onChangeTab: null,
+ children: null,
+};
+
+class Tabs extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ tabIndex: 0,
+ focusedId: null,
+ };
+ this.handleClicKTab = this.handleClicKTab.bind(this);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.handleDropOnTab = this.handleDropOnTab.bind(this);
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ this.handleChangeText = this.handleChangeText.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const maxIndex = Math.max(0, nextProps.component.children.length - 1);
+ if (this.state.tabIndex > maxIndex) {
+ this.setState(() => ({ tabIndex: maxIndex }));
+ }
+ }
+
+ handleClicKTab(tabIndex) {
+ const { onChangeTab, component, createComponent } = this.props;
+ const { focusedId } = this.state;
+
+ if (!focusedId && tabIndex !== NEW_TAB_INDEX && tabIndex !== this.state.tabIndex) {
+ this.setState(() => ({ tabIndex }));
+ if (onChangeTab) {
+ onChangeTab({ tabIndex, tab: component.children[tabIndex] });
+ }
+ } else if (!focusedId && tabIndex === NEW_TAB_INDEX) {
+ createComponent({
+ destination: {
+ droppableId: component.id,
+ index: component.children.length,
+ },
+ draggableId: TAB_TYPE,
+ });
+ }
+ }
+
+ handleChangeFocus(nextFocus) {
+ if (this.state.focusedId !== nextFocus) {
+ this.setState(() => ({ focusedId: nextFocus }));
+ }
+ }
+
+ handleChangeText({ id, nextTabText }) {
+ const { updateComponents, components } = this.props;
+ const tab = components[id];
+ if (nextTabText && tab && nextTabText !== tab.meta.text) {
+ updateComponents({
+ [tab.id]: {
+ ...tab,
+ meta: {
+ ...tab.meta,
+ text: nextTabText,
+ },
+ },
+ });
+ }
+ }
+
+ handleDeleteComponent(id) {
+ const { deleteComponent, component, parentId } = this.props;
+ const isTabsComponent = id === component.id;
+ if (isTabsComponent || component.children.length > 1) {
+ deleteComponent(id, isTabsComponent ? parentId : component.id);
+ }
+ }
+
+ handleDropOnTab(dropResult) {
+ const { component, handleComponentDrop } = this.props;
+ handleComponentDrop(dropResult);
+
+ // Ensure dropped tab is visible
+ const { destination } = dropResult;
+ if (destination) {
+ const dropTabIndex = destination.droppableId === component.id
+ ? destination.index // dropped ON tab
+ : component.children.indexOf(destination.droppableId); // dropped IN tab
+
+ if (dropTabIndex > -1) {
+ setTimeout(() => {
+ this.handleClicKTab(dropTabIndex);
+ }, 30);
+ }
+ }
+ }
+
+ render() {
+ const {
+ depth,
+ component: tabsComponent,
+ components,
+ parentId,
+ index,
+ availableColumnCount,
+ columnWidth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ } = this.props;
+
+ const { tabIndex: selectedTabIndex, focusedId } = this.state;
+ const { children: tabIds } = tabsComponent;
+
+ return (
+
+ {({ dropIndicatorProps: tabsDropIndicatorProps, dragSourceRef: tabsDragSourceRef }) => (
+
+
+
+ {
+ this.handleDeleteComponent(tabsComponent.id);
+ }}
+ />
+
+
+
+ {tabIds.map((tabId, tabIndex) => {
+ const tabComponent = components[tabId];
+ return (
+ // react-bootstrap doesn't render a Tab if we move this to its own Tab.jsx
+ // so we set the title as the Tab.jsx component. This also enables not needing
+ // the entire dashboard component lookup to render Tabs.jsx
+
+ {({ dropIndicatorProps, dragSourceRef }) => (
+
+
{
+ this.handleChangeFocus(nextFocus && tabId);
+ }}
+ menuItems={[
+ {
+ this.handleDeleteComponent(tabId);
+ }}
+ />,
+ ]}
+ >
+ {
+ this.handleChangeText({ id: tabId, nextTabText });
+ }}
+ showTooltip={false}
+ />
+
+
+ {dropIndicatorProps &&
+
}
+
+ )}
+
+ }
+ >
+ {/*
+ react-bootstrap renders all children with display:none, so we don't
+ render potentially-expensive charts (this also enables lazy loading
+ their content)
+ */}
+ {tabIndex === selectedTabIndex &&
+
+ {tabComponent.children.map((componentId, componentIndex) => (
+
+ ))}
+
}
+
+ );
+ })}
+
+ {tabIds.length < MAX_TAB_COUNT &&
+ }
+ />}
+
+
+ {tabsDropIndicatorProps
+ && tabsDropIndicatorProps.style
+ && tabsDropIndicatorProps.style.width === '100%'
+ &&
}
+
+
+ )}
+
+ );
+ }
+}
+
+Tabs.propTypes = propTypes;
+Tabs.defaultProps = defaultProps;
+
+export default Tabs;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/components.css b/superset/assets/javascripts/dashboard/v2/components/gridComponents/components.css
new file mode 100644
index 0000000000000..a88ea0991abbe
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/components.css
@@ -0,0 +1,455 @@
+/* Header */
+.dashboard-component-header {
+ width: 100%;
+ line-height: 1em;
+ font-weight: 700;
+ background-color: inherit;
+ padding: 16px 0;
+ color: #263238;
+}
+
+.header-small {
+ font-size: 16px;
+}
+
+.header-medium {
+ font-size: 22px;
+}
+
+.header-large {
+ font-size: 32px;
+}
+
+ .dragdroppable-row .dragdroppable-row .dashboard-component-header,
+ .dragdroppable-row .dragdroppable-row .dashboard-component-divider {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+
+/* Chart */
+.dashboard-component-chart {
+ width: 100%;
+ height: 100%;
+ color: #879399;
+ background-color: #fff;
+ padding: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.dashboard-component-chart .fa {
+ font-size: 100px;
+ opacity: 0.3;
+}
+
+.grid-container--resizing .dashboard-component-chart,
+.dashboard-builder--dragging .dashboard-component-chart,
+.dashboard-component-chart:hover {
+ box-shadow: inset 0 0 0 1px #CFD8DC;
+}
+
+/* Divider */
+.dashboard-component-divider {
+ width: 100%;
+ padding: 24px 0; /* this is padding not margin to enable a larger mouse target */
+ background-color: transparent;
+}
+
+.dashboard-component-divider:after {
+ content: "";
+ height: 1px;
+ width: 100%;
+ background-color: #CFD8DC;
+ display: block;
+}
+
+.new-component-placeholder.divider-placeholder:after {
+ content: "";
+ height: 2px;
+ width: 100%;
+ background-color: #CFD8DC;
+}
+
+.dragdroppable .dashboard-component-divider {
+ cursor: move;
+}
+
+/* Tabs -- this overwrites Superset bootstrap theme tab styling */
+.dashboard-component-tabs {
+ width: 100%;
+ background-color: white;
+}
+.dashboard-component-tabs .dashboard-component-tabs-content {
+ min-height: 48px;
+ margin-top: 1px;
+}
+
+.dashboard-component-tabs .nav-tabs {
+ border-bottom: none;
+}
+
+/* by moving padding from
to
we can restrict the selected tab indicator to text width */
+.dashboard-component-tabs .nav-tabs > li {
+ padding: 0 16px;
+}
+
+.dashboard-component-tabs .nav-tabs > li > a {
+ color: #263238;
+ border: none;
+ padding: 12px 0 14px 0;
+}
+
+.dashboard-component-tabs .nav-tabs > li.active > a {
+ border: none;
+}
+
+.dashboard-component-tabs .nav-tabs > li.active > a:after {
+ content: "";
+ position: absolute;
+ height: 3px;
+ width: 100%;
+ bottom: 0;
+ background: linear-gradient(to right, #E32464, #2C2261);
+}
+
+.dashboard-component-tabs .nav-tabs > li > a:hover {
+ border: none;
+ background: inherit;
+ color: #000000;
+}
+
+
+.dashboard-component-tabs .nav-tabs > li > a:focus {
+ outline: none;
+ background: #fff;
+}
+
+.dashboard-component-tabs .nav-tabs > li .dragdroppable-tab {
+ cursor: move;
+}
+
+.dashboard-component-tabs .nav-tabs > li .drop-indicator {
+ height: 40px !important;
+ top: -10px !important;
+ opacity: 0.5;
+}
+
+.dashboard-component-tabs .fa-plus-square {
+ background: linear-gradient(135deg, #E32464, #2C2261);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ display: initial;
+ font-size: 16px;
+}
+
+/* New components */
+.new-component {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: center;
+ padding: 16px;
+ background: white;
+}
+
+.new-component-placeholder {
+ position: relative;
+ background: #f5f5f5;
+ width: 40px;
+ height: 40px;
+ margin-right: 16px;
+ box-shadow: 0 0 1px #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #aaa;
+ font-size: 1.5em;
+}
+
+/* Spacer */
+.grid-container {
+ flex-grow: 1;
+ min-width: 66%;
+ margin: 24px 32px;
+ height: 100%;
+ position: relative;
+}
+
+.new-component-placeholder.spacer-placeholder {
+ font-size: 1em;
+}
+
+.new-component-placeholder.fa-window-restore {
+ font-size: 1em;
+}
+
+.new-component-placeholder.spacer-placeholder:after {
+ content: "";
+ position: absolute;
+ height: 60%;
+ width: 60%;
+ border: 1px dashed #aaa;
+}
+
+/* columns and rows */
+.grid-column {
+ width: 100%;
+ min-height: 56px;
+}
+
+.grid-column > .hover-menu--top {
+ top: -20px;
+}
+
+.grid-row {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ width: 100%;
+ height: fit-content;
+ background-color: transparent;
+}
+
+.grid-row--transparent {
+ background-color: transparent;
+}
+
+.grid-row--white {
+ background-color: #fff;
+}
+
+.dashboard-component-header.grid-row--white {
+ padding-left: 16px;
+}
+
+.grid-row.grid-row--empty {
+ align-items: center; /* this centers the empty note content */
+ height: 80px;
+}
+
+.grid-row--empty:after {
+ position: absolute;
+ top: 0;
+ left: 0;
+ content: "Empty row";
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ color: #aaa;
+}
+
+.grid-column--empty:after {
+ content: "Empty column";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #CFD8DC;
+}
+
+/* spacer */
+.grid-spacer {
+ width: 100%;
+ height: 100%;
+ background-color: transparent;
+}
+
+.dragdroppable .grid-spacer {
+ cursor: move;
+}
+
+.dragdroppable:hover .grid-spacer {
+ box-shadow: inset 0 0 0 1px #CFD8DC;
+}
+
+/* popover menu */
+.with-popover-menu {
+ position: relative;
+ outline: none;
+}
+
+.grid-row.grid-row--empty .with-popover-menu { /* drop indicator doesn't show up without this */
+ width: 100%;
+ height: 100%;
+}
+
+.with-popover-menu--focused:after {
+ content: "";
+ position: absolute;
+ top: 1;
+ left: -1;
+ width: 100%;
+ height: 100%;
+ box-shadow: inset 0 0 0 2px #44C0FF;
+ pointer-events: none;
+ z-index: 9;
+}
+
+.popover-menu {
+ position: absolute;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ flex-wrap: nowrap;
+ left: 1px;
+ top: -42px;
+ height: 40px;
+ padding: 0 16px;
+ background: #fff;
+ box-shadow: 0 1px 2px 1px rgba(0, 0, 0, 0.2);
+ font-size: 14px;
+ cursor: default;
+ z-index: 10;
+}
+
+.popover-menu .menu-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+/* vertical spacer after each menu item */
+.popover-menu .menu-item:not(:only-child):not(:last-child):after {
+ content: "";
+ width: 1;
+ height: 100%;
+ background: #CFD8DC;
+ margin: 0 16px;
+}
+
+.popover-menu .popover-dropdown.btn {
+ border: none;
+ padding: 0;
+ font-size: inherit;
+ color: #000;
+}
+
+.popover-menu .popover-dropdown.btn:hover,
+.popover-menu .popover-dropdown.btn:active,
+.popover-menu .popover-dropdown.btn:focus,
+.hover-dropdown .btn:hover,
+.hover-dropdown .btn:active,
+.hover-dropdown .btn:focus {
+ background: initial;
+ box-shadow: none;
+}
+
+.hover-dropdown li.dropdown-item:hover a,
+.popover-menu li.dropdown-item:hover a {
+ background: #CFD8DC;
+}
+
+.popover-dropdown .caret { /* without this the caret doesn't take up full width / is clipped */
+ width: auto;
+ border-top-color: transparent;
+}
+
+
+.hover-dropdown li.dropdown-item.active a,
+.popover-menu li.dropdown-item.active a {
+ background: #fff;
+ font-weight: bold;
+ color: #000;
+}
+
+/* row style menu */
+.row-style-option {
+ display: inline-block;
+}
+
+.row-style-option:before {
+ content: "";
+ width: 1em;
+ height: 1em;
+ margin-right: 8px;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.row-style-option.grid-row--white {
+ padding-left: 0;
+ background: transparent;
+}
+
+.row-style-option.grid-row--white:before {
+ background: #fff;
+ border: 1px solid #CFD8DC;
+}
+
+.row-style-option.grid-row--transparent:before {
+ background: #CFD8DC;
+}
+
+/* hover menu */
+.hover-menu {
+ opacity: 0;
+ position: absolute;
+ z-index: 2;
+}
+
+.hover-menu--left {
+ width: 20px;
+ height: 100%;
+ top: 0;
+ left: -20px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+.hover-menu--left > :nth-child(n):not(:only-child):not(:last-child) {
+ margin-bottom: 8px;
+}
+
+.dragdroppable-row .dragdroppable-row .hover-menu--left {
+ left: 1px;
+}
+
+.hover-menu--top {
+ width: 100%;
+ height: 20px;
+ top: 0;
+ left: 0;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+}
+
+.hover-menu--top > :nth-child(n):not(:only-child):not(:last-child) {
+ margin-right: 8px;
+}
+
+.dragdroppable:hover .hover-menu,
+.dragdroppable .hover-menu:hover {
+ opacity: 1;
+}
+
+
+/* Menu fa buttons */
+.icon-button {
+ color: #879399;
+ font-size: 1em;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ outline: none;
+}
+
+.icon-button:hover,
+.icon-button:active,
+.icon-button:focus {
+ color: #484848;
+ outline: none;
+ text-decoration: none;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/grid.css b/superset/assets/javascripts/dashboard/v2/components/gridComponents/grid.css
new file mode 100644
index 0000000000000..6119eabefcbea
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/grid.css
@@ -0,0 +1,17 @@
+/* Editing guides */
+.grid-column-guide {
+ position: absolute;
+ top: 0;
+ height: 100%;
+ background-color: rgba(68, 192, 255, 0.05);
+ pointer-events: none;
+ box-shadow: inset 0 0 0 1px rgba(68, 192, 255, 0.5);
+}
+
+.grid-row-guide {
+ position: absolute;
+ left: 0;
+ height: 1;
+ background-color: #44C0FF;
+ pointer-events: none;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js b/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js
new file mode 100644
index 0000000000000..1d1e4b3b752ad
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js
@@ -0,0 +1,39 @@
+import './components.css';
+
+import {
+ CHART_TYPE,
+ COLUMN_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ INVISIBLE_ROW_TYPE,
+ ROW_TYPE,
+ SPACER_TYPE,
+ TABS_TYPE,
+} from '../../util/componentTypes';
+
+import Chart from './Chart';
+import Column from './Column';
+import Divider from './Divider';
+import Header from './Header';
+import Row from './Row';
+import Spacer from './Spacer';
+import Tabs from './Tabs';
+
+export { default as Chart } from './Chart';
+export { default as Column } from './Column';
+export { default as Divider } from './Divider';
+export { default as Header } from './Header';
+export { default as Row } from './Row';
+export { default as Spacer } from './Spacer';
+export { default as Tabs } from './Tabs';
+
+export default {
+ [CHART_TYPE]: Chart,
+ [COLUMN_TYPE]: Column,
+ [DIVIDER_TYPE]: Divider,
+ [HEADER_TYPE]: Header,
+ [INVISIBLE_ROW_TYPE]: Row,
+ [ROW_TYPE]: Row,
+ [SPACER_TYPE]: Spacer,
+ [TABS_TYPE]: Tabs,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
new file mode 100644
index 0000000000000..10a157fc2886c
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../../dnd/DragDroppable';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ className: PropTypes.string,
+};
+
+const defaultProps = {
+ className: null,
+};
+
+export default class DraggableNewComponent extends React.PureComponent {
+ render() {
+ const { label, id, type, className } = this.props;
+ return (
+
+ {({ dragSourceRef }) => (
+
+ )}
+
+ );
+ }
+}
+
+DraggableNewComponent.propTypes = propTypes;
+DraggableNewComponent.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewChart.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewChart.jsx
new file mode 100644
index 0000000000000..0255755a888e4
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewChart.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { CHART_TYPE } from '../../../util/componentTypes';
+import { NEW_CHART_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewChart extends React.PureComponent {
+ render() {
+ return (
+
+ );
+ }
+}
+
+DraggableNewChart.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewColumn.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewColumn.jsx
new file mode 100644
index 0000000000000..654c60bd45a21
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewColumn.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { COLUMN_TYPE } from '../../../util/componentTypes';
+import { NEW_COLUMN_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewColumn extends React.PureComponent {
+ render() {
+ return (
+
+ );
+ }
+}
+
+DraggableNewColumn.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewDivider.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewDivider.jsx
new file mode 100644
index 0000000000000..5d70041a5d4da
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewDivider.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { DIVIDER_TYPE } from '../../../util/componentTypes';
+import { NEW_DIVIDER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewDivider extends React.PureComponent {
+ render() {
+ return (
+
+ );
+ }
+}
+
+DraggableNewDivider.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewHeader.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewHeader.jsx
new file mode 100644
index 0000000000000..d207a9c9c8fc0
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewHeader.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { HEADER_TYPE } from '../../../util/componentTypes';
+import { NEW_HEADER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewHeader extends React.Component {
+ render() {
+ return (
+
+ );
+ }
+}
+
+DraggableNewHeader.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewRow.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewRow.jsx
new file mode 100644
index 0000000000000..1d9ab103a98ec
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewRow.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { ROW_TYPE } from '../../../util/componentTypes';
+import { NEW_ROW_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewRow extends React.PureComponent {
+ render() {
+ return (
+
+ );
+ }
+}
+
+DraggableNewRow.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewSpacer.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewSpacer.jsx
new file mode 100644
index 0000000000000..7287770b275e2
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewSpacer.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { SPACER_TYPE } from '../../../util/componentTypes';
+import { NEW_SPACER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewChart extends React.PureComponent {
+ render() {
+ return (
+
+ );
+ }
+}
+
+DraggableNewChart.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewTabs.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewTabs.jsx
new file mode 100644
index 0000000000000..a473281984ceb
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewTabs.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { TABS_TYPE } from '../../../util/componentTypes';
+import { NEW_TABS_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewTabs extends React.PureComponent {
+ render() {
+ return (
+
+ );
+ }
+}
+
+DraggableNewTabs.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/HoverMenu.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/HoverMenu.jsx
new file mode 100644
index 0000000000000..c238d023d8dd1
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/HoverMenu.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+ position: PropTypes.oneOf(['left', 'top']),
+ innerRef: PropTypes.func,
+ children: PropTypes.node,
+};
+
+const defaultProps = {
+ position: 'left',
+ innerRef: null,
+ children: null,
+};
+
+export default class HoverMenu extends React.PureComponent {
+ render() {
+ const { innerRef, position, children } = this.props;
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+HoverMenu.propTypes = propTypes;
+HoverMenu.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/PopoverDropdown.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/PopoverDropdown.jsx
new file mode 100644
index 0000000000000..6a56eab239dd3
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/PopoverDropdown.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ options: PropTypes.arrayOf(
+ PropTypes.shape({
+ value: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ className: PropTypes.string,
+ }),
+ ).isRequired,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.string.isRequired,
+ renderButton: PropTypes.func,
+ renderOption: PropTypes.func,
+};
+
+const defaultProps = {
+ renderButton: option => option.label,
+ renderOption: option =>
{option.label}
,
+};
+
+class PopoverDropdown extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleSelect = this.handleSelect.bind(this);
+ }
+
+ handleSelect(nextValue) {
+ this.props.onChange(nextValue);
+ }
+
+ render() {
+ const { id, value, options, renderButton, renderOption } = this.props;
+ const selected = options.find(opt => opt.value === value);
+ return (
+
+ {options.map(option => (
+
+ {renderOption(option)}
+
+ ))}
+
+ );
+ }
+}
+
+PopoverDropdown.propTypes = propTypes;
+PopoverDropdown.defaultProps = defaultProps;
+
+export default PopoverDropdown;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/RowStyleDropdown.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/RowStyleDropdown.jsx
new file mode 100644
index 0000000000000..d3c7eff965774
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/RowStyleDropdown.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import rowStyleOptions from '../../util/rowStyleOptions';
+import PopoverDropdown from './PopoverDropdown';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+function renderButton(option) {
+ return (
+
+ {`${option.label} background`}
+
+ );
+}
+
+function renderOption(option) {
+ return (
+
+ {option.label}
+
+ );
+}
+
+export default class RowStyleDropdown extends React.PureComponent {
+ render() {
+ const { id, value, onChange } = this.props;
+ return (
+
+ );
+ }
+}
+
+RowStyleDropdown.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
new file mode 100644
index 0000000000000..1cd8458669e00
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+ children: PropTypes.node,
+ disableClick: PropTypes.bool,
+ menuItems: PropTypes.arrayOf(PropTypes.node),
+ onChangeFocus: PropTypes.func,
+ isFocused: PropTypes.bool,
+};
+
+const defaultProps = {
+ children: null,
+ disableClick: false,
+ onChangeFocus: null,
+ onPressDelete() {},
+ menuItems: [],
+ isFocused: false,
+};
+
+class WithPopoverMenu extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: props.isFocused,
+ };
+ this.setRef = this.setRef.bind(this);
+ // this.setPopoverRef = this.setPopoverRef.bind(this);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.isFocused && !this.state.isFocused) {
+ document.addEventListener('click', this.handleClick, true);
+ document.addEventListener('drag', this.handleClick, true);
+ this.setState({ isFocused: true });
+ }
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.handleClick, true);
+ document.removeEventListener('drag', this.handleClick, true);
+ }
+
+ setRef(ref) {
+ this.container = ref;
+ }
+ //
+ // setPopoverRef(ref) {
+ // this.popover = ref;
+ // }
+
+ handleClick(event) {
+ const { onChangeFocus } = this.props;
+ if (!this.state.isFocused) {
+ // if not focused, set focus and add a window event listener to capture outside clicks
+ // this enables us to not set a click listener for ever item on a dashboard
+ document.addEventListener('click', this.handleClick, true);
+ document.addEventListener('drag', this.handleClick, true);
+ this.setState(() => ({ isFocused: true }));
+ if (onChangeFocus) {
+ onChangeFocus(true);
+ }
+ } else if (!this.container.contains(event.target)) {
+ document.removeEventListener('click', this.handleClick, true);
+ document.removeEventListener('drag', this.handleClick, true);
+ this.setState(() => ({ isFocused: false }));
+ if (onChangeFocus) {
+ onChangeFocus(false);
+ }
+ }
+ }
+
+ render() {
+ const { children, menuItems, disableClick } = this.props;
+ const { isFocused } = this.state;
+
+ return (
+
+ {children}
+ {isFocused && menuItems.length &&
+
+ {menuItems.map((node, i) => (
+
{node}
+ ))}
+
}
+
+ );
+ }
+}
+
+WithPopoverMenu.propTypes = propTypes;
+WithPopoverMenu.defaultProps = defaultProps;
+
+export default WithPopoverMenu;
diff --git a/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
new file mode 100644
index 0000000000000..bd590ae2c82c3
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
@@ -0,0 +1,184 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Resizable from 're-resizable';
+import cx from 'classnames';
+
+import ResizableHandle from './ResizableHandle';
+import resizableConfig from '../../util/resizableConfig';
+import {
+ GRID_BASE_UNIT,
+ GRID_ROW_HEIGHT_UNIT,
+ GRID_GUTTER_SIZE,
+} from '../../util/constants';
+
+import './resizable.css';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ adjustableWidth: PropTypes.bool,
+ adjustableHeight: PropTypes.bool,
+ gutterWidth: PropTypes.number,
+ widthStep: PropTypes.number,
+ heightStep: PropTypes.number,
+ widthMultiple: PropTypes.number,
+ heightMultiple: PropTypes.number,
+ minWidthMultiple: PropTypes.number,
+ maxWidthMultiple: PropTypes.number,
+ minHeightMultiple: PropTypes.number,
+ maxHeightMultiple: PropTypes.number,
+ staticHeightMultiple: PropTypes.number,
+ onResizeStop: PropTypes.func,
+ onResize: PropTypes.func,
+ onResizeStart: PropTypes.func,
+};
+
+const defaultProps = {
+ children: null,
+ adjustableWidth: true,
+ adjustableHeight: true,
+ gutterWidth: GRID_GUTTER_SIZE,
+ widthStep: GRID_BASE_UNIT,
+ heightStep: GRID_ROW_HEIGHT_UNIT,
+ widthMultiple: null,
+ heightMultiple: null,
+ minWidthMultiple: 1,
+ maxWidthMultiple: Infinity,
+ minHeightMultiple: 1,
+ maxHeightMultiple: Infinity,
+ staticHeightMultiple: null,
+ onResizeStop: null,
+ onResize: null,
+ onResizeStart: null,
+};
+
+// because columns are not actually multiples of a single variable (width = n*cols + (n-1)*gutters)
+// we snap to the base unit and then snap to actual column multiples on stop
+const snapToGrid = [GRID_BASE_UNIT, GRID_BASE_UNIT];
+
+class ResizableContainer extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isResizing: false,
+ };
+
+ this.handleResizeStart = this.handleResizeStart.bind(this);
+ this.handleResize = this.handleResize.bind(this);
+ this.handleResizeStop = this.handleResizeStop.bind(this);
+ }
+
+ handleResizeStart(event, direction, ref) {
+ const { id, onResizeStart } = this.props;
+
+ if (onResizeStart) {
+ onResizeStart({ id, direction, ref });
+ }
+
+ this.setState(() => ({ isResizing: true }));
+ }
+
+ handleResize(event, direction, ref) {
+ const { onResize, id } = this.props;
+ if (onResize) {
+ onResize({ id, direction, ref });
+ }
+ }
+
+ handleResizeStop(event, direction, ref, delta) {
+ const {
+ id,
+ onResizeStop,
+ widthStep,
+ heightStep,
+ widthMultiple,
+ heightMultiple,
+ adjustableHeight,
+ adjustableWidth,
+ gutterWidth,
+ } = this.props;
+
+ if (onResizeStop) {
+ const nextWidthMultiple =
+ Math.round(widthMultiple + (delta.width / (widthStep + gutterWidth)));
+ const nextHeightMultiple =
+ Math.round(heightMultiple + (delta.height / heightStep));
+
+ onResizeStop({
+ id,
+ widthMultiple: adjustableWidth ? nextWidthMultiple : null,
+ heightMultiple: adjustableHeight ? nextHeightMultiple : null,
+ });
+
+ this.setState(() => ({ isResizing: false }));
+ }
+ }
+
+ render() {
+ const {
+ children,
+ adjustableWidth,
+ adjustableHeight,
+ widthStep,
+ heightStep,
+ staticHeightMultiple,
+ widthMultiple,
+ heightMultiple,
+ minWidthMultiple,
+ maxWidthMultiple,
+ minHeightMultiple,
+ maxHeightMultiple,
+ gutterWidth,
+ } = this.props;
+
+ const size = {
+ width: adjustableWidth
+ ? ((widthStep + gutterWidth) * widthMultiple) - gutterWidth : undefined,
+ height: adjustableHeight
+ ? heightStep * heightMultiple
+ : (staticHeightMultiple && staticHeightMultiple * heightStep) || undefined,
+ };
+
+ let enableConfig = resizableConfig.widthAndHeight;
+ if (!adjustableHeight) enableConfig = resizableConfig.widthOnly;
+ else if (!adjustableWidth) enableConfig = resizableConfig.heightOnly;
+
+ const { isResizing } = this.state;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+ResizableContainer.propTypes = propTypes;
+ResizableContainer.defaultProps = defaultProps;
+
+export default ResizableContainer;
diff --git a/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableHandle.jsx b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableHandle.jsx
new file mode 100644
index 0000000000000..9536f6bbf8bc5
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableHandle.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+export function BottomRightResizeHandle() {
+ return (
+
+ );
+}
+
+export function RightResizeHandle() {
+ return (
+
+ );
+}
+
+export function BottomResizeHandle() {
+ return (
+
+ );
+}
+
+export default {
+ right: RightResizeHandle,
+ bottom: BottomResizeHandle,
+ bottomRight: BottomRightResizeHandle,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/components/resizable/resizable.css b/superset/assets/javascripts/dashboard/v2/components/resizable/resizable.css
new file mode 100644
index 0000000000000..1d5de7271b5b6
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/resizable.css
@@ -0,0 +1,72 @@
+.grid-resizable-container {
+ background-color: transparent;
+ position: relative;
+}
+
+/* after ensures border visibility on top of any children */
+.grid-resizable-container--resizing:after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ box-shadow: inset 0 0 0 2px #44C0FF;
+}
+
+.resize-handle {
+ opacity: 0;
+}
+
+ .grid-resizable-container:hover .resize-handle,
+ .grid-resizable-container--resizing .resize-handle {
+ opacity: 1;
+ }
+
+.resize-handle--bottom-right {
+ position: absolute;
+ border: solid;
+ border-width: 0 1.5px 1.5px 0;
+ border-right-color: #879399;
+ border-bottom-color: #879399;
+ right: 16;
+ bottom: 16;
+ width: 8px;
+ height: 8px;
+}
+
+.resize-handle--right {
+ width: 2px;
+ height: 20px;
+ right: -2px;
+ top: 47%;
+ position: absolute;
+ border-left: 1px solid #879399;
+ border-right: 1px solid #879399;
+}
+
+ .grid-spacer + span .resize-handle--right {
+ right: 3px;
+ }
+
+.resize-handle--bottom {
+ height: 2px;
+ width: 20px;
+ bottom: 10px;
+ left: 47%;
+ position: absolute;
+ border-top: 1px solid #879399;
+ border-bottom: 1px solid #879399;
+}
+
+.grid-resizable-container--resizing > span .resize-handle {
+ border-color: #44C0FF;
+}
+
+/* re-resizable sets an empty div to 100% width and height, which doesn't
+ play well with many 100% height containers we need
+ */
+.grid-resizable-container ~ div {
+ width: auto !important;
+ height: auto !important;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
new file mode 100644
index 0000000000000..10313f185313b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import ComponentLookup from '../components/gridComponents';
+import { componentShape } from '../util/propShapes';
+
+import {
+ createComponent,
+ deleteComponent,
+ updateComponents,
+ handleComponentDrop,
+} from '../actions';
+
+const propTypes = {
+ component: componentShape.isRequired,
+ components: PropTypes.object.isRequired,
+ createComponent: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+};
+
+function mapStateToProps({ dashboard = {} }, ownProps) {
+ const { id } = ownProps;
+ return {
+ component: dashboard[id],
+ components: dashboard,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators({
+ createComponent,
+ deleteComponent,
+ updateComponents,
+ handleComponentDrop,
+ }, dispatch);
+}
+
+class DashboardComponent extends React.PureComponent {
+ render() {
+ const { component } = this.props;
+ const Component = ComponentLookup[component.type];
+ return Component ?
: null;
+ }
+}
+
+DashboardComponent.propTypes = propTypes;
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardComponent);
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
new file mode 100644
index 0000000000000..741151b780e82
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
@@ -0,0 +1,23 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import DashboardGrid from '../components/DashboardGrid';
+
+import {
+ updateComponents,
+ handleComponentDrop,
+} from '../actions';
+
+function mapStateToProps({ dashboard = {} }) {
+ return {
+ dashboard,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators({
+ updateComponents,
+ handleComponentDrop,
+ }, dispatch);
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardGrid);
diff --git a/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js b/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js
new file mode 100644
index 0000000000000..64b46725dea7e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js
@@ -0,0 +1,162 @@
+import {
+ COLUMN_TYPE,
+ HEADER_TYPE,
+ ROW_TYPE,
+ INVISIBLE_ROW_TYPE,
+ SPACER_TYPE,
+ TAB_TYPE,
+ TABS_TYPE,
+ CHART_TYPE,
+ DIVIDER_TYPE,
+ GRID_ROOT_TYPE,
+} from '../util/componentTypes';
+
+import { DASHBOARD_ROOT_ID } from '../util/constants';
+
+export default {
+ [DASHBOARD_ROOT_ID]: {
+ type: GRID_ROOT_TYPE,
+ id: DASHBOARD_ROOT_ID,
+ children: [
+ // 'header0',
+ // 'row0',
+ // 'divider0',
+ // 'row1',
+ // 'tabs0',
+ // 'divider1',
+ ],
+ },
+ // row0: {
+ // id: 'row0',
+ // type: INVISIBLE_ROW_TYPE,
+ // children: [
+ // // 'charta',
+ // // 'chartb',
+ // // 'chartc',
+ // ],
+ // },
+ // row1: {
+ // id: 'row1',
+ // type: ROW_TYPE,
+ // children: [
+ // 'header1',
+ // ],
+ // },
+ // row2: {
+ // id: 'row2',
+ // type: ROW_TYPE,
+ // children: [
+ // 'chartd',
+ // 'spacer0',
+ // 'charte',
+ // ],
+ // },
+ // tabs0: {
+ // id: 'tabs0',
+ // type: TABS_TYPE,
+ // children: [
+ // 'tab0',
+ // 'tab1',
+ // 'tab3',
+ // ],
+ // meta: {
+ // },
+ // },
+ // tab0: {
+ // id: 'tab0',
+ // type: TAB_TYPE,
+ // children: [
+ // // 'row2',
+ // ],
+ // meta: {
+ // text: 'Tab A',
+ // },
+ // },
+ // tab1: {
+ // id: 'tab1',
+ // type: TAB_TYPE,
+ // children: [
+ // ],
+ // meta: {
+ // text: 'Tab B',
+ // },
+ // },
+ // tab3: {
+ // id: 'tab3',
+ // type: TAB_TYPE,
+ // children: [
+ // ],
+ // meta: {
+ // text: 'Tab C',
+ // },
+ // },
+ // header0: {
+ // id: 'header0',
+ // type: HEADER_TYPE,
+ // meta: {
+ // text: 'Header 1',
+ // },
+ // },
+ // header1: {
+ // id: 'header1',
+ // type: HEADER_TYPE,
+ // meta: {
+ // text: 'Header 2',
+ // },
+ // },
+ // divider0: {
+ // id: 'divider0',
+ // type: DIVIDER_TYPE,
+ // },
+ // divider1: {
+ // id: 'divider1',
+ // type: DIVIDER_TYPE,
+ // },
+ // charta: {
+ // id: 'charta',
+ // type: CHART_TYPE,
+ // meta: {
+ // width: 3,
+ // height: 10,
+ // },
+ // },
+ // chartb: {
+ // id: 'chartb',
+ // type: CHART_TYPE,
+ // meta: {
+ // width: 3,
+ // height: 10,
+ // },
+ // },
+ // chartc: {
+ // id: 'chartc',
+ // type: CHART_TYPE,
+ // meta: {
+ // width: 3,
+ // height: 10,
+ // },
+ // },
+ // chartd: {
+ // id: 'chartd',
+ // type: CHART_TYPE,
+ // meta: {
+ // width: 3,
+ // height: 10,
+ // },
+ // },
+ // charte: {
+ // id: 'charte',
+ // type: CHART_TYPE,
+ // meta: {
+ // width: 3,
+ // height: 10,
+ // },
+ // },
+ // spacer0: {
+ // id: 'spacer0',
+ // type: SPACER_TYPE,
+ // meta: {
+ // width: 1,
+ // },
+ // },
+};
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js b/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
new file mode 100644
index 0000000000000..ea110e6b27d3f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
@@ -0,0 +1,107 @@
+import newComponentFactory from '../util/newComponentFactory';
+import newEntitiesFromDrop from '../util/newEntitiesFromDrop';
+import reorderItem from '../util/dnd-reorder';
+import shouldWrapChildInRow from '../util/shouldWrapChildInRow';
+import { ROW_TYPE } from '../util/componentTypes';
+
+import {
+ UPDATE_COMPONENTS,
+ DELETE_COMPONENT,
+ CREATE_COMPONENT,
+ MOVE_COMPONENT,
+} from '../actions';
+
+const actionHandlers = {
+ [UPDATE_COMPONENTS](state, action) {
+ const { payload: { nextComponents } } = action;
+ return {
+ ...state,
+ ...nextComponents,
+ };
+ },
+
+ [DELETE_COMPONENT](state, action) {
+ const { payload: { id, parentId } } = action;
+
+ if (!parentId || !id || !state[id] || !state[parentId]) return state;
+
+ const nextComponents = { ...state };
+
+ // recursively find children to remove
+ let deleteCount = 0;
+ function recursivelyDeleteChildren(componentId, componentParentId) {
+ // delete child and it's children
+ const component = nextComponents[componentId];
+ delete nextComponents[componentId];
+ deleteCount += 1;
+ const { children = [] } = component;
+ children.forEach((childId) => { recursivelyDeleteChildren(childId, componentId); });
+
+ const parent = nextComponents[componentParentId];
+ if (parent) { // may have been deleted in another recursion
+ const componentIndex = (parent.children || []).indexOf(componentId);
+ if (componentIndex > -1) {
+ parent.children.splice(componentIndex, 1);
+ }
+ }
+ }
+
+ recursivelyDeleteChildren(id, parentId);
+ console.log('deleted', deleteCount, 'total components', nextComponents);
+
+ return nextComponents;
+ },
+
+ [CREATE_COMPONENT](state, action) {
+ const { payload: { dropResult } } = action;
+ const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+ return {
+ ...state,
+ ...newEntities,
+ };
+ },
+
+ [MOVE_COMPONENT](state, action) {
+ const { payload: { dropResult } } = action;
+ const { source, destination, draggableId } = dropResult;
+
+ if (!source || !destination || !draggableId) return state;
+
+ const nextEntities = reorderItem({
+ entitiesMap: state,
+ source,
+ destination,
+ });
+
+ // wrap the dragged component in a row depening on destination type
+ const destinationType = (state[destination.droppableId] || {}).type;
+ const draggableType = (state[draggableId] || {}).type;
+ const wrapInRow = shouldWrapChildInRow({
+ parentType: destinationType,
+ childType: draggableType,
+ });
+
+ if (wrapInRow) {
+ const destinationEntity = nextEntities[destination.droppableId];
+ const destinationChildren = destinationEntity.children;
+ const newRow = newComponentFactory(ROW_TYPE);
+ newRow.children = [destinationChildren[destination.index]];
+ destinationChildren[destination.index] = newRow.id;
+ nextEntities[newRow.id] = newRow;
+ }
+
+ return {
+ ...state,
+ ...nextEntities,
+ };
+ },
+};
+
+export default function dashboardReducer(state = {}, action) {
+ if (action.type in actionHandlers) {
+ const handler = actionHandlers[action.type];
+ return handler(state, action);
+ }
+
+ return state;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/index.js b/superset/assets/javascripts/dashboard/v2/reducers/index.js
new file mode 100644
index 0000000000000..103fda0178890
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/reducers/index.js
@@ -0,0 +1,6 @@
+import { combineReducers } from 'redux';
+import dashboard from './dashboard';
+
+export default combineReducers({
+ dashboard,
+});
diff --git a/superset/assets/javascripts/dashboard/v2/testDashboardLayout.js b/superset/assets/javascripts/dashboard/v2/testDashboardLayout.js
deleted file mode 100644
index ef096d7f71233..0000000000000
--- a/superset/assets/javascripts/dashboard/v2/testDashboardLayout.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { CHART, HEADER, ROW } from './builderTypes';
-
-export default {};
diff --git a/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js b/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js
new file mode 100644
index 0000000000000..ab701a73e6e02
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js
@@ -0,0 +1,15 @@
+import {
+ SPACER_TYPE,
+ COLUMN_TYPE,
+ CHART_TYPE,
+ MARKDOWN_TYPE,
+} from './componentTypes';
+
+export default function componentIsResizable(entity) {
+ return [
+ SPACER_TYPE,
+ COLUMN_TYPE,
+ CHART_TYPE,
+ MARKDOWN_TYPE,
+ ].indexOf(entity.type) > -1;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/componentTypes.js b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
new file mode 100644
index 0000000000000..fd5d2940a7afa
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
@@ -0,0 +1,23 @@
+export const CHART_TYPE = 'DASHBOARD_CHART_TYPE';
+export const COLUMN_TYPE = 'DASHBOARD_COLUMN_TYPE';
+export const DIVIDER_TYPE = 'DASHBOARD_DIVIDER_TYPE';
+export const GRID_ROOT_TYPE = 'DASHBOARD_GRID_ROOT_TYPE';
+export const HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
+export const MARKDOWN_TYPE = 'DASHBOARD_MARKDOWN_TYPE';
+export const ROW_TYPE = 'DASHBOARD_ROW_TYPE';
+export const SPACER_TYPE = 'DASHBOARD_SPACER_TYPE';
+export const TABS_TYPE = 'DASHBOARD_TABS_TYPE';
+export const TAB_TYPE = 'DASHBOARD_TAB_TYPE';
+
+export default {
+ CHART_TYPE,
+ COLUMN_TYPE,
+ DIVIDER_TYPE,
+ GRID_ROOT_TYPE,
+ HEADER_TYPE,
+ MARKDOWN_TYPE,
+ ROW_TYPE,
+ SPACER_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/util/constants.js b/superset/assets/javascripts/dashboard/v2/util/constants.js
index a46585786b7fd..44a0f0e823b87 100644
--- a/superset/assets/javascripts/dashboard/v2/util/constants.js
+++ b/superset/assets/javascripts/dashboard/v2/util/constants.js
@@ -1,19 +1,30 @@
-export const CHART = 'DASHBOARD_CHART';
-export const MARKDOWN = 'DASHBOARD_MARKDOWN';
-export const SEPARATOR = 'DASHBOARD_SEPARATOR';
-export const ROW = 'DASHBOARD_ROW';
-export const TABS = 'DASHBOARD_TABS';
-export const HEADER = 'DASHBOARD_HEADER';
+// Ids
+export const DASHBOARD_ROOT_ID = 'DASHBOARD_ROOT_ID';
+export const NEW_CHART_ID = 'NEW_CHART_ID';
+export const NEW_COLUMN_ID = 'NEW_COLUMN_ID';
+export const NEW_DIVIDER_ID = 'NEW_DIVIDER_ID';
+export const NEW_HEADER_ID = 'NEW_HEADER_ID';
+export const NEW_MARKDOWN_ID = 'NEW_MARKDOWN_ID';
+export const NEW_ROW_ID = 'NEW_ROW_ID';
+export const NEW_SPACER_ID = 'NEW_SPACER_ID';
+export const NEW_TAB_ID = 'NEW_TAB_ID';
+export const NEW_TABS_ID = 'NEW_TABS_ID';
-export const DROPPABLE_NEW_COMPONENT = 'DROPPABLE_NEW_COMPONENT';
-export const DROPPABLE_DASHBOARD_ROOT = 'DASHBOARD_ROOT_DROPPABLE';
+// grid constants
+export const GRID_BASE_UNIT = 8;
+export const GRID_GUTTER_SIZE = 2 * GRID_BASE_UNIT;
+export const GRID_ROW_HEIGHT_UNIT = 2 * GRID_BASE_UNIT;
+export const GRID_COLUMN_COUNT = 12;
+export const GRID_MIN_COLUMN_COUNT = 3;
+export const GRID_MIN_ROW_UNITS = 5;
+export const GRID_MAX_ROW_UNITS = 100;
+export const GRID_MIN_ROW_HEIGHT = GRID_GUTTER_SIZE;
-export const DRAGGABLE_NEW_CHART = 'DRAGGABLE_NEW_CHART';
-export const DRAGGABLE_NEW_DIVIDER = 'DRAGGABLE_NEW_DIVIDER';
-export const DRAGGABLE_NEW_HEADER = 'DRAGGABLE_NEW_HEADER';
-export const DRAGGABLE_NEW_ROW = 'DRAGGABLE_NEW_ROW';
+// Header types
+export const SMALL_HEADER = 'SMALL_HEADER';
+export const MEDIUM_HEADER = 'MEDIUM_HEADER';
+export const LARGE_HEADER = 'LARGE_HEADER';
-export const DRAGGABLE_ROW_TYPE = 'DRAGGABLE_ROW_TYPE';
-
-export const VERTICAL_DIRECTION = 'vertical';
-export const HORIZONTAL_DIRECTION = 'horizontal';
+// Row types
+export const ROW_WHITE = 'ROW_WHITE';
+export const ROW_TRANSPARENT = 'ROW_TRANSPARENT';
diff --git a/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js b/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
index 81d479ae1e65e..5ebca8cf92034 100644
--- a/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
+++ b/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
@@ -1,16 +1,16 @@
-export const reorder = (list, startIndex, endIndex) => {
+export function reorder(list, startIndex, endIndex) {
const result = [...list];
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
-};
+}
-export const reorderRows = ({
+export default function reorderItem({
entitiesMap,
source,
destination,
-}) => {
+}) {
const current = [...entitiesMap[source.droppableId].children];
const next = [...entitiesMap[destination.droppableId].children];
const target = current[source.index];
@@ -51,4 +51,4 @@ export const reorderRows = ({
};
return result;
-};
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/headerStyleOptions.js b/superset/assets/javascripts/dashboard/v2/util/headerStyleOptions.js
new file mode 100644
index 0000000000000..309d482ab6aed
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/headerStyleOptions.js
@@ -0,0 +1,8 @@
+import { t } from '../../../locales';
+import { SMALL_HEADER, MEDIUM_HEADER, LARGE_HEADER } from './constants';
+
+export default [
+ { value: SMALL_HEADER, label: t('Small'), className: 'header-small' },
+ { value: MEDIUM_HEADER, label: t('Medium'), className: 'header-medium' },
+ { value: LARGE_HEADER, label: t('Large'), className: 'header-large' },
+];
diff --git a/superset/assets/javascripts/dashboard/v2/util/isValidChild.js b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
new file mode 100644
index 0000000000000..c8921ec9e91e6
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
@@ -0,0 +1,69 @@
+import {
+ CHART_TYPE,
+ COLUMN_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ GRID_ROOT_TYPE,
+ MARKDOWN_TYPE,
+ ROW_TYPE,
+ SPACER_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+} from './componentTypes';
+
+const typeToValidChildType = {
+ // while some components are wrapped in Rows, most types are valid root children
+ [GRID_ROOT_TYPE]: {
+ [CHART_TYPE]: true,
+ [COLUMN_TYPE]: true,
+ [DIVIDER_TYPE]: true,
+ [HEADER_TYPE]: true,
+ [ROW_TYPE]: true,
+ [SPACER_TYPE]: true,
+ [TABS_TYPE]: true,
+ },
+
+ [ROW_TYPE]: {
+ [CHART_TYPE]: true,
+ [MARKDOWN_TYPE]: true,
+ [COLUMN_TYPE]: true,
+ [SPACER_TYPE]: true,
+ },
+
+ [TABS_TYPE]: {
+ [TAB_TYPE]: true,
+ },
+
+ [TAB_TYPE]: {
+ [CHART_TYPE]: true,
+ [COLUMN_TYPE]: true,
+ [DIVIDER_TYPE]: true,
+ [HEADER_TYPE]: true,
+ [ROW_TYPE]: true,
+ [SPACER_TYPE]: true,
+ },
+
+ [COLUMN_TYPE]: {
+ [CHART_TYPE]: true,
+ [MARKDOWN_TYPE]: true,
+ [HEADER_TYPE]: true,
+ [SPACER_TYPE]: true,
+ },
+
+ // these have no valid children
+ [CHART_TYPE]: {},
+ [MARKDOWN_TYPE]: {},
+ [DIVIDER_TYPE]: {},
+ [HEADER_TYPE]: {},
+ [SPACER_TYPE]: {},
+};
+
+export default function isValidChild({ parentType, childType }) {
+ if (!parentType || !childType) return false;
+
+ const isValid = Boolean(
+ typeToValidChildType[parentType][childType],
+ );
+
+ return isValid;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js b/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
new file mode 100644
index 0000000000000..c1ed03e0aa548
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
@@ -0,0 +1,45 @@
+import {
+ CHART_TYPE,
+ COLUMN_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ MARKDOWN_TYPE,
+ ROW_TYPE,
+ SPACER_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+} from './componentTypes';
+
+import {
+ MEDIUM_HEADER,
+ ROW_TRANSPARENT,
+} from './constants';
+
+const typeToDefaultMetaData = {
+ [CHART_TYPE]: { width: 3, height: 15 },
+ [COLUMN_TYPE]: { width: 3 },
+ [DIVIDER_TYPE]: null,
+ [HEADER_TYPE]: { text: 'New header', headerSize: MEDIUM_HEADER, rowStyle: ROW_TRANSPARENT },
+ [MARKDOWN_TYPE]: { width: 3, height: 15 },
+ [ROW_TYPE]: { rowStyle: ROW_TRANSPARENT },
+ [SPACER_TYPE]: {},
+ [TABS_TYPE]: null,
+ [TAB_TYPE]: { text: 'New Tab' },
+};
+
+// @TODO this should be replaced by a more robust algorithm
+function uuid(type) {
+ return `${type}-${Math.random().toString(16)}`;
+}
+
+export default function entityFactory(type) {
+ return {
+ version: 'v0',
+ type,
+ id: uuid(type),
+ children: [],
+ meta: {
+ ...typeToDefaultMetaData[type],
+ },
+ };
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js b/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js
new file mode 100644
index 0000000000000..38d1c7ca3702f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js
@@ -0,0 +1,35 @@
+import {
+ CHART_TYPE,
+ COLUMN_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ MARKDOWN_TYPE,
+ ROW_TYPE,
+ SPACER_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+} from './componentTypes';
+
+import {
+ NEW_CHART_ID,
+ NEW_COLUMN_ID,
+ NEW_DIVIDER_ID,
+ NEW_HEADER_ID,
+ NEW_MARKDOWN_ID,
+ NEW_ROW_ID,
+ NEW_SPACER_ID,
+ NEW_TABS_ID,
+ NEW_TAB_ID,
+} from './constants';
+
+export default {
+ [NEW_CHART_ID]: CHART_TYPE, // @TODO we will have to encode real chart ids => type in the future
+ [NEW_COLUMN_ID]: COLUMN_TYPE,
+ [NEW_DIVIDER_ID]: DIVIDER_TYPE,
+ [NEW_HEADER_ID]: HEADER_TYPE,
+ [NEW_MARKDOWN_ID]: MARKDOWN_TYPE,
+ [NEW_ROW_ID]: ROW_TYPE,
+ [NEW_SPACER_ID]: SPACER_TYPE,
+ [NEW_TABS_ID]: TABS_TYPE,
+ [NEW_TAB_ID]: TAB_TYPE,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js b/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
new file mode 100644
index 0000000000000..a0d92fa7449c7
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
@@ -0,0 +1,55 @@
+import newComponentIdToType from './newComponentIdToType';
+import shouldWrapChildInRow from './shouldWrapChildInRow';
+import newComponentFactory from './newComponentFactory';
+
+import {
+ ROW_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+} from './componentTypes';
+
+export default function newEntitiesFromDrop({ dropResult, components }) {
+ const { draggableId, destination } = dropResult;
+
+ const dragType = newComponentIdToType[draggableId];
+ const dropEntity = components[destination.droppableId];
+
+ if (!dropEntity) {
+ console.warn('Drop target entity', destination.droppableId, 'not found');
+ return null;
+ }
+
+ if (!dragType) {
+ console.warn('Drag type not found for id', draggableId);
+ return null;
+ }
+
+ const dropType = dropEntity.type;
+ let newDropChild = newComponentFactory(dragType);
+ const wrapChildInRow = shouldWrapChildInRow({ parentType: dropType, childType: dragType });
+
+ const newEntities = {
+ [newDropChild.id]: newDropChild,
+ };
+
+ if (wrapChildInRow) {
+ const rowWrapper = newComponentFactory(ROW_TYPE);
+ rowWrapper.children = [newDropChild.id];
+ newEntities[rowWrapper.id] = rowWrapper;
+ newDropChild = rowWrapper;
+ } else if (dragType === TABS_TYPE) { // create a new tab component
+ const tabChild = newComponentFactory(TAB_TYPE);
+ newDropChild.children = [tabChild.id];
+ newEntities[tabChild.id] = tabChild;
+ }
+
+ const nextDropChildren = [...dropEntity.children];
+ nextDropChildren.splice(destination.index, 0, newDropChild.id);
+
+ newEntities[destination.droppableId] = {
+ ...dropEntity,
+ children: nextDropChildren,
+ };
+
+ return newEntities;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
new file mode 100644
index 0000000000000..be84965957163
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
@@ -0,0 +1,24 @@
+import PropTypes from 'prop-types';
+import componentTypes from './componentTypes';
+import rowStyleOptions from './rowStyleOptions';
+import headerStyleOptions from './headerStyleOptions';
+
+export const componentShape = PropTypes.shape({ // eslint-disable-line
+ id: PropTypes.string.isRequired,
+ type: PropTypes.oneOf(
+ Object.values(componentTypes),
+ ).isRequired,
+ children: PropTypes.arrayOf(PropTypes.string),
+ meta: PropTypes.shape({
+ // Dimensions
+ width: PropTypes.number,
+ height: PropTypes.number,
+
+ // Header
+ text: PropTypes.string,
+ headerSize: PropTypes.oneOf(headerStyleOptions.map(opt => opt.value)),
+
+ // Row
+ rowStyle: PropTypes.oneOf(rowStyleOptions.map(opt => opt.value)),
+ }),
+});
diff --git a/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js b/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js
new file mode 100644
index 0000000000000..40e9af68bbb0f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js
@@ -0,0 +1,30 @@
+// config for a ResizableContainer
+
+const adjustableWidthAndHeight = {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ topRight: false,
+ bottomRight: true,
+ bottomLeft: false,
+ topLeft: false,
+};
+
+const adjustableWidth = {
+ ...adjustableWidthAndHeight,
+ right: true,
+ bottomRight: false,
+};
+
+const adjustableHeight = {
+ ...adjustableWidthAndHeight,
+ bottom: true,
+ bottomRight: false,
+};
+
+export default {
+ widthAndHeight: adjustableWidthAndHeight,
+ widthOnly: adjustableWidth,
+ heightOnly: adjustableHeight,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js b/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js
new file mode 100644
index 0000000000000..ad42492296cfb
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js
@@ -0,0 +1,7 @@
+import { t } from '../../../locales';
+import { ROW_TRANSPARENT, ROW_WHITE } from './constants';
+
+export default [
+ { value: ROW_TRANSPARENT, label: t('Transparent'), className: 'grid-row--transparent' },
+ { value: ROW_WHITE, label: t('White'), className: 'grid-row--white' },
+];
diff --git a/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js b/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
new file mode 100644
index 0000000000000..487e247808e19
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
@@ -0,0 +1,30 @@
+import {
+ GRID_ROOT_TYPE,
+ CHART_TYPE,
+ COLUMN_TYPE,
+ MARKDOWN_TYPE,
+ TAB_TYPE,
+} from './componentTypes';
+
+const typeToWrapChildLookup = {
+ [GRID_ROOT_TYPE]: {
+ [CHART_TYPE]: true,
+ [COLUMN_TYPE]: true,
+ [MARKDOWN_TYPE]: true,
+ },
+
+ [TAB_TYPE]: {
+ [CHART_TYPE]: true,
+ [COLUMN_TYPE]: true,
+ [MARKDOWN_TYPE]: true,
+ },
+};
+
+export default function shouldWrapChildInRow({ parentType, childType }) {
+ if (!parentType || !childType) return false;
+
+ const wrapChildLookup = typeToWrapChildLookup[parentType];
+ if (!wrapChildLookup) return false;
+
+ return Boolean(wrapChildLookup[childType]);
+}
diff --git a/superset/assets/package.json b/superset/assets/package.json
index d6af355764934..797a6f745f9b5 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -42,6 +42,7 @@
"dependencies": {
"@data-ui/event-flow": "^0.0.8",
"@data-ui/sparkline": "^0.0.49",
+ "@vx/responsive": "0.0.153",
"babel-register": "^6.24.1",
"bootstrap": "^3.3.6",
"brace": "^0.10.0",
@@ -71,22 +72,23 @@
"nvd3": "1.8.6",
"po2json": "^0.4.5",
"prop-types": "^15.6.0",
+ "re-resizable": "^4.3.1",
"react": "^15.6.2",
"react-ace": "^5.0.1",
"react-addons-css-transition-group": "^15.6.0",
"react-addons-shallow-compare": "^15.4.2",
"react-alert": "^2.3.0",
- "react-beautiful-dnd": "^4.0.0",
- "react-bootstrap": "^0.31.5",
+ "react-bootstrap": "^0.32.0",
"react-bootstrap-table": "^4.0.2",
"react-color": "^2.13.8",
"react-datetime": "2.9.0",
+ "react-dnd": "^2.5.4",
+ "react-dnd-html5-backend": "^2.5.4",
"react-dom": "^15.6.2",
"react-gravatar": "^2.6.1",
"react-grid-layout": "^0.16.0",
"react-map-gl": "^3.0.4",
"react-redux": "^5.0.2",
- "react-resizable": "^1.3.3",
"react-select": "1.0.0-rc.10",
"react-select-fast-filter-options": "^0.2.1",
"react-sortable-hoc": "^0.6.7",
@@ -98,6 +100,7 @@
"redux": "^3.5.2",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0",
+ "reselect": "^3.0.1",
"shortid": "^2.2.6",
"sprintf-js": "^1.1.1",
"srcdoc-polyfill": "^1.0.0",
diff --git a/superset/assets/stylesheets/dashboard-v2.css b/superset/assets/stylesheets/dashboard-v2.css
index 0ad56d5d166cb..534a17eafa7f2 100644
--- a/superset/assets/stylesheets/dashboard-v2.css
+++ b/superset/assets/stylesheets/dashboard-v2.css
@@ -1,6 +1,7 @@
.dashboard-v2 {
margin-top: -20px;
position: relative;
+ color: #263238;
}
.dashboard-header {
@@ -9,39 +10,33 @@
flex-direction: row;
align-items: center;
justify-content: space-between;
- padding: 0 16px;
- box-shadow: 0 0px 6px #aaa; /* @TODO color */
+ padding: 0 24px;
+ box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
margin-bottom: 2px;
}
-.dashboard-builder-sidepane {
- background: white;
- box-shadow: 0 2px 6px #aaa; /* @TODO color */
-}
-
-.new-draggable-component {
+.dashboard-builder {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
- align-items: center;
- padding: 16;
- background: white;
+ height: auto;
}
- .new-draggable-component--dragging {
- box-shadow: 0 0 1px #aaa; /* @TODO color */
- }
+.dashboard-builder-sidepane {
+ background: white;
+ flex: 0 0 376px;
+ box-shadow: 0 0 0 1px #ccc; /* @TODO color */
+}
-.new-draggable-placeholder {
- background: #f5f5f5;
- width: 40px;
- height: 40px;
- margin-right: 16px;
- box-shadow: 0 0 1px #fff;
+.dashboard-builder-sidepane-header {
+ font-size: 16;
+ font-weight: 700;
+ border-bottom: 1px solid #ccc;
+ padding: 16px;
}
/* @TODO remove upon new theme */
.btn.btn-primary {
- background: #484848 !important;
+ background: #263238 !important;
color: white !important;
}
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index a89c0e09ac10b..5cf448852ace1 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -203,6 +203,17 @@ div.widget {
}
}
}
+/* brand icon */
+.navbar-brand > img.logo {
+ margin-left: 15px;
+ width: 36px;
+ display: inline;
+}
+.navbar-brand > span {
+ margin-left: 2px;
+ font-size: 15px;
+ font-weight: bold;
+}
.navbar .alert {
padding: 5px 10px;
@@ -223,23 +234,24 @@ table.table-no-hover tr:hover {
}
.editable-title input {
- padding: 2px 0px 3px 0px;
+ outline: none;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ padding-left: 0;
}
.editable-title input[type="button"] {
- border-color: transparent;
- background: inherit;
+ font-size: inherit;
+ line-height: inherit;
white-space: normal;
text-align: left;
}
-.editable-title input[type="button"]:hover {
+.editable-title--editable input[type="button"]:hover {
cursor: text;
}
-.editable-title input[type="button"]:focus {
- outline: none;
-}
.m-r-5 {
margin-right: 5px;
}
diff --git a/superset/config.py b/superset/config.py
index c2da1db8b55c7..a3fb4d3c622dd 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -95,7 +95,7 @@
APP_NAME = 'Superset'
# Uncomment to setup an App icon
-APP_ICON = '/static/assets/images/superset-logo@2x.png'
+APP_ICON = '/static/assets/images/favicon.png'
# Druid query timezone
# tz.tzutc() : Using utc timezone
diff --git a/superset/templates/appbuilder/navbar.html b/superset/templates/appbuilder/navbar.html
index 0ea2daec5fb15..0a946fe2bdcd6 100644
--- a/superset/templates/appbuilder/navbar.html
+++ b/superset/templates/appbuilder/navbar.html
@@ -12,9 +12,11 @@
+ Superset