FN-7451: preserve workflow setting edits during saves
Workflow settings now keep newer pending edits when earlier save requests finish.\n\n- Snapshot pending workflow values before saving so the request cannot mutate under the save.\n- Clear only keys whose pending value still matches the completed request in Workflow Settings and Project Models.\n- Cover same-key, clear-to-default, and model-lane edits made while saves are in flight.\n- Add a patch changeset for the published Fusion package.\n\nFiles changed:\n .changeset/fn-7451-workflow-settings-save-race.md | 7 ++\n .../app/__tests__/settings-sections.test.tsx | 109 ++++++++++++++++++-\n .../app/components/WorkflowSettingsPanel.tsx | 21 +++-\n .../__tests__/WorkflowSettingsPanel.test.tsx | 121 ++++++++++++++++++++-\n .../settings/sections/ProjectModelsSection.tsx | 25 ++++-\n 5 files changed, 272 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7451 Fusion-Task-Lineage: 82f5e1cf-fabb-4fc6-936c-f42dcd9337ac Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7451-workflow-settings-save-race.md
Normal file
7
.changeset/fn-7451-workflow-settings-save-race.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Preserve workflow setting edits made while a values save is still in flight.
|
||||||
|
category: fix
|
||||||
|
dev: WorkflowSettingsPanel and Project Models workflow lane saves now clear only snapshot-matching pending keys.
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
* component-test conventions in settings-primitives.test.tsx.
|
* component-test conventions in settings-primitives.test.tsx.
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
|
||||||
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
|
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
|
||||||
|
|
||||||
import { AppearanceSection } from "../components/settings/sections/AppearanceSection";
|
import { AppearanceSection } from "../components/settings/sections/AppearanceSection";
|
||||||
@@ -23,7 +23,8 @@ import { PromptsSection } from "../components/settings/sections/PromptsSection";
|
|||||||
import { SecretsSection } from "../components/settings/sections/SecretsSection";
|
import { SecretsSection } from "../components/settings/sections/SecretsSection";
|
||||||
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
|
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
|
||||||
import type { SettingsFormState } from "../components/settings/sections/context";
|
import type { SettingsFormState } from "../components/settings/sections/context";
|
||||||
import { fetchWorkflow, fetchWorkflowSettingValues } from "../api";
|
import { fetchWorkflow, fetchWorkflowSettingValues, updateWorkflowSettingValues } from "../api";
|
||||||
|
import type { WorkflowSettingValuesPayload } from "../api";
|
||||||
|
|
||||||
vi.mock("../components/AgentPromptsManager", () => ({
|
vi.mock("../components/AgentPromptsManager", () => ({
|
||||||
AgentPromptsManager: () => <div data-testid="agent-prompts-manager" />,
|
AgentPromptsManager: () => <div data-testid="agent-prompts-manager" />,
|
||||||
@@ -41,17 +42,32 @@ vi.mock("../api", async (importOriginal) => {
|
|||||||
fetchProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
|
fetchProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
|
||||||
setProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
|
setProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
|
||||||
fetchGlobalSettings: vi.fn(async () => ({})),
|
fetchGlobalSettings: vi.fn(async () => ({})),
|
||||||
|
updateWorkflowSettingValues: vi.fn(async () => ({ stored: {}, effective: {}, orphaned: [] })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("../components/CustomModelDropdown", () => ({
|
vi.mock("../components/CustomModelDropdown", () => ({
|
||||||
CustomModelDropdown: ({ id, label, menuWidth = "trigger" }: { id?: string; label: string; menuWidth?: "trigger" | "readable" }) => (
|
CustomModelDropdown: ({ id, label, value, onChange, menuWidth = "trigger" }: { id?: string; label: string; value?: string; onChange?: (value: string) => void; menuWidth?: "trigger" | "readable" }) => (
|
||||||
<button type="button" data-testid={`mock-model-dropdown-${id ?? label}`} data-menu-width={menuWidth}>
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`mock-model-dropdown-${id ?? label}`}
|
||||||
|
data-menu-width={menuWidth}
|
||||||
|
data-value={value ?? ""}
|
||||||
|
onClick={() => onChange?.("anthropic/claude-sonnet-4-5")}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect.extend(jestDomMatchers);
|
expect.extend(jestDomMatchers);
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchWorkflow).mockReset();
|
||||||
|
vi.mocked(fetchWorkflowSettingValues).mockReset();
|
||||||
|
vi.mocked(updateWorkflowSettingValues).mockReset();
|
||||||
|
vi.mocked(fetchWorkflow).mockResolvedValue({ id: "builtin:coding", name: "Coding", ir: {} } as never);
|
||||||
|
vi.mocked(fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
|
||||||
|
vi.mocked(updateWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
|
||||||
|
});
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
const emptyForm = {} as SettingsFormState;
|
const emptyForm = {} as SettingsFormState;
|
||||||
@@ -345,6 +361,89 @@ describe("ProjectModelsSection", () => {
|
|||||||
expect(await screen.findByTestId("mock-model-dropdown-workflow-planning-model")).toHaveAttribute("data-menu-width", "readable");
|
expect(await screen.findByTestId("mock-model-dropdown-workflow-planning-model")).toHaveAttribute("data-menu-width", "readable");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves workflow lane edits made while the registered saver is in flight", async () => {
|
||||||
|
let saver: (() => Promise<void>) | null = null;
|
||||||
|
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
|
||||||
|
vi.mocked(fetchWorkflow).mockResolvedValueOnce({
|
||||||
|
id: "builtin:coding",
|
||||||
|
name: "Coding",
|
||||||
|
ir: {
|
||||||
|
settings: [
|
||||||
|
{ id: "planningProvider", name: "Planning Provider", type: "string" },
|
||||||
|
{ id: "planningModelId", name: "Planning Model", type: "string" },
|
||||||
|
{ id: "executionProvider", name: "Execution Provider", type: "string" },
|
||||||
|
{ id: "executionModelId", name: "Execution Model", type: "string" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
vi.mocked(fetchWorkflowSettingValues).mockResolvedValueOnce({ stored: {}, effective: {}, orphaned: [] });
|
||||||
|
vi.mocked(updateWorkflowSettingValues)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise<WorkflowSettingValuesPayload>((resolve) => {
|
||||||
|
resolveSave = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
stored: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
|
||||||
|
effective: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
|
||||||
|
orphaned: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProjectModelsSection
|
||||||
|
scopeBanner={null}
|
||||||
|
form={{ defaultWorkflowId: "builtin:coding" } as SettingsFormState}
|
||||||
|
setForm={vi.fn()}
|
||||||
|
models={{
|
||||||
|
...models,
|
||||||
|
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
|
||||||
|
}}
|
||||||
|
projectId="project-1"
|
||||||
|
addToast={vi.fn()}
|
||||||
|
registerWorkflowLaneSaver={(next) => {
|
||||||
|
saver = next;
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const planning = await screen.findByTestId("mock-model-dropdown-workflow-planning-model");
|
||||||
|
const execution = await screen.findByTestId("mock-model-dropdown-workflow-execution-model");
|
||||||
|
fireEvent.click(planning);
|
||||||
|
await waitFor(() => expect(planning).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5"));
|
||||||
|
|
||||||
|
const firstSave = saver!();
|
||||||
|
await waitFor(() => expect(updateWorkflowSettingValues).toHaveBeenCalledTimes(1));
|
||||||
|
expect(updateWorkflowSettingValues).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"builtin:coding",
|
||||||
|
{ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
|
||||||
|
"project-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(execution);
|
||||||
|
await waitFor(() => expect(execution).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5"));
|
||||||
|
await act(async () => {
|
||||||
|
resolveSave({
|
||||||
|
stored: { planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
|
||||||
|
effective: { planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
|
||||||
|
orphaned: [],
|
||||||
|
});
|
||||||
|
await firstSave;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(execution).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5");
|
||||||
|
await act(async () => {
|
||||||
|
await saver!();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(updateWorkflowSettingValues).toHaveBeenCalledTimes(2));
|
||||||
|
expect(updateWorkflowSettingValues).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"builtin:coding",
|
||||||
|
{ executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
|
||||||
|
"project-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders PR prompt guidance textareas and emits edits through setForm", () => {
|
it("renders PR prompt guidance textareas and emits edits through setForm", () => {
|
||||||
function ProjectModelsHost() {
|
function ProjectModelsHost() {
|
||||||
const [form, setFormState] = useState<SettingsFormState>({
|
const [form, setFormState] = useState<SettingsFormState>({
|
||||||
|
|||||||
@@ -492,6 +492,14 @@ function rawValueDisplay(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowSettingPendingValueEquals(a: unknown, b: unknown): boolean {
|
||||||
|
if (Object.is(a, b)) return true;
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
return a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkflowModelLanePair {
|
export interface WorkflowModelLanePair {
|
||||||
id: string;
|
id: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -712,14 +720,21 @@ function ValuesTab({
|
|||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
if (!dirty) return;
|
if (!dirty) return;
|
||||||
const savedKeys = new Set(Object.keys(pending));
|
const pendingSnapshot = { ...pending };
|
||||||
|
const savedKeys = Object.keys(pendingSnapshot);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await updateWorkflowSettingValues(workflowId, pending, boundProjectId);
|
const res = await updateWorkflowSettingValues(workflowId, pendingSnapshot, boundProjectId);
|
||||||
setPayload(res);
|
setPayload(res);
|
||||||
setPending((prev) => {
|
setPending((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const k of savedKeys) delete next[k];
|
/*
|
||||||
|
* FNXC:WorkflowSettingsSaveRace 2026-07-02-13:28:
|
||||||
|
* Values remain editable during an async save, so success may only clear keys whose current pending value still equals this request's snapshot. New same-key edits, clear-to-default nulls, and model-lane pair edits must remain queued for the next save.
|
||||||
|
*/
|
||||||
|
for (const k of savedKeys) {
|
||||||
|
if (workflowSettingPendingValueEquals(prev[k], pendingSnapshot[k])) delete next[k];
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setRejections({});
|
setRejections({});
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ const modelResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockFetchModels.mockReset();
|
||||||
|
mockFetchValues.mockReset();
|
||||||
|
mockUpdateValues.mockReset();
|
||||||
mockFetchModels.mockResolvedValue(modelResponse);
|
mockFetchModels.mockResolvedValue(modelResponse);
|
||||||
mockFetchValues.mockResolvedValue(payload());
|
mockFetchValues.mockResolvedValue(payload());
|
||||||
mockUpdateValues.mockResolvedValue(payload());
|
mockUpdateValues.mockResolvedValue(payload());
|
||||||
@@ -89,7 +92,6 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkflowSettingsPanel — Definitions tab", () => {
|
describe("WorkflowSettingsPanel — Definitions tab", () => {
|
||||||
@@ -272,6 +274,81 @@ describe("WorkflowSettingsPanel — Values tab", () => {
|
|||||||
expect(mockUpdateValues).toHaveBeenNthCalledWith(2, "wf-1", { label: "mid-flight-edit" }, "proj-1");
|
expect(mockUpdateValues).toHaveBeenNthCalledWith(2, "wf-1", { label: "mid-flight-edit" }, "proj-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a newer same-key edit when an older save resolves", async () => {
|
||||||
|
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false, label: "server" } }));
|
||||||
|
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
|
||||||
|
mockUpdateValues
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise<WorkflowSettingValuesPayload>((resolve) => {
|
||||||
|
resolveSave = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
payload({
|
||||||
|
stored: { "timeout-ms": 7000 },
|
||||||
|
effective: { "timeout-ms": 7000, "new-sessions": false, label: "server" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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));
|
||||||
|
expect(mockUpdateValues).toHaveBeenNthCalledWith(1, "wf-1", { "timeout-ms": 5000 }, "proj-1");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "7000" } });
|
||||||
|
await act(async () => {
|
||||||
|
resolveSave(payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000, "new-sessions": false, label: "server" } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Timeout")).toHaveValue(7000);
|
||||||
|
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", { "timeout-ms": 7000 }, "proj-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves mid-flight clear-to-default edits after an unrelated save resolves", async () => {
|
||||||
|
mockFetchValues.mockResolvedValue(
|
||||||
|
payload({
|
||||||
|
stored: { label: "custom" },
|
||||||
|
effective: { "timeout-ms": 1000, "new-sessions": false, label: "custom" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
|
||||||
|
mockUpdateValues
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise<WorkflowSettingValuesPayload>((resolve) => {
|
||||||
|
resolveSave = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(payload({ stored: {}, effective: { "timeout-ms": 5000, "new-sessions": false } }));
|
||||||
|
|
||||||
|
render(<Host initial={decls} />);
|
||||||
|
openValues();
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("Label")).toHaveValue("custom"));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("wf-settings-customized-label")).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } });
|
||||||
|
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
|
||||||
|
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const labelRow = screen.getByTestId("wf-settings-value-label");
|
||||||
|
fireEvent.click(within(labelRow).getByRole("button"));
|
||||||
|
await act(async () => {
|
||||||
|
resolveSave(payload({ stored: { "timeout-ms": 5000, label: "custom" }, effective: { "timeout-ms": 5000, "new-sessions": false, label: "custom" } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Label")).toHaveValue("");
|
||||||
|
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: null }, "proj-1");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a per-field rejection on the matching row and keeps other edits applied", async () => {
|
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 } }));
|
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
|
||||||
mockUpdateValues.mockRejectedValueOnce(
|
mockUpdateValues.mockRejectedValueOnce(
|
||||||
@@ -520,6 +597,48 @@ describe("WorkflowSettingsPanel — Values tab", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves mid-flight model-lane pair edits after an unrelated save resolves", async () => {
|
||||||
|
mockFetchValues.mockResolvedValue(payload({ effective: { planningProvider: "", planningModelId: "", customModelProvider: "old" } }));
|
||||||
|
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
|
||||||
|
mockUpdateValues
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise<WorkflowSettingValuesPayload>((resolve) => {
|
||||||
|
resolveSave = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
payload({
|
||||||
|
stored: { planningProvider: "anthropic", planningModelId: "claude-sonnet", customModelProvider: "saved" },
|
||||||
|
effective: { planningProvider: "anthropic", planningModelId: "claude-sonnet", customModelProvider: "saved" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Host initial={modelDecls} readOnly />);
|
||||||
|
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Custom model provider"), { target: { value: "saved" } });
|
||||||
|
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
|
||||||
|
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockUpdateValues).toHaveBeenNthCalledWith(1, "wf-1", { customModelProvider: "saved" }, "proj-1");
|
||||||
|
|
||||||
|
await openPlanningDropdown();
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: /Claude Sonnet/i }));
|
||||||
|
await act(async () => {
|
||||||
|
resolveSave(payload({ stored: { customModelProvider: "saved" }, effective: { customModelProvider: "saved" } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Plan/Triage Model")).toHaveTextContent("Claude Sonnet");
|
||||||
|
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",
|
||||||
|
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" },
|
||||||
|
"proj-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows inherited/default dropdown state for undefined values without a customized badge", async () => {
|
it("shows inherited/default dropdown state for undefined values without a customized badge", async () => {
|
||||||
render(<Host initial={modelDecls} readOnly />);
|
render(<Host initial={modelDecls} readOnly />);
|
||||||
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
|
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ function modelPairValue(values: Record<string, unknown>, pair: WorkflowModelPair
|
|||||||
? `${provider}/${modelId}`
|
? `${provider}/${modelId}`
|
||||||
: "";
|
: "";
|
||||||
}
|
}
|
||||||
|
function workflowPendingValueEquals(a: unknown, b: unknown): boolean {
|
||||||
|
if (Object.is(a, b))
|
||||||
|
return true;
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
return a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
export interface ProjectModelsSectionModelProps {
|
export interface ProjectModelsSectionModelProps {
|
||||||
modelLanes: ModelLane[];
|
modelLanes: ModelLane[];
|
||||||
getLaneStatus: (lane: ModelLane) => LaneStatus;
|
getLaneStatus: (lane: ModelLane) => LaneStatus;
|
||||||
@@ -208,10 +216,23 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
|||||||
const saveWorkflowLanes = useCallback(async () => {
|
const saveWorkflowLanes = useCallback(async () => {
|
||||||
if (!projectId || !workflowDirty)
|
if (!projectId || !workflowDirty)
|
||||||
return;
|
return;
|
||||||
|
const pendingSnapshot = { ...workflowPending };
|
||||||
|
const savedKeys = Object.keys(pendingSnapshot);
|
||||||
try {
|
try {
|
||||||
const payload = await updateWorkflowSettingValues(workflowId, workflowPending, projectId);
|
const payload = await updateWorkflowSettingValues(workflowId, pendingSnapshot, projectId);
|
||||||
setWorkflowPayload(payload);
|
setWorkflowPayload(payload);
|
||||||
setWorkflowPending({});
|
setWorkflowPending((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
/*
|
||||||
|
* FNXC:ProjectModelsWorkflowLanes 2026-07-02-13:30:
|
||||||
|
* The registered Settings saver can resolve after workflow lane dropdowns changed again. Clear only snapshot-matching keys so Project Models follows the same no-lost-pending-edits invariant as Workflow Settings Values.
|
||||||
|
*/
|
||||||
|
for (const key of savedKeys) {
|
||||||
|
if (workflowPendingValueEquals(current[key], pendingSnapshot[key]))
|
||||||
|
delete next[key];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setWorkflowRejections({});
|
setWorkflowRejections({});
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user