mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
4cb6849291
commit
ad91d205fb
@ -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,11 +427,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,
|
||||||
@ -414,7 +442,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) => {
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user