diff --git a/evap/static/ts/src/student-vote.ts b/evap/static/ts/src/student-vote.ts index 0a2c6b47ef..62f1358527 100644 --- a/evap/static/ts/src/student-vote.ts +++ b/evap/static/ts/src/student-vote.ts @@ -1,4 +1,8 @@ -import { selectOrError } from "./utils.js"; +// declare const bootstrap: typeof import("bootstrap"); +import { selectOrError, assert } from "./utils.js"; +import { AutoFormSaver } from "./auto-form-saver.js"; +import { CSRF_HEADERS } from "./csrf-utils.js"; +import { initTextAnswerWarnings } from "./text-answer-warnings.js"; function isInvisible(el: Element): boolean { if (getComputedStyle(el).display === "none") { @@ -10,6 +14,7 @@ function isInvisible(el: Element): boolean { function hasTabbingTarget(element: HTMLElement): boolean { return element.querySelector(".tab-selectable") !== null; } +console.log("here"); function selectByNumberKey(row: HTMLElement, num: number) { let index = 2 * num - 1; @@ -176,3 +181,245 @@ function scrollToFirstChoiceError() { fancyFocus(findCorrectInputInRow(tabRow)); } } + +const dataElement = document.querySelector(".dataElement")!; +const evaluationId = dataElement.dataset.evaluation_id!; +const requestUserId = dataElement.dataset.request_user_id; +const languageStorageKey = `student-vote-last-saved-at-${evaluationId}-${requestUserId}`; +const textResultsPublishConfirmation = { + top: document.querySelector("#text_results_publish_confirmation_top")!, + bottom: document.querySelector("#text_results_publish_confirmation_bottom")!, + bottomCard: document.querySelector("#bottom_text_results_publish_confirmation_card"), +}; + +// Ensure that selected questionnaire language is saved and loaded +const params = new URLSearchParams(document.location.search); +const currentlySelectedLanguage = dataElement.dataset.evaluation_language!; +const savedLanguage = localStorage.getItem(languageStorageKey); + +if (params.get("language")) { + localStorage.setItem(languageStorageKey, currentlySelectedLanguage); +} else if (savedLanguage && savedLanguage !== currentlySelectedLanguage) { + params.set("language", savedLanguage); + document.location.search = params.toString(); +} + +const formSaver = new AutoFormSaver(document.getElementById("student-vote-form") as HTMLFormElement, { + customKeySuffix: `[user=${requestUserId}]`, // don't load data for other users + onRestore: function () { + // restore publish confirmation state + if (textResultsPublishConfirmation.bottomCard) { + updateTextResultsPublishConfirmation(); + } + + // show all non-empty additional text answer fields + document.querySelectorAll(".row-question .collapse textarea").forEach(el => { + if (el.value.length !== 0) { + const button = el.closest(".row")!.getElementsByClassName("btn-textanswer")[0] as HTMLButtonElement; + button.click(); + } + }); + }, + onSave: function () { + const timeNow = new Date(); + localStorage.setItem(languageStorageKey, timeNow.toString()); + }, +}); + +const languageCode = dataElement.dataset.language_code!; +function updateLastSavedLabel() { + const timeNow = new Date(); + const lastSavedLabel = document.getElementById("last-saved")!; + const lastSavedStorageValue = localStorage.getItem(languageStorageKey); + if (lastSavedStorageValue !== null) { + const lastSavedDate = new Date(lastSavedStorageValue); + const delta = Math.round((timeNow.getTime() - lastSavedDate.getTime()) / 1000); + const relativeTimeFormat = new Intl.RelativeTimeFormat(languageCode); + let timeStamp; + if (delta < 3) { + timeStamp = "{% translate 'just now' %}"; + } else if (delta < 10) { + timeStamp = "{% translate 'less than 10 seconds ago' %}"; + } else if (delta < 30) { + timeStamp = "{% translate 'less than 30 seconds ago' %}"; + } else if (delta < 60) { + timeStamp = "{% translate 'less than 1 minute ago' %}"; + } else if (delta < 60 * 30) { + timeStamp = relativeTimeFormat.format(-Math.round(delta / 60), "minutes"); + } else if (delta < 60 * 60 * 12) { + timeStamp = + padWithLeadingZeros(lastSavedDate.getHours()) + ":" + padWithLeadingZeros(lastSavedDate.getMinutes()); + } else { + timeStamp = + lastSavedDate.getFullYear().toString() + + "-" + + padWithLeadingZeros(lastSavedDate.getMonth() + 1) + + "-" + + padWithLeadingZeros(lastSavedDate.getDate()) + + " " + + padWithLeadingZeros(lastSavedDate.getHours()) + + ":" + + padWithLeadingZeros(lastSavedDate.getMinutes()); + } + lastSavedLabel.innerText = "{% translate 'Last saved locally' %}: " + timeStamp; + } else { + lastSavedLabel.innerText = "{% translate 'Could not save your information locally' %}"; + } +} + +function padWithLeadingZeros(number: number) { + return number.toString().padStart(2, "0"); +} + +// save all data after loading the page +// (the data gets deleted every time the form is submitted, i.e. also when the form had errors and is displayed again) +formSaver.saveAllData(); + +// Initialize lastSavedLabel and update it every second +updateLastSavedLabel(); +setInterval(updateLastSavedLabel, 1000); + +const textAnswerWarnings = document.getElementById("text-answer-warnings") as HTMLTextAreaElement; +initTextAnswerWarnings( + document.querySelectorAll("#student-vote-form textarea"), + JSON.parse(textAnswerWarnings.textContent) as string[][], +); + +const form = document.getElementById("student-vote-form") as HTMLFormElement; +const successMagicString = dataElement.dataset.success_magic_string!; +const successRedirectUrl = dataElement.dataset.success_redirect_url!; +const submitListener = (event: Event) => { + event.preventDefault(); // don't use the default submission + const submitButton = document.getElementById("vote-submit-btn") as HTMLButtonElement; + const originalText = submitButton.innerText; + + submitButton.innerText = "{% translate 'Submitting...' %}"; + submitButton.disabled = true; + + fetch(form.action, { + body: new FormData(form), + headers: CSRF_HEADERS, + method: form.method, + }) + .then(response => { + assert(response.ok); + return response.text(); + }) + .then(response_text => { + if (response_text === successMagicString) { + formSaver.releaseData(); + window.location.replace(successRedirectUrl); + } else { + // resubmit without this handler to show the site with the form errors + form.removeEventListener("submit", submitListener); + form.requestSubmit(); + } + }) + .catch((_: unknown) => { + // show a warning if the post isn't successful + document.getElementById("submit-error-warning")!.classList.remove("d-none"); + submitButton.innerText = originalText; + submitButton.disabled = false; + }); +}; +form.addEventListener("submit", submitListener); + +function clearChoiceError(voteButton: HTMLElement) { + voteButton + .closest(".row")! + .querySelectorAll(".choice-error") + .forEach(highlightedElement => { + highlightedElement.classList.remove("choice-error"); + }); +} + +console.log("here"); +document.querySelectorAll("[data-mark-no-answers-for]").forEach(button => { + console.log("here"); + const contributorId = button.dataset.markNoAnswersFor!; + const voteArea = document.getElementById(`vote-area-${contributorId}`)!; + const collapseToggle = voteArea.closest(".collapsible")!.querySelector(".collapse-toggle")!; + + button.addEventListener("click", () => { + voteArea.querySelectorAll(".vote-inputs [type=radio][value='6']").forEach(radioInput => { + radioInput.checked = true; + clearChoiceError(radioInput); + }); + + formSaver.saveAllData(); + + // hide questionnaire for contributor + // @ts-ignore + const voteAreaCollapse = bootstrap.Collapse.getOrCreateInstance(voteArea); + voteAreaCollapse.hide(); + collapseToggle.classList.add("tab-selectable"); + + // Disable this button, until user changes a value + button.classList.remove("tab-selectable"); + button.disabled = true; + }); + + voteArea.querySelectorAll(".vote-inputs [type=radio]:not([value='6'])").forEach(radioInput => { + radioInput.addEventListener("click", () => { + collapseToggle.classList.remove("tab-selectable"); + button.classList.add("tab-selectable"); + button.disabled = false; + }); + }); + + collapseToggle.addEventListener("click", () => { + if (button.classList.contains("tab-selectable")) { + collapseToggle.classList.remove("tab-selectable"); + } + }); +}); + +// remove error highlighting when an answer was selected +document.querySelectorAll(".vote-btn.choice-error").forEach(voteButton => { + voteButton.addEventListener("click", () => clearChoiceError(voteButton)); + console.log(voteButton.attributes); + const actualInput = document.getElementById(voteButton.htmlFor)!; + actualInput.addEventListener("click", () => clearChoiceError(voteButton)); +}); + +document.querySelectorAll(".btn-textanswer").forEach(textanswerButton => { + const textfieldClass = textanswerButton.dataset.bsTarget!; + const textfield = textanswerButton + .closest(".row")! + .querySelector(textfieldClass + " textarea")!; + textanswerButton.addEventListener("click", () => { + // focus textarea when opening the collapsed area + const isOpening = textanswerButton.classList.contains("collapsed"); + if (isOpening) { + requestAnimationFrame(() => { + textfield.focus(); + }); + } + }); + textfield.addEventListener("input", () => { + if (textfield.value.trim().length !== 0) { + textanswerButton.classList.add("has-contents"); + } else { + textanswerButton.classList.remove("has-contents"); + } + }); + textfield.dispatchEvent(new Event("input")); +}); + +// handle text_results_publish_confirmation checkbox changes +function updateTextResultsPublishConfirmation() { + const isChecked = textResultsPublishConfirmation.top.checked; + textResultsPublishConfirmation.bottom.checked = isChecked; + textResultsPublishConfirmation.bottomCard?.classList.toggle("d-none", isChecked); +} + +if (textResultsPublishConfirmation.bottomCard) { + textResultsPublishConfirmation.top.addEventListener("change", updateTextResultsPublishConfirmation); + textResultsPublishConfirmation.bottom.addEventListener("change", () => { + // The top checkbox should only be visually checked without triggering the change event, + // which would hide the bottom card. + // To keep the top checkbox checked (after a reload or submit), save the form manually. + textResultsPublishConfirmation.top.checked = textResultsPublishConfirmation.bottom.checked; + formSaver.saveAllData(); + }); +} diff --git a/evap/static/ts/tsconfig.json b/evap/static/ts/tsconfig.json index c765d806e0..e47ea2e495 100644 --- a/evap/static/ts/tsconfig.json +++ b/evap/static/ts/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "baseUrl": ".", - "target": "es2019", + "target": "es2020", "strict": true, "esModuleInterop": true, "moduleResolution": "node" diff --git a/evap/student/templates/student_vote.html b/evap/student/templates/student_vote.html index 46d49b4c35..320db72ec3 100644 --- a/evap/student/templates/student_vote.html +++ b/evap/student/templates/student_vote.html @@ -16,6 +16,13 @@ {% endblock %} {% block content %} +
{{ block.super }} {% if errors_exist %} - +
{% for language_code, language_name in languages %} {% if not preview %} {{ text_answer_warnings|text_answer_warning_trigger_strings|json_script:'text-answer-warnings' }} - {% endif %} {% endblock %}