diff --git a/app/assets/javascripts/app/frontend/controllers/home.js b/app/assets/javascripts/app/frontend/controllers/home.js index 830479df3..97216078a 100644 --- a/app/assets/javascripts/app/frontend/controllers/home.js +++ b/app/assets/javascripts/app/frontend/controllers/home.js @@ -8,6 +8,11 @@ angular.module('app.frontend') $rootScope.$broadcast('new-update-available', version); } + /* Used to avoid circular dependencies where syncManager cannot be imported but rootScope can */ + $rootScope.sync = function() { + syncManager.sync(); + } + $rootScope.lockApplication = function() { // Reloading wipes current objects from memory window.location.reload(); diff --git a/app/assets/javascripts/app/services/authManager.js b/app/assets/javascripts/app/services/authManager.js index e5126ab76..ef29e12f9 100644 --- a/app/assets/javascripts/app/services/authManager.js +++ b/app/assets/javascripts/app/services/authManager.js @@ -7,11 +7,11 @@ angular.module('app.frontend') return domain; } - this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager) { - return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager); + this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager) { + return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager); } - function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager) { + function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager) { this.loadInitialData = function() { var userData = storageManager.getItem("user"); @@ -290,5 +290,23 @@ angular.module('app.frontend') this._authParams = null; } - } + + /* User Preferences */ + + let prefsContentType = "SN|UserPreferences"; + + singletonManager.registerSingleton({content_type: prefsContentType}, (resolvedSingleton) => { + console.log("AuthManager received resolved", resolvedSingleton); + this.userPreferences = resolvedSingleton; + }, () => { + // Safe to create. Create and return object. + var prefs = new Item({content_type: prefsContentType}); + modelManager.addItem(prefs); + prefs.setDirty(true); + console.log("Created new prefs", prefs); + $rootScope.sync(); + return prefs; + }); + + } }); diff --git a/app/assets/javascripts/app/services/directives/functional/click-outside.js b/app/assets/javascripts/app/services/directives/functional/click-outside.js index b30e749da..10e40e1da 100644 --- a/app/assets/javascripts/app/services/directives/functional/click-outside.js +++ b/app/assets/javascripts/app/services/directives/functional/click-outside.js @@ -1,102 +1,27 @@ -// via https://github.com/IamAdamJowett/angular-click-outside - angular .module('app.frontend') - .directive('clickOutside', ['$document', '$parse', '$timeout', function($document, $parse, $timeout) { + .directive('clickOutside', ['$document', function($document) { return { restrict: 'A', replace: false, - link: function($scope, elem, attr) { - $timeout(() => { - // postpone linking to next digest to allow for unique id generation - var classList = (attr.outsideIfNot !== undefined) ? attr.outsideIfNot.split(/[ ,]+/) : [], - fn; + link : function($scope, $element, attrs) { - function eventHandler(e) { - var i, - element, - r, - id, - classNames, - l; + var didApplyClickOutside = false; - // check if our element already hidden and abort if so - if (angular.element(elem).hasClass("ng-hide")) { - return; - } - - // if there is no click target, no point going on - if (!e || !e.target) { - return; - } - - // loop through the available elements, looking for classes in the class list that might match and so will eat - for (element = e.target; element; element = element.parentNode) { - // check if the element is the same element the directive is attached to and exit if so (props @CosticaPuntaru) - if (element === elem[0]) { - return; - } - - // now we have done the initial checks, start gathering id's and classes - id = element.id, - classNames = element.className, - l = classList.length; - - // Unwrap SVGAnimatedString classes - if (classNames && classNames.baseVal !== undefined) { - classNames = classNames.baseVal; - } - - // if there are no class names on the element clicked, skip the check - if (classNames || id) { - - // loop through the elements id's and classnames looking for exceptions - for (i = 0; i < l; i++) { - //prepare regex for class word matching - r = new RegExp('\\b' + classList[i] + '\\b'); - - // check for exact matches on id's or classes, but only if they exist in the first place - if ((id !== undefined && id === classList[i]) || (classNames && r.test(classNames))) { - // now let's exit out as it is an element that has been defined as being ignored for clicking outside - return; - } - } - } - } - - // if we have got this far, then we are good to go with processing the command passed in via the click-outside attribute - $timeout(function() { - fn = $parse(attr['clickOutside']); - fn($scope, { event: e }); - }); + $element.bind('click', function(e) { + didApplyClickOutside = false; + if (attrs.isOpen) { + e.stopPropagation(); } + }); - // if the devices has a touchscreen, listen for this event - if (_hasTouch()) { - $document.on('touchstart', eventHandler); + $document.bind('click', function() { + if(!didApplyClickOutside) { + $scope.$apply(attrs.clickOutside); + didApplyClickOutside = true; } + }) - // still listen for the click event even if there is touch to cater for touchscreen laptops - $document.on('click', eventHandler); - - // when the scope is destroyed, clean up the documents event handlers as we don't want it hanging around - $scope.$on('$destroy', function() { - if (_hasTouch()) { - $document.off('touchstart', eventHandler); - } - - $document.off('click', eventHandler); - }); - - /** - * @description Private function to attempt to figure out if we are on a touch device - * @private - **/ - function _hasTouch() { - // works on most browsers, IE10/11 and Surface - return 'ontouchstart' in window || navigator.maxTouchPoints; - }; - }); + } } - } -}]); + }]); diff --git a/app/assets/javascripts/app/services/singletonManager.js b/app/assets/javascripts/app/services/singletonManager.js new file mode 100644 index 000000000..07e892c82 --- /dev/null +++ b/app/assets/javascripts/app/services/singletonManager.js @@ -0,0 +1,101 @@ +/* + The SingletonManager allows controllers to register an item as a singleton, which means only one instance of that model + should exist, both on the server and on the client. When the SingletonManager detects multiple items matching the singleton predicate, + the oldest ones will be deleted, leaving the newest ones +*/ + +class SingletonManager { + + constructor($rootScope, modelManager) { + this.$rootScope = $rootScope; + this.modelManager = modelManager; + this.singletonHandlers = []; + + $rootScope.$on("initial-data-loaded", (event, data) => { + this.resolveSingletons(modelManager.allItems, true); + }) + + $rootScope.$on("sync:completed", (event, data) => { + console.log("Sync completed", data); + this.resolveSingletons(data.retrievedItems || []); + }) + + setTimeout(function () { + var userPrefsTotal = modelManager.itemsForContentType("SN|UserPreferences"); + console.log("All extant prefs", userPrefsTotal); + }, 1000); + } + + registerSingleton(predicate, resolveCallback, createBlock) { + /* + predicate: a key/value pair that specifies properties that should match in order for an item to be considered a predicate + resolveCallback: called when one or more items are deleted and a new item becomes the reigning singleton + createBlock: called when a sync is complete and no items are found. The createBlock should create the item and return it. + */ + this.singletonHandlers.push({ + predicate: predicate, + resolutionCallback: resolveCallback, + createBlock: createBlock + }); + } + + resolveSingletons(retrievedItems, initialLoad) { + for(var singletonHandler of this.singletonHandlers) { + var predicate = singletonHandler.predicate; + var singletonItems = this.filterItemsWithPredicate(retrievedItems, predicate); + if(singletonItems.length > 0) { + // Check local inventory and make sure only 1 similar item exists. If more than 1, delete oldest + var allExtantItemsMatchingPredicate = this.filterItemsWithPredicate(this.modelManager.allItems, predicate); + + if(allExtantItemsMatchingPredicate.length >= 2) { + // Purge old ones + var sorted = allExtantItemsMatchingPredicate.sort((a, b) => { + return a.updated_at < b.updated_at; + }) + + var toDelete = sorted.slice(1, sorted.length); + for(var d of toDelete) { + this.modelManager.setItemToBeDeleted(d); + } + + this.$rootScope.sync(); + // Send remaining item to callback + var singleton = sorted[0]; + singletonHandler.singleton = singleton; + singletonHandler.resolutionCallback(singleton); + } else if(allExtantItemsMatchingPredicate.length == 1) { + if(!singletonHandler.singleton) { + // Not yet notified interested parties of object + var singleton = allExtantItemsMatchingPredicate[0]; + singletonHandler.singleton = singleton; + singletonHandler.resolutionCallback(singleton); + + } + } + } else { + // Retrieved items does not include any items of interest. If we don't have a singleton registered to this handler, + // we need to create one. Only do this on actual sync completetions and not on initial data load. Because we want + // to get the latest from the server before making the decision to create a new item + if(!singletonHandler.singleton && !initialLoad) { + var item = singletonHandler.createBlock(); + singletonHandler.singleton = item; + singletonHandler.resolutionCallback(item); + } + } + } + } + + filterItemsWithPredicate(items, predicate) { + return items.filter((candidate) => { + for(var key in predicate) { + if(candidate[key] != predicate[key]) { + return false; + } + } + return true; + }) + } + +} + +angular.module('app.frontend').service('singletonManager', SingletonManager); diff --git a/app/assets/javascripts/app/services/syncManager.js b/app/assets/javascripts/app/services/syncManager.js index 8e5bc90e6..17f13fd78 100644 --- a/app/assets/javascripts/app/services/syncManager.js +++ b/app/assets/javascripts/app/services/syncManager.js @@ -62,6 +62,8 @@ class SyncManager { } } + this.$rootScope.$broadcast("sync:completed", {}); + if(callback) { callback({success: true}); } @@ -319,10 +321,10 @@ class SyncManager { this.$rootScope.$broadcast("major-data-change"); } - this.allRetreivedItems = []; - this.callQueuedCallbacksAndCurrent(callback, response); - this.$rootScope.$broadcast("sync:completed"); + this.$rootScope.$broadcast("sync:completed", {retrievedItems: this.allRetreivedItems}); + + this.allRetreivedItems = []; } }.bind(this); diff --git a/app/assets/templates/frontend/directives/room-bar.html.haml b/app/assets/templates/frontend/directives/room-bar.html.haml index d941c3f29..76002f265 100644 --- a/app/assets/templates/frontend/directives/room-bar.html.haml +++ b/app/assets/templates/frontend/directives/room-bar.html.haml @@ -1,4 +1,4 @@ -.room-item{"ng-repeat" => "room in rooms", "ng-click" => "selectRoom(room)", "click-outside" => "hideRoom(room)"} +.room-item{"ng-repeat" => "room in rooms", "ng-click" => "selectRoom(room)", "click-outside" => "hideRoom(room)", "is-open" => "room.show && room.active"} %img.icon{"ng-src" => "{{room.package_info.icon_bar}}"} .label {{room.name}} .room-container.panel-right{"ng-if" => "room.show && room.active", "ng-attr-id" => "component-{{room.uuid}}"} diff --git a/app/assets/templates/frontend/editor.html.haml b/app/assets/templates/frontend/editor.html.haml index 0a812b61b..9b9ddf714 100644 --- a/app/assets/templates/frontend/editor.html.haml +++ b/app/assets/templates/frontend/editor.html.haml @@ -13,7 +13,7 @@ %input.tags-input{"ng-if" => "!(ctrl.tagsComponent && ctrl.tagsComponent.active)", "type" => "text", "ng-keyup" => "$event.keyCode == 13 && $event.target.blur();", "ng-model" => "ctrl.tagsString", "placeholder" => "#tags", "ng-blur" => "ctrl.updateTagsFromTagsString($event, ctrl.tagsString)"} %ul.section-menu-bar{"ng-if" => "ctrl.note"} - %li{"ng-class" => "{'selected' : ctrl.showMenu}", "click-outside" => "ctrl.showMenu = false;"} + %li{"ng-class" => "{'selected' : ctrl.showMenu}", "click-outside" => "ctrl.showMenu = false;", "is-open" => "ctrl.showMenu"} %label{"ng-click" => "ctrl.showMenu = !ctrl.showMenu; ctrl.showExtensions = false; ctrl.showEditorMenu = false;"} Menu %ul.dropdown-menu.sectioned-menu{"ng-if" => "ctrl.showMenu"} @@ -37,11 +37,11 @@ %li{"ng-if" => "ctrl.hasDisabledStackComponents()"} %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.restoreDisabledStackComponents()"} Restore Disabled Components - %li{"ng-class" => "{'selected' : ctrl.showEditorMenu}", "click-outside" => "ctrl.showEditorMenu = false;"} + %li{"ng-class" => "{'selected' : ctrl.showEditorMenu}", "click-outside" => "ctrl.showEditorMenu = false;", "is-open" => "ctrl.showEditorMenu"} %label{"ng-click" => "ctrl.showEditorMenu = !ctrl.showEditorMenu; ctrl.showMenu = false; ctrl.showExtensions = false;"} Editor %editor-menu{"ng-if" => "ctrl.showEditorMenu", "callback" => "ctrl.selectedEditor", "selected-editor" => "ctrl.editorComponent"} - %li{"ng-class" => "{'selected' : ctrl.showExtensions}", "ng-if" => "ctrl.hasAvailableExtensions()", "click-outside" => "ctrl.showExtensions = false;"} + %li{"ng-class" => "{'selected' : ctrl.showExtensions}", "ng-if" => "ctrl.hasAvailableExtensions()", "click-outside" => "ctrl.showExtensions = false;", "is-open" => "ctrl.showExtensions"} %label{"ng-click" => "ctrl.showExtensions = !ctrl.showExtensions; ctrl.showMenu = false; ctrl.showEditorMenu = false;"} Actions %contextual-extensions-menu{"ng-if" => "ctrl.showExtensions", "item" => "ctrl.note"} diff --git a/app/assets/templates/frontend/footer.html.haml b/app/assets/templates/frontend/footer.html.haml index 42452128b..b0943a8f9 100644 --- a/app/assets/templates/frontend/footer.html.haml +++ b/app/assets/templates/frontend/footer.html.haml @@ -1,10 +1,10 @@ #footer-bar .pull-left - .footer-bar-link{"click-outside" => "ctrl.showAccountMenu = false;"} + .footer-bar-link{"click-outside" => "ctrl.showAccountMenu = false;", "is-open" => "ctrl.showAccountMenu"} %a{"ng-click" => "ctrl.accountMenuPressed()", "ng-class" => "{red: ctrl.error}"} Account %account-menu{"ng-if" => "ctrl.showAccountMenu", "on-successful-auth" => "ctrl.onAuthSuccess"} - .footer-bar-link{"click-outside" => "ctrl.showExtensionsMenu = false;"} + .footer-bar-link{"click-outside" => "ctrl.showExtensionsMenu = false;", "is-open" => "ctrl.showExtensionsMenu"} %a{"ng-click" => "ctrl.toggleExtensions()"} Extensions %global-extensions-menu{"ng-if" => "ctrl.showExtensionsMenu"} diff --git a/app/assets/templates/frontend/notes.html.haml b/app/assets/templates/frontend/notes.html.haml index 76c75fa52..c5514fa91 100644 --- a/app/assets/templates/frontend/notes.html.haml +++ b/app/assets/templates/frontend/notes.html.haml @@ -9,11 +9,11 @@ #search-clear-button{"ng-if" => "ctrl.noteFilter.text", "ng-click" => "ctrl.noteFilter.text = ''; ctrl.filterTextChanged()"} ✕ %ul.section-menu-bar#notes-menu-bar %li.item-with-subtitle{"ng-class" => "{'selected' : ctrl.showMenu}"} - .wrapper{"ng-click" => "ctrl.showMenu = !ctrl.showMenu"} + .wrapper{"ng-click" => "ctrl.showMenu = !ctrl.showMenu", "click-outside" => "ctrl.showMenu = false;", "is-open" => "ctrl.showMenu"} %label Options .subtitle {{ctrl.optionsSubtitle()}} - .sectioned-menu.dropdown-menu{"ng-if" => "ctrl.showMenu", "click-outside" => "ctrl.showMenu = false;"} + .sectioned-menu.dropdown-menu{"ng-if" => "ctrl.showMenu"} %ul .header .title Sort by