UNPKG

angular-fireproof

Version:

Angular bindings for Fireproof. Replaces AngularFire.

1,043 lines (685 loc) 24.2 kB
(function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define(['angular', 'firebase', 'fireproof'], factory); } else if (typeof exports === 'object') { // Node. Does not work with strict CommonJS, but // only CommonJS-like environments that support module.exports, // like Node. factory(require('angular'), require('firebase'), require('fireproof')); } else { // Browser globals (root is window) factory(root.angular, root.Firebase, root.Fireproof); } }(this, function (angular, Firebase, Fireproof) { 'use strict'; angular.module('angular-fireproof', [ 'angular-fireproof.services.status', 'angular-fireproof.directives.firebase', 'angular-fireproof.directives.fpBind', 'angular-fireproof.directives.fpPage', 'angular-fireproof.directives.authIf', 'angular-fireproof.directives.authShow', 'angular-fireproof.directives.authClick', 'angular-fireproof.services.Fireproof' ]); angular.module('angular-fireproof.controllers.FirebaseCtl', [ 'angular-fireproof.services.Fireproof', 'angular-fireproof.services.status' ]) .controller('FirebaseCtl', function( $q, Firebase, Fireproof, $scope, $rootScope, $attrs ) { var self = this, userRef, userListener, profileListeners = []; var authErrorMessage = 'auth-handler is not set for this firebase. All ' + 'authentication requests are therefore rejected.'; self.login = function(options) { if ($attrs.loginHandler) { return $q.when($scope.$eval($attrs.loginHandler, { $root: self.root, $options: options })); } else { return $q.reject(new Error(authErrorMessage)); } }; self.onProfile = function(cb) { profileListeners.push(cb); // always notify once immediately with whatever the current state is Fireproof._nextTick(function() { cb(self.profile); }); }; self.offProfile = function(cb) { if (profileListeners && profileListeners.length > 0) { var index = profileListeners.indexOf(cb); if (index !== -1) { profileListeners.splice(cb, 1); } } }; function notifyProfileListeners() { profileListeners.forEach(function(cb) { Fireproof._nextTick(function() { cb(self.profile); }); }); } function authHandler(authData) { if (userListener) { // detach any previous user listener. userRef.off('value', userListener); userRef = null; userListener = null; } self.auth = authData; // get the user's profile object, if one exists if (self.auth && self.auth.provider !== 'anonymous' && self.auth.uid && $attrs.profilePath) { userRef = self.root.child($attrs.profilePath).child(self.auth.uid); userListener = userRef.on('value', function(snap) { self.profile = snap.val(); notifyProfileListeners(); }); } else { if (self.auth && self.auth.provider === 'custom' && self.auth.uid === null) { // superuser! self.profile = { super: true }; } else { // nobody. self.profile = null; } notifyProfileListeners(); } } function attachFireproof() { if (self.root) { // detach any remaining listeners here. self.root.offAuth(authHandler); self.root.off(); // clear the profile self.profile = null; notifyProfileListeners(); } self.root = new Fireproof(new Firebase($attrs.firebase)); self.root.onAuth(authHandler); } $attrs.$observe('firebase', attachFireproof); // always run attach at least once if ($attrs.firebase) { attachFireproof(); } $scope.$on('$destroy', function() { // remove all onProfile listeners. profileListeners = null; // detach onAuth listener. self.root.offAuth(authHandler); // detach all remaining listeners to prevent leaks. self.root.off(); // help out GC. userRef = null; userListener = null; }); }); angular.module('angular-fireproof.directives.authClick', [ 'angular-fireproof.directives.firebase' ]) .directive('authClick', function($log) { return { restrict: 'A', require: '^firebase', link: function(scope, el, attrs, firebase) { var authOK = false; firebase.onProfile(profileListener); function profileListener() { if (attrs.authCondition) { authOK = scope.$eval(attrs.authCondition, { $auth: firebase.$auth, $profile: firebase.$profile }); } else { // by default, we check to see if the user is authed at all. authOK = angular.isDefined(firebase.$auth) && firebase.$auth !== null && firebase.$auth.provider !== 'anonymous'; } } el.on('click', function() { if (authOK) { // auth check passed, perform the action scope.$eval(attrs.authClick, { $auth: firebase.$auth, $user: firebase.$user }); } else { // Perform login check, then the action if it passes. firebase.login() .then(function() { // auth check passed, perform the action scope.$eval(attrs.authClick, { $auth: firebase.$auth, $user: firebase.$user }); }, function(err) { // auth check FAILED, call the error handler if it exists. $log.debug(err); if (attrs.onAuthError) { scope.$eval(attrs.onAuthError, { $error: err }); } }); } }); scope.$on('$destroy', function() { // clear the listener. firebase.offProfile(profileListener); }); } }; }); angular.module('angular-fireproof.directives.authIf', [ 'angular-fireproof.directives.firebase' ]) .directive('authIf', function($animate) { return { restrict: 'A', transclude: 'element', scope: true, priority: 600, terminal: true, require: '^firebase', link: function(scope, el, attrs, firebase, transclude) { var block, childScope, previousElements; firebase.onProfile(profileListener); function profileListener() { var authOK; if (attrs.authIf) { authOK = scope.$eval(attrs.authIf, { $auth: firebase.auth, $profile: firebase.profile }); } else { // by default, we check to see if the user is authed at all. authOK = angular.isDefined(firebase.$auth) && firebase.$auth !== null && firebase.$auth.provider !== 'anonymous'; } if (authOK) { if (!childScope) { childScope = scope.$new(); transclude(childScope, function (clone) { clone[clone.length++] = document.createComment(' end authIf: ' + attrs.authIf + ' '); // Note: We only need the first/last node of the cloned nodes. // However, we need to keep the reference to the jqlite wrapper as it might be changed later // by a directive with templateUrl when its template arrives. block = { clone: clone }; $animate.enter(clone, el.parent(), el); }); } } else { if (previousElements) { previousElements.remove(); previousElements = null; } if (childScope) { childScope.$destroy(); childScope = null; } if (block) { previousElements = (function getBlockNodes(nodes) { var node = nodes[0]; var endNode = nodes[nodes.length - 1]; var blockNodes = [node]; do { node = node.nextSibling; if (!node) { break; } blockNodes.push(node); } while (node !== endNode); return angular.element(blockNodes); })(block.clone); $animate.leave(previousElements, function() { previousElements = null; }); block = null; } } } scope.$on('$destroy', function() { firebase.offProfile(profileListener); }); } }; }); // by default we need this directive to be invisible. write some CSS classes // to head right here. (function() { var css = '[auth-show]:not(.show) { display:none; }'; try { var head = document.getElementsByTagName('head')[0]; var s = document.createElement('style'); s.setAttribute('type', 'text/css'); if (s.styleSheet) { // IE s.styleSheet.cssText = css; } else { // others s.appendChild(document.createTextNode(css)); } head.appendChild(s); } catch(e) {} })(); angular.module('angular-fireproof.directives.authShow', [ 'angular-fireproof.directives.firebase' ]) .directive('authShow', function() { return { restrict: 'A', require: '^firebase', link: function(scope, el, attrs, firebase) { firebase.onProfile(profileListener); function profileListener() { var authOK; if (attrs.authShow) { authOK = scope.$eval(attrs.authShow, { $auth: firebase.auth, $profile: firebase.profile }); } else { // by default, we check to see if the user is authed at all. authOK = angular.isDefined(firebase.$auth) && firebase.auth !== null && firebase.auth.provider !== 'anonymous'; } if (authOK) { el.addClass('show'); } else { el.removeClass('show'); } } scope.$on('$destroy', function() { firebase.offProfile(profileListener); }); } }; }); angular.module('angular-fireproof.directives.firebase', [ 'angular-fireproof.controllers.FirebaseCtl', 'angular-fireproof.services.Fireproof' ]) .directive('firebase', function() { return { restrict: 'A', scope: true, controller: 'FirebaseCtl' }; }); angular.module('angular-fireproof.directives.fpBind', [ 'angular-fireproof.directives.firebase', 'angular-fireproof.services.status' ]) .directive('fpBind', function($q, _fireproofStatus) { return { restrict: 'A', scope: true, require: '^firebase', link: function(scope, el, attrs, firebase) { var fpWatcher, scopeWatchCancel, currentSnap, firstLoad; function loadOK(snap) { if (scopeWatchCancel) { scopeWatchCancel(); } currentSnap = snap; if (!firstLoad) { firstLoad = true; _fireproofStatus.finish(firebase.root.child(attrs.fpBind).toString()); } delete scope.$fireproofError; scope[attrs.as] = currentSnap.val(); if (attrs.sync) { scopeWatchCancel = scope.$watch(attrs.as, function(newVal) { if (newVal !== undefined && !angular.equals(newVal, currentSnap.val())) { scope.$sync(newVal); } }); } if (attrs.onLoad) { scope.$eval(attrs.onLoad, { '$snap': snap }); } scope.$syncing = false; } function loadError(err) { currentSnap = null; if (!firstLoad) { firstLoad = true; _fireproofStatus.finish(firebase.root.child(attrs.fpBind).toString()); } scope.$fireproofError = err; scope.syncing = false; var code = err.code.toLowerCase().replace(/[^a-z]/g, '-'); var lookup = attrs.$attr['on-' + code]; if (attrs[lookup]) { scope.$eval(attrs[lookup], { '$error': err }); } else if (attrs.onError) { scope.$eval(attrs.onError, { '$error': err }); } } scope.$reload = function() { if (!scope.$syncing) { firstLoad = false; delete scope.$fireproofError; scope.$syncing = true; if (fpWatcher) { firebase.root.child(attrs.fpBind).off('value', fpWatcher); } if (scopeWatchCancel) { scopeWatchCancel(); } _fireproofStatus.start(firebase.root.child(attrs.fpBind).toString()); if (attrs.watch) { fpWatcher = firebase.root.child(attrs.fpBind) .on('value', loadOK, loadError); } else { firebase.root.child(attrs.fpBind) .once('value', loadOK, loadError); } } }; // this function is a no-op if sync is set. scope.$revert = function() { if (!attrs.sync && currentSnap) { scope[attrs.as] = currentSnap.val(); } }; scope.$sync = function() { if (!scope.$syncing) { scope.$syncing = true; delete scope.$fireproofError; // if there's another location we're supposed to save to, // save there also var savePromises = []; savePromises.push(firebase.root.child(attrs.fpBind) .set(scope[attrs.as])); if (attrs.linkTo) { savePromises.push( firebase.root.child(attrs.linkTo) .set(scope[attrs.as])); } return $q.all(savePromises) .then(function() { if (attrs.onSave) { scope.$eval(attrs.onSave); } }) .catch(function(err) { scope.$fireproofError = err; var code = err.code.toLowerCase().replace(/[^a-z]/g, '-'); var lookup = attrs.$attr['on-' + code]; if (attrs[lookup]) { scope.$eval(attrs[lookup], { '$error': err }); } else if (attrs.onError) { scope.$eval(attrs.onError, { '$error': err }); } }) .finally(function() { scope.$syncing = false; }); } }; attrs.$observe('fpBind', function(path) { delete scope.$fireproofError; if (path[path.length-1] === '/') { // this is an incomplete eval. we're done. return; } else if (!attrs.as) { throw new Error('Missing "as" attribute on fp-bind="' + attrs.fpBind + '"'); } // shut down everything. if (scopeWatchCancel) { scopeWatchCancel(); } scope.$reload(); }); scope.$on('$destroy', function() { // if this scope object is destroyed, finalize the controller and // cancel the Firebase watcher if one exists if (fpWatcher) { firebase.root.child(attrs.fpBind).off('value', fpWatcher); } if (scopeWatchCancel) { scopeWatchCancel(); } // finalize as a favor to GC fpWatcher = null; scopeWatchCancel = null; currentSnap = null; }); scope.$watch('$syncing', function(syncing) { if (syncing) { el.addClass('syncing'); } else { el.removeClass('syncing'); } }); scope.$watch('$fireproofError', function(error, oldError) { var code; if (oldError) { code = oldError.code.toLowerCase().replace(/\W/g, '-'); el.removeClass('fireproof-error-' + code); } if (error) { code = error.code.toLowerCase().replace(/\W/g, '-'); el.addClass('fireproof-error-' + code); } }); } }; }); angular.module('angular-fireproof.directives.fpPage', [ 'angular-fireproof.directives.firebase', 'angular-fireproof.services.status' ]) .directive('fpPage', function($q) { return { restrict: 'A', scope: true, require: '^firebase', link: function(scope, el, attrs, fireproof) { var ref, pager, paging; function handleSnaps(snaps) { paging = false; if (snaps.length > 0) { scope[attrs.as] = snaps.map(function(snap) { return snap.val(); }); scope[attrs.as].$keys = snaps.map(function(snap) { return snap.name(); }); scope[attrs.as].$priorities = snaps.map(function(snap) { return snap.getPriority(); }); scope[attrs.as].$next = function() { scope.$next(); }; scope[attrs.as].$previous = function() { scope.$previous(); }; scope[attrs.as].$reset = function() { scope.$reset(); }; if (attrs.onPage) { scope.$eval(attrs.onPage, { '$snaps': snaps }); } } return snaps; } function handleError(err) { paging = false; var code = err.code.toLowerCase().replace(/[^a-z]/g, '-'); var lookup = attrs.$attr['on-' + code]; if (attrs[lookup]) { scope.$eval(attrs[lookup], { '$error': err }); } else if (attrs.onError) { scope.$eval(attrs.onError, { '$error': err }); } } scope.$next = function() { if (scope.$hasNext && !paging) { paging = true; var limit; if (attrs.limit) { limit = parseInt(scope.$eval(attrs.limit)); } else { limit = 5; } return pager.next(limit) .then(handleSnaps, handleError) .then(function(snaps) { scope.$hasPrevious = true; scope.$hasNext = snaps.length > 0; return snaps; }); } else if (paging) { return $q.reject(new Error('cannot call $next from here, no more objects')); } else { return $q.reject(new Error('cannot call $next from here, no more objects')); } }; scope.$previous = function() { // set position to the first item in the current list if (scope.$hasPrevious && !paging) { paging = true; var limit; if (attrs.limit) { limit = parseInt(scope.$eval(attrs.limit)); } else { limit = 5; } return pager.previous(limit) .then(handleSnaps, handleError) .then(function(snaps) { scope.$hasNext = true; scope.$hasPrevious = snaps.length > 0; return snaps; }); } else { return $q.reject(new Error('cannot call $next from here, no more objects')); } }; scope.$reset = function() { scope.$hasNext = true; scope.$hasPrevious = true; // create the pager. var limit; if (attrs.limit) { limit = parseInt(scope.$eval(attrs.limit)); } else { limit = 5; } pager = new Fireproof.Pager(ref, limit); if (attrs.startAtPriority && attrs.startAtName) { pager.setPosition( scope.$eval(attrs.startAtPriority), scope.$eval(attrs.startAtName)); } else if (attrs.startAtPriority) { pager.setPosition(scope.$eval(attrs.startAtPriority)); } // pull the first round of results out of the pager. paging = true; return pager.then(handleSnaps, handleError) .then(function(snaps) { scope.$hasPrevious = false; scope.$hasNext = true; return snaps; }); }; attrs.$observe('fpPage', function(path) { if (path[path.length-1] === '/') { // this is an incomplete eval. we're done. return; } else if (!attrs.as) { throw new Error('Missing "as" attribute on fp-page="' + attrs.fpPage + '"'); } ref = fireproof.root.child(path); // shut down everything. scope.$reset(); }); scope.$on('$destroy', function() { // if this scope object is destroyed, finalize the controller ref = null; pager = null; }); scope.$watch('$syncing', function(syncing) { if (syncing) { el.addClass('syncing'); } else { el.removeClass('syncing'); } }); scope.$watch('$fireproofError', function(error, oldError) { if (oldError) { el.removeClass('fireproof-error-' + oldError.code); } if (error) { el.addClass('fireproof-error-' + error.code); } }); } }; }); angular.module('angular-fireproof.services.Fireproof', []) .factory('Firebase', function() { return Firebase; }) .factory('ServerValue', function(Firebase) { return Firebase.ServerValue; }) .factory('Fireproof', function($timeout, $q) { Fireproof.setNextTick($timeout); Fireproof.bless($q); return Fireproof; }); angular.module('angular-fireproof.services.status', []) .service('_fireproofStatus', function($timeout, $rootScope) { var service = this; function reset() { service.loaded = false; service.running = {}; service.finished = {}; } reset(); service.start = function(path) { if (service.running[path]) { service.running[path]++; } else { service.running[path] = 1; } }; service.finish = function(path) { if (!angular.isDefined(service.running[path])) { throw new Error('Path ' + path + ' is not actually running right now -- race condition?'); } service.running[path]--; if (service.running[path] === 0) { delete service.running[path]; service.finished[path] = true; $timeout(function() { // how many operations are left? var done = true; angular.forEach(service.running, function() { done = false; }); if (done && !service.loaded) { service.loaded = true; // signal loaded $rootScope.$broadcast('angular-fireproof:loaded', service); } }, 10); } }; }); }));