The flow editor can now change a flow's shape on the canvas, not only through the inspector's fields: - drag a box onto another to move it and its branch there; a drop the save would refuse shows the target dashed red and gives the reason; - a palette of node kinds adds a node below the picked box on click, or below any box it is dragged onto; it offers what the controller lists; - arrow keys walk the tree, M picks a box up and Enter drops it under the focused one, Alt+Up/Down reorder a node among its siblings, Delete removes a branch; focus follows the edit; - each box with siblings says when it runs, and the inspector can move it earlier or later, since the router runs children in edge order; - request_reviewer is a second leaf kind beside todo, with a reviewer field; - leaving with unsaved edits asks first, also for in-app links and the back button (a router hook runs navigation past a page's guard).
88 lines
2.7 KiB
TypeScript
88 lines
2.7 KiB
TypeScript
// Stops a page with unsaved edits from losing them without asking.
|
|
//
|
|
// Closing or reloading the tab gets the browser's own prompt. A move inside the
|
|
// app goes past the nav guard, and the browser back button is caught with a
|
|
// spare history entry: while there are edits the page pushes a copy of its own
|
|
// URL, so back lands on the same page and the prompt can be shown.
|
|
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
import { navigate } from "wouter-preact/use-browser-location";
|
|
import { setNavGuard } from "../../shell/navGuard.js";
|
|
|
|
// `to` is where the person was headed, or null for the back button.
|
|
interface Pending {
|
|
to: string | null;
|
|
}
|
|
|
|
export function useLeaveGuard(dirty: boolean): {
|
|
pending: boolean;
|
|
leave: () => void;
|
|
stay: () => void;
|
|
// Lets the page's own navigation through, such as after deleting the flow.
|
|
release: () => void;
|
|
} {
|
|
const [pending, setPending] = useState<Pending | null>(null);
|
|
const spare = useRef(false);
|
|
const released = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!dirty) return;
|
|
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
|
window.addEventListener("beforeunload", warn);
|
|
return () => window.removeEventListener("beforeunload", warn);
|
|
}, [dirty]);
|
|
|
|
useEffect(() => {
|
|
if (!dirty) return;
|
|
setNavGuard((to) => {
|
|
if (released.current) return true;
|
|
setPending({ to });
|
|
return false;
|
|
});
|
|
return () => setNavGuard(null);
|
|
}, [dirty]);
|
|
|
|
useEffect(() => {
|
|
const onPop = () => {
|
|
if (!spare.current) return;
|
|
if (dirty && !released.current) {
|
|
// Back landed on the page again; the spare entry is used up.
|
|
spare.current = false;
|
|
setPending({ to: null });
|
|
} else {
|
|
// Nothing left to protect: go on to where back was meant to go.
|
|
spare.current = false;
|
|
history.back();
|
|
}
|
|
};
|
|
window.addEventListener("popstate", onPop);
|
|
if (dirty && !spare.current) {
|
|
history.pushState(history.state, "", location.href);
|
|
spare.current = true;
|
|
}
|
|
return () => window.removeEventListener("popstate", onPop);
|
|
}, [dirty]);
|
|
|
|
const stay = useCallback(() => {
|
|
if (pending?.to === null && !spare.current) {
|
|
history.pushState(history.state, "", location.href);
|
|
spare.current = true;
|
|
}
|
|
setPending(null);
|
|
}, [pending]);
|
|
|
|
const leave = useCallback(() => {
|
|
released.current = true;
|
|
setNavGuard(null);
|
|
const to = pending?.to;
|
|
setPending(null);
|
|
if (to === null) history.back();
|
|
else if (to !== undefined) navigate(to);
|
|
}, [pending]);
|
|
|
|
const release = useCallback(() => {
|
|
released.current = true;
|
|
setNavGuard(null);
|
|
}, []);
|
|
|
|
return { pending: pending !== null, leave, stay, release };
|
|
}
|