From 64d6fe6404ee0db5cb56b39a3a4495fc177609ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20H=C3=A4ttasch?= <benjamin.haettasch@fachschaft.informatik.tu-darmstadt.de> Date: Sun, 23 Oct 2022 21:40:27 +0200 Subject: [PATCH] Port availabilities editor to fullcalendar v5 and adjust usage This used the common fullcalendar v5 instance instead of a special v3 version only for this editor Improve visualization of availability deletion Improve (event specific) timezone handling and remove dependency to moment-timezones --- AKModel/availability/models.py | 2 +- AKModel/availability/serializers.py | 11 + .../admin/AKModel/room_change_form.html | 20 +- .../templates/AKSubmission/submit_new.html | 19 +- .../AKSubmission/submit_new_wish.html | 13 +- static_common/common/css/custom.css | 9 + static_common/common/js/availabilities.js | 215 +++++++++--------- 7 files changed, 158 insertions(+), 131 deletions(-) diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py index 20a7a36a..4f90ddc2 100644 --- a/AKModel/availability/models.py +++ b/AKModel/availability/models.py @@ -21,7 +21,7 @@ zero_time = datetime.time(0, 0) # remove serialization as requirements are not covered # add translation # add meta class -# enable availabilites for AKs and AKCategories +# enable availabilities for AKs and AKCategories # add verbose names and help texts to model attributes class Availability(models.Model): """The Availability class models when people or rooms are available for. diff --git a/AKModel/availability/serializers.py b/AKModel/availability/serializers.py index 57657257..92b3adc6 100644 --- a/AKModel/availability/serializers.py +++ b/AKModel/availability/serializers.py @@ -2,6 +2,7 @@ # Copyright 2017-2019, Tobias Kunze # Original Copyrights licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 # Changes are marked in the code +from django.utils import timezone from rest_framework.fields import SerializerMethodField from rest_framework.serializers import ModelSerializer @@ -10,10 +11,20 @@ from AKModel.availability.models import Availability class AvailabilitySerializer(ModelSerializer): allDay = SerializerMethodField() + start = SerializerMethodField() + end = SerializerMethodField() def get_allDay(self, obj): return obj.all_day + # Use already localized strings in serialized field + # (default would be UTC, but that would require heavy timezone calculation on client side) + def get_start(self, obj): + return timezone.localtime(obj.start, obj.event.timezone).strftime("%Y-%m-%dT%H:%M:%S") + + def get_end(self, obj): + return timezone.localtime(obj.end, obj.event.timezone).strftime("%Y-%m-%dT%H:%M:%S") + class Meta: model = Availability fields = ('id', 'start', 'end', 'allDay') diff --git a/AKModel/templates/admin/AKModel/room_change_form.html b/AKModel/templates/admin/AKModel/room_change_form.html index 4f305c4e..f2db1fbc 100644 --- a/AKModel/templates/admin/AKModel/room_change_form.html +++ b/AKModel/templates/admin/AKModel/room_change_form.html @@ -2,15 +2,23 @@ {% load i18n admin_urls %} {% load static %} {% load bootstrap4 %} +{% load tz %} {% block extrahead %} {{ block.super }} {% bootstrap_javascript jquery='slim' %} - <link href='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.css' %}' rel='stylesheet'/> - <link href='{% static 'AKSubmission/css/availabilities.css' %}' rel='stylesheet'/> - - <script src="{% static "AKSubmission/vendor/moment/moment-with-locales.js" %}"></script> - <script src="{% static "AKSubmission/vendor/moment-timezone/moment-timezone-with-data-10-year-range.js" %}"></script> - <script src='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.js' %}'></script> + {% include "AKPlan/load_fullcalendar.html" %} <script src="{% static "common/js/availabilities.js" %}"></script> + <script> + {% get_current_language as LANGUAGE_CODE %} + + document.addEventListener('DOMContentLoaded', function () { + createAvailabilityEditors( + '{{ original.event.timezone }}', + '{{ LANGUAGE_CODE }}', + '{{ original.event.start | timezone:original.event.timezone | date:"Y-m-d H:i:s" }}', + '{{ original.event.end | timezone:original.event.timezone | date:"Y-m-d H:i:s" }}' + ); + }); + </script> {% endblock %} diff --git a/AKSubmission/templates/AKSubmission/submit_new.html b/AKSubmission/templates/AKSubmission/submit_new.html index 42626533..60e387f7 100644 --- a/AKSubmission/templates/AKSubmission/submit_new.html +++ b/AKSubmission/templates/AKSubmission/submit_new.html @@ -4,19 +4,30 @@ {% load bootstrap4 %} {% load fontawesome_5 %} {% load static %} +{% load tz %} {% block title %}{% trans "AKs" %}: {{ event.name }} - {% trans "New AK" %}{% endblock %} {% block imports %} <link rel="stylesheet" href="{% static 'common/vendor/chosen-js/chosen.css' %}"> <link rel="stylesheet" href="{% static 'common/css/bootstrap-chosen.css' %}"> - <link href='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.css' %}' rel='stylesheet'/> - <link href='{% static 'AKSubmission/css/availabilities.css' %}' rel='stylesheet'/> + {% include "AKPlan/load_fullcalendar.html" %} <script src="{% static "AKSubmission/vendor/moment/moment-with-locales.js" %}"></script> - <script src="{% static "AKSubmission/vendor/moment-timezone/moment-timezone-with-data-10-year-range.js" %}"></script> - <script src='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.js' %}'></script> <script src="{% static "common/js/availabilities.js" %}"></script> + + <script> + {% get_current_language as LANGUAGE_CODE %} + + document.addEventListener('DOMContentLoaded', function () { + createAvailabilityEditors( + '{{ event.timezone }}', + '{{ LANGUAGE_CODE }}', + '{{ event.start | timezone:event.timezone | date:"Y-m-d H:i:s" }}', + '{{ event.end | timezone:event.timezone | date:"Y-m-d H:i:s" }}' + ); + }); + </script> {% endblock %} {% block breadcrumbs %} diff --git a/AKSubmission/templates/AKSubmission/submit_new_wish.html b/AKSubmission/templates/AKSubmission/submit_new_wish.html index 15a917d8..34e4bd83 100644 --- a/AKSubmission/templates/AKSubmission/submit_new_wish.html +++ b/AKSubmission/templates/AKSubmission/submit_new_wish.html @@ -6,17 +6,6 @@ {% block title %}{% trans "AKs" %}: {{ event.name }} - {% trans "New AK Wish" %}{% endblock %} -{% block imports %} - <link rel="stylesheet" href="{% static 'common/vendor/chosen-js/chosen.css' %}"> - <link rel="stylesheet" href="{% static 'common/css/bootstrap-chosen.css' %}"> - <link href='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.css' %}' rel='stylesheet'/> - <link href='{% static 'AKSubmission/css/availabilities.css' %}' rel='stylesheet'/> - - <script src="{% static "AKSubmission/vendor/moment/moment-with-locales.js" %}"></script> - <script src="{% static "AKSubmission/vendor/moment-timezone/moment-timezone-with-data-10-year-range.js" %}"></script> - <script src='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.js' %}'></script> - <script src="{% static "common/js/availabilities.js" %}"></script> -{% endblock %} {% block breadcrumbs %} {% include "AKSubmission/submission_breadcrumbs.html" %} @@ -27,4 +16,4 @@ {% block headline %} <h2>{% trans 'New AK Wish' %}</h2> -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/static_common/common/css/custom.css b/static_common/common/css/custom.css index eb1e18ca..d5ea6675 100644 --- a/static_common/common/css/custom.css +++ b/static_common/common/css/custom.css @@ -30,3 +30,12 @@ word-break: normal; overflow-wrap: anywhere; } + +.deleteEvent { + background-color: #6a6a6a !important; +} + +.deleteEvent .fc-event-title { + font-size: 5vw; + text-align: center; +} diff --git a/static_common/common/js/availabilities.js b/static_common/common/js/availabilities.js index 253e0a66..49d3e2bf 100644 --- a/static_common/common/js/availabilities.js +++ b/static_common/common/js/availabilities.js @@ -1,126 +1,125 @@ -// This part of the code was adapted from pretalx (https://github.com/pretalx/pretalx) +// Availability editor using fullcalendar v5 + +// This code was initially based on the availability editor from pretalx (https://github.com/pretalx/pretalx) // Copyright 2017-2019, Tobias Kunze // Original Copyrights licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 -// Changes are marked in the code -document.addEventListener("DOMContentLoaded", function () { - "use strict" +// It was significantly changed to deal with the newer fullcalendar version, event specific timezones, +// to remove the dependency to moments timezone and improve the visualization of deletion +function createAvailabilityEditors(timezone, language, startDate, endDate) { $("input.availabilities-editor-data").each(function () { - var data_field = $(this) - var editor = $('<div class="availabilities-editor">') - editor.attr("data-name", data_field.attr("name")) - data_field.after(editor) + const eventColor = '#28B62C'; - function save_events() { - data = { - availabilities: editor.fullCalendar("clientEvents").map(function (e) { - if (e.allDay) { - return { - start: e.start.format("YYYY-MM-DD HH:mm:ss"), - end: e.end.format("YYYY-MM-DD HH:mm:ss"), - } - } else { - return { - start: e.start.toISOString(), - end: e.end.toISOString(), - } - } - }), - } - data_field.attr("value", JSON.stringify(data)) - } + let data_field = $(this); + let editor = $('<div class="availabilities-editor">'); + editor.attr("data-name", data_field.attr("name")); + data_field.after(editor); + data_field.hide(); - var editable = !Boolean(data_field.attr("disabled")) + let editable = !Boolean(data_field.attr("disabled")); + let data = JSON.parse(data_field.attr("value")); + let events = data.availabilities.map(function (e) { + start = moment(e.start); + end = moment(e.end); + allDay = start.format("HHmmss") === 0 && end.format("HHmmss") === 0; - var data = JSON.parse(data_field.attr("value")) - var events = data.availabilities.map(function (e) { - e.start = moment(e.start)//.tz(data.event.timezone) - e.end = moment(e.end)//.tz(data.event.timezone) + return { + id: e.id, + start: start.format(), + end: end.format(), + allDay: allDay, + title: "" + }; + }); - if (e.start.format("HHmmss") == 0 && e.end.format("HHmmss") == 0) { - e.allDay = true - } + let eventMarkedForDeletion = undefined; + let eventMarkedForDeletionEl = undefined; + let newEventsCounter = 0; - return e - }) - editor.fullCalendar({ + let plan = new FullCalendar.Calendar(editor[0], { + timeZone: timezone, + themeSystem: 'bootstrap', + locale: language, + schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source', + editable: editable, + selectable: editable, + headerToolbar: false, + initialView: 'timeGridWholeEvent', views: { - agendaVariableDays: { - type: "agenda", - duration: { - days: - moment(data.event.date_to).diff( - moment(data.event.date_from), - "days" - ) + 1, + timeGridWholeEvent: { + type: 'timeGrid', + visibleRange: { + start: startDate, + end: endDate, }, - }, - }, - defaultView: "agendaVariableDays", - defaultDate: data.event.date_from, - visibleRange: { - start: data.event.date_from, - end: data.event.date_to, + } }, - events: events, - nowIndicator: false, - navLinks: false, - header: false, - timeFormat: "H:mm", - slotLabelFormat: "H:mm", - scrollTime: "09:00:00", - selectable: editable, - selectHelper: true, - select: function (start, end) { - var wasInDeleteMode = false - editor.fullCalendar("clientEvents").forEach(function (e) { - if (e.className.indexOf("delete") >= 0) { - wasInDeleteMode = true - } - e.className = "" - editor.fullCalendar("updateEvent", e) + allDaySlot: true, + events: data.availabilities, + eventBackgroundColor: eventColor, + select: function (info) { + resetDeletionCandidate(); + plan.addEvent({ + title: "", + start: info.start, + end: info.end, + id: 'new' + newEventsCounter }) - - if (wasInDeleteMode) { - editor.fullCalendar("unselect") - return - } - - var eventData = { - start: start, - end: end, + newEventsCounter++; + save_events(); + }, + eventClick: function (info) { + if (eventMarkedForDeletion !== undefined && (eventMarkedForDeletion.id === info.event.id)) { + info.event.remove(); + eventMarkedForDeletion = undefined; + eventMarkedForDeletionEl = undefined; + save_events(); + } else { + resetDeletionCandidate(); + makeDeletionCandidate(info.el); + eventMarkedForDeletion = info.event; + eventMarkedForDeletionEl = info.el; } - editor.fullCalendar("renderEvent", eventData, true) - editor.fullCalendar("unselect") - save_events() }, - eventResize: save_events, - eventDrop: save_events, - editable: editable, selectOverlap: false, eventOverlap: false, - eventColor: "#00DD00", - eventClick: function (calEvent, jsEvent, view) { - if (!editable) { - return - } + eventChange: save_events, + }); + plan.render(); - if (calEvent.className.indexOf("delete") >= 0) { - editor.fullCalendar("removeEvents", function (searchEvent) { - return searchEvent._id === calEvent._id - }) - save_events() - } else { - editor.fullCalendar("clientEvents").forEach(function (e) { - if (e._id == calEvent._id) { - e.className = "delete" - } else { - e.className = "" - } - editor.fullCalendar("updateEvent", e) - }) - } - }, - }) - }) -}) + function makeDeletionCandidate(el) { + el.classList.add("deleteEvent"); + $(el).find(".fc-event-title").html("<i class='fas fa-trash'></i> <i class='fas fa-question'></i>"); + } + + function resetDeletionCandidate() { + if (eventMarkedForDeletionEl !== undefined) { + eventMarkedForDeletionEl.classList.remove("deleteEvent"); + $(eventMarkedForDeletionEl).find(".fc-event-title").html(""); + } + eventMarkedForDeletionEl = undefined; + eventMarkedForDeletion = undefined; + } + + function save_events() { + data = { + availabilities: plan.getEvents().map(function (e) { + let id = e.id; + if(e.id.startsWith("new")) + id = ""; + return { + id: id, + // Make sure these timestamps are correctly interpreted as localized ones + // by removing the UTC-signaler ("Z" at the end) + // A bit dirty, but still more elegant than creating a timestamp with the + // required format manually + start: e.start.toISOString().replace("Z", ""), + end: e.end.toISOString().replace("Z", ""), + allDay: e.allDay, + } + }), + } + data_field.attr("value", JSON.stringify(data)); + } + }); +} -- GitLab