/* * 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; var extraEventSignature = $scope.calendarWatchEvent ? $scope.calendarWatchEvent : angular.noop; var 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); } var args = arguments; var that = this; return $scope.$root.$apply( function () { return functionToWrap.apply(that, 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].join(''); }; var sourceSerialId = 1; var sourceEventsSerialId = 1; // @return {String} fingerprint of the source object and its events array this.sourceFingerprint = function (source) { var fp = '' + (source.__id || (source.__id = sourceSerialId++)); var 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 () { return Array.prototype.concat.apply( [], (sources || []).reduce( function (previous, source) { if (angular.isArray(source)) { previous.push(source); } else if (angular.isObject(source) && angular.isArray(source.events)) { var extEvent = Object.keys(source).filter( function (key) { return (key !== '_id' && key !== 'events'); } ); source.events.forEach( function (event) { angular.extend(event, extEvent); } ); previous.push(source.events); } return previous; }, [] ) ); }; // 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 () { return ((angular.isFunction(arraySource) ? arraySource() : arraySource) || []).reduce( function (rslt, el) { var token = tokenFn(el); map[token] = el; rslt.push(token); return rslt; }, [] ); }; // @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 obj = (b || []).reduce( function (rslt, val) { rslt[val] = true; return rslt; }, Object.create(null) ); return (a || []).filter( function (val) { return !obj[val]; } ); }; // 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; var token; var replacedTokens = {}; var removedTokens = subtractAsSets(oldTokens, newTokens); for (i = 0; i < removedTokens.length; i++) { var removedToken = removedTokens[i]; var 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; i < addedTokens.length; i++) { token = addedTokens[i]; if (!replacedTokens[token]) { self.onAdded(map[token]); } } }; 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 }; return self; }; 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.locale || fullCalendarConfig.useNgLocale) { // Configure to use locale names by default var tValues = function (data) { // convert {0: "Jan", 1: "Feb", ...} to ["Jan", "Feb", ...] return (Object.keys(data) || []).reduce( function (rslt, el) { rslt.push(data[el]); return rslt; }, [] ); }; 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; var sourcesChanged = false; var calendar; var eventSourcesWatcher = controller.changeWatcher(sources, controller.sourceFingerprint); var eventsWatcher = controller.changeWatcher(controller.allEvents, controller.eventFingerprint); var options = null; function getOptions () { var calendarSettings = attrs.uiCalendar ? scope.$parent.$eval(attrs.uiCalendar) : {}; var 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] = angular.element(elm).html(''); } else { calendar = angular.element(elm).html(''); } }; scope.initCalendar = function () { if (!calendar) { calendar = $(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); } }; 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(); } }); } }; } ] );