import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Link } from "react-router-dom" import { BookOpen, ChevronDown, ChevronRight, FolderTree, Languages, Pencil, Plus, Trash2 } from "lucide-react" import { toast } from "sonner" import { Badge } from "../../components/ui/badge" import { Button } from "../../components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog" import { Input } from "../../components/ui/input" import { Select } from "../../components/ui/select" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { Textarea } from "../../components/ui/textarea" import { createModule, deleteModule, getCourseHierarchyByCourseId, getHumanLanguageHierarchy, getPracticesByLevel, updateModule, } from "../../api/courses.api" import { uploadImageFile } from "../../api/files.api" import type { CourseHierarchyRow, HumanLanguageHierarchyFlatRow, Practice } from "../../types/course.types" type IdFilterValue = number | "ALL" type LevelFilterValue = number | "ALL" type SubCategoryOption = { id: number; name: string; category_id: number; category_name: string } type CourseOption = { id: number; title: string } type SubModuleNode = { id: number; title: string; display_order: number | null } type ModuleNode = { id: number; title: string; icon_url?: string | null; sub_modules: SubModuleNode[] } type LevelNode = { id: number; cefr_level: string; title: string; modules: ModuleNode[] } type CourseTreeNode = { course_id: number; course_title: string; levels: LevelNode[] } type DeleteModuleTarget = { courseId: number moduleId: number moduleTitle: string levelTitle: string moduleKey: string } type EditModuleTarget = { courseId: number moduleId: number moduleKey: string levelKey: string } type HierarchyReturnState = { selectedSubCategoryId: IdFilterValue selectedCourseId: IdFilterValue selectedLevelId: LevelFilterValue expandedCourses: string[] expandedLevels: string[] expandedModules: string[] scrollY: number } const HUMAN_LANGUAGE_RETURN_STATE_KEY = "humanLanguageHierarchyReturnState" const CEFR_ORDER = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] const textCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }) const setHas = (set: Set, key: string) => set.has(key) const toggleSetValue = (setState: Dispatch>>, key: string) => { setState((previous) => { const next = new Set(previous) if (next.has(key)) next.delete(key) else next.add(key) return next }) } function cefrSortValue(level: string) { const idx = CEFR_ORDER.indexOf(level.trim().toUpperCase()) return idx === -1 ? Number.MAX_SAFE_INTEGER : idx } function toSubCategoryOptions(rows: HumanLanguageHierarchyFlatRow[]): SubCategoryOption[] { const map = new Map() rows.forEach((row) => { if (!row.sub_category_id || map.has(row.sub_category_id)) return map.set(row.sub_category_id, { id: row.sub_category_id, name: row.sub_category_name ?? "Unnamed sub-category", category_id: row.category_id, category_name: row.category_name, }) }) return Array.from(map.values()).sort((a, b) => textCollator.compare(a.name, b.name) || a.id - b.id) } function toCourseOptions(rows: HumanLanguageHierarchyFlatRow[], subCategoryId: number): CourseOption[] { const map = new Map() rows .filter((row) => row.sub_category_id === subCategoryId && !!row.course_id) .forEach((row) => { if (!row.course_id || map.has(row.course_id)) return map.set(row.course_id, { id: row.course_id, title: row.course_title ?? `Course ${row.course_id}` }) }) return Array.from(map.values()).sort((a, b) => textCollator.compare(a.title, b.title) || a.id - b.id) } function toLevelNodes(rows: CourseHierarchyRow[]): LevelNode[] { const levelMap = new Map }>() rows.forEach((row) => { if (!row.level_id) return const levelId = Number(row.level_id) const cefr = (row.cefr_level ?? "").trim().toUpperCase() if (!levelMap.has(levelId)) { levelMap.set(levelId, { cefr_level: cefr, title: row.level_title?.trim() || cefr || `Level ${levelId}`, modules: new Map(), }) } if (!row.module_id) return const moduleId = Number(row.module_id) const levelNode = levelMap.get(levelId)! if (!levelNode.modules.has(moduleId)) { levelNode.modules.set(moduleId, { id: moduleId, title: row.module_title?.trim() || `Module ${moduleId}`, icon_url: row.module_icon_url ?? null, sub_modules: [], }) } else if (row.module_icon_url && !levelNode.modules.get(moduleId)?.icon_url) { levelNode.modules.set(moduleId, { ...levelNode.modules.get(moduleId)!, icon_url: row.module_icon_url, }) } if (!row.sub_module_id) return const moduleNode = levelNode.modules.get(moduleId)! const subModuleId = Number(row.sub_module_id) if (moduleNode.sub_modules.some((item) => item.id === subModuleId)) return moduleNode.sub_modules.push({ id: subModuleId, title: row.sub_module_title?.trim() || `Sub-module ${subModuleId}`, display_order: row.sub_module_display_order ?? null, }) }) return Array.from(levelMap.entries()) .map(([id, level]) => ({ id, cefr_level: level.cefr_level, title: level.title, modules: Array.from(level.modules.values()) .map((module) => ({ ...module, sub_modules: [...module.sub_modules].sort((a, b) => { const ao = a.display_order ?? Number.MAX_SAFE_INTEGER const bo = b.display_order ?? Number.MAX_SAFE_INTEGER return ao - bo || textCollator.compare(a.title, b.title) || a.id - b.id }), })) .sort((a, b) => textCollator.compare(a.title, b.title) || a.id - b.id), })) .sort((a, b) => { const byCefr = cefrSortValue(a.cefr_level) - cefrSortValue(b.cefr_level) return byCefr || textCollator.compare(a.title, b.title) || a.id - b.id }) } function getNextDefaultModuleName(level: LevelNode) { const usedMinorNumbers = new Set() level.modules.forEach((module) => { const match = module.title.trim().match(/^module-\s*1\.(\d+)$/i) if (!match) return const minor = Number(match[1]) if (Number.isFinite(minor) && minor > 0) { usedMinorNumbers.add(minor) } }) let nextMinor = 1 while (usedMinorNumbers.has(nextMinor)) { nextMinor += 1 } return `Module-1.${nextMinor}` } export function HumanLanguageHierarchyPage() { const readPersistedReturnState = (): HierarchyReturnState | null => { try { const raw = window.sessionStorage.getItem(HUMAN_LANGUAGE_RETURN_STATE_KEY) if (!raw) return null const parsed = JSON.parse(raw) as Partial return { selectedSubCategoryId: parsed.selectedSubCategoryId ?? "ALL", selectedCourseId: parsed.selectedCourseId ?? "ALL", selectedLevelId: parsed.selectedLevelId ?? "ALL", expandedCourses: Array.isArray(parsed.expandedCourses) ? parsed.expandedCourses : [], expandedLevels: Array.isArray(parsed.expandedLevels) ? parsed.expandedLevels : [], expandedModules: Array.isArray(parsed.expandedModules) ? parsed.expandedModules : [], scrollY: Number.isFinite(parsed.scrollY) ? Number(parsed.scrollY) : 0, } } catch { return null } } const persistedReturnStateRef = useRef(readPersistedReturnState()) const skipInitialSubCategoryResetRef = useRef(!!persistedReturnStateRef.current) const skipFilterValidationRef = useRef(!!persistedReturnStateRef.current) const pendingRestoreScrollRef = useRef(persistedReturnStateRef.current?.scrollY ?? null) const [hierarchyRows, setHierarchyRows] = useState([]) const [hierarchyLoading, setHierarchyLoading] = useState(true) const [hierarchyError, setHierarchyError] = useState(null) const [selectedSubCategoryId, setSelectedSubCategoryId] = useState( persistedReturnStateRef.current?.selectedSubCategoryId ?? "ALL", ) const [selectedCourseId, setSelectedCourseId] = useState( persistedReturnStateRef.current?.selectedCourseId ?? "ALL", ) const [selectedLevelId, setSelectedLevelId] = useState( persistedReturnStateRef.current?.selectedLevelId ?? "ALL", ) const [courseRowsByCourseId, setCourseRowsByCourseId] = useState>({}) const [courseHierarchyLoading, setCourseHierarchyLoading] = useState(false) const [courseHierarchyError, setCourseHierarchyError] = useState(null) const [levelPracticesByLevelId, setLevelPracticesByLevelId] = useState>({}) const [expandedCourses, setExpandedCourses] = useState>( () => new Set(persistedReturnStateRef.current?.expandedCourses ?? []), ) const [expandedLevels, setExpandedLevels] = useState>( () => new Set(persistedReturnStateRef.current?.expandedLevels ?? []), ) const [expandedModules, setExpandedModules] = useState>( () => new Set(persistedReturnStateRef.current?.expandedModules ?? []), ) const [createModuleOpen, setCreateModuleOpen] = useState(false) const [createModuleSaving, setCreateModuleSaving] = useState(false) const [createModuleCourseId, setCreateModuleCourseId] = useState(null) const [createModuleLevelId, setCreateModuleLevelId] = useState(null) const [createModuleLevelKey, setCreateModuleLevelKey] = useState("") const [createModuleTitle, setCreateModuleTitle] = useState("") const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false) const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("") const [createModuleDescription, setCreateModuleDescription] = useState("") const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url") const [createModuleIconUrl, setCreateModuleIconUrl] = useState("") const [createModuleIconFile, setCreateModuleIconFile] = useState(null) const [createModuleDisplayOrder, setCreateModuleDisplayOrder] = useState(0) const [deleteModuleSavingId, setDeleteModuleSavingId] = useState(null) const [deleteModuleDialogOpen, setDeleteModuleDialogOpen] = useState(false) const [deleteModuleTarget, setDeleteModuleTarget] = useState(null) const [editModuleDialogOpen, setEditModuleDialogOpen] = useState(false) const [editModuleSaving, setEditModuleSaving] = useState(false) const [editModuleTarget, setEditModuleTarget] = useState(null) const [editModuleTitle, setEditModuleTitle] = useState("") const [editModuleDescription, setEditModuleDescription] = useState("") const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0) const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url") const [editModuleIconUrl, setEditModuleIconUrl] = useState("") const [editModuleOriginalIconUrl, setEditModuleOriginalIconUrl] = useState("") const [editModuleIconFile, setEditModuleIconFile] = useState(null) const subCategoryOptions = useMemo(() => toSubCategoryOptions(hierarchyRows), [hierarchyRows]) const selectedSubCategory = useMemo( () => (selectedSubCategoryId === "ALL" ? null : subCategoryOptions.find((item) => item.id === selectedSubCategoryId) ?? null), [selectedSubCategoryId, subCategoryOptions], ) const courseOptions = useMemo( () => (selectedSubCategoryId === "ALL" ? [] : toCourseOptions(hierarchyRows, selectedSubCategoryId)), [hierarchyRows, selectedSubCategoryId], ) const selectedCourse = useMemo( () => (selectedCourseId === "ALL" ? null : courseOptions.find((item) => item.id === selectedCourseId) ?? null), [selectedCourseId, courseOptions], ) const targetCourseIds = useMemo(() => { if (selectedSubCategoryId === "ALL") return [] if (selectedCourseId !== "ALL") return [selectedCourseId] return courseOptions.map((course) => course.id) }, [selectedSubCategoryId, selectedCourseId, courseOptions]) const courseTrees = useMemo(() => { const targetSet = new Set(targetCourseIds) return courseOptions .filter((course) => targetSet.has(course.id)) .map((course) => { const levels = toLevelNodes(courseRowsByCourseId[course.id] ?? []) return { course_id: course.id, course_title: course.title, levels: selectedLevelId === "ALL" ? levels : levels.filter((level) => level.id === selectedLevelId), } }) }, [courseOptions, courseRowsByCourseId, targetCourseIds, selectedLevelId]) const fetchHumanLanguageHierarchy = useCallback(async () => { setHierarchyLoading(true) setHierarchyError(null) try { const res = await getHumanLanguageHierarchy() setHierarchyRows(res.data?.data ?? []) } catch (err) { console.error(err) setHierarchyRows([]) setHierarchyError("Could not load Human Language hierarchy") toast.error("Failed to load Human Language hierarchy") } finally { setHierarchyLoading(false) } }, []) const fetchHierarchiesForCourses = useCallback(async (courseIds: number[]) => { if (courseIds.length === 0) { setCourseRowsByCourseId({}) setLevelPracticesByLevelId({}) setCourseHierarchyError(null) return } setCourseHierarchyLoading(true) setCourseHierarchyError(null) try { const courseEntries = await Promise.all( courseIds.map(async (courseId) => { try { const response = await getCourseHierarchyByCourseId(courseId) return [courseId, response.data?.data ?? []] as const } catch { return [courseId, [] as CourseHierarchyRow[]] as const } }), ) const nextRowsByCourseId = Object.fromEntries(courseEntries) setCourseRowsByCourseId(nextRowsByCourseId) const levelIds = Array.from( new Set( courseEntries .flatMap((entry) => entry[1]) .map((row) => Number(row.level_id)) .filter((id) => Number.isFinite(id) && id > 0), ), ) const levelPracticeEntries = await Promise.all( levelIds.map(async (levelId) => { try { const practiceRes = await getPracticesByLevel(levelId) return [levelId, (practiceRes.data?.data?.practices ?? []).filter((practice) => practice.is_active)] as const } catch { return [levelId, [] as Practice[]] as const } }), ) setLevelPracticesByLevelId(Object.fromEntries(levelPracticeEntries)) } catch (err) { console.error(err) setCourseRowsByCourseId({}) setLevelPracticesByLevelId({}) setCourseHierarchyError("Could not load hierarchy for selected courses") toast.error("Failed to load course hierarchy") } finally { setCourseHierarchyLoading(false) } }, []) useEffect(() => { void fetchHumanLanguageHierarchy() }, [fetchHumanLanguageHierarchy]) useEffect(() => { window.sessionStorage.removeItem(HUMAN_LANGUAGE_RETURN_STATE_KEY) }, []) useEffect(() => { if (!hierarchyLoading) { skipFilterValidationRef.current = false } }, [hierarchyLoading]) useEffect(() => { if (skipInitialSubCategoryResetRef.current) { skipInitialSubCategoryResetRef.current = false return } if (selectedSubCategoryId === "ALL") { setSelectedCourseId("ALL") setSelectedLevelId("ALL") setCourseRowsByCourseId({}) setLevelPracticesByLevelId({}) setExpandedCourses(new Set()) setExpandedLevels(new Set()) setExpandedModules(new Set()) return } setSelectedCourseId("ALL") setSelectedLevelId("ALL") }, [selectedSubCategoryId]) useEffect(() => { if (selectedSubCategoryId === "ALL") return void fetchHierarchiesForCourses(targetCourseIds) }, [selectedSubCategoryId, targetCourseIds, fetchHierarchiesForCourses]) useEffect(() => { if (skipFilterValidationRef.current) return if (selectedSubCategoryId !== "ALL" && !subCategoryOptions.some((item) => item.id === selectedSubCategoryId)) { setSelectedSubCategoryId("ALL") } }, [selectedSubCategoryId, subCategoryOptions]) useEffect(() => { if (skipFilterValidationRef.current) return if (selectedCourseId !== "ALL" && !courseOptions.some((item) => item.id === selectedCourseId)) { setSelectedCourseId("ALL") } }, [selectedCourseId, courseOptions]) useEffect(() => { if (skipFilterValidationRef.current) return const levelIds = new Set(courseTrees.flatMap((course) => course.levels.map((level) => level.id))) if (selectedLevelId !== "ALL" && !levelIds.has(selectedLevelId)) { setSelectedLevelId("ALL") } }, [selectedLevelId, courseTrees]) useEffect(() => { const courseKeys = new Set(courseTrees.map((course) => `course-${course.course_id}`)) const levelKeys = new Set( courseTrees.flatMap((course) => course.levels.map((level) => `level-${course.course_id}-${level.id}`)), ) const moduleKeys = new Set( courseTrees.flatMap((course) => course.levels.flatMap((level) => level.modules.map((module) => `module-${course.course_id}-${level.id}-${module.id}`)), ), ) setExpandedCourses((previous) => new Set([...previous].filter((key) => courseKeys.has(key)))) setExpandedLevels((previous) => new Set([...previous].filter((key) => levelKeys.has(key)))) setExpandedModules((previous) => new Set([...previous].filter((key) => moduleKeys.has(key)))) if (courseTrees.length > 0) { const firstCourseKey = `course-${courseTrees[0].course_id}` setExpandedCourses((previous) => (previous.size > 0 ? previous : new Set([firstCourseKey]))) } }, [courseTrees]) useEffect(() => { if (pendingRestoreScrollRef.current == null) return if (courseHierarchyLoading) return const targetY = pendingRestoreScrollRef.current pendingRestoreScrollRef.current = null window.setTimeout(() => { window.scrollTo({ top: targetY, behavior: "smooth" }) }, 60) }, [courseHierarchyLoading, courseTrees]) const handleActionClick = (label: string) => { toast.info(`${label} UI is ready. Endpoint wiring can be added next.`) } const openCreateModuleModal = (courseId: number, level: LevelNode, levelKey: string) => { setCreateModuleCourseId(courseId) setCreateModuleLevelId(level.id) setCreateModuleLevelKey(levelKey) setCreateModuleUseDefaultNaming(false) setCreateModuleDefaultTitle(getNextDefaultModuleName(level)) setCreateModuleTitle("") setCreateModuleDescription("") setCreateModuleIconSource("url") setCreateModuleIconUrl("") setCreateModuleIconFile(null) setCreateModuleDisplayOrder(level.modules.length) setCreateModuleOpen(true) } const handleCreateModule = async () => { if (createModuleLevelId == null || createModuleCourseId == null) return const title = (createModuleUseDefaultNaming ? createModuleDefaultTitle : createModuleTitle).trim() if (!title) { toast.error("Module title is required") return } setCreateModuleSaving(true) try { let uploadedIconUrl: string | undefined if (createModuleIconSource === "url" && createModuleIconUrl.trim()) { const uploadRes = await uploadImageFile(createModuleIconUrl.trim()) uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined if (!uploadedIconUrl) { throw new Error("Icon upload from URL did not return a file URL") } } else if (createModuleIconSource === "file" && createModuleIconFile) { const uploadRes = await uploadImageFile(createModuleIconFile) uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined if (!uploadedIconUrl) { throw new Error("Icon file upload did not return a file URL") } } await createModule({ level_id: createModuleLevelId, title, description: createModuleDescription.trim() || undefined, icon_url: uploadedIconUrl, display_order: createModuleDisplayOrder, is_active: true, }) toast.success("Module created") setExpandedCourses((previous) => new Set(previous).add(`course-${createModuleCourseId}`)) setExpandedLevels((previous) => new Set(previous).add(createModuleLevelKey)) setCreateModuleOpen(false) const refreshCourseIds = selectedCourseId === "ALL" ? targetCourseIds : [createModuleCourseId] await fetchHierarchiesForCourses(refreshCourseIds) } catch (error) { console.error(error) toast.error("Failed to create module") } finally { setCreateModuleSaving(false) } } const openDeleteModuleDialog = (courseId: number, levelTitle: string, module: ModuleNode, moduleKey: string) => { if (deleteModuleSavingId != null) return setDeleteModuleTarget({ courseId, moduleId: module.id, moduleTitle: module.title, levelTitle, moduleKey, }) setDeleteModuleDialogOpen(true) } const openEditModuleDialog = ( courseId: number, levelKey: string, moduleKey: string, module: ModuleNode, moduleDisplayOrder: number, ) => { if (editModuleSaving) return const existingIconUrl = module.icon_url?.trim() ?? "" setEditModuleTarget({ courseId, moduleId: module.id, moduleKey, levelKey, }) setEditModuleTitle(module.title) setEditModuleDescription("") setEditModuleDisplayOrder(moduleDisplayOrder) setEditModuleIconSource("url") setEditModuleIconUrl(existingIconUrl) setEditModuleOriginalIconUrl(existingIconUrl) setEditModuleIconFile(null) setEditModuleDialogOpen(true) } const handleUpdateModule = async () => { if (!editModuleTarget) return const title = editModuleTitle.trim() if (!title) { toast.error("Module title is required") return } setEditModuleSaving(true) try { const inputIconUrl = editModuleIconUrl.trim() let uploadedIconUrl: string | undefined if (editModuleIconSource === "file" && editModuleIconFile) { const uploadRes = await uploadImageFile(editModuleIconFile) uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined if (!uploadedIconUrl) { throw new Error("Icon file upload did not return a file URL") } } else if (editModuleIconSource === "url") { if (!inputIconUrl) uploadedIconUrl = undefined else if (inputIconUrl === editModuleOriginalIconUrl) uploadedIconUrl = inputIconUrl else { const uploadRes = await uploadImageFile(inputIconUrl) uploadedIconUrl = uploadRes.data?.data?.url?.trim() || undefined if (!uploadedIconUrl) { throw new Error("Icon upload from URL did not return a file URL") } } } await updateModule(editModuleTarget.moduleId, { title, description: editModuleDescription.trim() || undefined, icon_url: uploadedIconUrl, display_order: editModuleDisplayOrder, is_active: true, }) toast.success("Module updated") setExpandedCourses((previous) => new Set(previous).add(`course-${editModuleTarget.courseId}`)) setExpandedLevels((previous) => new Set(previous).add(editModuleTarget.levelKey)) setExpandedModules((previous) => new Set(previous).add(editModuleTarget.moduleKey)) setEditModuleDialogOpen(false) setEditModuleTarget(null) const refreshCourseIds = selectedCourseId === "ALL" ? targetCourseIds : [editModuleTarget.courseId] await fetchHierarchiesForCourses(refreshCourseIds) } catch (error) { console.error(error) toast.error("Failed to update module") } finally { setEditModuleSaving(false) } } const handleDeleteModule = async () => { if (deleteModuleSavingId != null) return if (!deleteModuleTarget) return setDeleteModuleSavingId(deleteModuleTarget.moduleId) try { await deleteModule(deleteModuleTarget.moduleId) toast.success("Module deleted") setExpandedModules((previous) => { const next = new Set(previous) next.delete(deleteModuleTarget.moduleKey) return next }) setDeleteModuleDialogOpen(false) setDeleteModuleTarget(null) const refreshCourseIds = selectedCourseId === "ALL" ? targetCourseIds : [deleteModuleTarget.courseId] await fetchHierarchiesForCourses(refreshCourseIds) } catch (error) { console.error(error) toast.error("Failed to delete module") } finally { setDeleteModuleSavingId(null) } } const persistReturnState = () => { const payload: HierarchyReturnState = { selectedSubCategoryId, selectedCourseId, selectedLevelId, expandedCourses: Array.from(expandedCourses), expandedLevels: Array.from(expandedLevels), expandedModules: Array.from(expandedModules), scrollY: window.scrollY, } window.sessionStorage.setItem(HUMAN_LANGUAGE_RETURN_STATE_KEY, JSON.stringify(payload)) } return (

Human Language

Hierarchy management

Filters
{hierarchyLoading ? (
Loading hierarchy...
) : hierarchyError ? (

{hierarchyError}

) : ( )}
{selectedSubCategoryId === "ALL" ? (

Select a sub-category first.

) : ( )}
{selectedCourseId === "ALL" ? ( ) : ( )}
{selectedSubCategoryId !== "ALL" ? (
) : null} {selectedSubCategoryId === "ALL" ? (

Select a sub-category to start managing hierarchy

Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`.

) : courseHierarchyLoading ? (
Loading hierarchy tree...
) : courseHierarchyError ? (

{courseHierarchyError}

) : courseTrees.length === 0 ? ( No hierarchy records found for this selection. ) : (
{courseTrees.map((course) => { const courseKey = `course-${course.course_id}` const courseOpen = setHas(expandedCourses, courseKey) return (
{courseOpen ? (
{course.levels.length === 0 ? (

No levels for this course.

) : ( course.levels.map((level) => { const levelKey = `level-${course.course_id}-${level.id}` const levelOpen = setHas(expandedLevels, levelKey) const levelPractices = levelPracticesByLevelId[level.id] ?? [] return (
{levelOpen ? (
{levelPractices.length > 0 ? (

Level practices

    {levelPractices.map((practice) => (
  • {practice.title}
  • ))}
) : null} {level.modules.length === 0 ? (

No modules yet.

) : (
{level.modules.map((module) => { const moduleKey = `module-${course.course_id}-${level.id}-${module.id}` const moduleOpen = setHas(expandedModules, moduleKey) return (
{moduleOpen ? (
{module.sub_modules.length === 0 ? (

No sub-modules yet.

) : ( module.sub_modules.map((subModule) => (
Sub-module: {subModule.title}
)) )}
) : null}
) })}
)}
) : null}
) }) )}
) : null}
) })}
)} (!createModuleSaving ? setCreateModuleOpen(open) : null)}> Create module Add a module to this level. This will call `POST /course-management/modules`.
{createModuleUseDefaultNaming ? (

Auto title: {createModuleDefaultTitle}

) : null}
setCreateModuleTitle(event.target.value)} placeholder="e.g. Introduction" disabled={createModuleSaving || createModuleUseDefaultNaming} />