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(['
  • something
  • '])).toBeUndefined(); + }); + }); + }); +}; + +describe('tree tests', () => { + describe('TreeNode', () => { + describe('#hasParent', () => { + context('parent is null', () => { + it('returns false', () => { + let treeNode = new TreeNode('123', {}, [], null); + expect(treeNode.hasParent()).toBe(false); + }); + }); + context('parent is undefined', () => { + it('returns false', () => { + let treeNode = new TreeNode('123', {}, [], undefined); + expect(treeNode.hasParent()).toBe(false); + }); + }); + context('parent exists', () => { + it('returns true', () => { + let parentNode = new TreeNode('456', {}, []); + let treeNode = new TreeNode('123', {}, [], parentNode); + expect(treeNode.hasParent()).toBe(true); + }); + }); + }); + + describe('#reload', () => { + let tree; + let level2; + beforeEach(() => { + tree = new TreeFake(); + tree.addNewNode('level1', {data: 'interesting'}, [{id: 'level1'}], []); + level2 = tree.addNewNode('level2', {data: 'data'}, [{id: 'level2'}], ['level1']); + tree.addNewNode('level3', {data: 'more data'}, [{id: 'level3'}], ['level1', 'level2']); + + tree.aciTreeApi = jasmine.createSpyObj( + 'ACITreeApi', ['setInode', 'unload', 'deselect', 'select']); + }); + + it('reloads the node and its children', () => { + level2.reload(tree); + expect(tree.findNodeByDomElement([{id: 'level2'}])).toEqual(level2); + }); + + it('does not reload the children of node', () => { + level2.reload(tree); + expect(tree.findNodeByDomElement([{id: 'level3'}])).toBeNull(); + }); + + it('select the node', (done) => { + level2.reload(tree); + setTimeout(() => { + expect(tree.selected()).toEqual([{id: 'level2'}]); + done(); + }, 20); + }); + + describe('ACITree specific', () => { + it('sets the current node as a Inode, changing the Icon back to +', () => { + level2.reload(tree); + expect(tree.aciTreeApi.setInode).toHaveBeenCalledWith([{id: 'level2'}]); + }); + + it('deselect the node and selects it again to trigger ACI tree' + + ' events', (done) => { + level2.reload(tree); + setTimeout(() => { + expect(tree.aciTreeApi.deselect).toHaveBeenCalledWith([{id: 'level2'}]); + done(); + }, 20); + }); + }); + }); + + describe('#unload', () => { + let tree; + let level2; + beforeEach(() => { + tree = new TreeFake(); + tree.addNewNode('level1', {data: 'interesting'}, ['
  • level1
  • '], []); + level2 = tree.addNewNode('level2', {data: 'data'}, ['
  • level2
  • '], ['level1']); + tree.addNewNode('level3', {data: 'more data'}, ['
  • level3
  • '], ['level1', 'level2']); + tree.aciTreeApi = jasmine.createSpyObj('ACITreeApi', ['unload']); + }); + + it('unloads the children of the current node', () => { + level2.unload(tree); + expect(tree.findNodeByDomElement([{id: 'level2'}])).toEqual(level2); + expect(tree.findNodeByDomElement([{id: 'level3'}])).toBeNull(); + }); + + it('calls unload on the ACI Tree', () => { + level2.unload(tree); + expect(tree.aciTreeApi.unload).toHaveBeenCalledWith(['
  • level2
  • ']); + }); + }); + }); + + describe('Tree', () => { + function realTreeSelectNode(tree, selectedNode) { + let aciTreeApi = jasmine.createSpyObj('ACITreeApi', [ + 'selected', + ]); + tree.aciTreeApi = aciTreeApi; + aciTreeApi.selected.and.returnValue(selectedNode); + } + + treeTests(Tree, realTreeSelectNode); + }); + + describe('TreeFake', () => { + function fakeTreeSelectNode(tree, selectedNode) { + tree.selectNode(selectedNode); + } + + treeTests(TreeFake, fakeTreeSelectNode); + + describe('#hasParent', () => { + context('tree contains multiple levels', () => { + let tree; + beforeEach(() => { + tree = new TreeFake(); + tree.addNewNode('level1', {data: 'interesting'}, undefined, []); + tree.addNewNode('level2', {data: 'interesting'}, undefined, ['level1']); + }); + + context('node is at the first level', () => { + it('returns false', () => { + expect(tree.hasParent([{id: 'level1'}])).toBe(false); + }); + }); + + context('node is at the second level', () => { + it('returns true', () => { + expect(tree.hasParent([{id: 'level2'}])).toBe(true); + }); + }); + }); + }); + + describe('#parent', () => { + let tree; + beforeEach(() => { + tree = new TreeFake(); + tree.addNewNode('level1', {data: 'interesting'}, undefined, []); + tree.addNewNode('level2', {data: 'interesting'}, undefined, ['level1']); + }); + + context('node is the root', () => { + it('returns null', () => { + expect(tree.parent([{id: 'level1'}])).toBeNull(); + }); + }); + + context('node is not root', () => { + it('returns root element', () => { + expect(tree.parent([{id: 'level2'}])).toEqual([{id: 'level1'}]); + }); + }); + }); + + describe('#itemData', () => { + let tree; + beforeEach(() => { + tree = new TreeFake(); + tree.addNewNode('level1', {data: 'interesting'}, undefined, []); + tree.addNewNode('level2', {data: 'expected data'}, undefined, ['level1']); + }); + + context('retrieve data from the node', () => { + it('return the node data', () => { + expect(tree.itemData([{id: 'level2'}])).toEqual({ + data: 'expected' + + ' data', + }); + }); + }); + + context('retrieve data from node not found', () => { + it('return undefined', () => { + expect(tree.itemData([{id: 'bamm'}])).toBeUndefined(); + }); + }); + }); + + describe('#addChild', () => { + let root, child; + beforeEach(() => { + let tree = new TreeFake(); + root = tree.addNewNode('root', {}, [{id: 'root'}]); + child = new TreeNode('node.1', {}, [{id: 'node.1'}]); + tree.addChild(root, child); + }); + + it('adds a new child to a node', () => { + expect(root.children).toEqual([child]); + }); + + it('changes the parent of the child node', () => { + expect(root.children[0].parentNode).toEqual(root); + expect(child.parentNode).toEqual(root); + }); + + it('changes the path of the child', () => { + expect(child.path).toEqual('root.node.1'); + }); + }); + }); +});