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:
parent
6f259c6bee
commit
404e04989a
14 changed files with 434 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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="")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -658,6 +658,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
|
|||
"sortableList",
|
||||
"connect",
|
||||
"auth",
|
||||
"query",
|
||||
]);
|
||||
|
||||
export const FLEX_VIEW_TYPES = ["bool"];
|
||||
|
|
|
|||
|
|
@ -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]";
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ interface BaseModalProps {
|
|||
| "retangular"
|
||||
| "smaller"
|
||||
| "small"
|
||||
| "small-query"
|
||||
| "medium"
|
||||
| "medium-tall"
|
||||
| "large"
|
||||
|
|
|
|||
79
src/frontend/src/modals/queryModal/index.tsx
Normal file
79
src/frontend/src/modals/queryModal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ export type InputFieldType = {
|
|||
icon?: string;
|
||||
text?: string;
|
||||
temp_file?: boolean;
|
||||
separator?: string;
|
||||
};
|
||||
|
||||
export type OutputFieldProxyType = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
141
src/frontend/tests/core/unit/queryInputComponent.spec.ts
Normal file
141
src/frontend/tests/core/unit/queryInputComponent.spec.ts
Normal 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(/"/g, '"')
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, "/");
|
||||
|
||||
return codeContent;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue