Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 248 additions & 1 deletion evap/static/ts/src/student-vote.ts
Original file line number Diff line number Diff line change
@@ -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") {
Expand All @@ -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;
Expand Down Expand Up @@ -176,3 +181,245 @@ function scrollToFirstChoiceError() {
fancyFocus(findCorrectInputInRow(tabRow));
}
}

const dataElement = document.querySelector<HTMLElement>(".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<HTMLInputElement>("#text_results_publish_confirmation_top")!,
bottom: document.querySelector<HTMLInputElement>("#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<HTMLTextAreaElement>(".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<HTMLButtonElement>("[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<HTMLInputElement>(".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<HTMLLabelElement>(".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<HTMLButtonElement>(".btn-textanswer").forEach(textanswerButton => {
const textfieldClass = textanswerButton.dataset.bsTarget!;
const textfield = textanswerButton
.closest(".row")!
.querySelector<HTMLTextAreaElement>(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();
});
}
2 changes: 1 addition & 1 deletion evap/static/ts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "es2019",
"target": "es2020",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node"
Expand Down
Loading
Loading