Align Human Language edit-question UI with create flow

Defer opening the dialog until details load; improve API mapping for true/false and types; add audio answer field; show edit loading on the row action.

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-07 10:29:20 -07:00
parent 53d16d9f93
commit cd7d330261

View File

@ -227,6 +227,8 @@ export function HumanLanguagePage() {
const [savingQuestion, setSavingQuestion] = useState(false) const [savingQuestion, setSavingQuestion] = useState(false)
const [deletingPractice, setDeletingPractice] = useState(false) const [deletingPractice, setDeletingPractice] = useState(false)
const [deletingQuestion, setDeletingQuestion] = useState(false) const [deletingQuestion, setDeletingQuestion] = useState(false)
/** While fetching full question detail before opening the edit dialog (avoids empty form flash). */
const [loadingQuestionEditId, setLoadingQuestionEditId] = useState<number | null>(null)
/** Show inline field errors only after an explicit save attempt (avoids noisy empty-state on open). */ /** Show inline field errors only after an explicit save attempt (avoids noisy empty-state on open). */
const [practiceSubmitAttempted, setPracticeSubmitAttempted] = useState(false) const [practiceSubmitAttempted, setPracticeSubmitAttempted] = useState(false)
const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false) const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false)
@ -694,29 +696,44 @@ export function HumanLanguagePage() {
setQuestionFormTouched(false) setQuestionFormTouched(false)
const qid = question.question_id ?? question.id const qid = question.question_id ?? question.id
resetQuestionForm() resetQuestionForm()
setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid }) setLoadingQuestionEditId(qid)
try { try {
const detail = questionDetailById[qid] ?? (await getQuestionById(qid)).data?.data const detail = questionDetailById[qid] ?? (await getQuestionById(qid)).data?.data
if (!detail) return if (!detail) {
toast.error("Could not load question details")
return
}
setQuestionDetailById((prev) => ({ ...prev, [qid]: detail })) setQuestionDetailById((prev) => ({ ...prev, [qid]: detail }))
const options = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order) const options = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order)
const correct = options.find((o) => o.is_correct)?.option_order ?? 1 const correctOpt = options.find((o) => o.is_correct)
const correctOrder = correctOpt?.option_order ?? 1
let correctOption: "A" | "B" | "C" | "D" = "A"
if (detail.question_type === "TRUE_FALSE") {
const t = (correctOpt?.option_text ?? "").trim().toLowerCase()
if (t === "false" || correctOrder === 2) correctOption = "B"
else correctOption = "A"
} else {
correctOption =
(["A", "B", "C", "D"][Math.min(Math.max(correctOrder - 1, 0), 3)] as "A" | "B" | "C" | "D") ?? "A"
}
const shortAnswer = const shortAnswer =
Array.isArray(detail.short_answers) && detail.short_answers.length > 0 Array.isArray(detail.short_answers) && detail.short_answers.length > 0
? typeof detail.short_answers[0] === "string" ? typeof detail.short_answers[0] === "string"
? detail.short_answers[0] ? detail.short_answers[0]
: detail.short_answers[0]?.acceptable_answer ?? "" : detail.short_answers[0]?.acceptable_answer ?? ""
: "" : ""
const qt = detail.question_type
let questionType: "MCQ" | "TRUE_FALSE" | "SHORT" = "MCQ"
if (qt === "TRUE_FALSE") questionType = "TRUE_FALSE"
else if (qt === "SHORT" || qt === "SHORT_ANSWER" || qt === "AUDIO") questionType = "SHORT"
const difficultyRaw = detail.difficulty_level
const difficulty =
difficultyRaw === "EASY" || difficultyRaw === "MEDIUM" || difficultyRaw === "HARD" ? difficultyRaw : "EASY"
setQuestionForm({ setQuestionForm({
questionText: detail.question_text ?? "", questionText: detail.question_text ?? "",
questionType: questionType,
detail.question_type === "TRUE_FALSE" || detail.question_type === "SHORT" || detail.question_type === "SHORT_ANSWER" difficulty,
? detail.question_type === "SHORT_ANSWER" points: detail.points && detail.points > 0 ? detail.points : 1,
? "SHORT"
: detail.question_type
: "MCQ",
difficulty: detail.difficulty_level ?? "EASY",
points: detail.points ?? 1,
tips: detail.tips ?? "", tips: detail.tips ?? "",
explanation: detail.explanation ?? "", explanation: detail.explanation ?? "",
imageUrl: detail.image_url ?? "", imageUrl: detail.image_url ?? "",
@ -727,12 +744,16 @@ export function HumanLanguagePage() {
optionB: options[1]?.option_text ?? "", optionB: options[1]?.option_text ?? "",
optionC: options[2]?.option_text ?? "", optionC: options[2]?.option_text ?? "",
optionD: options[3]?.option_text ?? "", optionD: options[3]?.option_text ?? "",
correctOption: (["A", "B", "C", "D"][Math.min(Math.max(correct - 1, 0), 3)] as "A" | "B" | "C" | "D") ?? "A", correctOption,
shortAnswer, shortAnswer,
}) })
// Open only after the same form shape as create is fully populated (no empty-state flash).
setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid })
} catch (error) { } catch (error) {
console.error("Failed to load question detail:", error) console.error("Failed to load question detail:", error)
toast.error("Could not load question details") toast.error("Could not load question details")
} finally {
setLoadingQuestionEditId(null)
} }
} }
@ -1591,7 +1612,11 @@ export function HumanLanguagePage() {
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-2 text-[10px]" className="h-7 gap-1 px-2 text-[10px]"
disabled={
loadingQuestionEditId ===
(q.question_id ?? q.id)
}
onClick={() => onClick={() =>
void openEditQuestionDialog( void openEditQuestionDialog(
selectedPracticeMeta.id, selectedPracticeMeta.id,
@ -1599,6 +1624,10 @@ export function HumanLanguagePage() {
) )
} }
> >
{loadingQuestionEditId ===
(q.question_id ?? q.id) ? (
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
) : null}
Edit Edit
</Button> </Button>
<Button <Button
@ -1855,9 +1884,10 @@ export function HumanLanguagePage() {
> >
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{questionDialog.open && questionDialog.mode === "edit" ? "Edit Question" : "Add Question"}</DialogTitle> <DialogTitle>{questionDialog.open && questionDialog.mode === "edit" ? "Edit question" : "Add question"}</DialogTitle>
<DialogDescription> <DialogDescription>
Create, edit, and attach questions to the selected practice. Use the same fields as when creating: type, scoring, prompts, media URLs, and answer options. Changes apply to this
practice only.
{!questionCanSave ? ( {!questionCanSave ? (
<span className="mt-1 block text-amber-700/90"> <span className="mt-1 block text-amber-700/90">
Fix the highlighted fields before saving. Save stays disabled until the form is valid. Fix the highlighted fields before saving. Save stays disabled until the form is valid.
@ -2086,6 +2116,18 @@ export function HumanLanguagePage() {
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm" className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
/> />
</div> </div>
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Audio / spoken correct answer text</label>
<textarea
value={questionForm.audioCorrectAnswerText}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, audioCorrectAnswerText: e.target.value }))
}}
className="min-h-[64px] w-full rounded-md border border-grayScale-200 px-3 py-2 text-sm"
placeholder="Optional; used for audio-style grading when applicable"
/>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => setQuestionDialog({ open: false })}> <Button type="button" variant="outline" onClick={() => setQuestionDialog({ open: false })}>