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:
parent
cd7d330261
commit
4210a05ba9
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user