diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 65b9c26..786d47c 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -97,6 +97,9 @@ import type { CreateExamPrepModuleLessonResponse, UpdateExamPrepModuleLessonRequest, UpdateExamPrepModuleLessonResponse, + PublishExamPrepModuleLessonRequest, + CreateExamPrepLessonPracticeRequest, + CreateExamPrepLessonPracticeResponse, GetExamPrepModuleLessonsResponse, GetTopLevelModuleLessonsResponse, GetPracticesByParentContextResponse, @@ -587,10 +590,26 @@ export const updateExamPrepModuleLesson = ( data, ) +/** PUT /exam-prep/lessons/:lessonId — set publish_status only (draft or published). */ +export const publishExamPrepModuleLesson = ( + lessonId: number, + data: PublishExamPrepModuleLessonRequest, +) => http.put(`/exam-prep/lessons/${lessonId}`, data) + /** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */ export const deleteExamPrepModuleLesson = (lessonId: number) => http.delete(`/exam-prep/lessons/${lessonId}`) +/** POST /exam-prep/lessons/:lessonId/practices */ +export const createExamPrepLessonPractice = ( + lessonId: number, + data: CreateExamPrepLessonPracticeRequest, +) => + http.post( + `/exam-prep/lessons/${lessonId}/practices`, + data, + ) + /** Top-level course resource (Learn English track) — PUT /courses/:id */ export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) => http.put(`/courses/${courseId}`, data) diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index e51afaf..fb38155 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -24,8 +24,6 @@ import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage"; import { AddVideoFlow } from "../pages/content-management/AddVideoFlow"; import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow"; import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage"; -import { AttachPracticeFlow } from "../pages/content-management/AttachPracticeFlow"; -import { AttachProgramPracticeFlow } from "../pages/content-management/AttachProgramPracticeFlow"; import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage"; import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage"; import { CourseManagementPage } from "../pages/content-management/CourseManagementPage"; @@ -191,12 +189,16 @@ export function AppRoutes() { element={} /> } + path="/new-content/courses/:programType/add-practice" + element={} /> } + path="/new-content/courses/:programType/:courseId/add-practice" + element={} + /> + } /> } /> + } + /> } diff --git a/src/lib/learnEnglishPracticePublish.ts b/src/lib/learnEnglishPracticePublish.ts index 1a47f4e..5225496 100644 --- a/src/lib/learnEnglishPracticePublish.ts +++ b/src/lib/learnEnglishPracticePublish.ts @@ -1,6 +1,7 @@ import type { AxiosError } from "axios" import { addQuestionToSet, + createExamPrepLessonPractice, createParentLinkedPractice, createQuestion, createQuestionSet, @@ -72,6 +73,8 @@ export async function executeLearnEnglishPracticeCreation(opts: { 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, @@ -128,17 +131,26 @@ export async function executeLearnEnglishPracticeCreation(opts: { }) } - const practiceRes = 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 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) { diff --git a/src/lib/multipleChoiceSlotValue.ts b/src/lib/multipleChoiceSlotValue.ts new file mode 100644 index 0000000..a23a19e --- /dev/null +++ b/src/lib/multipleChoiceSlotValue.ts @@ -0,0 +1,124 @@ +export interface MultipleChoiceOptionValue { + id: string + text: string + is_correct: boolean +} + +export interface MultipleChoiceSlotValue { + options: MultipleChoiceOptionValue[] +} + +const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const + +export function defaultMultipleChoiceSlotValue( + count = 4, +): MultipleChoiceSlotValue { + return { + options: Array.from({ length: count }, (_, index) => ({ + id: DEFAULT_OPTION_IDS[index] ?? String(index + 1), + text: "", + is_correct: index === 0, + })), + } +} + +export function serializeMultipleChoiceSlotValue( + value: MultipleChoiceSlotValue, +): string { + return JSON.stringify(value) +} + +export function parseMultipleChoiceSlotValue( + raw: string | undefined, +): MultipleChoiceSlotValue { + const trimmed = (raw ?? "").trim() + if (!trimmed) return defaultMultipleChoiceSlotValue() + try { + const parsed = JSON.parse(trimmed) as unknown + return normalizeMultipleChoiceValue(parsed) + } catch { + return defaultMultipleChoiceSlotValue() + } +} + +export function normalizeMultipleChoiceValue( + raw: unknown, + mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[], +): MultipleChoiceSlotValue { + if (mcqOptions?.some((o) => (o.option_text ?? o.text ?? "").trim())) { + return { + options: mcqOptions + .map((option, index) => ({ + id: DEFAULT_OPTION_IDS[index] ?? String(index + 1), + text: (option.option_text ?? option.text ?? "").trim(), + is_correct: Boolean(option.is_correct ?? option.isCorrect), + })) + .filter((option) => option.text), + } + } + + if (raw && typeof raw === "object") { + const record = raw as Record + if (Array.isArray(record.options)) { + return { + options: record.options.map((option, index) => + normalizeMultipleChoiceOption(option, index), + ), + } + } + } + + if (Array.isArray(raw)) { + return { + options: raw.map((option, index) => + normalizeMultipleChoiceOption(option, index), + ), + } + } + + if (typeof raw === "string" && raw.trim()) { + try { + return normalizeMultipleChoiceValue(JSON.parse(raw) as unknown) + } catch { + return defaultMultipleChoiceSlotValue() + } + } + + return defaultMultipleChoiceSlotValue() +} + +function normalizeMultipleChoiceOption( + raw: unknown, + index: number, +): MultipleChoiceOptionValue { + if (raw && typeof raw === "object") { + const record = raw as Record + return { + id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1), + text: String(record.text ?? record.option_text ?? "").trim(), + is_correct: Boolean(record.is_correct ?? record.isCorrect), + } + } + return { + id: DEFAULT_OPTION_IDS[index] ?? String(index + 1), + text: String(raw ?? "").trim(), + is_correct: false, + } +} + +export function multipleChoiceSlotHasContent( + value: MultipleChoiceSlotValue, +): boolean { + return value.options.some((option) => option.text.trim()) +} + +export function validateMultipleChoiceSlotValue( + value: MultipleChoiceSlotValue, +): string | null { + const filled = value.options.filter((option) => option.text.trim()) + if (filled.length < 2) return "Add at least two choices with text." + if (!filled.some((option) => option.is_correct)) { + return "Mark one choice as correct." + } + return null +} diff --git a/src/lib/practiceDynamicQuestionPayload.ts b/src/lib/practiceDynamicQuestionPayload.ts index 821e636..fbbd87c 100644 --- a/src/lib/practiceDynamicQuestionPayload.ts +++ b/src/lib/practiceDynamicQuestionPayload.ts @@ -1,4 +1,9 @@ import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types" +import { + multipleChoiceSlotHasContent, + normalizeMultipleChoiceValue, + parseMultipleChoiceSlotValue, +} from "./multipleChoiceSlotValue" /** Parse a single slot value: plain string/URL, or JSON object/array when input looks like JSON. */ export function parseDynamicSlotValue(raw: string | undefined): unknown { @@ -14,21 +19,78 @@ export function parseDynamicSlotValue(raw: string | undefined): unknown { return t } +const PLAIN_TEXT_STIMULUS_KINDS = new Set([ + "INSTRUCTION", + "QUESTION_TEXT", + "TEXT_PASSAGE", + "TEXT", + "TEXT_INPUT", +]) + +function isMultipleChoiceKind(kind: string): boolean { + const upper = kind.trim().toUpperCase() + return upper === "MULTIPLE_CHOICE" || upper === "OPTION" +} + +function slotValueForRow( + row: { id: string; kind: string }, + side: "stimulus" | "response", + fieldValues: Record, + mcqOptions: { option_text: string; is_correct: boolean }[] | undefined, + mcqOptionsConsumed: { current: boolean }, +): unknown { + const fieldKey = `${side}:${row.id}` + const rawField = fieldValues[fieldKey] + + if (isMultipleChoiceKind(row.kind)) { + const fromField = parseMultipleChoiceSlotValue(rawField) + if (multipleChoiceSlotHasContent(fromField)) { + return normalizeMultipleChoiceValue(fromField) + } + if (mcqOptions && !mcqOptionsConsumed.current) { + mcqOptionsConsumed.current = true + return normalizeMultipleChoiceValue(undefined, mcqOptions) + } + return normalizeMultipleChoiceValue(fromField) + } + + if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) { + return (rawField ?? "").trim() + } + + return parseDynamicSlotValue(rawField) +} + export function buildDynamicQuestionPayload(input: { stimulusRows: { id: string; kind: string }[] responseRows: { id: string; kind: string }[] fieldValues: Record + mcqOptions?: { option_text: string; is_correct: boolean }[] }): DynamicQuestionPayload { + const mcqOptionsConsumed = { current: false } + return { stimulus: input.stimulusRows.map((row) => ({ id: row.id, kind: row.kind, - value: parseDynamicSlotValue(input.fieldValues[`stimulus:${row.id}`]), + value: slotValueForRow( + row, + "stimulus", + input.fieldValues, + input.mcqOptions, + mcqOptionsConsumed, + ), })), response: input.responseRows.map((row) => ({ id: row.id, kind: row.kind, - value: parseDynamicSlotValue(input.fieldValues[`response:${row.id}`]), + value: slotValueForRow( + row, + "response", + input.fieldValues, + input.mcqOptions, + mcqOptionsConsumed, + ), })), } } diff --git a/src/pages/content-management/AddPracticeFlow.tsx b/src/pages/content-management/AddPracticeFlow.tsx index 2c506fe..8e3953d 100644 --- a/src/pages/content-management/AddPracticeFlow.tsx +++ b/src/pages/content-management/AddPracticeFlow.tsx @@ -35,13 +35,41 @@ const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const; export function AddPracticeFlow() { const navigate = useNavigate(); - const { level } = useParams<{ level: string }>(); + const { + level, + programType, + courseId: routeCourseId, + unitId: routeUnitId, + moduleId: routeModuleId, + } = useParams<{ + level?: string; + programType?: string; + courseId?: string; + unitId?: string; + moduleId?: string; + }>(); const [searchParams] = useSearchParams(); - const backTo = searchParams.get("backTo"); - const courseId = searchParams.get("courseId"); - const moduleId = searchParams.get("moduleId"); + const backToParam = searchParams.get("backTo"); const lessonId = searchParams.get("lessonId"); const lessonTitleRaw = searchParams.get("lessonTitle"); + + const isExamPrep = Boolean(programType?.trim()); + + const effectiveBackTo = useMemo(() => { + if (backToParam?.trim()) return backToParam.trim(); + if (isExamPrep && routeModuleId) return "module"; + if (isExamPrep && routeCourseId) return "courses"; + return null; + }, [backToParam, isExamPrep, routeModuleId, routeCourseId]); + + const courseId = isExamPrep + ? routeCourseId ?? searchParams.get("courseId") + : searchParams.get("courseId"); + const moduleId = isExamPrep + ? routeModuleId ?? searchParams.get("moduleId") + : searchParams.get("moduleId"); + const unitId = isExamPrep ? routeUnitId : null; + const lessonTitleDisplay = (() => { const raw = lessonTitleRaw?.trim(); if (!raw) return null; @@ -52,12 +80,15 @@ export function AddPracticeFlow() { } })(); - const isModuleContext = backTo === "module"; - const isCourseContext = backTo === "modules"; + const isModuleContext = effectiveBackTo === "module"; + const isCourseContext = + effectiveBackTo === "modules" || effectiveBackTo === "courses"; const isLessonPractice = useMemo(() => { const lid = lessonId ? Number(lessonId) : NaN; return Number.isFinite(lid) && lid > 0; }, [lessonId]); + /** Learn English lesson practices skip story fields; exam prep lessons use the full form. */ + const isLearnEnglishLessonPractice = isLessonPractice && !isExamPrep; const parentContext = useMemo((): { kind: PracticeParentKind; @@ -89,18 +120,60 @@ export function AddPracticeFlow() { courseId, ]); + const programLabel = isExamPrep + ? programType === "skill" + ? "Skill-Based Courses" + : "English Proficiency Exams" + : level + ? `Program ${level}` + : null; + const backLabel = - backTo === "module" + effectiveBackTo === "module" ? "Back to Module" - : backTo === "modules" + : effectiveBackTo === "modules" ? "Back to Modules" - : "Back to Courses"; - const backPath = - backTo === "module" && courseId && moduleId - ? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}` - : backTo === "modules" && courseId - ? `/new-content/learn-english/${level}/courses/${courseId}` - : `/new-content/learn-english/${level}/courses`; + : effectiveBackTo === "courses" + ? "Back to Course" + : isExamPrep + ? "Back to Program" + : "Back to Courses"; + + const backPath = useMemo(() => { + if (isExamPrep) { + if ( + effectiveBackTo === "module" && + programType && + courseId && + unitId && + moduleId + ) { + return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`; + } + if (effectiveBackTo === "courses" && programType && courseId) { + return `/new-content/courses/${programType}/${courseId}`; + } + if (programType) { + return `/new-content/courses/${programType}`; + } + return "/new-content"; + } + if (effectiveBackTo === "module" && level && courseId && moduleId) { + return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`; + } + if (effectiveBackTo === "modules" && level && courseId) { + return `/new-content/learn-english/${level}/courses/${courseId}`; + } + return `/new-content/learn-english/${level}/courses`; + }, [ + isExamPrep, + effectiveBackTo, + programType, + courseId, + unitId, + moduleId, + level, + ]); const [currentStep, setCurrentStep] = useState(1); const [isPublished, setIsPublished] = useState(false); @@ -194,7 +267,7 @@ export function AddPracticeFlow() { return; } if ( - !isLessonPractice && + !isLearnEnglishLessonPractice && (!formData.title.trim() || !formData.description.trim()) ) { toast.error("Title and story description are required", { @@ -243,26 +316,35 @@ export function AddPracticeFlow() { lessonTitleDisplay?.trim() || (lessonId ? `Lesson ${lessonId} practice` : "Lesson practice"); + const useExamPrepLessonApi = + isExamPrep && + isLessonPractice && + parentContext.kind === "LESSON" && + Number.isFinite(parentContext.id); + setSubmitting(true); try { await executeLearnEnglishPracticeCreation({ parentKind: parentContext.kind, parentId: parentContext.id, + examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined, status, - questionSetTitle: isLessonPractice + questionSetTitle: isLearnEnglishLessonPractice ? lessonDefaultTitle : formData.title.trim() || "Practice set", - questionSetDescription: isLessonPractice + questionSetDescription: isLearnEnglishLessonPractice ? null : formData.description.trim() || null, shuffleQuestions: formData.shuffleQuestions, - practiceTitle: isLessonPractice + practiceTitle: isLearnEnglishLessonPractice ? lessonDefaultTitle : formData.title.trim() || "Untitled practice", - storyDescription: isLessonPractice + storyDescription: isLearnEnglishLessonPractice ? "" : formData.description.trim(), - storyImage: isLessonPractice ? "" : formData.storyImageUrl.trim(), + storyImage: isLearnEnglishLessonPractice + ? "" + : formData.storyImageUrl.trim(), quickTips: formData.tips.trim(), personaName: persona?.name ?? null, personaId, @@ -368,7 +450,7 @@ export function AddPracticeFlow() { setFormData={setFormData} nextStep={nextStep} onCancel={() => navigate(backPath)} - isLessonPractice={isLessonPractice} + isLessonPractice={isLearnEnglishLessonPractice} lessonTitle={lessonTitleDisplay} parentSummary={parentSummary} /> @@ -404,9 +486,9 @@ export function AddPracticeFlow() { formData={formData} selectedPersona={selectedPersona} personas={personas} - isLessonPractice={isLessonPractice} + isLessonPractice={isLearnEnglishLessonPractice} lessonTitle={lessonTitleDisplay} - programLabel={level ? `Program ${level}` : null} + programLabel={programLabel} courseLabel={courseId ? `Course ${courseId}` : null} moduleLabel={moduleId ? `Module ${moduleId}` : null} prevStep={prevStep} @@ -466,9 +548,9 @@ export function AddPracticeFlow() { formData={formData} selectedPersona={selectedPersona} personas={personas} - isLessonPractice={isLessonPractice} + isLessonPractice={isLearnEnglishLessonPractice} lessonTitle={lessonTitleDisplay} - programLabel={level ? `Program ${level}` : null} + programLabel={programLabel} courseLabel={courseId ? `Course ${courseId}` : null} moduleLabel={moduleId ? `Module ${moduleId}` : null} prevStep={prevStep} diff --git a/src/pages/content-management/CourseDetailPage.tsx b/src/pages/content-management/CourseDetailPage.tsx index ff827fb..2c7b5aa 100644 --- a/src/pages/content-management/CourseDetailPage.tsx +++ b/src/pages/content-management/CourseDetailPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowLeft, Plus, @@ -26,17 +26,27 @@ import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.s import alertSrc from "../../assets/Alert.svg"; import { deleteTopLevelCourseModule, + getPracticesByParentCourse, getProgramCourses, getTopLevelCourseModules, + publishParentLinkedPractice, + updateParentLinkedPractice, updateTopLevelCourseModule, } from "../../api/courses.api"; import { refreshFileUrl, resolveFileUrl } from "../../api/files.api"; import type { + ParentContextPractice, ProgramCourseListItem, TopLevelCourseModuleItem, } from "../../types/course.types"; +import { + isPracticeDraft, + isPracticePublished, + unwrapPracticesList, +} from "../../lib/parentContextPractice"; import { AddModuleModal } from "./components/AddModuleModal"; import { ModuleIconUploadField } from "./components/ModuleIconUploadField"; +import { ModulePracticeCard } from "./components/ModulePracticeCard"; import { PublishPracticeButton } from "./components/PublishPracticeButton"; const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const; @@ -155,6 +165,17 @@ export function CourseDetailPage() { useState(null); const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false); + const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules"); + const [practiceFilter, setPracticeFilter] = useState("All"); + const [practices, setPractices] = useState([]); + const [practicesLoading, setPracticesLoading] = useState(false); + const [practicesLoadError, setPracticesLoadError] = useState( + null, + ); + const [publishStatusPracticeId, setPublishStatusPracticeId] = useState< + number | null + >(null); + const openEditModule = (module: TopLevelCourseModuleItem) => { setEditingModule(module); setEditModuleName(module.name ?? ""); @@ -260,6 +281,91 @@ export function CourseDetailPage() { void loadPage(); }, [loadPage]); + const loadCoursePractices = useCallback(async () => { + if (!Number.isFinite(courseIdNum) || courseIdNum < 1) { + setPractices([]); + setPracticesLoadError(null); + setPracticesLoading(false); + return; + } + setPracticesLoading(true); + setPracticesLoadError(null); + try { + const res = await getPracticesByParentCourse(courseIdNum, { + limit: 100, + offset: 0, + }); + setPractices(unwrapPracticesList(res)); + } catch { + setPractices([]); + setPracticesLoadError("Failed to load practices. Please try again."); + } finally { + setPracticesLoading(false); + } + }, [courseIdNum]); + + useEffect(() => { + if (activeTab !== "practice") return; + void loadCoursePractices(); + }, [activeTab, loadCoursePractices]); + + const filteredPractices = useMemo(() => { + if (practiceFilter === "Published") { + return practices.filter(isPracticePublished); + } + if (practiceFilter === "Draft") { + return practices.filter(isPracticeDraft); + } + if (practiceFilter === "Archived") { + return []; + } + return practices; + }, [practices, practiceFilter]); + + const handlePublishPractice = async (practiceId: number) => { + setPublishStatusPracticeId(practiceId); + try { + await publishParentLinkedPractice(practiceId); + setPractices((prev) => + prev.map((p) => + p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p, + ), + ); + toast.success("Practice published"); + } catch (e: unknown) { + console.error(e); + const msg = + (e as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to publish practice"; + toast.error(msg); + } finally { + setPublishStatusPracticeId(null); + } + }; + + const handleSavePracticeAsDraft = async (practiceId: number) => { + setPublishStatusPracticeId(practiceId); + try { + await updateParentLinkedPractice(practiceId, { + publish_status: "DRAFT", + }); + setPractices((prev) => + prev.map((p) => + p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p, + ), + ); + toast.success("Practice saved as draft"); + } catch (e: unknown) { + console.error(e); + const msg = + (e as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to save practice as draft"; + toast.error(msg); + } finally { + setPublishStatusPracticeId(null); + } + }; + const handleSaveModuleEdit = async () => { if (!editingModule) return; const name = editModuleName.trim(); @@ -391,20 +497,32 @@ export function CourseDetailPage() { -
-