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;
|