From 7aaa8f4a5e3d83a8cd84929f78a347b04083bfe1 Mon Sep 17 00:00:00 2001 From: Joan Sanchez <joan@verdnatura.es> Date: Wed, 16 Oct 2019 08:56:13 +0200 Subject: [PATCH] refactor drag & drop treeview --- front/core/components/treeview/child.html | 20 +++ .../treeview/{content.js => child.js} | 30 +++- front/core/components/treeview/childs.html | 33 +---- front/core/components/treeview/index.js | 129 ++++++++++++++++-- front/core/components/treeview/style.scss | 41 +++--- front/core/directives/draggable.js | 43 ------ front/core/directives/droppable.js | 93 +++++-------- front/core/directives/droppable.scss | 18 ++- front/core/directives/index.js | 1 - front/core/lib/event-emitter.js | 15 +- loopback/locale/es.json | 4 +- modules/claim/front/dms/index/index.html | 2 +- modules/claim/front/dms/index/index.js | 5 +- .../back/methods/department/moveChild.js | 42 ++++++ modules/worker/back/models/department.js | 1 + modules/worker/back/models/department.json | 9 ++ modules/worker/front/department/index.html | 5 +- modules/worker/front/department/index.js | 29 ++-- 18 files changed, 331 insertions(+), 189 deletions(-) create mode 100644 front/core/components/treeview/child.html rename front/core/components/treeview/{content.js => child.js} (51%) delete mode 100644 front/core/directives/draggable.js create mode 100644 modules/worker/back/methods/department/moveChild.js diff --git a/front/core/components/treeview/child.html b/front/core/components/treeview/child.html new file mode 100644 index 0000000000..1b928d5ea5 --- /dev/null +++ b/front/core/components/treeview/child.html @@ -0,0 +1,20 @@ +<div class="node clickable"> + <vn-icon + class="arrow" + ng-class="{invisible: !$ctrl.item.sons}" + icon="keyboard_arrow_down" + translate-attr="::{title: 'Toggle'}"> + </vn-icon> + <section class="content"></section> + <section class="buttons" ng-if="::!$ctrl.treeview.readOnly"> + <vn-icon-button translate-attr="::{title: 'Remove'}" + icon="delete" + ng-click="$ctrl.treeview.onRemove($ctrl.item)" + ng-if="$ctrl.item.parent"> + </vn-icon-button> + <vn-icon-button translate-attr="::{title: 'Create'}" + icon="add_circle" + ng-click="$ctrl.treeview.onCreate($ctrl.item)"> + </vn-icon-button> + </section> +</div> diff --git a/front/core/components/treeview/content.js b/front/core/components/treeview/child.js similarity index 51% rename from front/core/components/treeview/content.js rename to front/core/components/treeview/child.js index 506117d4f8..9e4edef35f 100644 --- a/front/core/components/treeview/content.js +++ b/front/core/components/treeview/child.js @@ -2,22 +2,33 @@ import ngModule from '../../module'; class Controller { constructor($element, $scope, $compile) { - this.$element = $element; this.$scope = $scope; this.$compile = $compile; + this.element = $element[0]; + + this.element.$ctrl = this; + this.element.droppable = true; + this.dropCount = 0; + this.element.classList.add('vn-droppable'); } $onInit() { + const transcludeElement = this.element.querySelector('.content'); + const content = angular.element(transcludeElement); + if (this.item.parent) { this.treeview.$transclude(($clone, $scope) => { this.$contentScope = $scope; $scope.item = this.item; - this.$element.append($clone); + content.append($clone); }); + + this.element.draggable = true; + this.element.classList.add('vn-draggable'); } else { let template = `<span translate>{{$ctrl.treeview.rootLabel}}</span>`; let $clone = this.$compile(template)(this.$scope); - this.$element.append($clone); + content.append($clone); } } @@ -25,10 +36,21 @@ class Controller { if (this.$contentScope) this.$contentScope.$destroy(); } + + dragEnter() { + this.dropCount++; + + if (element != this.dropping) { + this.undrop(); + if (element) element.classList.add('dropping'); + this.dropping = element; + } + } } Controller.$inject = ['$element', '$scope', '$compile']; -ngModule.component('vnTreeviewContent', { +ngModule.component('vnTreeviewChild', { + template: require('./child.html'), controller: Controller, bindings: { item: '<' diff --git a/front/core/components/treeview/childs.html b/front/core/components/treeview/childs.html index 2dd7e77ef4..de69ffb891 100644 --- a/front/core/components/treeview/childs.html +++ b/front/core/components/treeview/childs.html @@ -1,34 +1,11 @@ <ul ng-if="$ctrl.items"> - <li ng-repeat="item in $ctrl.items" > - <div - ng-class="{expanded: item.active}" - ng-click="$ctrl.onClick($event, item)" - class="node clickable"> - <vn-icon - class="arrow" - ng-class="{invisible: !item.sons}" - icon="keyboard_arrow_down" - translate-attr="::{title: 'Toggle'}"> - </vn-icon> - <vn-treeview-content - item="::item"> - </vn-treeview-content> - <section class="buttons" ng-if="::!$ctrl.treeview.readOnly"> - <vn-icon-button translate-attr="::{title: 'Remove'}" - icon="delete" - ng-click="$ctrl.treeview.onRemove(item)" - ng-if="item.parent"> - </vn-icon-button> - <vn-icon-button translate-attr="::{title: 'Create'}" - icon="add_circle" - ng-click="$ctrl.treeview.onCreate(item)"> - </vn-icon-button> - </section> - </div> + <li ng-repeat="item in $ctrl.items"> + <vn-treeview-child item="item" ng-class="{expanded: item.active}" + ng-click="$ctrl.onClick($event, item)"> + </vn-treeview-child> <vn-treeview-childs - items="item.childs" - parent="::item"> + items="item.childs"> </vn-treeview-childs> </li> </ul> \ No newline at end of file diff --git a/front/core/components/treeview/index.js b/front/core/components/treeview/index.js index d9da39215b..22d6739f76 100644 --- a/front/core/components/treeview/index.js +++ b/front/core/components/treeview/index.js @@ -1,18 +1,113 @@ import ngModule from '../../module'; import Component from '../../lib/component'; import './style.scss'; - import './childs'; -import './content'; +import './child'; /** * Treeview */ export default class Treeview extends Component { - constructor($element, $scope, $transclude) { + constructor($element, $scope, $transclude, $window) { super($element, $scope); this.$transclude = $transclude; + this.$window = $window; this.readOnly = true; + + this.element.addEventListener('dragstart', + event => this.dragStart(event)); + this.element.addEventListener('dragend', + event => this.dragEnd(event)); + + this.element.addEventListener('dragover', + event => this.dragOver(event)); + this.element.addEventListener('drop', + event => this.drop(event)); + this.element.addEventListener('dragenter', + event => this.dragEnter(event)); + this.element.addEventListener('dragleave', + event => this.dragLeave(event)); + + this.dropCount = 0; + } + + undrop() { + if (!this.dropping) return; + this.dropping.classList.remove('dropping'); + this.dropping = null; + } + + findDroppable(event) { + let element = event.target; + while (element != this.element && !element.droppable) + element = element.parentNode; + if (element == this.element) + return null; + return element; + } + + dragOver(event) { + let scrollY = this.$window.scrollY; + + if (event.clientY < this.draggingY) + scrollY -= 2; + else scrollY += 2; + + this.draggingY = event.clientY; + this.$window.scrollTo(0, scrollY); + + // Prevents page reload + event.preventDefault(); + } + + dragStart(event) { + event.target.classList.add('dragging'); + event.dataTransfer.setData('text', event.target.id); + + const element = this.findDroppable(event); + this.dragging = element; + } + + dragEnd(event) { + event.target.classList.remove('dragging'); + this.undrop(); + this.dropCount = 0; + this.dragging = null; + } + + dragEnter(event) { + let element = this.findDroppable(event); + if (element) this.dropCount++; + + if (element != this.dropping) { + this.undrop(); + if (element) element.classList.add('dropping'); + this.dropping = element; + } + } + + dragLeave(event) { + let element = this.findDroppable(event); + + if (element) { + this.dropCount--; + if (this.dropCount == 0) this.undrop(); + } + } + + drop(event) { + event.preventDefault(); + this.element.classList.remove('dropping'); + + const $dropped = this.dropping.$ctrl.item; + const $dragged = this.dragging.$ctrl.item; + + if (!$dropped.active && $dropped.sons) { + this.unfold($dropped).then(() => { + this.emit('drop', {$dropped, $dragged}); + }); + } else + this.emit('drop', {$dropped, $dragged}); } get data() { @@ -100,8 +195,14 @@ export default class Treeview extends Component { } onCreate(parent) { - if (this.createFunc) - this.createFunc({$parent: parent}); + if (this.createFunc) { + if (!parent.active && parent.sons) { + this.unfold(parent).then(() => { + this.createFunc({$parent: parent}); + }); + } else + this.createFunc({$parent: parent}); + } } create(item) { @@ -120,12 +221,24 @@ export default class Treeview extends Component { if (parent) parent.sons++; } - onDrop(item, dragged, dropped) { - this.emit('drop', {item, dragged, dropped}); + move(item, newParent) { + if (newParent == item) return; + + if (item.parent) { + const parent = item.parent; + const childs = parent.childs; + const index = childs.indexOf(item); + parent.sons--; + + childs.splice(index, 1); + } + + item.parent = newParent; + this.create(item); } } -Treeview.$inject = ['$element', '$scope', '$transclude']; +Treeview.$inject = ['$element', '$scope', '$transclude', '$window']; ngModule.component('vnTreeview', { template: require('./index.html'), diff --git a/front/core/components/treeview/style.scss b/front/core/components/treeview/style.scss index b3724a9f1d..e39d1708db 100644 --- a/front/core/components/treeview/style.scss +++ b/front/core/components/treeview/style.scss @@ -10,22 +10,6 @@ vn-treeview-childs { li { list-style: none; - & > .node { - @extend %clickable; - display: flex; - padding: 5px; - align-items: center; - } - - & > div > .arrow { - min-width: 24px; - margin-right: 10px; - transition: transform 200ms; - } - - & > div.expanded > .arrow { - transform: rotate(180deg); - } ul { padding-left: 2.2em; } @@ -45,8 +29,27 @@ vn-treeview-childs { .node:hover > .buttons { display: block } + + .content { + flex-grow: 1 + } } -vn-treeview-content { - flex-grow: 1 -} \ No newline at end of file +vn-treeview-child { + display: block; + + .node { + @extend %clickable; + display: flex; + padding: 5px; + align-items: center; + } + & > div > .arrow { + min-width: 24px; + margin-right: 10px; + transition: transform 200ms; + } + &.expanded > div > .arrow { + transform: rotate(180deg); + } +} \ No newline at end of file diff --git a/front/core/directives/draggable.js b/front/core/directives/draggable.js deleted file mode 100644 index 3b68a6cb6d..0000000000 --- a/front/core/directives/draggable.js +++ /dev/null @@ -1,43 +0,0 @@ -import ngModule from '../module'; - -/** - * Enables a draggable element and his drag events - * - * @return {Object} The directive - */ -export function directive() { - return { - restrict: 'A', - link: function($scope, $element, $attrs) { - const element = $element[0]; - const isDraggable = $attrs.vnDraggable === 'true'; - - if (!isDraggable) return; - - // Set draggable style properties - element.style.cursor = 'move'; - - - // Enable as draggable element - element.setAttribute('draggable', true); - - /** - * Fires when a drag event starts - */ - element.addEventListener('dragstart', event => { - element.style.opacity = 0.5; - event.stopPropagation(); - }); - - /** - * Fires when a drag event ends - */ - element.addEventListener('dragend', event => { - element.style.opacity = 1; - event.stopPropagation(); - }); - } - }; -} - -ngModule.directive('vnDraggable', directive); diff --git a/front/core/directives/droppable.js b/front/core/directives/droppable.js index 4832824183..929b64be77 100644 --- a/front/core/directives/droppable.js +++ b/front/core/directives/droppable.js @@ -1,68 +1,43 @@ import ngModule from '../module'; import './droppable.scss'; -export function directive($parse) { - return { - restrict: 'A', - link: function($scope, $element, $attrs) { - const element = $element[0]; - const onDropEvent = $parse($attrs.onDrop); - const isDroppable = $attrs.vnDroppable === 'true'; +class Controller { + constructor($element, $, $attrs) { + this.element = $element[0]; + this.$ = $; + this.$attrs = $attrs; - if (!isDroppable) return; + this.element.addEventListener('dragover', + event => event.preventDefault()); // Prevents page reload + this.element.addEventListener('drop', + event => this.drop(event)); + this.element.addEventListener('dragenter', + event => this.dragEnter(event)); + this.element.addEventListener('dragleave', + event => this.dragLeave(event)); + } - /** - * Captures current dragging element - */ - element.addEventListener('dragstart', () => { - this.dragged = element; - }); + dragEnter(event) { + this.droppedElement = event.target; + this.element.classList.add('dropping'); + } - /** - * Enter droppable area event - */ - element.addEventListener('dragenter', event => { - element.classList.add('active'); + dragLeave(event) { + if (this.droppedElement === event.target) + this.element.classList.remove('dropping'); + } - event.stopImmediatePropagation(); - event.preventDefault(); - }, false); - - - /** - * Exit droppable area event - */ - element.addEventListener('dragleave', event => { - element.classList.remove('active'); - - event.stopImmediatePropagation(); - event.preventDefault(); - }); - - /** - * Prevent dragover for allowing - * dispatch drop event - */ - element.addEventListener('dragover', event => { - event.stopPropagation(); - event.preventDefault(); - }); - - /** - * Fires when a drop events - */ - element.addEventListener('drop', event => { - element.classList.remove('active'); - - onDropEvent($scope, {event}); - - event.stopPropagation(); - event.preventDefault(); - }); - } - }; + drop(event) { + if (event.defaultPrevented) return; + event.preventDefault(); + this.element.classList.remove('dropping'); + this.$.$eval(this.$attrs.vnDroppable, {$event: event}); + } } +Controller.$inject = ['$element', '$scope', '$attrs']; -directive.$inject = ['$parse']; - -ngModule.directive('vnDroppable', directive); +ngModule.directive('vnDroppable', () => { + return { + controller: Controller + }; +}); diff --git a/front/core/directives/droppable.scss b/front/core/directives/droppable.scss index 749bc9a12f..97e6f8a197 100644 --- a/front/core/directives/droppable.scss +++ b/front/core/directives/droppable.scss @@ -1,11 +1,25 @@ @import "./variables"; + +.vn-droppable, +.vn-draggable, [vn-droppable] { border: 2px dashed transparent; + border-radius: 0.5em; transition: all 0.5s; +} - &.active { +.vn-droppable, +[vn-droppable] { + display: block; + + &.dropping { background-color: $color-hover-cd; - border: 2px dashed $color-bg-dark; + border-color: $color-bg-dark; } +} + +.vn-draggable.dragging { + background-color: $color-main-light; + border-color: $color-main; } \ No newline at end of file diff --git a/front/core/directives/index.js b/front/core/directives/index.js index 08adfac077..7ee63220b4 100644 --- a/front/core/directives/index.js +++ b/front/core/directives/index.js @@ -11,7 +11,6 @@ import './bind'; import './repeat-last'; import './title'; import './uvc'; -import './draggable'; import './droppable'; import './http-click'; import './http-submit'; diff --git a/front/core/lib/event-emitter.js b/front/core/lib/event-emitter.js index 2dede42ab9..022e4e98c2 100644 --- a/front/core/lib/event-emitter.js +++ b/front/core/lib/event-emitter.js @@ -24,10 +24,12 @@ export default class EventEmitter { */ off(callback) { if (!this.$events) return; - for (let event in this.$events) - for (let i = 0; i < event.length; i++) + for (let event in this.$events) { + for (let i = 0; i < event.length; i++) { if (event[i].callback === callback) event.splice(i--, 1); + } + } } /** @@ -37,10 +39,12 @@ export default class EventEmitter { */ disconnect(thisArg) { if (!this.$events) return; - for (let event in this.$events) - for (let i = 0; i < event.length; i++) + for (let event in this.$events) { + for (let i = 0; i < event.length; i++) { if (event[i].thisArg === thisArg) event.splice(i--, 1); + } + } } /** @@ -72,9 +76,10 @@ export default class EventEmitter { if (this[prop]) this[prop].disconnect(this); this[prop] = value; - if (value) + if (value) { for (let event in handlers) value.on(event, handlers[event], this); + } } } } diff --git a/loopback/locale/es.json b/loopback/locale/es.json index 07c95af32a..aa135d520f 100644 --- a/loopback/locale/es.json +++ b/loopback/locale/es.json @@ -108,5 +108,7 @@ "This postal code is not valid": "This postal code is not valid", "is invalid": "is invalid", "The postcode doesn't exists. Ensure you put the correct format": "El código postal no existe. Asegúrate de ponerlo con el formato correcto", - "The department name can't be repeated": "El nombre del departamento no puede repetirse" + "The department name can't be repeated": "El nombre del departamento no puede repetirse", + "You cannot move a parent to any of its sons": "You cannot move a parent to any of its sons", + "You cannot move a parent to its own sons": "You cannot move a parent to its own sons" } \ No newline at end of file diff --git a/modules/claim/front/dms/index/index.html b/modules/claim/front/dms/index/index.html index 10e35e58e6..81bd088a79 100644 --- a/modules/claim/front/dms/index/index.html +++ b/modules/claim/front/dms/index/index.html @@ -5,7 +5,7 @@ data="$ctrl.photos"> </vn-crud-model> -<section class="drop-zone" vn-droppable="true" on-drop="$ctrl.onDrop(event)"> +<section class="drop-zone" vn-droppable="$ctrl.onDrop($event)"> <section><vn-icon icon="add_circle"></vn-icon></section> <section translate>Drag & Drop files here...</section> </section> diff --git a/modules/claim/front/dms/index/index.js b/modules/claim/front/dms/index/index.js index f60feab6c7..f382e4d676 100644 --- a/modules/claim/front/dms/index/index.js +++ b/modules/claim/front/dms/index/index.js @@ -35,8 +35,9 @@ class Controller { } } - onDrop(event) { - const files = event.dataTransfer.files; + onDrop($event) { + console.log($event); + const files = $event.dataTransfer.files; this.setDefaultParams().then(() => { this.dms.files = files; this.create(); diff --git a/modules/worker/back/methods/department/moveChild.js b/modules/worker/back/methods/department/moveChild.js new file mode 100644 index 0000000000..97206f198c --- /dev/null +++ b/modules/worker/back/methods/department/moveChild.js @@ -0,0 +1,42 @@ +const UserError = require('vn-loopback/util/user-error'); + +module.exports = Self => { + Self.remoteMethod('moveChild', { + description: 'Changes the parent of a child department', + accessType: 'WRITE', + accepts: [{ + arg: 'id', + type: 'Number', + description: 'The department id', + http: {source: 'path'} + }, { + arg: 'parentId', + type: 'Number', + description: 'New parent id', + }], + returns: { + type: 'Object', + root: true + }, + http: { + path: `/:id/moveChild`, + verb: 'POST' + } + }); + + Self.moveChild = async(id, parentId = null) => { + const models = Self.app.models; + const child = await models.Department.findById(id); + + if (id == parentId) return; + + if (parentId) { + const parent = await models.Department.findById(parentId); + + if (child.lft < parent.lft && child.rgt > parent.rgt) + throw new UserError('You cannot move a parent to its own sons'); + } + + return child.updateAttribute('parentFk', parentId); + }; +}; diff --git a/modules/worker/back/models/department.js b/modules/worker/back/models/department.js index e6905d273c..5a927fc640 100644 --- a/modules/worker/back/models/department.js +++ b/modules/worker/back/models/department.js @@ -2,4 +2,5 @@ module.exports = Self => { require('../methods/department/getLeaves')(Self); require('../methods/department/createChild')(Self); require('../methods/department/removeChild')(Self); + require('../methods/department/moveChild')(Self); }; diff --git a/modules/worker/back/models/department.json b/modules/worker/back/models/department.json index bb5d5e9434..7de76e0395 100644 --- a/modules/worker/back/models/department.json +++ b/modules/worker/back/models/department.json @@ -16,6 +16,15 @@ }, "parentFk": { "type": "Number" + }, + "lft": { + "type": "Number" + }, + "rgt": { + "type": "Number" + }, + "sons": { + "type": "Number" } } } diff --git a/modules/worker/front/department/index.html b/modules/worker/front/department/index.html index 7736620322..c7abaf3f8a 100644 --- a/modules/worker/front/department/index.html +++ b/modules/worker/front/department/index.html @@ -10,7 +10,10 @@ fetch-func="$ctrl.onFetch($item)" remove-func="$ctrl.onRemove($item)" create-func="$ctrl.onCreate($parent)" - sort-func="$ctrl.onSort($a, $b)"> + sort-func="$ctrl.onSort($a, $b)" + on-drop="$ctrl.onDrop($dropped, $dragged)" + on-drag-start="$ctrl.onDragStart(item)" + on-drag-end="$ctrl.onDragEnd(item)"> {{::item.name}} </vn-treeview> </vn-card> diff --git a/modules/worker/front/department/index.js b/modules/worker/front/department/index.js index 1a72681bce..b103efff96 100644 --- a/modules/worker/front/department/index.js +++ b/modules/worker/front/department/index.js @@ -23,19 +23,21 @@ class Controller { return a.name.localeCompare(b.name); } - /* onDrop(item, dragged, dropped) { - if (dropped.scope.item) { - const droppedItem = dropped.scope.item; - const draggedItem = dragged.scope.item; + onDrop(dropped, dragged) { + if (!dropped.active) { + this.$.treeview.unfold(dropped).then(() => + this.move(dropped, dragged)); + } else + this.move(dropped, dragged); + } - if (droppedItem.childs) - droppedItem.childs.push(Object.assign({}, draggedItem)); - - dragged.element.remove(); - - this.$scope.$apply(); - } - } */ + move(dropped, dragged) { + const params = dropped ? {parentId: dropped.id} : null; + const query = `/api/departments/${dragged.id}/moveChild`; + this.$http.post(query, params).then(() => { + this.$.treeview.move(dragged, dropped); + }); + } onCreate(parent) { this.newChild = { @@ -62,9 +64,6 @@ class Controller { if (parent && parent.id) params.parentId = parent.id; - if (!parent.active) - this.$.treeview.unfold(parent); - const query = `/api/departments/createChild`; this.$http.post(query, params).then(res => { const parent = this.newChild.parent;