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
5 changes: 5 additions & 0 deletions .changeset/preview-navigation-vocabularies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Export the `EditorGlobalMode` and `SimulateViewMode` types, so a host encoding Petrinaut's navigation state into its own router can spell both vocabularies and fail its build when either gains a member.
51 changes: 51 additions & 0 deletions apps/petrinaut-website/src/examples/example-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,53 @@ import {
type SelectionItemType,
} from "@hashintel/petrinaut-core/selection";

/**
* The editor's mode, its Simulate section, and the overlay it has open, spelled
* for a URL.
*
* Declared here rather than imported so this module stays free of the editor:
* the oEmbed server function speaks the same contract and must not bundle it.
* `navigation-search.ts` maps each of these onto the editor's own vocabulary
* with an exhaustive switch, so a rename on either side fails to compile.
*/
export const sharedModes = ["edit", "simulate", "actual", "notebook"] as const;

export const sharedSimulateViews = [
"scenarios",
"metrics",
"experiments",
"optimizations",
] as const;

export const sharedOverlays = [
"viewport-settings",
"create-scenario",
"create-metric",
"create-experiment",
"create-optimization",
] as const;

export type SharedMode = (typeof sharedModes)[number];
export type SharedSimulateView = (typeof sharedSimulateViews)[number];
export type SharedOverlay = (typeof sharedOverlays)[number];

/**
* Search params understood by every example surface. A URL carries at most one
* focused item: multi-selection is in-app state, not a shareable location.
*
* A field the URL leaves out means "whatever this page starts from", which for
* every page but `/brunch` is the editor's own default. That is what lets Back
* undo a mode change or close an overlay: the entry it returns to simply does
* not name the field.
*/
export type SharedExampleSearch = {
scenario?: string;
subnet?: string;
itemType?: SelectionItemType;
itemId?: string;
mode?: SharedMode;
view?: SharedSimulateView;
overlay?: SharedOverlay;
};

/** The keys this contract owns. Anything else in a URL is foreign. */
Expand All @@ -30,6 +68,9 @@ const sharedSearchKeys = [
"subnet",
"itemType",
"itemId",
"mode",
"view",
"overlay",
] as const satisfies readonly (keyof SharedExampleSearch)[];

// `.catch(undefined)` is the contract's whole validation story: anything a URL
Expand All @@ -41,6 +82,13 @@ const optionalSelectionItemType = z
.optional()
.catch(undefined);

const optionalMode = z.enum(sharedModes).optional().catch(undefined);
const optionalSimulateView = z
.enum(sharedSimulateViews)
.optional()
.catch(undefined);
const optionalOverlay = z.enum(sharedOverlays).optional().catch(undefined);

/** The focused item, when the URL names a complete one. */
export const selectionFromInput = (
input: Record<string, unknown>,
Expand Down Expand Up @@ -68,6 +116,9 @@ export const validateSharedExampleSearch = (
): SharedExampleSearch => ({
scenario: optionalNonEmptyString.parse(input.scenario),
subnet: optionalNonEmptyString.parse(input.subnet),
mode: optionalMode.parse(input.mode),
view: optionalSimulateView.parse(input.view),
overlay: optionalOverlay.parse(input.overlay),
...selectionToSearch(selectionFromInput(input)),
});

Expand Down
77 changes: 66 additions & 11 deletions apps/petrinaut-website/src/examples/navigation-search.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
/**
* Projects the example URL contract onto Petrinaut's navigation state. The
* editor navigates more than the URL carries (mode, Simulate section,
* overlays), so those fields take editor defaults here and live in page state
* instead — see `useSharedSearchNavigation`.
* Projects the example URL contract onto Petrinaut's navigation state.
*
* The URL carries the location a reader can act on: the scenario, the subnet,
* the focused item, the editor's mode, its Simulate section, and the overlay it
* has open. It deliberately leaves out `simulateResource`, which names a run or
* a record inside the open document rather than a place in the app.
*
* Every field is decoded against a BASELINE — the location its page starts
* from. A URL that does not name a field means "the baseline's value", which is
* what makes Back undo a mode change or close an overlay: the entry Back
* returns to simply omits the field. The baseline is the editor's own default
* everywhere except `/brunch`, which starts in Actual mode.
*/
import { defaultPetrinautNavigationState } from "@hashintel/petrinaut/react";

import {
selectionFromInput,
selectionToSearch,
type SharedExampleSearch,
type SharedMode,
type SharedOverlay,
type SharedSimulateView,
} from "./example-search";

import type { PetrinautNavigationState } from "@hashintel/petrinaut/react";
import type {
EditorGlobalMode,
PetrinautNavigationOverlay,
PetrinautNavigationState,
SimulateViewMode,
} from "@hashintel/petrinaut/react";

/** `none` is an explicit no-scenario choice; absence means "first available". */
const scenarioFromSearch = (
Expand All @@ -28,19 +44,58 @@ const scenarioToSearch = (
scenarioId: string | null | undefined,
): string | undefined => (scenarioId === null ? "none" : scenarioId);

/**
* The editor's vocabularies, narrowed to the contract's. These are assignments
* rather than casts, so adding a mode, a Simulate section or an overlay to the
* editor fails this file's type check until the contract decides whether the
* URL should carry it.
*/
const modeToSearch = (mode: EditorGlobalMode): SharedMode => mode;

const simulateViewToSearch = (view: SimulateViewMode): SharedSimulateView =>
view;

const overlayToSearch = (
overlay: PetrinautNavigationOverlay,
): SharedOverlay | undefined => overlay?.type;

const overlayFromSearch = (
overlay: SharedOverlay,
): PetrinautNavigationOverlay => ({ type: overlay });

export const sharedSearchToNavigationState = (
search: SharedExampleSearch,
baseline: PetrinautNavigationState = defaultPetrinautNavigationState,
): PetrinautNavigationState => ({
...defaultPetrinautNavigationState,
...baseline,
scenarioId: scenarioFromSearch(search),
subnetId: search.subnet ?? null,
selection: selectionFromInput(search as Record<string, unknown>),
mode: search.mode ?? baseline.mode,
simulateView: search.view ?? baseline.simulateView,
overlay:
search.overlay === undefined
? baseline.overlay
: overlayFromSearch(search.overlay),
});

export const navigationStateToSharedSearch = (
state: Readonly<PetrinautNavigationState>,
): SharedExampleSearch => ({
scenario: scenarioToSearch(state.scenarioId),
subnet: state.subnetId ?? undefined,
...selectionToSearch(state.selection),
});
baseline: PetrinautNavigationState = defaultPetrinautNavigationState,
): SharedExampleSearch => {
const mode = modeToSearch(state.mode);
const view = simulateViewToSearch(state.simulateView);
const overlay = overlayToSearch(state.overlay);
return {
scenario: scenarioToSearch(state.scenarioId),
subnet: state.subnetId ?? undefined,
// Omitted at the baseline, so an untouched page keeps a clean URL and the
// decode above puts the baseline back.
mode: mode === modeToSearch(baseline.mode) ? undefined : mode,
view:
view === simulateViewToSearch(baseline.simulateView) ? undefined : view,
overlay:
overlay === overlayToSearch(baseline.overlay) ? undefined : overlay,
...selectionToSearch(state.selection),
};
};
4 changes: 3 additions & 1 deletion apps/petrinaut-website/src/examples/oembed-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ describe("Petrinaut oEmbed endpoint", () => {
it("preserves only valid embed state from the source URL", async () => {
const source = new URL("https://demo.petrinaut.org/examples/gases-2-spn");
source.searchParams.set("mode", "simulate");
// Neither of these is a contract key, so both drop: the Simulate section
// is carried as `view`, and `section` is somebody else's spelling.
source.searchParams.set("section", "metrics");
source.searchParams.set("scenario", "scenario-1");
source.searchParams.set("subnet", "subnet-1");
Expand All @@ -76,7 +78,7 @@ describe("Petrinaut oEmbed endpoint", () => {

expect(response.status).toBe(200);
expect(body.html).toBe(
'<iframe src="https://demo.petrinaut.org/embed/examples/gases-2-spn?itemId=transition-1&amp;itemType=transition&amp;scenario=scenario-1&amp;subnet=subnet-1" title="Gases 2 — Shared Tanker" width="800" height="450" style="border:0" loading="lazy" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer" allowfullscreen></iframe>',
'<iframe src="https://demo.petrinaut.org/embed/examples/gases-2-spn?itemId=transition-1&amp;itemType=transition&amp;mode=simulate&amp;scenario=scenario-1&amp;subnet=subnet-1" title="Gases 2 — Shared Tanker" width="800" height="450" style="border:0" loading="lazy" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer" allowfullscreen></iframe>',
);
});

Expand Down
Loading
Loading