diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 897d270..fc68089 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -1,4 +1,5 @@ define('pgadmin.browser', [ + 'sources/tree/tree', 'sources/gettext', 'sources/url_for', 'require', 'jquery', 'underscore', 'underscore.string', 'bootstrap', 'sources/pgadmin', 'pgadmin.alertifyjs', 'bundled_codemirror', 'sources/check_node_visibility', 'sources/modify_animation', 'pgadmin.browser.utils', 'wcdocker', @@ -10,6 +11,7 @@ define('pgadmin.browser', [ 'sources/codemirror/addon/fold/pgadmin-sqlfoldcode', 'pgadmin.browser.keyboard', ], function( + tree, gettext, url_for, require, $, _, S, Bootstrap, pgAdmin, Alertify, codemirror, checkNodeVisibility, modifyAnimation ) { @@ -86,6 +88,7 @@ define('pgadmin.browser', [ }); b.tree = $('#tree').aciTree('api'); + b.treeMenu.register($('#tree')); }; // Extend the browser class attributes @@ -100,6 +103,7 @@ define('pgadmin.browser', [ editor:null, // Left hand browser tree tree:null, + treeMenu: new tree.Tree(), // list of script to be loaded, when a certain type of node is loaded // It will be used to register extensions, tools, child node scripts, // etc. diff --git a/web/pgadmin/static/js/tree/tree.js b/web/pgadmin/static/js/tree/tree.js new file mode 100644 index 0000000..8048771 --- /dev/null +++ b/web/pgadmin/static/js/tree/tree.js @@ -0,0 +1,275 @@ +////////////////////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////////////////// + + +class BaseTreeNode { + constructor(id, data, domNode, parent) { + this.id = id; + this.data = data; + this.setParent(parent); + this.children = []; + this.domNode = domNode; + } + + hasParent() { + return isNotNullOrUndefined(this.parentNode); + } + + parent() { + return this.parentNode; + } + + setParent(parent) { + this.parentNode = parent; + if (isNotNullOrUndefined(parent)) { + this.path = this.id; + if (isNotNullOrUndefined(parent) && isNotNullOrUndefined(parent.path)) { + this.path = parent.path + '.' + this.id; + } + } else { + this.path = null; + } + } + + 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) { + // Implement it in the actual implementation + // This is more of a shell object. + throw 'Not Implemented', tree; + } + + unload(tree) { + this.children.forEach(function(child) { + child.unload(tree); + child.setParent(null); + }); + this.children = []; + } + + anyParent(condition) { + let node = this; + + while (node.hasParent()) { + node = node.parent(); + if (condition(node)) { + return true; + } + } + + return false; + } + + anyFamilyMember(condition) { + if(condition(this)) { + return true; + } + + return this.anyParent(condition); + } +} + + +class BaseTree { + constructor() { + this.rootNode = this._createNewTreeNode(undefined, {}); + } + + 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('.')); + } + + findNodeByDomElement(/* domElement */) { + // TODO:: + // Think through the new implementation - How is it going to work with real + // domElement? + // + // NOTE: We already have implementation for the aciTree. + throw 'Not Implemented'; + } + + selected() { + throw 'Not Implemented'; + } + + selectNode(/* node */) { + throw 'Not Implemented'; + } + + register(/* $treeJQuery */) { + throw 'Not Implemented'; + } + + _createNewTreeNode(/* id, data, domNode, parent */) { + throw 'Not Implemented'; + } + + 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 = Object.assign({}, data); + return oldNode; + } + + const node = this._createNewTreeNode(id, data, domNode, parent); + if (parent === this.rootNode) { + node.parentNode = null; + } + if (isNotNullOrUndefined(parent)) { + parent.children.push(node); + } + return node; + } +} + + +function findInTree(rootNode, path) { + if (path === null) { + return rootNode; + } + return (function findInNode(currentNode) { + 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); +} + + +class ACITreeNode extends BaseTreeNode { + constructor(id, data, domNode, parent) { + super(id, data, domNode, parent); + } + + reload(tree) { + this.unload(tree); + tree.aciTreeApi.setInode(this.domNode); + tree.aciTreeApi.deselect(this.domNode); + + setTimeout(() => { + tree.selectNode(this.domNode); + }, 0); + } + + unload(tree) { + // Do not pass on tree object to 'super' object to avoid unloading the + // children recursively for performance reason. + // + // Any way - unloading using 'aciTreeApi.unload(...)' will any way unload + // all children. + super.unload(null); + + if (isNotNullOrUndefined(tree) && isNotNullOrUndefined(tree.aciTreeApi)) { + tree.aciTreeApi.unload(this.domNode); + } else { + this.domNode = null; + } + } +} + + +function isNotNullOrUndefined(val) { + return (val !== undefined && val !== null); +} + + + +class ACITree extends BaseTree { + constructor() { + super(arguments); + this.aciTreeApi = null; + } + + _createNewTreeNode(id, data, domNode, parent) { + let node = new ACITreeNode(id, data, domNode, parent); + return node; + } + + findNodeByDomElement(domElement) { + const path = this.translateTreeNodeIdFromACITree(domElement); + if(!path || !path[0]) { + return undefined; + } + + return this.findNode(path); + } + + selected() { + return this.aciTreeApi.selected(); + } + + selectNode(aciTreeIdentifier) { + this.aciTreeApi.select(aciTreeIdentifier); + } + + register($treeJQuery) { + $treeJQuery.on('acitree', function (event, api, item, eventName) { + if (api.isItem(item)) { + if (eventName === 'added') { + const id = api.getId(item); + const data = api.itemData(item); + const parentId = this.translateTreeNodeIdFromACITree(api.parent(item)); + this.addNewNode(id, data, item, parentId); + } + } + }.bind(this)); + this.aciTreeApi = $treeJQuery.aciTree('api'); + } + + 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; + } + + static test() { + return 'ACITree'; + } +} + +export { ACITree as Tree, ACITreeNode as TreeNode }; diff --git a/web/regression/javascript/tree/tree_fake.js b/web/regression/javascript/tree/tree_fake.js new file mode 100644 index 0000000..b285a45 --- /dev/null +++ b/web/regression/javascript/tree/tree_fake.js @@ -0,0 +1,69 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import {Tree} from '../../../pgadmin/static/js/tree/tree'; + +export class TreeFake extends Tree { + constructor() { + super(); + this.aciTreeToOurTreeTranslator = {}; + this.aciTreeApi = jasmine.createSpyObj( + ['ACITreeApi'], ['setInode', 'unload', 'deselect', 'select']); + } + + addNewNode(id, data, domNode, path) { + this.aciTreeToOurTreeTranslator[id] = [id]; + if (path !== null && path !== undefined) { + this.aciTreeToOurTreeTranslator[id] = path.concat(id); + } + return super.addNewNode(id, data, domNode, path); + } + + addChild(parent, child) { + child.setParent(parent); + this.aciTreeToOurTreeTranslator[child.id] = this.aciTreeToOurTreeTranslator[parent.id].concat(child.id); + parent.children.push(child); + } + + hasParent(aciTreeNode) { + return this.translateTreeNodeIdFromACITree(aciTreeNode).length > 1; + } + + parent(aciTreeNode) { + if (this.hasParent(aciTreeNode)) { + let path = this.translateTreeNodeIdFromACITree(aciTreeNode); + return [{id: this.findNode(path).parent().id}]; + } + + return null; + } + + translateTreeNodeIdFromACITree(aciTreeNode) { + if(aciTreeNode === undefined || aciTreeNode[0] === undefined) { + return null; + } + return this.aciTreeToOurTreeTranslator[aciTreeNode[0].id]; + } + + itemData(aciTreeNode) { + let node = this.findNodeByDomElement(aciTreeNode); + if (node === undefined || node === null) { + return undefined; + } + return node.getData(); + } + + selected() { + return this.selectedNode; + } + + selectNode(selectedNode) { + this.selectedNode = selectedNode; + } +} diff --git a/web/regression/javascript/tree/tree_spec.js b/web/regression/javascript/tree/tree_spec.js new file mode 100644 index 0000000..164ceb5 --- /dev/null +++ b/web/regression/javascript/tree/tree_spec.js @@ -0,0 +1,421 @@ +////////////////////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////////////////// + +import {Tree, TreeNode} from '../../../pgadmin/static/js/tree/tree'; +import {TreeFake} from './tree_fake'; + +const context = describe; + +const treeTests = (treeClass, setDefaultCallBack) => { + let tree; + beforeEach(() => { + tree = new treeClass(); + }); + + describe('#addNewNode', () => { + describe('when add a new root element', () => { + context('using [] as the parent', () => { + beforeEach(() => { + tree.addNewNode('some new node', {data: 'interesting'}, undefined, []); + }); + + it('can be retrieved', () => { + const node = tree.findNode(['some new node']); + expect(node.data).toEqual({data: 'interesting'}); + }); + + it('return false for #hasParent()', () => { + const node = tree.findNode(['some new node']); + expect(node.hasParent()).toBe(false); + }); + + it('return null for #parent()', () => { + const node = tree.findNode(['some new node']); + expect(node.parent()).toBeNull(); + }); + }); + + context('using null as the parent', () => { + beforeEach(() => { + tree.addNewNode('some new node', {data: 'interesting'}, undefined, null); + }); + + it('can be retrieved', () => { + const node = tree.findNode(['some new node']); + expect(node.data).toEqual({data: 'interesting'}); + }); + + it('return false for #hasParent()', () => { + const node = tree.findNode(['some new node']); + expect(node.hasParent()).toBe(false); + }); + + it('return null for #parent()', () => { + const node = tree.findNode(['some new node']); + expect(node.parent()).toBeNull(); + }); + }); + + context('using undefined as the parent', () => { + beforeEach(() => { + tree.addNewNode('some new node', {data: 'interesting'}); + }); + + it('can be retrieved', () => { + const node = tree.findNode(['some new node']); + expect(node.data).toEqual({data: 'interesting'}); + }); + + it('return false for #hasParent()', () => { + const node = tree.findNode(['some new node']); + expect(node.hasParent()).toBe(false); + }); + + it('return null for #parent()', () => { + const node = tree.findNode(['some new node']); + expect(node.parent()).toBeNull(); + }); + }); + }); + + describe('when add a new element as a child', () => { + let parentNode; + beforeEach(() => { + parentNode = tree.addNewNode('parent node', {data: 'parent data'}, undefined, []); + tree.addNewNode('some new node', {data: 'interesting'}, undefined, ['parent' + + ' node']); + }); + + it('can be retrieved', () => { + const node = tree.findNode(['parent node', 'some new node']); + expect(node.data).toEqual({data: 'interesting'}); + }); + + it('return true for #hasParent()', () => { + const node = tree.findNode(['parent node', 'some new node']); + expect(node.hasParent()).toBe(true); + }); + + it('return "parent node" object for #parent()', () => { + const node = tree.findNode(['parent node', 'some new node']); + expect(node.parent()).toEqual(parentNode); + }); + }); + + describe('when add an element that already exists under a parent', () => { + beforeEach(() => { + tree.addNewNode('parent node', {data: 'parent data'}, undefined, []); + tree.addNewNode('some new node', {data: 'interesting'}, undefined, ['parent' + + ' node']); + }); + + it('does not add a new child', () => { + tree.addNewNode('some new node', {data: 'interesting 1'}, undefined, ['parent' + + ' node']); + const parentNode = tree.findNode(['parent node']); + expect(parentNode.children.length).toBe(1); + }); + + it('updates the existing node data', () => { + tree.addNewNode('some new node', {data: 'interesting 1'}, undefined, ['parent' + + ' node']); + const node = tree.findNode(['parent node', 'some new node']); + expect(node.data).toEqual({data: 'interesting 1'}); + }); + }); + }); + + describe('#translateTreeNodeIdFromACITree', () => { + let aciTreeApi; + beforeEach(() => { + aciTreeApi = jasmine.createSpyObj('ACITreeApi', [ + 'hasParent', + 'parent', + 'getId', + ]); + + aciTreeApi.getId.and.callFake((node) => { + return node[0].id; + }); + tree.aciTreeApi = aciTreeApi; + }); + + describe('When tree as a single level', () => { + beforeEach(() => { + aciTreeApi.hasParent.and.returnValue(false); + }); + + it('returns an array with the ID of the first level', () => { + let node = [{ + id: 'some id', + }]; + tree.addNewNode('some id', {}, undefined, []); + + expect(tree.translateTreeNodeIdFromACITree(node)).toEqual(['some id']); + }); + }); + + describe('When tree as a 2 levels', () => { + describe('When we try to retrieve the node in the second level', () => { + it('returns an array with the ID of the first level and second level', () => { + aciTreeApi.hasParent.and.returnValues(true, false); + aciTreeApi.parent.and.returnValue([{ + id: 'parent id', + }]); + let node = [{ + id: 'some id', + }]; + + tree.addNewNode('parent id', {}, undefined, []); + tree.addNewNode('some id', {}, undefined, ['parent id']); + + expect(tree.translateTreeNodeIdFromACITree(node)) + .toEqual(['parent id', 'some id']); + }); + }); + }); + }); + + describe('#selected', () => { + context('a node in the tree is selected', () => { + it('returns that node object', () => { + let selectedNode = new TreeNode('bamm', {}, []); + setDefaultCallBack(tree, selectedNode); + expect(tree.selected()).toEqual(selectedNode); + }); + }); + }); + + describe('#findNodeByTreeElement', () => { + context('retrieve data from node not found', () => { + it('return undefined', () => { + let aciTreeApi = jasmine.createSpyObj('ACITreeApi', [ + 'hasParent', + 'parent', + 'getId', + ]); + + aciTreeApi.getId.and.callFake((node) => { + return node[0].id; + }); + tree.aciTreeApi = aciTreeApi; + expect(tree.findNodeByDomElement(['