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(['
  • 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'); + }); + }); + }); +}); +