Hi, Please find the attached patch for RM #1253 - Store and reload current location in treeview
Feature Details: - The current tree state will be stored in the sqlite database. - The time interval to store the tree state is configurable via preferences and the default is 30 secs. -1 can be used to stop the tree saving functionality, - On window unload the current tree state will be saved. - On Reload, while opening the server, the stored path will be populated. - On closing the node the saved will be updated accordingly. - Jasmine test cases are included. Thanks, Khushboo
diff --git a/web/pgadmin/browser/register_browser_preferences.py b/web/pgadmin/browser/register_browser_preferences.py index 2eed500..df30346 100644 --- a/web/pgadmin/browser/register_browser_preferences.py +++ b/web/pgadmin/browser/register_browser_preferences.py @@ -28,6 +28,16 @@ def register_browser_preferences(self): True, category_label=gettext('Display') ) + self.preference.register( + 'display', 'browser_tree_state_save_interval', + gettext("Browser tree state saving interval"), 'integer', + 30, category_label=gettext('Display'), + help_str=gettext( + 'Browser tree state saving interval in seconds.' + 'Use -1 to disable the tree saving mechanism.' + ) + ) + self.table_row_count_threshold = self.preference.register( 'properties', 'table_row_count_threshold', gettext("Count rows if estimated less than"), 'integer', 2000, diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 7951f4a..ad6a39e 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -9,7 +9,7 @@ define('pgadmin.browser', [ 'pgadmin.browser.error', 'pgadmin.browser.frame', 'pgadmin.browser.node', 'pgadmin.browser.collection', 'sources/codemirror/addon/fold/pgadmin-sqlfoldcode', - 'pgadmin.browser.keyboard', + 'pgadmin.browser.keyboard', 'sources/tree/pgadmin_tree_save_state', ], function( tree, gettext, url_for, require, $, _, S, Bootstrap, pgAdmin, Alertify, @@ -507,9 +507,11 @@ define('pgadmin.browser', [ .done(function() {}) .fail(function() {}); }, 300000); + obj.Events.on('pgadmin:browser:tree:add', obj.onAddTreeNode, obj); obj.Events.on('pgadmin:browser:tree:update', obj.onUpdateTreeNode, obj); obj.Events.on('pgadmin:browser:tree:refresh', obj.onRefreshTreeNode, obj); + }, add_menu_category: function( @@ -1936,19 +1938,10 @@ define('pgadmin.browser', [ pgAdmin.Browser.editor_shortcut_keys.Tab = 'insertSoftTab'; } - window.onbeforeunload = function() { - var e = window.event, - msg = S(gettext('Are you sure you wish to close the %s browser?')).sprintf(pgBrowser.utils.app_name).value(); - - // For IE and Firefox prior to version 4 - if (e) { - e.returnValue = msg; - } - - // For Safari - return msg; - }; - + $(window).on('beforeunload', function() { + if (pgBrowser.get_preference('browser', 'browser_tree_state_save_interval').value !== -1) + pgAdmin.Browser.browserTreeState.save_state(); + }); return pgAdmin.Browser; }); diff --git a/web/pgadmin/browser/static/js/node.js b/web/pgadmin/browser/static/js/node.js index 24bf306..8c9e430 100644 --- a/web/pgadmin/browser/static/js/node.js +++ b/web/pgadmin/browser/static/js/node.js @@ -1,11 +1,11 @@ define('pgadmin.browser.node', [ - 'sources/tree/pgadmin_tree_node', + 'sources/tree/pgadmin_tree_node', 'sources/url_for', 'sources/gettext', 'jquery', 'underscore', 'underscore.string', 'sources/pgadmin', 'pgadmin.browser.menu', 'backbone', 'pgadmin.alertifyjs', 'pgadmin.browser.datamodel', 'backform', 'sources/browser/generate_url', 'sources/utils', 'pgadmin.browser.utils', 'pgadmin.backform', ], function( - pgadminTreeNode, + pgadminTreeNode, url_for, gettext, $, _, S, pgAdmin, Menu, Backbone, Alertify, pgBrowser, Backform, generateUrl, commonUtils ) { @@ -849,7 +849,6 @@ define('pgadmin.browser.node', [ } } }, - added: function(item, data, browser) { var b = browser || pgBrowser, t = b.tree, @@ -876,6 +875,8 @@ define('pgadmin.browser.node', [ ); } + pgBrowser.Events.trigger('pgadmin:browser:tree:expand-from-previous-tree-state', + item); pgBrowser.Node.callbacks.change_server_background(item, data); }, // Callback called - when a node is selected in browser tree. @@ -958,6 +959,14 @@ define('pgadmin.browser.node', [ }, }); }, + opened: function(item) { + pgBrowser.Events.trigger('pgadmin:browser:tree:update-tree-state', + item); + }, + closed: function(item) { + pgBrowser.Events.trigger('pgadmin:browser:tree:remove-from-tree-state', + item); + }, }, /********************************************************************** * A hook (not a callback) to show object properties in given HTML diff --git a/web/pgadmin/browser/static/js/preferences.js b/web/pgadmin/browser/static/js/preferences.js index 5ca9e3c..1767d72 100644 --- a/web/pgadmin/browser/static/js/preferences.js +++ b/web/pgadmin/browser/static/js/preferences.js @@ -94,6 +94,9 @@ _.extend(pgBrowser, { modifyAnimation.modifyAlertifyAnimation(self); } + // Initialize Tree saving/reloading + pgBrowser.browserTreeState.init(); + /* Once the cache is loaded after changing the preferences, * notify the modules of the change */ diff --git a/web/pgadmin/preferences/static/js/preferences.js b/web/pgadmin/preferences/static/js/preferences.js index eeaf4bb..840fc0a 100644 --- a/web/pgadmin/preferences/static/js/preferences.js +++ b/web/pgadmin/preferences/static/js/preferences.js @@ -2,6 +2,7 @@ define('pgadmin.preferences', [ 'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'backbone', 'pgadmin.alertifyjs', 'sources/pgadmin', 'pgadmin.backform', 'pgadmin.browser', 'sources/modify_animation', + 'sources/tree/pgadmin_tree_save_state', ], function( gettext, url_for, $, _, Backbone, Alertify, pgAdmin, Backform, pgBrowser, modifyAnimation diff --git a/web/pgadmin/settings/__init__.py b/web/pgadmin/settings/__init__.py index 269bfdf..d0b4fad 100644 --- a/web/pgadmin/settings/__init__.py +++ b/web/pgadmin/settings/__init__.py @@ -10,13 +10,15 @@ """Utility functions for storing and retrieving user configuration settings.""" import traceback +import json -from flask import Response, request, render_template, url_for +from flask import Response, request, render_template, url_for, current_app from flask_babelex import gettext from flask_login import current_user from flask_security import login_required from pgadmin.utils import PgAdminModule -from pgadmin.utils.ajax import make_json_response, bad_request +from pgadmin.utils.ajax import make_json_response, bad_request,\ + success_return, internal_server_error from pgadmin.utils.menu import MenuItem from pgadmin.model import db, Setting @@ -52,7 +54,8 @@ class SettingsModule(PgAdminModule): list: a list of url endpoints exposed to the client. """ return [ - 'settings.store', 'settings.store_bulk', 'settings.reset_layout' + 'settings.store', 'settings.store_bulk', 'settings.reset_layout', + 'settings.save_tree_state', 'settings.get_tree_state' ] @@ -145,3 +148,36 @@ def reset_layout(): ) return make_json_response(result=request.form) + + +@blueprint.route("/save_tree_state/", endpoint="save_tree_state", + methods=['POST']) +@login_required +def save_browser_tree_state(): + """Save the browser tree state.""" + data = request.form if request.form else request.data.decode('utf-8') + + try: + store_setting('browser_tree_state', data) + except Exception as e: + current_app.logger.exception(e) + return internal_server_error(errormsg=str(e)) + + return success_return() + + +@blueprint.route("/get_tree_state/", endpoint="get_tree_state", + methods=['GET']) +@login_required +def get_browser_tree_state(): + """Get the browser tree state.""" + + try: + data = get_setting('browser_tree_state') + except Exception as e: + current_app.logger.exception(e) + return internal_server_error(errormsg=str(e)) + + return Response(response=data, + status=200, + mimetype="application/json") diff --git a/web/pgadmin/static/js/tree/pgadmin_tree_save_state.js b/web/pgadmin/static/js/tree/pgadmin_tree_save_state.js new file mode 100644 index 0000000..d9958fd --- /dev/null +++ b/web/pgadmin/static/js/tree/pgadmin_tree_save_state.js @@ -0,0 +1,235 @@ +import _ from 'underscore'; +import $ from 'jquery'; +import url_for from 'sources/url_for'; +import gettext from 'sources/gettext'; +import pgAdmin from '../../../static/js/pgadmin'; + +const pgBrowser = pgAdmin.Browser = pgAdmin.Browser || {}; + + +const browserTreeState = pgBrowser.browserTreeState = pgBrowser.browserTreeState || {}; + +_.extend(pgBrowser.browserTreeState, { + + // Parent node to start saving / reloading the tree state + parent: 'server', + + // The original parent of the browser tree + orig_parent: 'server_group', + + // Last load tree state + + stored_state: {}, + + // Previous tree state + last_state: {}, + + // Current tree state + current_state: {}, + + init: function() { + + const save_tree_state_period = pgBrowser.get_preference('browser', 'browser_tree_state_save_interval'); + + if (!_.isUndefined(save_tree_state_period) && save_tree_state_period.value !== -1) { + // Save the tree state every 30 seconds + setInterval(this.save_state, (save_tree_state_period.value) * 1000); + + // Fetch the tree last state while loading the browser tree + this.fetch_state.apply(this); + + pgBrowser.Events.on('pgadmin:browser:tree:expand-from-previous-tree-state', + this.expand_from_previous_state, this); + pgBrowser.Events.on('pgadmin:browser:tree:remove-from-tree-state', + this.remove_from_cache, this); + pgBrowser.Events.on('pgadmin:browser:tree:update-tree-state', + this.update_cache, this); + } + }, + + save_state: function() { + + var self = pgBrowser.browserTreeState; + if(self.last_state == JSON.stringify(self.current_state)) + return; + + $.ajax({ + url: url_for('settings.save_tree_state'), + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(self.current_state), + }) + .done(function() { + self.last_state = JSON.stringify(self.current_state); + }) + .fail(function(jqx) { + var msg = jqx.responseText; + /* Error from the server */ + if (jqx.status == 417 || jqx.status == 410 || jqx.status == 500) { + try { + var data = JSON.parse(jqx.responseText); + msg = data.errormsg; + } catch (e) { + console.warn(e.stack || e); + } + } + console.warn( + gettext('Error saving the tree state."'), msg); + }); + + }, + fetch_state: function() { + + var self = this; + $.ajax({ + url: url_for('settings.get_tree_state'), + type: 'GET', + dataType: 'json', + contentType: 'application/json', + }) + .done(function(res) { + self.stored_state = res; + }) + .fail(function(jqx) { + var msg = jqx.responseText; + /* Error from the server */ + if (jqx.status == 417 || jqx.status == 410 || jqx.status == 500) { + try { + var data = JSON.parse(jqx.responseText); + msg = data.errormsg; + } catch (e) { + console.warn(e.stack || e); + } + } + console.warn( + gettext('Error fetching the tree state."'), msg); + }); + }, + update_cache: function(item) { + let data = item && pgBrowser.tree.itemData(item), + node = data && pgBrowser.Nodes[data._type], + treeHierarchy = node.getTreeNodeHierarchy(item), + topParent = undefined, + pathIDs = pgBrowser.tree.pathId(item), + oldPath = pathIDs.join(), + path = [], + tmpIndex = -1; + + // If no parent so or the server not in tree hierarchy then return + if (!pgBrowser.tree.hasParent(item) || !(this.parent in treeHierarchy)) + return; + + topParent = treeHierarchy[this.parent]['_id']; + + if (pgBrowser.tree.isOpen(item)) { + pathIDs.push(data.id); + path = pathIDs.join(); + + // IF the current path is already saved then return + let index = _.find(this.current_state[topParent], function(tData) { + return (tData.search(path) !== -1); + }); + if(!_.isUndefined(index)) + return; + + // Add / Update the current item into the tree path + if (!_.isUndefined(this.current_state[topParent])) { + tmpIndex = this.current_state[topParent].indexOf(oldPath); + } else { + this.current_state[topParent] = []; + } + if (tmpIndex !== -1) { + this.current_state[topParent][tmpIndex] = path; + } + else { + this.current_state[topParent].push(path); + } + } + }, + remove_from_cache: function(item) { + let self= this, + data = item && pgBrowser.tree.itemData(item), + node = data && pgBrowser.Nodes[data._type], + treeHierarchy = node && node.getTreeNodeHierarchy(item); + + if (!pgBrowser.tree.hasParent(item) || !(self.parent in treeHierarchy)) + return; + + let topParent = treeHierarchy && treeHierarchy[self.parent]['_id'], + origParent = treeHierarchy && treeHierarchy[self.orig_parent]['id']; + + if (pgBrowser.tree.isClosed(item)) { + let tmpTreeData = self.current_state[topParent]; + if (!_.isUndefined(tmpTreeData) && !_.isUndefined(tmpTreeData.length)) { + let tcnt = 0, + tmpItemDataStr = undefined; + _.each(tmpTreeData, function(tData) { + if (_.isUndefined(tData)) + return; + + let tmpItemData = tData.split(','); + + if (tmpItemData.indexOf(data.id) !== -1 ) { + let index = tmpItemData.indexOf(data.id); + tmpItemData.splice(index); + tmpItemDataStr = tmpItemData.join(); + + if (tmpItemDataStr == origParent) + self.current_state[topParent].splice(tData, 1); + else + self.current_state[topParent][tcnt] = tmpItemDataStr; + } + tcnt ++; + }); + + if (tmpItemDataStr !== undefined) { + let tmpIndex = _.find(tmpTreeData, function(tData) { + return (tData.search(tmpItemDataStr) !== -1); + }); + + if(tmpIndex && tmpIndex !== tmpTreeData.indexOf(tmpItemDataStr)) + this.current_state[topParent].splice(tmpIndex, 1); + } + } + } + }, + expand_from_previous_state: function(item) { + let self = this, + treeData = self.stored_state || {}, + data = item && pgBrowser.tree.itemData(item), + node = data && pgBrowser.Nodes[data._type], + treeHierarchy = node && node.getTreeNodeHierarchy(item); + + if (!pgBrowser.tree.hasParent(item) || !(self.parent in treeHierarchy)) + return; + + // If the server node is open then only we should populate the tree + if (data['_type'] == self.parent && (pgBrowser.tree.isOpen(item) === false)) + return; + + let tmpTreeData = treeData[treeHierarchy[self.parent]['_id']]; + + if (!_.isUndefined(tmpTreeData) && !_.isUndefined(tmpTreeData.length)) { + _.each(tmpTreeData, function(tData) { + if (_.isUndefined(tData)) + return; + + let tmpItemData = tData.split(','); + + // If the item is in the lastTreeState then open it + if (tmpItemData.indexOf(data.id) !== -1 ) { + let index = tmpItemData.indexOf(data.id); + pgBrowser.tree.toggle(item); + + if (index == (tmpItemData.length - 1 )) { + let tIndex = treeData[treeHierarchy[self.parent]['_id']].indexOf(tData); + treeData[treeHierarchy[self.parent]['_id']].splice(tIndex, 1); + } + } + }); + } + }, + +}); + +export {pgBrowser, browserTreeState}; diff --git a/web/regression/javascript/tree/pgadmin_tree_state_save_spec.js b/web/regression/javascript/tree/pgadmin_tree_state_save_spec.js new file mode 100644 index 0000000..769b663 --- /dev/null +++ b/web/regression/javascript/tree/pgadmin_tree_state_save_spec.js @@ -0,0 +1,240 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2018, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import {pgBrowser, browserTreeState} from '../../../pgadmin/static/js/tree/pgadmin_tree_save_state'; + + +describe('#browserTreeState', () => { + beforeEach(() => { + pgBrowser.Nodes = { + server_group: { + _type: 'server_group', + hasId: true, + id: 'server_group/1', + _id: 1, + getTreeNodeHierarchy: function() { return { + server_group: { + _type: 'server_group', + hasId: true, + id: 'server_group/1', + _id: 1, + }, + };}, + }, + server: { + _type: 'server', + hasId: true, + id: 'server/1', + _id: 1, + getTreeNodeHierarchy: function() { return { + server_group: { + _type: 'server_group', + hasId: true, + id: 'server_group/1', + _id: 1, + }, + server: { + _type: 'server', + hasId: true, + id: 'server/1', + _id: 1, + }, + };}, + }, + coll_database: { + _type: 'coll_database', + hasId: false, + id: 'coll_database/1', + _id: 1, + getTreeNodeHierarchy: function() { return { + server_group: { + _type: 'server_group', + hasId: true, + id: 'server_group/1', + _id: 1, + }, + server: { + _type: 'server', + hasId: true, + id: 'server/1', + _id: 1, + }, + coll_database: { + _type: 'coll_database', + hasId: true, + id: 'coll_database/1', + _id: 1, + }, + };}, + }, + database: { + _type: 'database', + hasId: true, + id: 'database/10', + _id: 10, + getTreeNodeHierarchy: function() { return { + server_group: { + _type: 'server_group', + hasId: true, + id: 'server_group/1', + _id: 1, + }, + server: { + _type: 'server', + hasId: true, + id: 'server/1', + _id: 1, + }, + coll_database: { + _type: 'coll_database', + hasId: true, + id: 'coll_database/1', + _id: 1, + }, + database: { + _type: 'database', + hasId: true, + id: 'database/10', + _id: 10, + }, + };}, + }, + }; + pgBrowser.tree = jasmine.createSpyObj('tree', ['itemData', 'pathId', 'hasParent', 'isOpen', 'isClosed']); + }); + + describe('When node is opened tree state is getting updated', () => { + + describe('When tree node server group is opened', () => { + let item = { + _type: 'server_group', + hasId: true, + id: 'server_group/1', + _id: 1, + }; + beforeEach(() => { + pgBrowser.tree.itemData.and.returnValue(item); + pgBrowser.tree.pathId.and.returnValue([]); + pgBrowser.tree.hasParent.and.returnValue(false); + pgBrowser.tree.isOpen.and.returnValue(true); + }); + + it('The tree current state will be empty', () => { + browserTreeState.update_cache(item); + expect(browserTreeState.current_state, {}); + }); + }); + + describe('When server node is opened', () => { + let item = { + _type: 'server', + hasId: true, + id: 'server/1', + _id: 1, + }; + beforeEach(() => { + pgBrowser.tree.itemData.and.returnValue(item); + pgBrowser.tree.pathId.and.returnValue(['server_group/1']); + pgBrowser.tree.hasParent.and.returnValue(true); + pgBrowser.tree.isOpen.and.returnValue(true); + }); + + it('The tree current state will have server', () => { + browserTreeState.update_cache(item); + expect(browserTreeState.current_state, {1: ['server_group/1,server/1']}); + }); + }); + + describe('When coll_database node is opened', () => { + let item = { + _type: 'coll_database', + hasId: true, + id: 'coll_database/1', + _id: 1, + }; + beforeEach(() => { + pgBrowser.tree.itemData.and.returnValue(item); + pgBrowser.tree.pathId.and.returnValue(['server_group/1', 'server/1']); + pgBrowser.tree.hasParent.and.returnValue(true); + pgBrowser.tree.isOpen.and.returnValue(true); + }); + + it('The tree current state will have coll_database', () => { + browserTreeState.update_cache(item); + expect(browserTreeState.current_state, {1: ['server_group/1,server/1,coll_database/1']}); + }); + }); + + describe('When database node is opened', () => { + let item = { + _type: 'database', + hasId: true, + id: 'database/10', + _id: 10, + }; + beforeEach(() => { + pgBrowser.tree.itemData.and.returnValue(item); + pgBrowser.tree.pathId.and.returnValue(['server_group/1', 'server/1', 'coll_database/1']); + pgBrowser.tree.hasParent.and.returnValue(true); + pgBrowser.tree.isOpen.and.returnValue(true); + }); + + it('The tree current state will have coll_database', () => { + browserTreeState.update_cache(item); + expect(browserTreeState.current_state, {1: ['server_group/1,server/1,coll_database/1','database/10']}); + }); + }); + }); + + + describe('When node is closed, node should get removed from the tree state cache', () => { + describe('When coll_database node is closed, both database and coll_database should be removed', () => { + let item = { + _type: 'coll_database', + hasId: true, + id: 'coll_database/1', + _id: 1, + }; + beforeEach(() => { + pgBrowser.tree.itemData.and.returnValue(item); + pgBrowser.tree.pathId.and.returnValue(['server_group/1', 'server/1']); + pgBrowser.tree.hasParent.and.returnValue(true); + pgBrowser.tree.isOpen.and.returnValue(true); + pgBrowser.tree.isClosed.and.returnValue(true); + }); + + it('The tree current state will remove coll_database and database', () => { + browserTreeState.remove_from_cache(item); + expect(browserTreeState.current_state, {1: ['server_group/1,server/1']}); + }); + }); + + describe('When server node is closed, both server and server_group should be removed', () => { + let item = { + _type: 'server', + hasId: true, + id: 'server/1', + _id: 1, + }; + beforeEach(() => { + pgBrowser.tree.itemData.and.returnValue(item); + pgBrowser.tree.pathId.and.returnValue(['server_group/1']); + pgBrowser.tree.hasParent.and.returnValue(true); + pgBrowser.tree.isOpen.and.returnValue(true); + pgBrowser.tree.isClosed.and.returnValue(true); + }); + + it('The tree current state will remove server_group and server', () => { + browserTreeState.remove_from_cache(item); + expect(browserTreeState.current_state, {1: []}); + }); + }); + }); + +});