Google Calendar in Grails – Part 3: Creating and Modifying Events
Following up on Part 1 and Part 2 of this series where we created a model and rendered a Google calendar-like calendar. Now, finally we’ll finish things out by creating the actions and view that will allow us to view events as well as create and edit new events.
Let’s start with by creating the tip balloon that shows the event title as well as the event time. Here’s what it looks like in Google Calendar:
One thing to keep in mind with this, is that for repeating events we want to show the particular event times for the particular day we clicked on. For example if we have an event that repeats on MWF and starts at the beginning of the month, if we click on the last Friday of the month it should display that particular date. So we need to pass the startTime of that particular occurrence to our show method. So here’s how we modify our calendar.js to do this:
$("#calendar").fullCalendar({
events: 'list.json',
header: {
left: 'prev,next today',
center: 'title',
right: 'month,agendaWeek,agendaDay'
},
eventRender: function(event, element) {
$(element).addClass(event.cssClass);
var occurrenceStart = event.start.getTime();
var occurrenceEnd = event.end.getTime();
var data = {id: event.id, occurrenceStart: occurrenceStart, occurrenceEnd: occurrenceEnd};
$(element).qtip({
content: {
text: ' ',
ajax: {
url: "show",
type: "GET",
data: data
}
},
show: {
event: 'click',
solo: true
},
hide: {
event: 'click'
},
style: {
width: '500px',
widget: true
},
position: {
my: 'bottom middle',
at: 'top middle',
viewport: true
}
});
}
});
You’ll notice that I used the jQuery plugin qTip2 for our tooltip here. Much like the original qTip it seems this will forever be classified as a release candidate. The plugin, though, is very stable and the constant development and great support from the author are enough for me to use it in a production setting.
So our show action that this javascript uses is pretty simple (just showing different views based on whether or not this is an ajax request or not):
class EventController {
def show = {
def (occurrenceStart, occurrenceEnd) = [params.long('occurrenceStart'), params.long('occurrenceEnd')]
def eventInstance = Event.get(params.id)
if (!eventInstance) {
flash.message = "${message(code: 'default.not.found.message', args: [message(code: 'event.label', default: 'Event'), params.id])}"
redirect(action: "index")
}
else {
def model = [eventInstance: eventInstance, occurrenceStart: occurrenceStart, occurrenceEnd: occurrenceEnd]
if (request.xhr) {
render(template: "showPopup", model: model)
}
else {
model
}
}
}
}
To round out our jQuery plugins we’ll need a modal popup (I’m using the dialog component found in the JQuery UI library), as well as a good datepicker (I’m again using the JQuery UI for this), along with the timePicker add-on to select the specific time of our event. In terms of UI this is a bit different than the way Google Calendar works, but I find Google’s time selection to be a bit clunky. So here’s what our datepicker popup will look like on our startTime and endTime fields:
In order for the dates in this new format to be automatically bound to our Event domain object, I created a custom date registar. First I created a class CustomDateEditorRegistrar.groovy that looks like this:
public class CustomDateEditorRegistrar implements PropertyEditorRegistrar {
public void registerCustomEditors(PropertyEditorRegistry registry) {
registry.registerCustomEditor(Date.class, new CustomDateEditor(new SimpleDateFormat("MM/dd/yyyy hh:mm a"), true))
}
}
Then I referenced this new class in the conf/spring/resources.groovy file:
beans = {
customDateEditorRegistrar(com.craigburke.CustomDateEditorRegistrar)
}
So we have our datepicker setup, now we can use the dialog popup to show and hide the recurring options as needed. Here’s what the recurring options looks like in Google Calendar:
The one thing that makes reproducing this a bit tricky is if we throw the recurring options into a popup, the dialog plugin will pull them out of the form in the DOM. We can handle this by appending the recurring options to the popup when it opens and then putting them back in the form when it closes. That way all our recurring options get posted when we go to save. Here’s what that looks like:
var recurPopup = $("#recurPopup").dialog({
title: 'Repeat',
width: 400,
modal: true,
open: function(event, ui) {
$("#recurOptions").show().appendTo("#recurPopup");
},
close: function(event, ui) {
$("#recurOptions").hide().appendTo("form.main");
},
buttons: {
Ok: function() {
$( this ).dialog( "close" );
}
}
});
Now, as we look to finish up our controller, we’re not going to be able to use the generated actions to edit or delete and event (again the recurring events make this more complicated). Here’s the prompt that google calendar gives you if you try to edit a recurring event (we get a similar one if we try to delete a recurring event):
So here’s what should actually happen in these three cases when updating a recurring event:
- Only this event
- Create a new (non-recurring event) with these properties. Add the date of this new event as an exclusion on the original event.
- Following events
- Create a new recurring event that begins on the selected day. The original event should now end on this day.
- All events
- Edit the existing event record. No new event record needs to be created.
Here’s what should happen when deleting a recurring event:
- Only this event
- Add this date as an exclusion on the event.
- Following events
- Set the recurUntil date to the selected date.
- All events
- Delete the event record.
In order to keep our Controller lean and to take advantage of the transactions found in services, we’re going to move our update and delete code to our EventService.groovy file:
class EventService {
def updateEvent(Event eventInstance, String editType, def params) {
def result = [:]
try {
if (!eventInstance) {
result = [error: 'not.found']
}
else if (!eventInstance.isRecurring) {
eventInstance.properties = params
if (eventInstance.hasErrors() || !eventInstance.save(flush: true)) {
result = [error: 'has.errors']
}
}
else {
Date startTime = params.date('startTime', ['MM/dd/yyyy hh:mm a'])
Date endTime = params.date('endTime', ['MM/dd/yyyy hh:mm a'])
// Using the date from the original startTime and endTime with the update time from the form
int updatedDuration = Minutes.minutesBetween(new DateTime(startTime), new DateTime(endTime)).minutes
Date updatedStartTime = new DateTime(eventInstance.startTime).withTime(startTime.hours, startTime.minutes, 0, 0).toDate()
Date updatedEndTime = new DateTime(updatedStartTime).plusMinutes(updatedDuration).toDate()
if (editType == "occurrence") {
// Add an exclusion
eventInstance.with {
addToExcludeDays(new DateTime(startTime).withTime(0, 0, 0, 0).toDate())
save(flush: true)
}
// single event
new Event(params).with {
startTime = updatedStartTime
endTime = updatedEndTime
isRecurring = false // ignore recurring options this is a single event
save(flush: true)
}
}
else if (editType == "following") {
// following event
new Event(params).with {
recurUntil = eventInstance.recurUntil
save(flush: true)
}
eventInstance.with {
recurUntil = startTime
save(flush: true)
}
}
else if (editType == "all") {
eventInstance.properties = params
eventInstance.startTime = updatedStartTime
eventInstance.endTime = updatedEndTime
if (eventInstance.hasErrors() || !eventInstance.save()) {
result = [error: 'has.errors']
}
}
}
}
catch (Exception ex) {
result = [error: 'has.errors']
}
result
}
def deleteEvent(Event eventInstance, Date occurrenceStart, String deleteType) {
def result = [:]
try {
if (!eventInstance) {
result = [error: 'not.found']
}
if (!eventInstance.isRecurring || deleteType == "all") {
eventInstance.delete(flush: true)
}
else if (eventInstance && deleteType) {
if (deleteType == "occurrence") {
// Add an exclusion
eventInstance.addToExcludeDays(new DateTime(occurrenceStart).withTime(0, 0, 0, 0).toDate())
eventInstance.save(flush: true);
}
else if (deleteType == "following") {
eventInstance.recurUntil = occurrenceStart
eventInstance.save(flush: true)
}
}
}
catch (Exception ex) {
result = [error: 'has.errors']
}
result
}
}
Now our update and delete controller actions are pretty straightforward:
class EventController {
def eventService
def create = {
def eventInstance = new Event()
eventInstance.properties = params
[eventInstance: eventInstance]
}
def save = {
def eventInstance = new Event(params)
if (eventInstance.save(flush: true)) {
flash.message = "${message(code: 'default.created.message', args: [message(code: 'event.label', default: 'Event'), eventInstance.id])}"
redirect(action: "show", id: eventInstance.id)
}
else {
render(view: "create", model: [eventInstance: eventInstance])
}
}
def edit = {
def eventInstance = Event.get(params.id)
def (occurrenceStart, occurrenceEnd) = [params.long('occurrenceStart'), params.long('occurrenceEnd')]
if (!eventInstance) {
flash.message = "${message(code: 'default.not.found.message', args: [message(code: 'event.label', default: 'Event'), params.id])}"
redirect(action: "index")
}
else {
[eventInstance: eventInstance, occurrenceStart: occurrenceStart, occurrenceEnd: occurrenceEnd]
}
}
def update = {
def eventInstance = Event.get(params.id)
String editType = params.editType
def result = eventService.updateEvent(eventInstance, editType, params)
if (!result.error) {
flash.message = "${message(code: 'default.updated.message', args: [message(code: 'event.label', default: 'Event'), eventInstance.id])}"
redirect(action: "index")
}
if (result.error == 'not.found') {
flash.message = "${message(code: 'default.not.found.message', args: [message(code: 'event.label', default: 'Event'), params.id])}"
redirect(action: "index")
}
else if (result.error == 'has.errors') {
render(view: "edit", model: [eventInstance: eventInstance])
}
}
def delete = {
def eventInstance = Event.get(params.id)
String deleteType = params.deleteType
Date occurrenceStart = new Instant(params.long('occurrenceStart')).toDate()
def result = eventService.deleteEvent(eventInstance, occurrenceStart, deleteType)
if (!result.error) {
redirect(action: "index")
}
if (result.error == 'not.found') {
flash.message = "${message(code: 'default.not.found.message', args: [message(code: 'event.label', default: 'Event'), params.id])}"
redirect(action: "index")
}
else if (result.error == 'has.errors') {
redirect(action: "index")
}
}
}
We’re almost finished now, we just need to add some code to make sure the recurUntil value is set if the user specifies a recurCount value (notice the methods to view the events only look at the recurUntil property):
class Event {
def beforeUpdate() {
updateRecurringValues()
}
def beforeInsert() {
updateRecurringValues()
}
private void updateRecurringValues() {
if (!isRecurring) {
recurType = null
recurCount = null
recurInterval = null
recurUntil = null
excludeDays?.clear()
recurDaysOfWeek?.clear()
}
// Set recurUntil date based on the recurCount value
if (recurCount && !recurUntil) {
Date recurCountDate = startTime
for (int i in 1..recurCount) {
recurCountDate = eventService.findNextOccurrence(this, new DateTime(recurCountDate).plusMinutes(1).toDate())
}
recurUntil = new DateTime(recurCountDate).plusMinutes(durationMinutes).toDate()
}
}
def beforeDelete() {
def associatedEvents = Event.withCriteria {
eq('sourceEvent.id', this.id)
}
associatedEvents.each{def event ->
event.with {
sourceEvent = null
save(flush: true)
}
}
}
Google Calendar in Grails Series
- Google Calendar in Grails Part 1: The Model
- Google Calendar in Grails Part 2: Displaying the Calendar
- Google Calendar in Grails Part 3: Creating and Modifying Events
- Google Calendar in Grails - Adding Tests with Spock
Check out the Live Calendar Demo