From e2ff7b314b508ec9da33e48b5f1b00a98155184e Mon Sep 17 00:00:00 2001 From: Cristhian Zanforlin Lousa Date: Fri, 10 Jan 2025 17:35:13 -0300 Subject: [PATCH] feat: enhance URL component with improved description and render parameters (#5623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [LFOSS-74]: new input list UI * [LFOSS-93]: node colors * 📝 (Graph Vector Store RAG.json): Update description of ChatInput class to improve clarity and consistency 📝 (Graph Vector Store RAG.json): Update description of URLComponent class to improve clarity and consistency 📝 (select-items.tsx): Change noteDataType import to NoteDataType for consistency and clarity 📝 (dropdown-menu.tsx): Add RenderIcons component to display keyboard shortcuts for actions 📝 (index.tsx): Change parameter type from React.MouseEvent to KeyboardEvent for removeInput and handleDuplicateInput functions 📝 (use-overlap-shortcuts.tsx): Create custom hook to handle keyboard shortcuts with support for multiple key variations and modifiers * 📝 (backend): Remove unnecessary metadata for URLComponent in multiple files 📝 (frontend): Refactor input components to use listAddLabel instead of metadata in multiple files * ♻️ (NodeInputField/index.tsx): Remove unused 'metadata' variable to clean up the code and improve readability * [autofix.ci] apply automated fixes * merge fix * [autofix.ci] apply automated fixes * fix tests * 🐛 (generalBugs-shard-5.spec.ts): fix incorrect comments numbering connections 💡 (generalBugs-shard-5.spec.ts): add clarifying comments for connection steps in the test case * 🐛 (generalBugs-shard-5.spec.ts): fix filling delimiter in popover-anchor-input to resolve UI bug 📝 (generalBugs-shard-5.spec.ts): update test cases to improve test coverage and accuracy * ✨ (NodeOutputfield/index.tsx): Add top margin to improve spacing of NodeOutputField component 🔧 (generalBugs-shard-5.spec.ts): Remove commented out code related to input filling and waiting for visibility to clean up the test file and improve readability * 🔧 (index.tsx): increase padding-right from 6 to 10 in input-edit-node class to improve spacing for better user experience --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../base/langflow/components/data/url.py | 4 +- .../starter_projects/Blog Writer.json | 2 +- .../Custom Component Maker.json | 6 +- .../Graph Vector Store RAG.json | 2 +- .../base/langflow/inputs/input_mixin.py | 1 + .../components/NodeInputField/index.tsx | 6 +- .../NoteNode/components/select-items.tsx | 4 +- .../components/button-input-list.tsx | 32 +-- .../components/dropdown-menu.tsx | 147 +++++++++++++ .../components/inputListComponent/index.tsx | 197 +++++++++++------- .../core/parameterRenderComponent/index.tsx | 2 + .../core/parameterRenderComponent/types.ts | 2 + .../src/hooks/use-overlap-shortcuts.tsx | 120 +++++++++++ src/frontend/src/style/applies.css | 4 + src/frontend/src/utils/styleUtils.ts | 2 +- .../tests/core/features/stop-building.spec.ts | 2 +- .../regression/generalBugs-shard-5.spec.ts | 51 +++-- .../tests/core/unit/chatInputOutput.spec.ts | 7 +- .../core/unit/inputListComponent.spec.ts | 139 ++++++------ 19 files changed, 529 insertions(+), 201 deletions(-) create mode 100644 src/frontend/src/components/core/parameterRenderComponent/components/inputListComponent/components/dropdown-menu.tsx create mode 100644 src/frontend/src/hooks/use-overlap-shortcuts.tsx diff --git a/src/backend/base/langflow/components/data/url.py b/src/backend/base/langflow/components/data/url.py index 613040208..d1b247260 100644 --- a/src/backend/base/langflow/components/data/url.py +++ b/src/backend/base/langflow/components/data/url.py @@ -12,7 +12,7 @@ from langflow.schema.message import Message class URLComponent(Component): display_name = "URL" - description = "Fetch content from one or more URLs." + description = "Load and retrive data from specified URLs." icon = "layout-template" name = "URL" @@ -23,6 +23,8 @@ class URLComponent(Component): info="Enter one or more URLs, by clicking the '+' button.", is_list=True, tool_mode=True, + placeholder="Enter a URL...", + list_add_label="Add URL", ), DropdownInput( name="format", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Blog Writer.json b/src/backend/base/langflow/initial_setup/starter_projects/Blog Writer.json index 0ad5e9c30..5ade172c4 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Blog Writer.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Blog Writer.json @@ -219,7 +219,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\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 tool_mode=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" + "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\nfrom langflow.schema.message import Message\n\n\nclass URLComponent(Component):\n display_name = \"URL\"\n description = \"Load and retrive data from specified 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 tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" }, "format": { "_input_type": "DropdownInput", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Custom Component Maker.json b/src/backend/base/langflow/initial_setup/starter_projects/Custom Component Maker.json index 2c73025c7..7d1e333c1 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Custom Component Maker.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Custom Component Maker.json @@ -1668,7 +1668,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\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 tool_mode=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" + "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\nfrom langflow.schema.message import Message\n\n\nclass URLComponent(Component):\n display_name = \"URL\"\n description = \"Load and retrive data from specified 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 tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" }, "format": { "_input_type": "DropdownInput", @@ -1814,7 +1814,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\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 tool_mode=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" + "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\nfrom langflow.schema.message import Message\n\n\nclass URLComponent(Component):\n display_name = \"URL\"\n description = \"Load and retrive data from specified 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 tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" }, "format": { "_input_type": "DropdownInput", @@ -1966,7 +1966,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\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 tool_mode=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" + "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\nfrom langflow.schema.message import Message\n\n\nclass URLComponent(Component):\n display_name = \"URL\"\n description = \"Load and retrive data from specified 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 tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" }, "format": { "_input_type": "DropdownInput", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Graph Vector Store RAG.json b/src/backend/base/langflow/initial_setup/starter_projects/Graph Vector Store RAG.json index ad8a38bcb..a1d8fb755 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Graph Vector Store RAG.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Graph Vector Store RAG.json @@ -2638,7 +2638,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\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 tool_mode=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" + "value": "import re\n\nfrom langchain_community.document_loaders import AsyncHtmlLoader, WebBaseLoader\n\nfrom langflow.custom import Component\nfrom langflow.helpers.data import data_to_text\nfrom langflow.io import DropdownInput, MessageTextInput, Output\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\nfrom langflow.schema.message import Message\n\n\nclass URLComponent(Component):\n display_name = \"URL\"\n description = \"Load and retrive data from specified 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 tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=\"Output Format. Use 'Text' to extract the text from the HTML or 'Raw HTML' for the raw HTML content.\",\n options=[\"Text\", \"Raw HTML\"],\n value=\"Text\",\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 Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def ensure_url(self, string: str) -> str:\n \"\"\"Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.\n\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 msg = f\"Invalid URL: {string}\"\n raise ValueError(msg)\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 if self.format == \"Raw HTML\":\n loader = AsyncHtmlLoader(web_path=urls, encoding=\"utf-8\")\n else:\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\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.fetch_content())\n" }, "format": { "_input_type": "DropdownInput", diff --git a/src/backend/base/langflow/inputs/input_mixin.py b/src/backend/base/langflow/inputs/input_mixin.py index 7656ae23a..ee256ddf8 100644 --- a/src/backend/base/langflow/inputs/input_mixin.py +++ b/src/backend/base/langflow/inputs/input_mixin.py @@ -121,6 +121,7 @@ class MetadataTraceMixin(BaseModel): # Mixin for input fields that can be listable class ListableInputMixin(BaseModel): is_list: bool = Field(default=False, alias="list") + list_add_label: str | None = Field(default="Add More") # Specific mixin for fields needing database interaction diff --git a/src/frontend/src/CustomNodes/GenericNode/components/NodeInputField/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/NodeInputField/index.tsx index a1d1f977b..8f03374ad 100644 --- a/src/frontend/src/CustomNodes/GenericNode/components/NodeInputField/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/components/NodeInputField/index.tsx @@ -187,7 +187,11 @@ export default function NodeInputField({ handleNodeClass={handleNodeClass} nodeClass={data.node!} disabled={disabled} - placeholder={isToolMode ? DEFAULT_TOOLSET_PLACEHOLDER : undefined} + placeholder={ + isToolMode + ? DEFAULT_TOOLSET_PLACEHOLDER + : data.node?.template[name].placeholder + } isToolMode={isToolMode} /> )} diff --git a/src/frontend/src/CustomNodes/NoteNode/components/select-items.tsx b/src/frontend/src/CustomNodes/NoteNode/components/select-items.tsx index 041c3691a..d89fc2070 100644 --- a/src/frontend/src/CustomNodes/NoteNode/components/select-items.tsx +++ b/src/frontend/src/CustomNodes/NoteNode/components/select-items.tsx @@ -2,12 +2,12 @@ import { ForwardedIconComponent } from "@/components/common/genericIconComponent import { SelectItem } from "@/components/ui/select"; import { SelectContentWithoutPortal } from "@/components/ui/select-custom"; import ToolbarSelectItem from "@/pages/FlowPage/components/nodeToolbarComponent/toolbarSelectItem"; -import { noteDataType } from "@/types/flow"; +import { NoteDataType } from "@/types/flow"; import { memo } from "react"; export const SelectItems = memo( - ({ shortcuts, data }: { shortcuts: any[]; data: noteDataType }) => ( + ({ shortcuts, data }: { shortcuts: any[]; data: NoteDataType }) => ( void; - removeInput: (index: number, e: React.MouseEvent) => void; disabled: boolean; editNode: boolean; - addIcon: boolean; componentName: string; }) => { return ( <>
removeInput(index, e) - } + onClick={addNewInput} className={cn( - "hit-area-icon group flex items-center justify-center text-center", + "hit-area-icon group absolute flex -translate-y-8 translate-x-[15.5rem] items-center justify-center bg-background text-center hover:bg-muted", disabled ? "pointer-events-none bg-background hover:bg-background" : "", - (index === 0 && value.length <= 1) || addIcon - ? "bg-background hover:bg-muted" - : "hover:bg-smooth-red", )} > + + + { + handleDuplicateInput(index, e); + e.stopPropagation(); + }} + className="cursor-pointer" + data-testid={`input-list-dropdown-menu-${index}-duplicate`} + > + + { + removeInput(index, e); + e.stopPropagation(); + }} + className="cursor-pointer text-destructive" + data-testid={`input-list-dropdown-menu-${index}-delete`} + > + + + + + ); +}; diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/inputListComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/inputListComponent/index.tsx index c2a27895c..7eb8d7c19 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/inputListComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/inputListComponent/index.tsx @@ -1,11 +1,15 @@ -import { useEffect, useRef } from "react"; - import _ from "lodash"; -import { classNames, cn } from "../../../../../utils/utils"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; import { Input } from "../../../../ui/input"; +import { ButtonInputList } from "./components/button-input-list"; +import { DropdownMenuInputList } from "./components/dropdown-menu"; + +import { GRADIENT_CLASS } from "@/constants/constants"; +import { cn } from "../../../../../utils/utils"; import { getPlaceholder } from "../../helpers/get-placeholder-disabled"; import { InputListComponentType, InputProps } from "../../types"; -import { ButtonInputList } from "./components/button-input-list"; export default function InputListComponent({ value = [""], @@ -15,89 +19,140 @@ export default function InputListComponent({ componentName, id, placeholder, + listAddLabel, }: InputProps): JSX.Element { + const [dropdownOpen, setDropdownOpen] = useState(null); + const [focusedIndex, setFocusedIndex] = useState(null); + const inputRef = useRef(null); + useEffect(() => { if (disabled && value.length > 0 && value[0] !== "") { handleOnNewValue({ value: [""] }, { skipSnapshot: true }); } - }, [disabled]); - const inputRef = useRef(null); + }, [disabled, handleOnNewValue, value]); - // @TODO Recursive Character Text Splitter - the value might be in string format, whereas the InputListComponent specifically requires an array format. To ensure smooth operation and prevent potential errors, it's crucial that we handle the conversion from a string to an array with the string as its element. if (typeof value === "string") { value = [value]; } - if (!value?.length) value = [""]; - const handleInputChange = (index, newValue) => { - const newInputList = _.cloneDeep(value); - newInputList[index] = newValue; - handleOnNewValue({ value: newInputList }); - }; + const handleInputChange = useCallback( + (index: number, newValue: string) => { + const newInputList = _.cloneDeep(value); + newInputList[index] = newValue; + handleOnNewValue({ value: newInputList }); + }, + [value, handleOnNewValue], + ); - const addNewInput = (e) => { - e.preventDefault(); - const newInputList = _.cloneDeep(value); - newInputList.push(""); - handleOnNewValue({ value: newInputList }); - }; + const addNewInput = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const newInputList = _.cloneDeep(value); + newInputList.push(""); + handleOnNewValue({ value: newInputList }); + }, + [value, handleOnNewValue], + ); - const removeInput = (index, e) => { - e.preventDefault(); - const newInputList = _.cloneDeep(value); - newInputList.splice(index, 1); - handleOnNewValue({ value: newInputList }); - }; + const removeInput = useCallback( + (index: number, e: React.MouseEvent | KeyboardEvent) => { + e.preventDefault(); + const newInputList = _.cloneDeep(value); + newInputList.splice(index, 1); + handleOnNewValue({ value: newInputList }); + setDropdownOpen(null); + }, + [value, handleOnNewValue], + ); + + const handleDuplicateInput = useCallback( + (index: number, e: React.MouseEvent | KeyboardEvent) => { + e.preventDefault(); + const newInputList = _.cloneDeep(value); + newInputList.splice(index, 0, newInputList[index]); + handleOnNewValue({ value: newInputList }); + setDropdownOpen(null); + }, + [value, handleOnNewValue], + ); return ( -
1 && editNode ? "my-1" : "", - "flex w-full flex-col gap-3", +
+ {!editNode && !disabled && ( + + )} + +
+ {value.map((singleValue, index) => ( +
+
+ + handleInputChange(index, event.target.value) + } + data-testid={`${id}_${index}`} + onFocus={() => setFocusedIndex(index)} + onBlur={() => setFocusedIndex(null)} + /> + + {focusedIndex !== index && !disabled && ( + +
+ ))} +
+ + {!disabled && ( + )} - > - {value.map((singleValue, index) => ( -
- 1 && "w-3/4 pr-7 focus:pr-3", - )} - placeholder={getPlaceholder(disabled, placeholder)} - onChange={(event) => handleInputChange(index, event.target.value)} - data-testid={`${id}_${index}`} - /> - {index === 0 && value.length > 1 && ( - - )} - -
- ))}
); } diff --git a/src/frontend/src/components/core/parameterRenderComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/index.tsx index 118ad39df..fa4972560 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/index.tsx @@ -65,6 +65,7 @@ export function ParameterRenderComponent({ placeholder, isToolMode, }; + if (TEXT_FIELD_TYPES.includes(templateData.type ?? "")) { if (templateData.list) { if (!templateData.options) { @@ -73,6 +74,7 @@ export function ParameterRenderComponent({ {...baseInputProps} componentName={name} id={`inputlist_${id}`} + listAddLabel={templateData?.list_add_label} /> ); } diff --git a/src/frontend/src/components/core/parameterRenderComponent/types.ts b/src/frontend/src/components/core/parameterRenderComponent/types.ts index ba8b2d119..b0391b6b4 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/types.ts +++ b/src/frontend/src/components/core/parameterRenderComponent/types.ts @@ -15,6 +15,7 @@ export type BaseInputProps = { readonly?: boolean; placeholder?: string; isToolMode?: boolean; + metadata?: any; }; // Generic type for composing input props @@ -73,6 +74,7 @@ export type StrRenderComponentType = { export type InputListComponentType = { componentName?: string; id?: string; + listAddLabel?: string; }; export type DropDownComponentType = { diff --git a/src/frontend/src/hooks/use-overlap-shortcuts.tsx b/src/frontend/src/hooks/use-overlap-shortcuts.tsx new file mode 100644 index 000000000..0e8736227 --- /dev/null +++ b/src/frontend/src/hooks/use-overlap-shortcuts.tsx @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef } from "react"; + +const KEY_MAPPINGS: { [key: string]: string[] } = { + backspace: ["backspace", "delete"], + delete: ["delete", "backspace"], + enter: ["enter", "return"], + escape: ["escape", "esc"], + space: [" ", "spacebar", "space"], + arrowup: ["arrowup", "up"], + arrowdown: ["arrowdown", "down"], + arrowleft: ["arrowleft", "left"], + arrowright: ["arrowright", "right"], +}; + +interface UseKeyboardShortcutProps { + shortcutKeys: { [key: string]: string }; + isEnabled?: boolean; + onShortcut: (shortcutName: string, event: KeyboardEvent) => void; + preventDefault?: boolean; + stopPropagation?: boolean; +} + +export const useKeyboardShortcut = ({ + shortcutKeys, + isEnabled = true, + onShortcut, + preventDefault = true, + stopPropagation = true, +}: UseKeyboardShortcutProps) => { + const propsRef = useRef({ shortcutKeys, isEnabled, onShortcut }); + + useEffect(() => { + propsRef.current = { shortcutKeys, isEnabled, onShortcut }; + }, [shortcutKeys, isEnabled, onShortcut]); + + const normalizeKey = useCallback((key: string): string[] => { + const lowercaseKey = key.toLowerCase(); + return KEY_MAPPINGS[lowercaseKey] || [lowercaseKey]; + }, []); + + const parseShortcut = useCallback((shortcut: string) => { + const parts = shortcut + .toLowerCase() + .split("+") + .map((part) => part.trim()); + + // Handle 'mod' key (cmd on Mac, ctrl on others) + const modIndex = parts.indexOf("mod"); + if (modIndex !== -1) { + parts[modIndex] = navigator.platform.toLowerCase().includes("mac") + ? "meta" + : "ctrl"; + } + + return parts; + }, []); + + const matchesShortcut = useCallback( + (event: KeyboardEvent, shortcut: string) => { + if (!shortcut) return false; + + const parts = parseShortcut(shortcut); + const shortcutKey = parts[parts.length - 1]; + const possibleKeys = normalizeKey(shortcutKey); + const eventKey = event.key.toLowerCase(); + + // Check if the pressed key matches any possible variations + const keyMatches = possibleKeys.includes(eventKey); + + // Get modifiers state + const modifiers = { + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + }; + + // Check modifiers + const modifierParts = parts.slice(0, -1); + const modifiersMatch = modifierParts.every( + (mod) => modifiers[mod as keyof typeof modifiers], + ); + + // Check no extra modifiers + const hasExtraModifiers = Object.entries(modifiers).some( + ([mod, pressed]) => pressed && !modifierParts.includes(mod), + ); + + return keyMatches && modifiersMatch && !hasExtraModifiers; + }, + [normalizeKey, parseShortcut], + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const { shortcutKeys, isEnabled, onShortcut } = propsRef.current; + + if (!isEnabled) return; + + for (const [name, shortcut] of Object.entries(shortcutKeys)) { + if (matchesShortcut(event, shortcut)) { + if (preventDefault) { + event.preventDefault(); + } + if (stopPropagation) { + event.stopPropagation(); + } + onShortcut(name, event); + break; + } + } + }; + + window.addEventListener("keydown", handleKeyDown, true); + + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [preventDefault, stopPropagation, matchesShortcut]); +}; diff --git a/src/frontend/src/style/applies.css b/src/frontend/src/style/applies.css index 23a35adc3..9fef5085d 100644 --- a/src/frontend/src/style/applies.css +++ b/src/frontend/src/style/applies.css @@ -1276,6 +1276,10 @@ .input-slider-text { @apply absolute bottom-[4.2rem] right-3 w-14 cursor-text rounded-sm px-2 py-[1px] text-center hover:ring-[1px] hover:ring-slider-input-border; } + + .btn-add-input-list { + @apply mt-3 flex h-8 w-full items-center justify-center rounded-md p-2 text-sm hover:bg-muted; + } } /* Gradient background */ diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts index dcd1183f1..be0332e42 100644 --- a/src/frontend/src/utils/styleUtils.ts +++ b/src/frontend/src/utils/styleUtils.ts @@ -431,6 +431,7 @@ export const nodeColors: { [char: string]: string } = { }; export const nodeColorsName: { [char: string]: string } = { + // custom_components: "#ab11ab", inputs: "emerald", outputs: "red", data: "sky", @@ -462,7 +463,6 @@ export const nodeColorsName: { [char: string]: string } = { astra_assistants: "indigo", langchain_utilities: "sky", output_parsers: "yellow", - // custom_components: "#ab11ab", retrievers: "yellow", str: "indigo", Text: "indigo", diff --git a/src/frontend/tests/core/features/stop-building.spec.ts b/src/frontend/tests/core/features/stop-building.spec.ts index 50b81965b..5f18fe887 100644 --- a/src/frontend/tests/core/features/stop-building.spec.ts +++ b/src/frontend/tests/core/features/stop-building.spec.ts @@ -54,7 +54,7 @@ test( await page .getByTestId("processingParse Data") .dragTo(page.locator('//*[@id="react-flow-id"]'), { - targetPosition: { x: 100, y: 400 }, + targetPosition: { x: 100, y: 500 }, }); //fifth component diff --git a/src/frontend/tests/core/regression/generalBugs-shard-5.spec.ts b/src/frontend/tests/core/regression/generalBugs-shard-5.spec.ts index d1062fa66..7b9fc4ea1 100644 --- a/src/frontend/tests/core/regression/generalBugs-shard-5.spec.ts +++ b/src/frontend/tests/core/regression/generalBugs-shard-5.spec.ts @@ -26,12 +26,22 @@ test( await zoomOut(page, 4); + await page.waitForTimeout(500); + await page .getByTestId("inputsText Input") .dragTo(page.locator('//*[@id="react-flow-id"]'), { targetPosition: { x: 500, y: 150 }, }); + await page.waitForTimeout(500); + + await page + .getByTestId("inputsText Input") + .dragTo(page.locator('//*[@id="react-flow-id"]'), { + targetPosition: { x: 670, y: 200 }, + }); + await page.getByTestId("sidebar-search-input").click(); await page.getByTestId("sidebar-search-input").fill("combine text"); @@ -45,12 +55,20 @@ test( targetPosition: { x: 10, y: 10 }, }); + await page.waitForTimeout(500); + + await page.getByTestId("popover-anchor-input-delimiter").fill("-"); + await page .getByTestId("processingCombine Text") .dragTo(page.locator('//*[@id="react-flow-id"]'), { targetPosition: { x: 200, y: 10 }, }); + await page.waitForTimeout(500); + + await page.getByTestId("popover-anchor-input-delimiter").last().fill("-"); + await page.getByTestId("sidebar-search-input").click(); await page.getByTestId("sidebar-search-input").fill("text"); @@ -161,7 +179,7 @@ test( await page.getByTestId("group-node").click(); - //connection 2 + //connection 1 const elementTextOutput0 = page .getByTestId("handle-textinput-shownode-text-right") .nth(0); @@ -171,17 +189,28 @@ test( ); await elementGroupInput0.click(); - //connection 3 + //connection 2 const elementTextOutput1 = page .getByTestId("handle-textinput-shownode-text-right") - .nth(2); + .nth(4); await elementTextOutput1.click(); - const elementGroupInput1 = page .getByTestId("handle-groupnode-shownode-second text-left") - .nth(1); + .first(); await elementGroupInput1.click(); + //connection 3 + const elementTextOutput2 = page + .getByTestId("handle-textinput-shownode-text-right") + .nth(2); + await elementTextOutput2.click(); + + const elementGroupInput2 = page + .getByTestId("handle-groupnode-shownode-second text-left") + .nth(1) + .last(); + await elementGroupInput2.click(); + //connection 4 const elementGroupOutput = page .getByTestId("handle-groupnode-shownode-combined text-right") @@ -200,20 +229,10 @@ test( .nth(1) .fill(secondRandomName); - await page - .getByPlaceholder("Type something...", { exact: true }) - .nth(4) - .fill(thirdRandomName); - - await page - .getByPlaceholder("Type something...", { exact: true }) - .nth(3) - .fill("-"); - await page .getByPlaceholder("Type something...", { exact: true }) .nth(2) - .fill("-"); + .fill(thirdRandomName); await page.getByTestId("button_run_text output").last().click(); diff --git a/src/frontend/tests/core/unit/chatInputOutput.spec.ts b/src/frontend/tests/core/unit/chatInputOutput.spec.ts index b0f0ed08d..5f7d1fe02 100644 --- a/src/frontend/tests/core/unit/chatInputOutput.spec.ts +++ b/src/frontend/tests/core/unit/chatInputOutput.spec.ts @@ -92,6 +92,9 @@ test("chat_io_teste", { tag: ["@release", "@workspace"] }, async ({ page }) => { await page.getByTestId("input-chat-playground").click(); await page.getByTestId("input-chat-playground").fill("teste"); await page.getByTestId("button-send").first().click(); - const chat_input = page.getByTestId("chat-message-User-teste"); - await expect(chat_input).toHaveText("teste", { timeout: 10000 }); + const chat_input = await page + .getByTestId("chat-message-User-teste") + .textContent(); + + expect(chat_input).toBe("teste"); }); diff --git a/src/frontend/tests/core/unit/inputListComponent.spec.ts b/src/frontend/tests/core/unit/inputListComponent.spec.ts index 676a26287..c459fc706 100644 --- a/src/frontend/tests/core/unit/inputListComponent.spec.ts +++ b/src/frontend/tests/core/unit/inputListComponent.spec.ts @@ -17,9 +17,10 @@ test( }); await page .getByTestId("dataURL") - .dragTo(page.locator('//*[@id="react-flow-id"]')); - await page.mouse.up(); - await page.mouse.down(); + .hover() + .then(async () => { + await page.getByTestId("add-component-button-url").click(); + }); await adjustScreenView(page); await page.getByTestId("inputlist_str_urls_0").fill("test test test test"); @@ -53,93 +54,83 @@ test( expect(false).toBeTruthy(); } - await page.getByTestId("input-list-minus-btn-edit_urls-1").click(); + await page.getByTestId("input-list-dropdown-menu-0-edit").click(); - const plusButtonLocator = page.getByTestId( - "input-list-minus-btn-edit_urls-1", - ); - const elementCount = await plusButtonLocator?.count(); + await page.getByTestId("input-list-dropdown-menu-0-delete").click(); - if (elementCount > 1) { - expect(false).toBeTruthy(); - } + expect( + await page.getByTestId("input-list-dropdown-menu-2-edit").count(), + ).toBe(0); + + await page.getByTestId("input-list-dropdown-menu-1-edit").click(); + + await page.getByTestId("input-list-dropdown-menu-1-delete").click(); + + expect( + await page.getByTestId("input-list-dropdown-menu-1-edit").count(), + ).toBe(0); await page.getByText("Close").last().click(); - await page.getByTestId("input-list-minus-btn_urls-2").isHidden(); + await page.getByTestId("input-list-add-more-view").click(); + await page.getByTestId("input-list-add-more-view").click(); + await page.getByTestId("input-list-add-more-view").click(); - await page.getByTestId("input-list-plus-btn_urls-0").click(); - await page.getByTestId("input-list-plus-btn_urls-0").click(); + expect( + await page.getByTestId("input-list-dropdown-menu-0-view").count(), + ).toBe(1); - await page.getByTestId("inputlist_str_urls_0").fill("test test test test"); - await page - .getByTestId("inputlist_str_urls_1") - .fill("test1 test1 test1 test1"); - await page - .getByTestId("inputlist_str_urls_2") - .fill("test2 test2 test2 test2"); - await page - .getByTestId("inputlist_str_urls_3") - .fill("test3 test3 test3 test3"); + expect( + await page.getByTestId("input-list-dropdown-menu-1-view").count(), + ).toBe(1); - await page.getByTestId("div-generic-node").click(); - await page.getByTestId("more-options-modal").click(); - await page.getByTestId("advanced-button-modal").click(); + expect( + await page.getByTestId("input-list-dropdown-menu-2-view").count(), + ).toBe(1); - const value0Edit = await page - .getByTestId("inputlist_str_edit_urls_0") - .inputValue(); - const value1Edit = await page - .getByTestId("inputlist_str_edit_urls_1") - .inputValue(); - const value2Edit = await page - .getByTestId("inputlist_str_edit_urls_2") - .inputValue(); - const value3Edit = await page - .getByTestId("inputlist_str_edit_urls_3") - .inputValue(); + expect( + await page.getByTestId("input-list-dropdown-menu-3-view").count(), + ).toBe(1); - if ( - value0Edit !== "test test test test" || - value1Edit !== "test1 test1 test1 test1" || - value2Edit !== "test2 test2 test2 test2" || - value3Edit !== "test3 test3 test3 test3" - ) { - expect(false).toBeTruthy(); - } + expect( + await page.getByTestId("input-list-dropdown-menu-4-view").count(), + ).toBe(0); - await page.getByTestId("input-list-minus-btn-edit_urls-1").click(); - await page.getByTestId("input-list-minus-btn-edit_urls-1").click(); - await page.getByTestId("input-list-minus-btn-edit_urls-1").click(); + await page.getByTestId("input-list-dropdown-menu-0-view").click(); + await page.getByTestId("input-list-dropdown-menu-0-duplicate").click(); - const plusButtonLocatorEdit0 = await page.getByTestId( - "input-list-plus-btn-edit_urls-0", - ); - const elementCountEdit0 = await plusButtonLocatorEdit0?.count(); - - const plusButtonLocatorEdit2 = await page.getByTestId( - "input-list-plus-btn-edit_urls-1", - ); - const elementCountEdit2 = await plusButtonLocatorEdit2?.count(); - - if (elementCountEdit0 > 1 || elementCountEdit2 > 0) { - expect(false).toBeTruthy(); - } - - const minusButtonLocatorEdit1 = await page.getByTestId( - "input-list-minus-btn-edit_urls-1", + expect(await page.getByTestId("inputlist_str_urls_0").inputValue()).toBe( + "test1 test1 test1 test1", ); - const elementCountMinusEdit1 = await minusButtonLocatorEdit1?.count(); - - const minusButtonLocatorEdit2 = await page.getByTestId( - "input-list-minus-btn-edit_urls-2", + expect(await page.getByTestId("inputlist_str_urls_1").inputValue()).toBe( + "test1 test1 test1 test1", ); - const elementCountMinusEdit2 = await minusButtonLocatorEdit2?.count(); + await page.getByTestId("edit-button-modal").click(); - if (elementCountMinusEdit1 > 1 || elementCountMinusEdit2 > 0) { - expect(false).toBeTruthy(); - } + expect( + await page.getByTestId("inputlist_str_edit_urls_0").inputValue(), + ).toBe("test1 test1 test1 test1"); + + expect( + await page.getByTestId("inputlist_str_edit_urls_1").inputValue(), + ).toBe("test1 test1 test1 test1"); + + await page.getByTestId("input-list-dropdown-menu-1-edit").click(); + + await page.getByTestId("input-list-dropdown-menu-1-duplicate").click(); + + expect( + await page.getByTestId("inputlist_str_edit_urls_0").inputValue(), + ).toBe("test1 test1 test1 test1"); + + expect( + await page.getByTestId("inputlist_str_edit_urls_1").inputValue(), + ).toBe("test1 test1 test1 test1"); + + expect( + await page.getByTestId("inputlist_str_edit_urls_2").inputValue(), + ).toBe("test1 test1 test1 test1"); }, );