feat(admin): shared practice question editor for Human Language and Add Practice

- Add PracticeQuestionEditorFields for Step-3-style MCQ/T-F/short/audio UI
- Wire Human Language question dialog to shared editor and fix dialog padding
- Refactor AddNewPracticePage step 3 to reuse the same component

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-07 10:41:43 -07:00
parent cd7d330261
commit 4210a05ba9
3 changed files with 534 additions and 580 deletions

View File

@ -0,0 +1,348 @@
import { Check, Plus, X } from "lucide-react"
import { Input } from "../ui/input"
import { Select } from "../ui/select"
import { cn } from "../../lib/utils"
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
export interface PracticeQuestionOptionDraft {
text: string
isCorrect: boolean
}
export interface PracticeQuestionEditorValue {
questionText: string
questionType: PracticeQuestionEditorType
difficultyLevel: PracticeQuestionEditorDifficulty
points: number
tips: string
explanation: string
options: PracticeQuestionOptionDraft[]
voicePrompt: string
sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string
shortAnswer: string
}
export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue {
return {
questionText: "",
questionType: "MCQ",
difficultyLevel: "EASY",
points: 1,
tips: "",
explanation: "",
options: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
shortAnswer: "",
}
}
function defaultOptionsForType(
type: PracticeQuestionEditorType,
previousType: PracticeQuestionEditorType,
current: PracticeQuestionOptionDraft[],
): PracticeQuestionOptionDraft[] {
if (type === "TRUE_FALSE") {
if (previousType === "TRUE_FALSE" && current.length >= 2) {
return current.map((o, i) => ({
text: i === 0 ? "True" : "False",
isCorrect: o.isCorrect,
}))
}
return [
{ text: "True", isCorrect: true },
{ text: "False", isCorrect: false },
]
}
if (type === "MCQ") {
if (previousType === "MCQ" && current.length >= 2) {
const hasCorrect = current.some((o) => o.isCorrect)
return current.map((o, i) => ({
text: o.text,
isCorrect: hasCorrect ? o.isCorrect : i === 0,
}))
}
return [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
]
}
return current
}
export type PracticeQuestionFieldErrorKey =
| "questionText"
| "points"
| "shortAnswer"
| "options"
| "correctOption"
export interface PracticeQuestionEditorFieldsProps {
value: PracticeQuestionEditorValue
onChange: (next: PracticeQuestionEditorValue) => void
fieldErrors?: Partial<Record<PracticeQuestionFieldErrorKey, string>>
showFieldErrors?: boolean
}
export function PracticeQuestionEditorFields({
value,
onChange,
fieldErrors = {},
showFieldErrors = false,
}: PracticeQuestionEditorFieldsProps) {
const patch = (partial: Partial<PracticeQuestionEditorValue>) => {
onChange({ ...value, ...partial })
}
const setType = (questionType: PracticeQuestionEditorType) => {
const options = defaultOptionsForType(questionType, value.questionType, value.options)
onChange({ ...value, questionType, options })
}
const updateOption = (optionIndex: number, updates: Partial<PracticeQuestionOptionDraft>) => {
const options = value.options.map((opt, i) => (i === optionIndex ? { ...opt, ...updates } : opt))
onChange({ ...value, options })
}
const addOption = () => {
onChange({ ...value, options: [...value.options, { text: "", isCorrect: false }] })
}
const removeOption = (optionIndex: number) => {
if (value.options.length <= 2) return
const options = value.options.filter((_, i) => i !== optionIndex)
if (!options.some((o) => o.isCorrect) && options.length > 0) {
options[0] = { ...options[0], isCorrect: true }
}
onChange({ ...value, options })
}
const setCorrectOption = (optionIndex: number) => {
const options = value.options.map((opt, i) => ({ ...opt, isCorrect: i === optionIndex }))
onChange({ ...value, options })
}
return (
<div className="mt-5 space-y-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label>
<textarea
value={value.questionText}
onChange={(e) => patch({ questionText: e.target.value })}
placeholder="Enter your question..."
className={cn(
"w-full rounded-lg border px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100",
showFieldErrors && fieldErrors.questionText ? "border-red-300 ring-1 ring-red-200" : "border-grayScale-200",
)}
rows={2}
aria-invalid={Boolean(showFieldErrors && fieldErrors.questionText)}
/>
{showFieldErrors && fieldErrors.questionText ? (
<p className="text-xs text-red-600">{fieldErrors.questionText}</p>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label>
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)}>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option>
<option value="AUDIO">Audio</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label>
<Select
value={value.difficultyLevel}
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
<Input
type="number"
value={value.points}
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
min={1}
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)}
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
/>
{showFieldErrors && fieldErrors.points ? (
<p className="text-xs text-red-600">{fieldErrors.points}</p>
) : null}
</div>
</div>
{value.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
<div className="space-y-2.5">
{value.options.map((option, optIdx) => (
<div
key={optIdx}
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors ${
option.isCorrect ? "border-green-200 bg-green-50/50" : "border-grayScale-200 bg-white"
}`}
>
<button
type="button"
onClick={() => setCorrectOption(optIdx)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
option.isCorrect
? "border-green-500 bg-green-500 text-white shadow-sm"
: "border-grayScale-300 hover:border-brand-400 hover:shadow-sm"
}`}
>
{option.isCorrect ? <Check className="h-3 w-3" /> : null}
</button>
<Input
value={option.text}
onChange={(e) => updateOption(optIdx, { text: e.target.value })}
placeholder={`Option ${optIdx + 1}`}
className="flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0"
/>
{value.options.length > 2 ? (
<button
type="button"
onClick={() => removeOption(optIdx)}
className="rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
))}
<button
type="button"
onClick={addOption}
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Plus className="h-4 w-4" />
Add Option
</button>
</div>
<p className="text-xs text-grayScale-400">Click the circle to mark the correct answer.</p>
{showFieldErrors && fieldErrors.options ? (
<p className="text-xs text-red-600">{fieldErrors.options}</p>
) : null}
{showFieldErrors && fieldErrors.correctOption ? (
<p className="text-xs text-red-600">{fieldErrors.correctOption}</p>
) : null}
</div>
)}
{value.questionType === "TRUE_FALSE" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Correct Answer</label>
<div className="flex gap-3">
{["True", "False"].map((val, i) => (
<button
key={val}
type="button"
onClick={() =>
patch({
options: [
{ text: "True", isCorrect: i === 0 },
{ text: "False", isCorrect: i === 1 },
],
})
}
className={`flex-1 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
value.options[i]?.isCorrect
? "border-green-500 bg-green-50 text-green-700"
: "border-grayScale-200 text-grayScale-600 hover:border-grayScale-300"
}`}
>
{val}
</button>
))}
</div>
</div>
)}
{value.questionType === "SHORT" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Expected Short Answer</label>
<Input
value={value.shortAnswer}
onChange={(e) => patch({ shortAnswer: e.target.value })}
placeholder="Enter the acceptable answer"
className={cn(showFieldErrors && fieldErrors.shortAnswer ? "border-red-300 ring-1 ring-red-200" : undefined)}
aria-invalid={Boolean(showFieldErrors && fieldErrors.shortAnswer)}
/>
{showFieldErrors && fieldErrors.shortAnswer ? (
<p className="text-xs text-red-600">{fieldErrors.shortAnswer}</p>
) : null}
</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
<Input
value={value.tips}
onChange={(e) => patch({ tips: e.target.value })}
placeholder="Helpful tip for the student"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Explanation (Optional)</label>
<Input
value={value.explanation}
onChange={(e) => patch({ explanation: e.target.value })}
placeholder="Why this is the correct answer"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
<Input
value={value.voicePrompt}
onChange={(e) => patch({ voicePrompt: e.target.value })}
placeholder="Voice prompt text"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Sample Answer Voice Prompt (Optional)
</label>
<Input
value={value.sampleAnswerVoicePrompt}
onChange={(e) => patch({ sampleAnswerVoicePrompt: e.target.value })}
placeholder="Sample answer voice prompt"
/>
</div>
</div>
{value.questionType === "AUDIO" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Audio Correct Answer Text</label>
<Input
value={value.audioCorrectAnswerText}
onChange={(e) => patch({ audioCorrectAnswerText: e.target.value })}
placeholder="Expected correct answer text for audio response"
/>
</div>
)}
</div>
)
}

View File

@ -1,10 +1,11 @@
import { useMemo, useRef, useState, type ChangeEvent } from "react" import { useMemo, useRef, useState, type ChangeEvent } from "react"
import { Link, useLocation, useParams, useNavigate } from "react-router-dom" import { Link, useLocation, useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, X, Edit, Rocket, Loader2, Upload } from "lucide-react" import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, Edit, Rocket, Loader2, Upload } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api" import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api" import { uploadVideoFile } from "../../api/files.api"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
@ -186,35 +187,6 @@ export function AddNewPracticePage() {
setQuestions(questions.map(q => q.id === id ? { ...q, ...updates } : q)) setQuestions(questions.map(q => q.id === id ? { ...q, ...updates } : q))
} }
const updateOption = (questionId: string, optionIndex: number, updates: Partial<MCQOption>) => {
setQuestions(questions.map(q => {
if (q.id !== questionId) return q
const newOptions = q.options.map((opt, i) => i === optionIndex ? { ...opt, ...updates } : opt)
return { ...q, options: newOptions }
}))
}
const addOption = (questionId: string) => {
setQuestions(questions.map(q => {
if (q.id !== questionId) return q
return { ...q, options: [...q.options, { text: "", isCorrect: false }] }
}))
}
const removeOption = (questionId: string, optionIndex: number) => {
setQuestions(questions.map(q => {
if (q.id !== questionId) return q
return { ...q, options: q.options.filter((_, i) => i !== optionIndex) }
}))
}
const setCorrectOption = (questionId: string, optionIndex: number) => {
setQuestions(questions.map(q => {
if (q.id !== questionId) return q
return { ...q, options: q.options.map((opt, i) => ({ ...opt, isCorrect: i === optionIndex })) }
}))
}
const saveQuestionSet = async (status: "DRAFT" | "PUBLISHED") => { const saveQuestionSet = async (status: "DRAFT" | "PUBLISHED") => {
setSaving(true) setSaving(true)
setSaveError(null) setSaveError(null)
@ -621,213 +593,36 @@ export function AddNewPracticePage() {
</button> </button>
</div> </div>
<div className="mt-5 space-y-5"> <PracticeQuestionEditorFields
{/* Question Text */} value={{
<div className="space-y-2"> questionText: question.questionText,
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> questionType: question.questionType,
Question Text difficultyLevel: question.difficultyLevel,
</label> points: question.points,
<textarea tips: question.tips,
value={question.questionText} explanation: question.explanation,
onChange={(e) => updateQuestion(question.id, { questionText: e.target.value })} options: question.options,
placeholder="Enter your question..." voicePrompt: question.voicePrompt,
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt,
rows={2} audioCorrectAnswerText: question.audioCorrectAnswerText,
/> shortAnswer: question.shortAnswers[0] ?? "",
</div> }}
onChange={(next) => {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5"> updateQuestion(question.id, {
{/* Question Type */} questionText: next.questionText,
<div className="space-y-2"> questionType: next.questionType as QuestionType,
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> difficultyLevel: next.difficultyLevel as DifficultyLevel,
Type points: next.points,
</label> tips: next.tips,
<Select explanation: next.explanation,
value={question.questionType} options: next.options,
onChange={(e) => updateQuestion(question.id, { questionType: e.target.value as QuestionType })} voicePrompt: next.voicePrompt,
> sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
<option value="MCQ">Multiple Choice</option> audioCorrectAnswerText: next.audioCorrectAnswerText,
<option value="TRUE_FALSE">True/False</option> shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
<option value="SHORT">Short Answer</option> })
<option value="AUDIO">Audio</option> }}
</Select> />
</div>
{/* Difficulty */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Difficulty
</label>
<Select
value={question.difficultyLevel}
onChange={(e) => updateQuestion(question.id, { difficultyLevel: e.target.value as DifficultyLevel })}
>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
</div>
{/* Points */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Points
</label>
<Input
type="number"
value={question.points}
onChange={(e) => updateQuestion(question.id, { points: Number(e.target.value) || 1 })}
min={1}
/>
</div>
</div>
{/* MCQ Options */}
{question.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Options
</label>
<div className="space-y-2.5">
{question.options.map((option, optIdx) => (
<div key={optIdx} className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors ${
option.isCorrect ? "border-green-200 bg-green-50/50" : "border-grayScale-200 bg-white"
}`}>
<button
type="button"
onClick={() => setCorrectOption(question.id, optIdx)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
option.isCorrect
? "border-green-500 bg-green-500 text-white shadow-sm"
: "border-grayScale-300 hover:border-brand-400 hover:shadow-sm"
}`}
>
{option.isCorrect && <Check className="h-3 w-3" />}
</button>
<Input
value={option.text}
onChange={(e) => updateOption(question.id, optIdx, { text: e.target.value })}
placeholder={`Option ${optIdx + 1}`}
className="flex-1 border-0 bg-transparent shadow-none focus:ring-0"
/>
{question.options.length > 2 && (
<button
onClick={() => removeOption(question.id, optIdx)}
className="rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => addOption(question.id)}
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Plus className="h-4 w-4" />
Add Option
</button>
</div>
<p className="text-xs text-grayScale-400">Click the circle to mark the correct answer.</p>
</div>
)}
{/* TRUE_FALSE Options */}
{question.questionType === "TRUE_FALSE" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Correct Answer
</label>
<div className="flex gap-3">
{["True", "False"].map((val, i) => (
<button
key={val}
type="button"
onClick={() => updateQuestion(question.id, {
options: [
{ text: "True", isCorrect: i === 0 },
{ text: "False", isCorrect: i === 1 },
],
})}
className={`flex-1 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
question.options[i]?.isCorrect
? "border-green-500 bg-green-50 text-green-700"
: "border-grayScale-200 text-grayScale-600 hover:border-grayScale-300"
}`}
>
{val}
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
{/* Tips */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Tips (Optional)
</label>
<Input
value={question.tips}
onChange={(e) => updateQuestion(question.id, { tips: e.target.value })}
placeholder="Helpful tip for the student"
/>
</div>
{/* Explanation */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Explanation (Optional)
</label>
<Input
value={question.explanation}
onChange={(e) => updateQuestion(question.id, { explanation: e.target.value })}
placeholder="Why this is the correct answer"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
{/* Voice Prompt */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Voice Prompt (Optional)
</label>
<Input
value={question.voicePrompt}
onChange={(e) => updateQuestion(question.id, { voicePrompt: e.target.value })}
placeholder="Voice prompt text"
/>
</div>
{/* Sample Answer Voice Prompt */}
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Sample Answer Voice Prompt (Optional)
</label>
<Input
value={question.sampleAnswerVoicePrompt}
onChange={(e) => updateQuestion(question.id, { sampleAnswerVoicePrompt: e.target.value })}
placeholder="Sample answer voice prompt"
/>
</div>
</div>
{question.questionType === "AUDIO" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Audio Correct Answer Text
</label>
<Input
value={question.audioCorrectAnswerText}
onChange={(e) => updateQuestion(question.id, { audioCorrectAnswerText: e.target.value })}
placeholder="Expected correct answer text for audio response"
/>
</div>
)}
</div>
</Card> </Card>
))} ))}
</div> </div>

View File

@ -4,6 +4,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
ClipboardList, ClipboardList,
GripVertical,
HelpCircle, HelpCircle,
Image as ImageIcon, Image as ImageIcon,
Languages, Languages,
@ -56,6 +57,12 @@ import type {
} from "../../types/course.types" } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { Input } from "../../components/ui/input"
import {
createEmptyPracticeQuestionDraft,
PracticeQuestionEditorFields,
type PracticeQuestionEditorValue,
} from "../../components/content-management/PracticeQuestionEditorFields"
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
type SubModulePanelTab = "lessons" | "practices" type SubModulePanelTab = "lessons" | "practices"
@ -202,24 +209,8 @@ export function HumanLanguagePage() {
const [practiceDialog, setPracticeDialog] = useState<PracticeDialogState>({ open: false }) const [practiceDialog, setPracticeDialog] = useState<PracticeDialogState>({ open: false })
const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false }) const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false })
const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" }) const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" })
const [questionForm, setQuestionForm] = useState({ const [questionDraft, setQuestionDraft] = useState<PracticeQuestionEditorValue>(() => createEmptyPracticeQuestionDraft())
questionText: "", const [questionImageUrl, setQuestionImageUrl] = useState("")
questionType: "MCQ" as "MCQ" | "TRUE_FALSE" | "SHORT",
difficulty: "EASY",
points: 1,
tips: "",
explanation: "",
imageUrl: "",
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
optionA: "",
optionB: "",
optionC: "",
optionD: "",
correctOption: "A" as "A" | "B" | "C" | "D",
shortAnswer: "",
})
const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({}) const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({})
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null) const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null)
const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null) const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null)
@ -604,25 +595,10 @@ export function HumanLanguagePage() {
subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null } subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null }
const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" }) const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" })
const resetQuestionForm = () => const resetQuestionForm = () => {
setQuestionForm({ setQuestionDraft(createEmptyPracticeQuestionDraft())
questionText: "", setQuestionImageUrl("")
questionType: "MCQ", }
difficulty: "EASY",
points: 1,
tips: "",
explanation: "",
imageUrl: "",
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
optionA: "",
optionB: "",
optionC: "",
optionD: "",
correctOption: "A",
shortAnswer: "",
})
const openCreatePracticeDialog = (subModuleId: number) => { const openCreatePracticeDialog = (subModuleId: number) => {
setPracticeSubmitAttempted(false) setPracticeSubmitAttempted(false)
@ -704,18 +680,8 @@ export function HumanLanguagePage() {
return 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) setQuestionImageUrl(detail.image_url ?? "")
const correctOpt = options.find((o) => o.is_correct) const sortedOpts = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order)
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"
@ -723,28 +689,54 @@ export function HumanLanguagePage() {
: detail.short_answers[0]?.acceptable_answer ?? "" : detail.short_answers[0]?.acceptable_answer ?? ""
: "" : ""
const qt = detail.question_type const qt = detail.question_type
let questionType: "MCQ" | "TRUE_FALSE" | "SHORT" = "MCQ" let questionType: PracticeQuestionEditorValue["questionType"] = "MCQ"
if (qt === "TRUE_FALSE") questionType = "TRUE_FALSE" if (qt === "TRUE_FALSE") questionType = "TRUE_FALSE"
else if (qt === "SHORT" || qt === "SHORT_ANSWER" || qt === "AUDIO") questionType = "SHORT" else if (qt === "SHORT" || qt === "SHORT_ANSWER") questionType = "SHORT"
else if (qt === "AUDIO") questionType = "AUDIO"
const difficultyRaw = detail.difficulty_level const difficultyRaw = detail.difficulty_level
const difficulty = const difficultyLevel =
difficultyRaw === "EASY" || difficultyRaw === "MEDIUM" || difficultyRaw === "HARD" ? difficultyRaw : "EASY" difficultyRaw === "EASY" || difficultyRaw === "MEDIUM" || difficultyRaw === "HARD" ? difficultyRaw : "EASY"
setQuestionForm({
let options: PracticeQuestionEditorValue["options"]
if (questionType === "TRUE_FALSE") {
const trueRow =
sortedOpts.find((o) => /\btrue\b/i.test((o.option_text ?? "").trim())) ?? sortedOpts[0]
const falseRow =
sortedOpts.find((o) => /\bfalse\b/i.test((o.option_text ?? "").trim())) ?? sortedOpts[1]
const correctIsTrue =
trueRow?.is_correct === true
? true
: falseRow?.is_correct === true
? false
: true
options = [
{ text: "True", isCorrect: correctIsTrue },
{ text: "False", isCorrect: !correctIsTrue },
]
} else {
options =
sortedOpts.length > 0
? sortedOpts.map((o) => ({
text: o.option_text ?? "",
isCorrect: !!o.is_correct,
}))
: createEmptyPracticeQuestionDraft().options
if (!options.some((o) => o.isCorrect) && options.length > 0) {
options = options.map((o, i) => ({ ...o, isCorrect: i === 0 }))
}
}
setQuestionDraft({
questionText: detail.question_text ?? "", questionText: detail.question_text ?? "",
questionType, questionType,
difficulty, difficultyLevel,
points: detail.points && detail.points > 0 ? detail.points : 1, points: detail.points && detail.points > 0 ? detail.points : 1,
tips: detail.tips ?? "", tips: detail.tips ?? "",
explanation: detail.explanation ?? "", explanation: detail.explanation ?? "",
imageUrl: detail.image_url ?? "", options,
voicePrompt: detail.voice_prompt ?? "", voicePrompt: detail.voice_prompt ?? "",
sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "", sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "",
audioCorrectAnswerText: detail.audio_correct_answer_text ?? "", audioCorrectAnswerText: detail.audio_correct_answer_text ?? "",
optionA: options[0]?.option_text ?? "",
optionB: options[1]?.option_text ?? "",
optionC: options[2]?.option_text ?? "",
optionD: options[3]?.option_text ?? "",
correctOption,
shortAnswer, shortAnswer,
}) })
// Open only after the same form shape as create is fully populated (no empty-state flash). // Open only after the same form shape as create is fully populated (no empty-state flash).
@ -758,41 +750,45 @@ export function HumanLanguagePage() {
} }
const buildQuestionPayload = (): CreateQuestionRequest => { const buildQuestionPayload = (): CreateQuestionRequest => {
const d = questionDraft
const payload: CreateQuestionRequest = { const payload: CreateQuestionRequest = {
question_text: questionForm.questionText.trim(), question_text: d.questionText.trim(),
question_type: questionForm.questionType, question_type: d.questionType,
difficulty_level: questionForm.difficulty, difficulty_level: d.difficultyLevel,
points: Number(questionForm.points) || 1, points: Number(d.points) || 1,
tips: questionForm.tips.trim() || undefined, tips: d.tips.trim() || undefined,
explanation: questionForm.explanation.trim() || undefined, explanation: d.explanation.trim() || undefined,
image_url: questionForm.imageUrl.trim() || undefined, image_url: questionImageUrl.trim() || undefined,
voice_prompt: questionForm.voicePrompt.trim() || undefined, voice_prompt: d.voicePrompt.trim() || undefined,
sample_answer_voice_prompt: questionForm.sampleAnswerVoicePrompt.trim() || undefined, sample_answer_voice_prompt: d.sampleAnswerVoicePrompt.trim() || undefined,
audio_correct_answer_text: questionForm.audioCorrectAnswerText.trim() || undefined, audio_correct_answer_text: d.audioCorrectAnswerText.trim() || undefined,
status: "PUBLISHED", status: "PUBLISHED",
} }
if (questionForm.questionType === "SHORT") { if (d.questionType === "SHORT") {
payload.short_answers = questionForm.shortAnswer.trim() payload.short_answers = d.shortAnswer.trim()
? [ ? [
{ acceptable_answer: questionForm.shortAnswer.trim(), match_type: "EXACT" }, { acceptable_answer: d.shortAnswer.trim(), match_type: "EXACT" },
{ acceptable_answer: questionForm.shortAnswer.trim(), match_type: "CASE_INSENSITIVE" }, { acceptable_answer: d.shortAnswer.trim(), match_type: "CASE_INSENSITIVE" },
] ]
: undefined : undefined
return payload return payload
} }
const options = if (d.questionType === "TRUE_FALSE") {
questionForm.questionType === "TRUE_FALSE" const trueCorrect = d.options[0]?.isCorrect === true && d.options[1]?.isCorrect !== true
? [ payload.options = [
{ option_order: 1, option_text: "True", is_correct: questionForm.correctOption === "A" }, { option_order: 1, option_text: "True", is_correct: trueCorrect },
{ option_order: 2, option_text: "False", is_correct: questionForm.correctOption === "B" }, { option_order: 2, option_text: "False", is_correct: !trueCorrect },
] ]
: [ return payload
{ option_order: 1, option_text: questionForm.optionA.trim(), is_correct: questionForm.correctOption === "A" }, }
{ option_order: 2, option_text: questionForm.optionB.trim(), is_correct: questionForm.correctOption === "B" }, if (d.questionType === "MCQ") {
{ option_order: 3, option_text: questionForm.optionC.trim(), is_correct: questionForm.correctOption === "C" }, const filtered = d.options.filter((o) => o.text.trim())
{ option_order: 4, option_text: questionForm.optionD.trim(), is_correct: questionForm.correctOption === "D" }, payload.options = filtered.map((o, idx) => ({
].filter((o) => o.option_text) option_order: idx + 1,
payload.options = options option_text: o.text.trim(),
is_correct: o.isCorrect,
}))
}
return payload return payload
} }
@ -804,26 +800,23 @@ export function HumanLanguagePage() {
options?: string options?: string
correctOption?: string correctOption?: string
} = {} } = {}
if (!questionForm.questionText.trim()) errors.questionText = "Question text is required." const d = questionDraft
const pts = Number(questionForm.points) if (!d.questionText.trim()) errors.questionText = "Question text is required."
const pts = Number(d.points)
if (!Number.isFinite(pts) || pts < 1) errors.points = "Enter a valid number (minimum 1)." if (!Number.isFinite(pts) || pts < 1) errors.points = "Enter a valid number (minimum 1)."
if (questionForm.questionType === "SHORT" && !questionForm.shortAnswer.trim()) { if (d.questionType === "SHORT" && !d.shortAnswer.trim()) {
errors.shortAnswer = "Expected answer is required for short-answer questions." errors.shortAnswer = "Expected answer is required for short-answer questions."
} }
if (questionForm.questionType === "MCQ") { if (d.questionType === "MCQ") {
const opts = { const filled = d.options.filter((o) => o.text.trim()).length
A: questionForm.optionA.trim(),
B: questionForm.optionB.trim(),
C: questionForm.optionC.trim(),
D: questionForm.optionD.trim(),
}
const filled = Object.values(opts).filter(Boolean).length
if (filled < 2) errors.options = "Enter at least two non-empty options." if (filled < 2) errors.options = "Enter at least two non-empty options."
const correct = questionForm.correctOption const correctIdx = d.options.findIndex((o) => o.isCorrect)
if (opts[correct] === "") errors.correctOption = "The marked correct option must include text." if (correctIdx >= 0 && !d.options[correctIdx]?.text?.trim()) {
errors.correctOption = "The marked correct option must include text."
}
} }
return errors return errors
}, [questionForm]) }, [questionDraft])
const questionCanSave = Object.keys(questionFieldErrors).length === 0 const questionCanSave = Object.keys(questionFieldErrors).length === 0
@ -1882,261 +1875,79 @@ export function HumanLanguagePage() {
} }
}} }}
> >
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto gap-0 p-0 sm:max-w-4xl">
<DialogHeader> <div className="border-b border-grayScale-100 px-5 py-5 sm:px-8">
<DialogTitle>{questionDialog.open && questionDialog.mode === "edit" ? "Edit question" : "Add question"}</DialogTitle> <DialogHeader className="space-y-1.5 text-left">
<DialogDescription> <DialogTitle className="text-xl">
Use the same fields as when creating: type, scoring, prompts, media URLs, and answer options. Changes apply to this {questionDialog.open && questionDialog.mode === "edit" ? "Edit question" : "Add question"}
practice only. </DialogTitle>
{!questionCanSave ? ( <DialogDescription className="text-sm leading-relaxed">
<span className="mt-1 block text-amber-700/90"> Same layout as <span className="font-medium text-grayScale-700">Add New Practice Step 3: Questions</span>. Add MCQ,
Fix the highlighted fields before saving. Save stays disabled until the form is valid. True/False, Short Answer, or Audio; optional tips and voice prompts below.
</span> {!questionCanSave ? (
) : null} <span className="mt-2 block text-amber-800/90">
</DialogDescription> Fix the highlighted fields before saving. Save stays disabled until the form is valid.
</DialogHeader> </span>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> ) : null}
<div className="space-y-1 sm:col-span-2"> </DialogDescription>
<label className="text-xs font-medium text-grayScale-600">Question text</label> </DialogHeader>
<textarea </div>
value={questionForm.questionText}
onChange={(e) => { <div className="space-y-4 px-4 py-4 sm:px-6 sm:py-5">
<div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-4 py-4 shadow-sm sm:px-6 sm:py-5">
<h2 className="text-base font-semibold tracking-tight text-grayScale-900 sm:text-lg">Step 3: Questions</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
Add MCQ, True/False, Short Answer, or Audio items. Use the full width for stems and options.
</p>
</div>
<Card className="border border-grayScale-200/90 border-l-4 border-l-brand-500 p-5 shadow-sm transition-shadow hover:shadow-md sm:p-6 lg:p-8">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 shrink-0 cursor-grab text-grayScale-300" aria-hidden />
<span className="text-base font-semibold text-grayScale-900">Question 1</span>
</div>
</div>
<PracticeQuestionEditorFields
value={questionDraft}
onChange={(next) => {
setQuestionFormTouched(true) setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, questionText: e.target.value })) setQuestionDraft(next)
}} }}
className={cn( fieldErrors={questionFieldErrors}
"min-h-[96px] w-full rounded-md border px-3 py-2 text-sm", showFieldErrors={questionSubmitAttempted || questionFormTouched}
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.questionText
? "border-red-300 ring-1 ring-red-200"
: "border-grayScale-200",
)}
placeholder="Type question"
aria-invalid={Boolean(
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.questionText,
)}
/> />
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.questionText ? (
<p className="text-xs text-red-600">{questionFieldErrors.questionText}</p> <div className="mt-5 space-y-2 border-t border-grayScale-100 pt-5">
) : null} <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Image URL (Optional)</label>
</div> <Input
<div className="space-y-1"> value={questionImageUrl}
<label className="text-xs font-medium text-grayScale-600">Type</label>
<select
value={questionForm.questionType}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, questionType: e.target.value as "MCQ" | "TRUE_FALSE" | "SHORT" }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
>
<option value="MCQ">MCQ</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short answer</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Difficulty</label>
<select
value={questionForm.difficulty}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, difficulty: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
>
<option value="EASY">EASY</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HARD">HARD</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Points</label>
<input
type="number"
min={1}
value={questionForm.points}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, points: Number(e.target.value) }))
}}
className={cn(
"h-10 w-full rounded-md border px-3 text-sm",
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.points
? "border-red-300 ring-1 ring-red-200"
: "border-grayScale-200",
)}
aria-invalid={Boolean(
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.points,
)}
/>
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.points ? (
<p className="text-xs text-red-600">{questionFieldErrors.points}</p>
) : null}
</div>
{questionForm.questionType === "SHORT" ? (
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Expected short answer</label>
<input
value={questionForm.shortAnswer}
onChange={(e) => { onChange={(e) => {
setQuestionFormTouched(true) setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, shortAnswer: e.target.value })) setQuestionImageUrl(e.target.value)
}} }}
className={cn( placeholder="https://…"
"h-10 w-full rounded-md border px-3 text-sm", type="url"
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.shortAnswer className="h-11 font-mono text-[13px]"
? "border-red-300 ring-1 ring-red-200"
: "border-grayScale-200",
)}
aria-invalid={Boolean(
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.shortAnswer,
)}
/> />
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.shortAnswer ? (
<p className="text-xs text-red-600">{questionFieldErrors.shortAnswer}</p>
) : null}
</div> </div>
) : ( </Card>
<div className="space-y-2 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Options</label>
{questionForm.questionType === "TRUE_FALSE" ? (
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={questionForm.correctOption === "A" ? "default" : "outline"}
onClick={() => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, correctOption: "A" }))
}}
>
True
</Button>
<Button
type="button"
variant={questionForm.correctOption === "B" ? "default" : "outline"}
onClick={() => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, correctOption: "B" }))
}}
>
False
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{(["A", "B", "C", "D"] as const).map((opt) => (
<div key={opt} className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant={questionForm.correctOption === opt ? "default" : "outline"}
onClick={() => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, correctOption: opt }))
}}
>
{opt}
</Button>
<input
value={questionForm[`option${opt}` as "optionA" | "optionB" | "optionC" | "optionD"]}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({
...f,
[`option${opt}`]: e.target.value,
}))
}}
className="h-9 w-full rounded-md border border-grayScale-200 px-3 text-sm"
placeholder={`Option ${opt}`}
/>
</div>
))}
</div>
)}
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.options ? (
<p className="text-xs text-red-600">{questionFieldErrors.options}</p>
) : null}
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.correctOption ? (
<p className="text-xs text-red-600">{questionFieldErrors.correctOption}</p>
) : null}
</div>
)}
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Tips</label>
<textarea
value={questionForm.tips}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, tips: e.target.value }))
}}
className="min-h-[74px] w-full rounded-md border border-grayScale-200 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Explanation / sample answer</label>
<textarea
value={questionForm.explanation}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, explanation: e.target.value }))
}}
className="min-h-[74px] w-full rounded-md border border-grayScale-200 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Image URL</label>
<input
value={questionForm.imageUrl}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, imageUrl: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Voice prompt URL</label>
<input
value={questionForm.voicePrompt}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, voicePrompt: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Sample answer voice URL</label>
<input
value={questionForm.sampleAnswerVoicePrompt}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, sampleAnswerVoicePrompt: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
/>
</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>
<Button type="button" variant="outline" onClick={() => setQuestionDialog({ open: false })}> <div className="flex flex-col-reverse items-stretch justify-end gap-2 border-t border-grayScale-100 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6">
<Button type="button" variant="outline" onClick={() => setQuestionDialog({ open: false })} className="sm:mr-auto">
Cancel Cancel
</Button> </Button>
<Button type="button" onClick={() => void handleSaveQuestion()} disabled={savingQuestion || !questionCanSave}> <Button
type="button"
className="bg-brand-500 hover:bg-brand-600"
onClick={() => void handleSaveQuestion()}
disabled={savingQuestion || !questionCanSave}
>
{savingQuestion ? "Saving..." : "Save question"} {savingQuestion ? "Saving..." : "Save question"}
</Button> </Button>
</DialogFooter> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>