Skip to content

Commit ae8aaef

Browse files
bchapuisclaude
andcommitted
Add dynamic inputs system for nodes with variable input count
Replace the repeated parameter pattern (single input accepting arrays) with explicit numbered inputs (input_1, input_2, …) that users can add/remove via a widget. Adds DynamicInputsConfig type, collectDynamicInputs base method on ExecutableNode, and a reusable DynamicInputsWidget. Migrates StringConcatNode as the first adopter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d5557be commit ae8aaef

6 files changed

Lines changed: 246 additions & 41 deletions

File tree

apps/app/src/components/workflow/widgets/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { cronInputWidget } from "./input/cron-input";
1515
import { dateInputWidget } from "./input/date-input";
1616
import { discordTriggerInputWidget } from "./input/discord-trigger-input";
1717
import { documentInputWidget } from "./input/document-input";
18+
import { createDynamicInputsWidget } from "./input/dynamic-inputs-widget";
1819
import { emailTriggerInputWidget } from "./input/email-trigger-input";
1920
import {
2021
httpRequestEndpointWidget,
@@ -88,6 +89,12 @@ const widgets = [
8889
canvasInputWidget,
8990
replicateModelInputWidget,
9091
schemaExtractInputWidget,
92+
createDynamicInputsWidget("string-concat", {
93+
prefix: "input",
94+
type: "string",
95+
defaultCount: 2,
96+
minCount: 1,
97+
}),
9198

9299
// Output widgets
93100
textOutputWidget,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { DynamicInputsConfig } from "@dafthunk/types";
2+
import MinusIcon from "lucide-react/icons/minus";
3+
import PlusIcon from "lucide-react/icons/plus";
4+
import { useCallback } from "react";
5+
6+
import { cn } from "@/utils/utils";
7+
8+
import { useWorkflow } from "../../workflow-context";
9+
import type { WorkflowParameter } from "../../workflow-types";
10+
import type { BaseWidgetProps } from "../widget";
11+
import { createWidget } from "../widget";
12+
13+
interface DynamicInputsWidgetProps extends BaseWidgetProps {
14+
nodeId: string;
15+
nodeType: string;
16+
inputCount: number;
17+
}
18+
19+
function DynamicInputsWidget({
20+
nodeId,
21+
nodeType,
22+
inputCount,
23+
className,
24+
disabled = false,
25+
}: DynamicInputsWidgetProps) {
26+
const { updateNodeData, edges, deleteEdge, nodeTypes } = useWorkflow();
27+
28+
const config = nodeTypes?.find((t) => t.type === nodeType)?.dynamicInputs;
29+
const canRemove = config ? inputCount > config.minCount : false;
30+
31+
const handleAdd = useCallback(() => {
32+
if (disabled || !updateNodeData || !config) return;
33+
34+
updateNodeData(nodeId, (current) => {
35+
const pattern = new RegExp(`^${config.prefix}_(\\d+)$`);
36+
const maxIndex = current.inputs.reduce((max, inp) => {
37+
const match = inp.id.match(pattern);
38+
return match ? Math.max(max, Number.parseInt(match[1])) : max;
39+
}, 0);
40+
const nextIndex = maxIndex + 1;
41+
const template = current.inputs[0];
42+
const newInput: WorkflowParameter = {
43+
...template,
44+
id: `${config.prefix}_${nextIndex}`,
45+
name: `${config.prefix}_${nextIndex}`,
46+
value: undefined,
47+
};
48+
return { inputs: [...current.inputs, newInput] };
49+
});
50+
}, [disabled, updateNodeData, nodeId, config]);
51+
52+
const handleRemove = useCallback(() => {
53+
if (disabled || !updateNodeData || !config) return;
54+
if (inputCount <= config.minCount) return;
55+
56+
// Find and disconnect edges to the last input before updating node data
57+
if (edges && deleteEdge) {
58+
// Find the last dynamic input by looking at current edges
59+
const lastInputId = `${config.prefix}_${inputCount}`;
60+
for (const edge of edges) {
61+
if (edge.target === nodeId && edge.targetHandle === lastInputId) {
62+
deleteEdge(edge.id);
63+
}
64+
}
65+
}
66+
67+
updateNodeData(nodeId, (current) => {
68+
if (current.inputs.length <= config.minCount) return {};
69+
return { inputs: current.inputs.slice(0, -1) };
70+
});
71+
}, [disabled, updateNodeData, nodeId, config, inputCount, edges, deleteEdge]);
72+
73+
if (!config) return null;
74+
75+
return (
76+
<div className={cn("px-2 py-1.5 flex items-center gap-1", className)}>
77+
<button
78+
type="button"
79+
className={cn(
80+
"flex items-center justify-center rounded p-0.5",
81+
"border border-border bg-background hover:bg-accent",
82+
"text-muted-foreground hover:text-foreground",
83+
{ "opacity-50 cursor-not-allowed": disabled || !canRemove }
84+
)}
85+
onClick={handleRemove}
86+
disabled={disabled || !canRemove}
87+
aria-label="Remove input"
88+
>
89+
<MinusIcon className="h-3 w-3" />
90+
</button>
91+
<span className="flex-1 text-center text-xs text-muted-foreground tabular-nums">
92+
{inputCount} {inputCount === 1 ? "input" : "inputs"}
93+
</span>
94+
<button
95+
type="button"
96+
className={cn(
97+
"flex items-center justify-center rounded p-0.5",
98+
"border border-border bg-background hover:bg-accent",
99+
"text-muted-foreground hover:text-foreground",
100+
{ "opacity-50 cursor-not-allowed": disabled }
101+
)}
102+
onClick={handleAdd}
103+
disabled={disabled}
104+
aria-label="Add input"
105+
>
106+
<PlusIcon className="h-3 w-3" />
107+
</button>
108+
</div>
109+
);
110+
}
111+
112+
/**
113+
* Counts inputs matching the dynamic prefix pattern (e.g. input_1, input_2, …)
114+
*/
115+
function countDynamicInputs(
116+
inputs: WorkflowParameter[],
117+
config: DynamicInputsConfig
118+
): number {
119+
const pattern = new RegExp(`^${config.prefix}_\\d+$`);
120+
return inputs.filter((i) => pattern.test(i.id)).length;
121+
}
122+
123+
/**
124+
* Creates a dynamic inputs widget descriptor for a given node type.
125+
*/
126+
export function createDynamicInputsWidget(
127+
nodeType: string,
128+
config: DynamicInputsConfig
129+
) {
130+
return createWidget({
131+
component: DynamicInputsWidget,
132+
nodeTypes: [nodeType],
133+
inputField: `${config.prefix}_1`,
134+
extractConfig: (nodeId, inputs) => ({
135+
nodeId,
136+
nodeType,
137+
inputCount: countDynamicInputs(inputs, config),
138+
}),
139+
});
140+
}

packages/runtime/src/node-types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,30 @@ export abstract class ExecutableNode {
328328
} as NodeExecution;
329329
}
330330

331+
/**
332+
* Collect dynamic inputs matching a prefix, sorted by numeric suffix.
333+
* For nodes using dynamicInputs (e.g. input_1, input_2, …).
334+
*/
335+
protected collectDynamicInputs(
336+
inputs: Record<string, unknown>,
337+
prefix: string
338+
): unknown[] {
339+
const prefixUnderscore = `${prefix}_`;
340+
return Object.entries(inputs)
341+
.filter(([key]) => {
342+
if (!key.startsWith(prefixUnderscore)) return false;
343+
const suffix = key.slice(prefixUnderscore.length);
344+
return /^\d+$/.test(suffix);
345+
})
346+
.sort(([a], [b]) => {
347+
const numA = Number.parseInt(a.slice(prefixUnderscore.length), 10);
348+
const numB = Number.parseInt(b.slice(prefixUnderscore.length), 10);
349+
return numA - numB;
350+
})
351+
.map(([_, value]) => value)
352+
.filter((v) => v !== undefined && v !== null);
353+
}
354+
331355
public createErrorResult(error: string, usage?: number): NodeExecution {
332356
return {
333357
nodeId: this.node.id,

packages/runtime/src/nodes/text/string-concat-node.test.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe("StringConcatNode", () => {
1313
const context = {
1414
nodeId,
1515
inputs: {
16-
strings: "Hello World",
16+
input_1: "Hello World",
1717
},
1818
getIntegration: async () => {
1919
throw new Error("No integrations in test");
@@ -27,7 +27,7 @@ describe("StringConcatNode", () => {
2727
expect(result.outputs?.result).toBe("Hello World");
2828
});
2929

30-
it("should concatenate multiple strings from array", async () => {
30+
it("should concatenate multiple strings from dynamic inputs", async () => {
3131
const nodeId = "string-concat";
3232
const node = new StringConcatNode({
3333
nodeId,
@@ -36,7 +36,10 @@ describe("StringConcatNode", () => {
3636
const context = {
3737
nodeId,
3838
inputs: {
39-
strings: ["Hello", " ", "World", "!"],
39+
input_1: "Hello",
40+
input_2: " ",
41+
input_3: "World",
42+
input_4: "!",
4043
},
4144
getIntegration: async () => {
4245
throw new Error("No integrations in test");
@@ -50,7 +53,7 @@ describe("StringConcatNode", () => {
5053
expect(result.outputs?.result).toBe("Hello World!");
5154
});
5255

53-
it("should handle empty strings in array", async () => {
56+
it("should handle empty strings in inputs", async () => {
5457
const nodeId = "string-concat";
5558
const node = new StringConcatNode({
5659
nodeId,
@@ -59,7 +62,9 @@ describe("StringConcatNode", () => {
5962
const context = {
6063
nodeId,
6164
inputs: {
62-
strings: ["", "test", ""],
65+
input_1: "",
66+
input_2: "test",
67+
input_3: "",
6368
},
6469
getIntegration: async () => {
6570
throw new Error("No integrations in test");
@@ -92,7 +97,7 @@ describe("StringConcatNode", () => {
9297
expect(result.error).toContain("No string inputs provided");
9398
});
9499

95-
it("should return error for invalid input type in array", async () => {
100+
it("should return error for invalid input type", async () => {
96101
const nodeId = "string-concat";
97102
const node = new StringConcatNode({
98103
nodeId,
@@ -101,7 +106,9 @@ describe("StringConcatNode", () => {
101106
const context = {
102107
nodeId,
103108
inputs: {
104-
strings: ["Hello", 123, "World"],
109+
input_1: "Hello",
110+
input_2: 123,
111+
input_3: "World",
105112
},
106113
getIntegration: async () => {
107114
throw new Error("No integrations in test");
@@ -115,4 +122,28 @@ describe("StringConcatNode", () => {
115122
"Invalid input at position 1: expected string, got number"
116123
);
117124
});
125+
126+
it("should skip undefined inputs and preserve order", async () => {
127+
const nodeId = "string-concat";
128+
const node = new StringConcatNode({
129+
nodeId,
130+
} as unknown as Node);
131+
132+
const context = {
133+
nodeId,
134+
inputs: {
135+
input_1: "A",
136+
input_2: undefined,
137+
input_3: "B",
138+
},
139+
getIntegration: async () => {
140+
throw new Error("No integrations in test");
141+
},
142+
env: {},
143+
} as unknown as NodeContext;
144+
145+
const result = await node.execute(context);
146+
expect(result.status).toBe("completed");
147+
expect(result.outputs?.result).toBe("AB");
148+
});
118149
});

packages/runtime/src/nodes/text/string-concat-node.ts

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,30 @@ import type { NodeExecution, NodeType } from "@dafthunk/types";
44
export class StringConcatNode extends ExecutableNode {
55
public static readonly nodeType: NodeType = {
66
id: "string-concat",
7-
name: "Concat",
7+
name: "String Concat",
88
type: "string-concat",
99
description: "Concatenate multiple strings together",
1010
tags: ["Text", "Concat"],
1111
icon: "link",
1212
documentation:
1313
"This node concatenates multiple strings together into a single string, joining them in the order they are provided.",
1414
inlinable: true,
15+
dynamicInputs: {
16+
prefix: "input",
17+
type: "string",
18+
defaultCount: 2,
19+
minCount: 1,
20+
},
1521
inputs: [
1622
{
17-
name: "strings",
23+
name: "input_1",
1824
type: "string",
19-
description:
20-
"String inputs to concatenate (supports multiple connections)",
21-
required: true,
22-
repeated: true,
25+
description: "String to concatenate",
26+
},
27+
{
28+
name: "input_2",
29+
type: "string",
30+
description: "String to concatenate",
2331
},
2432
],
2533
outputs: [
@@ -33,41 +41,23 @@ export class StringConcatNode extends ExecutableNode {
3341

3442
public async execute(context: NodeContext): Promise<NodeExecution> {
3543
try {
36-
const { strings } = context.inputs;
44+
const strings = this.collectDynamicInputs(context.inputs, "input");
3745

38-
// Handle missing input
39-
if (strings === null || strings === undefined) {
46+
if (strings.length === 0) {
4047
return this.createErrorResult("No string inputs provided");
4148
}
4249

43-
// Handle single string input
44-
if (typeof strings === "string") {
45-
return this.createSuccessResult({
46-
result: strings,
47-
});
48-
}
49-
50-
// Handle array of strings (multiple connections)
51-
if (Array.isArray(strings)) {
52-
// Validate all inputs are strings
53-
for (let i = 0; i < strings.length; i++) {
54-
if (typeof strings[i] !== "string") {
55-
return this.createErrorResult(
56-
`Invalid input at position ${i}: expected string, got ${typeof strings[i]}`
57-
);
58-
}
50+
for (let i = 0; i < strings.length; i++) {
51+
if (typeof strings[i] !== "string") {
52+
return this.createErrorResult(
53+
`Invalid input at position ${i}: expected string, got ${typeof strings[i]}`
54+
);
5955
}
60-
61-
const result = strings.join("");
62-
63-
return this.createSuccessResult({
64-
result,
65-
});
6656
}
6757

68-
return this.createErrorResult(
69-
"Invalid input type: expected string or array of strings"
70-
);
58+
const result = (strings as string[]).join("");
59+
60+
return this.createSuccessResult({ result });
7161
} catch (err) {
7262
const error = err as Error;
7363
return this.createErrorResult(

0 commit comments

Comments
 (0)