feat(admin): exam prep practice APIs and coordinated creation flow

Wire exam prep lesson practice list and delete endpoints, refactor practice creation into a five-step orchestrator, and add per-question difficulty and points in the add-practice form.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-08 02:39:57 -07:00
parent b21c679e56
commit f06bbbee47
8 changed files with 444 additions and 154 deletions

View File

@ -100,6 +100,7 @@ import type {
PublishExamPrepModuleLessonRequest,
CreateExamPrepLessonPracticeRequest,
CreateExamPrepLessonPracticeResponse,
GetExamPrepLessonPracticesResponse,
GetExamPrepModuleLessonsResponse,
GetTopLevelModuleLessonsResponse,
GetPracticesByParentContextResponse,
@ -610,6 +611,22 @@ export const createExamPrepLessonPractice = (
data,
)
/** GET /exam-prep/lessons/:lessonId/practices */
export const getExamPrepLessonPractices = (
lessonId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetExamPrepLessonPracticesResponse>(
`/exam-prep/lessons/${lessonId}/practices`,
{ params },
)
/** DELETE /exam-prep/practices/:practiceId */
export const deleteExamPrepPractice = (practiceId: number) =>
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
`/exam-prep/practices/${practiceId}`,
)
/** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data)

View File

@ -160,10 +160,14 @@ export function legacyQuestionTypeFromDefinition(
return null
}
export type QuestionDifficultyLevel = "EASY" | "MEDIUM" | "HARD"
export interface LearnEnglishDefinitionQuestionInput {
questionText: string
questionTypeDefinitionId: number
dynamicFieldValues: Record<string, string>
difficultyLevel?: QuestionDifficultyLevel
points?: number
mcqOptions?: { option_text: string; is_correct: boolean }[]
trueFalseAnswerIsTrue?: boolean
shortAnswers?: string[]
@ -269,13 +273,27 @@ export function questionRowHasContent(
return false
}
function normalizeQuestionDifficulty(
value: string | undefined,
): QuestionDifficultyLevel {
const upper = (value ?? "EASY").trim().toUpperCase()
if (upper === "MEDIUM" || upper === "HARD") return upper
return "EASY"
}
function normalizeQuestionPoints(value: number | undefined): number {
const n = Number(value)
if (!Number.isFinite(n) || n < 1) return 1
return Math.round(n)
}
export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
status: "DRAFT" | "PUBLISHED",
): CreateQuestionRequest {
const difficulty = "EASY"
const points = 1
const difficulty = normalizeQuestionDifficulty(q.difficultyLevel)
const points = normalizeQuestionPoints(q.points)
const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) {

View File

@ -1,21 +1,14 @@
import type { AxiosError } from "axios"
import {
addQuestionToSet,
createExamPrepLessonPractice,
createParentLinkedPractice,
createQuestion,
createQuestionSet,
} from "../api/courses.api"
import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
questionRowHasContent,
validateDefinitionQuestion,
executePracticeCreation,
validatePracticeQuestionsWithDefinitions,
type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion"
type PracticeCreationInput,
} from "./practiceCreationOrchestrator"
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
export type { LearnEnglishDefinitionQuestionInput } from "./practiceCreationOrchestrator"
export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
const ax = err as AxiosError<{ message?: string; error?: string }>
@ -28,34 +21,11 @@ export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
return "Request failed"
}
export function validateLearnEnglishQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[],
): string | null {
const byId = new Map(definitions.map((d) => [d.id, d]))
const filled = questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
if (filled.length === 0) return "Add at least one question with content."
for (let i = 0; i < filled.length; i++) {
const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
return `Question ${i + 1}: select a question type from the list.`
}
const def = byId.get(q.questionTypeDefinitionId)
if (!def) {
return `Question ${i + 1}: type definition #${q.questionTypeDefinitionId} was not found. Refresh and try again.`
}
const err = validateDefinitionQuestion(def, q, i + 1)
if (err) return err
}
return null
}
export const validateLearnEnglishQuestionsWithDefinitions =
validatePracticeQuestionsWithDefinitions
/**
* Learn English parent-linked practice: create PRACTICE question set,
* create questions from GET /questions/type-definitions entries, attach them, POST /practices.
* @deprecated Use executePracticeCreation kept for existing imports.
*/
export async function executeLearnEnglishPracticeCreation(opts: {
parentKind: PracticeParentKind
@ -69,96 +39,10 @@ export async function executeLearnEnglishPracticeCreation(opts: {
storyImage: string
quickTips: string
personaName?: string | null
/** Selected persona from step 2 — sent as `persona_id` on POST /practices. */
personaId: number
questions: LearnEnglishDefinitionQuestionInput[]
definitions: QuestionTypeDefinition[]
/** When set, links practice via POST /exam-prep/lessons/:id/practices instead of POST /practices. */
examPrepLessonId?: number
}): Promise<{ questionSetId: number; practiceId: number }> {
const err = validateLearnEnglishQuestionsWithDefinitions(
opts.questions,
opts.definitions,
)
if (err) throw new Error(err)
if (!Number.isFinite(opts.personaId) || opts.personaId < 1) {
throw new Error("persona_id is required. Select a persona before saving.")
}
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
const setRes = await createQuestionSet({
title: opts.questionSetTitle.trim() || "Practice question set",
description: opts.questionSetDescription?.trim() || null,
set_type: "PRACTICE",
owner_type: opts.parentKind,
owner_id: opts.parentId,
shuffle_questions: opts.shuffleQuestions,
status: opts.status,
...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
})
const setId = setRes.data?.data?.id
if (!setId) {
throw new Error(
(setRes.data as { message?: string } | undefined)?.message ??
"Could not create question set",
)
}
const toCreate = opts.questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
let displayOrder = 0
for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId)
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
displayOrder += 1
const payload = buildCreateQuestionFromDefinition(def, q, opts.status)
const qRes = await createQuestion(payload)
const questionId = qRes.data?.data?.id
if (!questionId) {
throw new Error(
(qRes.data as { message?: string } | undefined)?.message ??
"Could not create question",
)
}
await addQuestionToSet(setId, {
question_id: questionId,
display_order: displayOrder,
})
}
const practiceRes = opts.examPrepLessonId
? await createExamPrepLessonPractice(opts.examPrepLessonId, {
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
persona_id: opts.personaId,
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
})
: await createParentLinkedPractice({
parent_kind: opts.parentKind,
parent_id: opts.parentId,
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
publish_status: opts.status,
persona_id: opts.personaId,
})
const practiceId = practiceRes.data?.data?.id
if (!practiceId) {
throw new Error(
(practiceRes.data as { message?: string } | undefined)?.message ??
"Could not create practice",
)
}
return { questionSetId: setId, practiceId }
return executePracticeCreation(opts satisfies PracticeCreationInput)
}

View File

@ -0,0 +1,152 @@
import type { AxiosResponse } from "axios"
import {
addQuestionToSet,
createExamPrepLessonPractice,
createParentLinkedPractice,
createQuestion,
createQuestionSet,
} from "../api/courses.api"
import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
questionRowHasContent,
validateDefinitionQuestion,
type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion"
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
export interface PracticeCreationInput {
parentKind: PracticeParentKind
parentId: number
status: "DRAFT" | "PUBLISHED"
questionSetTitle: string
questionSetDescription?: string | null
shuffleQuestions: boolean
practiceTitle: string
storyDescription: string
storyImage: string
quickTips: string
personaName?: string | null
personaId: number
questions: LearnEnglishDefinitionQuestionInput[]
definitions: QuestionTypeDefinition[]
/** English proficiency lesson practices use POST /exam-prep/lessons/:id/practices. */
examPrepLessonId?: number
}
function extractCreatedResourceId(
res: AxiosResponse<{ data?: { id?: number }; message?: string }>,
fallbackMessage: string,
): number {
const id = res.data?.data?.id
if (typeof id === "number" && Number.isFinite(id) && id > 0) return id
const message = res.data?.message?.trim()
throw new Error(message || fallbackMessage)
}
export function validatePracticeQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[],
): string | null {
const byId = new Map(definitions.map((d) => [d.id, d]))
const filled = questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
if (filled.length === 0) return "Add at least one question with content."
for (let i = 0; i < filled.length; i++) {
const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
return `Question ${i + 1}: select a question type from the list.`
}
const def = byId.get(q.questionTypeDefinitionId)
if (!def) {
return `Question ${i + 1}: type definition #${q.questionTypeDefinitionId} was not found. Refresh and try again.`
}
const err = validateDefinitionQuestion(def, q, i + 1)
if (err) return err
}
return null
}
/**
* Coordinated practice creation (5 APIs):
* 1. POST /question-sets create PRACTICE question set
* 2. POST /questions create each question (DYNAMIC or legacy type)
* 3. POST /question-sets/:id/questions attach questions to the set
* 4. Personas are loaded in the UI (GET /personas) before submit; persona_id is sent on step 5
* 5. POST /practices or POST /exam-prep/lessons/:id/practices create practice
*/
export async function executePracticeCreation(
opts: PracticeCreationInput,
): Promise<{ questionSetId: number; practiceId: number }> {
const err = validatePracticeQuestionsWithDefinitions(opts.questions, opts.definitions)
if (err) throw new Error(err)
if (!Number.isFinite(opts.personaId) || opts.personaId < 1) {
throw new Error("persona_id is required. Select a persona before saving.")
}
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
// Step 1 — create question set
const setRes = await createQuestionSet({
title: opts.questionSetTitle.trim() || "Practice question set",
description: opts.questionSetDescription?.trim() || null,
set_type: "PRACTICE",
owner_type: opts.parentKind,
owner_id: opts.parentId,
shuffle_questions: opts.shuffleQuestions,
status: opts.status,
...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
})
const setId = extractCreatedResourceId(setRes, "Could not create question set")
const toCreate = opts.questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
// Steps 2 & 3 — create questions and attach to set
let displayOrder = 0
for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId)
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
displayOrder += 1
const payload = buildCreateQuestionFromDefinition(def, q, opts.status)
const qRes = await createQuestion(payload)
const questionId = extractCreatedResourceId(qRes, "Could not create question")
await addQuestionToSet(setId, {
question_id: questionId,
display_order: displayOrder,
})
}
// Step 5 — create practice (persona_id + question_set_id from step 1)
const practiceRes = opts.examPrepLessonId
? await createExamPrepLessonPractice(opts.examPrepLessonId, {
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
persona_id: opts.personaId,
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
publish_status: opts.status,
})
: await createParentLinkedPractice({
parent_kind: opts.parentKind,
parent_id: opts.parentId,
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
publish_status: opts.status,
persona_id: opts.personaId,
})
const practiceId = extractCreatedResourceId(practiceRes, "Could not create practice")
return { questionSetId: setId, practiceId }
}

View File

@ -15,10 +15,10 @@ import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
import { emptyDynamicFieldValuesForDefinition } from "../../lib/learnEnglishDefinitionQuestion";
import {
executeLearnEnglishPracticeCreation,
learnEnglishPracticeApiErrorMessage,
validateLearnEnglishQuestionsWithDefinitions,
} from "../../lib/learnEnglishPracticePublish";
import { executePracticeCreation } from "../../lib/practiceCreationOrchestrator";
import { ContextStep } from "./components/practice-steps/ContextStep";
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
@ -198,6 +198,8 @@ export function AddPracticeFlow() {
id: "q1",
questionTypeDefinitionId: null as number | null,
text: "",
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
points: 1,
dynamicFieldValues: {} as Record<string, string>,
mcqOptions: [
{ text: "", isCorrect: true },
@ -292,6 +294,11 @@ export function AddPracticeFlow() {
const mappedQuestions = formData.questions.map((q) => ({
questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
difficultyLevel: (q.difficultyLevel ?? "EASY") as
| "EASY"
| "MEDIUM"
| "HARD",
points: Number.isFinite(Number(q.points)) ? Number(q.points) : 1,
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
mcqOptions: (q.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
@ -324,7 +331,7 @@ export function AddPracticeFlow() {
setSubmitting(true);
try {
await executeLearnEnglishPracticeCreation({
await executePracticeCreation({
parentKind: parentContext.kind,
parentId: parentContext.id,
examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined,
@ -351,13 +358,7 @@ export function AddPracticeFlow() {
questions: mappedQuestions,
definitions: typeDefinitions,
});
toast.success(
status === "PUBLISHED" ? "Practice published" : "Draft saved",
{
description:
"Question set, questions, and parent-linked practice were created.",
},
);
toast.success("Practice created successfully");
setIsPublished(true);
} catch (e) {
toast.error("Could not save practice", {
@ -415,6 +416,8 @@ export function AddPracticeFlow() {
questionTypeDefinitionId:
typeDefinitions[0]?.id ?? (null as number | null),
text: "",
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
points: 1,
dynamicFieldValues: typeDefinitions[0]
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
: {},

View File

@ -9,14 +9,28 @@ import {
Loader2,
RefreshCw,
Sparkles,
Trash2,
} from "lucide-react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { getPracticesByParentLesson } from "../../api/courses.api";
import {
deleteExamPrepPractice,
getExamPrepLessonPractices,
getPracticesByParentLesson,
} from "../../api/courses.api";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import type {
ExamPrepLessonPractice,
GetExamPrepLessonPracticesResponse,
GetPracticesByParentContextResponse,
ParentContextPractice,
} from "../../types/course.types";
@ -31,6 +45,32 @@ function unwrapPracticesEnvelope(
return b.data ?? b.Data ?? null;
}
function unwrapExamPrepPracticesEnvelope(
res: { data?: GetExamPrepLessonPracticesResponse & { Data?: GetExamPrepLessonPracticesResponse["data"] } },
): GetExamPrepLessonPracticesResponse["data"] | null {
const b = res.data;
if (!b) return null;
return b.data ?? b.Data ?? null;
}
function mapExamPrepPracticeToCard(
practice: ExamPrepLessonPractice,
): ParentContextPractice {
return {
id: practice.id,
parent_kind: "LESSON",
parent_id: practice.lesson_id,
title: practice.title,
story_description: practice.story_description ?? "",
story_image: practice.story_image ?? "",
question_set_id: practice.question_set_id,
quick_tips: practice.quick_tips ?? "",
publish_status: practice.publish_status,
persona_id: practice.persona_id,
created_at: practice.created_at,
};
}
function formatPracticeDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
@ -42,10 +82,12 @@ function PracticeCard({
practice,
index,
total,
onDelete,
}: {
practice: ParentContextPractice;
index: number;
total: number;
onDelete?: () => void;
}) {
const [imgFailed, setImgFailed] = useState(false);
const thumb = resolveThumbnailForPreview(practice.story_image);
@ -91,13 +133,35 @@ function PracticeCard({
</div>
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
Practice {index + 1} of {total}
</span>
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
ID {practice.id}
</Badge>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
Practice {index + 1} of {total}
</span>
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
ID {practice.id}
</Badge>
{practice.publish_status ? (
<Badge
variant={practice.publish_status === "PUBLISHED" ? "default" : "secondary"}
className="text-[10px] font-semibold normal-case"
>
{practice.publish_status}
</Badge>
) : null}
</div>
{onDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
) : null}
</div>
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
@ -165,6 +229,8 @@ export function LessonPracticesPage() {
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [practiceToDelete, setPracticeToDelete] = useState<ParentContextPractice | null>(null);
const [deleting, setDeleting] = useState(false);
const lid = lessonId ? Number(lessonId) : NaN;
const validLesson = Number.isFinite(lid) && lid > 0;
@ -179,15 +245,29 @@ export function LessonPracticesPage() {
setLoading(true);
setLoadError(null);
try {
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
const envelope = unwrapPracticesEnvelope(res);
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
setPractices(list);
setTotalCount(
typeof envelope?.total_count === "number"
? envelope.total_count
: list.length,
);
if (isExamPrep) {
const res = await getExamPrepLessonPractices(lid, { limit: 100, offset: 0 });
const envelope = unwrapExamPrepPracticesEnvelope(res);
const list = Array.isArray(envelope?.practices)
? envelope.practices.map(mapExamPrepPracticeToCard)
: [];
setPractices(list);
setTotalCount(
typeof envelope?.total_count === "number"
? envelope.total_count
: list.length,
);
} else {
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
const envelope = unwrapPracticesEnvelope(res);
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
setPractices(list);
setTotalCount(
typeof envelope?.total_count === "number"
? envelope.total_count
: list.length,
);
}
} catch {
setPractices([]);
setTotalCount(0);
@ -196,7 +276,7 @@ export function LessonPracticesPage() {
} finally {
setLoading(false);
}
}, [lid, validLesson]);
}, [isExamPrep, lid, validLesson]);
useEffect(() => {
void load();
@ -209,6 +289,22 @@ export function LessonPracticesPage() {
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
const confirmDeletePractice = async () => {
if (!practiceToDelete) return;
setDeleting(true);
try {
await deleteExamPrepPractice(practiceToDelete.id);
toast.success("Practice deleted");
setPracticeToDelete(null);
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
toast.error(err.response?.data?.message || "Failed to delete practice");
} finally {
setDeleting(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
@ -369,12 +465,52 @@ export function LessonPracticesPage() {
practice={p}
index={i}
total={practices.length}
onDelete={
isExamPrep ? () => setPracticeToDelete(p) : undefined
}
/>
))}
</div>
)}
</div>
</div>
<Dialog
open={practiceToDelete !== null}
onOpenChange={(open) => {
if (!open && !deleting) setPracticeToDelete(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this practice?</DialogTitle>
</DialogHeader>
<p className="text-sm text-grayScale-600">
<span className="font-semibold text-grayScale-900">
{practiceToDelete?.title}
</span>{" "}
will be removed from this lesson. This action cannot be undone.
</p>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={deleting}
onClick={() => setPracticeToDelete(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={deleting}
onClick={() => void confirmDeletePractice()}
>
{deleting ? "Deleting…" : "Delete practice"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -27,6 +27,8 @@ function createEmptyQuestionRow(id: string) {
id,
questionTypeDefinitionId: null as number | null,
text: "",
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
points: 1,
dynamicFieldValues: {} as Record<string, string>,
mcqOptions: defaultMcqOptions(),
trueFalseCorrect: true,
@ -376,6 +378,55 @@ export function QuestionsStep({
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Difficulty
</label>
<select
className="h-11 w-full rounded-lg border border-grayScale-200 bg-white px-3 text-sm font-medium text-grayScale-800"
value={q.difficultyLevel ?? "EASY"}
onChange={(e) => {
const newQuestions = [...formData.questions];
newQuestions[i] = {
...newQuestions[i],
difficultyLevel: e.target.value as
| "EASY"
| "MEDIUM"
| "HARD",
};
setFormData({ ...formData, questions: newQuestions });
}}
>
<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-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Points
</label>
<Input
type="number"
min={1}
step={1}
value={q.points ?? 1}
onChange={(e) => {
const newQuestions = [...formData.questions];
const parsed = Number.parseInt(e.target.value, 10);
newQuestions[i] = {
...newQuestions[i],
points:
Number.isFinite(parsed) && parsed > 0 ? parsed : 1,
};
setFormData({ ...formData, questions: newQuestions });
}}
className="h-11 rounded-lg border-grayScale-200"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Question type

View File

@ -375,6 +375,7 @@ export interface CreateExamPrepLessonPracticeRequest {
persona_id: number
question_set_id: number
quick_tips: string
publish_status?: PracticePublishStatus
}
export interface CreateExamPrepLessonPracticeResponse {
@ -385,6 +386,34 @@ export interface CreateExamPrepLessonPracticeResponse {
metadata: unknown | null
}
/** Row from GET /exam-prep/lessons/:lessonId/practices */
export interface ExamPrepLessonPractice {
id: number
lesson_id: number
title: string
story_description?: string
story_image?: string
persona_id?: number | null
question_set_id: number
publish_status?: PracticePublishStatus | string | null
quick_tips?: string
created_at: string
updated_at?: string
}
export interface GetExamPrepLessonPracticesResponse {
message: string
data: {
practices: ExamPrepLessonPractice[]
total_count: number
limit: number
offset: number
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepModuleLessonResponse {
message: string
data: ExamPrepModuleLessonItem