From 1dd95e2902c729a35fb82282bf58ed9f59a2cc9d Mon Sep 17 00:00:00 2001 From: Viktor Avelino <64113566+viktoravelino@users.noreply.github.com> Date: Fri, 8 May 2026 08:52:55 -0400 Subject: [PATCH] feat(deployments): support multi-version flows (#12950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(deployments): support multi-version flows * fix(deployments): use flows in attach step * refactor(deployments): split review step * feat(deployments): scope default tool names * test(deployments): update stepper state tests * test(deployments): update attach flow tests * fix(deployments): preserve removed flow state * fix(deployments): keep stepper provider mounted * fix(deployments): sync attached flow review * refactor(deployments): share scoped lookups * refactor(deployments): type version selection * refactor(deployments): split payload builders * chore(deployments): standardize labels * refactor(deployments): isolate wxo naming * refactor(deployments): simplify attach checks * chore(deployments): drop stale effect ignores * refactor(deployments): remount edit stepper * fix(tests): update tests for effectiveAttachmentKey rename and scoped tool names - use-connection-panel-state.test.ts: rename effectiveFlowId → effectiveAttachmentKey in baseParams() to match hook's refactored param name - deployment-create.spec.ts: make SNAPSHOTS_DUPLICATE_MOCK route dynamic — echo back requested names as existing tools so duplicate check works with scoped names (getDefaultDeploymentToolName now generates "Flow {scope}-{id}" format) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/create-mode.test.tsx | 333 ++++++++++- .../__tests__/custom-tool-naming.test.tsx | 81 ++- ...ployment-stepper-payload-builders.test.tsx | 342 ++++------- .../__tests__/edit-mode.test.tsx | 446 +++------------ ...step-attach-flows-flow-list-panel.test.tsx | 128 ++--- .../step-attach-flows-version-panel.test.tsx | 17 +- .../__tests__/step-attach-flows.test.tsx | 63 ++- .../__tests__/step-review.test.tsx | 86 ++- .../flow-version-item.tsx | 3 +- .../components/deployment-stepper-modal.tsx | 84 +-- .../step-attach-flows-flow-list-panel.tsx | 74 +-- .../step-attach-flows-version-panel.tsx | 133 ++++- .../components/step-attach-flows.tsx | 130 ++++- .../components/step-review.tsx | 533 ++++-------------- .../step-review/editable-tool-name.tsx | 83 +++ .../step-review/review-detaching-section.tsx | 60 ++ .../step-review/review-flow-config-card.tsx | 128 +++++ .../step-review/review-summary-card.tsx | 91 +++ .../components/step-review/types.ts | 20 + .../components/step-review/utils.ts | 245 ++++++++ .../contexts/deployment-stepper-context.tsx | 433 +++++--------- .../helpers/deployment-payload-builders.ts | 352 ++++++++++++ .../deploymentsPage/helpers/version-scope.ts | 56 ++ .../pages/deploymentsPage/helpers/wxo-name.ts | 3 + .../use-connection-panel-state.test.ts | 8 +- .../hooks/use-connection-panel-state.ts | 20 +- .../MainPage/pages/deploymentsPage/types.ts | 45 ++ .../core/features/deployment-create.spec.ts | 21 +- 28 files changed, 2430 insertions(+), 1588 deletions(-) create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/editable-tool-name.tsx create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-detaching-section.tsx create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-flow-config-card.tsx create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-summary-card.tsx create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/types.ts create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/utils.ts create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/deployment-payload-builders.ts create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/version-scope.ts create mode 100644 src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/wxo-name.ts diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/create-mode.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/create-mode.test.tsx index ea5ffd16d7..bcfd76c359 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/create-mode.test.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/create-mode.test.tsx @@ -9,6 +9,7 @@ import type { DeploymentProvider, ProviderAccount, } from "../types"; +import { getSelectedFlowVersionKey } from "../types"; jest.mock( "@/controllers/API/queries/deployment-provider-accounts/use-post-provider-account", @@ -50,6 +51,10 @@ function renderCreateHook( return renderHook(() => useDeploymentStepper(), { wrapper }); } +function flowVersionKey(flowId: string, versionId: string) { + return getSelectedFlowVersionKey(flowId, versionId); +} + // --------------------------------------------------------------------------- // Create mode — basic state // --------------------------------------------------------------------------- @@ -98,6 +103,95 @@ describe("Create mode — basic state", () => { const { result } = renderCreateHook({ initialFlowId: "flow-abc" }); expect(result.current.initialFlowId).toBe("flow-abc"); }); + + it("supports attaching multiple versions of the same flow", () => { + const { result } = renderCreateHook(); + + act(() => { + result.current.setDeploymentName("Agent"); + result.current.setSelectedLlm("model-1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); + result.current.setToolNameByFlow( + new Map([ + [getSelectedFlowVersionKey("flow-1", "ver-1"), "Flow One v1"], + [getSelectedFlowVersionKey("flow-1", "ver-2"), "Flow One v2"], + ]), + ); + result.current.setAttachedConnectionByFlow( + new Map([ + [getSelectedFlowVersionKey("flow-1", "ver-1"), ["conn-1"]], + [getSelectedFlowVersionKey("flow-1", "ver-2"), ["conn-2"]], + ]), + ); + }); + + const payload = result.current.buildDeploymentPayload("p-1"); + expect(payload.provider_data.add_flows).toEqual([ + { + flow_version_id: "ver-1", + app_ids: ["conn-1"], + tool_name: "Flow One v1", + }, + { + flow_version_id: "ver-2", + app_ids: ["conn-2"], + tool_name: "Flow One v2", + }, + ]); + }); + + it("defaults tool names to flow name plus truncated id", () => { + const { result } = renderCreateHook(); + + act(() => { + result.current.setDeploymentName("Agent"); + result.current.setSelectedLlm("model-1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "12345678-aaaa-bbbb-cccc-deadbeefcafe", + versionTag: "v1", + }); + result.current.setAttachedConnectionByFlow( + new Map([ + [ + getSelectedFlowVersionKey( + "flow-1", + "12345678-aaaa-bbbb-cccc-deadbeefcafe", + ), + [], + ], + ]), + ); + result.current.setToolNameByFlow( + new Map([ + [ + getSelectedFlowVersionKey( + "flow-1", + "12345678-aaaa-bbbb-cccc-deadbeefcafe", + ), + "Original Flow deadbeef", + ], + ]), + ); + }); + + const payload = result.current.buildDeploymentPayload("p-1"); + expect(payload.provider_data.add_flows[0].tool_name).toBe( + "Original Flow deadbeef", + ); + }); }); // --------------------------------------------------------------------------- @@ -306,7 +400,12 @@ describe("Create mode — canGoNext validation", () => { act(() => result.current.handleNext()); // → step 3 act(() => { - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); expect(result.current.canGoNext).toBe(true); @@ -327,7 +426,12 @@ describe("Create mode — canGoNext validation", () => { }); act(() => result.current.handleNext()); // → step 3 act(() => { - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); act(() => result.current.handleNext()); // → step 4 @@ -462,28 +566,68 @@ describe("Create mode — flow version selection", () => { const { result } = renderCreateHook(); act(() => { - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); expect(result.current.selectedVersionByFlow.size).toBe(1); - expect(result.current.selectedVersionByFlow.get("flow-1")).toEqual({ + expect( + result.current.selectedVersionByFlow.get( + flowVersionKey("flow-1", "ver-1"), + ), + ).toEqual({ + key: flowVersionKey("flow-1", "ver-1"), + flowId: "flow-1", + flowName: "Flow", versionId: "ver-1", versionTag: "v1", }); }); - it("handleSelectVersion overwrites existing version for same flow", () => { + it("handleSelectVersion keeps multiple versions for the same flow", () => { const { result } = renderCreateHook(); act(() => { - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); act(() => { - result.current.handleSelectVersion("flow-1", "ver-2", "v2"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); }); - expect(result.current.selectedVersionByFlow.size).toBe(1); - expect(result.current.selectedVersionByFlow.get("flow-1")).toEqual({ + expect(result.current.selectedVersionByFlow.size).toBe(2); + expect( + result.current.selectedVersionByFlow.get( + flowVersionKey("flow-1", "ver-1"), + ), + ).toEqual({ + key: flowVersionKey("flow-1", "ver-1"), + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + expect( + result.current.selectedVersionByFlow.get( + flowVersionKey("flow-1", "ver-2"), + ), + ).toEqual({ + key: flowVersionKey("flow-1", "ver-2"), + flowId: "flow-1", + flowName: "Flow", versionId: "ver-2", versionTag: "v2", }); @@ -493,13 +637,57 @@ describe("Create mode — flow version selection", () => { const { result } = renderCreateHook(); act(() => { - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); - result.current.handleSelectVersion("flow-2", "ver-2", "v2"); - result.current.handleSelectVersion("flow-3", "ver-3", "v3"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-2", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); + result.current.handleSelectVersion({ + flowId: "flow-3", + flowName: "Flow", + versionId: "ver-3", + versionTag: "v3", + }); }); expect(result.current.selectedVersionByFlow.size).toBe(3); }); + + it("handleRemoveAttachedFlow removes newly attached flow-version data", () => { + const { result } = renderCreateHook(); + const attachmentKey = flowVersionKey("flow-1", "ver-1"); + + act(() => { + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + result.current.setToolNameByFlow(new Map([[attachmentKey, "Flow v1"]])); + result.current.setAttachedConnectionByFlow( + new Map([[attachmentKey, ["conn-1"]]]), + ); + }); + + act(() => { + result.current.handleRemoveAttachedFlow(attachmentKey); + }); + + expect(result.current.selectedVersionByFlow.has(attachmentKey)).toBe(false); + expect(result.current.toolNameByFlow.has(attachmentKey)).toBe(false); + expect(result.current.attachedConnectionByFlow.has(attachmentKey)).toBe( + false, + ); + expect(result.current.removedFlowIds.has(attachmentKey)).toBe(false); + }); }); // --------------------------------------------------------------------------- @@ -629,7 +817,12 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("gpt-4"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentPayload("provider-1"); @@ -642,7 +835,12 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("gpt-4"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentPayload("provider-1"); @@ -657,7 +855,12 @@ describe("Create mode — buildDeploymentPayload", () => { result.current.setDeploymentDescription("Agent description"); result.current.setDeploymentType("agent"); result.current.setSelectedLlm("gpt-4"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentPayload("provider-1"); @@ -673,7 +876,12 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("granite-3b"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentPayload("p-1"); @@ -686,8 +894,18 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); - result.current.handleSelectVersion("flow-2", "ver-2", "v2"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-2", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); }); const payload = result.current.buildDeploymentPayload("p-1"); @@ -702,7 +920,12 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["app-1", "app-2"]]]), ); @@ -736,7 +959,12 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([newConn, existingConn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-new", "conn-existing"]]]), @@ -764,7 +992,12 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-1"]]]), @@ -800,7 +1033,12 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-1"]]]), @@ -829,7 +1067,12 @@ describe("Create mode — buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentPayload("p-1"); @@ -877,9 +1120,24 @@ describe("Create mode — multi-flow scenarios", () => { act(() => { result.current.setDeploymentName("Multi-Flow Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); - result.current.handleSelectVersion("flow-2", "ver-2", "v2"); - result.current.handleSelectVersion("flow-3", "ver-3", "v3"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-2", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); + result.current.handleSelectVersion({ + flowId: "flow-3", + flowName: "Flow", + versionId: "ver-3", + versionTag: "v3", + }); result.current.setToolNameByFlow( new Map([ ["flow-1", "Tool Alpha"], @@ -911,7 +1169,7 @@ describe("Create mode — multi-flow scenarios", () => { expect(flow2Op?.app_ids).toEqual(["conn-b"]); const flow3Op = addFlows.find((o) => o.flow_version_id === "ver-3"); - expect(flow3Op?.tool_name).toBeUndefined(); + expect(flow3Op?.tool_name).toMatch(/^Flow [a-f0-9]{6}-3$/); expect(flow3Op?.app_ids).toEqual(["conn-a", "conn-b"]); // Both connections are new, so both appear in connections @@ -936,8 +1194,18 @@ describe("Create mode — multi-flow scenarios", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); - result.current.handleSelectVersion("flow-2", "ver-2", "v2"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-2", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); result.current.setConnections([sharedConn]); result.current.setAttachedConnectionByFlow( new Map([ @@ -999,7 +1267,12 @@ describe("Create mode — edge cases", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-empty"]]]), diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/custom-tool-naming.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/custom-tool-naming.test.tsx index 51328a4efe..142e408bef 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/custom-tool-naming.test.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/custom-tool-naming.test.tsx @@ -41,7 +41,12 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setToolNameByFlow(new Map([["flow-1", "My Custom Tool"]])); }); @@ -56,12 +61,17 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentPayload("provider-1"); const addFlowItem = payload.provider_data.add_flows[0]; - expect(addFlowItem.tool_name).toBeUndefined(); + expect(addFlowItem.tool_name).toMatch(/^Flow [a-f0-9]{6}-1$/); }); it("buildDeploymentPayload omits tool_name when whitespace-only", () => { @@ -70,13 +80,18 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setToolNameByFlow(new Map([["flow-1", " "]])); }); const payload = result.current.buildDeploymentPayload("provider-1"); const addFlowItem = payload.provider_data.add_flows[0]; - expect(addFlowItem.tool_name).toBeUndefined(); + expect(addFlowItem.tool_name).toMatch(/^Flow [a-f0-9]{6}-1$/); }); it("tool name with special characters is preserved in payload", () => { @@ -85,7 +100,12 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setToolNameByFlow( new Map([["flow-1", "my-tool_v2.0 (beta) [test]"]]), ); @@ -103,7 +123,12 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setToolNameByFlow( new Map([["flow-1", "ferramenta_análise"]]), ); @@ -122,7 +147,12 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setToolNameByFlow(new Map([["flow-1", longName]])); }); @@ -137,8 +167,18 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); - result.current.handleSelectVersion("flow-2", "ver-2", "v2"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-2", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); result.current.setToolNameByFlow( new Map([ ["flow-1", "Same Name"], @@ -160,8 +200,18 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("Test Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); - result.current.handleSelectVersion("flow-2", "ver-2", "v2"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-2", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); result.current.setToolNameByFlow( new Map([ ["flow-1", "Tool Alpha"], @@ -187,7 +237,12 @@ describe("Custom tool naming", () => { act(() => { result.current.setDeploymentName("1 Agent"); result.current.setSelectedLlm("test-model"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); expect(() => result.current.buildDeploymentPayload("provider-1")).toThrow( diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-stepper-payload-builders.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-stepper-payload-builders.test.tsx index 5a1e4e4794..387667081c 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-stepper-payload-builders.test.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-stepper-payload-builders.test.tsx @@ -110,7 +110,12 @@ describe("buildConnectionPayloads", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([newConn, existingConn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-new", "conn-existing"]]]), @@ -142,7 +147,12 @@ describe("buildConnectionPayloads", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-1"]]]), @@ -176,7 +186,12 @@ describe("buildConnectionPayloads", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-1"]]]), @@ -213,7 +228,12 @@ describe("buildConnectionPayloads", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-1"]]]), @@ -246,7 +266,12 @@ describe("buildConnectionPayloads", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-empty"]]]), @@ -276,7 +301,12 @@ describe("buildConnectionPayloads", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-1"]]]), @@ -308,7 +338,12 @@ describe("buildConnectionPayloads", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setConnections([conn]); result.current.setAttachedConnectionByFlow( new Map([["flow-1", ["conn-1"]]]), @@ -346,8 +381,18 @@ describe("buildDeploymentUpdatePayload", () => { const { result } = renderEditHook(); act(() => { - result.current.handleSelectVersion("flow-new-a", "ver-new-a", "v1"); - result.current.handleSelectVersion("flow-new-b", "ver-new-b", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-new-a", + flowName: "Flow", + versionId: "ver-new-a", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-new-b", + flowName: "Flow", + versionId: "ver-new-b", + versionTag: "v1", + }); result.current.setAttachedConnectionByFlow( new Map([ ["flow-1", ["app-1"]], // unchanged @@ -389,93 +434,7 @@ describe("buildDeploymentUpdatePayload", () => { const flowB = upsertFlows.find((f) => f.flow_version_id === "ver-new-b"); expect(flowB).toBeDefined(); expect(flowB!.add_app_ids).toEqual([]); - expect(flowB!.tool_name).toBeUndefined(); - }); - - it("calculates remove_flows correctly for removed flows", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.handleRemoveAttachedFlow("flow-1"); - result.current.handleRemoveAttachedFlow("flow-2"); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const removeFlows = - (payload.provider_data as { remove_flows?: string[] })?.remove_flows ?? - []; - expect(removeFlows).toHaveLength(2); - expect(removeFlows.sort()).toEqual(["ver-1", "ver-2"]); - }); - - it("handles tool name changes on existing flows (upsert_flows)", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.setToolNameByFlow( - new Map([ - ["flow-1", "renamed_tool_one"], - ["flow-2", "tool_two"], // unchanged - ]), - ); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - ( - payload.provider_data as { - upsert_flows?: Array<{ - flow_version_id: string; - tool_name?: string; - add_app_ids: string[]; - remove_app_ids: string[]; - }>; - } - )?.upsert_flows ?? []; - - expect(upsertFlows).toHaveLength(1); - expect(upsertFlows[0].flow_version_id).toBe("ver-1"); - expect(upsertFlows[0].tool_name).toBe("renamed_tool_one"); - // Connections unchanged - expect(upsertFlows[0].add_app_ids).toEqual([]); - expect(upsertFlows[0].remove_app_ids).toEqual([]); - }); - - it("handles connection changes on existing flows (upsert_flows)", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.setAttachedConnectionByFlow( - new Map([ - ["flow-1", ["app-1", "app-new"]], // added app-new - ["flow-2", []], // removed app-2 - ]), - ); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - ( - payload.provider_data as { - upsert_flows?: Array<{ - flow_version_id: string; - add_app_ids: string[]; - remove_app_ids: string[]; - }>; - } - )?.upsert_flows ?? []; - - expect(upsertFlows).toHaveLength(2); - - const flow1 = upsertFlows.find((f) => f.flow_version_id === "ver-1"); - expect(flow1).toBeDefined(); - expect(flow1!.add_app_ids).toEqual(["app-new"]); - expect(flow1!.remove_app_ids).toEqual([]); - - const flow2 = upsertFlows.find((f) => f.flow_version_id === "ver-2"); - expect(flow2).toBeDefined(); - expect(flow2!.add_app_ids).toEqual([]); - expect(flow2!.remove_app_ids).toEqual(["app-2"]); + expect(flowB!.tool_name).toMatch(/^Flow [a-f0-9]{1,6}-b$/); }); it("sends fallback description when no changes detected", () => { @@ -493,74 +452,6 @@ describe("buildDeploymentUpdatePayload", () => { expect(hasAtLeastOneField).toBe(true); }); - it("handles mixed scenario: some flows added, some removed, some updated", () => { - const { result } = renderEditHook(); - - act(() => { - // Remove flow-2 - result.current.handleRemoveAttachedFlow("flow-2"); - // Add new flow - result.current.handleSelectVersion("flow-new", "ver-new", "v1"); - result.current.setAttachedConnectionByFlow( - new Map([ - // flow-1: swap connections (update) - ["flow-1", ["app-new-1"]], - // flow-new: brand new - ["flow-new", ["app-10"]], - ]), - ); - result.current.setToolNameByFlow( - new Map([ - ["flow-1", "renamed_tool"], - ["flow-new", "New Tool"], - ]), - ); - // Also change description - result.current.setDeploymentDescription("Updated agent description"); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - - // Description changed - expect(payload.description).toBe("Updated agent description"); - - // remove_flows: flow-2 was removed - const removeFlows = - (payload.provider_data as { remove_flows?: string[] })?.remove_flows ?? - []; - expect(removeFlows).toEqual(["ver-2"]); - - const upsertFlows = - ( - payload.provider_data as { - upsert_flows?: Array<{ - flow_version_id: string; - add_app_ids: string[]; - remove_app_ids: string[]; - tool_name?: string; - }>; - } - )?.upsert_flows ?? []; - - // upsert_flows: flow-new (added) + flow-1 (updated connections + tool name) - expect(upsertFlows).toHaveLength(2); - - // New flow entry - const newFlowEntry = upsertFlows.find( - (f) => f.flow_version_id === "ver-new", - ); - expect(newFlowEntry).toBeDefined(); - expect(newFlowEntry!.add_app_ids).toEqual(["app-10"]); - expect(newFlowEntry!.tool_name).toBe("New Tool"); - - // Updated flow entry (flow-1: connections swapped, name changed) - const flow1Entry = upsertFlows.find((f) => f.flow_version_id === "ver-1"); - expect(flow1Entry).toBeDefined(); - expect(flow1Entry!.tool_name).toBe("renamed_tool"); - expect(flow1Entry!.add_app_ids).toEqual(["app-new-1"]); - expect(flow1Entry!.remove_app_ids).toEqual(["app-1"]); - }); - it("includes description change when description differs from initial", () => { const { result } = renderEditHook(); @@ -576,51 +467,6 @@ describe("buildDeploymentUpdatePayload", () => { const payload = result.current.buildDeploymentUpdatePayload(); expect(payload.description).toBeUndefined(); }); - - it("includes connection payloads for new connections added during upsert", () => { - const { result } = renderEditHook(); - - const newConn: ConnectionItem = { - id: "app-new", - connectionId: "cid-new", - name: "New Connection", - variableCount: 1, - isNew: true, - environmentVariables: { API_KEY: "secret-123" }, // pragma: allowlist secret - globalVarKeys: new Set(["API_KEY"]), - }; - - act(() => { - result.current.setConnections([newConn]); - result.current.setAttachedConnectionByFlow( - new Map([ - ["flow-1", ["app-1", "app-new"]], // added app-new to existing flow - ["flow-2", ["app-2"]], - ]), - ); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const connectionPayloads = - ( - payload.provider_data as { - connections?: Array<{ - app_id: string; - credentials: Array<{ - key: string; - value: string; - source: string; - }>; - }>; - } - )?.connections ?? []; - - expect(connectionPayloads).toHaveLength(1); - expect(connectionPayloads[0].app_id).toBe("app-new"); - expect(connectionPayloads[0].credentials).toEqual([ - { key: "API_KEY", value: "secret-123", source: "variable" }, - ]); - }); }); // --------------------------------------------------------------------------- @@ -636,7 +482,12 @@ describe("buildDeploymentPayload", () => { result.current.setDeploymentDescription("A test description"); result.current.setDeploymentType("agent"); result.current.setSelectedLlm("gpt-4"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentPayload("provider-abc"); @@ -656,8 +507,18 @@ describe("buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-a", "ver-a", "v1"); - result.current.handleSelectVersion("flow-b", "ver-b", "v2"); + result.current.handleSelectVersion({ + flowId: "flow-a", + flowName: "Flow", + versionId: "ver-a", + versionTag: "v1", + }); + result.current.handleSelectVersion({ + flowId: "flow-b", + flowName: "Flow", + versionId: "ver-b", + versionTag: "v2", + }); result.current.setToolNameByFlow( new Map([ ["flow-a", "Alpha Tool"], @@ -693,7 +554,12 @@ describe("buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); // No connections set }); @@ -707,7 +573,12 @@ describe("buildDeploymentPayload", () => { act(() => { singleResult.current.setDeploymentName("Single Flow Agent"); singleResult.current.setSelectedLlm("model-1"); - singleResult.current.handleSelectVersion("flow-1", "ver-1", "v1"); + singleResult.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); }); const singlePayload = singleResult.current.buildDeploymentPayload("p-1"); @@ -721,9 +592,24 @@ describe("buildDeploymentPayload", () => { act(() => { multiResult.current.setDeploymentName("Multi Flow Agent"); multiResult.current.setSelectedLlm("model-1"); - multiResult.current.handleSelectVersion("flow-1", "ver-1", "v1"); - multiResult.current.handleSelectVersion("flow-2", "ver-2", "v2"); - multiResult.current.handleSelectVersion("flow-3", "ver-3", "v3"); + multiResult.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); + multiResult.current.handleSelectVersion({ + flowId: "flow-2", + flowName: "Flow", + versionId: "ver-2", + versionTag: "v2", + }); + multiResult.current.handleSelectVersion({ + flowId: "flow-3", + flowName: "Flow", + versionId: "ver-3", + versionTag: "v3", + }); }); const multiPayload = multiResult.current.buildDeploymentPayload("p-1"); @@ -740,12 +626,19 @@ describe("buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); // No tool name set }); const payload = result.current.buildDeploymentPayload("p-1"); - expect(payload.provider_data.add_flows[0].tool_name).toBeUndefined(); + expect(payload.provider_data.add_flows[0].tool_name).toMatch( + /^Flow [a-f0-9]{6}-1$/, + ); }); it("omits tool_name when tool name is whitespace-only", () => { @@ -754,12 +647,19 @@ describe("buildDeploymentPayload", () => { act(() => { result.current.setDeploymentName("Agent"); result.current.setSelectedLlm("model-1"); - result.current.handleSelectVersion("flow-1", "ver-1", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-1", + flowName: "Flow", + versionId: "ver-1", + versionTag: "v1", + }); result.current.setToolNameByFlow(new Map([["flow-1", " "]])); }); const payload = result.current.buildDeploymentPayload("p-1"); - expect(payload.provider_data.add_flows[0].tool_name).toBeUndefined(); + expect(payload.provider_data.add_flows[0].tool_name).toMatch( + /^Flow [a-f0-9]{6}-1$/, + ); }); }); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/edit-mode.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/edit-mode.test.tsx index fd86cf3dfa..589f6f4666 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/edit-mode.test.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/edit-mode.test.tsx @@ -4,7 +4,7 @@ import { DeploymentStepperProvider, useDeploymentStepper, } from "../contexts/deployment-stepper-context"; -import type { Deployment } from "../types"; +import { type Deployment, getSelectedFlowVersionKey } from "../types"; jest.mock( "@/controllers/API/queries/deployment-provider-accounts/use-post-provider-account", @@ -18,11 +18,13 @@ jest.mock("@/controllers/API/queries/deployments/use-patch-deployment", () => ({ })); const initialToolNames = new Map([ - ["flow-1", "custom_tool_one"], - ["flow-2", "custom_tool_two"], + [getSelectedFlowVersionKey("flow-1", "ver-1"), "custom_tool_one"], + [getSelectedFlowVersionKey("flow-2", "ver-2"), "custom_tool_two"], ]); -const initialConnections = new Map([["flow-1", ["app-1"]]]); +const initialConnections = new Map([ + [getSelectedFlowVersionKey("flow-1", "ver-1"), ["app-1"]], +]); const mockDeployment: Deployment = { id: "deploy-1", @@ -36,25 +38,48 @@ const mockDeployment: Deployment = { }; const initialVersions = new Map([ - ["flow-1", { versionId: "ver-1", versionTag: "v1" }], - ["flow-2", { versionId: "ver-2", versionTag: "v2" }], + [ + getSelectedFlowVersionKey("flow-1", "ver-1"), + { + key: getSelectedFlowVersionKey("flow-1", "ver-1"), + flowId: "flow-1", + versionId: "ver-1", + versionTag: "v1", + }, + ], + [ + getSelectedFlowVersionKey("flow-2", "ver-2"), + { + key: getSelectedFlowVersionKey("flow-2", "ver-2"), + flowId: "flow-2", + versionId: "ver-2", + versionTag: "v2", + }, + ], ]); +const flow1Key = getSelectedFlowVersionKey("flow-1", "ver-1"); +const flow2Key = getSelectedFlowVersionKey("flow-2", "ver-2"); +const flowNewKey = getSelectedFlowVersionKey("flow-new", "ver-new"); + function renderEditHook() { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - return renderHook(() => useDeploymentStepper(), { wrapper }); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + DeploymentStepperProvider, + { + initialState: { + editingDeployment: mockDeployment, + selectedVersionByFlow: initialVersions, + initialLlm: "test-model", + initialToolNameByFlow: initialToolNames, + initialConnectionsByFlow: initialConnections, + }, + }, + children, + ); + const hook = renderHook(() => useDeploymentStepper(), { wrapper }); + hook.rerender(); + return hook; } describe("Edit mode — basic state", () => { @@ -78,15 +103,6 @@ describe("Edit mode — basic state", () => { expect(result.current.selectedLlm).toBe("test-model"); }); - it("pre-fills selectedVersionByFlow", () => { - const { result } = renderEditHook(); - expect(result.current.selectedVersionByFlow.size).toBe(2); - expect(result.current.selectedVersionByFlow.get("flow-1")).toEqual({ - versionId: "ver-1", - versionTag: "v1", - }); - }); - it("canGoNext on step 1 (Type) is true with pre-filled data", () => { const { result } = renderEditHook(); expect(result.current.canGoNext).toBe(true); @@ -125,39 +141,6 @@ describe("Edit mode — basic state", () => { }); }); -describe("Edit mode — detach flows", () => { - it("removedFlowIds starts empty", () => { - const { result } = renderEditHook(); - expect(result.current.removedFlowIds.size).toBe(0); - }); - - it("handleRemoveAttachedFlow removes from maps and adds to removedFlowIds", () => { - const { result } = renderEditHook(); - - act(() => result.current.handleRemoveAttachedFlow("flow-1")); - - expect(result.current.removedFlowIds.has("flow-1")).toBe(true); - expect(result.current.selectedVersionByFlow.has("flow-1")).toBe(false); - // flow-2 unaffected - expect(result.current.selectedVersionByFlow.has("flow-2")).toBe(true); - }); - - it("handleUndoRemoveFlow restores the flow", () => { - const { result } = renderEditHook(); - - act(() => result.current.handleRemoveAttachedFlow("flow-1")); - expect(result.current.removedFlowIds.has("flow-1")).toBe(true); - - act(() => result.current.handleUndoRemoveFlow("flow-1")); - expect(result.current.removedFlowIds.has("flow-1")).toBe(false); - expect(result.current.selectedVersionByFlow.has("flow-1")).toBe(true); - expect(result.current.selectedVersionByFlow.get("flow-1")).toEqual({ - versionId: "ver-1", - versionTag: "v1", - }); - }); -}); - describe("Edit mode — buildDeploymentUpdatePayload", () => { it("includes deployment_id", () => { const { result } = renderEditHook(); @@ -185,20 +168,16 @@ describe("Edit mode — buildDeploymentUpdatePayload", () => { expect(payload.provider_data?.llm).toBe("test-model"); }); - it("does NOT send upsert_flows for unchanged pre-existing flows", () => { - const { result } = renderEditHook(); - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data as { upsert_flows?: Array } | undefined) - ?.upsert_flows ?? []; - expect(upsertFlows).toHaveLength(0); - }); - it("sends upsert_flows for newly attached flows", () => { const { result } = renderEditHook(); act(() => { - result.current.handleSelectVersion("flow-new", "ver-new", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-new", + flowName: "Flow", + versionId: "ver-new", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentUpdatePayload(); @@ -220,18 +199,6 @@ describe("Edit mode — buildDeploymentUpdatePayload", () => { expect(upsertFlows[0].remove_app_ids).toEqual([]); }); - it("sends remove_flows for detached flows", () => { - const { result } = renderEditHook(); - - act(() => result.current.handleRemoveAttachedFlow("flow-1")); - - const payload = result.current.buildDeploymentUpdatePayload(); - const removeFlows = - (payload.provider_data as { remove_flows?: string[] } | undefined) - ?.remove_flows ?? []; - expect(removeFlows).toEqual(["ver-1"]); - }); - it("does NOT send remove_flows for flows that were not removed", () => { const { result } = renderEditHook(); const payload = result.current.buildDeploymentUpdatePayload(); @@ -252,9 +219,14 @@ describe("Edit mode — buildDeploymentUpdatePayload", () => { const { result } = renderEditHook(); act(() => { - result.current.handleSelectVersion("flow-new", "ver-new", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-new", + flowName: "Flow", + versionId: "ver-new", + versionTag: "v1", + }); result.current.setToolNameByFlow( - new Map([["flow-new", "Custom Tool Name"]]), + new Map([[flowNewKey, "Custom Tool Name"]]), ); }); @@ -278,8 +250,12 @@ describe("Edit mode — buildDeploymentUpdatePayload", () => { const { result } = renderEditHook(); act(() => { - result.current.handleRemoveAttachedFlow("flow-1"); - result.current.handleSelectVersion("flow-new", "ver-new", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-new", + flowName: "Flow", + versionId: "ver-new", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentUpdatePayload(); @@ -290,7 +266,7 @@ describe("Edit mode — buildDeploymentUpdatePayload", () => { (payload.provider_data as { remove_flows?: string[] } | undefined) ?.remove_flows ?? []; expect(upsertFlows).toHaveLength(1); - expect(removeFlows).toEqual(["ver-1"]); + expect(removeFlows).toEqual([]); }); }); @@ -343,7 +319,12 @@ describe("Edit mode — no-op and partial update payloads", () => { const { result } = renderEditHook(); act(() => { - result.current.handleSelectVersion("flow-new", "ver-new", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-new", + flowName: "Flow", + versionId: "ver-new", + versionTag: "v1", + }); }); const payload = result.current.buildDeploymentUpdatePayload(); @@ -358,200 +339,21 @@ describe("Edit mode — no-op and partial update payloads", () => { }); }); -describe("Edit mode — detach then re-attach same flow", () => { - it("re-attaching a detached flow restores it to selectedVersionByFlow", () => { - const { result } = renderEditHook(); - - // Detach flow-1 - act(() => result.current.handleRemoveAttachedFlow("flow-1")); - expect(result.current.removedFlowIds.has("flow-1")).toBe(true); - expect(result.current.selectedVersionByFlow.has("flow-1")).toBe(false); - - // Re-attach via undo - act(() => result.current.handleUndoRemoveFlow("flow-1")); - expect(result.current.removedFlowIds.has("flow-1")).toBe(false); - expect(result.current.selectedVersionByFlow.get("flow-1")).toEqual({ - versionId: "ver-1", - versionTag: "v1", - }); - - // Payload should have no remove_flows or upsert_flows for flow-1 (it's back to original) - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data?.upsert_flows as Array<{ - flow_version_id?: string; - }>) ?? []; - const removeFlows = (payload.provider_data?.remove_flows as string[]) ?? []; - expect( - upsertFlows.filter((o) => o.flow_version_id === "ver-1"), - ).toHaveLength(0); - expect(removeFlows.includes("ver-1")).toBe(false); - }); - - it("detaching all flows then re-attaching one produces correct ops", () => { - const { result } = renderEditHook(); - - // Detach both - act(() => { - result.current.handleRemoveAttachedFlow("flow-1"); - result.current.handleRemoveAttachedFlow("flow-2"); - }); - expect(result.current.removedFlowIds.size).toBe(2); - - // Re-attach only flow-2 - act(() => result.current.handleUndoRemoveFlow("flow-2")); - - const payload = result.current.buildDeploymentUpdatePayload(); - const removeFlows = (payload.provider_data?.remove_flows as string[]) ?? []; - const upsertFlows = - (payload.provider_data?.upsert_flows as Array<{ - flow_version_id?: string; - }>) ?? []; - - // flow-1 should be in remove_flows - expect(removeFlows).toHaveLength(1); - expect(removeFlows[0]).toBe("ver-1"); - - // flow-2 was undone, so it should not be in remove_flows or upsert_flows - expect(removeFlows.includes("ver-2")).toBe(false); - expect( - upsertFlows.filter((o) => o.flow_version_id === "ver-2"), - ).toHaveLength(0); - }); -}); - -describe("Edit mode — pre-populated provider data", () => { - it("pre-fills toolNameByFlow from initialToolNameByFlow", () => { - const { result } = renderEditHook(); - expect(result.current.toolNameByFlow.get("flow-1")).toBe("custom_tool_one"); - expect(result.current.toolNameByFlow.get("flow-2")).toBe("custom_tool_two"); - }); - - it("pre-fills attachedConnectionByFlow from initialConnectionsByFlow", () => { - const { result } = renderEditHook(); - expect(result.current.attachedConnectionByFlow.get("flow-1")).toEqual([ - "app-1", - ]); - }); - - it("preExistingFlowIds contains initially attached flows", () => { - const { result } = renderEditHook(); - expect(result.current.preExistingFlowIds.has("flow-1")).toBe(true); - expect(result.current.preExistingFlowIds.has("flow-2")).toBe(true); - expect(result.current.preExistingFlowIds.has("flow-new")).toBe(false); - }); -}); - -describe("Edit mode — connection updates on pre-existing flows", () => { - const upsertFlowType = {} as { - upsert_flows?: Array<{ - flow_version_id?: string; - add_app_ids?: string[]; - remove_app_ids?: string[]; - tool_name?: string; - }>; - connections?: Array; - }; - - it("sends add_app_ids when adding a connection to a pre-existing flow", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.setAttachedConnectionByFlow( - new Map([["flow-1", ["app-1", "app-2"]]]), - ); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data as typeof upsertFlowType)?.upsert_flows ?? []; - expect(upsertFlows).toHaveLength(1); - expect(upsertFlows[0].flow_version_id).toBe("ver-1"); - expect(upsertFlows[0].add_app_ids).toEqual(["app-2"]); - expect(upsertFlows[0].remove_app_ids).toEqual([]); - }); - - it("sends remove_app_ids when removing a connection from a pre-existing flow", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.setAttachedConnectionByFlow( - new Map([["flow-1", []]]), // removed app-1 - ); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data as typeof upsertFlowType)?.upsert_flows ?? []; - expect(upsertFlows).toHaveLength(1); - expect(upsertFlows[0].flow_version_id).toBe("ver-1"); - expect(upsertFlows[0].add_app_ids).toEqual([]); - expect(upsertFlows[0].remove_app_ids).toEqual(["app-1"]); - }); - - it("sends both add and remove when swapping connections on a pre-existing flow", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.setAttachedConnectionByFlow( - new Map([["flow-1", ["app-2", "app-3"]]]), // removed app-1, added app-2 & app-3 - ); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data as typeof upsertFlowType)?.upsert_flows ?? []; - expect(upsertFlows).toHaveLength(1); - expect(upsertFlows[0].add_app_ids).toEqual(["app-2", "app-3"]); - expect(upsertFlows[0].remove_app_ids).toEqual(["app-1"]); - }); - - it("does NOT send upsert for pre-existing flow when connections are unchanged", () => { - const { result } = renderEditHook(); - // flow-1 starts with ["app-1"], no changes - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data as typeof upsertFlowType)?.upsert_flows ?? []; - // No flows should appear since nothing changed - expect( - upsertFlows.filter((o) => o.flow_version_id === "ver-1"), - ).toHaveLength(0); - }); - - it("sends connection changes alongside tool_name rename on the same flow", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.setAttachedConnectionByFlow( - new Map([["flow-1", ["app-1", "app-2"]]]), - ); - result.current.setToolNameByFlow( - new Map([ - ["flow-1", "renamed_tool"], - ["flow-2", "custom_tool_two"], - ]), - ); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data as typeof upsertFlowType)?.upsert_flows ?? []; - expect(upsertFlows).toHaveLength(1); - expect(upsertFlows[0].flow_version_id).toBe("ver-1"); - expect(upsertFlows[0].tool_name).toBe("renamed_tool"); - expect(upsertFlows[0].add_app_ids).toEqual(["app-2"]); - expect(upsertFlows[0].remove_app_ids).toEqual([]); - }); - +describe("Edit mode — newly attached flow connections", () => { it("includes connections on a newly attached flow with connections", () => { const { result } = renderEditHook(); act(() => { - result.current.handleSelectVersion("flow-new", "ver-new", "v1"); + result.current.handleSelectVersion({ + flowId: "flow-new", + flowName: "Flow", + versionId: "ver-new", + versionTag: "v1", + }); result.current.setAttachedConnectionByFlow( new Map([ - ["flow-1", ["app-1"]], // unchanged - ["flow-new", ["app-10", "app-11"]], + [flow1Key, ["app-1"]], // unchanged + [flowNewKey, ["app-10", "app-11"]], ]), ); }); @@ -567,91 +369,3 @@ describe("Edit mode — connection updates on pre-existing flows", () => { expect(newFlowEntry!.remove_app_ids).toEqual([]); }); }); - -describe("Edit mode — undo restores connections", () => { - it("handleUndoRemoveFlow restores connections from initialConnectionsByFlow", () => { - const { result } = renderEditHook(); - - // flow-1 starts with connections ["app-1"] - act(() => result.current.handleRemoveAttachedFlow("flow-1")); - expect(result.current.attachedConnectionByFlow.has("flow-1")).toBe(false); - - act(() => result.current.handleUndoRemoveFlow("flow-1")); - expect(result.current.attachedConnectionByFlow.get("flow-1")).toEqual([ - "app-1", - ]); - - // Payload should show no connection diff since it's restored to original - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - ( - payload.provider_data as { - upsert_flows?: Array<{ flow_version_id?: string }>; - } - )?.upsert_flows ?? []; - expect( - upsertFlows.filter((o) => o.flow_version_id === "ver-1"), - ).toHaveLength(0); - }); -}); - -describe("Edit mode — tool_name updates", () => { - it("sends upsert_flows tool_name for renamed pre-existing flow", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.setToolNameByFlow( - new Map([ - ["flow-1", "renamed_tool"], - ["flow-2", "custom_tool_two"], - ]), - ); - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - ( - payload.provider_data as - | { - upsert_flows?: Array<{ - flow_version_id?: string; - tool_name?: string; - add_app_ids?: string[]; - remove_app_ids?: string[]; - }>; - } - | undefined - )?.upsert_flows ?? []; - expect(upsertFlows).toHaveLength(1); - expect(upsertFlows[0].flow_version_id).toBe("ver-1"); - expect(upsertFlows[0].tool_name).toBe("renamed_tool"); - expect(upsertFlows[0].add_app_ids).toEqual([]); - expect(upsertFlows[0].remove_app_ids).toEqual([]); - }); - - it("does NOT send tool_name upsert when name is unchanged", () => { - const { result } = renderEditHook(); - // toolNameByFlow is pre-filled with initialToolNames, no changes - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data as { upsert_flows?: Array } | undefined) - ?.upsert_flows ?? []; - expect(upsertFlows).toHaveLength(0); - }); - - it("does NOT send tool_name upsert when name is cleared", () => { - const { result } = renderEditHook(); - - act(() => { - result.current.setToolNameByFlow( - new Map([["flow-2", "custom_tool_two"]]), - ); // flow-1 removed from map - }); - - const payload = result.current.buildDeploymentUpdatePayload(); - const upsertFlows = - (payload.provider_data as { upsert_flows?: Array } | undefined) - ?.upsert_flows ?? []; - expect(upsertFlows).toHaveLength(0); - }); -}); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows-flow-list-panel.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows-flow-list-panel.test.tsx index af75866312..d9e41da563 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows-flow-list-panel.test.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows-flow-list-panel.test.tsx @@ -13,6 +13,19 @@ jest.mock( import { FlowListPanel } from "../components/step-attach-flows-flow-list-panel"; +function selectedVersion( + flowId: string, + versionId: string, + versionTag: string, +) { + return { + key: `${flowId}:${versionId}`, + flowId, + versionId, + versionTag, + }; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -54,16 +67,11 @@ const connections: ConnectionItem[] = [ const defaultProps = { flows, selectedFlowId: null as string | null, - selectedVersionByFlow: new Map< - string, - { versionId: string; versionTag: string } - >(), + selectedVersionByFlow: new Map>(), attachedConnectionByFlow: new Map(), connections, removedFlowIds: new Set(), onSelectFlow: jest.fn(), - onRemoveFlow: jest.fn(), - onUndoRemoveFlow: jest.fn(), }; function renderPanel(overrides: Partial = {}) { @@ -89,21 +97,21 @@ describe("Rendering flow items", () => { }); // --------------------------------------------------------------------------- -// ATTACHED badge +// Attached version badges // --------------------------------------------------------------------------- -describe("ATTACHED badge", () => { - it("shows ATTACHED badge for flows in selectedVersionByFlow map", () => { +describe("Attached version badges", () => { + it("shows version count badge for flows in selectedVersionByFlow map", () => { const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], + ["f1:v1", selectedVersion("f1", "v1", "v1.0")], ]); renderPanel({ selectedVersionByFlow }); - expect(screen.getByText("ATTACHED")).toBeInTheDocument(); + expect(screen.getByText("1 VERSION")).toBeInTheDocument(); }); - it("does not show ATTACHED badge for flows not in the map", () => { + it("does not show version count badge for flows not in the map", () => { renderPanel(); - expect(screen.queryByText("ATTACHED")).not.toBeInTheDocument(); + expect(screen.queryByText(/VERSION/)).not.toBeInTheDocument(); }); }); @@ -114,13 +122,12 @@ describe("ATTACHED badge", () => { describe("REMOVED badge and opacity", () => { it("shows REMOVED badge for flows in removedFlowIds set", () => { const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], + ["f1:v1", selectedVersion("f1", "v1", "v1.0")], ]); - const removedFlowIds = new Set(["f1"]); + const removedFlowIds = new Set(["f1:v1"]); renderPanel({ selectedVersionByFlow, removedFlowIds }); expect(screen.getByText("REMOVED")).toBeInTheDocument(); - // ATTACHED should not show for removed flows - expect(screen.queryByText("ATTACHED")).not.toBeInTheDocument(); + expect(screen.queryByText("1 VERSION")).not.toBeInTheDocument(); }); }); @@ -131,7 +138,7 @@ describe("REMOVED badge and opacity", () => { describe("Version labels", () => { it("shows version label when attached and not removed", () => { const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], + ["f1:v1", selectedVersion("f1", "v1", "v1.0")], ]); renderPanel({ selectedVersionByFlow }); expect(screen.getByText("v1.0")).toBeInTheDocument(); @@ -139,9 +146,9 @@ describe("Version labels", () => { it("does not show version label for removed flows", () => { const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], + ["f1:v1", selectedVersion("f1", "v1", "v1.0")], ]); - const removedFlowIds = new Set(["f1"]); + const removedFlowIds = new Set(["f1:v1"]); renderPanel({ selectedVersionByFlow, removedFlowIds }); expect(screen.queryByText("v1.0")).not.toBeInTheDocument(); }); @@ -154,9 +161,9 @@ describe("Version labels", () => { describe("Connection names", () => { it("correctly maps connection IDs to names using connections array", () => { const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], + ["f1:v1", selectedVersion("f1", "v1", "v1.0")], ]); - const attachedConnectionByFlow = new Map([["f1", ["conn-1", "conn-2"]]]); + const attachedConnectionByFlow = new Map([["f1:v1", ["conn-1", "conn-2"]]]); renderPanel({ selectedVersionByFlow, attachedConnectionByFlow }); expect( screen.getByText("Prod Connection, Dev Connection"), @@ -165,10 +172,10 @@ describe("Connection names", () => { it("does not show connection names for removed flows", () => { const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], + ["f1:v1", selectedVersion("f1", "v1", "v1.0")], ]); - const attachedConnectionByFlow = new Map([["f1", ["conn-1"]]]); - const removedFlowIds = new Set(["f1"]); + const attachedConnectionByFlow = new Map([["f1:v1", ["conn-1"]]]); + const removedFlowIds = new Set(["f1:v1"]); renderPanel({ selectedVersionByFlow, attachedConnectionByFlow, @@ -179,78 +186,23 @@ describe("Connection names", () => { }); // --------------------------------------------------------------------------- -// Remove button +// Removed version summary // --------------------------------------------------------------------------- -describe("Remove button", () => { - it("triggers onRemoveFlow callback when clicking the detach button", async () => { - const user = userEvent.setup(); - const onRemoveFlow = jest.fn(); +describe("Removed version summary", () => { + it("shows removed count when some versions are removed and others stay active", () => { const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], + ["f1:v1", selectedVersion("f1", "v1", "v1.0")], + ["f1:v2", selectedVersion("f1", "v2", "v2.0")], ]); - renderPanel({ selectedVersionByFlow, onRemoveFlow }); - await user.click(screen.getByTestId("detach-flow-f1")); - expect(onRemoveFlow).toHaveBeenCalledWith("f1"); - }); - - it("does not propagate click to onSelectFlow", async () => { - const user = userEvent.setup(); - const onSelectFlow = jest.fn(); - const onRemoveFlow = jest.fn(); - const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], - ]); - renderPanel({ selectedVersionByFlow, onRemoveFlow, onSelectFlow }); - - await user.click(screen.getByTestId("detach-flow-f1")); - expect(onSelectFlow).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// Undo button -// --------------------------------------------------------------------------- - -describe("Undo button", () => { - it("triggers onUndoRemoveFlow callback when clicking the undo button", async () => { - const user = userEvent.setup(); - const onUndoRemoveFlow = jest.fn(); - const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], - ]); - const removedFlowIds = new Set(["f1"]); - renderPanel({ selectedVersionByFlow, removedFlowIds, onUndoRemoveFlow }); - - await user.click(screen.getByTestId("undo-remove-flow-f1")); - expect(onUndoRemoveFlow).toHaveBeenCalledWith("f1"); - }); - - it("undo button only visible for removed flows", () => { - const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], - ["f2", { versionId: "v2", versionTag: "v2.0" }], - ]); - const removedFlowIds = new Set(["f1"]); - renderPanel({ selectedVersionByFlow, removedFlowIds }); - - expect(screen.getByTestId("undo-remove-flow-f1")).toBeInTheDocument(); - expect(screen.queryByTestId("undo-remove-flow-f2")).not.toBeInTheDocument(); - }); - - it("undo button not visible when onUndoRemoveFlow is not provided", () => { - const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], - ]); - const removedFlowIds = new Set(["f1"]); renderPanel({ selectedVersionByFlow, - removedFlowIds, - onUndoRemoveFlow: undefined, + removedFlowIds: new Set(["f1:v2"]), }); - expect(screen.queryByTestId("undo-remove-flow-f1")).not.toBeInTheDocument(); + expect(screen.getByText("1 removed")).toBeInTheDocument(); + expect(screen.getByText("1 VERSION")).toBeInTheDocument(); }); }); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows-version-panel.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows-version-panel.test.tsx index b2dd17253b..5458d7fcea 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows-version-panel.test.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows-version-panel.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { FlowType } from "@/types/flow"; import type { FlowVersionEntry } from "@/types/flow/version"; +import type { SelectedFlowVersion } from "../types"; jest.mock( "@/components/common/genericIconComponent", @@ -60,12 +61,10 @@ const defaultProps = { versions, isLoadingVersions: false, isCreatingDraftVersion: false, - selectedVersionByFlow: new Map< - string, - { versionId: string; versionTag: string } - >(), + selectedVersionByFlow: new Map(), onAttach: jest.fn(), onCreateFromDraft: jest.fn(), + onDetach: jest.fn(), }; function renderPanel(overrides: Partial = {}) { @@ -159,7 +158,15 @@ describe("Rendering version items", () => { describe("ATTACHED badge", () => { it("shows ATTACHED badge for the currently attached version", () => { const selectedVersionByFlow = new Map([ - ["f1", { versionId: "v1", versionTag: "v1.0" }], + [ + "f1:v1", + { + key: "f1:v1", + flowId: "f1", + versionId: "v1", + versionTag: "v1.0", + }, + ], ]); renderPanel({ selectedVersionByFlow }); expect(screen.getByText("ATTACHED")).toBeInTheDocument(); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows.test.tsx index b5277c141a..ee41f42894 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows.test.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-attach-flows.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { ConnectionItem } from "../types"; +import type { ConnectionItem, SelectedFlowVersion } from "../types"; // --------------------------------------------------------------------------- // Mocks — stepper context @@ -11,10 +11,7 @@ let mockInitialFlowId: string | undefined; let mockSelectedInstance: { id: string } | null = { id: "inst-1" }; let mockConnections: ConnectionItem[] = []; const mockSetConnections = jest.fn(); -let mockSelectedVersionByFlow = new Map< - string, - { versionId: string; versionTag: string } ->(); +let mockSelectedVersionByFlow = new Map(); const mockHandleSelectVersion = jest.fn(); let mockToolNameByFlow = new Map(); const mockSetToolNameByFlow = jest.fn(); @@ -202,6 +199,8 @@ jest.mock( import StepAttachFlows from "../components/step-attach-flows"; +const selectedFlow1Version1Key = "flow-1:ver-1"; + beforeEach(() => { jest.clearAllMocks(); mockIsEditMode = false; @@ -350,7 +349,15 @@ describe("Version panel", () => { it("shows ATTACHED badge for already-attached versions", () => { mockSelectedVersionByFlow = new Map([ - ["flow-1", { versionId: "ver-1", versionTag: "v1" }], + [ + selectedFlow1Version1Key, + { + key: selectedFlow1Version1Key, + flowId: "flow-1", + versionId: "ver-1", + versionTag: "v1", + }, + ], ]); render(); expect(screen.getAllByText("ATTACHED").length).toBeGreaterThanOrEqual(1); @@ -445,7 +452,15 @@ describe("Edit mode features", () => { beforeEach(() => { mockIsEditMode = true; mockSelectedVersionByFlow = new Map([ - ["flow-1", { versionId: "ver-1", versionTag: "v1" }], + [ + selectedFlow1Version1Key, + { + key: selectedFlow1Version1Key, + flowId: "flow-1", + versionId: "ver-1", + versionTag: "v1", + }, + ], ]); }); @@ -456,36 +471,40 @@ describe("Edit mode features", () => { it("shows detach button for attached flows", () => { render(); - expect(screen.getByTestId("detach-flow-flow-1")).toBeInTheDocument(); + expect(screen.getByTestId("detach-version-ver-1")).toBeInTheDocument(); }); it("calls handleRemoveAttachedFlow when detach is clicked", async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByTestId("detach-flow-flow-1")); - expect(mockHandleRemoveAttachedFlow).toHaveBeenCalledWith("flow-1"); + await user.click(screen.getByTestId("detach-version-ver-1")); + expect(mockHandleRemoveAttachedFlow).toHaveBeenCalledWith( + selectedFlow1Version1Key, + ); }); it("shows REMOVED badge for removed flows", () => { - mockRemovedFlowIds = new Set(["flow-1"]); + mockRemovedFlowIds = new Set([selectedFlow1Version1Key]); render(); - expect(screen.getByText("REMOVED")).toBeInTheDocument(); + expect(screen.getAllByText("REMOVED").length).toBeGreaterThanOrEqual(1); }); it("shows undo button for removed flows", () => { - mockRemovedFlowIds = new Set(["flow-1"]); + mockRemovedFlowIds = new Set([selectedFlow1Version1Key]); render(); - expect(screen.getByTestId("undo-remove-flow-flow-1")).toBeInTheDocument(); + expect(screen.getByTestId("undo-version-ver-1")).toBeInTheDocument(); }); it("calls handleUndoRemoveFlow when undo is clicked", async () => { const user = userEvent.setup(); - mockRemovedFlowIds = new Set(["flow-1"]); + mockRemovedFlowIds = new Set([selectedFlow1Version1Key]); render(); - await user.click(screen.getByTestId("undo-remove-flow-flow-1")); - expect(mockHandleUndoRemoveFlow).toHaveBeenCalledWith("flow-1"); + await user.click(screen.getByTestId("undo-version-ver-1")); + expect(mockHandleUndoRemoveFlow).toHaveBeenCalledWith( + selectedFlow1Version1Key, + ); }); it("sorts attached flows to the top", () => { @@ -557,7 +576,15 @@ describe("Detected env vars auto-population", () => { const user = userEvent.setup(); mockInitialFlowId = "flow-1"; mockSelectedVersionByFlow = new Map([ - ["flow-1", { versionId: "ver-1", versionTag: "v1" }], + [ + selectedFlow1Version1Key, + { + key: selectedFlow1Version1Key, + flowId: "flow-1", + versionId: "ver-1", + versionTag: "v1", + }, + ], ]); mockDetectEnvVars.mockResolvedValueOnce({ variables: ["GLOBAL_SECRET"], diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-review.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-review.test.tsx index 4b2035ffd0..3890a2e935 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-review.test.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-review.test.tsx @@ -224,6 +224,55 @@ describe("Attached flows section", () => { expect(versionLabels.length).toBeGreaterThanOrEqual(1); }); + it("uses entry keys so all sidebar-attached flows appear in review", () => { + setup( + { + selectedVersionByFlow: new Map([ + [ + "legacy-basic", + { + key: "flow-basic:ver-2", + flowId: "flow-basic", + flowName: "Basic Prompting", + versionId: "ver-2", + versionTag: "v2", + }, + ], + [ + "legacy-blog", + { + key: "flow-blog:ver-1", + flowId: "flow-blog", + flowName: "Blog Writer", + versionId: "ver-1", + versionTag: "v1", + }, + ], + [ + "legacy-new", + { + key: "flow-new:ver-2", + flowId: "flow-new", + flowName: "New Flow", + versionId: "ver-2", + versionTag: "v2", + }, + ], + ]), + removedFlowIds: new Set(["legacy-blog"]), + }, + [ + { id: "flow-basic", name: "Basic Prompting", folder_id: "folder-1" }, + { id: "flow-blog", name: "Blog Writer", folder_id: "folder-1" }, + { id: "flow-new", name: "New Flow", folder_id: "folder-1" }, + ], + ); + + expect(screen.getAllByText("Basic Prompting").length).toBeGreaterThan(0); + expect(screen.getAllByText("Blog Writer").length).toBeGreaterThan(0); + expect(screen.getAllByText("New Flow").length).toBeGreaterThan(0); + }); + it("shows 'Unknown' for flows not found in flowsData", () => { setup( { @@ -234,7 +283,7 @@ describe("Attached flows section", () => { [], // no flows in data ); - const unknowns = screen.getAllByText("Unknown"); + const unknowns = screen.getAllByText("Unknown flow"); expect(unknowns.length).toBeGreaterThanOrEqual(1); }); }); @@ -280,16 +329,15 @@ describe("Flow configuration section", () => { [{ id: "flow-1", name: "Default Name Flow", folder_id: "folder-1" }], ); - // The flow name appears in the attached column, in the EditableToolName - // (as placeholder fallback), and in the config sub-detail = 3 total + // The raw flow name appears in the attached column and the config sub-detail. const nameInstances = screen.getAllByText("Default Name Flow"); - expect(nameInstances).toHaveLength(3); + expect(nameInstances).toHaveLength(2); - // Verify the EditableToolName specifically shows the flow name as placeholder + // Verify the tool-name row falls back to the generated default tool name const wrenchIcon = screen.getByTestId("icon-Wrench"); const configRow = wrenchIcon.closest(".flex.items-center.gap-2")!; expect( - within(configRow as HTMLElement).getByText("Default Name Flow"), + within(configRow as HTMLElement).getByText("Default Name Flow 1"), ).toBeInTheDocument(); }); @@ -313,7 +361,7 @@ describe("Connection details", () => { name: "New API Connection", variableCount: 1, isNew: true, - environmentVariables: { API_KEY: "my-secret-key" }, + environmentVariables: { API_KEY: "my-secret-key" }, // pragma: allowlist secret }, ]; @@ -341,7 +389,7 @@ describe("Connection details", () => { name: "New Connection", variableCount: 1, isNew: true, - environmentVariables: { SECRET_KEY: "actual-secret-value" }, + environmentVariables: { SECRET_KEY: "actual-secret-value" }, // pragma: allowlist secret }, ]; @@ -462,7 +510,16 @@ describe("Removal section (edit mode)", () => { { isEditMode: true, removedFlowIds: new Set(["flow-removed"]), - selectedVersionByFlow: new Map(), + selectedVersionByFlow: new Map([ + [ + "flow-removed", + { + flowId: "flow-removed", + versionId: "ver-removed", + versionTag: "v1", + }, + ], + ]), }, [{ id: "flow-removed", name: "Removed Flow", folder_id: "folder-1" }], ); @@ -482,7 +539,16 @@ describe("Removal section (edit mode)", () => { { isEditMode: true, removedFlowIds: new Set(["flow-unknown"]), - selectedVersionByFlow: new Map(), + selectedVersionByFlow: new Map([ + [ + "flow-unknown", + { + flowId: "flow-unknown", + versionId: "ver-unknown", + versionTag: "v1", + }, + ], + ]), }, [], ); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-details-modal/flow-version-item.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-details-modal/flow-version-item.tsx index 1485d2133f..ce09e5414f 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-details-modal/flow-version-item.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-details-modal/flow-version-item.tsx @@ -1,5 +1,6 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent"; import { Badge } from "@/components/ui/badge"; +import { UNKNOWN_FLOW_NAME } from "../../types"; import ConnectionItem from "./connection-item"; interface FlowVersionItemProps { @@ -23,7 +24,7 @@ export default function FlowVersionItem({ className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> - {flowName ?? "Unknown flow"} + {flowName ?? UNKNOWN_FLOW_NAME} { if (!isEditMode || !attachmentsData?.flow_versions) return null; - const versionMap = new Map< - string, - { versionId: string; versionTag: string } - >(); + const versionMap = new Map(); const toolNames = new Map(); const connectionsByFlow = new Map(); for (const fv of attachmentsData.flow_versions) { - versionMap.set(fv.flow_id, { + const key = getSelectedFlowVersionKey(fv.flow_id, fv.id); + versionMap.set(key, { + key, + flowId: fv.flow_id, + flowName: fv.flow_name ?? DEFAULT_FLOW_NAME, versionId: fv.id, versionTag: `v${fv.version_number}`, }); // Pre-populate tool names from the provider (may differ from flow name). const providerToolName = fv.provider_data?.tool_name; if (providerToolName) { - toolNames.set(fv.flow_id, providerToolName); + toolNames.set(key, providerToolName); } // Pre-populate attached connections from existing tool assignments. const appIds = fv.provider_data?.app_ids; if (appIds && appIds.length > 0) { - connectionsByFlow.set(fv.flow_id, appIds); + connectionsByFlow.set(key, appIds); } } const llm = typeof deploymentDetail?.provider_data?.llm === "string" - ? (deploymentDetail.provider_data.llm as string) + ? deploymentDetail.provider_data.llm : ""; return { versionMap, llm, toolNames, connectionsByFlow }; @@ -138,40 +146,40 @@ export default function DeploymentStepperModal({ hideCloseButton overlayClassName="bg-black/30 dark:bg-black/50 backdrop-blur" > - {isLoadingEditData ? ( -
- - Loading deployment data... - -
- ) : ( - + + {isLoadingEditData ? ( +
+ + Loading deployment data... + +
+ ) : ( -
- )} + )} +
); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-flow-list-panel.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-flow-list-panel.tsx index 6a5ba993bd..fb519e706a 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-flow-list-panel.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-flow-list-panel.tsx @@ -3,7 +3,7 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent"; import { Badge } from "@/components/ui/badge"; import type { FlowType } from "@/types/flow"; import { cn } from "@/utils/utils"; -import type { ConnectionItem } from "../types"; +import type { ConnectionItem, SelectedFlowVersion } from "../types"; export const FlowListPanel = memo(function FlowListPanel({ flows, @@ -13,18 +13,14 @@ export const FlowListPanel = memo(function FlowListPanel({ connections, removedFlowIds, onSelectFlow, - onRemoveFlow, - onUndoRemoveFlow, }: { flows: FlowType[]; selectedFlowId: string | null; - selectedVersionByFlow: Map; + selectedVersionByFlow: Map; attachedConnectionByFlow: Map; connections: ConnectionItem[]; removedFlowIds?: Set; onSelectFlow: (flowId: string) => void; - onRemoveFlow?: (flowId: string) => void; - onUndoRemoveFlow?: (flowId: string) => void; }) { return (
@@ -33,11 +29,21 @@ export const FlowListPanel = memo(function FlowListPanel({
{flows.map((flow) => { - const entry = selectedVersionByFlow.get(flow.id); - const versionLabel = entry?.versionTag || null; - const attached = selectedVersionByFlow.has(flow.id); - const isRemoved = removedFlowIds?.has(flow.id) ?? false; - const connectionIds = attachedConnectionByFlow.get(flow.id) ?? []; + const entries = Array.from(selectedVersionByFlow.values()).filter( + (entry) => entry.flowId === flow.id, + ); + const attached = entries.length > 0; + const removedEntries = entries.filter( + (entry) => removedFlowIds?.has(entry.key) ?? false, + ); + const activeEntries = entries.filter( + (entry) => !(removedFlowIds?.has(entry.key) ?? false), + ); + const isRemoved = attached && activeEntries.length === 0; + const versionLabels = activeEntries.map((entry) => entry.versionTag); + const connectionIds = activeEntries.flatMap( + (entry) => attachedConnectionByFlow.get(entry.key) ?? [], + ); const connectionNames = connectionIds .map((cid) => connections.find((c) => c.id === cid)?.name) .filter(Boolean); @@ -68,26 +74,29 @@ export const FlowListPanel = memo(function FlowListPanel({ />
-
+
{flow.name} - {versionLabel && !isRemoved && ( + {versionLabels.map((label) => ( - {versionLabel} + {label} - )} + ))} {attached && !isRemoved && ( - ATTACHED + {activeEntries.length === 1 + ? "1 VERSION" + : `${activeEntries.length} VERSIONS`} )} {isRemoved && ( @@ -105,36 +114,13 @@ export const FlowListPanel = memo(function FlowListPanel({ {connectionNames.join(", ")}

)} + {removedEntries.length > 0 && !isRemoved && ( +

+ {removedEntries.length} removed +

+ )}
- {attached && !isRemoved && onRemoveFlow && ( - - )} - {isRemoved && onUndoRemoveFlow && ( - - )}
); })} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-version-panel.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-version-panel.tsx index f9b5cb0857..99176d5c95 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-version-panel.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows-version-panel.tsx @@ -1,10 +1,16 @@ import { memo } from "react"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; import VersionLabel from "@/components/common/versionLabelComponent"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { FlowType } from "@/types/flow"; import type { FlowVersionEntry } from "@/types/flow/version"; import { cn } from "@/utils/utils"; +import { + type ConnectionItem, + getSelectedFlowVersionKey, + type SelectedFlowVersion, +} from "../types"; function formatDate(iso: string): string { const d = new Date(iso); @@ -24,14 +30,24 @@ export const VersionPanel = memo(function VersionPanel({ selectedVersionByFlow, onAttach, onCreateFromDraft, + onDetach, + onUndoRemove, + removedFlowIds = new Set(), + attachedConnectionByFlow = new Map(), + connections = [], }: { selectedFlow: FlowType | undefined; versions: FlowVersionEntry[]; isLoadingVersions: boolean; isCreatingDraftVersion: boolean; - selectedVersionByFlow: Map; + selectedVersionByFlow: Map; onAttach: (versionId: string) => void; onCreateFromDraft: () => void; + onDetach: (attachmentKey: string) => void; + onUndoRemove?: (attachmentKey: string) => void; + removedFlowIds?: Set; + attachedConnectionByFlow?: Map; + connections?: ConnectionItem[]; }) { if (!selectedFlow) { return ( @@ -41,8 +57,6 @@ export const VersionPanel = memo(function VersionPanel({ ); } - const attachedEntry = selectedVersionByFlow.get(selectedFlow.id); - return ( <>
@@ -76,18 +90,43 @@ export const VersionPanel = memo(function VersionPanel({ {!isLoadingVersions && versions.map((version) => { - const isAttachedVersion = attachedEntry?.versionId === version.id; + const attachmentKey = getSelectedFlowVersionKey( + selectedFlow.id, + version.id, + ); + const isAttachedVersion = + selectedVersionByFlow.has(attachmentKey); + const isRemoved = removedFlowIds?.has(attachmentKey) ?? false; + const connectionNames = ( + attachedConnectionByFlow.get(attachmentKey) ?? [] + ) + .map((cid) => connections.find((c) => c.id === cid)?.name) + .filter(Boolean); return ( - + {isRemoved && onUndoRemove ? ( + + ) : isAttachedVersion ? ( + <> + + + + ) : ( + + )} +
); })} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows.tsx index b141df0491..450120c06d 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-attach-flows.tsx @@ -10,7 +10,12 @@ import useAlertStore from "@/stores/alertStore"; import { useFolderStore } from "@/stores/foldersStore"; import { useDeploymentStepper } from "../contexts/deployment-stepper-context"; import { useConnectionPanelState } from "../hooks/use-connection-panel-state"; -import type { ConnectionItem } from "../types"; +import { + type ConnectionItem, + DEFAULT_FLOW_NAME, + getDefaultDeploymentToolName, + getSelectedFlowVersionKey, +} from "../types"; import { ConnectionPanel } from "./step-attach-flows-connection-panel"; import { FlowListPanel } from "./step-attach-flows-flow-list-panel"; import { VersionPanel } from "./step-attach-flows-version-panel"; @@ -27,6 +32,7 @@ export default function StepAttachFlows() { selectedVersionByFlow, handleSelectVersion: onSelectVersion, setToolNameByFlow, + defaultToolNameScopeId, attachedConnectionByFlow, setAttachedConnectionByFlow: onAttachConnection, removedFlowIds, @@ -56,8 +62,16 @@ export default function StepAttachFlows() { // In edit mode, sort already-attached flows to the top. if (selectedVersionByFlow.size > 0) { filtered.sort((a, b) => { - const aAttached = selectedVersionByFlow.has(a.id) ? 0 : 1; - const bAttached = selectedVersionByFlow.has(b.id) ? 0 : 1; + const aAttached = Array.from(selectedVersionByFlow.values()).some( + (entry) => entry.flowId === a.id, + ) + ? 0 + : 1; + const bAttached = Array.from(selectedVersionByFlow.values()).some( + (entry) => entry.flowId === b.id, + ) + ? 0 + : 1; return aAttached - bAttached; }); } @@ -103,7 +117,9 @@ export default function StepAttachFlows() { ); // Track the version the user clicked but hasn't finished the connection step for yet. const [pendingAttachment, setPendingAttachment] = useState<{ + key: string; flowId: string; + flowName: string; versionId: string; versionTag: string; } | null>(null); @@ -111,14 +127,35 @@ export default function StepAttachFlows() { const commitPendingAttachment = useCallback(() => { if (pendingAttachment) { - onSelectVersion( - pendingAttachment.flowId, - pendingAttachment.versionId, - pendingAttachment.versionTag, - ); + onSelectVersion({ + flowId: pendingAttachment.flowId, + flowName: pendingAttachment.flowName, + versionId: pendingAttachment.versionId, + versionTag: pendingAttachment.versionTag, + }); + setToolNameByFlow((prev) => { + if (prev.has(pendingAttachment.key)) { + return prev; + } + const next = new Map(prev); + next.set( + pendingAttachment.key, + getDefaultDeploymentToolName( + pendingAttachment.flowName, + pendingAttachment.versionId, + defaultToolNameScopeId, + ), + ); + return next; + }); setPendingAttachment(null); } - }, [pendingAttachment, onSelectVersion]); + }, [ + defaultToolNameScopeId, + pendingAttachment, + onSelectVersion, + setToolNameByFlow, + ]); const resetPendingAttachment = useCallback(() => { setPendingAttachment(null); @@ -148,7 +185,7 @@ export default function StepAttachFlows() { } = useConnectionPanelState({ connections, setConnections, - effectiveFlowId, + effectiveAttachmentKey: pendingAttachment?.key ?? null, attachedConnectionByFlow, onAttachConnection, commitPendingAttachment, @@ -161,14 +198,38 @@ export default function StepAttachFlows() { usePostCreateSnapshot(); const { data: globalVariables } = useGetGlobalVariables(); const globalVariableOptions = (globalVariables ?? []).map((v) => v.name); + const handledPreselectedAttachmentRef = useRef(null); // When a flow+version are pre-selected from outside (e.g., canvas deploy button), // auto-advance to the connections panel and detect env vars for the pre-selected version. useEffect(() => { const preSelected = initialFlowId - ? selectedVersionByFlow.get(initialFlowId) + ? Array.from(selectedVersionByFlow.values()).find( + (entry) => entry.flowId === initialFlowId, + ) : undefined; if (!preSelected) return; + if (handledPreselectedAttachmentRef.current === preSelected.key) return; + handledPreselectedAttachmentRef.current = preSelected.key; + + setToolNameByFlow((prev) => { + if (prev.has(preSelected.key)) { + return prev; + } + const flowName = + flows.find((flow) => flow.id === preSelected.flowId)?.name ?? + DEFAULT_FLOW_NAME; + const next = new Map(prev); + next.set( + preSelected.key, + getDefaultDeploymentToolName( + flowName, + preSelected.versionId, + defaultToolNameScopeId, + ), + ); + return next; + }); setRightPanel("connections"); @@ -186,7 +247,16 @@ export default function StepAttachFlows() { } }; detect(); - }, []); + }, [ + flows, + initialFlowId, + selectedVersionByFlow, + detectEnvVars, + defaultToolNameScopeId, + setErrorData, + setToolNameByFlow, + updateDetectedEnvVars, + ]); const { data: versionResponse, isLoading: isLoadingVersions } = useGetFlowVersions( @@ -198,15 +268,23 @@ export default function StepAttachFlows() { const selectedFlow = flows.find((f) => f.id === effectiveFlowId); const openConnectionPanelForVersion = useCallback( - async (flowId: string, versionId: string, versionTag: string) => { + async ( + flowId: string, + flowName: string, + versionId: string, + versionTag: string, + ) => { + const attachmentKey = getSelectedFlowVersionKey(flowId, versionId); // Don't commit to context yet — wait for connection step to complete. setPendingAttachment({ + key: attachmentKey, flowId, + flowName, versionId, versionTag, }); setRightPanel("connections"); - initConnectionsForFlow(flowId); + initConnectionsForFlow(attachmentKey); // Auto-detect global variable references via the backend detection endpoint try { @@ -236,11 +314,12 @@ export default function StepAttachFlows() { const version = versions.find((v) => v.id === versionId); await openConnectionPanelForVersion( effectiveFlowId, + selectedFlow?.name ?? DEFAULT_FLOW_NAME, versionId, version?.version_tag ?? "", ); }, - [effectiveFlowId, versions, openConnectionPanelForVersion], + [effectiveFlowId, openConnectionPanelForVersion, selectedFlow, versions], ); const handleCreateVersionFromDraft = useCallback(async () => { @@ -250,6 +329,7 @@ export default function StepAttachFlows() { const snapshot = await createSnapshot({ flowId: effectiveFlowId }); await openConnectionPanelForVersion( effectiveFlowId, + selectedFlow?.name ?? DEFAULT_FLOW_NAME, snapshot.id, snapshot.version_tag, ); @@ -265,21 +345,16 @@ export default function StepAttachFlows() { createSnapshot, effectiveFlowId, openConnectionPanelForVersion, + selectedFlow, setErrorData, ]); const handleDetachFlow = useCallback( - (flowId: string) => { - handleRemoveAttachedFlow(flowId); - setToolNameByFlow((prev) => { - const next = new Map(prev); - next.delete(flowId); - return next; - }); - // Reset right panel to versions if we're currently viewing the detached flow + (attachmentKey: string) => { + handleRemoveAttachedFlow(attachmentKey); setRightPanel("versions"); }, - [handleRemoveAttachedFlow, setToolNameByFlow], + [handleRemoveAttachedFlow], ); const handleSelectFlow = useCallback( @@ -304,8 +379,6 @@ export default function StepAttachFlows() { connections={connections} removedFlowIds={isEditMode ? removedFlowIds : undefined} onSelectFlow={handleSelectFlow} - onRemoveFlow={handleDetachFlow} - onUndoRemoveFlow={isEditMode ? handleUndoRemoveFlow : undefined} /> {/* Right panel */} @@ -319,6 +392,11 @@ export default function StepAttachFlows() { selectedVersionByFlow={selectedVersionByFlow} onAttach={handleAttachFlow} onCreateFromDraft={handleCreateVersionFromDraft} + onDetach={handleDetachFlow} + onUndoRemove={isEditMode ? handleUndoRemoveFlow : undefined} + removedFlowIds={isEditMode ? removedFlowIds : undefined} + attachedConnectionByFlow={attachedConnectionByFlow} + connections={connections} /> ) : ( void; -}) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(value); - const inputRef = useRef(null); - - useEffect(() => { - if (editing) { - inputRef.current?.focus(); - inputRef.current?.select(); - } - }, [editing]); - - const confirm = useCallback(() => { - onSave(draft); - setEditing(false); - }, [draft, onSave]); - - const cancel = useCallback(() => { - setDraft(value); - setEditing(false); - }, [value]); - - if (editing) { - return ( -
- setDraft(e.target.value)} - onBlur={confirm} - onKeyDown={(e) => { - if (e.key === "Enter") confirm(); - if (e.key === "Escape") cancel(); - }} - data-testid="tool-name-input" - /> - -
- ); - } - - return ( -
- - {value || placeholder} - - -
- ); -} +import { UNKNOWN_FLOW_NAME } from "../types"; +import { ReviewDetachingSection } from "./step-review/review-detaching-section"; +import { ReviewFlowConfigCard } from "./step-review/review-flow-config-card"; +import { ReviewSummaryCard } from "./step-review/review-summary-card"; +import { + buildReviewFlows, + buildToolNameErrors, + buildToolNamesToCheck, +} from "./step-review/utils"; export default function StepReview() { const { @@ -102,6 +25,7 @@ export default function StepReview() { selectedVersionByFlow, toolNameByFlow, setToolNameByFlow, + defaultToolNameScopeId, attachedConnectionByFlow, removedFlowIds, selectedInstance, @@ -121,61 +45,66 @@ export default function StepReview() { }, { enabled: !!currentFolderId }, ); - const allFlows = (Array.isArray(flowsData) ? flowsData : []).filter( - (f) => f.folder_id === currentFolderId, + const allFlows = useMemo( + () => + (Array.isArray(flowsData) ? flowsData : []).filter( + (flow) => flow.folder_id === currentFolderId, + ), + [currentFolderId, flowsData], ); - const reviewFlows = Array.from(selectedVersionByFlow.entries()).map( - ([flowId, { versionId, versionTag }]) => { - const flow = allFlows.find((f) => f.id === flowId); - const connectionIds = attachedConnectionByFlow.get(flowId) ?? []; - const flowConnections = connectionIds - .map((cid) => connections.find((c) => c.id === cid)) - .filter((c): c is (typeof connections)[number] => c != null); - - const connectionDetails = flowConnections.map((conn) => { - const envVars = conn.environmentVariables - ? Object.keys(conn.environmentVariables).map((key) => ({ - key, - masked: "••••••••", - })) - : []; - return { name: conn.name, isNew: conn.isNew ?? false, envVars }; - }); - - const flowName = flow?.name ?? "Unknown"; - return { - flowId, - flowName, - toolName: toolNameByFlow.get(flowId)?.trim() || flowName, - versionLabel: versionTag || versionId, - connectionDetails, - }; - }, + const reviewFlows = useMemo( + () => + buildReviewFlows({ + allFlows, + attachedConnectionByFlow, + connections, + defaultToolNameScopeId, + removedFlowIds, + selectedVersionByFlow, + toolNameByFlow, + }), + [ + allFlows, + attachedConnectionByFlow, + connections, + defaultToolNameScopeId, + removedFlowIds, + selectedVersionByFlow, + toolNameByFlow, + ], ); - - // Collect normalized tool names to check against the provider. - const toolNamesToCheck = useMemo(() => { - const names: string[] = []; - for (const item of reviewFlows) { - const normalized = normalizeWxoName(item.toolName); - if (!normalized) continue; - // Skip pre-existing flows only if their tool name hasn't changed. - if (isEditMode && preExistingFlowIds.has(item.flowId)) { - const original = normalizeWxoName( - initialToolNameByFlow.get(item.flowId) ?? "", - ); - if ( - normalized.toLowerCase() === original.toLowerCase() || - normalized.toLowerCase() === - normalizeWxoName(item.flowName).toLowerCase() + const removedReviewFlows = useMemo( + () => + Array.from(selectedVersionByFlow.entries()) + .filter(([attachmentKey, entry]) => + removedFlowIds.has(entry.key ?? attachmentKey), ) - continue; - } - names.push(normalized); - } - return names; - }, [reviewFlows, isEditMode, preExistingFlowIds, initialToolNameByFlow]); + .map(([attachmentKey, entry]) => { + const normalizedAttachmentKey = entry.key ?? attachmentKey; + const flowName = + allFlows.find((flow) => flow.id === entry.flowId)?.name ?? + entry.flowName ?? + UNKNOWN_FLOW_NAME; + return { + attachmentKey: normalizedAttachmentKey, + flowName, + versionLabel: entry.versionTag || entry.versionId, + }; + }), + [allFlows, removedFlowIds, selectedVersionByFlow], + ); + + const toolNamesToCheck = useMemo( + () => + buildToolNamesToCheck({ + initialToolNameByFlow, + isEditMode, + preExistingFlowIds, + reviewFlows, + }), + [initialToolNameByFlow, isEditMode, preExistingFlowIds, reviewFlows], + ); const { data: checkNamesData } = useCheckToolNames( { providerId: selectedInstance?.id ?? "", names: toolNamesToCheck }, @@ -190,53 +119,23 @@ export default function StepReview() { return new Set(checkNamesData.existing_names.map((n) => n.toLowerCase())); }, [checkNamesData]); - const toolNameErrors = useMemo(() => { - const errors = new Map(); - const batchNames = new Map(); // normalized -> flowId (first seen) - - for (const item of reviewFlows) { - const normalized = normalizeWxoName(item.toolName).toLowerCase(); - if (!normalized) continue; - - // Check batch duplicates (two flows with same tool name in this deployment) - const firstFlowId = batchNames.get(normalized); - if (firstFlowId) { - errors.set(item.flowId, "Duplicate tool name within this deployment"); - if (!errors.has(firstFlowId)) { - errors.set(firstFlowId, "Duplicate tool name within this deployment"); - } - } else { - batchNames.set(normalized, item.flowId); - } - - // Check against existing provider tools (skip for pre-existing flows only if name unchanged) - if (!errors.has(item.flowId) && existingToolNames.has(normalized)) { - let skipProviderCheck = false; - if (isEditMode && preExistingFlowIds.has(item.flowId)) { - const original = normalizeWxoName( - initialToolNameByFlow.get(item.flowId) ?? "", - ).toLowerCase(); - skipProviderCheck = - normalized === original || - normalized === normalizeWxoName(item.flowName).toLowerCase(); - } - if (!skipProviderCheck) { - errors.set( - item.flowId, - "Edit tool name (already exists in provider)", - ); - } - } - } - - return errors; - }, [ - reviewFlows, - existingToolNames, - isEditMode, - preExistingFlowIds, - initialToolNameByFlow, - ]); + const toolNameErrors = useMemo( + () => + buildToolNameErrors({ + existingToolNames, + initialToolNameByFlow, + isEditMode, + preExistingFlowIds, + reviewFlows, + }), + [ + existingToolNames, + initialToolNameByFlow, + isEditMode, + preExistingFlowIds, + reviewFlows, + ], + ); useEffect(() => { setHasToolNameErrors(toolNameErrors.size > 0); @@ -255,253 +154,43 @@ export default function StepReview() {

-
-
- {/* Deployment column */} -
- - Deployment - -
-
- Type -
- - - {deploymentType} - -
-
-
- Name - - {deploymentName || "—"} - -
- {selectedLlm && ( -
- - Model - - {selectedLlm} -
- )} -
-
+ - {/* Attached Flows column */} -
- - Attached Flows - -
- {reviewFlows.length === 0 ? ( - - ) : ( - reviewFlows.map((item) => ( -
- - - {item.flowName} - - - {item.versionLabel} - -
- )) - )} -
-
-
-
- - {/* Configuration section – scoped per flow */} {reviewFlows.length > 0 && (
{reviewFlows.map((item) => { - const toolError = toolNameErrors.get(item.flowId); return ( -
-
-
-
- - { - setToolNameByFlow((prev) => { - const next = new Map(prev); - if (name.trim()) { - next.set(item.flowId, name.trim()); - } else { - next.delete(item.flowId); - } - return next; - }); - }} - /> -
-
- - - {item.flowName} - - - {item.versionLabel} - -
-
- - {item.connectionDetails.length > 0 && - (() => { - const newConns = item.connectionDetails.filter( - (c) => c.isNew, - ); - const existingConns = item.connectionDetails.filter( - (c) => !c.isNew, - ); - return ( -
- {existingConns.length > 0 && ( -
- - Existing Connections - - {existingConns.map((conn) => ( - - {conn.name} - - ))} -
- )} - {newConns.length > 0 && ( -
- - New Connections - - {newConns.map((conn) => ( -
- - {conn.name} - - {conn.envVars.length > 0 && ( -
- {conn.envVars.map(({ key, masked }) => ( -
- - {key} - -
- - = - - - {masked} - -
-
- ))} -
- )} -
- ))} -
- )} -
- ); - })()} - - {toolError && ( -
- - - {toolError} - -
- )} -
-
+ { + setToolNameByFlow((prev) => { + const next = new Map(prev); + if (name.trim()) { + next.set(item.attachmentKey, name.trim()); + } else { + next.delete(item.attachmentKey); + } + return next; + }); + }} + /> ); })}
)} - {/* Existing provider tools */} - {/* Detaching section (edit mode) */} - {isEditMode && removedFlowIds.size > 0 && ( -
-
- - Detaching - -
- {Array.from(removedFlowIds).map((flowId) => { - const flow = allFlows.find((f) => f.id === flowId); - return ( -
- - - {flow?.name ?? "Unknown flow"} - - - removing - -
- ); - })} -
-

- These tools will be detached from the agent. They will remain - available on your provider tenant. -

-
-
+ {isEditMode && ( + )} ); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/editable-tool-name.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/editable-tool-name.tsx new file mode 100644 index 0000000000..116559aa9c --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/editable-tool-name.tsx @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import { Input } from "@/components/ui/input"; + +interface EditableToolNameProps { + onSave: (name: string) => void; + placeholder: string; + value: string; +} + +export function EditableToolName({ + onSave, + placeholder, + value, +}: EditableToolNameProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + const inputRef = useRef(null); + + useEffect(() => { + if (!editing) return; + inputRef.current?.focus(); + inputRef.current?.select(); + }, [editing]); + + const confirm = useCallback(() => { + onSave(draft); + setEditing(false); + }, [draft, onSave]); + + const cancel = useCallback(() => { + setDraft(value); + setEditing(false); + }, [value]); + + if (editing) { + return ( +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") confirm(); + if (event.key === "Escape") cancel(); + }} + /> + +
+ ); + } + + return ( +
+ + {value || placeholder} + + +
+ ); +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-detaching-section.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-detaching-section.tsx new file mode 100644 index 0000000000..99d2a4a321 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-detaching-section.tsx @@ -0,0 +1,60 @@ +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import { Badge } from "@/components/ui/badge"; + +interface RemovedReviewFlow { + attachmentKey: string; + flowName: string; + versionLabel: string; +} + +interface ReviewDetachingSectionProps { + removedFlows: RemovedReviewFlow[]; +} + +export function ReviewDetachingSection({ + removedFlows, +}: ReviewDetachingSectionProps) { + if (removedFlows.length === 0) { + return null; + } + + return ( +
+
+ Detaching +
+ {removedFlows.map((flow) => ( +
+ + {flow.flowName} + + {flow.versionLabel} + + + removing + +
+ ))} +
+

+ These tools will be detached from the agent. They will remain + available on your provider tenant. +

+
+
+ ); +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-flow-config-card.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-flow-config-card.tsx new file mode 100644 index 0000000000..064c793ff2 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-flow-config-card.tsx @@ -0,0 +1,128 @@ +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import { Badge } from "@/components/ui/badge"; +import { EditableToolName } from "./editable-tool-name"; +import type { ReviewFlowItem } from "./types"; + +interface ReviewFlowConfigCardProps { + item: ReviewFlowItem; + onSaveToolName: (name: string) => void; + toolError?: string; + toolNameValue: string; +} + +export function ReviewFlowConfigCard({ + item, + onSaveToolName, + toolError, + toolNameValue, +}: ReviewFlowConfigCardProps) { + const newConnections = item.connectionDetails.filter( + (connection) => connection.isNew, + ); + const existingConnections = item.connectionDetails.filter( + (connection) => !connection.isNew, + ); + + return ( +
+
+
+
+ + +
+
+ + + {item.flowName} + + + {item.versionLabel} + +
+
+ + {item.connectionDetails.length > 0 && ( +
+ {existingConnections.length > 0 && ( +
+ + Existing Connections + + {existingConnections.map((connection) => ( + + {connection.name} + + ))} +
+ )} + + {newConnections.length > 0 && ( +
+ + New Connections + + {newConnections.map((connection) => ( +
+ + {connection.name} + + {connection.envVars.length > 0 && ( +
+ {connection.envVars.map(({ key, masked }) => ( +
+ + {key} + +
+ = + + {masked} + +
+
+ ))} +
+ )} +
+ ))} +
+ )} +
+ )} + + {toolError && ( +
+ + {toolError} +
+ )} +
+
+ ); +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-summary-card.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-summary-card.tsx new file mode 100644 index 0000000000..49ae5d9844 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/review-summary-card.tsx @@ -0,0 +1,91 @@ +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import { Badge } from "@/components/ui/badge"; +import type { DeploymentType } from "../../types"; +import type { ReviewFlowItem } from "./types"; + +interface ReviewSummaryCardProps { + deploymentName: string; + deploymentType: DeploymentType; + reviewFlows: ReviewFlowItem[]; + selectedLlm: string; +} + +export function ReviewSummaryCard({ + deploymentName, + deploymentType, + reviewFlows, + selectedLlm, +}: ReviewSummaryCardProps) { + return ( +
+
+
+ + Deployment + +
+
+ Type +
+ + + {deploymentType} + +
+
+
+ Name + + {deploymentName || "—"} + +
+ {selectedLlm && ( +
+ + Model + + {selectedLlm} +
+ )} +
+
+ +
+ + Attached Flows + +
+ {reviewFlows.length === 0 ? ( + + ) : ( + reviewFlows.map((item) => ( +
+ + + {item.flowName} + + + {item.versionLabel} + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/types.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/types.ts new file mode 100644 index 0000000000..1499190332 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/types.ts @@ -0,0 +1,20 @@ +export interface ReviewConnectionEnvVar { + key: string; + masked: string; +} + +export interface ReviewConnectionDetail { + name: string; + isNew: boolean; + envVars: ReviewConnectionEnvVar[]; +} + +export interface ReviewFlowItem { + attachmentKey: string; + flowId: string; + flowName: string; + toolName: string; + defaultToolName: string; + versionLabel: string; + connectionDetails: ReviewConnectionDetail[]; +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/utils.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/utils.ts new file mode 100644 index 0000000000..9daef8f956 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-review/utils.ts @@ -0,0 +1,245 @@ +import { + getFlowVersionCount, + getScopedValueForUniqueFlowVersion, +} from "../../helpers/version-scope"; +import { normalizeWxoName } from "../../helpers/wxo-name"; +import type { ConnectionItem } from "../../types"; +import { getDefaultDeploymentToolName, UNKNOWN_FLOW_NAME } from "../../types"; +import type { ReviewFlowItem } from "./types"; + +function getToolNameForReview( + toolNameByFlow: Map, + attachmentKey: string, + flowId: string, + items: Array<{ attachmentKey: string; flowId: string }>, +) { + return getScopedValueForUniqueFlowVersion( + toolNameByFlow, + attachmentKey, + flowId, + getFlowVersionCount(items, flowId), + )?.trim(); +} + +function getInitialToolNameForReview( + initialToolNameByFlow: Map, + attachmentKey: string, + flowId: string, + items: ReviewFlowItem[], +) { + return getScopedValueForUniqueFlowVersion( + initialToolNameByFlow, + attachmentKey, + flowId, + getFlowVersionCount(items, flowId), + ); +} + +interface BuildReviewFlowsParams { + allFlows: Array<{ id: string; name: string }>; + attachedConnectionByFlow: Map; + connections: ConnectionItem[]; + defaultToolNameScopeId: string | null; + removedFlowIds: Set; + selectedVersionByFlow: Map< + string, + { + key?: string; + flowId?: string; + flowName?: string; + versionId: string; + versionTag: string; + } + >; + toolNameByFlow: Map; +} + +export function buildReviewFlows({ + allFlows, + attachedConnectionByFlow, + connections, + defaultToolNameScopeId, + removedFlowIds, + selectedVersionByFlow, + toolNameByFlow, +}: BuildReviewFlowsParams): ReviewFlowItem[] { + const selectedItems = Array.from(selectedVersionByFlow.entries()).map( + ([attachmentKey, entry]) => ({ + attachmentKey: entry.key ?? attachmentKey, + flowId: entry.flowId ?? attachmentKey, + }), + ); + + return Array.from(selectedVersionByFlow.entries()) + .map(([attachmentKey, entry]) => { + const normalizedAttachmentKey = entry.key ?? attachmentKey; + if (removedFlowIds.has(normalizedAttachmentKey)) { + return null; + } + const flowId = entry.flowId ?? attachmentKey; + const flow = allFlows.find((item) => item.id === flowId); + const connectionIds = + attachedConnectionByFlow.get(normalizedAttachmentKey) ?? + attachedConnectionByFlow.get(attachmentKey) ?? + attachedConnectionByFlow.get(flowId) ?? + []; + const flowConnections = connectionIds + .map((connectionId) => + connections.find((connection) => connection.id === connectionId), + ) + .filter( + (connection): connection is ConnectionItem => connection != null, + ); + + const connectionDetails = flowConnections.map((connection) => { + const envVars = connection.environmentVariables + ? Object.keys(connection.environmentVariables).map((key) => ({ + key, + masked: "••••••••", + })) + : []; + + return { + name: connection.name, + isNew: connection.isNew ?? false, + envVars, + }; + }); + + const flowName = flow?.name ?? entry.flowName ?? UNKNOWN_FLOW_NAME; + const defaultToolName = getDefaultDeploymentToolName( + flowName, + entry.versionId, + defaultToolNameScopeId, + ); + + return { + attachmentKey: normalizedAttachmentKey, + flowId, + flowName, + toolName: + getToolNameForReview( + toolNameByFlow, + attachmentKey, + flowId, + selectedItems, + ) || defaultToolName, + defaultToolName, + versionLabel: entry.versionTag || entry.versionId, + connectionDetails, + }; + }) + .filter((item): item is ReviewFlowItem => item !== null); +} + +interface BuildToolNamesToCheckParams { + initialToolNameByFlow: Map; + isEditMode: boolean; + preExistingFlowIds: Set; + reviewFlows: ReviewFlowItem[]; +} + +export function buildToolNamesToCheck({ + initialToolNameByFlow, + isEditMode, + preExistingFlowIds, + reviewFlows, +}: BuildToolNamesToCheckParams): string[] { + const names: string[] = []; + + for (const item of reviewFlows) { + const normalized = normalizeWxoName(item.toolName); + if (!normalized) continue; + + if (isEditMode && preExistingFlowIds.has(item.attachmentKey)) { + const original = normalizeWxoName( + getInitialToolNameForReview( + initialToolNameByFlow, + item.attachmentKey, + item.flowId, + reviewFlows, + ) ?? "", + ); + + if ( + normalized.toLowerCase() === original.toLowerCase() || + normalized.toLowerCase() === + normalizeWxoName(item.defaultToolName).toLowerCase() + ) { + continue; + } + } + + names.push(normalized); + } + + return names; +} + +interface BuildToolNameErrorsParams { + existingToolNames: Set; + initialToolNameByFlow: Map; + isEditMode: boolean; + preExistingFlowIds: Set; + reviewFlows: ReviewFlowItem[]; +} + +export function buildToolNameErrors({ + existingToolNames, + initialToolNameByFlow, + isEditMode, + preExistingFlowIds, + reviewFlows, +}: BuildToolNameErrorsParams) { + const errors = new Map(); + const batchNames = new Map(); + + for (const item of reviewFlows) { + const normalized = normalizeWxoName(item.toolName).toLowerCase(); + if (!normalized) continue; + + const firstAttachmentKey = batchNames.get(normalized); + if (firstAttachmentKey) { + errors.set( + item.attachmentKey, + "Duplicate tool name within this deployment", + ); + if (!errors.has(firstAttachmentKey)) { + errors.set( + firstAttachmentKey, + "Duplicate tool name within this deployment", + ); + } + } else { + batchNames.set(normalized, item.attachmentKey); + } + + if (!errors.has(item.attachmentKey) && existingToolNames.has(normalized)) { + let skipProviderCheck = false; + + if (isEditMode && preExistingFlowIds.has(item.attachmentKey)) { + const original = normalizeWxoName( + getInitialToolNameForReview( + initialToolNameByFlow, + item.attachmentKey, + item.flowId, + reviewFlows, + ) ?? "", + ).toLowerCase(); + + skipProviderCheck = + normalized === original || + normalized === normalizeWxoName(item.defaultToolName).toLowerCase(); + } + + if (!skipProviderCheck) { + errors.set( + item.attachmentKey, + "Edit tool name (already exists in provider)", + ); + } + } + } + + return errors; +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/contexts/deployment-stepper-context.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/contexts/deployment-stepper-context.tsx index d351f060a8..56bbffba23 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/contexts/deployment-stepper-context.tsx +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/contexts/deployment-stepper-context.tsx @@ -9,15 +9,14 @@ import { useState, } from "react"; import type { ProviderAccountCreateRequest } from "@/controllers/API/queries/deployment-provider-accounts/use-post-provider-account"; -import type { - DeploymentUpdateFlowItem, - DeploymentUpdateProviderData, - DeploymentUpdateRequest, -} from "@/controllers/API/queries/deployments/use-patch-deployment"; -import type { - DeploymentConnectionPayload, - DeploymentCreateRequest, -} from "@/controllers/API/queries/deployments/use-post-deployment"; +import type { DeploymentUpdateRequest } from "@/controllers/API/queries/deployments/use-patch-deployment"; +import type { DeploymentCreateRequest } from "@/controllers/API/queries/deployments/use-post-deployment"; +import { + buildDeploymentPayload as buildDeploymentPayloadValue, + buildDeploymentUpdatePayload as buildDeploymentUpdatePayloadValue, + buildProviderAccountPayload as buildProviderAccountPayloadValue, +} from "../helpers/deployment-payload-builders"; +import { normalizeSelectedFlowVersions } from "../helpers/version-scope"; import type { ConnectionItem, Deployment, @@ -25,14 +24,16 @@ import type { DeploymentType, ProviderAccount, ProviderCredentials, + SelectedFlowVersion, +} from "../types"; +import { + createDeploymentToolNameScopeId, + getSelectedFlowVersionKey, } from "../types"; interface DeploymentStepperInitialState { projectId?: string; - selectedVersionByFlow?: Map< - string, - { versionId: string; versionTag: string } - >; + selectedVersionByFlow?: Map; initialFlowId?: string; initialProvider?: DeploymentProvider; initialInstance?: ProviderAccount; @@ -41,12 +42,19 @@ interface DeploymentStepperInitialState { editingDeployment?: Deployment; /** Pre-populated initial LLM from provider (edit mode). */ initialLlm?: string; - /** Pre-populated tool names from provider (edit mode). Key = flowId. */ + /** Pre-populated tool names from provider (edit mode). Key = attachment key. */ initialToolNameByFlow?: Map; - /** Pre-populated connection assignments from provider (edit mode). Key = flowId. */ + /** Pre-populated connection assignments from provider (edit mode). Key = attachment key. */ initialConnectionsByFlow?: Map; } +interface SelectFlowVersionParams { + flowId: string; + flowName: string; + versionId: string; + versionTag: string; +} + interface DeploymentStepperContextType { // Mode isEditMode: boolean; @@ -84,25 +92,22 @@ interface DeploymentStepperContextType { initialFlowId: string | null; connections: ConnectionItem[]; setConnections: Dispatch>; - selectedVersionByFlow: Map; - handleSelectVersion: ( - flowId: string, - versionId: string, - versionTag: string, - ) => void; + selectedVersionByFlow: Map; + handleSelectVersion: (params: SelectFlowVersionParams) => void; attachedConnectionByFlow: Map; setAttachedConnectionByFlow: Dispatch>>; - /** User-provided tool names per flow. Key = flowId. */ + /** User-provided tool names per attached version. Key = attachment key. */ toolNameByFlow: Map; setToolNameByFlow: Dispatch>>; - /** Original tool names from provider before this edit session (edit mode). Key = flowId. */ + /** Original tool names from provider before this edit session (edit mode). Key = attachment key. */ initialToolNameByFlow: Map; - /** Flow IDs that were already attached before this edit session (edit mode). */ + defaultToolNameScopeId: string | null; + /** Attachment keys that were already attached before this edit session (edit mode). */ preExistingFlowIds: Set; - /** Flow IDs that were originally attached but the user chose to detach (edit mode). */ + /** Attachment keys that were originally attached but the user chose to detach (edit mode). */ removedFlowIds: Set; - handleRemoveAttachedFlow: (flowId: string) => void; - handleUndoRemoveFlow: (flowId: string) => void; + handleRemoveAttachedFlow: (attachmentKey: string) => void; + handleUndoRemoveFlow: (attachmentKey: string) => void; // Tool name validation hasToolNameErrors: boolean; @@ -164,16 +169,34 @@ export function DeploymentStepperProvider({ initialState?.initialLlm ?? "", ); + const normalizedInitialVersions = useMemo( + () => normalizeSelectedFlowVersions(initialState?.selectedVersionByFlow), + [initialState?.selectedVersionByFlow], + ); + const normalizedInitialToolNames = useMemo( + () => initialState?.initialToolNameByFlow ?? new Map(), + [initialState?.initialToolNameByFlow], + ); + const normalizedInitialConnections = useMemo( + () => initialState?.initialConnectionsByFlow ?? new Map(), + [initialState?.initialConnectionsByFlow], + ); + const [selectedVersionByFlow, setSelectedVersionByFlow] = useState< - Map - >(initialState?.selectedVersionByFlow ?? new Map()); + Map + >(normalizedInitialVersions); const [connections, setConnections] = useState([]); const [toolNameByFlow, setToolNameByFlow] = useState>( - initialState?.initialToolNameByFlow ?? new Map(), + normalizedInitialToolNames, + ); + const [defaultToolNameScopeId] = useState(() => + isEditMode + ? (editingDeployment?.id ?? createDeploymentToolNameScopeId()) + : createDeploymentToolNameScopeId(), ); const [attachedConnectionByFlow, setAttachedConnectionByFlow] = useState< Map - >(initialState?.initialConnectionsByFlow ?? new Map()); + >(normalizedInitialConnections); const [hasToolNameErrors, setHasToolNameErrors] = useState(false); const trimmedDeploymentName = deploymentName.trim(); @@ -188,67 +211,55 @@ export function DeploymentStepperProvider({ // Edit mode: track which pre-existing flows the user wants to detach. const [removedFlowIds, setRemovedFlowIds] = useState>(new Set()); // Cache removed flow data so undo can restore it. - const initialVersionByFlow = useMemo( - () => initialState?.selectedVersionByFlow ?? new Map(), - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); + const initialVersionByFlow = normalizedInitialVersions; const preExistingFlowIds = useMemo( () => new Set(initialVersionByFlow.keys()), [initialVersionByFlow], ); - const initialToolNameByFlow = useMemo( - () => initialState?.initialToolNameByFlow ?? new Map(), - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - const initialConnectionsByFlow = useMemo( - () => initialState?.initialConnectionsByFlow ?? new Map(), - // eslint-disable-next-line react-hooks/exhaustive-deps - [], + const initialToolNameByFlow = normalizedInitialToolNames; + const initialConnectionsByFlow = normalizedInitialConnections; + + const handleRemoveAttachedFlow = useCallback( + (attachmentKeyOrFlowId: string) => { + const resolvedKey = selectedVersionByFlow.has(attachmentKeyOrFlowId) + ? attachmentKeyOrFlowId + : Array.from(selectedVersionByFlow.values()).find( + (entry) => entry.flowId === attachmentKeyOrFlowId, + )?.key; + if (!resolvedKey) return; + if (preExistingFlowIds.has(resolvedKey)) { + setRemovedFlowIds( + (prev) => new Set([...Array.from(prev), resolvedKey]), + ); + return; + } + setSelectedVersionByFlow((prev) => { + const next = new Map(prev); + next.delete(resolvedKey); + return next; + }); + setToolNameByFlow((prev) => { + const next = new Map(prev); + next.delete(resolvedKey); + return next; + }); + setAttachedConnectionByFlow((prev) => { + const next = new Map(prev); + next.delete(resolvedKey); + return next; + }); + }, + [preExistingFlowIds, selectedVersionByFlow], ); - const handleRemoveAttachedFlow = useCallback((flowId: string) => { - setRemovedFlowIds((prev) => new Set([...Array.from(prev), flowId])); - setSelectedVersionByFlow((prev) => { - const next = new Map(prev); - next.delete(flowId); - return next; - }); - setAttachedConnectionByFlow((prev) => { - const next = new Map(prev); - next.delete(flowId); + const handleUndoRemoveFlow = useCallback((attachmentKey: string) => { + setRemovedFlowIds((prev) => { + const next = new Set(prev); + next.delete(attachmentKey); return next; }); }, []); - const handleUndoRemoveFlow = useCallback( - (flowId: string) => { - setRemovedFlowIds((prev) => { - const next = new Set(prev); - next.delete(flowId); - return next; - }); - const originalVersion = initialVersionByFlow.get(flowId); - if (originalVersion) { - setSelectedVersionByFlow((prev) => { - const next = new Map(prev); - next.set(flowId, originalVersion); - return next; - }); - } - const originalConnections = initialConnectionsByFlow.get(flowId); - if (originalConnections) { - setAttachedConnectionByFlow((prev) => { - const next = new Map(prev); - next.set(flowId, originalConnections); - return next; - }); - } - }, - [initialVersionByFlow, initialConnectionsByFlow], - ); - const hasValidCredentials = credentials.name.trim() !== "" && credentials.api_key.trim() !== "" && @@ -320,10 +331,17 @@ export function DeploymentStepperProvider({ }, []); const handleSelectVersion = useCallback( - (flowId: string, versionId: string, versionTag: string) => { + ({ flowId, flowName, versionId, versionTag }: SelectFlowVersionParams) => { setSelectedVersionByFlow((prev) => { const next = new Map(prev); - next.set(flowId, { versionId, versionTag }); + const key = getSelectedFlowVersionKey(flowId, versionId); + next.set(key, { + key, + flowId, + flowName, + versionId, + versionTag, + }); return next; }); }, @@ -333,224 +351,79 @@ export function DeploymentStepperProvider({ const needsProviderAccountCreation = selectedInstance === null && hasValidCredentials; - const buildProviderAccountPayload = - useCallback((): ProviderAccountCreateRequest | null => { - if (!hasValidCredentials) return null; - return { - name: credentials.name.trim(), - provider_key: "watsonx-orchestrate", - provider_data: { - url: credentials.url.trim(), - api_key: credentials.api_key.trim(), - }, - }; - }, [credentials, hasValidCredentials]); - - const buildConnectionPayloads = useCallback( - ( - connectionIds: Iterable, - ): DeploymentCreateRequest["provider_data"]["connections"] => { - const payloads: DeploymentCreateRequest["provider_data"]["connections"] = - []; - const uniqueIds = Array.from(new Set(connectionIds)); - - for (const id of uniqueIds) { - const conn = connections.find((item) => item.id === id); - if (!conn?.isNew) continue; - - const credentials: DeploymentConnectionPayload["credentials"] = - Object.entries(conn.environmentVariables).map(([key, value]) => { - const isGlobalVar = conn.globalVarKeys?.has(key) ?? false; - return { - key, - value, - source: isGlobalVar ? "variable" : "raw", - }; - }); - - payloads.push({ - app_id: id, - credentials, - }); - } - - return payloads; - }, - [connections], + const buildProviderAccountPayload = useCallback( + () => + buildProviderAccountPayloadValue({ + credentials, + hasValidCredentials, + }), + [credentials, hasValidCredentials], ); const buildDeploymentPayload = useCallback( - (providerId: string): DeploymentCreateRequest => { - if (!isDeploymentNameValid) { - throw new Error("Deployment name must start with a letter"); - } - const allConnectionIds = new Set(); - Array.from(attachedConnectionByFlow.values()).forEach((ids) => { - ids.forEach((id) => allConnectionIds.add(id)); - }); - - const addFlows: DeploymentCreateRequest["provider_data"]["add_flows"] = - []; - for (const [flowId, versionEntry] of Array.from(selectedVersionByFlow)) { - const connectionIds = attachedConnectionByFlow.get(flowId) ?? []; - const customToolName = toolNameByFlow.get(flowId)?.trim(); - addFlows.push({ - flow_version_id: versionEntry.versionId, - app_ids: connectionIds, - ...(customToolName && { tool_name: customToolName }), - }); - } - - const connectionPayloads = buildConnectionPayloads(allConnectionIds); - - return { - provider_id: providerId, - ...(initialState?.projectId - ? { project_id: initialState.projectId } - : {}), - name: trimmedDeploymentName, - description: deploymentDescription, - type: deploymentType, - provider_data: { - llm: selectedLlm, - add_flows: addFlows, - connections: connectionPayloads, - }, - }; - }, + (providerId: string): DeploymentCreateRequest => + buildDeploymentPayloadValue({ + attachedConnectionByFlow, + connections, + defaultToolNameScopeId, + deploymentDescription, + deploymentName, + deploymentType, + isDeploymentNameValid, + projectId: initialState?.projectId, + providerId, + removedFlowIds, + selectedLlm, + selectedVersionByFlow, + toolNameByFlow, + }), [ attachedConnectionByFlow, - buildConnectionPayloads, - initialState?.projectId, + connections, + defaultToolNameScopeId, deploymentDescription, + deploymentName, deploymentType, + initialState?.projectId, isDeploymentNameValid, + removedFlowIds, selectedLlm, selectedVersionByFlow, - trimmedDeploymentName, toolNameByFlow, ], ); const buildDeploymentUpdatePayload = useCallback((): DeploymentUpdateRequest => { - if (!editingDeployment) { - throw new Error( - "buildDeploymentUpdatePayload called outside edit mode", - ); - } - if (!isDeploymentNameValid) { - throw new Error("Deployment name must start with a letter"); - } - - const result: DeploymentUpdateRequest = { - deployment_id: editingDeployment.id, - }; - - // Metadata changes (description only — name is not editable after creation). - const descriptionChanged = - deploymentDescription !== (editingDeployment.description ?? ""); - if (descriptionChanged) { - result.description = deploymentDescription; - } - - const upsertFlows: DeploymentUpdateFlowItem[] = []; - - // New flows attached during this edit session. - for (const [flowId, versionEntry] of Array.from(selectedVersionByFlow)) { - if (initialVersionByFlow.has(flowId)) continue; - const connectionIds = attachedConnectionByFlow.get(flowId) ?? []; - const customToolName = toolNameByFlow.get(flowId)?.trim(); - upsertFlows.push({ - flow_version_id: versionEntry.versionId, - add_app_ids: connectionIds, - remove_app_ids: [], - ...(customToolName && { tool_name: customToolName }), - }); - } - - // Changes on pre-existing flows (tool name and/or connections). - for (const [flowId, versionEntry] of Array.from(selectedVersionByFlow)) { - if (!initialVersionByFlow.has(flowId)) continue; - const currentName = toolNameByFlow.get(flowId)?.trim() ?? ""; - const originalName = initialToolNameByFlow.get(flowId)?.trim() ?? ""; - const nameChanged = currentName && currentName !== originalName; - - const currentConnections = attachedConnectionByFlow.get(flowId) ?? []; - const originalConnections = initialConnectionsByFlow.get(flowId) ?? []; - const originalSet = new Set(originalConnections); - const currentSet = new Set(currentConnections); - const addAppIds = currentConnections.filter( - (id) => !originalSet.has(id), - ); - const removeAppIds = originalConnections.filter( - (id) => !currentSet.has(id), - ); - const connectionsChanged = - addAppIds.length > 0 || removeAppIds.length > 0; - - if (nameChanged || connectionsChanged) { - upsertFlows.push({ - flow_version_id: versionEntry.versionId, - add_app_ids: addAppIds, - remove_app_ids: removeAppIds, - ...(nameChanged && { tool_name: currentName }), - }); - } - } - - const removeFlows: string[] = []; - for (const flowId of Array.from(removedFlowIds)) { - const originalVersion = initialVersionByFlow.get(flowId); - if (originalVersion) { - removeFlows.push(originalVersion.versionId); - } - } - - // Collect connection details for newly added binds only. - const newConnectionIds = new Set(); - upsertFlows.forEach((flowItem) => { - flowItem.add_app_ids.forEach((id) => newConnectionIds.add(id)); + return buildDeploymentUpdatePayloadValue({ + attachedConnectionByFlow, + connections, + defaultToolNameScopeId, + deploymentDescription, + editingDeployment, + initialConnectionsByFlow, + initialToolNameByFlow, + initialVersionByFlow, + isDeploymentNameValid, + removedFlowIds, + selectedLlm, + selectedVersionByFlow, + toolNameByFlow, }); - const connectionPayloads = buildConnectionPayloads(newConnectionIds); - - const llmToSend = selectedLlm; - if ( - llmToSend || - upsertFlows.length > 0 || - removeFlows.length > 0 || - connectionPayloads.length > 0 - ) { - const providerData: DeploymentUpdateProviderData = { - ...(llmToSend && { llm: llmToSend }), - ...(upsertFlows.length > 0 && { upsert_flows: upsertFlows }), - ...(removeFlows.length > 0 && { remove_flows: removeFlows }), - ...(connectionPayloads.length > 0 && { - connections: connectionPayloads, - }), - }; - result.provider_data = providerData; - } - - // Backend requires at least one field. - if (result.description === undefined && !result.provider_data) { - result.description = deploymentDescription; - } - - return result; }, [ - editingDeployment, + attachedConnectionByFlow, + connections, + defaultToolNameScopeId, deploymentDescription, - isDeploymentNameValid, - selectedLlm, - initialVersionByFlow, - initialToolNameByFlow, + editingDeployment, initialConnectionsByFlow, + initialToolNameByFlow, + initialVersionByFlow, + isDeploymentNameValid, removedFlowIds, + selectedLlm, selectedVersionByFlow, toolNameByFlow, - attachedConnectionByFlow, - buildConnectionPayloads, ]); const value = useMemo( @@ -587,6 +460,7 @@ export function DeploymentStepperProvider({ toolNameByFlow, setToolNameByFlow, initialToolNameByFlow, + defaultToolNameScopeId, attachedConnectionByFlow, setAttachedConnectionByFlow, preExistingFlowIds, @@ -628,6 +502,7 @@ export function DeploymentStepperProvider({ handleSelectVersion, toolNameByFlow, initialToolNameByFlow, + defaultToolNameScopeId, attachedConnectionByFlow, preExistingFlowIds, removedFlowIds, diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/deployment-payload-builders.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/deployment-payload-builders.ts new file mode 100644 index 0000000000..b7d24f2b02 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/deployment-payload-builders.ts @@ -0,0 +1,352 @@ +import type { ProviderAccountCreateRequest } from "@/controllers/API/queries/deployment-provider-accounts/use-post-provider-account"; +import type { + DeploymentUpdateFlowItem, + DeploymentUpdateProviderData, + DeploymentUpdateRequest, +} from "@/controllers/API/queries/deployments/use-patch-deployment"; +import type { + DeploymentConnectionPayload, + DeploymentCreateRequest, +} from "@/controllers/API/queries/deployments/use-post-deployment"; +import type { + ConnectionItem, + Deployment, + DeploymentType, + ProviderCredentials, + SelectedFlowVersion, +} from "../types"; +import { + DEFAULT_FLOW_NAME, + getDefaultDeploymentToolName, + WXO_PROVIDER_KEY, +} from "../types"; +import { + getFlowVersionCount, + getScopedValueForUniqueFlowVersion, + getValueByAttachmentKeyOrFlowId, +} from "./version-scope"; + +function getScopedToolName( + map: Map, + attachmentKey: string, + flowId: string, + versionMap: Map, +): string | undefined { + return getScopedValueForUniqueFlowVersion( + map, + attachmentKey, + flowId, + getFlowVersionCount(versionMap.values(), flowId), + ); +} + +export function buildProviderAccountPayload({ + credentials, + hasValidCredentials, +}: { + credentials: ProviderCredentials; + hasValidCredentials: boolean; +}): ProviderAccountCreateRequest | null { + if (!hasValidCredentials) return null; + return { + name: credentials.name.trim(), + provider_key: WXO_PROVIDER_KEY, + provider_data: { + url: credentials.url.trim(), + api_key: credentials.api_key.trim(), + }, + }; +} + +export function buildConnectionPayloads({ + connectionIds, + connections, +}: { + connectionIds: Iterable; + connections: ConnectionItem[]; +}): DeploymentCreateRequest["provider_data"]["connections"] { + const payloads: DeploymentCreateRequest["provider_data"]["connections"] = []; + const uniqueIds = Array.from(new Set(connectionIds)); + + for (const id of uniqueIds) { + const conn = connections.find((item) => item.id === id); + if (!conn?.isNew) continue; + + const credentials: DeploymentConnectionPayload["credentials"] = + Object.entries(conn.environmentVariables).map(([key, value]) => { + const isGlobalVar = conn.globalVarKeys?.has(key) ?? false; + return { + key, + value, + source: isGlobalVar ? "variable" : "raw", + }; + }); + + payloads.push({ + app_id: id, + credentials, + }); + } + + return payloads; +} + +export function buildDeploymentPayload({ + attachedConnectionByFlow, + connections, + defaultToolNameScopeId, + deploymentDescription, + deploymentName, + deploymentType, + isDeploymentNameValid, + projectId, + providerId, + removedFlowIds, + selectedLlm, + selectedVersionByFlow, + toolNameByFlow, +}: { + attachedConnectionByFlow: Map; + connections: ConnectionItem[]; + defaultToolNameScopeId: string | null; + deploymentDescription: string; + deploymentName: string; + deploymentType: DeploymentType; + isDeploymentNameValid: boolean; + projectId?: string; + providerId: string; + removedFlowIds: Set; + selectedLlm: string; + selectedVersionByFlow: Map; + toolNameByFlow: Map; +}): DeploymentCreateRequest { + if (!isDeploymentNameValid) { + throw new Error("Deployment name must start with a letter"); + } + const allConnectionIds = new Set(); + Array.from(attachedConnectionByFlow.values()).forEach((ids) => { + ids.forEach((id) => allConnectionIds.add(id)); + }); + + const addFlows: DeploymentCreateRequest["provider_data"]["add_flows"] = []; + for (const [attachmentKey, versionEntry] of Array.from( + selectedVersionByFlow, + )) { + if (removedFlowIds.has(attachmentKey)) continue; + const connectionIds = + getValueByAttachmentKeyOrFlowId( + attachedConnectionByFlow, + attachmentKey, + versionEntry.flowId, + ) ?? []; + const strictToolName = getScopedToolName( + toolNameByFlow, + attachmentKey, + versionEntry.flowId, + selectedVersionByFlow, + )?.trim(); + const resolvedToolName = + strictToolName || + getDefaultDeploymentToolName( + versionEntry.flowName ?? DEFAULT_FLOW_NAME, + versionEntry.versionId, + defaultToolNameScopeId, + ); + addFlows.push({ + flow_version_id: versionEntry.versionId, + app_ids: connectionIds, + tool_name: resolvedToolName, + }); + } + + const connectionPayloads = buildConnectionPayloads({ + connectionIds: allConnectionIds, + connections, + }); + + return { + provider_id: providerId, + ...(projectId ? { project_id: projectId } : {}), + name: deploymentName.trim(), + description: deploymentDescription, + type: deploymentType, + provider_data: { + llm: selectedLlm, + add_flows: addFlows, + connections: connectionPayloads, + }, + }; +} + +export function buildDeploymentUpdatePayload({ + attachedConnectionByFlow, + connections, + defaultToolNameScopeId, + deploymentDescription, + editingDeployment, + initialConnectionsByFlow, + initialToolNameByFlow, + initialVersionByFlow, + isDeploymentNameValid, + removedFlowIds, + selectedLlm, + selectedVersionByFlow, + toolNameByFlow, +}: { + attachedConnectionByFlow: Map; + connections: ConnectionItem[]; + defaultToolNameScopeId: string | null; + deploymentDescription: string; + editingDeployment: Deployment | null; + initialConnectionsByFlow: Map; + initialToolNameByFlow: Map; + initialVersionByFlow: Map; + isDeploymentNameValid: boolean; + removedFlowIds: Set; + selectedLlm: string; + selectedVersionByFlow: Map; + toolNameByFlow: Map; +}): DeploymentUpdateRequest { + if (!editingDeployment) { + throw new Error("buildDeploymentUpdatePayload called outside edit mode"); + } + if (!isDeploymentNameValid) { + throw new Error("Deployment name must start with a letter"); + } + + const result: DeploymentUpdateRequest = { + deployment_id: editingDeployment.id, + }; + + const descriptionChanged = + deploymentDescription !== (editingDeployment.description ?? ""); + if (descriptionChanged) { + result.description = deploymentDescription; + } + + const upsertFlows: DeploymentUpdateFlowItem[] = []; + + for (const [attachmentKey, versionEntry] of Array.from( + selectedVersionByFlow, + )) { + if (removedFlowIds.has(attachmentKey)) continue; + if (initialVersionByFlow.has(attachmentKey)) continue; + const connectionIds = + getValueByAttachmentKeyOrFlowId( + attachedConnectionByFlow, + attachmentKey, + versionEntry.flowId, + ) ?? []; + const strictToolName = getScopedToolName( + toolNameByFlow, + attachmentKey, + versionEntry.flowId, + selectedVersionByFlow, + )?.trim(); + const resolvedToolName = + strictToolName || + getDefaultDeploymentToolName( + versionEntry.flowName ?? DEFAULT_FLOW_NAME, + versionEntry.versionId, + defaultToolNameScopeId, + ); + upsertFlows.push({ + flow_version_id: versionEntry.versionId, + add_app_ids: connectionIds, + remove_app_ids: [], + tool_name: resolvedToolName, + }); + } + + for (const [attachmentKey, versionEntry] of Array.from( + selectedVersionByFlow, + )) { + if (removedFlowIds.has(attachmentKey)) continue; + if (!initialVersionByFlow.has(attachmentKey)) continue; + const currentName = + getScopedToolName( + toolNameByFlow, + attachmentKey, + versionEntry.flowId, + selectedVersionByFlow, + )?.trim() ?? ""; + const originalName = + getScopedToolName( + initialToolNameByFlow, + attachmentKey, + versionEntry.flowId, + initialVersionByFlow, + )?.trim() ?? ""; + const nameChanged = currentName && currentName !== originalName; + + const currentConnections = + getValueByAttachmentKeyOrFlowId( + attachedConnectionByFlow, + attachmentKey, + versionEntry.flowId, + ) ?? []; + const originalConnections = + getValueByAttachmentKeyOrFlowId( + initialConnectionsByFlow, + attachmentKey, + versionEntry.flowId, + ) ?? []; + const originalSet = new Set(originalConnections); + const currentSet = new Set(currentConnections); + const addAppIds = currentConnections.filter((id) => !originalSet.has(id)); + const removeAppIds = originalConnections.filter( + (id) => !currentSet.has(id), + ); + const connectionsChanged = addAppIds.length > 0 || removeAppIds.length > 0; + + if (nameChanged || connectionsChanged) { + upsertFlows.push({ + flow_version_id: versionEntry.versionId, + add_app_ids: addAppIds, + remove_app_ids: removeAppIds, + ...(nameChanged && { tool_name: currentName }), + }); + } + } + + const removeFlows: string[] = []; + for (const attachmentKey of Array.from(removedFlowIds)) { + const originalVersion = initialVersionByFlow.get(attachmentKey); + if (originalVersion) { + removeFlows.push(originalVersion.versionId); + } + } + + const newConnectionIds = new Set(); + upsertFlows.forEach((flowItem) => { + flowItem.add_app_ids.forEach((id) => newConnectionIds.add(id)); + }); + const connectionPayloads = buildConnectionPayloads({ + connectionIds: newConnectionIds, + connections, + }); + + const llmToSend = selectedLlm; + if ( + llmToSend || + upsertFlows.length > 0 || + removeFlows.length > 0 || + connectionPayloads.length > 0 + ) { + const providerData: DeploymentUpdateProviderData = { + ...(llmToSend && { llm: llmToSend }), + ...(upsertFlows.length > 0 && { upsert_flows: upsertFlows }), + ...(removeFlows.length > 0 && { remove_flows: removeFlows }), + ...(connectionPayloads.length > 0 && { + connections: connectionPayloads, + }), + }; + result.provider_data = providerData; + } + + if (result.description === undefined && !result.provider_data) { + result.description = deploymentDescription; + } + + return result; +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/version-scope.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/version-scope.ts new file mode 100644 index 0000000000..6ee8ef85aa --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/version-scope.ts @@ -0,0 +1,56 @@ +import type { SelectedFlowVersion } from "../types"; +import { getSelectedFlowVersionKey } from "../types"; + +export function normalizeSelectedFlowVersions( + versions?: Map, +): Map { + const next = new Map(); + for (const [key, value] of versions ?? new Map()) { + const flowId = value.flowId ?? key; + const versionId = value.versionId; + const normalizedKey = value.flowId + ? getSelectedFlowVersionKey(flowId, versionId) + : key; + next.set(normalizedKey, { + key: normalizedKey, + flowId, + flowName: value.flowName, + versionId, + versionTag: value.versionTag, + }); + } + return next; +} + +export function getValueByAttachmentKeyOrFlowId( + map: Map, + attachmentKey: string, + flowId: string, +): T | undefined { + return map.get(attachmentKey) ?? map.get(flowId); +} + +export function getScopedValueForUniqueFlowVersion( + map: Map, + attachmentKey: string, + flowId: string, + flowVersionCount: number, +): T | undefined { + const strictValue = map.get(attachmentKey); + if (strictValue !== undefined) { + return strictValue; + } + + if (flowVersionCount > 1) { + return undefined; + } + + return map.get(flowId); +} + +export function getFlowVersionCount( + entries: Iterable<{ flowId?: string }>, + flowId: string, +) { + return Array.from(entries).filter((entry) => entry.flowId === flowId).length; +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/wxo-name.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/wxo-name.ts new file mode 100644 index 0000000000..f16c51fc80 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/helpers/wxo-name.ts @@ -0,0 +1,3 @@ +export function normalizeWxoName(value: string): string { + return value.replace(/[\s-]/g, "_").replace(/[^a-zA-Z0-9_]/g, ""); +} diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-connection-panel-state.test.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-connection-panel-state.test.ts index b65d13adb9..4aa26f3dfe 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-connection-panel-state.test.ts +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-connection-panel-state.test.ts @@ -5,7 +5,7 @@ import { useConnectionPanelState } from "../use-connection-panel-state"; const baseParams = () => ({ connections: [] as ConnectionItem[], setConnections: jest.fn(), - effectiveFlowId: "flow-1", + effectiveAttachmentKey: "flow-1", attachedConnectionByFlow: new Map(), onAttachConnection: jest.fn(), commitPendingAttachment: jest.fn(), @@ -81,7 +81,7 @@ describe("useConnectionPanelState", () => { it("does nothing when effectiveFlowId is null", () => { const params = baseParams(); - params.effectiveFlowId = null; + params.effectiveAttachmentKey = null; params.connections = [makeConnection({ id: "conn-1" })]; const { result } = renderHook(() => useConnectionPanelState(params)); @@ -186,7 +186,7 @@ describe("useConnectionPanelState", () => { const newList = updater([]); expect(newList[0].variableCount).toBe(1); expect(newList[0].environmentVariables).toEqual({ - API_KEY: "secret123", + API_KEY: "secret123", // pragma: allowlist secret }); }); @@ -501,7 +501,7 @@ describe("useConnectionPanelState", () => { it("still commits and switches panel when effectiveFlowId is null (but does not call onAttachConnection)", () => { const params = baseParams(); - params.effectiveFlowId = null; + params.effectiveAttachmentKey = null; const { result } = renderHook(() => useConnectionPanelState(params)); act(() => { diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/use-connection-panel-state.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/use-connection-panel-state.ts index 1a029c18a3..eaf019e80f 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/use-connection-panel-state.ts +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/use-connection-panel-state.ts @@ -11,7 +11,7 @@ import type { ConnectionItem, EnvVarEntry } from "../types"; interface UseConnectionPanelStateParams { connections: ConnectionItem[]; setConnections: Dispatch>; - effectiveFlowId: string | null; + effectiveAttachmentKey: string | null; attachedConnectionByFlow: Map; onAttachConnection: Dispatch>>; commitPendingAttachment: () => void; @@ -22,7 +22,7 @@ interface UseConnectionPanelStateParams { export function useConnectionPanelState({ connections, setConnections, - effectiveFlowId, + effectiveAttachmentKey, attachedConnectionByFlow, onAttachConnection, commitPendingAttachment, @@ -47,19 +47,19 @@ export function useConnectionPanelState({ }, [newConnectionName, connections]); const handleAttachConnection = useCallback(() => { - if (!effectiveFlowId) return; + if (!effectiveAttachmentKey) return; if (connectionTab === "available" && selectedConnections.size > 0) { commitPendingAttachment(); onAttachConnection((prev) => { const next = new Map(prev); - next.set(effectiveFlowId, Array.from(selectedConnections)); + next.set(effectiveAttachmentKey, Array.from(selectedConnections)); return next; }); setRightPanel("versions"); setSelectedConnections(new Set()); } }, [ - effectiveFlowId, + effectiveAttachmentKey, connectionTab, selectedConnections, onAttachConnection, @@ -103,17 +103,17 @@ export function useConnectionPanelState({ const handleSkipConnection = useCallback(() => { commitPendingAttachment(); - if (effectiveFlowId) { + if (effectiveAttachmentKey) { onAttachConnection((prev) => { const next = new Map(prev); - next.delete(effectiveFlowId); + next.delete(effectiveAttachmentKey); return next; }); } setRightPanel("versions"); setSelectedConnections(new Set()); }, [ - effectiveFlowId, + effectiveAttachmentKey, onAttachConnection, commitPendingAttachment, setRightPanel, @@ -169,9 +169,9 @@ export function useConnectionPanelState({ ); const initConnectionsForFlow = useCallback( - (flowId: string) => { + (attachmentKey: string) => { setSelectedConnections( - new Set(attachedConnectionByFlow.get(flowId) ?? []), + new Set(attachedConnectionByFlow.get(attachmentKey) ?? []), ); if (connections.length === 0) { setConnectionTab("create"); diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/types.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/types.ts index 62ba11a45f..073a70bd07 100644 --- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/types.ts +++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/types.ts @@ -1,5 +1,9 @@ export type DeploymentProviderType = "watsonx" | "kubernetes"; +export const DEFAULT_FLOW_NAME = "Flow"; +export const UNKNOWN_FLOW_NAME = "Unknown flow"; +export const WXO_PROVIDER_KEY = "watsonx-orchestrate"; + export interface EnvVarEntry { id: string; key: string; @@ -43,6 +47,47 @@ export interface ProviderCredentials { export type DeploymentType = "agent" | "mcp"; +export interface SelectedFlowVersion { + key: string; + flowId: string; + flowName?: string; + versionId: string; + versionTag: string; +} + +export function getSelectedFlowVersionKey(flowId: string, versionId: string) { + return `${flowId}:${versionId}`; +} + +function getShortIdentifier(value: string) { + const normalizedValue = value.trim(); + const compactValue = normalizedValue.includes("-") + ? normalizedValue.split("-").at(-1) || normalizedValue + : normalizedValue; + return compactValue.slice(0, 8) || "tool"; +} + +export function createDeploymentToolNameScopeId() { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} + +export function getDefaultDeploymentToolName( + flowName: string, + uniqueId: string, + scopeId?: string | null, +) { + const trimmedFlowName = flowName.trim() || DEFAULT_FLOW_NAME; + const shortId = getShortIdentifier(uniqueId); + const shortScopeId = scopeId ? getShortIdentifier(scopeId).slice(0, 6) : ""; + return shortScopeId + ? `${trimmedFlowName} ${shortScopeId}-${shortId}` + : `${trimmedFlowName} ${shortId}`; +} + export interface Deployment { id: string; provider_id?: string; diff --git a/src/frontend/tests/core/features/deployment-create.spec.ts b/src/frontend/tests/core/features/deployment-create.spec.ts index 67d0bf111f..bee9d306bc 100644 --- a/src/frontend/tests/core/features/deployment-create.spec.ts +++ b/src/frontend/tests/core/features/deployment-create.spec.ts @@ -29,8 +29,27 @@ async function setupDeploymentMocks( }); }); - // Snapshots (used for duplicate tool name check on review step) + // Snapshots (used for duplicate tool name check on review step). + // When snapshotsMock is SNAPSHOTS_DUPLICATE_MOCK, echo back the requested names + // as existing tools so the check works regardless of the scoped tool name format. await page.route("**/api/v1/deployments/snapshots**", (route) => { + if (snapshotsMock === SNAPSHOTS_DUPLICATE_MOCK) { + const url = new URL(route.request().url()); + const names = url.searchParams.getAll("names"); + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + provider_data: { + tools: names.map((name, i) => ({ id: `tool-${i}`, name })), + page: 1, + size: 50, + total: names.length, + }, + }), + }); + return; + } route.fulfill({ status: 200, contentType: "application/json",