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

95.45% Statements 42/44
71.42% Branches 20/28
95.23% Functions 20/21
94.44% Lines 34/36

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                    5x 200x 200x 200x     200x                 50x                     5x 600x           5x 50x 50x 50x   81x 50x   50x         50x 25x                         50x 9x 9x 18x   3x           50x 14x           50x 50x         50x   50x                                                             100x                                   114x 100x                                         200x 200x                       7x                                    
import React, { useEffect, useRef, useState } from "react";
import { Box, Paper, Radio, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useDispatch, useSelector } from "react-redux";
import { _addMyAnswer } from "@/store/slices/student/StudentQuestionPage";
import ReadOnlyMathEditor from "@/utils/components/TinyMCETextEditorReadOnly";
import { sanitizeRichText } from "@/utils/helpers/sanitizeHtml";
import { STUDENT_QUESTION_FONT_SIZE, choiceItemBg, questionContentBg } from "../studentQuestionSurfaces";
import "../StudentQuestion.scss";
 
const unwrapBlock = (html) => {
    const el = document.createElement("div");
    el.innerHTML = html;
    Iif (el.children.length === 1 && el.children[0].tagName === "P") {
        return el.children[0].innerHTML;
    }
    return html;
};
 
// The app's global theme overrides (src/theme/ui/overrides/components/table.jsx) fight a fully
// outlined grid on two fronts: MuiTableCell gives every header cell a dashed bottom border and a
// `background.neutral` fill, and MuiTableRow clears the LAST row's cell borders entirely
// (`borderColor: transparent`, at higher specificity than a plain cell-level override) — both
// reasonable for an ordinary data table, but this is an answer grid, where every cell in every
// row, including the last, needs a visible boundary and the header shouldn't read as a tinted band.
const gridTableSx = (theme) => ({
    "& .MuiTableCell-root": { border: `1px solid ${theme.palette.divider}` },
    "& .MuiTableCell-head": { backgroundColor: "transparent" },
    "& .MuiTableRow-root:last-of-type .MuiTableCell-root": { borderColor: theme.palette.divider },
});
 
// The first (statement) column always gets exactly half the table, capped at
// that share even for long text — it never yields width to the option
// columns. The option columns split the OTHER half evenly between however
// many of them there are, so the two always sum to 100% regardless of column
// count.
const ROW_HEADER_COLUMN_WIDTH = "50%";
const optionColumnWidth = (columnCount) => `${50 / Math.max(columnCount, 1)}%`;
 
// A student picks one column per row. Submitted answer shape is the same
// id-keyed {id, answer} list Drag-and-Drop already uses — id = row id,
// answer = the chosen column's TEXT (not its id) — so grading needs no new
// backend logic (see answer_checking.py's _POSITION_SENSITIVE_TYPES).
const GridQuestion = ({ question }) => {
    const theme = useTheme();
    const dispatch = useDispatch();
    const containerRef = useRef(null);
 
    const { myAnswers } = useSelector((state) => state.StudentQuestionPage);
    const existingAnswer = myAnswers?.data?.find((item) => item.questionId === question._id);
 
    const [answers, setAnswers] = useState(existingAnswer?.answer || []);
 
    // Sync answer to Redux. Include question._id so switching questions
    // (paginated layout) doesn't dispatch this question's answers against the
    // previous question id — same guard DragAndDrop.jsx uses.
    useEffect(() => {
        dispatch(
            _addMyAnswer({
                questionId: question._id,
                questionType: "grid-question",
                answer: answers,
            })
        );
    }, [answers, question._id]);
 
    // Rebuild answers FRESH for the current question, restoring THIS
    // question's saved answer by row id — same re-sync-on-question-switch
    // pattern as DragAndDrop.jsx (a reused component instance must not leak a
    // previous question's selections into the next one, EI-2906).
    useEffect(() => {
        const saved = myAnswers?.data?.find((item) => item.questionId === question._id)?.answer || [];
        setAnswers(
            (question.rows ?? []).map((row) => ({
                id: row.id,
                answer: saved.find((a) => a.id === row.id)?.answer ?? "",
            }))
        );
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [question._id]);
 
    const handleSelect = (rowId, columnText) => {
        setAnswers((prev) => prev.map((a) => (a.id === rowId ? { ...a, answer: columnText } : a)));
    };
 
    // Typeset any \(...\) LaTeX in row/column text after each render — same
    // pattern EmbeddedMultipleChoice.jsx uses for plain-text math delimiters
    // (a MathLive formula chip, if any, is already pre-rendered).
    useEffect(() => {
        Iif (window.MathJax?.typesetPromise && containerRef.current) {
            window.MathJax.typesetPromise([containerRef.current]).catch(() => {});
        }
    });
 
    const questionBg = questionContentBg(theme);
 
    return (
        <Box className="student-grid-question">
            <Paper variant="outlined" className="student-question-content" sx={{ bgcolor: questionBg }}>
                <ReadOnlyMathEditor
                    content={question?.question}
                    backgroundColor={questionBg}
                    fontSize={STUDENT_QUESTION_FONT_SIZE}
                />
            </Paper>
 
            <TableContainer
                ref={containerRef}
                component={Paper}
                variant="outlined"
                sx={{ bgcolor: questionBg, fontSize: STUDENT_QUESTION_FONT_SIZE, mt: 2 }}
            >
                <Table sx={{ ...gridTableSx(theme), tableLayout: "fixed" }}>
                    <TableHead>
                        <TableRow>
                            <TableCell
                                sx={{
                                    width: ROW_HEADER_COLUMN_WIDTH,
                                    maxWidth: ROW_HEADER_COLUMN_WIDTH,
                                    fontWeight: 600,
                                }}
                            >
                                <Box component="span" sx={{ ml: 1 }}>
                                    {question.rowHeaderLabel || "Statement"}
                                </Box>
                            </TableCell>
                            {(question.choices ?? []).map((column) => (
                                <TableCell
                                    key={column.id}
                                    align="center"
                                    sx={{
                                        width: optionColumnWidth(question.choices?.length),
                                        maxWidth: optionColumnWidth(question.choices?.length),
                                    }}
                                >
                                    <Box
                                        dangerouslySetInnerHTML={{ __html: sanitizeRichText(unwrapBlock(column.text)) }}
                                        sx={{ fontWeight: 600, "& p": { margin: 0 } }}
                                    />
                                </TableCell>
                            ))}
                        </TableRow>
                    </TableHead>
                    <TableBody>
                        {(question.rows ?? []).map((row) => {
                            const selectedAnswer = answers.find((a) => a.id === row.id)?.answer ?? "";
                            return (
                                <TableRow key={row.id}>
                                    <TableCell
                                        sx={{
                                            width: ROW_HEADER_COLUMN_WIDTH,
                                            maxWidth: ROW_HEADER_COLUMN_WIDTH,
                                        }}
                                    >
                                        <Box
                                            dangerouslySetInnerHTML={{
                                                __html: sanitizeRichText(unwrapBlock(row.text)),
                                            }}
                                            sx={{
                                                ml: 1,
                                                overflowWrap: "break-word",
                                                wordBreak: "break-word",
                                                "& p": { margin: 0 },
                                            }}
                                        />
                                    </TableCell>
                                    {(question.choices ?? []).map((column) => {
                                        const isSelected = selectedAnswer === column.text;
                                        return (
                                            <TableCell
                                                key={column.id}
                                                align="center"
                                                sx={{
                                                    width: optionColumnWidth(question.choices?.length),
                                                    maxWidth: optionColumnWidth(question.choices?.length),
                                                    bgcolor: choiceItemBg(theme, { selected: isSelected }),
                                                }}
                                            >
                                                <Radio
                                                    checked={isSelected}
                                                    onChange={() => handleSelect(row.id, column.text)}
                                                    name={`grid-question-row-${row.id}`}
                                                    value={column.id}
                                                />
                                            </TableCell>
                                        );
                                    })}
                                </TableRow>
                            );
                        })}
                    </TableBody>
                </Table>
            </TableContainer>
        </Box>
    );
};
 
export default GridQuestion;