align flows tab data sources with unified sub-module hierarchy

Switch flows detail loading from legacy learning-path assumptions to unified course hierarchy APIs (sub-modules, sub-module videos, and SUB_MODULE-owned practice sets).

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-14 09:55:28 -07:00
parent 416b18794c
commit 5b1d3903e0

View File

@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { import {
BadgeCheck,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
GripVertical, GripVertical,
@ -32,9 +31,9 @@ import { Badge } from "../../components/ui/badge"
import { import {
getCourseCategories, getCourseCategories,
getCoursesByCategory, getCoursesByCategory,
getLearningPath, getSubModulesByCourse,
getVideosBySubModule,
getQuestionSetsByOwner, getQuestionSetsByOwner,
getSubModuleEntryAssessment,
reorderCategories, reorderCategories,
reorderCourses, reorderCourses,
reorderSubModules, reorderSubModules,
@ -194,9 +193,7 @@ export function CourseFlowBuilderPage() {
const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>( const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>(
{}, {},
) )
const [entryAssessmentBySubCourse, setEntryAssessmentBySubCourse] = useState<Record<number, QuestionSet | null>>( const [videosBySubCourse, setVideosBySubCourse] = useState<Record<number, LearningPathVideo[]>>({})
{},
)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingCourses, setLoadingCourses] = useState(false) const [loadingCourses, setLoadingCourses] = useState(false)
@ -280,47 +277,94 @@ export function CourseFlowBuilderPage() {
const load = async () => { const load = async () => {
setLoadingPath(true) setLoadingPath(true)
try { try {
const res = await getLearningPath(selectedCourseId) const selectedCourse = activeCourses.find((course) => course.id === selectedCourseId)
const path = res.data.data const subRes = await getSubModulesByCourse(selectedCourseId)
const subCourses = sortByDisplayOrder((subRes.data.data.sub_courses ?? []) as any[]).map((sc) => ({
id: sc.id,
title: sc.title,
description: sc.description ?? "",
thumbnail: sc.thumbnail ?? "",
display_order: sc.display_order ?? 0,
level: sc.level ?? sc.cefr_level ?? "",
sub_level: sc.sub_level ?? "",
prerequisite_count: 0,
video_count: 0,
practice_count: 0,
prerequisites: [],
videos: [],
practices: [],
}))
setLearningPath({ setLearningPath({
...path, course_id: selectedCourseId,
sub_courses: sortByDisplayOrder(path.sub_courses ?? []), course_title: selectedCourse?.title ?? "",
description: selectedCourse?.description ?? "",
thumbnail: selectedCourse?.thumbnail ?? "",
intro_video_url: "",
category_id: selectedCategoryId ?? 0,
category_name: topLevelCategories.find((cat) => cat.id === selectedCategoryId)?.name ?? "",
sub_courses: subCourses,
}) })
// Practices source of truth: question sets by SUB_COURSE owner. if (subCourses.length === 0) {
const subCourses = path.sub_courses ?? [] setPracticesBySubCourse({})
if (subCourses.length > 0) { setVideosBySubCourse({})
const ownerResults = await Promise.all( return
}
const [ownerResults, videoResults] = await Promise.all([
Promise.all(
subCourses.map(async (sc) => { subCourses.map(async (sc) => {
const setsRes = await getQuestionSetsByOwner("SUB_COURSE", sc.id) const setsRes = await getQuestionSetsByOwner("SUB_MODULE", sc.id)
return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const
}), }),
) ),
const practiceMap: Record<number, PracticeListItem[]> = {} Promise.all(
ownerResults.forEach(([subCourseId, practiceItems]) => { subCourses.map(async (sc) => {
practiceMap[subCourseId] = practiceItems const videosRes = await getVideosBySubModule(sc.id)
}) const rows = videosRes.data?.data?.videos ?? []
setPracticesBySubCourse(practiceMap) const mapped = sortByDisplayOrder(
} else { rows.map((video: any, idx: number) => ({
setPracticesBySubCourse({}) id: Number(video.id),
} title: String(video.title ?? "Video"),
display_order: Number(video.display_order ?? idx),
duration: Number(video.duration ?? 0),
video_url: String(video.video_url ?? ""),
})),
)
return [sc.id, mapped] as const
}),
),
])
const practiceMap: Record<number, PracticeListItem[]> = {}
ownerResults.forEach(([subCourseId, practiceItems]) => {
practiceMap[subCourseId] = practiceItems
})
setPracticesBySubCourse(practiceMap)
const videoMap: Record<number, LearningPathVideo[]> = {}
videoResults.forEach(([subCourseId, videos]) => {
videoMap[subCourseId] = videos
})
setVideosBySubCourse(videoMap)
} catch { } catch {
toast.error("Failed to load course sub-category learning path.") toast.error("Failed to load course flow detail.")
setLearningPath(null) setLearningPath(null)
} finally { } finally {
setLoadingPath(false) setLoadingPath(false)
} }
} }
load() load()
}, [selectedCourseId]) }, [selectedCourseId, activeCourses, selectedCategoryId, topLevelCategories])
const loadSubCoursePracticeAndEntry = async (subCourseId: number) => { const loadSubCoursePracticeAndEntry = async (subCourseId: number) => {
if (practicesBySubCourse[subCourseId] && entryAssessmentBySubCourse[subCourseId] !== undefined) return if (practicesBySubCourse[subCourseId] && videosBySubCourse[subCourseId]) return
setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true })) setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true }))
try { try {
const [setsRes, entryRes] = await Promise.allSettled([ const [setsRes, videosRes] = await Promise.allSettled([
getQuestionSetsByOwner("SUB_COURSE", subCourseId), getQuestionSetsByOwner("SUB_MODULE", subCourseId),
getSubModuleEntryAssessment(subCourseId), getVideosBySubModule(subCourseId),
]) ])
// No practice sets is a valid empty-state scenario; do not toast for 404/empty. // No practice sets is a valid empty-state scenario; do not toast for 404/empty.
@ -339,20 +383,21 @@ export function CourseFlowBuilderPage() {
[subCourseId]: mapPracticeSetsToPracticeItems(ownerSets), [subCourseId]: mapPracticeSetsToPracticeItems(ownerSets),
})) }))
// Entry assessment may legitimately be absent. const videos =
let entryAssessment: QuestionSet | null = null videosRes.status === "fulfilled"
if (entryRes.status === "fulfilled") { ? sortByDisplayOrder(
entryAssessment = (entryRes.value.data.data ?? null) as QuestionSet | null (videosRes.value.data?.data?.videos ?? []).map((video: any, idx: number) => ({
} else { id: Number(video.id),
const status = entryRes.reason?.response?.status title: String(video.title ?? "Video"),
if (status !== 404) { display_order: Number(video.display_order ?? idx),
throw entryRes.reason duration: Number(video.duration ?? 0),
} video_url: String(video.video_url ?? ""),
} })),
)
setEntryAssessmentBySubCourse((prev) => ({ : []
setVideosBySubCourse((prev) => ({
...prev, ...prev,
[subCourseId]: entryAssessment, [subCourseId]: videos,
})) }))
} catch { } catch {
toast.error("Failed to load practice sets for course.") toast.error("Failed to load practice sets for course.")
@ -694,6 +739,7 @@ export function CourseFlowBuilderPage() {
{learningPath.sub_courses.map((subCourse) => { {learningPath.sub_courses.map((subCourse) => {
const expanded = expandedSubCourseIds.has(subCourse.id) const expanded = expandedSubCourseIds.has(subCourse.id)
const practices = practicesBySubCourse[subCourse.id] ?? [] const practices = practicesBySubCourse[subCourse.id] ?? []
const videos = videosBySubCourse[subCourse.id] ?? subCourse.videos ?? []
return ( return (
<SortableRow key={subCourse.id} id={subCourse.id}> <SortableRow key={subCourse.id} id={subCourse.id}>
<button <button
@ -723,17 +769,12 @@ export function CourseFlowBuilderPage() {
{subCourse.sub_level} {subCourse.sub_level}
</Badge> </Badge>
)} )}
{entryAssessmentBySubCourse[subCourse.id] && ( {/* entry-assessment route is no longer guaranteed across deployments */}
<span className="inline-flex items-center gap-1 text-emerald-600">
<BadgeCheck className="h-3.5 w-3.5" />
Entry assessment
</span>
)}
</p> </p>
</div> </div>
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end"> <div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
{subCourse.videos.length} videos / {practices.length || subCourse.practice_count} practices {videos.length} videos / {practices.length} practices
</Badge> </Badge>
{expanded ? ( {expanded ? (
<ChevronDown className="h-4 w-4 text-grayScale-400" /> <ChevronDown className="h-4 w-4 text-grayScale-400" />
@ -755,16 +796,16 @@ export function CourseFlowBuilderPage() {
onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)} onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)}
> >
<SortableContext <SortableContext
items={subCourse.videos.map((item) => item.id)} items={videos.map((item) => item.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-1.5"> <div className="space-y-1.5">
{subCourse.videos.length === 0 ? ( {videos.length === 0 ? (
<p className="rounded-lg border border-dashed border-grayScale-200 px-2 py-2 text-[11px] text-grayScale-400"> <p className="rounded-lg border border-dashed border-grayScale-200 px-2 py-2 text-[11px] text-grayScale-400">
No videos No videos
</p> </p>
) : ( ) : (
subCourse.videos.map((video) => ( videos.map((video) => (
<SortableChip <SortableChip
key={video.id} key={video.id}
id={video.id} id={video.id}
@ -842,7 +883,7 @@ export function CourseFlowBuilderPage() {
</p> </p>
<p> <p>
Practices load from <code>/question-sets/by-owner</code> filtered by Practices load from <code>/question-sets/by-owner</code> filtered by
<code> set_type=PRACTICE</code>; entry assessment loads from dedicated course endpoint. <code> set_type=PRACTICE</code> and <code>owner_type=SUB_MODULE</code>.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>