diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 83f2063..a1eaab6 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -351,10 +351,37 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { const [expanded, setExpanded] = useState>( () => new Set(initialNodes.map((n) => n.node_key)), ); + // Tracks whether the in-memory state has user edits that haven't been + // PUT yet. We use a wrapper setState (`setStateDirty`) for user edits; + // status-only changes after the activate API succeeds use raw setState + // so they don't falsely re-flag the form as dirty. + const [dirty, setDirty] = useState(false); + const setStateDirty = useCallback((updaterOrValue) => { + setDirty(true); + setState(updaterOrValue); + }, []); + // Used by jumpToNode() to scroll the target into view + flash its border. const nodeRefs = useRef>(new Map()); const [flashedKey, setFlashedKey] = useState(null); + // Browser-level reload / tab-close / external-link guard. SPA + // navigation (sidebar links, back button) isn't covered here — Next 16 + // routes through the App Router and beforeunload doesn't fire on + // client-side route changes. That's a follow-up; this catches the + // accidental refresh / closed-window class of data loss. + useEffect(() => { + if (!dirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + // Modern browsers ignore the return value but require something + // truthy to actually show the native prompt. + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [dirty]); + // ---- Validation ---- const issues = useMemo( () => @@ -392,6 +419,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { const json = await res.json().catch(() => ({})); throw new Error(json.error ?? `Save failed: ${res.status}`); } + setDirty(false); toast.success("Saved."); } catch (err) { const msg = err instanceof Error ? err.message : "Save failed"; @@ -464,31 +492,31 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { // ---- Node helpers ---- const updateNode = useCallback( (key: string, patch: Partial) => { - setState((s) => ({ + setStateDirty((s) => ({ ...s, nodes: s.nodes.map((n) => (n.node_key === key ? { ...n, ...patch } : n)), })); }, - [], + [setStateDirty], ); const updateNodeConfig = useCallback( (key: string, configPatch: Record) => { - setState((s) => ({ + setStateDirty((s) => ({ ...s, nodes: s.nodes.map((n) => n.node_key === key ? { ...n, config: { ...n.config, ...configPatch } } : n, ), })); }, - [], + [setStateDirty], ); const addNode = useCallback( (type: NodeType) => { const meta = NODE_META[type]; const base = slugify(meta.label, type); - setState((s) => { + setStateDirty((s) => { const node_key = uniqueNodeKey(base, s.nodes); const next: BuilderNode = { node_key, @@ -507,21 +535,24 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { }; }); }, - [], + [setStateDirty], ); - const removeNode = useCallback((key: string) => { - setState((s) => ({ - ...s, - nodes: s.nodes.filter((n) => n.node_key !== key), - entry_node_id: s.entry_node_id === key ? null : s.entry_node_id, - })); - setExpanded((prev) => { - const next = new Set(prev); - next.delete(key); - return next; - }); - }, []); + const removeNode = useCallback( + (key: string) => { + setStateDirty((s) => ({ + ...s, + nodes: s.nodes.filter((n) => n.node_key !== key), + entry_node_id: s.entry_node_id === key ? null : s.entry_node_id, + })); + setExpanded((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }, + [setStateDirty], + ); const toggleExpanded = useCallback((key: string) => { setExpanded((prev) => { @@ -567,7 +598,8 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
i.scope === "trigger")} /> - +
@@ -618,7 +650,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { onUpdateConfig={(patch) => updateNodeConfig(node.node_key, patch)} onRemove={() => removeNode(node.node_key)} onSetEntry={() => - setState((s) => ({ ...s, entry_node_id: node.node_key })) + setStateDirty((s) => ({ ...s, entry_node_id: node.node_key })) } /> )) @@ -637,6 +669,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { function Header({ state, setState, + dirty, saving, activating, onSave, @@ -648,6 +681,7 @@ function Header({ }: { state: BuilderState; setState: React.Dispatch>; + dirty: boolean; saving: boolean; activating: boolean; onSave: () => void; @@ -681,6 +715,16 @@ function Header({ className="max-w-md bg-slate-900 text-lg font-semibold" /> + {dirty && ( + + + Edited + + )}