All files / src/app/student-page/page/assignments/take-assignment/question-list/drop-down-menu DropDownMenu.jsx

85.18% Statements 69/81
64.1% Branches 25/39
88.46% Functions 23/26
92.18% Lines 59/64

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                  3x 11x 11x 11x 11x     3x 16x 16x     3x       3x                           4x 4x             1x   4x         3x 4x 4x 4x   4x 4x   4x 1x 1x 1x             4x   4x 3x                         4x 2x   2x 2x                 4x 4x 4x 4x 4x 4x 4x 4x   4x   8x 8x       4x                                                               4x 4x 4x       4x 4x 4x 4x   4x 4x 4x 4x   4x             4x 4x     4x     4x                                                              
import React, { useState, useEffect, useRef } from "react";
import { Box, Paper } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useDispatch, useSelector } from "react-redux";
import { _addMyAnswer } from "@/store/slices/student/StudentQuestionPage";
import { sanitizeRichText } from "@/utils/helpers/sanitizeHtml";
import { STUDENT_QUESTION_FONT_SIZE, dropdownSelectColors, questionContentBg } from "../studentQuestionSurfaces";
import "../StudentQuestion.scss";
 
const stripHtml = (text) => {
    Iif (!text || typeof text !== "string") return "";
    const el = document.createElement("div");
    el.innerHTML = text;
    return el.textContent || el.innerText || "";
};
 
const escapeAttr = (str) => {
    Iif (!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 — kept in sync by hand
// since both come from the same raw HTML string, not shared CSS.
const SELECT_CHROME_WIDTH = 4 + 26 + 2 + 2;
let measureCtx;
 
/**
 * A native <select> with no explicit width sizes itself to its WIDEST
 * <option> — not the option currently displayed — so after picking a short
 * answer from a list that also has longer ones, the box stayed as wide as
 * the longest option (or the "Select an answer" placeholder), leaving a gap
 * between the short text and the arrow that looked like unfitted padding.
 * Measuring only the currently-shown text and setting an explicit width
 * makes the box track what's actually displayed, recomputed on every
 * selection change since buildSelectHTML reruns from render.
 */
function measureSelectWidth(text) {
    Iif (typeof document === "undefined") return SELECT_CHROME_WIDTH;
    if (measureCtx === undefined) {
        // getContext("2d") returns NULL where canvas is unavailable — jsdom
        // without the canvas package, and any browser with canvas disabled.
        // The old guard only checked whether measureCtx was falsy before
        // ASSIGNING it, then set .font unconditionally, so a null context threw
        // "Cannot set properties of null (setting 'font')" and took down the
        // whole DropDownMenu render. Cache null so we probe once, not per call.
        measureCtx = document.createElement("canvas").getContext("2d") || null;
    }
    Eif (!measureCtx) return SELECT_CHROME_WIDTH;
    measureCtx.font = SELECT_FONT;
    return Math.ceil(measureCtx.measureText(text).width) + SELECT_CHROME_WIDTH;
}
 
const DropDownMenu = ({ question }) => {
    const theme = useTheme();
    const dispatch = useDispatch();
    const { question: questionText, choices } = question;
 
    const { myAnswers } = useSelector((state) => state.StudentQuestionPage);
    const existingAnswer = myAnswers?.data?.find((item) => item.questionId === question._id);
 
    const [selectedValues, setSelectedValues] = useState(() =>
        choices.map((choice) => {
            const answer = existingAnswer?.answer?.find?.((a) => a.id === choice.id)?.answer;
            return {
                id: choice.id,
                answer: Array.isArray(existingAnswer?.answer) ? answer || "" : "",
            };
        })
    );
 
    const containerRef = useRef(null);
 
    useEffect(() => {
        dispatch(
            _addMyAnswer({
                questionId: question._id,
                questionType: "drop-down-menu",
                answer: selectedValues,
            })
        );
    }, [selectedValues, dispatch]);
 
    // Reset selections on question switch — the lazy useState init only runs
    // once, so a reused instance in the paginated layout would otherwise keep
    // the previous question's choices. (EI-2906 cross-question bleed —
    // defense-in-depth alongside the per-question key.)
    useEffect(() => {
        setSelectedValues(
            choices.map((choice) => {
                const answer = existingAnswer?.answer?.find?.((a) => a.id === choice.id)?.answer;
                return {
                    id: choice.id,
                    answer: Array.isArray(existingAnswer?.answer) ? answer || "" : "",
                };
            })
        );
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [question._id]);
 
    const buildSelectHTML = (index) => {
        const choice = choices[index];
        Iif (!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 (
            // No min-width — the select hugs its current option's text, same as
            // the embedded-multiple-choice mark style (tinyMCEUtility's
            // buildEmbeddedChoiceHTML / EmbeddedMultipleChoice.jsx), instead of
            // forcing every blank to a fixed 160px box regardless of content.
            //
            // font-size:inherit, not a fixed 14px — DragAndDrop's blanks already
            // inherit the 20px question-stem size, so a hardcoded 14px select made
            // this blank type look shrunk relative to both its own "test" label and
            // its drag-and-drop sibling. Matches EmbeddedMultipleChoice's marks too.
            // The url()'s single quotes are percent-encoded (%27) and the url()
            // itself is wrapped in single quotes: the outer HTML `style="..."`
            // attribute is double-quoted, and a literal `"` inside it (from the
            // old `url("data:...")`) closed that attribute early — the browser
            // dropped everything from background-image onward (arrow never
            // rendered) and reparsed the leftover SVG markup as bogus attributes
            // on the <select> itself. That's what made the reserved arrow gutter
            // on the right look like unexplained empty padding.
            `<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>`
        );
    };
 
    // Replace __blank__ markers in the full HTML string so paragraph structure and
    // inline formatting from TinyMCE are preserved.
    const buildContent = () => {
        let dropIndex = 0;
        return questionText.replace(/__blank__/g, () => buildSelectHTML(dropIndex++));
    };
 
    // Attach DOM change listeners after every render so they stay in sync with selectedValues.
    useEffect(() => {
        const container = containerRef.current;
        Iif (!container) return;
        const cleanup = [];
 
        container.querySelectorAll("select[data-dropdown-index]").forEach((select) => {
            const index = parseInt(select.getAttribute("data-dropdown-index"), 10);
            const choice = choices[index];
            Iif (!choice) return;
 
            const onChange = (e) => {
                const value = e.target.value;
                setSelectedValues((prev) =>
                    prev.map((item) => (item.id === choice.id ? { ...item, answer: value } : item))
                );
            };
 
            select.addEventListener("change", onChange);
            cleanup.push(() => select.removeEventListener("change", onChange));
        });
 
        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 DropDownMenu;