feat: new tool mode dialog and UI (#7647)

* Add tools input to the backend

* Add tools type

* Add tools component to parameter render

* Added tools to langflow supported types

* Add tools modal

* Instantiate tools component in parameter render

* Removed div when there is not any visible actions

* Added margin

* Add ComboBoxItem and ListItem components for enhanced selection UI

* Update ComboBoxItem to display item description and enhance ToolsModal layout

* Refactor ToolsModal header styling for improved layout and icon padding

* Enhance ComboBoxItem layout and styling; update ToolsModal size and class for better responsiveness

* Changed display name

* Adds truncate for badges

* Adds custom styling for table used for Tools modal

* Added Tools modal with AgGrid

* Changed button

* made name and description editable

* Parse values for saving

* Add focused row without triggering checkbox click

* [autofix.ci] apply automated fixes

* Added types for tools modal

* added toolsTable with sidebar on toolsModal

* Added changes to work with MCP

* update component.py to add display name and display description in tool mode

* removed editing directly

* Fixed editing

* removed to upper case

* Make editing apply filters

* Adds design changes for MCP

* Adds new design and null check

* 📝 (frontend): add data-testid attribute to elements for testing purposes
🔧 (frontend): update data-testid attribute values for consistency and clarity in testing
✅ (frontend): update tests to use correct selectors and improve test coverage for editing tools functionality

* ✅ (edit-tools.spec.ts): update test to use a more reliable method for checking visibility of an element

* ✅ (edit-tools.spec.ts): add "@components" tag to the test to categorize it under components for better organization and filtering in test suites.

* Updated design with new design

* update padding

* send args to tools data

* Implemented showing arguments passed to LLM in tool mode

* add componentg name to description

* update package lock

* fixed tests

* fixed backend test

* fixed backend test

* fixed formatting

* Fixed frontend tests

* updated font sizes for badges on actions and styling on sortable list

* Update tool mode design

* added tooltips for info

* tool name update

* Update component_tool.py

* styling utils

* default values change

* fixed tools test

* fix format issues

---------

Co-authored-by: deon-sanchez <deon.sanchez@datastax.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>
This commit is contained in:
Lucas Oliveira 2025-04-28 20:24:48 -03:00 • committed by GitHub
commit b1217f96fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1045 additions and 188 deletions

View file

@ -1,12 +1,12 @@
{
"name": "langflow",
"version": "1.3.3",
"version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langflow",
"version": "1.3.3",
"version": "1.3.4",
"dependencies": {
"@chakra-ui/number-input": "^2.1.2",
"@headlessui/react": "^2.0.4",

View file

@ -0,0 +1,64 @@
import { Button } from "@/components/ui/button";
import { useState } from "react";
interface ComboBoxItemProps {
item: any;
}
const ComboBoxItem = ({ item }: ComboBoxItemProps) => {
const [isChecked, setIsChecked] = useState(false);
return (
<div className="flex flex-row pb-1">
<div className="inline-flex justify-start">
<label
className="relative flex cursor-pointer"
htmlFor={`check-${item?.name}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => setIsChecked(!isChecked)}
className="peer h-5 w-5 cursor-pointer appearance-none rounded border border-border border-muted-foreground shadow transition-all hover:shadow-md"
id={`check-${item?.name}`}
/>
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform text-black opacity-0 peer-checked:bg-primary peer-checked:opacity-100">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
stroke="currentColor"
stroke-width="1"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
</span>
</label>
<Button
unstyled
className="mx-2 flex w-full justify-start rounded-md hover:bg-accent"
>
<label
className="ml-2 flex w-72 cursor-pointer text-sm font-bold text-primary"
htmlFor={`check-${item?.name}`}
>
<span className="truncate">{item?.name}</span>
</label>
<label
className="flex w-72 cursor-pointer text-sm text-muted-foreground"
htmlFor={`check-${item?.name}`}
>
<span className="truncate">{item?.description}</span>
</label>
</Button>
</div>
</div>
);
};
export default ComboBoxItem;

View file

@ -0,0 +1,118 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/utils";
import { useEffect, useRef, useState } from "react";
const ListItem = ({
item,
isSelected,
onClick,
className,
onMouseEnter,
onMouseLeave,
isFocused,
isKeyboardNavActive,
dataTestId,
}: {
item: any;
isSelected: boolean;
onClick: () => void;
className?: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
isFocused: boolean;
isKeyboardNavActive: boolean;
dataTestId: string;
}) => {
const [isHovered, setIsHovered] = useState(false);
const itemRef = useRef<HTMLButtonElement>(null);
// Clear hover state when keyboard navigation is active
useEffect(() => {
if (isKeyboardNavActive) {
setIsHovered(false);
}
}, [isKeyboardNavActive]);
// Scroll into view when focused by keyboard
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.scrollIntoView({ block: "nearest" });
}
}, [isFocused]);
return (
<Button
ref={itemRef}
key={item.id}
data-testid={dataTestId}
unstyled
size="sm"
className={cn(
"group flex w-full rounded-md px-2 py-0.5",
!isKeyboardNavActive && "hover:bg-muted", // Only apply hover styles when not in keyboard nav
!item.metaData && "py-2.5",
isFocused && "bg-muted",
className,
)}
onClick={onClick}
onMouseEnter={() => {
if (!isKeyboardNavActive) {
setIsHovered(true);
onMouseEnter();
}
}}
onMouseLeave={() => {
setIsHovered(false);
onMouseLeave();
}}
// Disable pointer events during keyboard navigation
style={{ pointerEvents: isKeyboardNavActive ? "none" : "auto" }}
>
<div className="flex w-full items-center gap-2">
{item.icon && (
<div>
<ForwardedIconComponent name={item.icon} className="mr-2 h-4 w-4" />
</div>
)}
<div className="flex w-full flex-col truncate">
<div className="flex w-full truncate text-mmd font-semibold">
<span className="truncate">{item.name}</span>
</div>
{"metaData" in item && item.metaData && (
<div className="flex w-full truncate text-mmd text-gray-500">
<span className="truncate">{item.metaData}</span>
</div>
)}
</div>
{isHovered || isFocused ? (
<div className="ml-auto flex items-center justify-start rounded-md">
<div className="flex items-center pr-1.5 text-mmd font-semibold text-muted-foreground">
Select
</div>
<div className="flex items-center justify-center rounded-md">
<ForwardedIconComponent
name="corner-down-left"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</div>
</div>
) : (
// Always show the check icon when selected, regardless of hover/focus state
isSelected && (
<ForwardedIconComponent
name="check"
className={cn(
"ml-auto flex h-4 w-4",
item.link === "validated" && "text-green-500",
)}
/>
)
)}
</div>
</Button>
);
};
export default ListItem;

View file

@ -1,12 +1,11 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import SearchBarComponent from "@/components/core/parameterRenderComponent/components/searchBarComponent";
import { InputProps } from "@/components/core/parameterRenderComponent/types";
import { Button } from "@/components/ui/button";
import { DialogHeader } from "@/components/ui/dialog";
import { Dialog, DialogContent } from "@/components/ui/dialog-with-no-close";
import { cn, testIdCase } from "@/utils/utils";
import { testIdCase } from "@/utils/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ListItem from "./ListItem";
// Update interface with better types
interface ListSelectionComponentProps {
@ -20,118 +19,6 @@ interface ListSelectionComponentProps {
limit?: number;
}
const ListItem = ({
item,
isSelected,
onClick,
className,
onMouseEnter,
onMouseLeave,
isFocused,
isKeyboardNavActive,
dataTestId,
}: {
item: any;
isSelected: boolean;
onClick: () => void;
className?: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
isFocused: boolean;
isKeyboardNavActive: boolean;
dataTestId: string;
}) => {
const [isHovered, setIsHovered] = useState(false);
const itemRef = useRef<HTMLButtonElement>(null);
// Clear hover state when keyboard navigation is active
useEffect(() => {
if (isKeyboardNavActive) {
setIsHovered(false);
}
}, [isKeyboardNavActive]);
// Scroll into view when focused by keyboard
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.scrollIntoView({ block: "nearest" });
}
}, [isFocused]);
return (
<Button
ref={itemRef}
key={item.id}
data-testid={dataTestId}
unstyled
size="sm"
className={cn(
"group flex w-full rounded-md px-2 py-0.5",
!isKeyboardNavActive && "hover:bg-muted", // Only apply hover styles when not in keyboard nav
!item.metaData && "py-2.5",
isFocused && "bg-muted",
className,
)}
onClick={onClick}
onMouseEnter={() => {
if (!isKeyboardNavActive) {
setIsHovered(true);
onMouseEnter();
}
}}
onMouseLeave={() => {
setIsHovered(false);
onMouseLeave();
}}
// Disable pointer events during keyboard navigation
style={{ pointerEvents: isKeyboardNavActive ? "none" : "auto" }}
>
<div className="flex w-full items-center gap-2">
{item.icon && (
<div>
<ForwardedIconComponent name={item.icon} className="mr-2 h-4 w-4" />
</div>
)}
<div className="flex w-full flex-col truncate">
<div className="flex w-full truncate text-mmd font-semibold">
<span className="truncate">{item.name}</span>
</div>
{"metaData" in item && item.metaData && (
<div className="flex w-full truncate text-mmd text-gray-500">
<span className="truncate">{item.metaData}</span>
</div>
)}
</div>
{isHovered || isFocused ? (
<div className="ml-auto flex items-center justify-start rounded-md">
<div className="flex items-center pr-1.5 text-mmd font-semibold text-muted-foreground">
Select
</div>
<div className="flex items-center justify-center rounded-md">
<ForwardedIconComponent
name="corner-down-left"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</div>
</div>
) : (
// Always show the check icon when selected, regardless of hover/focus state
isSelected && (
<ForwardedIconComponent
name="check"
className={cn(
"ml-auto flex h-4 w-4",
item.link === "validated" && "text-green-500",
)}
/>
)
)}
</div>
</Button>
);
};
const ListSelectionComponent = ({
open,
onClose,
@ -286,7 +173,7 @@ const ListSelectionComponent = ({
name={nodeClass?.icon || "unknown"}
className="h-[18px] w-[18px] text-muted-foreground"
/>
<div className="text-mmd font-semibold">
<div className="text-[13px] font-semibold">
{nodeClass?.display_name}
</div>
</div>

View file

@ -78,7 +78,11 @@ const RenderInputParameters = ({
(templateField: string, idx) => {
const template = data.node?.template[templateField];
if (!template?.show || template?.advanced) {
if (
!template?.show ||
template?.advanced ||
(template?.tool_mode && isToolMode)
) {
return null;
}

View file

@ -0,0 +1,142 @@
import { ICON_STROKE_WIDTH } from "@/constants/constants";
import ToolsModal from "@/modals/toolsModal";
import { cn, testIdCase } from "@/utils/utils";
import { useState } from "react";
import { ForwardedIconComponent } from "../../../../common/genericIconComponent";
import { Badge } from "../../../../ui/badge";
import { Button } from "../../../../ui/button";
import { Skeleton } from "../../../../ui/skeleton";
import { InputProps, ToolsComponentType } from "../../types";
export default function ToolsComponent({
description,
value,
editNode = false,
id = "",
handleOnNewValue,
isAction = false,
button_description,
title,
icon,
disabled = false,
template,
}: InputProps<any[] | undefined, ToolsComponentType>): JSX.Element {
const [isModalOpen, setIsModalOpen] = useState(false);
const actions = value
?.filter((action) => action.status === true)
.map((action) => {
return {
name: action.name,
tags: action.tags,
description: action.description,
};
});
const visibleActionsQt = isAction ? 20 : 4;
const visibleActions = actions?.slice(0, visibleActionsQt) || [];
const remainingCount = actions
? Math.max(0, actions.length - visibleActionsQt)
: 0;
return (
<div
className={cn(
"flex w-full items-center",
disabled && "cursor-not-allowed",
)}
>
{value && (
<ToolsModal
open={isModalOpen}
setOpen={setIsModalOpen}
isAction={isAction}
description={description}
template={template}
rows={value}
handleOnNewValue={handleOnNewValue}
title={title}
icon={icon}
/>
)}
<div
className="relative flex w-full items-center gap-3"
data-testid={"div-" + id}
>
{(visibleActions.length > 0 || isAction) && (
<Button
variant={"ghost"}
disabled={!value || disabled}
size={"iconMd"}
className={cn(
"absolute -top-8 right-0 font-semibold text-muted-foreground group-hover:text-primary",
)}
data-testid="button_open_actions"
onClick={() => setIsModalOpen(true)}
>
<ForwardedIconComponent
name="Settings2"
className="icon-size"
strokeWidth={ICON_STROKE_WIDTH}
/>
{button_description}
</Button>
)}
{!value ? (
<div className="flex w-full flex-wrap gap-1 overflow-hidden py-1.5">
{[...Array(4)].map((_, index) => (
<Skeleton key={index} className="h-6 w-20 rounded-full" />
))}
</div>
) : visibleActions.length > 0 ? (
<div className="flex w-full flex-wrap gap-1 overflow-hidden py-1.5">
{visibleActions.map((action, index) => (
<Badge
key={index}
variant="secondaryStatic"
size="sq"
className="truncate font-normal"
data-testid={testIdCase(`tool_${action.name}`)}
>
<span className="truncate text-xxs font-medium">
{action.name.toUpperCase()}
</span>
</Badge>
))}
{remainingCount > 0 && (
<span className="ml-1 self-center text-xs font-normal text-muted-foreground">
+{remainingCount} more
</span>
)}
</div>
) : (
visibleActions.length === 0 &&
isAction && (
<div className="mt-2 flex w-full flex-col items-center gap-2 rounded-md border border-dashed p-8">
<span className="text-sm text-muted-foreground">
No actions added to this server
</span>
<Button size={"sm"} onClick={() => setIsModalOpen(true)}>
<span>Add actions</span>
</Button>
</div>
)
)}
{visibleActions.length === 0 && !isAction && value && (
<Button
disabled={disabled}
size={editNode ? "xs" : "default"}
className={
"w-full " +
(disabled ? "pointer-events-none cursor-not-allowed" : "")
}
onClick={() => setIsModalOpen(true)}
>
<span>Select actions</span>
</Button>
)}
</div>
</div>
);
}

View file

@ -32,27 +32,27 @@ const SortableListItem = memo(
}) => (
<li
className={cn(
"inline-flex h-12 w-full items-center gap-2 text-sm font-medium text-gray-800",
"inline-flex h-12 w-full items-center gap-2 text-sm font-medium",
limit === 1 ? "h-6 rounded-md bg-muted" : "group cursor-grab",
)}
>
{limit !== 1 && (
<ForwardedIconComponent
name="grid-horizontal"
className="h-5 w-5 fill-gray-300 text-gray-300"
name="GridHorizontal"
className="h-5 w-5 text-muted-foreground"
/>
)}
<div className="flex w-full items-center gap-x-2">
{limit !== 1 && (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-400 text-center text-white">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-border text-center text-mmd text-primary">
{index + 1}
</div>
)}
<span
className={cn(
"truncate text-muted-foreground",
"truncate text-xxs font-medium text-muted-foreground",
limit === 1 ? "max-w-56 pl-2" : "max-w-48",
)}
>
@ -61,22 +61,16 @@ const SortableListItem = memo(
</div>
<Button
size="icon"
variant={limit !== 1 ? "outline" : "ghost"}
variant={"ghost"}
className={cn(
"ml-auto h-6 w-6 opacity-0 transition-opacity duration-200",
"ml-auto h-6 w-6 text-muted-foreground opacity-0 transition-opacity duration-200",
limit === 1
? "group pr-1 opacity-100"
: "hover:border hover:border-destructive hover:bg-transparent hover:opacity-100",
? "group pr-1 opacity-100 hover:text-foreground"
: "hover:text-destructive group-hover:opacity-100",
)}
onClick={onRemove}
>
<ForwardedIconComponent
name="x"
className={cn(
"h-6 w-6 text-red-500",
limit === 1 && "text-gray-500 group-hover:text-input",
)}
/>
<ForwardedIconComponent name="x" className={cn("h-6 w-6")} />
</Button>
</li>
),

View file

@ -6,6 +6,7 @@ import TabComponent from "@/components/core/parameterRenderComponent/components/
import { TEXT_FIELD_TYPES } from "@/constants/constants";
import { APIClassType, InputFieldType } from "@/types/api";
import { useMemo } from "react";
import ToolsComponent from "./components/ToolsComponent";
import ConnectionComponent from "./components/connectionComponent";
import DictComponent from "./components/dictComponent";
import { EmptyParameterComponent } from "./components/emptyParameterComponent";
@ -203,6 +204,16 @@ export function ParameterRenderComponent({
table_icon={templateData?.table_icon}
/>
);
case "tools":
return (
<ToolsComponent
{...baseInputProps}
description={templateData.info || "Add or edit data"}
title={nodeClass?.display_name ?? "Tools"}
icon={nodeClass?.icon ?? ""}
template={nodeClass?.template}
/>
);
case "slider":
return (
<SliderComponent

View file

@ -1,5 +1,10 @@
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APIClassType, InputFieldType, TableOptionsTypeAPI } from "@/types/api";
import {
APIClassType,
APITemplateType,
InputFieldType,
TableOptionsTypeAPI,
} from "@/types/api";
import { RangeSpecType } from "@/types/components";
import { ColumnField } from "@/types/utils/functions";
@ -40,6 +45,15 @@ export type TableComponentType = {
table_icon?: string;
};
export type ToolsComponentType = {
description: string;
title: string;
icon?: string;
button_description?: string;
isAction?: boolean;
template?: APITemplateType;
};
export type FloatComponentType = {
rangeSpec: RangeSpecType;
};

View file

@ -659,6 +659,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
"connect",
"auth",
"query",
"tools",
]);
export const FLEX_VIEW_TYPES = ["bool"];

View file

@ -0,0 +1,386 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import TableComponent from "@/components/core/parameterRenderComponent/components/tableComponent";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
useSidebar,
} from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APITemplateType } from "@/types/api";
import { parseString } from "@/utils/stringManipulation";
import { ColDef } from "ag-grid-community";
import { AgGridReact } from "ag-grid-react";
import { cloneDeep } from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
export default function ToolsTable({
rows,
data,
setData,
isAction,
open,
handleOnNewValue,
template,
}: {
rows: any[];
data: any[];
template?: APITemplateType;
setData: (data: any[]) => void;
open: boolean;
handleOnNewValue: handleOnNewValueType;
isAction: boolean;
}) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<any[] | null>(null);
const agGrid = useRef<AgGridReact>(null);
const [focusedRow, setFocusedRow] = useState<any | null>(null);
const [sidebarName, setSidebarName] = useState<string>("");
const [sidebarDescription, setSidebarDescription] = useState<string>("");
const { setOpen: setSidebarOpen } = useSidebar();
const getRowId = useMemo(() => {
return (params: any) => params.data.display_name ?? params.data.name;
}, []);
useEffect(() => {
const initialData = cloneDeep(rows);
setData(initialData);
const filter = initialData.filter((row) => row.status === true);
setSelectedRows(filter);
}, [rows, open]);
useEffect(() => {
const initialData = cloneDeep(rows);
const filter = initialData.filter((row) => row.status === true);
if (agGrid.current) {
agGrid.current?.api?.forEachNode((node) => {
if (
filter.some(
(row) =>
(row.display_name ?? row.name) ===
(node.data.display_name ?? node.data.name),
)
) {
node.setSelected(true);
} else {
node.setSelected(false);
}
});
}
}, [agGrid.current]);
useEffect(() => {
if (!open && selectedRows) {
handleOnNewValue({
value: data.map((row) => {
const processedValue = (
row.name !== ""
? parseString(row.name, ["snake_case", "no_blank", "lowercase"])
: parseString(row.display_name, [
"snake_case",
"no_blank",
"lowercase",
])
).slice(0, 46);
const processedDescription =
row.description !== "" ? row.description : row.display_description;
return selectedRows?.some(
(selected) =>
(selected.display_name ?? selected.name) ===
(row.display_name ?? row.name),
)
? {
...row,
status: true,
name: processedValue,
description: processedDescription,
}
: {
...row,
status: false,
name: processedValue,
description: processedDescription,
};
}),
});
}
}, [open]);
useEffect(() => {
if (focusedRow) {
setSidebarName(focusedRow.name);
setSidebarDescription(focusedRow.description);
} else {
setSidebarName("");
setSidebarDescription("");
}
}, [focusedRow]);
const columnDefs: ColDef[] = [
{
field: isAction ? "display_name" : "name",
headerName: isAction ? "Flow Name" : "Name",
flex: 1,
valueGetter: (params) =>
!isAction
? parseString(
params.data.display_name !== ""
? params.data.display_name
: params.data.name,
["space_case"],
)
: params.data.display_name,
},
{
field: "description",
headerName: "Description",
flex: 2,
cellClass: "text-muted-foreground",
},
{
field: isAction ? "name" : "tags",
headerName: isAction ? "Action" : "Slug",
flex: 1,
resizable: false,
valueGetter: (params) =>
isAction
? params.data.name !== ""
? parseString(params.data.name, [
"snake_case",
"no_blank",
"uppercase",
])
: parseString(params.data.display_name, [
"snake_case",
"no_blank",
"uppercase",
])
: parseString(params.data.tags.join(", "), [
"snake_case",
"uppercase",
]),
cellClass: "text-muted-foreground",
},
{
field: "tags",
headerName: "Tags",
flex: 1,
hide: true,
},
];
const handleSelectionChanged = (event) => {
if (open) {
const selectedData = event.api.getSelectedRows();
setSelectedRows(selectedData);
}
};
const handleSidebarInputChange = (
field: "name" | "description",
value: string,
) => {
if (!focusedRow) return;
const originalName = focusedRow.display_name;
setFocusedRow((prev) => (prev ? { ...prev, [field]: value } : null));
if (agGrid.current) {
const updatedRow = { ...focusedRow, [field]: value };
agGrid.current.api.applyTransaction({
update: [updatedRow],
});
const updatedData = data.map((row) =>
(row.display_name ?? row.name) === originalName ? updatedRow : row,
);
setData(updatedData);
}
};
const actionArgs = useMemo(() => {
return Object.entries(focusedRow?.args ?? {}).map(
([key, value]: [string, any]) => ({
display_name: value.title,
name: key,
description: value.description ?? null,
}),
);
}, [focusedRow]);
return (
<>
<main className="flex h-full w-full flex-1 flex-col gap-2 overflow-hidden py-4">
<div className="flex-none px-4">
<Input
icon="Search"
placeholder="Search actions..."
inputClassName="h-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex-1 overflow-auto">
<TableComponent
columnDefs={columnDefs}
rowData={data}
quickFilterText={searchQuery}
ref={agGrid}
rowSelection="multiple"
suppressRowClickSelection={true}
className="ag-tool-mode h-full w-full overflow-visible"
headerHeight={32}
rowHeight={32}
onSelectionChanged={handleSelectionChanged}
tableOptions={{
block_hide: true,
}}
onRowClicked={(event) => {
setFocusedRow(event.data);
setSidebarOpen(true);
}}
getRowId={getRowId}
/>
</div>
</main>
<Sidebar
side="right"
className="flex h-full flex-col overflow-auto border-l border-border"
>
<SidebarHeader className="flex-none px-4 py-4">
{focusedRow &&
(isAction ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label
className="text-mmd font-medium"
htmlFor="sidebar-name-input"
>
Name
</label>
<Input
id="sidebar-name-input"
value={sidebarName}
onChange={(e) => {
setSidebarName(e.target.value);
handleSidebarInputChange("name", e.target.value);
}}
maxLength={46}
placeholder="Edit name..."
data-testid="input_update_name"
/>
<div className="text-xs text-muted-foreground">
{isAction
? "Used as the function name when this flow is exposed to clients."
: "Used as the function name when this tool is exposed to the agent."}
</div>
</div>
<div className="flex flex-col gap-2">
<label
className="text-mmd font-medium"
htmlFor="sidebar-desc-input"
>
Description
</label>
<Textarea
id="sidebar-desc-input"
value={sidebarDescription}
onChange={(e) => {
setSidebarDescription(e.target.value);
handleSidebarInputChange("description", e.target.value);
}}
placeholder="Edit description..."
className="h-24"
data-testid="input_update_description"
/>
<div className="text-xs text-muted-foreground">
{isAction
? "This is the description for the action exposed to the clients."
: "This is the description for the tool exposed to the agents."}
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-1" data-testid="sidebar_header">
<h3
className="text-base font-medium"
data-testid="sidebar_header_name"
>
{parseString(focusedRow?.display_name || focusedRow?.name, [
"space_case",
])}
</h3>
<p
className="text-mmd text-muted-foreground"
data-testid="sidebar_header_description"
>
{focusedRow?.display_description ?? focusedRow?.description}
</p>
</div>
))}
</SidebarHeader>
{!isAction && <Separator />}
<SidebarContent className="flex flex-1 flex-col gap-0 overflow-visible px-2">
{focusedRow && (
<div className="flex h-full flex-col gap-4">
<SidebarGroup className="flex-1">
<SidebarGroupContent className="h-full pb-4">
<div className="flex h-full flex-col gap-4">
{actionArgs.length > 0 && (
<div className="flex flex-col gap-1.5">
<h3 className="mt-2 text-base font-medium">
Parameters
</h3>
<p className="text-mmd text-muted-foreground">
Manage inputs for this action
</p>
</div>
)}
{actionArgs.map((field) => (
<div key={field.name} className="flex flex-col gap-2">
<label className="flex text-sm font-medium">
{field.display_name}
{field.description && (
<ShadTooltip content={field.description}>
<div className="flex items-center text-sm font-medium hover:cursor-help">
<ForwardedIconComponent
name="info"
className="ml-1.5 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
</div>
</ShadTooltip>
)}
</label>
<Input
id="sidebar-desc-input"
disabled
placeholder="Input controlled by the agent"
onChange={(e) => {}}
/>
</div>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
</div>
)}
</SidebarContent>
</Sidebar>
</>
);
}

View file

@ -0,0 +1,89 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { SidebarProvider } from "@/components/ui/sidebar";
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APITemplateType } from "@/types/api";
import { AgGridReact } from "ag-grid-react";
import { cloneDeep } from "lodash";
import { ForwardedRef, forwardRef, useState } from "react";
import BaseModal from "../baseModal";
import ToolsTable from "./components/toolsTable";
interface ToolsModalProps {
open: boolean;
setOpen: (open: boolean) => void;
description: string;
rows: {
name: string;
tags: string[];
description: string;
status: boolean;
}[];
handleOnNewValue: handleOnNewValueType;
title: string;
icon?: string;
isAction?: boolean;
template?: APITemplateType;
}
const ToolsModal = forwardRef<AgGridReact, ToolsModalProps>(
(
{
description,
rows,
handleOnNewValue,
template,
title,
icon,
open,
isAction = false,
setOpen,
...props
}: ToolsModalProps,
ref: ForwardedRef<AgGridReact>,
) => {
const handleSetOpen = (newOpen: boolean) => {
if (setOpen) {
setOpen(newOpen);
}
};
const [data, setData] = useState<any[]>(cloneDeep(rows));
return (
<BaseModal
open={open}
size="templates"
className="flex max-h-[50vh] gap-0 p-0"
setOpen={(newOpen) => {
handleSetOpen(newOpen);
}}
>
<BaseModal.Header>
<div className="flex w-full flex-row items-center border-b border-border px-4 py-3">
{icon && (
<ForwardedIconComponent name={icon} className="mr-2 h-6 w-6" />
)}
<div>{title}</div>
</div>
</BaseModal.Header>
<BaseModal.Content overflowHidden className="flex flex-col p-0">
<div className="flex h-full">
<SidebarProvider width="20rem" defaultOpen={false}>
<ToolsTable
rows={rows}
isAction={isAction}
template={template}
data={data}
setData={setData}
open={open}
handleOnNewValue={handleOnNewValue}
/>
</SidebarProvider>
</div>
</BaseModal.Content>
</BaseModal>
);
},
);
export default ToolsModal;

View file

@ -14,6 +14,7 @@
--ag-panel-background-color: hsl(var(--accent)) !important;
--ag-row-hover-color: hsl(var(--accent)) !important;
--ag-header-height: 2.5rem !important;
--ag-checkbox-checked-color: hsl(var(--primary)) !important;
}
.ag-theme-shadcn .ag-paging-panel {
@ -33,7 +34,6 @@
.ag-cell-wrapper > *:not(.ag-cell-value):not(.ag-group-value) {
align-items: self-start;
position: relative;
top: 2px;
}
.ag-cell-wrapper > *:not(.ag-cell-value):not(.ag-group-value) {
@ -99,3 +99,46 @@
box-shadow: none !important;
outline: none !important;
}
.ag-tool-mode .ag-root-wrapper {
border: none !important;
}
.ag-tool-mode .ag-row {
border-bottom: none !important;
}
.ag-tool-mode .ag-header {
margin-bottom: 0rem !important;
border-bottom: 0 !important;
}
.ag-tool-mode .ag-header-cell {
padding: 0rem 1rem !important;
margin: 1px;
}
.ag-tool-mode .ag-ltr .ag-header-select-all {
margin-right: var(--ag-cell-widget-spacing);
}
.ag-tool-mode .ag-paging-panel {
border-top: none !important;
}
.ag-tool-mode .ag-cell-focus:not(.ag-cell-inline-editing) {
border: 1px solid transparent !important;
box-shadow: none !important;
outline: none !important;
}
.ag-tool-mode .ag-cell {
padding: 0rem 1rem !important;
}
.ag-tool-mode .ag-row-focus {
background-color: hsl(var(--accent)) !important;
}
.ag-tool-mode .ag-row-selected:not(.ag-row-focus)::before {
background-color: hsl(var(--background)) !important;
}

View file

@ -324,6 +324,7 @@ export type FieldParserType =
| "uppercase"
| "no_blank"
| "valid_csv"
| "space_case"
| "commands";
export type TableOptionsTypeAPI = {

View file

@ -1,3 +1,4 @@
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { ReactFlowJsonObject } from "@xyflow/react";
import { ReactElement, ReactNode } from "react";
import { InputOutput } from "../../constants/enums";
@ -676,6 +677,21 @@ export type textModalPropsType = {
setOpen?: (open: boolean) => void;
onCloseModal?: () => void;
};
export interface ToolsModalProps {
open: boolean;
setOpen: (open: boolean) => void;
description: string;
rows: {
name: string;
tags: string[];
description: string;
status: boolean;
}[];
handleOnNewValue: handleOnNewValueType;
title: string;
icon: string;
}
export type queryModalPropsType = {
setValue: (value: string) => void;
value: string;

View file

@ -3,7 +3,7 @@ import React from "react";
import { FieldParserType } from "../types/api";
function toSnakeCase(str: string): string {
return str.trim().replace(/\s+/g, "_");
return str.trim().replace(/[-\s]+/g, "_");
}
function toCamelCase(str: string): string {
@ -35,6 +35,15 @@ function toUpperCase(str: string): string {
return str?.toUpperCase();
}
function toSpaceCase(str: string): string {
return str
.trim()
.replace(/[_\s-]+/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
function noBlank(str: string): string {
const trim = str.trim();
if (trim === "") {
@ -101,6 +110,9 @@ export function parseString(
case "no_blank":
result = noBlank(result);
break;
case "space_case":
result = toSpaceCase(result);
break;
case "valid_csv":
result = validCsv(result);
break;

View file

@ -35,26 +35,46 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.locator('[data-testid="icon-Hammer"]').nth(1).waitFor({
await page.locator('[data-testid="icon-Hammer"]').nth(0).waitFor({
timeout: 3000,
state: "visible",
});
await page.getByTestId("icon-Hammer").nth(1).click();
await page.waitForSelector("text=actions", { timeout: 30000 });
await page.waitForSelector("text=edit tools", { timeout: 30000 });
await page.getByTestId("button_open_actions").click();
await page.waitForSelector("text=API Request", { timeout: 30000 });
const rowsCount = await page.getByRole("gridcell").count();
expect(rowsCount).toBeGreaterThan(3);
expect(await page.getByRole("switch").nth(0).isChecked()).toBe(true);
expect(
await page.locator('input[data-ref="eInput"]').nth(0).isChecked(),
).toBe(true);
await page.getByRole("switch").nth(0).click();
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(true);
expect(await page.getByRole("switch").nth(0).isChecked()).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(4).isChecked(),
).toBe(true);
await page.getByText("Save").last().click();
await page.locator('input[data-ref="eInput"]').nth(0).click();
await page.waitForTimeout(500);
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(4).isChecked(),
).toBe(false);
await page.getByText("Close").last().click();
await page.waitForSelector(
'[data-testid="generic-node-title-arrangement"]',
@ -63,14 +83,54 @@ test(
},
);
await page.waitForTimeout(500);
await page.waitForSelector('[data-testid="div-tools_tools_metadata"]', {
timeout: 3000,
});
await page.getByTestId("icon-Hammer").nth(1).click();
expect(
await page
.locator('[data-testid="div-tools_tools_metadata"]')
.isVisible(),
).toBe(true);
await page.waitForSelector("text=edit tools", { timeout: 30000 });
await page.getByTestId("div-tools_tools_metadata").click();
await page.waitForTimeout(500);
expect(await page.getByRole("switch").nth(0).isChecked()).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(4).isChecked(),
).toBe(false);
await page.locator('input[data-ref="eInput"]').nth(3).click();
await page.waitForTimeout(500);
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(true);
await page.getByRole("gridcell").nth(0).click();
expect(
await page.locator('[data-testid="sidebar_header_name"]').isVisible(),
).toBe(true);
expect(
await page
.locator('[data-testid="sidebar_header_description"]')
.isVisible(),
).toBe(true);
await page.waitForTimeout(500);
await page.getByText("Close").last().click();
expect(
await page.locator('[data-testid="tool_make_requests"]').isVisible(),
).toBe(true);
},
);