(feat) Handle invitations in appointment viewer

pull/95/merge
Francis Lachapelle 2015-08-05 16:44:25 -04:00
parent 87aec2fc01
commit 5e19a889c2
15 changed files with 518 additions and 199 deletions

View File

@ -200,7 +200,7 @@
"General" = "General"; "General" = "General";
"Reply" = "Reply"; "Reply" = "Reply";
"Created by" = "Created by"; "Created by" = "Created by";
"You are invited to participate" = "You are invited to participate";
"Target:" = "Target:"; "Target:" = "Target:";
@ -431,6 +431,7 @@ validate_endbeforestart = "The end date that you entered occurs before the st
"New Event" = "New Event"; "New Event" = "New Event";
"New Task" = "New Task"; "New Task" = "New Task";
"Edit" = "Edit"; "Edit" = "Edit";
"Update" = "Update";
"Delete" = "Delete"; "Delete" = "Delete";
"Go to Today" = "Go to Today"; "Go to Today" = "Go to Today";
"Day View" = "Day View"; "Day View" = "Day View";
@ -523,6 +524,8 @@ vtodo_class2 = "(Confidential task)";
"button_allOccurrences" = "All occurences"; "button_allOccurrences" = "All occurences";
"Edit This Occurrence" = "Edit This Occurrence"; "Edit This Occurrence" = "Edit This Occurrence";
"Edit All Occurrences" = "Edit All Occurrences"; "Edit All Occurrences" = "Edit All Occurrences";
"Update This Occurrence" = "Update This Occurrence";
"Update All Occurrences" = "Update All Occurrences";
/* Properties dialog */ /* Properties dialog */
"Name" = "Name"; "Name" = "Name";

View File

@ -237,55 +237,77 @@
} }
} }
// /**
// * @api {post} /so/:username/Calendar/:calendarId/:appointmentId/rsvpAppointment Set participation state
// * @apiVersion 1.0.0
* @apiName PostEventRsvp
* @apiGroup Calendar
* @apiDescription Set the participation state of an attendee.
* @apiExample {curl} Example usage:
* curl -i http://localhost/SOGo/so/sogo1/Calendar/personal/71B6-54904400-1-7C308500.ics/rsvpAppointment \
* -H 'Content-Type: application/json' \
* -d '{ "reply": 1, \
* "alarm": { { "quantity": 15, "unit": "MINUTES", "action": "display", "reference": "BEFORE", "relation": "START" } }'
*
* @apiParam {Number} reply 0 if needs-action, 1 if accepted, 2 if declined, 3 if tentative, 4 if delegated
* @apiParam {String} [delegatedTo] Email address of delegated attendee
* @apiParam {Object[]} [alarm] Set an alarm for the attendee
* @apiParam {String} alarm.action Either display or email
* @apiParam {Number} alarm.quantity Quantity of units
* @apiParam {String} alarm.unit Either MINUTES, HOURS, or DAYS
* @apiParam {String} alarm.reference Either BEFORE or AFTER
* @apiParam {String} alarm.relation Either START or END
* @apiParam {Boolean} [alarm.attendees] Alert attendees by email if 1 and action is email
* @apiParam {Boolean} [alarm.organizer] Alert organizer by email if 1 and action is email
*/
- (id <WOActionResults>) rsvpAction - (id <WOActionResults>) rsvpAction
{ {
iCalPerson *delegatedAttendee; iCalPerson *delegatedAttendee;
NSDictionary *message; NSDictionary *params, *jsonResponse;
WOResponse *response; WOResponse *response;
WORequest *request; WORequest *request;
iCalAlarm *anAlarm; iCalAlarm *anAlarm;
NSException *ex;
NSString *status; NSString *status;
id alarm;
int replyList, reminderList; int replyList;
request = [context request]; request = [context request];
message = [[request contentAsString] objectFromJSONString]; params = [[request contentAsString] objectFromJSONString];
delegatedAttendee = nil; delegatedAttendee = nil;
anAlarm = nil; anAlarm = nil;
status = nil; status = nil;
replyList = [[message objectForKey: @"replyList"] intValue]; replyList = [[params objectForKey: @"reply"] intValue];
switch (replyList) switch (replyList)
{ {
case 0: case iCalPersonPartStatAccepted:
status = @"ACCEPTED"; status = @"ACCEPTED";
break; break;
case 1: case iCalPersonPartStatDeclined:
status = @"DECLINED"; status = @"DECLINED";
break; break;
case 2: case iCalPersonPartStatNeedsAction:
status = @"NEEDS-ACTION"; status = @"NEEDS-ACTION";
break; break;
case 3: case iCalPersonPartStatTentative:
status = @"TENTATIVE"; status = @"TENTATIVE";
break; break;
case 4: case iCalPersonPartStatDelegated:
default: default:
{ {
NSString *delegatedEmail, *delegatedUid; NSString *delegatedEmail, *delegatedUid;
SOGoUser *user; SOGoUser *user;
status = @"DELEGATED"; status = @"DELEGATED";
delegatedEmail = [[message objectForKey: @"delegatedTo"] stringByTrimmingSpaces]; delegatedEmail = [[params objectForKey: @"delegatedTo"] stringByTrimmingSpaces];
if ([delegatedEmail length]) if ([delegatedEmail length])
{ {
@ -308,60 +330,58 @@
[NSString stringWithFormat: @"mailto:%@", [[user allEmails] objectAtIndex: 0]]]; [NSString stringWithFormat: @"mailto:%@", [[user allEmails] objectAtIndex: 0]]];
} }
else else
return [NSException exceptionWithHTTPStatus: 400 {
reason: @"missing 'to' parameter"]; jsonResponse = [NSDictionary dictionaryWithObjectsAndKeys:
@"failure", @"status",
@"missing 'delegatedTo' parameter", @"message",
nil];
return [self responseWithStatus: 400
andString: [jsonResponse jsonRepresentation]];
}
} }
break; break;
} }
// Extract the user alarm, if any // Set an alarm for the user
reminderList = [[message objectForKey: @"reminderList"] intValue]; alarm = [params objectForKey: @"alarm"];
if ([alarm isKindOfClass: [NSDictionary class]])
{
NSString *reminderAction, *reminderUnit, *reminderQuantity, *reminderReference, *reminderRelation;
BOOL reminderEmailAttendees, reminderEmailOrganizer;
if ([[message objectForKey: @"reminderList"] isEqualToString: @"WONoSelectionString"] || reminderList == 5 || reminderList == 10 || reminderList == 14) reminderAction = [alarm objectForKey: @"action"];
{ reminderUnit = [alarm objectForKey: @"unit"];
// No selection, wipe alarm which will be done in changeParticipationStatus... reminderQuantity = [alarm objectForKey: @"quantity"];
} reminderReference = [alarm objectForKey: @"reference"];
else if (reminderList == 15) reminderRelation = [alarm objectForKey: @"relation"];
{ reminderEmailAttendees = [[alarm objectForKey: @"attendees"] boolValue];
// Custom reminderEmailOrganizer = [[alarm objectForKey: @"organizer"] boolValue];
anAlarm = [iCalAlarm alarmForEvent: [self event] anAlarm = [iCalAlarm alarmForEvent: [self event]
owner: [[self clientObject] ownerInContext: context] owner: [[self clientObject] ownerInContext: context]
action: [message objectForKey: @"reminderAction"] action: reminderAction
unit: [message objectForKey: @"reminderUnit"] unit: reminderUnit
quantity: [message objectForKey: @"reminderQuantity"] quantity: reminderQuantity
reference: [message objectForKey: @"reminderReference"] reference: reminderReference
reminderRelation: [message objectForKey: @"reminderRelation"] reminderRelation: reminderRelation
emailAttendees: [[message objectForKey: @"reminderEmailAttendees"] boolValue] emailAttendees: reminderEmailAttendees
emailOrganizer: [[message objectForKey: @"reminderEmailOrganizer"] boolValue]]; emailOrganizer: reminderEmailOrganizer];
}
ex = [[self clientObject] changeParticipationStatus: status
withDelegate: delegatedAttendee
alarm: anAlarm];
if (ex)
{
jsonResponse = [NSDictionary dictionaryWithObjectsAndKeys:
[ex reason], @"message",
nil];
response = [self responseWithStatus: [ex httpStatus]
andString: [jsonResponse jsonRepresentation]];
} }
else else
{
// Standard
NSString *aValue;
aValue = [[UIxComponentEditor reminderValues] objectAtIndex: reminderList];
// Predefined alarm
if ([aValue length])
{
iCalTrigger *aTrigger;
anAlarm = [[[iCalAlarm alloc] init] autorelease];
aTrigger = [iCalTrigger elementWithTag: @"TRIGGER"];
[aTrigger setValueType: @"DURATION"];
[anAlarm setTrigger: aTrigger];
[anAlarm setAction: @"DISPLAY"];
[aTrigger setSingleValue: aValue forKey: @""];
}
}
response = (WOResponse *)[[self clientObject] changeParticipationStatus: status
withDelegate: delegatedAttendee
alarm: anAlarm];
if (!response)
response = [self responseWith204]; response = [self responseWith204];
return response; return response;
} }
@ -413,9 +433,7 @@
* @apiParam {String} alarm.reference Either BEFORE or AFTER * @apiParam {String} alarm.reference Either BEFORE or AFTER
* @apiParam {String} alarm.relation Either START or END * @apiParam {String} alarm.relation Either START or END
* @apiParam {Boolean} [alarm.attendees] Alert attendees by email if true and action is email * @apiParam {Boolean} [alarm.attendees] Alert attendees by email if true and action is email
* @apiParam {Object} [alarm.organizer] Alert organizer at this email address if action is email * @apiParam {Boolean} [alarm.organizer] Alert organizer by email if true and action is email
* @apiParam {String} [alarm.organizer.name] Attendee's name
* @apiParam {String} alarm.organizer.email Attendee's email address
* *
* @apiParam {_} ... _Save in [iCalRepeatbleEntityObject+SOGo setAttributes:inContext:]_ * @apiParam {_} ... _Save in [iCalRepeatbleEntityObject+SOGo setAttributes:inContext:]_
* *
@ -561,6 +579,8 @@
* @apiSuccess (Success 200) {String} localizedEndDate Formatted end date * @apiSuccess (Success 200) {String} localizedEndDate Formatted end date
* @apiSuccess (Success 200) {String} [localizedEndTime] Formatted end time * @apiSuccess (Success 200) {String} [localizedEndTime] Formatted end time
* @apiSuccess (Success 200) {Number} isReadOnly 1 if event is read-only * @apiSuccess (Success 200) {Number} isReadOnly 1 if event is read-only
* @apiSuccess (Success 200) {Number} userHasRSVP 1 if owner is invited
* @apiSuccess (Success 200) {Number} [reply] 0 if needs-action, 1 if accepted, 2 if declined, 3 if tentative, 4 if delegated
* @apiSuccess (Success 200) {Object[]} [attachUrls] Attached URLs * @apiSuccess (Success 200) {Object[]} [attachUrls] Attached URLs
* @apiSuccess (Success 200) {String} attachUrls.value URL * @apiSuccess (Success 200) {String} attachUrls.value URL
* *
@ -572,10 +592,8 @@
* @apiSuccess (Success 200) {String} alarm.unit Either MINUTES, HOURS, or DAYS * @apiSuccess (Success 200) {String} alarm.unit Either MINUTES, HOURS, or DAYS
* @apiSuccess (Success 200) {String} alarm.reference Either BEFORE or AFTER * @apiSuccess (Success 200) {String} alarm.reference Either BEFORE or AFTER
* @apiSuccess (Success 200) {String} alarm.relation Either START or END * @apiSuccess (Success 200) {String} alarm.relation Either START or END
* @apiSuccess (Success 200) {Object[]} [alarm.attendees] List of attendees * @apiSuccess (Success 200) {Boolean} alarm.attendees Alert attendees by email if true and action is email
* @apiSuccess (Success 200) {String} [alarm.attendees.name] Attendee's name * @apiSuccess (Success 200) {Boolean} alarm.organizer Alert organizer by email if true and action is email
* @apiSuccess (Success 200) {String} alarm.attendees.email Attendee's email address
* @apiSuccess (Success 200) {String} [alarm.attendees.uid] System user ID
* *
* @apiSuccess {_} ... _From [iCalEvent+SOGo attributes]_ * @apiSuccess {_} ... _From [iCalEvent+SOGo attributes]_
* *
@ -687,6 +705,7 @@
[componentCalendar nameInContainer], @"pid", [componentCalendar nameInContainer], @"pid",
[componentCalendar displayName], @"calendar", [componentCalendar displayName], @"calendar",
[NSNumber numberWithBool: [self isReadOnly]], @"isReadOnly", [NSNumber numberWithBool: [self isReadOnly]], @"isReadOnly",
[NSNumber numberWithBool: [self userHasRSVP]], @"userHasRSVP",
[dateFormatter formattedDate: eventStartDate], @"localizedStartDate", [dateFormatter formattedDate: eventStartDate], @"localizedStartDate",
[dateFormatter formattedDate: eventEndDate], @"localizedEndDate", [dateFormatter formattedDate: eventEndDate], @"localizedEndDate",
[self alarm], @"alarm", [self alarm], @"alarm",
@ -711,6 +730,9 @@
[data setObject: [dateFormatter formattedTime: eventEndDate] forKey: @"localizedEndTime"]; [data setObject: [dateFormatter formattedTime: eventEndDate] forKey: @"localizedEndTime"];
} }
if ([self userHasRSVP])
[data setObject: [self reply] forKey: @"reply"];
// Add attributes from iCalEvent+SOGo, iCalEntityObject+SOGo and iCalRepeatableEntityObject+SOGo // Add attributes from iCalEvent+SOGo, iCalEntityObject+SOGo and iCalRepeatableEntityObject+SOGo
[data addEntriesFromDictionary: [event attributesInContext: context]]; [data addEntriesFromDictionary: [event attributesInContext: context]];

View File

@ -30,6 +30,8 @@
#import <NGObjWeb/WORequest.h> #import <NGObjWeb/WORequest.h>
#import <NGObjWeb/WOResponse.h> #import <NGObjWeb/WOResponse.h>
#import <NGCards/iCalPerson.h>
#import <SOGo/NSArray+Utilities.h> #import <SOGo/NSArray+Utilities.h>
#import <SOGo/SOGoPermissions.h> #import <SOGo/SOGoPermissions.h>
#import <SOGo/SOGoUser.h> #import <SOGo/SOGoUser.h>
@ -329,9 +331,67 @@
@end @end
/* Component Viewer, parent class of Appointment Viewer and Task Viewer */
@interface UIxComponentViewTemplate : UIxComponent
{
id item;
}
@end
@implementation UIxComponentViewTemplate
- (id) init
{
if ((self = [super init]))
{
item = nil;
}
return self;
}
- (void) dealloc
{
[item release];
[super dealloc];
}
- (void) setItem: (id) _item
{
ASSIGN (item, _item);
}
- (id) item
{
return item;
}
- (NSArray *) replyList
{
return [NSArray arrayWithObjects:
[NSNumber numberWithInt: iCalPersonPartStatAccepted],
[NSNumber numberWithInt: iCalPersonPartStatDeclined],
[NSNumber numberWithInt: iCalPersonPartStatNeedsAction],
[NSNumber numberWithInt: iCalPersonPartStatTentative],
[NSNumber numberWithInt: iCalPersonPartStatDelegated],
nil];
}
- (NSString *) itemReplyText
{
NSString *word;
word = [iCalPerson descriptionForParticipationStatus: [item intValue]];
return [self labelForKey: [NSString stringWithFormat: @"partStat_%@", word]];
}
@end
/* Appointment Viewer */ /* Appointment Viewer */
@interface UIxAppointmentViewTemplate : UIxComponent @interface UIxAppointmentViewTemplate : UIxComponentViewTemplate
@end @end
@implementation UIxAppointmentViewTemplate @implementation UIxAppointmentViewTemplate
@ -339,7 +399,7 @@
/* Task Viewer */ /* Task Viewer */
@interface UIxTaskViewTemplate : UIxComponent @interface UIxTaskViewTemplate : UIxComponentViewTemplate
@end @end
@implementation UIxTaskViewTemplate @implementation UIxTaskViewTemplate

View File

@ -34,6 +34,8 @@
} }
- (BOOL) isReadOnly; - (BOOL) isReadOnly;
- (BOOL) userHasRSVP;
- (NSNumber *) reply;
- (BOOL) isChildOccurrence; - (BOOL) isChildOccurrence;
- (void) setAttributes: (NSDictionary *) attributes; - (void) setAttributes: (NSDictionary *) attributes;

View File

@ -75,6 +75,10 @@
#import "UIxComponentEditor.h" #import "UIxComponentEditor.h"
#import "UIxDatePicker.h" #import "UIxDatePicker.h"
#define componentReadableWritable 0
#define componentOwnerIsInvited 1
#define componentReadableOnly 2
static NSArray *reminderItems = nil; static NSArray *reminderItems = nil;
static NSArray *reminderValues = nil; static NSArray *reminderValues = nil;
@ -317,14 +321,21 @@ static NSArray *reminderValues = nil;
// nil]; // nil];
//} //}
// //
//- (NSNumber *) reply - (NSNumber *) reply
//{ {
// iCalPersonPartStat participationStatus; NSString *owner, *ownerEmail;
// SOGoUserManager *um;
// participationStatus = [ownerAsAttendee participationStatus]; iCalPerson *ownerAsAttendee;
// iCalPersonPartStat participationStatus;
// return [NSNumber numberWithInt: participationStatus];
//} um = [SOGoUserManager sharedUserManager];
owner = [componentCalendar ownerInContext: context];
ownerEmail = [um getEmailForUID: owner];
ownerAsAttendee = [component findAttendeeWithEmail: (id)ownerEmail];
participationStatus = [ownerAsAttendee participationStatus];
return [NSNumber numberWithInt: participationStatus];
}
///* priorities */ ///* priorities */
// //
@ -798,7 +809,7 @@ static NSArray *reminderValues = nil;
iCalPerson *ownerAttendee; iCalPerson *ownerAttendee;
int rc; int rc;
rc = 0; rc = componentReadableWritable;
sm = [SoSecurityManager sharedSecurityManager]; sm = [SoSecurityManager sharedSecurityManager];
if (![sm validatePermission: SOGoCalendarPerm_ModifyComponent if (![sm validatePermission: SOGoCalendarPerm_ModifyComponent
@ -813,42 +824,42 @@ static NSArray *reminderValues = nil;
ownerAttendee = [component userAsAttendee: ownerUser]; ownerAttendee = [component userAsAttendee: ownerUser];
if ([[ownerAttendee rsvp] isEqualToString: @"true"] if ([[ownerAttendee rsvp] isEqualToString: @"true"]
&& ![component userIsOrganizer: ownerUser]) && ![component userIsOrganizer: ownerUser])
rc = 1; rc = componentOwnerIsInvited;
else else
rc = 2; rc = componentReadableOnly;
} }
else else
rc = 2; // not invited, just RO rc = componentReadableOnly;
return rc; return rc;
} }
- (int) getEventRWType - (int) getEventRWType
{ {
SOGoContentObject <SOGoComponentOccurence> *clientObject; SOGoContentObject <SOGoComponentOccurence> *clientObject;
SOGoUser *ownerUser; SOGoUser *ownerUser;
int rc; int rc;
clientObject = [self clientObject]; clientObject = [self clientObject];
ownerUser = [SOGoUser userWithLogin: [clientObject ownerInContext: context]]; ownerUser = [SOGoUser userWithLogin: [clientObject ownerInContext: context]];
if ([componentCalendar isKindOfClass: [SOGoWebAppointmentFolder class]]) if ([clientObject isKindOfClass: [SOGoWebAppointmentFolder class]])
rc = 2; rc = componentReadableOnly;
else else
{ {
if ([ownerUser isEqual: [context activeUser]]) if ([ownerUser isEqual: [context activeUser]])
rc = [self ownerIsAttendee: ownerUser rc = [self ownerIsAttendee: ownerUser
andClientObject: clientObject]; andClientObject: clientObject];
else else
rc = [self delegateIsAttendee: ownerUser rc = [self delegateIsAttendee: ownerUser
andClientObject: clientObject]; andClientObject: clientObject];
} }
return rc; return rc;
} }
- (BOOL) isReadOnly - (BOOL) isReadOnly
{ {
return [self getEventRWType] != 0; return [self getEventRWType] != componentReadableWritable;
} }
// //
//- (NSString *) emailAlarmsEnabled //- (NSString *) emailAlarmsEnabled
@ -862,10 +873,10 @@ static NSArray *reminderValues = nil;
// : @"false"); // : @"false");
//} //}
//- (BOOL) userHasRSVP - (BOOL) userHasRSVP
//{ {
// return ([self getEventRWType] == 1); return ([self getEventRWType] == componentOwnerIsInvited);
//} }
//- (unsigned int) firstDayOfWeek //- (unsigned int) firstDayOfWeek
//{ //{

View File

@ -4,21 +4,20 @@
xmlns:var="http://www.skyrix.com/od/binding" xmlns:var="http://www.skyrix.com/od/binding"
xmlns:const="http://www.skyrix.com/od/constant" xmlns:const="http://www.skyrix.com/od/constant"
xmlns:label="OGo:label"> xmlns:label="OGo:label">
<md-dialog flex="40"> <md-dialog flex="40" flex-md="60" flex-sm="100">
<form name="eventForm" ng-submit="viewer.save(eventForm)"> <md-toolbar class="md-padding" ng-class="editor.component.getClassName('bg')">
<md-toolbar class="md-padding" ng-class="viewer.component.getClassName('bg')">
<div class="md-toolbar-tools"> <div class="md-toolbar-tools">
<md-icon class="material-icons sg-icon-toolbar-bg">event</md-icon> <md-icon class="material-icons sg-icon-toolbar-bg">event</md-icon>
<h2 class="md-flex"> <div class="sg-md-title md-flex">
<!-- classification --> <!-- classification -->
<md-icon ng-if="viewer.component.classification == 'confidential'">visibility_off</md-icon> <md-icon ng-if="editor.component.classification == 'confidential'">visibility_off</md-icon>
<md-icon ng-if="viewer.component.classification == 'private'">vpn_key</md-icon> <md-icon ng-if="editor.component.classification == 'private'">vpn_key</md-icon>
<!-- summary --> <!-- summary -->
{{viewer.component.summary}} {{::editor.component.summary}}
<!-- priority --> <!-- priority -->
<md-icon ng-repeat="i in viewer.component.priority | range">star</md-icon> <md-icon ng-repeat="i in ::editor.component.priority | range">star</md-icon>
</h2> </div>
<md-button class="md-icon-button" ng-click="viewer.close()"> <md-button class="md-icon-button" ng-click="editor.close()">
<md-icon aria-label="Close dialog">close</md-icon> <md-icon aria-label="Close dialog">close</md-icon>
</md-button> </md-button>
</div> </div>
@ -26,104 +25,177 @@
<md-dialog-content> <md-dialog-content>
<md-list> <md-list>
<!-- categories --> <!-- categories -->
<md-list-item ng-show="viewer.component.categories.length > 0"> <md-list-item ng-show="editor.component.categories.length > 0">
<md-chips class="sg-readonly" ng-model="viewer.component.categories" readonly="true"> <md-chips class="sg-readonly" ng-model="::editor.component.categories" readonly="true">
<md-chip-template> <md-chip-template>
<span>{{$chip}}</span> <span>{{$chip}}</span>
</md-chip-template> </md-chip-template>
</md-chips> </md-chips>
</md-list-item> </md-list-item>
<!-- location --> <!-- location -->
<md-list-item ng-show="viewer.component.location"> <md-list-item ng-show="editor.component.location">
<md-icon>place</md-icon> <md-icon>place</md-icon>
<p>{{viewer.component.location}}</p> <p>{{::editor.component.location}}</p>
</md-list-item> </md-list-item>
<!-- calendar --> <!-- calendar -->
<md-list-item> <md-list-item>
<md-icon>event</md-icon> <md-icon>event</md-icon>
<p>{{viewer.component.calendar}}</p> <p>{{editor.component.calendar}}</p>
</md-list-item> </md-list-item>
<!-- start/end dates --> <!-- start/end dates -->
<md-list-item class="md-2-line"> <md-list-item ng-class="{ 'md-2-line': editor.component.isAllDay, 'md-3-line': !editor.component.isAllDay }">
<md-icon>access_time</md-icon> <md-icon>access_time</md-icon>
<div layout="row" class="md-flex"> <div layout="row" class="md-flex">
<div class="md-list-item-text" ng-show="viewer.component.startDate"> <div class="md-list-item-text" ng-show="editor.component.startDate">
<p><var:string label:value="Start"/></p> <p><var:string label:value="Start"/></p>
<h3> <h3>{{::editor.component.localizedStartDate}}</h3>
{{viewer.component.localizedStartDate}} <h3 ng-hide="editor.component.isAllDay">{{::editor.component.localizedStartTime}}</h3>
<span ng-hide="viewer.component.isAllDay"> {{viewer.component.localizedStartTime}}</span>
</h3>
</div> </div>
<div class="md-list-item-text" ng-show="viewer.component.endDate"> <div class="md-list-item-text" ng-show="editor.component.endDate">
<p><var:string label:value="End"/></p> <p><var:string label:value="End"/></p>
<h3> <h3>{{::editor.component.localizedEndDate}}</h3>
{{viewer.component.localizedEndDate}} <h3 ng-hide="editor.component.isAllDay">{{::editor.component.localizedEndTime}}</h3>
<span ng-hide="viewer.component.isAllDay">{{viewer.component.localizedEndTime}}</span>
</h3>
</div> </div>
</div> </div>
</md-list-item> </md-list-item>
<!-- is transparent --> <!-- is transparent -->
<md-list-item ng-show="viewer.component.isTransparent"> <md-list-item ng-show="editor.component.isTransparent">
<md-icon>event_available</md-icon> <md-icon>event_available</md-icon>
<p><var:string label:value="Show Time as Free"/></p> <p><var:string label:value="Show Time as Free"/></p>
</md-list-item> </md-list-item>
<!-- send appointment notifications --> <!-- send appointment notifications -->
<md-list-item ng-show="viewer.component.sendAppointmentNotifications"> <md-list-item ng-show="editor.component.sendAppointmentNotifications">
<md-icon>send</md-icon> <md-icon>send</md-icon>
<p><var:string label:value="Send Appointment Notifications"/></p> <p><var:string label:value="Send Appointment Notifications"/></p>
</md-list-item> </md-list-item>
<!-- attach urls --> <!-- attach urls -->
<md-list-item ng-show="viewer.component.attachUrls.length > 0"> <md-list-item ng-show="editor.component.attachUrls.length > 0">
<md-icon>link</md-icon> <md-icon>link</md-icon>
<p ng-repeat="url in viewer.component.attachUrls"><a ng-href="{{url.value}}">{{url.value}}</a></p> <p ng-repeat="url in ::editor.component.attachUrls"><a ng-href="{{url.value}}">{{url.value}}</a></p>
</md-list-item> </md-list-item>
<!-- comment --> <!-- comment -->
<md-list-item ng-show="viewer.component.comment"> <md-list-item ng-show="editor.component.comment">
<md-icon>mode_comment</md-icon> <md-icon>mode_comment</md-icon>
<p>{{viewer.component.comment}}</p> <p>{{::editor.component.comment}}</p>
</md-list-item> </md-list-item>
<!-- repeat --> <!-- repeat -->
<md-list-item ng-show="viewer.component.repeat"> <md-list-item ng-show="editor.component.$isRecurrent">
<md-icon>repeat</md-icon> <md-icon>repeat</md-icon>
<p><!-- viewer.component.repeat.toString() --></p> <p><!-- editor.component.repeat.toString() --></p>
</md-list-item> </md-list-item>
<!-- reminder --> <!-- reminder -->
<md-list-item ng-show="viewer.component.$hasAlarm"> <md-list-item ng-show="editor.component.$hasAlarm" ng-hide="editor.component.userHasRSVP">
<md-icon>alarm</md-icon> <md-icon>alarm_on</md-icon>
<p><!-- viewer.component.alarm.toString() --></p> <p><!-- editor.component.alarm.toString() --></p>
</md-list-item> </md-list-item>
<!-- attendees --> <md-list-item class="md-2-line" ng-show="editor.component.organizer">
<md-list-item ng-show="viewer.component.attendees.length > 0"> <md-icon>people</md-icon>
<div class="pseudo-input-container"> <div layout="column" layout-fill="layout-fill">
<label class="pseudo-input-label"><var:string label:value="Attendees"/></label> <!-- organizer -->
<!-- md-contact-chips don't support "readonly", so we build them using md-chips <div class="pseudo-input-container" ng-show="editor.component.organizer">
in readonly mode and a template similar to the one of md-contact-chips --> <label class="pseudo-input-label"><var:string label:value="Organizer"/></label>
<md-chips class="md-contact-chips" <md-chips class="md-contact-chips sg-readonly"
ng-model="viewer.component.attendees" ng-model="::editor.organizer"
readonly="true"> readonly="true">
<md-chip-template> <md-chip-template>
<div class="md-contact-avatar"><img src="#" ng-src="{{$chip.image}}" alt="{{$chip.name}}"/></div> <div class="md-contact-avatar"><img src="#" ng-src="{{$chip.$image}}" alt="{{$chip.name}}"/></div>
<div class="md-contact-name">{{$chip.name}}</div> <div class="md-contact-name">{{$chip.name}}</div>
<md-icon ng-class="'icon-' + $chip.status"><!-- partstat --></md-icon> </md-chip-template>
</md-chip-template> </md-chips>
</md-chips> </div>
<!-- attendees -->
<div class="pseudo-input-container" ng-show="editor.component.attendees.length > 0">
<label class="pseudo-input-label"><var:string label:value="Attendees"/></label>
<!-- md-contact-chips don't support "readonly", so we build them using md-chips
in readonly mode and a template similar to the one of md-contact-chips -->
<md-chips class="md-contact-chips sg-readonly"
ng-model="::editor.component.attendees"
readonly="true">
<md-chip-template>
<div class="md-contact-avatar"><img src="#" ng-src="{{$chip.image}}" alt="{{$chip.name}}"/></div>
<div class="md-contact-name">{{$chip.name}}</div>
<md-icon ng-class="'icon-' + $chip.status"><!-- partstat --></md-icon>
</md-chip-template>
</md-chips>
</div>
</div> </div>
</md-list-item> </md-list-item>
</md-list> </md-list>
<!-- invitation -->
<md-list ng-show="editor.component.userHasRSVP">
<md-divider><!-- dividerd --></md-divider>
<md-subheader class="md-no-sticky sg-padded--top"><var:string label:value="You are invited to participate"/></md-subheader>
<!-- participation status -->
<md-list-item>
<md-icon>insert_invitation</md-icon>
<md-input-container class="md-no-flex">
<md-select ng-model="editor.component.reply">
<var:foreach list="replyList" item="item">
<md-option var:value="item">
<var:string value="itemReplyText"/>
</md-option>
</var:foreach>
</md-select>
</md-input-container>
</md-list-item>
<md-list-item ng-show="editor.component.reply == 4">
<md-icon><!-- space --></md-icon>
<md-autocomplete
class="md-flex"
md-input-name="delegatedTo"
md-search-text="editor.component.delegatedTo"
md-selected-item="editor.cardToAdd"
md-items="card in editor.cardFilter(editor.component.delegatedTo)"
md-item-text="card.$$email"
md-min-length="3"
md-no-cache="true"
label:md-floating-label="Delegated to">
<span class="md-contact-suggestion" layout="row" layout-align="space-between center">
<span class="md-contact-name"
md-highlight-text="editor.component.delegatedTo"
md-highlight-flags="^i">{{card.$$fullname}}</span> <span class="md-contact-email"
md-highlight-text="editor.component.delegatedTo"
md-highlight-flags="^i">{{card.$$email}}</span>
</span>
</md-autocomplete>
</md-list-item>
<!-- reminder -->
<md-list-item ng-show="editor.component.userHasRSVP">
<md-checkbox ng-model="editor.component.$hasAlarm"
label:aria-label="Reminder"><!-- reminder --></md-checkbox>
<p><var:string label:value="Reminder"/></p>
</md-list-item>
</md-list>
<div class="sg-inset" ng-show="editor.component.userHasRSVP">
<span ng-show="editor.component.$hasAlarm"><var:component className="UIxReminderEditor" /></span>
</div>
</md-dialog-content> </md-dialog-content>
<!-- actions --> <!-- actions -->
<div class="md-actions"> <div class="md-actions" ng-show="editor.component.isEditable()">
<md-button type="button" ng-click="viewer.edit()" ng-hide="viewer.component.occurrenceId"> <md-button type="button" ng-click="editor.edit()">
<var:string label:value="Edit"/> <var:string label:value="Edit"/>
</md-button> </md-button>
<md-button type="button" ng-click="viewer.editAllOccurrences()" ng-show="viewer.component.occurrenceId"> </div>
<div class="md-actions" ng-show="editor.component.isEditableOccurrence()">
<md-button type="button" ng-click="editor.editAllOccurrences()">
<var:string label:value="Edit All Occurrences"/> <var:string label:value="Edit All Occurrences"/>
</md-button> </md-button>
<md-button type="button" ng-click="viewer.edit()" ng-show="viewer.component.occurrenceId"> <md-button type="button" ng-click="editor.edit()">
<var:string label:value="Edit This Occurrence"/> <var:string label:value="Edit This Occurrence"/>
</md-button> </md-button>
</div> </div>
</form> <div class="md-actions" ng-show="editor.component.isInvitation()">
</md-dialog> <md-button type="button" ng-click="editor.reply()">
<var:string label:value="Update"/>
</md-button>
</div>
<div class="md-actions" ng-show="editor.component.isInvitationOccurrence()">
<md-button type="button" ng-click="editor.replyAllOccurrences()">
<var:string label:value="Update All Occurrences"/>
</md-button>
<md-button type="button" ng-click="editor.reply()">
<var:string label:value="Update This Occurrence"/>
</md-button>
</div>
</md-dialog>
</container> </container>

View File

@ -38,6 +38,7 @@
* @memberof User * @memberof User
* @desc Search for users that match a string. * @desc Search for users that match a string.
* @param {string} search - a string used to performed the search * @param {string} search - a string used to performed the search
* @param {object[]} excludedUsers - a list of User objects that must be excluded from the results
* @return a promise of an array of matching User objects * @return a promise of an array of matching User objects
*/ */
User.$filter = function(search, excludedUsers) { User.$filter = function(search, excludedUsers) {

View File

@ -67,19 +67,58 @@
* @desc Search for cards among all addressbooks matching some criterias. * @desc Search for cards among all addressbooks matching some criterias.
* @param {string} search - the search string to match * @param {string} search - the search string to match
* @param {object} [options] - additional options to the query (excludeGroups and excludeLists) * @param {object} [options] - additional options to the query (excludeGroups and excludeLists)
* @param {object[]} excludedCards - a list of Card objects that must be excluded from the results
* @returns a collection of Cards instances * @returns a collection of Cards instances
*/ */
AddressBook.$filterAll = function(search, options) { AddressBook.$filterAll = function(search, options, excludedCards) {
var params = {search: search}; var params = {search: search};
if (!search) {
// No query specified
AddressBook.$cards = [];
return AddressBook.$q.when(AddressBook.$cards);
}
if (angular.isUndefined(AddressBook.$cards)) {
// First session query
AddressBook.$cards = [];
}
else if (AddressBook.$query == search) {
// Query hasn't changed
return AddressBook.$q.when(AddressBook.$cards);
}
AddressBook.$query = search;
angular.extend(params, options); angular.extend(params, options);
return AddressBook.$$resource.fetch(null, 'allContactSearch', params).then(function(response) { return AddressBook.$$resource.fetch(null, 'allContactSearch', params).then(function(response) {
var results = []; var results, card, index,
angular.forEach(response.contacts, function(data) { compareIds = function(data) {
var card = new AddressBook.$Card(data); return this.id == data.id;
results.push(card); };
if (excludedCards) {
// Remove excluded cards from results
results = _.filter(response.contacts, function(data) {
return _.isUndefined(_.find(excludedCards, compareIds, data));
});
}
else {
results = response.contacts;
}
// Remove cards that no longer match the search query
for (index = AddressBook.$cards.length - 1; index >= 0; index--) {
card = AddressBook.$cards[index];
if (_.isUndefined(_.find(results, compareIds, card))) {
AddressBook.$cards.splice(index, 1);
}
}
// Add new cards matching the search query
_.each(results, function(data, index) {
if (_.isUndefined(_.find(AddressBook.$cards, compareIds, data))) {
var card = new AddressBook.$Card(data, search);
AddressBook.$cards.splice(index, 0, card);
}
}); });
return results; return AddressBook.$cards;
}); });
}; };

View File

@ -133,6 +133,9 @@
if (!this.$$image) if (!this.$$image)
this.$$image = this.image || Card.$gravatar(this.$preferredEmail(partial), 32); this.$$image = this.image || Card.$gravatar(this.$preferredEmail(partial), 32);
this.selected = false; this.selected = false;
// An empty attribute to trick md-autocomplete when adding attendees from the appointment editor
this.empty = ' ';
}; };
/** /**

View File

@ -68,7 +68,7 @@
escapeToClose: true, escapeToClose: true,
templateUrl: templateUrl, templateUrl: templateUrl,
controller: 'ComponentController', controller: 'ComponentController',
controllerAs: 'viewer', controllerAs: 'editor',
locals: { locals: {
stateComponent: component stateComponent: component
} }

View File

@ -311,6 +311,7 @@
this.due = new Date(this.dueDate.substring(0,10) + ' ' + this.dueDate.substring(11,16)); this.due = new Date(this.dueDate.substring(0,10) + ' ' + this.dueDate.substring(11,16));
// Parse recurrence rule definition and initialize default values // Parse recurrence rule definition and initialize default values
this.$isRecurrent = angular.isDefined(data.repeat);
if (this.repeat.days) { if (this.repeat.days) {
var byDayMask = _.find(this.repeat.days, function(o) { var byDayMask = _.find(this.repeat.days, function(o) {
return angular.isDefined(o.occurrence); return angular.isDefined(o.occurrence);
@ -368,6 +369,10 @@
// Allow the component to be moved to a different calendar // Allow the component to be moved to a different calendar
this.destinationCalendar = this.pid; this.destinationCalendar = this.pid;
if (this.organizer && this.organizer.email) {
this.organizer.$image = Component.$gravatar(this.organizer.email, 32);
}
// Load freebusy of attendees // Load freebusy of attendees
this.freebusy = this.updateFreeBusyCoverage(); this.freebusy = this.updateFreeBusyCoverage();
@ -394,6 +399,56 @@
return b; return b;
}; };
/**
* @function isEditable
* @memberof Component.prototype
* @desc Check if the component is editable and not an occurrence of a recurrent component
* @returns true or false
*/
Component.prototype.isEditable = function() {
return (!this.occurrenceId && !this.isReadOnly);
};
/**
* @function isEditableOccurrence
* @memberof Component.prototype
* @desc Check if the component is editable and an occurrence of a recurrent component
* @returns true or false
*/
Component.prototype.isEditableOccurrence = function() {
return (this.occurrenceId && !this.isReadOnly);
};
/**
* @function isInvitation
* @memberof Component.prototype
* @desc Check if the component an invitation and not an occurrence of a recurrent component
* @returns true or false
*/
Component.prototype.isInvitation = function() {
return (!this.occurrenceId && this.userHasRSVP);
};
/**
* @function isInvitationOccurrence
* @memberof Component.prototype
* @desc Check if the component an invitation and an occurrence of a recurrent component
* @returns true or false
*/
Component.prototype.isInvitationOccurrence = function() {
return (this.occurrenceId && this.userHasRSVP);
};
/**
* @function isReadOnly
* @memberof Component.prototype
* @desc Check if the component is not editable and not an invitation
* @returns true or false
*/
Component.prototype.isReadOnly = function() {
return (this.isReadOnly && !this.userHasRSVP);
};
/** /**
* @function enablePercentComplete * @function enablePercentComplete
* @memberof Component.prototype * @memberof Component.prototype
@ -581,6 +636,7 @@
*/ */
Component.prototype.canRemindAttendeesByEmail = function() { Component.prototype.canRemindAttendeesByEmail = function() {
return this.alarm.action == 'email' && return this.alarm.action == 'email' &&
!this.isReadOnly &&
this.attendees && this.attendees.length > 0; this.attendees && this.attendees.length > 0;
}; };
@ -635,6 +691,32 @@
this.$shadowData = this.$omit(true); this.$shadowData = this.$omit(true);
}; };
/**
* @function reply
* @memberof Component.prototype
* @desc Reply to an invitation.
* @returns a promise of the HTTP operation
*/
Component.prototype.$reply = function() {
var _this = this, data, path = [this.pid, this.id];
if (this.occurrenceId)
path.push(this.occurrenceId);
data = {
reply: this.reply,
delegatedTo: this.delegatedTo,
alarm: this.$hasAlarm? this.alarm : {}
};
return Component.$$resource.save(path.join('/'), data, { action: 'rsvpAppointment' })
.then(function(data) {
// Make a copy of the data for an eventual reset
_this.$shadowData = _this.$omit(true);
return data;
});
};
/** /**
* @function $save * @function $save
* @memberof Component.prototype * @memberof Component.prototype

View File

@ -6,20 +6,24 @@
/** /**
* @ngInject * @ngInject
*/ */
ComponentController.$inject = ['$mdDialog', 'Calendar', 'stateComponent']; ComponentController.$inject = ['$rootScope', '$mdDialog', 'Calendar', 'AddressBook', 'Alarm', 'stateComponent'];
function ComponentController($mdDialog, Calendar, stateComponent) { function ComponentController($rootScope, $mdDialog, Calendar, AddressBook, Alarm, stateComponent) {
var vm = this, component; var vm = this, component;
vm.component = stateComponent; vm.component = stateComponent;
vm.close = close; vm.close = close;
vm.cardFilter = cardFilter;
vm.edit = edit; vm.edit = edit;
vm.editAllOccurrences = editAllOccurrences; vm.editAllOccurrences = editAllOccurrences;
vm.reply = reply;
vm.replyAllOccurrences = replyAllOccurrences;
// Load all attributes of component // Load all attributes of component
if (angular.isUndefined(vm.component.$futureComponentData)) { if (angular.isUndefined(vm.component.$futureComponentData)) {
component = Calendar.$get(vm.component.c_folder).$getComponent(vm.component.c_name, vm.component.c_recurrence_id); component = Calendar.$get(vm.component.c_folder).$getComponent(vm.component.c_name, vm.component.c_recurrence_id);
component.$futureComponentData.then(function() { component.$futureComponentData.then(function() {
vm.component = component; vm.component = component;
vm.organizer = [vm.component.organizer];
}); });
} }
@ -27,12 +31,10 @@
$mdDialog.hide(); $mdDialog.hide();
} }
function editAllOccurrences() { // Autocomplete cards for attendees
component = Calendar.$get(vm.component.pid).$getComponent(vm.component.id); function cardFilter($query) {
component.$futureComponentData.then(function() { AddressBook.$filterAll($query);
vm.component = component; return AddressBook.$cards;
edit();
});
} }
function edit() { function edit() {
@ -54,6 +56,38 @@
}); });
}); });
} }
function editAllOccurrences() {
component = Calendar.$get(vm.component.pid).$getComponent(vm.component.id);
component.$futureComponentData.then(function() {
vm.component = component;
edit();
});
}
function reply(component) {
var c = component || vm.component;
c.$reply().then(function() {
$rootScope.$broadcast('calendars:list');
$mdDialog.hide();
Alarm.getAlarms();
});
}
function replyAllOccurrences() {
// Retrieve master event
component = Calendar.$get(vm.component.pid).$getComponent(vm.component.id);
component.$futureComponentData.then(function() {
// Propagate the participant status and alarm to the master event
component.reply = vm.component.reply;
component.delegatedTo = vm.component.delegatedTo;
component.$hasAlarm = vm.component.$hasAlarm;
component.alarm = vm.component.alarm;
// Send reply to the server
reply(component);
});
}
} }
/** /**
@ -71,7 +105,6 @@
vm.showAttendeesEditor = angular.isDefined(vm.component.attendees); vm.showAttendeesEditor = angular.isDefined(vm.component.attendees);
vm.toggleAttendeesEditor = toggleAttendeesEditor; vm.toggleAttendeesEditor = toggleAttendeesEditor;
vm.cardFilter = cardFilter; vm.cardFilter = cardFilter;
vm.cardResults = [];
vm.addAttendee = addAttendee; vm.addAttendee = addAttendee;
vm.addAttachUrl = addAttachUrl; vm.addAttachUrl = addAttachUrl;
vm.cancel = cancel; vm.cancel = cancel;
@ -119,29 +152,8 @@
// Autocomplete cards for attendees // Autocomplete cards for attendees
function cardFilter($query) { function cardFilter($query) {
var index, indexResult, card; AddressBook.$filterAll($query);
if ($query) { return AddressBook.$cards;
AddressBook.$filterAll($query).then(function(results) {
var compareIds = function(result) {
return this.id == result.id;
};
// Remove cards that no longer match the search query
for (index = vm.cardResults.length - 1; index >= 0; index--) {
card = vm.cardResults[index];
indexResult = _.findIndex(results, compareIds, card);
if (indexResult >= 0)
results.splice(indexResult, 1);
else
vm.cardResults.splice(index, 1);
}
_.each(results, function(card) {
// Add cards matching the search query but not already in the list of attendees
if (!vm.component.hasAttendee(card))
vm.cardResults.push(card);
});
});
}
return vm.cardResults;
} }
function addAttendee(card) { function addAttendee(card) {
@ -202,7 +214,7 @@
} }
angular angular
.module('SOGo.SchedulerUI') .module('SOGo.SchedulerUI')
.controller('ComponentController', ComponentController) .controller('ComponentController', ComponentController)
.controller('ComponentEditorController', ComponentEditorController); .controller('ComponentEditorController', ComponentEditorController);
})(); })();

View File

@ -21,4 +21,9 @@ md-content {
border-left: $baseline-grid solid sg-color($sogoGreen, 100); border-left: $baseline-grid solid sg-color($sogoGreen, 100);
margin-left: ($baseline-grid / 2); margin-left: ($baseline-grid / 2);
padding-left: $baseline-grid; padding-left: $baseline-grid;
}
.sg-inset {
// Inspired from <md-divider md-inset>
margin-left: $baseline-grid * 10;
} }

View File

@ -26,6 +26,7 @@ md-list-item {
.md-list-item-inner { .md-list-item-inner {
flex-grow: 1; // use all column space flex-grow: 1; // use all column space
} }
md-icon,
.md-button md-icon { .md-button md-icon {
margin-left: 0; margin-left: 0;
} }

View File

@ -11,9 +11,15 @@ main {
.sg-padded { .sg-padded {
padding-left: $mg; padding-left: $mg;
padding-right: $mg; padding-right: $mg;
&--left {
padding-left: $mg;
}
&--right { &--right {
padding-right: $mg; padding-right: $mg;
} }
&--top {
padding-top: $mg;
}
} }
.sg-logo { .sg-logo {