/* ==================================================================
Manual Paper Authoring — section/question composer
Routed via: Test Library → "Create test" → Test Details modal → here
================================================================== */
const { Icon: MIcon, StatusPill: MStatusPill } = window.AppUI;
const TYPE_OPTIONS = [
{ value: "MCQ", label: "MCQ", defaultMarks: 1 },
{ value: "Short", label: "Short", defaultMarks: 2 },
{ value: "Long", label: "Long", defaultMarks: 5 },
{ value: "TF", label: "True/False", defaultMarks: 1 },
{ value: "Fill", label: "Fill blank", defaultMarks: 1 },
{ value: "Match", label: "Match", defaultMarks: 2 },
];
const SECTION_NAMES = ["A","B","C","D","E","F","G","H"];
let _uidCounter = 0;
const uid = (prefix) => `${prefix}-${Date.now().toString(36)}-${++_uidCounter}`;
const newSubQuestion = () => ({ id: uid("sq"), text: "" });
const newQuestion = (type = "MCQ") => ({
id: uid("q"),
text: "",
type,
options: type === "MCQ" || type === "TF"
? (type === "TF"
? [{ id:"a", text:"True", correct:false }, { id:"b", text:"False", correct:false }]
: [{ id:"a", text:"", correct:false }, { id:"b", text:"", correct:false }, { id:"c", text:"", correct:false }, { id:"d", text:"", correct:false }])
: [],
subQuestions: type === "Long" ? [newSubQuestion(), newSubQuestion()] : [],
expectedAnswer: "",
concepts: [],
marks: null, // when null, inherits from section.marks
});
const newSection = (idx) => ({
id: uid("s"),
label: `Section ${SECTION_NAMES[idx] || (idx + 1)}`,
type: "MCQ",
marks: 1,
target: 5,
instructions: "",
questions: [newQuestion("MCQ")],
});
/* ---------- main component ---------- */
const ManualAuthoring = ({ meta, initialSections, onSave, onCancel }) => {
const [title, setTitle] = useState(meta?.title || "Untitled test");
const [sections, setSections] = useState(() => (initialSections && initialSections.length) ? initialSections : [newSection(0)]);
const [editing, setEditing] = useState(null); // { sectionId, questionId }
const [savedAt, setSavedAt] = useState(null);
const [showSaveConfirm, setShowSaveConfirm] = useState(false);
/* ------- derived stats ------- */
const totalQ = sections.reduce((a, s) => a + s.questions.length, 0);
const totalMarks = sections.reduce(
(a, s) => a + s.questions.reduce((b, q) => b + (q.marks ?? s.marks), 0),
0
);
const targetQ = sections.reduce((a, s) => a + s.target, 0);
const emptyQuestions = sections.reduce(
(a, s) => a + s.questions.filter(q => !q.text.trim()).length,
0
);
const validation = {
emptyQ: emptyQuestions,
underTarget: sections.filter(s => s.questions.length < s.target).length,
mcqMissingCorrect: sections.reduce((a, s) =>
a + s.questions.filter(q =>
(q.type === "MCQ" || q.type === "TF") && !q.options.some(o => o.correct)
).length, 0),
};
const blocking = validation.emptyQ + validation.mcqMissingCorrect;
/* ------- autosave indicator (cosmetic) ------- */
useEffect(() => {
const t = setTimeout(() => setSavedAt(new Date()), 600);
return () => clearTimeout(t);
}, [sections, title]);
/* ------- section mutations ------- */
const updateSection = (id, patch) =>
setSections(s => s.map(x => x.id === id ? { ...x, ...patch } : x));
const insertSection = (afterIdx) => {
setSections(s => {
const copy = [...s];
copy.splice(afterIdx + 1, 0, newSection(copy.length));
return copy;
});
};
const deleteSection = (id) =>
setSections(s => s.length > 1 ? s.filter(x => x.id !== id) : s);
/* ------- question mutations ------- */
const updateQuestion = (sid, qid, patch) =>
setSections(s => s.map(x => x.id !== sid ? x : {
...x, questions: x.questions.map(q => q.id === qid ? { ...q, ...patch } : q)
}));
const addQuestion = (sid) =>
setSections(s => s.map(x => x.id !== sid ? x : {
...x, questions: [...x.questions, newQuestion(x.type)]
}));
const deleteQuestion = (sid, qid) =>
setSections(s => s.map(x => x.id !== sid ? x : {
...x, questions: x.questions.filter(q => q.id !== qid)
}));
const duplicateQuestion = (sid, qid) =>
setSections(s => s.map(x => x.id !== sid ? x : {
...x, questions: x.questions.flatMap(q => q.id === qid ? [q, { ...q, id: uid("q") }] : [q])
}));
/* ------- on section type change, normalise question types & options ------- */
const changeSectionType = (sid, newType) => {
setSections(s => s.map(x => {
if (x.id !== sid) return x;
const defMarks = TYPE_OPTIONS.find(t => t.value === newType)?.defaultMarks ?? x.marks;
return {
...x, type: newType, marks: defMarks,
questions: x.questions.map(q => {
if (q.type === newType) return q;
const fresh = newQuestion(newType);
return { ...fresh, id: q.id, text: q.text, concepts: q.concepts };
}),
};
}));
};
/* ------- save ------- */
const doSave = async (asDraft) => {
if (!asDraft && blocking > 0) {
setShowSaveConfirm(true);
return;
}
const newTest = {
title: title || "Untitled test",
class_section_ids: meta.class_section_ids,
className: meta.className,
subject: meta.subject,
topic: meta.topic || "—",
chapter: meta.chapter || "—",
date: meta.date,
status: asDraft ? "draft" : "live",
questions: totalQ,
students: 0, reviewed: 0,
language: meta.language || "en",
_payload: { title, meta, sections, asDraft },
};
try { await onSave(newTest); }
catch { /* parent already surfaced the error; stay on the authoring screen */ }
};
const editingSection = editing ? sections.find(s => s.id === editing.sectionId) : null;
const editingQ = editingSection?.questions.find(q => q.id === editing?.questionId);
return (
{/* ----- sticky top bar ----- */}
Test Library
New test · manual
setTitle(e.target.value)}
style={{ background:"transparent", border:"none", outline:"none", color:"var(--ink-0)", fontSize:14, fontWeight:600, padding:"2px 4px", borderRadius:3, minWidth:260, width:`${Math.max(260, title.length * 9)}px` }}
onFocus={e => e.target.style.background = "var(--bg-2)"}
onBlur={e => e.target.style.background = "transparent"}
/>
Class {meta.className}
{meta.subject}
{meta.chapter && {meta.chapter} }
{meta.date}
{savedAt ? `Autosaved ${savedAt.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}` : "Editing…"}
Cancel
doSave(true)}>Save draft
doSave(false)} disabled={totalQ === 0}>
Save test
{/* ----- main split ----- */}
{/* LEFT: outline summary */}
{
const el = document.querySelector(`[data-sec-id="${sid}"]`);
if (el) el.scrollIntoView({ block:"start" });
}}
/>
{/* RIGHT: workspace */}
{sections.map((sec, idx) => (
updateSection(sec.id, patch)}
onTypeChange={(t) => changeSectionType(sec.id, t)}
onDelete={() => deleteSection(sec.id)}
canDelete={sections.length > 1}
onUpdateQuestion={(qid, patch) => updateQuestion(sec.id, qid, patch)}
onAddQuestion={() => addQuestion(sec.id)}
onDeleteQuestion={(qid) => deleteQuestion(sec.id, qid)}
onDuplicateQuestion={(qid) => duplicateQuestion(sec.id, qid)}
onEditQuestion={(qid) => setEditing({ sectionId: sec.id, questionId: qid })}
questionStartIndex={sections.slice(0, idx).reduce((a, s) => a + s.questions.length, 0)}
/>
insertSection(idx)} />
))}
{/* footer save */}
doSave(true)}>Save as draft
doSave(false)} disabled={totalQ === 0}>
Save test
{/* Edit question drawer */}
{editing && editingQ && (
updateQuestion(editing.sectionId, editing.questionId, patch)}
onClose={() => setEditing(null)}
onDelete={() => { deleteQuestion(editing.sectionId, editing.questionId); setEditing(null); }}
/>
)}
{/* Save-with-issues confirmation */}
{showSaveConfirm && (
setShowSaveConfirm(false)}
onSaveDraft={() => { setShowSaveConfirm(false); doSave(true); }}
/>
)}
);
};
/* ---------- Left outline panel ---------- */
const ManualOutline = ({ title, meta, sections, totalQ, totalMarks, targetQ, validation, onJump }) => (
Paper outline
Test
{title || "Untitled test"}
Class {meta.className} · {meta.subject}
{meta.chapter ? ` · ${meta.chapter}` : ""}
0 ? "var(--red)" : "var(--green)"}/>
Sections
{sections.map((s, i) => {
const sMarks = s.questions.reduce((a, q) => a + (q.marks ?? s.marks), 0);
const onTrack = s.questions.length >= s.target;
return (
onJump(s.id)}
style={{ padding:"10px 14px", borderBottom: i < sections.length - 1 ? "1px solid var(--line-soft)" : "none", cursor:"pointer", display:"flex", alignItems:"center", gap:10 }}
onMouseEnter={e => e.currentTarget.style.background = "var(--bg-2)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
{SECTION_NAMES[i] || i + 1}
{s.label}
{s.type} · {s.questions.length}/{s.target} Q · {sMarks} marks
);
})}
{(validation.emptyQ + validation.mcqMissingCorrect + validation.underTarget) > 0 && (
Pre-save checks
{validation.emptyQ > 0 && (
{validation.emptyQ} question{validation.emptyQ === 1 ? "" : "s"} missing text
)}
{validation.mcqMissingCorrect > 0 && (
{validation.mcqMissingCorrect} MCQ{validation.mcqMissingCorrect === 1 ? "" : "s"} without correct answer
)}
{validation.underTarget > 0 && (
{validation.underTarget} section{validation.underTarget === 1 ? "" : "s"} under target count
)}
)}
Tips: Click any question to edit its stem, options, and concept tags. Sections inherit marks per Q; override per question in the editor.
);
const Stat3 = ({ label, value, sub, accent }) => (
{label}
{value}
{sub &&
{sub}
}
);
/* ---------- Section card ---------- */
const SectionCard = ({
section, index,
onUpdate, onTypeChange, onDelete, canDelete,
onUpdateQuestion, onAddQuestion, onDeleteQuestion, onDuplicateQuestion, onEditQuestion,
questionStartIndex,
}) => (
{/* section header row — orange-tinted, matches wireframe */}
onUpdate({ label: e.target.value })}
style={{ ...inputStyle, color:"var(--accent)", fontWeight:600 }}
/>
onTypeChange(e.target.value)} style={inputStyle}>
{TYPE_OPTIONS.map(t => {t.label} )}
onUpdate({ marks: parseInt(e.target.value) || 1 })}
style={inputStyle}/>
onUpdate({ target: parseInt(e.target.value) || 1 })}
style={inputStyle}/>
{/* instructions row (optional) */}
Instructions:
onUpdate({ instructions: e.target.value })}
placeholder={`e.g. ${section.type === "MCQ" ? "Tick the correct option." : "Answer in 2–3 sentences."}`}
style={{ flex:1, background:"transparent", border:"none", borderBottom:"1px dashed var(--line-strong)", color:"var(--ink-1)", fontSize:12, padding:"3px 0", outline:"none", fontStyle: section.instructions ? "normal" : "italic" }}/>
{section.questions.length}/{section.target} Q · {section.questions.reduce((a,q) => a + (q.marks ?? section.marks), 0)} marks
{/* questions */}
{section.questions.map((q, i) => (
onUpdateQuestion(q.id, patch)}
onEdit={() => onEditQuestion(q.id)}
onDelete={() => onDeleteQuestion(q.id)}
onDuplicate={() => onDuplicateQuestion(q.id)}
canDelete={section.questions.length > 1}
/>
))}
{/* add question button — green pill (matches wireframe) */}
e.currentTarget.style.background = "color-mix(in srgb, var(--green-solid) 22%, transparent)"}
onMouseLeave={e => e.currentTarget.style.background = "color-mix(in srgb, var(--green-solid) 12%, transparent)"}>
Add question
);
const SectionField = ({ label, children }) => (
{label}
{children}
);
const inputStyle = {
width:"100%",
background:"var(--bg-1)",
border:"1px solid var(--line-strong)",
borderRadius:5,
padding:"6px 9px",
color:"var(--ink-0)",
fontSize:12.5,
outline:"none",
fontFamily:"inherit",
};
/* ---------- Question row ---------- */
const QuestionRow = ({ q, serial, sectionMarks, onUpdate, onEdit, onDelete, onDuplicate, canDelete }) => {
const isMcq = q.type === "MCQ" || q.type === "TF";
const isLong = q.type === "Long";
const hasCorrect = isMcq && q.options.some(o => o.correct);
const empty = !q.text.trim() && (!isLong || (q.subQuestions || []).every(sq => !sq.text.trim()));
const marks = q.marks ?? sectionMarks;
/* ---- LONG ANSWER: stacked sub-question rows ---- */
if (isLong) {
const subs = q.subQuestions || [];
const updateSub = (sid, patch) =>
onUpdate({ subQuestions: subs.map(s => s.id === sid ? { ...s, ...patch } : s) });
const addSub = () =>
onUpdate({ subQuestions: [...subs, newSubQuestion()] });
const removeSub = (sid) =>
onUpdate({ subQuestions: subs.length > 1 ? subs.filter(s => s.id !== sid) : subs });
return (
{/* tall serial badge spans the whole group */}
Q{serial}
{marks}m
Long
{/* sub-questions stack */}
{/* optional parent stem (small, dashed) */}
onUpdate({ text: e.target.value })}
placeholder="Optional context / parent prompt (e.g. “Answer the following parts based on the passage above.”)"
style={{
background:"transparent", border:"1px dashed var(--line-strong)", borderRadius:5,
color:"var(--ink-1)", padding:"6px 10px", fontSize:11.5, outline:"none",
fontStyle: q.text ? "normal" : "italic",
}}/>
{subs.map((sq, i) => (
{String.fromCharCode(97 + i)}.
))}
{/* add sub-question + meta strip */}
Long · {subs.length} sub-Q
{q.concepts.length > 0 && (
{q.concepts.slice(0, 2).map((c, i) => {c} )}
{q.concepts.length > 2 && +{q.concepts.length - 2} }
)}
e.currentTarget.style.background = "var(--bg-2)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
Add sub-question
{/* Edit pill */}
e.currentTarget.style.background = "color-mix(in srgb, var(--red-solid) 18%, transparent)"}
onMouseLeave={e => e.currentTarget.style.background = "color-mix(in srgb, var(--red-solid) 8%, transparent)"}>
Edit
);
}
return (
{/* serial badge — blue (matches wireframe) */}
Q{serial}
{marks}m
{/* main question text area */}
{/* Edit button — pill (matches wireframe) */}
e.currentTarget.style.background = "color-mix(in srgb, var(--red-solid) 18%, transparent)"}
onMouseLeave={e => e.currentTarget.style.background = "color-mix(in srgb, var(--red-solid) 8%, transparent)"}>
Edit
);
};
/* ---------- Add-section divider ---------- */
const AddSectionDivider = ({ onClick }) => (
e.currentTarget.style.background = "color-mix(in srgb, var(--amber-solid) 20%, transparent)"}
onMouseLeave={e => e.currentTarget.style.background = "color-mix(in srgb, var(--amber-solid) 10%, transparent)"}>
Add section
);
/* ---------- Question Edit Drawer ---------- */
const QuestionEditDrawer = ({ q, sectionMarks, sectionType, onUpdate, onClose, onDelete }) => {
const [conceptDraft, setConceptDraft] = useState("");
const [suggesting, setSuggesting] = useState(false);
const [suggestErr, setSuggestErr] = useState(null);
const isMcq = q.type === "MCQ" || q.type === "TF";
const effectiveMarks = q.marks ?? sectionMarks;
const overrideMarks = q.marks != null;
const setOption = (idx, patch) => {
onUpdate({ options: q.options.map((o, i) => i === idx ? { ...o, ...patch } : o) });
};
const toggleCorrect = (idx) => {
// single-correct for MCQ/TF
onUpdate({ options: q.options.map((o, i) => ({ ...o, correct: i === idx ? !o.correct : false })) });
};
const addOption = () => {
if (q.options.length >= 6) return;
const nextId = String.fromCharCode(97 + q.options.length);
onUpdate({ options: [...q.options, { id: nextId, text:"", correct:false }] });
};
const removeOption = (idx) => {
if (q.options.length <= 2) return;
onUpdate({ options: q.options.filter((_, i) => i !== idx).map((o, i) => ({ ...o, id: String.fromCharCode(97 + i) })) });
};
const addConcept = () => {
const t = conceptDraft.trim();
if (!t) return;
if (q.concepts.includes(t)) { setConceptDraft(""); return; }
onUpdate({ concepts: [...q.concepts, t] });
setConceptDraft("");
};
const removeConcept = (c) => onUpdate({ concepts: q.concepts.filter(x => x !== c) });
return (
e.stopPropagation()} style={{
width:560, maxWidth:"100%", height:"100%",
background:"var(--bg-1)", borderLeft:"1px solid var(--line-strong)",
display:"flex", flexDirection:"column", overflow:"hidden",
animation:"slideInRight .2s ease-out",
}}>
Edit question
{q.type}
{effectiveMarks}m
✕
{/* Stem */}
Question stem
{/* Type override */}
{/* Options for MCQ / TF */}
{isMcq && (
Options · select correct answer
{q.options.map((o, i) => (
toggleCorrect(i)}
title={o.correct ? "Correct answer" : "Mark as correct"}
style={{
width:22, height:22, borderRadius:"50%",
border:`1.5px solid ${o.correct ? "var(--green)" : "var(--line-strong)"}`,
background: o.correct ? "var(--green)" : "transparent",
color:"var(--on-solid)", display:"grid", placeItems:"center",
cursor:"pointer", fontSize:11, fontWeight:700, padding:0,
}}>
{o.correct ? "✓" : ""}
({o.id})
setOption(i, { text: e.target.value })}
placeholder={`Option ${o.id}`}
disabled={q.type === "TF"}
style={{ flex:1, background:"transparent", border:"none", padding:"2px 4px", fontSize:13 }}/>
{q.type !== "TF" && (
removeOption(i)}
disabled={q.options.length <= 2}
style={{ padding:"2px 6px", opacity: q.options.length <= 2 ? 0.3 : 1 }}>
)}
))}
{q.type !== "TF" && q.options.length < 6 && (
Add option
)}
)}
{/* Sub-questions for Long */}
{q.type === "Long" && (
Sub-questions
{(q.subQuestions || []).map((sq, i) => (
{String.fromCharCode(97 + i)}.
onUpdate({ subQuestions: q.subQuestions.map(x => x.id === sq.id ? { ...x, text: e.target.value } : x) })}
placeholder={`Sub-question ${String.fromCharCode(97 + i)}`}
style={{ flex:1 }}/>
onUpdate({ subQuestions: q.subQuestions.filter(x => x.id !== sq.id) })}
disabled={q.subQuestions.length <= 1}>
))}
onUpdate({ subQuestions: [...(q.subQuestions || []), newSubQuestion()] })}
style={{ marginTop:6, alignSelf:"flex-start" }}>
Add sub-question
)}
{/* Expected answer for non-MCQ */}
{!isMcq && (
Expected answer / marking scheme · optional
{
if (!q.text.trim()) return;
setSuggesting(true);
setSuggestErr(null);
try {
const out = await window.AppApi.post("/questions/suggest-expected-answer", {
question_text: q.text,
question_type: q.type === "Long" ? "long" : q.type === "Short" ? "short" : q.type.toLowerCase(),
max_marks: q.marks ?? sectionMarks ?? 1,
language: meta?.language || "en",
concepts: q.concepts || [],
});
// Compose marking scheme + model answer into the textarea.
const body = [
out.marking_scheme && `Marking scheme:\n${out.marking_scheme}`,
out.model_answer && `Model answer:\n${out.model_answer}`,
].filter(Boolean).join("\n\n");
onUpdate({ expectedAnswer: body });
} catch (e) { setSuggestErr(e?.message || String(e)); }
setSuggesting(false);
}}>
{suggesting ? "Suggesting…" : "✨ Suggest with Gemini"}
)}
{/* Concepts */}
Concept tags
{q.concepts.map(c => (
{c}
removeConcept(c)} style={{ background:"none", border:"none", color:"var(--ink-3)", cursor:"pointer", padding:"0 2px", fontSize:11 }}>×
))}
{q.concepts.length === 0 && No concepts tagged. }
setConceptDraft(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); addConcept(); } }}
placeholder="e.g. chem.xi.hydrocarbons.unsaturation"
style={{ flex:1 }}/>
Add
Used by class-report misconception detection and personalised study guides.
Delete question
Done
);
};
/* ---------- Save-with-issues confirmation ---------- */
const SaveConfirmModal = ({ validation, onCancel, onSaveDraft }) => (
e.stopPropagation()} style={{ background:"var(--bg-1)", border:"1px solid var(--line-strong)", borderRadius:10, width:480, padding:20 }}>
Can't publish this test yet
Fix the issues below to mark this test as Upcoming , or save as Draft and continue later.
{validation.emptyQ > 0 && {validation.emptyQ} question{validation.emptyQ === 1 ? "" : "s"} missing text }
{validation.mcqMissingCorrect > 0 && {validation.mcqMissingCorrect} MCQ{validation.mcqMissingCorrect === 1 ? "" : "s"} without a correct option marked }
Back to fix
Save as draft
);
/* ====================================================================
Chapter + topic multi-select chip picker
--------------------------------------------------------------------
Sourced from the admin-curated catalog (/catalog/chapters scoped to
class × subject, /catalog/topics scoped to chapter). meta.chapter
and meta.topic stay as comma-separated TEXT — back-compat with legacy
single-name rows is automatic since a one-element list serialises to
the original text. Topics span every selected chapter; deselecting a
chapter auto-unselects any of its topics so the saved set stays
internally consistent.
==================================================================== */
const parseCSV = (raw) => {
if (raw == null) return [];
const s = String(raw).trim();
if (!s) return [];
return s.split(/\s*,\s*/).map((x) => x.trim()).filter(Boolean);
};
const joinCSV = (xs) => {
const arr = (xs || []).map((s) => String(s).trim()).filter(Boolean);
if (arr.length === 0) return "";
const seen = new Set(); const uniq = [];
for (const x of arr) { if (!seen.has(x)) { seen.add(x); uniq.push(x); } }
return uniq.join(", ");
};
const ChapterTopicFields = ({ meta, setMeta, subjects }) => {
const subjectId = React.useMemo(
() => subjects.find((s) => s.name === meta.subject)?.id || null,
[subjects, meta.subject],
);
const [chapters, setChapters] = React.useState([]);
// Per-chapter topic cache keyed by chapter_id. Lets us cheaply unite topics
// across every selected chapter without refetching when the user toggles
// chapters on/off.
const [topicsByChapter, setTopicsByChapter] = React.useState({});
const [loadingChapters, setLoadingChapters] = React.useState(false);
// Chapter list keyed on (className × subjectId). Cleared on switch so stale
// catalog rows never linger.
React.useEffect(() => {
if (!subjectId || !meta.className) { setChapters([]); return; }
let aborted = false;
setLoadingChapters(true);
const q = new URLSearchParams({ subject_id: subjectId, class_level: meta.className });
window.AppApi.get(`/catalog/chapters?${q.toString()}`)
.then((rows) => { if (!aborted) setChapters(rows || []); })
.catch(() => { if (!aborted) setChapters([]); })
.finally(() => { if (!aborted) setLoadingChapters(false); });
return () => { aborted = true; };
}, [subjectId, meta.className]);
// Parse the comma-joined selection back into arrays for the chip UI.
const selectedChapterNames = React.useMemo(() => parseCSV(meta.chapter), [meta.chapter]);
const selectedTopicNames = React.useMemo(() => parseCSV(meta.topic), [meta.topic]);
// Match selected chapter names back to catalog rows so we know their IDs
// (for the topic fetch) and can warn when a name is freeform (legacy).
const selectedChapterRows = chapters.filter((c) => selectedChapterNames.includes(c.chapter_name));
const freeformChapterNames = selectedChapterNames.filter(
(n) => !chapters.some((c) => c.chapter_name === n),
);
// Fetch topics for any selected chapter we haven't seen yet. Cached
// forever in this component instance — refetching on every toggle would
// waste round-trips.
React.useEffect(() => {
const missing = selectedChapterRows.filter((c) => !(c.id in topicsByChapter));
if (missing.length === 0) return;
let aborted = false;
Promise.all(missing.map((c) =>
window.AppApi.get(`/catalog/topics?chapter_id=${encodeURIComponent(c.id)}`)
.then((rows) => ({ id: c.id, rows: rows || [] }))
.catch(() => ({ id: c.id, rows: [] })),
)).then((results) => {
if (aborted) return;
setTopicsByChapter((prev) => {
const next = { ...prev };
for (const { id, rows } of results) next[id] = rows;
return next;
});
});
return () => { aborted = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedChapterRows.map((c) => c.id).join("|")]);
// Flat list of available topics across every selected chapter, with the
// chapter number prefixed so the same-named "Mole concept" topic in two
// chapters can be told apart at a glance.
const availableTopics = selectedChapterRows.flatMap((c) =>
(topicsByChapter[c.id] || []).map((t) => ({
chapterId: c.id, chapterNumber: c.chapter_number,
topicId: t.id, topicNumber: t.topic_number, topicName: t.topic_name,
})),
);
const toggleChapter = (name) => {
const has = selectedChapterNames.includes(name);
const nextChapterNames = has
? selectedChapterNames.filter((n) => n !== name)
: [...selectedChapterNames, name];
// When a chapter is removed, prune topics that only existed under it.
// Topic names are unique within a chapter; we drop any topic whose name
// doesn't appear in ANY still-selected chapter's topic catalog.
let nextTopicNames = selectedTopicNames;
if (has) {
const stillValid = new Set();
for (const c of chapters) {
if (!nextChapterNames.includes(c.chapter_name)) continue;
for (const t of (topicsByChapter[c.id] || [])) stillValid.add(t.topic_name);
}
nextTopicNames = selectedTopicNames.filter((n) => stillValid.has(n));
}
setMeta({ ...meta, chapter: joinCSV(nextChapterNames), topic: joinCSV(nextTopicNames) });
};
const toggleTopic = (name) => {
const has = selectedTopicNames.includes(name);
const next = has
? selectedTopicNames.filter((n) => n !== name)
: [...selectedTopicNames, name];
setMeta({ ...meta, topic: joinCSV(next) });
};
const removeFreeformChapter = (name) => {
setMeta({
...meta,
chapter: joinCSV(selectedChapterNames.filter((n) => n !== name)),
});
};
const noPair = !subjectId || !meta.className;
return (
<>
Chapter
tap to {selectedChapterNames.length ? "toggle" : "add"} — multi-select
{noPair ? (
Pick a class + subject first.
) : loadingChapters ? (
Loading chapters…
) : chapters.length === 0 ? (
No chapters for class {meta.className} · {meta.subject}. Add some on the Chapters screen.
) : (
{chapters.map((c) => {
const on = selectedChapterNames.includes(c.chapter_name);
return (
toggleChapter(c.chapter_name)}>
Ch {c.chapter_number}. {c.chapter_name}
);
})}
{freeformChapterNames.map((n) => (
{n} (legacy)
removeFreeformChapter(n)}>✕
))}
)}
Topic
optional — multi-select across selected chapters
{selectedChapterRows.length === 0 ? (
Pick a chapter first.
) : availableTopics.length === 0 ? (
No topics under the selected chapter{selectedChapterRows.length === 1 ? "" : "s"} yet.
) : (
{availableTopics.map((t) => {
const on = selectedTopicNames.includes(t.topicName);
return (
toggleTopic(t.topicName)}
title={`Ch ${t.chapterNumber}`}>
Ch{t.chapterNumber}·
{t.topicNumber}. {t.topicName}
);
})}
)}
>
);
};
/* ====================================================================
Test Details modal — opens before manual authoring
==================================================================== */
const TestDetailsModal = ({ open, onClose, onContinue }) => {
const classSections = window.APP?.CLASS_SECTIONS || [];
const subjects = window.APP?.SUBJECTS || [];
const defaultCs = classSections[0]?.id || "";
const defaultSubject = subjects[0]?.name || "Chemistry";
const [meta, setMeta] = useState({
title: "",
class_section_ids: defaultCs ? [defaultCs] : [],
className: classSections[0]?.class_level || "",
subject: defaultSubject,
topic: "", chapter: "", date: "", language: "en",
durationMin: 60,
// How students hand in descriptive answers: 'per_question' (upload during
// the exam) | 'bulk' (upload all photos + an optional PDF after submit).
// Only applies to tests with a descriptive (Short/Long) section — the
// backend coerces it back to per_question otherwise.
submissionMode: "per_question",
});
const [method, setMethod] = useState("manual"); // manual | paste | upload
// Reset defaults when the catalog finishes loading after first render.
React.useEffect(() => {
if (meta.class_section_ids.length === 0 && defaultCs) {
setMeta(m => ({
...m,
class_section_ids: [defaultCs],
className: classSections[0]?.class_level || "",
}));
}
}, [defaultCs]);
// Distinct class levels for the Class dropdown.
const classLevels = Array.from(new Set(classSections.map(cs => cs.class_level))).sort();
const pickClass = (cl) => {
// When the class changes, pre-select its first section so the chip row
// isn't empty; the teacher can toggle more on/off from there.
const first = classSections.find(cs => cs.class_level === cl);
setMeta({ ...meta, className: cl, class_section_ids: first ? [first.id] : [] });
};
if (!open) return null;
const canContinue = meta.title.trim().length >= 3 && meta.class_section_ids.length > 0;
return (
e.stopPropagation()} style={{
background:"var(--bg-1)", border:"1px solid var(--line-strong)", borderRadius:10,
width:780, maxWidth:"100%", maxHeight:"90vh", display:"flex", flexDirection:"column",
}}>
Create new test
Set the details — you can edit anything later.
✕
{/* Method picker */}
1 · How to author?
setMethod("manual")}
icon="edit" title="Compose manually"
sub="Add sections & questions one by one." />
setMethod("paste")}
icon="sparkle" title="Paste & structure"
sub="Paste text, Gemini structures it." />
setMethod("upload")}
icon="upload" title="Upload PDF"
sub="OCR + structure with Gemini." />
{/* Meta form */}
2 · Test details
Test title *
setMeta({ ...meta, title: e.target.value })}/>
Class
pickClass(e.target.value)}>
{classLevels.length === 0 && — add a class first — }
{classLevels.map(c => {c} )}
Subject
setMeta({ ...meta, subject: e.target.value })}>
{subjects.length === 0 && — add a subject first — }
{subjects.map(s => {s.name} )}
Sections tap to add — one test can target several
setMeta({ ...meta, class_section_ids: ids })}/>
{/* Chapter + topic — cascading dropdowns sourced from the
admin-curated catalog. The chapter list narrows by
(className × subject); topics by chapter. Empty catalogs
fall back to "— none —" so the modal doesn't block test
creation when the catalog hasn't been populated yet. */}
Scheduled date
setMeta({ ...meta, date: e.target.value })}/>
Duration (min)
setMeta({ ...meta, durationMin: parseInt(e.target.value) || 60 })}/>
Language
setMeta({ ...meta, language: e.target.value })}>
Bengali
Hindi
English
Mixed
{/* Submission mode — applies to descriptive (Short/Long) tests,
where students hand in photos of their written answers. Ignored
(coerced to per-question) by the backend for all-MCQ tests. */}
Student answer submission descriptive tests only
setMeta({ ...meta, submissionMode: e.target.value })}>
Photo per question — uploaded during the exam
Bulk at the end — all photos / a PDF after submission
{method === "manual" && "→ Opens the manual authoring page."}
{method === "paste" && "→ Opens the paste-text + Gemini parser."}
{method === "upload" && "→ Opens the PDF upload flow."}
Cancel
onContinue(method, meta)}>
Continue →
);
};
const MethodCard = ({ active, onClick, icon, title, sub }) => (
{title}
{sub}
);
/* ====================================================================
Helper: convert Gemini-parsed JSON into ManualAuthoring sections
==================================================================== */
const parsedToManualSections = (parsed) => {
const typeMap = { mcq:"MCQ", short:"Short", long:"Long", tf:"TF", fill:"Fill", match:"Match" };
return (parsed?.sections || []).map((s, i) => {
const firstQ = s.questions?.[0];
const sectionType = typeMap[firstQ?.type?.toLowerCase?.()] || typeMap[firstQ?.type] || "MCQ";
const sectionMarks = firstQ?.marks || 1;
return {
id: uid("s"),
label: s.title || `Section ${SECTION_NAMES[i] || i + 1}`,
type: sectionType,
marks: sectionMarks,
target: s.questions?.length || 1,
instructions: s.instructions || "",
questions: (s.questions || []).map(q => {
const qType = typeMap[q.type?.toLowerCase?.()] || typeMap[q.type] || sectionType;
const base = newQuestion(qType);
const override = q.marks != null && q.marks !== sectionMarks ? q.marks : null;
return {
...base,
text: q.stem || q.text || "",
marks: override,
concepts: q.concepts || [],
options: (q.options && q.options.length)
? q.options.map((o, idx) => ({
id: o.id || String.fromCharCode(97 + idx),
text: o.text || "",
correct: !!o.correct,
}))
: base.options,
subQuestions: (q.subQuestions && q.subQuestions.length)
? q.subQuestions.map(sq => ({ id: uid("sq"), text: sq.text || "" }))
: (qType === "Long" ? base.subQuestions : []),
expectedAnswer: q.expectedAnswer || q.markingScheme || "",
};
}),
};
});
};
/* ====================================================================
exports
==================================================================== */
window.ManualAuthoring = ManualAuthoring;
window.TestDetailsModal = TestDetailsModal;
// Exported so screen-library.jsx can reuse the same chapter+topic picker
// without duplicating the cascading fetch logic.
window.ChapterTopicFields = ChapterTopicFields;
window.parsedToManualSections = parsedToManualSections;