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