From 245149423a6dc0076581082e2ca502b1a627500c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sun, 30 Aug 2026 03:07:22 +0200 Subject: [PATCH] FE-1500: switch the example embed to the Petrinaut preview --- .../src/examples/embedded-example-page.tsx | 68 ++++++++----------- .../examples/example-search.property.test.ts | 18 ++++- .../src/examples/navigation-search.ts | 30 +++++++- .../src/routes/embed.examples.$slug.tsx | 36 +++++++--- 4 files changed, 98 insertions(+), 54 deletions(-) diff --git a/apps/petrinaut-website/src/examples/embedded-example-page.tsx b/apps/petrinaut-website/src/examples/embedded-example-page.tsx index 9eda19e9bd6..b16b8477e71 100644 --- a/apps/petrinaut-website/src/examples/embedded-example-page.tsx +++ b/apps/petrinaut-website/src/examples/embedded-example-page.tsx @@ -2,15 +2,19 @@ import { lazy, Suspense, type FunctionComponent } from "react"; import { css } from "@hashintel/ds-helpers/css"; -import { getReadonlyExampleHandle } from "./readonly-example-handle"; -import { useSharedSearchNavigation } from "./use-shared-search-navigation"; +import { previewSearchToNavigationState } from "./navigation-search"; -import type { LoadedExample } from "./catalog"; +import type { GeneratedExampleRuntime, LoadedExample } from "./catalog"; import type { SharedExampleSearch } from "./example-search"; +import type { + PetrinautPreviewNavigationState, + PetrinautPreviewQuickSimulation, +} from "@hashintel/petrinaut/preview"; +import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; -const LazyPetrinaut = lazy(async () => { - const { Petrinaut } = await import("@hashintel/petrinaut/ui"); - return { default: Petrinaut }; +const LazyPetrinautPreview = lazy(async () => { + const { PetrinautPreview } = await import("@hashintel/petrinaut/preview"); + return { default: PetrinautPreview }; }); // The page frame and the loading fallback render outside Petrinaut, and the @@ -36,55 +40,37 @@ const loadingStyle = css({ fontSize: "[14px]", }); -const embedTitleStyle = css({ - minWidth: "0", - overflow: "hidden", - color: "neutral.s90", - fontSize: "sm", - fontWeight: "medium", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - export type EmbeddedExamplePageProps = { example: LoadedExample; - /** Writes the shared search subset back to the embed URL. */ - onSearchChange: ( - search: SharedExampleSearch, - history: "push" | "replace", - ) => void; + runtime: GeneratedExampleRuntime; + onNavigate: PetrinautNavigationController["onNavigate"]; search: SharedExampleSearch; }; -/** - * Embed of an example: the full Petrinaut component in its read-only - * presentation, navigated through the shared search contract. - */ export const EmbeddedExamplePage: FunctionComponent< EmbeddedExamplePageProps -> = ({ example, onSearchChange, search }) => { - const handle = getReadonlyExampleHandle(example); - const navigation = useSharedSearchNavigation(search, onSearchChange, { - // The embed lives in an iframe; it must not grow the host page's history. - historyPolicy: () => "replace", - }); +> = ({ example, onNavigate, runtime, search }) => { + const navigation: PetrinautNavigationController = + { + state: previewSearchToNavigationState(search), + historyPolicy: () => "replace", + onNavigate, + }; + const quickSimulation: PetrinautPreviewQuickSimulation = { + ...runtime, + parameterBounds: example.catalog.parameterBounds, + }; return (
Loading Petrinaut…} > - {example.catalog.title} - ), - }} + quickSimulation={quickSimulation} title={example.catalog.title} /> diff --git a/apps/petrinaut-website/src/examples/example-search.property.test.ts b/apps/petrinaut-website/src/examples/example-search.property.test.ts index a9f59af0172..9f94d6515e9 100644 --- a/apps/petrinaut-website/src/examples/example-search.property.test.ts +++ b/apps/petrinaut-website/src/examples/example-search.property.test.ts @@ -6,6 +6,10 @@ import { sharedSearchesMatch, validateSharedExampleSearch, } from "./example-search"; +import { + navigationStateToPreviewSearch, + previewSearchToNavigationState, +} from "./navigation-search"; const knownKeys = ["scenario", "subnet", "itemType", "itemId"] as const; @@ -50,7 +54,7 @@ describe("example search contract laws", () => { }); test.prop([searchInput])( - "validation is idempotent, so the embed entry redirect terminates", + "validation is idempotent, so re-validating a location is a no-op", (input) => { const once = validateSharedExampleSearch(input); const twice = validateSharedExampleSearch(once); @@ -70,3 +74,15 @@ describe("example search contract laws", () => { }, ); }); + +describe("preview codec", () => { + test.prop([searchInput])( + "the preview codec spells locations exactly like the shared contract", + (input) => { + const search = validateSharedExampleSearch(input); + expect( + navigationStateToPreviewSearch(previewSearchToNavigationState(search)), + ).toEqual(search); + }, + ); +}); diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts index 94698850184..4e3e46d160d 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -12,7 +12,11 @@ import { type SharedExampleSearch, } from "./example-search"; -import type { PetrinautNavigationState } from "@hashintel/petrinaut/react"; +import type { PetrinautPreviewNavigationState } from "@hashintel/petrinaut/preview"; +import type { + PetrinautNavigationState, + PetrinautNavigationUpdater, +} from "@hashintel/petrinaut/react"; /** `none` is an explicit no-scenario choice; absence means "first available". */ const scenarioFromSearch = ( @@ -24,6 +28,14 @@ const scenarioFromSearch = ( return search.scenario === "none" ? null : search.scenario; }; +export const previewSearchToNavigationState = ( + search: SharedExampleSearch, +): PetrinautPreviewNavigationState => ({ + scenarioId: scenarioFromSearch(search), + subnetId: search.subnet ?? null, + selection: selectionFromInput(search as Record), +}); + const scenarioToSearch = ( scenarioId: string | null | undefined, ): string | undefined => (scenarioId === null ? "none" : scenarioId); @@ -44,3 +56,19 @@ export const navigationStateToSharedSearch = ( subnet: state.subnetId ?? undefined, ...selectionToSearch(state.selection), }); + +export const navigationStateToPreviewSearch = ( + state: Readonly, +): SharedExampleSearch => ({ + scenario: scenarioToSearch(state.scenarioId), + subnet: state.subnetId ?? undefined, + ...selectionToSearch(state.selection), +}); + +export const applyPreviewNavigationUpdate = ( + search: SharedExampleSearch, + update: PetrinautNavigationUpdater, +): SharedExampleSearch => + navigationStateToPreviewSearch( + update(previewSearchToNavigationState(search)), + ); diff --git a/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx b/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx index e37432ba788..47c28992827 100644 --- a/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx +++ b/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx @@ -6,26 +6,34 @@ import { useSearch, } from "@tanstack/react-router"; -import { isExampleSlug, loadExample } from "../examples/catalog"; +import { + isExampleSlug, + loadExample, + loadExampleRuntime, +} from "../examples/catalog"; import { EmbeddedExamplePage } from "../examples/embedded-example-page"; import { validateSharedExampleSearch } from "../examples/example-search"; +import { applyPreviewNavigationUpdate } from "../examples/navigation-search"; import { EmbedStatusPanel } from "./-embed-status-panel"; const routePath = "/embed/examples/$slug" as const; function EmbeddedExampleRoute() { const navigate = useNavigate({ from: routePath }); - const example = useLoaderData({ from: routePath }); + const { example, runtime } = useLoaderData({ from: routePath }); return ( { - void navigate({ replace: history === "replace", search }); + onNavigate={(update, { history }) => { + void navigate({ + replace: history === "replace", + search: (previous) => applyPreviewNavigationUpdate(previous, update), + }); }} + runtime={runtime} search={useSearch({ from: routePath })} /> ); @@ -38,20 +46,26 @@ export const Route = createFileRoute("/embed/examples/$slug")({ } }, component: EmbeddedExampleRoute, - // The editor is imported lazily, so a chunk that fails to load throws after - // mount. Without a boundary here the root unmounts and the frame goes blank - // on someone else's page. + // The Preview is imported lazily, so a chunk that fails to load throws + // after mount. Without a boundary here the root unmounts and the frame goes + // blank on someone else's page. errorComponent: () => ( ), - loader: ({ params }) => { + loader: async ({ params }) => { if (!isExampleSlug(params.slug)) { throw notFound(); } - return loadExample(params.slug); + + const [example, runtime] = await Promise.all([ + loadExample(params.slug), + loadExampleRuntime(params.slug), + ]); + + return { example, runtime }; }, // The site-wide not-found page is a full viewport panel with a link that // would navigate the embedder's frame, so the embed answers for itself.