refactor drag & drop treeview
gitea/salix/1757-drag_and_drop_department This commit looks good Details

This commit is contained in:
Joan Sanchez 2019-10-16 08:56:13 +02:00
parent 4b235faa8a
commit 7aaa8f4a5e
18 changed files with 331 additions and 189 deletions

View File

@ -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>

View File

@ -2,22 +2,33 @@ import ngModule from '../../module';
class Controller { class Controller {
constructor($element, $scope, $compile) { constructor($element, $scope, $compile) {
this.$element = $element;
this.$scope = $scope; this.$scope = $scope;
this.$compile = $compile; 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() { $onInit() {
const transcludeElement = this.element.querySelector('.content');
const content = angular.element(transcludeElement);
if (this.item.parent) { if (this.item.parent) {
this.treeview.$transclude(($clone, $scope) => { this.treeview.$transclude(($clone, $scope) => {
this.$contentScope = $scope; this.$contentScope = $scope;
$scope.item = this.item; $scope.item = this.item;
this.$element.append($clone); content.append($clone);
}); });
this.element.draggable = true;
this.element.classList.add('vn-draggable');
} else { } else {
let template = `<span translate>{{$ctrl.treeview.rootLabel}}</span>`; let template = `<span translate>{{$ctrl.treeview.rootLabel}}</span>`;
let $clone = this.$compile(template)(this.$scope); let $clone = this.$compile(template)(this.$scope);
this.$element.append($clone); content.append($clone);
} }
} }
@ -25,10 +36,21 @@ class Controller {
if (this.$contentScope) if (this.$contentScope)
this.$contentScope.$destroy(); 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']; Controller.$inject = ['$element', '$scope', '$compile'];
ngModule.component('vnTreeviewContent', { ngModule.component('vnTreeviewChild', {
template: require('./child.html'),
controller: Controller, controller: Controller,
bindings: { bindings: {
item: '<' item: '<'

View File

@ -1,34 +1,11 @@
<ul ng-if="$ctrl.items"> <ul ng-if="$ctrl.items">
<li ng-repeat="item in $ctrl.items" > <li ng-repeat="item in $ctrl.items">
<div <vn-treeview-child item="item" ng-class="{expanded: item.active}"
ng-class="{expanded: item.active}" ng-click="$ctrl.onClick($event, item)">
ng-click="$ctrl.onClick($event, item)" </vn-treeview-child>
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>
<vn-treeview-childs <vn-treeview-childs
items="item.childs" items="item.childs">
parent="::item">
</vn-treeview-childs> </vn-treeview-childs>
</li> </li>
</ul> </ul>

View File

@ -1,18 +1,113 @@
import ngModule from '../../module'; import ngModule from '../../module';
import Component from '../../lib/component'; import Component from '../../lib/component';
import './style.scss'; import './style.scss';
import './childs'; import './childs';
import './content'; import './child';
/** /**
* Treeview * Treeview
*/ */
export default class Treeview extends Component { export default class Treeview extends Component {
constructor($element, $scope, $transclude) { constructor($element, $scope, $transclude, $window) {
super($element, $scope); super($element, $scope);
this.$transclude = $transclude; this.$transclude = $transclude;
this.$window = $window;
this.readOnly = true; 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() { get data() {
@ -100,8 +195,14 @@ export default class Treeview extends Component {
} }
onCreate(parent) { onCreate(parent) {
if (this.createFunc) if (this.createFunc) {
this.createFunc({$parent: parent}); if (!parent.active && parent.sons) {
this.unfold(parent).then(() => {
this.createFunc({$parent: parent});
});
} else
this.createFunc({$parent: parent});
}
} }
create(item) { create(item) {
@ -120,12 +221,24 @@ export default class Treeview extends Component {
if (parent) parent.sons++; if (parent) parent.sons++;
} }
onDrop(item, dragged, dropped) { move(item, newParent) {
this.emit('drop', {item, dragged, dropped}); 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', { ngModule.component('vnTreeview', {
template: require('./index.html'), template: require('./index.html'),

View File

@ -10,22 +10,6 @@ vn-treeview-childs {
li { li {
list-style: none; 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 { ul {
padding-left: 2.2em; padding-left: 2.2em;
} }
@ -45,8 +29,27 @@ vn-treeview-childs {
.node:hover > .buttons { .node:hover > .buttons {
display: block display: block
} }
.content {
flex-grow: 1
}
} }
vn-treeview-content { vn-treeview-child {
flex-grow: 1 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);
}
}

View File

@ -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);

View File

@ -1,68 +1,43 @@
import ngModule from '../module'; import ngModule from '../module';
import './droppable.scss'; import './droppable.scss';
export function directive($parse) { class Controller {
return { constructor($element, $, $attrs) {
restrict: 'A', this.element = $element[0];
link: function($scope, $element, $attrs) { this.$ = $;
const element = $element[0]; this.$attrs = $attrs;
const onDropEvent = $parse($attrs.onDrop);
const isDroppable = $attrs.vnDroppable === 'true';
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));
}
/** dragEnter(event) {
* Captures current dragging element this.droppedElement = event.target;
*/ this.element.classList.add('dropping');
element.addEventListener('dragstart', () => { }
this.dragged = element;
});
/** dragLeave(event) {
* Enter droppable area event if (this.droppedElement === event.target)
*/ this.element.classList.remove('dropping');
element.addEventListener('dragenter', event => { }
element.classList.add('active');
event.stopImmediatePropagation(); drop(event) {
event.preventDefault(); if (event.defaultPrevented) return;
}, false); event.preventDefault();
this.element.classList.remove('dropping');
this.$.$eval(this.$attrs.vnDroppable, {$event: event});
/** }
* 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();
});
}
};
} }
Controller.$inject = ['$element', '$scope', '$attrs'];
directive.$inject = ['$parse']; ngModule.directive('vnDroppable', () => {
return {
ngModule.directive('vnDroppable', directive); controller: Controller
};
});

View File

@ -1,11 +1,25 @@
@import "./variables"; @import "./variables";
.vn-droppable,
.vn-draggable,
[vn-droppable] { [vn-droppable] {
border: 2px dashed transparent; border: 2px dashed transparent;
border-radius: 0.5em;
transition: all 0.5s; transition: all 0.5s;
}
&.active { .vn-droppable,
[vn-droppable] {
display: block;
&.dropping {
background-color: $color-hover-cd; 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;
} }

View File

@ -11,7 +11,6 @@ import './bind';
import './repeat-last'; import './repeat-last';
import './title'; import './title';
import './uvc'; import './uvc';
import './draggable';
import './droppable'; import './droppable';
import './http-click'; import './http-click';
import './http-submit'; import './http-submit';

View File

@ -24,10 +24,12 @@ export default class EventEmitter {
*/ */
off(callback) { off(callback) {
if (!this.$events) return; if (!this.$events) return;
for (let event in this.$events) for (let event in this.$events) {
for (let i = 0; i < event.length; i++) for (let i = 0; i < event.length; i++) {
if (event[i].callback === callback) if (event[i].callback === callback)
event.splice(i--, 1); event.splice(i--, 1);
}
}
} }
/** /**
@ -37,10 +39,12 @@ export default class EventEmitter {
*/ */
disconnect(thisArg) { disconnect(thisArg) {
if (!this.$events) return; if (!this.$events) return;
for (let event in this.$events) for (let event in this.$events) {
for (let i = 0; i < event.length; i++) for (let i = 0; i < event.length; i++) {
if (event[i].thisArg === thisArg) if (event[i].thisArg === thisArg)
event.splice(i--, 1); event.splice(i--, 1);
}
}
} }
/** /**
@ -72,9 +76,10 @@ export default class EventEmitter {
if (this[prop]) if (this[prop])
this[prop].disconnect(this); this[prop].disconnect(this);
this[prop] = value; this[prop] = value;
if (value) if (value) {
for (let event in handlers) for (let event in handlers)
value.on(event, handlers[event], this); value.on(event, handlers[event], this);
}
} }
} }
} }

View File

@ -108,5 +108,7 @@
"This postal code is not valid": "This postal code is not valid", "This postal code is not valid": "This postal code is not valid",
"is invalid": "is invalid", "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 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"
} }

View File

@ -5,7 +5,7 @@
data="$ctrl.photos"> data="$ctrl.photos">
</vn-crud-model> </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><vn-icon icon="add_circle"></vn-icon></section>
<section translate>Drag & Drop files here...</section> <section translate>Drag & Drop files here...</section>
</section> </section>

View File

@ -35,8 +35,9 @@ class Controller {
} }
} }
onDrop(event) { onDrop($event) {
const files = event.dataTransfer.files; console.log($event);
const files = $event.dataTransfer.files;
this.setDefaultParams().then(() => { this.setDefaultParams().then(() => {
this.dms.files = files; this.dms.files = files;
this.create(); this.create();

View File

@ -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);
};
};

View File

@ -2,4 +2,5 @@ module.exports = Self => {
require('../methods/department/getLeaves')(Self); require('../methods/department/getLeaves')(Self);
require('../methods/department/createChild')(Self); require('../methods/department/createChild')(Self);
require('../methods/department/removeChild')(Self); require('../methods/department/removeChild')(Self);
require('../methods/department/moveChild')(Self);
}; };

View File

@ -16,6 +16,15 @@
}, },
"parentFk": { "parentFk": {
"type": "Number" "type": "Number"
},
"lft": {
"type": "Number"
},
"rgt": {
"type": "Number"
},
"sons": {
"type": "Number"
} }
} }
} }

View File

@ -10,7 +10,10 @@
fetch-func="$ctrl.onFetch($item)" fetch-func="$ctrl.onFetch($item)"
remove-func="$ctrl.onRemove($item)" remove-func="$ctrl.onRemove($item)"
create-func="$ctrl.onCreate($parent)" 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}} {{::item.name}}
</vn-treeview> </vn-treeview>
</vn-card> </vn-card>

View File

@ -23,19 +23,21 @@ class Controller {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
} }
/* onDrop(item, dragged, dropped) { onDrop(dropped, dragged) {
if (dropped.scope.item) { if (!dropped.active) {
const droppedItem = dropped.scope.item; this.$.treeview.unfold(dropped).then(() =>
const draggedItem = dragged.scope.item; this.move(dropped, dragged));
} else
this.move(dropped, dragged);
}
if (droppedItem.childs) move(dropped, dragged) {
droppedItem.childs.push(Object.assign({}, draggedItem)); const params = dropped ? {parentId: dropped.id} : null;
const query = `/api/departments/${dragged.id}/moveChild`;
dragged.element.remove(); this.$http.post(query, params).then(() => {
this.$.treeview.move(dragged, dropped);
this.$scope.$apply(); });
} }
} */
onCreate(parent) { onCreate(parent) {
this.newChild = { this.newChild = {
@ -62,9 +64,6 @@ class Controller {
if (parent && parent.id) if (parent && parent.id)
params.parentId = parent.id; params.parentId = parent.id;
if (!parent.active)
this.$.treeview.unfold(parent);
const query = `/api/departments/createChild`; const query = `/api/departments/createChild`;
this.$http.post(query, params).then(res => { this.$http.post(query, params).then(res => {
const parent = this.newChild.parent; const parent = this.newChild.parent;