Khanh Hoang - Kenn
Kenn is a user experience designer and front end developer who enjoys creating beautiful and usable web and mobile experiences.
Recently I needed to create a calendar view of nodes that had a date field (with start/end dates). After researching my options, I came to the conclusion that I had the following choices:
The first option looked promising but I quickly found that it didn't quite fit my needs. The calendar needed to be able to show nodes of different types (events/tasks that also used different fields for their dates) and this was simply not possible using Views (could be wrong but I doubt it).
The second option offered more flexibility but also meant I'd have to build/theme a calendar from scratch and honestly, who wants to do that?
In the end, I went with the third option which allowed me to build the query how I needed while relying on the handiwork of a jQuery plugin to make it pretty. I decided to use Adam Shaw's excellent FullCalendar jQuery plugin since it's dead simple to use and offers a myriad of options to help suit your needs.
Here's a basic overview of how I got things set up.
First, I downloaded the FullCalendar module and placed it in my "sites/all/libraries" directory. The great thing about Drupal is that you really don't need to do much to get jQuery plugins working on your site. To make things easy, I used hook_library() to define the default javascript & css files that need to be added like so:
/** * Implements hook_library(). */ function mymodule_library() { if($fc_path = libraries_get_path('fullcalendar-1.5.4')) { $items['fullcalendar'] = array( 'title' => 'FullCalendar', 'version' => '1.5.4', 'js' => array( $fc_path . '/fullcalendar/fullcalendar.min.js' => array(), ), 'css' => array( $fc_path . '/fullcalendar/fullcalendar.css' => array(), ), 'dependencies' => array( array('system', 'ui'), array('system', 'ui.draggable'), array('system', 'drupal.ajax'), array('system', 'jquery.form'), ), ); } }
Next, in my custom module I created two page callbacks (one to display the calendar, the other to be an ajax callback that FullCalendar uses to populate events
$items['node/%node/calendar'] = array( 'title' => 'Calendar', 'page callback' => 'mymodule_calendar_page', 'page arguments' => array(1), 'access callback' => 'mymodule_menu_access_callback', 'access arguments' => array(1, 'access content'), 'file' => 'mymodule.pages.calendar.inc', 'type' => MENU_LOCAL_TASK, ); $items['ajax/mymodule/calendar/%node/events'] = array( 'page callback' => 'mymodule_ajax_calendar_events', 'page arguments' => array(3), 'access arguments' => array('access content'), 'file' => 'mymodule.pages.calendar.inc', );
In the first callback, I simply return a div with an id that I can target in the javascript. It should be noted that in my module, I used a $settings array to pass any needed data to Drupal.settings so that I have access to that data in javascript. This keeps you from hardcoding things like the selector in your javascript file. You'll also note that I use the #attached">#attached FAPI property to include the library we created above, and attach any other javascript files/settings.
/** * Returns markup for calendar page. */ function mymodule_calendar_page() { $selector = 'mymodule_calendar_div'; $settings = array( //set any custom settings that you need to access in javascript here ); $output['calendar'] = array( '#markup' => '<div id="' . $selector . '"></div>', '#attached' => array( 'library' => array( array('mymodule', 'fullcalendar'), ), 'js' => array( $path . '/js/mymodule.calendar.js' => array(), array('data' => $settings, 'type' => 'setting'), ), ), ); return $output; }
In the second callback I use EntityFieldQuery to get the data I need, then return the data for each event/task. Note, I'm pasting the code directly from my module as-is for reference but you'll need to adapt what I've done for your own needs.
/** * Ajax callback to return project events. */ function mhm_project_ajax_calendar_events($project) { drupal_add_library('system', 'drupal.ajax'); $events = array(); $og = og_get_group('node', $project->nid); $start = (int) $_GET['start']; $end = (int) $_GET['end']; // This is a separate function that gets events for this date range. $result = mhm_project_get_project_events($og->gid, $start, $end); if(module_exists('mhm_og_tasks')) { // Get tasks for this project. $query = new EntityFieldQuery(); $query ->entityCondition('entity_type', 'node') ->propertyCondition('type', array('task'), 'IN') ->propertyCondition('status', 1) ->fieldCondition('group_audience', 'gid', $og->gid) ->fieldCondition('field_due_date', 'value', array($start, $end), 'BETWEEN'); if($tasks = $query->execute()) { $result = array_merge($result, $tasks['node']); } } foreach($result as $row) { $node = entity_metadata_wrapper('node', $row->nid); $entity_uri = entity_uri('node', $node->value()); $event = (object) array( 'id' => $node->nid->value(), 'title' => $node->title->value(), 'url' => url($entity_uri['path'], $entity_uri['options']), 'event_type' => $node->type->value(), ); switch($node->type->value()) { case 'event': $event->start = $node->field_date->value->value(); $event->project_team = $node->field_project_team[0]->value(); if($node->field_project_team->value()) { $event->color = $node->field_project_team[0]->field_color->value(); $event->className = 'project-team-' . $node->field_project_team[0]->nid->value(); } if($node->field_date->value->value() != $node->field_date->value2->value()) { $event->end = $node->field_date->value2->value(); } break; case 'task': $form = drupal_get_form('mhm_og_tasks_task_completion_form', $node->value()); $form['task']['#title'] = $form['#task']->title; $form['task']['#suffix'] = NULL; $event->task_form = drupal_render($form); $event->start = $node->field_due_date->value(); $event->className = 'mhm-project-task'; $event->color = '#FFF'; $event->textColor = '#000'; break; } if($body = $node->body->value()) { $event->description = $node->body->value->value(array('sanatize' => TRUE)); } $events[] = $event; } echo drupal_json_encode($events); drupal_exit(); }
Basically, this function gets both the events and tasks for the given date range, loops through the results and builds the object needed for FullCalendar to use, then returns the results as a json-encoded array. There's a lot that I could cover in this function (like the use of the Entity module to get values from a node, but that's for another post.
Lastly, I created a javascript file that initializes the fullCalendar object and calls the ajax callback page to get the events for the displayed month. As with the ajax function above, I'm also posting the javascript file as is for reference. I used the fullCalendar documentation to help with setup but all in all, it was pretty easy to set up and get going.
(function($) { Drupal.behaviors.mymodule_calendar = { attach: function(context, settings) { $("a.fc-event").popover({ trigger: "hover", }); $(settings.mymodule.calendar.calendar_selector).fullCalendar({ events: { url: settings.basePath + settings.mymodule.calendar.project_events_json_url.replace("%nid", settings.mymodule.calendar.project.nid), }, dayClick: function(date, allDay, jsEvent, view) { var frag = "?gids_node[0]=" + settings.mymodule.calendar.project.nid; var d = new Date(date); frag += "&field_date_value=" + d.getFullYear() + "-" + d.getMonth() + "-" + d.getDate(); Drupal.overlay.open("#overlay=node/add/event" + escape(frag)); }, eventRender: function(event, element) { // Since we always return events for all project teams, check // to see if this event's team is currently hidden. If so, hide // this event. switch(event.event_type) { case 'event': if(event.project_team != null) { if(settings.mymodule.calendar.teams[event.project_team.nid] == null) { element.hide(); } } break; case 'task': if(settings.mymodulecalendar.teams['task'] == null) { element.hide(); } break; } if(event.task_form != null) { element.find('span.fc-event-title').html(event.task_form); } element.tooltip({ title: element.description, }); } }); // Allows clicking legend items to hide/show coresponding events. $("ul.mymodule-project-team-legend span.swatch").click(function() { switch($(this).attr("data-legend-type")) { case 'project-team': var team_id = $(this).attr("data-mymodule-team-id"); $("a.project-team-" + team_id).toggle(); break; case 'task': var team_id = "task"; $("a.mymodule-task").toggle(); break; } if($("i", this).hasClass("icon-ok")) { settings.mymodule.calendar.teams[team_id] = null; } else { settings.mymodule.calendar.teams[team_id] = team_id; } $("i", this).toggleClass("icon-ok"); }); } } })(jQuery);
All in all, the combination of EntityFieldQuery, the fullCalendar jQuery plugin, and a little bit of putting it all together made it rather easy to accomplish what I was after. I hope this helps to show how easy Drupal 7 makes it to do things like this. Feel free to leave a comment with any questions or suggestions.
Until next time!
Here's a list of references that might help explain some of the concepts I didn't delve into here: