MDL-59382 calendar: add modal to create and update events
authorRyan Wyllie <ryan@moodle.com>
Mon, 24 Jul 2017 08:01:14 +0000 (08:01 +0000)
committerRyan Wyllie <ryan@moodle.com>
Wed, 2 Aug 2017 04:47:43 +0000 (04:47 +0000)
23 files changed:
calendar/amd/build/calendar.min.js
calendar/amd/build/event_form.min.js [new file with mode: 0644]
calendar/amd/build/modal_event_form.min.js [new file with mode: 0644]
calendar/amd/build/summary_modal.min.js
calendar/amd/src/calendar.js
calendar/amd/src/event_form.js [new file with mode: 0644]
calendar/amd/src/modal_event_form.js [new file with mode: 0644]
calendar/amd/src/summary_modal.js
calendar/classes/local/event/forms/create.php [new file with mode: 0644]
calendar/classes/local/event/forms/update.php [new file with mode: 0644]
calendar/classes/local/event/mappers/create_update_form_mapper.php [new file with mode: 0644]
calendar/classes/local/event/mappers/create_update_form_mapper_interface.php [new file with mode: 0644]
calendar/externallib.php
calendar/lib.php
calendar/renderer.php
calendar/templates/modal_event_form.mustache [new file with mode: 0644]
calendar/tests/lib_test.php
lang/en/calendar.php
lang/en/moodle.php
lib/db/services.php
lib/grouplib.php
lib/tests/grouplib_test.php
version.php

index d54e1ca..fab055d 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/event_form.min.js b/calendar/amd/build/event_form.min.js
new file mode 100644 (file)
index 0000000..8d2d5d6
Binary files /dev/null and b/calendar/amd/build/event_form.min.js differ
diff --git a/calendar/amd/build/modal_event_form.min.js b/calendar/amd/build/modal_event_form.min.js
new file mode 100644 (file)
index 0000000..78b804a
Binary files /dev/null and b/calendar/amd/build/modal_event_form.min.js differ
index 3c917ad..9c0bb22 100644 (file)
Binary files a/calendar/amd/build/summary_modal.min.js and b/calendar/amd/build/summary_modal.min.js differ
index 5de6318..a76db0e 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * A javascript module to calendar events.
+ * This module is the highest level module for the calendar. It is
+ * responsible for initialising all of the components required for
+ * the calendar to run. It also coordinates the interaction between
+ * components by listening for and responding to different events
+ * triggered within the calendar UI.
  *
  * @module     core_calendar/calendar
  * @package    core_calendar
  * @copyright  2017 Simey Lameze <simey@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/ajax', 'core/str', 'core/templates', 'core/notification', 'core/custom_interaction_events',
-        'core/modal_factory', 'core_calendar/summary_modal', 'core/modal_events', 'core_calendar/calendar_repository'],
-    function($, Ajax, Str, Templates, Notification, CustomEvents, ModalFactory, SummaryModal, ModalEvents, CalendarRepository) {
-
-        var SELECTORS = {
-            ROOT: "[data-region='calendar']",
-            EVENT_LINK: "[data-action='view-event']",
-        };
-
-        /**
-         * Get the event type lang string.
-         *
-         * @param {String} eventType The event type.
-         * @return {promise} The lang string promise.
-         */
-        var getEventType = function(eventType) {
-            var lang = 'type' + eventType;
-            return Str.get_string(lang, 'core_calendar').then(function(langStr) {
-                return langStr;
-            });
-        };
-
-        /**
-         * Get the event source.
-         *
-         * @param {Object} subscription The event subscription object.
-         * @return {promise} The lang string promise.
-         */
-        var getEventSource = function(subscription) {
-            return Str.get_string('subsource', 'core_calendar', subscription).then(function(langStr) {
-                if (subscription.url) {
-                    return '<a href="' + subscription.url + '">' + langStr + '</a>';
-                }
-                return langStr;
-            });
-        };
-
-        /**
-         * Render the event summary modal.
-         *
-         * @param {Number} eventId The calendar event id.
-         */
-        var renderEventSummaryModal = function(eventId) {
-            // Calendar repository promise.
-            CalendarRepository.getEventById(eventId).then(function(getEventResponse) {
-                if (!getEventResponse.event) {
-                    throw new Error('Error encountered while trying to fetch calendar event with ID: ' + eventId);
-                }
-                var eventData = getEventResponse.event;
-                var eventTypePromise = getEventType(eventData.eventtype);
-
-                // If the calendar event has event source, get the source's language string/link.
-                if (eventData.displayeventsource) {
-                    eventData.subscription = JSON.parse(eventData.subscription);
-                    var eventSourceParams = {
-                        url: eventData.subscription.url,
-                        name: eventData.subscription.name
-                    };
-                    var eventSourcePromise = getEventSource(eventSourceParams);
-
-                    // Return event data with event type and event source info.
-                    return $.when(eventTypePromise, eventSourcePromise).then(function(eventType, eventSource) {
-                        eventData.eventtype = eventType;
-                        eventData.source = eventSource;
-                        return eventData;
-                    });
-                }
+define([
+            'jquery',
+            'core/ajax',
+            'core/str',
+            'core/templates',
+            'core/notification',
+            'core/custom_interaction_events',
+            'core/modal_events',
+            'core/modal_factory',
+            'core_calendar/modal_event_form',
+            'core_calendar/summary_modal',
+            'core_calendar/repository',
+            'core_calendar/events'
+        ],
+        function(
+            $,
+            Ajax,
+            Str,
+            Templates,
+            Notification,
+            CustomEvents,
+            ModalEvents,
+            ModalFactory,
+            ModalEventForm,
+            SummaryModal,
+            CalendarRepository,
+            CalendarEvents
+        ) {
+
+    var SELECTORS = {
+        ROOT: "[data-region='calendar']",
+        EVENT_LINK: "[data-action='view-event']",
+        NEW_EVENT_BUTTON: "[data-action='new-event-button']"
+    };
+
+    /**
+     * Get the event type lang string.
+     *
+     * @param {String} eventType The event type.
+     * @return {promise} The lang string promise.
+     */
+    var getEventType = function(eventType) {
+        var lang = 'type' + eventType;
+        return Str.get_string(lang, 'core_calendar').then(function(langStr) {
+            return langStr;
+        });
+    };
+
+    /**
+     * Get the event source.
+     *
+     * @param {Object} subscription The event subscription object.
+     * @return {promise} The lang string promise.
+     */
+    var getEventSource = function(subscription) {
+        return Str.get_string('subsource', 'core_calendar', subscription).then(function(langStr) {
+            if (subscription.url) {
+                return '<a href="' + subscription.url + '">' + langStr + '</a>';
+            }
+            return langStr;
+        });
+    };
 
-                // Return event data with event type info.
-                return eventTypePromise.then(function(eventType) {
+    /**
+     * Render the event summary modal.
+     *
+     * @param {Number} eventId The calendar event id.
+     */
+    var renderEventSummaryModal = function(eventId) {
+        // Calendar repository promise.
+        CalendarRepository.getEventById(eventId).then(function(getEventResponse) {
+            if (!getEventResponse.event) {
+                throw new Error('Error encountered while trying to fetch calendar event with ID: ' + eventId);
+            }
+            var eventData = getEventResponse.event;
+            var eventTypePromise = getEventType(eventData.eventtype);
+
+            // If the calendar event has event source, get the source's language string/link.
+            if (eventData.displayeventsource) {
+                eventData.subscription = JSON.parse(eventData.subscription);
+                var eventSourceParams = {
+                    url: eventData.subscription.url,
+                    name: eventData.subscription.name
+                };
+                var eventSourcePromise = getEventSource(eventSourceParams);
+
+                // Return event data with event type and event source info.
+                return $.when(eventTypePromise, eventSourcePromise).then(function(eventType, eventSource) {
                     eventData.eventtype = eventType;
+                    eventData.source = eventSource;
                     return eventData;
                 });
+            }
 
-            }).then(function(eventData) {
-                // Build the modal parameters from the event data.
-                var modalParams = {
-                    title: eventData.name,
-                    type: SummaryModal.TYPE,
-                    body: Templates.render('core_calendar/event_summary_body', eventData)
-                };
-                if (!eventData.caneditevent) {
-                    modalParams.footer = '';
+            // Return event data with event type info.
+            return eventTypePromise.then(function(eventType) {
+                eventData.eventtype = eventType;
+                return eventData;
+            });
+
+        }).then(function(eventData) {
+            // Build the modal parameters from the event data.
+            var modalParams = {
+                title: eventData.name,
+                type: SummaryModal.TYPE,
+                body: Templates.render('core_calendar/event_summary_body', eventData)
+            };
+            if (!eventData.caneditevent) {
+                modalParams.footer = '';
+            }
+            // Create the modal.
+            return ModalFactory.create(modalParams);
+
+        }).done(function(modal) {
+            // Handle hidden event.
+            modal.getRoot().on(ModalEvents.hidden, function() {
+                // Destroy when hidden.
+                modal.destroy();
+            });
+
+            // Finally, render the modal!
+            modal.show();
+
+        }).fail(Notification.exception);
+    };
+
+    /**
+     * Create the event form modal for creating new events and
+     * editing existing events.
+     *
+     * @method registerEventFormModal
+     * @param {object} root The calendar root element
+     * @return {object} The create modal promise
+     */
+    var registerEventFormModal = function(root) {
+        var newEventButton = root.find(SELECTORS.NEW_EVENT_BUTTON);
+        var contextId = newEventButton.attr('data-context-id');
+
+        return ModalFactory.create(
+            {
+                type: ModalEventForm.TYPE,
+                large: true,
+                templateContext: {
+                    contextid: contextId
                 }
-                // Create the modal.
-                return ModalFactory.create(modalParams);
-
-            }).done(function(modal) {
-                // Handle hidden event.
-                modal.getRoot().on(ModalEvents.hidden, function() {
-                    // Destroy when hidden.
-                    modal.destroy();
-                });
+            },
+            newEventButton
+        );
+    };
 
-                // Finally, render the modal!
-                modal.show();
+    /**
+     * Listen to and handle any calendar events fired by the calendar UI.
+     *
+     * @method registerCalendarEventListeners
+     * @param {object} root The calendar root element
+     * @param {object} eventFormModalPromise A promise reolved with the event form modal
+     */
+    var registerCalendarEventListeners = function(root, eventFormModalPromise) {
+        var body = $('body');
+
+        // TODO: Replace these with actual logic to update
+        // the UI without having to force a page reload.
+        body.on(CalendarEvents.created, function() { window.location.reload(); });
+        body.on(CalendarEvents.deleted, function() { window.location.reload(); });
+        body.on(CalendarEvents.updated, function() { window.location.reload(); });
 
-            }).fail(Notification.exception);
-        };
-
-        /**
-         * Register event listeners for the module.
-         */
-        var registerEventListeners = function() {
-            // Bind click events to event links.
-            $(SELECTORS.EVENT_LINK).click(function(e) {
-                e.preventDefault();
-                var eventId = $(this).attr('data-event-id');
-                renderEventSummaryModal(eventId);
+        eventFormModalPromise.then(function(modal) {
+            // When something within the calendar tells us the user wants
+            // to edit an event then show the event form modal.
+            body.on(CalendarEvents.editEvent, function(e, eventId) {
+                modal.setEventId(eventId);
+                modal.show();
             });
-        };
 
-        return {
-            init: function() {
-                registerEventListeners();
-            }
-        };
-    });
+            return;
+        });
+    };
+
+    /**
+     * Register event listeners for the module.
+     */
+    var registerEventListeners = function() {
+        var root = $(SELECTORS.ROOT);
+
+        // Bind click events to event links.
+        $(SELECTORS.EVENT_LINK).click(function(e) {
+            e.preventDefault();
+            var eventId = $(this).attr('data-event-id');
+            renderEventSummaryModal(eventId);
+        });
+
+        var eventFormPromise = registerEventFormModal(root);
+        registerCalendarEventListeners(root, eventFormPromise);
+    };
+
+    return {
+        init: function() {
+            registerEventListeners();
+        }
+    };
+});
diff --git a/calendar/amd/src/event_form.js b/calendar/amd/src/event_form.js
new file mode 100644 (file)
index 0000000..6fbd3ea
--- /dev/null
@@ -0,0 +1,282 @@
+// 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 enhance the event form.
+ *
+ * @module     core_calendar/event_form
+ * @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/templates'], function($, Templates) {
+
+    var SELECTORS = {
+        EVENT_TYPE: '[name="eventtype"]',
+        EVENT_COURSE_ID: '[name="courseid"]',
+        EVENT_GROUP_COURSE_ID: '[name="groupcourseid"]',
+        EVENT_GROUP_ID: '[name="groupid"]',
+        FORM_GROUP: '.form-group',
+        SELECT_OPTION: 'option',
+        ADVANCED_ELEMENT: '.fitem.advanced',
+        FIELDSET_ADVANCED_ELEMENTS: 'fieldset.containsadvancedelements',
+        MORELESS_TOGGLE: '.moreless-actions'
+    };
+
+    var EVENT_TYPES = {
+        USER: 'user',
+        SITE: 'site',
+        COURSE: 'course',
+        GROUP: 'group'
+    };
+
+    var EVENTS = {
+        SHOW_ADVANCED: 'event_form-show-advanced',
+        HIDE_ADVANCED: 'event_form-hide-advanced',
+        ADVANCED_SHOWN: 'event_form-advanced-shown',
+        ADVANCED_HIDDEN: 'event_form-advanced-hidden',
+    };
+
+    /**
+     * Find the old show more / show less toggle added by the mform and destroy it.
+     * We are handling the visibility of the advanced fields with the more/less button
+     * in the footer of the modal that this form is rendered within.
+     *
+     * @method destroyOldMoreLessToggle
+     * @param {object} formElement The root form element
+     */
+    var destroyOldMoreLessToggle = function(formElement) {
+        formElement.find(SELECTORS.FIELDSET_ADVANCED_ELEMENTS).removeClass('containsadvancedelements');
+        var element = formElement.find(SELECTORS.MORELESS_TOGGLE);
+        Templates.replaceNode(element, '', '');
+    };
+
+    /**
+     * Find each of the advanced form elements and make them visible.
+     *
+     * This function triggers the ADVANCED_SHOWN event for any other
+     * component to handle (e.g. the event form modal).
+     *
+     * @method destroyOldMoreLessToggle
+     * @param {object} formElement The root form element
+     */
+    var showAdvancedElements = function(formElement) {
+        formElement.find(SELECTORS.ADVANCED_ELEMENT).removeClass('hidden');
+        formElement.trigger(EVENTS.ADVANCED_SHOWN);
+    };
+
+    /**
+     * Find each of the advanced form elements and hide them.
+     *
+     * This function triggers the ADVANCED_HIDDEN event for any other
+     * component to handle (e.g. the event form modal).
+     *
+     * @method hideAdvancedElements
+     * @param {object} formElement The root form element
+     */
+    var hideAdvancedElements = function(formElement) {
+        formElement.find(SELECTORS.ADVANCED_ELEMENT).addClass('hidden');
+        formElement.trigger(EVENTS.ADVANCED_HIDDEN);
+    };
+
+    /**
+     * Listen for any events telling this module to show or hide it's
+     * advanced elements.
+     *
+     * This function listens for SHOW_ADVANCED and HIDE_ADVANCED.
+     *
+     * @method listenForShowHideEvents
+     * @param {object} formElement The root form element
+     */
+    var listenForShowHideEvents = function(formElement) {
+        formElement.on(EVENTS.SHOW_ADVANCED, function() {
+            showAdvancedElements(formElement);
+        });
+
+        formElement.on(EVENTS.HIDE_ADVANCED, function() {
+            hideAdvancedElements(formElement);
+        });
+    };
+
+    /**
+     * Parse the group id select element in the event form and pull out
+     * the course id from the value to allow us to toggle other select
+     * elements based on the course id for the group a user selects.
+     *
+     * This is a little hacky but I couldn't find a better way to pass
+     * the course id for each group id with the limitations of mforms.
+     *
+     * The group id options are rendered with a value like:
+     * "<courseid>-<groupid>"
+     * E.g.
+     * For a group with id 10 in a course with id 3 the value of the
+     * option will be 3-10.
+     *
+     * @method parseGroupSelect
+     * @param {object} formElement The root form element
+     */
+    var parseGroupSelect = function(formElement) {
+        formElement.find(SELECTORS.EVENT_GROUP_ID)
+            .find(SELECTORS.SELECT_OPTION)
+            .each(function(index, element) {
+                var element = $(element);
+                var value = element.attr('value');
+                var splits = value.split('-');
+                var courseId = splits[0];
+
+                element.attr('data-course-id', courseId);
+            });
+    };
+
+    /**
+     * Toggle the visibility of the secondary select elements based on
+     * the event type the user has selected.
+     *
+     * There are 3 secondary select elements within the form:
+     *      - course: a list of all courses a user can add course events to
+     *      - group course: a list of all courses a user can add group events to.
+     *                      this list can be different from the course list above.
+     *      - group: a list of all groups a user can add an event to. This list will
+     *               be filtered further based on the group course selected.
+     *
+     *  There are 4 event types:
+     *      - user: none of the secondary selects should be visible.
+     *      - site: none of the secondary selects should be visible.
+     *      - course: "course" select should be visible and both "group course"
+     *                and "group" should be hidden.
+     *      - group: "group course" and "group" should be visible and "course"
+     *               should be hidden.
+     *
+     * @method hideTypeSubSelects
+     * @param {object} formElement The root form element
+     */
+    var hideTypeSubSelects = function(formElement) {
+        var typeSelect = formElement.find(SELECTORS.EVENT_TYPE);
+        var eventType = typeSelect.val();
+        var courseIdSelect = formElement.find(SELECTORS.EVENT_COURSE_ID)
+            .closest(SELECTORS.FORM_GROUP)
+            .removeClass('hidden');
+        var groupCourseIdSelect = formElement.find(SELECTORS.EVENT_GROUP_COURSE_ID)
+            .closest(SELECTORS.FORM_GROUP)
+            .removeClass('hidden');
+        var groupIdSelect = formElement.find(SELECTORS.EVENT_GROUP_ID)
+            .closest(SELECTORS.FORM_GROUP)
+            .removeClass('hidden');
+
+        // Hide the unreleated selectors for the given event type.
+        switch (eventType) {
+            case EVENT_TYPES.COURSE:
+                groupCourseIdSelect.addClass('hidden');
+                groupIdSelect.addClass('hidden');
+                break;
+            case EVENT_TYPES.GROUP:
+                courseIdSelect.addClass('hidden');
+                break;
+            default:
+                courseIdSelect.addClass('hidden');
+                groupCourseIdSelect.addClass('hidden');
+                groupIdSelect.addClass('hidden');
+        }
+    };
+
+    /**
+     * Listen for when the user changes the event type select in the
+     * form and then toggle the visibility of the appropriate secondary
+     * select elements.
+     *
+     * See: hideTypeSubSelects.
+     *
+     * @method addTypeSelectListeners
+     * @param {object} formElement The root form element
+     */
+    var addTypeSelectListeners = function(formElement) {
+        var typeSelect = formElement.find(SELECTORS.EVENT_TYPE);
+
+        typeSelect.on('change', function() {
+            hideTypeSubSelects(formElement);
+        });
+    };
+
+    /**
+     * Listen for when the user changes the group course when configuring
+     * a group event and filter the options in the group select to only
+     * show the groups available within the course the user has selected.
+     *
+     * @method addCourseGroupSelectListeners
+     * @param {object} formElement The root form element
+     */
+    var addCourseGroupSelectListeners = function(formElement) {
+        var courseGroupSelect = formElement.find(SELECTORS.EVENT_GROUP_COURSE_ID);
+        var groupSelect = formElement.find(SELECTORS.EVENT_GROUP_ID);
+        var groupSelectOptions = groupSelect.find(SELECTORS.SELECT_OPTION);
+        var filterGroupSelectOptions = function() {
+            var selectedCourseId = courseGroupSelect.val();
+            var selectedIndex = null;
+
+            groupSelectOptions.each(function(index, element) {
+                element = $(element);
+
+                if (element.attr('data-course-id') == selectedCourseId) {
+                    element.removeClass('hidden');
+                    element.prop('disabled', false);
+
+                    if (selectedIndex === null) {
+                        selectedIndex = index;
+                    }
+                } else {
+                    element.addClass('hidden');
+                    element.prop('disabled', true);
+                }
+            });
+
+            groupSelect.prop('selectedIndex', selectedIndex);
+        };
+
+        courseGroupSelect.on('change', filterGroupSelectOptions);
+        filterGroupSelectOptions();
+    };
+
+    /**
+     * Initialise all of the form enhancementds.
+     *
+     * @method init
+     * @param {string} formId The value of the form's id attribute
+     * @param {bool} hasError If the form has errors rendered form the server.
+     */
+    var init = function(formId, hasError) {
+        var formElement = $('#' + formId);
+
+        listenForShowHideEvents(formElement);
+        destroyOldMoreLessToggle(formElement);
+        hideTypeSubSelects(formElement);
+        parseGroupSelect(formElement);
+        addTypeSelectListeners(formElement);
+        addCourseGroupSelectListeners(formElement);
+
+        // If we know that the form has been rendered with server side
+        // errors then we need to display all of the elements in the form
+        // in case one of those elements has the error.
+        if (hasError) {
+            showAdvancedElements(formElement);
+        } else {
+            hideAdvancedElements(formElement);
+        }
+    };
+
+    return {
+        init: init,
+        events: EVENTS,
+    };
+});
diff --git a/calendar/amd/src/modal_event_form.js b/calendar/amd/src/modal_event_form.js
new file mode 100644 (file)
index 0000000..64d675a
--- /dev/null
@@ -0,0 +1,429 @@
+// 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/>.
+
+/**
+ * Contain the logic for the quick add or update event modal.
+ *
+ * @module     calendar/modal_quick_add_event
+ * @class      modal_quick_add_event
+ * @package    core
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+            'jquery',
+            'core/event',
+            'core/str',
+            'core/notification',
+            'core/templates',
+            'core/custom_interaction_events',
+            'core/modal',
+            'core/modal_registry',
+            'core/fragment',
+            'core_calendar/events',
+            'core_calendar/repository',
+            'core_calendar/event_form'
+        ],
+        function(
+            $,
+            Event,
+            Str,
+            Notification,
+            Templates,
+            CustomEvents,
+            Modal,
+            ModalRegistry,
+            Fragment,
+            CalendarEvents,
+            Repository,
+            EventForm
+        ) {
+
+    var registered = false;
+    var SELECTORS = {
+        MORELESS_BUTTON: '[data-action="more-less-toggle"]',
+        SAVE_BUTTON: '[data-action="save"]',
+        LOADING_ICON_CONTAINER: '[data-region="loading-icon-container"]',
+    };
+
+    /**
+     * Constructor for the Modal.
+     *
+     * @param {object} root The root jQuery element for the modal
+     */
+    var ModalEventForm = function(root) {
+        Modal.call(this, root);
+        this.eventId = null;
+        this.reloadingBody = false;
+        this.reloadingTitle = false;
+        this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);
+        this.moreLessButton = this.getFooter().find(SELECTORS.MORELESS_BUTTON);
+    };
+
+    ModalEventForm.TYPE = 'core_calendar-modal_event_form';
+    ModalEventForm.prototype = Object.create(Modal.prototype);
+    ModalEventForm.prototype.constructor = ModalEventForm;
+
+    /**
+     * Set the event id to the given value.
+     *
+     * @method setEventId
+     * @param {int} id The event id
+     */
+    ModalEventForm.prototype.setEventId = function(id) {
+        this.eventId = id;
+    };
+
+    /**
+     * Retrieve the current event id, if any.
+     *
+     * @method getEventId
+     * @return {int|null} The event id
+     */
+    ModalEventForm.prototype.getEventId = function() {
+        return this.eventId;
+    };
+
+    /**
+     * Check if the modal has an event id.
+     *
+     * @method hasEventId
+     * @return {bool}
+     */
+    ModalEventForm.prototype.hasEventId = function() {
+        return this.eventId !== null;
+    };
+
+    /**
+     * Get the form element from the modal.
+     *
+     * @method getForm
+     * @return {object}
+     */
+    ModalEventForm.prototype.getForm = function() {
+        return this.getBody().find('form');
+    };
+
+    /**
+     * Disable the buttons in the footer.
+     *
+     * @method disableButtons
+     */
+    ModalEventForm.prototype.disableButtons = function() {
+        this.saveButton.prop('disabled', true);
+        this.moreLessButton.prop('disabled', true);
+    };
+
+    /**
+     * Enable the buttons in the footer.
+     *
+     * @method enableButtons
+     */
+    ModalEventForm.prototype.enableButtons = function() {
+        this.saveButton.prop('disabled', false);
+        this.moreLessButton.prop('disabled', false);
+    };
+
+    /**
+     * Set the more/less button in the footer to the "more"
+     * state.
+     *
+     * @method setMoreButton
+     */
+    ModalEventForm.prototype.setMoreButton = function() {
+        this.moreLessButton.attr('data-collapsed', 'true');
+        Str.get_string('more', 'calendar').then(function(string) {
+            this.moreLessButton.text(string);
+        }.bind(this));
+    };
+
+    /**
+     * Set the more/less button in the footer to the "less"
+     * state.
+     *
+     * @method setLessButton
+     */
+    ModalEventForm.prototype.setLessButton = function() {
+        this.moreLessButton.attr('data-collapsed', 'false');
+        Str.get_string('less', 'calendar').then(function(string) {
+            this.moreLessButton.text(string);
+        }.bind(this));
+    };
+
+    /**
+     * Toggle the more/less button in the footer from the current
+     * state to it's opposite state.
+     *
+     * @method toggleMoreLessButton
+     */
+    ModalEventForm.prototype.toggleMoreLessButton = function() {
+        var form = this.getForm();
+
+        if (this.moreLessButton.attr('data-collapsed') == 'true') {
+            form.trigger(EventForm.events.SHOW_ADVANCED);
+            this.setLessButton();
+        } else {
+            form.trigger(EventForm.events.HIDE_ADVANCED);
+            this.setMoreButton();
+        }
+    };
+
+    /**
+     * Reload the title for the modal to the appropriate value
+     * depending on whether we are creating a new event or
+     * editing an existing event.
+     *
+     * @method reloadTitleContent
+     * @return {object} A promise resolved with the new title text
+     */
+    ModalEventForm.prototype.reloadTitleContent = function() {
+        if (this.reloadingTitle) {
+            return this.titlePromise;
+        }
+
+        this.reloadingTitle = true;
+
+        if (this.hasEventId()) {
+            this.titlePromise = Str.get_string('editevent', 'calendar');
+        } else {
+            this.titlePromise = Str.get_string('newevent', 'calendar');
+        }
+
+        this.titlePromise.then(function(string) {
+            this.setTitle(string);
+            return string;
+        }.bind(this))
+        .always(function() {
+            this.reloadingTitle = false;
+            return;
+        }.bind(this));
+
+        return this.titlePromise;
+    };
+
+    /**
+     * Send a request to the server to get the event_form in a fragment
+     * and render the result in the body of the modal.
+     *
+     * If serialised form data is provided then it will be sent in the
+     * request to the server to have the form rendered with the data. This
+     * is used when the form had a server side error and we need the server
+     * to re-render it for us to display the error to the user.
+     *
+     * @method reloadBodyContent
+     * @param {string} formData The serialised form data
+     * @param {bool} hasError True if we know the form data is erroneous
+     * @return {object} A promise resolved with the fragment html and js from
+     */
+    ModalEventForm.prototype.reloadBodyContent = function(formData, hasError) {
+        if (this.reloadingBody) {
+            return this.bodyPromise;
+        }
+
+        this.reloadingBody = true;
+        this.disableButtons();
+
+        var contextId = this.saveButton.attr('data-context-id');
+        var args = {};
+
+        if (this.hasEventId()) {
+            args.eventid = this.getEventId();
+        }
+
+        if (typeof formData !== 'undefined') {
+            args.formdata = formData;
+        }
+
+        args.haserror = (typeof hasError == 'undefined') ? false : hasError;
+
+        this.bodyPromise = Fragment.loadFragment('calendar', 'event_form', contextId, args);
+
+        this.setBody(this.bodyPromise);
+
+        this.bodyPromise.then(function() {
+            this.enableButtons();
+            return;
+        }.bind(this))
+        .catch(Notification.exception)
+        .always(function() {
+            this.reloadingBody = false;
+            return;
+        }.bind(this));
+
+        return this.bodyPromise;
+    };
+
+    /**
+     * Reload both the title and body content.
+     *
+     * @method reloadAllContent
+     * @return {object} promise
+     */
+    ModalEventForm.prototype.reloadAllContent = function() {
+        return $.when(this.reloadTitleContent(), this.reloadBodyContent());
+    };
+
+    /**
+     * Kick off a reload the modal content before showing it. This
+     * is to allow us to re-use the same modal for creating and
+     * editing different events within the page.
+     *
+     * We do the reload when showing the modal rather than hiding it
+     * to save a request to the server if the user closes the modal
+     * and never re-opens it.
+     *
+     * @method show
+     */
+    ModalEventForm.prototype.show = function() {
+        this.reloadAllContent();
+        Modal.prototype.show.call(this);
+    };
+
+    /**
+     * Clear the event id from the modal when it's closed so
+     * that it is loaded fresh next time it's displayed.
+     *
+     * The event id will be set by the calling code if it wants
+     * to edit a specific event.
+     *
+     * @method hide
+     */
+    ModalEventForm.prototype.hide = function() {
+        Modal.prototype.hide.call(this);
+        this.setEventId(null);
+    };
+
+    /**
+     * Get the serialised form data.
+     *
+     * @method getFormData
+     * @return {string} serialised form data
+     */
+    ModalEventForm.prototype.getFormData = function() {
+        return this.getForm().serialize();
+    };
+
+    /**
+     * Send the form data to the server to create or update
+     * an event.
+     *
+     * If there is a server side validation error then we re-request the
+     * rendered form (with the data) from the server in order to get the
+     * server side errors to display.
+     *
+     * On success the modal is hidden and the page is reloaded so that the
+     * new event will display.
+     *
+     * @method save
+     * @return {object} A promise
+     */
+    ModalEventForm.prototype.save = function() {
+        var loadingContainer = this.saveButton.find(SELECTORS.LOADING_ICON_CONTAINER);
+
+        loadingContainer.removeClass('hidden');
+        this.disableButtons();
+
+        var formData = this.getFormData();
+        // Send the form data to the server for processing.
+        return Repository.submitCreateUpdateForm(formData)
+            .then(function(response) {
+                if (response.validationerror) {
+                    // If there was a server side validation error then
+                    // we need to re-request the rendered form from the server
+                    // in order to display the error for the user.
+                    return this.reloadBodyContent(formData, true);
+                } else {
+                    // No problemo! Our work here is done.
+                    this.hide();
+
+                    // Trigger the appropriate calendar event so that the view can
+                    // be updated.
+                    if (this.hasEventId()) {
+                        $('body').trigger(CalendarEvents.updated, [response.event]);
+                    } else {
+                        $('body').trigger(CalendarEvents.created, [response.event]);
+                    }
+                }
+            }.bind(this))
+            .always(function() {
+                // Regardless of success or error we should always stop
+                // the loading icon and re-enable the buttons.
+                loadingContainer.addClass('hidden');
+                this.enableButtons();
+            }.bind(this))
+            .catch(Notification.exception);
+    };
+
+    /**
+     * Set up all of the event handling for the modal.
+     *
+     * @method registerEventListeners
+     */
+    ModalEventForm.prototype.registerEventListeners = function() {
+        // Apply parent event listeners.
+        Modal.prototype.registerEventListeners.call(this);
+
+        // When the user clicks the save button we trigger the form submission. We need to
+        // trigger an actual submission because there is some JS code in the form that is
+        // listening for this event and doing some stuff (e.g. saving draft areas etc).
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, function(e, data) {
+            this.getForm().submit();
+            data.originalEvent.preventDefault();
+            e.stopPropagation();
+        }.bind(this));
+
+        // Catch the submit event before it is actually processed by the browser and
+        // prevent the submission. We'll take it from here.
+        this.getModal().on('submit', function(e) {
+            this.save();
+
+            // Stop the form from actually submitting and prevent it's
+            // propagation because we have already handled the event.
+            e.preventDefault();
+            e.stopPropagation();
+        }.bind(this));
+
+        // Toggle the state of the more/less button in the footer.
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.MORELESS_BUTTON, function(e, data) {
+            this.toggleMoreLessButton();
+
+            data.originalEvent.preventDefault();
+            e.stopPropagation();
+        }.bind(this));
+
+        // When the event form tells us that the advanced fields are shown
+        // then the more/less button should be set to less to allow the user
+        // to hide the advanced fields.
+        this.getModal().on(EventForm.events.ADVANCED_SHOWN, function() {
+            this.setLessButton();
+        }.bind(this));
+
+        // When the event form tells us that the advanced fields are hidden
+        // then the more/less button should be set to more to allow the user
+        // to show the advanced fields.
+        this.getModal().on(EventForm.events.ADVANCED_HIDDEN, function() {
+            this.setMoreButton();
+        }.bind(this));
+    };
+
+    // Automatically register with the modal registry the first time this module is imported so that you can create modals
+    // of this type using the modal factory.
+    if (!registered) {
+        ModalRegistry.register(ModalEventForm.TYPE, ModalEventForm, 'calendar/modal_event_form');
+        registered = true;
+    }
+
+    return ModalEventForm;
+});
index e344d62..1b44f8d 100644 (file)
@@ -22,8 +22,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_events', 'core/modal',
-    'core/modal_registry', 'core/modal_factory', 'core/modal_events', 'core_calendar/calendar_repository',
-    'core_calendar/calendar_events'],
+    'core/modal_registry', 'core/modal_factory', 'core/modal_events', 'core_calendar/repository',
+    'core_calendar/events'],
     function($, Str, Notification, CustomEvents, Modal, ModalRegistry, ModalFactory, ModalEvents, CalendarRepository,
              CalendarEvents) {
 
@@ -32,7 +32,6 @@ define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_even
         ROOT: "[data-region='summary-modal-container']",
         EDIT_BUTTON: '[data-action="edit"]',
         DELETE_BUTTON: '[data-action="delete"]',
-        EVENT_LINK: '[data-action="event-link"]'
     };
 
     /**
@@ -43,11 +42,11 @@ define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_even
     var ModalEventSummary = function(root) {
         Modal.call(this, root);
 
-        if (!this.getFooter().find(SELECTORS.EDIT_BUTTON).length) {
+        if (!this.getEditButton().length) {
             Notification.exception({message: 'No edit button found'});
         }
 
-        if (!this.getFooter().find(SELECTORS.DELETE_BUTTON).length) {
+        if (!this.getDeleteButton().length) {
             Notification.exception({message: 'No delete button found'});
         }
     };
@@ -56,6 +55,48 @@ define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_even
     ModalEventSummary.prototype = Object.create(Modal.prototype);
     ModalEventSummary.prototype.constructor = ModalEventSummary;
 
+    /**
+     * Get the edit button element from the footer. The button is cached
+     * as it's not expected to change.
+     *
+     * @method getEditButton
+     * @return {object} button element
+     */
+    ModalEventSummary.prototype.getEditButton = function() {
+        if (typeof this.editButton == 'undefined') {
+            this.editButton = this.getFooter().find(SELECTORS.EDIT_BUTTON);
+        }
+
+        return this.editButton;
+    };
+
+    /**
+     * Get the delete button element from the footer. The button is cached
+     * as it's not expected to change.
+     *
+     * @method getDeleteButton
+     * @return {object} button element
+     */
+    ModalEventSummary.prototype.getDeleteButton = function() {
+        if (typeof this.deleteButton == 'undefined') {
+            this.deleteButton = this.getFooter().find(SELECTORS.DELETE_BUTTON);
+        }
+
+        return this.deleteButton;
+    };
+
+    /**
+     * Get the id for the event being shown in this modal. This value is
+     * not cached because it will change depending on which event is
+     * being displayed.
+     *
+     * @method getEventId
+     * @return {int}
+     */
+    ModalEventSummary.prototype.getEventId = function() {
+        return this.getBody().find(SELECTORS.ROOT).attr('data-event-id');
+    };
+
     /**
      * Set up all of the event handling for the modal.
      *
@@ -64,28 +105,51 @@ define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_even
     ModalEventSummary.prototype.registerEventListeners = function() {
         // Apply parent event listeners.
         Modal.prototype.registerEventListeners.call(this);
-        var confirmPromise = ModalFactory.create({
-            type: ModalFactory.types.CONFIRM,
-        }, this.getFooter().find(SELECTORS.DELETE_BUTTON)).then(function(modal) {
-            Str.get_string('confirm').then(function(languagestring) {
-                modal.setTitle(languagestring);
-            }.bind(this)).catch(Notification.exception);
+
+        var confirmPromise = ModalFactory.create(
+            { type: ModalFactory.types.CONFIRM },
+            this.getDeleteButton()
+        ).then(function(modal) {
             modal.getRoot().on(ModalEvents.yes, function() {
-                var eventId = this.getBody().find(SELECTORS.ROOT).attr('data-event-id');
-                CalendarRepository.deleteEvent(eventId).done(function() {
-                    modal.getRoot().trigger(CalendarEvents.deleted, eventId);
-                    window.location.reload();
-                }).fail(Notification.exception);
+                var eventId = this.getEventId();
+
+                CalendarRepository.deleteEvent(eventId)
+                    .then(function() {
+                        $('body').trigger(CalendarEvents.deleted, [eventId]);
+                        this.hide();
+                    }.bind(this))
+                    .catch(Notification.exception);
             }.bind(this));
+
             return modal;
         }.bind(this));
 
+        // We have to wait for the modal to finish rendering in order to ensure that
+        // the data-event-title property is available to use as the modal title.
         this.getRoot().on(ModalEvents.bodyRendered, function() {
             var eventTitle = this.getBody().find(SELECTORS.ROOT).attr('data-event-title');
             confirmPromise.then(function(modal) {
                 modal.setBody(Str.get_string('confirmeventdelete', 'core_calendar', eventTitle));
             });
         }.bind(this));
+
+        CustomEvents.define(this.getEditButton(), [
+            CustomEvents.events.activate
+        ]);
+
+        this.getEditButton().on(CustomEvents.events.activate, function(e, data) {
+            // When the edit button is clicked we fire an event for the calendar UI to handle.
+            // We don't care how the UI chooses to handle it.
+            $('body').trigger(CalendarEvents.editEvent, [this.getEventId()]);
+            // There is nothing else for us to do so let's hide.
+            this.hide();
+
+            // We've handled this event so no need to propagate it.
+            e.preventDefault();
+            e.stopPropagation();
+            data.originalEvent.preventDefault();
+            data.originalEvent.stopPropagation();
+        }.bind(this));
     };
 
     // Automatically register with the modal registry the first time this module is imported so that you can create modals
diff --git a/calendar/classes/local/event/forms/create.php b/calendar/classes/local/event/forms/create.php
new file mode 100644 (file)
index 0000000..4003c49
--- /dev/null
@@ -0,0 +1,283 @@
+<?php
+
+// 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/>.
+
+/**
+ * The mform for creating a calendar event. Based on the
+ * old event form.
+ *
+ * @copyright 2017 Ryan Wyllie <ryan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package calendar
+ */
+namespace core_calendar\local\event\forms;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot.'/lib/formslib.php');
+
+/**
+ * The mform class for creating a calendar event.
+ *
+ * @copyright 2017 Ryan Wyllie <ryan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class create extends \moodleform {
+    /**
+     * The form definition
+     */
+    public function definition () {
+        global $PAGE;
+
+        $mform = $this->_form;
+        $haserror = !empty($this->_customdata['haserror']);
+        $eventtypes = calendar_get_all_allowed_types();
+
+        $mform->setDisableShortforms();
+        $mform->disable_form_change_checker();
+
+        // Empty string so that the element doesn't get rendered.
+        $mform->addElement('header', 'general', '');
+
+        $this->add_default_hidden_elements($mform);
+
+        // Event name field.
+        $mform->addElement('text', 'name', get_string('eventname','calendar'), 'size="50"');
+        $mform->addRule('name', get_string('required'), 'required', null, 'client');
+        $mform->setType('name', PARAM_TEXT);
+
+        // Event time start field.
+        $mform->addElement('date_time_selector', 'timestart', get_string('date'));
+
+        // Add the select elements for the available event types.
+        $this->add_event_type_elements($mform, $eventtypes);
+
+        // ********* START OF ADVANCED ELEMENTS *********.
+        // Advanced elements are not visible to the user by default. They are
+        // displayed through the user of a show more / less button.
+        $mform->addElement('editor', 'description', get_string('eventdescription','calendar'), ['rows' => 3]);
+        $mform->setType('description', PARAM_RAW);
+        $mform->setAdvanced('description');
+
+        // Add the variety of elements allowed for selecting event duration.
+        $this->add_event_duration_elements($mform);
+
+        // Add the form elements for repeating events.
+        $this->add_event_repeat_elements($mform);
+
+        // Add the javascript required to enhance this mform. Including the show/hide of advanced elements
+        // and the display of the correct select elements for chosen event types.
+        $PAGE->requires->js_call_amd('core_calendar/event_form', 'init', [$mform->getAttribute('id'), $haserror]);
+    }
+
+    /**
+     * A bit of custom validation for this form
+     *
+     * @param array $data An assoc array of field=>value
+     * @param array $files An array of files
+     * @return array
+     */
+    public function validation($data, $files) {
+        global $DB, $CFG;
+
+        $errors = parent::validation($data, $files);
+        $coursekey = isset($data['groupcourseid']) ? 'groupcourseid' : 'courseid';
+
+        if (isset($data[$coursekey]) && $data[$coursekey] > 0) {
+            if ($course = $DB->get_record('course', ['id' => $data[$coursekey]])) {
+                if ($data['timestart'] < $course->startdate) {
+                    $errors['timestart'] = get_string('errorbeforecoursestart', 'calendar');
+                }
+            } else {
+                $errors[$coursekey] = get_string('invalidcourse', 'error');
+            }
+        }
+
+        if ($data['duration'] == 1 && $data['timestart'] > $data['timedurationuntil']) {
+            $errors['durationgroup'] = get_string('invalidtimedurationuntil', 'calendar');
+        } else if ($data['duration'] == 2 && (trim($data['timedurationminutes']) == '' || $data['timedurationminutes'] < 1)) {
+            $errors['durationgroup'] = get_string('invalidtimedurationminutes', 'calendar');
+        }
+
+        return $errors;
+    }
+
+    /**
+     * Add the list of hidden elements that should appear in this form each
+     * time. These elements will never be visible to the user.
+     *
+     * @method add_default_hidden_elements
+     * @param MoodleQuickForm $mform
+     */
+    protected function add_default_hidden_elements($mform) {
+        global $USER;
+
+        // Add some hidden fields
+        $mform->addElement('hidden', 'id');
+        $mform->setType('id', PARAM_INT);
+        $mform->setDefault('id', 0);
+
+        $mform->addElement('hidden', 'userid');
+        $mform->setType('userid', PARAM_INT);
+        $mform->setDefault('userid', $USER->id);
+
+        $mform->addElement('hidden', 'modulename');
+        $mform->setType('modulename', PARAM_INT);
+        $mform->setDefault('modulename', '');
+
+        $mform->addElement('hidden', 'instance');
+        $mform->setType('instance', PARAM_INT);
+        $mform->setDefault('instance', 0);
+
+        $mform->addElement('hidden', 'visible');
+        $mform->setType('visible', PARAM_INT);
+        $mform->setDefault('visible', 1);
+    }
+
+    /**
+     * Add the appropriate elements for the available event types.
+     *
+     * If the only event type available is 'user' then we add a hidden
+     * element because there is nothing for the user to choose.
+     *
+     * If more than one type is available then we add the elements as
+     * follows:
+     *      - Always add the event type selector
+     *      - Elements per type:
+     *          - course: add an additional select element with each
+     *                    course as an option.
+     *          - group: add a select element for the course (different
+     *                   from the above course select) and a select
+     *                   element for the group.
+     *
+     * @method add_event_type_elements
+     * @param MoodleQuickForm $mform
+     * @param array $eventtypes The available event types for the user
+     */
+    protected function add_event_type_elements($mform, $eventtypes) {
+        $options = [];
+
+        if (isset($eventtypes['user'])) {
+            $options['user'] = get_string('user');
+        }
+        if (isset($eventtypes['group'])) {
+            $options['group'] = get_string('group');
+        }
+        if (isset($eventtypes['course'])) {
+            $options['course'] = get_string('course');
+        }
+        if (isset($eventtypes['site'])) {
+            $options['site'] = get_string('site');
+        }
+
+        // If we only have one event type and it's 'user' event then don't bother
+        // rendering the select boxes because there is no choice for the user to
+        // make.
+        if (count(array_keys($eventtypes)) == 1 && isset($eventtypes['user'])) {
+            $mform->addElement('hidden', 'eventtype');
+            $mform->setType('eventtype', PARAM_TEXT);
+            $mform->setDefault('eventtype', 'user');
+
+            // Render a static element to tell the user what type of event will
+            // be created.
+            $mform->addElement('static', 'staticeventtype', get_string('eventkind', 'calendar'), $options['user']);
+            return;
+        } else {
+            $mform->addElement('select', 'eventtype', get_string('eventkind', 'calendar'), $options);
+        }
+
+        if (isset($eventtypes['course'])) {
+            $courseoptions = [];
+            foreach ($eventtypes['course'] as $course) {
+                $courseoptions[$course->id] = format_string($course->fullname, true,
+                    ['context' => \context_course::instance($course->id)]);
+            }
+
+            $mform->addElement('select', 'courseid', get_string('course'), $courseoptions);
+            $mform->disabledIf('courseid', 'eventtype', 'noteq', 'course');
+        }
+
+        if (isset($eventtypes['group'])) {
+            $courseoptions = [];
+            foreach ($eventtypes['groupcourses'] as $course) {
+                $courseoptions[$course->id] = format_string($course->fullname, true,
+                    ['context' => \context_course::instance($course->id)]);
+            }
+
+            $mform->addElement('select', 'groupcourseid', get_string('course'), $courseoptions);
+            $mform->disabledIf('groupcourseid', 'eventtype', 'noteq', 'group');
+
+            $groupoptions = [];
+            foreach ($eventtypes['group'] as $group) {
+                // We are formatting it this way in order to provide the javascript both
+                // the course and group ids so that it can enhance the form for the user.
+                $index = "{$group->courseid}-{$group->id}";
+                $groupoptions[$index] = format_string($group->name, true,
+                    ['context' => \context_course::instance($group->courseid)]);
+            }
+
+            $mform->addElement('select', 'groupid', get_string('group'), $groupoptions);
+            $mform->disabledIf('groupid', 'eventtype', 'noteq', 'group');
+        }
+    }
+
+    /**
+     * Add the various elements to express the duration options available
+     * for an event.
+     *
+     * @method add_event_duration_elements
+     * @param MoodleQuickForm $mform
+     */
+    protected function add_event_duration_elements($mform) {
+        $group = [];
+        $group[] = $mform->createElement('radio', 'duration', null, get_string('durationnone', 'calendar'), 0);
+        $group[] = $mform->createElement('radio', 'duration', null, get_string('durationuntil', 'calendar'), 1);
+        $group[] = $mform->createElement('date_time_selector', 'timedurationuntil', '');
+        $group[] = $mform->createElement('radio', 'duration', null, get_string('durationminutes', 'calendar'), 2);
+        $group[] = $mform->createElement('text', 'timedurationminutes', get_string('durationminutes', 'calendar'));
+
+        $mform->addGroup($group, 'durationgroup', get_string('eventduration', 'calendar'), '<br />', false);
+        $mform->setAdvanced('durationgroup');
+
+        $mform->disabledIf('timedurationuntil',         'duration', 'noteq', 1);
+        $mform->disabledIf('timedurationuntil[day]',    'duration', 'noteq', 1);
+        $mform->disabledIf('timedurationuntil[month]',  'duration', 'noteq', 1);
+        $mform->disabledIf('timedurationuntil[year]',   'duration', 'noteq', 1);
+        $mform->disabledIf('timedurationuntil[hour]',   'duration', 'noteq', 1);
+        $mform->disabledIf('timedurationuntil[minute]', 'duration', 'noteq', 1);
+
+        $mform->setType('timedurationminutes', PARAM_INT);
+        $mform->disabledIf('timedurationminutes','duration','noteq', 2);
+
+        $mform->setDefault('duration', 0);
+    }
+
+    /**
+     * Add the repeat elements for the form when creating a new event.
+     *
+     * @method add_event_repeat_elements
+     * @param MoodleQuickForm $mform
+     */
+    protected function add_event_repeat_elements($mform) {
+        $mform->addElement('checkbox', 'repeat', get_string('repeatevent', 'calendar'), null);
+        $mform->addElement('text', 'repeats', get_string('repeatweeksl', 'calendar'), 'maxlength="10" size="10"');
+        $mform->setType('repeats', PARAM_INT);
+        $mform->setDefault('repeats', 1);
+        $mform->disabledIf('repeats','repeat','notchecked');
+        $mform->setAdvanced('repeat');
+        $mform->setAdvanced('repeats');
+    }
+}
diff --git a/calendar/classes/local/event/forms/update.php b/calendar/classes/local/event/forms/update.php
new file mode 100644 (file)
index 0000000..ec8905b
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+// 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/>.
+
+/**
+ * The mform for updating a calendar event. Based on the
+ * old event form.
+ *
+ * @copyright 2017 Ryan Wyllie <ryan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package calendar
+ */
+namespace core_calendar\local\event\forms;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot.'/lib/formslib.php');
+
+/**
+ * The mform class for updating a calendar event.
+ *
+ * @copyright 2017 Ryan Wyllie <ryan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class update extends create {
+    /**
+     * Add the repeat elements for the form when editing an existing event.
+     *
+     * @method add_event_repeat_elements
+     * @param MoodleQuickForm $mform
+     * @param stdClass $event The event properties
+     */
+    protected function add_event_repeat_elements($mform) {
+        $event = $this->_customdata['event'];
+
+        $mform->addElement('hidden', 'repeatid');
+        $mform->setType('repeatid', PARAM_INT);
+
+        $group = [];
+        $group[] = $mform->createElement('radio', 'repeateditall', null, get_string('repeateditall', 'calendar', $event->eventrepeats), 1);
+        $group[] = $mform->createElement('radio', 'repeateditall', null, get_string('repeateditthis', 'calendar'), 0);
+        $mform->addGroup($group, 'repeatgroup', get_string('repeatedevents', 'calendar'), '<br />', false);
+
+        $mform->setDefault('repeateditall', 1);
+        $mform->setAdvanced('repeatgroup');
+    }
+}
diff --git a/calendar/classes/local/event/mappers/create_update_form_mapper.php b/calendar/classes/local/event/mappers/create_update_form_mapper.php
new file mode 100644 (file)
index 0000000..00b7002
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+// 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/>.
+
+/**
+ * Event create form and update form mapper.
+ *
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_calendar\local\event\mappers;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/calendar/lib.php');
+
+/**
+ * Event create form and update form mapper class.
+ *
+ * This class will perform the necessary data transformations to take
+ * a legacy event and build the appropriate data structure for both the
+ * create and update event forms.
+ *
+ * It will also do the reverse transformation
+ * and take the returned form data and provide a data structure that can
+ * be used to set legacy event properties.
+ *
+ * @copyright 2017 Ryan Wyllie <ryan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class create_update_form_mapper implements create_update_form_mapper_interface {
+
+    /**
+     * Generate the appropriate data for the form from a legacy event.
+     *
+     * @method from_legacy_event_to_data
+     * @param calendar_event $legacyevent
+     * @return stdClass
+     */
+    public function from_legacy_event_to_data(\calendar_event $legacyevent) {
+        $legacyevent->count_repeats();
+        $data = $legacyevent->properties(true);
+        $data->timedurationuntil = $legacyevent->timestart + $legacyevent->timeduration;
+        $data->duration = (empty($legacyevent->timeduration)) ? 0 : 1;
+
+        if ($legacyevent->eventtype == 'group') {
+            // Set up the correct value for the to display on the form.
+            $data->groupid = "{$legacyevent->courseid}-{$legacyevent->groupid}";
+            $data->groupcourseid = $legacyevent->courseid;
+        }
+
+        return $data;
+    }
+
+    /**
+     * Generate the appropriate calendar_event properties from the form data.
+     *
+     * @method from_data_to_event_properties
+     * @param stdClass $data
+     * @return stdClass
+     */
+    public function from_data_to_event_properties(\stdClass $data) {
+        $properties = clone($data);
+
+        // Undo the form definition work around to allow us to have two different
+        // course selectors present depending on which event type the user selects.
+        if (isset($data->groupcourseid)) {
+            $properties->courseid = $data->groupcourseid;
+            unset($properties->groupcourseid);
+        }
+
+        // Pull the group id back out of the value. The form saves the value
+        // as "<courseid>-<groupid>" to allow the javascript to work correctly.
+        if (isset($data->groupid)) {
+            list($courseid, $groupid) = explode('-', $data->groupid);
+            $properties->groupid = $groupid;
+        }
+
+        // Default course id if none is set.
+        if (!isset($data->courseid)) {
+            $properties->courseid = 0;
+        }
+
+        // Decode the form fields back into valid event property.
+        $properties->timeduration = $this->get_time_duration_from_form_data($data);
+
+        return $properties;
+    }
+
+    /**
+     * A helper function to calculate the time duration for an event based on
+     * the event_form data.
+     *
+     * @method get_time_duration_from_form_data
+     * @param \stdClass $data event_form data
+     * @return int
+     */
+    private function get_time_duration_from_form_data(\stdClass $data) {
+        if ($data->duration == 1) {
+            return $data->timedurationuntil- $data->timestart;
+        } else if ($data->duration == 2) {
+            return $data->timedurationminutes * MINSECS;
+        } else {
+            return 0;
+        }
+    }
+}
diff --git a/calendar/classes/local/event/mappers/create_update_form_mapper_interface.php b/calendar/classes/local/event/mappers/create_update_form_mapper_interface.php
new file mode 100644 (file)
index 0000000..16e2902
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+// 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/>.
+
+/**
+ * Create update form mapper interface.
+ *
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_calendar\local\event\mappers;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/calendar/lib.php');
+
+/**
+ * Interface for a create_update_form_mapper class
+ *
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface create_update_form_mapper_interface {
+    /**
+     * Generate the appropriate data for the form from a legacy event.
+     *
+     * @method from_legacy_event_to_data
+     * @param calendar_event $legacyevent
+     * @return stdClass
+     */
+    public function from_legacy_event_to_data(\calendar_event $legacyevent);
+
+    /**
+     * Generate the appropriate calendar_event properties from the form data.
+     *
+     * @method from_data_to_event_properties
+     * @param stdClass $data
+     * @return stdClass
+     */
+    public function from_data_to_event_properties(\stdClass $data);
+}
index 6fd68d4..5c3bdc1 100644 (file)
@@ -30,6 +30,11 @@ defined('MOODLE_INTERNAL') || die;
 require_once("$CFG->libdir/externallib.php");
 
 use \core_calendar\local\api as local_api;
+use \core_calendar\local\event\container as event_container;
+use \core_calendar\local\event\forms\create as create_event_form;
+use \core_calendar\local\event\forms\update as update_event_form;
+use \core_calendar\local\event\mappers\create_update_form_mapper;
+use \core_calendar\external\event_exporter;
 use \core_calendar\external\events_exporter;
 use \core_calendar\external\events_grouped_by_course_exporter;
 use \core_calendar\external\events_related_objects_cache;
@@ -779,4 +784,90 @@ class core_calendar_external extends external_api {
             )
         );
     }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters.
+     */
+    public static function submit_create_update_form_parameters() {
+        return new external_function_parameters(
+            [
+                'formdata' => new external_value(PARAM_RAW, 'The data from the event form'),
+            ]
+        );
+    }
+
+    /**
+     * Handles the event form submission.
+     *
+     * @param string $formdata The event form data in a URI encoded param string
+     * @return array The created or modified event
+     * @throws moodle_exception
+     */
+    public static function submit_create_update_form($formdata) {
+        global $CFG, $USER, $PAGE;
+        require_once($CFG->dirroot."/calendar/lib.php");
+
+        // Parameter validation.
+        $params = self::validate_parameters(self::submit_create_update_form_parameters(), ['formdata' => $formdata]);
+        $context = \context_user::instance($USER->id);
+        $data = [];
+
+        self::validate_context($context);
+        parse_str($params['formdata'], $data);
+
+        if (!empty($data['id'])) {
+            $eventid = clean_param($data['id'], PARAM_INT);
+            $legacyevent = calendar_event::load($eventid);
+            $legacyevent->count_repeats();
+            $formoptions = ['event' => $legacyevent];
+            $mform = new update_event_form(null, $formoptions, 'post', '', null, true, $data);
+        } else {
+            $legacyevent = null;
+            $mform = new create_event_form(null, null, 'post', '', null, true, $data);
+        }
+
+        if ($validateddata = $mform->get_data()) {
+            $formmapper = new create_update_form_mapper();
+            $properties = $formmapper->from_data_to_event_properties($validateddata);
+
+            if (is_null($legacyevent)) {
+                $legacyevent = new \calendar_event($properties);
+            }
+
+            $legacyevent->update($properties);
+
+            $eventmapper = event_container::get_event_mapper();
+            $event = $eventmapper->from_legacy_event_to_event($legacyevent);
+            $cache = new events_related_objects_cache([$event]);
+            $relatedobjects = [
+                'context' => $cache->get_context($event),
+                'course' => $cache->get_course($event),
+            ];
+            $exporter = new event_exporter($event, $relatedobjects);
+            $renderer = $PAGE->get_renderer('core_calendar');
+
+            return [ 'event' => $exporter->export($renderer) ];
+        } else {
+            return [ 'validationerror' => true ];
+        }
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description.
+     */
+    public static function  submit_create_update_form_returns() {
+        $eventstructure = event_exporter::get_read_structure();
+        $eventstructure->required = VALUE_OPTIONAL;
+
+        return new external_single_structure(
+            array(
+                'event' => $eventstructure,
+                'validationerror' => new external_value(PARAM_BOOL, 'Invalid form data', VALUE_DEFAULT, false),
+            )
+        );
+    }
 }
index 94e617b..2f3bdf7 100644 (file)
@@ -2686,8 +2686,9 @@ function calendar_set_event_type_display($type, $display = null, $user = null) {
  *
  * @param stdClass $allowed list of allowed edit for event  type
  * @param stdClass|int $course object of a course or course id
+ * @param array $groups array of groups for the given course
  */
-function calendar_get_allowed_types(&$allowed, $course = null) {
+function calendar_get_allowed_types(&$allowed, $course = null, $groups = null) {
     global $USER, $DB;
 
     $allowed = new \stdClass();
@@ -2695,6 +2696,23 @@ function calendar_get_allowed_types(&$allowed, $course = null) {
     $allowed->groups = false;
     $allowed->courses = false;
     $allowed->site = has_capability('moodle/calendar:manageentries', \context_course::instance(SITEID));
+    $getgroupsfunc = function($course, $context, $user) use ($groups) {
+        if ($course->groupmode != NOGROUPS || !$course->groupmodeforce) {
+            if (has_capability('moodle/site:accessallgroups', $context)) {
+                return is_null($groups) ? groups_get_all_groups($course->id) : $groups;
+            } else {
+                if (is_null($groups)) {
+                    return groups_get_all_groups($course->id, $user->id);
+                } else {
+                    return array_filter($groups, function($group) use ($user) {
+                        return isset($group->members[$user->id]);
+                    });
+                }
+            }
+        }
+
+        return false;
+    };
 
     if (!empty($course)) {
         if (!is_object($course)) {
@@ -2706,25 +2724,82 @@ function calendar_get_allowed_types(&$allowed, $course = null) {
 
             if (has_capability('moodle/calendar:manageentries', $coursecontext)) {
                 $allowed->courses = array($course->id => 1);
-
-                if ($course->groupmode != NOGROUPS || !$course->groupmodeforce) {
-                    if (has_capability('moodle/site:accessallgroups', $coursecontext)) {
-                        $allowed->groups = groups_get_all_groups($course->id);
-                    } else {
-                        $allowed->groups = groups_get_all_groups($course->id, $USER->id);
-                    }
-                }
+                $allowed->groups = $getgroupsfunc($course, $coursecontext, $USER);
             } else if (has_capability('moodle/calendar:managegroupentries', $coursecontext)) {
-                if ($course->groupmode != NOGROUPS || !$course->groupmodeforce) {
-                    if (has_capability('moodle/site:accessallgroups', $coursecontext)) {
-                        $allowed->groups = groups_get_all_groups($course->id);
-                    } else {
-                        $allowed->groups = groups_get_all_groups($course->id, $USER->id);
-                    }
-                }
+                $allowed->groups = $getgroupsfunc($course, $coursecontext, $USER);
+            }
+        }
+    }
+}
+
+/**
+ * Get all of the allowed types for all of the courses and groups
+ * the logged in user belongs to.
+ *
+ * The returned array will optionally have 5 keys:
+ *      'user' : true if the logged in user can create user events
+ *      'site' : true if the logged in user can create site events
+ *      'course' : array of courses that the user can create events for
+ *      'group': array of groups that the user can create events for
+ *      'groupcourses' : array of courses that the groups belong to (can
+ *                       be different from the list in 'course'.
+ *
+ * @param array The available types for the logged in user
+ */
+function calendar_get_all_allowed_types() {
+    global $CFG, $USER;
+
+    require_once($CFG->libdir . '/enrollib.php');
+
+    $types = [];
+
+    calendar_get_allowed_types($allowed);
+
+    if ($allowed->user) {
+        $types['user'] = true;
+    }
+
+    if ($allowed->site) {
+        $types['site'] = true;
+    }
+
+    // This function warms the context cache for the course so the calls
+    // to load the course context in calendar_get_allowed_types don't result
+    // in additional DB queries.
+    $courses = enrol_get_users_courses($USER->id, true);
+    // We want to pre-fetch all of the groups for each course in a single
+    // query to avoid calendar_get_allowed_types from hitting the DB for
+    // each separate course.
+    $groups = groups_get_all_groups_for_courses($courses);
+
+    foreach ($courses as $course) {
+        $coursegroups = isset($groups[$course->id]) ? $groups[$course->id] : null;
+        calendar_get_allowed_types($allowed, $course, $coursegroups);
+
+        if (!empty($allowed->courses)) {
+            if (!isset($types['course'])) {
+                $types['course'] = [$course];
+            } else {
+                $types['course'][] = $course;
+            }
+        }
+
+        if (!empty($allowed->groups)) {
+            if (!isset($types['groupcourses'])) {
+                $types['groupcourses'] = [$course];
+            } else {
+                $types['groupcourses'][] = $course;
+            }
+
+            if (!isset($types['group'])) {
+                $types['group'] = array_values($allowed->groups);
+            } else {
+                $types['group'] = array_merge($types['group'], array_values($allowed->groups));
             }
         }
     }
+
+    return $types;
 }
 
 /**
@@ -3340,3 +3415,70 @@ function calendar_get_legacy_events($tstart, $tend, $users, $groups, $courses, $
         return $carry + [$event->get_id() => $mapper->from_event_to_stdclass($event)];
     }, []);
 }
+
+function calendar_output_fragment_event_form($args) {
+    global $CFG, $OUTPUT;
+    require_once($CFG->dirroot.'/calendar/event_form.php');
+
+    $html = '';
+    $data = null;
+    $eventid = isset($args['eventid']) ? clean_param($args['eventid'], PARAM_INT) : null;
+    $event = null;
+    $hasformdata = isset($args['formdata']) && !empty($args['formdata']);
+    $formoptions = [];
+
+    if ($hasformdata) {
+        parse_str(clean_param($args['formdata'], PARAM_TEXT), $data);
+    }
+
+    if (isset($args['haserror'])) {
+        $formoptions['haserror'] = clean_param($args['haserror'], PARAM_BOOL);
+    }
+
+    if (is_null($eventid)) {
+        $mform = new \core_calendar\local\event\forms\create(
+            null,
+            $formoptions,
+            'post',
+            '',
+            null,
+            true,
+            $data
+        );
+    } else {
+        $event = calendar_event::load($eventid);
+        $event->count_repeats();
+        $formoptions['event'] = $event;
+        $mform = new \core_calendar\local\event\forms\update(
+            null,
+            $formoptions,
+            'post',
+            '',
+            null,
+            true,
+            $data
+        );
+    }
+
+    if ($hasformdata) {
+        $mform->is_validated();
+    } else if (!is_null($event)) {
+        $mapper = new \core_calendar\local\event\mappers\create_update_form_mapper();
+        $data = $mapper->from_legacy_event_to_data($event);
+        $mform->set_data($data);
+
+        // Check to see if this event is part of a subscription or import.
+        // If so display a warning on edit.
+        if (isset($event->subscriptionid) && ($event->subscriptionid != null)) {
+            $renderable = new \core\output\notification(
+                get_string('eventsubscriptioneditwarning', 'calendar'),
+                \core\output\notification::NOTIFY_INFO
+            );
+
+            $html .= $OUTPUT->render($renderable);
+        }
+    }
+
+    $html .= $mform->render();
+    return $html;
+}
index ee450e1..940be31 100644 (file)
@@ -142,18 +142,13 @@ class core_calendar_renderer extends plugin_renderer_base {
             $time = time();
         }
 
-        $output = html_writer::start_tag('div', array('class'=>'buttons'));
-        $output .= html_writer::start_tag('form', array('action' => CALENDAR_URL . 'event.php', 'method' => 'get'));
-        $output .= html_writer::start_tag('div');
-        $output .= html_writer::empty_tag('input', array('type'=>'hidden', 'name' => 'action', 'value' => 'new'));
-        $output .= html_writer::empty_tag('input', array('type'=>'hidden', 'name' => 'course', 'value' => $courseid));
-        $output .= html_writer::empty_tag('input', array('type'=>'hidden', 'name' => 'time', 'value' => $time));
-        $attributes = array('type' => 'submit', 'value' => get_string('newevent', 'calendar'), 'class' => 'btn btn-secondary');
-        $output .= html_writer::empty_tag('input', $attributes);
-        $output .= html_writer::end_tag('div');
-        $output .= html_writer::end_tag('form');
-        $output .= html_writer::end_tag('div');
-        return $output;
+        $coursecontext = \context_course::instance($courseid);
+        $attributes = [
+            'class' => 'btn btn-secondary pull-xs-right pull-right',
+            'data-context-id' => $coursecontext->id,
+            'data-action' => 'new-event-button'
+        ];
+        return html_writer::tag('button', get_string('newevent', 'calendar'), $attributes);
     }
 
     /**
diff --git a/calendar/templates/modal_event_form.mustache b/calendar/templates/modal_event_form.mustache
new file mode 100644 (file)
index 0000000..e99c491
--- /dev/null
@@ -0,0 +1,61 @@
+{{!
+    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/>.
+}}
+{{!
+    @template calendar/modal_event_form
+
+    Moodle modal template with save and cancel buttons.
+
+    The purpose of this template is to render a modal.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * title A cleaned string (use clean_text()) to display.
+    * body HTML content for the boday
+
+    Example context (json):
+    {
+        "title": "Example save cancel modal",
+        "body": "Some example content for the body"
+    }
+}}
+
+{{< core/modal }}
+    {{$footer}}
+        <button type="button"
+                class="btn btn-secondary"
+                data-collapsed="true"
+                data-action="more-less-toggle">
+
+            {{#str}} more, calendar {{/str}}
+        </button>
+        <button type="button"
+                class="btn btn-primary"
+                data-context-id="{{contextid}}"
+                data-action="save">
+
+            {{#str}} save {{/str}}
+            <span class="hidden" data-region="loading-icon-container">
+                {{> core/loading }}
+            </span>
+        </button>
+    {{/footer}}
+{{/ core/modal }}
index 2f627ed..23efe02 100644 (file)
@@ -409,4 +409,267 @@ class core_calendar_lib_testcase extends advanced_testcase {
         $events = calendar_get_legacy_events($timestart, $timeend, true, true, true);
         $this->assertCount(3, $events);
     }
-}
\ No newline at end of file
+
+    public function test_calendar_get_all_allowed_types_no_types() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $systemcontext = context_system::instance();
+        $sitecontext = context_course::instance(SITEID);
+        $roleid = $generator->create_role();
+
+        $generator->role_assign($roleid, $user->id, $systemcontext->id);
+        $generator->role_assign($roleid, $user->id, $sitecontext->id);
+        $this->setUser($user);
+
+        assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $sitecontext, true);
+        assign_capability('moodle/calendar:manageownentries', CAP_PROHIBIT, $roleid, $systemcontext, true);
+
+        $types = calendar_get_all_allowed_types();
+        $this->assertEmpty($types);
+    }
+
+    public function test_calendar_get_all_allowed_types_user() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $context = context_system::instance();
+        $roleid = $generator->create_role();
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        $this->setUser($user);
+
+        assign_capability('moodle/calendar:manageownentries', CAP_ALLOW, $roleid, $context, true);
+
+        $types = calendar_get_all_allowed_types();
+        $this->assertTrue($types['user']);
+
+        assign_capability('moodle/calendar:manageownentries', CAP_PROHIBIT, $roleid, $context, true);
+
+        $types = calendar_get_all_allowed_types();
+        $this->assertArrayNotHasKey('user', $types);
+    }
+
+    public function test_calendar_get_all_allowed_types_site() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $context = context_course::instance(SITEID);
+        $roleid = $generator->create_role();
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        $this->setUser($user);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+
+        $types = calendar_get_all_allowed_types();
+        $this->assertTrue($types['site']);
+
+        assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $context, true);
+
+        $types = calendar_get_all_allowed_types();
+        $this->assertArrayNotHasKey('site', $types);
+    }
+
+    public function test_calendar_get_all_allowed_types_course() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course1 = $generator->create_course(); // Has capability.
+        $course2 = $generator->create_course(); // Doesn't have capability.
+        $course3 = $generator->create_course(); // Not enrolled.
+        $context1 = context_course::instance($course1->id);
+        $context2 = context_course::instance($course2->id);
+        $context3 = context_course::instance($course3->id);
+        $roleid = $generator->create_role();
+        $contexts = [$context1, $context2, $context3];
+        $enrolledcourses = [$course1, $course2];
+
+        foreach ($enrolledcourses as $course) {
+            $generator->enrol_user($user->id, $course->id, 'student');
+        }
+
+        foreach ($contexts as $context) {
+            $generator->role_assign($roleid, $user->id, $context->id);
+        }
+
+        $this->setUser($user);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context1, true);
+        assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $context2, true);
+
+        // The user only has the correct capability in course 1 so that is the only
+        // one that should be in the results.
+        $types = calendar_get_all_allowed_types();
+        $typecourses = $types['course'];
+        $this->assertCount(1, $typecourses);
+        $this->assertEquals($course1->id, $typecourses[0]->id);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context2, true);
+
+        // The user only now has the correct capability in both course 1 and 2 so we
+        // expect both to be in the results.
+        $types = calendar_get_all_allowed_types();
+        $typecourses = $types['course'];
+        // Sort the results by id ascending to ensure the test is consistent
+        // and repeatable.
+        usort($typecourses, function($a, $b) {
+            $aid = $a->id;
+            $bid = $b->id;
+
+            if ($aid == $bid) {
+                return 0;
+            }
+            return ($aid < $bid) ? -1 : 1;
+        });
+
+        $this->assertCount(2, $typecourses);
+        $this->assertEquals($course1->id, $typecourses[0]->id);
+        $this->assertEquals($course2->id, $typecourses[1]->id);
+    }
+
+    public function test_calendar_get_all_allowed_types_group_no_groups() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $context = context_course::instance($course->id);
+        $roleid = $generator->create_role();
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        $this->setUser($user);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+
+        // The user has the correct capability in the course but there are
+        // no groups so we shouldn't see a group type.
+        $types = calendar_get_all_allowed_types();
+        $typecourses = $types['course'];
+        $this->assertCount(1, $typecourses);
+        $this->assertEquals($course->id, $typecourses[0]->id);
+        $this->assertArrayNotHasKey('group', $types);
+        $this->assertArrayNotHasKey('groupcourses', $types);
+    }
+
+    public function test_calendar_get_all_allowed_types_group_no_acces_to_diff_groups() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $context = context_course::instance($course->id);
+        $group1 = $generator->create_group(array('courseid' => $course->id));
+        $group2 = $generator->create_group(array('courseid' => $course->id));
+        $roleid = $generator->create_role();
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        $this->setUser($user);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+        assign_capability('moodle/site:accessallgroups', CAP_PROHIBIT, $roleid, $context, true);
+
+        // The user has the correct capability in the course but they aren't a member
+        // of any of the groups and don't have the accessallgroups capability.
+        $types = calendar_get_all_allowed_types();
+        $typecourses = $types['course'];
+        $this->assertCount(1, $typecourses);
+        $this->assertEquals($course->id, $typecourses[0]->id);
+        $this->assertArrayNotHasKey('group', $types);
+        $this->assertArrayNotHasKey('groupcourses', $types);
+    }
+
+    public function test_calendar_get_all_allowed_types_group_access_all_groups() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course1 = $generator->create_course();
+        $course2 = $generator->create_course();
+        $context1 = context_course::instance($course1->id);
+        $context2 = context_course::instance($course2->id);
+        $group1 = $generator->create_group(array('courseid' => $course1->id));
+        $group2 = $generator->create_group(array('courseid' => $course1->id));
+        $roleid = $generator->create_role();
+
+        $generator->enrol_user($user->id, $course1->id, 'student');
+        $generator->enrol_user($user->id, $course2->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context1->id);
+        $generator->role_assign($roleid, $user->id, $context2->id);
+
+        $this->setUser($user);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context1, true);
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context2, true);
+        assign_capability('moodle/site:accessallgroups', CAP_ALLOW, $roleid, $context1, true);
+        assign_capability('moodle/site:accessallgroups', CAP_ALLOW, $roleid, $context2, true);
+
+        // The user has the correct capability in the course and has
+        // the accessallgroups capability.
+        $types = calendar_get_all_allowed_types();
+        $typecourses = $types['course'];
+        $typegroups = $types['group'];
+        $typegroupcourses = $types['groupcourses'];
+        $idascfunc = function($a, $b) {
+            $aid = $a->id;
+            $bid = $b->id;
+
+            if ($aid == $bid) {
+                return 0;
+            }
+            return ($aid < $bid) ? -1 : 1;
+        };
+        // Sort the results by id ascending to ensure the test is consistent
+        // and repeatable.
+        usort($typecourses, $idascfunc);
+        usort($typegroups, $idascfunc);
+
+        $this->assertCount(2, $typecourses);
+        $this->assertEquals($course1->id, $typecourses[0]->id);
+        $this->assertEquals($course2->id, $typecourses[1]->id);
+        $this->assertCount(1, $typegroupcourses);
+        $this->assertEquals($course1->id, $typegroupcourses[0]->id);
+        $this->assertCount(2, $typegroups);
+        $this->assertEquals($group1->id, $typegroups[0]->id);
+        $this->assertEquals($group2->id, $typegroups[1]->id);
+    }
+
+    public function test_calendar_get_all_allowed_types_group_no_access_all_groups() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $context = context_course::instance($course->id);
+        $group1 = $generator->create_group(array('courseid' => $course->id));
+        $group2 = $generator->create_group(array('courseid' => $course->id));
+        $group3 = $generator->create_group(array('courseid' => $course->id));
+        $roleid = $generator->create_role();
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+        $generator->create_group_member(array('groupid' => $group1->id, 'userid' => $user->id));
+        $generator->create_group_member(array('groupid' => $group2->id, 'userid' => $user->id));
+
+        $this->setUser($user);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+        assign_capability('moodle/site:accessallgroups', CAP_PROHIBIT, $roleid, $context, true);
+
+        // The user has the correct capability in the course but can't access
+        // groups that they are not a member of.
+        $types = calendar_get_all_allowed_types();
+        $typegroups = $types['group'];
+        $typegroupcourses = $types['groupcourses'];
+        $idascfunc = function($a, $b) {
+            $aid = $a->id;
+            $bid = $b->id;
+
+            if ($aid == $bid) {
+                return 0;
+            }
+            return ($aid < $bid) ? -1 : 1;
+        };
+        // Sort the results by id ascending to ensure the test is consistent
+        // and repeatable.
+        usort($typegroups, $idascfunc);
+
+        $this->assertCount(1, $typegroupcourses);
+        $this->assertEquals($course->id, $typegroupcourses[0]->id);
+        $this->assertCount(2, $typegroups);
+        $this->assertEquals($group1->id, $typegroups[0]->id);
+        $this->assertEquals($group2->id, $typegroups[1]->id);
+    }
+}
index 5fca2dc..60b8075 100644 (file)
@@ -149,6 +149,7 @@ $string['importfrominstructions'] = 'Please provide either a URL to a remote cal
 $string['invalidtimedurationminutes'] = 'The duration in minutes you have entered is invalid. Please enter the duration in minutes greater than 0 or select no duration.';
 $string['invalidtimedurationuntil'] = 'The date and time you selected for duration until is before the start time of the event. Please correct this before proceeding.';
 $string['iwanttoexport'] = 'Export';
+$string['less'] = 'Less';
 $string['managesubscriptions'] = 'Manage subscriptions';
 $string['manyevents'] = '{$a} events';
 $string['mon'] = 'Mon';
@@ -157,6 +158,7 @@ $string['monthly'] = 'Monthly';
 $string['monthlyview'] = 'Monthly view';
 $string['monthnext'] = 'Next month';
 $string['monththis'] = 'This month';
+$string['more'] = 'More';
 $string['namewithsource'] = '{$a->name}({$a->source})';
 $string['never'] = 'Never';
 $string['newevent'] = 'New event';
index 6f54a70..5edcbfc 100644 (file)
@@ -1627,6 +1627,7 @@ $string['rsserrorauth'] = 'Your RSS link does not contain a valid authentication
 $string['rsserrorguest'] = 'This feed uses guest access to access the data, but guest does not have permission to read the data. Visit the original location that this feed comes from (URL) as a valid user and get a new RSS link from there.';
 $string['rsskeyshelp'] = '<p>To ensure security and privacy, RSS feed URLs contain a special token that identifies the user they are for. This prevents other users from accessing areas of the site where they are not allowed.</p><p>The token is automatically created the first time you access an area that produces an RSS feed. If you think that your RSS feed token has been compromised, you can request a new one by clicking the reset link. Please note that your current RSS feed URLs will then become invalid.</p>';
 $string['rsstype'] = 'RSS feed for this activity';
+$string['save'] = 'Save';
 $string['saveandnext'] = 'Save and show next';
 $string['savedat'] = 'Saved at:';
 $string['savechanges'] = 'Save changes';
index 14385ae..909b7fa 100644 (file)
@@ -126,6 +126,15 @@ $functions = array(
         'ajax' => true,
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_calendar_submit_create_update_form' => array(
+        'classname' => 'core_calendar_external',
+        'methodname' => 'submit_create_update_form',
+        'description' => 'Submit form data for event form',
+        'classpath' => 'calendar/externallib.php',
+        'type' => 'write',
+        'capabilities' => 'moodle/calendar:manageentries, moodle/calendar:manageownentries, moodle/calendar:managegroupentries',
+        'ajax' => true,
+    ),
     'core_cohort_add_cohort_members' => array(
         'classname' => 'core_cohort_external',
         'methodname' => 'add_cohort_members',
index 08bee9a..25d1e96 100644 (file)
@@ -294,6 +294,150 @@ function groups_get_all_groups($courseid, $userid=0, $groupingid=0, $fields='g.*
     return $results;
 }
 
+/**
+ * Gets array of all groups in a set of course.
+ *
+ * @category group
+ * @param array $courses Array of course objects or course ids.
+ * @return array Array of groups indexed by course id.
+ */
+function groups_get_all_groups_for_courses($courses) {
+    global $DB;
+
+    if (empty($courses)) {
+        return [];
+    }
+
+    $groups = [];
+    $courseids = [];
+
+    foreach ($courses as $course) {
+        $courseid = is_object($course) ? $course->id : $course;
+        $groups[$courseid] = [];
+        $courseids[] = $courseid;
+    }
+
+    $groupfields = [
+        'g.id as gid',
+        'g.courseid',
+        'g.idnumber',
+        'g.name',
+        'g.description',
+        'g.descriptionformat',
+        'g.enrolmentkey',
+        'g.picture',
+        'g.hidepicture',
+        'g.timecreated',
+        'g.timemodified'
+    ];
+
+    $groupsmembersfields = [
+        'gm.id as gmid',
+        'gm.groupid',
+        'gm.userid',
+        'gm.timeadded',
+        'gm.component',
+        'gm.itemid'
+    ];
+
+    $concatidsql = $DB->sql_concat_join("'-'", ['g.id', 'COALESCE(gm.id, 0)']) . ' AS uniqid';
+    list($courseidsql, $params) = $DB->get_in_or_equal($courseids);
+    $groupfieldssql = implode(',', $groupfields);
+    $groupmembersfieldssql = implode(',', $groupsmembersfields);
+    $sql = "SELECT {$concatidsql}, {$groupfieldssql}, {$groupmembersfieldssql}
+              FROM {groups} g
+         LEFT JOIN {groups_members} gm
+                ON gm.groupid = g.id
+             WHERE g.courseid {$courseidsql}";
+
+    $results = $DB->get_records_sql($sql, $params);
+
+    // The results will come back as a flat dataset thanks to the left
+    // join so we will need to do some post processing to blow it out
+    // into a more useable data structure.
+    //
+    // This loop will extract the distinct groups from the result set
+    // and add it's list of members to the object as a property called
+    // 'members'. Then each group will be added to the result set indexed
+    // by it's course id.
+    //
+    // The resulting data structure for $groups should be:
+    // $groups = [
+    //      '1' = [
+    //          '1' => (object) [
+    //              'id' => 1,
+    //              <rest of group properties>
+    //              'members' => [
+    //                  '1' => (object) [
+    //                      <group member properties>
+    //                  ],
+    //                  '2' => (object) [
+    //                      <group member properties>
+    //                  ]
+    //              ]
+    //          ],
+    //          '2' => (object) [
+    //              'id' => 2,
+    //              <rest of group properties>
+    //              'members' => [
+    //                  '1' => (object) [
+    //                      <group member properties>
+    //                  ],
+    //                  '3' => (object) [
+    //                      <group member properties>
+    //                  ]
+    //              ]
+    //          ]
+    //      ]
+    // ]
+    foreach ($results as $key => $result) {
+        $groupid = $result->gid;
+        $courseid = $result->courseid;
+        $coursegroups = $groups[$courseid];
+        $groupsmembersid = $result->gmid;
+        $reducefunc = function($carry, $field) use ($result) {
+            // Iterate over the groups properties and pull
+            // them out into a separate object.
+            list($prefix, $field) = explode('.', $field);
+
+            if (property_exists($result, $field)) {
+                $carry[$field] = $result->{$field};
+            }
+
+            return $carry;
+        };
+
+        if (isset($coursegroups[$groupid])) {
+            $group = $coursegroups[$groupid];
+        } else {
+            $initial = [
+                'id' => $groupid,
+                'members' => []
+            ];
+            $group = (object) array_reduce(
+                $groupfields,
+                $reducefunc,
+                $initial
+            );
+        }
+
+        if (!empty($groupsmembersid)) {
+            $initial = ['id' => $groupsmembersid];
+            $groupsmembers = (object) array_reduce(
+                $groupsmembersfields,
+                $reducefunc,
+                $initial
+            );
+
+            $group->members[$groupsmembers->userid] = $groupsmembers;
+        }
+
+        $coursegroups[$groupid] = $group;
+        $groups[$courseid] = $coursegroups;
+    }
+
+    return $groups;
+}
 
 /**
  * Gets array of all groups in current user.
index f9bfbee..4679db6 100644 (file)
@@ -1547,4 +1547,148 @@ class core_grouplib_testcase extends advanced_testcase {
         $this->assertCount(2, $members);    // Now I see members of group 3.
         $this->assertEquals([$user1->id, $user3->id], array_keys($members), '', 0.0, 10, true);
     }
+
+    public function test_groups_get_all_groups_for_courses_no_courses() {
+        $this->resetAfterTest(true);
+        $generator = $this->getDataGenerator();
+
+        $this->assertEquals([], groups_get_all_groups_for_courses([]));
+    }
+
+    public function test_groups_get_all_groups_for_courses_with_courses() {
+        $this->resetAfterTest(true);
+        $generator = $this->getDataGenerator();
+
+        // Create courses.
+        $course1 = $generator->create_course(); // no groups.
+        $course2 = $generator->create_course(); // one group, no members.
+        $course3 = $generator->create_course(); // one group, one member.
+        $course4 = $generator->create_course(); // one group, multiple members.
+        $course5 = $generator->create_course(); // two groups, no members.
+        $course6 = $generator->create_course(); // two groups, one member.
+        $course7 = $generator->create_course(); // two groups, multiple members.
+
+        $courses = [$course1, $course2, $course3, $course4, $course5, $course6, $course7];
+        // Create users.
+        $user1 = $generator->create_user();
+        $user2 = $generator->create_user();
+        $user3 = $generator->create_user();
+        $user4 = $generator->create_user();
+
+        // Enrol users.
+        foreach ($courses as $course) {
+            $generator->enrol_user($user1->id, $course->id);
+            $generator->enrol_user($user2->id, $course->id);
+            $generator->enrol_user($user3->id, $course->id);
+            $generator->enrol_user($user4->id, $course->id);
+        }
+
+        // Create groups.
+        $group1 = $generator->create_group(array('courseid' => $course2->id)); // no members.
+        $group2 = $generator->create_group(array('courseid' => $course3->id)); // one member.
+        $group3 = $generator->create_group(array('courseid' => $course4->id)); // multiple members.
+        $group4 = $generator->create_group(array('courseid' => $course5->id)); // no members.
+        $group5 = $generator->create_group(array('courseid' => $course5->id)); // no members.
+        $group6 = $generator->create_group(array('courseid' => $course6->id)); // one member.
+        $group7 = $generator->create_group(array('courseid' => $course6->id)); // one member.
+        $group8 = $generator->create_group(array('courseid' => $course7->id)); // multiple members.
+        $group9 = $generator->create_group(array('courseid' => $course7->id)); // multiple members.
+
+        // Assign users to groups.
+        $generator->create_group_member(array('groupid' => $group2->id, 'userid' => $user1->id));
+        $generator->create_group_member(array('groupid' => $group3->id, 'userid' => $user1->id));
+        $generator->create_group_member(array('groupid' => $group3->id, 'userid' => $user2->id));
+        $generator->create_group_member(array('groupid' => $group6->id, 'userid' => $user1->id));
+        $generator->create_group_member(array('groupid' => $group7->id, 'userid' => $user1->id));
+        $generator->create_group_member(array('groupid' => $group8->id, 'userid' => $user1->id));
+        $generator->create_group_member(array('groupid' => $group8->id, 'userid' => $user2->id));
+        $generator->create_group_member(array('groupid' => $group9->id, 'userid' => $user1->id));
+        $generator->create_group_member(array('groupid' => $group9->id, 'userid' => $user2->id));
+
+        $result = groups_get_all_groups_for_courses($courses);
+        $assertPropertiesMatch = function($expected, $actual) {
+            $props = get_object_vars($expected);
+
+            foreach ($props as $name => $val) {
+                $got = $actual->{$name};
+                $this->assertEquals(
+                    $val,
+                    $actual->{$name},
+                    "Failed asserting that {$got} equals {$val} for property {$name}"
+                );
+            }
+        };
+
+        // Course 1 has no groups.
+        $this->assertEquals([], $result[$course1->id]);
+
+        // Course 2 has one group with no members.
+        $coursegroups = $result[$course2->id];
+        $coursegroup = $coursegroups[$group1->id];
+        $this->assertCount(1, $coursegroups);
+        $this->assertEquals([], $coursegroup->members);
+        $assertPropertiesMatch($group1, $coursegroup);
+
+        // Course 3 has one group with one member.
+        $coursegroups = $result[$course3->id];
+        $coursegroup = $coursegroups[$group2->id];
+        $groupmember1 = $coursegroup->members[$user1->id];
+        $this->assertCount(1, $coursegroups);
+        $this->assertCount(1, $coursegroup->members);
+        $assertPropertiesMatch($group2, $coursegroup);
+        $this->assertEquals($user1->id, $groupmember1->userid);
+
+        // Course 4 has one group with multiple members.
+        $coursegroups = $result[$course4->id];
+        $coursegroup = $coursegroups[$group3->id];
+        $groupmember1 = $coursegroup->members[$user1->id];
+        $groupmember2 = $coursegroup->members[$user2->id];
+        $this->assertCount(1, $coursegroups);
+        $this->assertCount(2, $coursegroup->members);
+        $assertPropertiesMatch($group3, $coursegroup);
+        $this->assertEquals($user1->id, $groupmember1->userid);
+        $this->assertEquals($user2->id, $groupmember2->userid);
+
+        // Course 5 has multiple groups with no members.
+        $coursegroups = $result[$course5->id];
+        $coursegroup1 = $coursegroups[$group4->id];
+        $coursegroup2 = $coursegroups[$group5->id];
+        $this->assertCount(2, $coursegroups);
+        $this->assertEquals([], $coursegroup1->members);
+        $this->assertEquals([], $coursegroup2->members);
+        $assertPropertiesMatch($group4, $coursegroup1);
+        $assertPropertiesMatch($group5, $coursegroup2);
+
+        // Course 6 has multiple groups with one member.
+        $coursegroups = $result[$course6->id];
+        $coursegroup1 = $coursegroups[$group6->id];
+        $coursegroup2 = $coursegroups[$group7->id];
+        $group1member1 = $coursegroup1->members[$user1->id];
+        $group2member1 = $coursegroup2->members[$user1->id];
+        $this->assertCount(2, $coursegroups);
+        $this->assertCount(1, $coursegroup1->members);
+        $this->assertCount(1, $coursegroup2->members);
+        $assertPropertiesMatch($group6, $coursegroup1);
+        $assertPropertiesMatch($group7, $coursegroup2);
+        $this->assertEquals($user1->id, $group1member1->userid);
+        $this->assertEquals($user1->id, $group2member1->userid);
+
+        // Course 7 has multiple groups with multiple members.
+        $coursegroups = $result[$course7->id];
+        $coursegroup1 = $coursegroups[$group8->id];
+        $coursegroup2 = $coursegroups[$group9->id];
+        $group1member1 = $coursegroup1->members[$user1->id];
+        $group1member2 = $coursegroup1->members[$user2->id];
+        $group2member1 = $coursegroup2->members[$user1->id];
+        $group2member2 = $coursegroup2->members[$user2->id];
+        $this->assertCount(2, $coursegroups);
+        $this->assertCount(2, $coursegroup1->members);
+        $this->assertCount(2, $coursegroup2->members);
+        $assertPropertiesMatch($group8, $coursegroup1);
+        $assertPropertiesMatch($group9, $coursegroup2);
+        $this->assertEquals($user1->id, $group1member1->userid);
+        $this->assertEquals($user2->id, $group1member2->userid);
+        $this->assertEquals($user1->id, $group2member1->userid);
+        $this->assertEquals($user2->id, $group2member2->userid);
+    }
 }
index 7ae262a..cf1bb36 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2017072700.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2017072700.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.