mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
Merge branch 'dev' into dependabot/npm_and_yarn/autogpt_platform/frontend/dev/faker-js/faker-10.0.0
This commit is contained in:
@@ -554,6 +554,89 @@ class AgentToggleInputBlock(AgentInputBlock):
|
||||
)
|
||||
|
||||
|
||||
class AgentTableInputBlock(AgentInputBlock):
|
||||
"""
|
||||
This block allows users to input data in a table format.
|
||||
|
||||
Configure the table columns at build time, then users can input
|
||||
rows of data at runtime. Each row is output as a dictionary
|
||||
with column names as keys.
|
||||
"""
|
||||
|
||||
class Input(AgentInputBlock.Input):
|
||||
value: Optional[list[dict[str, Any]]] = SchemaField(
|
||||
description="The table data as a list of dictionaries.",
|
||||
default=None,
|
||||
advanced=False,
|
||||
title="Default Value",
|
||||
)
|
||||
column_headers: list[str] = SchemaField(
|
||||
description="Column headers for the table.",
|
||||
default_factory=lambda: ["Column 1", "Column 2", "Column 3"],
|
||||
advanced=False,
|
||||
title="Column Headers",
|
||||
)
|
||||
|
||||
def generate_schema(self):
|
||||
"""Generate schema for the value field with table format."""
|
||||
schema = super().generate_schema()
|
||||
schema["type"] = "array"
|
||||
schema["format"] = "table"
|
||||
schema["items"] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
header: {"type": "string"}
|
||||
for header in (
|
||||
self.column_headers or ["Column 1", "Column 2", "Column 3"]
|
||||
)
|
||||
},
|
||||
}
|
||||
if self.value is not None:
|
||||
schema["default"] = self.value
|
||||
return schema
|
||||
|
||||
class Output(AgentInputBlock.Output):
|
||||
result: list[dict[str, Any]] = SchemaField(
|
||||
description="The table data as a list of dictionaries with headers as keys."
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="5603b273-f41e-4020-af7d-fbc9c6a8d928",
|
||||
description="Block for table data input with customizable headers.",
|
||||
disabled=not config.enable_agent_input_subtype_blocks,
|
||||
input_schema=AgentTableInputBlock.Input,
|
||||
output_schema=AgentTableInputBlock.Output,
|
||||
test_input=[
|
||||
{
|
||||
"name": "test_table",
|
||||
"column_headers": ["Name", "Age", "City"],
|
||||
"value": [
|
||||
{"Name": "John", "Age": "30", "City": "New York"},
|
||||
{"Name": "Jane", "Age": "25", "City": "London"},
|
||||
],
|
||||
"description": "Example table input",
|
||||
}
|
||||
],
|
||||
test_output=[
|
||||
(
|
||||
"result",
|
||||
[
|
||||
{"Name": "John", "Age": "30", "City": "New York"},
|
||||
{"Name": "Jane", "Age": "25", "City": "London"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def run(self, input_data: Input, *args, **kwargs) -> BlockOutput:
|
||||
"""
|
||||
Yields the table data as a list of dictionaries.
|
||||
"""
|
||||
# Pass through the value, defaulting to empty list if None
|
||||
yield "result", input_data.value if input_data.value is not None else []
|
||||
|
||||
|
||||
IO_BLOCK_IDs = [
|
||||
AgentInputBlock().id,
|
||||
AgentOutputBlock().id,
|
||||
@@ -565,4 +648,5 @@ IO_BLOCK_IDs = [
|
||||
AgentFileInputBlock().id,
|
||||
AgentDropdownInputBlock().id,
|
||||
AgentToggleInputBlock().id,
|
||||
AgentTableInputBlock().id,
|
||||
]
|
||||
|
||||
@@ -101,6 +101,7 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
||||
CLAUDE_4_1_OPUS = "claude-opus-4-1-20250805"
|
||||
CLAUDE_4_OPUS = "claude-opus-4-20250514"
|
||||
CLAUDE_4_SONNET = "claude-sonnet-4-20250514"
|
||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5-20250929"
|
||||
CLAUDE_3_7_SONNET = "claude-3-7-sonnet-20250219"
|
||||
CLAUDE_3_5_SONNET = "claude-3-5-sonnet-latest"
|
||||
CLAUDE_3_5_HAIKU = "claude-3-5-haiku-latest"
|
||||
@@ -213,6 +214,9 @@ MODEL_METADATA = {
|
||||
LlmModel.CLAUDE_4_SONNET: ModelMetadata(
|
||||
"anthropic", 200000, 64000
|
||||
), # claude-4-sonnet-20250514
|
||||
LlmModel.CLAUDE_4_5_SONNET: ModelMetadata(
|
||||
"anthropic", 200000, 64000
|
||||
), # claude-sonnet-4-5-20250929
|
||||
LlmModel.CLAUDE_3_7_SONNET: ModelMetadata(
|
||||
"anthropic", 200000, 64000
|
||||
), # claude-3-7-sonnet-20250219
|
||||
|
||||
131
autogpt_platform/backend/backend/blocks/test/test_table_input.py
Normal file
131
autogpt_platform/backend/backend/blocks/test/test_table_input.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import pytest
|
||||
|
||||
from backend.blocks.io import AgentTableInputBlock
|
||||
from backend.util.test import execute_block_test
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_input_block():
|
||||
"""Test the AgentTableInputBlock with basic input/output."""
|
||||
block = AgentTableInputBlock()
|
||||
await execute_block_test(block)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_input_with_data():
|
||||
"""Test AgentTableInputBlock with actual table data."""
|
||||
block = AgentTableInputBlock()
|
||||
|
||||
input_data = block.Input(
|
||||
name="test_table",
|
||||
column_headers=["Name", "Age", "City"],
|
||||
value=[
|
||||
{"Name": "John", "Age": "30", "City": "New York"},
|
||||
{"Name": "Jane", "Age": "25", "City": "London"},
|
||||
{"Name": "Bob", "Age": "35", "City": "Paris"},
|
||||
],
|
||||
)
|
||||
|
||||
output_data = []
|
||||
async for output_name, output_value in block.run(input_data):
|
||||
output_data.append((output_name, output_value))
|
||||
|
||||
assert len(output_data) == 1
|
||||
assert output_data[0][0] == "result"
|
||||
|
||||
result = output_data[0][1]
|
||||
assert len(result) == 3
|
||||
assert result[0]["Name"] == "John"
|
||||
assert result[1]["Age"] == "25"
|
||||
assert result[2]["City"] == "Paris"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_input_empty_data():
|
||||
"""Test AgentTableInputBlock with empty data."""
|
||||
block = AgentTableInputBlock()
|
||||
|
||||
input_data = block.Input(
|
||||
name="empty_table", column_headers=["Col1", "Col2"], value=[]
|
||||
)
|
||||
|
||||
output_data = []
|
||||
async for output_name, output_value in block.run(input_data):
|
||||
output_data.append((output_name, output_value))
|
||||
|
||||
assert len(output_data) == 1
|
||||
assert output_data[0][0] == "result"
|
||||
assert output_data[0][1] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_input_with_missing_columns():
|
||||
"""Test AgentTableInputBlock passes through data with missing columns as-is."""
|
||||
block = AgentTableInputBlock()
|
||||
|
||||
input_data = block.Input(
|
||||
name="partial_table",
|
||||
column_headers=["Name", "Age", "City"],
|
||||
value=[
|
||||
{"Name": "John", "Age": "30"}, # Missing City
|
||||
{"Name": "Jane", "City": "London"}, # Missing Age
|
||||
{"Age": "35", "City": "Paris"}, # Missing Name
|
||||
],
|
||||
)
|
||||
|
||||
output_data = []
|
||||
async for output_name, output_value in block.run(input_data):
|
||||
output_data.append((output_name, output_value))
|
||||
|
||||
result = output_data[0][1]
|
||||
assert len(result) == 3
|
||||
|
||||
# Check data is passed through as-is
|
||||
assert result[0] == {"Name": "John", "Age": "30"}
|
||||
assert result[1] == {"Name": "Jane", "City": "London"}
|
||||
assert result[2] == {"Age": "35", "City": "Paris"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_input_none_value():
|
||||
"""Test AgentTableInputBlock with None value returns empty list."""
|
||||
block = AgentTableInputBlock()
|
||||
|
||||
input_data = block.Input(
|
||||
name="none_table", column_headers=["Name", "Age"], value=None
|
||||
)
|
||||
|
||||
output_data = []
|
||||
async for output_name, output_value in block.run(input_data):
|
||||
output_data.append((output_name, output_value))
|
||||
|
||||
assert len(output_data) == 1
|
||||
assert output_data[0][0] == "result"
|
||||
assert output_data[0][1] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_input_with_default_headers():
|
||||
"""Test AgentTableInputBlock with default column headers."""
|
||||
block = AgentTableInputBlock()
|
||||
|
||||
# Don't specify column_headers, should use defaults
|
||||
input_data = block.Input(
|
||||
name="default_headers_table",
|
||||
value=[
|
||||
{"Column 1": "A", "Column 2": "B", "Column 3": "C"},
|
||||
{"Column 1": "D", "Column 2": "E", "Column 3": "F"},
|
||||
],
|
||||
)
|
||||
|
||||
output_data = []
|
||||
async for output_name, output_value in block.run(input_data):
|
||||
output_data.append((output_name, output_value))
|
||||
|
||||
assert len(output_data) == 1
|
||||
assert output_data[0][0] == "result"
|
||||
|
||||
result = output_data[0][1]
|
||||
assert len(result) == 2
|
||||
assert result[0]["Column 1"] == "A"
|
||||
assert result[1]["Column 3"] == "F"
|
||||
@@ -69,6 +69,7 @@ MODEL_COST: dict[LlmModel, int] = {
|
||||
LlmModel.CLAUDE_4_1_OPUS: 21,
|
||||
LlmModel.CLAUDE_4_OPUS: 21,
|
||||
LlmModel.CLAUDE_4_SONNET: 5,
|
||||
LlmModel.CLAUDE_4_5_SONNET: 9,
|
||||
LlmModel.CLAUDE_3_7_SONNET: 5,
|
||||
LlmModel.CLAUDE_3_5_SONNET: 4,
|
||||
LlmModel.CLAUDE_3_5_HAIKU: 1, # $0.80 / $4.00
|
||||
|
||||
@@ -270,6 +270,7 @@ def SchemaField(
|
||||
min_length: Optional[int] = None,
|
||||
max_length: Optional[int] = None,
|
||||
discriminator: Optional[str] = None,
|
||||
format: Optional[str] = None,
|
||||
json_schema_extra: Optional[dict[str, Any]] = None,
|
||||
) -> T:
|
||||
if default is PydanticUndefined and default_factory is None:
|
||||
@@ -285,6 +286,7 @@ def SchemaField(
|
||||
"advanced": advanced,
|
||||
"hidden": hidden,
|
||||
"depends_on": depends_on,
|
||||
"format": format,
|
||||
**(json_schema_extra or {}),
|
||||
}.items()
|
||||
if v is not None
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
BlockIOSimpleTypeSubSchema,
|
||||
BlockIOStringSubSchema,
|
||||
BlockIOSubSchema,
|
||||
BlockIOTableSubSchema,
|
||||
DataType,
|
||||
determineDataType,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
@@ -56,6 +57,7 @@ import { LocalValuedInput } from "../../../../../components/__legacy__/ui/input"
|
||||
import NodeHandle from "./NodeHandle";
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
|
||||
import { Switch } from "../../../../../components/atoms/Switch/Switch";
|
||||
import { NodeTableInput } from "../../../../../components/node-table-input";
|
||||
|
||||
type NodeObjectInputTreeProps = {
|
||||
nodeId: string;
|
||||
@@ -106,6 +108,7 @@ const NodeObjectInputTree: FC<NodeObjectInputTreeProps> = ({
|
||||
handleInputChange={handleInputChange}
|
||||
handleInputClick={handleInputClick}
|
||||
displayName={propSchema.title || beautifyString(propKey)}
|
||||
parentContext={object}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -315,6 +318,7 @@ export const NodeGenericInputField: FC<{
|
||||
handleInputClick: NodeObjectInputTreeProps["handleInputClick"];
|
||||
className?: string;
|
||||
displayName?: string;
|
||||
parentContext?: { [key: string]: any };
|
||||
}> = ({
|
||||
nodeId,
|
||||
propKey,
|
||||
@@ -326,6 +330,7 @@ export const NodeGenericInputField: FC<{
|
||||
handleInputClick,
|
||||
className,
|
||||
displayName,
|
||||
parentContext,
|
||||
}) => {
|
||||
className = cn(className);
|
||||
displayName ||= propSchema.title || beautifyString(propKey);
|
||||
@@ -467,6 +472,28 @@ export const NodeGenericInputField: FC<{
|
||||
/>
|
||||
);
|
||||
|
||||
case DataType.TABLE:
|
||||
const tableSchema = propSchema as BlockIOTableSubSchema;
|
||||
// Extract headers from the schema's items properties
|
||||
const headers = tableSchema.items?.properties
|
||||
? Object.keys(tableSchema.items.properties)
|
||||
: ["Column 1", "Column 2", "Column 3"];
|
||||
return (
|
||||
<NodeTableInput
|
||||
nodeId={nodeId}
|
||||
selfKey={propKey}
|
||||
schema={tableSchema}
|
||||
headers={headers}
|
||||
rows={currentValue}
|
||||
errors={errors}
|
||||
connections={connections}
|
||||
handleInputChange={handleInputChange}
|
||||
handleInputClick={handleInputClick}
|
||||
className={className}
|
||||
displayName={displayName}
|
||||
/>
|
||||
);
|
||||
|
||||
case DataType.ARRAY:
|
||||
return (
|
||||
<NodeArrayInput
|
||||
@@ -480,6 +507,7 @@ export const NodeGenericInputField: FC<{
|
||||
connections={connections}
|
||||
handleInputChange={handleInputChange}
|
||||
handleInputClick={handleInputClick}
|
||||
parentContext={parentContext}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -894,6 +922,7 @@ const NodeArrayInput: FC<{
|
||||
handleInputClick: NodeObjectInputTreeProps["handleInputClick"];
|
||||
className?: string;
|
||||
displayName?: string;
|
||||
parentContext?: { [key: string]: any };
|
||||
}> = ({
|
||||
nodeId,
|
||||
selfKey,
|
||||
@@ -905,6 +934,7 @@ const NodeArrayInput: FC<{
|
||||
handleInputClick,
|
||||
className,
|
||||
displayName,
|
||||
parentContext: _parentContext,
|
||||
}) => {
|
||||
entries ??= schema.default;
|
||||
if (!entries || !Array.isArray(entries)) entries = [];
|
||||
|
||||
@@ -8,13 +8,17 @@ import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
|
||||
import {
|
||||
BlockIOObjectSubSchema,
|
||||
BlockIOSubSchema,
|
||||
BlockIOTableSubSchema,
|
||||
DataType,
|
||||
determineDataType,
|
||||
TableRow,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
|
||||
import { FileInput } from "@/components/atoms/FileInput/FileInput";
|
||||
import { useRunAgentInputs } from "./useRunAgentInputs";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
/**
|
||||
* A generic prop structure for the TypeBasedInput.
|
||||
@@ -44,6 +48,7 @@ export function RunAgentInputs({
|
||||
const { handleUploadFile, uploadProgress } = useRunAgentInputs();
|
||||
|
||||
const dataType = determineDataType(schema);
|
||||
|
||||
const baseId = String(schema.title ?? "input")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
@@ -211,6 +216,101 @@ export function RunAgentInputs({
|
||||
break;
|
||||
}
|
||||
|
||||
case DataType.TABLE: {
|
||||
// Render a simple table UI for the run modal
|
||||
const tableSchema = schema as BlockIOTableSubSchema;
|
||||
const headers = tableSchema.items?.properties
|
||||
? Object.keys(tableSchema.items.properties)
|
||||
: ["Column 1", "Column 2", "Column 3"];
|
||||
|
||||
const tableData: TableRow[] = Array.isArray(value) ? value : [];
|
||||
|
||||
const updateRow = (index: number, header: string, newValue: string) => {
|
||||
const newData = [...tableData];
|
||||
if (!newData[index]) {
|
||||
newData[index] = {};
|
||||
}
|
||||
newData[index][header] = newValue;
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
const newRow: TableRow = {};
|
||||
headers.forEach((header) => {
|
||||
newRow[header] = "";
|
||||
});
|
||||
onChange([...tableData, newRow]);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const newData = tableData.filter((_, i) => i !== index);
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
innerInputElement = (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800">
|
||||
{headers.map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t dark:border-gray-700">
|
||||
{headers.map((header) => (
|
||||
<td key={header} className="px-3 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={String(row[header] || "")}
|
||||
onChange={(e) =>
|
||||
updateRow(rowIndex, header, e.target.value)
|
||||
}
|
||||
className="w-full rounded border px-2 py-1 dark:border-gray-700 dark:bg-gray-900"
|
||||
placeholder={`Enter ${header}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XIcon className="h-4 w-4" weight="bold" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={addRow}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" weight="bold" />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case DataType.SHORT_TEXT:
|
||||
default:
|
||||
innerInputElement = (
|
||||
|
||||
210
autogpt_platform/frontend/src/components/node-table-input.tsx
Normal file
210
autogpt_platform/frontend/src/components/node-table-input.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
|
||||
import {
|
||||
BlockIOTableSubSchema,
|
||||
TableRow,
|
||||
TableCellValue,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { Input } from "./atoms/Input/Input";
|
||||
import { Button } from "./atoms/Button/Button";
|
||||
|
||||
interface NodeTableInputProps {
|
||||
/** Unique identifier for the node in the builder graph */
|
||||
nodeId: string;
|
||||
/** Key identifier for this specific input field within the node */
|
||||
selfKey: string;
|
||||
/** Schema definition for the table structure */
|
||||
schema: BlockIOTableSubSchema;
|
||||
/** Column headers for the table */
|
||||
headers: string[];
|
||||
/** Initial row data for the table */
|
||||
rows?: TableRow[];
|
||||
/** Validation errors mapped by field key */
|
||||
errors: { [key: string]: string | undefined };
|
||||
/** Graph connections between nodes in the builder */
|
||||
connections: {
|
||||
edge_id: string;
|
||||
source: string;
|
||||
sourceHandle: string;
|
||||
target: string;
|
||||
targetHandle: string;
|
||||
}[];
|
||||
/** Callback when table data changes */
|
||||
handleInputChange: (key: string, value: TableRow[]) => void;
|
||||
/** Callback when input field is clicked (for builder selection) */
|
||||
handleInputClick: (key: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Display name for the input field */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table input component for the workflow builder interface.
|
||||
*
|
||||
* This component is specifically designed for use in the agent builder where users
|
||||
* design workflows with connected nodes. It includes graph connection capabilities
|
||||
* via NodeHandle and is tightly integrated with the builder's state management.
|
||||
*
|
||||
* @warning Do NOT use this component in runtime/execution contexts (like RunAgentInputs).
|
||||
* For runtime table inputs, use a simpler implementation without builder-specific features.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <NodeTableInput
|
||||
* nodeId="node-123"
|
||||
* selfKey="table_data"
|
||||
* schema={tableSchema}
|
||||
* headers={["Name", "Value"]}
|
||||
* rows={existingData}
|
||||
* connections={graphConnections}
|
||||
* handleInputChange={handleChange}
|
||||
* handleInputClick={handleClick}
|
||||
* errors={{}}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @see Used exclusively in: `/app/(platform)/build/components/legacy-builder/NodeInputs.tsx`
|
||||
*/
|
||||
export const NodeTableInput: FC<NodeTableInputProps> = ({
|
||||
nodeId,
|
||||
selfKey,
|
||||
schema,
|
||||
headers,
|
||||
rows = [],
|
||||
errors,
|
||||
connections,
|
||||
handleInputChange,
|
||||
handleInputClick: _handleInputClick,
|
||||
className,
|
||||
displayName,
|
||||
}) => {
|
||||
const [tableData, setTableData] = useState<TableRow[]>(rows);
|
||||
|
||||
// Sync with parent state when rows change
|
||||
useEffect(() => {
|
||||
setTableData(rows);
|
||||
}, [rows]);
|
||||
|
||||
const isConnected = (key: string) =>
|
||||
connections.some((c) => c.targetHandle === key && c.target === nodeId);
|
||||
|
||||
const updateTableData = useCallback(
|
||||
(newData: TableRow[]) => {
|
||||
setTableData(newData);
|
||||
handleInputChange(selfKey, newData);
|
||||
},
|
||||
[selfKey, handleInputChange],
|
||||
);
|
||||
|
||||
const updateCell = (
|
||||
rowIndex: number,
|
||||
header: string,
|
||||
value: TableCellValue,
|
||||
) => {
|
||||
const newData = [...tableData];
|
||||
if (!newData[rowIndex]) {
|
||||
newData[rowIndex] = {};
|
||||
}
|
||||
newData[rowIndex][header] = value;
|
||||
updateTableData(newData);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
if (!headers || headers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const newRow: TableRow = {};
|
||||
headers.forEach((header) => {
|
||||
newRow[header] = "";
|
||||
});
|
||||
updateTableData([...tableData, newRow]);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const newData = tableData.filter((_, i) => i !== index);
|
||||
updateTableData(newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2", className)}>
|
||||
<NodeHandle
|
||||
title={displayName || selfKey}
|
||||
keyName={selfKey}
|
||||
schema={schema}
|
||||
isConnected={isConnected(selfKey)}
|
||||
isRequired={false}
|
||||
side="left"
|
||||
/>
|
||||
|
||||
{!isConnected(selfKey) && (
|
||||
<div className="nodrag overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className="border border-gray-300 bg-gray-100 px-2 py-1 text-left text-sm font-medium dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{headers.map((header, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="border border-gray-300 p-1 dark:border-gray-600"
|
||||
>
|
||||
<Input
|
||||
id={`${selfKey}-${rowIndex}-${header}`}
|
||||
label={header}
|
||||
type="text"
|
||||
value={String(row[header] || "")}
|
||||
onChange={(e) =>
|
||||
updateCell(rowIndex, header, e.target.value)
|
||||
}
|
||||
className="h-8 w-full"
|
||||
placeholder={`Enter ${header}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button
|
||||
className="mt-2 bg-gray-200 font-normal text-black hover:text-white dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
|
||||
onClick={addRow}
|
||||
size="small"
|
||||
>
|
||||
<PlusIcon className="mr-2" /> Add Row
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors[selfKey] && (
|
||||
<span className="text-sm text-red-500">{errors[selfKey]}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -58,6 +58,7 @@ export type BlockIOSimpleTypeSubSchema =
|
||||
| BlockIOCredentialsSubSchema
|
||||
| BlockIOKVSubSchema
|
||||
| BlockIOArraySubSchema
|
||||
| BlockIOTableSubSchema
|
||||
| BlockIOStringSubSchema
|
||||
| BlockIONumberSubSchema
|
||||
| BlockIOBooleanSubSchema
|
||||
@@ -78,6 +79,7 @@ export enum DataType {
|
||||
OBJECT = "object",
|
||||
KEY_VALUE = "key-value",
|
||||
ARRAY = "array",
|
||||
TABLE = "table",
|
||||
}
|
||||
|
||||
export type BlockIOSubSchemaMeta = {
|
||||
@@ -114,6 +116,20 @@ export type BlockIOArraySubSchema = BlockIOSubSchemaMeta & {
|
||||
secret?: boolean;
|
||||
};
|
||||
|
||||
// Table cell values are typically primitives
|
||||
export type TableCellValue = string | number | boolean | null;
|
||||
|
||||
export type TableRow = Record<string, TableCellValue>;
|
||||
|
||||
export type BlockIOTableSubSchema = BlockIOSubSchemaMeta & {
|
||||
type: "array";
|
||||
format: "table";
|
||||
items: BlockIOObjectSubSchema;
|
||||
const?: TableRow[];
|
||||
default?: TableRow[];
|
||||
secret?: boolean;
|
||||
};
|
||||
|
||||
export type BlockIOStringSubSchema = BlockIOSubSchemaMeta & {
|
||||
type: "string";
|
||||
enum?: string[];
|
||||
@@ -192,6 +208,7 @@ type BlockIOCombinedTypeSubSchema = BlockIOSubSchemaMeta & {
|
||||
anyOf: BlockIOSimpleTypeSubSchema[];
|
||||
default?: string | number | boolean | null;
|
||||
secret?: boolean;
|
||||
format?: string; // For table format and other formats on anyOf schemas
|
||||
}
|
||||
| BlockIOOneOfSubSchema
|
||||
| BlockIODiscriminatedOneOfSubSchema
|
||||
@@ -1061,6 +1078,10 @@ function _handleSingleTypeSchema(subSchema: BlockIOSubSchema): DataType {
|
||||
return DataType.NUMBER;
|
||||
}
|
||||
if (subSchema.type === "array") {
|
||||
// Check for table format first
|
||||
if ("format" in subSchema && subSchema.format === "table") {
|
||||
return DataType.TABLE;
|
||||
}
|
||||
/** Commented code below since we haven't yet support rendering of a multi-select with array { items: enum } type */
|
||||
// if ("items" in subSchema && subSchema.items && "enum" in subSchema.items) {
|
||||
// return DataType.MULTI_SELECT; // array + enum => multi-select
|
||||
@@ -1140,6 +1161,11 @@ export function determineDataType(schema: BlockIOSubSchema): DataType {
|
||||
|
||||
// (array | null)
|
||||
if (types.includes("array") && types.includes("null")) {
|
||||
// Check for table format on the parent schema (where anyOf is)
|
||||
if ("format" in schema && schema.format === "table") {
|
||||
return DataType.TABLE;
|
||||
}
|
||||
|
||||
const arrSchema = schema.anyOf.find((s) => s.type === "array");
|
||||
if (arrSchema) return _handleSingleTypeSchema(arrSchema);
|
||||
return DataType.ARRAY;
|
||||
|
||||
Reference in New Issue
Block a user