Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

Full Calendar – A Complete Web Diary System for jQuery and C# MVC

4.90/5 (149 votes)
14 Sep 2016CPOL13 min read 824.5K   46.8K  
Using FullCalendar, jQuery and MVC to give your web-app ready-to-go appointment diary functionality

Introduction

This article describes using the very fine open source JQuery plugin “FullCalendar” by Adam Shaw to develop an appointment booking system. I will demonstrate numerous ways to use the plugin and show how it can integrate with an SQL Backend with Entity-Framework. The aim of this article is give you almost everything you need, that you can tweak immediately, to bring diary/appointment functionality to your MVC application. This is a complete walk-through including setup of a linked EF database in SQL. I am going to use the Twitter Bootstrap framework to help speed things along. I am also going into a lot of detail to help those wishing to use the plugin to get started as fast as possible. The attached project is written in C# MVC 4.

For those that need additional functionality for multi-user or multi-resource, you can get information and a full example to download and play with on this in my other article on multi-user multi-resource diary with FullCalendar.

Image 1

Background

I was recently looking for a good reliable web based diary plugin that would allow me to give solid appointment management functionality. Apart from some commercial offerings, the most suitable solution open source plugin I found was FullCalendar. Get it here. The demo scenario will have the following functionality:

  • For each day, manage appointments.
  • In month view, see overview summary information on all appointment events.

Gotchas

Some of the gotchas included date format (this plugin uses a Unix timestamp), and having to work with multiple plugin data sources as I wanted different kinds of data queried depending on the view of the calendar being seen (day, week, month..). I also had some issues with trying to get the events rendering exactly how I wanted - the DOH! moment came when I realised I was referencing some CSS in the wrong order/sequence. For drag/drop functionality, the plugin depends on jQueryUI to be included, and finally, in order to stop the plugin triggering before I wanted it to, I had to control the flow of loading of data carefully.

Setting Things Up

To get started:

Image 2

Image 3

You will also note that I have added the JQuery-UI JS file to my bundle - this is needed to support drag/drop.

JavaScript
<div id='calendar' style="width:65%"></div> 

(Note that the inline style is simply to force the width for screenshots inside the CodeProject publishing size guidelines!)

  1. Create a new C# MVC project and clear out the default view templates.
  2. Download FullCalendar from here.
  3. Download Twitter Bootstrap (I am using the stable version 2.3.2 until v3 is out of RC).
  4. For both libraries, unpack and place the CSS file in your /Content folder, and the JS scripts in the /Scripts folder.
  5. In your MVC project, add the bootstrap files to your script bundles. This is in BundleConfig.cs and is located in your App_Start folder.
  6. We will do our work in the default index.cshml page, so in there, place a DIV to hold the plugin:
  7. Finally, to prove it's at least loading and rendering correctly, we will add some basic JS when the document is ready and loaded:

     

    Image 4

    The initialization settings tell the plugin what to show in the header, the default view (in this case "agenda day" ..familiar to most), and to have a default time slot of 15 minutes.

When we run the project, as expected, the plugin appears:

Image 5

OK, so let's get busy!

Using the Code

Rather than use dummy client-side data, I wanted to show how we might store and manipulate data in a working environment, so I put together an SQL database to store data and linked to it using Entity Framework. (The SQL source is attached to this article.)

The SQL table is called "AppointmentDiary" and contains these fields:

Image 6

The ID is an auto-increment, Title gets displayed within the Calendar control as an event, SomeImportantKey represents a referential link to some other data table, DateTimescheduled is the diary date/time, Appointment length is an integer that represents the number of minutes the appointment lasts, and finally StatusENUM is an integer that links to an ENUM value to say if the appointment is an enquiry, a booking, confirmed, etc.

We add a new Entity Data Model to the project and point it at our new database and table. This creates an EDMX file that in our case we call "Diary" and reference as "DiaryContainer".

As this example is date based, I didn't want to pre-populate it with data, because any events I stored would be out of date the day after publishing! For this reason, I put together a quick method that initialises the database. The method is called by pressing a button in the browser that sends an Ajax call to the application (let's minimise full server round-trips when we can!).

The button starts life as a link:

HTML
<a href="#" id="btnInit" class="btn btn-secondary">Initialise database!</a>  

but through the wonder of Bootstrap, the addition of the class "btn" turns the link into a button, and "btn-secondary" gives it a gray colour.

Image 7

The script to call the initialise code is basic enough:

JavaScript
$('#btnInit').click(function () {
  $.ajax({
        type: 'POST',
        url: "/Home/Init",
        success: function (response) {
            if (response == 'True') {
                $('#calendar').fullCalendar('refetchEvents');
                alert('Database populated! ');
            }
            else {
                alert('Error, could not populate database!');
            }
        }
    }); 
});  

Server-side, we have a controller "/Home/Init" that calls a method in a shared file called Utils called "InitialiseDiary". The objective of this method is to generate a series of test diary appointments that centre around the current date. It creates some items for the current date, and others before and after the date.

C#
public static bool InitialiseDiary() { 
    // init connection to database
    DiaryContainer ent = new DiaryContainer();
    try
    { 
        for(int i= 0; i<30; i++){
        AppointmentDiary item = new AppointmentDiary();
        // record ID is auto generated
        item.Title = "Appt: " + i.ToString();
        item.SomeImportantKey = i;
        item.StatusENUM = GetRandomValue(0,3); // random is exclusive - we have 
                                               // three status enums
        if (i <= 5) // create ten appointments for today's date
        {
            item.DateTimeScheduled = GetRandomAppointmentTime(false, true);
        }
        else {  // rest of the appointments on previous and future dates
            if (i % 2 == 0)
                item.DateTimeScheduled = GetRandomAppointmentTime(true, false);
                // flip/flop between date ahead of today and behind today
            else item.DateTimeScheduled = GetRandomAppointmentTime(false, false);
        }
        item.AppointmentLength = GetRandomValue(1,5) * 15;
        // appointment length always less than an hour in this demo 
        // in blocks of fifteen minutes

        ent.AppointmentDiary.Add(item);
        ent.SaveChanges();
    }
    }
    catch (Exception)
    {
        return false;
    }

    return ent.AppointmentDiary.Count() > 0;         
}

This method calls two other supporting methods, one which generates a random number, the other that generates a random date/time.

C#
/// <summary>
/// sends back a date/time +/- 15 days from todays date
/// </summary>
public static DateTime GetRandomAppointmentTime(bool GoBackwards, bool Today) {
    Random rnd = new Random(Environment.TickCount); // set a new random seed each call
    var baseDate = DateTime.Today;
    if (Today)
        return new DateTime(baseDate.Year, baseDate.Month, 
                baseDate.Day, rnd.Next(9, 18), rnd.Next(1, 6)*5, 0);
    else
    {
        int rndDays = rnd.Next(1, 15);
        if (GoBackwards)
            rndDays = rndDays * -1; // make into negative number
        return new DateTime(baseDate.Year, baseDate.Month, 
          baseDate.Day, rnd.Next(9, 18), rnd.Next(1, 6)*5, 0).AddDays(rndDays);             
    }
} 

Now we have that, we can generate sample data by running the application and clicking the button (made delicious and wonderful by the magic of the twittering bootstrap....) however, before we click that object of wonder, let's put in a controller and method to send our sample data back to the plugin....

Full calendar can create diary "events" to render in a number of different ways. One of the more common is to send in data as a JSON list.

"Events" need at a minimum the following information:

  • ID: a unique identifier for the diary item
  • Title: Some text to render on the screen
  • Start: Starting date/time of the event
  • End: Ending date/time of the event

You can also send some other information back such as the color you would like the event to be on the screen, a CSS class-name if you wish to render the event in a particular way. You can also send back any other information you might need to handle client-side, for example, key fields to related data tables, etc.

To hook into the data table, I created a model called DiaryEvent, and gave it some fields that map to the Entity model.

C#
public class DiaryEvent
{
   public int ID;
   public string Title;
   public int SomeImportantKeyID; 
   public string StartDateString;
   public string EndDateString;
   public string StatusString;
   public string StatusColor;
   public string ClassName;
   ...
}

In addition, I added some methods to extract information and save it back. The first we are interested in takes as parameters, a start and end date, and returns a list of DiaryEvents:

C#
public static List<DiaryEvent> LoadAllAppointmentsInDateRange(double start, double end)
{
    var fromDate = ConvertFromUnixTimestamp(start);
    var toDate = ConvertFromUnixTimestamp(end);
    using (DiaryContainer ent = new DiaryContainer())
    {
        var rslt = ent.AppointmentDiary.Where(s => s.DateTimeScheduled >= 
            fromDate && System.Data.Objects.EntityFunctions.AddMinutes(
            s.DateTimeScheduled, s.AppointmentLength) <= toDate);
        List<DiaryEvent> result = new List<DiaryEvent>();
        foreach (var item in rslt)
        {
            DiaryEvent rec = new DiaryEvent();
            rec.ID = item.ID;
            rec.SomeImportantKeyID = item.SomeImportantKey;
            rec.StartDateString = item.DateTimeScheduled.ToString("s");
            // "s" is a preset format that outputs as: "2009-02-27T12:12:22"

            rec.EndDateString = item.DateTimeScheduled.AddMinutes
                                (item.AppointmentLength).ToString("s");
            // field AppointmentLength is in minutes

            rec.Title = item.Title + " - " + item.AppointmentLength.ToString() + " mins";
            rec.StatusString = Enums.GetName<AppointmentStatus>
                               ((AppointmentStatus)item.StatusENUM);
            rec.StatusColor = Enums.GetEnumDescription<AppointmentStatus>(rec.StatusString);
            string ColorCode = rec.StatusColor.Substring(0, rec.StatusColor.IndexOf(":"));
            rec.ClassName = rec.StatusColor.Substring(rec.StatusColor.IndexOf(":")+1, 
                            rec.StatusColor.Length - ColorCode.Length-1);
            rec.StatusColor = ColorCode;                   
            result.Add(rec);
        }
        return result;
    }
}

The code is quite simple:

  1. We connect to the database using Entity Framework, run a LINQ query that extracts all appointment events between the start and end dates (using "EntityFunctions.AddMinutes" to create an end-date on the fly from the StartDateTime + AppointmentLength (in minutes)
  2. For each event returned, we create a DiaryEvent item and add the data table record information to it ready to send back.

Some things to note - first, FullCalander deals in dates in a UNIX format, therefore we had to run a conversion for this before querying. Second, I stored the color attribute of the "Status" of the event in the "DESCRIPTION ANNOTATION" of the StatusENUM, and then used a method to extract the description, color code, etc. The color code, etc. includes a CSS "class name" we will use later in the article to make the event item look a wee bit fancier...

CSS:

CSS
.ENQUIRY {
    
    background-color: #FF9933;
    border-color: #C0C0C0; 
    color: White;
    background-position: 1px 1px;
    background-repeat: no-repeat;
    background-image: url('Bubble.png');
    padding-left: 50px;
}
 
.BOOKED {
    background-color: #33CCFF;
    border-color: #C0C0C0;
    color: White;
    background-position: 1px 1px;
    background-repeat: no-repeat;
    background-image: url('ok.png');
    padding-left: 50px;
... etc...   

StatusENUM:

Image 8

Method to extract CSS class/color from Description attribute of StatusENUM:

C#
public static string GetEnumDescription<T>(string value)
{
    Type type = typeof(T);
    var name = Enum.GetNames(type).Where(f => f.Equals(value, 
        StringComparison.CurrentCultureIgnoreCase)).Select(d => d).FirstOrDefault();
    if (name == null)
    {
        return string.Empty;
    }
    var field = type.GetField(name);
    var customAttribute = field.GetCustomAttributes(typeof(DescriptionAttribute), false);
    return customAttribute.Length > 0 ? 
           ((DescriptionAttribute)customAttribute[0]).Description : name;
}

Ok, almost there, we need to take the list and put that into JSON format to send back to the Plugin ... this is done in a controller...

C#
public JsonResult GetDiaryEvents(double start, double end)
{
    var ApptListForDate = DiaryEvent.LoadAllAppointmentsInDateRange(start, end);
    var eventList = from e in ApptListForDate
                    select new
                    {
                        id = e.ID,
                        title = e.Title,
                        start = e.StartDateString,
                        end = e.EndDateString,
                        color = e.StatusColor,
                        someKey = e.SomeImportantKeyID,
                        allDay = false
                    };
    var rows = eventList.ToArray();
    return Json(rows, JsonRequestBehavior.AllowGet); 
} 

Finally (!) ... let's go back to our INDEX page, and add one line that tells the FullCalendar plugin where to go to get its JSON data...

Image 9

Now when we run the application, we can click our INIT button, which will populate the database, and see the data coming through...

Image 10

weehoo Smile | <img src= ....

Image 12

You recall earlier I talked about color, and ClassName - the plugin allows us to pass in a CSS class name, and it will use this to help render the event. In the CSS I showed earlier, I added an icon image to the CSS decoration to make the event look visually better. To make this pass through, we add in the ClassName string...

Image 13

And this is how it renders....

Image 14

That's superb, now let's look at client-side user functionality - how can the user interact with our diary?

Typically, one would expect to be able to select an event and get information on it, edit it, move it around, resize it, etc. Let's see how to make that happen.

First, let's examine the information we sent along with the event. We can do this by adding a callback function when we initialise the plugin:

JavaScript
eventClick: function (calEvent, jsEvent, view) {
    alert('You clicked on event id: ' + calEvent.id
        + "\nSpecial ID: " + calEvent.someKey 
        + "\nAnd the title is: " + calEvent.title); 

The important param here is the first one, calEvent - from it, we can access any of the data we sent through when we sent back the JSON records.

Here's how it looks:

Image 15

Using this information, you can hook in and use it to popup an edit form, redirect to a details window, etc. - your code chops are your magic wand.

To move an event around the plugin, we hook eventDrop.

JavaScript
eventDrop: function (event, dayDelta, minuteDelta, allDay, revertFunc) {
    if (confirm("Confirm move?")) {
        UpdateEvent(event.id, event.start);
    }
    else {
        revertFunc();
    }  
}  

The parameters dayDelta and minuteDelta are useful, as they tell us the number of days or minutes the event has been moved by. We could use this to adjust the backend database, but there is another method I used.

I decided in this case to take the event object and use its information. When we create the event initially, it is set with the start/end date and time we give it. When the item is moved/dropped, that date and time change, however, the ID and the extra information we saved along with the event do not. Therefore, my strategy in this case was to take the ID and the new start and end time and use these to update the database.

FullCalendar provides a "revertFunc" method that resets the event move to its previous state if the user decides not to confirm the event move.

On event move, I call a local script function "UpdateEvent". This takes the relevant data, and sends it by Ajax to a controller back on the server:

JavaScript
function UpdateEvent(EventID, EventStart, EventEnd) {
    var dataRow = {
        'ID': EventID,
        'NewEventStart': EventStart,
        'NewEventEnd': EventEnd
    }
    $.ajax({
        type: 'POST',
        url: "/Home/UpdateEvent",
        dataType: "json",
        contentType: "application/json",
        data: JSON.stringify(dataRow)
    }); 
}

Controller:

C#
public void UpdateEvent(int id, string NewEventStart, string NewEventEnd)
{
    DiaryEvent.UpdateDiaryEvent(id, NewEventStart, NewEventEnd); 
} 

Method called from controller:

C#
public static void UpdateDiaryEvent(int id, string NewEventStart, string NewEventEnd) 
{
    // EventStart comes ISO 8601 format, 
    // e.g.:  "2000-01-10T10:00:00Z" - need to convert to DateTime
    using (DiaryContainer ent = new DiaryContainer()) {
        var rec = ent.AppointmentDiary.FirstOrDefault(s => s.ID == id);
        if (rec != null)
        {
            DateTime DateTimeStart = DateTime.Parse(NewEventStart, null, 
               DateTimeStyles.RoundtripKind).ToLocalTime(); // and convert offset to localtime
            rec.DateTimeScheduled = DateTimeStart;
            if (!String.IsNullOrEmpty(NewEventEnd)) { 
                TimeSpan span = DateTime.Parse(NewEventEnd, null, 
                   DateTimeStyles.RoundtripKind).ToLocalTime() - DateTimeStart;
                rec.AppointmentLength = Convert.ToInt32(span.TotalMinutes);
                }
            ent.SaveChanges();
        }
    }
} 

The important thing to note here is that the date format is sent in IS8601 format so we need to convert it. I also use a Timespan to calculate the new appointment length if any.

Event resizing, and updating the database appointment length server-side is done in a similar fashion. First, hook the event:

JavaScript
eventResize: function (event, dayDelta, minuteDelta, revertFunc) {
    if (confirm("Confirm change appointment length?")) {
        UpdateEvent(event.id, event.start, event.end);
    }
    else {
        revertFunc();
    } 
},  

You will notice that I have used the same controller and method to update the database - all in an effort to reuse code wherever possible! What happens is that if the update method sees that a new end date/time has been sent in, it assumes a RESIZE has happened and adjusts the "AppointmentLength" value accordingly, else it just updates the new start/date time.

C#
if (!String.IsNullOrEmpty(NewEventEnd)) { 
    TimeSpan span = DateTime.Parse(NewEventEnd, null, 
                    DateTimeStyles.RoundtripKind).ToLocalTime() - DateTimeStart;
    rec.AppointmentLength = Convert.ToInt32(span.TotalMinutes); 
}

Now, that's great, and we have a lovely warm fuzzy feeling about making it happen, until we turn to look at the MONTH view....

Image 16

Oh dear .... it renders every single event, and makes our calendar look rather messy... it would be better, for month view, if the control just showed a summary of appointment/diary events for each date. There is an elegant solution to this issue in "data sources".

Full Calendar can store an array of event SOURCES. All we need to do is hook into the "viewRender" event, query what kind of view is active (day, week, month...), and based on this, change the source and tell the plugin to refresh the data.

First, let's go back to our index.cshtml plugin init section and remove the reference to the data path as we will replace it with something else...

Image 17

To facilitate the new functionality we require, I created two variables to hold the URL path of each view.

JavaScript
var sourceSummaryView = { url: '/Home/GetDiarySummary/' };
var sourceFullView = { url: '/Home/GetDiaryEvents/' }; 

What we are aiming to do is hook into the Click/change event of the view button group...

Image 18

The way to do this is to drop in code that examines when the user clicks the buttons to change the view - this is "viewRender". Two parameters are passed, the first is "view" and that tells us what was just clicked:

Image 19

So depending on which view was clicked, we remove any data sources from the source array (by name), remove any events from the plugin, and finally assign one of the string variables we created earlier as the new data source which then gets immediately loaded.

As our summary view holds slightly different information, we need to create a different method to extract data server side. This is almost the same as our previous query, with the exception that we need to extend our LINQ query to use GROUP and COUNT. Our objective is to group by date, and get a count of diary events for each day. Let's have a quick look at that method...

C#
public static List<DiaryEvent> LoadAppointmentSummaryInDateRange(double start, double end)
{
    var fromDate = ConvertFromUnixTimestamp(start);
    var toDate = ConvertFromUnixTimestamp(end);
    using (DiaryContainer ent = new DiaryContainer())
    {
        var rslt = ent.AppointmentDiary.Where(
           s => s.DateTimeScheduled >= fromDate && 
           System.Data.Objects.EntityFunctions.AddMinutes
                       (s.DateTimeScheduled, s.AppointmentLength) <= toDate)
           .GroupBy(s => 
              System.Data.Objects.EntityFunctions.TruncateTime(s.DateTimeScheduled))
           .Select(x => new { DateTimeScheduled = x.Key, Count = x.Count() });
        List<DiaryEvent> result = new List<DiaryEvent>();
        int i = 0;
        foreach (var item in rslt)
        {
            DiaryEvent rec = new DiaryEvent();
            rec.ID = i; //we don't link this back to anything as it's a group summary
            // but the fullcalendar needs unique IDs for each event item 
            // (unless it's a repeating event)

            rec.SomeImportantKeyID = -1;  
            string StringDate = string.Format("{0:yyyy-MM-dd}", item.DateTimeScheduled);
            rec.StartDateString = StringDate + "T00:00:00"; //ISO 8601 format
            rec.EndDateString = StringDate +"T23:59:59";
            rec.Title = "Booked: " + item.Count.ToString();
            result.Add(rec);
            i++;
        }
        return result;
    }
}

As before, we need to convert the incoming unix format date. Next, because we are grouping by date, but we are storing a dateTIME, we need to remove the time part from the query - for this, we use the EntityFunctions.TruncateTime method, and chain the result of this into a sub select which gives us back a key field of date and a value of count.

Note that as this is summary information, we don't have a key-id-field to link back to, but this is a problem for FullCalendar as it needs a unique ID for keeping track of events, so we assign an arbitrary one in this case of a simple counting variable ("i"). The other thing we need to do is because we stripped out the time part of the result to enable the grouping, we need to put it back in using ISO format.

Once the code is implemented, everything looks far better!

Image 20

The last thing we are going to do is add a new event when a blank space on the calendar is clicked.

We will construct a quick bootstrap modal popup to capture an event title, date/time and appointment length:

HTML
<div id="popupEventForm" class="modal hide" style="display: none;">
   <div class="modal-header"><h3>Add new event</h3></div>
  <div class="modal-body">
    <form id="EventForm" class="well">
        <input type="hidden" id="eventID">
        <label>Event title</label>
        <input type="text" id="eventTitle" placeholder="Title here"><br />
        <label>Scheduled date</label>
        <input type="text" id="eventDate"><br />
        <label>Scheduled time</label>
        <input type="text" id="eventTime"><br />
        <label>Appointment length (minutes)</label>
        <input type="text" id="eventDuration" placeholder="15"><br />
    </form>
</div>
  <div class="modal-footer">
    <button type="button" id="btnPopupCancel" data-dismiss="modal" class="btn">Cancel</button>
    <button type="button" id="btnPopupSave" data-dismiss="modal" 
     class="btn btn-primary">Save event</button>
  </div>
</div> 

Next, we will add a hook to our friendly plugin to show this modal popup when a day slot is clicked.

JavaScript
dayClick: function (date, allDay, jsEvent, view) {
    $('#eventTitle').val("");
    $('#eventDate').val($.fullCalendar.formatDate(date, 'dd/MM/yyyy'));
    $('#eventTime').val($.fullCalendar.formatDate(date, 'HH:mm'));
    ShowEventPopup(date); 
},

A small script method initialises the popup form, first clearing any input values hanging around and then setting focus to the first input box:

JavaScript
function ShowEventPopup(date) {
    ClearPopupFormValues();
    $('#popupEventForm').show();
    $('#eventTitle').focus(); 
}  

Image 21

Finally, we have script attached to the "save event" button to send the data back to the server via Ajax.

JavaScript
$('#btnPopupSave').click(function () {
    $('#popupEventForm').hide();
    var dataRow = {
        'Title':$('#eventTitle').val(),
        'NewEventDate': $('#eventDate').val(),
        'NewEventTime': $('#eventTime').val(),
        'NewEventDuration': $('#eventDuration').val()
    }
    ClearPopupFormValues();
    $.ajax({
        type: 'POST',
        url: "/Home/SaveEvent",
        data: dataRow,
        success: function (response) {
            if (response == 'True') {
                $('#calendar').fullCalendar('refetchEvents');
                alert('New event saved!');
            }
            else {
                alert('Error, could not save event!');
            }
        }
    });
}); 

If the server saves the record successfully, it sends back 'True' and we tell the plugin to refetch its events.

The very last thing I want to address is double loading of data. When we are changing data sources using the viewRender callback, what happens is that on initial page load, the viewRender gets kicked off twice. To fix this, I added in a variable var CalLoading = true; at the top of the main script before the plugin is initialised, examine that when rendering the view, and reset it after the document is ready.

Image 22

So that's it! ... there's enough detail in this article to allow you, with very little effort, to include quite useful diary functionality into your MVC application. The full source code is included at the top of the article.

(P.S.: If you found this article useful or downloaded the code, please let me know by giving a rating below!)

History

  • 18th August, 2013: Initial version
  • 14th September, 2016: Updated - link to new article on FullCalander

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)