diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 14640ae82..d9693486a 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 000000000..01edb6c3c --- /dev/null +++ b/web/pgadmin/static/js/tree/tree.js @@ -0,0 +1,212 @@ +////////////////////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////////////////// + +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) { + this.unload(tree); + tree.aciTreeApi.setInode(this.domNode); + tree.aciTreeApi.deselect(this.domNode); + setTimeout(() => { + tree.selectNode(this.domNode); + }, 0); + } + + unload(tree) { + this.children = []; + tree.aciTreeApi.unload(this.domNode); + } + + anyParent(condition) { + let node = this; + + while (node.hasParent()) { + node = node.parent(); + if (condition(node)) { + return true; + } + } + + return false; + } + + /** + * 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.anyParent(condition); + } +} + +export class Tree { + constructor() { + this.rootNode = new TreeNode(undefined, {}); + this.aciTreeApi = 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) { + 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); + } + + 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 = new TreeNode(id, data, domNode, parent); + if (parent === this.rootNode) { + node.parentNode = null; + } + parent.children.push(node); + return node; + } + + /** + * 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 (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'); + } + + /** + * 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) { + 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); +} diff --git a/web/regression/javascript/tree/tree_fake.js b/web/regression/javascript/tree/tree_fake.js new file mode 100644 index 000000000..b285a45f3 --- /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 000000000..9260d9b17 --- /dev/null +++ b/web/regression/javascript/tree/tree_spec.js @@ -0,0 +1,420 @@ +////////////////////////////////////////////////////////////////////////// +// +// 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(['