From 8874f1b08cfb3514904cc98941d4c6a3322a706a Mon Sep 17 00:00:00 2001
From: Tom Robinson <tlrobinson@gmail.com>
Date: Fri, 5 Feb 2016 16:10:53 -0800
Subject: [PATCH] =?UTF-8?q?Remove=20react-grid-layout,=20replace=20with=20?=
 =?UTF-8?q?simplified=20version,=20update=20to=20React=200.14=20?=
 =?UTF-8?q?=F0=9F=98=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/components/dashboard/dashboard.css    |  26 +--
 .../dashboard/components/DashboardGrid.jsx    |  85 ++++---
 .../dashboard/components/grid/GridItem.jsx    |  88 ++++++++
 .../dashboard/components/grid/GridLayout.jsx  | 211 ++++++++++++++++++
 package.json                                  |   6 +-
 5 files changed, 365 insertions(+), 51 deletions(-)
 create mode 100644 frontend/src/dashboard/components/grid/GridItem.jsx
 create mode 100644 frontend/src/dashboard/components/grid/GridLayout.jsx

diff --git a/frontend/src/components/dashboard/dashboard.css b/frontend/src/components/dashboard/dashboard.css
index beff9a09d38..5fbe5813204 100644
--- a/frontend/src/components/dashboard/dashboard.css
+++ b/frontend/src/components/dashboard/dashboard.css
@@ -19,9 +19,9 @@
 }
 
 .DashboardGrid {
-    /* offset RGL's 10px padding */
-    margin-left: -10px;
-    margin-right: -10px;
+    margin-top: 5px;
+    margin-left: -5px;
+    margin-right: -5px;
 }
 
 .DashCard {
@@ -35,6 +35,7 @@
   left: 0;
   bottom: 0;
   right: 0;
+  overflow: hidden;
 }
 
 .DashCard .Card-outer {
@@ -86,38 +87,35 @@
 
 .Dash--editing .DashCard .Card {
     background: #fff;
-}
-
-.Dash--editing .DashCard.react-draggable {
     user-select: none;
 }
 
-.Dash--editing .DashCard.react-draggable-dragging .Card {
+.Dash--editing .DashCard.dragging .Card {
     box-shadow: 3px 3px 8px rgba(0,0,0,0.1);
 }
 
-.Dash--editing .DashCard.react-draggable-dragging,
+.Dash--editing .DashCard.dragging,
 .Dash--editing .DashCard.resizing {
     z-index: 2;
 }
 
-.Dash--editing .DashCard.react-draggable-dragging .Card,
+.Dash--editing .DashCard.dragging .Card,
 .Dash--editing .DashCard.resizing .Card {
     background-color: #E5F1FB !important;
     border: 1px solid var(--brand-color);
 }
 
-.Dash--editing .DashCard.react-draggable-dragging .DashCard-actions,
+.Dash--editing .DashCard.dragging .DashCard-actions,
 .Dash--editing .DashCard.resizing .DashCard-actions {
-  opacity: 0;
-  transition: opacity .3s linear;
+    opacity: 0;
+    transition: opacity .3s linear;
 }
 
 .Dash--editing .DashCard {
     transition: transform .3s;
 }
 
-.Dash--editing .DashCard.react-draggable-dragging,
+.Dash--editing .DashCard.dragging,
 .Dash--editing .DashCard.resizing {
     transition: transform 0s;
 }
@@ -136,7 +134,9 @@
 }
 
 .Dash--editing .react-grid-placeholder {
+    z-index: 0;
     background-color: #F2F2F2;
+    transition: all 0.15s linear;
 }
 
 .Dash--editing .Card-title {
diff --git a/frontend/src/dashboard/components/DashboardGrid.jsx b/frontend/src/dashboard/components/DashboardGrid.jsx
index 916600c7010..bb92becfb88 100644
--- a/frontend/src/dashboard/components/DashboardGrid.jsx
+++ b/frontend/src/dashboard/components/DashboardGrid.jsx
@@ -1,6 +1,6 @@
 import React, { Component, PropTypes } from "react";
 
-import { Responsive as ResponsiveReactGridLayout } from "react-grid-layout";
+import GridLayout from "./grid/GridLayout.jsx";
 
 import MetabaseAnalytics from "metabase/lib/analytics";
 import DashCard from "./DashCard.jsx";
@@ -16,10 +16,10 @@ export default class DashboardGrid extends Component {
         super(props, context);
 
         this.state = {
-            layouts: this.getLayouts(props),
+            layout: this.getLayout(props),
             removeModalDashCard: null,
             addSeriesModalDashCard: null,
-            rowHeight: 0,
+            width: 0,
             isDragging: false
         };
 
@@ -40,18 +40,12 @@ export default class DashboardGrid extends Component {
 
     componentWillReceiveProps(nextProps) {
         this.setState({
-            layouts: this.getLayouts(nextProps)
+            layout: this.getLayout(nextProps)
         });
     }
 
-    onLayoutChange(layout, allLayouts) {
-        // only set the layout for large breakpoint
-        if (layout !== allLayouts["lg"]) {
-            return;
-        }
-        var changes = layout.filter(newLayout => {
-            return !_.isEqual(newLayout, this.getLayoutForDashCard(newLayout.dashcard))
-        });
+    onLayoutChange(layout) {
+        var changes = layout.filter(newLayout => !_.isEqual(newLayout, this.getLayoutForDashCard(newLayout.dashcard)));
         for (var change of changes) {
             this.props.setDashCardAttributes({
                 id: change.dashcard.id,
@@ -79,11 +73,8 @@ export default class DashboardGrid extends Component {
         });
     }
 
-    getLayouts(props) {
-        var mainLayout = props.dashboard.ordered_cards.map(this.getLayoutForDashCard);
-        // for mobile just layout cards vertically
-        var mobileLayout = mainLayout.map((l, i) => ({ ...l, x: 0, y: i * 4, w: 6, h: 4}));
-        return { lg: mainLayout, sm: mobileLayout };
+    getLayout(props) {
+        return props.dashboard.ordered_cards.map(this.getLayoutForDashCard);
     }
 
     renderRemoveModal() {
@@ -124,9 +115,8 @@ export default class DashboardGrid extends Component {
     // make grid square by getting the container width, then dividing by 6
     calculateSizing() {
         let width = React.findDOMNode(this).offsetWidth;
-        let rowHeight = Math.floor(width / 6);
-        if (this.state.rowHeight !== rowHeight) {
-            this.setState({ rowHeight });
+        if (this.state.width !== width) {
+            this.setState({ width });
         }
     }
 
@@ -178,25 +168,48 @@ export default class DashboardGrid extends Component {
         this.setState({ addSeriesModalDashCard: dc });
     }
 
+    renderMobile() {
+        const { dashboard, isEditing } = this.props;
+        const { width } = this.state;
+        return (
+            <div
+                className={cx("DashboardGrid", { "Dash--editing": isEditing, "Dash--dragging": this.state.isDragging })}
+                style={{ margin: 0 }}
+            >
+                {dashboard.ordered_cards.map(dc =>
+                    <div key={dc.id} className="DashCard" style={{ left: 10, width: width - 20, marginTop: 10, marginBottom: 10, height: width / (6 / 4)}}>
+                        <DashCard
+                            dashcard={dc}
+                            cardData={this.props.cardData}
+                            fetchCardData={this.props.fetchCardData}
+                            markNewCardSeen={this.props.markNewCardSeen}
+                            onEdit={this.onDashCardEdit.bind(this, dc)}
+                            onRemove={this.onDashCardRemove.bind(this, dc)}
+                            onAddSeries={isEditing && this.onDashCardAddSeries.bind(this, dc)}
+                        />
+                    </div>
+                )}
+            </div>
+        )
+    }
+
     render() {
-        var { dashboard, isEditing } = this.props;
+        const { dashboard, isEditing } = this.props;
+        const { width } = this.state;
+
+        // Responsiveâ„¢
+        if (width <= 752) {
+            return this.renderMobile();
+        }
+
+        let rowHeight = Math.floor(width / 6);
         return (
             <div>
-                <ResponsiveReactGridLayout
+                <GridLayout
                     className={cx("DashboardGrid", { "Dash--editing": isEditing, "Dash--dragging": this.state.isDragging })}
-                    breakpoints={{lg: 753, sm: 752}}
-                    layouts={this.state.layouts}
-                    // NOTE: these need to be different otherwise RGL doesn't switch breakpoints
-                    // https://github.com/STRML/react-grid-layout/issues/92
-                    cols={{lg: 6, sm: 1}}
-                    // NOTE: ideally this would vary based on the breakpoint but RGL doesn't support that yet
-                    // instead we keep the same row height and adjust the layout height to get the right aspect ratio
-                    rowHeight={this.state.rowHeight}
-                    verticalCompact={false}
-                    // NOTE: currently leaving these true always instead of using this.props.isEditing due to perf issues caused by
-                    // https://github.com/STRML/react-grid-layout/issues/89
-                    isDraggable={true}
-                    isResizable={true}
+                    layout={this.state.layout}
+                    cols={6}
+                    rowHeight={rowHeight}
                     onLayoutChange={(...args) => this.onLayoutChange(...args)}
                     onDrag={(...args) => this.onDrag(...args)}
                     onDragStop={(...args) => this.onDragStop(...args)}
@@ -214,7 +227,7 @@ export default class DashboardGrid extends Component {
                             />
                         </div>
                     )}
-                </ResponsiveReactGridLayout>
+                </GridLayout>
                 {this.renderRemoveModal()}
                 {this.renderAddSeriesModal()}
             </div>
diff --git a/frontend/src/dashboard/components/grid/GridItem.jsx b/frontend/src/dashboard/components/grid/GridItem.jsx
new file mode 100644
index 00000000000..13b572a95b5
--- /dev/null
+++ b/frontend/src/dashboard/components/grid/GridItem.jsx
@@ -0,0 +1,88 @@
+import React, { Component, PropTypes } from "react";
+
+import { DraggableCore } from "react-draggable";
+import { Resizable } from "react-resizable";
+
+import cx from "classnames";
+
+export default class GridItem extends Component {
+    constructor(props, context) {
+        super(props, context);
+
+        this.state = {
+            dragging: null,
+            resizing: null
+        };
+    }
+
+    onDragHandler(handlerName) {
+      return (e, {element, position}) => {
+
+        let { dragStartPosition } = this.state;
+        if (handlerName === "onDragStart") {
+            dragStartPosition = position;
+            this.setState({ dragStartPosition: position });
+        }
+        let pos = {
+            x: position.clientX - dragStartPosition.clientX,
+            y: position.clientY - dragStartPosition.clientY,
+        }
+        this.setState({ dragging: handlerName === "onDragStop" ? null : pos });
+
+        this.props[handlerName](this.props.i, {e, element, position: pos });
+      };
+    }
+
+    onResizeHandler(handlerName) {
+      return (e, {element, size}) => {
+
+        if (handlerName !== "onResizeStart") {
+            this.setState({ resizing: handlerName === "onResizeStop" ? null : size });
+        }
+
+        this.props[handlerName](this.props.i, {e, element, size});
+      };
+    }
+
+    render() {
+        let { width, height, top, left } = this.props;
+
+        if (this.state.dragging) {
+            left += this.state.dragging.x;
+            top += this.state.dragging.y;
+        }
+
+        if (this.state.resizing) {
+            width = this.state.resizing.width;
+            height = this.state.resizing.height;
+        }
+
+        let style = {
+            width, height, top, left,
+            position: "absolute"
+        };
+
+        let child = React.Children.only(this.props.children);
+        return (
+            <DraggableCore
+                cancel=".react-resizable-handle"
+                onStart={this.onDragHandler("onDragStart")}
+                onDrag={this.onDragHandler("onDrag")}
+                onStop={this.onDragHandler("onDragStop")}
+            >
+                <Resizable
+                    width={this.props.width}
+                    height={this.props.height}
+                    onResizeStart={this.onResizeHandler("onResizeStart")}
+                    onResize={this.onResizeHandler("onResize")}
+                    onResizeStop={this.onResizeHandler("onResizeStop")}
+                >
+                    {React.cloneElement(child, {
+                        style: style,
+                        className: cx(child.props.className, { dragging: !!this.state.dragging, resizing: !!this.state.resizing })
+                    })}
+                </Resizable>
+            </DraggableCore>
+        );
+    }
+}
diff --git a/frontend/src/dashboard/components/grid/GridLayout.jsx b/frontend/src/dashboard/components/grid/GridLayout.jsx
new file mode 100644
index 00000000000..64f22cfffde
--- /dev/null
+++ b/frontend/src/dashboard/components/grid/GridLayout.jsx
@@ -0,0 +1,211 @@
+import React, { Component, PropTypes } from "react";
+
+import GridItem from "./GridItem.jsx";
+
+import _ from "underscore";
+
+export default class GridLayout extends Component {
+    constructor(props, context) {
+        super(props, context);
+
+        this.state = {
+            width: 0,
+            layout: props.layout,
+            dragging: false,
+            resizing: false,
+            placeholderLayout: null
+        };
+
+        _.bindAll(this,
+            "onDrag", "onDragStart", "onDragStop",
+            "onResize", "onResizeStart", "onResizeStop"
+        );
+    }
+
+    componentWillReceiveProps(newProps) {
+        const { dragging, resizing } = this.state;
+        if (!dragging && !resizing && this.state.layout !== newProps.layout) {
+            this.setState({ layout: newProps.layout });
+        }
+    }
+
+    componentDidMount() {
+        this.componentDidUpdate();
+    }
+
+    componentDidUpdate() {
+        let width = React.findDOMNode(this).parentNode.offsetWidth;
+        if (this.state.width !== width) {
+            this.setState({ width });
+        }
+    }
+
+    onDragStart(i, { position }) {
+        this.setState({ dragging: true })
+    }
+
+    layoutsOverlap(a, b) {
+        return (
+            a.x < (b.x + b.w) &&
+            b.x < (a.x + a.w) &&
+            a.y < (b.y + b.h) &&
+            b.y < (a.y + a.h)
+        );
+    }
+
+    onDrag(i, { position }) {
+        let placeholderLayout = {
+            ...this.computeDraggedLayout(i, position),
+            i: "placeholder"
+        }
+        this.setState({ placeholderLayout: placeholderLayout });
+    }
+
+    onDragStop(i, { position }) {
+        let { x, y, w, h } = this.state.placeholderLayout;
+        let newLayout = this.state.layout.map(l => l.i === i ?
+            { ...l, x, y, w, h } :
+            l
+        );
+        this.setState({ dragging: false, placeholderLayout: null });
+        this.props.onLayoutChange(newLayout);
+    }
+
+    computeDraggedLayout(i, position) {
+        const cellSize = this.getCellSize();
+        let originalLayout = this.getLayoutForItem(i);
+        let pos = this.getStyleForLayout(originalLayout);
+        pos.top += position.y;
+        pos.left += position.x;
+
+        let maxX = this.props.cols - originalLayout.w;
+        let maxY = Infinity;
+
+        let targetLayout = {
+            w: originalLayout.w,
+            h: originalLayout.h,
+            x: Math.min(maxX, Math.max(0, Math.round(pos.left / cellSize.width))),
+            y: Math.min(maxY, Math.max(0, Math.round(pos.top / cellSize.height)))
+        };
+        let proposedLayout = targetLayout;
+        for (let otherLayout of this.state.layout) {
+            if (originalLayout !== otherLayout && this.layoutsOverlap(proposedLayout, otherLayout)) {
+                return this.state.placeholderLayout || originalLayout;
+            }
+        }
+        return proposedLayout;
+    }
+
+    onResizeStart(i, { size }) {
+        this.setState({ resizing: true });
+    }
+
+    onResize(i, { size }) {
+        let placeholderLayout = {
+            ...this.computeResizedLayout(i, size),
+            i: "placeholder"
+        };
+        this.setState({ placeholderLayout: placeholderLayout });
+    }
+
+    onResizeStop(i, { size }) {
+        let { x, y, w, h } = this.state.placeholderLayout;
+        let newLayout = this.state.layout.map(l => l.i === i ?
+            { ...l, x, y, w, h } :
+            l
+        );
+        this.setState({ resizing: false, placeholderLayout: null });
+        this.props.onLayoutChange(newLayout);
+    }
+
+    computeResizedLayout(i, size) {
+        let cellSize = this.getCellSize();
+        let originalLayout = this.getLayoutForItem(i);
+
+        let maxW = this.props.cols - originalLayout.x;
+        let maxH = Infinity;
+        let targetLayout = {
+            w: Math.min(maxW, Math.max(1, Math.round(size.width / cellSize.width))),
+            h: Math.min(maxH, Math.max(1, Math.round(size.height / cellSize.height))),
+            x: originalLayout.x,
+            y: originalLayout.y
+        };
+
+        let proposedLayout = targetLayout;
+        for (let otherLayout of this.state.layout) {
+            if (originalLayout !== otherLayout && this.layoutsOverlap(proposedLayout, otherLayout)) {
+                return this.state.placeholderLayout || originalLayout;
+            }
+        }
+        return proposedLayout;
+    }
+
+    getLayoutForItem(i) {
+        return _.findWhere(this.state.layout, { i: i });
+    }
+
+    getCellSize() {
+        return {
+            width: this.state.width / this.props.cols,
+            height: this.props.rowHeight
+        };
+    }
+
+    getStyleForLayout(l) {
+        let cellSize = this.getCellSize();
+        let margin = l.i === "placeholder" ? -10 : 10;
+        return {
+            width: cellSize.width * l.w - margin,
+            height: cellSize.height * l.h - margin,
+            left: cellSize.width * l.x + margin / 2,
+            top: cellSize.height * l.y + margin / 2
+        };
+    }
+
+    renderChild(child) {
+        let l = this.getLayoutForItem(child.key);
+        let style = this.getStyleForLayout(l);
+        return (
+            <GridItem
+                key={l.i}
+                onDragStart={this.onDragStart}
+                onDrag={this.onDrag}
+                onDragStop={this.onDragStop}
+                onResizeStart={this.onResizeStart}
+                onResize={this.onResize}
+                onResizeStop={this.onResizeStop}
+                {...l}
+                {...style}
+            >
+                {child}
+            </GridItem>
+        )
+    }
+
+    renderPlaceholder() {
+        if (this.state.placeholderLayout) {
+            let style = {
+                ...this.getStyleForLayout(this.state.placeholderLayout)
+            }
+            return (
+                <div className="react-grid-placeholder absolute" style={style}></div>
+            );
+        }
+    }
+
+    render() {
+        const { className, layout, rowHeight } = this.props;
+
+        let bottom = Math.max(...layout.map(l => l.y + l.h));
+        let totalHeight = (bottom + 3) * rowHeight;
+
+        return (
+            <div className={className} style={{ position: "relative", height: totalHeight }}>
+                {this.props.children.map(child =>
+                    this.renderChild(child)
+                )}
+                {this.renderPlaceholder()}
+            </div>
+        );
+    }
+}
diff --git a/package.json b/package.json
index a79b591a6f5..f924ef866a4 100644
--- a/package.json
+++ b/package.json
@@ -32,12 +32,14 @@
     "moment": "^2.10.6",
     "normalizr": "^1.4.0",
     "password-generator": "^2.0.1",
-    "react": "^0.13.3",
+    "react": "^0.14.7",
     "react-chartjs": "^0.6.0",
     "react-components": "^0.3.0",
-    "react-grid-layout": "^0.8.5",
+    "react-dom": "^0.14.7",
+    "react-draggable": "^1.1.3",
     "react-onclickout": "^1.1.0",
     "react-redux": "^3.1.0",
+    "react-resizable": "^1.0.1",
     "react-retina-image": "^2.0.0",
     "react-router": "1.0.0",
     "redux": "^3.0.4",
-- 
GitLab