Skip to content
Open
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/smooth-readonly-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Explain blocked edits with a brief read-only notice and an optional action to create an editable copy.
27 changes: 13 additions & 14 deletions apps/petrinaut-website/src/examples/full-example-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ export const FullExamplePage = ({
const handle = getReadonlyExampleHandle(example);
const navigation = useSharedSearchNavigation(search, onSearchChange);

const forkLocalCopy = () => {
try {
onFork();
setForkError(null);
} catch {
setForkError(
"Your browser couldn't save a copy. Free up browser storage and try again.",
);
}
};

useEffect(() => {
const previousTitle = document.title;
document.title = `${example.catalog.title} · Petrinaut`;
Expand Down Expand Up @@ -97,22 +108,10 @@ export const FullExamplePage = ({
navigation={navigation}
presentationProfile="review"
readonly
readOnlyAction={{ label: "Make a local copy", onClick: forkLocalCopy }}
slots={{
topBarEnd: (
<Button
size="xs"
variant="subtle"
onClick={() => {
try {
onFork();
setForkError(null);
} catch {
setForkError(
"Your browser couldn't save a copy. Free up browser storage and try again.",
);
}
}}
>
<Button size="xs" variant="subtle" onClick={forkLocalCopy}>
Make a local copy
</Button>
),
Expand Down
6 changes: 6 additions & 0 deletions libs/@hashintel/petrinaut/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ passes `readonly` as well, which is what disables the fields themselves:
/>
```

Blocked node drags and editing shortcuts show a brief read-only notice. To offer
an action such as saving an editable copy, pass
`readOnlyAction={{ label: "Make a copy", onClick: createEditableCopy }}`. The host
owns the callback and persistence. The action appears for host-enforced
read-only documents; mode and simulation restrictions show the relevant next step.

## Commands and the palette

Petrinaut registers its user-invocable actions (undo, tools, search, panel
Expand Down
6 changes: 6 additions & 0 deletions libs/@hashintel/petrinaut/docs/drawing-a-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ move it between browsers.
To edit a published example, choose **Make a local copy** in its top bar. The copy
opens at its own local URL, and the published example stays unchanged.

Trying to drag a node or use an editing shortcut on a read-only net shows a brief
notice. Clicking to inspect a node, panning, and zooming remain available. On a
published example, the notice also offers **Make a local copy**. In Simulate or
Actual mode, it points you back to Edit; when a simulation holds the net, it asks
you to reset the simulation first.

## Editor layout

The editor is organized around a central canvas where you build your net:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { use } from "react";
import { PetrinautInstanceContext } from "../instance-context";
import { ActiveNetContext } from "../state/active-net-context";
import { useIsReadOnly } from "../state/use-is-read-only";
import { useReadOnlyFeedback } from "./use-read-only-feedback";

import type { PetrinautCommands } from "@hashintel/petrinaut-core";

Expand All @@ -23,12 +24,14 @@ export function usePetrinautCommands(): PetrinautCommands {
);
}
const isReadOnly = useIsReadOnly();
const notifyReadOnly = useReadOnlyFeedback();
const { activeSubnetId } = use(ActiveNetContext);
const { commands } = instance;

return {
applyClipboardPaste(input) {
if (isReadOnly) {
notifyReadOnly();
return { newItemIds: [] };
}
return commands.applyClipboardPaste({
Expand All @@ -38,6 +41,7 @@ export function usePetrinautCommands(): PetrinautCommands {
},
async applyAutoLayout() {
if (isReadOnly) {
notifyReadOnly();
return { commitCount: 0 };
}
return commands.applyAutoLayout({ targetSubnetId: activeSubnetId });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { act, renderHook } from "@testing-library/react";
import { type ReactNode } from "react";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";

import {
DEFAULT_PETRINAUT_EXTENSIONS,
Expand All @@ -14,6 +14,7 @@ import {
} from "@hashintel/petrinaut-core";

import { PetrinautInstanceContext } from "../instance-context";
import { NotificationsContext } from "../notifications/context";
import { SimulationContext, type SimulationState } from "../simulation/context";
import {
EditorContext,
Expand Down Expand Up @@ -378,3 +379,68 @@ describe("usePetrinautMutations", () => {
});
});
});

test.each([
[{ readonly: true }, "This document is read-only."],
[{ globalMode: "simulate" }, "Switch to Edit to change this net."],
[{ simulationState: "Paused" }, "Reset the simulation to edit this net."],
] satisfies [WrapperOptions, string][])(
"explains blocked mutations for %j",
(options, message) => {
const { Wrapper, instance } = createWrapper(options);
const addNotification = vi.fn(() => "notice");
const { result } = renderHook(usePetrinautMutations, {
wrapper: ({ children }) => (
<Wrapper>
<NotificationsContext
value={{ addNotification, dismissNotification: () => {} }}
>
{children}
</NotificationsContext>
</Wrapper>
),
});
act(() =>
result.current.addPlace({
id: "blocked",
name: "Blocked",
colorId: null,
dynamicsEnabled: false,
differentialEquationId: null,
x: 0,
y: 0,
}),
);
expect(instance.definition.get().places).toHaveLength(0);
expect(addNotification).toHaveBeenCalledWith(
expect.objectContaining({ message, tone: "neutral" }),
);
},
);

test("allowed scenario mutations in Simulate stay quiet", () => {
const { Wrapper, instance } = createWrapper({ globalMode: "simulate" });
const addNotification = vi.fn(() => "notice");
const { result } = renderHook(usePetrinautMutations, {
wrapper: ({ children }) => (
<Wrapper>
<NotificationsContext
value={{ addNotification, dismissNotification: () => {} }}
>
{children}
</NotificationsContext>
</Wrapper>
),
});
act(() =>
result.current.addScenario({
id: "scenario",
name: "Scenario",
scenarioParameters: [],
parameterOverrides: {},
initialState: { type: "per_place", content: {} },
}),
);
expect(instance.definition.get().scenarios).toHaveLength(1);
expect(addNotification).not.toHaveBeenCalled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ActiveNetContext } from "../state/active-net-context";
import { SDCPNContext } from "../state/sdcpn-context";
import { simulateModeAllowedMutationNames } from "../state/simulate-mode-allowed-mutation-names";
import { useIsReadOnly } from "../state/use-is-read-only";
import { useReadOnlyFeedback } from "./use-read-only-feedback";

import type { PetrinautMutations } from "@hashintel/petrinaut-core";

Expand Down Expand Up @@ -38,6 +39,7 @@ export function usePetrinautMutations(): PetrinautMutations {
const { readonly } = use(SDCPNContext);
const { activeSubnetId } = use(ActiveNetContext);
const isReadOnly = useIsReadOnly();
const notifyReadOnly = useReadOnlyFeedback();
const { mutations } = instance;

const withReadonlyGuard = <Name extends keyof PetrinautMutations>(
Expand All @@ -50,6 +52,7 @@ export function usePetrinautMutations(): PetrinautMutations {
) => void;
const wrapped = ((input: PetrinautMutationInput<Name>) => {
if (allowedInSimulate ? readonly : isReadOnly) {
notifyReadOnly();
return;
}
const nextInput =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { use } from "react";

import { NotificationsContext } from "../notifications/context";
import { ReadOnlyActionContext } from "../state/read-only-action-context";
import { SDCPNContext } from "../state/sdcpn-context";
import { useReadOnlyReason } from "../state/use-read-only-reason";

export const useReadOnlyFeedback = () => {
const reason = useReadOnlyReason();
const action = use(ReadOnlyActionContext);
const { petriNetId } = use(SDCPNContext);
const { addNotification } = use(NotificationsContext);

return () => {
if (reason === null) return;
const message =
reason.kind === "host-readonly"
? "This document is read-only."
: reason.kind === "simulation-active"
? "Reset the simulation to edit this net."
: "Switch to Edit to change this net.";
addNotification({
id: `read-only:${petriNetId}:${reason.kind}`,
message,
tone: "neutral",
durationMs: 4500,
action: reason.kind === "host-readonly" ? action : undefined,
});
};
};
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut/src/react/notifications/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createContext } from "react";
export type NotificationTone = "error" | "neutral" | "success";

export type AddNotificationInput = {
id?: string;
action?: { label: string; onClick: () => void };
detail?: string;
message: string;
tone?: NotificationTone;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,42 @@ test("shows a way out only where a notification needs one", async () => {
);
expect(failure?.querySelector("[aria-label='Copy details']")).toBeTruthy();
});

test("deduplicates a notice and offers its action without stealing focus", async () => {
const onClick = vi.fn();
const Trigger = () => {
const { addNotification } = use(NotificationsContext);
return (
<button
type="button"
onClick={() => {
for (let i = 0; i < 3; i++)
addNotification({
id: "read-only:test",
message: "This document is read-only.",
tone: "neutral",
durationMs: 4500,
action: { label: "Make a local copy", onClick },
});
}}
>
Try editing
</button>
);
};
render(
<NotificationsProvider>
<Trigger />
</NotificationsProvider>,
);
const trigger = screen.getByRole("button", { name: "Try editing" });
trigger.focus();
fireEvent.click(trigger);
const action = await screen.findByRole("button", {
name: "Make a local copy",
});
expect(screen.getAllByText("This document is read-only.")).toHaveLength(1);
expect(document.activeElement).toBe(trigger);
fireEvent.click(action);
expect(onClick).toHaveBeenCalledOnce();
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,29 @@ export const NotificationsProvider = ({
};

const addNotification = ({
action,
id: suppliedId,
detail,
durationMs,
message,
tone = "success",
}: AddNotificationInput) => {
const id = `notification-${nextNotificationId}`;
const id = suppliedId ?? `notification-${nextNotificationId}`;
nextNotificationId += 1;
const effectiveDurationMs =
tone === "error"
? Infinity
: (durationMs ?? DEFAULT_NOTIFICATION_DURATION_MS);

queueMicrotask(() => {
if (notificationsToaster.isVisible(id)) return;
notificationsToaster.create({
...(action ? { action } : {}),
description: detail,
duration: effectiveDurationMs,
id,
title: message,
type: tone,
type: tone === "neutral" ? "info" : tone,
});
});

Expand Down
7 changes: 7 additions & 0 deletions libs/@hashintel/petrinaut/src/react/notifications/toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export const NotificationsToaster = () => (
</Toast.Description>
)}
</div>
{toast.action && (
<Toast.ActionTrigger asChild>
<Button className={toastActionStyle} size="xs" variant="ghost">
{toast.action.label}
</Button>
</Toast.ActionTrigger>
)}
{dismissible && (
<div className={toastActionsStyle}>
{detail && (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createContext } from "react";

export type ReadOnlyAction = { label: string; onClick: () => void };

export const ReadOnlyActionContext = createContext<ReadOnlyAction | undefined>(
undefined,
);
Loading
Loading