fix: textarea with password visual bug (#3739)

* Fix textarea with password visual bug

* Changed password field to default to none

* updated sanitizedHTMLWrapper to receive ref

* fixed sanitizedhtmlwrapper type

* Added back to scroll position and cursor position on Chrome

* [autofix.ci] apply automated fixes

* Fix position of password

* Fixed tests

* Fixed examples

* Fixed test schema

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: anovazzi1 <otavio2204@gmail.com>
This commit is contained in:
Lucas Oliveira 2024-09-10 16:20:50 -03:00 • committed by GitHub
commit 5bb266cbc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 69 additions and 36 deletions

View file

@ -200,7 +200,7 @@
"show": true, "show": true,
"title_case": false, "title_case": false,
"type": "code", "type": "code",
"value": "import re\n\nfrom langchain_community.document_loaders.web_base import WebBaseLoader\n\nfrom langflow.helpers.data import data_to_text\nfrom langflow.custom import Component\nfrom langflow.io import MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.message import Message\n\n\nclass URLComponent(Component):\n display_name = \"URL\"\n description = \"Fetch content from one or more URLs.\"\n icon = \"layout-template\"\n name = \"URL\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs, by clicking the '+' button.\",\n is_list=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Data\", name=\"data\", method=\"fetch_content\"),\n Output(display_name=\"Text\", name=\"text\", method=\"fetch_content_text\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"\n Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n Raises an error if the string is not a valid URL.\n\n Parameters:\n string (str): The string to be checked and possibly modified.\n\n Returns:\n str: The modified string that is ensured to be a URL.\n\n Raises:\n ValueError: If the string is not a valid URL.\n \"\"\"\n if not string.startswith((\"http://\", \"https://\")):\n string = \"http://\" + string\n\n # Basic URL validation regex\n url_regex = re.compile(\n r\"^(https?:\\/\\/)?\" # optional protocol\n r\"(www\\.)?\" # optional www\n r\"([a-zA-Z0-9.-]+)\" # domain\n r\"(\\.[a-zA-Z]{2,})?\" # top-level domain\n r\"(:\\d+)?\" # optional port\n r\"(\\/[^\\s]*)?$\", # optional path\n re.IGNORECASE,\n )\n\n if not url_regex.match(string):\n raise ValueError(f\"Invalid URL: {string}\")\n\n return string\n\n def fetch_content(self) -> list[Data]:\n urls = [self.ensure_url(url.strip()) for url in self.urls if url.strip()]\n loader = WebBaseLoader(web_paths=urls, encoding=\"utf-8\")\n docs = loader.load()\n data = [Data(text=doc.page_content, **doc.metadata) for doc in docs]\n self.status = data\n return data\n\n def fetch_content_text(self) -> Message:\n content = self.fetch_content()\n data = content if isinstance(content, list) else [content]\n\n result_string = data_to_text(\"{text}\", data)\n self.status = result_string\n return Message(text=result_string)\n" "value": "import re\n\nfrom langchain_community.document_loaders.web_base import WebBaseLoader\n\nfrom langflow.helpers.data import data_to_text\nfrom langflow.custom import Component\nfrom langflow.io import MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.message import Message\n\n\nclass URLComponent(Component):\n display_name = \"URL\"\n description = \"Fetch content from one or more URLs.\"\n icon = \"layout-template\"\n name = \"URL\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs, by clicking the '+' button.\",\n is_list=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Data\", name=\"data\", method=\"fetch_content\"),\n Output(display_name=\"Text\", name=\"text\", method=\"fetch_content_text\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"\n Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n Raises an error if the string is not a valid URL.\n\n Parameters:\n string (str): The string to be checked and possibly modified.\n\n Returns:\n str: The modified string that is ensured to be a URL.\n\n Raises:\n ValueError: If the string is not a valid URL.\n \"\"\"\n if not string.startswith((\"http://\", \"https://\")):\n string = \"http://\" + string\n\n # Basic URL validation regex\n url_regex = re.compile(\n r\"^(https?:\\/\\/)?\" # optional protocol\n r\"(www\\.)?\" # optional www\n r\"([a-zA-Z0-9.-]+)\" # domain\n r\"(\\.[a-zA-Z]{2,})?\" # top-level domain\n r\"(:\\d+)?\" # optional port\n r\"(\\/[^\\s]*)?$\", # optional path\n re.IGNORECASE,\n )\n\n if not url_regex.match(string):\n raise ValueError(f\"Invalid URL: {string}\")\n\n return string\n\n def fetch_content(self) -> list[Data]:\n urls = [self.ensure_url(url.strip()) for url in self.urls if url.strip()]\n loader = WebBaseLoader(web_paths=urls, encoding=\"utf-8\")\n docs = loader.load()\n data = [Data(text=doc.page_content, **doc.metadata) for doc in docs]\n self.status = data\n return data\n\n def fetch_content_text(self) -> Message:\n data = self.fetch_content()\n\n result_string = data_to_text(\"{text}\", data)\n self.status = result_string\n return Message(text=result_string)\n"
}, },
"urls": { "urls": {
"advanced": false, "advanced": false,

View file

@ -5,7 +5,15 @@ from typing import _UnionGenericAlias # type: ignore
from typing import Any from typing import Any
from collections.abc import Callable from collections.abc import Callable
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_serializer, model_validator from pydantic import (
BaseModel,
ConfigDict,
Field,
field_serializer,
field_validator,
model_serializer,
model_validator,
)
from langflow.field_typing import Text from langflow.field_typing import Text
from langflow.field_typing.range_spec import RangeSpec from langflow.field_typing.range_spec import RangeSpec
@ -50,8 +58,8 @@ class Input(BaseModel):
file_path: str | None = "" file_path: str | None = ""
"""The file path of the field if it is a file. Defaults to None.""" """The file path of the field if it is a file. Defaults to None."""
password: bool = False password: bool | None = None
"""Specifies if the field is a password. Defaults to False.""" """Specifies if the field is a password. Defaults to None."""
options: list[str] | Callable | None = None options: list[str] | Callable | None = None
"""List of options for the field. Only used when is_list=True. Default is an empty list.""" """List of options for the field. Only used when is_list=True. Default is an empty list."""

View file

@ -40,8 +40,8 @@ def test_template_field_defaults(sample_template_field: Input):
assert sample_template_field.value is None assert sample_template_field.value is None
assert sample_template_field.file_types == [] assert sample_template_field.file_types == []
assert sample_template_field.file_path == "" assert sample_template_field.file_path == ""
assert sample_template_field.password is False
assert sample_template_field.name == "test_field" assert sample_template_field.name == "test_field"
assert sample_template_field.password is None
def test_template_to_dict(sample_template: Template, sample_template_field: Input): def test_template_to_dict(sample_template: Template, sample_template_field: Input):

View file

@ -57,7 +57,6 @@ class TestInput:
"multiline": False, "multiline": False,
"fileTypes": [], "fileTypes": [],
"file_path": "", "file_path": "",
"password": False,
"advanced": False, "advanced": False,
"title_case": False, "title_case": False,
"dynamic": False, "dynamic": False,

View file

@ -1,23 +1,24 @@
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { forwardRef } from "react";
import { SanitizedHTMLWrapperType } from "../../types/components"; import { SanitizedHTMLWrapperType } from "../../types/components";
const SanitizedHTMLWrapper = ({ const SanitizedHTMLWrapper = forwardRef<
className, HTMLDivElement,
content, React.HTMLAttributes<HTMLHeadingElement> & SanitizedHTMLWrapperType
onClick, >(({ content, suppressWarning = false, ...props }, ref) => {
suppressWarning = false,
}: SanitizedHTMLWrapperType): JSX.Element => {
const sanitizedHTML = DOMPurify.sanitize(content); const sanitizedHTML = DOMPurify.sanitize(content);
return ( return (
<div <div
ref={ref}
data-testid="edit-prompt-sanitized" data-testid="edit-prompt-sanitized"
className={className}
dangerouslySetInnerHTML={{ __html: sanitizedHTML }} dangerouslySetInnerHTML={{ __html: sanitizedHTML }}
suppressContentEditableWarning={suppressWarning} suppressContentEditableWarning={suppressWarning}
onClick={onClick} {...props}
/> />
); );
}; });
SanitizedHTMLWrapper.displayName = "SanitizedHTMLWrapper";
export default SanitizedHTMLWrapper; export default SanitizedHTMLWrapper;

View file

@ -50,12 +50,7 @@ export default function TextAreaComponent({
disabled={disabled} disabled={disabled}
password={password} password={password}
> >
<div <div className={classNames("flex items-center")}>
className={classNames(
"flex items-center",
password ? "relative left-6" : "",
)}
>
<Button unstyled> <Button unstyled>
<IconComponent <IconComponent
strokeWidth={1.5} strokeWidth={1.5}
@ -79,10 +74,7 @@ export default function TextAreaComponent({
unstyled unstyled
tabIndex={-1} tabIndex={-1}
className={classNames( className={classNames(
"side-bar-button-size absolute mb-px text-muted-foreground hover:text-current", "side-bar-button-size absolute right-3 top-1/2 mb-px -translate-y-1/2 text-muted-foreground hover:text-current",
editNode
? "bottom-[1.3rem] right-[4.2rem]"
: "bottom-4 right-[4.2rem]",
)} )}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
@ -128,12 +120,12 @@ export default function TextAreaComponent({
}; };
return ( return (
<div className={classNames("flex w-full items-center", disabled ? "" : "")}> <div className={classNames("flex w-full items-center gap-3")}>
<div className="flex w-full items-center gap-3" data-testid={`div-${id}`}> <div className="relative w-full" data-testid={`div-${id}`}>
{renderTextarea()} {renderTextarea()}
{renderExternalLinkButton()}
{renderPasswordToggle()} {renderPasswordToggle()}
</div> </div>
{renderExternalLinkButton()}
</div> </div>
); );
} }

View file

@ -1,5 +1,5 @@
import { usePostValidatePrompt } from "@/controllers/API/queries/nodes/use-post-validate-prompt"; import { usePostValidatePrompt } from "@/controllers/API/queries/nodes/use-post-validate-prompt";
import { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import IconComponent from "../../components/genericIconComponent"; import IconComponent from "../../components/genericIconComponent";
import SanitizedHTMLWrapper from "../../components/sanitizedHTMLWrapper"; import SanitizedHTMLWrapper from "../../components/sanitizedHTMLWrapper";
import ShadTooltip from "../../components/shadTooltipComponent"; import ShadTooltip from "../../components/shadTooltipComponent";
@ -47,6 +47,10 @@ export default function PromptModal({
const divRef = useRef(null); const divRef = useRef(null);
const divRefPrompt = useRef(null); const divRefPrompt = useRef(null);
const { mutate: postValidatePrompt } = usePostValidatePrompt(); const { mutate: postValidatePrompt } = usePostValidatePrompt();
const [clickPosition, setClickPosition] = useState({ x: 0, y: 0 });
const [scrollPosition, setScrollPosition] = useState(0);
const previewRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
function checkVariables(valueToCheck: string): void { function checkVariables(valueToCheck: string): void {
const regex = /\{([^{}]+)\}/g; const regex = /\{([^{}]+)\}/g;
@ -172,6 +176,37 @@ export default function PromptModal({
); );
} }
const handlePreviewClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isEdit && !readonly) {
const clickX = e.clientX;
const clickY = e.clientY;
setClickPosition({ x: clickX, y: clickY });
setScrollPosition(e.currentTarget.scrollTop);
setIsEdit(true);
}
};
useEffect(() => {
if (isEdit && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.scrollTop = scrollPosition;
const textArea = textareaRef.current;
const { x, y } = clickPosition;
// Use caretPositionFromPoint to get the closest text position. Does not work on Safari.
if ("caretPositionFromPoint" in document) {
let range = (document as any).caretPositionFromPoint(x, y)?.offset ?? 0;
if (range) {
const position = range;
textArea.setSelectionRange(position, position);
}
}
} else if (!isEdit && previewRef.current) {
previewRef.current.scrollTop = scrollPosition;
}
}, [isEdit, clickPosition, scrollPosition]);
return ( return (
<BaseModal <BaseModal
onChangeOpenModal={(open) => {}} onChangeOpenModal={(open) => {}}
@ -202,10 +237,11 @@ export default function PromptModal({
<Textarea <Textarea
id={"modal-" + id} id={"modal-" + id}
data-testid={"modal-" + id} data-testid={"modal-" + id}
ref={divRefPrompt} ref={textareaRef}
className="form-input h-full w-full resize-none rounded-lg custom-scroll focus-visible:ring-1" className="form-input h-full w-full resize-none rounded-lg border-0 custom-scroll focus-visible:ring-1"
value={inputValue} value={inputValue}
onBlur={() => { onBlur={() => {
setScrollPosition(textareaRef.current?.scrollTop || 0);
setIsEdit(false); setIsEdit(false);
}} }}
autoFocus autoFocus
@ -220,11 +256,10 @@ export default function PromptModal({
/> />
) : ( ) : (
<SanitizedHTMLWrapper <SanitizedHTMLWrapper
ref={previewRef}
className={getClassByNumberLength() + " bg-muted"} className={getClassByNumberLength() + " bg-muted"}
onClick={handlePreviewClick}
content={coloredContent} content={coloredContent}
onClick={() => {
if (!readonly) setIsEdit(true);
}}
suppressWarning={true} suppressWarning={true}
/> />
)} )}

View file

@ -644,9 +644,7 @@ export type parsedDataType = {
}; };
export type SanitizedHTMLWrapperType = { export type SanitizedHTMLWrapperType = {
className: string;
content: string; content: string;
onClick: () => void;
suppressWarning?: boolean; suppressWarning?: boolean;
}; };