From 159b0fdc75dccecdf6b33b98a3eca6072ae33a83 Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Wed, 10 Jun 2026 15:25:51 +0200 Subject: generalize our selection component --- cockpit/js/cockpit.js | 101 +++-------------------------- cockpit/js/selection.js | 167 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 93 deletions(-) create mode 100644 cockpit/js/selection.js diff --git a/cockpit/js/cockpit.js b/cockpit/js/cockpit.js index 8041e77..c6524d6 100644 --- a/cockpit/js/cockpit.js +++ b/cockpit/js/cockpit.js @@ -11,6 +11,7 @@ import { import { fetchStudent, fetchStudents } from "./student-ENV.js"; import { isTeacher as getIsTeacher } from "../../utils-ENV.js"; +import { initSelection } from "./selection.js"; let isTeacher; let firstname; @@ -37,99 +38,13 @@ async function loadStudent(studentToLoad) { updateStudentGraduation(); } -async function initStudentSelection() { - const sel = document.getElementById("student-selection"); - const list = document.getElementById('student-selection-list'); - const noResults = document.getElementById('student-selection-noresults'); - const clearBtn = document.getElementById('student-selection-clear'); - - const students = await fetchStudents(); - - let items = []; - - let currentFocus = -1; - let visibleItems = []; - function setActive() { - visibleItems.forEach(item => item.classList.remove("active")); - if (currentFocus >= 0 && visibleItems[currentFocus]) { - visibleItems[currentFocus].classList.add("active"); - } - } - - for (const student of students) { - let item = document.createElement("li"); - item.student = student; - item.innerHTML = `${student.name} ${student.group}`; - item.style.display = 'none'; - - item.addEventListener('click', function() { - sel.value = ""; - sel.dispatchEvent(new Event('keyup')); - loadStudent(student); - }); - - list.appendChild(item); - items.push(item); - } - - sel.addEventListener('keyup', function(e) { - if ((["ArrowDown", "ArrowUp", "Enter"]).includes(e.key)) - return; - - const filter = sel.value.toLowerCase(); - visibleItems = []; - for (const item of items) { - const itemText = item.textContent.trim().toLowerCase(); - - if (itemText.indexOf(filter) > -1 && filter !== "") { - item.style.display = ""; - visibleItems.push(item); - - // Highlight matched text - const regex = new RegExp(`(${filter})`, 'gi'); - item.innerHTML = item.textContent.replace( - regex, - "$1" - ); - } else { - item.style.display = "none"; - } - } - - // Show the clear button if there is an input - clearBtn.style.display = sel.value ? "block" : "none"; - - // Select the first or none if there are no items visible - currentFocus = visibleItems.length ? 0 : -1; - setActive(visibleItems); - noResults.style.display = visibleItems.length || filter === "" ? "none" : "block"; - }); - - clearBtn.addEventListener('click', function() { - sel.value = ""; - sel.dispatchEvent(new Event('keyup')); // reset list - sel.focus(); - }); - - sel.addEventListener('keydown', function(e) { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - if(e.key === "ArrowDown") - currentFocus = (currentFocus + 1) % visibleItems.length; - else - currentFocus = (currentFocus - 1 + visibleItems.length) % visibleItems.length; - setActive(); - e.preventDefault(); - } else if (e.key === "Enter") { - if (currentFocus > -1 && visibleItems[currentFocus]) { - - const visibleItem = visibleItems[currentFocus]; - sel.value = ""; - sel.dispatchEvent(new Event('keyup')); - loadStudent(visibleItem.student); - } - e.preventDefault(); - } - }); +function initStudentSelection() { + initSelection( + "student-selection", + fetchStudents, + (student) => `${student.name} ${student.group}`, + loadStudent + ); } function pointClickHandler(color) { diff --git a/cockpit/js/selection.js b/cockpit/js/selection.js new file mode 100644 index 0000000..a1da244 --- /dev/null +++ b/cockpit/js/selection.js @@ -0,0 +1,167 @@ +/** + * Initialises a selection based on existing HTML elements. + * For HTML elements are required: + * - A text input with id $idPrefix + * - A list with id ${idPrefix}-list + * - An element shown if no results are available with id ${idPrefix}-noresults + * - A button to clear the input with id ${idPrefix}-clear + * + * @param {string} idPrefix - The id prefix of the four HTML elements. + * @param {async function} fetchCandidates - The async function to retrieve the selectable candidates. + * @param {function} fmtCandidate - The function to format the displayed HTML for each candidate. + * @param {function} onSelection - The function to call when a candidate is selected. + */ +async function initSelection(idPrefix, fetchCandidates, fmtCandidate, onSelection) { + const sel = document.getElementById(idPrefix); + const list = document.getElementById(`${idPrefix}-list`); + const noResults = document.getElementById(`${idPrefix}-noresults`); + const clearBtn = document.getElementById(`${idPrefix}-clear`); + + const candidates = await fetchCandidates(); + + let items = []; + + let currentFocus = -1; + let visibleItems = []; + function setActive() { + visibleItems.forEach(item => item.classList.remove("active")); + if (currentFocus >= 0 && visibleItems[currentFocus]) { + visibleItems[currentFocus].classList.add("active"); + } + } + + for (const candidate of candidates) { + let item = document.createElement("li"); + item.candidate = candidate; + item.innerHTML = fmtCandidate(candidate); + item.style.display = 'none'; + + item.addEventListener('click', function() { + sel.value = ""; + sel.dispatchEvent(new Event('keyup')); + onSelection(candidate); + }); + + list.appendChild(item); + items.push(item); + } + + sel.addEventListener('keyup', function(e) { + if ((["ArrowDown", "ArrowUp", "Enter"]).includes(e.key)) + return; + + const filter = sel.value.toLowerCase(); + visibleItems = []; + for (const item of items) { + const itemText = item.textContent.trim().toLowerCase(); + + if (itemText.indexOf(filter) > -1 && filter !== "") { + item.style.display = ""; + visibleItems.push(item); + + // Highlight matched text + const regex = new RegExp(`(${filter})`, 'gi'); + item.innerHTML = item.textContent.replace( + regex, + "$1" + ); + } else { + item.style.display = "none"; + } + } + + // Show the clear button if there is an input + clearBtn.style.display = sel.value ? "block" : "none"; + + // Select the first or none if there are no items visible + currentFocus = visibleItems.length ? 0 : -1; + setActive(visibleItems); + noResults.style.display = visibleItems.length || filter === "" ? "none" : "block"; + }); + + clearBtn.addEventListener('click', function() { + sel.value = ""; + sel.dispatchEvent(new Event('keyup')); // reset list + sel.focus(); + }); + + sel.addEventListener('keydown', function(e) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + if(e.key === "ArrowDown") + currentFocus = (currentFocus + 1) % visibleItems.length; + else + currentFocus = (currentFocus - 1 + visibleItems.length) % visibleItems.length; + setActive(); + e.preventDefault(); + } else if (e.key === "Enter") { + if (currentFocus > -1 && visibleItems[currentFocus]) { + + const visibleItem = visibleItems[currentFocus]; + sel.value = ""; + sel.dispatchEvent(new Event('keyup')); + onSelection(visibleItem.candidate); + } + e.preventDefault(); + } + }); +} + +function pointClickHandler(color) { + const textareaDiv = document.getElementById("student-info-point-reason-container"); + const textarea = document.getElementById("student-info-point-reason"); + const submitBtn = document.getElementById("student-info-point-reason-submit"); + + // TODO(muhq): Prefill the textarea with common reasons + switch (color) { + case "green": + textarea.style.background = "rgb(from green r g b / 20%)" + textarea.value = ""; + break; + case "orange": + textarea.style.background = "rgb(from orange r g b / 20%)" + textarea.value = ""; + break; + case "red": + textarea.style.background = "rgb(from red r g b / 20%)" + textarea.value = ""; + break; + } + textareaDiv.style.display = "block"; + textarea.focus(); + + // Can not use string interpolation because of Moodle's firstname filter. + const coach = firstname + " " + lastname; + + let submit = function() { + let change; + switch (color) { + case "green": + change = GraduationChange.newGreenPointChange(student, textarea.value, coach); + break; + case "orange": + change = GraduationChange.newOrangePointChange(student, textarea.value, coach); + break; + case "red": + change = GraduationChange.newRedPointChange(student, textarea.value, coach); + break; + default: + alert("Invalid color"); + break; + } + submitGraduationChange(student, change); + textareaDiv.style.display = "none"; + updateStudentInfo(); + } + // Use onEVENT to override any previous handler + textarea.onkeydown = function(e) { + if (e.shiftKey && e.key === "Enter") { + submit(); + } + if (e.key === "Escape") { + textareaDiv.style.display = "none"; + } + }; + submitBtn.onclick = submit; +} + +export { initSelection }; -- cgit v1.2.3