poulpe/event/static/js/calendar.js
Daru13 3c848fe47c Improve event detail popup positionning (no above-positionning yet).
The popup is horizontally aligned with the click location, and 
vertically close to event (slightly on top of it, from below).

If the click occurs too much on the left or the right side of the 
screen, the horizontal position is shifted accordingly, so that it is 
never displayed outside of the screen. Note, however, that the small 
arrow supposed to point the related event is not shifted as well as of 
now (not as straightforward since it is a CSS pseudo-element).

This commit also removes the ability to display the popup _above_ the 
event in case the click occurs too close to the bottom of the screen. 
This feature should be reintroduced by a later commit once it will have 
been fixed :).
2018-11-24 03:37:40 +01:00

984 lines
25 KiB
JavaScript

// Based on https://stackoverflow.com/a/15289883
function computeDateDifferenceInHours (date1, date2) {
d1 = new Date(date1.getYear(), date1.getMonth(), date1.getDate(), date1.getHours());
d2 = new Date(date2.getYear(), date2.getMonth(), date2.getDate(), date2.getHours());
const msPerHour = 60 * 60 * 1000;
return Math.abs(d2.getTime() - d1.getTime()) / msPerHour;
}
class Calendar {
constructor (calendarParameters = {}) {
this.containerNode = calendarParameters.containerNode !== undefined
? calendarParameters.containerNode
: $("#cal-container");
this.eventContainerNode = null;
this.timeSlotsContainerNode = null;
this.eventDetailsContainerNode = null;
this.startDate = calendarParameters.startDate !== undefined
? calendarParameters.startDate
: new Date();
this.endDate = calendarParameters.endDate !== undefined
? calendarParameters.endDate
: new Date(Date.now() + (24 * 60 * 60 * 1000));
this.nbHoursToDisplay = 0;
this.firstHourToDisplay = 0;
this.endHourToDisplay = 0;
this.events = [];
this.subscriptionURLFormat = calendarParameters.subscriptionURLFormat !== undefined
? calendarParameters.subscriptionURLFormat
: "";
this.csrfToken = calendarParameters.csrfToken !== undefined
? calendarParameters.csrfToken
: "";
// Map from locations to their CSS styles
this.locationStyles = new Map();
this.init();
}
init () {
this.updateHoursToDisplay();
this.createTimeSlotContainer();
this.createEventContainer();
this.createEventDetailsContainer();
this.updateTimeSlotContainerGridStyle();
this.updateEventContainerGridStyle();
this.createTimeSlots();
this.createEvents();
this.createLocationStyles();
this.applyLocationStylesAsCSS();
this.updateEventLocationStyleID();
this.sortEventNodesByEndTimeAndLocation();
this.updateCalendarNodeHeight();
}
// Date change
setStartDate (newStartDate) {
this.startDate = newStartDate;
this.updateHoursToDisplay();
this.updateEventContainerGridStyle();
this.updateTimeSlots();
this.updateEventVisibilities();
this.updateCalendarNodeHeight();
}
setEndDate (newEndDate) {
this.endDate = newEndDate;
this.updateHoursToDisplay();
this.updateEventContainerGridStyle();
this.updateTimeSlots();
this.updateEventVisibilities();
this.updateCalendarNodeHeight();
}
updateHoursToDisplay () {
this.startHourToDisplay = this.startDate.getHours();
this.endHourToDisplay = this.endDate.getHours();
this.nbHoursToDisplay = Math.floor(computeDateDifferenceInHours(this.startDate, this.endDate));
}
// Calendar container
updateCalendarNodeHeight () {
// Time slot hour row
let timeSlotHourRowHeight = $(".cal-time-slot-hour").outerHeight();
// Event grid
let eventContainerHeight = $(".cal-event-container")
.css("grid-template-rows")
.split("px ")
.reduce((heightAccumulator, currentRowHeight) => {
return heightAccumulator + parseInt(currentRowHeight);
}, 0);
this.containerNode.css("height", timeSlotHourRowHeight + eventContainerHeight);
}
// Time slots
createTimeSlotContainer () {
this.timeSlotsContainerNode = $("<div>")
.addClass("cal-time-slot-container")
.appendTo(this.containerNode);
}
createTimeSlots () {
// Populate the container hour by hour
let self = this;
function getHourStringToDisplay (index, hour) {
if (index === self.nbHoursToDisplay - 1
|| hour === 23) {
return "";
}
if (hour >= 10) {
return hour + 1;
}
else {
return "&nbsp;" + (hour + 1);
}
}
for (let i = 0; i < this.nbHoursToDisplay; i++) {
let hour = (this.startHourToDisplay + i) % 24;
// Time slot hour
let timeSlotHourNode = $("<div>")
.addClass("cal-time-slot-hour")
.css({
"grid-column-start": `${i + 1}`,
"grid-column-end" : "span 1",
"grid-row-start" : "1",
"grid-row-end" : "1"
})
.html(getHourStringToDisplay(i, hour))
.prependTo(this.timeSlotsContainerNode);
// Time slot block
let timeSlotBlockNode = $("<div>")
.addClass("cal-time-slot")
.css({
"grid-column-start": `${i + 1}`,
"grid-column-end" : "span 1",
"grid-row-start" : "2",
"grid-row-end" : "2"
})
.appendTo(this.timeSlotsContainerNode);
if (hour === 23) {
timeSlotHourNode.addClass("cal-last-hour");
timeSlotBlockNode.addClass("cal-last-hour");
}
if (hour === 0) {
timeSlotHourNode.addClass("cal-first-hour");
timeSlotBlockNode.addClass("cal-first-hour");
}
}
}
updateTimeSlotContainerGridStyle () {
this.timeSlotsContainerNode.css("grid-template-columns",
`repeat(${this.nbHoursToDisplay}, ${100 / this.nbHoursToDisplay }%)`);
}
updateTimeSlots () {
this.timeSlotsContainerNode.empty();
this.createTimeSlots();
this.updateTimeSlotContainerGridStyle();
}
getHourSlotWidth () {
return this.timeSlotsContainerNode.width() / this.nbHoursToDisplay;
}
// Events
createEventContainer () {
this.eventContainerNode = $("<div>")
.addClass("cal-event-container")
.appendTo(this.containerNode);
}
createEvents () {
// Move all event nodes into the event container
let eventElements = this.containerNode.find(".cal-event");
eventElements.appendTo(this.eventContainerNode);
// Create event objects from them all
for (let element of eventElements) {
let newEvent = new Event($(element), this);
this.events.push(newEvent);
}
}
updateEventContainerGridStyle () {
this.eventContainerNode.css("grid-template-columns",
`repeat(${this.nbHoursToDisplay}, ${100 / this.nbHoursToDisplay }%)`);
}
updateEventVisibilities () {
for (let event of this.events) {
event.updateVisibility();
}
}
// Event details
createEventDetailsContainer () {
this.eventDetailsContainerNode = $("<div>")
.addClass("cal-details-container")
.appendTo(this.containerNode);
}
// Location styles
createLocationStyles () {
let locationIndices = new Map();
for (let event of this.events) {
if (! locationIndices.has(event.location)) {
locationIndices.set(event.location, [...locationIndices.keys()].length);
}
}
let nbUniqueLocations = [...locationIndices.keys()].length;
let styleID = 0;
for (let [location, index] of locationIndices.entries()) {
let hue = (index / (nbUniqueLocations + 1)) * 255;
styleID += 1;
this.locationStyles.set(location, {
id: styleID,
normal: [
`background-color: hsl(${hue}, 60%, 80%);`,
`border-color: hsl(${hue}, 50%, 50%);`,
`color: #000;`
],
hover: [
`background-color: hsl(${hue}, 65%, 85%);`,
`border-color: hsl(${hue}, 52%, 55%);`,
`color: #000;`
],
subscribed: [
`background-color: hsl(${hue}, 75%, 75%);`,
`border-color: hsl(${hue}, 60%, 50%);`,
`color: #000;`
],
selected: [
`background-color: hsl(${hue}, 45%, 50%);`,
`border-color: hsl(${hue}, 40%, 35%);`,
`color: #FFF;`
]
});
}
}
applyLocationStylesAsCSS () {
let styleNode = $("<style>");
let styleNodeContent = "";
for (let styles of this.locationStyles.values()) {
let normalRules = styles.normal.join("\n");
styleNodeContent += `.cal-location-${styles.id} {${normalRules}}\n`;
let hoverRules = styles.hover.join("\n");
styleNodeContent += `.cal-location-${styles.id}:hover {${hoverRules}}\n`;
let subscribedRules = styles.subscribed.join("\n");
styleNodeContent += `.cal-location-${styles.id}.cal-event-subscribed {${subscribedRules}}\n`;
let selectedRules = styles.selected.join("\n");
styleNodeContent += `.cal-location-${styles.id}.cal-selected {${selectedRules}}\n`;
}
styleNode
.html(styleNodeContent)
.appendTo($("head"));
}
updateEventLocationStyleID () {
for (let event of this.events) {
let style = this.locationStyles.get(event.location);
event.setLocationStyleID(style.id);
}
}
// Event node sorting
sortEventNodesByEndTimeAndLocation () {
this.events.sort((event1, event2) => {
return event2.endDate.getTime() - event1.endDate.getTime();
});
this.events.sort((event1, event2) => {
return event2.location.localeCompare(event1.location);
});
this.eventContainerNode.detach();
for (let event of this.events) {
event.node.prependTo(this.eventContainerNode);
}
this.eventContainerNode.appendTo(this.containerNode);
}
}
class Event {
constructor (eventNode, calendar) {
this.node = eventNode;
this.calendar = calendar;
this.details = null;
this.detailsNodeRemovalTimer = null;
this.id = null;
this.name = null;
this.startDate = null;
this.endDate = null;
this.location = null;
this.description = null;
this.hasPerms = false;
this.nbPerms = null;
this.minNbPerms = null;
this.maxNbPerms = null;
this.subscribedByUser = false;
this.tags = [];
this.displayed = false;
this.selected = false;
this.locationStyleID = 0;
// TODO: move this elsewhere
// Callback to display the details popup on click on the event node
this.showDetailPopupOnClickCallback = (event) => {
if ($(event.target).closest(".cal-event-details").length === 0) {
this.details.showPopup(event);
}
};
this.init();
}
init () {
this.parseAndSetID();
this.parseAndSetName();
this.parseAndSetDates();
this.parseAndSetLocation();
this.parseAndSetDescription();
this.parseAndSetPermRelatedFields();
this.parseAndSetTags();
this.addPermCounter();
this.createDetails();
this.updateVisibility();
}
// Event data parsers + setters
parseAndSetID () {
this.id = this.node.find(".cal-event-id").text();
}
parseAndSetName () {
this.name = this.node.find(".cal-event-name").text();
}
parseDate (dateString) {
let regex = /(\d+)\/(\d+)\/(\d+) (\d+)\:(\d+)/g;
let [day, month, year, hours, minutes] = regex
.exec(dateString)
.slice(1)
.map((intString) => {
return parseInt(intString);
});
return new Date(year,
month - 1,
day,
hours,
minutes);
}
parseAndSetStartDate () {
let startDateString = this.node.find(".cal-event-start-date").text();
let startDate = this.parseDate(startDateString);
this.startDate = startDate;
}
parseAndSetEndDate () {
let endDateString = this.node.find(".cal-event-end-date").text();
let endDate = this.parseDate(endDateString);
this.endDate = endDate;
}
parseAndSetDates () {
this.parseAndSetStartDate();
this.parseAndSetEndDate();
}
parseAndSetLocation () {
this.location = this.node.find(".cal-event-location").text();
}
parseAndSetDescription () {
this.description = this.node.find(".cal-event-description").text();
}
parseAndSetPermRelatedFields () {
let hasPerms = !! parseInt(this.node.find(".cal-event-has-perms").text());
if (hasPerms) {
this.hasPerms = true;
this.nbPerms = parseInt(this.node.find(".cal-event-nb-perms").text());
this.minNbPerms = parseInt(this.node.find(".cal-event-min-nb-perms").text());
this.maxNbPerms = parseInt(this.node.find(".cal-event-max-nb-perms").text());
this.subscribedByUser = !! parseInt(this.node.find(".cal-event-subscribed").text());
}
}
parseAndSetTags () {
this.node.find(".cal-event-tag")
.each((_, element) => {
this.tags.push(element.innerText);
});
}
// Event details
createDetails () {
this.details = new EventDetails(this);
}
startDisplayingDetailsPopupOnClick () {
this.node.on("click", this.showDetailPopupOnClickCallback);
}
stopDisplayingDetailsPopupOnClick () {
this.node.off("click", this.showDetailPopupOnClickCallback);
}
// Event node content
addPermCounter () {
if (! this.hasPerms) {
return;
}
let permCounterNode = $("<div>")
.addClass("cal-event-perm-count")
.appendTo(this.node);
this.updatePermCounter();
}
updatePermCounter () {
let permCounterNode = this.node.find(".cal-event-perm-count")
.html(`<span>&#x1f464; ${this.nbPerms}/${this.maxNbPerms}</span>`);
if (this.minNbPerms > this.nbPerms) {
permCounterNode.addClass("cal-perms-missing");
}
else {
permCounterNode.removeClass("cal-perms-missing");
}
}
// Grid position
// Assuming the events can appear in the calendar
getGridStartColumn () {
if (this.startDate.getTime() < this.calendar.startDate.getTime()) {
return 1;
}
return 1 + Math.floor(computeDateDifferenceInHours(this.calendar.startDate, this.startDate));
}
// Assuming the events can appear in the calendar
getGridEndColumn () {
if (this.endDate.getTime() > this.calendar.endDate.getTime()) {
return 1 + this.calendar.nbHoursToDisplay;
}
let shiftedEndDate = new Date(this.endDate.getTime() + 1000 * 60 * 59);
return 1 + this.calendar.nbHoursToDisplay
- Math.ceil(computeDateDifferenceInHours(shiftedEndDate, this.calendar.endDate));
}
updatePositionInGrid () {
// Align on the grid according to the hours
this.node.css({
"grid-column-start": `${this.getGridStartColumn()}`,
"grid-column-end" : `${this.getGridEndColumn()}`
});
// Add left and right padding according to the minutes
let columnWidth = this.calendar.getHourSlotWidth();
let startMinutes = this.startDate.getMinutes();
let endMinutes = this.endDate.getMinutes();
let marginLeft = 0;
if (this.startDate.getTime() >= this.calendar.startDate.getTime()
&& startMinutes !== 0) {
marginLeft = columnWidth * (startMinutes / 60);
}
let marginRight = 0;
if (this.endDate.getTime() <= this.calendar.endDate.getTime()
&& endMinutes !== 0) {
marginRight = columnWidth * ((60 - endMinutes) / 60);
}
this.node.css({
"margin-left" : marginLeft,
"margin-right": marginRight
});
}
// Event style
updateNodeStyle () {
if (this.subscribedByUser) {
this.node.addClass("cal-event-subscribed");
}
else {
this.node.removeClass("cal-event-subscribed");
}
}
// Location style
setLocationStyleID (styleID) {
this.locationStyleID = styleID;
this.node.addClass(`cal-location-${styleID}`);
}
// Selection
select () {
this.selected = true;
this.node.addClass("cal-selected");
}
deselect () {
this.selected = false;
this.node.removeClass("cal-selected");
}
// Visibility
updateVisibility () {
// Hide events which cannot apear in the calendar time span
if (this.calendar.startDate.getTime() >= this.endDate.getTime()
|| this.calendar.endDate.getTime() <= this.startDate.getTime()) {
this.stopDisplayingDetailsPopupOnClick();
this.node.hide();
this.displayed = false;
}
else {
this.node.show();
this.updateNodeStyle();
this.updatePositionInGrid();
this.startDisplayingDetailsPopupOnClick();
this.displayed = true;
}
}
}
class EventDetails {
constructor (event) {
this.event = event;
this.node = null;
// TODO: move this elsewhere
// Callback to close the details popup on click outside the popup
this.closePopupOnClickOutsideCallback = (event) => {
if ($(event.target).closest(".cal-event-details").length === 0) {
this.hidePopup();
}
};
this.init();
}
init () {
// Create the container node
this.node = $("<div>")
.addClass("cal-event-details")
.appendTo(this.event.calendar.eventDetailsContainerNode)
.hide();
this.createAndAppendTitle();
this.createAndAppendCloseButton();
// Table of (label, value) details
let tableNode = $("<table>")
.appendTo(this.node);
function addRowToDetailTable (label, value) {
let rowNode = $("<tr>")
.addClass("cal-detail-list")
.appendTo(tableNode);
$("<td>")
.addClass("cal-detail-label")
.html(label)
.appendTo(rowNode);
$("<td>")
.addClass("cal-detail-value")
.html(value)
.appendTo(rowNode);
}
this.createAndAppendLocation(addRowToDetailTable);
this.createAndAppendStartDate(addRowToDetailTable);
this.createAndAppendEndDate(addRowToDetailTable);
this.createAndAppendDuration(addRowToDetailTable);
this.createAndAppendPermManagementArea();
this.createAndAppendDescription();
this.createAndAppendTags();
}
createAndAppendCloseButton () {
$("<button>")
.attr("type", "button")
.addClass("cal-detail-close-button")
.html("&#x2715;") // cancelation cross
.appendTo(this.node)
.on("click", (event) => {
this.hidePopup();
});
}
createAndAppendTitle () {
let linkToEventPageNode = $("<a>")
.attr("href", "TODO") // TODO
.addClass("cal-detail-name")
.appendTo(this.node);
$("<h3>")
.html(this.event.name)
.appendTo(linkToEventPageNode);
}
createAndAppendLocation (addRowToDetailTable) {
addRowToDetailTable("Lieu", this.event.location);
}
createAndAppendStartDate (addRowToDetailTable) {
let startDateString = this.event.startDate
.toLocaleDateString("fr-FR", {year: "2-digit", month: "2-digit", day: "2-digit"});
let startTimeString = this.event.startDate
.toLocaleDateString("fr-FR", {hour: "2-digit", minute: "2-digit"})
.slice(-5);
addRowToDetailTable("Début", `Le ${startDateString} à ${startTimeString}`);
}
createAndAppendEndDate (addRowToDetailTable) {
let endDateString = this.event.endDate
.toLocaleDateString("fr-FR", {year: "2-digit", month: "2-digit", day: "2-digit"});
let endTimeString = this.event.endDate
.toLocaleDateString("fr-FR", {hour: "2-digit", minute: "2-digit"})
.slice(-5);
addRowToDetailTable("Fin", `Le ${endDateString} à ${endTimeString}`);
}
createAndAppendDuration (addRowToDetailTable) {
const msPerMinute = 60 * 1000;
const msPerHour = 60 * msPerMinute;
let durationInMs = Math.abs(this.event.startDate.getTime() - this.event.endDate.getTime());
let hours = Math.floor(durationInMs / msPerHour);
let minutes = Math.abs((durationInMs % msPerHour) / msPerMinute);
addRowToDetailTable("Durée", hours !== 0
? `${hours}h ${minutes}min`
: `${minutes}min`);
}
createAndAppendPermManagementArea () {
if (! this.event.hasPerms) {
return;
}
let permAreaNode = $("<div>")
.addClass("cal-detail-perm-area")
.appendTo(this.node);
let permTitleNode = this.createPermManagementTitle()
.appendTo(permAreaNode);
let permCountNode = this.createPermManagementCounter()
.appendTo(permAreaNode);
let permSubscribeButtonNode = this.createPermManagementSwitchButton()
.appendTo(permAreaNode);
this.updatePermManagementArea();
}
updatePermManagementArea () {
let permAreaNode = this.node.find(".cal-detail-perm-area");
this.updatePermManagementCounter();
this.updatePermManagementSwitchButton();
// Additional warning message if there are too little perms,
// and if the user has not subscribed to this event
permAreaNode
.find(".cal-detail-perm-nb-missing-perms")
.remove();
if (! this.event.subscribedByUser
&& this.event.minNbPerms > this.event.nbPerms) {
let nbMissingPerms = this.event.minNbPerms - this.event.nbPerms;
let optionnalPluralMark = nbMissingPerms > 1 ? "s" : "";
$("<p>")
.addClass("cal-detail-perm-nb-missing-perms")
.html(`${nbMissingPerms} personne${optionnalPluralMark}</br>min. manquante${optionnalPluralMark} !`)
.appendTo(permAreaNode);
}
}
createPermManagementTitle () {
return $("<h4>")
.addClass("cal-detail-perm-title")
.html("Permanences");
}
createPermManagementCounter () {
return $("<span>")
.addClass("cal-detail-perm-count")
.html(`&#x1f464; ${this.event.nbPerms}/${this.event.maxNbPerms}`);
}
updatePermManagementCounter () {
let permCounterNode = this.node
.find(".cal-detail-perm-count")
.html(`&#x1f464; ${this.event.nbPerms}/${this.event.maxNbPerms}`);
if (this.event.minNbPerms > this.event.nbPerms) {
permCounterNode.addClass("cal-perms-missing");
}
else if (this.event.nbPerms === this.event.maxNbPerms) {
permCounterNode.addClass("cal-perms-full");
}
}
createPermManagementSwitchButton () {
let buttonNode = $("<button>")
.addClass("cal-detail-perm-subscription-switch");
// On click, switch the subscription state
// and update objects related to the perm count
let event = this.event;
buttonNode.on("click", () => {
let goal = event.subscribedByUser ? "unenrol" : "enrol";
let url = event.calendar.subscriptionURLFormat
.replace("999999", event.id);
$.ajax(url, {
method: "POST",
dataType: "json",
headers: {
"X-CSRFToken": event.calendar.csrfToken
},
data: {
"goal": goal
},
success: (jsonAnswer) => {
event.subscribedByUser = jsonAnswer.enrolled;
event.nbPerms = jsonAnswer.number;
this.updatePermManagementArea();
event.updatePermCounter();
event.updateNodeStyle();
},
error: () => {
alert("Erreur lors de l'inscription ou de la désinscription à cette permanence.");
}
});
});
return buttonNode;
}
updatePermManagementSwitchButton () {
let event = this.event;
let buttonNode = this.node.find(".cal-detail-perm-subscription-switch");
// Reset the button state
buttonNode.prop("disabled", false);
// Update its content and state
if (event.subscribedByUser) {
buttonNode.html("Se désinscrire");
}
else {
buttonNode.html("S'inscrire");
// Disable the button if the maximum number of perms is already reached
if (event.nbPerms === event.maxNbPerms) {
buttonNode.prop("disabled", true);
}
}
}
createAndAppendDescription () {
$("<p>")
.addClass("cal-detail-description")
.html(this.event.description)
.appendTo(this.node);
}
createAndAppendTags () {
let tagContainerNode = $("<div>")
.addClass("cal-detail-tag-list")
.appendTo(this.node);
for (let tag of this.event.tags) {
$("<span>")
.addClass("cal-detail-tag")
.html(tag)
.appendTo(tagContainerNode);
}
}
setPopupPosition (mouseClickEvent) {
let calendarOffset = this.event.calendar.containerNode.offset();
let eventOffset = this.event.node.position();
let eventNodeHeight = this.event.node.outerHeight();
let detailNodeOffset = this.node.position();
let detailsNodeWidth = this.node.outerWidth();
let detailsNodeHeight = this.node.outerHeight();
let x = mouseClickEvent.pageX - (detailsNodeWidth / 2)
- calendarOffset.left;
let y = eventOffset.top + eventNodeHeight + 50;
// If the popup is too high to vertically fit in the window,
// it is displayed above the event instead
// let bottomMargin = 50; // px
// let detailsNodeBottom = y
// + calendarOffset.top
// + detailsNodeHeight;
// if (detailsNodeBottom > $(window).height() - bottomMargin) {
// y = eventOffset.top - detailsNodeHeight - 10;
// this.node.addClass("above-event")
// }
// If the popup is about to be displayed outside of
// the left/right side of the screen, correct its future x position
let absoluteX = x + calendarOffset.left;
let sideMargin = 20; // px
if (absoluteX < sideMargin) {
x += sideMargin - absoluteX;
}
else if (absoluteX + detailsNodeWidth > $(window).width() - sideMargin) {
x -= (absoluteX + detailsNodeWidth) - ($(window).width() - sideMargin);
}
this.node.css({
"left": x,
"top": y
});
}
showPopup (mouseClickEvent) {
if (this.node.is(":visible")) {
return;
}
this.event.select();
this.node.show();
this.setPopupPosition(mouseClickEvent);
// TODO: use a clean solution ;)
window.setTimeout(() => {
this.startHidingOnOutsideClick();
}, 20);
}
hidePopup () {
this.event.deselect();
this.stopHidingOnOutsideClick();
this.node.hide();
}
// TODO: define the callback elsewhere
startHidingOnOutsideClick () {
$(document).on("click", this.closePopupOnClickOutsideCallback);
}
stopHidingOnOutsideClick () {
$(document).off("click", this.closePopupOnClickOutsideCallback);
}
}