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/clear-code-editor-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

Make the code editor guide available to the in-app assistant.
5 changes: 5 additions & 0 deletions .changeset/tidy-code-editor-layouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Expand code sections to fill the Properties Panel with smooth transitions, a parent breadcrumb, keyboard focus restoration, and browser history navigation. Make Canvas and Definitions available as Edit views with browser history navigation. Preserve the latest code when switching between Edit views.
20 changes: 20 additions & 0 deletions apps/petrinaut-website/src/examples/example-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ import {
} from "./example-search";

describe("example search contract", () => {
it("carries expanded sections as complete panel and section pairs", () => {
const expanded = {
expandedPanel: "transition-properties",
expandedSection: "transition-results",
};
expect(validateSharedExampleSearch(expanded)).toMatchObject(expanded);
expect(canonicalSearchString(expanded)).toBe(
"expandedPanel=transition-properties&expandedSection=transition-results",
);
for (const input of [
{ expandedPanel: "transition-properties" },
{ expandedSection: "transition-results" },
{ expandedPanel: 42, expandedSection: "transition-results" },
{ expandedPanel: "transition-properties", expandedSection: "" },
]) {
const search = validateSharedExampleSearch(input);
expect(search.expandedPanel).toBeUndefined();
expect(search.expandedSection).toBeUndefined();
}
});
it("validates settings sections only for the user settings dialog", () => {
expect(
validateSharedExampleSearch({
Expand Down
26 changes: 24 additions & 2 deletions apps/petrinaut-website/src/examples/example-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
* `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 sharedModes = ["edit", "simulate", "actual"] as const;

export const sharedEditViews = ["canvas", "definitions"] as const;

export const sharedSimulateViews = [
"scenarios",
Expand All @@ -45,6 +47,7 @@ export const sharedSettingsSections = [
"labs",
] as const;

export type SharedEditView = (typeof sharedEditViews)[number];
export type SharedMode = (typeof sharedModes)[number];
export type SharedSimulateView = (typeof sharedSimulateViews)[number];
export type SharedOverlay = (typeof sharedOverlays)[number];
Expand All @@ -64,9 +67,12 @@ export type SharedExampleSearch = {
itemType?: SelectionItemType;
itemId?: string;
mode?: SharedMode;
editView?: SharedEditView;
view?: SharedSimulateView;
overlay?: SharedOverlay;
settings?: (typeof sharedSettingsSections)[number];
expandedPanel?: string;
expandedSection?: string;
};

/** The keys this contract owns. Anything else in a URL is foreign. */
Expand All @@ -76,9 +82,12 @@ const sharedSearchKeys = [
"itemType",
"itemId",
"mode",
"editView",
"view",
"overlay",
"settings",
"expandedPanel",
"expandedSection",
] as const satisfies readonly (keyof SharedExampleSearch)[];

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

const optionalEditView = z.enum(sharedEditViews).optional().catch(undefined);
const optionalMode = z.enum(sharedModes).optional().catch(undefined);
const optionalSimulateView = z
.enum(sharedSimulateViews)
Expand Down Expand Up @@ -124,9 +134,21 @@ export const validateSharedExampleSearch = (
): SharedExampleSearch => ({
scenario: optionalNonEmptyString.parse(input.scenario),
subnet: optionalNonEmptyString.parse(input.subnet),
mode: optionalMode.parse(input.mode),
mode: input.mode === "notebook" ? "edit" : optionalMode.parse(input.mode),
editView:
input.mode === "notebook"
? "definitions"
: optionalEditView.parse(
input.editView === "notebook" ? "definitions" : input.editView,
),
view: optionalSimulateView.parse(input.view),
overlay: optionalOverlay.parse(input.overlay),
expandedPanel: optionalNonEmptyString.parse(input.expandedSection)
? optionalNonEmptyString.parse(input.expandedPanel)
: undefined,
expandedSection: optionalNonEmptyString.parse(input.expandedPanel)
? optionalNonEmptyString.parse(input.expandedSection)
: undefined,
settings:
input.overlay === "user-settings"
? z
Expand Down
64 changes: 63 additions & 1 deletion apps/petrinaut-website/src/examples/navigation-search.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,75 @@
import { describe, expect, it } from "vitest";

import { sharedOverlays, sharedSimulateViews } from "./example-search";
import {
sharedOverlays,
sharedSimulateViews,
validateSharedExampleSearch,
} from "./example-search";
import {
applyPreviewNavigationUpdate,
navigationStateToSharedSearch,
sharedSearchToNavigationState,
} from "./navigation-search";

describe("navigation state projection", () => {
it("routes Definitions within Edit and preserves the selection in shared links", () => {
const search = {
editView: "definitions",
itemType: "transition",
itemId: "collision",
} as const;
const state = sharedSearchToNavigationState(search);
expect(state.mode).toBe("edit");
expect(state.editView).toBe("definitions");
expect(navigationStateToSharedSearch(state)).toMatchObject(search);
expect(
applyPreviewNavigationUpdate(search, (current) => current),
).toMatchObject(search);
expect(sharedSearchToNavigationState({}).editView).toBe("canvas");
});

it.each([{ mode: "notebook" }, { editView: "notebook" }])(
"opens legacy Notebook links in the Edit workspace: %s",
(legacy) => {
const search = validateSharedExampleSearch({
...legacy,
itemType: "place",
itemId: "space",
});
expect(search).toMatchObject({
editView: "definitions",
itemId: "space",
});
expect(sharedSearchToNavigationState(search)).toMatchObject({
mode: "edit",
editView: "definitions",
});
},
);

it("round-trips an expanded Properties Panel section with its selected item", () => {
const search = {
itemType: "transition",
itemId: "collision",
expandedPanel: "transition-properties",
expandedSection: "transition-results",
} as const;
const state = sharedSearchToNavigationState(search);
expect(state.expandedSubView).toEqual({
container: search.expandedPanel,
id: search.expandedSection,
});
expect(navigationStateToSharedSearch(state)).toMatchObject(search);
expect(
sharedSearchToNavigationState({
itemType: "transition",
itemId: "collision",
}).expandedSubView,
).toBeNull();
expect(
applyPreviewNavigationUpdate(search, (current) => current),
).toMatchObject(search);
});
it.each(["general", "viewport", "simulation", "labs"] as const)(
"round-trips the %s settings section in Simulate",
(settings) => {
Expand Down
23 changes: 20 additions & 3 deletions apps/petrinaut-website/src/examples/navigation-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
* 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.
* the focused item, its expanded properties section, the editor's mode, its
* Simulate section and the overlay it has open. It 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
Expand All @@ -18,6 +18,7 @@ import {
selectionFromInput,
selectionToSearch,
type SharedExampleSearch,
type SharedEditView,
type SharedMode,
type SharedOverlay,
type SharedSimulateView,
Expand All @@ -26,6 +27,7 @@ import {
import type { PetrinautPreviewNavigationState } from "@hashintel/petrinaut/preview";
import type {
EditorGlobalMode,
EditViewMode,
PetrinautNavigationOverlay,
PetrinautNavigationState,
PetrinautNavigationUpdater,
Expand Down Expand Up @@ -60,6 +62,8 @@ const scenarioToSearch = (
* editor fails this file's type check until the contract decides whether the
* URL should carry it.
*/
const editViewToSearch = (view: EditViewMode): SharedEditView => view;

const modeToSearch = (mode: EditorGlobalMode): SharedMode => mode;

const simulateViewToSearch = (view: SimulateViewMode): SharedSimulateView =>
Expand All @@ -84,6 +88,11 @@ export const sharedSearchToNavigationState = (
subnetId: search.subnet ?? null,
selection: selectionFromInput(search as Record<string, unknown>),
mode: search.mode ?? baseline.mode,
editView: search.editView ?? baseline.editView,
expandedSubView:
search.expandedPanel && search.expandedSection
? { container: search.expandedPanel, id: search.expandedSection }
: null,
simulateView: search.view ?? baseline.simulateView,
overlay:
search.overlay === undefined
Expand All @@ -96,14 +105,19 @@ export const navigationStateToSharedSearch = (
baseline: PetrinautNavigationState = defaultPetrinautNavigationState,
): SharedExampleSearch => {
const mode = modeToSearch(state.mode);
const editView = editViewToSearch(state.editView);
const view = simulateViewToSearch(state.simulateView);
const overlay = overlayToSearch(state.overlay);
return {
scenario: scenarioToSearch(state.scenarioId),
subnet: state.subnetId ?? undefined,
expandedPanel: state.expandedSubView?.container,
expandedSection: state.expandedSubView?.id,
// 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,
editView:
editView === editViewToSearch(baseline.editView) ? undefined : editView,
view:
view === simulateViewToSearch(baseline.simulateView) ? undefined : view,
overlay:
Expand Down Expand Up @@ -143,9 +157,12 @@ export const applyPreviewNavigationUpdate = (
update: PetrinautNavigationUpdater<PetrinautPreviewNavigationState>,
): SharedExampleSearch => ({
mode: search.mode,
editView: search.editView,
view: search.view,
overlay: search.overlay,
settings: search.settings,
expandedPanel: search.expandedPanel,
expandedSection: search.expandedSection,
...navigationStateToPreviewSearch(
update(previewSearchToNavigationState(search)),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import { act, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { useSharedSearchNavigation } from "./use-shared-search-navigation";
import {
useSharedSearchNavigation,
withClearedSharedLocation,
} from "./use-shared-search-navigation";

import type { SharedExampleSearch } from "./example-search";
import type {
Expand Down Expand Up @@ -34,6 +37,104 @@ const Probe = ({
};

describe("useSharedSearchNavigation", () => {
it("pushes Edit view switches and follows Back and Forward without changing selection", () => {
let controller!: PetrinautNavigationController;
const onSearchChange = vi.fn();
const parent = { itemType: "transition", itemId: "collision" } as const;
const notebook = { ...parent, editView: "definitions" } as const;
const onController = (value: PetrinautNavigationController) => {
controller = value;
};
const props = { onController, onSearchChange };
const view = render(<Probe {...props} search={parent} />);
act(() =>
controller.onNavigate(
(current) => ({ ...current, editView: "definitions" }),
{ history: "push", intent: { cause: "user", action: "edit-view" } },
),
);
expect(onSearchChange).toHaveBeenCalledWith(
expect.objectContaining(notebook),
"push",
);
view.rerender(<Probe {...props} search={notebook} />);
view.rerender(<Probe {...props} search={parent} />);
expect(controller.state.editView).toBe("canvas");
view.rerender(<Probe {...props} search={notebook} />);
expect(controller.state.editView).toBe("definitions");
expect(controller.state.mode).toBe("edit");
expect(controller.state.selection).toEqual([
{ type: "transition", id: "collision" },
]);
expect(onSearchChange).toHaveBeenCalledOnce();
});

it("writes section expansion to history and follows Back, Forward, and document replacement", () => {
let controller!: PetrinautNavigationController;
const onSearchChange = vi.fn();
const parent = { itemType: "transition", itemId: "collision" } as const;
const expanded = {
...parent,
expandedPanel: "transition-properties",
expandedSection: "transition-results",
};
const onController = (value: PetrinautNavigationController) => {
controller = value;
};
const view = render(
<Probe
onController={onController}
onSearchChange={onSearchChange}
search={parent}
/>,
);
act(() =>
controller.onNavigate(
(current) => ({
...current,
expandedSubView: {
container: "transition-properties",
id: "transition-results",
},
}),
{ history: "push", intent: { cause: "user", action: "subview" } },
),
);
expect(onSearchChange).toHaveBeenCalledWith(
expect.objectContaining(expanded),
"push",
);
view.rerender(
<Probe
onController={onController}
onSearchChange={onSearchChange}
search={expanded}
/>,
);
view.rerender(
<Probe
onController={onController}
onSearchChange={onSearchChange}
search={parent}
/>,
);
expect(controller.state.expandedSubView).toBeNull();
view.rerender(
<Probe
onController={onController}
onSearchChange={onSearchChange}
search={expanded}
/>,
);
expect(controller.state.expandedSubView).toEqual({
container: "transition-properties",
id: "transition-results",
});
expect(onSearchChange).toHaveBeenCalledOnce();
expect(
withClearedSharedLocation(controller.state).expandedSubView,
).toBeNull();
});
it("keeps URL-unrepresentable state in memory and mirrors the shared subset", () => {
let controller!: PetrinautNavigationController;
const onSearchChange = vi.fn();
Expand Down
Loading
Loading