feature: Improve Table customization to enhance ux on tool mode (#5216)

* refactor: Add field validation options to TableOptions

* refactor: Add field validation options and trigger text/icon to TableMixin

* refactor: Add field validation options and trigger text/icon to TableMixin

* refactor: Add field validation options and trigger text/icon to TableMixin

* update table trigger for toolmode usage

* Refactor table trigger and field validation options

- Updated the table trigger for toolmode usage
- Added field validation options and trigger text/icon to TableMixin
- Modified TableOptions to block certain actions and hide options

* Refactor TableOptionsTypeAPI field names for blocking actions

* Refactor TableOptions default values for blocking actions

* Refactor TableOptions default values for blocking actions

* Refactor TableOptions component to include tableOptions prop

* Refactor table selection and pagination options

* Refactor TOOL_TABLE_SCHEMA to disable sorting and filtering for the "name" and "description" fields

* Refactor TableOptions to allow blocking hiding of fields

* Refactor TableModal and TableNodeComponent to include support for block hiding columns

* Refactor Column model to include support for different edit modes

* Refactor TableOptions to include support for field parsers

* Refactor TableOptions to include support for field parsers and blocking hiding of fields

* Refactor TableOptions to include support for inline editing of fields

* Refactor App.css to style large text inputs and text areas in AgGrid

* update types

* Update table modal to prevent closing the the modal while editing cell

* Refactor string manipulation utilities to support parsing and transforming strings based on specified field parsers

* add inline input support

* Refactor TextModal component to remove close button in the footer

* add field parser in context

* format code

* format code

* Add disable_edit field to Column class

* Refactor TableNodeComponent to exclude columns with disable_edit field from being editable

* [autofix.ci] apply automated fixes

* Fix casing in selector text for "Open table" in tableInputComponent tests

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
anovazzi1 2024-12-16 14:50:00 -03:00 committed by GitHub
commit dffc2d51cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 320 additions and 41 deletions

View file

@ -1,3 +1,5 @@
from langflow.schema.table import EditMode
TOOL_OUTPUT_NAME = "component_as_tool"
TOOL_OUTPUT_DISPLAY_NAME = "Toolset"
TOOLS_METADATA_INPUT_NAME = "tools_metadata"
@ -7,11 +9,17 @@ TOOL_TABLE_SCHEMA = [
"display_name": "Name",
"type": "str",
"description": "Specify the name of the output field.",
"sortable": False,
"filterable": False,
"edit_mode": EditMode.INLINE,
},
{
"name": "description",
"display_name": "Description",
"type": "str",
"description": "Describe the purpose of the output field.",
"sortable": False,
"filterable": False,
"edit_mode": EditMode.INLINE,
},
]

View file

@ -30,6 +30,7 @@ from langflow.schema.artifact import get_artifact_type, post_process_raw
from langflow.schema.data import Data
from langflow.schema.message import ErrorMessage, Message
from langflow.schema.properties import Source
from langflow.schema.table import FieldParserType, TableOptions
from langflow.services.tracing.schema import Log
from langflow.template.field.base import UNDEFINED, Input, Output
from langflow.template.frontend_node.custom_components import ComponentFrontendNode
@ -1183,8 +1184,22 @@ class Component(CustomComponent):
return TableInput(
name=TOOLS_METADATA_INPUT_NAME,
display_name="Tools Metadata",
info="Use the table to configure the tools.",
display_name="Toolset configuration",
real_time_refresh=True,
table_schema=TOOL_TABLE_SCHEMA,
value=tool_data,
trigger_icon="Hammer",
trigger_text="Open toolset",
table_options=TableOptions(
block_add=True,
block_delete=True,
block_edit=True,
block_sort=True,
block_filter=True,
block_hide=True,
block_select=True,
hide_options=True,
field_parsers={"name": FieldParserType.SNAKE_CASE},
),
)

View file

@ -12,7 +12,7 @@ from pydantic import (
from langflow.field_typing.range_spec import RangeSpec
from langflow.inputs.validators import CoalesceBool
from langflow.schema.table import Column, TableSchema
from langflow.schema.table import Column, TableOptions, TableSchema
class FieldTypes(str, Enum):
@ -184,6 +184,9 @@ class SliderMixin(BaseModel):
class TableMixin(BaseModel):
table_schema: TableSchema | list[Column] | None = None
trigger_text: str = Field(default="Open table")
trigger_icon: str = Field(default="Table")
table_options: TableOptions | None = None
@field_validator("table_schema")
@classmethod

View file

@ -13,6 +13,11 @@ class FormatterType(str, Enum):
boolean = "boolean"
class EditMode(str, Enum):
MODAL = "modal"
INLINE = "inline"
class Column(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
@ -22,6 +27,8 @@ class Column(BaseModel):
formatter: FormatterType | str | None = Field(default=None, alias="type")
description: str | None = None
default: str | None = None
disable_edit: bool = Field(default=False)
edit_mode: EditMode | None = Field(default=EditMode.MODAL)
@model_validator(mode="after")
def set_display_name(self):
@ -48,3 +55,44 @@ class Column(BaseModel):
class TableSchema(BaseModel):
columns: list[Column]
class FieldValidatorType(str, Enum):
"""Enum for field validation types."""
NO_SPACES = "no_spaces" # Prevents spaces in input
LOWERCASE = "lowercase" # Forces lowercase
UPPERCASE = "uppercase" # Forces uppercase
EMAIL = "email" # Validates email format
URL = "url" # Validates URL format
ALPHANUMERIC = "alphanumeric" # Only letters and numbers
NUMERIC = "numeric" # Only numbers
ALPHA = "alpha" # Only letters
PHONE = "phone" # Phone number format
SLUG = "slug" # URL slug format (lowercase, hyphens)
USERNAME = "username" # Alphanumeric with underscores
PASSWORD = "password" # Minimum security requirements # noqa: S105
class FieldParserType(str, Enum):
"""Enum for field parser types."""
SNAKE_CASE = "snake_case"
CAMEL_CASE = "camel_case"
PASCAL_CASE = "pascal_case"
KEBAB_CASE = "kebab_case"
LOWERCASE = "lowercase"
UPPERCASE = "uppercase"
class TableOptions(BaseModel):
block_add: bool = Field(default=False)
block_delete: bool = Field(default=False)
block_edit: bool = Field(default=False)
block_sort: bool = Field(default=False)
block_filter: bool = Field(default=False)
block_hide: bool | list[str] = Field(default=False)
block_select: bool = Field(default=False)
hide_options: bool = Field(default=False)
field_validators: dict[str, list[FieldValidatorType] | FieldValidatorType] | None = Field(default=None)
field_parsers: dict[str, list[FieldParserType] | FieldParserType] | None = Field(default=None)

View file

@ -197,3 +197,12 @@ code {
appearance: none;
margin: 0;
}
.ag-large-text-input.ag-text-area.ag-input-field {
background-color: hsl(var(--background)) !important;
}
.ag-large-text-input.ag-text-area.ag-input-field textarea {
resize: none !important;
height: 100% !important;
}

View file

@ -17,6 +17,9 @@ export default function TableNodeComponent({
columns,
handleOnNewValue,
disabled = false,
table_options,
trigger_icon = "Table",
trigger_text = "Open Table",
}: InputProps<any[], TableComponentType>): JSX.Element {
const dataTypeDefinitions: {
[cellDataType: string]: DataTypeDefinition<any>;
@ -63,7 +66,7 @@ export default function TableNodeComponent({
const agGrid = useRef<AgGridReact>(null);
const componentColumns = columns
? columns
: generateBackendColumnsFromValue(value ?? []);
: generateBackendColumnsFromValue(value ?? [], table_options);
const AgColumns = FormatColumns(componentColumns);
function setAllRows() {
if (agGrid.current && !agGrid.current.api.isDestroyed()) {
@ -100,16 +103,22 @@ export default function TableNodeComponent({
function updateComponent() {
setAllRows();
}
const editable = componentColumns.map((column) => {
const isCustomEdit =
column.formatter &&
(column.formatter === "text" || column.formatter === "json");
return {
field: column.name,
onUpdate: updateComponent,
editableCell: isCustomEdit ? false : true,
};
});
const editable = componentColumns
.map((column) => {
const isCustomEdit =
column.formatter &&
((column.formatter === "text" && column.edit_mode !== "inline") ||
column.formatter === "json");
return {
field: column.name,
onUpdate: updateComponent,
editableCell: isCustomEdit ? false : true,
};
})
.filter(
(col) =>
columns?.find((c) => c.name === col.field)?.disable_edit !== true,
);
return (
<div
@ -119,6 +128,7 @@ export default function TableNodeComponent({
>
<div className="flex w-full items-center gap-3" data-testid={"div-" + id}>
<TableModal
tableOptions={table_options}
dataTypeDefinitions={dataTypeDefinitions}
autoSizeStrategy={{ type: "fitGridWidth", defaultMinWidth: 100 }}
tableTitle={tableTitle}
@ -127,10 +137,10 @@ export default function TableNodeComponent({
onSelectionChanged={(event: SelectionChangedEvent) => {
setSelectedNodes(event.api.getSelectedNodes());
}}
rowSelection="multiple"
rowSelection={table_options?.block_select ? undefined : "multiple"}
suppressRowClickSelection={true}
editable={editable}
pagination={true}
pagination={!table_options?.hide_options}
addRow={addRow}
onDelete={deleteRow}
onDuplicate={duplicateRow}
@ -138,6 +148,7 @@ export default function TableNodeComponent({
className="h-full w-full"
columnDefs={AgColumns}
rowData={value}
context={{ field_parsers: table_options?.field_parsers }}
>
<Button
disabled={disabled}
@ -148,8 +159,11 @@ export default function TableNodeComponent({
(disabled ? "pointer-events-none cursor-not-allowed" : "")
}
>
<ForwardedIconComponent name="Table" className="mt-px h-4 w-4" />
<span className="font-normal">Open Table</span>
<ForwardedIconComponent
name={trigger_icon}
className="mt-px h-4 w-4"
/>
<span className="font-normal">{trigger_text}</span>
</Button>
</TableModal>
</div>

View file

@ -1,6 +1,7 @@
import IconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { Button } from "@/components/ui/button";
import { TableOptionsTypeAPI } from "@/types/api";
import { cn } from "@/utils/utils";
export default function TableOptions({
@ -10,6 +11,7 @@ export default function TableOptions({
hasSelection,
stateChange,
addRow,
tableOptions,
}: {
resetGrid: () => void;
duplicateRow?: () => void;
@ -17,11 +19,12 @@ export default function TableOptions({
addRow?: () => void;
hasSelection: boolean;
stateChange: boolean;
tableOptions?: TableOptionsTypeAPI;
}): JSX.Element {
return (
<div className={cn("absolute bottom-3 left-6")}>
<div className="flex items-center gap-3">
{addRow && (
{addRow && !tableOptions?.block_add && (
<div>
<ShadTooltip content={"Add a new row"}>
<Button data-testid="add-row-button" unstyled onClick={addRow}>

View file

@ -8,6 +8,7 @@ import {
} from "@/constants/constants";
import { useDarkStore } from "@/stores/darkStore";
import "@/style/ag-theme-shadcn.css"; // Custom CSS applied to the grid
import { TableOptionsTypeAPI } from "@/types/api";
import { cn } from "@/utils/utils";
import { ColDef } from "ag-grid-community";
import "ag-grid-community/styles/ag-grid.css"; // Mandatory CSS required by the grid
@ -36,6 +37,7 @@ export interface TableComponentProps extends AgGridReactProps {
onDelete?: () => void;
onDuplicate?: () => void;
addRow?: () => void;
tableOptions?: TableOptionsTypeAPI;
}
const TableComponent = forwardRef<
@ -55,7 +57,7 @@ const TableComponent = forwardRef<
let newCol = {
...col,
};
if (props.onSelectionChanged && index === 0) {
if (props.rowSelection && props.onSelectionChanged && index === 0) {
newCol = {
...newCol,
checkboxSelection: true,
@ -63,6 +65,17 @@ const TableComponent = forwardRef<
headerCheckboxSelectionFilteredOnly: true,
};
}
if (
(typeof props.tableOptions?.block_hide === "boolean" &&
props.tableOptions?.block_hide) ||
(Array.isArray(props.tableOptions?.block_hide) &&
props.tableOptions?.block_hide.includes(newCol.field ?? ""))
) {
newCol = {
...newCol,
lockVisible: true,
};
}
if (
(typeof props.editable === "boolean" && props.editable) ||
(Array.isArray(props.editable) &&
@ -95,6 +108,7 @@ const TableComponent = forwardRef<
}
return newCol;
});
console.log(colDef);
// @ts-ignore
const realRef: React.MutableRefObject<AgGridReact> =
useRef<AgGridReact | null>(null);
@ -170,6 +184,7 @@ const TableComponent = forwardRef<
);
}
}
console.log(colDef);
return (
<div
className={cn(
@ -206,8 +221,9 @@ const TableComponent = forwardRef<
}
}}
/>
{props.pagination && (
{!props.tableOptions?.hide_options && (
<TableOptions
tableOptions={props.tableOptions}
stateChange={columnStateChange}
hasSelection={realRef.current?.api?.getSelectedRows().length > 0}
duplicateRow={props.onDuplicate ? props.onDuplicate : undefined}

View file

@ -177,6 +177,9 @@ export function ParameterRenderComponent({
description={templateData.info || "Add or edit data"}
columns={templateData?.table_schema?.columns}
tableTitle={templateData?.display_name ?? "Table"}
table_options={templateData?.table_options}
trigger_icon={templateData?.trigger_icon}
trigger_text={templateData?.trigger_text}
/>
);
case "slider":

View file

@ -1,5 +1,5 @@
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APIClassType, InputFieldType } from "@/types/api";
import { APIClassType, InputFieldType, TableOptionsTypeAPI } from "@/types/api";
import { RangeSpecType } from "@/types/components";
import { ColumnField } from "@/types/utils/functions";
@ -28,6 +28,9 @@ export type TableComponentType = {
description: string;
tableTitle: string;
columns?: ColumnField[];
table_options?: TableOptionsTypeAPI;
trigger_text?: string;
trigger_icon?: string;
};
export type FloatComponentType = {

View file

@ -3,8 +3,10 @@ import TableComponent, {
TableComponentProps,
} from "@/components/core/parameterRenderComponent/components/tableComponent";
import { Button } from "@/components/ui/button";
import { TableOptionsTypeAPI } from "@/types/api";
import { DialogClose } from "@radix-ui/react-dialog";
import { ElementRef, forwardRef, useState } from "react";
import { AgGridReact } from "ag-grid-react";
import { ElementRef, ForwardedRef, forwardRef } from "react";
import BaseModal from "../baseModal";
interface TableModalProps extends TableComponentProps {
@ -12,18 +14,28 @@ interface TableModalProps extends TableComponentProps {
description: string;
disabled?: boolean;
children: React.ReactNode;
tableOptions?: TableOptionsTypeAPI;
hideColumns?: boolean | string[];
}
const TableModal = forwardRef<
ElementRef<typeof TableComponent>,
TableModalProps
>(
const TableModal = forwardRef<AgGridReact, TableModalProps>(
(
{ tableTitle, description, children, disabled, ...props }: TableModalProps,
ref,
ref: ForwardedRef<AgGridReact>,
) => {
return (
<BaseModal disable={disabled}>
<BaseModal
onEscapeKeyDown={(e) => {
if (
(
ref as React.RefObject<AgGridReact>
)?.current?.api.getEditingCells().length
) {
e.preventDefault();
}
}}
disable={disabled}
>
<BaseModal.Trigger asChild>{children}</BaseModal.Trigger>
<BaseModal.Header description={description}>
<span className="pr-2">{tableTitle}</span>

View file

@ -70,9 +70,6 @@ export default function TextModal({
Save
</Button>
)}
<Button className="flex gap-2 px-3" onClick={() => setOpen(false)}>
Close
</Button>
</div>
</BaseModal.Footer>
</BaseModal>

View file

@ -290,3 +290,40 @@ export type useMutationFunctionType<
"mutationFn" | "mutationKey"
>,
) => UseMutationResult<Data, Error, Variables>;
export type FieldValidatorType =
| "no_spaces"
| "lowercase"
| "uppercase"
| "email"
| "url"
| "alphanumeric"
| "numeric"
| "alpha"
| "phone"
| "slug"
| "username"
| "password";
export type FieldParserType =
| "snake_case"
| "camel_case"
| "pascal_case"
| "kebab_case"
| "lowercase"
| "uppercase";
export type TableOptionsTypeAPI = {
block_add?: boolean;
block_delete?: boolean;
block_edit?: boolean;
block_sort?: boolean;
block_filter?: boolean;
block_hide?: boolean | string[];
block_select?: boolean;
hide_options?: boolean;
field_validators?: Array<
FieldValidatorType | { [key: string]: FieldValidatorType }
>;
field_parsers?: Array<FieldParserType | { [key: string]: FieldParserType }>;
};

View file

@ -1,3 +1,5 @@
import { FieldParserType, FieldValidatorType } from "../api";
export type getCodesObjProps = {
runCurlCode: string;
webhookCurlCode: string;
@ -23,5 +25,7 @@ export interface ColumnField {
filterable: boolean;
formatter?: FormatterType;
description?: string;
disable_edit?: boolean;
default?: any; // Add this line
edit_mode?: "modal" | "inline";
}

View file

@ -0,0 +1,74 @@
import { FieldParserType } from "../types/api";
function toSnakeCase(str: string): string {
return str.trim().replace(/\s+/g, "_");
}
function toCamelCase(str: string): string {
return str
.replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
.replace(/^[A-Z]/, (c) => c.toLowerCase());
}
function toPascalCase(str: string): string {
return str
.replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
.replace(/^[a-z]/, (c) => c.toUpperCase());
}
function toKebabCase(str: string): string {
return str
.replace(/([A-Z])/g, " $1")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[_]+/g, "-");
}
function toLowerCase(str: string): string {
return str.toLowerCase();
}
function toUpperCase(str: string): string {
return str.toUpperCase();
}
export function parseString(
str: string,
parsers: FieldParserType[] | FieldParserType,
): string {
let result = str;
let parsersArray: FieldParserType[] = [];
if (typeof parsers === "string") {
parsersArray = [parsers];
} else {
parsersArray = parsers;
}
for (const parser of parsersArray) {
switch (parser) {
case "snake_case":
result = toSnakeCase(result);
break;
case "camel_case":
result = toCamelCase(result);
break;
case "pascal_case":
result = toPascalCase(result);
break;
case "kebab_case":
result = toKebabCase(result);
break;
case "lowercase":
result = toLowerCase(result);
break;
case "uppercase":
result = toUpperCase(result);
break;
}
}
return result;
}

View file

@ -1,6 +1,6 @@
import TableAutoCellRender from "@/components/core/parameterRenderComponent/components/tableComponent/components/tableAutoCellRender";
import { ColumnField, FormatterType } from "@/types/utils/functions";
import { ColDef, ColGroupDef } from "ag-grid-community";
import { ColDef, ColGroupDef, ValueParserParams } from "ag-grid-community";
import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import {
@ -9,7 +9,12 @@ import {
MODAL_CLASSES,
SHORTCUT_KEYS,
} from "../constants/constants";
import { APIDataType, InputFieldType, VertexDataTypeAPI } from "../types/api";
import {
APIDataType,
InputFieldType,
TableOptionsTypeAPI,
VertexDataTypeAPI,
} from "../types/api";
import {
groupedObjType,
nodeGroupedObjType,
@ -18,6 +23,7 @@ import {
import { NodeDataType, NodeType } from "../types/flow";
import { FlowState } from "../types/tabs";
import { isErrorLog } from "../types/utils/typeCheckingUtils";
import { parseString } from "./stringManipulation";
export function classNames(...classes: Array<string>): string {
return classes.filter(Boolean).join(" ");
@ -515,6 +521,20 @@ export function FormatColumns(columns: ColumnField[]): ColDef<any>[] {
field: col.name,
sortable: col.sortable,
filter: col.filterable,
editable: !col.disable_edit,
valueParser: (params: ValueParserParams) => {
const { context, newValue, colDef } = params;
if (
context.field_parsers &&
context.field_parsers[colDef.field ?? ""]
) {
return parseString(
newValue,
context.field_parsers[colDef.field ?? ""],
);
}
return newValue;
},
};
if (!col.formatter) {
col.formatter = FormatterType.text;
@ -525,7 +545,17 @@ export function FormatColumns(columns: ColumnField[]): ColDef<any>[] {
newCol.cellRendererParams = {
formatter: col.formatter,
};
newCol.cellRenderer = TableAutoCellRender;
if (col.formatter !== FormatterType.text || col.edit_mode !== "inline") {
newCol.cellRenderer = TableAutoCellRender;
} else {
newCol.wrapText = true;
newCol.autoHeight = true;
newCol.cellEditor = "agLargeTextCellEditor";
newCol.cellEditorPopup = true;
newCol.cellEditorParams = {
maxLength: 100000000,
};
}
}
return newCol;
});
@ -533,14 +563,17 @@ export function FormatColumns(columns: ColumnField[]): ColDef<any>[] {
return colDefs;
}
export function generateBackendColumnsFromValue(rows: Object[]): ColumnField[] {
export function generateBackendColumnsFromValue(
rows: Object[],
tableOptions?: TableOptionsTypeAPI,
): ColumnField[] {
const columns = extractColumnsFromRows(rows, "union");
return columns.map((column) => {
const newColumn: ColumnField = {
name: column.field ?? "",
display_name: column.headerName ?? "",
sortable: true,
filterable: true,
sortable: !tableOptions?.block_sort,
filterable: !tableOptions?.block_filter,
default: null, // Initialize default to null or appropriate value
};

View file

@ -83,11 +83,11 @@ class CustomComponent(Component):
await page.getByText("Check & Save").last().click();
await page.waitForSelector('text="Open Table"', {
await page.waitForSelector('text="Open table"', {
timeout: 3000,
});
await page.getByText("Open Table").click();
await page.getByText("Open table").click();
await page.waitForSelector(".ag-cell-value", {
timeout: 3000,
@ -166,11 +166,11 @@ class CustomComponent(Component):
await page.getByText("Close").last().click();
await page.waitForSelector("text=Open Table", {
await page.waitForSelector("text=Open table", {
timeout: 3000,
});
await page.getByText("Open Table").click();
await page.getByText("Open table").click();
await page.waitForSelector(".ag-cell-value", {
timeout: 3000,