MDL-59393 calendar: add event drag and drop to monthly view
authorRyan Wyllie <ryan@moodle.com>
Thu, 17 Aug 2017 06:27:27 +0000 (06:27 +0000)
committerRyan Wyllie <ryan@moodle.com>
Wed, 23 Aug 2017 00:57:22 +0000 (00:57 +0000)
calendar/amd/build/calendar.min.js
calendar/amd/build/drag_drop.min.js [new file with mode: 0644]
calendar/amd/build/events.min.js
calendar/amd/build/repository.min.js
calendar/amd/src/calendar.js
calendar/amd/src/drag_drop.js [new file with mode: 0644]
calendar/amd/src/events.js
calendar/amd/src/repository.js
calendar/templates/month_detailed.mustache
theme/boost/scss/moodle/core.scss

index 59256a9..7d3c291 100644 (file)
Binary files a/calendar/amd/build/calendar.min.js and b/calendar/amd/build/calendar.min.js differ
diff --git a/calendar/amd/build/drag_drop.min.js b/calendar/amd/build/drag_drop.min.js
new file mode 100644 (file)
index 0000000..249c6d6
Binary files /dev/null and b/calendar/amd/build/drag_drop.min.js differ
index ca56cb1..762a824 100644 (file)
Binary files a/calendar/amd/build/events.min.js and b/calendar/amd/build/events.min.js differ
index 8cc9d4b..a7c085d 100644 (file)
Binary files a/calendar/amd/build/repository.min.js and b/calendar/amd/build/repository.min.js differ
index b1f37e7..3e49acb 100644 (file)
@@ -59,7 +59,9 @@ define([
     var SELECTORS = {
         ROOT: "[data-region='calendar']",
         EVENT_LINK: "[data-action='view-event']",
-        NEW_EVENT_BUTTON: "[data-action='new-event-button']"
+        NEW_EVENT_BUTTON: "[data-action='new-event-button']",
+        DAY_CONTENT: "[data-region='day-content']",
+        LOADING_ICON: '.loading-icon',
     };
 
     /**
@@ -155,6 +157,60 @@ define([
         }).fail(Notification.exception);
     };
 
+    /**
+     * Handler for the drag and drop move event. Provides a loading indicator
+     * while the request is sent to the server to update the event start date.
+     *
+     * Triggers a eventMoved calendar javascript event if the event was successfully
+     * updated.
+     *
+     * @param {event} e The calendar move event
+     * @param {object} eventElement The jQuery element with the event id
+     * @param {object} originElement The jQuery element for where the event is moving from
+     * @param {object} destinationElement The jQuery element for where the event is moving to
+     */
+    var handleMoveEvent = function(e, eventElement, originElement, destinationElement) {
+        var eventId = eventElement.attr('data-event-id');
+        var originTimestamp = originElement.attr('data-day-timestamp');
+        var destinationTimestamp = destinationElement.attr('data-day-timestamp');
+
+        // If the event has actually changed day.
+        if (originTimestamp != destinationTimestamp) {
+            Templates.render('core/loading', {})
+                .then(function(html, js) {
+                    // First we show some loading icons in each of the days being affected.
+                    originElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
+                    destinationElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
+                    Templates.appendNodeContents(originElement, html, js);
+                    Templates.appendNodeContents(destinationElement, html, js);
+                    return;
+                })
+                .then(function() {
+                    // Send a request to the server to make the change.
+                    return CalendarRepository.updateEventStartDay(eventId, destinationTimestamp);
+                })
+                .then(function() {
+                    // If the update was successful then broadcast an event letting the calendar
+                    // know that an event has been moved.
+                    $('body').trigger(CalendarEvents.eventMoved, [eventElement, originElement, destinationElement]);
+                    return;
+                })
+                .always(function() {
+                    // Always remove the loading icons regardless of whether the update
+                    // request was successful or not.
+                    var originLoadingElement = originElement.find(SELECTORS.LOADING_ICON);
+                    var destinationLoadingElement = destinationElement.find(SELECTORS.LOADING_ICON);
+                    originElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
+                    destinationElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
+
+                    Templates.replaceNode(originLoadingElement, '', '');
+                    Templates.replaceNode(destinationLoadingElement, '', '');
+                    return;
+                })
+                .fail(Notification.exception);
+        }
+    };
+
     /**
      * Create the event form modal for creating new events and
      * editing existing events.
@@ -204,6 +260,12 @@ define([
             // Action events needs to be edit directly on the course module.
             window.location.assign(url);
         });
+        // Handle the event fired by the drag and drop code.
+        body.on(CalendarEvents.moveEvent, handleMoveEvent);
+        // When an event is successfully moved we should updated the UI.
+        body.on(CalendarEvents.eventMoved, function() {
+            window.location.reload();
+        });
 
         eventFormModalPromise.then(function(modal) {
             // When something within the calendar tells us the user wants
diff --git a/calendar/amd/src/drag_drop.js b/calendar/amd/src/drag_drop.js
new file mode 100644 (file)
index 0000000..f2b17e6
--- /dev/null
@@ -0,0 +1,206 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to handle calendar drag and drop. This module
+ * unfortunately requires some state to be maintained because of the
+ * limitations of the HTML5 drag and drop API which means it can't
+ * be used multiple times with the current implementation.
+ *
+ * @module     core_calendar/drag_drop
+ * @class      drag_drop
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+            'jquery',
+            'core_calendar/events'
+        ],
+        function(
+            $,
+            CalendarEvents
+        ) {
+
+    var SELECTORS = {
+        ROOT: "[data-region='calendar']",
+        DRAGGABLE: '[draggable="true"]',
+        DROP_ZONE: '[data-drop-zone="true"]',
+        WEEK: '[data-region="month-view-week"]',
+    };
+    var HOVER_CLASS = 'bg-primary';
+
+    // Unfortunately we are required to maintain some module
+    // level state due to the limitations of the HTML5 drag
+    // and drop API. Specifically the inability to pass data
+    // between the dragstate and dragover events handlers
+    // using the DataTransfer object in the event.
+
+    /** @var int eventId The event id being moved. */
+    var eventId = null;
+    /** @var int duration The number of days the event spans */
+    var duration = null;
+
+    /**
+     * Update the hover state for the event in the calendar to reflect
+     * which days the event will be moved to.
+     *
+     * This funciton supports events spanning multiple days and will
+     * recurse to highlight (or remove highlight) each of the days
+     * that the event will be moved to.
+     *
+     * For example: An event with a duration of 3 days will have
+     * 3 days highlighted when it's dragged elsewhere in the calendar.
+     * The current drag target and the 2 days following it (including
+     * wrapping to the next week if necessary).
+     *
+     * @param {string|object} target The drag target element
+     * @param {bool} hovered If the target is hovered or not
+     * @param {int} count How many days to highlight (default to duration)
+     */
+    var updateHoverState = function(target, hovered, count) {
+        var dropZone = $(target).closest(SELECTORS.DROP_ZONE);
+        if (typeof count === 'undefined') {
+            // This is how many days we need to highlight.
+            count = duration;
+        }
+
+        if (hovered) {
+            dropZone.addClass(HOVER_CLASS);
+        } else {
+            dropZone.removeClass(HOVER_CLASS);
+        }
+
+        count--;
+
+        // If we've still got days to highlight then we should
+        // find the next day.
+        if (count > 0) {
+            var nextDropZone = dropZone.next();
+
+            // If there are no more days in this week then we
+            // need to move down to the next week in the calendar.
+            if (!nextDropZone.length) {
+                var nextWeek = dropZone.closest(SELECTORS.WEEK).next();
+
+                if (nextWeek.length) {
+                    nextDropZone = nextWeek.children(SELECTORS.DROP_ZONE).first();
+                }
+            }
+
+            // If we found another day then let's recursively
+            // update it's hover state.
+            if (nextDropZone.length) {
+                updateHoverState(nextDropZone, hovered, count);
+            }
+        }
+    };
+
+    /**
+     * Set up the module level variables to track which event is being
+     * dragged and how many days it spans.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragstartHandler = function(e) {
+        var eventElement = $(e.target);
+
+        if (!eventElement.is('[data-event-id]')) {
+            eventElement = eventElement.find('[data-event-id]');
+        }
+
+        eventId = eventElement.attr('data-event-id');
+
+        var eventsSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
+        duration = $(eventsSelector).length;
+
+        e.dataTransfer.effectAllowed = "move";
+        e.dataTransfer.dropEffect = "move";
+        // Firefox requires a value to be set here or the drag won't
+        // work and the dragover handler won't fire.
+        e.dataTransfer.setData('text/plain', eventId);
+        e.dropEffect = "move";
+    };
+
+    /**
+     * Update the hover state of the target day element when
+     * the user is dragging an event over it.
+     *
+     * This will add a visual indicator to the calendar UI to
+     * indicate which day(s) the event will be moved to.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragoverHandler = function(e) {
+        e.preventDefault();
+        updateHoverState(e.target, true);
+    };
+
+    /**
+     * Update the hover state of the target day element that was
+     * previously dragged over but has is no longer a drag target.
+     *
+     * This will remove the visual indicator from the calendar UI
+     * that was added by the dragoverHandler.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragleaveHandler = function(e) {
+        e.preventDefault();
+        updateHoverState(e.target, false);
+    };
+
+    /**
+     * Determines the event element, origin day, and destination day
+     * once the user drops the calendar event. These three bits of data
+     * are provided as the payload to the "moveEvent" calendar javascript
+     * event that is fired.
+     *
+     * This will remove the visual indicator from the calendar UI
+     * that was added by the dragoverHandler.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dropHandler = function(e) {
+        e.preventDefault();
+
+        var eventElementSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
+        var eventElement = $(eventElementSelector);
+        var origin = eventElement.closest(SELECTORS.DROP_ZONE);
+        var destination = $(e.target).closest(SELECTORS.DROP_ZONE);
+
+        updateHoverState(e.target, false);
+        $('body').trigger(CalendarEvents.moveEvent, [eventElement, origin, destination]);
+    };
+
+    return {
+        /**
+         * Initialise the event handlers for the drag events.
+         */
+        init: function(root) {
+            root = $(root);
+
+            root.find(SELECTORS.DRAGGABLE).each(function(index, element) {
+                element.addEventListener('dragstart', dragstartHandler, true);
+            });
+
+            root.find(SELECTORS.DROP_ZONE).each(function(index, element) {
+                element.addEventListener('dragover', dragoverHandler, true);
+                element.addEventListener('dragleave', dragleaveHandler, true);
+                element.addEventListener('drop', dropHandler, true);
+            });
+        },
+    };
+});
index 465e337..95e0968 100644 (file)
@@ -29,6 +29,8 @@ define([], function() {
         updated: 'calendar-events:updated',
         editEvent: 'calendar-events:edit_event',
         editActionEvent: 'calendar-events:edit_action_event',
-        monthChanged: 'calendar-events:month_changed'
+        eventMoved: 'calendar-events:event_moved',
+        monthChanged: 'calendar-events:month_changed',
+        moveEvent: 'calendar-events:move_event'
     };
 });
index 5c95483..a8aa489 100644 (file)
@@ -103,9 +103,31 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
         return Ajax.call([request])[0];
     };
 
+    /**
+     * Change the start day for the given event id. The day timestamp
+     * only has to be any time during the target day because only the
+     * date information is extracted, the time of the day is ignored.
+     *
+     * @param {int} eventId The id of the event to update
+     * @param {int} dayTimestamp A timestamp for some time during the target day
+     * @return {promise}
+     */
+    var updateEventStartDay = function(eventId, dayTimestamp) {
+        var request = {
+            methodname: 'core_calendar_update_event_start_day',
+            args: {
+                eventId: eventId,
+                dayTimestamp: dayTimestamp
+            }
+        };
+
+        return Ajax.call([request])[0];
+    };
+
     return {
         getEventById: getEventById,
         deleteEvent: deleteEvent,
+        updateEventStartDay: updateEventStartDay,
         submitCreateUpdateForm: submitCreateUpdateForm,
         getCalendarMonthData: getCalendarMonthData
     };
index 9809309..19b12ff 100644 (file)
@@ -31,7 +31,7 @@
     {
     }
 }}
-<span class="calendarwrapper" data-courseid="{{courseid}}" data-current-time="{{time}}">
+<span id="month-detailed-{{uniqid}}" class="calendarwrapper" data-courseid="{{courseid}}" data-current-time="{{time}}">
     {{> core_calendar/month_header }}
     {{> core_calendar/month_navigation }}
     <table class="calendarmonth calendartable card-deck m-b-0">
@@ -46,7 +46,7 @@
         </thead>
         <tbody>
     {{#weeks}}
-            <tr>
+            <tr data-region="month-view-week">
                 {{#prepadding}}
                     <td class="dayblank">&nbsp;</td>
                 {{/prepadding}}
@@ -56,7 +56,9 @@
                             }}{{#isweekend}} weekend{{/isweekend}}{{!
                             }}{{#durationevents.0}} duration{{/durationevents.0}}{{!
                             }}{{#durationevents}} duration_{{.}}{{/durationevents}}{{!
-                        }}">
+                        }}"
+                        data-day-timestamp="{{timestamp}}"
+                        data-drop-zone="true">
                         <div class="hidden-sm-down text-xs-center">
                             {{#events.0}}
                                 <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                                 {{mday}}
                             {{/events.0}}
                             {{#events.0}}
-                                <ul>
-                                    {{#events}}
+                                <div data-region="day-content">
+                                    <ul>
+                                        {{#events}}
                                         {{#underway}}
                                             <li class="events-underway">[{{name}}]</li>
                                         {{/underway}}
                                         {{^underway}}
-                                            <li class="calendar_event_{{eventtype}}">
+                                            <li class="calendar_event_{{eventtype}}"
+                                                {{#canedit}}
+                                                    draggable="true"
+                                                    data-drag-type="move"
+                                                {{/canedit}}>
+
                                                 <a data-action="view-event" data-event-id="{{id}}" href="{{url}}">{{name}}</a>
                                             </li>
                                         {{/underway}}
-                                    {{/events}}
-                                </ul>
+                                        {{/events}}
+                                    </ul>
+                                </div>
                             {{/events.0}}
                         </div>
                         <div class="hidden-md-up hidden-desktop">
@@ -84,7 +93,9 @@
                                 <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                             {{/events.0}}
                             {{^events.0}}
-                                {{mday}}
+                                <div data-region="day-content">
+                                    {{mday}}
+                                </div>
                             {{/events.0}}
                         </div>
                     </td>
         </tbody>
     </table>
 </span>
+{{#js}}
+require(['jquery', 'core_calendar/drag_drop'], function($, DragDrop) {
+    var root = $('#month-detailed-{{uniqid}}');
+    DragDrop.init(root);
+});
+{{/js}}
index e87fa51..966d885 100644 (file)
@@ -2151,3 +2151,7 @@ $footer-link-color: $bg-inverse-link-color !default;
         top: 0;
     }
 }
+
+[data-drag-type="move"] {
+    cursor: move;
+}