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:
parent
b21c679e56
commit
f06bbbee47
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
152
src/lib/practiceCreationOrchestrator.ts
Normal file
152
src/lib/practiceCreationOrchestrator.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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])
|
||||
: {},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user