mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added draft for quiz and assessment unfinish
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
|
||||
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2 } from "lucide-react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
@@ -34,6 +34,22 @@ function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestio
|
||||
});
|
||||
}
|
||||
|
||||
// ── Server row → form state ──────────────────────────────────────────────────
|
||||
|
||||
function toFormState(a) {
|
||||
return {
|
||||
title: a?.title ?? "",
|
||||
passingScore: a?.passing_score ?? 70,
|
||||
timeLimit: a?.time_limit_minutes ?? "",
|
||||
isRequired: a?.is_required === true || a?.is_required === 1,
|
||||
maxQuestions: a?.max_questions ?? "",
|
||||
maxAttempts: a?.max_attempts ?? 3,
|
||||
cooldownHours: a?.cooldown_hours ?? 24,
|
||||
shuffleQuestions: a?.shuffle_questions === true || a?.shuffle_questions === 1,
|
||||
questions: (a?.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] })),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
function validate(questions) {
|
||||
@@ -213,6 +229,9 @@ export default function CourseAssessment() {
|
||||
const [localAssessment, setLocalAssessment] = useState(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
const DRAFT_KEY = `draftAssessment_${courseId}`;
|
||||
const [draftInfo, setDraftInfo] = useState(null); // { savedAt } — non-null while an unsaved local draft exists
|
||||
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [errors, setErrors] = useState({});
|
||||
@@ -260,21 +279,39 @@ export default function CourseAssessment() {
|
||||
// ── Seed ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!localAssessment) return;
|
||||
const t = localAssessment.title ?? "";
|
||||
const ps = localAssessment.passing_score ?? 70;
|
||||
const tl = localAssessment.time_limit_minutes ?? "";
|
||||
const ir = localAssessment.is_required === true || localAssessment.is_required === 1;
|
||||
const mq = localAssessment.max_questions ?? "";
|
||||
const ma = localAssessment.max_attempts ?? 3;
|
||||
const ch = localAssessment.cooldown_hours ?? 24;
|
||||
const sq = localAssessment.shuffle_questions === true || localAssessment.shuffle_questions === 1;
|
||||
const qs = (localAssessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
||||
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
|
||||
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 });
|
||||
originalQuestionIdsRef.current = qs.map((q) => q.question_id).filter(Boolean);
|
||||
const s = toFormState(localAssessment);
|
||||
setTitle(s.title); setPassingScore(s.passingScore); setTimeLimit(s.timeLimit); setIsRequired(s.isRequired);
|
||||
setMaxQuestions(s.maxQuestions); setMaxAttempts(s.maxAttempts); setCooldownHours(s.cooldownHours);
|
||||
setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
|
||||
initialSnapshot.current = snapAssessment(s);
|
||||
originalQuestionIdsRef.current = s.questions.map((q) => q.question_id).filter(Boolean);
|
||||
}, [localAssessment]);
|
||||
|
||||
// ── Restore an unsaved local draft (survives a reload before Save Assessment) ─
|
||||
useEffect(() => {
|
||||
if (initializing) return;
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft = JSON.parse(raw);
|
||||
if (!draft?.questions) return;
|
||||
setTitle(draft.title ?? "");
|
||||
setPassingScore(draft.passingScore ?? 70);
|
||||
setTimeLimit(draft.timeLimit ?? "");
|
||||
setIsRequired(draft.isRequired ?? false);
|
||||
setMaxQuestions(draft.maxQuestions ?? "");
|
||||
setMaxAttempts(draft.maxAttempts ?? 3);
|
||||
setCooldownHours(draft.cooldownHours ?? 24);
|
||||
setShuffleQuestions(draft.shuffleQuestions ?? false);
|
||||
setQuestions(draft.questions ?? []);
|
||||
setDraftInfo({ savedAt: draft.savedAt });
|
||||
toast("Restored an unsaved draft from this browser.");
|
||||
} catch {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initializing]);
|
||||
|
||||
// ── Measure sticky header → --assessment-h ────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!headerRef.current) return;
|
||||
@@ -384,6 +421,38 @@ export default function CourseAssessment() {
|
||||
? questions.length > 0
|
||||
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) !== initialSnapshot.current;
|
||||
|
||||
// ── Auto-save unsaved changes to localStorage as a draft ───────────────────
|
||||
useEffect(() => {
|
||||
if (initializing) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (isDirty) {
|
||||
const payload = { title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions, savedAt: Date.now() };
|
||||
try {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload));
|
||||
setDraftInfo({ savedAt: payload.savedAt });
|
||||
} catch {
|
||||
// localStorage full/unavailable — draft persistence is best-effort only.
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
setDraftInfo(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [initializing, isDirty, title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions, DRAFT_KEY]);
|
||||
|
||||
// ── Clear draft — discards the local draft and reverts to the last loaded state ─
|
||||
const handleClearDraft = () => {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
setDraftInfo(null);
|
||||
const s = toFormState(localAssessment);
|
||||
setTitle(s.title); setPassingScore(s.passingScore); setTimeLimit(s.timeLimit); setIsRequired(s.isRequired);
|
||||
setMaxQuestions(s.maxQuestions); setMaxAttempts(s.maxAttempts); setCooldownHours(s.cooldownHours);
|
||||
setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
|
||||
setErrors({});
|
||||
toast("Draft cleared.");
|
||||
};
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
const errs = validate(questions);
|
||||
@@ -456,6 +525,8 @@ export default function CourseAssessment() {
|
||||
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id, wipesPool);
|
||||
|
||||
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
setDraftInfo(null);
|
||||
};
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
@@ -478,7 +549,7 @@ export default function CourseAssessment() {
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -491,6 +562,22 @@ export default function CourseAssessment() {
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
{draftInfo && (
|
||||
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span className="size-2 rounded-full bg-amber-500" />
|
||||
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClearDraft}
|
||||
>
|
||||
<Trash2 />
|
||||
Clear draft
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={loading || confirmLoading || !isDirty}>
|
||||
{(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Assessment
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
@@ -49,6 +49,19 @@ function snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuesti
|
||||
});
|
||||
}
|
||||
|
||||
// ── Server row → form state ──────────────────────────────────────────────────
|
||||
|
||||
function toFormState(q) {
|
||||
return {
|
||||
title: q?.title ?? "",
|
||||
passingScore: q?.passing_score ?? 70,
|
||||
isRequired: q?.is_required === true || q?.is_required === 1,
|
||||
maxQuestions: q?.max_questions ?? "",
|
||||
shuffleQuestions: q?.shuffle_questions === true || q?.shuffle_questions === 1,
|
||||
questions: (q?.questions ?? []).map((qq) => ({ ...qq, _tempId: qq.question_id, options: qq.options ?? [] })),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Jump to input ─────────────────────────────────────────────────────────────
|
||||
|
||||
function JumpToInput({ max, onJump }) {
|
||||
@@ -206,6 +219,9 @@ export default function ModifyQuiz() {
|
||||
const [localQuiz, setLocalQuiz] = useState(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
const DRAFT_KEY = `draftQuiz_${courseId ?? "lib"}_${unitId}`;
|
||||
const [draftInfo, setDraftInfo] = useState(null); // { savedAt } — non-null while an unsaved local draft exists
|
||||
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [errors, setErrors] = useState({});
|
||||
@@ -266,17 +282,35 @@ export default function ModifyQuiz() {
|
||||
// ── Seed form from fetched quiz ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!localQuiz) return;
|
||||
const t = localQuiz.title ?? "";
|
||||
const ps = localQuiz.passing_score ?? 70;
|
||||
const ir = localQuiz.is_required === true || localQuiz.is_required === 1;
|
||||
const mq = localQuiz.max_questions ?? "";
|
||||
const sq = localQuiz.shuffle_questions === true || localQuiz.shuffle_questions === 1;
|
||||
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);
|
||||
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);
|
||||
const s = toFormState(localQuiz);
|
||||
setTitle(s.title); setPassingScore(s.passingScore); setIsRequired(s.isRequired);
|
||||
setMaxQuestions(s.maxQuestions); setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
|
||||
initialSnapshot.current = snapQuiz(s);
|
||||
originalQuestionIdsRef.current = s.questions.map((q) => q.question_id).filter(Boolean);
|
||||
}, [localQuiz]);
|
||||
|
||||
// ── Restore an unsaved local draft (survives a reload before Save Quiz) ────
|
||||
useEffect(() => {
|
||||
if (initializing) return;
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft = JSON.parse(raw);
|
||||
if (!draft?.questions) return;
|
||||
setTitle(draft.title ?? "");
|
||||
setPassingScore(draft.passingScore ?? 70);
|
||||
setIsRequired(draft.isRequired ?? false);
|
||||
setMaxQuestions(draft.maxQuestions ?? "");
|
||||
setShuffleQuestions(draft.shuffleQuestions ?? false);
|
||||
setQuestions(draft.questions ?? []);
|
||||
setDraftInfo({ savedAt: draft.savedAt });
|
||||
toast("Restored an unsaved draft from this browser.");
|
||||
} catch {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initializing]);
|
||||
|
||||
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!headerRef.current) return;
|
||||
@@ -386,6 +420,37 @@ export default function ModifyQuiz() {
|
||||
? questions.length > 0 // new quiz — enable once they've added a question
|
||||
: snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) !== initialSnapshot.current;
|
||||
|
||||
// ── Auto-save unsaved changes to localStorage as a draft ───────────────────
|
||||
useEffect(() => {
|
||||
if (initializing) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (isDirty) {
|
||||
const payload = { title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions, savedAt: Date.now() };
|
||||
try {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload));
|
||||
setDraftInfo({ savedAt: payload.savedAt });
|
||||
} catch {
|
||||
// localStorage full/unavailable — draft persistence is best-effort only.
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
setDraftInfo(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [initializing, isDirty, title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions, DRAFT_KEY]);
|
||||
|
||||
// ── Clear draft — discards the local draft and reverts to the last loaded state ─
|
||||
const handleClearDraft = () => {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
setDraftInfo(null);
|
||||
const s = toFormState(localQuiz);
|
||||
setTitle(s.title); setPassingScore(s.passingScore); setIsRequired(s.isRequired);
|
||||
setMaxQuestions(s.maxQuestions); setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
|
||||
setErrors({});
|
||||
toast("Draft cleared.");
|
||||
};
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
const errs = validate(questions);
|
||||
@@ -432,6 +497,8 @@ export default function ModifyQuiz() {
|
||||
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id, wipesPool);
|
||||
|
||||
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
setDraftInfo(null);
|
||||
navigate(`${scopeBase}/view`);
|
||||
};
|
||||
|
||||
@@ -460,6 +527,24 @@ export default function ModifyQuiz() {
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
{draftInfo && (
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={handleClearDraft}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Clear draft
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={loading || !isDirty}>
|
||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Quiz
|
||||
|
||||
Reference in New Issue
Block a user