From ad91d205fb59ca58be0426c2356101ecf55ac51a Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 15:10:06 +0300 Subject: [PATCH] feat(flows): unsaved-changes indicator + reload guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing 5 node configs and then refreshing the tab silently wiped them. Activate also silently invoked Save first — useful, but invisible: users had no way to know if pending edits had landed. Adds: - A `dirty` boolean flipped true on any user edit, false on a successful PUT. Internal `state.status` changes from the activate API path use raw setState and don't dirty the form. - An "● Edited" amber chip next to the StatusBadge while dirty. - A `beforeunload` listener that prompts the native confirm on reload / tab-close / external-link click when dirty. SPA navigation (sidebar, back button) isn't covered — Next 16's App Router doesn't fire beforeunload on client-side routes. Worth a follow-up but the accidental-refresh case is the noisier one. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/flows/flow-builder.tsx | 87 ++++++++++++++++++++------- 1 file changed, 65 insertions(+), 22 deletions(-) diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 095d097..aed9155 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -247,6 +247,32 @@ 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); + }, []); + + // 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( @@ -285,6 +311,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"; @@ -357,31 +384,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, @@ -400,21 +427,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) => { @@ -430,7 +460,8 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
i.scope === "trigger")} /> - +
@@ -479,7 +510,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 })) } /> )) @@ -498,6 +529,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { function Header({ state, setState, + dirty, saving, activating, onSave, @@ -509,6 +541,7 @@ function Header({ }: { state: BuilderState; setState: React.Dispatch>; + dirty: boolean; saving: boolean; activating: boolean; onSave: () => void; @@ -542,6 +575,16 @@ function Header({ className="max-w-md bg-slate-900 text-lg font-semibold" /> + {dirty && ( + + + Edited + + )}