Selaa lähdekoodia

Merged dashboards

Andrew Kane 9 vuotta sitten
vanhempi
commit
c36e1da4ff

+ 1144 - 0
app/assets/javascripts/blazer/Sortable.js

@@ -0,0 +1,1144 @@
+/**!
+ * Sortable
+ * @author  RubaXa   <trash@rubaxa.org>
+ * @license MIT
+ */
+
+
+(function (factory) {
+  "use strict";
+
+  if (typeof define === "function" && define.amd) {
+    define(factory);
+  }
+  else if (typeof module != "undefined" && typeof module.exports != "undefined") {
+    module.exports = factory();
+  }
+  else if (typeof Package !== "undefined") {
+    Sortable = factory();  // export for Meteor.js
+  }
+  else {
+    /* jshint sub:true */
+    window["Sortable"] = factory();
+  }
+})(function () {
+  "use strict";
+
+  var dragEl,
+    ghostEl,
+    cloneEl,
+    rootEl,
+    nextEl,
+
+    scrollEl,
+    scrollParentEl,
+
+    lastEl,
+    lastCSS,
+
+    oldIndex,
+    newIndex,
+
+    activeGroup,
+    autoScroll = {},
+
+    tapEvt,
+    touchEvt,
+
+    /** @const */
+    RSPACE = /\s+/g,
+
+    expando = 'Sortable' + (new Date).getTime(),
+
+    win = window,
+    document = win.document,
+    parseInt = win.parseInt,
+
+    supportDraggable = !!('draggable' in document.createElement('div')),
+
+    _silent = false,
+
+    abs = Math.abs,
+    slice = [].slice,
+
+    touchDragOverListeners = [],
+
+    _autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl) {
+      // Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521
+      if (rootEl && options.scroll) {
+        var el,
+          rect,
+          sens = options.scrollSensitivity,
+          speed = options.scrollSpeed,
+
+          x = evt.clientX,
+          y = evt.clientY,
+
+          winWidth = window.innerWidth,
+          winHeight = window.innerHeight,
+
+          vx,
+          vy
+        ;
+
+        // Delect scrollEl
+        if (scrollParentEl !== rootEl) {
+          scrollEl = options.scroll;
+          scrollParentEl = rootEl;
+
+          if (scrollEl === true) {
+            scrollEl = rootEl;
+
+            do {
+              if ((scrollEl.offsetWidth < scrollEl.scrollWidth) ||
+                (scrollEl.offsetHeight < scrollEl.scrollHeight)
+              ) {
+                break;
+              }
+              /* jshint boss:true */
+            } while (scrollEl = scrollEl.parentNode);
+          }
+        }
+
+        if (scrollEl) {
+          el = scrollEl;
+          rect = scrollEl.getBoundingClientRect();
+          vx = (abs(rect.right - x) <= sens) - (abs(rect.left - x) <= sens);
+          vy = (abs(rect.bottom - y) <= sens) - (abs(rect.top - y) <= sens);
+        }
+
+
+        if (!(vx || vy)) {
+          vx = (winWidth - x <= sens) - (x <= sens);
+          vy = (winHeight - y <= sens) - (y <= sens);
+
+          /* jshint expr:true */
+          (vx || vy) && (el = win);
+        }
+
+
+        if (autoScroll.vx !== vx || autoScroll.vy !== vy || autoScroll.el !== el) {
+          autoScroll.el = el;
+          autoScroll.vx = vx;
+          autoScroll.vy = vy;
+
+          clearInterval(autoScroll.pid);
+
+          if (el) {
+            autoScroll.pid = setInterval(function () {
+              if (el === win) {
+                win.scrollTo(win.pageXOffset + vx * speed, win.pageYOffset + vy * speed);
+              } else {
+                vy && (el.scrollTop += vy * speed);
+                vx && (el.scrollLeft += vx * speed);
+              }
+            }, 24);
+          }
+        }
+      }
+    }, 30)
+  ;
+
+
+
+  /**
+   * @class  Sortable
+   * @param  {HTMLElement}  el
+   * @param  {Object}       [options]
+   */
+  function Sortable(el, options) {
+    this.el = el; // root element
+    this.options = options = _extend({}, options);
+
+
+    // Export instance
+    el[expando] = this;
+
+
+    // Default options
+    var defaults = {
+      group: Math.random(),
+      sort: true,
+      disabled: false,
+      store: null,
+      handle: null,
+      scroll: true,
+      scrollSensitivity: 30,
+      scrollSpeed: 10,
+      draggable: /[uo]l/i.test(el.nodeName) ? 'li' : '>*',
+      ghostClass: 'sortable-ghost',
+      ignore: 'a, img',
+      filter: null,
+      animation: 0,
+      setData: function (dataTransfer, dragEl) {
+        dataTransfer.setData('Text', dragEl.textContent);
+      },
+      dropBubble: false,
+      dragoverBubble: false,
+      dataIdAttr: 'data-id',
+      delay: 0
+    };
+
+
+    // Set default options
+    for (var name in defaults) {
+      !(name in options) && (options[name] = defaults[name]);
+    }
+
+
+    var group = options.group;
+
+    if (!group || typeof group != 'object') {
+      group = options.group = { name: group };
+    }
+
+
+    ['pull', 'put'].forEach(function (key) {
+      if (!(key in group)) {
+        group[key] = true;
+      }
+    });
+
+
+    options.groups = ' ' + group.name + (group.put.join ? ' ' + group.put.join(' ') : '') + ' ';
+
+
+    // Bind all private methods
+    for (var fn in this) {
+      if (fn.charAt(0) === '_') {
+        this[fn] = _bind(this, this[fn]);
+      }
+    }
+
+
+    // Bind events
+    _on(el, 'mousedown', this._onTapStart);
+    _on(el, 'touchstart', this._onTapStart);
+
+    _on(el, 'dragover', this);
+    _on(el, 'dragenter', this);
+
+    touchDragOverListeners.push(this._onDragOver);
+
+    // Restore sorting
+    options.store && this.sort(options.store.get(this));
+  }
+
+
+  Sortable.prototype = /** @lends Sortable.prototype */ {
+    constructor: Sortable,
+
+    _onTapStart: function (/** Event|TouchEvent */evt) {
+      var _this = this,
+        el = this.el,
+        options = this.options,
+        type = evt.type,
+        touch = evt.touches && evt.touches[0],
+        target = (touch || evt).target,
+        originalTarget = target,
+        filter = options.filter;
+
+
+      if (type === 'mousedown' && evt.button !== 0 || options.disabled) {
+        return; // only left button or enabled
+      }
+
+      target = _closest(target, options.draggable, el);
+
+      if (!target) {
+        return;
+      }
+
+      // get the index of the dragged element within its parent
+      oldIndex = _index(target);
+
+      // Check filter
+      if (typeof filter === 'function') {
+        if (filter.call(this, evt, target, this)) {
+          _dispatchEvent(_this, originalTarget, 'filter', target, el, oldIndex);
+          evt.preventDefault();
+          return; // cancel dnd
+        }
+      }
+      else if (filter) {
+        filter = filter.split(',').some(function (criteria) {
+          criteria = _closest(originalTarget, criteria.trim(), el);
+
+          if (criteria) {
+            _dispatchEvent(_this, criteria, 'filter', target, el, oldIndex);
+            return true;
+          }
+        });
+
+        if (filter) {
+          evt.preventDefault();
+          return; // cancel dnd
+        }
+      }
+
+
+      if (options.handle && !_closest(originalTarget, options.handle, el)) {
+        return;
+      }
+
+
+      // Prepare `dragstart`
+      this._prepareDragStart(evt, touch, target);
+    },
+
+    _prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target) {
+      var _this = this,
+        el = _this.el,
+        options = _this.options,
+        ownerDocument = el.ownerDocument,
+        dragStartFn;
+
+      if (target && !dragEl && (target.parentNode === el)) {
+        tapEvt = evt;
+
+        rootEl = el;
+        dragEl = target;
+        nextEl = dragEl.nextSibling;
+        activeGroup = options.group;
+
+        dragStartFn = function () {
+          // Delayed drag has been triggered
+          // we can re-enable the events: touchmove/mousemove
+          _this._disableDelayedDrag();
+
+          // Make the element draggable
+          dragEl.draggable = true;
+
+          // Disable "draggable"
+          options.ignore.split(',').forEach(function (criteria) {
+            _find(dragEl, criteria.trim(), _disableDraggable);
+          });
+
+          // Bind the events: dragstart/dragend
+          _this._triggerDragStart(touch);
+        };
+
+        _on(ownerDocument, 'mouseup', _this._onDrop);
+        _on(ownerDocument, 'touchend', _this._onDrop);
+        _on(ownerDocument, 'touchcancel', _this._onDrop);
+
+        if (options.delay) {
+          // If the user moves the pointer before the delay has been reached:
+          // disable the delayed drag
+          _on(ownerDocument, 'mousemove', _this._disableDelayedDrag);
+          _on(ownerDocument, 'touchmove', _this._disableDelayedDrag);
+
+          _this._dragStartTimer = setTimeout(dragStartFn, options.delay);
+        } else {
+          dragStartFn();
+        }
+      }
+    },
+
+    _disableDelayedDrag: function () {
+      var ownerDocument = this.el.ownerDocument;
+
+      clearTimeout(this._dragStartTimer);
+
+      _off(ownerDocument, 'mousemove', this._disableDelayedDrag);
+      _off(ownerDocument, 'touchmove', this._disableDelayedDrag);
+    },
+
+    _triggerDragStart: function (/** Touch */touch) {
+      if (touch) {
+        // Touch device support
+        tapEvt = {
+          target: dragEl,
+          clientX: touch.clientX,
+          clientY: touch.clientY
+        };
+
+        this._onDragStart(tapEvt, 'touch');
+      }
+      else if (!supportDraggable) {
+        this._onDragStart(tapEvt, true);
+      }
+      else {
+        _on(dragEl, 'dragend', this);
+        _on(rootEl, 'dragstart', this._onDragStart);
+      }
+
+      try {
+        if (document.selection) {
+          document.selection.empty();
+        } else {
+          window.getSelection().removeAllRanges();
+        }
+      } catch (err) {
+      }
+    },
+
+    _dragStarted: function () {
+      if (rootEl && dragEl) {
+        // Apply effect
+        _toggleClass(dragEl, this.options.ghostClass, true);
+
+        Sortable.active = this;
+
+        // Drag start event
+        _dispatchEvent(this, rootEl, 'start', dragEl, rootEl, oldIndex);
+      }
+    },
+
+    _emulateDragOver: function () {
+      if (touchEvt) {
+        _css(ghostEl, 'display', 'none');
+
+        var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY),
+          parent = target,
+          groupName = ' ' + this.options.group.name + '',
+          i = touchDragOverListeners.length;
+
+        if (parent) {
+          do {
+            if (parent[expando] && parent[expando].options.groups.indexOf(groupName) > -1) {
+              while (i--) {
+                touchDragOverListeners[i]({
+                  clientX: touchEvt.clientX,
+                  clientY: touchEvt.clientY,
+                  target: target,
+                  rootEl: parent
+                });
+              }
+
+              break;
+            }
+
+            target = parent; // store last element
+          }
+          /* jshint boss:true */
+          while (parent = parent.parentNode);
+        }
+
+        _css(ghostEl, 'display', '');
+      }
+    },
+
+
+    _onTouchMove: function (/**TouchEvent*/evt) {
+      if (tapEvt) {
+        var touch = evt.touches ? evt.touches[0] : evt,
+          dx = touch.clientX - tapEvt.clientX,
+          dy = touch.clientY - tapEvt.clientY,
+          translate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)';
+
+        touchEvt = touch;
+
+        _css(ghostEl, 'webkitTransform', translate3d);
+        _css(ghostEl, 'mozTransform', translate3d);
+        _css(ghostEl, 'msTransform', translate3d);
+        _css(ghostEl, 'transform', translate3d);
+
+        evt.preventDefault();
+      }
+    },
+
+
+    _onDragStart: function (/**Event*/evt, /**boolean*/useFallback) {
+      var dataTransfer = evt.dataTransfer,
+        options = this.options;
+
+      this._offUpEvents();
+
+      if (activeGroup.pull == 'clone') {
+        cloneEl = dragEl.cloneNode(true);
+        _css(cloneEl, 'display', 'none');
+        rootEl.insertBefore(cloneEl, dragEl);
+      }
+
+      if (useFallback) {
+        var rect = dragEl.getBoundingClientRect(),
+          css = _css(dragEl),
+          ghostRect;
+
+        ghostEl = dragEl.cloneNode(true);
+
+        _css(ghostEl, 'top', rect.top - parseInt(css.marginTop, 10));
+        _css(ghostEl, 'left', rect.left - parseInt(css.marginLeft, 10));
+        _css(ghostEl, 'width', rect.width);
+        _css(ghostEl, 'height', rect.height);
+        _css(ghostEl, 'opacity', '0.8');
+        _css(ghostEl, 'position', 'fixed');
+        _css(ghostEl, 'zIndex', '100000');
+
+        rootEl.appendChild(ghostEl);
+
+        // Fixing dimensions.
+        ghostRect = ghostEl.getBoundingClientRect();
+        _css(ghostEl, 'width', rect.width * 2 - ghostRect.width);
+        _css(ghostEl, 'height', rect.height * 2 - ghostRect.height);
+
+        if (useFallback === 'touch') {
+          // Bind touch events
+          _on(document, 'touchmove', this._onTouchMove);
+          _on(document, 'touchend', this._onDrop);
+          _on(document, 'touchcancel', this._onDrop);
+        } else {
+          // Old brwoser
+          _on(document, 'mousemove', this._onTouchMove);
+          _on(document, 'mouseup', this._onDrop);
+        }
+
+        this._loopId = setInterval(this._emulateDragOver, 150);
+      }
+      else {
+        if (dataTransfer) {
+          dataTransfer.effectAllowed = 'move';
+          options.setData && options.setData.call(this, dataTransfer, dragEl);
+        }
+
+        _on(document, 'drop', this);
+      }
+
+      setTimeout(this._dragStarted, 0);
+    },
+
+    _onDragOver: function (/**Event*/evt) {
+      var el = this.el,
+        target,
+        dragRect,
+        revert,
+        options = this.options,
+        group = options.group,
+        groupPut = group.put,
+        isOwner = (activeGroup === group),
+        canSort = options.sort;
+
+      if (evt.preventDefault !== void 0) {
+        evt.preventDefault();
+        !options.dragoverBubble && evt.stopPropagation();
+      }
+
+      if (activeGroup && !options.disabled &&
+        (isOwner
+          ? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list
+          : activeGroup.pull && groupPut && (
+            (activeGroup.name === group.name) || // by Name
+            (groupPut.indexOf && ~groupPut.indexOf(activeGroup.name)) // by Array
+          )
+        ) &&
+        (evt.rootEl === void 0 || evt.rootEl === this.el) // touch fallback
+      ) {
+        // Smart auto-scrolling
+        _autoScroll(evt, options, this.el);
+
+        if (_silent) {
+          return;
+        }
+
+        target = _closest(evt.target, options.draggable, el);
+        dragRect = dragEl.getBoundingClientRect();
+
+
+        if (revert) {
+          _cloneHide(true);
+
+          if (cloneEl || nextEl) {
+            rootEl.insertBefore(dragEl, cloneEl || nextEl);
+          }
+          else if (!canSort) {
+            rootEl.appendChild(dragEl);
+          }
+
+          return;
+        }
+
+
+        if ((el.children.length === 0) || (el.children[0] === ghostEl) ||
+          (el === evt.target) && (target = _ghostInBottom(el, evt))
+        ) {
+          if (target) {
+            if (target.animated) {
+              return;
+            }
+            targetRect = target.getBoundingClientRect();
+          }
+
+          _cloneHide(isOwner);
+
+          if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect) !== false) {
+            el.appendChild(dragEl);
+            this._animate(dragRect, dragEl);
+            target && this._animate(targetRect, target);
+          }
+        }
+        else if (target && !target.animated && target !== dragEl && (target.parentNode[expando] !== void 0)) {
+          if (lastEl !== target) {
+            lastEl = target;
+            lastCSS = _css(target);
+          }
+
+
+          var targetRect = target.getBoundingClientRect(),
+            width = targetRect.right - targetRect.left,
+            height = targetRect.bottom - targetRect.top,
+            floating = /left|right|inline/.test(lastCSS.cssFloat + lastCSS.display),
+            isWide = (target.offsetWidth > dragEl.offsetWidth),
+            isLong = (target.offsetHeight > dragEl.offsetHeight),
+            halfway = (floating ? (evt.clientX - targetRect.left) / width : (evt.clientY - targetRect.top) / height) > 0.5,
+            nextSibling = target.nextElementSibling,
+            moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect),
+            after
+          ;
+
+          if (moveVector !== false) {
+            _silent = true;
+            setTimeout(_unsilent, 30);
+
+            _cloneHide(isOwner);
+
+            if (moveVector === 1 || moveVector === -1) {
+              after = (moveVector === 1);
+            }
+            else if (floating) {
+              after = (target.previousElementSibling === dragEl) && !isWide || halfway && isWide;
+            } else {
+              after = (nextSibling !== dragEl) && !isLong || halfway && isLong;
+            }
+
+            if (after && !nextSibling) {
+              el.appendChild(dragEl);
+            } else {
+              target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
+            }
+
+            this._animate(dragRect, dragEl);
+            this._animate(targetRect, target);
+          }
+        }
+      }
+    },
+
+    _animate: function (prevRect, target) {
+      var ms = this.options.animation;
+
+      if (ms) {
+        var currentRect = target.getBoundingClientRect();
+
+        _css(target, 'transition', 'none');
+        _css(target, 'transform', 'translate3d('
+          + (prevRect.left - currentRect.left) + 'px,'
+          + (prevRect.top - currentRect.top) + 'px,0)'
+        );
+
+        target.offsetWidth; // repaint
+
+        _css(target, 'transition', 'all ' + ms + 'ms');
+        _css(target, 'transform', 'translate3d(0,0,0)');
+
+        clearTimeout(target.animated);
+        target.animated = setTimeout(function () {
+          _css(target, 'transition', '');
+          _css(target, 'transform', '');
+          target.animated = false;
+        }, ms);
+      }
+    },
+
+    _offUpEvents: function () {
+      var ownerDocument = this.el.ownerDocument;
+
+      _off(document, 'touchmove', this._onTouchMove);
+      _off(ownerDocument, 'mouseup', this._onDrop);
+      _off(ownerDocument, 'touchend', this._onDrop);
+      _off(ownerDocument, 'touchcancel', this._onDrop);
+    },
+
+    _onDrop: function (/**Event*/evt) {
+      var el = this.el,
+        options = this.options;
+
+      clearInterval(this._loopId);
+      clearInterval(autoScroll.pid);
+      clearTimeout(this._dragStartTimer);
+
+      // Unbind events
+      _off(document, 'drop', this);
+      _off(document, 'mousemove', this._onTouchMove);
+      _off(el, 'dragstart', this._onDragStart);
+
+      this._offUpEvents();
+
+      if (evt) {
+        evt.preventDefault();
+        !options.dropBubble && evt.stopPropagation();
+
+        ghostEl && ghostEl.parentNode.removeChild(ghostEl);
+
+        if (dragEl) {
+          _off(dragEl, 'dragend', this);
+
+          _disableDraggable(dragEl);
+          _toggleClass(dragEl, this.options.ghostClass, false);
+
+          if (rootEl !== dragEl.parentNode) {
+            newIndex = _index(dragEl);
+
+            // drag from one list and drop into another
+            _dispatchEvent(null, dragEl.parentNode, 'sort', dragEl, rootEl, oldIndex, newIndex);
+            _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
+
+            // Add event
+            _dispatchEvent(null, dragEl.parentNode, 'add', dragEl, rootEl, oldIndex, newIndex);
+
+            // Remove event
+            _dispatchEvent(this, rootEl, 'remove', dragEl, rootEl, oldIndex, newIndex);
+          }
+          else {
+            // Remove clone
+            cloneEl && cloneEl.parentNode.removeChild(cloneEl);
+
+            if (dragEl.nextSibling !== nextEl) {
+              // Get the index of the dragged element within its parent
+              newIndex = _index(dragEl);
+
+              // drag & drop within the same list
+              _dispatchEvent(this, rootEl, 'update', dragEl, rootEl, oldIndex, newIndex);
+              _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
+            }
+          }
+
+          if (Sortable.active) {
+            // Drag end event
+            _dispatchEvent(this, rootEl, 'end', dragEl, rootEl, oldIndex, newIndex);
+
+            // Save sorting
+            this.save();
+          }
+        }
+
+        // Nulling
+        rootEl =
+        dragEl =
+        ghostEl =
+        nextEl =
+        cloneEl =
+
+        scrollEl =
+        scrollParentEl =
+
+        tapEvt =
+        touchEvt =
+
+        lastEl =
+        lastCSS =
+
+        activeGroup =
+        Sortable.active = null;
+      }
+    },
+
+
+    handleEvent: function (/**Event*/evt) {
+      var type = evt.type;
+
+      if (type === 'dragover' || type === 'dragenter') {
+        if (dragEl) {
+          this._onDragOver(evt);
+          _globalDragOver(evt);
+        }
+      }
+      else if (type === 'drop' || type === 'dragend') {
+        this._onDrop(evt);
+      }
+    },
+
+
+    /**
+     * Serializes the item into an array of string.
+     * @returns {String[]}
+     */
+    toArray: function () {
+      var order = [],
+        el,
+        children = this.el.children,
+        i = 0,
+        n = children.length,
+        options = this.options;
+
+      for (; i < n; i++) {
+        el = children[i];
+        if (_closest(el, options.draggable, this.el)) {
+          order.push(el.getAttribute(options.dataIdAttr) || _generateId(el));
+        }
+      }
+
+      return order;
+    },
+
+
+    /**
+     * Sorts the elements according to the array.
+     * @param  {String[]}  order  order of the items
+     */
+    sort: function (order) {
+      var items = {}, rootEl = this.el;
+
+      this.toArray().forEach(function (id, i) {
+        var el = rootEl.children[i];
+
+        if (_closest(el, this.options.draggable, rootEl)) {
+          items[id] = el;
+        }
+      }, this);
+
+      order.forEach(function (id) {
+        if (items[id]) {
+          rootEl.removeChild(items[id]);
+          rootEl.appendChild(items[id]);
+        }
+      });
+    },
+
+
+    /**
+     * Save the current sorting
+     */
+    save: function () {
+      var store = this.options.store;
+      store && store.set(this);
+    },
+
+
+    /**
+     * For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.
+     * @param   {HTMLElement}  el
+     * @param   {String}       [selector]  default: `options.draggable`
+     * @returns {HTMLElement|null}
+     */
+    closest: function (el, selector) {
+      return _closest(el, selector || this.options.draggable, this.el);
+    },
+
+
+    /**
+     * Set/get option
+     * @param   {string} name
+     * @param   {*}      [value]
+     * @returns {*}
+     */
+    option: function (name, value) {
+      var options = this.options;
+
+      if (value === void 0) {
+        return options[name];
+      } else {
+        options[name] = value;
+      }
+    },
+
+
+    /**
+     * Destroy
+     */
+    destroy: function () {
+      var el = this.el;
+
+      el[expando] = null;
+
+      _off(el, 'mousedown', this._onTapStart);
+      _off(el, 'touchstart', this._onTapStart);
+
+      _off(el, 'dragover', this);
+      _off(el, 'dragenter', this);
+
+      // Remove draggable attributes
+      Array.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) {
+        el.removeAttribute('draggable');
+      });
+
+      touchDragOverListeners.splice(touchDragOverListeners.indexOf(this._onDragOver), 1);
+
+      this._onDrop();
+
+      this.el = el = null;
+    }
+  };
+
+
+  function _cloneHide(state) {
+    if (cloneEl && (cloneEl.state !== state)) {
+      _css(cloneEl, 'display', state ? 'none' : '');
+      !state && cloneEl.state && rootEl.insertBefore(cloneEl, dragEl);
+      cloneEl.state = state;
+    }
+  }
+
+
+  function _bind(ctx, fn) {
+    var args = slice.call(arguments, 2);
+    return  fn.bind ? fn.bind.apply(fn, [ctx].concat(args)) : function () {
+      return fn.apply(ctx, args.concat(slice.call(arguments)));
+    };
+  }
+
+
+  function _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx) {
+    if (el) {
+      ctx = ctx || document;
+      selector = selector.split('.');
+
+      var tag = selector.shift().toUpperCase(),
+        re = new RegExp('\\s(' + selector.join('|') + ')(?=\\s)', 'g');
+
+      do {
+        if (
+          (tag === '>*' && el.parentNode === ctx) || (
+            (tag === '' || el.nodeName.toUpperCase() == tag) &&
+            (!selector.length || ((' ' + el.className + ' ').match(re) || []).length == selector.length)
+          )
+        ) {
+          return el;
+        }
+      }
+      while (el !== ctx && (el = el.parentNode));
+    }
+
+    return null;
+  }
+
+
+  function _globalDragOver(/**Event*/evt) {
+    evt.dataTransfer.dropEffect = 'move';
+    evt.preventDefault();
+  }
+
+
+  function _on(el, event, fn) {
+    el.addEventListener(event, fn, false);
+  }
+
+
+  function _off(el, event, fn) {
+    el.removeEventListener(event, fn, false);
+  }
+
+
+  function _toggleClass(el, name, state) {
+    if (el) {
+      if (el.classList) {
+        el.classList[state ? 'add' : 'remove'](name);
+      }
+      else {
+        var className = (' ' + el.className + ' ').replace(RSPACE, ' ').replace(' ' + name + ' ', ' ');
+        el.className = (className + (state ? ' ' + name : '')).replace(RSPACE, ' ');
+      }
+    }
+  }
+
+
+  function _css(el, prop, val) {
+    var style = el && el.style;
+
+    if (style) {
+      if (val === void 0) {
+        if (document.defaultView && document.defaultView.getComputedStyle) {
+          val = document.defaultView.getComputedStyle(el, '');
+        }
+        else if (el.currentStyle) {
+          val = el.currentStyle;
+        }
+
+        return prop === void 0 ? val : val[prop];
+      }
+      else {
+        if (!(prop in style)) {
+          prop = '-webkit-' + prop;
+        }
+
+        style[prop] = val + (typeof val === 'string' ? '' : 'px');
+      }
+    }
+  }
+
+
+  function _find(ctx, tagName, iterator) {
+    if (ctx) {
+      var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length;
+
+      if (iterator) {
+        for (; i < n; i++) {
+          iterator(list[i], i);
+        }
+      }
+
+      return list;
+    }
+
+    return [];
+  }
+
+
+
+  function _dispatchEvent(sortable, rootEl, name, targetEl, fromEl, startIndex, newIndex) {
+    var evt = document.createEvent('Event'),
+      options = (sortable || rootEl[expando]).options,
+      onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1);
+
+    evt.initEvent(name, true, true);
+
+    evt.to = rootEl;
+    evt.from = fromEl || rootEl;
+    evt.item = targetEl || rootEl;
+    evt.clone = cloneEl;
+
+    evt.oldIndex = startIndex;
+    evt.newIndex = newIndex;
+
+    rootEl.dispatchEvent(evt);
+
+    if (options[onName]) {
+      options[onName].call(sortable, evt);
+    }
+  }
+
+
+  function _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect) {
+    var evt,
+      sortable = fromEl[expando],
+      onMoveFn = sortable.options.onMove,
+      retVal;
+
+    if (onMoveFn) {
+      evt = document.createEvent('Event');
+      evt.initEvent('move', true, true);
+
+      evt.to = toEl;
+      evt.from = fromEl;
+      evt.dragged = dragEl;
+      evt.draggedRect = dragRect;
+      evt.related = targetEl || toEl;
+      evt.relatedRect = targetRect || toEl.getBoundingClientRect();
+
+      retVal = onMoveFn.call(sortable, evt);
+    }
+
+    return retVal;
+  }
+
+
+  function _disableDraggable(el) {
+    el.draggable = false;
+  }
+
+
+  function _unsilent() {
+    _silent = false;
+  }
+
+
+  /** @returns {HTMLElement|false} */
+  function _ghostInBottom(el, evt) {
+    var lastEl = el.lastElementChild,
+      rect = lastEl.getBoundingClientRect();
+
+    return (evt.clientY - (rect.top + rect.height) > 5) && lastEl; // min delta
+  }
+
+
+  /**
+   * Generate id
+   * @param   {HTMLElement} el
+   * @returns {String}
+   * @private
+   */
+  function _generateId(el) {
+    var str = el.tagName + el.className + el.src + el.href + el.textContent,
+      i = str.length,
+      sum = 0;
+
+    while (i--) {
+      sum += str.charCodeAt(i);
+    }
+
+    return sum.toString(36);
+  }
+
+  /**
+   * Returns the index of an element within its parent
+   * @param el
+   * @returns {number}
+   * @private
+   */
+  function _index(/**HTMLElement*/el) {
+    var index = 0;
+    while (el && (el = el.previousElementSibling)) {
+      if (el.nodeName.toUpperCase() !== 'TEMPLATE') {
+        index++;
+      }
+    }
+    return index;
+  }
+
+  function _throttle(callback, ms) {
+    var args, _this;
+
+    return function () {
+      if (args === void 0) {
+        args = arguments;
+        _this = this;
+
+        setTimeout(function () {
+          if (args.length === 1) {
+            callback.call(_this, args[0]);
+          } else {
+            callback.apply(_this, args);
+          }
+
+          args = void 0;
+        }, ms);
+      }
+    };
+  }
+
+  function _extend(dst, src) {
+    if (dst && src) {
+      for (var key in src) {
+        if (src.hasOwnProperty(key)) {
+          dst[key] = src[key];
+        }
+      }
+    }
+
+    return dst;
+  }
+
+
+  // Export utils
+  Sortable.utils = {
+    on: _on,
+    off: _off,
+    css: _css,
+    find: _find,
+    bind: _bind,
+    is: function (el, selector) {
+      return !!_closest(el, selector, el);
+    },
+    extend: _extend,
+    throttle: _throttle,
+    closest: _closest,
+    toggleClass: _toggleClass,
+    index: _index
+  };
+
+
+  Sortable.version = '1.2.1';
+
+
+  /**
+   * Create sortable instance
+   * @param {HTMLElement}  el
+   * @param {Object}      [options]
+   */
+  Sortable.create = function (el, options) {
+    return new Sortable(el, options);
+  };
+
+  // Export
+  return Sortable;
+});

+ 2 - 1
app/assets/javascripts/blazer/application.js

@@ -8,10 +8,11 @@
 //= require ./moment
 //= require ./moment-timezone
 //= require ./daterangepicker
-//= require chartkick
+//= require ./chartkick
 //= require ./ace/ace
 //= require ./ace/ext-language_tools
 //= require ./ace/theme-twilight
 //= require ./ace/mode-sql
 //= require ./ace/snippets/text
 //= require ./ace/snippets/sql
+//= require ./Sortable

+ 935 - 0
app/assets/javascripts/blazer/chartkick.js

@@ -0,0 +1,935 @@
+/*
+ * Chartkick.js
+ * Create beautiful Javascript charts with minimal code
+ * https://github.com/ankane/chartkick.js
+ * v1.4.1
+ * MIT License
+ */
+
+/*jslint browser: true, indent: 2, plusplus: true, vars: true */
+
+(function (window) {
+  'use strict';
+
+  var config = window.Chartkick || {};
+  var Chartkick, DATE_PATTERN, ISO8601_PATTERN, DECIMAL_SEPARATOR, adapters = [];
+  DATE_PATTERN = /^(\d\d\d\d)(\-)?(\d\d)(\-)?(\d\d)$/i;
+
+  // helpers
+
+  function isArray(variable) {
+    return Object.prototype.toString.call(variable) === "[object Array]";
+  }
+
+  function isFunction(variable) {
+    return variable instanceof Function;
+  }
+
+  function isPlainObject(variable) {
+    return !isFunction(variable) && variable instanceof Object;
+  }
+
+  // https://github.com/madrobby/zepto/blob/master/src/zepto.js
+  function extend(target, source) {
+    var key;
+    for (key in source) {
+      if (isPlainObject(source[key]) || isArray(source[key])) {
+        if (isPlainObject(source[key]) && !isPlainObject(target[key])) {
+          target[key] = {};
+        }
+        if (isArray(source[key]) && !isArray(target[key])) {
+          target[key] = [];
+        }
+        extend(target[key], source[key]);
+      } else if (source[key] !== undefined) {
+        target[key] = source[key];
+      }
+    }
+  }
+
+  function merge(obj1, obj2) {
+    var target = {};
+    extend(target, obj1);
+    extend(target, obj2);
+    return target;
+  }
+
+  // https://github.com/Do/iso8601.js
+  ISO8601_PATTERN = /(\d\d\d\d)(\-)?(\d\d)(\-)?(\d\d)(T)?(\d\d)(:)?(\d\d)?(:)?(\d\d)?([\.,]\d+)?($|Z|([\+\-])(\d\d)(:)?(\d\d)?)/i;
+  DECIMAL_SEPARATOR = String(1.5).charAt(1);
+
+  function parseISO8601(input) {
+    var day, hour, matches, milliseconds, minutes, month, offset, result, seconds, type, year;
+    type = Object.prototype.toString.call(input);
+    if (type === '[object Date]') {
+      return input;
+    }
+    if (type !== '[object String]') {
+      return;
+    }
+    if (matches = input.match(ISO8601_PATTERN)) {
+      year = parseInt(matches[1], 10);
+      month = parseInt(matches[3], 10) - 1;
+      day = parseInt(matches[5], 10);
+      hour = parseInt(matches[7], 10);
+      minutes = matches[9] ? parseInt(matches[9], 10) : 0;
+      seconds = matches[11] ? parseInt(matches[11], 10) : 0;
+      milliseconds = matches[12] ? parseFloat(DECIMAL_SEPARATOR + matches[12].slice(1)) * 1000 : 0;
+      result = Date.UTC(year, month, day, hour, minutes, seconds, milliseconds);
+      if (matches[13] && matches[14]) {
+        offset = matches[15] * 60;
+        if (matches[17]) {
+          offset += parseInt(matches[17], 10);
+        }
+        offset *= matches[14] === '-' ? -1 : 1;
+        result -= offset * 60 * 1000;
+      }
+      return new Date(result);
+    }
+  }
+  // end iso8601.js
+
+  function negativeValues(series) {
+    var i, j, data;
+    for (i = 0; i < series.length; i++) {
+      data = series[i].data;
+      for (j = 0; j < data.length; j++) {
+        if (data[j][1] < 0) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  function jsOptionsFunc(defaultOptions, hideLegend, setMin, setMax, setStacked, setXtitle, setYtitle) {
+    return function (series, opts, chartOptions) {
+      var options = merge({}, defaultOptions);
+      options = merge(options, chartOptions || {});
+
+      // hide legend
+      // this is *not* an external option!
+      if (opts.hideLegend) {
+        hideLegend(options);
+      }
+
+      // min
+      if ("min" in opts) {
+        setMin(options, opts.min);
+      } else if (!negativeValues(series)) {
+        setMin(options, 0);
+      }
+
+      // max
+      if (opts.max) {
+        setMax(options, opts.max);
+      }
+
+      if (opts.stacked) {
+        setStacked(options);
+      }
+
+      if (opts.colors) {
+        options.colors = opts.colors;
+      }
+
+      if (opts.xtitle) {
+        setXtitle(options, opts.xtitle);
+      }
+
+      if (opts.ytitle) {
+        setYtitle(options, opts.ytitle);
+      }
+
+      // merge library last
+      options = merge(options, opts.library || {});
+
+      return options;
+    };
+  }
+
+  function setText(element, text) {
+    if (document.body.innerText) {
+      element.innerText = text;
+    } else {
+      element.textContent = text;
+    }
+  }
+
+  function chartError(element, message) {
+    setText(element, "Error Loading Chart: " + message);
+    element.style.color = "#ff0000";
+  }
+
+  function getJSON(element, url, success) {
+    var $ = window.jQuery || window.Zepto || window.$;
+    $.ajax({
+      dataType: "json",
+      url: url,
+      success: success,
+      error: function (jqXHR, textStatus, errorThrown) {
+        var message = (typeof errorThrown === "string") ? errorThrown : errorThrown.message;
+        chartError(element, message);
+      }
+    });
+  }
+
+  function errorCatcher(chart, callback) {
+    try {
+      callback(chart);
+    } catch (err) {
+      chartError(chart.element, err.message);
+      throw err;
+    }
+  }
+
+  function fetchDataSource(chart, callback) {
+    if (typeof chart.dataSource === "string") {
+      getJSON(chart.element, chart.dataSource, function (data, textStatus, jqXHR) {
+        chart.data = data;
+        errorCatcher(chart, callback);
+      });
+    } else {
+      chart.data = chart.dataSource;
+      errorCatcher(chart, callback);
+    }
+  }
+
+  // type conversions
+
+  function toStr(n) {
+    return "" + n;
+  }
+
+  function toFloat(n) {
+    return parseFloat(n);
+  }
+
+  function toDate(n) {
+    var matches, year, month, day;
+    if (typeof n !== "object") {
+      if (typeof n === "number") {
+        n = new Date(n * 1000); // ms
+      } else if (matches = n.match(DATE_PATTERN)) {
+        year = parseInt(matches[1], 10);
+        month = parseInt(matches[3], 10) - 1;
+        day = parseInt(matches[5], 10);
+        return new Date(year, month, day);
+      } else { // str
+        // try our best to get the str into iso8601
+        // TODO be smarter about this
+        var str = n.replace(/ /, "T").replace(" ", "").replace("UTC", "Z");
+        n = parseISO8601(str) || new Date(n);
+      }
+    }
+    return n;
+  }
+
+  function toArr(n) {
+    if (!isArray(n)) {
+      var arr = [], i;
+      for (i in n) {
+        if (n.hasOwnProperty(i)) {
+          arr.push([i, n[i]]);
+        }
+      }
+      n = arr;
+    }
+    return n;
+  }
+
+  function sortByTime(a, b) {
+    return a[0].getTime() - b[0].getTime();
+  }
+
+  if ("Highcharts" in window) {
+    var HighchartsAdapter = new function () {
+      var Highcharts = window.Highcharts;
+
+      this.name = "highcharts";
+
+      var defaultOptions = {
+        chart: {},
+        xAxis: {
+          title: {
+            text: null
+          },
+          labels: {
+            style: {
+              fontSize: "12px"
+            }
+          }
+        },
+        yAxis: {
+          title: {
+            text: null
+          },
+          labels: {
+            style: {
+              fontSize: "12px"
+            }
+          }
+        },
+        title: {
+          text: null
+        },
+        credits: {
+          enabled: false
+        },
+        legend: {
+          borderWidth: 0
+        },
+        tooltip: {
+          style: {
+            fontSize: "12px"
+          }
+        },
+        plotOptions: {
+          areaspline: {},
+          series: {
+            marker: {}
+          }
+        }
+      };
+
+      var hideLegend = function (options) {
+        options.legend.enabled = false;
+      };
+
+      var setMin = function (options, min) {
+        options.yAxis.min = min;
+      };
+
+      var setMax = function (options, max) {
+        options.yAxis.max = max;
+      };
+
+      var setStacked = function (options) {
+        options.plotOptions.series.stacking = "normal";
+      };
+
+      var setXtitle = function (options, title) {
+        options.xAxis.title.text = title;
+      };
+
+      var setYtitle = function (options, title) {
+        options.yAxis.title.text = title;
+      };
+
+      var jsOptions = jsOptionsFunc(defaultOptions, hideLegend, setMin, setMax, setStacked, setXtitle, setYtitle);
+
+      this.renderLineChart = function (chart, chartType) {
+        chartType = chartType || "spline";
+        var chartOptions = {};
+        if (chartType === "areaspline") {
+          chartOptions = {
+            plotOptions: {
+              areaspline: {
+                stacking: "normal"
+              },
+              series: {
+                marker: {
+                  enabled: false
+                }
+              }
+            }
+          };
+        }
+        var options = jsOptions(chart.data, chart.options, chartOptions), data, i, j;
+        options.xAxis.type = chart.options.discrete ? "category" : "datetime";
+        options.chart.type = chartType;
+        options.chart.renderTo = chart.element.id;
+
+        var series = chart.data;
+        for (i = 0; i < series.length; i++) {
+          data = series[i].data;
+          if (!chart.options.discrete) {
+            for (j = 0; j < data.length; j++) {
+              data[j][0] = data[j][0].getTime();
+            }
+          }
+          series[i].marker = {symbol: "circle"};
+        }
+        options.series = series;
+        new Highcharts.Chart(options);
+      };
+
+      this.renderScatterChart = function (chart) {
+        var chartOptions = {};
+        var options = jsOptions(chart.data, chart.options, chartOptions);
+        options.chart.type = 'scatter';
+        options.chart.renderTo = chart.element.id;
+        options.series = chart.data;
+        new Highcharts.Chart(options);
+      };
+
+      this.renderPieChart = function (chart) {
+        var chartOptions = {};
+        if (chart.options.colors) {
+          chartOptions.colors = chart.options.colors;
+        }
+        var options = merge(merge(defaultOptions, chartOptions), chart.options.library || {});
+        options.chart.renderTo = chart.element.id;
+        options.series = [{
+          type: "pie",
+          name: "Value",
+          data: chart.data
+        }];
+        new Highcharts.Chart(options);
+      };
+
+      this.renderColumnChart = function (chart, chartType) {
+        var chartType = chartType || "column";
+        var series = chart.data;
+        var options = jsOptions(series, chart.options), i, j, s, d, rows = [];
+        options.chart.type = chartType;
+        options.chart.renderTo = chart.element.id;
+
+        for (i = 0; i < series.length; i++) {
+          s = series[i];
+
+          for (j = 0; j < s.data.length; j++) {
+            d = s.data[j];
+            if (!rows[d[0]]) {
+              rows[d[0]] = new Array(series.length);
+            }
+            rows[d[0]][i] = d[1];
+          }
+        }
+
+        var categories = [];
+        for (i in rows) {
+          if (rows.hasOwnProperty(i)) {
+            categories.push(i);
+          }
+        }
+        options.xAxis.categories = categories;
+
+        var newSeries = [];
+        for (i = 0; i < series.length; i++) {
+          d = [];
+          for (j = 0; j < categories.length; j++) {
+            d.push(rows[categories[j]][i] || 0);
+          }
+
+          newSeries.push({
+            name: series[i].name,
+            data: d
+          });
+        }
+        options.series = newSeries;
+
+        new Highcharts.Chart(options);
+      };
+
+      var self = this;
+
+      this.renderBarChart = function (chart) {
+        self.renderColumnChart(chart, "bar");
+      };
+
+      this.renderAreaChart = function (chart) {
+        self.renderLineChart(chart, "areaspline");
+      };
+    };
+    adapters.push(HighchartsAdapter);
+  }
+  if (window.google && window.google.setOnLoadCallback) {
+    var GoogleChartsAdapter = new function () {
+      var google = window.google;
+
+      this.name = "google";
+
+      var loaded = {};
+      var callbacks = [];
+
+      var runCallbacks = function () {
+        var cb, call;
+        for (var i = 0; i < callbacks.length; i++) {
+          cb = callbacks[i];
+          call = google.visualization && ((cb.pack === "corechart" && google.visualization.LineChart) || (cb.pack === "timeline" && google.visualization.Timeline))
+          if (call) {
+            cb.callback();
+            callbacks.splice(i, 1);
+            i--;
+          }
+        }
+      };
+
+      var waitForLoaded = function (pack, callback) {
+        if (!callback) {
+          callback = pack;
+          pack = "corechart";
+        }
+
+        callbacks.push({pack: pack, callback: callback});
+
+        if (loaded[pack]) {
+          runCallbacks();
+        } else {
+          loaded[pack] = true;
+
+          // https://groups.google.com/forum/#!topic/google-visualization-api/fMKJcyA2yyI
+          var loadOptions = {
+            packages: [pack],
+            callback: runCallbacks
+          };
+          if (config.language) {
+            loadOptions.language = config.language;
+          }
+          google.load("visualization", "1", loadOptions);
+        }
+      };
+
+      // Set chart options
+      var defaultOptions = {
+        chartArea: {},
+        fontName: "'Lucida Grande', 'Lucida Sans Unicode', Verdana, Arial, Helvetica, sans-serif",
+        pointSize: 6,
+        legend: {
+          textStyle: {
+            fontSize: 12,
+            color: "#444"
+          },
+          alignment: "center",
+          position: "right"
+        },
+        curveType: "function",
+        hAxis: {
+          textStyle: {
+            color: "#666",
+            fontSize: 12
+          },
+          titleTextStyle: {},
+          gridlines: {
+            color: "transparent"
+          },
+          baselineColor: "#ccc",
+          viewWindow: {}
+        },
+        vAxis: {
+          textStyle: {
+            color: "#666",
+            fontSize: 12
+          },
+          titleTextStyle: {},
+          baselineColor: "#ccc",
+          viewWindow: {}
+        },
+        tooltip: {
+          textStyle: {
+            color: "#666",
+            fontSize: 12
+          }
+        }
+      };
+
+      var hideLegend = function (options) {
+        options.legend.position = "none";
+      };
+
+      var setMin = function (options, min) {
+        options.vAxis.viewWindow.min = min;
+      };
+
+      var setMax = function (options, max) {
+        options.vAxis.viewWindow.max = max;
+      };
+
+      var setBarMin = function (options, min) {
+        options.hAxis.viewWindow.min = min;
+      };
+
+      var setBarMax = function (options, max) {
+        options.hAxis.viewWindow.max = max;
+      };
+
+      var setStacked = function (options) {
+        options.isStacked = true;
+      };
+
+      var setXtitle = function (options, title) {
+        options.hAxis.title = title;
+        options.hAxis.titleTextStyle.italic = false;
+      }
+
+      var setYtitle = function (options, title) {
+        options.vAxis.title = title;
+        options.vAxis.titleTextStyle.italic = false;
+      };
+
+      var jsOptions = jsOptionsFunc(defaultOptions, hideLegend, setMin, setMax, setStacked, setXtitle, setYtitle);
+
+      // cant use object as key
+      var createDataTable = function (series, columnType) {
+        var data = new google.visualization.DataTable();
+        data.addColumn(columnType, "");
+
+        var i, j, s, d, key, rows = [];
+        for (i = 0; i < series.length; i++) {
+          s = series[i];
+          data.addColumn("number", s.name);
+
+          for (j = 0; j < s.data.length; j++) {
+            d = s.data[j];
+            key = (columnType === "datetime") ? d[0].getTime() : d[0];
+            if (!rows[key]) {
+              rows[key] = new Array(series.length);
+            }
+            rows[key][i] = toFloat(d[1]);
+          }
+        }
+
+        var rows2 = [];
+        var day = true;
+        var value;
+        for (i in rows) {
+          if (rows.hasOwnProperty(i)) {
+            if (columnType === "datetime") {
+              value = new Date(toFloat(i));
+              day = day && isDay(value);
+            } else if (columnType === "number") {
+              value = toFloat(i);
+            } else {
+              value = i;
+            }
+            rows2.push([value].concat(rows[i]));
+          }
+        }
+        if (columnType === "datetime") {
+          rows2.sort(sortByTime);
+        }
+        data.addRows(rows2);
+
+        if (columnType === "datetime" && day) {
+          var formatter = new google.visualization.DateFormat({
+            pattern: "MMM d, yyyy"
+          });
+          formatter.format(data, 0);
+        }
+
+        return data;
+      };
+
+      var resize = function (callback) {
+        if (window.attachEvent) {
+          window.attachEvent("onresize", callback);
+        } else if (window.addEventListener) {
+          window.addEventListener("resize", callback, true);
+        }
+        callback();
+      };
+
+      this.renderLineChart = function (chart) {
+        waitForLoaded(function () {
+          var options = jsOptions(chart.data, chart.options);
+          var data = createDataTable(chart.data, chart.options.discrete ? "string" : "datetime");
+          chart.chart = new google.visualization.LineChart(chart.element);
+          resize(function () {
+            chart.chart.draw(data, options);
+          });
+        });
+      };
+
+      this.renderPieChart = function (chart) {
+        waitForLoaded(function () {
+          var chartOptions = {
+            chartArea: {
+              top: "10%",
+              height: "80%"
+            }
+          };
+          if (chart.options.colors) {
+            chartOptions.colors = chart.options.colors;
+          }
+          var options = merge(merge(defaultOptions, chartOptions), chart.options.library || {});
+
+          var data = new google.visualization.DataTable();
+          data.addColumn("string", "");
+          data.addColumn("number", "Value");
+          data.addRows(chart.data);
+
+          chart.chart = new google.visualization.PieChart(chart.element);
+          resize(function () {
+            chart.chart.draw(data, options);
+          });
+        });
+      };
+
+      this.renderColumnChart = function (chart) {
+        waitForLoaded(function () {
+          var options = jsOptions(chart.data, chart.options);
+          var data = createDataTable(chart.data, "string");
+          chart.chart = new google.visualization.ColumnChart(chart.element);
+          resize(function () {
+            chart.chart.draw(data, options);
+          });
+        });
+      };
+
+      this.renderBarChart = function (chart) {
+        waitForLoaded(function () {
+          var chartOptions = {
+            hAxis: {
+              gridlines: {
+                color: "#ccc"
+              }
+            }
+          };
+          var options = jsOptionsFunc(defaultOptions, hideLegend, setBarMin, setBarMax, setStacked)(chart.data, chart.options, chartOptions);
+          var data = createDataTable(chart.data, "string");
+          chart.chart = new google.visualization.BarChart(chart.element);
+          resize(function () {
+            chart.chart.draw(data, options);
+          });
+        });
+      };
+
+      this.renderAreaChart = function (chart) {
+        waitForLoaded(function () {
+          var chartOptions = {
+            isStacked: true,
+            pointSize: 0,
+            areaOpacity: 0.5
+          };
+          var options = jsOptions(chart.data, chart.options, chartOptions);
+          var data = createDataTable(chart.data, chart.options.discrete ? "string" : "datetime");
+          chart.chart = new google.visualization.AreaChart(chart.element);
+          resize(function () {
+            chart.chart.draw(data, options);
+          });
+        });
+      };
+
+      this.renderGeoChart = function (chart) {
+        waitForLoaded(function () {
+          var chartOptions = {
+            legend: "none",
+            colorAxis: {
+              colors: chart.options.colors || ["#f6c7b6", "#ce502d"]
+            }
+          };
+          var options = merge(merge(defaultOptions, chartOptions), chart.options.library || {});
+
+          var data = new google.visualization.DataTable();
+          data.addColumn("string", "");
+          data.addColumn("number", "Value");
+          data.addRows(chart.data);
+
+          chart.chart = new google.visualization.GeoChart(chart.element);
+          resize(function () {
+            chart.chart.draw(data, options);
+          });
+        });
+      };
+
+      this.renderScatterChart = function (chart) {
+        waitForLoaded(function () {
+          var chartOptions = {};
+          var options = jsOptions(chart.data, chart.options, chartOptions);
+          var data = createDataTable(chart.data, "number");
+
+          chart.chart = new google.visualization.ScatterChart(chart.element);
+          resize(function () {
+            chart.chart.draw(data, options);
+          });
+        });
+      };
+
+      this.renderTimeline = function (chart) {
+        waitForLoaded("timeline", function () {
+          var chartOptions = {
+            legend: "none"
+          };
+
+          if (chart.options.colors) {
+            chartOptions.colors = chart.options.colors;
+          }
+          var options = merge(merge(defaultOptions, chartOptions), chart.options.library || {});
+
+          var data = new google.visualization.DataTable();
+          data.addColumn({type: "string", id: "Name"});
+          data.addColumn({type: "date", id: "Start"});
+          data.addColumn({type: "date", id: "End"});
+          data.addRows(chart.data);
+
+          chart.chart = new google.visualization.Timeline(chart.element);
+
+          resize(function () {
+            chart.chart.draw(data, options);
+          });
+        });
+      };
+    };
+
+    adapters.push(GoogleChartsAdapter);
+  }
+
+  // TODO remove chartType if cross-browser way
+  // to get the name of the chart class
+  function renderChart(chartType, chart) {
+    var i, adapter, fnName, adapterName;
+    fnName = "render" + chartType;
+    adapterName = chart.options.adapter;
+
+    for (i = 0; i < adapters.length; i++) {
+      adapter = adapters[i];
+      if ((!adapterName || adapterName === adapter.name) && isFunction(adapter[fnName])) {
+        return adapter[fnName](chart);
+      }
+    }
+    throw new Error("No adapter found");
+  }
+
+  // process data
+
+  var toFormattedKey = function (key, keyType) {
+    if (keyType === "number") {
+      key = toFloat(key);
+    } else if (keyType === "datetime") {
+      key = toDate(key);
+    } else {
+      key = toStr(key);
+    }
+    return key;
+  };
+
+  var formatSeriesData = function (data, keyType) {
+    var r = [], key, j;
+    for (j = 0; j < data.length; j++) {
+      key = toFormattedKey(data[j][0], keyType);
+      r.push([key, toFloat(data[j][1])]);
+    }
+    if (keyType === "datetime") {
+      r.sort(sortByTime);
+    }
+    return r;
+  };
+
+  function isDay(d) {
+    return d.getMilliseconds() + d.getSeconds() + d.getMinutes() + d.getHours() === 0;
+  }
+
+  function processSeries(series, opts, keyType) {
+    var i;
+
+    // see if one series or multiple
+    if (!isArray(series) || typeof series[0] !== "object" || isArray(series[0])) {
+      series = [{name: "Value", data: series}];
+      opts.hideLegend = true;
+    } else {
+      opts.hideLegend = false;
+    }
+    if (opts.discrete) {
+      keyType = "string";
+    }
+
+    // right format
+    for (i = 0; i < series.length; i++) {
+      series[i].data = formatSeriesData(toArr(series[i].data), keyType);
+    }
+
+    return series;
+  }
+
+  function processSimple(data) {
+    var perfectData = toArr(data), i;
+    for (i = 0; i < perfectData.length; i++) {
+      perfectData[i] = [toStr(perfectData[i][0]), toFloat(perfectData[i][1])];
+    }
+    return perfectData;
+  }
+
+  function processTime(data)
+  {
+    var i;
+    for (i = 0; i < data.length; i++) {
+      data[i][1] = toDate(data[i][1]);
+      data[i][2] = toDate(data[i][2]);
+    }
+    return data;
+  }
+
+  function processLineData(chart) {
+    chart.data = processSeries(chart.data, chart.options, "datetime");
+    renderChart("LineChart", chart);
+  }
+
+  function processColumnData(chart) {
+    chart.data = processSeries(chart.data, chart.options, "string");
+    renderChart("ColumnChart", chart);
+  }
+
+  function processPieData(chart) {
+    chart.data = processSimple(chart.data);
+    renderChart("PieChart", chart);
+  }
+
+  function processBarData(chart) {
+    chart.data = processSeries(chart.data, chart.options, "string");
+    renderChart("BarChart", chart);
+  }
+
+  function processAreaData(chart) {
+    chart.data = processSeries(chart.data, chart.options, "datetime");
+    renderChart("AreaChart", chart);
+  }
+
+  function processGeoData(chart) {
+    chart.data = processSimple(chart.data);
+    renderChart("GeoChart", chart);
+  }
+
+  function processScatterData(chart) {
+    chart.data = processSeries(chart.data, chart.options, "number");
+    renderChart("ScatterChart", chart);
+  }
+
+  function processTimelineData(chart) {
+    chart.data = processTime(chart.data);
+    renderChart("Timeline", chart);
+  }
+
+  function setElement(chart, element, dataSource, opts, callback) {
+    if (typeof element === "string") {
+      element = document.getElementById(element);
+    }
+    chart.element = element;
+    chart.options = opts || {};
+    chart.dataSource = dataSource;
+    Chartkick.charts[element.id] = chart;
+    fetchDataSource(chart, callback);
+  }
+
+  // define classes
+
+  Chartkick = {
+    LineChart: function (element, dataSource, opts) {
+      setElement(this, element, dataSource, opts, processLineData);
+    },
+    PieChart: function (element, dataSource, opts) {
+      setElement(this, element, dataSource, opts, processPieData);
+    },
+    ColumnChart: function (element, dataSource, opts) {
+      setElement(this, element, dataSource, opts, processColumnData);
+    },
+    BarChart: function (element, dataSource, opts) {
+      setElement(this, element, dataSource, opts, processBarData);
+    },
+    AreaChart: function (element, dataSource, opts) {
+      setElement(this, element, dataSource, opts, processAreaData);
+    },
+    GeoChart: function (element, dataSource, opts) {
+      setElement(this, element, dataSource, opts, processGeoData);
+    },
+    ScatterChart: function (element, dataSource, opts) {
+      setElement(this, element, dataSource, opts, processScatterData);
+    },
+    Timeline: function (element, dataSource, opts) {
+      setElement(this, element, dataSource, opts, processTimelineData);
+    },
+    charts: {}
+  };
+
+  window.Chartkick = Chartkick;
+}(window));

+ 17 - 2
app/assets/stylesheets/blazer/application.css

@@ -16,11 +16,11 @@ body {
   padding-bottom: 20px;
 }
 
-#results th {
+.results-table th {
   cursor: pointer;
 }
 
-#results thead {
+.results-table thead {
   background-color: #fff;
 }
 
@@ -68,3 +68,18 @@ input.search:focus {
 .ace_gutter-cell.error {
   background-color: red;
 }
+
+.chart {
+  height: 300px;
+  text-align: center;
+  display: flex;
+  justify-content: center;
+  flex-direction: column;
+}
+
+.chart > .results-container {
+  height: 300px;
+  border: solid 1px #ddd;
+  overflow: scroll;
+  text-align: left;
+}

+ 26 - 0
app/controllers/blazer/base_controller.rb

@@ -18,5 +18,31 @@ module Blazer
     def ensure_database_url
       render text: "BLAZER_DATABASE_URL required" if !ENV["BLAZER_DATABASE_URL"] && !Rails.env.development?
     end
+
+    def process_vars(statement)
+      (@bind_vars ||= []).concat(extract_vars(statement)).uniq!
+      @success = @bind_vars.all? { |v| params[v] }
+
+      if @success
+        @bind_vars.each do |var|
+          value = params[var].presence
+          value = value.to_i if value.to_i.to_s == value
+          if var.end_with?("_at")
+            value = Blazer.time_zone.parse(value) rescue nil
+          end
+          statement.gsub!("{#{var}}", ActiveRecord::Base.connection.quote(value))
+        end
+      end
+    end
+
+    def extract_vars(statement)
+      statement.scan(/\{.*?\}/).map { |v| v[1...-1] }.uniq
+    end
+    helper_method :extract_vars
+
+    def variable_params
+      params.except(:controller, :action, :id, :host, :query, :table_names, :authenticity_token, :utf8, :_method, :commit, :statement)
+    end
+    helper_method :variable_params
   end
 end

+ 1 - 1
app/controllers/blazer/checks_controller.rb

@@ -1,6 +1,6 @@
 module Blazer
   class ChecksController < BaseController
-    before_action :set_check, only: [:show, :edit, :update, :destroy, :run]
+    before_action :set_check, only: [:edit, :update, :destroy, :run]
 
     def index
       @checks = Blazer::Check.joins(:blazer_query).includes(:blazer_query).order("state, blazer_queries.name, blazer_checks.id").to_a

+ 26 - 0
app/controllers/blazer/dashboard_queries_controller.rb

@@ -0,0 +1,26 @@
+module Blazer
+  class DashboardQueriesController < BaseController
+    def create
+      @dashboard_query = Blazer::DashboardQuery.new(dashboard_query_params)
+      @dashboard_query.position = @dashboard_query.blazer_dashboard.blazer_dashboard_queries.maximum(:position).to_i + 1
+
+      if @dashboard_query.save
+        redirect_to dashboard_path(@dashboard_query.blazer_dashboard_id)
+      else
+        raise "boom"
+      end
+    end
+
+    def update
+    end
+
+    def destroy
+    end
+
+    protected
+
+    def dashboard_query_params
+      params.require(:dashboard_query).permit(:blazer_dashboard_id, :blazer_query_id)
+    end
+  end
+end

+ 91 - 0
app/controllers/blazer/dashboards_controller.rb

@@ -0,0 +1,91 @@
+module Blazer
+  class DashboardsController < BaseController
+    before_action :set_dashboard, only: [:show, :edit, :update, :destroy]
+
+    def index
+      @dashboards = Blazer::Dashboard.order(:name)
+    end
+
+    def new
+      @dashboard = Blazer::Dashboard.new
+    end
+
+    def create
+      @dashboard = Blazer::Dashboard.new
+
+      if update_dashboard(@dashboard)
+        redirect_to dashboard_path(@dashboard)
+      else
+        render :new
+      end
+    end
+
+    def show
+      @queries = @dashboard.blazer_dashboard_queries.order(:position).preload(:blazer_query).map(&:blazer_query)
+      @queries.each do |query|
+        process_vars(query.statement)
+      end
+      @bind_vars ||= []
+
+      @smart_vars = {}
+      @sql_errors = []
+      @bind_vars.each do |var|
+        query = Blazer.smart_variables[var]
+        if query
+          rows, error = Blazer.run_statement(query)
+          @smart_vars[var] = rows.map { |v| v.values.reverse }
+          @sql_errors << error if error
+        end
+      end
+    end
+
+    def edit
+    end
+
+    def update
+      if update_dashboard(@dashboard)
+        redirect_to dashboard_path(@dashboard)
+      else
+        render :edit
+      end
+    end
+
+    def destroy
+      @dashboard.destroy
+      redirect_to dashboards_path
+    end
+
+    protected
+
+    def dashboard_params
+      params.require(:dashboard).permit(:name)
+    end
+
+    def set_dashboard
+      @dashboard = Blazer::Dashboard.find(params[:id])
+    end
+
+    def update_dashboard(dashboard)
+      dashboard.assign_attributes(dashboard_params)
+      Blazer::Dashboard.transaction do
+        if params[:blazer_query_ids].is_a?(Array)
+          query_ids = params[:blazer_query_ids].map(&:to_i)
+          @queries = Blazer::Query.find(query_ids).sort_by { |q| query_ids.index(q.id) }
+        end
+        if dashboard.save
+          if @queries
+            @queries.each_with_index do |query, i|
+              dashboard_query = dashboard.blazer_dashboard_queries.where(blazer_query_id: query.id).first_or_initialize
+              dashboard_query.position = i
+              dashboard_query.save!
+            end
+            if dashboard.persisted?
+              dashboard.blazer_dashboard_queries.where.not(blazer_query_id: query_ids).destroy_all
+            end
+          end
+          true
+        end
+      end
+    end
+  end
+end

+ 1 - 26
app/controllers/blazer/queries_controller.rb

@@ -46,6 +46,7 @@ module Blazer
     def run
       @statement = params[:statement]
       process_vars(@statement)
+      @only_chart = params[:only_chart]
 
       if @success
         @query = Query.find_by(id: params[:query_id]) if params[:query_id]
@@ -141,31 +142,5 @@ module Blazer
         end
       end
     end
-
-    def extract_vars(statement)
-      statement.scan(/\{.*?\}/).map { |v| v[1...-1] }.uniq
-    end
-    helper_method :extract_vars
-
-    def process_vars(statement)
-      @bind_vars = extract_vars(statement)
-      @success = @bind_vars.all? { |v| params[v] }
-
-      if @success
-        @bind_vars.each do |var|
-          value = params[var].presence
-          value = value.to_i if value.to_i.to_s == value
-          if var.end_with?("_at")
-            value = Blazer.time_zone.parse(value) rescue nil
-          end
-          statement.gsub!("{#{var}}", ActiveRecord::Base.connection.quote(value))
-        end
-      end
-    end
-
-    def variable_params
-      params.except(:controller, :action, :id, :host, :query, :table_names, :authenticity_token, :utf8, :_method, :commit, :statement)
-    end
-    helper_method :variable_params
   end
 end

+ 12 - 0
app/models/blazer/dashboard.rb

@@ -0,0 +1,12 @@
+module Blazer
+  class Dashboard < ActiveRecord::Base
+    has_many :blazer_dashboard_queries, class_name: "Blazer::DashboardQuery", foreign_key: "blazer_dashboard_id", dependent: :destroy
+    has_many :blazer_queries, class_name: "Blazer::Query", through: :blazer_dashboard_queries
+
+    validates :name, presence: true
+
+    def to_param
+      [id, name.gsub("'", "").parameterize].join("-")
+    end
+  end
+end

+ 9 - 0
app/models/blazer/dashboard_query.rb

@@ -0,0 +1,9 @@
+module Blazer
+  class DashboardQuery < ActiveRecord::Base
+    belongs_to :blazer_dashboard, class_name: "Blazer::Dashboard"
+    belongs_to :blazer_query, class_name: "Blazer::Query"
+
+    validates :blazer_dashboard_id, presence: true
+    validates :blazer_query_id, presence: true
+  end
+end

+ 4 - 0
app/models/blazer/query.rb

@@ -9,5 +9,9 @@ module Blazer
     def to_param
       [id, name.gsub("'", "").parameterize].join("-")
     end
+
+    def friendly_name
+      name.gsub(/\[.+\]/, "").strip
+    end
   end
 end

+ 86 - 0
app/views/blazer/dashboards/_form.html.erb

@@ -0,0 +1,86 @@
+<% if @dashboard.errors.any? %>
+  <div class="alert alert-danger"><%= @dashboard.errors.full_messages.first %></div>
+<% end %>
+
+<style>
+.glyphicon-remove {
+  cursor: pointer;
+  color: #d9534f;
+  display: none;
+}
+
+li:hover .glyphicon-remove {
+  display: inline;
+}
+</style>
+
+<%= form_for @dashboard do |f| %>
+  <div class="form-group">
+    <%= f.label :name %>
+    <%= f.text_field :name, class: "form-control" %>
+  </div>
+  <div class="form-group <%= "hide" if (@queries || @dashboard.blazer_queries).empty? %>">
+    <%= f.label :charts %>
+    <ul class="list-group">
+      <% (@queries || @dashboard.blazer_dashboard_queries.order(:position).map(&:blazer_query)).each do |query| %>
+        <li class="list-group-item">
+          <span class="glyphicon glyphicon-remove" aria-hidden="true" style="float: right; margin-top: 3px;"></span>
+          <%= query.name %>
+          <%= hidden_field_tag "blazer_query_ids[]", query.id %>
+        </li>
+      <% end %>
+    </ul>
+  </div>
+  <div class="form-group">
+    <%= f.label :blazer_query_id, "Add Chart" %>
+    <div class="hide">
+      <%= select_tag :blazer_query_id, options_for_select(Blazer::Query.order(:name).map { |q| [q.name, q.id] }, {include_blank: true}) %>
+    </div>
+    <script>
+      $("#blazer_query_id").selectize({allowEmptyOption: true}).parents(".hide").removeClass("hide");
+      $("#blazer_query_id").change( function () {
+        var $option = $(this).find("option:selected");
+        if ($option.val() !== "") {
+          // console.log($option.val());
+          // console.log($option.text());
+          var $li = $("<li></li>");
+          $li.addClass("list-group-item");
+          $li.text($option.text());
+          $li.prepend('<span class="glyphicon glyphicon-remove" aria-hidden="true" style="float: right; margin-top: 3px;"></span><input type="hidden" name="blazer_query_ids[]" id="blazer_query_ids_" value="' + $option.val() + '">');
+          $(".list-group").append($li);
+          $(this).val("");
+          $(".form-group").removeClass("hide");
+        }
+      });
+    </script>
+  </div>
+  <p>
+    <% if @dashboard.persisted? %>
+      <%= link_to "Delete", dashboard_path(@dashboard), method: :delete, "data-confirm" => "Are you sure?", class: "btn btn-danger" %>
+    <% end %>
+    <%= f.submit "Save", class: "btn btn-success" %>
+  </p>
+<% end %>
+
+<script>
+  $(".list-group").on("click", ".glyphicon-remove", function () {
+    $(this).parents("li:first").remove();
+  });
+  Sortable.create($(".list-group").get(0));
+
+  // $("form").submit( function () {
+  //   var query_ids = $("li").map( function () {
+  //     return $(this).attr("data-query-id");
+  //   });
+  //   console.log(query_ids.join(","));
+  //   return false;
+  // });
+
+  // var editableList = Sortable.create($(".list-group").get(0), {
+  //   filter: '.js-remove',
+  //   onFilter: function (evt) {
+  //     var el = editableList.closest(evt.item); // get dragged item
+  //     el && el.parentNode.removeChild(el);
+  //   }
+  // });
+</script>

+ 1 - 0
app/views/blazer/dashboards/edit.html.erb

@@ -0,0 +1 @@
+<%= render partial: "form" %>

+ 21 - 0
app/views/blazer/dashboards/index.html.erb

@@ -0,0 +1,21 @@
+<% title "Dashboards" %>
+
+<p style="float: right;"><%= link_to "New Dashboard", new_dashboard_path, class: "btn btn-info" %></p>
+<p>
+  <%= link_to "Home", root_path, class: "btn btn-primary", style: "margin-right: 10px;" %>
+</p>
+
+<table class="table">
+  <thead>
+    <tr>
+      <th>Dashboard</th>
+    </tr>
+  </thead>
+  <tbody>
+    <% @dashboards.each do |dashboard| %>
+      <tr>
+        <td><%= link_to dashboard.name, dashboard %></td>
+      </tr>
+    <% end %>
+  </tbody>
+</table>

+ 1 - 0
app/views/blazer/dashboards/new.html.erb

@@ -0,0 +1 @@
+<%= render partial: "form" %>

+ 148 - 0
app/views/blazer/dashboards/show.html.erb

@@ -0,0 +1,148 @@
+<% title @dashboard.name %>
+
+<script>
+  function submitIfCompleted($form) {
+    var completed = true;
+    $form.find("input[name], select").each( function () {
+      if ($(this).val() == "") {
+        completed = false;
+      }
+    });
+    if (completed) {
+      $form.submit();
+    }
+  }
+</script>
+
+<div style="position: fixed; top: 0; left: 0; right: 0; background-color: whitesmoke; height: 60px; z-index: 1001;">
+  <div class="container">
+    <div class="row" style="padding-top: 13px;">
+      <div class="col-sm-9">
+        <%= link_to "Back", dashboards_path, class: "btn btn-primary", style: "vertical-align: top; margin-right: 5px;" %>
+        <h3 style="margin: 0; line-height: 34px; display: inline-block;">
+          <%= @dashboard.name %>
+        </h3>
+      </div>
+      <div class="col-sm-3 text-right">
+        <%= link_to "Edit", edit_dashboard_path(@dashboard), class: "btn btn-info" %>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div style="margin-bottom: 60px;"></div>
+
+<% if @bind_vars.any? %>
+  <form id="bind" method="get" action="<%= url_for(params) %>" class="form-inline" style="margin-bottom: 10px;">
+    <% date_vars = ["start_time", "end_time"] %>
+    <% if (date_vars - @bind_vars).empty? %>
+      <% @bind_vars = @bind_vars - date_vars %>
+    <% else %>
+      <% date_vars = nil %>
+    <% end %>
+
+    <% @bind_vars.each_with_index do |var, i| %>
+      <%= label_tag var, var %>
+      <% if (data = @smart_vars[var]) %>
+        <%= select_tag var, options_for_select([[nil, nil]] + data, selected: params[var]), style: "margin-right: 20px; width: 200px; display: none;" %>
+        <script>
+          $("#<%= var %>").selectize({
+            create: true
+          });
+        </script>
+      <% else %>
+        <%= text_field_tag var, params[var], style: "width: 120px; margin-right: 20px;", autofocus: i == 0 && !var.end_with?("_at") && !params[var], class: "form-control" %>
+        <% if var.end_with?("_at") %>
+          <script>
+            $("#<%= var %>").datepicker({format: "yyyy-mm-dd", autoclose: true, todayBtn: "linked"})
+          </script>
+        <% end %>
+      <% end %>
+    <% end %>
+
+    <% if date_vars %>
+      <% date_vars.each do |var| %>
+        <%= hidden_field_tag var, params[var] %>
+      <% end %>
+
+      <%= label_tag nil, date_vars.join(" & ") %>
+      <div class="selectize-control single" style="width: 300px;">
+        <div id="reportrange" class="selectize-input" style="display: inline-block;">
+          <span>Select a time range</span>
+        </div>
+      </div>
+
+      <script>
+        var timeZone = "<%= Blazer.time_zone.tzinfo.name %>";
+        var format = "YYYY-MM-DD";
+        var now = moment.tz(timeZone);
+
+        function dateStr(daysAgo) {
+          return now.clone().subtract(daysAgo || 0, "days").format(format);
+        }
+
+        function toDate(time) {
+          return moment.tz(time.format(format), timeZone);
+        }
+
+        function setTimeInputs(start, end) {
+          $("#start_time").val(toDate(start).utc().format());
+          $("#end_time").val(toDate(end).endOf("day").utc().format());
+        }
+
+        $('#reportrange').daterangepicker(
+          {
+            ranges: {
+             "Today": [dateStr(), dateStr()],
+             "Last 7 Days": [dateStr(6), dateStr()],
+             "Last 30 Days": [dateStr(29), dateStr()]
+            },
+            format: format,
+            startDate: dateStr(29),
+            endDate: dateStr(),
+            opens: "left"
+          },
+          function(start, end) {
+            setTimeInputs(start, end);
+            submitIfCompleted($("#start_time").closest("form"));
+          }
+        ).on('apply.daterangepicker', function(ev, picker) {
+          setTimeInputs(picker.startDate, picker.endDate);
+          $('#reportrange span').html(toDate(picker.startDate).format('MMMM D, YYYY') + ' - ' + toDate(picker.endDate).format('MMMM D, YYYY'));
+        })
+
+        if ($("#start_time").val().length > 0) {
+          var picker = $("#reportrange").data('daterangepicker');
+          picker.setStartDate(moment.tz($("#start_time").val(), timeZone));
+          picker.setEndDate(moment.tz($("#end_time").val(), timeZone));
+          $("#reportrange").trigger('apply.daterangepicker', picker)
+        } else {
+          var picker = $("#reportrange").data('daterangepicker');
+          $("#reportrange").trigger('apply.daterangepicker', picker);
+          submitIfCompleted($("#start_time").closest("form"));
+        }
+      </script>
+    <% end %>
+  </form>
+<% end %>
+
+<% @queries.each_with_index do |query, i| %>
+  <div style="padding-top: 10px;">
+    <h4 style="text-align: center;"><%= link_to query.friendly_name, query_path(query, variable_params), target: "_blank", style: "color: inherit;" %></h4>
+    <div id="chart-<%= i %>" class="chart">
+      <p class="text-muted">Loading...</p>
+    </div>
+  </div>
+  <script>
+    $.post("<%= run_queries_path %>", <%= json_escape({statement: query.statement, query_id: query.id, only_chart: true}.to_json).html_safe %>, function (data) {
+      $("#chart-<%= i %>").html(data);
+      $("#chart-<%= i %> table").stupidtable();
+    });
+  </script>
+<% end %>
+
+<script>
+  $(".form-inline input, .form-inline select").change( function () {
+    submitIfCompleted($(this).closest("form"));
+  });
+</script>

+ 1 - 0
app/views/blazer/queries/index.html.erb

@@ -2,6 +2,7 @@
   <div id="header" style="margin-bottom: 20px;">
     <div class="pull-right">
       <%= link_to "New Query", new_query_path, class: "btn btn-info" %>
+      <%= link_to "Dashboards", dashboards_path, class: "btn btn-primary" %>
       <%= link_to "Checks", checks_path, class: "btn btn-primary" %>
     </div>
     <input type="text" placeholder="Start typing a query or person" style="width: 300px; display: inline-block;" autofocus=true class="search form-control" />

+ 57 - 42
app/views/blazer/queries/run.html.erb

@@ -1,61 +1,76 @@
 <% if @error %>
   <div class="alert alert-danger"><%= @error %></div>
 <% elsif !@success %>
-  <div class="alert alert-info">Can’t preview queries with variables...yet!</div>
+  <% if @only_chart %>
+    <p class="text-muted">Select variables</p>
+  <% else %>
+    <div class="alert alert-info">Can’t preview queries with variables...yet!</div>
+  <% end %>
 <% else %>
-  <p class="text-muted"><%= pluralize(@rows.size, "row") %></p>
+  <% unless @only_chart %>
+    <p class="text-muted"><%= pluralize(@rows.size, "row") %></p>
+  <% end %>
   <% if @rows.any? %>
     <% values = @rows.first.values %>
+    <% chart_id = SecureRandom.hex %>
     <% if values.size >= 2 && (values.first.is_a?(Time) || values.first.is_a?(Date)) && values[1..-1].all?{|v| v.is_a?(Numeric) } %>
       <% time_k = @columns.keys.first %>
-      <%= line_chart @columns.keys[1..-1].map{|k| {name: k, data: @rows.map{|r| [r[time_k].in_time_zone(Blazer.time_zone), r[k]] }} } %>
+      <%= line_chart @columns.keys[1..-1].map{|k| {name: k, data: @rows.map{|r| [r[time_k], r[k]] }} }, id: chart_id, min: nil %>
     <% elsif values.size == 3 && (values.first.is_a?(Time) || values.first.is_a?(Date)) && values[1].is_a?(String) && values[2].is_a?(Numeric) %>
       <% keys = @columns.keys %>
-      <%= line_chart @rows.group_by { |v| v[keys[1]] }.map { |name, v| {name: name, data: v.map { |v2| [v2[keys[0]].in_time_zone(Blazer.time_zone), v2[keys[2]]] } } } %>
+      <%= line_chart @rows.group_by { |v| v[keys[1]] }.map { |name, v| {name: name, data: v.map { |v2| [v2[keys[0]], v2[keys[2]]] } } }, id: chart_id, min: nil %>
     <% elsif values.size == 2 && values.first.is_a?(String) && values.last.is_a?(Numeric) %>
-      <%= pie_chart @rows.map(&:values), library: {sliceVisibilityThreshold: 1 / 40.0} %>
+      <%= pie_chart @rows.map(&:values), library: {sliceVisibilityThreshold: 1 / 40.0}, id: chart_id %>
+    <% elsif @only_chart %>
+      <% @no_chart = true %>
     <% end %>
 
-    <% header_width = 100 / @rows.first.keys.size.to_f %>
-    <table class="table">
-      <thead>
-        <tr>
-          <% @columns.each do |key, type| %>
-            <th style="width: <%= header_width %>%;" data-sort="<%= type %>">
-              <div style="min-width: <%= @min_width_types.include?(key) ? 180 : 60 %>px;">
-                <%= key %>
-              </div>
-            </th>
-          <% end %>
-        </tr>
-      </thead>
-      <tbody>
-        <% @rows.each do |row| %>
-          <tr>
-            <% row.each do |k, v| %>
-              <td>
-                <% if v.is_a?(Time) %>
-                  <% v = v.in_time_zone(Blazer.time_zone) %>
-                <% end %>
+    <% unless @only_chart && !@no_chart %>
+      <% header_width = 100 / @rows.first.keys.size.to_f %>
+      <div class="results-container">
+        <table class="table results-table" style="margin-bottom: 0;">
+          <thead>
+            <tr>
+              <% @columns.each do |key, type| %>
+                <th style="width: <%= header_width %>%;" data-sort="<%= type %>">
+                  <div style="min-width: <%= @min_width_types.include?(key) ? 180 : 60 %>px;">
+                    <%= key %>
+                  </div>
+                </th>
+              <% end %>
+            </tr>
+          </thead>
+          <tbody>
+            <% @rows.each do |row| %>
+              <tr>
+                <% row.each do |k, v| %>
+                  <td>
+                    <% if v.is_a?(Time) %>
+                      <% v = v.in_time_zone(Blazer.time_zone) %>
+                    <% end %>
 
-                <% unless v.nil? %>
-                  <% if v == "" %>
-                    <div class="text-muted">empty string</div>
-                  <% elsif @linked_columns[k] %>
-                    <%= link_to format_value(k, v), @linked_columns[k].gsub("{value}", u(v.to_s)), target: "_blank" %>
-                  <% else %>
-                    <%= format_value(k, v) %>
-                  <% end %>
+                    <% unless v.nil? %>
+                      <% if v == "" %>
+                        <div class="text-muted">empty string</div>
+                      <% elsif @linked_columns[k] %>
+                        <%= link_to format_value(k, v), @linked_columns[k].gsub("{value}", u(v.to_s)), target: "_blank" %>
+                      <% else %>
+                        <%= format_value(k, v) %>
+                      <% end %>
 
-                  <% if v2 = (@boom[k] || {})[v] %>
-                    <div class="text-muted"><%= v2 %></div>
-                  <% end %>
+                      <% if v2 = (@boom[k] || {})[v] %>
+                        <div class="text-muted"><%= v2 %></div>
+                      <% end %>
+                    <% end %>
+                  </td>
                 <% end %>
-              </td>
+              </tr>
             <% end %>
-          </tr>
-        <% end %>
-      </tbody>
-    </table>
+          </tbody>
+        </table>
+      </div>
+    <% end %>
+  <% elsif @only_chart %>
+    <p class="text-muted">No rows</p>
   <% end %>
 <% end %>

+ 1 - 3
app/views/blazer/queries/show.html.erb

@@ -63,7 +63,7 @@
     <% @bind_vars.each_with_index do |var, i| %>
       <%= label_tag var, var %>
       <% if (data = @smart_vars[var]) %>
-        <%= select_tag var, options_for_select([[nil, nil]] + data, selected: params[var]), style: "margin-right: 20px; width: 200px;" %>
+        <%= select_tag var, options_for_select([[nil, nil]] + data, selected: params[var]), style: "margin-right: 20px; width: 200px; display: none;" %>
         <script>
           $("#<%= var %>").selectize({
             create: true
@@ -142,8 +142,6 @@
         }
       </script>
     <% end %>
-
-    <input type="submit" class="btn btn-success" value="Run" style="vertical-align: top;" />
   </form>
 <% end %>
 

+ 2 - 0
config/routes.rb

@@ -5,5 +5,7 @@ Blazer::Engine.routes.draw do
   resources :checks, except: [:show] do
     get :run, on: :member
   end
+  resources :dashboards
+  resources :dashboard_queries, only: [:create, :destroy]
   root to: "queries#index"
 end

+ 12 - 0
lib/generators/blazer/templates/install.rb

@@ -21,5 +21,17 @@ class <%= migration_class_name %> < ActiveRecord::Migration
       t.text :emails
       t.timestamps
     end
+
+    create_table :blazer_dashboards do |t|
+      t.text :name
+      t.timestamps
+    end
+
+    create_table :blazer_dashboard_queries do |t|
+      t.references :blazer_dashboard
+      t.references :blazer_query
+      t.integer :position
+      t.timestamps
+    end
   end
 end