feat(student): add structured course learning experience
CourseView.jsx — My Programs -> ROOT_ -> Course -> Module -> Activity -> Launch Lab -> back -> continue, nested inside the existing self-paced ProgramsTab rather than a second portal. Renders lesson/demonstration/ knowledge-check/guided-exercise/reflection content via the existing react-markdown+remark-gfm stack (already used by ScenarioPanel.jsx — no new dependency). Locked modules/courses are inert, not hidden — the server is still the actual gate (course-routes.js). ProgramsTab.jsx gains a 'View Courses' button alongside the existing 'Continue Learning' button — the original direct-to-Lab flow is byte-for-byte unchanged; both paths call the identical launch sequence underneath. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,214 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { api } from './api'
|
||||||
|
import styles from './Platform.module.css'
|
||||||
|
|
||||||
|
const ACTIVITY_LABELS = {
|
||||||
|
lesson: 'Lesson', demonstration: 'Demonstration', knowledge_check: 'Knowledge Check',
|
||||||
|
guided_exercise: 'Guided Exercise', lab: 'Lab', reflection: 'Reflection',
|
||||||
|
assessment: 'Assessment', capstone: 'Capstone',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 6A §6 — structured course learning, nested inside "My Programs" (not a second portal).
|
||||||
|
// Local drill-down state only (course -> module -> activity), same "no router" convention as
|
||||||
|
// the rest of the Platform Shell (see PlatformShell.jsx). Separate from PracticeTab — this is
|
||||||
|
// the guided course path, Practice stays the free-form scenario list it always was.
|
||||||
|
export default function CourseView({ enrollmentId, onLaunchLab, onClose }) {
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [openCourse, setOpenCourse] = useState(null)
|
||||||
|
const [openModule, setOpenModule] = useState(null)
|
||||||
|
const [openActivity, setOpenActivity] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api.courses(enrollmentId).then(setData).catch((e) => setError(e.message))
|
||||||
|
}, [enrollmentId])
|
||||||
|
useEffect(load, [load])
|
||||||
|
|
||||||
|
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||||
|
if (!data) return <p className={styles.muted}>Loading…</p>
|
||||||
|
|
||||||
|
const course = openCourse && data.courses.find((c) => c.course_id === openCourse)
|
||||||
|
const mod = course && openModule && course.modules.find((m) => m.module_id === openModule)
|
||||||
|
const activity = mod && openActivity && mod.activities.find((a) => a.activity_id === openActivity)
|
||||||
|
|
||||||
|
const crumb = (label, onClick) => <button key={label} className={styles.linkBtn} onClick={onClick}>{label}</button>
|
||||||
|
|
||||||
|
if (activity) {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.btnRow} style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
{crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null); setOpenActivity(null) })}
|
||||||
|
{crumb(course.title, () => setOpenActivity(null))}
|
||||||
|
</div>
|
||||||
|
<ActivityDetail activity={activity} onLaunchLab={onLaunchLab} onChange={load} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mod) {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.btnRow} style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
{crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null) })}
|
||||||
|
{crumb(course.title, () => setOpenModule(null))}
|
||||||
|
</div>
|
||||||
|
<h2>{mod.title}</h2>
|
||||||
|
{mod.description && <p className={styles.muted}>{mod.description}</p>}
|
||||||
|
<ModuleMeta mod={mod} />
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
{mod.activities.map((a) => (
|
||||||
|
<ActivityRow key={a.activity_id} activity={a} onOpen={() => setOpenActivity(a.activity_id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (course) {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.btnRow} style={{ marginBottom: '1rem' }}>
|
||||||
|
{crumb('← All courses', () => setOpenCourse(null))}
|
||||||
|
</div>
|
||||||
|
<h2>{course.title}</h2>
|
||||||
|
{course.description && <p className={styles.muted}>{course.description}</p>}
|
||||||
|
{course.objectives?.length > 0 && (
|
||||||
|
<ul style={{ marginTop: '0.5rem' }}>
|
||||||
|
{course.objectives.map((o, i) => <li key={i} className={styles.checklistItem}>{o}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
{course.modules.length === 0 ? (
|
||||||
|
<p className={styles.muted}>No modules published for this course yet.</p>
|
||||||
|
) : course.modules.map((m) => (
|
||||||
|
<ModuleRow key={m.module_id} mod={m} onOpen={() => !m.locked && setOpenModule(m.module_id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.btnRow} style={{ marginBottom: '1rem' }}>
|
||||||
|
{onClose && crumb('← Back to program', onClose)}
|
||||||
|
</div>
|
||||||
|
<h2>ROOT_ Courses</h2>
|
||||||
|
{data.resume && (
|
||||||
|
<p className={styles.nextUp}>
|
||||||
|
Resume: {data.courses.find((c) => c.course_id === data.resume.course_id)?.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.courses.map((c) => (
|
||||||
|
<CourseRow key={c.course_id} course={c} onOpen={() => !c.locked && setOpenCourse(c.course_id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPill(status) {
|
||||||
|
const cls = status === 'ready' ? styles.pillOk : status === 'partial' ? styles.pillWarn : styles.pillMuted
|
||||||
|
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CourseRow({ course, onOpen }) {
|
||||||
|
return (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', padding: '0.75rem 0' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<button className={styles.linkBtn} disabled={course.locked} onClick={onOpen} style={{ fontWeight: 600, textAlign: 'left' }}>
|
||||||
|
{course.title}
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{statusPill(course.status)}
|
||||||
|
{course.locked && <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!course.locked && course.modules.length > 0 && (
|
||||||
|
<div style={{ marginTop: '0.4rem' }}>
|
||||||
|
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${course.percent_complete}%` }} /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleRow({ mod, onOpen }) {
|
||||||
|
return (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', padding: '0.6rem 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<button className={styles.linkBtn} disabled={mod.locked} onClick={onOpen} style={{ textAlign: 'left' }}>{mod.title}</button>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{mod.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span> : statusPill(mod.status)}
|
||||||
|
{mod.locked && <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleMeta({ mod }) {
|
||||||
|
if (!mod.objectives?.length && !mod.estimated_duration_minutes) return null
|
||||||
|
return (
|
||||||
|
<div className={styles.hint} style={{ marginTop: '0.5rem' }}>
|
||||||
|
{mod.estimated_duration_minutes && <div>Estimated time: ~{mod.estimated_duration_minutes} min</div>}
|
||||||
|
{mod.objectives?.length > 0 && (
|
||||||
|
<ul style={{ marginTop: '0.3rem' }}>{mod.objectives.map((o, i) => <li key={i}>{o}</li>)}</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityRow({ activity, onOpen }) {
|
||||||
|
return (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', padding: '0.6rem 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<button className={styles.linkBtn} onClick={onOpen} style={{ textAlign: 'left' }}>
|
||||||
|
<span className={styles.stageCount}>{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}</span>{' — '}{activity.title}
|
||||||
|
</button>
|
||||||
|
{activity.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>done</span> : activity.estimated_duration_minutes ? <span className={styles.hint}>~{activity.estimated_duration_minutes} min</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityDetail({ activity, onLaunchLab, onChange }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const markComplete = async () => {
|
||||||
|
setBusy(true); setError(null)
|
||||||
|
try { await api.completeActivity(activity.activity_id); onChange() }
|
||||||
|
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<h2>{activity.title}</h2>
|
||||||
|
{activity.complete && <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>}
|
||||||
|
</div>
|
||||||
|
<p className={styles.stageCount}>{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}
|
||||||
|
{activity.estimated_duration_minutes && ` · ~${activity.estimated_duration_minutes} min`}</p>
|
||||||
|
|
||||||
|
{activity.body ? (
|
||||||
|
<div className="md" style={{ marginTop: '1rem' }}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{activity.body}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className={styles.muted} style={{ marginTop: '1rem' }}>No content authored for this activity yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className={styles.errorBox}>{error}</p>}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
{activity.activity_type === 'lab' && !activity.complete && (
|
||||||
|
<button className={styles.primaryBtn} onClick={() => onLaunchLab?.(activity.scenario_id)}>Launch Lab</button>
|
||||||
|
)}
|
||||||
|
{activity.completion_behavior === 'manual_marker' && !activity.complete && (
|
||||||
|
<button className={styles.primaryBtn} disabled={busy} onClick={markComplete}>{busy ? 'Saving…' : 'Mark Complete'}</button>
|
||||||
|
)}
|
||||||
|
{activity.activity_type === 'capstone' && (
|
||||||
|
<p className={styles.muted}>Continue via the Capstone tab to start or resume this assessment.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import styles from './Platform.module.css'
|
import styles from './Platform.module.css'
|
||||||
|
import CourseView from './CourseView'
|
||||||
|
|
||||||
const POLL_MS = 2500
|
const POLL_MS = 2500
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ function ProgramCard({ program, onChange }) {
|
|||||||
const [envStatus, setEnvStatus] = useState(null)
|
const [envStatus, setEnvStatus] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [showCourses, setShowCourses] = useState(false)
|
||||||
const pollRef = useRef(null)
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||||
@@ -97,6 +99,19 @@ function ProgramCard({ program, onChange }) {
|
|||||||
const p = program.progress
|
const p = program.progress
|
||||||
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
|
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
|
||||||
|
|
||||||
|
// Phase 6A §6 — "View Courses" opens the structured course/module/activity tree for this
|
||||||
|
// enrollment, right here inline (My Programs -> ROOT_ -> Course -> Module -> Activity), no
|
||||||
|
// separate portal. "Continue Learning" keeps its original Phase 5B behavior unchanged
|
||||||
|
// (straight to the disposable Lab) — CourseView's own Launch Lab buttons call the exact same
|
||||||
|
// handleContinue, so there is still only one launch path underneath both entry points.
|
||||||
|
if (showCourses) {
|
||||||
|
return (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||||
|
<CourseView enrollmentId={program.enrollment_id} onLaunchLab={handleContinue} onClose={() => setShowCourses(false)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
@@ -116,7 +131,10 @@ function ProgramCard({ program, onChange }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{program.effective_status === 'completed' ? (
|
{program.effective_status === 'completed' ? (
|
||||||
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
|
<>
|
||||||
|
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
|
||||||
|
<button className={styles.secondaryBtn} onClick={() => setShowCourses(true)}>View Courses</button>
|
||||||
|
</>
|
||||||
) : program.blocked_reason ? (
|
) : program.blocked_reason ? (
|
||||||
<p className={styles.muted}>
|
<p className={styles.muted}>
|
||||||
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
|
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
|
||||||
@@ -129,9 +147,12 @@ function ProgramCard({ program, onChange }) {
|
|||||||
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
|
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
|
||||||
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
|
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
|
||||||
) : (
|
) : (
|
||||||
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
<div className={styles.btnRow}>
|
||||||
{busy ? 'Starting…' : 'Continue Learning'}
|
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
||||||
</button>
|
{busy ? 'Starting…' : 'Continue Learning'}
|
||||||
|
</button>
|
||||||
|
<button className={styles.secondaryBtn} onClick={() => setShowCourses(true)}>View Courses</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -124,4 +124,8 @@ export const api = {
|
|||||||
programs: () => fetch('/api/student/programs').then(json),
|
programs: () => fetch('/api/student/programs').then(json),
|
||||||
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
|
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
|
||||||
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
|
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
|
||||||
|
|
||||||
|
// ── Phase 6A: course/module/activity content ─────────────────────────────────────────────
|
||||||
|
courses: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}/courses`).then(json),
|
||||||
|
completeActivity: (activityId) => mutate(`/api/student/activities/${activityId}/complete`),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user