feat: add refresh buttons for exams and packets with loading state management

This commit is contained in:
Ross
2026-07-05 20:49:01 +01:00
parent 8ab8a7e8db
commit 32655c9fa8
3 changed files with 263 additions and 107 deletions
+26
View File
@@ -1321,4 +1321,30 @@ h2:has(+ #exam-list:empty), h2:has(+ #packet-list:empty){
to {
box-shadow: 0 0 12px #F44336;
}
}
.refresh-button {
background-color: transparent;
border: 1px solid #555;
border-radius: 3px;
color: #bbb;
padding: 1px 6px;
font-size: 10px;
cursor: pointer;
margin-left: 10px;
vertical-align: middle;
transition: all 0.2s;
}
.refresh-button:hover {
background-color: #333;
border-color: #888;
color: #fff;
}
.global-refresh-container {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 15px;
}
+64 -16
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xml:lang="en"
@@ -26,7 +26,9 @@
<div id="content">
<div class="content-panel">
<div class="nav-bar">
<button id="btn-review" class="navigation nav-right"><b></b><br/>OVERVIEW</button>
<button id="btn-review" class="navigation nav-right">
<b></b><br />OVERVIEW
</button>
<button
id="btn-submit-nav"
class="submit-button navigation nav-right"
@@ -51,7 +53,9 @@
<div id="sync-status" class="nav-left"></div>
<div id="timer" title="click to pause"></div>
<div class="exam-name" title="currently loaded packet"></div>
<button class="navigation nav-left" disabled="disabled"
<button
class="navigation nav-left"
disabled="disabled"
data-nav="previous"
>
<svg
@@ -70,9 +74,7 @@
<br />
<span>PREVIOUS</span>
</button>
<button class="navigation nav-left"
data-nav="next"
>
<button class="navigation nav-left" data-nav="next">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -108,20 +110,46 @@
</div>
</div>
</div>
<div class="logout-background" role="dialog" aria-modal="true" aria-labelledby="finish-title">
<div
class="logout-background"
role="dialog"
aria-modal="true"
aria-labelledby="finish-title"
>
<div class="finish-header">
<div class="login-risr-box"><img class="login-risr-logo" draggable="false" alt="Logo" src="risr-assess-on-dark.png"></div>
<div class="login-risr-box">
<img
class="login-risr-logo"
draggable="false"
alt="Logo"
src="risr-assess-on-dark.png"
/>
</div>
</div>
<div class="logout-panel finish-dialog" id="finish-dialog-panel">
<div class="logout-title" id="finish-title">Ready to finish?</div>
<div class="logout-error info">
<p><strong>Answers are submitted automatically. You can complete the exam by clicking below.</strong></p>
<p class="unanswered-line">You have <span id="unanswered-count">0</span> unanswered questions</p>
<p>
<strong
>Answers are submitted automatically. You can complete the
exam by clicking below.</strong
>
</p>
<p class="unanswered-line">
You have <span id="unanswered-count">0</span> unanswered
questions
</p>
</div>
<div class="logout-text">
Click “Submit exam” before closing the tab or window
</div>
<div class="logout-text">Click “Submit exam” before closing the tab or window</div>
<div class="logout-buttons">
<button class="logout-button danger" id="button-logout">Submit exam</button>
<button class="logout-button primary" id="button-continue">Continue exam</button>
<button class="logout-button danger" id="button-logout">
Submit exam
</button>
<button class="logout-button primary" id="button-continue">
Continue exam
</button>
</div>
</div>
</div>
@@ -137,7 +165,7 @@
id="btn-candidate-login"
class="login-button"
value="Enter candidate details"
onclick="void(0)"
onclick="void 0"
/>
<input
type="button"
@@ -150,14 +178,34 @@
<div id="user" class="user-text"></div>
</span>
<div id="packets">
<p>Click buttons below to load an exam / packet</p>
<div class="global-refresh-container">
<span id="load-exam-packet-instructions">
<span>Click buttons below to load an exam / packet</span>
</span>
<button
id="btn-refresh-all"
class="refresh-button"
title="Refresh all exams and packets"
>
Refresh All
</button>
</div>
<span class="exam-wrapper">
<h2>Exams:</h2>
<span id="exam-list"></span>
</span>
<span class="packet-wrapper">
<h2>Packets:</h2>
<h2>
Packets:
<button
id="btn-refresh-packets"
class="refresh-button"
title="Refresh packets"
>
Refresh
</button>
</h2>
<span id="packet-list"></span>
</span>
</div>
+173 -91
View File
@@ -273,12 +273,29 @@ catch {
*
* if exam_query_url is defined in config.js this will be used in preference
*/
async function retrievePacketList() {
let url = "packets/packets.json";
function showLoadingState($btn, $targetContainer) {
if (!$btn || !$btn.length) return null;
let oldText = $btn.text();
$btn.prop("disabled", true).text("...");
if ($targetContainer && $targetContainer.length) {
$targetContainer.css({
"opacity": "0.4",
"transition": "opacity 0.2s"
});
}
return function restore(success = true) {
$btn.prop("disabled", false).text(oldText);
if ($targetContainer && $targetContainer.length) {
$targetContainer.css("opacity", "1");
if (success) {
$targetContainer.fadeOut(100).fadeIn(100);
}
}
};
}
async function retrievePacketList(target = "all", onComplete = null) {
log.debug(`Load users from database...`)
//let users = await user_db.user.toArray();
//log.debug(`available users "${users}"`)
let cid = localStorage.getItem("cid.cid")
let passcode = localStorage.getItem("cid.passcode")
@@ -322,110 +339,147 @@ async function retrievePacketList() {
} catch (e) {
log.debug("Error parsing URL params for CID/passcode", e);
}
}
try {
log.debug("Try to set exam query url")
if (URLS.exam_query_url != undefined) {
if (global_cid == null) {
url = URLS.exam_query_url;
} else {
url = `${URLS.exam_query_url}/${global_cid}/${global_passcode}`;
}
}
log.debug(`url: ${url}`)
} catch (e) {
log.debug("Unable to set exam query url", e)
log.debug("this will default to packets/packets.json")
}
if (url && (url.includes("exam/json") || url.endsWith("exam/json"))) {
const types = ["anatomy", "rapid", "short", "long"];
// Clear UI elements depending on target
if (target === "all") {
$("#packet-list").empty();
$("#exam-list").empty();
$("#cache-details summary").empty();
$("#cache-details ul").empty();
} else if (target === "exams") {
$("#exam-list").empty();
} else if (target === "packets") {
$("#packet-list").empty();
} else if (["anatomy", "rapid", "short", "long"].includes(target)) {
$(`.exam-list.${target}`).find(".packet-button").remove();
$(`.packet-list.${target}`).find(".packet-button").remove();
}
types.forEach(type => {
let typeUrl = url;
if (url.includes("exam/json/unbased")) {
typeUrl = url.replace("exam/json/unbased", `exam/json/${type}/unbased`);
} else {
typeUrl = url.replace("exam/json", `exam/json/${type}`);
}
let promises = [];
$.ajax({
dataType: "json",
cache: false,
url: typeUrl,
success: function(data) {
if (data.hasOwnProperty("exams")) {
setUserInfo(data);
loadExamList(data, false);
} else {
loadPacketList(data);
}
},
error: function(httpObj) {
console.debug(httpObj);
if (httpObj.status == 401 || httpObj.status == 404) {
localStorage.removeItem("cid.cid");
localStorage.removeItem("cid.passcode");
$.notify("Unable to login", "error");
$.notify("This page will now reload", "error");
setTimeout(() => {
window.location.reload();
}, 3000);
$("#options-panel").show();
$("#candidate-details").addClass("invalid-login");
}
}
});
});
} else {
$.ajax({
// 1. Fetch packets if target is "all" or "packets"
if (target === "all" || target === "packets") {
let packetsUrl = "packets/packets.json";
let p = $.ajax({
dataType: "json",
cache: false,
url: url,
url: packetsUrl,
success: function(data) {
if (data.hasOwnProperty("exams")) {
loadExamList(data, true);
} else {
if (!data.hasOwnProperty("exams")) {
loadPacketList(data);
}
},
error: function(httpObj, textStatus) {
console.debug(httpObj);
if (httpObj.status == 401 || httpObj.status == 404) {
localStorage.removeItem("cid.cid");
localStorage.removeItem("cid.passcode");
$.notify("Unable to login", "error");
$.notify("This page will now reload", "error");
setTimeout(() => {
window.location.reload();
}, 3000);
$("#options-panel").show();
$("#candidate-details").addClass("invalid-login");
}
},
})
.done(function() {})
.fail(function() {
log.debug("Unable to load packets / exams, try loading '/packets' (legacy)")
error: function(jqXHR, textStatus, errorThrown) {
log.debug("Unable to load packets, trying legacy '/packets'");
$.getJSON("packets", function(data) {
if (data.hasOwnProperty("exams")) {
loadExamList(data, true);
} else {
if (!data.hasOwnProperty("exams")) {
loadPacketList(data);
}
}).fail(function(jqXHR, textStatus, errorThrown) {
console.warn("No packet list available");
});
}
});
promises.push(p);
}
// 2. Fetch exams if target is "all" or "exams" or one of the types
if (target === "all" || target === "exams" || ["anatomy", "rapid", "short", "long"].includes(target)) {
let examUrl = null;
try {
if (URLS.exam_query_url != undefined) {
if (global_cid == null || String(global_cid).startsWith("u-")) {
examUrl = URLS.exam_query_url;
} else {
examUrl = `${URLS.exam_query_url}/${global_cid}/${global_passcode}`;
}
}
} catch (e) {
log.debug("Unable to set exam query url", e);
}
if (examUrl) {
if (examUrl.includes("exam/json") || examUrl.endsWith("exam/json")) {
const types = ["anatomy", "rapid", "short", "long"];
const typesToFetch = types.includes(target) ? [target] : types;
typesToFetch.forEach(type => {
let typeUrl = examUrl;
if (examUrl.includes("exam/json/unbased")) {
typeUrl = examUrl.replace("exam/json/unbased", `exam/json/${type}/unbased`);
} else {
typeUrl = examUrl.replace("exam/json", `exam/json/${type}`);
}
let p = $.ajax({
dataType: "json",
cache: false,
url: typeUrl,
success: function(data) {
if (data.hasOwnProperty("exams")) {
setUserInfo(data);
loadExamList(data, false, target);
}
},
error: function(httpObj) {
console.debug(httpObj);
if ((httpObj.status == 401 || httpObj.status == 404) && global_cid !== null && !String(global_cid).startsWith("u-")) {
localStorage.removeItem("cid.cid");
localStorage.removeItem("cid.passcode");
$.notify("Unable to login", "error");
$.notify("This page will now reload", "error");
setTimeout(() => {
window.location.reload();
}, 3000);
$("#options-panel").show();
$("#candidate-details").addClass("invalid-login");
}
}
});
promises.push(p);
});
} else {
let p = $.ajax({
dataType: "json",
cache: false,
url: examUrl,
success: function(data) {
if (data.hasOwnProperty("exams")) {
loadExamList(data, true, target);
}
},
error: function(httpObj, textStatus) {
console.debug(httpObj);
if ((httpObj.status == 401 || httpObj.status == 404) && global_cid !== null && !String(global_cid).startsWith("u-")) {
localStorage.removeItem("cid.cid");
localStorage.removeItem("cid.passcode");
$.notify("Unable to login", "error");
$.notify("This page will now reload", "error");
setTimeout(() => {
window.location.reload();
}, 3000);
$("#options-panel").show();
$("#candidate-details").addClass("invalid-login");
}
}
});
promises.push(p);
}
}
}
if (promises.length) {
Promise.all(promises)
.then(() => {
if (onComplete) onComplete(true);
})
.always(function() {});
.catch((err) => {
console.warn("One or more requests failed", err);
if (onComplete) onComplete(false);
});
} else {
if (onComplete) onComplete(true);
}
}
@@ -435,7 +489,7 @@ async function retrievePacketList() {
*
* @param {JSON} data - json containing available exams
*/
async function loadExamList(data, clearExistingList = true) {
async function loadExamList(data, clearExistingList = true, target = "all") {
// If exam mode was enabled via URL param, hide packets by default
if (typeof url_exam_mode !== "undefined" && url_exam_mode) {
log.debug("URL exam mode active - hiding packets list");
@@ -491,8 +545,12 @@ async function loadExamList(data, clearExistingList = true) {
// enable invalidation when the question is updated on the server
let exam_generated_map = {};
if (clearExistingList) {
$("#packet-list").empty();
$("#exam-list").empty();
if (target === "all" || target === "packets") {
$("#packet-list").empty();
}
if (target === "all" || target === "exams") {
$("#exam-list").empty();
}
$("#cache-details summary").empty();
$("#cache-details ul").empty();
}
@@ -547,7 +605,11 @@ async function loadExamList(data, clearExistingList = true) {
list = $(`.${target_list}.${exam.type}`);
// or create and add it
} else {
list = $(`<div class='${target_list} ${exam.type}'><span class='packet-list-title'>${exam.type}</span><br/></div>`);
let refreshBtnHtml = "";
if (target_list === "exam-list") {
refreshBtnHtml = ` <button class="refresh-button type-refresh-button" data-type="${exam.type}" title="Refresh ${exam.type} exams">Refresh</button>`;
}
list = $(`<div class='${target_list} ${exam.type}'><span class='packet-list-title'>${exam.type}${refreshBtnHtml}</span><br/></div>`);
$(`#${target_list}`).append(list);
}
@@ -2904,6 +2966,26 @@ $("#btn-user-login").click(function(evt) {
//$(".exam-wrapper").toggle();
});
$("#btn-refresh-all").on("click", function(e) {
e.stopPropagation();
let restore = showLoadingState($(this), $("#packets"));
retrievePacketList("all", restore);
});
$("#btn-refresh-packets").on("click", function(e) {
e.stopPropagation();
let restore = showLoadingState($(this), $("#packet-list"));
retrievePacketList("packets", restore);
});
// Delegate click handlers for the dynamic per-exam-type refresh buttons
$("#exam-list").on("click", ".type-refresh-button", function(e) {
e.stopPropagation();
let type = $(this).data("type");
let $categoryContainers = $(`.exam-list.${type}, .packet-list.${type}`);
let restore = showLoadingState($(this), $categoryContainers);
retrievePacketList(type, restore);
});
$("#btn-login").click(function(evt) {