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:
@@ -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
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user