$(function () { 'use strict'; let schedEditor = $(".edit-meeting-schedule"); /* Drag data stored via the drag event dataTransfer interface is only accessible on * dragstart and dragend events. Other drag events can see only the MIME types that have * data. Use a non-registered type to identify our session drags. Unregistered MIME * types are strongly discouraged by RFC6838, but we are not actually attempting to * exchange data with anything outside this script so that really does not apply. */ const dnd_mime_type = 'text/x.session-drag'; const meetingTimeZone = schedEditor.data('timezone'); const lockSeconds = Number(schedEditor.data('lock-seconds') || 0); function reportServerError(xhr, textStatus, error) { let errorText = error || textStatus; if (xhr && xhr.responseText) { errorText += '\n\n' + xhr.responseText; } alert("Error: " + errorText); } function ajaxCall(action, data) { const ajaxData = { action: action }; Object.assign(ajaxData, data); return jQuery.ajax({ url: window.location.href, method: "post", timeout: 5 * 1000, data: ajaxData, }); } /** * Time to treat as current time for computing whether to lock timeslots * @returns {*} Moment object equal to lockSeconds in the future */ function effectiveNow() { return moment().add(lockSeconds, 'seconds'); } let sessions = schedEditor.find(".session").not(".readonly"); let sessionConstraints = sessions.find('.constraints > span'); let timeslots = schedEditor.find(".timeslot"); let timeslotLabels = schedEditor.find(".time-label"); let swapDaysButtons = schedEditor.find('.swap-days'); let swapTimeslotButtons = schedEditor.find('.swap-timeslot-col'); let days = schedEditor.find(".day-flow .day"); let officialSchedule = schedEditor.hasClass('official-schedule'); let timeSlotTypeInputs = schedEditor.find('.timeslot-type-toggles input'); let sessionPurposeInputs = schedEditor.find('.session-purpose-toggles input'); let timeSlotGroupInputs = schedEditor.find("#timeslot-group-toggles-modal .modal-body .individual-timeslots input"); let sessionParentInputs = schedEditor.find(".session-parent-toggles input"); const classes_to_hide = '.hidden-timeslot-group,.hidden-timeslot-type'; // hack to work around lack of position sticky support in old browsers, see https://caniuse.com/#feat=css-sticky if (schedEditor.find(".scheduling-panel").css("position") !== "sticky") { schedEditor.find(".scheduling-panel").css("position", "fixed"); schedEditor.css("padding-bottom", "14em"); } /** * Parse a timestamp using meeting-local time zone if the timestamp does not specify one. */ function parseISOTimestamp(s) { return moment.tz(s, moment.ISO_8601, meetingTimeZone); } function startMoment(timeslot) { return parseISOTimestamp(timeslot.data('start')); } function endMoment(timeslot) { return parseISOTimestamp(timeslot.data('end')); } function findTimeslotsOverlapping(intervals) { let res = []; timeslots.each(function () { const timeslot = jQuery(this); let start = startMoment(timeslot); let end = endMoment(timeslot); for (let i = 0; i < intervals.length; ++i) { if (end >= intervals[i][0] && intervals[i][1] >= start) { res.push(timeslot); break; } } }); return res; } // selecting function selectSessionElement(element) { sessions.removeClass("other-session-selected"); if (element) { sessions.not(element).removeClass("selected"); jQuery(element).addClass("selected"); showConstraintHints(element); showTimeSlotTypeIndicators(element.dataset.type); let sessionInfoContainer = schedEditor.find(".scheduling-panel .session-info-container"); sessionInfoContainer.html(jQuery(element).find(".session-info").html()); sessionInfoContainer.find("[data-bs-original-title]").tooltip(); sessionInfoContainer.find(".time").text(jQuery(element).closest(".timeslot").data('scheduledatlabel')); sessionInfoContainer.find(".other-session").each(function () { let otherSessionElement = sessions.filter("#session" + this.dataset.othersessionid).first(); let scheduledAt = otherSessionElement.closest(".timeslot").data('scheduledatlabel'); let timeElement = jQuery(this).find(".time"); otherSessionElement.addClass("other-session-selected"); if (scheduledAt) { timeElement.text(timeElement.data('scheduled').replace('{time}', scheduledAt)); } else { timeElement.text(timeElement.data('notscheduled')); } }); } else { sessions.removeClass("selected"); showConstraintHints(); resetTimeSlotTypeIndicators(); schedEditor.find(".scheduling-panel .session-info-container").html(""); } } /** * Mark or unmark a session that conflicts with the selected session * * @param constraintElt The element corresponding to the specific constraint * @param wouldViolate True to mark or false to unmark */ function setSessionWouldViolate(constraintElt, wouldViolate) { constraintElt = jQuery(constraintElt); let constraintDiv = constraintElt.closest('div.session'); // find enclosing session div constraintDiv.toggleClass('would-violate-hint', wouldViolate); // mark the session container constraintElt.toggleClass('would-violate-hint', wouldViolate); // and the specific constraint } /** * Mark or unmark a timeslot that conflicts with the selected session * * If wholeInterval is true, marks the entire column in addition to the timeslot. * This currently works by setting the class for the timeslot and the time-label * in its column. Because this is called for every timeslot in the interval, the * overall effect is to highlight the entire column. * * @param timeslotElt Timeslot element to be marked/unmarked * @param wouldViolate True to mark or false to unmark * @param wholeInterval Should the entire time interval be flagged or just the timeslot? */ function setTimeslotWouldViolate(timeslotElt, wouldViolate, wholeInterval) { timeslotElt = jQuery(timeslotElt); timeslotElt.toggleClass('would-violate-hint', wouldViolate); if (wholeInterval) { let index = timeslotElt.index(); // position of this timeslot relative to its container let label = timeslotElt .closest('div.room-group') .find('div.time-header .time-label') .get(index); // get time-label corresponding to this timeslot jQuery(label).toggleClass('would-violate-hint', wouldViolate); } } /** * Remove all would-violate-hint classes on timeslots */ function resetTimeslotsWouldViolate() { timeslots.removeClass("would-violate-hint"); timeslotLabels.removeClass("would-violate-hint"); } /** * Remove all would-violate-hint classes on sessions and their formatted constraints */ function resetSessionsWouldViolate() { sessions.removeClass("would-violate-hint"); sessionConstraints.removeClass("would-violate-hint"); } function showConstraintHints(selectedSession) { let sessionId = selectedSession ? selectedSession.id.slice("session".length) : null; // hints on the sessions resetSessionsWouldViolate(); if (sessionId) { sessionConstraints.each(function () { let sessionIds = this.dataset.sessions; if (sessionIds && (sessionIds.split(",").indexOf(sessionId) !== -1)) { setSessionWouldViolate(this, true); } }); } // hints on timeslots resetTimeslotsWouldViolate(); if (selectedSession) { let intervals = []; timeslots.filter(":has(.session .constraints > span.would-violate-hint)").each(function () { intervals.push( [parseISOTimestamp(this.dataset.start), parseISOTimestamp(this.dataset.end)] ); }); let overlappingTimeslots = findTimeslotsOverlapping(intervals); for (let i = 0; i < overlappingTimeslots.length; ++i) { setTimeslotWouldViolate(overlappingTimeslots[i], true, true); } // check room sizes let attendees = +selectedSession.dataset.attendees; if (attendees) { timeslots.not(".would-violate-hint").each(function () { if (attendees > +jQuery(this).closest(".timeslots").data("roomcapacity")) { setTimeslotWouldViolate(this, true, false); } }); } } } /** * Remove timeslot classes indicating timeslot type disagreement */ function resetTimeSlotTypeIndicators() { timeslots.removeClass('wrong-timeslot-type'); } /** * Add timeslot classes indicating timeslot type disagreement * * @param timeslot_type */ function showTimeSlotTypeIndicators(timeslot_type) { timeslots.removeClass('wrong-timeslot-type'); timeslots.filter('[data-type!="' + timeslot_type + '"]').addClass('wrong-timeslot-type'); } /** * Should this timeslot be treated as a future timeslot? * * @param timeslot timeslot to test * @param now (optional) threshold time (defaults to effectiveNow()) * @returns Boolean true if the timeslot is in the future */ function isFutureTimeslot(timeslot, now) { // resist the temptation to use native JS Date parsing, it is hopelessly broken const timeslot_time = startMoment(timeslot); return timeslot_time.isAfter(now || effectiveNow()); } function hidePastTimeslotHints() { timeslots.removeClass('past-hint'); } function showPastTimeslotHints() { timeslots.filter('.past').addClass('past-hint'); } function updatePastTimeslots() { const now = effectiveNow(); // mark timeslots timeslots.filter(':not(.past)') .filter((_, ts) => !isFutureTimeslot(jQuery(ts), now)) .addClass('past'); // hide swap day/timeslot column buttons if (officialSchedule) { swapDaysButtons.filter( (_, elt) => parseISOTimestamp(elt.closest('*[data-start]').dataset.start).isSameOrBefore(now, 'day') ).hide(); swapTimeslotButtons.filter( (_, elt) => parseISOTimestamp(elt.closest('*[data-start]').dataset.start).isSameOrBefore(now, 'minute') ).hide(); } } function canEditSession(session) { if (!officialSchedule) { return true; } const timeslot = jQuery(session).closest('div.timeslot'); if (timeslot.length === 0) { return true; } return isFutureTimeslot(timeslot); } schedEditor.on("click", function (event) { if (!( jQuery(event.target).is('.session-info-container') || jQuery(event.target).closest('.session-info-container').length > 0 )) { selectSessionElement(null); } }); sessions.on("click", function (event) { event.stopPropagation(); // do not allow hidden sessions to be selected if (!jQuery(this).hasClass('hidden-parent')) { selectSessionElement(this); } }); // Was this drag started by dragging a session? function isSessionDragEvent(event) { return event.originalEvent.dataTransfer.types.some( (item_type) => item_type.indexOf(dnd_mime_type) === 0 ); } /** * Get the session element being dragged * * @param event drag-related event */ function getDraggedSession(event) { if (!isSessionDragEvent(event)) { return null; } const sessionId = event.originalEvent.dataTransfer.types[0].slice(dnd_mime_type.length); const sessionElements = sessions.filter("#" + sessionId); if (sessionElements.length > 0) { return sessionElements[0]; } return null; } /** * Can a session be dropped in this element? * * Drop is allowed in drop-zones that are in unassigned-session or timeslot containers * not marked as 'past'. */ function sessionDropAllowed(dropElement, sessionElement) { const relevant_parent = dropElement.closest('.timeslot, .unassigned-sessions'); if (!relevant_parent || !sessionElement) { return false; } if (officialSchedule && relevant_parent.classList.contains('past')) { return false; } return !relevant_parent.dataset.type || ( relevant_parent.dataset.type === sessionElement.dataset.type ); } if (!schedEditor.find(".edit-grid").hasClass("read-only")) { // dragging sessions.on("dragstart", function (event) { if (canEditSession(this)) { /* Bit of a hack here - per the w3c drag and drop spec, the data being dragged * and dropped are only available during dragstart and drop events. Otherwise, * only their count and type are guaranteed to be available. (See * https://www.w3.org/TR/2011/WD-html5-20110113/dnd.html#drag-data-store-mode) * To work around this, append the sessionId to the dnd_mime_type in the type we * report for our event. The event handlers can then pull it out when needed. * (At least Chrome v106 breaks if we try to peek at the payload.) */ event.originalEvent.dataTransfer.setData(dnd_mime_type + this.id, this.id); jQuery(this).addClass("dragging"); selectSessionElement(this); showPastTimeslotHints(); } else { event.preventDefault(); // do not start the drag } }); sessions.on("dragend", function () { jQuery(this).removeClass("dragging"); hidePastTimeslotHints(); }); sessions.prop('draggable', true); // dropping let dropElements = schedEditor.find(".timeslot .drop-target,.unassigned-sessions .drop-target"); dropElements.on('dragenter', function (event) { if (sessionDropAllowed(this, getDraggedSession(event))) { event.preventDefault(); // default action is signalling that this is not a valid target jQuery(this).parent().addClass("dropping"); } }); dropElements.on('dragover', function (event) { // we don't actually need this event, except we need to signal // that this is a valid drop target, by cancelling the default // action if (sessionDropAllowed(this, getDraggedSession(event))) { event.preventDefault(); } }); dropElements.on('dragleave', function (event) { // skip dragleave events if they are to children const leaving_child = event.originalEvent.currentTarget.contains(event.originalEvent.relatedTarget); if (!leaving_child && sessionDropAllowed(this, getDraggedSession(event))) { jQuery(this).parent().removeClass('dropping'); } }); dropElements.on('drop', function (event) { let dropElement = jQuery(this); const sessionElement = getDraggedSession(event); if (!sessionElement) { // not drag event or not from a session we recognize dropElement.parent().removeClass("dropping"); return; } if (!sessionDropAllowed(this, sessionElement)) { dropElement.parent().removeClass("dropping"); // just in case return; // drop not allowed } event.preventDefault(); // prevent opening as link let dragParent = jQuery(sessionElement).parent(); if (dragParent.is(this)) { dropElement.parent().removeClass("dropping"); return; } let dropParent = dropElement.parent(); function failHandler(xhr, textStatus, error) { dropElement.parent().removeClass("dropping"); reportServerError(xhr, textStatus, error); } function done(response) { dropElement.parent().removeClass("dropping"); if (!response.success) { reportServerError(null, null, response); return; } dropElement.append(sessionElement); // move element if (response.tombstone) { dragParent.append(response.tombstone); } updateCurrentSchedulingHints(); if (dropParent.hasClass("unassigned-sessions")) { sortUnassigned(); } } if (dropParent.hasClass("unassigned-sessions")) { ajaxCall( "unassign", { session: sessionElement.id.slice('session'.length) } ).fail(failHandler) .done(done); } else { ajaxCall( "assign", { session: sessionElement.id.slice("session".length), timeslot: dropParent.attr("id").slice("timeslot".length) } ).fail(failHandler) .done(done); } }); // Helpers for swap days / timeslots // Enable or disable a swap modal's submit button let updateSwapSubmitButton = function (modal, inputName) { modal.find("button[type=submit]").prop( "disabled", modal.find("input[name='" + inputName + "']:checked").length === 0 ); }; // Disable a particular swap modal radio input let updateSwapRadios = function (labels, radios, disableValue, datePrecision) { labels.removeClass('text-body-secondary'); radios.prop('disabled', false); radios.prop('checked', false); // disable the input requested by value let disableInput = radios.filter('[value="' + disableValue + '"]'); if (disableInput) { disableInput.parent().addClass('text-body-secondary'); disableInput.prop('disabled', true); } if (officialSchedule) { // disable any that have passed const now=effectiveNow(); const past_radios = radios.filter( (_, radio) => parseISOTimestamp(radio.closest('*[data-start]').dataset.start).isSameOrBefore(now, datePrecision) ); past_radios.parent().addClass('text-body-secondary'); past_radios.prop('disabled', true); } return disableInput; // return the input that was specifically disabled, if any }; // swap days let swapDaysModal = schedEditor.find("#swap-days-modal"); let swapDaysLabels = swapDaysModal.find(".modal-body label"); let swapDaysRadios = swapDaysLabels.find('input[name=target_day]'); let updateSwapDaysSubmitButton = function () { updateSwapSubmitButton(swapDaysModal, 'target_day'); }; // handler to prep and open the modal schedEditor.find(".swap-days").on("click", function () { let originDay = this.dataset.dayid; let originRadio = updateSwapRadios(swapDaysLabels, swapDaysRadios, originDay, 'day'); // Fill in label in the modal title swapDaysModal.find(".modal-title .day").text(originRadio.parent().text().trim()); // Fill in the hidden form fields swapDaysModal.find("input[name=source_day]").val(originDay); updateSwapDaysSubmitButton(); swapDaysModal.modal('show'); // show via JS so it won't open until it is initialized }); swapDaysRadios.on("change", function () {updateSwapDaysSubmitButton();}); // swap timeslot columns let swapTimeslotsModal = schedEditor.find('#swap-timeslot-col-modal'); let swapTimeslotsLabels = swapTimeslotsModal.find(".modal-body label"); let swapTimeslotsRadios = swapTimeslotsLabels.find('input[name=target_timeslot]'); let updateSwapTimeslotsSubmitButton = function () { updateSwapSubmitButton(swapTimeslotsModal, 'target_timeslot'); }; // handler to prep and open the modal schedEditor.find('.swap-timeslot-col').on('click', function() { let roomGroup = this.closest('.room-group').dataset; updateSwapRadios(swapTimeslotsLabels, swapTimeslotsRadios, this.dataset.timeslotPk, 'minute'); // show only options for this room group swapTimeslotsModal.find('.room-group').hide(); swapTimeslotsModal.find('.room-group-' + roomGroup.index).show(); // Fill in label in the modal title swapTimeslotsModal.find('.modal-title .origin-label').text(this.dataset.originLabel); // Fill in the hidden form fields swapTimeslotsModal.find('input[name="origin_timeslot"]').val(this.dataset.timeslotPk); swapTimeslotsModal.find('input[name="rooms"]').val(roomGroup.rooms); // Open the modal via JS so it won't open until it is initialized updateSwapTimeslotsSubmitButton(); swapTimeslotsModal.modal('show'); }); swapTimeslotsRadios.on("change", function () {updateSwapTimeslotsSubmitButton();}); } // hints for the current schedule /** Find all pairs of overlapping intervals * * @param data Array of arbitrary interval-like objects with 'start' and 'end' properties * @returns Map from data item index to a list of overlapping data item indexes */ function findOverlappingIntervals(data) { const overlaps = {}; // results // Build ordered lists of start/end times, keeping track of the original index for each item const startIndexes = data.map((d, i) => ({time: d.start, index: i})); startIndexes.sort((a, b) => (b.time - a.time)); // sort reversed const endIndexes = data.map((d, i) => ({time: d.end, index: i})); endIndexes.sort((a, b) => (b.time - a.time)); // sort reversed // items are sorted in reverse, so pop() will get the earliest item from each list let nextStart = startIndexes.pop(); let nextEnd = endIndexes.pop(); const openIntervalIndexes = []; while (nextStart && nextEnd) { if (nextStart.time < nextEnd.time) { // an interval opened - it overlaps all open intervals and all open intervals overlap it for (const intervalIndex of openIntervalIndexes) { overlaps[intervalIndex].push(nextStart.index); } overlaps[nextStart.index] = [...openIntervalIndexes]; // make a copy of the open list openIntervalIndexes.push(nextStart.index); nextStart = startIndexes.pop(); } else { // an interval closed - remove its index from the list of open intervals openIntervalIndexes.splice(openIntervalIndexes.indexOf(nextEnd.index), 1); nextEnd = endIndexes.pop(); } } return overlaps; } function updateSessionConstraintViolations() { let scheduledSessions = []; sessions.each(function () { let timeslot = jQuery(this).closest(".timeslot"); if (timeslot.length === 1) { scheduledSessions.push({ start: startMoment(timeslot), end: endMoment(timeslot), id: this.id.slice('session'.length), element: jQuery(this), timeslot: timeslot.get(0) }); } }); // helper function to mark constraint violations const markSessionConstraintViolations = function (sess, currentlyOpen) { sess.element.find(".constraints > span").each(function() { let sessionIds = this.dataset.sessions; let violated = sessionIds && sessionIds.split(",").filter(function (v) { return ( v !== sess.id && v in currentlyOpen && // ignore errors within the same timeslot // under the assumption that the sessions // in the timeslot happen sequentially sess.timeslot !== currentlyOpen[v].timeslot ); }).length > 0; jQuery(this).toggleClass("violated-hint", violated); }); }; // now go through the sessions and mark constraint violations const overlaps = findOverlappingIntervals(scheduledSessions); for (const index in overlaps) { const currentlyOpen = {}; for (const overlapIndex of overlaps[index]) { const otherSess = scheduledSessions[overlapIndex]; currentlyOpen[otherSess.id] = otherSess; } markSessionConstraintViolations(scheduledSessions[index], currentlyOpen); } } function updateTimeSlotDurationViolations() { timeslots.each(function () { const sessionsInSlot = Array.from(this.getElementsByClassName('session')); const requiredDuration = Math.max(sessionsInSlot.map(elt => Number(elt.dataset.duration))); this.classList.toggle('overfull', requiredDuration > Number(this.dataset.duration)); }); } function updateAttendeesViolations() { sessions.each(function () { let roomCapacity = jQuery(this).closest(".timeslots").data("roomcapacity"); if (roomCapacity && this.dataset.attendees) { jQuery(this).toggleClass("too-many-attendees", +this.dataset.attendees > +roomCapacity); } }); } function updateCurrentSchedulingHints() { updateSessionConstraintViolations(); updateAttendeesViolations(); updateTimeSlotDurationViolations(); } updateCurrentSchedulingHints(); // sorting unassigned function sortArrayWithKeyFunctions(array, keyFunctions) { function compareArrays(a, b) { for (let i = 1; i < a.length; ++i) { let ai = a[i]; let bi = b[i]; if (ai > bi) { return 1; } else if (ai < bi) { return -1; } } return 0; } let arrayWithSortKeys = array.map(function (a) { let res = [a]; for (let i = 0; i < keyFunctions.length; ++i) { res.push(keyFunctions[i](a)); } return res; }); arrayWithSortKeys.sort(compareArrays); return arrayWithSortKeys.map(function (l) { return l[0]; }); } function sortUnassigned() { let sortBy = schedEditor.find("select[name=sort_unassigned]").val(); function extractId(e) { return e.id.slice("session".length); } function extractName(e) { let labelElement = e.querySelector(".session-label"); return labelElement ? labelElement.innerHTML : ''; } function extractParent(e) { let parentElement = e.querySelector(".session-parent"); return parentElement ? parentElement.innerHTML : ''; } function extractDuration(e) { return +e.dataset.duration; } function extractComments(e) { return e.querySelector(".session-info .comments") ? 0 : 1; } const keyFunctionMap = { name: [extractName, extractDuration, extractId], parent: [extractParent, extractName, extractDuration, extractId], duration: [extractDuration, extractParent, extractName, extractId], comments: [extractComments, extractParent, extractName, extractDuration, extractId] }; let keyFunctions = keyFunctionMap[sortBy]; let unassignedSessionsContainer = schedEditor.find(".unassigned-sessions .drop-target"); let sortedSessions = sortArrayWithKeyFunctions(unassignedSessionsContainer.children(".session").toArray(), keyFunctions); for (let i = 0; i < sortedSessions.length; ++i) { unassignedSessionsContainer.append(sortedSessions[i]); } } schedEditor.find("select[name=sort_unassigned]").on("change click", function () { sortUnassigned(); }); sortUnassigned(); // toggling visible sessions by session parents function setSessionHiddenParent(sess, hide) { sess.toggleClass('hidden-parent', hide); sess.prop('draggable', !hide); } function updateSessionParentToggling() { let checked = []; sessionParentInputs.filter(":checked").each(function () { checked.push(".parent-" + this.value); }); setSessionHiddenParent(sessions.not(".untoggleable-by-parent").filter(checked.join(",")), false); setSessionHiddenParent(sessions.not(".untoggleable-by-parent").not(checked.join(",")), true); } sessionParentInputs.on("click", updateSessionParentToggling); updateSessionParentToggling(); // Toggling timeslot types function updateTimeSlotTypeToggling() { const checkedTypes = jQuery.map(timeSlotTypeInputs.filter(":checked"), elt => elt.value); const checkedSelectors = checkedTypes.map(t => '[data-type="' + t + '"]').join(","); sessions.filter(checkedSelectors).removeClass('hidden-timeslot-type'); sessions.not(checkedSelectors).addClass('hidden-timeslot-type'); timeslots.filter(checkedSelectors).removeClass('hidden-timeslot-type'); timeslots.not(checkedSelectors).addClass('hidden-timeslot-type'); updateGridVisibility(); return checkedTypes; } function updateTimeSlotTypeTogglingAndSave() { const checkedTypes = updateTimeSlotTypeToggling(); ajaxCall('updateview', {enabled_timeslot_types: checkedTypes}); } // Toggling session purposes function updateSessionPurposeToggling() { let checked = []; sessionPurposeInputs.filter(":checked").each(function () { checked.push(".purpose-" + this.value); }); sessions.filter(checked.join(",")).removeClass('hidden-purpose'); sessions.not(checked.join(",")).addClass('hidden-purpose'); } if (timeSlotTypeInputs.length > 0) { timeSlotTypeInputs.on("change", updateTimeSlotTypeTogglingAndSave); updateTimeSlotTypeToggling(); schedEditor.find('#timeslot-type-toggles-modal .timeslot-type-toggles .select-all') .get(0) .addEventListener( 'click', function() { timeSlotTypeInputs.prop('checked', true); updateTimeSlotTypeTogglingAndSave(); }); schedEditor.find('#timeslot-type-toggles-modal .timeslot-type-toggles .clear-all') .get(0) .addEventListener( 'click', function() { timeSlotTypeInputs.prop('checked', false); updateTimeSlotTypeTogglingAndSave(); }); } if (sessionPurposeInputs.length > 0) { sessionPurposeInputs.on("change", updateSessionPurposeToggling); updateSessionPurposeToggling(); schedEditor.find('#session-toggles-modal .select-all') .get(0) .addEventListener( 'click', function() { sessionPurposeInputs.not(':disabled').prop('checked', true); updateSessionPurposeToggling(); }); schedEditor.find('#session-toggles-modal .clear-all') .get(0) .addEventListener( 'click', function() { sessionPurposeInputs.not(':disabled').prop('checked', false); updateSessionPurposeToggling(); }); } // toggling visible timeslots function updateTimeSlotGroupToggling() { let checked = []; timeSlotGroupInputs.filter(":checked").each(function () { checked.push("." + this.value); }); timeslots.filter(checked.join(",")).removeClass("hidden-timeslot-group"); timeslots.not(checked.join(",")).addClass("hidden-timeslot-group"); updateGridVisibility(); } function updateSessionPurposeOptions() { sessionPurposeInputs.each((_, purpose_input) => { if (sessions .filter('.purpose-' + purpose_input.value) .not('.hidden') .length === 0) { purpose_input.setAttribute('disabled', 'disabled'); purpose_input.closest('.session-purpose-toggle').classList.add('text-body-secondary'); } else { purpose_input.removeAttribute('disabled'); purpose_input.closest('.session-purpose-toggle').classList.remove('text-body-secondary'); } }); } /** * Hide timeslot toggles for hidden timeslots */ function updateTimeSlotOptions() { timeSlotGroupInputs.each((_, timeslot_input) => { if (timeslots .filter('.' + timeslot_input.value) .not('.hidden-timeslot-type') .length === 0) { timeslot_input.setAttribute('disabled', 'disabled'); } else { timeslot_input.removeAttribute('disabled'); } }); } /** * Make timeslots visible/invisible/hidden * * Responsible for final determination of whether a timeslot is visible, invisible, or hidden. */ function updateTimeSlotVisibility() { const tsToShow = timeslots.not(classes_to_hide); tsToShow.removeClass('hidden'); tsToShow.show(); const tsToHide = timeslots.filter(classes_to_hide); tsToHide.addClass('hidden'); tsToHide.hide(); } /** * Make sessions visible/invisible/hidden * * Responsible for final determination of whether a session is visible or hidden. */ function updateSessionVisibility() { const sessToShow = sessions.not(classes_to_hide); sessToShow.removeClass('hidden'); sessToShow.show(); const sessToHide = sessions.filter(classes_to_hide); sessToHide.addClass('hidden'); sessToHide.hide(); } /** * Make day / time headers visible / hidden to match visible grid contents */ function updateHeaderVisibility() { days.each(function () { jQuery(this).toggle(jQuery(this).find(".timeslot").not(".hidden").length > 0); }); const rgs = schedEditor.find('.day-flow .room-group'); rgs.each(function (index, roomGroup) { const headerLabels = jQuery(roomGroup).find('.time-header .time-label'); const rgTimeslots = jQuery(roomGroup).find('.timeslot'); headerLabels.each(function(index, label) { jQuery(label).toggle( rgTimeslots .filter('[data-start="' + label.dataset.start + '"][data-end="' + label.dataset.end + '"]') .not('.hidden') .length > 0 ); }); }); } /** * Update visibility of room rows */ function updateRoomVisibility() { const tsContainers = { toShow: [], toHide: [] }; const roomGroups = { toShow: [], toHide: [] }; // roomsWithVisibleSlots is an array of room IDs that have at least one visible timeslot let roomsWithVisibleSlots = schedEditor.find('.day-flow .timeslots') .has('.timeslot:not(.hidden)') .map((_, e) => e.dataset.roomId).get(); roomsWithVisibleSlots = [...new Set(roomsWithVisibleSlots)]; // unique-ify by converting to Set and back /* The "timeslots" class identifies elements (now and probably always
s) that are containers (i.e., * parents) of timeslots (elements with the "timeslot" class). Sort these containers based on whether * their room has at least one timeslot visible - if so, we will show it, if not it will be hidden. * This will hide containers both in the day-flow and room label sections, so it will hide the room * labels for rooms with no visible timeslots. */ schedEditor.find('.timeslots').each((_, e) => { if (roomsWithVisibleSlots.indexOf(e.dataset.roomId) === -1) { tsContainers.toHide.push(e); } else { tsContainers.toShow.push(e); } }); /* Now check whether each room group has any rooms not being hidden. If not, entirely hide the * room group so that all its headers, etc, do not take up space. */ schedEditor.find('.room-group').each((_, e) => { if (jQuery(e).has(tsContainers.toShow).length > 0) { roomGroups.toShow.push(e); } else { roomGroups.toHide.push(e); } }); jQuery(roomGroups.toShow).show(); jQuery(roomGroups.toHide).hide(); jQuery(tsContainers.toShow).show(); jQuery(tsContainers.toHide).hide(); } /** * Update visibility of UI elements * * Call this after changing 'hidden-*' classes on timeslots */ function updateGridVisibility() { updateTimeSlotVisibility(); updateSessionVisibility(); updateHeaderVisibility(); updateRoomVisibility(); updateTimeSlotOptions(); updateSessionPurposeOptions(); schedEditor.find('div.edit-grid').removeClass('hidden'); } timeSlotGroupInputs.on("click change", updateTimeSlotGroupToggling); schedEditor.find('#timeslot-group-toggles-modal .timeslot-group-buttons .select-all') .get(0) .addEventListener( 'click', function() { timeSlotGroupInputs.not(':disabled').prop('checked', true); updateTimeSlotGroupToggling(); }); schedEditor.find('#timeslot-group-toggles-modal .timeslot-group-buttons .clear-all') .get(0) .addEventListener( 'click', function() { timeSlotGroupInputs.not(':disabled').prop('checked', false); updateTimeSlotGroupToggling(); }); updateTimeSlotGroupToggling(); updatePastTimeslots(); setInterval(updatePastTimeslots, 10 * 1000 /* ms */); // session info schedEditor.find(".session-info-container") .on("mouseover", ".other-session", function () { sessions.filter("#session" + this.dataset.othersessionid) .addClass("highlight"); }) .on("mouseleave", ".other-session", function () { sessions.filter("#session" + this.dataset.othersessionid).removeClass("highlight"); }); });