feat(builder): UX and style updates (#7550)

- Handles:
  - Add `NodeHandle` to draw input and output handles
  - Position handles relatively
  - Make entire handle label clickable/connectable
  - Add input/output types below labels
  - Change color on hover and when connected
  - "Connected" no longer shows up when connected
- Edges:
  - Draw edge above node when connecting to the same node
  - Add custom `ConnectionLine`; drawn when making a connection
  - Add `CustomEdge`; drawn for existing connections
  - Add arrow to the edge end
  - Colorize depending on type
- Input field modal:
  - Select all text when opened
  - Disable node dragging
- CSS:
  - Remove not needed styling
  - Use tailwind classes instead of css for some components
  - Minor style changes
- Add shadcn switch
- Change bottom node buttons (for properties and advanced) to switches
- Format code
This commit is contained in:
Krzysztof Czerwinski
2024-07-23 09:36:42 +01:00
committed by GitHub
parent ab0df04bfe
commit 902d2a8924
9 changed files with 338 additions and 191 deletions

View File

@@ -0,0 +1,22 @@
import { BaseEdge, ConnectionLineComponentProps, getBezierPath, Position } from "reactflow";
const ConnectionLine: React.FC<ConnectionLineComponentProps> = ({ fromPosition, fromHandle, fromX, fromY, toPosition, toX, toY }) => {
const sourceX = fromPosition === Position.Right ?
fromX + (fromHandle?.width! / 2 - 5) : fromX - (fromHandle?.width! / 2 - 5);
const [path] = getBezierPath({
sourceX: sourceX,
sourceY: fromY,
sourcePosition: fromPosition,
targetX: toX,
targetY: toY,
targetPosition: toPosition,
});
return (
<BaseEdge path={path} style={{ strokeWidth: 2, stroke: '#555' }} />
);
};
export default ConnectionLine;

View File

@@ -0,0 +1,37 @@
import { FC, memo, useMemo } from "react";
import { BaseEdge, EdgeProps, getBezierPath, XYPosition } from "reactflow";
export type CustomEdgeData = {
edgeColor: string
sourcePos: XYPosition
}
const CustomEdgeFC: FC<EdgeProps<CustomEdgeData>> = ({ data, selected, source, sourcePosition, sourceX, sourceY, target, targetPosition, targetX, targetY, markerEnd }) => {
const [path] = getBezierPath({
sourceX: sourceX - 5,
sourceY,
sourcePosition,
targetX: targetX + 4,
targetY,
targetPosition,
});
// Calculate y difference between source and source node, to adjust self-loop edge
const yDifference = useMemo(() => sourceY - data!.sourcePos.y, [data!.sourcePos.y]);
// Define special edge path for self-loop
const edgePath = source === target ?
`M ${sourceX - 5} ${sourceY} C ${sourceX + 128} ${sourceY - yDifference - 128} ${targetX - 128} ${sourceY - yDifference - 128} ${targetX + 3}, ${targetY}` :
path;
return (
<BaseEdge
style={{ strokeWidth: 2, stroke: (data?.edgeColor ?? '#555555') + (selected ? '' : '80') }}
path={edgePath}
markerEnd={markerEnd}
/>
)
};
export const CustomEdge = memo(CustomEdgeFC);

View File

@@ -1,13 +1,14 @@
import React, { useState, useEffect, FC, memo } from 'react';
import { Handle, Position, NodeProps } from 'reactflow';
import { NodeProps } from 'reactflow';
import 'reactflow/dist/style.css';
import './customnode.css';
import ModalComponent from './ModalComponent';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { BlockSchema } from '@/lib/types';
import SchemaTooltip from './SchemaTooltip';
import { beautifyString } from '@/lib/utils';
import { Switch } from "@/components/ui/switch"
import NodeHandle from './NodeHandle';
type CustomNodeData = {
blockType: string;
@@ -43,12 +44,12 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
console.log(`Node ${id} data:`, data);
}, [id, data]);
const toggleOutput = () => {
setIsOutputOpen(!isOutputOpen);
const toggleOutput = (checked: boolean) => {
setIsOutputOpen(checked);
};
const toggleAdvancedSettings = () => {
setIsAdvancedOpen(!isAdvancedOpen);
const toggleAdvancedSettings = (checked: boolean) => {
setIsAdvancedOpen(checked);
};
const hasOptionalFields = () => {
@@ -57,33 +58,12 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
});
};
const generateHandles = (schema: BlockSchema, type: 'source' | 'target') => {
const generateOutputHandles = (schema: BlockSchema) => {
if (!schema?.properties) return null;
const keys = Object.keys(schema.properties);
return keys.map((key) => (
<div key={key} className="handle-container">
{type === 'target' && (
<>
<Handle
type={type}
position={Position.Left}
id={key}
style={{ background: '#555', borderRadius: '50%', width: '10px', height: '10px' }}
/>
<span className="handle-label">{schema.properties[key].title || beautifyString(key)}</span>
</>
)}
{type === 'source' && (
<>
<span className="handle-label">{schema.properties[key].title || beautifyString(key)}</span>
<Handle
type={type}
position={Position.Right}
id={key}
style={{ background: '#555', borderRadius: '50%', width: '10px', height: '10px' }}
/>
</>
)}
<div key={key}>
<NodeHandle keyName={key} isConnected={isHandleConnected(key)} schema={schema.properties[key]} side="right" />
</div>
));
};
@@ -113,9 +93,11 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
return data.connections && data.connections.some((conn: any) => {
if (typeof conn === 'string') {
const [source, target] = conn.split(' -> ');
return target.includes(key) && target.includes(data.title);
return (target.includes(key) && target.includes(data.title)) ||
(source.includes(key) && source.includes(data.title));
}
return conn.target === id && conn.targetHandle === key;
return (conn.target === id && conn.targetHandle === key) ||
(conn.source === id && conn.sourceHandle === key);
});
};
@@ -159,7 +141,7 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
}
if (isHandleConnected(fullKey)) {
return <div className="connected-input">Connected</div>;
return <></>;
}
const renderClickableInput = (value: string | null = null, placeholder: string = "", secret: boolean = false) => {
@@ -420,33 +402,24 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
return (
<div className={`custom-node dark-theme ${data.status === 'RUNNING' ? 'running' : data.status === 'COMPLETED' ? 'completed' : data.status === 'FAILED' ? 'failed' : ''}`}>
<div className="node-header">
<div className="node-title">{beautifyString(data.blockType?.replace(/Block$/, '') || data.title)}</div>
<div className="mb-2">
<div className="text-lg font-bold">{beautifyString(data.blockType?.replace(/Block$/, '') || data.title)}</div>
</div>
<div className="node-content">
<div className="input-section">
<div>
{data.inputSchema &&
Object.entries(data.inputSchema.properties).map(([key, schema]) => {
const isRequired = data.inputSchema.required?.includes(key);
return (isRequired || isAdvancedOpen) && (
<div key={key}>
<div className="handle-container">
<Handle
type="target"
position={Position.Left}
id={key}
style={{ background: '#555', borderRadius: '50%', width: '10px', height: '10px' }}
/>
<span className="handle-label">{schema.title || beautifyString(key)}</span>
<SchemaTooltip schema={schema} />
</div>
<NodeHandle keyName={key} isConnected={isHandleConnected(key)} schema={schema} side="left" />
{renderInputField(key, schema, '', schema.title || beautifyString(key))}
</div>
);
})}
</div>
<div className="output-section">
{data.outputSchema && generateHandles(data.outputSchema, 'source')}
<div>
{data.outputSchema && generateOutputHandles(data.outputSchema)}
</div>
</div>
{isOutputOpen && (
@@ -463,14 +436,14 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
</p>
</div>
)}
<div className="node-footer">
<Button onClick={toggleOutput} className="toggle-button">
Toggle Output
</Button>
<div className="flex items-center mt-2.5">
<Switch onCheckedChange={toggleOutput} className='custom-switch' />
<span className='m-1 mr-4'>Output</span>
{hasOptionalFields() && (
<Button onClick={toggleAdvancedSettings} className="toggle-button">
Toggle Advanced
</Button>
<>
<Switch onCheckedChange={toggleAdvancedSettings} className='custom-switch' />
<span className='m-1'>Advanced</span>
</>
)}
</div>
<ModalComponent

View File

@@ -11,6 +11,8 @@ import ReactFlow, {
OnConnect,
NodeTypes,
Connection,
EdgeTypes,
MarkerType,
} from 'reactflow';
import 'reactflow/dist/style.css';
import CustomNode from './CustomNode';
@@ -19,8 +21,10 @@ import AutoGPTServerAPI, { Block, Graph, ObjectSchema } from '@/lib/autogpt-serv
import { Button } from './ui/button';
import { Input } from './ui/input';
import { ChevronRight, ChevronLeft } from "lucide-react";
import { deepEquals } from '@/lib/utils';
import { deepEquals, getTypeColor } from '@/lib/utils';
import { beautifyString } from '@/lib/utils';
import { CustomEdge, CustomEdgeData } from './CustomEdge';
import ConnectionLine from './ConnectionLine';
type CustomNodeData = {
@@ -73,7 +77,7 @@ const FlowEditor: React.FC<{
className?: string;
}> = ({ flowID, template, className }) => {
const [nodes, setNodes] = useState<Node<CustomNodeData>[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [edges, setEdges] = useState<Edge<CustomEdgeData>[]>([]);
const [nodeId, setNodeId] = useState<number>(1);
const [availableNodes, setAvailableNodes] = useState<Block[]>([]);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
@@ -116,6 +120,7 @@ const FlowEditor: React.FC<{
}, [flowID, template, availableNodes]);
const nodeTypes: NodeTypes = useMemo(() => ({ custom: CustomNode }), []);
const edgeTypes: EdgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
@@ -127,58 +132,81 @@ const FlowEditor: React.FC<{
[]
);
const onConnect: OnConnect = useCallback(
(connection: Connection) => {
setEdges((eds) => addEdge(connection, eds));
setNodes((nds) =>
nds.map((node) => {
if (node.id === connection.target) {
return {
...node,
data: {
...node.data,
connections: [
...node.data.connections,
{
source: connection.source,
sourceHandle: connection.sourceHandle,
target: connection.target,
targetHandle: connection.targetHandle,
} as { source: string; sourceHandle: string; target: string; targetHandle: string },
],
},
};
}
return node;
})
);
},
[setEdges, setNodes]
);
const getOutputType = (id: string, handleId: string) => {
const node = nodes.find((node) => node.id === id);
if (!node) return 'unknown';
const outputSchema = node.data.outputSchema;
if (!outputSchema) return 'unknown';
const outputType = outputSchema.properties[handleId].type;
return outputType;
}
const getNodePos = (id: string) => {
const node = nodes.find((node) => node.id === id);
if (!node) return 0;
return node.position;
}
const onConnect: OnConnect = (connection: Connection) => {
const edgeColor = getTypeColor(getOutputType(connection.source!, connection.sourceHandle!));
const sourcePos = getNodePos(connection.source!)
console.log('sourcePos', sourcePos);
setEdges((eds) => addEdge({
type: 'custom',
markerEnd: { type: MarkerType.ArrowClosed, strokeWidth: 2, color: edgeColor },
data: { edgeColor, sourcePos },
...connection
}, eds));
setNodes((nds) =>
nds.map((node) => {
if (node.id === connection.target || node.id === connection.source) {
return {
...node,
data: {
...node.data,
connections: [
...node.data.connections,
{
source: connection.source,
sourceHandle: connection.sourceHandle,
target: connection.target,
targetHandle: connection.targetHandle,
} as { source: string; sourceHandle: string; target: string; targetHandle: string },
],
},
};
}
return node;
})
);
}
const onEdgesDelete = useCallback(
(edgesToDelete: Edge[]) => {
setNodes((nds) =>
nds.map((node) => ({
...node,
data: {
...node.data,
connections: node.data.connections.filter(
(conn: any) =>
!edgesToDelete.some(
(edge) =>
edge.source === conn.source &&
edge.target === conn.target &&
edge.sourceHandle === conn.sourceHandle &&
edge.targetHandle === conn.targetHandle
)
),
},
}))
);
},
[setNodes]
);
(edgesToDelete: Edge<CustomEdgeData>[]) => {
setNodes((nds) =>
nds.map((node) => ({
...node,
data: {
...node.data,
connections: node.data.connections.filter(
(conn: any) =>
!edgesToDelete.some(
(edge) =>
edge.source === conn.source &&
edge.target === conn.target &&
edge.sourceHandle === conn.sourceHandle &&
edge.targetHandle === conn.targetHandle
)
),
},
}))
);
},
[setNodes]
);
const addNode = (blockId: string, nodeType: string) => {
const nodeSchema = availableNodes.find(node => node.id === blockId);
@@ -247,14 +275,20 @@ const FlowEditor: React.FC<{
setEdges(graph.links.map(link => ({
id: `${link.source_id}_${link.source_name}_${link.sink_id}_${link.sink_name}`,
type: 'custom',
data: {
edgeColor: getTypeColor(getOutputType(link.source_id, link.source_name!)),
sourcePos: getNodePos(link.source_id)
},
markerEnd: { type: MarkerType.ArrowClosed, strokeWidth: 2, color: getTypeColor(getOutputType(link.source_id, link.source_name!)) },
source: link.source_id,
target: link.sink_id,
sourceHandle: link.source_name || undefined,
targetHandle: link.sink_name || undefined
})));
}) as Edge<CustomEdgeData>));
}
const prepareNodeInputData = (node: Node<CustomNodeData>, allNodes: Node<CustomNodeData>[], allEdges: Edge[]) => {
const prepareNodeInputData = (node: Node<CustomNodeData>, allNodes: Node<CustomNodeData>[], allEdges: Edge<CustomEdgeData>[]) => {
console.log("Preparing input data for node:", node.id, node.data.blockType);
const blockSchema = availableNodes.find(n => n.id === node.data.block_id)?.inputSchema;
@@ -376,13 +410,13 @@ const FlowEditor: React.FC<{
return frontendNode
? {
...frontendNode,
position: backendNode.metadata.position,
data: {
...frontendNode.data,
backend_id: backendNode.id,
},
}
...frontendNode,
position: backendNode.metadata.position,
data: {
...frontendNode.data,
backend_id: backendNode.id,
},
}
: null;
}).filter(node => node !== null);
@@ -433,20 +467,20 @@ const FlowEditor: React.FC<{
return (
<div className={className}>
<Button
variant="outline"
size="icon"
onClick={toggleSidebar}
style={{
position: 'fixed',
left: isSidebarOpen ? '350px' : '10px',
zIndex: 10000,
backgroundColor: 'black',
color: 'white',
}}
>
{isSidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={toggleSidebar}
style={{
position: 'fixed',
left: isSidebarOpen ? '350px' : '10px',
zIndex: 10000,
backgroundColor: 'black',
color: 'white',
}}
>
{isSidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<Sidebar isOpen={isSidebarOpen} availableNodes={availableNodes} addNode={addNode} />
<ReactFlow
nodes={nodes}
@@ -455,6 +489,8 @@ const FlowEditor: React.FC<{
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineComponent={ConnectionLine}
onEdgesDelete={onEdgesDelete}
deleteKeyCode={["Backspace", "Delete"]}
>

View File

@@ -1,4 +1,4 @@
import React, { FC, useEffect } from 'react';
import React, { FC, useEffect, useRef } from 'react';
import { Button } from './ui/button';
import { Textarea } from './ui/textarea';
@@ -11,10 +11,14 @@ interface ModalProps {
const ModalComponent: FC<ModalProps> = ({ isOpen, onClose, onSave, value }) => {
const [tempValue, setTempValue] = React.useState(value);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isOpen) {
setTempValue(value);
if (textAreaRef.current) {
textAreaRef.current.select();
}
}
}, [isOpen, value]);
@@ -28,10 +32,11 @@ const ModalComponent: FC<ModalProps> = ({ isOpen, onClose, onSave, value }) => {
}
return (
<div className="fixed inset-0 bg-white bg-opacity-60 flex justify-center items-center">
<div className="nodrag fixed inset-0 bg-white bg-opacity-60 flex justify-center items-center">
<div className="bg-white p-5 rounded-lg w-[500px] max-w-[90%]">
<center><h1>Enter input text</h1></center>
<Textarea
ref={textAreaRef}
className="w-full h-[200px] p-2.5 rounded border border-[#dfdfdf] text-black bg-[#dfdfdf]"
value={tempValue}
onChange={(e) => setTempValue(e.target.value)}

View File

@@ -0,0 +1,74 @@
import { BlockSchema } from "@/lib/types";
import { beautifyString, getTypeBgColor, getTypeTextColor } from "@/lib/utils";
import { FC } from "react";
import { Handle, Position } from "reactflow";
import SchemaTooltip from "./SchemaTooltip";
type HandleProps = {
keyName: string,
schema: BlockSchema,
isConnected: boolean,
side: 'left' | 'right'
}
const NodeHandle: FC<HandleProps> = ({ keyName, isConnected, schema, side }) => {
const typeName: Record<string, string> = {
string: 'text',
number: 'number',
boolean: 'true/false',
object: 'complex',
array: 'list',
null: 'null',
};
const typeClass = `text-sm ${getTypeTextColor(schema.type)} ${side === 'left' ? 'text-left' : 'text-right'}`;
const label = (
<div className="flex flex-col flex-grow">
<span className="text-m text-gray-900 -mb-1 green">{schema.title || beautifyString(keyName)}</span>
<span className={typeClass}>{typeName[schema.type]}</span>
</div>
);
const dot = (
<div className={`w-4 h-4 m-1 ${isConnected ? getTypeBgColor(schema.type) : 'bg-gray-600'} rounded-full transition-colors duration-100 group-hover:bg-gray-300`} />
);
if (side === 'left') {
return (
<div key={keyName} className="handle-container">
<Handle
type="target"
position={Position.Left}
id={keyName}
className='group -ml-[29px]'
>
<div className="pointer-events-none flex items-center">
{dot}
{label}
</div>
</Handle>
<SchemaTooltip schema={schema} />
</div>
)
} else {
return (
<div key={keyName} className="handle-container justify-end">
<Handle
type="source"
position={Position.Right}
id={keyName}
className='group -mr-[29px]'
>
<div className="pointer-events-none flex items-center">
{label}
{dot}
</div>
</Handle>
</div >
)
}
}
export default NodeHandle;

View File

@@ -14,7 +14,7 @@ const SchemaTooltip: React.FC<{ schema: BlockSchema }> = ({ schema }) => {
return (
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger className="flex items-center justify-center" asChild>
<TooltipTrigger asChild>
<Info className="p-1 rounded-full hover:bg-gray-300" size={24} />
</TooltipTrigger>
<TooltipContent className="max-w-xs tooltip-content">

View File

@@ -1,6 +1,6 @@
.custom-node {
padding: 15px;
border: 3px solid #000; /* Thicker border */
border: 3px solid #4b5563;
border-radius: 12px;
background: #ffffff; /* White background */
color: #000000;
@@ -9,24 +9,6 @@
transition: border-color 0.3s ease-in-out;
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.node-title {
font-size: 18px;
font-weight: bold;
}
.node-footer {
display: flex;
align-items: center;
margin-top: 10px;
}
.node-content {
display: flex;
justify-content: space-between;
@@ -34,39 +16,23 @@
gap: 1px;
}
.input-section {
display: flex;
flex-direction: column;
flex: 1;
}
.toggle-button {
color: #000000;
background: #ffffff;
}
.toggle-button:hover {
color: #000000;
background: #d1d1d1;
}
.handle-label {
color: #000000;
margin-left: 10px;
margin-right: 10px;
}
.output-section {
display: flex;
flex-direction: column;
flex: 1;
align-items: flex-end;
}
.handle-container {
display: flex;
position: relative;
margin-bottom: 5px;
margin-bottom: 0px;
padding: 5px;
min-height: 44px;
width: 100%;
height: 100%;
}
.react-flow__handle {
background: transparent;
width: auto;
height: auto;
border: 0;
position: relative;
transform: none;
}
.input-container {
@@ -206,13 +172,6 @@
border-color: #c0392b; /* Red border for failed nodes */
}
/* Adjust handle size */
.custom-node .react-flow__handle {
width: 10px;
height: 10px;
}
/* Make edges thicker */
.react-flow__edge {
stroke-width: 2px !important;
.custom-switch {
padding-left: 2px;
}

View File

@@ -30,6 +30,47 @@ export function deepEquals(x: any, y: any): boolean {
);
}
/** Get tailwind text color class from type name */
export function getTypeTextColor(type: string | null): string {
if (type === null) return 'bg-gray-500';
return {
string: 'text-green-500',
number: 'text-blue-500',
boolean: 'text-yellow-500',
object: 'text-purple-500',
array: 'text-indigo-500',
null: 'text-gray-500',
'': 'text-gray-500',
}[type] || 'text-gray-500';
}
/** Get tailwind bg color class from type name */
export function getTypeBgColor(type: string | null): string {
if (type === null) return 'bg-gray-500';
return {
string: 'bg-green-500',
number: 'bg-blue-500',
boolean: 'bg-yellow-500',
object: 'bg-purple-500',
array: 'bg-indigo-500',
null: 'bg-gray-500',
'': 'bg-gray-500',
}[type] || 'bg-gray-500';
}
export function getTypeColor(type: string | null): string {
if (type === null) return 'bg-gray-500';
return {
string: '#22c55e',
number: '#3b82f6',
boolean: '#eab308',
object: '#a855f7',
array: '#6366f1',
null: '#6b7280',
'': '#6b7280',
}[type] || '#6b7280';
}
export function beautifyString(name: string): string {
// Regular expression to identify places to split, considering acronyms
const result = name