Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 234 additions & 67 deletions runner/apps/authoring/src/App.tsx

Large diffs are not rendered by default.

33 changes: 21 additions & 12 deletions runner/apps/authoring/src/docs-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
//
// The importer (runner/pipeline/import-docs.mjs) emits, under
// apps/authoring/public/docs-examples/:
// - manifest.json — metadata list driving the grouped dropdown
// - <encoded-docsPath>.json — one full CatalogEntry per example, lazy-fetched
// - <bucket>/manifest.json — metadata list driving the grouped dropdown
// - <bucket>/<encoded-docsPath>.json — one full CatalogEntry per example, lazy-fetched
//
// Examples are opened by their docs content path via the `?docs=` URL param, e.g.
// /?docs=guides/columns/column-adding/javascript/example1.ts
Expand All @@ -13,6 +13,7 @@ import type { CatalogEntry } from "@handsontable/demo-runtime";
const BASE = "/docs-examples";

export interface DocsManifestItem {
bucket: string;
docsPath: string;
file: string;
breadcrumb: string[];
Expand All @@ -28,6 +29,8 @@ export interface DocsManifestItem {
}

export interface DocsManifest {
bucket: string;
docsBranch: string;
generatedFrom: string;
hotVersion: string;
count: number;
Expand All @@ -50,20 +53,22 @@ export interface DocsGroup {
items: DocsManifestItem[];
}

let manifestPromise: Promise<DocsManifest> | null = null;
const manifestPromises = new Map<string, Promise<DocsManifest>>();

/** Fetch (once) and cache the docs example manifest. */
export function fetchDocsManifest(): Promise<DocsManifest> {
/** Fetch (once per bucket) and cache a docs example manifest. */
export function fetchDocsManifest(bucket: string): Promise<DocsManifest> {
let manifestPromise = manifestPromises.get(bucket);
if (!manifestPromise) {
manifestPromise = fetch(`${BASE}/manifest.json`)
manifestPromise = fetch(`${BASE}/${bucket}/manifest.json`)
.then((r) => {
if (!r.ok) throw new Error(`docs manifest ${r.status}`);
return r.json() as Promise<DocsManifest>;
})
.catch((e) => {
manifestPromise = null; // allow retry
manifestPromises.delete(bucket); // allow retry
throw e;
});
manifestPromises.set(bucket, manifestPromise);
}
return manifestPromise;
}
Expand All @@ -90,14 +95,18 @@ export function optionLabel(it: DocsManifestItem): string {

const entryCache = new Map<string, DocsCatalogEntry>();

/** Fetch (and cache) the full CatalogEntry for one docs example by its docsPath. */
export async function loadDocsExample(docsPath: string): Promise<DocsCatalogEntry> {
const cached = entryCache.get(docsPath);
/** Fetch and cache one bucket's full CatalogEntry by its docsPath. */
export async function loadDocsExample(
bucket: string,
docsPath: string,
): Promise<DocsCatalogEntry> {
const cacheKey = `${bucket}:${docsPath}`;
const cached = entryCache.get(cacheKey);
if (cached) return cached;
const file = docsPath.replace(/\//g, "__") + ".json";
const res = await fetch(`${BASE}/${file}`);
const res = await fetch(`${BASE}/${bucket}/${file}`);
if (!res.ok) throw new Error(`docs example not found: ${docsPath} (${res.status})`);
const entry = (await res.json()) as DocsCatalogEntry;
entryCache.set(docsPath, entry);
entryCache.set(cacheKey, entry);
return entry;
}
225 changes: 222 additions & 3 deletions runner/e2e/docs-examples.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,154 @@ import { test, expect } from "@playwright/test";
// gated behind E2E_LIVE=1 (run against prod in the post-deploy smoke).

const REACT_EXAMPLE = "/?docs=guides/columns/column-adding/react/example2.tsx";
const DOCS_PATH = "guides/columns/column-adding/react/example2.tsx";
const NEXT_VERSION = "19.0.0-next.1";

const FRAMEWORKS = [
["react", "React", "tsx"],
["typescript", "TypeScript", "ts"],
["javascript", "JavaScript", "js"],
["vue", "Vue", "vue"],
["angular", "Angular", "ts"],
] as const;

function docsPath(framework: string, exampleId: string, extension: string) {
return `guides/columns/column-adding/${framework}/${exampleId}.${extension}`;
}

function manifestItem(bucket: string, framework: string, displayName: string, path: string, exampleId: string) {
return {
bucket,
docsPath: path,
file: path.replace(/\//g, "__") + ".json",
breadcrumb: ["Columns", "Adding and removing columns"],
guide: "guides/columns/column-adding/column-adding.md",
guideTitle: "Adding and removing columns",
exampleId,
exampleTitle: exampleId === "example2"
? "Add and remove columns from the context menu"
: "Standard example",
docPermalink: "/column-adding",
framework,
displayName,
};
}

function fixtureItems(bucket: string) {
return FRAMEWORKS.flatMap(([framework, displayName, extension]) => [
manifestItem(bucket, framework, displayName, docsPath(framework, "example1", extension), "example1"),
manifestItem(bucket, framework, displayName, docsPath(framework, "example2", extension), "example2"),
]);
}

function fixtureEntry(bucket: string, path: string, generatedVersion: string) {
const framework = path.split("/").at(-2) ?? "react";
const source = `export const fixture = "${bucket}:${path}";\n`;
return {
framework,
displayName: FRAMEWORKS.find(([name]) => name === framework)?.[1] ?? framework,
tier: 1,
engine: "sandpack",
sandpackTemplate: "react-ts",
sandpackEnvironment: "parcel",
container: null,
htWrappers: framework === "react" ? ["@handsontable/react-wrapper"] : [],
entry: "/src/App.tsx",
htmlEntry: "/index.html",
devCommand: null,
buildCommand: "vite build",
outputDir: "dist",
outputGlob: null,
staticExport: false,
spaMode: false,
port: null,
installCommand: "pnpm install",
htCoreRange: generatedVersion,
fileCount: 3,
assets: [],
skipped: [],
docsPath: path,
breadcrumb: ["Columns", "Adding and removing columns"],
guide: "guides/columns/column-adding/column-adding.md",
guideTitle: "Adding and removing columns",
exampleId: path.includes("example2") ? "example2" : "example1",
lang: "tsx",
files: {
"/src/App.tsx": source,
"/index.html": `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable@${generatedVersion}/styles/handsontable.min.css"><div id="root"></div>`,
"/package.json": JSON.stringify({
dependencies: {
handsontable: generatedVersion,
...(framework === "react" ? { "@handsontable/react-wrapper": generatedVersion } : {}),
},
}, null, 2),
},
};
}

async function installRouteFixtures(
page: import("@playwright/test").Page,
{
latest = "18.0.0",
availableBuckets = ["18.0", "next"],
missingPaths = [],
requests = [],
}: {
latest?: string;
availableBuckets?: string[];
missingPaths?: string[];
requests?: string[];
} = {},
) {
await page.route("**/api/versions", (route) => route.fulfill({
json: {
latest,
next: NEXT_VERSION,
versions: ["18.0.0", "17.1.0", NEXT_VERSION],
},
}));
await page.route("https://sandpack.codesandbox.io/**", (route) => route.abort());
await page.route("https://sandpack-bundler.codesandbox.io/**", (route) => route.abort());
await page.route("**/docs-examples/*/*.json", async (route) => {
const url = new URL(route.request().url());
requests.push(url.pathname);
const [, bucket, file] = url.pathname.match(/\/docs-examples\/([^/]+)\/(.+)$/) ?? [];
const path = decodeURIComponent(file ?? "").replace(/\.json$/, "").replace(/__/g, "/");
if (!bucket || !availableBuckets.includes(bucket) || missingPaths.includes(path)) {
await route.fulfill({ status: 404, body: "not found" });
return;
}
const generatedVersion = bucket === "next" ? "19.0.0-next.0" : "18.0.0";
await route.fulfill({ json: fixtureEntry(bucket, path, generatedVersion) });
});
await page.route("**/docs-examples/*/manifest.json", async (route) => {
const url = new URL(route.request().url());
requests.push(url.pathname);
const bucket = url.pathname.split("/").at(-2) ?? "";
if (!availableBuckets.includes(bucket)) {
await route.fulfill({ status: 404, body: "not found" });
return;
}
const examples = fixtureItems(bucket).filter((item) => !missingPaths.includes(item.docsPath));
await route.fulfill({
json: {
bucket,
docsBranch: bucket === "next" ? "develop" : `prod-docs/${bucket}`,
generatedFrom: "e2e fixture",
hotVersion: bucket === "next" ? "19.0.0-next.0" : "18.0.0",
count: examples.length,
examples,
},
});
});
}

function editor(page: import("@playwright/test").Page) {
return page.locator(".cm-content");
}

test("opens a docs example: breadcrumb, framework picker, docs link", async ({ page }) => {
await installRouteFixtures(page);
await page.goto(REACT_EXAMPLE);

// The picker trigger shows "<breadcrumb> · <example title>" (no framework).
Expand All @@ -30,6 +176,7 @@ test("opens a docs example: breadcrumb, framework picker, docs link", async ({ p
});

test("cascader drills down and highlights the current selection", async ({ page }) => {
await installRouteFixtures(page);
await page.goto(REACT_EXAMPLE);
await page.getByRole("button", { name: /Adding and removing columns/ }).click();

Expand All @@ -42,17 +189,19 @@ test("cascader drills down and highlights the current selection", async ({ page
await expect(page.getByText("Add and remove columns from the context menu", { exact: true })).toBeVisible();

// Search narrows results.
await page.getByPlaceholder("Search examples…").fill("dropdown array of values");
await expect(page.getByText(/Dropdown cell type ▸ Array of values/).first()).toBeVisible();
await page.getByPlaceholder("Search examples…").fill("context menu");
await expect(page.getByText(/Adding and removing columns ▸ Add and remove columns/).first()).toBeVisible();
});

test("switching framework updates the URL", async ({ page }) => {
await installRouteFixtures(page);
await page.goto(REACT_EXAMPLE);
await page.getByRole("button", { name: "Vue", exact: true }).click();
await expect(page).toHaveURL(/docs=guides%2Fcolumns%2Fcolumn-adding%2Fvue%2Fexample2\.vue/);
});

test("selecting an example from the cascader loads it", async ({ page }) => {
await installRouteFixtures(page);
await page.goto("/?example=react"); // start on a starter
await page.getByRole("button", { name: /React/ }).first().click(); // open picker
await page.getByText("Columns", { exact: true }).click();
Expand All @@ -62,7 +211,8 @@ test("selecting an example from the cascader loads it", async ({ page }) => {
});

test("unresolved docs path shows a not-found screen, not the default starter", async ({ page }) => {
await page.goto("/?docs=guides/does/not/exist.tsx");
await installRouteFixtures(page, { missingPaths: ["guides/does/not/exist.tsx"] });
await page.goto("/?docs=guides/does/not/exist.tsx&v=18.0.0");

await expect(page.getByText("Example not found")).toBeVisible();
await expect(page.getByText("guides/does/not/exist.tsx")).toBeVisible();
Expand All @@ -73,6 +223,75 @@ test("unresolved docs path shows a not-found screen, not the default starter", a
await expect(page.locator("iframe")).toHaveCount(0);
});

test("clean version switch replaces docs source from the target bucket", async ({ page }) => {
const requests: string[] = [];
await installRouteFixtures(page, { requests });
await page.goto(`/?docs=${DOCS_PATH}&v=18.0.0`);
await expect(editor(page)).toContainText(`18.0:${DOCS_PATH}`);

await page.getByLabel("Handsontable version", { exact: true }).selectOption(NEXT_VERSION);

await expect(editor(page)).toContainText(`next:${DOCS_PATH}`);
expect(requests).toContain(`/docs-examples/18.0/${DOCS_PATH.replace(/\//g, "__")}.json`);
expect(requests).toContain(`/docs-examples/next/${DOCS_PATH.replace(/\//g, "__")}.json`);
});

test("dirty version switch preserves edits, re-pins, and warns", async ({ page }) => {
await installRouteFixtures(page);
await page.goto(`/?docs=${DOCS_PATH}&v=18.0.0`);
await expect(editor(page)).toContainText(`18.0:${DOCS_PATH}`);
await editor(page).fill("export const userEdit = true;");

await page.getByLabel("Handsontable version", { exact: true }).selectOption(NEXT_VERSION);

await expect(editor(page)).toContainText("export const userEdit = true;");
await expect(page.getByText(/content may not match the selected version API/i)).toBeVisible();
await page.getByTitle("/package.json").click();
await expect(editor(page)).toContainText(`"handsontable": "${NEXT_VERSION}"`);
});

test("same docs path uses isolated next and release cache entries", async ({ page }) => {
const requests: string[] = [];
await installRouteFixtures(page, { latest: NEXT_VERSION, requests });
await page.goto(`/?docs=${DOCS_PATH}&v=${NEXT_VERSION}`);
await expect(editor(page)).toContainText(`next:${DOCS_PATH}`);

await page.getByLabel("Handsontable version", { exact: true }).selectOption("18.0.0");

await expect(editor(page)).toContainText(`18.0:${DOCS_PATH}`);
expect(requests.filter((path) => path.endsWith(`${DOCS_PATH.replace(/\//g, "__")}.json`))).toEqual([
`/docs-examples/next/${DOCS_PATH.replace(/\//g, "__")}.json`,
`/docs-examples/18.0/${DOCS_PATH.replace(/\//g, "__")}.json`,
]);
});

test("a version without a bucket leaves only starters in the picker", async ({ page }) => {
const requests: string[] = [];
await installRouteFixtures(page, { requests });
await page.goto("/?example=react&v=17.1.0");
await page.getByRole("button", { name: /React/ }).first().click();

await expect(page.getByText("Starter templates", { exact: true })).toBeVisible();
await expect(page.getByText("Columns", { exact: true })).toHaveCount(0);
expect(requests).toContain("/docs-examples/17.1/manifest.json");
});

test("an open docs example with no target bucket stops preview and remains recoverable", async ({ page }) => {
await installRouteFixtures(page);
await page.goto(`/?docs=${DOCS_PATH}&v=18.0.0`);
await expect(editor(page)).toContainText(`18.0:${DOCS_PATH}`);

await page.getByLabel("Handsontable version", { exact: true }).selectOption("17.1.0");

await expect(page.getByText(/No documentation examples are available for Handsontable 17\.1\.0/).first()).toBeVisible();
await expect(page.locator("section[aria-label='Preview'] iframe")).toHaveAttribute("src", "about:blank");
await page.getByRole("button", { name: /React/ }).first().click();
await expect(page.getByText("Starter templates", { exact: true })).toBeVisible();
await page.getByText("Starter templates", { exact: true }).click();
const reactStarter = page.locator(".hot-casc-row").filter({ hasText: "React (Vite, TS)" });
await expect(reactStarter).not.toContainText("✓");
});

// Live render — needs the external Sandpack bundler; opt-in via E2E_LIVE=1.
test("live: a JavaScript example renders a Handsontable grid", async ({ page }) => {
test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks");
Expand Down
25 changes: 15 additions & 10 deletions runner/packages/runtime/src/docs-bucket.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { parse } from "semver";

export interface DocsBucketResolution {
selectedVersion: string;
nextVersion: string;
bucketKeys: Iterable<string>;
}

/** Derive the docs bucket key a selected version would use, if available. */
export function deriveDocsBucketCandidate(
selectedVersion: string,
nextVersion: string,
): string | null {
if (selectedVersion === nextVersion) return "next";

const version = parse(selectedVersion);
return version ? `${version.major}.${version.minor}` : null;
}

/**
* Resolve a selected Handsontable version to an imported docs-example bucket.
* Unmatched versions intentionally have no bucket; callers must not fall back
Expand All @@ -15,14 +28,6 @@ export function resolveDocsBucket({
bucketKeys,
}: DocsBucketResolution): string | null {
const buckets = new Set(bucketKeys);

if (selectedVersion === nextVersion) {
return buckets.has("next") ? "next" : null;
}

const match = selectedVersion.match(/^(\d+)\.(\d+)(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$/);
if (!match) return null;

const bucket = `${match[1]}.${match[2]}`;
return buckets.has(bucket) ? bucket : null;
const candidate = deriveDocsBucketCandidate(selectedVersion, nextVersion);
return candidate && buckets.has(candidate) ? candidate : null;
}
1 change: 1 addition & 0 deletions runner/packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
export type { ValidationResult } from "./version.js";

export {
deriveDocsBucketCandidate,
resolveDocsBucket,
} from "./docs-bucket.js";
export type { DocsBucketResolution } from "./docs-bucket.js";
Expand Down
Loading
Loading