This commit is contained in:
rgrgogu
2026-07-19 10:49:56 +08:00
parent 6d9e8e1db9
commit bd7200a9cf
3 changed files with 36 additions and 7 deletions
+4 -4
View File
@@ -614,9 +614,9 @@ export function CoursesProvider({ children }) {
); );
const bulkSyncQuizQuestions = useCallback( const bulkSyncQuizQuestions = useCallback(
(courseId, unitId, quizId, questions, updatedBy) => (courseId, unitId, quizId, questions, updatedBy, confirmFullReplace = false) =>
request(async () => { request(async () => {
const { data } = await api.put(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/bulk-sync`, { questions, updatedBy }); const { data } = await api.put(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/bulk-sync`, { questions, updatedBy, confirmFullReplace });
const result = data?.data?.data ?? []; const result = data?.data?.data ?? [];
setQuestions(result); setQuestions(result);
toast("Quiz saved."); toast("Quiz saved.");
@@ -975,9 +975,9 @@ export function CoursesProvider({ children }) {
); );
const bulkSyncAssessmentQuestions = useCallback( const bulkSyncAssessmentQuestions = useCallback(
(courseId, assessmentId, questions, updatedBy) => (courseId, assessmentId, questions, updatedBy, confirmFullReplace = false) =>
request(async () => { request(async () => {
const { data } = await api.put(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk-sync`, { questions, updatedBy }); const { data } = await api.put(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk-sync`, { questions, updatedBy, confirmFullReplace });
const result = data?.data?.data ?? []; const result = data?.data?.data ?? [];
setQuestions(result); setQuestions(result);
toast("Assessment saved."); toast("Assessment saved.");
@@ -233,6 +233,7 @@ export default function CourseAssessment() {
const [confirmLoading, setConfirmLoading] = useState(false); const [confirmLoading, setConfirmLoading] = useState(false);
const pendingSaveRef = useRef(null); // stores the meta+questions payload until confirmed const pendingSaveRef = useRef(null); // stores the meta+questions payload until confirmed
const initialSnapshot = useRef(null); const initialSnapshot = useRef(null);
const originalQuestionIdsRef = useRef([]); // ids loaded from the server — used to detect a full-pool wipe on save
const questionRefs = useRef([]); const questionRefs = useRef([]);
const navItemRefs = useRef([]); const navItemRefs = useRef([]);
@@ -271,6 +272,7 @@ export default function CourseAssessment() {
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir); setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs); setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs }); initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs });
originalQuestionIdsRef.current = qs.map((q) => q.question_id).filter(Boolean);
}, [localAssessment]); }, [localAssessment]);
// ── Measure sticky header → --assessment-h ──────────────────────────────── // ── Measure sticky header → --assessment-h ────────────────────────────────
@@ -430,14 +432,28 @@ export default function CourseAssessment() {
let id = assessmentId; let id = assessmentId;
if (!id) { if (!id) {
const res = await createAssessment(courseId, meta); const res = await createAssessment(courseId, meta);
id = res?.data?.data?.data?.assessment_id; // res is the raw response body: { status, message, data: { data: assessment } }
// (createAssessment() itself unwraps the same object as data?.data?.data to get
// the assessment row — this needs the same two levels, not three).
id = res?.data?.data?.assessment_id;
if (!id) return; if (!id) return;
setLocalAssessment((prev) => ({ ...prev, assessment_id: id })); setLocalAssessment((prev) => ({ ...prev, assessment_id: id }));
} else { } else {
await updateAssessment(courseId, id, meta); await updateAssessment(courseId, id, meta);
} }
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id); // A save that keeps none of the previously-loaded question ids would archive the
// entire existing pool in one call — could be a deliberate "start over", but is
// exactly the signature of a stale/incomplete state bug. Require explicit confirmation.
const retainedIds = questions.map((q) => q.question_id).filter(Boolean);
const wipesPool = originalQuestionIdsRef.current.length > 0 && retainedIds.length === 0;
if (wipesPool && !window.confirm(
`This will permanently remove all ${originalQuestionIdsRef.current.length} existing question(s) from this assessment. Continue?`
)) {
return;
}
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id, wipesPool);
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }); initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
}; };
@@ -222,6 +222,7 @@ export default function ModifyQuiz() {
const navContainerRef = useRef(null); const navContainerRef = useRef(null);
const headerRef = useRef(null); const headerRef = useRef(null);
const initialSnapshot = useRef(null); const initialSnapshot = useRef(null);
const originalQuestionIdsRef = useRef([]); // ids loaded from the server — used to detect a full-pool wipe on save
// Junction revamp — this builder runs course-scoped AND from the standalone // Junction revamp — this builder runs course-scoped AND from the standalone
// Unit Library (/admin/units/:unitId/quiz/edit, no :courseId param). // Unit Library (/admin/units/:unitId/quiz/edit, no :courseId param).
@@ -273,6 +274,7 @@ export default function ModifyQuiz() {
const qs = (localQuiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] })); const qs = (localQuiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs); setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs }); initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs });
originalQuestionIdsRef.current = qs.map((q) => q.question_id).filter(Boolean);
}, [localQuiz]); }, [localQuiz]);
// ── Measure sticky header → --quiz-h ────────────────────────────────────── // ── Measure sticky header → --quiz-h ──────────────────────────────────────
@@ -416,7 +418,18 @@ export default function ModifyQuiz() {
await updateQuiz(courseId, unitId, quizId, meta); await updateQuiz(courseId, unitId, quizId, meta);
} }
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id); // A save that keeps none of the previously-loaded question ids would archive the
// entire existing pool in one call — could be a deliberate "start over", but is
// exactly the signature of a stale/incomplete state bug. Require explicit confirmation.
const retainedIds = questions.map((q) => q.question_id).filter(Boolean);
const wipesPool = originalQuestionIdsRef.current.length > 0 && retainedIds.length === 0;
if (wipesPool && !window.confirm(
`This will permanently remove all ${originalQuestionIdsRef.current.length} existing question(s) from this quiz. Continue?`
)) {
return;
}
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id, wipesPool);
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }); initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
navigate(`${scopeBase}/view`); navigate(`${scopeBase}/view`);