diff --git a/UI/Templates/SchedulerUI/UIxAppointmentEditorTemplate.wox b/UI/Templates/SchedulerUI/UIxAppointmentEditorTemplate.wox index c97b722df..b591d742e 100644 --- a/UI/Templates/SchedulerUI/UIxAppointmentEditorTemplate.wox +++ b/UI/Templates/SchedulerUI/UIxAppointmentEditorTemplate.wox @@ -151,6 +151,43 @@ + +
+
+ +
+ + + +
+
+ + + +
+
+ +
+ + + {{card.$$fullname}} {{card.$$email}} + +
diff --git a/UI/Templates/SchedulerUI/UIxAttendeesEditor.wox b/UI/Templates/SchedulerUI/UIxAttendeesEditor.wox index 0c7754ca9..0bea9f2ac 100644 --- a/UI/Templates/SchedulerUI/UIxAttendeesEditor.wox +++ b/UI/Templates/SchedulerUI/UIxAttendeesEditor.wox @@ -1,141 +1,57 @@ - - -
-
-
+ -
-
- - - - - - - - - - - - - - - - - -
- -
- - - - - - - - - - - - - -
-
- - - -
-
-
- - - - -
-
-
- - - - -
-
-
-
-
-
    -
  • -
  • -
  • -
  • -
- -
    -
  • -
  • - - -
  • -
-
-
-
-
-
-
- - -
-
-
-
+ + + + +
+
+ + + + +
+
{{currentAttendee.name}}
+
{{currentAttendee.email}}
+
+
+
+ + + +
{{day.stringWithSeparator}}
+
+
{{hour}}
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ diff --git a/UI/WebServerResources/js/Common/utils.js b/UI/WebServerResources/js/Common/utils.js index e425d5c54..7a6f68c6c 100644 --- a/UI/WebServerResources/js/Common/utils.js +++ b/UI/WebServerResources/js/Common/utils.js @@ -1,3 +1,5 @@ +/* -*- Mode: javascript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ + String.prototype.endsWith = function(suffix) { return this.indexOf(suffix, this.length - suffix.length) !== -1; }; @@ -101,6 +103,52 @@ String.prototype.asDate = function () { return newDate; }; +String.prototype.formatTime = function(hours, minutes) { + var newString = this; + + // See http://www.gnustep.org/resources/documentation/Developer/Base/Reference/NSCalendarDate.html#method$NSCalendarDate-descriptionWithCalendarFormat$ + var p = 'am', i = hours, m = minutes; + if (hours > 12) { + p = 'pm'; + i = hours % 12; + } + if (minutes < 10) { + m = '0' + minutes; + } + + // %H : hour as a decimal number using 24-hour clock + newString = newString.replace("%H", hours < 10 ? '0' + hours : hours); + // %I : hour as a decimal number using 12-hour clock + newString = newString.replace("%I", i < 10 ? '0' + i : i); + // %M : minute as decimal number + newString = newString.replace("%M", m); + // %p : 'am' or 'pm' + newString = newString.replace("%p", p); + + return newString; +}; + +Date.prototype.daysUpTo = function(otherDate) { + var days = new Array(); + + var day1 = this.getTime(); + var day2 = otherDate.getTime(); + if (day1 > day2) { + var tmp = day1; + day1 = day2; + day2 = tmp; + } + + var nbrDays = Math.round((day2 - day1) / 86400000) + 1; + for (var i = 0; i < nbrDays; i++) { + var newDate = new Date(); + newDate.setTime(day1 + (i * 86400000)); + days.push(newDate); + } + + return days; +}; + String.prototype.asCSSIdentifier = function() { var characters = [ '_' , '\\.', '#' , '@' , '\\*', ':' , ',' , ' ' , "'", '&', '\\+' ]; @@ -145,6 +193,18 @@ Date.prototype.addDays = function(nbrDays) { this.setTime(milliSeconds); }; +Date.prototype.addHours = function(nbrHours) { + var milliSeconds = this.getTime(); + milliSeconds += 3600000 * nbrHours; + this.setTime(milliSeconds); +}; + +Date.prototype.addMinutes = function(nbrMinutes) { + var milliSeconds = this.getTime(); + milliSeconds += 60000 * nbrMinutes; + this.setTime(milliSeconds); +}; + Date.prototype.beginOfDay = function() { var beginOfDay = new Date(this.getTime()); beginOfDay.setHours(0); diff --git a/UI/WebServerResources/js/Scheduler/Component.service.js b/UI/WebServerResources/js/Scheduler/Component.service.js index f376c5fd9..bc3acaa0c 100644 --- a/UI/WebServerResources/js/Scheduler/Component.service.js +++ b/UI/WebServerResources/js/Scheduler/Component.service.js @@ -25,7 +25,7 @@ this.$unwrap(futureComponentData); } } - + /** * @memberof Component * @desc The factory we'll use to register with Angular @@ -40,6 +40,11 @@ $categories: window.UserDefaults.SOGoCalendarCategoriesColors }); + if (window.UserDefaults && window.UserDefaults.SOGoTimeFormat) + Component.timeFormat = window.UserDefaults.SOGoTimeFormat; + else + Component.timeFormat = "%H:%M"; + return Component; // return constructor }]; @@ -236,10 +241,17 @@ * @param {object} data - attributes of component */ Component.prototype.init = function(data) { + var _this = this; + this.categories = []; this.repeat = {}; angular.extend(this, data); + if (this.startDate) + this.start = new Date(this.startDate.substring(0,10) + ' ' + this.startDate.substring(11,16)); + if (this.endDate) + this.end = new Date(this.endDate.substring(0,10) + ' ' + this.endDate.substring(11,16)); + // Parse recurrence rule definition and initialize default values if (this.repeat.days) { var byDayMask = _.find(this.repeat.days, function(o) { @@ -281,8 +293,23 @@ // Allow the event to be moved to a different calendar this.destinationCalendar = this.pid; + + // Load freebusy of attendees + this.freebusy = this.updateFreeBusyCoverage(); + + if (this.attendees) { + _.each(this.attendees, function(attendee) { + _this.updateFreeBusy(attendee); + }); + } }; + /** + * @function hasCustomRepeat + * @memberof Component.prototype + * @desc Check if the component has a custom recurrence rule. + * @returns true if the the recurrence rule requires the full recurrence editor + */ Component.prototype.hasCustomRepeat = function() { var b = angular.isDefined(this.repeat) && (this.repeat.interval > 1 || @@ -292,6 +319,114 @@ return b; }; + /** + * @function coversFreeBusy + * @memberof Component.prototype + * @desc Check if a specific quarter matches the component's period + * @returns true if the quarter covers the component's period + */ + Component.prototype.coversFreeBusy = function(day, hour, quarter) { + var b = (angular.isDefined(this.freebusy[day]) && + angular.isDefined(this.freebusy[day][hour]) && + this.freebusy[day][hour][quarter] == 1); + return b; + }; + + /** + * @function updateFreeBusyCoverage + * @memberof Component.prototype + * @desc Build a 15-minute-based representation of the component's period. + * @returns an object literal hashed by days and hours and arrays of four 1's and 0's + */ + Component.prototype.updateFreeBusyCoverage = function() { + var _this = this, freebusy = {}; + + if (this.start && this.end) { + var roundedStart = new Date(this.start.getTime()), + roundedEnd = new Date(this.end.getTime()), + startQuarter = parseInt(roundedStart.getMinutes()/15 + 0.5), + endQuarter = parseInt(roundedEnd.getMinutes()/15 + 0.5); + roundedStart.setMinutes(15*startQuarter); + roundedEnd.setMinutes(15*endQuarter); + + _.each(roundedStart.daysUpTo(roundedEnd), function(date, index) { + var currentDay = date.getDate(), + dayKey = date.getDayString(), + hourKey; + if (dayKey == _this.start.getDayString()) { + hourKey = date.getHours().toString(); + freebusy[dayKey] = {}; + freebusy[dayKey][hourKey] = []; + while (startQuarter > 0) { + freebusy[dayKey][hourKey].push(0); + startQuarter--; + } + } + else { + date = date.beginOfDay(); + freebusy[dayKey] = {}; + } + while (date.getTime() < _this.end.getTime() && + date.getDate() == currentDay) { + hourKey = date.getHours().toString(); + if (angular.isUndefined(freebusy[dayKey][hourKey])) + freebusy[dayKey][hourKey] = []; + freebusy[dayKey][hourKey].push(1); + date.addMinutes(15); + } + }); + return freebusy; + } + }; + + /** + * @function updateFreeBusy + * @memberof Component.prototype + * @desc Update the freebusy information for the component's period for a specific attendee. + * @param {Object} card - an Card object instance of the attendee + */ + Component.prototype.updateFreeBusy = function(attendee) { + var params, url, days; + if (attendee.uid) { + params = + { + sday: this.start.getDayString(), + eday: this.end.getDayString() + }; + url = ['..', '..', attendee.uid, 'freebusy.ifb']; + days = _.map(this.start.daysUpTo(this.end), function(day) { return day.getDayString(); }); + + if (angular.isUndefined(attendee.freebusy)) + attendee.freebusy = {}; + + // Fetch FreeBusy information + Component.$$resource.fetch(url.join('/'), 'ajaxRead', params).then(function(data) { + _.each(days, function(day) { + var hour; + + if (angular.isUndefined(attendee.freebusy[day])) + attendee.freebusy[day] = {}; + + if (angular.isUndefined(data[day])) + data[day] = {}; + + for (var i = 0; i <= 23; i++) { + hour = i.toString(); + if (data[day][hour]) + attendee.freebusy[day][hour] = [ + data[day][hour]["0"], + data[day][hour]["15"], + data[day][hour]["30"], + data[day][hour]["45"] + ]; + else + attendee.freebusy[day][hour] = [0, 0, 0, 0]; + } + }); + }); + } + }; + /** * @function getClassName * @memberof Component.prototype @@ -305,6 +440,34 @@ return base + '-folder' + (this.destinationCalendar || this.c_folder); }; + /** + * @function addAttendee + * @memberof Component.prototype + * @desc Add an attendee and fetch his freebusy info. + * @param {Object} card - an Card object instance to be added to the attendees list + */ + Component.prototype.addAttendee = function(card) { + var attendee, url, params; + if (card) { + attendee = { + name: card.c_cn, + email: card.$preferredEmail(), + role: 'req-participant', + status: 'needs-action', + uid: card.c_uid + }; + if (!_.find(this.attendees, function(o) { + return o.email == attendee.email; + })) { + if (this.attendees) + this.attendees.push(attendee); + else + this.attendees = [attendee]; + this.updateFreeBusy(attendee); + } + } + }; + /** * @function $reset * @memberof Component.prototype @@ -343,7 +506,7 @@ /** * @function $unwrap * @memberof Component.prototype - * @desc Unwrap a promise. + * @desc Unwrap a promise. * @param {promise} futureComponentData - a promise of some of the Component's data */ Component.prototype.$unwrap = function(futureComponentData) { diff --git a/UI/WebServerResources/js/Scheduler/ComponentController.js b/UI/WebServerResources/js/Scheduler/ComponentController.js index 35bbc6081..533704dae 100644 --- a/UI/WebServerResources/js/Scheduler/ComponentController.js +++ b/UI/WebServerResources/js/Scheduler/ComponentController.js @@ -6,8 +6,8 @@ /** * @ngInject */ - ComponentController.$inject = ['$scope', '$log', '$timeout', '$state', '$previousState', '$mdSidenav', '$mdDialog', 'Calendar', 'Component', 'stateCalendars', 'stateComponent']; - function ComponentController($scope, $log, $timeout, $state, $previousState, $mdSidenav, $mdDialog, Calendar, Component, stateCalendars, stateComponent) { + ComponentController.$inject = ['$scope', '$log', '$q', '$timeout', '$state', '$previousState', '$mdSidenav', '$mdDialog', 'User', 'Calendar', 'Component', 'AddressBook', 'Card', 'stateCalendars', 'stateComponent']; + function ComponentController($scope, $log, $q, $timeout, $state, $previousState, $mdSidenav, $mdDialog, User, Calendar, Component, AddressBook, Card, stateCalendars, stateComponent) { var vm = this; vm.calendars = stateCalendars; @@ -15,8 +15,19 @@ vm.categories = {}; vm.showRecurrenceEditor = vm.event.$hasCustomRepeat; vm.toggleRecurrenceEditor = toggleRecurrenceEditor; + vm.showAttendeesEditor = angular.isDefined(vm.event.attendees); + vm.toggleAttendeesEditor = toggleAttendeesEditor; + vm.cardFilter = cardFilter; + vm.cardResults = []; + vm.addAttendee = addAttendee; vm.cancel = cancel; vm.save = save; + vm.attendeesEditor = { + startDate: vm.event.startDate, + endDate: vm.event.endDate, + days: getDays(), + hours: getHours() + }; // Open sidenav when loading the view; // Return to previous state when closing the sidenav. @@ -36,11 +47,59 @@ }, 100); // don't ask why }); + $scope.$watch('editor.event.startDate', function(newStartDate, oldStartDate) { + if (newStartDate) { + $timeout(function() { + vm.event.start = new Date(newStartDate.substring(0,10) + ' ' + newStartDate.substring(11,16)); + vm.event.freebusy = vm.event.updateFreeBusyCoverage(); + vm.attendeesEditor.days = getDays(); + }); + } + }); + + $scope.$watch('editor.event.endDate', function(newEndDate, oldEndDate) { + if (newEndDate) { + $timeout(function() { + vm.event.end = new Date(newEndDate.substring(0,10) + ' ' + newEndDate.substring(11,16)); + vm.event.freebusy = vm.event.updateFreeBusyCoverage(); + vm.attendeesEditor.days = getDays(); + }); + } + }); + function toggleRecurrenceEditor() { vm.showRecurrenceEditor = !vm.showRecurrenceEditor; vm.event.$hasCustomRepeat = vm.showRecurrenceEditor; } + function toggleAttendeesEditor() { + vm.showAttendeesEditor = !vm.showAttendeesEditor; + } + + // Autocomplete cards for attendees + function cardFilter($query) { + if ($query) { + AddressBook.$filterAll($query).then(function(results) { + vm.cardResults.splice(0, vm.cardResults.length); + _.each(results, function(card) { + // TODO don't show cards matching an attendee's email address + vm.cardResults.push(card); + }); + }); + } + return vm.cardResults; + } + + function addAttendee(card) { + if (angular.isString(card)) { + // User pressed "Enter" in search field, adding a non-matching card + // TODO: only create card if the string is an email address + card = new Card({ emails: [{ value: card }] }); + vm.searchText = ''; + } + vm.event.addAttendee(card); + } + function save(form) { if (form.$valid) { vm.event.$save() @@ -61,6 +120,27 @@ } $mdSidenav('right').close(); } + + function getDays() { + var days = []; + + if (vm.event.start && vm.event.end) + days = vm.event.start.daysUpTo(vm.event.end); + + return _.map(days, function(date) { + return { stringWithSeparator: date.stringWithSeparator(), + getDayString: date.getDayString() }; + }); + } + + function getHours() { + var hours = []; + for (var i = 0; i <= 23; i++) { + //hours.push(Component.timeFormat.formatTime(i, 0)); + hours.push(i.toString()); + } + return hours; + } } angular diff --git a/UI/WebServerResources/scss/components/list/list.scss b/UI/WebServerResources/scss/components/list/list.scss index 913e28b16..7e31ab14f 100644 --- a/UI/WebServerResources/scss/components/list/list.scss +++ b/UI/WebServerResources/scss/components/list/list.scss @@ -80,6 +80,13 @@ div.md-tile-left { height:(7 * $line); } +.sg-avatars { + margin: ($mg / 2) 0 0 ($mg / 2); + img { + border-radius: 100%; + margin-right: ($mg / 2); + } +} // Avatar placeholder // ------------------------------------ .md-tile-left:before { diff --git a/UI/WebServerResources/scss/views/SchedulerUI.scss b/UI/WebServerResources/scss/views/SchedulerUI.scss index e382f23e7..cd557bf00 100644 --- a/UI/WebServerResources/scss/views/SchedulerUI.scss +++ b/UI/WebServerResources/scss/views/SchedulerUI.scss @@ -50,11 +50,14 @@ $hours_margin: 50px; } .sg-calendar-tile-header { - color: sg-color($sogoPaper, 800); - font-size: $sg-font-size-2; - font-weight: $sg-font-light; - overflow: hidden; - padding: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: sg-color($sogoPaper, 800); + font-size: $sg-font-size-2; + font-weight: $sg-font-light; + overflow: hidden; + padding: 2px; } .daysView { @@ -156,3 +159,56 @@ $hours_margin: 50px; min-height: 15px; /* for 15-minute events */ width: 100%; } + +/* Attendees Editor */ +.attendees { + overflow: hidden; + overflow-x: scroll; + md-content { + display: table-row; + } + md-list { + display: table-cell; + &.day { + min-width: 408px; + md-list-item { + padding: 0; + align-items: stretch; + } + } + md-list-item { + &:hover { + background-color: initial; + } + img { + margin-right: $mg/4; + } + } + .hours { + font-size: 9px; + } + .hour { + display: flex; + border-left: 1px solid sg-color($sogoPaper, 100); + min-width: 16px; + min-height: 16px; + flex-wrap: nowrap; + flex-grow: 0; + flex-basis: 17px; // hour's width + hour's border + align-items: stretch; + } + .quarter { + min-width: 4px; + display: flex; + align-items: stretch; + .busy { + margin: 8px 0; + min-width: 4px; + background-color: sg-color($sogoPaper, 600); + } + &.event { + background-color: sg-color($sogoBlue, 300); + } + } + } +} \ No newline at end of file