Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 | 6x 6x 6x 6x 764x 736x 14x 3x 102x 627x 6x 60x 60x 6x 8x 8x 8x 3x 3x 3x 3x 59x 53x 59x 2x 2x 3x 3x 1x 6x 18x 14x 14x 36x 17x 15x 12x 14x 14x 2x 2x 1x 2x 2x 2x 2x 12x 18x 7x 7x 7x 7x 7x 7x 1x 7x 3x 2x 6x 6x 6x 6x 6x 6x 6x 75x 70x 75x 3x 2x 14x 14x 14x 40x 34x 95x 34x 4x 3x 5x 14x 5x 3x 5x 5x 9x 9x 8x 2x 6x 74x 69x 74x 63x 74x 32x 62x 32x 1x 4x 3x 4x 12x 4x 3x 14x 14x 3x 2x 6x 8x 6x 2x 6x 192x 184x 192x 192x 6x 83x 72x 71x 71x 57x 50x | /**
* Pure helpers for rendering a submitted assignment answer-by-answer — shared
* between the teacher's "Individual Answers" analytics tab and the student's
* own "My Submission" page, so a question reads identically either way.
* Applied to two different answer sources: the question's correctAnswer, and
* a specific student's submitted answer entry.
*
* Modified by Allan Ninal — 2026-08-12: added `parsePointsDraft`, which mirrors the
* server's points-override contract client-side.
*/
import tinyMCEUtility from "@/utils/helpers/tinyMCEUtility";
import { describeInteractiveDotsAnswer } from "@/utils/components/question-bank/question-display/interactive-dots/interactiveDotsUtils";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const { getChoiceComparisonKey } = tinyMCEUtility();
/** Mirrors the API's `le=1000` bound on a question-points override. */
export const MAX_QUESTION_POINTS = 1000;
/**
* Answer entries aren't always plain strings — checkbox / drag-and-drop /
* drop-down-menu (and some multiple-choice / free-response from global or
* legacy question banks) store each answer as `{ id, answer }` (or
* `{ text }` / `{ value }`). Unwrap those to their text before stripping
* HTML; without this, stripHtml silently returned "" for any object,
* so the correct-answer lookup never matched and rendered blank.
*/
export const extractContent = (value) => {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "object") return value.answer ?? value.text ?? value.value ?? "";
return String(value);
};
export const stripHtml = (text) => (typeof text === "string" ? text : "").replace(/<[^>]+>/g, "").trim();
/**
* Stable key for matching choice HTML to a stored answer.
* Plain stripHtml collapses image-only choices to "" so every image choice
* matched every other image choice (and the correct letter came back blank).
*/
const choiceMatchKey = (value) => getChoiceComparisonKey(extractContent(value));
export const getQuestionTypeLabel = (type) => {
const labels = {
"multiple-choice": "Multiple Choice",
checkbox: "Checkbox",
"free-response": "Free Response",
graph: "Graph",
"drop-down-menu": "Dropdown",
"drop-down": "Drop-down",
"drag-and-drop": "Drag & Drop",
"embedded-multiple-choice": "Embedded Multiple-Choice",
"single-stimulus": "Single Stimulus",
"multi-part-question": "Multi-Part Question",
"grid-question": "Grid Question",
"graph-multiple-select": "Graph Multiple-Select",
"graph-multiple-choice": "Graph Multiple-Select",
};
return labels[type] || type;
};
const extractJson = (str, key) => {
const prefix = `${key}#o#`;
const startIndex = str.indexOf(prefix);
if (startIndex === -1) return null;
const jsonStart = startIndex + prefix.length;
let braceCount = 0;
let jsonEnd = jsonStart;
for (let i = jsonStart; i < str.length; i++) {
if (str[i] === "{") braceCount++;
else if (str[i] === "}") braceCount--;
if (braceCount === 0) {
jsonEnd = i + 1;
break;
}
}
try {
return JSON.parse(str.slice(jsonStart, jsonEnd));
} catch {
return null;
}
};
/**
* Extracts a readable "(x: 1, y: 2), (x: 3, y: 4)" point list from a graph
* answer payload — either the new `{format:"Graph2D", data:{...}}` JSON, or
* the old `tplines#o#{...}&dots#o#{...}` encoded string. Used for both the
* correct answer (question.correctAnswer.content) and a student's submitted
* answer (their raw graph answer string).
*/
export const parseGraphAnswerPoints = (content) => {
if (!content) return "";
const points = [];
const collect = (arr) => {
if (!Array.isArray(arr)) return;
for (const point of arr) {
if (typeof point?.x === "number" && typeof point?.y === "number") {
points.push(`(x: ${point.x}, y: ${point.y})`);
}
}
};
try {
// The legacy check MUST come before JSON.parse. A `tplines#o#{...}` string
// is not valid JSON, so parsing it first threw and the catch below returned
// "" — making this whole branch (and extractJson) unreachable, so every
// legacy graph answer rendered blank in submission review despite being a
// documented supported format.
if (typeof content === "string" && content.includes("#o#")) {
const tplines = extractJson(content, "tplines");
if (tplines) {
for (const line of Object.values(tplines)) collect(line.dotData);
}
collect(Object.values(extractJson(content, "dots") || {}));
collect(Object.values(extractJson(content, "pointValueDots") || {}));
collect(Object.values(extractJson(content, "labeldots") || {}));
return points.join(", ");
}
const graphData = typeof content === "string" ? JSON.parse(content) : content;
if (graphData?.format === "Graph2D" && graphData.data) {
const data = graphData.data;
collect(data.pointValueDots);
collect(data.dots);
collect(data.labeldots);
collect(data.interactiveDots);
if (Array.isArray(data.tplines)) {
for (const line of data.tplines) collect(line.dotData);
}
return points.join(", ");
}
} catch {
return "";
}
return points.join(", ");
};
/**
* Resolves a list of raw answer values (choice text, or a single choice text)
* against a group's own `choices` to their letter(s) — the same
* text-to-letter lookup `getCorrectAnswerText`/`getGivenAnswerText` do for
* top-level multiple-choice/checkbox, factored out since a Single-Stimulus
* question repeats this once per group.
*/
const resolveChoiceLetters = (values, choices) => {
const entries = Array.isArray(values) ? values : values ? [values] : [];
return entries
.map((value) => {
const text = choiceMatchKey(value);
const index = choices.findIndex((c) => choiceMatchKey(c.text) === text);
return index >= 0 && index < ALPHABET.length ? ALPHABET[index] : null;
})
.filter(Boolean);
};
/** Truncates a group's free-response text for the compact "Group N: ..." summary line. */
const truncateGroupText = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text);
/** Group types whose answer is a per-blank array of `{id, answer}` entries
* (or plain strings) — "drop-down" and "drag-and-drop" — formatted the same
* way as the standalone Drop-down-Menu/Drag-and-Drop question types. */
const BLANK_BASED_GROUP_TYPES = new Set(["drop-down", "drag-and-drop"]);
/** Formats a blank-based group's answers (an array of `{id, answer}` / plain strings) as "[a] [b]". */
const describeBlankAnswers = (blanks) => {
if (!Array.isArray(blanks)) return "";
const filled = blanks.map((b) => stripHtml(extractContent(b))).filter((text) => text !== "");
return filled.length ? filled.map((text) => `[${text}]`).join(" ") : "";
};
/** A Single-Stimulus group's own correct answer, formatted for the compact summary line. */
const describeGroupCorrectAnswer = (group) => {
if (group.type === "free-response") {
const text = stripHtml(extractContent(group?.correctAnswer?.answers));
return text ? truncateGroupText(text) : "";
}
if (group.type === "graph") {
return group?.correctAnswer?.answers ? "Graph answer" : "";
}
if (BLANK_BASED_GROUP_TYPES.has(group.type)) {
return describeBlankAnswers(group?.correctAnswer?.answers);
}
return resolveChoiceLetters(group?.correctAnswer?.answers, group.choices || []).join(", ");
};
/** What a student gave for one Single-Stimulus group, formatted for the compact summary line. */
const describeGroupGivenAnswer = (group, groupAnswer) => {
if (group.type === "free-response") {
const text = stripHtml(extractContent(groupAnswer));
return text ? truncateGroupText(text) : "";
}
if (group.type === "graph") {
return typeof groupAnswer === "string" && groupAnswer.trim() ? "Graph answer" : "";
}
if (BLANK_BASED_GROUP_TYPES.has(group.type)) {
return describeBlankAnswers(groupAnswer);
}
return resolveChoiceLetters(groupAnswer, group.choices || []).join(", ");
};
/** Formats a question's correct answer as readable text (letters for choice-based types). */
export const getCorrectAnswerText = (entry) => {
if (!entry || !entry.questionType) return "Invalid";
const choices = entry.choices || [];
switch (entry.questionType) {
case "graph":
return parseGraphAnswerPoints(entry?.correctAnswer?.content) || "";
case "graph-multiple-select":
case "graph-multiple-choice":
return (
describeInteractiveDotsAnswer(entry?.correctAnswer?.content || entry?.correctAnswer?.answers) || ""
);
case "free-response": {
// Model answer is authored via the same rich-text/math editor as the
// student's own answer (see getGivenAnswerText below) — kept as HTML
// (not stripped) so a math-formula answer renders instead of losing
// its markup to a blind tag strip.
const content = entry?.correctAnswer?.content;
const value = Array.isArray(content) ? content[0] : content;
return extractContent(value);
}
case "multiple-choice": {
if (!Array.isArray(entry?.correctAnswer?.content) || entry.correctAnswer.content.length === 0) return "";
const correctText = choiceMatchKey(entry.correctAnswer.content[0]);
const index = choices.findIndex((c) => choiceMatchKey(c.text) === correctText);
return index >= 0 && index < ALPHABET.length ? ALPHABET[index] : "";
}
case "checkbox": {
if (!Array.isArray(entry?.correctAnswer?.content)) return "";
const letters = entry.correctAnswer.content
.map((ans) => {
const text = choiceMatchKey(ans);
const index = choices.findIndex((c) => choiceMatchKey(c.text) === text);
return index >= 0 && index < ALPHABET.length ? ALPHABET[index] : null;
})
.filter(Boolean);
return letters.join(", ");
}
case "drag-and-drop":
case "drop-down-menu": {
Iif (!Array.isArray(entry?.correctAnswer?.content)) return "";
return entry.correctAnswer.content
.map((item) => stripHtml(extractContent(item)))
.filter((text) => text !== "")
.map((text) => `[${text}]`)
.join(" ");
}
case "embedded-multiple-choice": {
// correctAnswer.content is a single-element list holding the correct
// choice's id (not its text — the same phrase can be marked more than
// once in the passage), so resolve it against `choices` for display.
if (!Array.isArray(entry?.correctAnswer?.content) || entry.correctAnswer.content.length === 0) return "";
const correctId = stripHtml(extractContent(entry.correctAnswer.content[0]));
const match = choices.find((c) => String(c.id) === correctId);
return match ? stripHtml(extractContent(match.text)) : "";
}
case "grid-question": {
// correctAnswer.content is [{id: row id, answer: correct column TEXT}]
// — the same {id, answer} shape drag-and-drop uses. Prefix each with
// its row's own statement text since, unlike drag-and-drop's inline
// blanks, a grid's rows aren't visually ordered in the stem.
if (!Array.isArray(entry?.correctAnswer?.content)) return "";
const rows = entry.rows || [];
return entry.correctAnswer.content
.map((item) => {
const row = rows.find((r) => r.id === item?.id);
const rowLabel = row ? stripHtml(extractContent(row.text)) : "";
const answerText = stripHtml(extractContent(item));
return rowLabel ? `${rowLabel}: ${answerText}` : answerText;
})
.filter((text) => text !== "")
.join(" · ");
}
case "single-stimulus":
case "multi-part-question": {
// Each group carries its own choices/correctAnswer.answers (raw
// question-bank shape, passed through untouched by the backend) —
// resolved per group and joined into one "Group 1: A · Group 2: A, B"
// summary line, mirroring the take-assignment AnswerTracker's format.
const groups = entry.groups || [];
if (!groups.length) return "";
return groups
.map((group, index) => `Group ${index + 1}: ${describeGroupCorrectAnswer(group) || "N/A"}`)
.join(" · ");
}
default:
return "Invalid";
}
};
/** Formats what a specific student submitted for a question as readable text. */
export const getGivenAnswerText = (entry, rawAnswer) => {
if (!entry || !entry.questionType) return "No answer";
const isEmpty =
rawAnswer === undefined ||
rawAnswer === null ||
rawAnswer === "" ||
(Array.isArray(rawAnswer) && rawAnswer.length === 0);
if (isEmpty) return "No answer";
const choices = entry.choices || [];
switch (entry.questionType) {
case "multiple-choice": {
const text = choiceMatchKey(rawAnswer);
const index = choices.findIndex((c) => choiceMatchKey(c.text) === text);
if (index >= 0 && index < ALPHABET.length) return ALPHABET[index];
// Unmatched: prefer plain stripped text over comparison tokens like [[img:…]].
return stripHtml(extractContent(rawAnswer)) || "No answer";
}
case "checkbox": {
if (!Array.isArray(rawAnswer)) return "No answer";
const letters = rawAnswer
.map((ans) => {
const text = choiceMatchKey(ans);
const index = choices.findIndex((c) => choiceMatchKey(c.text) === text);
return index >= 0 && index < ALPHABET.length ? ALPHABET[index] : null;
})
.filter(Boolean);
return letters.length ? letters.join(", ") : "No answer";
}
case "free-response": {
// TinyMCE HTML from the rich-text/math answer field (see
// FreeResponse.jsx) — not stripped, same reasoning as the correct-answer
// case above: rendering it as HTML is the caller's job (SubmissionQuestionCard).
const text = extractContent(rawAnswer).trim();
return text || "No answer";
}
case "graph":
return parseGraphAnswerPoints(rawAnswer) || "No answer";
case "graph-multiple-select":
case "graph-multiple-choice":
return describeInteractiveDotsAnswer(rawAnswer) || "No answer";
case "drag-and-drop":
case "drop-down-menu": {
if (!Array.isArray(rawAnswer)) return "No answer";
const filled = rawAnswer.map((item) => stripHtml(extractContent(item))).filter((text) => text !== "");
return filled.length ? filled.map((text) => `[${text}]`).join(" ") : "No answer";
}
case "embedded-multiple-choice": {
// rawAnswer is the selected choice's id — resolve it against `choices`
// for display, same reasoning as getCorrectAnswerText above.
const id = stripHtml(extractContent(rawAnswer));
const match = choices.find((c) => String(c.id) === id);
return match ? stripHtml(extractContent(match.text)) : "No answer";
}
case "grid-question": {
// rawAnswer is [{id: row id, answer: chosen column TEXT}] — same
// {id, answer} shape as drag-and-drop, prefixed with each row's own
// statement text (see getCorrectAnswerText above).
if (!Array.isArray(rawAnswer)) return "No answer";
const rows = entry.rows || [];
const filled = rawAnswer
.map((item) => {
const row = rows.find((r) => r.id === item?.id);
const rowLabel = row ? stripHtml(extractContent(row.text)) : "";
const answerText = stripHtml(extractContent(item));
return rowLabel ? `${rowLabel}: ${answerText}` : answerText;
})
.filter((text) => text !== "");
return filled.length ? filled.join(" · ") : "No answer";
}
case "single-stimulus":
case "multi-part-question": {
// rawAnswer is `{ [group_id]: <that group's own answer> }` — a string
// for a "multiple-choice"/"free-response"/"graph" group, an array for
// "checkbox" (see SingleStimulus.jsx / MultiPartQuestion.jsx). isEmpty
// above doesn't catch an empty {}, so an unanswered group correctly
// falls through to "No answer" here.
const groups = entry.groups || [];
if (!groups.length) return "No answer";
const answerByGroup = typeof rawAnswer === "object" ? rawAnswer : {};
return groups
.map(
(group, index) =>
`Group ${index + 1}: ${describeGroupGivenAnswer(group, answerByGroup[group.group_id]) || "No answer"}`
)
.join(" · ");
}
default:
return "No answer";
}
};
/** True when a choice's text matches one of the entries in a multiple-choice/checkbox answer. */
export const isChoiceInAnswer = (rawAnswer, choiceText) => {
if (rawAnswer === undefined || rawAnswer === null) return false;
const entries = Array.isArray(rawAnswer) ? rawAnswer : [rawAnswer];
const choiceKey = choiceMatchKey(choiceText);
return entries.some((ans) => choiceMatchKey(ans) === choiceKey);
};
/**
* Validate a teacher's points-override draft against the SERVER's contract.
*
* `UpdateQuestionPointsRequest` is `points: float = Field(..., ge=0, le=1000)`, so
* the API is the real guard. This mirrors it client-side so a typo fails
* immediately instead of costing a request and a generic "Failed to update points."
*
* On `Number.isFinite` rather than `!Number.isNaN`: a number input accepts exponent
* notation, and `Number("1e999")` is Infinity — not NaN and not negative, which is
* exactly what got past the ORIGINAL guard here (it had no upper bound), sending
* `JSON.stringify(Infinity)` === `null` to an API that requires the field.
*
* With the 0..1000 bounds below, the two spellings are now EQUIVALENT: Infinity
* fails `> MAX` and -Infinity fails `< 0`. Verified — a mutation swapping isFinite
* for isNaN killed no test. `isFinite` is kept because it states the intent
* directly and is the only check that rejects NaN, which the bounds do not.
*
* @param {string|number} draft raw field value
* @returns {number|null} the points to send, or null when the draft is unusable
*/
export const parsePointsDraft = (draft) => {
if (draft === "" || draft === null || draft === undefined) return null;
if (typeof draft === "string" && draft.trim() === "") return null;
const parsed = Number(draft);
if (!Number.isFinite(parsed)) return null;
if (parsed < 0 || parsed > MAX_QUESTION_POINTS) return null;
return parsed;
};
|