Skip to content

Commit 0010c1f

Browse files
committed
fix: preserve locked flow saves
1 parent b4c8c76 commit 0010c1f

2 files changed

Lines changed: 130 additions & 26 deletions

File tree

src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// biome-ignore-all lint/suspicious/noExplicitAny: store mocks intentionally accept multiple selector shapes
12
import { renderHook } from "@testing-library/react";
23
import useSaveFlow from "../use-save-flow";
34

@@ -129,6 +130,82 @@ describe("useSaveFlow", () => {
129130
expect(mockSetCurrentFlow).toHaveBeenCalled();
130131
});
131132

133+
it("does not autosave hydrated data while the persisted flow is locked", async () => {
134+
const persistedFlow = {
135+
...flowsManagerState.currentFlow,
136+
locked: true,
137+
};
138+
flowsManagerState.currentFlow = persistedFlow;
139+
flowsManagerState.flows = [persistedFlow];
140+
flowStoreState.currentFlow = {
141+
...persistedFlow,
142+
data: {
143+
...persistedFlow.data,
144+
nodes: [{ id: "old-node", data: { is_refresh: true } }],
145+
viewport: { x: 10, y: 20, zoom: 0.75 },
146+
},
147+
};
148+
149+
const { result } = renderHook(() => useSaveFlow());
150+
151+
await expect(result.current()).resolves.toBeUndefined();
152+
153+
expect(mockMutate).not.toHaveBeenCalled();
154+
expect(mockSetSaveLoading).not.toHaveBeenCalled();
155+
});
156+
157+
it("unlocks a persisted flow before saving other settings changes", async () => {
158+
const persistedFlow = {
159+
...flowsManagerState.currentFlow,
160+
locked: true,
161+
};
162+
const requestedFlow = {
163+
...persistedFlow,
164+
name: "Renamed after unlock",
165+
locked: false,
166+
data: {
167+
...persistedFlow.data,
168+
nodes: [{ id: "old-node", data: { is_refresh: true } }],
169+
},
170+
};
171+
flowsManagerState.currentFlow = persistedFlow;
172+
flowsManagerState.flows = [persistedFlow];
173+
flowStoreState.currentFlow = persistedFlow;
174+
175+
mockMutate.mockImplementation((payload, options) => {
176+
options.onSuccess({
177+
...requestedFlow,
178+
...payload,
179+
});
180+
});
181+
182+
const { result } = renderHook(() => useSaveFlow());
183+
184+
await expect(result.current(requestedFlow)).resolves.toBeUndefined();
185+
186+
expect(mockMutate).toHaveBeenCalledTimes(2);
187+
expect(mockMutate.mock.calls[0][0]).toEqual({
188+
id: "flow-1",
189+
locked: false,
190+
});
191+
expect(mockMutate.mock.calls[1][0]).toEqual(
192+
expect.objectContaining({
193+
id: "flow-1",
194+
name: "Renamed after unlock",
195+
locked: false,
196+
data: requestedFlow.data,
197+
}),
198+
);
199+
expect(mockSetSaveLoading).toHaveBeenCalledWith(true);
200+
expect(mockSetSaveLoading).toHaveBeenCalledWith(false);
201+
expect(mockSetCurrentFlow).toHaveBeenCalledWith(
202+
expect.objectContaining({
203+
name: "Renamed after unlock",
204+
locked: false,
205+
}),
206+
);
207+
});
208+
132209
it("should_update_store_flow_folder_id_when_moved_via_drag_drop_from_dashboard", async () => {
133210
// Arrange — dashboard scenario: no flow open in the editor, the
134211
// global flows store is populated from `header_flows=true`, so the

src/frontend/src/hooks/flows/use-save-flow.ts

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,21 @@ const useSaveFlow = () => {
2121
const saveFlow = async (flow?: FlowType): Promise<void> => {
2222
const currentFlow = useFlowStore.getState().currentFlow;
2323
const currentSavedFlow = useFlowsManagerStore.getState().currentFlow;
24-
if (
25-
customStringify(flow || currentFlow) !== customStringify(currentSavedFlow)
26-
) {
24+
const requestedFlow = flow || currentFlow;
25+
const isPersistedFlowLocked =
26+
currentSavedFlow?.id === requestedFlow?.id &&
27+
currentSavedFlow?.locked === true;
28+
const isUnlockingPersistedFlow =
29+
isPersistedFlowLocked && requestedFlow?.locked === false;
30+
31+
// Hydrating a flow can change client-only node metadata and the viewport.
32+
// Do not let those differences trigger saves while the persisted flow is
33+
// locked. Unlocking is handled separately below.
34+
if (isPersistedFlowLocked && !isUnlockingPersistedFlow) {
35+
return;
36+
}
37+
38+
if (customStringify(requestedFlow) !== customStringify(currentSavedFlow)) {
2739
setSaveLoading(true);
2840

2941
const flowData = currentFlow?.data;
@@ -72,17 +84,28 @@ const useSaveFlow = () => {
7284
endpoint_name,
7385
locked,
7486
} = flow;
75-
mutate(
76-
{
77-
id,
78-
name,
79-
data: data!,
80-
description,
81-
folder_id,
82-
endpoint_name,
83-
locked,
84-
},
85-
{
87+
const updatePayload = {
88+
id,
89+
name,
90+
data: data!,
91+
description,
92+
folder_id,
93+
endpoint_name,
94+
locked,
95+
};
96+
// biome-ignore lint/suspicious/noExplicitAny: legacy
97+
const handleError = (e: any) => {
98+
const detail =
99+
e.response?.data?.detail || e.message || "Unknown error";
100+
setErrorData({
101+
title: t("errors.failedToSaveFlow"),
102+
list: [detail],
103+
});
104+
setSaveLoading(false);
105+
reject(e);
106+
};
107+
const persistFlow = () => {
108+
mutate(updatePayload, {
86109
onSuccess: (updatedFlow) => {
87110
const flows = useFlowsManagerStore.getState().flows;
88111
setSaveLoading(false);
@@ -112,19 +135,23 @@ const useSaveFlow = () => {
112135
reject(new Error("Flows variable undefined"));
113136
}
114137
},
115-
// biome-ignore lint/suspicious/noExplicitAny: legacy
116-
onError: (e: any) => {
117-
const detail =
118-
e.response?.data?.detail || e.message || "Unknown error";
119-
setErrorData({
120-
title: t("errors.failedToSaveFlow"),
121-
list: [detail],
122-
});
123-
setSaveLoading(false);
124-
reject(e);
138+
onError: handleError,
139+
});
140+
};
141+
142+
if (isUnlockingPersistedFlow) {
143+
mutate(
144+
{ id, locked: false },
145+
{
146+
// Preserve any settings edits by applying them only after the
147+
// backend has committed the unlock-only request.
148+
onSuccess: persistFlow,
149+
onError: handleError,
125150
},
126-
},
127-
);
151+
);
152+
} else {
153+
persistFlow();
154+
}
128155
} else {
129156
setErrorData({
130157
title: t("errors.failedToSaveFlow"),

0 commit comments

Comments
 (0)