/* * AngularJs Fullcalendar Wrapper for the JQuery FullCalendar * API @ http://arshaw.com/fullcalendar/ * * Angular Calendar Directive that takes in the [eventSources] nested array object as the ng-model and watches it deeply changes. * Can also take in multiple event urls as a source object(s) and feed the events per view. * The calendar will watch any eventSource array and update itself when a change is made. * */ angular.module('ui.calendar', []) .constant('uiCalendarConfig', {calendars: {}}) .controller('uiCalendarCtrl', ['$scope', '$locale', function( $scope, $locale){ var sources = $scope.eventSources, extraEventSignature = $scope.calendarWatchEvent ? $scope.calendarWatchEvent : angular.noop, wrapFunctionWithScopeApply = function(functionToWrap){ return function(){ // This may happen outside of angular context, so create one if outside. if ($scope.$root.$$phase) { return functionToWrap.apply(this, arguments); } else { var args = arguments; var self = this; return $scope.$root.$apply(function(){ return functionToWrap.apply(self, args); }); } }; }; var eventSerialId = 1; // @return {String} fingerprint of the event object and its properties this.eventFingerprint = function(e) { if (!e._id) { e._id = eventSerialId++; } var extraSignature = extraEventSignature({event: e}) || ''; var start = moment.isMoment(e.start) ? e.start.unix() : (e.start ? moment(e.start).unix() : ''); var end = moment.isMoment(e.end) ? e.end.unix() : (e.end ? moment(e.end).unix() : ''); // This extracts all the information we need from the event. http://jsperf.com/angular-calendar-events-fingerprint/3 return "" + e._id + (e.id || '') + (e.title || '') + (e.url || '') + start + end + (e.allDay || '') + (e.className || '') + extraSignature; }; var sourceSerialId = 1, sourceEventsSerialId = 1; // @return {String} fingerprint of the source object and its events array this.sourceFingerprint = function(source) { var fp = '' + (source.__id || (source.__id = sourceSerialId++)), events = angular.isObject(source) && source.events; if (events) { fp = fp + '-' + (events.__id || (events.__id = sourceEventsSerialId++)); } return fp; }; // @return {Array} all events from all sources this.allEvents = function() { // do sources.map(&:events).flatten(), but we don't have flatten var arraySources = []; for (var i = 0, srcLen = sources.length; i < srcLen; i++) { var source = sources[i]; if (angular.isArray(source)) { // event source as array arraySources.push(source); } else if(angular.isObject(source) && angular.isArray(source.events)){ // event source as object, ie extended form var extEvent = {}; for(var key in source){ if(key !== '_id' && key !== 'events'){ extEvent[key] = source[key]; } } for(var eI = 0;eI < source.events.length;eI++){ angular.extend(source.events[eI],extEvent); } arraySources.push(source.events); } } return Array.prototype.concat.apply([], arraySources); }; // Track changes in array of objects by assigning id tokens to each element and watching the scope for changes in the tokens // @param {Array|Function} arraySource array of objects to watch // @param tokenFn {Function} that returns the token for a given object // @return {Object} // subscribe: function(scope, function(newTokens, oldTokens)) // called when source has changed. return false to prevent individual callbacks from firing // onAdded/Removed/Changed: // when set to a callback, called each item where a respective change is detected this.changeWatcher = function(arraySource, tokenFn) { var self; var getTokens = function() { var array = angular.isFunction(arraySource) ? arraySource() : arraySource; var result = [], token, el; for (var i = 0, n = array.length; i < n; i++) { el = array[i]; token = tokenFn(el); map[token] = el; result.push(token); } return result; }; // @param {Array} a // @param {Array} b // @return {Array} elements in that are in a but not in b // @example // subtractAsSets([6, 100, 4, 5], [4, 5, 7]) // [6, 100] var subtractAsSets = function(a, b) { var result = [], inB = {}, i, n; for (i = 0, n = b.length; i < n; i++) { inB[b[i]] = true; } for (i = 0, n = a.length; i < n; i++) { if (!inB[a[i]]) { result.push(a[i]); } } return result; }; // Map objects to tokens and vice-versa var map = {}; // Compare newTokens to oldTokens and call onAdded, onRemoved, and onChanged handlers for each affected event respectively. var applyChanges = function(newTokens, oldTokens) { var i, n, el, token; var replacedTokens = {}; var removedTokens = subtractAsSets(oldTokens, newTokens); for (i = 0, n = removedTokens.length; i < n; i++) { var removedToken = removedTokens[i]; el = map[removedToken]; delete map[removedToken]; var newToken = tokenFn(el); // if the element wasn't removed but simply got a new token, its old token will be different from the current one if (newToken === removedToken) { self.onRemoved(el); } else { replacedTokens[newToken] = removedToken; self.onChanged(el); } } var addedTokens = subtractAsSets(newTokens, oldTokens); for (i = 0, n = addedTokens.length; i < n; i++) { token = addedTokens[i]; el = map[token]; if (!replacedTokens[token]) { self.onAdded(el); } } }; return self = { subscribe: function(scope, onArrayChanged) { scope.$watch(getTokens, function(newTokens, oldTokens) { var notify = !(onArrayChanged && onArrayChanged(newTokens, oldTokens) === false); if (notify) { applyChanges(newTokens, oldTokens); } }, true); }, onAdded: angular.noop, onChanged: angular.noop, onRemoved: angular.noop }; }; this.getFullCalendarConfig = function(calendarSettings, uiCalendarConfig){ var config = {}; angular.extend(config, uiCalendarConfig); angular.extend(config, calendarSettings); angular.forEach(config, function(value,key){ if (typeof value === 'function'){ config[key] = wrapFunctionWithScopeApply(config[key]); } }); return config; }; this.getLocaleConfig = function(fullCalendarConfig) { if (!fullCalendarConfig.lang || fullCalendarConfig.useNgLocale) { // Configure to use locale names by default var tValues = function(data) { // convert {0: "Jan", 1: "Feb", ...} to ["Jan", "Feb", ...] var r, k; r = []; for (k in data) { r[k] = data[k]; } return r; }; var dtf = $locale.DATETIME_FORMATS; return { monthNames: tValues(dtf.MONTH), monthNamesShort: tValues(dtf.SHORTMONTH), dayNames: tValues(dtf.DAY), dayNamesShort: tValues(dtf.SHORTDAY) }; } return {}; }; }]) .directive('uiCalendar', ['uiCalendarConfig', function(uiCalendarConfig) { return { restrict: 'A', scope: {eventSources:'=ngModel',calendarWatchEvent: '&'}, controller: 'uiCalendarCtrl', link: function(scope, elm, attrs, controller) { var sources = scope.eventSources, sourcesChanged = false, calendar, eventSourcesWatcher = controller.changeWatcher(sources, controller.sourceFingerprint), eventsWatcher = controller.changeWatcher(controller.allEvents, controller.eventFingerprint), options = null; function getOptions(){ var calendarSettings = attrs.uiCalendar ? scope.$parent.$eval(attrs.uiCalendar) : {}, fullCalendarConfig; fullCalendarConfig = controller.getFullCalendarConfig(calendarSettings, uiCalendarConfig); var localeFullCalendarConfig = controller.getLocaleConfig(fullCalendarConfig); angular.extend(localeFullCalendarConfig, fullCalendarConfig); options = { eventSources: sources }; angular.extend(options, localeFullCalendarConfig); //remove calendars from options options.calendars = null; var options2 = {}; for(var o in options){ if(o !== 'eventSources'){ options2[o] = options[o]; } } return JSON.stringify(options2); } scope.destroyCalendar = function(){ if(calendar && calendar.fullCalendar){ calendar.fullCalendar('destroy'); } if(attrs.calendar) { calendar = uiCalendarConfig.calendars[attrs.calendar] = $(elm).html(''); } else { calendar = $(elm).html(''); } }; scope.initCalendar = function(){ if (!calendar) { calendar = angular.element(elm).html(''); } calendar.fullCalendar(options); if(attrs.calendar) { uiCalendarConfig.calendars[attrs.calendar] = calendar; } }; scope.$on('$destroy', function() { scope.destroyCalendar(); }); eventSourcesWatcher.onAdded = function(source) { if (calendar && calendar.fullCalendar) { calendar.fullCalendar(options); if (attrs.calendar) { uiCalendarConfig.calendars[attrs.calendar] = calendar; } calendar.fullCalendar('addEventSource', source); sourcesChanged = true; } }; eventSourcesWatcher.onRemoved = function(source) { if (calendar && calendar.fullCalendar) { calendar.fullCalendar('removeEventSource', source); sourcesChanged = true; } }; eventSourcesWatcher.onChanged = function() { if (calendar && calendar.fullCalendar) { calendar.fullCalendar('refetchEvents'); sourcesChanged = true; } }; eventsWatcher.onAdded = function(event) { if (calendar && calendar.fullCalendar) { calendar.fullCalendar('renderEvent', event, (event.stick ? true : false)); } }; eventsWatcher.onRemoved = function(event) { if (calendar && calendar.fullCalendar) { calendar.fullCalendar('removeEvents', event._id); } }; eventsWatcher.onChanged = function(event) { if (calendar && calendar.fullCalendar) { var clientEvents = calendar.fullCalendar('clientEvents', event._id); for (var i = 0; i < clientEvents.length; i++) { var clientEvent = clientEvents[i]; clientEvent = angular.extend(clientEvent, event); calendar.fullCalendar('updateEvent', clientEvent); } } }; eventSourcesWatcher.subscribe(scope); eventsWatcher.subscribe(scope, function() { if (sourcesChanged === true) { sourcesChanged = false; // return false to prevent onAdded/Removed/Changed handlers from firing in this case return false; } }); scope.$watch(getOptions, function(newValue, oldValue) { if(newValue !== oldValue) { scope.destroyCalendar(); scope.initCalendar(); } else if((newValue && angular.isUndefined(calendar))) { scope.initCalendar(); } }); } }; }]);