Merge pull request #122 from ArnasDon/feat/flows-unsaved-changes-guard

feat(flows): unsaved-changes indicator + reload guard
This commit is contained in:
Arnas Donauskas 2026-05-22 15:17:11 +03:00 committed by GitHub
commit 9ff4a8d1ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -351,10 +351,37 @@ 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);
}, []);
// Used by jumpToNode() to scroll the target into view + flash its border. // Used by jumpToNode() to scroll the target into view + flash its border.
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [flashedKey, setFlashedKey] = useState<string | null>(null); const [flashedKey, setFlashedKey] = useState<string | null>(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 ---- // ---- Validation ----
const issues = useMemo<ValidationIssue[]>( const issues = useMemo<ValidationIssue[]>(
() => () =>
@ -392,6 +419,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";
@ -464,31 +492,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,
@ -507,11 +535,12 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
}; };
}); });
}, },
[], [setStateDirty],
); );
const removeNode = useCallback((key: string) => { const removeNode = useCallback(
setState((s) => ({ (key: string) => {
setStateDirty((s) => ({
...s, ...s,
nodes: s.nodes.filter((n) => n.node_key !== key), nodes: s.nodes.filter((n) => n.node_key !== key),
entry_node_id: s.entry_node_id === key ? null : s.entry_node_id, entry_node_id: s.entry_node_id === key ? null : s.entry_node_id,
@ -521,7 +550,9 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
next.delete(key); next.delete(key);
return next; return next;
}); });
}, []); },
[setStateDirty],
);
const toggleExpanded = useCallback((key: string) => { const toggleExpanded = useCallback((key: string) => {
setExpanded((prev) => { setExpanded((prev) => {
@ -567,7 +598,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}
@ -580,11 +612,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">
@ -618,7 +650,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 }))
} }
/> />
)) ))
@ -637,6 +669,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
function Header({ function Header({
state, state,
setState, setState,
dirty,
saving, saving,
activating, activating,
onSave, onSave,
@ -648,6 +681,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;
@ -681,6 +715,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