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:
parent
416b18794c
commit
5b1d3903e0
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user