FN-6073: preserve pending workflow value edits during save

Preserve unsaved workflow value edits made while a save request is in flight.

- track the keys included in the current save request before sending it
- clear only the saved keys after a successful save so newer edits remain pending
- add regression coverage for mid-flight edits that trigger a follow-up save

Files changed:
 packages/dashboard/app/components/WorkflowSettingsPanel.tsx            |  7 +++-
 packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx | 41 +++++++++++++++++++++-
 2 files changed, 46 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6073

Fusion-Task-Lineage: 31e05269-d51d-40c0-9691-87e61d464992
This commit is contained in:
gsxdsm
2026-06-09 06:25:16 -07:00
parent f8200adb71
commit d66630a43f
2 changed files with 46 additions and 2 deletions

View File

@@ -721,11 +721,16 @@ function ValuesTab({
const save = useCallback(async () => {
if (!dirty) return;
const savedKeys = new Set(Object.keys(pending));
setSaving(true);
try {
const res = await updateWorkflowSettingValues(workflowId, pending, boundProjectId);
setPayload(res);
setPending({});
setPending((prev) => {
const next = { ...prev };
for (const k of savedKeys) delete next[k];
return next;
});
setRejections({});
addToast(t("workflowSettings.valuesSaved", "Setting values saved"), "success");
} catch (err) {

View File

@@ -9,7 +9,7 @@
* `updateWorkflowSettingValues`).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, cleanup, within, act } from "@testing-library/react";
import { useState } from "react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
@@ -227,6 +227,45 @@ describe("WorkflowSettingsPanel — Values tab", () => {
);
});
it("preserves mid-flight edits after a successful save and resaves only the new keys", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false, label: "" } }));
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
mockUpdateValues
.mockReturnValueOnce(
new Promise<WorkflowSettingValuesPayload>((resolve) => {
resolveSave = resolve;
}),
)
.mockResolvedValueOnce(
payload({
stored: { "timeout-ms": 5000, label: "mid-flight-edit" },
effective: { "timeout-ms": 5000, "new-sessions": false, label: "mid-flight-edit" },
}),
);
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } });
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "mid-flight-edit" } });
await act(async () => {
resolveSave(payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000, "new-sessions": false } }));
});
expect(mockUpdateValues).toHaveBeenNthCalledWith(1, "wf-1", { "timeout-ms": 5000 }, "proj-1");
expect(screen.getByLabelText("Label")).toHaveValue("mid-flight-edit");
expect(screen.getByLabelText("Timeout")).toHaveValue(5000);
await waitFor(() => expect(screen.getByTestId("wf-settings-save-values")).not.toBeDisabled());
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(2));
expect(mockUpdateValues).toHaveBeenNthCalledWith(2, "wf-1", { label: "mid-flight-edit" }, "proj-1");
});
it("renders a per-field rejection on the matching row and keeps other edits applied", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
mockUpdateValues.mockRejectedValueOnce(