From ac2313b3fde07ca85b9e6c7ac46777884ae30bdf Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Fri, 6 Mar 2026 14:06:14 +1100 Subject: [PATCH] MDL-81559 calendar: adjust end date automatically --- .../amd/build/modal_event_form.min.js | 2 +- .../amd/build/modal_event_form.min.js.map | 2 +- public/calendar/amd/src/modal_event_form.js | 108 ++++++++++++++++++ .../classes/local/event/forms/create.php | 11 +- public/calendar/tests/behat/calendar.feature | 14 +++ 5 files changed, 129 insertions(+), 8 deletions(-) diff --git a/public/calendar/amd/build/modal_event_form.min.js b/public/calendar/amd/build/modal_event_form.min.js index 58aaca6b672..474aaa65df3 100644 --- a/public/calendar/amd/build/modal_event_form.min.js +++ b/public/calendar/amd/build/modal_event_form.min.js @@ -1,3 +1,3 @@ -define("core_calendar/modal_event_form",["exports","jquery","core/custom_interaction_events","core/modal","core_form/events","./events","core/str","core/notification","core/fragment","core_calendar/repository"],(function(_exports,_jquery,CustomEvents,_modal,FormEvents,_events2,Str,Notification,Fragment,Repository){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),CustomEvents=_interopRequireWildcard(CustomEvents),_modal=_interopRequireDefault(_modal),FormEvents=_interopRequireWildcard(FormEvents),_events2=_interopRequireDefault(_events2),Str=_interopRequireWildcard(Str),Notification=_interopRequireWildcard(Notification),Fragment=_interopRequireWildcard(Fragment),Repository=_interopRequireWildcard(Repository);const SELECTORS_SAVE_BUTTON='[data-action="save"]',SELECTORS_LOADING_ICON_CONTAINER='[data-region="loading-icon-container"]';class ModalEventForm extends _modal.default{constructor(root){super(root),this.eventId=null,this.startTime=null,this.courseId=null,this.categoryId=null,this.contextId=null,this.reloadingBody=!1,this.reloadingTitle=!1,this.saveButton=this.getFooter().find(SELECTORS_SAVE_BUTTON)}configure(modalConfig){modalConfig.large=!0,super.configure(modalConfig)}setContextId(id){this.contextId=id}getContextId(){return this.contextId}setCourseId(id){this.courseId=id}getCourseId(){return this.courseId}setCategoryId(id){this.categoryId=id}getCategoryId(){return this.categoryId}hasCourseId(){return null!==this.courseId}hasCategoryId(){return null!==this.categoryId}setEventId(id){this.eventId=id}getEventId(){return this.eventId}hasEventId(){return null!==this.eventId}setStartTime(time){this.startTime=time}getStartTime(){return this.startTime}hasStartTime(){return null!==this.startTime}getForm(){return this.getBody().find("form")}disableButtons(){this.saveButton.prop("disabled",!0)}enableButtons(){this.saveButton.prop("disabled",!1)}reloadTitleContent(){return this.reloadingTitle||(this.reloadingTitle=!0,this.hasEventId()?this.titlePromise=Str.get_string("editevent","calendar"):this.titlePromise=Str.get_string("newevent","calendar"),this.titlePromise.then((string=>(this.setTitle(string),string))).catch(Notification.exception).always((()=>{this.reloadingTitle=!1}))),this.titlePromise}reloadBodyContent(formData){if(this.reloadingBody)return this.bodyPromise;this.reloadingBody=!0,this.disableButtons();const args={};return this.hasEventId()&&(args.eventid=this.getEventId()),this.hasStartTime()&&(args.starttime=this.getStartTime()),this.hasCourseId()&&(args.courseid=this.getCourseId()),this.hasCategoryId()&&(args.categoryid=this.getCategoryId()),void 0!==formData&&(args.formdata=formData),this.bodyPromise=Fragment.loadFragment("calendar","event_form",this.getContextId(),args),this.setBody(this.bodyPromise),this.bodyPromise.then((()=>{this.enableButtons()})).catch(Notification.exception).always((()=>{this.reloadingBody=!1})),this.bodyPromise}reloadAllContent(){return _jquery.default.when(this.reloadTitleContent(),this.reloadBodyContent())}show(){this.reloadAllContent(),super.show(this)}hide(){super.hide(this),this.setEventId(null),this.setStartTime(null),this.setCourseId(null),this.setCategoryId(null)}getFormData(){return this.getForm().serialize()}save(){const loadingContainer=this.saveButton.find(SELECTORS_LOADING_ICON_CONTAINER),invalid=this.getForm().find('[aria-invalid="true"]');if(invalid.length)return invalid.first().focus(),Promise.resolve();loadingContainer.removeClass("hidden"),this.disableButtons();const formData=this.getFormData();return Repository.submitCreateUpdateForm(formData).then((response=>{if(response.validationerror)this.reloadBodyContent(formData);else{const isExisting=this.hasEventId();this.hide(),isExisting?(0,_jquery.default)("body").trigger(_events2.default.updated,[response.event]):(0,_jquery.default)("body").trigger(_events2.default.created,[response.event])}})).catch(Notification.exception).always((()=>{loadingContainer.addClass("hidden"),this.enableButtons()}))}registerEventListeners(){super.registerEventListeners(this),this.getModal().on(CustomEvents.events.activate,SELECTORS_SAVE_BUTTON,((e,data)=>{this.getForm().submit(),data.originalEvent.preventDefault(),e.stopPropagation()})),this.getModal().on("submit",(e=>{FormEvents.notifyFormSubmittedByJavascript(this.getForm()[0]),this.save(),e.preventDefault(),e.stopPropagation()}))}}return _exports.default=ModalEventForm,_defineProperty(ModalEventForm,"TYPE","core_calendar-modal_event_form"),_defineProperty(ModalEventForm,"TEMPLATE","calendar/modal_event_form"),ModalEventForm.registerModalType(),_exports.default})); +define("core_calendar/modal_event_form",["exports","jquery","core/custom_interaction_events","core/modal","core_form/events","./events","core/str","core/notification","core/fragment","core_calendar/repository"],(function(_exports,_jquery,CustomEvents,_modal,FormEvents,_events2,Str,Notification,Fragment,Repository){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),CustomEvents=_interopRequireWildcard(CustomEvents),_modal=_interopRequireDefault(_modal),FormEvents=_interopRequireWildcard(FormEvents),_events2=_interopRequireDefault(_events2),Str=_interopRequireWildcard(Str),Notification=_interopRequireWildcard(Notification),Fragment=_interopRequireWildcard(Fragment),Repository=_interopRequireWildcard(Repository);const SELECTORS={SAVE_BUTTON:'[data-action="save"]',LOADING_ICON_CONTAINER:'[data-region="loading-icon-container"]',START_DATE_FIELDS:'select[name^="timestart"]',END_DATE_FIELDS:'select[name^="timedurationuntil"]',START_DATE_DATE_PICKER:"#id_timestart_calendar",END_DATE_DATE_PICKER:"#id_timedurationuntil_calendar",DURATION_FIELD:"#id_timedurationminutes"};class ModalEventForm extends _modal.default{constructor(root){super(root),this.eventId=null,this.startTime=null,this.courseId=null,this.categoryId=null,this.contextId=null,this.reloadingBody=!1,this.reloadingTitle=!1,this.saveButton=this.getFooter().find(SELECTORS.SAVE_BUTTON),this.eventTimeDuration=0,this.lastUsedDatePickerId=null}configure(modalConfig){modalConfig.large=!0,super.configure(modalConfig)}setContextId(id){this.contextId=id}getContextId(){return this.contextId}setCourseId(id){this.courseId=id}getCourseId(){return this.courseId}setCategoryId(id){this.categoryId=id}getCategoryId(){return this.categoryId}hasCourseId(){return null!==this.courseId}hasCategoryId(){return null!==this.categoryId}setEventId(id){this.eventId=id}getEventId(){return this.eventId}hasEventId(){return null!==this.eventId}setStartTime(time){this.startTime=time}getStartTime(){return this.startTime}hasStartTime(){return null!==this.startTime}getForm(){return this.getBody().find("form")}disableButtons(){this.saveButton.prop("disabled",!0)}enableButtons(){this.saveButton.prop("disabled",!1)}getDateFromFields(selector){const identifier=selector.match(/"([^"]+)"/)[1],d=new Date;return d.setFullYear(document.getElementById("id_".concat(identifier,"_year")).value),d.setMonth(document.getElementById("id_".concat(identifier,"_month")).value-1),d.setDate(document.getElementById("id_".concat(identifier,"_day")).value),d.setHours(document.getElementById("id_".concat(identifier,"_hour")).value),d.setMinutes(document.getElementById("id_".concat(identifier,"_minute")).value),d}updateEndTime(){const startDate=this.getDateFromFields(SELECTORS.START_DATE_FIELDS),endDate=new Date(startDate.getTime()+this.eventTimeDuration),identifier=SELECTORS.END_DATE_FIELDS.match(/"([^"]+)"/)[1];document.getElementById("id_".concat(identifier,"_year")).value=endDate.getFullYear(),document.getElementById("id_".concat(identifier,"_month")).value=endDate.getMonth()+1,document.getElementById("id_".concat(identifier,"_day")).value=endDate.getDate(),document.getElementById("id_".concat(identifier,"_hour")).value=endDate.getHours(),document.getElementById("id_".concat(identifier,"_minute")).value=endDate.getMinutes()}calculateDuration(){const startDate=this.getDateFromFields(SELECTORS.START_DATE_FIELDS);return this.getDateFromFields(SELECTORS.END_DATE_FIELDS)-startDate}registerCalendarEvents(){var _M,_M$form,_M$form$dateselector;const calendar=null===(_M=M)||void 0===_M||null===(_M$form=_M.form)||void 0===_M$form||null===(_M$form$dateselector=_M$form.dateselector)||void 0===_M$form$dateselector?void 0:_M$form$dateselector.calendar;calendar&&calendar.on("dateClick",(()=>{this.lastUsedDatePickerId===SELECTORS.START_DATE_DATE_PICKER?this.updateEndTime():this.lastUsedDatePickerId===SELECTORS.END_DATE_DATE_PICKER&&(this.eventTimeDuration=this.calculateDuration())}),this)}reloadTitleContent(){return this.reloadingTitle||(this.reloadingTitle=!0,this.hasEventId()?this.titlePromise=Str.get_string("editevent","calendar"):this.titlePromise=Str.get_string("newevent","calendar"),this.titlePromise.then((string=>(this.setTitle(string),string))).catch(Notification.exception).always((()=>{this.reloadingTitle=!1}))),this.titlePromise}reloadBodyContent(formData){if(this.reloadingBody)return this.bodyPromise;this.reloadingBody=!0,this.disableButtons();const args={};return this.hasEventId()&&(args.eventid=this.getEventId()),this.hasStartTime()&&(args.starttime=this.getStartTime()),this.hasCourseId()&&(args.courseid=this.getCourseId()),this.hasCategoryId()&&(args.categoryid=this.getCategoryId()),void 0!==formData&&(args.formdata=formData),this.bodyPromise=Fragment.loadFragment("calendar","event_form",this.getContextId(),args),this.setBody(this.bodyPromise),this.bodyPromise.then((()=>{this.enableButtons(),this.updateEndTime(),this.eventTimeDuration=this.calculateDuration(),this.registerCalendarEvents()})).catch(Notification.exception).always((()=>{this.reloadingBody=!1})),this.bodyPromise}reloadAllContent(){return _jquery.default.when(this.reloadTitleContent(),this.reloadBodyContent())}show(){this.reloadAllContent(),super.show(this)}hide(){super.hide(this),this.setEventId(null),this.setStartTime(null),this.setCourseId(null),this.setCategoryId(null)}getFormData(){return this.getForm().serialize()}save(){const loadingContainer=this.saveButton.find(SELECTORS.LOADING_ICON_CONTAINER),invalid=this.getForm().find('[aria-invalid="true"]');if(invalid.length)return invalid.first().focus(),Promise.resolve();loadingContainer.removeClass("hidden"),this.disableButtons();const formData=this.getFormData();return Repository.submitCreateUpdateForm(formData).then((response=>{if(response.validationerror)this.reloadBodyContent(formData);else{const isExisting=this.hasEventId();this.hide(),isExisting?(0,_jquery.default)("body").trigger(_events2.default.updated,[response.event]):(0,_jquery.default)("body").trigger(_events2.default.created,[response.event])}})).catch(Notification.exception).always((()=>{loadingContainer.addClass("hidden"),this.enableButtons()}))}registerEventListeners(){super.registerEventListeners(this),this.getModal().on(CustomEvents.events.activate,SELECTORS.SAVE_BUTTON,((e,data)=>{this.getForm().submit(),data.originalEvent.preventDefault(),e.stopPropagation()})),this.getModal().on("submit",(e=>{FormEvents.notifyFormSubmittedByJavascript(this.getForm()[0]),this.save(),e.preventDefault(),e.stopPropagation()})),this.getModal().on("change",SELECTORS.START_DATE_FIELDS,(()=>{this.updateEndTime()})),this.getModal().on("change",SELECTORS.END_DATE_FIELDS,(()=>{this.eventTimeDuration=this.calculateDuration()})),this.getModal().on("click",SELECTORS.START_DATE_DATE_PICKER,(()=>{this.lastUsedDatePickerId=SELECTORS.START_DATE_DATE_PICKER})),this.getModal().on("click",SELECTORS.END_DATE_DATE_PICKER,(()=>{this.lastUsedDatePickerId=SELECTORS.END_DATE_DATE_PICKER})),this.getModal().on("focusout",SELECTORS.DURATION_FIELD,(e=>{const duration=parseInt(e.target.value);isNaN(duration)||(this.eventTimeDuration=60*duration*1e3,this.updateEndTime())}))}}return _exports.default=ModalEventForm,_defineProperty(ModalEventForm,"TYPE","core_calendar-modal_event_form"),_defineProperty(ModalEventForm,"TEMPLATE","calendar/modal_event_form"),ModalEventForm.registerModalType(),_exports.default})); //# sourceMappingURL=modal_event_form.min.js.map \ No newline at end of file diff --git a/public/calendar/amd/build/modal_event_form.min.js.map b/public/calendar/amd/build/modal_event_form.min.js.map index a68ddc571bb..5eb18397e54 100644 --- a/public/calendar/amd/build/modal_event_form.min.js.map +++ b/public/calendar/amd/build/modal_event_form.min.js.map @@ -1 +1 @@ -{"version":3,"file":"modal_event_form.min.js","sources":["../src/modal_event_form.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Contain the logic for the quick add or update event modal.\n *\n * @module core_calendar/modal_event_form\n * @copyright 2017 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport * as CustomEvents from 'core/custom_interaction_events';\nimport Modal from 'core/modal';\nimport * as FormEvents from 'core_form/events';\nimport CalendarEvents from './events';\nimport * as Str from 'core/str';\nimport * as Notification from 'core/notification';\nimport * as Fragment from 'core/fragment';\nimport * as Repository from 'core_calendar/repository';\n\nconst SELECTORS = {\n SAVE_BUTTON: '[data-action=\"save\"]',\n LOADING_ICON_CONTAINER: '[data-region=\"loading-icon-container\"]',\n};\n\nexport default class ModalEventForm extends Modal {\n static TYPE = 'core_calendar-modal_event_form';\n static TEMPLATE = 'calendar/modal_event_form';\n\n /**\n * Constructor for the Modal.\n *\n * @param {object} root The root jQuery element for the modal\n */\n constructor(root) {\n super(root);\n\n this.eventId = null;\n this.startTime = null;\n this.courseId = null;\n this.categoryId = null;\n this.contextId = null;\n this.reloadingBody = false;\n this.reloadingTitle = false;\n this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);\n }\n\n configure(modalConfig) {\n modalConfig.large = true;\n super.configure(modalConfig);\n }\n\n /**\n * Set the context id to the given value.\n *\n * @method setContextId\n * @param {Number} id The event id\n */\n setContextId(id) {\n this.contextId = id;\n }\n\n /**\n * Retrieve the current context id, if any.\n *\n * @method getContextId\n * @return {Number|null} The event id\n */\n getContextId() {\n return this.contextId;\n }\n\n /**\n * Set the course id to the given value.\n *\n * @method setCourseId\n * @param {Number} id The event id\n */\n setCourseId(id) {\n this.courseId = id;\n }\n\n /**\n * Retrieve the current course id, if any.\n *\n * @method getCourseId\n * @return {Number|null} The event id\n */\n getCourseId() {\n return this.courseId;\n }\n\n /**\n * Set the category id to the given value.\n *\n * @method setCategoryId\n * @param {Number} id The event id\n */\n setCategoryId(id) {\n this.categoryId = id;\n }\n\n /**\n * Retrieve the current category id, if any.\n *\n * @method getCategoryId\n * @return {Number|null} The event id\n */\n getCategoryId() {\n return this.categoryId;\n }\n\n /**\n * Check if the modal has an course id.\n *\n * @method hasCourseId\n * @return {bool}\n */\n hasCourseId() {\n return this.courseId !== null;\n }\n\n /**\n * Check if the modal has an category id.\n *\n * @method hasCategoryId\n * @return {bool}\n */\n hasCategoryId() {\n return this.categoryId !== null;\n }\n\n /**\n * Set the event id to the given value.\n *\n * @method setEventId\n * @param {Number} id The event id\n */\n setEventId(id) {\n this.eventId = id;\n }\n\n /**\n * Retrieve the current event id, if any.\n *\n * @method getEventId\n * @return {Number|null} The event id\n */\n getEventId() {\n return this.eventId;\n }\n\n /**\n * Check if the modal has an event id.\n *\n * @method hasEventId\n * @return {bool}\n */\n hasEventId() {\n return this.eventId !== null;\n }\n\n /**\n * Set the start time to the given value.\n *\n * @method setStartTime\n * @param {Number} time The start time\n */\n setStartTime(time) {\n this.startTime = time;\n }\n\n /**\n * Retrieve the current start time, if any.\n *\n * @method getStartTime\n * @return {Number|null} The start time\n */\n getStartTime() {\n return this.startTime;\n }\n\n /**\n * Check if the modal has start time.\n *\n * @method hasStartTime\n * @return {bool}\n */\n hasStartTime() {\n return this.startTime !== null;\n }\n\n /**\n * Get the form element from the modal.\n *\n * @method getForm\n * @return {object}\n */\n getForm() {\n return this.getBody().find('form');\n }\n\n /**\n * Disable the buttons in the footer.\n *\n * @method disableButtons\n */\n disableButtons() {\n this.saveButton.prop('disabled', true);\n }\n\n /**\n * Enable the buttons in the footer.\n *\n * @method enableButtons\n */\n enableButtons() {\n this.saveButton.prop('disabled', false);\n }\n\n /**\n * Reload the title for the modal to the appropriate value\n * depending on whether we are creating a new event or\n * editing an existing event.\n *\n * @method reloadTitleContent\n * @return {object} A promise resolved with the new title text\n */\n reloadTitleContent() {\n if (this.reloadingTitle) {\n return this.titlePromise;\n }\n\n this.reloadingTitle = true;\n\n if (this.hasEventId()) {\n this.titlePromise = Str.get_string('editevent', 'calendar');\n } else {\n this.titlePromise = Str.get_string('newevent', 'calendar');\n }\n\n this.titlePromise.then((string) => {\n this.setTitle(string);\n return string;\n })\n .catch(Notification.exception)\n .always(() => {\n this.reloadingTitle = false;\n return;\n });\n\n return this.titlePromise;\n }\n\n /**\n * Send a request to the server to get the event_form in a fragment\n * and render the result in the body of the modal.\n *\n * If serialised form data is provided then it will be sent in the\n * request to the server to have the form rendered with the data. This\n * is used when the form had a server side error and we need the server\n * to re-render it for us to display the error to the user.\n *\n * @method reloadBodyContent\n * @param {string} formData The serialised form data\n * @return {object} A promise resolved with the fragment html and js from\n */\n reloadBodyContent(formData) {\n if (this.reloadingBody) {\n return this.bodyPromise;\n }\n\n this.reloadingBody = true;\n this.disableButtons();\n\n const args = {};\n\n if (this.hasEventId()) {\n args.eventid = this.getEventId();\n }\n\n if (this.hasStartTime()) {\n args.starttime = this.getStartTime();\n }\n\n if (this.hasCourseId()) {\n args.courseid = this.getCourseId();\n }\n\n if (this.hasCategoryId()) {\n args.categoryid = this.getCategoryId();\n }\n\n if (typeof formData !== 'undefined') {\n args.formdata = formData;\n }\n\n this.bodyPromise = Fragment.loadFragment('calendar', 'event_form', this.getContextId(), args);\n\n this.setBody(this.bodyPromise);\n\n this.bodyPromise.then(() => {\n this.enableButtons();\n return;\n })\n .catch(Notification.exception)\n .always(() => {\n this.reloadingBody = false;\n return;\n });\n\n return this.bodyPromise;\n }\n\n /**\n * Reload both the title and body content.\n *\n * @method reloadAllContent\n * @return {object} promise\n */\n reloadAllContent() {\n return $.when(this.reloadTitleContent(), this.reloadBodyContent());\n }\n\n /**\n * Kick off a reload the modal content before showing it. This\n * is to allow us to re-use the same modal for creating and\n * editing different events within the page.\n *\n * We do the reload when showing the modal rather than hiding it\n * to save a request to the server if the user closes the modal\n * and never re-opens it.\n *\n * @method show\n */\n show() {\n this.reloadAllContent();\n super.show(this);\n }\n\n /**\n * Clear the event id from the modal when it's closed so\n * that it is loaded fresh next time it's displayed.\n *\n * The event id will be set by the calling code if it wants\n * to edit a specific event.\n *\n * @method hide\n */\n hide() {\n super.hide(this);\n this.setEventId(null);\n this.setStartTime(null);\n this.setCourseId(null);\n this.setCategoryId(null);\n }\n\n /**\n * Get the serialised form data.\n *\n * @method getFormData\n * @return {string} serialised form data\n */\n getFormData() {\n return this.getForm().serialize();\n }\n\n /**\n * Send the form data to the server to create or update\n * an event.\n *\n * If there is a server side validation error then we re-request the\n * rendered form (with the data) from the server in order to get the\n * server side errors to display.\n *\n * On success the modal is hidden and the page is reloaded so that the\n * new event will display.\n *\n * @method save\n * @return {object} A promise\n */\n save() {\n const loadingContainer = this.saveButton.find(SELECTORS.LOADING_ICON_CONTAINER);\n\n // Now the change events have run, see if there are any \"invalid\" form fields.\n const invalid = this.getForm().find('[aria-invalid=\"true\"]');\n\n // If we found invalid fields, focus on the first one and do not submit via ajax.\n if (invalid.length) {\n invalid.first().focus();\n return Promise.resolve();\n }\n\n loadingContainer.removeClass('hidden');\n this.disableButtons();\n\n const formData = this.getFormData();\n // Send the form data to the server for processing.\n return Repository.submitCreateUpdateForm(formData)\n .then((response) => {\n if (response.validationerror) {\n // If there was a server side validation error then\n // we need to re-request the rendered form from the server\n // in order to display the error for the user.\n this.reloadBodyContent(formData);\n return;\n } else {\n // Check whether this was a new event or not.\n // The hide function unsets the form data so grab this before the hide.\n const isExisting = this.hasEventId();\n\n // No problemo! Our work here is done.\n this.hide();\n\n // Trigger the appropriate calendar event so that the view can be updated.\n if (isExisting) {\n $('body').trigger(CalendarEvents.updated, [response.event]);\n } else {\n $('body').trigger(CalendarEvents.created, [response.event]);\n }\n }\n\n return;\n })\n .catch(Notification.exception)\n .always(() => {\n // Regardless of success or error we should always stop\n // the loading icon and re-enable the buttons.\n loadingContainer.addClass('hidden');\n this.enableButtons();\n\n return;\n });\n }\n\n /**\n * Set up all of the event handling for the modal.\n *\n * @method registerEventListeners\n * @fires event:uploadStarted\n * @fires event:formSubmittedByJavascript\n */\n registerEventListeners() {\n // Apply parent event listeners.\n super.registerEventListeners(this);\n\n // When the user clicks the save button we trigger the form submission. We need to\n // trigger an actual submission because there is some JS code in the form that is\n // listening for this event and doing some stuff (e.g. saving draft areas etc).\n this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, (e, data) => {\n this.getForm().submit();\n data.originalEvent.preventDefault();\n e.stopPropagation();\n });\n\n // Catch the submit event before it is actually processed by the browser and\n // prevent the submission. We'll take it from here.\n this.getModal().on('submit', (e) => {\n FormEvents.notifyFormSubmittedByJavascript(this.getForm()[0]);\n\n this.save();\n\n // Stop the form from actually submitting and prevent it's\n // propagation because we have already handled the event.\n e.preventDefault();\n e.stopPropagation();\n });\n }\n}\n\nModalEventForm.registerModalType();\n"],"names":["SELECTORS","ModalEventForm","Modal","constructor","root","eventId","startTime","courseId","categoryId","contextId","reloadingBody","reloadingTitle","saveButton","this","getFooter","find","configure","modalConfig","large","setContextId","id","getContextId","setCourseId","getCourseId","setCategoryId","getCategoryId","hasCourseId","hasCategoryId","setEventId","getEventId","hasEventId","setStartTime","time","getStartTime","hasStartTime","getForm","getBody","disableButtons","prop","enableButtons","reloadTitleContent","titlePromise","Str","get_string","then","string","setTitle","catch","Notification","exception","always","reloadBodyContent","formData","bodyPromise","args","eventid","starttime","courseid","categoryid","formdata","Fragment","loadFragment","setBody","reloadAllContent","$","when","show","hide","getFormData","serialize","save","loadingContainer","invalid","length","first","focus","Promise","resolve","removeClass","Repository","submitCreateUpdateForm","response","validationerror","isExisting","trigger","CalendarEvents","updated","event","created","addClass","registerEventListeners","getModal","on","CustomEvents","events","activate","e","data","submit","originalEvent","preventDefault","stopPropagation","FormEvents","notifyFormSubmittedByJavascript","registerModalType"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"modal_event_form.min.js","sources":["../src/modal_event_form.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Contain the logic for the quick add or update event modal.\n *\n * @module core_calendar/modal_event_form\n * @copyright 2017 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport * as CustomEvents from 'core/custom_interaction_events';\nimport Modal from 'core/modal';\nimport * as FormEvents from 'core_form/events';\nimport CalendarEvents from './events';\nimport * as Str from 'core/str';\nimport * as Notification from 'core/notification';\nimport * as Fragment from 'core/fragment';\nimport * as Repository from 'core_calendar/repository';\n\nconst SELECTORS = {\n SAVE_BUTTON: '[data-action=\"save\"]',\n LOADING_ICON_CONTAINER: '[data-region=\"loading-icon-container\"]',\n START_DATE_FIELDS: 'select[name^=\"timestart\"]',\n END_DATE_FIELDS: 'select[name^=\"timedurationuntil\"]',\n START_DATE_DATE_PICKER: '#id_timestart_calendar',\n END_DATE_DATE_PICKER: '#id_timedurationuntil_calendar',\n DURATION_FIELD: '#id_timedurationminutes',\n};\n\nexport default class ModalEventForm extends Modal {\n static TYPE = 'core_calendar-modal_event_form';\n static TEMPLATE = 'calendar/modal_event_form';\n\n /**\n * Constructor for the Modal.\n *\n * @param {object} root The root jQuery element for the modal\n */\n constructor(root) {\n super(root);\n\n this.eventId = null;\n this.startTime = null;\n this.courseId = null;\n this.categoryId = null;\n this.contextId = null;\n this.reloadingBody = false;\n this.reloadingTitle = false;\n this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);\n this.eventTimeDuration = 0;\n this.lastUsedDatePickerId = null;\n }\n\n configure(modalConfig) {\n modalConfig.large = true;\n super.configure(modalConfig);\n }\n\n /**\n * Set the context id to the given value.\n *\n * @method setContextId\n * @param {Number} id The event id\n */\n setContextId(id) {\n this.contextId = id;\n }\n\n /**\n * Retrieve the current context id, if any.\n *\n * @method getContextId\n * @return {Number|null} The event id\n */\n getContextId() {\n return this.contextId;\n }\n\n /**\n * Set the course id to the given value.\n *\n * @method setCourseId\n * @param {Number} id The event id\n */\n setCourseId(id) {\n this.courseId = id;\n }\n\n /**\n * Retrieve the current course id, if any.\n *\n * @method getCourseId\n * @return {Number|null} The event id\n */\n getCourseId() {\n return this.courseId;\n }\n\n /**\n * Set the category id to the given value.\n *\n * @method setCategoryId\n * @param {Number} id The event id\n */\n setCategoryId(id) {\n this.categoryId = id;\n }\n\n /**\n * Retrieve the current category id, if any.\n *\n * @method getCategoryId\n * @return {Number|null} The event id\n */\n getCategoryId() {\n return this.categoryId;\n }\n\n /**\n * Check if the modal has an course id.\n *\n * @method hasCourseId\n * @return {bool}\n */\n hasCourseId() {\n return this.courseId !== null;\n }\n\n /**\n * Check if the modal has an category id.\n *\n * @method hasCategoryId\n * @return {bool}\n */\n hasCategoryId() {\n return this.categoryId !== null;\n }\n\n /**\n * Set the event id to the given value.\n *\n * @method setEventId\n * @param {Number} id The event id\n */\n setEventId(id) {\n this.eventId = id;\n }\n\n /**\n * Retrieve the current event id, if any.\n *\n * @method getEventId\n * @return {Number|null} The event id\n */\n getEventId() {\n return this.eventId;\n }\n\n /**\n * Check if the modal has an event id.\n *\n * @method hasEventId\n * @return {bool}\n */\n hasEventId() {\n return this.eventId !== null;\n }\n\n /**\n * Set the start time to the given value.\n *\n * @method setStartTime\n * @param {Number} time The start time\n */\n setStartTime(time) {\n this.startTime = time;\n }\n\n /**\n * Retrieve the current start time, if any.\n *\n * @method getStartTime\n * @return {Number|null} The start time\n */\n getStartTime() {\n return this.startTime;\n }\n\n /**\n * Check if the modal has start time.\n *\n * @method hasStartTime\n * @return {bool}\n */\n hasStartTime() {\n return this.startTime !== null;\n }\n\n /**\n * Get the form element from the modal.\n *\n * @method getForm\n * @return {object}\n */\n getForm() {\n return this.getBody().find('form');\n }\n\n /**\n * Disable the buttons in the footer.\n *\n * @method disableButtons\n */\n disableButtons() {\n this.saveButton.prop('disabled', true);\n }\n\n /**\n * Enable the buttons in the footer.\n *\n * @method enableButtons\n */\n enableButtons() {\n this.saveButton.prop('disabled', false);\n }\n\n /**\n * Create a date object from the fields in the form.\n *\n * @method getDateFromFields\n * @param {string} selector The selector that is used to build the id of the start/end date fields.\n * @return {Date} The date object created from the fields\n */\n getDateFromFields(selector) {\n const identifier = selector.match(/\"([^\"]+)\"/)[1];\n const d = new Date();\n d.setFullYear(document.getElementById(`id_${identifier}_year`).value);\n d.setMonth(document.getElementById(`id_${identifier}_month`).value - 1);\n d.setDate(document.getElementById(`id_${identifier}_day`).value);\n d.setHours(document.getElementById(`id_${identifier}_hour`).value);\n d.setMinutes(document.getElementById(`id_${identifier}_minute`).value);\n return d;\n }\n\n /**\n * Update the end time value based on the duration of the event.\n *\n * If the event ends in 1 hour and 15 minutes, then updating the\n * start time will respect this duration and push the end time forward\n * and retain the 1 hour and 15 minute duration. This behaviour matches\n * those of Google and iOS calendars in 2026.\n *\n * @method updateEndTime\n */\n updateEndTime() {\n const startDate = this.getDateFromFields(SELECTORS.START_DATE_FIELDS);\n const endDate = new Date(startDate.getTime() + this.eventTimeDuration);\n const identifier = SELECTORS.END_DATE_FIELDS.match(/\"([^\"]+)\"/)[1];\n document.getElementById(`id_${identifier}_year`).value = endDate.getFullYear();\n document.getElementById(`id_${identifier}_month`).value = endDate.getMonth() + 1;\n document.getElementById(`id_${identifier}_day`).value = endDate.getDate();\n document.getElementById(`id_${identifier}_hour`).value = endDate.getHours();\n document.getElementById(`id_${identifier}_minute`).value = endDate.getMinutes();\n }\n\n /**\n * Calculate the duration of the event by comparing the start and end dates.\n *\n * @method calculateDuration\n * @return {Number} Millisecond difference.\n */\n calculateDuration() {\n const startDate = this.getDateFromFields(SELECTORS.START_DATE_FIELDS);\n const endDate = this.getDateFromFields(SELECTORS.END_DATE_FIELDS);\n return (endDate - startDate);\n }\n\n /**\n * Register the YUI calendar events.\n *\n * These events need to be registered after the elements have been rendered.\n *\n * @method registerCalendarEvents\n */\n registerCalendarEvents() {\n const calendar = M?.form?.dateselector?.calendar;\n if (calendar) {\n calendar.on('dateClick', () => {\n // The YUI date pickers share the same elements.\n // Determine which ones was last used and perform the required method.\n if (this.lastUsedDatePickerId === SELECTORS.START_DATE_DATE_PICKER) {\n this.updateEndTime();\n } else if (this.lastUsedDatePickerId === SELECTORS.END_DATE_DATE_PICKER) {\n this.eventTimeDuration = this.calculateDuration();\n }\n }, this);\n }\n }\n\n /**\n * Reload the title for the modal to the appropriate value\n * depending on whether we are creating a new event or\n * editing an existing event.\n *\n * @method reloadTitleContent\n * @return {object} A promise resolved with the new title text\n */\n reloadTitleContent() {\n if (this.reloadingTitle) {\n return this.titlePromise;\n }\n\n this.reloadingTitle = true;\n\n if (this.hasEventId()) {\n this.titlePromise = Str.get_string('editevent', 'calendar');\n } else {\n this.titlePromise = Str.get_string('newevent', 'calendar');\n }\n\n this.titlePromise.then((string) => {\n this.setTitle(string);\n return string;\n })\n .catch(Notification.exception)\n .always(() => {\n this.reloadingTitle = false;\n return;\n });\n\n return this.titlePromise;\n }\n\n /**\n * Send a request to the server to get the event_form in a fragment\n * and render the result in the body of the modal.\n *\n * If serialised form data is provided then it will be sent in the\n * request to the server to have the form rendered with the data. This\n * is used when the form had a server side error and we need the server\n * to re-render it for us to display the error to the user.\n *\n * @method reloadBodyContent\n * @param {string} formData The serialised form data\n * @return {object} A promise resolved with the fragment html and js from\n */\n reloadBodyContent(formData) {\n if (this.reloadingBody) {\n return this.bodyPromise;\n }\n\n this.reloadingBody = true;\n this.disableButtons();\n\n const args = {};\n\n if (this.hasEventId()) {\n args.eventid = this.getEventId();\n }\n\n if (this.hasStartTime()) {\n args.starttime = this.getStartTime();\n }\n\n if (this.hasCourseId()) {\n args.courseid = this.getCourseId();\n }\n\n if (this.hasCategoryId()) {\n args.categoryid = this.getCategoryId();\n }\n\n if (typeof formData !== 'undefined') {\n args.formdata = formData;\n }\n\n this.bodyPromise = Fragment.loadFragment('calendar', 'event_form', this.getContextId(), args);\n\n this.setBody(this.bodyPromise);\n\n this.bodyPromise.then(() => {\n this.enableButtons();\n this.updateEndTime();\n this.eventTimeDuration = this.calculateDuration();\n this.registerCalendarEvents();\n return;\n })\n .catch(Notification.exception)\n .always(() => {\n this.reloadingBody = false;\n return;\n });\n\n return this.bodyPromise;\n }\n\n /**\n * Reload both the title and body content.\n *\n * @method reloadAllContent\n * @return {object} promise\n */\n reloadAllContent() {\n return $.when(this.reloadTitleContent(), this.reloadBodyContent());\n }\n\n /**\n * Kick off a reload the modal content before showing it. This\n * is to allow us to re-use the same modal for creating and\n * editing different events within the page.\n *\n * We do the reload when showing the modal rather than hiding it\n * to save a request to the server if the user closes the modal\n * and never re-opens it.\n *\n * @method show\n */\n show() {\n this.reloadAllContent();\n super.show(this);\n }\n\n /**\n * Clear the event id from the modal when it's closed so\n * that it is loaded fresh next time it's displayed.\n *\n * The event id will be set by the calling code if it wants\n * to edit a specific event.\n *\n * @method hide\n */\n hide() {\n super.hide(this);\n this.setEventId(null);\n this.setStartTime(null);\n this.setCourseId(null);\n this.setCategoryId(null);\n }\n\n /**\n * Get the serialised form data.\n *\n * @method getFormData\n * @return {string} serialised form data\n */\n getFormData() {\n return this.getForm().serialize();\n }\n\n /**\n * Send the form data to the server to create or update\n * an event.\n *\n * If there is a server side validation error then we re-request the\n * rendered form (with the data) from the server in order to get the\n * server side errors to display.\n *\n * On success the modal is hidden and the page is reloaded so that the\n * new event will display.\n *\n * @method save\n * @return {object} A promise\n */\n save() {\n const loadingContainer = this.saveButton.find(SELECTORS.LOADING_ICON_CONTAINER);\n\n // Now the change events have run, see if there are any \"invalid\" form fields.\n const invalid = this.getForm().find('[aria-invalid=\"true\"]');\n\n // If we found invalid fields, focus on the first one and do not submit via ajax.\n if (invalid.length) {\n invalid.first().focus();\n return Promise.resolve();\n }\n\n loadingContainer.removeClass('hidden');\n this.disableButtons();\n\n const formData = this.getFormData();\n // Send the form data to the server for processing.\n return Repository.submitCreateUpdateForm(formData)\n .then((response) => {\n if (response.validationerror) {\n // If there was a server side validation error then\n // we need to re-request the rendered form from the server\n // in order to display the error for the user.\n this.reloadBodyContent(formData);\n return;\n } else {\n // Check whether this was a new event or not.\n // The hide function unsets the form data so grab this before the hide.\n const isExisting = this.hasEventId();\n\n // No problemo! Our work here is done.\n this.hide();\n\n // Trigger the appropriate calendar event so that the view can be updated.\n if (isExisting) {\n $('body').trigger(CalendarEvents.updated, [response.event]);\n } else {\n $('body').trigger(CalendarEvents.created, [response.event]);\n }\n }\n\n return;\n })\n .catch(Notification.exception)\n .always(() => {\n // Regardless of success or error we should always stop\n // the loading icon and re-enable the buttons.\n loadingContainer.addClass('hidden');\n this.enableButtons();\n\n return;\n });\n }\n\n /**\n * Set up all of the event handling for the modal.\n *\n * @method registerEventListeners\n * @fires event:uploadStarted\n * @fires event:formSubmittedByJavascript\n */\n registerEventListeners() {\n // Apply parent event listeners.\n super.registerEventListeners(this);\n\n // When the user clicks the save button we trigger the form submission. We need to\n // trigger an actual submission because there is some JS code in the form that is\n // listening for this event and doing some stuff (e.g. saving draft areas etc).\n this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, (e, data) => {\n this.getForm().submit();\n data.originalEvent.preventDefault();\n e.stopPropagation();\n });\n\n // Catch the submit event before it is actually processed by the browser and\n // prevent the submission. We'll take it from here.\n this.getModal().on('submit', (e) => {\n FormEvents.notifyFormSubmittedByJavascript(this.getForm()[0]);\n\n this.save();\n\n // Stop the form from actually submitting and prevent it's\n // propagation because we have already handled the event.\n e.preventDefault();\n e.stopPropagation();\n });\n\n this.getModal().on('change', SELECTORS.START_DATE_FIELDS, () => {\n this.updateEndTime();\n });\n\n this.getModal().on('change', SELECTORS.END_DATE_FIELDS, () => {\n this.eventTimeDuration = this.calculateDuration();\n });\n\n this.getModal().on('click', SELECTORS.START_DATE_DATE_PICKER, () => {\n this.lastUsedDatePickerId = SELECTORS.START_DATE_DATE_PICKER;\n });\n\n this.getModal().on('click', SELECTORS.END_DATE_DATE_PICKER, () => {\n this.lastUsedDatePickerId = SELECTORS.END_DATE_DATE_PICKER;\n });\n\n this.getModal().on('focusout', SELECTORS.DURATION_FIELD, (e) => {\n const duration = parseInt(e.target.value);\n if (isNaN(duration)) {\n return;\n }\n this.eventTimeDuration = duration * 60 * 1000;\n this.updateEndTime();\n });\n }\n}\n\nModalEventForm.registerModalType();\n"],"names":["SELECTORS","SAVE_BUTTON","LOADING_ICON_CONTAINER","START_DATE_FIELDS","END_DATE_FIELDS","START_DATE_DATE_PICKER","END_DATE_DATE_PICKER","DURATION_FIELD","ModalEventForm","Modal","constructor","root","eventId","startTime","courseId","categoryId","contextId","reloadingBody","reloadingTitle","saveButton","this","getFooter","find","eventTimeDuration","lastUsedDatePickerId","configure","modalConfig","large","setContextId","id","getContextId","setCourseId","getCourseId","setCategoryId","getCategoryId","hasCourseId","hasCategoryId","setEventId","getEventId","hasEventId","setStartTime","time","getStartTime","hasStartTime","getForm","getBody","disableButtons","prop","enableButtons","getDateFromFields","selector","identifier","match","d","Date","setFullYear","document","getElementById","value","setMonth","setDate","setHours","setMinutes","updateEndTime","startDate","endDate","getTime","getFullYear","getMonth","getDate","getHours","getMinutes","calculateDuration","registerCalendarEvents","calendar","M","_M","form","_M$form","dateselector","_M$form$dateselector","on","reloadTitleContent","titlePromise","Str","get_string","then","string","setTitle","catch","Notification","exception","always","reloadBodyContent","formData","bodyPromise","args","eventid","starttime","courseid","categoryid","formdata","Fragment","loadFragment","setBody","reloadAllContent","$","when","show","hide","getFormData","serialize","save","loadingContainer","invalid","length","first","focus","Promise","resolve","removeClass","Repository","submitCreateUpdateForm","response","validationerror","isExisting","trigger","CalendarEvents","updated","event","created","addClass","registerEventListeners","getModal","CustomEvents","events","activate","e","data","submit","originalEvent","preventDefault","stopPropagation","FormEvents","notifyFormSubmittedByJavascript","duration","parseInt","target","isNaN","registerModalType"],"mappings":"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"} \ No newline at end of file diff --git a/public/calendar/amd/src/modal_event_form.js b/public/calendar/amd/src/modal_event_form.js index dc4b0599424..1ddc9837bed 100644 --- a/public/calendar/amd/src/modal_event_form.js +++ b/public/calendar/amd/src/modal_event_form.js @@ -34,6 +34,11 @@ import * as Repository from 'core_calendar/repository'; const SELECTORS = { SAVE_BUTTON: '[data-action="save"]', LOADING_ICON_CONTAINER: '[data-region="loading-icon-container"]', + START_DATE_FIELDS: 'select[name^="timestart"]', + END_DATE_FIELDS: 'select[name^="timedurationuntil"]', + START_DATE_DATE_PICKER: '#id_timestart_calendar', + END_DATE_DATE_PICKER: '#id_timedurationuntil_calendar', + DURATION_FIELD: '#id_timedurationminutes', }; export default class ModalEventForm extends Modal { @@ -56,6 +61,8 @@ export default class ModalEventForm extends Modal { this.reloadingBody = false; this.reloadingTitle = false; this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON); + this.eventTimeDuration = 0; + this.lastUsedDatePickerId = null; } configure(modalConfig) { @@ -231,6 +238,79 @@ export default class ModalEventForm extends Modal { this.saveButton.prop('disabled', false); } + /** + * Create a date object from the fields in the form. + * + * @method getDateFromFields + * @param {string} selector The selector that is used to build the id of the start/end date fields. + * @return {Date} The date object created from the fields + */ + getDateFromFields(selector) { + const identifier = selector.match(/"([^"]+)"/)[1]; + const d = new Date(); + d.setFullYear(document.getElementById(`id_${identifier}_year`).value); + d.setMonth(document.getElementById(`id_${identifier}_month`).value - 1); + d.setDate(document.getElementById(`id_${identifier}_day`).value); + d.setHours(document.getElementById(`id_${identifier}_hour`).value); + d.setMinutes(document.getElementById(`id_${identifier}_minute`).value); + return d; + } + + /** + * Update the end time value based on the duration of the event. + * + * If the event ends in 1 hour and 15 minutes, then updating the + * start time will respect this duration and push the end time forward + * and retain the 1 hour and 15 minute duration. This behaviour matches + * those of Google and iOS calendars in 2026. + * + * @method updateEndTime + */ + updateEndTime() { + const startDate = this.getDateFromFields(SELECTORS.START_DATE_FIELDS); + const endDate = new Date(startDate.getTime() + this.eventTimeDuration); + const identifier = SELECTORS.END_DATE_FIELDS.match(/"([^"]+)"/)[1]; + document.getElementById(`id_${identifier}_year`).value = endDate.getFullYear(); + document.getElementById(`id_${identifier}_month`).value = endDate.getMonth() + 1; + document.getElementById(`id_${identifier}_day`).value = endDate.getDate(); + document.getElementById(`id_${identifier}_hour`).value = endDate.getHours(); + document.getElementById(`id_${identifier}_minute`).value = endDate.getMinutes(); + } + + /** + * Calculate the duration of the event by comparing the start and end dates. + * + * @method calculateDuration + * @return {Number} Millisecond difference. + */ + calculateDuration() { + const startDate = this.getDateFromFields(SELECTORS.START_DATE_FIELDS); + const endDate = this.getDateFromFields(SELECTORS.END_DATE_FIELDS); + return (endDate - startDate); + } + + /** + * Register the YUI calendar events. + * + * These events need to be registered after the elements have been rendered. + * + * @method registerCalendarEvents + */ + registerCalendarEvents() { + const calendar = M?.form?.dateselector?.calendar; + if (calendar) { + calendar.on('dateClick', () => { + // The YUI date pickers share the same elements. + // Determine which ones was last used and perform the required method. + if (this.lastUsedDatePickerId === SELECTORS.START_DATE_DATE_PICKER) { + this.updateEndTime(); + } else if (this.lastUsedDatePickerId === SELECTORS.END_DATE_DATE_PICKER) { + this.eventTimeDuration = this.calculateDuration(); + } + }, this); + } + } + /** * Reload the title for the modal to the appropriate value * depending on whether we are creating a new event or @@ -314,6 +394,9 @@ export default class ModalEventForm extends Modal { this.bodyPromise.then(() => { this.enableButtons(); + this.updateEndTime(); + this.eventTimeDuration = this.calculateDuration(); + this.registerCalendarEvents(); return; }) .catch(Notification.exception) @@ -478,6 +561,31 @@ export default class ModalEventForm extends Modal { e.preventDefault(); e.stopPropagation(); }); + + this.getModal().on('change', SELECTORS.START_DATE_FIELDS, () => { + this.updateEndTime(); + }); + + this.getModal().on('change', SELECTORS.END_DATE_FIELDS, () => { + this.eventTimeDuration = this.calculateDuration(); + }); + + this.getModal().on('click', SELECTORS.START_DATE_DATE_PICKER, () => { + this.lastUsedDatePickerId = SELECTORS.START_DATE_DATE_PICKER; + }); + + this.getModal().on('click', SELECTORS.END_DATE_DATE_PICKER, () => { + this.lastUsedDatePickerId = SELECTORS.END_DATE_DATE_PICKER; + }); + + this.getModal().on('focusout', SELECTORS.DURATION_FIELD, (e) => { + const duration = parseInt(e.target.value); + if (isNaN(duration)) { + return; + } + this.eventTimeDuration = duration * 60 * 1000; + this.updateEndTime(); + }); } } diff --git a/public/calendar/classes/local/event/forms/create.php b/public/calendar/classes/local/event/forms/create.php index 2297ae87450..3f94ad0ada9 100644 --- a/public/calendar/classes/local/event/forms/create.php +++ b/public/calendar/classes/local/event/forms/create.php @@ -87,11 +87,14 @@ class create extends \moodleform { $mform->addRule('name', get_string('required'), 'required', null, 'client'); $mform->setType('name', PARAM_TEXT); + // Add the select elements for the available event types. + $this->add_event_type_elements($mform, $eventtypes); + // Event time start field. $mform->addElement('date_time_selector', 'timestart', get_string('date'), ['defaulttime' => $starttime]); - // Add the select elements for the available event types. - $this->add_event_type_elements($mform, $eventtypes); + // Add the variety of elements allowed for selecting event duration. + $this->add_event_duration_elements($mform); // Start of advanced elements. // Advanced elements are not visible to the user by default. @@ -104,9 +107,6 @@ class create extends \moodleform { $mform->setType('location', PARAM_RAW_TRIMMED); $mform->setAdvanced('location'); - // 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); @@ -217,7 +217,6 @@ class create extends \moodleform { get_string('durationminutes', 'calendar'), 'maxlength="7" size="7"'); $mform->addGroup($group, 'durationgroup', get_string('eventduration', 'calendar'), '
', false); - $mform->setAdvanced('durationgroup'); $mform->disabledIf('timedurationuntil', 'duration', 'noteq', 1); $mform->disabledIf('timedurationuntil[day]', 'duration', 'noteq', 1); diff --git a/public/calendar/tests/behat/calendar.feature b/public/calendar/tests/behat/calendar.feature index 98d0ed35ede..564ebe24abf 100644 --- a/public/calendar/tests/behat/calendar.feature +++ b/public/calendar/tests/behat/calendar.feature @@ -468,3 +468,17 @@ Feature: Perform basic calendar functionality And I press "Save" And I click on "type change test event" "link" And I should see "Category event" + + @javascript + Scenario: Move a date in the event form should update the event end date + Given I log in as "admin" + And the following "events" exist: + | name | eventtype | timestart | timeduration | + | Change date test event | user | ##today midnight +1 seconds## | 86400 | + When I am on "Course 1" course homepage + And I follow "Course calendar" + And I click on "Change date test event" "link" + And I click on "Edit" "button" in the "Change date test event" "dialogue" + And I set the following fields to these values: + | Date | ##today midnight +2 days## | + Then the field "Until" matches value "##today midnight +3 days##"