feat(flows): unsaved-changes indicator + reload guard

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-22 15:10:06 +03:00
parent 4cb6849291
commit ad91d205fb
No known key found for this signature in database

View File

@ -247,6 +247,32 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
const [expanded, setExpanded] = useState<Set<string>>( const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(initialNodes.map((n) => n.node_key)), () => 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<typeof setState>((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 ---- // ---- Validation ----
const issues = useMemo<ValidationIssue[]>( const issues = useMemo<ValidationIssue[]>(
@ -285,6 +311,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
const json = await res.json().catch(() => ({})); const json = await res.json().catch(() => ({}));
throw new Error(json.error ?? `Save failed: ${res.status}`); throw new Error(json.error ?? `Save failed: ${res.status}`);
} }
setDirty(false);
toast.success("Saved."); toast.success("Saved.");
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Save failed"; const msg = err instanceof Error ? err.message : "Save failed";
@ -357,31 +384,31 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
// ---- Node helpers ---- // ---- Node helpers ----
const updateNode = useCallback( const updateNode = useCallback(
(key: string, patch: Partial<BuilderNode>) => { (key: string, patch: Partial<BuilderNode>) => {
setState((s) => ({ setStateDirty((s) => ({
...s, ...s,
nodes: s.nodes.map((n) => (n.node_key === key ? { ...n, ...patch } : n)), nodes: s.nodes.map((n) => (n.node_key === key ? { ...n, ...patch } : n)),
})); }));
}, },
[], [setStateDirty],
); );
const updateNodeConfig = useCallback( const updateNodeConfig = useCallback(
(key: string, configPatch: Record<string, unknown>) => { (key: string, configPatch: Record<string, unknown>) => {
setState((s) => ({ setStateDirty((s) => ({
...s, ...s,
nodes: s.nodes.map((n) => nodes: s.nodes.map((n) =>
n.node_key === key ? { ...n, config: { ...n.config, ...configPatch } } : n, n.node_key === key ? { ...n, config: { ...n.config, ...configPatch } } : n,
), ),
})); }));
}, },
[], [setStateDirty],
); );
const addNode = useCallback( const addNode = useCallback(
(type: NodeType) => { (type: NodeType) => {
const meta = NODE_META[type]; const meta = NODE_META[type];
const base = slugify(meta.label, type); const base = slugify(meta.label, type);
setState((s) => { setStateDirty((s) => {
const node_key = uniqueNodeKey(base, s.nodes); const node_key = uniqueNodeKey(base, s.nodes);
const next: BuilderNode = { const next: BuilderNode = {
node_key, node_key,
@ -400,21 +427,24 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
}; };
}); });
}, },
[], [setStateDirty],
); );
const removeNode = useCallback((key: string) => { const removeNode = useCallback(
setState((s) => ({ (key: string) => {
...s, setStateDirty((s) => ({
nodes: s.nodes.filter((n) => n.node_key !== key), ...s,
entry_node_id: s.entry_node_id === key ? null : s.entry_node_id, 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); setExpanded((prev) => {
next.delete(key); const next = new Set(prev);
return next; next.delete(key);
}); return next;
}, []); });
},
[setStateDirty],
);
const toggleExpanded = useCallback((key: string) => { const toggleExpanded = useCallback((key: string) => {
setExpanded((prev) => { setExpanded((prev) => {
@ -430,7 +460,8 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6"> <div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6">
<Header <Header
state={state} state={state}
setState={setState} setState={setStateDirty}
dirty={dirty}
saving={saving} saving={saving}
activating={activating} activating={activating}
onSave={handleSave} onSave={handleSave}
@ -443,11 +474,11 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
<TriggerPanel <TriggerPanel
state={state} state={state}
setState={setState} setState={setStateDirty}
triggerIssues={issues.filter((i) => i.scope === "trigger")} triggerIssues={issues.filter((i) => i.scope === "trigger")}
/> />
<EntryPicker state={state} setState={setState} /> <EntryPicker state={state} setState={setStateDirty} />
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -479,7 +510,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
onUpdateConfig={(patch) => updateNodeConfig(node.node_key, patch)} onUpdateConfig={(patch) => updateNodeConfig(node.node_key, patch)}
onRemove={() => removeNode(node.node_key)} onRemove={() => removeNode(node.node_key)}
onSetEntry={() => 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({ function Header({
state, state,
setState, setState,
dirty,
saving, saving,
activating, activating,
onSave, onSave,
@ -509,6 +541,7 @@ function Header({
}: { }: {
state: BuilderState; state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>; setState: React.Dispatch<React.SetStateAction<BuilderState>>;
dirty: boolean;
saving: boolean; saving: boolean;
activating: boolean; activating: boolean;
onSave: () => void; onSave: () => void;
@ -542,6 +575,16 @@ function Header({
className="max-w-md bg-slate-900 text-lg font-semibold" className="max-w-md bg-slate-900 text-lg font-semibold"
/> />
<StatusBadge status={state.status} /> <StatusBadge status={state.status} />
{dirty && (
<span
className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-amber-300"
title="Unsaved changes — hit Save to persist"
aria-live="polite"
>
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
Edited
</span>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button