diff --git a/package.json b/package.json index 5422d2386050f..95980d9b1c0fe 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "commander": "2.8.1", "css-loader": "0.17.0", "d3": "3.5.6", + "dragula": "3.7.0", "elasticsearch": "10.1.2", "elasticsearch-browser": "10.1.2", "expiry-js": "0.1.7", diff --git a/src/plugins/dev_mode/public/vis_debug_spy_panel.js b/src/plugins/dev_mode/public/vis_debug_spy_panel.js index d691a806de038..05ce703ab2de6 100644 --- a/src/plugins/dev_mode/public/vis_debug_spy_panel.js +++ b/src/plugins/dev_mode/public/vis_debug_spy_panel.js @@ -9,7 +9,7 @@ function VisDetailsSpyProvider(Notifier, $filter, $rootScope, config) { template: visDebugSpyPanelTemplate, order: 5, link: function ($scope, $el) { - $scope.$watch('vis.getState() | json', function (json) { + $scope.$watch('vis.getEnabledState() | json', function (json) { $scope.visStateJson = json; }); } diff --git a/src/plugins/kibana/public/discover/controllers/discover.js b/src/plugins/kibana/public/discover/controllers/discover.js index 1743e75c8bcf8..a8c0a01ef37bf 100644 --- a/src/plugins/kibana/public/discover/controllers/discover.js +++ b/src/plugins/kibana/public/discover/controllers/discover.js @@ -496,7 +496,7 @@ app.controller('discover', function ($scope, config, courier, $route, $window, N // we have a vis, just modify the aggs if ($scope.vis) { - const visState = $scope.vis.getState(); + const visState = $scope.vis.getEnabledState(); visState.aggs = visStateAggs; $scope.vis.setState(visState); diff --git a/src/plugins/kibana/public/settings/styles/main.less b/src/plugins/kibana/public/settings/styles/main.less index 451dd5e9a4bc2..beddd122eed35 100644 --- a/src/plugins/kibana/public/settings/styles/main.less +++ b/src/plugins/kibana/public/settings/styles/main.less @@ -201,3 +201,5 @@ kbn-settings-indices { .kbn-settings-indices-create { .time-and-pattern > div {} } + +@import "~ui/dragula/gu-dragula.less"; diff --git a/src/plugins/kibana/public/visualize/editor/__tests__/draggable.js b/src/plugins/kibana/public/visualize/editor/__tests__/draggable.js new file mode 100644 index 0000000000000..d8673d30a42e4 --- /dev/null +++ b/src/plugins/kibana/public/visualize/editor/__tests__/draggable.js @@ -0,0 +1,121 @@ +import angular from 'angular'; +import sinon from 'sinon'; +import expect from 'expect.js'; +import ngMock from 'ng_mock'; + +let init; +let $rootScope; +let $compile; + +describe('draggable_* directives', function () { + + beforeEach(ngMock.module('kibana')); + beforeEach(ngMock.inject(function ($injector) { + $rootScope = $injector.get('$rootScope'); + $compile = $injector.get('$compile'); + init = function init(markup = '') { + const $parentScope = $rootScope.$new(); + $parentScope.items = [ + { name: 'item_1' }, + { name: 'item_2' }, + { name: 'item_3' } + ]; + + // create the markup + const $elem = angular.element(`
`); + $elem.html(markup); + + // compile the directive + $compile($elem)($parentScope); + $parentScope.$apply(); + + const $scope = $elem.scope(); + + return { $parentScope, $scope, $elem }; + }; + })); + + describe('draggable_container directive', function () { + it('should expose the drake', function () { + const { $scope } = init(); + expect($scope.drake).to.be.an(Object); + }); + + it('should expose the controller', function () { + const { $scope } = init(); + expect($scope.draggableContainerCtrl).to.be.an(Object); + }); + + it('should pull item list from directive attribute', function () { + const { $scope, $parentScope } = init(); + expect($scope.draggableContainerCtrl.getList()).to.eql($parentScope.items); + }); + + it('should not be able to move extraneous DOM elements', function () { + const bare = angular.element(`
`); + const { $scope } = init(); + expect($scope.drake.canMove(bare[0])).to.eql(false); + }); + + it('should not be able to move non-[draggable-item] elements', function () { + const bare = angular.element(`
`); + const { $scope, $elem } = init(); + $elem.append(bare); + expect($scope.drake.canMove(bare[0])).to.eql(false); + }); + + it('shouldn\'t be able to move extraneous [draggable-item] elements', function () { + const anotherParent = angular.element(`
`); + const item = angular.element(`
`); + const scope = $rootScope.$new(); + anotherParent.append(item); + $compile(anotherParent)(scope); + $compile(item)(scope); + scope.$apply(); + const { $scope } = init(); + expect($scope.drake.canMove(item[0])).to.eql(false); + }); + + it('shouldn\'t be able to move [draggable-item] if it has a handle', function () { + const { $scope, $elem } = init(` +
+
+
+ `); + const item = $elem.find(`[draggable-item]`); + expect($scope.drake.canMove(item[0])).to.eql(false); + }); + + it('should be able to move [draggable-item] by its handle', function () { + const { $scope, $elem } = init(` +
+
+
+ `); + const handle = $elem.find(`[draggable-handle]`); + expect($scope.drake.canMove(handle[0])).to.eql(true); + }); + }); + + describe('draggable_item', function () { + it('should be required to be a child to [draggable-container]', function () { + const item = angular.element(`
`); + const scope = $rootScope.$new(); + expect(() => { + $compile(item)(scope); + scope.$apply(); + }).to.throwException(/controller(.+)draggableContainer(.+)required/i); + }); + }); + + describe('draggable_handle', function () { + it('should be required to be a child to [draggable-item]', function () { + const handle = angular.element(`
`); + const scope = $rootScope.$new(); + expect(() => { + $compile(handle)(scope); + scope.$apply(); + }).to.throwException(/controller(.+)draggableItem(.+)required/i); + }); + }); +}); diff --git a/src/plugins/kibana/public/visualize/editor/agg.html b/src/plugins/kibana/public/visualize/editor/agg.html index ee1224da61bee..965c54cc8fcb0 100644 --- a/src/plugins/kibana/public/visualize/editor/agg.html +++ b/src/plugins/kibana/public/visualize/editor/agg.html @@ -27,30 +27,40 @@
- + + + + - + @@ -79,5 +89,6 @@ diff --git a/src/plugins/kibana/public/visualize/editor/agg.js b/src/plugins/kibana/public/visualize/editor/agg.js index d7c2b42daca21..a64311e8848f8 100644 --- a/src/plugins/kibana/public/visualize/editor/agg.js +++ b/src/plugins/kibana/public/visualize/editor/agg.js @@ -47,13 +47,16 @@ uiModules return label ? label : ''; }; - function move(below, agg) { - _.move($scope.vis.aggs, agg, below, function (otherAgg) { - return otherAgg.schema.group === agg.schema.group; - }); - } - $scope.moveUp = _.partial(move, false); - $scope.moveDown = _.partial(move, true); + $scope.$on('drag-start', e => { + $scope.editorWasOpen = $scope.editorOpen; + $scope.editorOpen = false; + $scope.$emit('agg-drag-start', $scope.agg); + }); + + $scope.$on('drag-end', e => { + $scope.editorOpen = $scope.editorWasOpen; + $scope.$emit('agg-drag-end', $scope.agg); + }); $scope.remove = function (agg) { const aggs = $scope.vis.aggs; diff --git a/src/plugins/kibana/public/visualize/editor/agg_group.html b/src/plugins/kibana/public/visualize/editor/agg_group.html index 66b9d886a4221..98b12162d7c59 100644 --- a/src/plugins/kibana/public/visualize/editor/agg_group.html +++ b/src/plugins/kibana/public/visualize/editor/agg_group.html @@ -3,9 +3,9 @@ {{ groupName }}
-
+
-
+
diff --git a/src/plugins/kibana/public/visualize/editor/agg_group.js b/src/plugins/kibana/public/visualize/editor/agg_group.js index 1718d80c1b580..6bc2341d7df1d 100644 --- a/src/plugins/kibana/public/visualize/editor/agg_group.js +++ b/src/plugins/kibana/public/visualize/editor/agg_group.js @@ -41,6 +41,9 @@ uiModules if (count < schema.max) return true; }); }); + + $scope.$on('agg-drag-start', e => $scope.dragging = true); + $scope.$on('agg-drag-end', e => $scope.dragging = false); } }; diff --git a/src/plugins/kibana/public/visualize/editor/draggable_container.js b/src/plugins/kibana/public/visualize/editor/draggable_container.js new file mode 100644 index 0000000000000..c4d7ba76ea2b8 --- /dev/null +++ b/src/plugins/kibana/public/visualize/editor/draggable_container.js @@ -0,0 +1,88 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import dragula from 'dragula'; +import uiModules from 'ui/modules'; + +uiModules +.get('app/visualize') +.directive('draggableContainer', function () { + + return { + restrict: 'A', + scope: true, + controllerAs: 'draggableContainerCtrl', + controller($scope, $attrs, $parse) { + this.getList = () => $parse($attrs.draggableContainer)($scope); + }, + link($scope, $el, attr) { + const drake = dragula({ + containers: $el.toArray(), + moves(el, source, handle) { + const itemScope = $(el).scope(); + if (!('draggableItemCtrl' in itemScope)) { + return; // only [draggable-item] is draggable + } + return itemScope.draggableItemCtrl.moves(handle); + } + }); + + const drakeEvents = [ + 'cancel', + 'cloned', + 'drag', + 'dragend', + 'drop', + 'out', + 'over', + 'remove', + 'shadow' + ]; + const prettifiedDrakeEvents = { + drag: 'start', + dragend: 'end' + }; + + drakeEvents.forEach(type => { + drake.on(type, (el, ...args) => forwardEvent(type, el, ...args)); + }); + drake.on('drag', markDragging(true)); + drake.on('dragend', markDragging(false)); + drake.on('drop', drop); + $scope.$on('$destroy', drake.destroy); + $scope.drake = drake; + + function markDragging(isDragging) { + return el => { + const scope = $(el).scope(); + scope.isDragging = isDragging; + scope.$apply(); + }; + } + + function forwardEvent(type, el, ...args) { + const name = `drag-${prettifiedDrakeEvents[type] || type}`; + const scope = $(el).scope(); + scope.$broadcast(name, el, ...args); + } + + function drop(el, target, source, sibling) { + const list = $scope.draggableContainerCtrl.getList(); + const itemScope = $(el).scope(); + const item = itemScope.draggableItemCtrl.getItem(); + const toIndex = getSiblingItemIndex(list, sibling); + _.move(list, item, toIndex); + } + + function getSiblingItemIndex(list, sibling) { + if (!sibling) { // means the item was dropped at the end of the list + return list.length - 1; + } + const siblingScope = $(sibling).scope(); + const siblingItem = siblingScope.draggableItemCtrl.getItem(); + const siblingIndex = list.indexOf(siblingItem); + return siblingIndex; + } + } + }; + +}); diff --git a/src/plugins/kibana/public/visualize/editor/draggable_handle.js b/src/plugins/kibana/public/visualize/editor/draggable_handle.js new file mode 100644 index 0000000000000..7e62160b8e91a --- /dev/null +++ b/src/plugins/kibana/public/visualize/editor/draggable_handle.js @@ -0,0 +1,14 @@ +import uiModules from 'ui/modules'; + +uiModules +.get('app/visualize') +.directive('draggableHandle', function () { + return { + restrict: 'A', + require: '^draggableItem', + link($scope, $el, attr, ctrl) { + ctrl.registerHandle($el); + $el.addClass('gu-handle'); + } + }; +}); diff --git a/src/plugins/kibana/public/visualize/editor/draggable_item.js b/src/plugins/kibana/public/visualize/editor/draggable_item.js new file mode 100644 index 0000000000000..e949caf5a89fd --- /dev/null +++ b/src/plugins/kibana/public/visualize/editor/draggable_item.js @@ -0,0 +1,29 @@ +import $ from 'jquery'; +import uiModules from 'ui/modules'; + +uiModules +.get('app/visualize') +.directive('draggableItem', function () { + return { + restrict: 'A', + require: '^draggableContainer', + scope: true, + controllerAs: 'draggableItemCtrl', + controller($scope, $attrs, $parse) { + const dragHandles = $(); + + this.getItem = () => $parse($attrs.draggableItem)($scope); + this.registerHandle = $el => { + dragHandles.push(...$el); + }; + this.moves = handle => { + const $handle = $(handle); + const $anywhereInParentChain = $handle.parents().addBack(); + const movable = dragHandles.is($anywhereInParentChain); + return movable; + }; + }, + link($scope, $el, attr) { + } + }; +}); diff --git a/src/plugins/kibana/public/visualize/editor/editor.js b/src/plugins/kibana/public/visualize/editor/editor.js index 2b9341f86a886..5543ecd6dd647 100644 --- a/src/plugins/kibana/public/visualize/editor/editor.js +++ b/src/plugins/kibana/public/visualize/editor/editor.js @@ -119,8 +119,8 @@ uiModules if (!angular.equals($state.vis, savedVisState)) { Promise.try(function () { - vis.setState($state.vis); editableVis.setState($state.vis); + vis.setState(editableVis.getEnabledState()); }) .catch(courier.redirectWhenMissing({ 'index-pattern-field': '/visualize' @@ -149,9 +149,9 @@ uiModules $scope.stageEditableVis = transferVisState(editableVis, vis, true); $scope.resetEditableVis = transferVisState(vis, editableVis); $scope.$watch(function () { - return editableVis.getState(); + return editableVis.getEnabledState(); }, function (newState) { - editableVis.dirty = !angular.equals(newState, vis.getState()); + editableVis.dirty = !angular.equals(newState, vis.getEnabledState()); $scope.responseValueAggs = null; try { @@ -289,14 +289,16 @@ uiModules } }; - function transferVisState(fromVis, toVis, fetch) { + function transferVisState(fromVis, toVis, stage) { return function () { - toVis.setState(fromVis.getState()); + const view = fromVis.getEnabledState(); + const full = fromVis.getState(); + toVis.setState(view); editableVis.dirty = false; - $state.vis = vis.getState(); + $state.vis = full; $state.save(); - if (fetch) $scope.fetch(); + if (stage) $scope.fetch(); }; } diff --git a/src/plugins/kibana/public/visualize/index.js b/src/plugins/kibana/public/visualize/index.js index 185abd4310e99..88f684f59ccb9 100644 --- a/src/plugins/kibana/public/visualize/index.js +++ b/src/plugins/kibana/public/visualize/index.js @@ -11,6 +11,9 @@ import 'plugins/kibana/visualize/editor/agg_params'; import 'plugins/kibana/visualize/editor/nesting_indicator'; import 'plugins/kibana/visualize/editor/sidebar'; import 'plugins/kibana/visualize/editor/vis_options'; +import 'plugins/kibana/visualize/editor/draggable_container'; +import 'plugins/kibana/visualize/editor/draggable_item'; +import 'plugins/kibana/visualize/editor/draggable_handle'; import 'plugins/kibana/visualize/saved_visualizations/_saved_vis'; import 'plugins/kibana/visualize/saved_visualizations/saved_visualizations'; import uiRoutes from 'ui/routes'; diff --git a/src/ui/public/dragula/gu-dragula.less b/src/ui/public/dragula/gu-dragula.less new file mode 100644 index 0000000000000..c51c873601806 --- /dev/null +++ b/src/ui/public/dragula/gu-dragula.less @@ -0,0 +1,13 @@ +.gu-handle { + cursor: move; + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; +} + +.gu-mirror, +.gu-mirror .gu-handle { + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; +} diff --git a/src/ui/public/styles/base.less b/src/ui/public/styles/base.less index e9d47e40671c8..bc5d2eb534cc4 100644 --- a/src/ui/public/styles/base.less +++ b/src/ui/public/styles/base.less @@ -607,3 +607,5 @@ fieldset { } } } + +@import (reference) "~dragula/dist/dragula.css"; diff --git a/src/ui/public/vis/__tests__/_vis.js b/src/ui/public/vis/__tests__/_vis.js index d750bc5226fbf..ad4aa0ad837e5 100644 --- a/src/ui/public/vis/__tests__/_vis.js +++ b/src/ui/public/vis/__tests__/_vis.js @@ -58,7 +58,7 @@ describe('Vis Class', function () { describe('getState()', function () { it('should get a state that represents the... er... state', function () { - let state = vis.getState(); + let state = vis.getEnabledState(); expect(state).to.have.property('type', 'pie'); expect(state).to.have.property('params'); diff --git a/src/ui/public/vis/agg_config.js b/src/ui/public/vis/agg_config.js index ab6c649bce58b..c1250db719b98 100644 --- a/src/ui/public/vis/agg_config.js +++ b/src/ui/public/vis/agg_config.js @@ -9,6 +9,7 @@ export default function AggConfigFactory(Private, fieldTypeFilter) { self.id = String(opts.id || AggConfig.nextId(vis.aggs)); self.vis = vis; self._opts = opts = (opts || {}); + self.enabled = typeof opts.enabled === 'boolean' ? opts.enabled : true; // setters self.type = opts.type; @@ -232,6 +233,7 @@ export default function AggConfigFactory(Private, fieldTypeFilter) { return { id: self.id, + enabled: self.enabled, type: self.type && self.type.name, schema: self.schema && self.schema.name, params: outParams diff --git a/src/ui/public/vis/vis.js b/src/ui/public/vis/vis.js index dbf29fe416429..6525b0c35b3fd 100644 --- a/src/ui/public/vis/vis.js +++ b/src/ui/public/vis/vis.js @@ -44,7 +44,7 @@ export default function VisFactory(Notifier, Private) { oldConfigs.forEach(function (oldConfig) { let agg = { schema: schema.name, - type: oldConfig.agg, + type: oldConfig.agg }; let aggType = aggTypes.byName[agg.type]; @@ -81,18 +81,27 @@ export default function VisFactory(Notifier, Private) { this.aggs = new AggConfigs(this, state.aggs); }; - Vis.prototype.getState = function () { + Vis.prototype.getStateInternal = function (includeDisabled) { return { title: this.title, type: this.type.name, params: this.params, - aggs: this.aggs.map(function (agg) { - return agg.toJSON(); - }).filter(Boolean), + aggs: this.aggs + .filter(agg => includeDisabled || agg.enabled) + .map(agg => agg.toJSON()) + .filter(Boolean), listeners: this.listeners }; }; + Vis.prototype.getEnabledState = function () { + return this.getStateInternal(false); + }; + + Vis.prototype.getState = function () { + return this.getStateInternal(true); + }; + Vis.prototype.createEditableVis = function () { return this._editableVis || (this._editableVis = this.clone()); }; diff --git a/src/ui/public/visualize/visualize.js b/src/ui/public/visualize/visualize.js index 86c9a5beae6be..45aed2c04c57e 100644 --- a/src/ui/public/visualize/visualize.js +++ b/src/ui/public/visualize/visualize.js @@ -29,8 +29,7 @@ uiModules }, template: visualizeTemplate, link: function ($scope, $el, attr) { - let chart; // set in "vis" watcher - let minVisChartHeight = 180; + const minVisChartHeight = 180; if (_.isUndefined($scope.showSpyPanel)) { $scope.showSpyPanel = true;