All files / src/app/student-page/page/assignments/take-assignment/question-list GroupDropdownAnswer.jsx

7.24% Statements 5/69
0% Branches 0/43
0% Functions 0/19
9.09% Lines 5/55

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              6x             6x         6x       6x                                               6x                                                                                                                                                                                                              
import React, { useEffect, useRef } from "react";
import { Box, Paper } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { sanitizeRichText } from "@/utils/helpers/sanitizeHtml";
import { STUDENT_QUESTION_FONT_SIZE, dropdownSelectColors, questionContentBg } from "./studentQuestionSurfaces";
import "./StudentQuestion.scss";
 
const stripHtml = (text) => {
    if (!text || typeof text !== "string") return "";
    const el = document.createElement("div");
    el.innerHTML = text;
    return el.textContent || el.innerText || "";
};
 
const escapeAttr = (str) => {
    if (!str) return "";
    return String(str).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
};
 
const SELECT_FONT = `${STUDENT_QUESTION_FONT_SIZE} "Open Sans", Helvetica, Arial, sans-serif`;
// Horizontal padding (4px left, 26px right for the arrow) + border (2px each
// side) from buildSelectHTML's own style string below — same chrome-width
// constant the standalone take-assignment DropDownMenu.jsx uses.
const SELECT_CHROME_WIDTH = 4 + 26 + 2 + 2;
let measureCtx;
 
function measureSelectWidth(text) {
    if (typeof document === "undefined") return SELECT_CHROME_WIDTH;
    if (measureCtx === undefined) {
        measureCtx = document.createElement("canvas").getContext("2d") || null;
    }
    if (!measureCtx) return SELECT_CHROME_WIDTH;
    measureCtx.font = SELECT_FONT;
    return Math.ceil(measureCtx.measureText(text).width) + SELECT_CHROME_WIDTH;
}
 
/**
 * A Single-Stimulus "drop-down" group's own sentence-with-blanks
 * interaction — the group-scoped counterpart of the standalone take-assignment
 * DropDownMenu.jsx, reporting through `value`/`onChange` (like
 * GroupGraphAnswer.jsx) instead of dispatching straight to Redux, since the
 * parent (SingleStimulus.jsx) owns the combined per-group answer object.
 *
 * `content` is the group's own text with `__blank__` markers, `choices` is
 * that group's own `[{id, items: [htmlString, ...]}]` (one entry per blank),
 * and `value`/`onChange` carry that group's answer as `[{id, answer}, ...]`.
 */
const GroupDropdownAnswer = ({ content, choices, value, onChange }) => {
    const theme = useTheme();
    const containerRef = useRef(null);
    const safeChoices = Array.isArray(choices) ? choices : [];
    const selectedValues = safeChoices.map((choice) => {
        const answer = Array.isArray(value) ? value.find((v) => v?.id === choice.id)?.answer : undefined;
        return { id: choice.id, answer: answer || "" };
    });
 
    const buildSelectHTML = (index) => {
        const choice = safeChoices[index];
        if (!choice) return "";
        const currentAnswer = selectedValues.find((sv) => sv.id === choice.id)?.answer || "";
        const hasValue = currentAnswer !== "";
        const { bg: bgColor, border: borderColor, text: textColor } = dropdownSelectColors(theme, hasValue);
        const displayText = hasValue ? stripHtml(currentAnswer) : "Select an answer";
        const selectWidth = measureSelectWidth(displayText);
 
        const optionsHTML = (choice.items || [])
            .map((item) => {
                const isSelected = item === currentAnswer ? " selected" : "";
                return `<option value="${escapeAttr(item)}"${isSelected}>${escapeAttr(stripHtml(item))}</option>`;
            })
            .join("");
 
        return (
            `<select data-dropdown-index="${index}" style="` +
            `display:inline-block;width:${selectWidth}px;padding:2px 26px 2px 4px;font-size:inherit;` +
            `border:2px solid ${borderColor};border-radius:3px;background-color:${bgColor};color:${textColor};` +
            `cursor:pointer;-webkit-appearance:none;appearance:none;margin:4px 8px;vertical-align:middle;` +
            `background-image:url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23666%27 d=%27M6 8L1 3h10z%27/%3E%3C%2Fsvg%3E');` +
            `background-repeat:no-repeat;background-position:right 8px center;transition:all 0.2s ease;">` +
            `<option value="">Select an answer</option>` +
            `${optionsHTML}` +
            `</select>`
        );
    };
 
    const buildContent = () => {
        let dropIndex = 0;
        return (content || "").replace(/__blank__/g, () => buildSelectHTML(dropIndex++));
    };
 
    // Attach DOM change listeners after every render so they stay in sync
    // with the latest selectedValues, same pattern as the standalone
    // DropDownMenu.jsx.
    useEffect(() => {
        const container = containerRef.current;
        if (!container) return undefined;
        const cleanup = [];
 
        container.querySelectorAll("select[data-dropdown-index]").forEach((select) => {
            const index = parseInt(select.getAttribute("data-dropdown-index"), 10);
            const choice = safeChoices[index];
            if (!choice) return;
 
            const onSelectChange = (e) => {
                const newAnswer = e.target.value;
                const next = safeChoices.map((c) => ({
                    id: c.id,
                    answer: c.id === choice.id ? newAnswer : selectedValues.find((sv) => sv.id === c.id)?.answer || "",
                }));
                onChange(next);
            };
 
            select.addEventListener("change", onSelectChange);
            cleanup.push(() => select.removeEventListener("change", onSelectChange));
        });
 
        return () => cleanup.forEach((fn) => fn());
    });
 
    return (
        <Box className="student-dropdown">
            <Paper
                ref={containerRef}
                variant="outlined"
                className="student-question-content student-dropdown-question"
                dangerouslySetInnerHTML={{ __html: sanitizeRichText(buildContent()) }}
                sx={{
                    display: "block !important",
                    bgcolor: questionContentBg(theme),
                    fontSize: STUDENT_QUESTION_FONT_SIZE,
                    lineHeight: 2,
                    fontWeight: "normal",
                    "& p": { margin: 0, padding: 0 },
                    "& u": { textDecoration: "underline" },
                    "& s, & del": { textDecoration: "line-through" },
                    "& strong, & b": { fontWeight: "bold" },
                    "& em, & i": { fontStyle: "italic" },
                    "& sub": { verticalAlign: "sub", fontSize: "smaller" },
                    "& sup": { verticalAlign: "super", fontSize: "smaller" },
                    "& ul": { listStyle: "disc", paddingLeft: "2em" },
                    "& ol": { listStyle: "decimal", paddingLeft: "2em" },
                    "& li": { display: "list-item" },
                    "& img": { maxWidth: "100%", height: "auto", verticalAlign: "middle" },
                }}
            />
        </Box>
    );
};
 
export default GroupDropdownAnswer;