feat(deployments): support multi-version flows (#12950)

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Viktor Avelino
2026-05-08 08:52:55 -04:00
committed by GitHub
parent 9ba0976ff2
commit 1dd95e2902
28 changed files with 2430 additions and 1588 deletions

View File

@ -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"]]]),

View File

@ -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(

View File

@ -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$/,
);
});
});

View File

@ -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 }) => (
<DeploymentStepperProvider
initialState={{
editingDeployment: mockDeployment,
selectedVersionByFlow: initialVersions,
initialLlm: "test-model",
initialToolNameByFlow: initialToolNames,
initialConnectionsByFlow: initialConnections,
}}
>
{children}
</DeploymentStepperProvider>
);
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<unknown> } | 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<unknown>;
};
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<unknown> } | 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<unknown> } | undefined)
?.upsert_flows ?? [];
expect(upsertFlows).toHaveLength(0);
});
});

View File

@ -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<string, ReturnType<typeof selectedVersion>>(),
attachedConnectionByFlow: new Map<string, string[]>(),
connections,
removedFlowIds: new Set<string>(),
onSelectFlow: jest.fn(),
onRemoveFlow: jest.fn(),
onUndoRemoveFlow: jest.fn(),
};
function renderPanel(overrides: Partial<typeof defaultProps> = {}) {
@ -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();
});
});

View File

@ -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<string, SelectedFlowVersion>(),
onAttach: jest.fn(),
onCreateFromDraft: jest.fn(),
onDetach: jest.fn(),
};
function renderPanel(overrides: Partial<typeof defaultProps> = {}) {
@ -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();

View File

@ -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<string, SelectedFlowVersion>();
const mockHandleSelectVersion = jest.fn();
let mockToolNameByFlow = new Map<string, string>();
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(<StepAttachFlows />);
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(<StepAttachFlows />);
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(<StepAttachFlows />);
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(<StepAttachFlows />);
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(<StepAttachFlows />);
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(<StepAttachFlows />);
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"],

View File

@ -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",
},
],
]),
},
[],
);

View File

@ -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"
/>
<span className="text-sm font-medium text-foreground">
{flowName ?? "Unknown flow"}
{flowName ?? UNKNOWN_FLOW_NAME}
</span>
<Badge
variant="secondaryStatic"

View File

@ -18,7 +18,14 @@ import {
useDeploymentStepper,
} from "../contexts/deployment-stepper-context";
import { useErrorAlert } from "../hooks/use-error-alert";
import type { Deployment, DeploymentProvider, ProviderAccount } from "../types";
import {
DEFAULT_FLOW_NAME,
type Deployment,
type DeploymentProvider,
getSelectedFlowVersionKey,
type ProviderAccount,
type SelectedFlowVersion,
} from "../types";
import DeploymentStepper, { CREATE_DEPLOYED_STEPS } from "./deployment-stepper";
import DeploymentStepperFooter from "./deployment-stepper-footer";
import DeploymentSuccessContent from "./deployment-success-content";
@ -90,33 +97,34 @@ export default function DeploymentStepperModal({
const editInitialState = useMemo(() => {
if (!isEditMode || !attachmentsData?.flow_versions) return null;
const versionMap = new Map<
string,
{ versionId: string; versionTag: string }
>();
const versionMap = new Map<string, SelectedFlowVersion>();
const toolNames = new Map<string, string>();
const connectionsByFlow = new Map<string, string[]>();
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 ? (
<div className="flex flex-1 items-center justify-center">
<span className="text-sm text-muted-foreground">
Loading deployment data...
</span>
</div>
) : (
<DeploymentStepperProvider
key={`${open}-${editingDeployment?.id ?? ""}-${initialProvider?.id ?? ""}-${initialInstance?.id ?? ""}`}
initialState={{
projectId: resolvedProjectId,
initialFlowId,
selectedVersionByFlow:
initialVersionByFlow ?? editInitialState?.versionMap,
initialProvider,
initialInstance,
initialStep: isEditMode
? 1
: initialProvider && initialInstance
? 2
: 1,
editingDeployment: editingDeployment ?? undefined,
initialLlm: editInitialState?.llm,
initialToolNameByFlow: editInitialState?.toolNames,
initialConnectionsByFlow: editInitialState?.connectionsByFlow,
}}
>
<DeploymentStepperProvider
key={`${open}-${editingDeployment?.id ?? ""}-${initialProvider?.id ?? ""}-${initialInstance?.id ?? ""}-${isLoadingEditData}`}
initialState={{
projectId: resolvedProjectId,
initialFlowId,
selectedVersionByFlow:
initialVersionByFlow ?? editInitialState?.versionMap,
initialProvider,
initialInstance,
initialStep: isEditMode
? 1
: initialProvider && initialInstance
? 2
: 1,
editingDeployment: editingDeployment ?? undefined,
initialLlm: editInitialState?.llm,
initialToolNameByFlow: editInitialState?.toolNames,
initialConnectionsByFlow: editInitialState?.connectionsByFlow,
}}
>
{isLoadingEditData ? (
<div className="flex flex-1 items-center justify-center">
<span className="text-sm text-muted-foreground">
Loading deployment data...
</span>
</div>
) : (
<DeploymentStepperModalContent
setOpen={setOpen}
onTestDeployment={onTestDeployment}
onDeployingChange={setIsDeploying}
/>
</DeploymentStepperProvider>
)}
)}
</DeploymentStepperProvider>
</DialogContent>
</Dialog>
);

View File

@ -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<string, { versionId: string; versionTag: string }>;
selectedVersionByFlow: Map<string, SelectedFlowVersion>;
attachedConnectionByFlow: Map<string, string[]>;
connections: ConnectionItem[];
removedFlowIds?: Set<string>;
onSelectFlow: (flowId: string) => void;
onRemoveFlow?: (flowId: string) => void;
onUndoRemoveFlow?: (flowId: string) => void;
}) {
return (
<div className="flex w-[280px] flex-shrink-0 flex-col border-r border-border">
@ -33,11 +29,21 @@ export const FlowListPanel = memo(function FlowListPanel({
</div>
<div className="flex-1 space-y-1 overflow-y-auto p-2">
{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({
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-semibold">
{flow.name}
</span>
{versionLabel && !isRemoved && (
{versionLabels.map((label) => (
<Badge
key={label}
variant="secondaryStatic"
size="tag"
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
>
{versionLabel}
{label}
</Badge>
)}
))}
{attached && !isRemoved && (
<Badge
variant="secondaryStatic"
size="tag"
className="bg-accent-blue-muted text-accent-blue-muted-foreground"
>
ATTACHED
{activeEntries.length === 1
? "1 VERSION"
: `${activeEntries.length} VERSIONS`}
</Badge>
)}
{isRemoved && (
@ -105,36 +114,13 @@ export const FlowListPanel = memo(function FlowListPanel({
{connectionNames.join(", ")}
</p>
)}
{removedEntries.length > 0 && !isRemoved && (
<p className="truncate text-xs text-muted-foreground">
{removedEntries.length} removed
</p>
)}
</div>
</div>
{attached && !isRemoved && onRemoveFlow && (
<button
type="button"
data-testid={`detach-flow-${flow.id}`}
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
title="Detach flow"
onClick={(e) => {
e.stopPropagation();
onRemoveFlow(flow.id);
}}
>
<ForwardedIconComponent name="X" className="h-3.5 w-3.5" />
</button>
)}
{isRemoved && onUndoRemoveFlow && (
<button
type="button"
data-testid={`undo-remove-flow-${flow.id}`}
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:bg-accent-blue-muted hover:text-accent-blue-muted-foreground"
title="Undo detach"
onClick={() => onUndoRemoveFlow(flow.id)}
>
<ForwardedIconComponent
name="Undo2"
className="h-3.5 w-3.5"
/>
</button>
)}
</div>
);
})}

View File

@ -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<string>(),
attachedConnectionByFlow = new Map<string, string[]>(),
connections = [],
}: {
selectedFlow: FlowType | undefined;
versions: FlowVersionEntry[];
isLoadingVersions: boolean;
isCreatingDraftVersion: boolean;
selectedVersionByFlow: Map<string, { versionId: string; versionTag: string }>;
selectedVersionByFlow: Map<string, SelectedFlowVersion>;
onAttach: (versionId: string) => void;
onCreateFromDraft: () => void;
onDetach: (attachmentKey: string) => void;
onUndoRemove?: (attachmentKey: string) => void;
removedFlowIds?: Set<string>;
attachedConnectionByFlow?: Map<string, string[]>;
connections?: ConnectionItem[];
}) {
if (!selectedFlow) {
return (
@ -41,8 +57,6 @@ export const VersionPanel = memo(function VersionPanel({
);
}
const attachedEntry = selectedVersionByFlow.get(selectedFlow.id);
return (
<>
<div className="border-b border-border p-4 text-sm text-muted-foreground">
@ -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 (
<button
<div
key={version.id}
type="button"
data-testid={`version-item-${version.id}`}
onClick={() => onAttach(version.id)}
role="button"
tabIndex={0}
onClick={() => {
if (!isRemoved) onAttach(version.id);
}}
onKeyDown={(event) => {
if (
(event.key === "Enter" || event.key === " ") &&
!isRemoved
) {
event.preventDefault();
onAttach(version.id);
}
}}
className={cn(
"flex w-full cursor-pointer items-center gap-4 rounded-xl border p-3 text-left transition-colors",
isAttachedVersion
"flex w-full items-center gap-4 rounded-xl border p-3 text-left transition-colors",
isAttachedVersion && !isRemoved
? "border-accent-blue-foreground bg-accent-blue-muted/40"
: "border-transparent bg-muted hover:border-border",
: isRemoved
? "border-destructive/40 bg-destructive/5 opacity-70"
: "border-transparent bg-muted hover:border-border",
)}
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
@ -97,7 +136,7 @@ export const VersionPanel = memo(function VersionPanel({
description={version.description}
className="truncate"
/>
{isAttachedVersion && (
{isAttachedVersion && !isRemoved && (
<Badge
variant="secondaryStatic"
size="tag"
@ -106,12 +145,82 @@ export const VersionPanel = memo(function VersionPanel({
ATTACHED
</Badge>
)}
{isRemoved && (
<Badge
variant="secondaryStatic"
size="tag"
className="shrink-0 bg-destructive/10 text-destructive"
>
REMOVED
</Badge>
)}
</span>
<span className="text-xxs leading-tight text-muted-foreground">
Created: {formatDate(version.created_at)}
</span>
{connectionNames.length > 0 && !isRemoved && (
<span className="truncate text-xxs leading-tight text-muted-foreground">
{connectionNames.join(", ")}
</span>
)}
</span>
</button>
{isRemoved && onUndoRemove ? (
<button
type="button"
className="rounded p-1 text-muted-foreground hover:bg-accent-blue-muted hover:text-accent-blue-muted-foreground"
data-testid={`undo-version-${version.id}`}
onClick={(event) => {
event.stopPropagation();
onUndoRemove(attachmentKey);
}}
>
<ForwardedIconComponent
name="Undo2"
className="h-3.5 w-3.5"
/>
</button>
) : isAttachedVersion ? (
<>
<button
type="button"
className="rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
data-testid={`edit-version-${version.id}`}
onClick={(event) => {
event.stopPropagation();
onAttach(version.id);
}}
>
Edit
</button>
<button
type="button"
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
data-testid={`detach-version-${version.id}`}
onClick={(event) => {
event.stopPropagation();
onDetach(attachmentKey);
}}
>
<ForwardedIconComponent
name="X"
className="h-3.5 w-3.5"
/>
</button>
</>
) : (
<button
type="button"
className="rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
data-testid={`attach-version-${version.id}`}
onClick={(event) => {
event.stopPropagation();
onAttach(version.id);
}}
>
Attach
</button>
)}
</div>
);
})}
</div>

View File

@ -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<string | null>(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}
/>
) : (
<ConnectionPanel

View File

@ -1,96 +1,19 @@
import { keepPreviousData } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { useCheckToolNames } from "@/controllers/API/queries/deployments";
import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows/use-get-refresh-flows-query";
import { useFolderStore } from "@/stores/foldersStore";
import { useDeploymentStepper } from "../contexts/deployment-stepper-context";
function normalizeWxoName(s: string): string {
return s.replace(/[\s-]/g, "_").replace(/[^a-zA-Z0-9_]/g, "");
}
function EditableToolName({
value,
placeholder,
onSave,
}: {
value: string;
placeholder: string;
onSave: (name: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex items-center gap-1.5">
<Input
ref={inputRef}
className="h-7 w-48 text-sm"
value={draft}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onBlur={confirm}
onKeyDown={(e) => {
if (e.key === "Enter") confirm();
if (e.key === "Escape") cancel();
}}
data-testid="tool-name-input"
/>
<button
type="button"
onClick={confirm}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
title="Confirm"
>
<ForwardedIconComponent name="Check" className="h-3.5 w-3.5" />
</button>
</div>
);
}
return (
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
{value || placeholder}
</span>
<button
type="button"
onClick={() => {
setDraft(value);
setEditing(true);
}}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
title="Edit tool name"
data-testid="edit-tool-name"
>
<ForwardedIconComponent name="Pencil" className="h-3.5 w-3.5" />
</button>
</div>
);
}
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<string, string>();
const batchNames = new Map<string, string>(); // 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() {
</p>
</div>
<div className="rounded-xl border border-border bg-background p-4">
<div className="grid grid-cols-2 gap-6">
{/* Deployment column */}
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-foreground">
Deployment
</span>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="w-10 text-xs text-muted-foreground">Type</span>
<div className="flex items-center gap-1.5">
<ForwardedIconComponent
name={deploymentType === "agent" ? "Bot" : "Server"}
className="h-3.5 w-3.5 text-muted-foreground"
/>
<span className="text-sm text-foreground capitalize">
{deploymentType}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="w-10 text-xs text-muted-foreground">Name</span>
<span className="text-sm text-foreground">
{deploymentName || "—"}
</span>
</div>
{selectedLlm && (
<div className="flex items-center gap-2">
<span className="w-10 text-xs text-muted-foreground">
Model
</span>
<span className="text-sm text-foreground">{selectedLlm}</span>
</div>
)}
</div>
</div>
<ReviewSummaryCard
deploymentName={deploymentName}
deploymentType={deploymentType}
reviewFlows={reviewFlows}
selectedLlm={selectedLlm}
/>
{/* Attached Flows column */}
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-foreground">
Attached Flows
</span>
<div className="flex flex-col gap-1.5">
{reviewFlows.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
) : (
reviewFlows.map((item) => (
<div key={item.flowId} className="flex items-center gap-1.5">
<ForwardedIconComponent
name="Workflow"
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<span className="text-sm text-foreground">
{item.flowName}
</span>
<Badge
variant="secondaryStatic"
size="tag"
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
>
{item.versionLabel}
</Badge>
</div>
))
)}
</div>
</div>
</div>
</div>
{/* Configuration section scoped per flow */}
{reviewFlows.length > 0 && (
<div className="flex flex-col gap-3">
{reviewFlows.map((item) => {
const toolError = toolNameErrors.get(item.flowId);
return (
<div
key={item.flowId}
className={`rounded-xl border bg-background p-4 ${toolError ? "border-destructive/50" : "border-border"}`}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ForwardedIconComponent
name="Wrench"
className={`h-3.5 w-3.5 shrink-0 ${toolError ? "text-destructive" : "text-muted-foreground"}`}
/>
<EditableToolName
value={toolNameByFlow.get(item.flowId)?.trim() ?? ""}
placeholder={item.flowName}
onSave={(name) => {
setToolNameByFlow((prev) => {
const next = new Map(prev);
if (name.trim()) {
next.set(item.flowId, name.trim());
} else {
next.delete(item.flowId);
}
return next;
});
}}
/>
</div>
<div className="flex items-center gap-2 pl-5">
<ForwardedIconComponent
name="Workflow"
className="h-3 w-3 shrink-0 text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">
{item.flowName}
</span>
<Badge
variant="secondaryStatic"
size="tag"
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
>
{item.versionLabel}
</Badge>
</div>
</div>
{item.connectionDetails.length > 0 &&
(() => {
const newConns = item.connectionDetails.filter(
(c) => c.isNew,
);
const existingConns = item.connectionDetails.filter(
(c) => !c.isNew,
);
return (
<div className="flex flex-col gap-4">
{existingConns.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
Existing Connections
</span>
{existingConns.map((conn) => (
<span
key={conn.name}
className="text-xs font-medium text-foreground"
>
{conn.name}
</span>
))}
</div>
)}
{newConns.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
New Connections
</span>
{newConns.map((conn) => (
<div
key={conn.name}
className="flex flex-col gap-1.5"
>
<span className="text-xs font-medium text-foreground">
{conn.name}
</span>
{conn.envVars.length > 0 && (
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-md border border-border">
{conn.envVars.map(({ key, masked }) => (
<div
key={key}
className="flex items-center justify-between bg-muted/40 px-3 py-1.5"
>
<span className="font-mono text-xs text-foreground">
{key}
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
=
</span>
<span className="font-mono text-xs text-muted-foreground">
{masked}
</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
})()}
{toolError && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
<ForwardedIconComponent
name="AlertTriangle"
className="h-3.5 w-3.5 shrink-0 text-destructive"
/>
<span className="text-xs text-destructive">
{toolError}
</span>
</div>
)}
</div>
</div>
<ReviewFlowConfigCard
key={item.attachmentKey}
item={item}
toolError={toolNameErrors.get(item.attachmentKey)}
toolNameValue={
toolNameByFlow.get(item.attachmentKey)?.trim() ?? ""
}
onSaveToolName={(name) => {
setToolNameByFlow((prev) => {
const next = new Map(prev);
if (name.trim()) {
next.set(item.attachmentKey, name.trim());
} else {
next.delete(item.attachmentKey);
}
return next;
});
}}
/>
);
})}
</div>
)}
{/* Existing provider tools */}
{/* Detaching section (edit mode) */}
{isEditMode && removedFlowIds.size > 0 && (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-destructive">
Detaching
</span>
<div className="flex flex-col gap-2">
{Array.from(removedFlowIds).map((flowId) => {
const flow = allFlows.find((f) => f.id === flowId);
return (
<div
key={flowId}
className="flex items-center gap-2 rounded-lg border border-destructive/20 bg-background p-3"
>
<ForwardedIconComponent
name="Workflow"
className="h-3.5 w-3.5 shrink-0 text-destructive/60"
/>
<span className="text-sm text-foreground">
{flow?.name ?? "Unknown flow"}
</span>
<Badge
variant="secondaryStatic"
size="tag"
className="bg-destructive/10 text-destructive"
>
removing
</Badge>
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
These tools will be detached from the agent. They will remain
available on your provider tenant.
</p>
</div>
</div>
{isEditMode && (
<ReviewDetachingSection removedFlows={removedReviewFlows} />
)}
</div>
);

View File

@ -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<HTMLInputElement>(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 (
<div className="flex items-center gap-1.5">
<Input
ref={inputRef}
className="h-7 w-48 text-sm"
data-testid="tool-name-input"
placeholder={placeholder}
value={draft}
onBlur={confirm}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") confirm();
if (event.key === "Escape") cancel();
}}
/>
<button
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
title="Confirm"
type="button"
onClick={confirm}
>
<ForwardedIconComponent name="Check" className="h-3.5 w-3.5" />
</button>
</div>
);
}
return (
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
{value || placeholder}
</span>
<button
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
data-testid="edit-tool-name"
title="Edit tool name"
type="button"
onClick={() => {
setDraft(value);
setEditing(true);
}}
>
<ForwardedIconComponent name="Pencil" className="h-3.5 w-3.5" />
</button>
</div>
);
}

View File

@ -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 (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-destructive">Detaching</span>
<div className="flex flex-col gap-2">
{removedFlows.map((flow) => (
<div
key={flow.attachmentKey}
className="flex items-center gap-2 rounded-lg border border-destructive/20 bg-background p-3"
>
<ForwardedIconComponent
name="Workflow"
className="h-3.5 w-3.5 shrink-0 text-destructive/60"
/>
<span className="text-sm text-foreground">{flow.flowName}</span>
<Badge
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
size="tag"
variant="secondaryStatic"
>
{flow.versionLabel}
</Badge>
<Badge
className="bg-destructive/10 text-destructive"
size="tag"
variant="secondaryStatic"
>
removing
</Badge>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
These tools will be detached from the agent. They will remain
available on your provider tenant.
</p>
</div>
</div>
);
}

View File

@ -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 (
<div
className={`rounded-xl border bg-background p-4 ${toolError ? "border-destructive/50" : "border-border"}`}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ForwardedIconComponent
name="Wrench"
className={`h-3.5 w-3.5 shrink-0 ${toolError ? "text-destructive" : "text-muted-foreground"}`}
/>
<EditableToolName
onSave={onSaveToolName}
placeholder={item.defaultToolName}
value={toolNameValue}
/>
</div>
<div className="flex items-center gap-2 pl-5">
<ForwardedIconComponent
name="Workflow"
className="h-3 w-3 shrink-0 text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">
{item.flowName}
</span>
<Badge
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
size="tag"
variant="secondaryStatic"
>
{item.versionLabel}
</Badge>
</div>
</div>
{item.connectionDetails.length > 0 && (
<div className="flex flex-col gap-4">
{existingConnections.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
Existing Connections
</span>
{existingConnections.map((connection) => (
<span
key={connection.name}
className="text-xs font-medium text-foreground"
>
{connection.name}
</span>
))}
</div>
)}
{newConnections.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
New Connections
</span>
{newConnections.map((connection) => (
<div key={connection.name} className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">
{connection.name}
</span>
{connection.envVars.length > 0 && (
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-md border border-border">
{connection.envVars.map(({ key, masked }) => (
<div
key={key}
className="flex items-center justify-between bg-muted/40 px-3 py-1.5"
>
<span className="font-mono text-xs text-foreground">
{key}
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">=</span>
<span className="font-mono text-xs text-muted-foreground">
{masked}
</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{toolError && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
<ForwardedIconComponent
name="AlertTriangle"
className="h-3.5 w-3.5 shrink-0 text-destructive"
/>
<span className="text-xs text-destructive">{toolError}</span>
</div>
)}
</div>
</div>
);
}

View File

@ -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 (
<div className="rounded-xl border border-border bg-background p-4">
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-foreground">
Deployment
</span>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="w-10 text-xs text-muted-foreground">Type</span>
<div className="flex items-center gap-1.5">
<ForwardedIconComponent
name={deploymentType === "agent" ? "Bot" : "Server"}
className="h-3.5 w-3.5 text-muted-foreground"
/>
<span className="text-sm capitalize text-foreground">
{deploymentType}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="w-10 text-xs text-muted-foreground">Name</span>
<span className="text-sm text-foreground">
{deploymentName || "—"}
</span>
</div>
{selectedLlm && (
<div className="flex items-center gap-2">
<span className="w-10 text-xs text-muted-foreground">
Model
</span>
<span className="text-sm text-foreground">{selectedLlm}</span>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-foreground">
Attached Flows
</span>
<div className="flex flex-col gap-1.5">
{reviewFlows.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
) : (
reviewFlows.map((item) => (
<div
key={item.attachmentKey}
className="flex items-center gap-1.5"
>
<ForwardedIconComponent
name="Workflow"
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<span className="text-sm text-foreground">
{item.flowName}
</span>
<Badge
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
size="tag"
variant="secondaryStatic"
>
{item.versionLabel}
</Badge>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -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[];
}

View File

@ -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<string, string>,
attachmentKey: string,
flowId: string,
items: Array<{ attachmentKey: string; flowId: string }>,
) {
return getScopedValueForUniqueFlowVersion(
toolNameByFlow,
attachmentKey,
flowId,
getFlowVersionCount(items, flowId),
)?.trim();
}
function getInitialToolNameForReview(
initialToolNameByFlow: Map<string, string>,
attachmentKey: string,
flowId: string,
items: ReviewFlowItem[],
) {
return getScopedValueForUniqueFlowVersion(
initialToolNameByFlow,
attachmentKey,
flowId,
getFlowVersionCount(items, flowId),
);
}
interface BuildReviewFlowsParams {
allFlows: Array<{ id: string; name: string }>;
attachedConnectionByFlow: Map<string, string[]>;
connections: ConnectionItem[];
defaultToolNameScopeId: string | null;
removedFlowIds: Set<string>;
selectedVersionByFlow: Map<
string,
{
key?: string;
flowId?: string;
flowName?: string;
versionId: string;
versionTag: string;
}
>;
toolNameByFlow: Map<string, string>;
}
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<string, string>;
isEditMode: boolean;
preExistingFlowIds: Set<string>;
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<string>;
initialToolNameByFlow: Map<string, string>;
isEditMode: boolean;
preExistingFlowIds: Set<string>;
reviewFlows: ReviewFlowItem[];
}
export function buildToolNameErrors({
existingToolNames,
initialToolNameByFlow,
isEditMode,
preExistingFlowIds,
reviewFlows,
}: BuildToolNameErrorsParams) {
const errors = new Map<string, string>();
const batchNames = new Map<string, string>();
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;
}

View File

@ -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<string, SelectedFlowVersion>;
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<string, string>;
/** Pre-populated connection assignments from provider (edit mode). Key = flowId. */
/** Pre-populated connection assignments from provider (edit mode). Key = attachment key. */
initialConnectionsByFlow?: Map<string, string[]>;
}
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<SetStateAction<ConnectionItem[]>>;
selectedVersionByFlow: Map<string, { versionId: string; versionTag: string }>;
handleSelectVersion: (
flowId: string,
versionId: string,
versionTag: string,
) => void;
selectedVersionByFlow: Map<string, SelectedFlowVersion>;
handleSelectVersion: (params: SelectFlowVersionParams) => void;
attachedConnectionByFlow: Map<string, string[]>;
setAttachedConnectionByFlow: Dispatch<SetStateAction<Map<string, string[]>>>;
/** User-provided tool names per flow. Key = flowId. */
/** User-provided tool names per attached version. Key = attachment key. */
toolNameByFlow: Map<string, string>;
setToolNameByFlow: Dispatch<SetStateAction<Map<string, string>>>;
/** 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<string, string>;
/** 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<string>;
/** 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<string>;
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<string, string>(),
[initialState?.initialToolNameByFlow],
);
const normalizedInitialConnections = useMemo(
() => initialState?.initialConnectionsByFlow ?? new Map<string, string[]>(),
[initialState?.initialConnectionsByFlow],
);
const [selectedVersionByFlow, setSelectedVersionByFlow] = useState<
Map<string, { versionId: string; versionTag: string }>
>(initialState?.selectedVersionByFlow ?? new Map());
Map<string, SelectedFlowVersion>
>(normalizedInitialVersions);
const [connections, setConnections] = useState<ConnectionItem[]>([]);
const [toolNameByFlow, setToolNameByFlow] = useState<Map<string, string>>(
initialState?.initialToolNameByFlow ?? new Map(),
normalizedInitialToolNames,
);
const [defaultToolNameScopeId] = useState<string | null>(() =>
isEditMode
? (editingDeployment?.id ?? createDeploymentToolNameScopeId())
: createDeploymentToolNameScopeId(),
);
const [attachedConnectionByFlow, setAttachedConnectionByFlow] = useState<
Map<string, string[]>
>(initialState?.initialConnectionsByFlow ?? new Map());
>(normalizedInitialConnections);
const [hasToolNameErrors, setHasToolNameErrors] = useState(false);
const trimmedDeploymentName = deploymentName.trim();
@ -188,67 +211,55 @@ export function DeploymentStepperProvider({
// Edit mode: track which pre-existing flows the user wants to detach.
const [removedFlowIds, setRemovedFlowIds] = useState<Set<string>>(new Set());
// Cache removed flow data so undo can restore it.
const initialVersionByFlow = useMemo(
() => initialState?.selectedVersionByFlow ?? new Map(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const initialVersionByFlow = normalizedInitialVersions;
const preExistingFlowIds = useMemo(
() => new Set(initialVersionByFlow.keys()),
[initialVersionByFlow],
);
const initialToolNameByFlow = useMemo(
() => initialState?.initialToolNameByFlow ?? new Map<string, string>(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const initialConnectionsByFlow = useMemo(
() => initialState?.initialConnectionsByFlow ?? new Map<string, string[]>(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
const initialToolNameByFlow = normalizedInitialToolNames;
const initialConnectionsByFlow = normalizedInitialConnections;
const handleRemoveAttachedFlow = useCallback(
(attachmentKeyOrFlowId: string) => {
const resolvedKey = selectedVersionByFlow.has(attachmentKeyOrFlowId)
? attachmentKeyOrFlowId
: Array.from(selectedVersionByFlow.values()).find(
(entry) => entry.flowId === attachmentKeyOrFlowId,
)?.key;
if (!resolvedKey) return;
if (preExistingFlowIds.has(resolvedKey)) {
setRemovedFlowIds(
(prev) => new Set([...Array.from(prev), resolvedKey]),
);
return;
}
setSelectedVersionByFlow((prev) => {
const next = new Map(prev);
next.delete(resolvedKey);
return next;
});
setToolNameByFlow((prev) => {
const next = new Map(prev);
next.delete(resolvedKey);
return next;
});
setAttachedConnectionByFlow((prev) => {
const next = new Map(prev);
next.delete(resolvedKey);
return next;
});
},
[preExistingFlowIds, selectedVersionByFlow],
);
const handleRemoveAttachedFlow = useCallback((flowId: string) => {
setRemovedFlowIds((prev) => new Set([...Array.from(prev), flowId]));
setSelectedVersionByFlow((prev) => {
const next = new Map(prev);
next.delete(flowId);
return next;
});
setAttachedConnectionByFlow((prev) => {
const next = new Map(prev);
next.delete(flowId);
const handleUndoRemoveFlow = useCallback((attachmentKey: string) => {
setRemovedFlowIds((prev) => {
const next = new Set(prev);
next.delete(attachmentKey);
return next;
});
}, []);
const handleUndoRemoveFlow = useCallback(
(flowId: string) => {
setRemovedFlowIds((prev) => {
const next = new Set(prev);
next.delete(flowId);
return next;
});
const originalVersion = initialVersionByFlow.get(flowId);
if (originalVersion) {
setSelectedVersionByFlow((prev) => {
const next = new Map(prev);
next.set(flowId, originalVersion);
return next;
});
}
const originalConnections = initialConnectionsByFlow.get(flowId);
if (originalConnections) {
setAttachedConnectionByFlow((prev) => {
const next = new Map(prev);
next.set(flowId, originalConnections);
return next;
});
}
},
[initialVersionByFlow, initialConnectionsByFlow],
);
const hasValidCredentials =
credentials.name.trim() !== "" &&
credentials.api_key.trim() !== "" &&
@ -320,10 +331,17 @@ export function DeploymentStepperProvider({
}, []);
const handleSelectVersion = useCallback(
(flowId: string, versionId: string, versionTag: string) => {
({ flowId, flowName, versionId, versionTag }: SelectFlowVersionParams) => {
setSelectedVersionByFlow((prev) => {
const next = new Map(prev);
next.set(flowId, { versionId, versionTag });
const key = getSelectedFlowVersionKey(flowId, versionId);
next.set(key, {
key,
flowId,
flowName,
versionId,
versionTag,
});
return next;
});
},
@ -333,224 +351,79 @@ export function DeploymentStepperProvider({
const needsProviderAccountCreation =
selectedInstance === null && hasValidCredentials;
const buildProviderAccountPayload =
useCallback((): ProviderAccountCreateRequest | null => {
if (!hasValidCredentials) return null;
return {
name: credentials.name.trim(),
provider_key: "watsonx-orchestrate",
provider_data: {
url: credentials.url.trim(),
api_key: credentials.api_key.trim(),
},
};
}, [credentials, hasValidCredentials]);
const buildConnectionPayloads = useCallback(
(
connectionIds: Iterable<string>,
): DeploymentCreateRequest["provider_data"]["connections"] => {
const payloads: DeploymentCreateRequest["provider_data"]["connections"] =
[];
const uniqueIds = Array.from(new Set(connectionIds));
for (const id of uniqueIds) {
const conn = connections.find((item) => item.id === id);
if (!conn?.isNew) continue;
const credentials: DeploymentConnectionPayload["credentials"] =
Object.entries(conn.environmentVariables).map(([key, value]) => {
const isGlobalVar = conn.globalVarKeys?.has(key) ?? false;
return {
key,
value,
source: isGlobalVar ? "variable" : "raw",
};
});
payloads.push({
app_id: id,
credentials,
});
}
return payloads;
},
[connections],
const buildProviderAccountPayload = useCallback(
() =>
buildProviderAccountPayloadValue({
credentials,
hasValidCredentials,
}),
[credentials, hasValidCredentials],
);
const buildDeploymentPayload = useCallback(
(providerId: string): DeploymentCreateRequest => {
if (!isDeploymentNameValid) {
throw new Error("Deployment name must start with a letter");
}
const allConnectionIds = new Set<string>();
Array.from(attachedConnectionByFlow.values()).forEach((ids) => {
ids.forEach((id) => allConnectionIds.add(id));
});
const addFlows: DeploymentCreateRequest["provider_data"]["add_flows"] =
[];
for (const [flowId, versionEntry] of Array.from(selectedVersionByFlow)) {
const connectionIds = attachedConnectionByFlow.get(flowId) ?? [];
const customToolName = toolNameByFlow.get(flowId)?.trim();
addFlows.push({
flow_version_id: versionEntry.versionId,
app_ids: connectionIds,
...(customToolName && { tool_name: customToolName }),
});
}
const connectionPayloads = buildConnectionPayloads(allConnectionIds);
return {
provider_id: providerId,
...(initialState?.projectId
? { project_id: initialState.projectId }
: {}),
name: trimmedDeploymentName,
description: deploymentDescription,
type: deploymentType,
provider_data: {
llm: selectedLlm,
add_flows: addFlows,
connections: connectionPayloads,
},
};
},
(providerId: string): DeploymentCreateRequest =>
buildDeploymentPayloadValue({
attachedConnectionByFlow,
connections,
defaultToolNameScopeId,
deploymentDescription,
deploymentName,
deploymentType,
isDeploymentNameValid,
projectId: initialState?.projectId,
providerId,
removedFlowIds,
selectedLlm,
selectedVersionByFlow,
toolNameByFlow,
}),
[
attachedConnectionByFlow,
buildConnectionPayloads,
initialState?.projectId,
connections,
defaultToolNameScopeId,
deploymentDescription,
deploymentName,
deploymentType,
initialState?.projectId,
isDeploymentNameValid,
removedFlowIds,
selectedLlm,
selectedVersionByFlow,
trimmedDeploymentName,
toolNameByFlow,
],
);
const buildDeploymentUpdatePayload =
useCallback((): DeploymentUpdateRequest => {
if (!editingDeployment) {
throw new Error(
"buildDeploymentUpdatePayload called outside edit mode",
);
}
if (!isDeploymentNameValid) {
throw new Error("Deployment name must start with a letter");
}
const result: DeploymentUpdateRequest = {
deployment_id: editingDeployment.id,
};
// Metadata changes (description only — name is not editable after creation).
const descriptionChanged =
deploymentDescription !== (editingDeployment.description ?? "");
if (descriptionChanged) {
result.description = deploymentDescription;
}
const upsertFlows: DeploymentUpdateFlowItem[] = [];
// New flows attached during this edit session.
for (const [flowId, versionEntry] of Array.from(selectedVersionByFlow)) {
if (initialVersionByFlow.has(flowId)) continue;
const connectionIds = attachedConnectionByFlow.get(flowId) ?? [];
const customToolName = toolNameByFlow.get(flowId)?.trim();
upsertFlows.push({
flow_version_id: versionEntry.versionId,
add_app_ids: connectionIds,
remove_app_ids: [],
...(customToolName && { tool_name: customToolName }),
});
}
// Changes on pre-existing flows (tool name and/or connections).
for (const [flowId, versionEntry] of Array.from(selectedVersionByFlow)) {
if (!initialVersionByFlow.has(flowId)) continue;
const currentName = toolNameByFlow.get(flowId)?.trim() ?? "";
const originalName = initialToolNameByFlow.get(flowId)?.trim() ?? "";
const nameChanged = currentName && currentName !== originalName;
const currentConnections = attachedConnectionByFlow.get(flowId) ?? [];
const originalConnections = initialConnectionsByFlow.get(flowId) ?? [];
const originalSet = new Set(originalConnections);
const currentSet = new Set(currentConnections);
const addAppIds = currentConnections.filter(
(id) => !originalSet.has(id),
);
const removeAppIds = originalConnections.filter(
(id) => !currentSet.has(id),
);
const connectionsChanged =
addAppIds.length > 0 || removeAppIds.length > 0;
if (nameChanged || connectionsChanged) {
upsertFlows.push({
flow_version_id: versionEntry.versionId,
add_app_ids: addAppIds,
remove_app_ids: removeAppIds,
...(nameChanged && { tool_name: currentName }),
});
}
}
const removeFlows: string[] = [];
for (const flowId of Array.from(removedFlowIds)) {
const originalVersion = initialVersionByFlow.get(flowId);
if (originalVersion) {
removeFlows.push(originalVersion.versionId);
}
}
// Collect connection details for newly added binds only.
const newConnectionIds = new Set<string>();
upsertFlows.forEach((flowItem) => {
flowItem.add_app_ids.forEach((id) => newConnectionIds.add(id));
return buildDeploymentUpdatePayloadValue({
attachedConnectionByFlow,
connections,
defaultToolNameScopeId,
deploymentDescription,
editingDeployment,
initialConnectionsByFlow,
initialToolNameByFlow,
initialVersionByFlow,
isDeploymentNameValid,
removedFlowIds,
selectedLlm,
selectedVersionByFlow,
toolNameByFlow,
});
const connectionPayloads = buildConnectionPayloads(newConnectionIds);
const llmToSend = selectedLlm;
if (
llmToSend ||
upsertFlows.length > 0 ||
removeFlows.length > 0 ||
connectionPayloads.length > 0
) {
const providerData: DeploymentUpdateProviderData = {
...(llmToSend && { llm: llmToSend }),
...(upsertFlows.length > 0 && { upsert_flows: upsertFlows }),
...(removeFlows.length > 0 && { remove_flows: removeFlows }),
...(connectionPayloads.length > 0 && {
connections: connectionPayloads,
}),
};
result.provider_data = providerData;
}
// Backend requires at least one field.
if (result.description === undefined && !result.provider_data) {
result.description = deploymentDescription;
}
return result;
}, [
editingDeployment,
attachedConnectionByFlow,
connections,
defaultToolNameScopeId,
deploymentDescription,
isDeploymentNameValid,
selectedLlm,
initialVersionByFlow,
initialToolNameByFlow,
editingDeployment,
initialConnectionsByFlow,
initialToolNameByFlow,
initialVersionByFlow,
isDeploymentNameValid,
removedFlowIds,
selectedLlm,
selectedVersionByFlow,
toolNameByFlow,
attachedConnectionByFlow,
buildConnectionPayloads,
]);
const value = useMemo<DeploymentStepperContextType>(
@ -587,6 +460,7 @@ export function DeploymentStepperProvider({
toolNameByFlow,
setToolNameByFlow,
initialToolNameByFlow,
defaultToolNameScopeId,
attachedConnectionByFlow,
setAttachedConnectionByFlow,
preExistingFlowIds,
@ -628,6 +502,7 @@ export function DeploymentStepperProvider({
handleSelectVersion,
toolNameByFlow,
initialToolNameByFlow,
defaultToolNameScopeId,
attachedConnectionByFlow,
preExistingFlowIds,
removedFlowIds,

View File

@ -0,0 +1,352 @@
import type { ProviderAccountCreateRequest } from "@/controllers/API/queries/deployment-provider-accounts/use-post-provider-account";
import type {
DeploymentUpdateFlowItem,
DeploymentUpdateProviderData,
DeploymentUpdateRequest,
} from "@/controllers/API/queries/deployments/use-patch-deployment";
import type {
DeploymentConnectionPayload,
DeploymentCreateRequest,
} from "@/controllers/API/queries/deployments/use-post-deployment";
import type {
ConnectionItem,
Deployment,
DeploymentType,
ProviderCredentials,
SelectedFlowVersion,
} from "../types";
import {
DEFAULT_FLOW_NAME,
getDefaultDeploymentToolName,
WXO_PROVIDER_KEY,
} from "../types";
import {
getFlowVersionCount,
getScopedValueForUniqueFlowVersion,
getValueByAttachmentKeyOrFlowId,
} from "./version-scope";
function getScopedToolName(
map: Map<string, string>,
attachmentKey: string,
flowId: string,
versionMap: Map<string, SelectedFlowVersion>,
): string | undefined {
return getScopedValueForUniqueFlowVersion(
map,
attachmentKey,
flowId,
getFlowVersionCount(versionMap.values(), flowId),
);
}
export function buildProviderAccountPayload({
credentials,
hasValidCredentials,
}: {
credentials: ProviderCredentials;
hasValidCredentials: boolean;
}): ProviderAccountCreateRequest | null {
if (!hasValidCredentials) return null;
return {
name: credentials.name.trim(),
provider_key: WXO_PROVIDER_KEY,
provider_data: {
url: credentials.url.trim(),
api_key: credentials.api_key.trim(),
},
};
}
export function buildConnectionPayloads({
connectionIds,
connections,
}: {
connectionIds: Iterable<string>;
connections: ConnectionItem[];
}): DeploymentCreateRequest["provider_data"]["connections"] {
const payloads: DeploymentCreateRequest["provider_data"]["connections"] = [];
const uniqueIds = Array.from(new Set(connectionIds));
for (const id of uniqueIds) {
const conn = connections.find((item) => item.id === id);
if (!conn?.isNew) continue;
const credentials: DeploymentConnectionPayload["credentials"] =
Object.entries(conn.environmentVariables).map(([key, value]) => {
const isGlobalVar = conn.globalVarKeys?.has(key) ?? false;
return {
key,
value,
source: isGlobalVar ? "variable" : "raw",
};
});
payloads.push({
app_id: id,
credentials,
});
}
return payloads;
}
export function buildDeploymentPayload({
attachedConnectionByFlow,
connections,
defaultToolNameScopeId,
deploymentDescription,
deploymentName,
deploymentType,
isDeploymentNameValid,
projectId,
providerId,
removedFlowIds,
selectedLlm,
selectedVersionByFlow,
toolNameByFlow,
}: {
attachedConnectionByFlow: Map<string, string[]>;
connections: ConnectionItem[];
defaultToolNameScopeId: string | null;
deploymentDescription: string;
deploymentName: string;
deploymentType: DeploymentType;
isDeploymentNameValid: boolean;
projectId?: string;
providerId: string;
removedFlowIds: Set<string>;
selectedLlm: string;
selectedVersionByFlow: Map<string, SelectedFlowVersion>;
toolNameByFlow: Map<string, string>;
}): DeploymentCreateRequest {
if (!isDeploymentNameValid) {
throw new Error("Deployment name must start with a letter");
}
const allConnectionIds = new Set<string>();
Array.from(attachedConnectionByFlow.values()).forEach((ids) => {
ids.forEach((id) => allConnectionIds.add(id));
});
const addFlows: DeploymentCreateRequest["provider_data"]["add_flows"] = [];
for (const [attachmentKey, versionEntry] of Array.from(
selectedVersionByFlow,
)) {
if (removedFlowIds.has(attachmentKey)) continue;
const connectionIds =
getValueByAttachmentKeyOrFlowId(
attachedConnectionByFlow,
attachmentKey,
versionEntry.flowId,
) ?? [];
const strictToolName = getScopedToolName(
toolNameByFlow,
attachmentKey,
versionEntry.flowId,
selectedVersionByFlow,
)?.trim();
const resolvedToolName =
strictToolName ||
getDefaultDeploymentToolName(
versionEntry.flowName ?? DEFAULT_FLOW_NAME,
versionEntry.versionId,
defaultToolNameScopeId,
);
addFlows.push({
flow_version_id: versionEntry.versionId,
app_ids: connectionIds,
tool_name: resolvedToolName,
});
}
const connectionPayloads = buildConnectionPayloads({
connectionIds: allConnectionIds,
connections,
});
return {
provider_id: providerId,
...(projectId ? { project_id: projectId } : {}),
name: deploymentName.trim(),
description: deploymentDescription,
type: deploymentType,
provider_data: {
llm: selectedLlm,
add_flows: addFlows,
connections: connectionPayloads,
},
};
}
export function buildDeploymentUpdatePayload({
attachedConnectionByFlow,
connections,
defaultToolNameScopeId,
deploymentDescription,
editingDeployment,
initialConnectionsByFlow,
initialToolNameByFlow,
initialVersionByFlow,
isDeploymentNameValid,
removedFlowIds,
selectedLlm,
selectedVersionByFlow,
toolNameByFlow,
}: {
attachedConnectionByFlow: Map<string, string[]>;
connections: ConnectionItem[];
defaultToolNameScopeId: string | null;
deploymentDescription: string;
editingDeployment: Deployment | null;
initialConnectionsByFlow: Map<string, string[]>;
initialToolNameByFlow: Map<string, string>;
initialVersionByFlow: Map<string, SelectedFlowVersion>;
isDeploymentNameValid: boolean;
removedFlowIds: Set<string>;
selectedLlm: string;
selectedVersionByFlow: Map<string, SelectedFlowVersion>;
toolNameByFlow: Map<string, string>;
}): DeploymentUpdateRequest {
if (!editingDeployment) {
throw new Error("buildDeploymentUpdatePayload called outside edit mode");
}
if (!isDeploymentNameValid) {
throw new Error("Deployment name must start with a letter");
}
const result: DeploymentUpdateRequest = {
deployment_id: editingDeployment.id,
};
const descriptionChanged =
deploymentDescription !== (editingDeployment.description ?? "");
if (descriptionChanged) {
result.description = deploymentDescription;
}
const upsertFlows: DeploymentUpdateFlowItem[] = [];
for (const [attachmentKey, versionEntry] of Array.from(
selectedVersionByFlow,
)) {
if (removedFlowIds.has(attachmentKey)) continue;
if (initialVersionByFlow.has(attachmentKey)) continue;
const connectionIds =
getValueByAttachmentKeyOrFlowId(
attachedConnectionByFlow,
attachmentKey,
versionEntry.flowId,
) ?? [];
const strictToolName = getScopedToolName(
toolNameByFlow,
attachmentKey,
versionEntry.flowId,
selectedVersionByFlow,
)?.trim();
const resolvedToolName =
strictToolName ||
getDefaultDeploymentToolName(
versionEntry.flowName ?? DEFAULT_FLOW_NAME,
versionEntry.versionId,
defaultToolNameScopeId,
);
upsertFlows.push({
flow_version_id: versionEntry.versionId,
add_app_ids: connectionIds,
remove_app_ids: [],
tool_name: resolvedToolName,
});
}
for (const [attachmentKey, versionEntry] of Array.from(
selectedVersionByFlow,
)) {
if (removedFlowIds.has(attachmentKey)) continue;
if (!initialVersionByFlow.has(attachmentKey)) continue;
const currentName =
getScopedToolName(
toolNameByFlow,
attachmentKey,
versionEntry.flowId,
selectedVersionByFlow,
)?.trim() ?? "";
const originalName =
getScopedToolName(
initialToolNameByFlow,
attachmentKey,
versionEntry.flowId,
initialVersionByFlow,
)?.trim() ?? "";
const nameChanged = currentName && currentName !== originalName;
const currentConnections =
getValueByAttachmentKeyOrFlowId(
attachedConnectionByFlow,
attachmentKey,
versionEntry.flowId,
) ?? [];
const originalConnections =
getValueByAttachmentKeyOrFlowId(
initialConnectionsByFlow,
attachmentKey,
versionEntry.flowId,
) ?? [];
const originalSet = new Set(originalConnections);
const currentSet = new Set(currentConnections);
const addAppIds = currentConnections.filter((id) => !originalSet.has(id));
const removeAppIds = originalConnections.filter(
(id) => !currentSet.has(id),
);
const connectionsChanged = addAppIds.length > 0 || removeAppIds.length > 0;
if (nameChanged || connectionsChanged) {
upsertFlows.push({
flow_version_id: versionEntry.versionId,
add_app_ids: addAppIds,
remove_app_ids: removeAppIds,
...(nameChanged && { tool_name: currentName }),
});
}
}
const removeFlows: string[] = [];
for (const attachmentKey of Array.from(removedFlowIds)) {
const originalVersion = initialVersionByFlow.get(attachmentKey);
if (originalVersion) {
removeFlows.push(originalVersion.versionId);
}
}
const newConnectionIds = new Set<string>();
upsertFlows.forEach((flowItem) => {
flowItem.add_app_ids.forEach((id) => newConnectionIds.add(id));
});
const connectionPayloads = buildConnectionPayloads({
connectionIds: newConnectionIds,
connections,
});
const llmToSend = selectedLlm;
if (
llmToSend ||
upsertFlows.length > 0 ||
removeFlows.length > 0 ||
connectionPayloads.length > 0
) {
const providerData: DeploymentUpdateProviderData = {
...(llmToSend && { llm: llmToSend }),
...(upsertFlows.length > 0 && { upsert_flows: upsertFlows }),
...(removeFlows.length > 0 && { remove_flows: removeFlows }),
...(connectionPayloads.length > 0 && {
connections: connectionPayloads,
}),
};
result.provider_data = providerData;
}
if (result.description === undefined && !result.provider_data) {
result.description = deploymentDescription;
}
return result;
}

View File

@ -0,0 +1,56 @@
import type { SelectedFlowVersion } from "../types";
import { getSelectedFlowVersionKey } from "../types";
export function normalizeSelectedFlowVersions(
versions?: Map<string, SelectedFlowVersion>,
): Map<string, SelectedFlowVersion> {
const next = new Map<string, SelectedFlowVersion>();
for (const [key, value] of versions ?? new Map()) {
const flowId = value.flowId ?? key;
const versionId = value.versionId;
const normalizedKey = value.flowId
? getSelectedFlowVersionKey(flowId, versionId)
: key;
next.set(normalizedKey, {
key: normalizedKey,
flowId,
flowName: value.flowName,
versionId,
versionTag: value.versionTag,
});
}
return next;
}
export function getValueByAttachmentKeyOrFlowId<T>(
map: Map<string, T>,
attachmentKey: string,
flowId: string,
): T | undefined {
return map.get(attachmentKey) ?? map.get(flowId);
}
export function getScopedValueForUniqueFlowVersion<T>(
map: Map<string, T>,
attachmentKey: string,
flowId: string,
flowVersionCount: number,
): T | undefined {
const strictValue = map.get(attachmentKey);
if (strictValue !== undefined) {
return strictValue;
}
if (flowVersionCount > 1) {
return undefined;
}
return map.get(flowId);
}
export function getFlowVersionCount(
entries: Iterable<{ flowId?: string }>,
flowId: string,
) {
return Array.from(entries).filter((entry) => entry.flowId === flowId).length;
}

View File

@ -0,0 +1,3 @@
export function normalizeWxoName(value: string): string {
return value.replace(/[\s-]/g, "_").replace(/[^a-zA-Z0-9_]/g, "");
}

View File

@ -5,7 +5,7 @@ import { useConnectionPanelState } from "../use-connection-panel-state";
const baseParams = () => ({
connections: [] as ConnectionItem[],
setConnections: jest.fn(),
effectiveFlowId: "flow-1",
effectiveAttachmentKey: "flow-1",
attachedConnectionByFlow: new Map<string, string[]>(),
onAttachConnection: jest.fn(),
commitPendingAttachment: jest.fn(),
@ -81,7 +81,7 @@ describe("useConnectionPanelState", () => {
it("does nothing when effectiveFlowId is null", () => {
const params = baseParams();
params.effectiveFlowId = null;
params.effectiveAttachmentKey = null;
params.connections = [makeConnection({ id: "conn-1" })];
const { result } = renderHook(() => useConnectionPanelState(params));
@ -186,7 +186,7 @@ describe("useConnectionPanelState", () => {
const newList = updater([]);
expect(newList[0].variableCount).toBe(1);
expect(newList[0].environmentVariables).toEqual({
API_KEY: "secret123",
API_KEY: "secret123", // pragma: allowlist secret
});
});
@ -501,7 +501,7 @@ describe("useConnectionPanelState", () => {
it("still commits and switches panel when effectiveFlowId is null (but does not call onAttachConnection)", () => {
const params = baseParams();
params.effectiveFlowId = null;
params.effectiveAttachmentKey = null;
const { result } = renderHook(() => useConnectionPanelState(params));
act(() => {

View File

@ -11,7 +11,7 @@ import type { ConnectionItem, EnvVarEntry } from "../types";
interface UseConnectionPanelStateParams {
connections: ConnectionItem[];
setConnections: Dispatch<SetStateAction<ConnectionItem[]>>;
effectiveFlowId: string | null;
effectiveAttachmentKey: string | null;
attachedConnectionByFlow: Map<string, string[]>;
onAttachConnection: Dispatch<SetStateAction<Map<string, string[]>>>;
commitPendingAttachment: () => void;
@ -22,7 +22,7 @@ interface UseConnectionPanelStateParams {
export function useConnectionPanelState({
connections,
setConnections,
effectiveFlowId,
effectiveAttachmentKey,
attachedConnectionByFlow,
onAttachConnection,
commitPendingAttachment,
@ -47,19 +47,19 @@ export function useConnectionPanelState({
}, [newConnectionName, connections]);
const handleAttachConnection = useCallback(() => {
if (!effectiveFlowId) return;
if (!effectiveAttachmentKey) return;
if (connectionTab === "available" && selectedConnections.size > 0) {
commitPendingAttachment();
onAttachConnection((prev) => {
const next = new Map(prev);
next.set(effectiveFlowId, Array.from(selectedConnections));
next.set(effectiveAttachmentKey, Array.from(selectedConnections));
return next;
});
setRightPanel("versions");
setSelectedConnections(new Set());
}
}, [
effectiveFlowId,
effectiveAttachmentKey,
connectionTab,
selectedConnections,
onAttachConnection,
@ -103,17 +103,17 @@ export function useConnectionPanelState({
const handleSkipConnection = useCallback(() => {
commitPendingAttachment();
if (effectiveFlowId) {
if (effectiveAttachmentKey) {
onAttachConnection((prev) => {
const next = new Map(prev);
next.delete(effectiveFlowId);
next.delete(effectiveAttachmentKey);
return next;
});
}
setRightPanel("versions");
setSelectedConnections(new Set());
}, [
effectiveFlowId,
effectiveAttachmentKey,
onAttachConnection,
commitPendingAttachment,
setRightPanel,
@ -169,9 +169,9 @@ export function useConnectionPanelState({
);
const initConnectionsForFlow = useCallback(
(flowId: string) => {
(attachmentKey: string) => {
setSelectedConnections(
new Set(attachedConnectionByFlow.get(flowId) ?? []),
new Set(attachedConnectionByFlow.get(attachmentKey) ?? []),
);
if (connections.length === 0) {
setConnectionTab("create");

View File

@ -1,5 +1,9 @@
export type DeploymentProviderType = "watsonx" | "kubernetes";
export const DEFAULT_FLOW_NAME = "Flow";
export const UNKNOWN_FLOW_NAME = "Unknown flow";
export const WXO_PROVIDER_KEY = "watsonx-orchestrate";
export interface EnvVarEntry {
id: string;
key: string;
@ -43,6 +47,47 @@ export interface ProviderCredentials {
export type DeploymentType = "agent" | "mcp";
export interface SelectedFlowVersion {
key: string;
flowId: string;
flowName?: string;
versionId: string;
versionTag: string;
}
export function getSelectedFlowVersionKey(flowId: string, versionId: string) {
return `${flowId}:${versionId}`;
}
function getShortIdentifier(value: string) {
const normalizedValue = value.trim();
const compactValue = normalizedValue.includes("-")
? normalizedValue.split("-").at(-1) || normalizedValue
: normalizedValue;
return compactValue.slice(0, 8) || "tool";
}
export function createDeploymentToolNameScopeId() {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
}
export function getDefaultDeploymentToolName(
flowName: string,
uniqueId: string,
scopeId?: string | null,
) {
const trimmedFlowName = flowName.trim() || DEFAULT_FLOW_NAME;
const shortId = getShortIdentifier(uniqueId);
const shortScopeId = scopeId ? getShortIdentifier(scopeId).slice(0, 6) : "";
return shortScopeId
? `${trimmedFlowName} ${shortScopeId}-${shortId}`
: `${trimmedFlowName} ${shortId}`;
}
export interface Deployment {
id: string;
provider_id?: string;

View File

@ -29,8 +29,27 @@ async function setupDeploymentMocks(
});
});
// Snapshots (used for duplicate tool name check on review step)
// Snapshots (used for duplicate tool name check on review step).
// When snapshotsMock is SNAPSHOTS_DUPLICATE_MOCK, echo back the requested names
// as existing tools so the check works regardless of the scoped tool name format.
await page.route("**/api/v1/deployments/snapshots**", (route) => {
if (snapshotsMock === SNAPSHOTS_DUPLICATE_MOCK) {
const url = new URL(route.request().url());
const names = url.searchParams.getAll("names");
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
provider_data: {
tools: names.map((name, i) => ({ id: `tool-${i}`, name })),
page: 1,
size: 50,
total: names.length,
},
}),
});
return;
}
route.fulfill({
status: 200,
contentType: "application/json",