feat: adds new queryInput with separator and dialog (#7458)

* Add Query Input and Mixin on backend

* Adds Query on supported types

* Adds types for query modal and component

* Adds size for new query modal

* Adds query modal

* Adds query component

* Adds query component on parameter render

* [autofix.ci] apply automated fixes

*  (switch-case-size.ts): Update height value to 'h-fit' for 'small-query' case to improve responsiveness
 (queryInputComponent.spec.ts): Add unit test for user interaction with query input component, including updating code and testing functionality

* Fixed handle not working on Query Input

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: cristhianzl <cristhian.lousa@gmail.com>
This commit is contained in:
Lucas Oliveira 2025-04-10 10:04:54 -03:00 committed by GitHub
commit 404e04989a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 434 additions and 0 deletions

View file

@ -21,6 +21,7 @@ from .inputs import (
MultiselectInput,
NestedDictInput,
PromptInput,
QueryInput,
SecretStrInput,
SliderInput,
SortableListInput,
@ -54,6 +55,7 @@ __all__ = [
"MultiselectInput",
"NestedDictInput",
"PromptInput",
"QueryInput",
"SecretStrInput",
"SliderInput",
"SortableListInput",

View file

@ -35,6 +35,7 @@ class FieldTypes(str, Enum):
LINK = "link"
SLIDER = "slider"
TAB = "tab"
QUERY = "query"
SerializableFieldTypes = Annotated[FieldTypes, PlainSerializer(lambda v: v.value, return_type=str)]
@ -142,6 +143,11 @@ class AuthMixin(BaseModel):
auth_tooltip: str | None = Field(default="")
class QueryMixin(BaseModel):
separator: str | None = Field(default=None)
"""Separator for the query input. Defaults to None."""
# Specific mixin for fields needing file interaction
class FileMixin(BaseModel):
file_path: list[str] | str | None = Field(default="")

View file

@ -24,6 +24,7 @@ from .input_mixin import (
ListableInputMixin,
MetadataTraceMixin,
MultilineMixin,
QueryMixin,
RangeMixin,
SerializableFieldTypes,
SliderMixin,
@ -492,6 +493,22 @@ class AuthInput(BaseInputMixin, AuthMixin, MetadataTraceMixin):
show: bool = False
class QueryInput(MessageTextInput, QueryMixin):
"""Represents a query input field.
This class represents an query input field and provides functionality for handling search values.
It inherits from the `BaseInputMixin` and `QueryMixin` classes.
Attributes:
field_type (SerializableFieldTypes): The field type of the input. Defaults to FieldTypes.SEARCH.
separator (str | None): The separator for the query input. Defaults to None.
value (str): The value for the query input. Defaults to an empty string.
"""
field_type: SerializableFieldTypes = FieldTypes.QUERY
separator: str | None = Field(default=None)
class SortableListInput(BaseInputMixin, SortableListMixin, MetadataTraceMixin, ToolModeMixin):
"""Represents a list selection input field.
@ -606,6 +623,7 @@ class DefaultPromptField(Input):
InputTypes: TypeAlias = (
Input
| AuthInput
| QueryInput
| DefaultPromptField
| BoolInput
| DataInput

View file

@ -19,6 +19,7 @@ from langflow.inputs import (
MultiselectInput,
NestedDictInput,
PromptInput,
QueryInput,
SecretStrInput,
SliderInput,
StrInput,
@ -50,6 +51,7 @@ __all__ = [
"NestedDictInput",
"Output",
"PromptInput",
"QueryInput",
"SecretStrInput",
"SliderInput",
"StrInput",

View file

@ -0,0 +1,151 @@
import { GRADIENT_CLASS } from "@/constants/constants";
import QueryModal from "@/modals/queryModal";
import { useRef, useState } from "react";
import { cn } from "../../../../../utils/utils";
import IconComponent from "../../../../common/genericIconComponent";
import { Input } from "../../../../ui/input";
import { getPlaceholder } from "../../helpers/get-placeholder-disabled";
import { InputProps, QueryComponentType } from "../../types";
import { getIconName } from "../inputComponent/components/helpers/get-icon-name";
const inputClasses = {
base: ({ isFocused }: { isFocused: boolean }) =>
`w-full ${isFocused ? "" : "pr-3"}`,
editNode: "input-edit-node",
normal: ({ isFocused }: { isFocused: boolean }) =>
`primary-input ${isFocused ? "text-primary" : "text-muted-foreground"}`,
disabled: "disabled-state",
password: "password",
};
const externalLinkIconClasses = {
gradient: ({
disabled,
editNode,
}: {
disabled: boolean;
editNode: boolean;
}) =>
disabled
? ""
: editNode
? "gradient-fade-input-edit-node"
: "gradient-fade-input",
background: ({
disabled,
editNode,
}: {
disabled: boolean;
editNode: boolean;
}) =>
disabled
? ""
: editNode
? "background-fade-input-edit-node"
: "background-fade-input",
icon: "icons-parameters-comp absolute right-3 h-4 w-4 shrink-0",
editNodeTop: "top-[-1.4rem] h-5",
normalTop: "top-[-2.1rem] h-7",
iconTop: "top-[-1.7rem]",
};
export default function QueryComponent({
value,
disabled,
handleOnNewValue,
editNode = false,
id = "",
placeholder,
isToolMode = false,
display_name,
info,
separator,
}: InputProps<string, QueryComponentType>): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
const getInputClassName = () => {
return cn(
inputClasses.base({ isFocused }),
editNode ? inputClasses.editNode : inputClasses.normal({ isFocused }),
disabled && inputClasses.disabled,
isFocused && "pr-10",
);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleOnNewValue({ value: e.target.value });
};
const renderIcon = () => (
<div>
{!disabled && !isFocused && (
<div
className={cn(
externalLinkIconClasses.gradient({
disabled,
editNode,
}),
editNode
? externalLinkIconClasses.editNodeTop
: externalLinkIconClasses.normalTop,
)}
style={{
pointerEvents: "none",
background: isFocused
? undefined
: disabled
? "bg-background"
: GRADIENT_CLASS,
}}
aria-hidden="true"
/>
)}
<IconComponent
dataTestId={`button_open_text_area_modal_${id}${editNode ? "_advanced" : ""}`}
name={getIconName(disabled, "", "", false, isToolMode) || "Scan"}
className={cn(
"cursor-pointer bg-background",
externalLinkIconClasses.icon,
editNode
? externalLinkIconClasses.editNodeTop
: externalLinkIconClasses.iconTop,
disabled
? "bg-muted text-placeholder-foreground"
: "bg-background text-foreground",
)}
/>
</div>
);
return (
<div className={cn("w-full", disabled && "pointer-events-none")}>
<Input
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
id={id}
data-testid={id}
value={disabled ? "" : value}
onChange={handleInputChange}
disabled={disabled}
className={getInputClassName()}
placeholder={getPlaceholder(disabled, placeholder)}
aria-label={disabled ? value : undefined}
ref={inputRef}
type={"text"}
/>
<QueryModal
title={display_name}
description={info}
placeholder={placeholder}
value={value}
setValue={(newValue) => handleOnNewValue({ value: newValue })}
disabled={disabled}
>
<div className="relative w-full">{renderIcon()}</div>
</QueryModal>
</div>
);
}

View file

@ -17,6 +17,7 @@ import KeypairListComponent from "./components/keypairListComponent";
import LinkComponent from "./components/linkComponent";
import MultiselectComponent from "./components/multiselectComponent";
import PromptAreaComponent from "./components/promptComponent";
import QueryComponent from "./components/queryComponent";
import { RefreshParameterComponent } from "./components/refreshParameterComponent";
import SortableListComponent from "./components/sortableListComponent";
import { StrRenderComponent } from "./components/strRenderComponent";
@ -257,6 +258,16 @@ export function ParameterRenderComponent({
id={`tab_${id}`}
/>
);
case "query":
return (
<QueryComponent
{...baseInputProps}
display_name={templateData.display_name ?? ""}
info={templateData.info ?? ""}
separator={templateData.separator}
id={`query_${id}`}
/>
);
default:
return <EmptyParameterComponent {...baseInputProps} />;
}

View file

@ -104,6 +104,12 @@ export type TextAreaComponentType = {
updateVisibility?: () => void;
};
export type QueryComponentType = {
display_name: string;
info: string;
separator?: string;
};
export type InputGlobalComponentType = {
load_from_db: boolean | undefined;
password: boolean | undefined;

View file

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

View file

@ -18,6 +18,10 @@ export const switchCaseModalSize = (size: string) => {
minWidth = "min-w-[40vw]";
height = "h-[40vh]";
break;
case "small-query":
minWidth = "min-w-[35vw]";
height = "h-fit";
break;
case "medium-small-tall":
minWidth = "min-w-[50vw]";
height = "h-[70vh]";

View file

@ -171,6 +171,7 @@ interface BaseModalProps {
| "retangular"
| "smaller"
| "small"
| "small-query"
| "medium"
| "medium-tall"
| "large"

View file

@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from "react";
import { Textarea } from "../../components/ui/textarea";
import {
EDIT_TEXT_PLACEHOLDER,
TEXT_DIALOG_TITLE,
} from "../../constants/constants";
import { queryModalPropsType } from "../../types/components";
import { handleKeyDown } from "../../utils/reactflowUtils";
import { classNames } from "../../utils/utils";
import BaseModal from "../baseModal";
export default function QueryModal({
value,
setValue,
title,
description,
placeholder,
children,
disabled,
}: queryModalPropsType): JSX.Element {
const [modalOpen, setModalOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const textRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (typeof value === "string") setInputValue(value);
}, [value, modalOpen]);
return (
<BaseModal
onChangeOpenModal={(open) => {}}
open={modalOpen}
setOpen={setModalOpen}
size="small-query"
>
<BaseModal.Trigger disable={disabled} asChild>
{children}
</BaseModal.Trigger>
<BaseModal.Header>
<div className="flex w-full items-start gap-3">
<div className="flex">
<span data-testid="modal-title">{title ?? TEXT_DIALOG_TITLE}</span>
</div>
</div>
</BaseModal.Header>
<BaseModal.Content className="flex flex-col gap-2" overflowHidden>
<div className={classNames("flex h-full w-full rounded-lg border")}>
<Textarea
ref={textRef}
className="form-input h-full w-full resize-none overflow-auto rounded-lg focus-visible:ring-1"
value={inputValue}
onChange={(event) => {
setInputValue(event.target.value);
}}
placeholder={placeholder ?? EDIT_TEXT_PLACEHOLDER}
onKeyDown={(e) => {
handleKeyDown(e, value, "");
}}
id={"text-area-modal"}
data-testid={"text-area-modal"}
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</BaseModal.Content>
<BaseModal.Footer
submit={{
label: "Apply",
dataTestId: "genericModalBtnSave",
onClick: () => {
setValue(inputValue);
setModalOpen(false);
},
}}
/>
</BaseModal>
);
}

View file

@ -91,6 +91,7 @@ export type InputFieldType = {
icon?: string;
text?: string;
temp_file?: boolean;
separator?: string;
};
export type OutputFieldProxyType = {

View file

@ -673,6 +673,17 @@ export type textModalPropsType = {
setOpen?: (open: boolean) => void;
onCloseModal?: () => void;
};
export type queryModalPropsType = {
setValue: (value: string) => void;
value: string;
title: string;
description: string;
placeholder?: string;
disabled?: boolean;
children?: ReactNode;
open?: boolean;
setOpen?: (open: boolean) => void;
};
export type newFlowModalPropsType = {
open: boolean;

View file

@ -0,0 +1,141 @@
import { expect, Page, test } from "@playwright/test";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
// TODO: This component doesn't have slider needs updating
test(
"user should be able to use query input",
{
tag: ["@release", "@workspace"],
},
async ({ page }) => {
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("openai");
await page.waitForSelector('[data-testid="modelsOpenAI"]', {
timeout: 3000,
});
await page
.getByTestId("modelsOpenAI")
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.mouse.up();
await page.mouse.down();
await page.getByTestId("fit_view").click();
await page.getByTestId("title-OpenAI").click();
await page.getByTestId("code-button-modal").click();
let cleanCode = await extractAndCleanCode(page);
// Replace the multiline string in the code
let newCode = cleanCode.replace(
`StrInput(
name="openai_api_base",
display_name="OpenAI API Base",
advanced=True,
info="The base URL of the OpenAI API. "
"Defaults to https://api.openai.com/v1. "
"You can change this to use other APIs like JinaChat, LocalAI and Prem.",
),`,
`QueryInput(
name="openai_api_base",
display_name="OpenAI API Base",
advanced=False,
info="THIS IS A TEST INFORMATION TO DISPLAY",
placeholder="THIS IS A TEST PLACEHOLDER",
),`,
);
newCode = newCode.replace(
`from langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput`,
`from langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput, QueryInput`,
);
// make sure codes are different
expect(cleanCode).not.toEqual(newCode);
await page.locator("textarea").last().press(`ControlOrMeta+a`);
await page.keyboard.press("Backspace");
await page.locator("textarea").last().fill(newCode);
await page.locator('//*[@id="checkAndSaveBtn"]').click();
await page.getByTestId("fit_view").click();
await page
.getByTestId("query_query_openai_api_base")
.fill("THIS IS A TEST VALUE");
await page
.getByTestId("button_open_text_area_modal_query_query_openai_api_base")
.click();
expect(await page.getByTestId("text-area-modal").inputValue()).toEqual(
"THIS IS A TEST VALUE",
);
expect(
await page.getByText("THIS IS A TEST INFORMATION TO DISPLAY").isVisible(),
).toBeTruthy();
await page.getByTestId("text-area-modal").fill("THIS IS A NEW VALUE");
await page.getByTestId("genericModalBtnSave").click();
expect(
await page.getByTestId("query_query_openai_api_base").inputValue(),
).toEqual("THIS IS A NEW VALUE");
await page.getByTestId("edit-button-modal").click();
expect(
await page.getByTestId("query_query_edit_openai_api_base").inputValue(),
).toEqual("THIS IS A NEW VALUE");
await page
.getByTestId(
"button_open_text_area_modal_query_query_edit_openai_api_base_advanced",
)
.click();
await page
.getByTestId("text-area-modal")
.fill("THIS IA TEST TEXT INSIDE CONTROLS PANEL");
await page.getByTestId("genericModalBtnSave").click();
expect(
await page.getByTestId("query_query_edit_openai_api_base").inputValue(),
).toEqual("THIS IA TEST TEXT INSIDE CONTROLS PANEL");
await page.getByText("Close").last().click();
expect(
await page.getByTestId("query_query_openai_api_base").inputValue(),
).toEqual("THIS IA TEST TEXT INSIDE CONTROLS PANEL");
},
);
async function extractAndCleanCode(page: Page): Promise<string> {
const outerHTML = await page
.locator('//*[@id="codeValue"]')
.evaluate((el) => el.outerHTML);
const valueMatch = outerHTML.match(/value="([\s\S]*?)"/);
if (!valueMatch) {
throw new Error("Could not find value attribute in the HTML");
}
let codeContent = valueMatch[1]
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/");
return codeContent;
}