mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 13:52:34 +08:00
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:
@ -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"]]]),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export function normalizeWxoName(value: string): string {
|
||||
return value.replace(/[\s-]/g, "_").replace(/[^a-zA-Z0-9_]/g, "");
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user