Merge pull request #203 from CoderLambert/product/177-learning-path-navigation

feat: add Workbench learning path navigation
This commit was merged in pull request #203.
This commit is contained in:
CoderLambert
2026-09-14 18:11:56 +08:00
committed by GitHub
7 changed files with 239 additions and 3 deletions
+1
View File
@@ -26,6 +26,7 @@ export const DOMAIN_BROWSER_SUITES = Object.freeze({
"tests/e2e/chapter-checkpoints.spec.js",
"tests/e2e/code-viewer.spec.js",
"tests/e2e/effects-cleanup.spec.js",
"tests/e2e/learning-path-navigation.spec.js",
"tests/e2e/responsive.spec.js",
"tests/e2e/source-locator-ai.spec.js",
"tests/e2e/surface-boundaries.spec.js",
+17 -2
View File
@@ -21,6 +21,7 @@ import { WorkbenchNavigation } from "./workbench/WorkbenchNavigation";
import { WorkbenchShell } from "./workbench/WorkbenchShell";
import { toLearningUnit } from "./workbench/contracts";
import { useDemoUrlState } from "./workbench/demoUrlState";
import { getLearningPathEntry } from "./workbench/learningPath";
import { usePersistedWorkbenchState } from "./workbench/usePersistedWorkbenchState";
const SourceViewer = lazy(() =>
@@ -69,6 +70,7 @@ export default function App() {
);
const currentCategory = useMemo(() => CATEGORIES.find((category) => category.id === currentDemo?.category), [currentDemo]);
const currentCheckpointChapter = currentDemo ? getCheckpointChapter(currentDemo.id) : null;
const currentLearningPathEntry = currentDemo ? getLearningPathEntry(demos, currentDemo.id) : null;
const assessment = useAssessmentApplication({
learningUnitId: currentLearningUnit?.id ?? null,
@@ -488,7 +490,13 @@ export default function App() {
>
<currentDemo.Component />
</DemoSourceLocator>
{currentCheckpointChapter && <ChapterCheckpoint chapter={currentCheckpointChapter} />}
{currentCheckpointChapter && (
<ChapterCheckpoint
chapter={currentCheckpointChapter}
nextUnitId={currentLearningPathEntry?.nextChapterFirstId}
onNavigate={handleSelectDemo}
/>
)}
</div>
) : null
) : (
@@ -496,6 +504,7 @@ export default function App() {
{demos.map((demo, index) => {
const checkpointChapter = getCheckpointChapter(demo.id);
const learningUnit = enrichLearningUnitSourceSemantics(toLearningUnit(demo));
const learningPathEntry = getLearningPathEntry(demos, demo.id);
return (
<div key={demo.id} id={`demo-${demo.id}`}>
{index > 0 && <hr className="demo-divider" />}
@@ -511,7 +520,13 @@ export default function App() {
>
<demo.Component />
</DemoSourceLocator>
{checkpointChapter && <ChapterCheckpoint chapter={checkpointChapter} />}
{checkpointChapter && (
<ChapterCheckpoint
chapter={checkpointChapter}
nextUnitId={learningPathEntry?.nextChapterFirstId}
onNavigate={handleSelectDemo}
/>
)}
</div>
);
})}
+13 -1
View File
@@ -192,11 +192,12 @@ const CHECKPOINTS = {
},
};
export function ChapterCheckpoint({ chapter }) {
export function ChapterCheckpoint({ chapter, nextUnitId = null, onNavigate }) {
const checkpoint = CHECKPOINTS[chapter];
if (!checkpoint) return null;
const nextStep = getChapterNextStep(chapter);
const integrationLab = getIntegrationLab(chapter);
const canNavigate = Boolean(nextUnitId && typeof onNavigate === "function");
return (
<section className="demo-section" data-chapter-checkpoint={chapter} aria-labelledby={`chapter-${chapter}-checkpoint-title`}>
@@ -249,6 +250,17 @@ export function ChapterCheckpoint({ chapter }) {
<div className="demo-alert-title"> 下一步</div>
<p>{nextStep.understood} {nextStep.next}</p>
<strong>{nextStep.target}</strong>
{canNavigate && (
<div style={{ marginTop: 12 }}>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => onNavigate(nextUnitId)}
>
前往下一章
</button>
</div>
)}
</div>
)}
</section>
+48
View File
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { toLearningUnit } from "./contracts";
import { buildLearningPath } from "./learningPath";
function normalizeUnits(items) {
return items.map((item) => (item.component ? item : toLearningUnit(item)));
@@ -19,6 +20,10 @@ function getSearchText(unit, categoryName) {
.toLowerCase();
}
function formatChapter(chapter) {
return String(chapter).padStart(2, "0");
}
export function WorkbenchNavigation({
categories,
learningUnits,
@@ -33,6 +38,8 @@ export function WorkbenchNavigation({
className = "",
}) {
const units = useMemo(() => normalizeUnits(learningUnits), [learningUnits]);
const learningPath = useMemo(() => buildLearningPath(units), [units]);
const activePath = viewMode === "focused" ? learningPath.byUnitId.get(activeId) : null;
const categoryMap = useMemo(() => new Map(categories.map((category) => [category.id, category])), [categories]);
const filteredUnits = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
@@ -105,6 +112,47 @@ export function WorkbenchNavigation({
)}
</button>
{!collapsed && activePath && (
<section aria-label="当前学习路径" data-learning-path-current style={{ marginBottom: 14 }}>
<div className="workbench-navigation-group-header">
<span aria-hidden="true">🧭</span>
<span>Chapter {formatChapter(activePath.chapter)}</span>
<span className="workbench-navigation-count">{activePath.position}/{activePath.chapterSize}</span>
</div>
<div className="workbench-navigation-items">
<button
type="button"
className="workbench-navigation-overview"
onClick={() => onSelectUnit?.(activePath.previousId)}
disabled={!activePath.previousId}
aria-label="上一知识点"
data-learning-path-action="previous"
>
<span> 上一知识点</span>
</button>
<button
type="button"
className={`workbench-navigation-overview ${activeId === activePath.checkpointId ? "is-active" : ""}`}
onClick={() => onSelectUnit?.(activePath.checkpointId)}
aria-label={`前往 Chapter ${formatChapter(activePath.chapter)} Checkpoint`}
data-learning-path-action="checkpoint"
>
<span>本章 Checkpoint</span>
</button>
<button
type="button"
className="workbench-navigation-overview"
onClick={() => onSelectUnit?.(activePath.nextId)}
disabled={!activePath.nextId}
aria-label="下一知识点"
data-learning-path-action="next"
>
<span>下一知识点 </span>
</button>
</div>
</section>
)}
{groups.map((group) => (
<section key={group.id} className="workbench-navigation-group" aria-label={group.name}>
<div className="workbench-navigation-group-header" title={collapsed ? group.name : undefined}>
+61
View File
@@ -0,0 +1,61 @@
import { getCheckpointChapter } from "../components/chapterCheckpointMap.js";
function getUnitId(unit) {
return typeof unit === "string" ? unit : unit?.id;
}
/**
* Derive chapter/path navigation from the authoritative registry order plus the
* existing chapter checkpoint terminal mapping. No chapter lesson list is
* duplicated here.
*/
export function buildLearningPath(learningUnits) {
const units = Array.isArray(learningUnits) ? learningUnits.filter((unit) => getUnitId(unit)) : [];
const chapters = [];
let chapterStart = 0;
units.forEach((unit, index) => {
const chapter = getCheckpointChapter(getUnitId(unit));
if (!chapter) return;
const chapterUnits = units.slice(chapterStart, index + 1);
if (chapterUnits.length === 0) return;
chapters.push({
chapter,
units: chapterUnits,
firstId: getUnitId(chapterUnits[0]),
checkpointId: getUnitId(chapterUnits[chapterUnits.length - 1]),
});
chapterStart = index + 1;
});
const byUnitId = new Map();
const orderedIds = units.map(getUnitId);
const globalIndexById = new Map(orderedIds.map((id, index) => [id, index]));
chapters.forEach((chapterEntry, chapterIndex) => {
const nextChapterFirstId = chapters[chapterIndex + 1]?.firstId ?? null;
chapterEntry.units.forEach((unit, positionIndex) => {
const id = getUnitId(unit);
const globalIndex = globalIndexById.get(id);
byUnitId.set(id, {
chapter: chapterEntry.chapter,
position: positionIndex + 1,
chapterSize: chapterEntry.units.length,
previousId: globalIndex > 0 ? orderedIds[globalIndex - 1] : null,
nextId: globalIndex < orderedIds.length - 1 ? orderedIds[globalIndex + 1] : null,
checkpointId: chapterEntry.checkpointId,
nextChapterFirstId,
});
});
});
return { chapters, byUnitId };
}
export function getLearningPathEntry(learningUnits, unitId) {
if (!unitId) return null;
return buildLearningPath(learningUnits).byUnitId.get(unitId) ?? null;
}
@@ -0,0 +1,59 @@
import { expect } from "@playwright/test";
import { loadApp, openDemo, test } from "./test-fixtures.js";
test.describe("Workbench learning path navigation", () => {
test("shows chapter position and navigates previous, next and checkpoint without search", async ({ page }) => {
await loadApp(page);
await openDemo(page, "Event Handler 与事件传播");
const path = page.locator("[data-learning-path-current]");
await expect(path).toContainText("Chapter 02");
await expect(path).toContainText("1/4");
const nextButton = path.getByRole("button", { name: "下一知识点" });
await nextButton.focus();
await expect(nextButton).toBeFocused();
await nextButton.press("Enter");
await expect(page).toHaveURL(/demo=state-snapshot-queue/);
await expect(page.locator(".breadcrumb-current")).toContainText("State Snapshot");
await page.locator("[data-learning-path-current]").getByRole("button", { name: "上一知识点" }).click();
await expect(page).toHaveURL(/demo=event-propagation/);
await page.locator("[data-learning-path-current]").getByRole("button", { name: /Checkpoint/ }).click();
await expect(page).toHaveURL(/demo=render-commit/);
await expect(page.locator('[data-chapter-checkpoint="2"]')).toBeVisible();
});
test("chapter checkpoint next step enters the next chapter first unit through the existing URL contract", async ({ page }) => {
await loadApp(page);
await openDemo(page, "Trigger → Render → Commit");
const checkpoint = page.locator('[data-chapter-checkpoint="2"]');
await expect(checkpoint).toBeVisible();
const nextChapterButton = checkpoint.getByRole("button", { name: "前往下一章" });
await nextChapterButton.focus();
await expect(nextChapterButton).toBeFocused();
await nextChapterButton.press("Enter");
await expect(page).toHaveURL(/demo=state-dry/);
await expect(page.locator("[data-learning-path-current]")).toContainText("Chapter 03");
});
test("path navigation preserves persisted inspector state", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("react-learning-workbench:inspector-open", "true");
localStorage.setItem("react-learning-workbench:inspector-tab", "source");
localStorage.setItem("react-learning-workbench:inspector-width", "640");
});
await loadApp(page);
await openDemo(page, "Event Handler 与事件传播");
const shell = page.locator(".workbench-shell");
await expect(shell).toHaveAttribute("data-inspector-open", "true");
await page.locator("[data-learning-path-current]").getByRole("button", { name: "下一知识点" }).click();
await expect(shell).toHaveAttribute("data-inspector-open", "true");
await expect(page.getByRole("tab", { name: "源码" })).toHaveAttribute("aria-selected", "true");
await expect.poll(async () => page.evaluate(() => localStorage.getItem("react-learning-workbench:inspector-width"))).toBe("640");
});
});
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildLearningPath, getLearningPathEntry } from "../src/workbench/learningPath.js";
const units = [
{ id: "component-jsx-pure-render" },
{ id: "prop-drilling" },
{ id: "event-propagation" },
{ id: "render-commit" },
{ id: "state-dry" },
{ id: "use-reduce-with-context" },
];
test("derives chapter boundaries from registry order and checkpoint terminals", () => {
const path = buildLearningPath(units);
assert.deepEqual(
path.chapters.map(({ chapter, firstId, checkpointId }) => ({ chapter, firstId, checkpointId })),
[
{ chapter: 1, firstId: "component-jsx-pure-render", checkpointId: "prop-drilling" },
{ chapter: 2, firstId: "event-propagation", checkpointId: "render-commit" },
{ chapter: 3, firstId: "state-dry", checkpointId: "use-reduce-with-context" },
],
);
});
test("exposes chapter position, previous/next lesson and checkpoint targets", () => {
assert.deepEqual(getLearningPathEntry(units, "event-propagation"), {
chapter: 2,
position: 1,
chapterSize: 2,
previousId: "prop-drilling",
nextId: "render-commit",
checkpointId: "render-commit",
nextChapterFirstId: "state-dry",
});
assert.equal(getLearningPathEntry(units, "unknown"), null);
});