//////////////////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////////////////
import {isValidData} from 'sources/utils';
import $ from 'jquery';
import Alertify from 'pgadmin.alertifyjs';
export class TreeNode {
constructor(id, data, domNode, parent) {
this.id = id;
this.data = data;
this.setParent(parent);
this.children = [];
this.domNode = domNode;
}
hasParent() {
return this.parentNode !== null && this.parentNode !== undefined;
}
parent() {
return this.parentNode;
}
setParent(parent) {
this.parentNode = parent;
this.path = this.id;
if (parent !== null && parent !== undefined && parent.path !== undefined) {
this.path = parent.path + '.' + this.id;
}
}
getData() {
if (this.data === undefined) {
return undefined;
} else if (this.data === null) {
return null;
}
return Object.assign({}, this.data);
}
getHtmlIdentifier() {
return this.domNode;
}
reload(tree) {
return new Promise((resolve)=>{
this.unload(tree)
.then(()=>{
tree.aciTreeApi.setInode(this.domNode);
tree.aciTreeApi.deselect(this.domNode);
setTimeout(() => {
tree.selectNode(this.domNode);
}, 0);
resolve();
});
});
}
unload(tree) {
return new Promise((resolve, reject)=>{
this.children = [];
tree.aciTreeApi.unload(this.domNode, {
success: ()=>{
resolve(true);
},
fail: ()=>{
reject();
},
});
});
}
open(tree, suppressNoDom) {
return new Promise((resolve, reject)=>{
if(suppressNoDom && (this.domNode == null || typeof(this.domNode) === 'undefined')) {
resolve(true);
} else if(tree.aciTreeApi.isOpen(this.domNode)) {
resolve(true);
} else {
tree.aciTreeApi.open(this.domNode, {
success: ()=>{
resolve(true);
},
fail: ()=>{
reject(true);
},
});
}
});
}
/*
* Find the ancestor with matches this condition
*/
ancestorNode(condition) {
let node = this;
while (node.hasParent()) {
node = node.parent();
if (condition(node)) {
return node;
}
}
return null;
}
/**
* Given a condition returns true if the current node
* or any of the parent nodes condition result is true
*/
anyFamilyMember(condition) {
if(condition(this)) {
return true;
}
return this.ancestorNode(condition) !== null;
}
anyParent(condition) {
return this.ancestorNode(condition) !== null;
}
}
export class Tree {
constructor() {
this.rootNode = new TreeNode(undefined, {});
this.aciTreeApi = undefined;
this.draggableTypes = {};
}
/*
*
* The dropDetailsFunc should return an object of sample
* {text: 'xyz', cur: {from:0, to:0} where text is the drop text and
* cur is selection range of text after dropping. If returned as
* string, by default cursor will be set to the end of text
*/
registerDraggableType(typeOrTypeDict, dropDetailsFunc=null) {
if(typeof typeOrTypeDict == 'object') {
Object.keys(typeOrTypeDict).forEach((type)=>{
this.registerDraggableType(type, typeOrTypeDict[type]);
});
} else {
if(dropDetailsFunc != null) {
typeOrTypeDict.replace(/ +/, ' ').split(' ').forEach((type)=>{
this.draggableTypes[type] = dropDetailsFunc;
});
}
}
}
getDraggable(type) {
if(this.draggableTypes[type]) {
return this.draggableTypes[type];
} else {
return null;
}
}
prepareDraggable(data, item) {
let dropDetailsFunc = this.getDraggable(data._type);
if(dropDetailsFunc != null) {
/* addEventListener is used here because import jquery.drag.event
* overrides the dragstart event set using element.on('dragstart')
* This will avoid conflict.
*/
item.find('.aciTreeItem')
.attr('draggable', true)[0]
.addEventListener('dragstart', (e)=> {
let dropDetails = dropDetailsFunc(data, item);
if(typeof dropDetails == 'string') {
dropDetails = {
text:dropDetails,
cur:{
from:dropDetails.length,
to: dropDetails.length,
},
};
} else {
if(!dropDetails.cur) {
dropDetails = {
...dropDetails,
cur:{
from:dropDetails.text.length,
to: dropDetails.text.length,
},
};
}
}
e.dataTransfer.setData('text', JSON.stringify(dropDetails));
/* Required by Firefox */
if(e.dataTransfer.dropEffect) {
e.dataTransfer.dropEffect = 'move';
}
/* setDragImage is not supported in IE. We leave it to
* its default look and feel
*/
if(e.dataTransfer.setDragImage) {
let dragItem = $(`
${_.escape(dropDetails.text)}
`
);
$('body .drag-tree-node').remove();
$('body').append(dragItem);
e.dataTransfer.setDragImage(dragItem[0], 0, 0);
}
});
}
}
addNewNode(id, data, domNode, parentPath) {
const parent = this.findNode(parentPath);
return this.createOrUpdateNode(id, data, parent, domNode);
}
findNode(path) {
if (path === null || path === undefined || path.length === 0) {
return this.rootNode;
}
return findInTree(this.rootNode, path.join('.'));
}
findNodeWithToggle(path) {
let tree = this;
if(path == null || !Array.isArray(path)) {
return Promise.reject();
}
path = path.join('.');
let onCorrectPath = function(matchPath) {
return (matchPath !== undefined && path !== undefined
&& (path.startsWith(matchPath + '.') || path === matchPath));
};
return (function findInNode(currentNode) {
return new Promise((resolve, reject)=>{
if (path === null || path === undefined || path.length === 0) {
resolve(null);
}
/* No point in checking the children if
* the path for currentNode itself is not matching
*/
if (currentNode.path !== undefined && !onCorrectPath(currentNode.path)) {
reject(null);
} else if (currentNode.path === path) {
resolve(currentNode);
} else {
currentNode.open(tree, true)
.then(()=>{
for (let i = 0, length = currentNode.children.length; i < length; i++) {
let childNode = currentNode.children[i];
if(onCorrectPath(childNode.path)) {
resolve(findInNode(childNode));
return;
}
}
reject(null);
})
.catch(()=>{
reject(null);
});
}
});
})(this.rootNode);
}
findNodeByDomElement(domElement) {
const path = this.translateTreeNodeIdFromACITree(domElement);
if(!path || !path[0]) {
return undefined;
}
return this.findNode(path);
}
selected() {
return this.aciTreeApi.selected();
}
/* scrollIntoView will scroll only to top and bottom
* Logic can be added for scroll to middle
*/
scrollTo(domElement) {
domElement.scrollIntoView();
}
selectNode(aciTreeIdentifier, scrollOnSelect) {
this.aciTreeApi.select(aciTreeIdentifier);
if(scrollOnSelect) {
this.scrollTo(aciTreeIdentifier[0]);
}
}
createOrUpdateNode(id, data, parent, domNode) {
let oldNodePath = [id];
if(parent !== null && parent !== undefined) {
oldNodePath = [parent.path, id];
}
const oldNode = this.findNode(oldNodePath);
if (oldNode !== null) {
oldNode.data = data;
oldNode.domNode = domNode;
return oldNode;
}
const node = new TreeNode(id, data, domNode, parent);
if (parent === this.rootNode) {
node.parentNode = null;
}
if (parent !== null && parent !== undefined)
parent.children.push(node);
return node;
}
unloadNode(id, data, domNode, parentPath) {
let oldNodePath = [id];
const parent = this.findNode(parentPath);
if(parent !== null && parent !== undefined) {
oldNodePath = [parent.path, id];
}
const oldNode = this.findNode(oldNodePath);
if(oldNode) {
oldNode.children = [];
}
}
/**
* Given the JQuery object that contains the ACI Tree
* this method is responsible for registering this tree class
* to listen to all the events that happen in the ACI Tree.
*
* At this point in time the only event that we care about is
* the addition of a new node.
* The function will create a new tree node to store the information
* that exist in the ACI for it.
*/
register($treeJQuery) {
$treeJQuery.on('acitree', function (event, api, item, eventName) {
if (api.isItem(item)) {
/* If the id of node is changed, the path should also be changed */
if (['added', 'idset', 'beforeunload'].indexOf(eventName) != -1) {
const id = api.getId(item);
const data = api.itemData(item);
const parentId = this.translateTreeNodeIdFromACITree(api.parent(item));
if(eventName === 'beforeunload') {
this.unloadNode(id, data, item, parentId);
} else {
if(eventName === 'added') {
this.prepareDraggable(data, item);
}
this.addNewNode(id, data, item, parentId);
}
if(data.errmsg) {
Alertify.error(data.errmsg);
}
}
}
}.bind(this));
this.aciTreeApi = $treeJQuery.aciTree('api');
/* Ctrl + Click will trigger context menu. Select the node when Ctrl+Clicked.
* When the context menu is visible, the tree should lose focus
* to use context menu with keyboard. Otherwise, the tree functions
* overrides the keyboard events.
*/
let contextHandler = (ev)=>{
let treeItem = this.aciTreeApi.itemFrom(ev.target);
if(treeItem.length) {
if(ev.ctrlKey) {
this.aciTreeApi.select(treeItem);
}
$(treeItem).on('contextmenu:visible', ()=>{
$(treeItem).trigger('blur');
$(treeItem).off('contextmenu:visible');
});
}
};
$treeJQuery
.off('mousedown', contextHandler)
.on('mousedown', contextHandler);
}
/**
* As the name hints this functions works as a layer in between ACI and
* the adaptor. Given a ACITree JQuery node find the location of it in the
* Tree and then returns and array with the path to to the Tree Node in
* question
*
* This is not optimized and will always go through the full tree
*/
translateTreeNodeIdFromACITree(aciTreeNode) {
let currentTreeNode = aciTreeNode;
let path = [];
while (currentTreeNode !== null && currentTreeNode !== undefined && currentTreeNode.length > 0) {
path.unshift(this.aciTreeApi.getId(currentTreeNode));
if (this.aciTreeApi.hasParent(currentTreeNode)) {
currentTreeNode = this.aciTreeApi.parent(currentTreeNode);
} else {
break;
}
}
return path;
}
}
/**
* Given an initial node and a path, it will navigate through
* the new tree to find the node that matches the path
*/
function findInTree(rootNode, path) {
if (path === null) {
return rootNode;
}
return (function findInNode(currentNode) {
/* No point in checking the children if
* the path for currentNode itself is not matching
*/
if (currentNode.path !== undefined && path !== undefined
&& !path.startsWith(currentNode.path)) {
return null;
}
for (let i = 0, length = currentNode.children.length; i < length; i++) {
const calculatedNode = findInNode(currentNode.children[i]);
if (calculatedNode !== null) {
return calculatedNode;
}
}
if (currentNode.path === path) {
return currentNode;
} else {
return null;
}
})(rootNode);
}
let isValidTreeNodeData = isValidData;
export {isValidTreeNodeData};