Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
b448593671 Add table input block with dynamic headers and rows support
Co-authored-by: nicholas.tindle <nicholas.tindle@agpt.co>
2025-09-02 17:25:03 +00:00
3 changed files with 349 additions and 18 deletions

View File

@@ -549,6 +549,97 @@ class AgentToggleInputBlock(AgentInputBlock):
)
class AgentTableInputBlock(AgentInputBlock):
"""
A table input block that allows users to define column headers and input data in a table format.
The block outputs a list of dictionaries where each dictionary represents a row,
with keys being the column headers and values being the cell data.
"""
class Input(AgentInputBlock.Input):
value: list[dict[str, Any]] = SchemaField(
description="Table data as a list of dictionaries (rows).",
default_factory=list,
advanced=False,
title="Default Table Data",
)
headers: list[str] = SchemaField(
description="Column headers for the table.",
default_factory=list,
advanced=False,
title="Table Headers",
)
allow_add_rows: bool = SchemaField(
description="Whether users can add new rows to the table.",
default=True,
advanced=True,
)
allow_edit_headers: bool = SchemaField(
description="Whether users can edit the column headers.",
default=True,
advanced=True,
)
min_rows: int = SchemaField(
description="Minimum number of rows in the table.",
default=0,
advanced=True,
)
max_rows: Optional[int] = SchemaField(
description="Maximum number of rows in the table.",
default=None,
advanced=True,
)
class Output(AgentInputBlock.Output):
result: list[dict[str, Any]] = SchemaField(
description="Table data as a list of dictionaries with headers as keys."
)
def __init__(self):
super().__init__(
id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
description="Block for table input with customizable headers and rows.",
disabled=not config.enable_agent_input_subtype_blocks,
input_schema=AgentTableInputBlock.Input,
output_schema=AgentTableInputBlock.Output,
test_input=[
{
"value": [
{"Name": "John Doe", "Age": "30", "City": "New York"},
{"Name": "Jane Smith", "Age": "25", "City": "Los Angeles"},
],
"headers": ["Name", "Age", "City"],
"name": "table_1",
"description": "Example table with user data",
},
{
"value": [
{"Product": "Laptop", "Price": "999", "Stock": "50"},
{"Product": "Mouse", "Price": "25", "Stock": "100"},
],
"headers": ["Product", "Price", "Stock"],
"name": "table_2",
"description": "Example table with product data",
},
],
test_output=[
("result", [
{"Name": "John Doe", "Age": "30", "City": "New York"},
{"Name": "Jane Smith", "Age": "25", "City": "Los Angeles"},
]),
("result", [
{"Product": "Laptop", "Price": "999", "Stock": "50"},
{"Product": "Mouse", "Price": "25", "Stock": "100"},
]),
],
)
async def run(self, input_data: Input, *args, **kwargs) -> BlockOutput:
if input_data.value is not None:
yield "result", input_data.value
IO_BLOCK_IDs = [
AgentInputBlock().id,
AgentOutputBlock().id,
@@ -560,4 +651,5 @@ IO_BLOCK_IDs = [
AgentFileInputBlock().id,
AgentDropdownInputBlock().id,
AgentToggleInputBlock().id,
AgentTableInputBlock().id,
]

View File

@@ -512,6 +512,22 @@ export const NodeGenericInputField: FC<{
/>
);
case DataType.TABLE:
return (
<NodeTableInput
nodeId={nodeId}
selfKey={propKey}
schema={propSchema as BlockIOObjectSubSchema}
tableData={currentValue}
errors={errors}
connections={connections}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
className={className}
displayName={displayName}
/>
);
case DataType.LONG_TEXT:
case DataType.SHORT_TEXT:
default:
@@ -815,12 +831,14 @@ const NodeKeyValueInput: FC<{
placeholder="Key"
value={key ?? ""}
onChange={(e) =>
updateKeyValuePairs(
keyValuePairs.toSpliced(index, 1, {
updateKeyValuePairs([
...keyValuePairs.slice(0, index),
{
key: e.target.value,
value: value,
}),
)
},
...keyValuePairs.slice(index + 1),
])
}
/>
<NodeGenericInputField
@@ -833,12 +851,14 @@ const NodeKeyValueInput: FC<{
connections={connections}
displayName={displayName || beautifyString(key)}
handleInputChange={(_, newValue) =>
updateKeyValuePairs(
keyValuePairs.toSpliced(index, 1, {
updateKeyValuePairs([
...keyValuePairs.slice(0, index),
{
key: key,
value: newValue,
}),
)
},
...keyValuePairs.slice(index + 1),
])
}
handleInputClick={handleInputClick}
/>
@@ -846,7 +866,10 @@ const NodeKeyValueInput: FC<{
variant="ghost"
className="px-2"
onClick={() =>
updateKeyValuePairs(keyValuePairs.toSpliced(index, 1))
updateKeyValuePairs([
...keyValuePairs.slice(0, index),
...keyValuePairs.slice(index + 1),
])
}
>
<Cross2Icon />
@@ -971,7 +994,10 @@ const NodeArrayInput: FC<{
variant="ghost"
size="icon"
onClick={() =>
handleInputChange(selfKey, entries.toSpliced(index, 1))
handleInputChange(selfKey, [
...entries.slice(0, index),
...entries.slice(index + 1),
])
}
>
<Cross2Icon />
@@ -1241,6 +1267,191 @@ const NodeBooleanInput: FC<{
);
};
const NodeTableInput: FC<{
nodeId: string;
selfKey: string;
schema: BlockIOObjectSubSchema;
tableData?: { value?: Array<Record<string, any>>; headers?: string[] };
errors: { [key: string]: string | undefined };
connections: NodeObjectInputTreeProps["connections"];
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
handleInputClick: NodeObjectInputTreeProps["handleInputClick"];
className?: string;
displayName?: string;
}> = ({
nodeId,
selfKey,
schema,
tableData,
errors,
connections,
handleInputChange,
handleInputClick,
className,
displayName,
}) => {
const [headers, setHeaders] = useState<string[]>(tableData?.headers || ["Column 1"]);
const [rows, setRows] = useState<Array<Record<string, any>>>(
tableData?.value || [{}]
);
useEffect(() => {
if (tableData?.headers) setHeaders(tableData.headers);
if (tableData?.value) setRows(tableData.value);
}, [tableData]);
const updateTableData = useCallback(
(newHeaders: string[], newRows: Array<Record<string, any>>) => {
setHeaders(newHeaders);
setRows(newRows);
handleInputChange(selfKey, {
headers: newHeaders,
value: newRows,
});
},
[selfKey, handleInputChange]
);
const addHeader = () => {
const newHeaders = [...headers, `Column ${headers.length + 1}`];
const newRows = rows.map(row => ({ ...row, [newHeaders[newHeaders.length - 1]]: "" }));
updateTableData(newHeaders, newRows);
};
const updateHeader = (index: number, newHeader: string) => {
const oldHeader = headers[index];
const newHeaders = [...headers];
newHeaders[index] = newHeader;
const newRows = rows.map(row => {
const newRow = { ...row };
if (oldHeader in newRow) {
newRow[newHeader] = newRow[oldHeader];
delete newRow[oldHeader];
}
return newRow;
});
updateTableData(newHeaders, newRows);
};
const removeHeader = (index: number) => {
if (headers.length <= 1) return; // Keep at least one header
const headerToRemove = headers[index];
const newHeaders = headers.filter((_, i) => i !== index);
const newRows = rows.map(row => {
const newRow = { ...row };
delete newRow[headerToRemove];
return newRow;
});
updateTableData(newHeaders, newRows);
};
const addRow = () => {
const newRow = headers.reduce((acc, header) => ({ ...acc, [header]: "" }), {});
const newRows = [...rows, newRow];
updateTableData(headers, newRows);
};
const updateCell = (rowIndex: number, header: string, value: any) => {
const newRows = [...rows];
newRows[rowIndex] = { ...newRows[rowIndex], [header]: value };
updateTableData(headers, newRows);
};
const removeRow = (index: number) => {
if (rows.length <= 1) return; // Keep at least one row
const newRows = rows.filter((_, i) => i !== index);
updateTableData(headers, newRows);
};
return (
<div className={cn(className, "flex flex-col space-y-2")}>
<div className="text-sm font-medium">{displayName || "Table"}</div>
{/* Headers */}
<div className="flex items-center space-x-2 border-b pb-2">
{headers.map((header, index) => (
<div key={index} className="flex items-center space-x-1">
<LocalValuedInput
type="text"
value={header}
onChange={(e) => updateHeader(index, e.target.value)}
className="min-w-[100px] text-sm font-medium"
placeholder={`Column ${index + 1}`}
/>
{headers.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeHeader(index)}
className="h-6 w-6 p-0"
>
<Cross2Icon className="h-3 w-3" />
</Button>
)}
</div>
))}
<Button
variant="ghost"
size="sm"
onClick={addHeader}
className="h-6 w-6 p-0"
>
<PlusIcon className="h-3 w-3" />
</Button>
</div>
{/* Rows */}
<div className="space-y-1">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex items-center space-x-2">
{headers.map((header) => (
<LocalValuedInput
key={header}
type="text"
value={row[header] || ""}
onChange={(e) => updateCell(rowIndex, header, e.target.value)}
className="min-w-[100px] text-sm"
placeholder={`Enter ${header}`}
/>
))}
{rows.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeRow(rowIndex)}
className="h-6 w-6 p-0"
>
<Cross2Icon className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
{/* Add Row Button */}
<Button
variant="outline"
size="sm"
onClick={addRow}
className="self-start"
>
<PlusIcon className="mr-1 h-3 w-3" /> Add Row
</Button>
{errors[selfKey] && (
<span className="error-message text-sm text-red-500">
{errors[selfKey]}
</span>
)}
</div>
);
};
const NodeFallbackInput: FC<{
selfKey: string;
schema?: BlockIOSubSchema;

View File

@@ -78,6 +78,7 @@ export enum DataType {
OBJECT = "object",
KEY_VALUE = "key-value",
ARRAY = "array",
TABLE = "table",
}
export type BlockIOSubSchemaMeta = {
@@ -1082,6 +1083,33 @@ export function determineDataType(schema: BlockIOSubSchema): DataType {
schema = schema.allOf[0];
}
// Table detection: Check if this is an object with both 'value' (array of objects) and 'headers' (array of strings)
if (
"type" in schema &&
schema.type === "object" &&
"properties" in schema &&
schema.properties &&
"value" in schema.properties &&
"headers" in schema.properties
) {
const valueSchema = schema.properties.value;
const headersSchema = schema.properties.headers;
// Check if value is array of objects and headers is array of strings
if (
valueSchema.type === "array" &&
"items" in valueSchema &&
valueSchema.items &&
valueSchema.items.type === "object" &&
headersSchema.type === "array" &&
"items" in headersSchema &&
headersSchema.items &&
headersSchema.items.type === "string"
) {
return DataType.TABLE;
}
}
// Credentials override
if ("credentials_provider" in schema) {
return DataType.CREDENTIALS;
@@ -1095,14 +1123,14 @@ export function determineDataType(schema: BlockIOSubSchema): DataType {
// Handle anyOf => optional types (string|null, number|null, etc.)
if ("anyOf" in schema) {
// e.g. schema.anyOf might look like [{ type: "string", ... }, { type: "null" }]
const types = schema.anyOf.map((sub) =>
const types = (schema.anyOf as any[]).map((sub: any) =>
"type" in sub ? sub.type : undefined,
);
// (string | null)
if (types.includes("string") && types.includes("null")) {
const strSchema = schema.anyOf.find(
(s) => s.type === "string",
const strSchema = (schema.anyOf as any[]).find(
(s: any) => s.type === "string",
) as BlockIOStringSubSchema;
return _handleStringSchema(strSchema);
}
@@ -1113,8 +1141,8 @@ export function determineDataType(schema: BlockIOSubSchema): DataType {
types.includes("null")
) {
// Just reuse our single-type logic for whichever is not null
const numSchema = schema.anyOf.find(
(s) => s.type === "number" || s.type === "integer",
const numSchema = (schema.anyOf as any[]).find(
(s: any) => s.type === "number" || s.type === "integer",
);
if (numSchema) {
return _handleSingleTypeSchema(numSchema);
@@ -1124,15 +1152,15 @@ export function determineDataType(schema: BlockIOSubSchema): DataType {
// (array | null)
if (types.includes("array") && types.includes("null")) {
const arrSchema = schema.anyOf.find((s) => s.type === "array");
const arrSchema = (schema.anyOf as any[]).find((s: any) => s.type === "array");
if (arrSchema) return _handleSingleTypeSchema(arrSchema);
return DataType.ARRAY;
}
// (object | null)
if (types.includes("object") && types.includes("null")) {
const objSchema = schema.anyOf.find(
(s) => s.type === "object",
const objSchema = (schema.anyOf as any[]).find(
(s: any) => s.type === "object",
) as BlockIOObjectSubSchema;
if (objSchema) return _handleSingleTypeSchema(objSchema);
return DataType.OBJECT;