feat: Composio Gmail component and AuthInput feature (#7364)
* old composio Gmail component * Update gmail_composio.py * [autofix.ci] apply automated fixes * Removed input types from secret input * Changed starter projects * Update gmail_composio.py * composio base * [autofix.ci] apply automated fixes * updated composio with multi output * [autofix.ci] apply automated fixes * fix lint errors * [autofix.ci] apply automated fixes * Added sortableList and connect to frontend types constant * Added AuthInput to backend and frontend constant * Added auth input to InputTypes and added show = false by default * fix: Update Composio icon (#7407) fix: update Composio icon dimensions and simplify SVG structure * Fix amber color * Fix button and voice assistant button to use correct design and colors * Fixed button design to include bg * remove bg definition from voice assistant * Added auth input to composio base * Added helper text to sortable list * Add unlink icon * Add node connection button * Changed to isPolling * [autofix.ci] apply automated fixes * Added auth tooltip * Added auth tooltip to mixinn * Add auth mixin to input * update the field visibility * Fixed disconnect * Update composio_base.py * Updated node status to show correct statuses * Added handling for API errors and disconnections * limit to dataframe output * add basic tests for base and gmail component * fix lint errors * 📝 (test files): Remove unnecessary blank lines to improve code readability and consistency. * Add result_field to GMAIL_FETCH_EMAILS action and change how result key is used * fix: Add validation for result structure in ComposioGmailAPIComponent * fix: Ensure result is a list of dicts before converting to DataFrame in ComposioBaseComponent * feat: Introduce get_result_field option for Gmail actions to control result retrieval behavior * Fixed status not updating in real time * Added default API value to Composio * Made sortableList only be openable if no helper text is present * fix: Update validation logic in ComposioGmailAPIComponent to incorporate get_result_field option for improved result handling * Fixed bug where pre-filled Global Variable didn't trigger login * refactor: Remove commented-out output definitions in ComposioBaseComponent for cleaner code * refactor: Clean up ComposioGmailAPIComponent by removing outdated comments for improved readability * ✨ (NodeStatus/index.tsx): refactor getConnectionButtonClasses and getConnectionIconClasses functions to improve code readability and maintainability * ♻️ (NodeStatus/index.tsx): refactor getConnectionButtonClasses and getConnectionIconClasses functions to use arrow function syntax for better readability and maintainability * 🔧 (NodeStatus/index.tsx): define constants POLLING_TIMEOUT and POLLING_INTERVAL for better readability and maintainability * ✨ (ListSelectionComponent): Add dataTestId prop to ListItem component for better testing 📝 (NodeStatus): Refactor data-testid value to be dynamically generated based on node status 📝 (searchBarComponent): Add data-testid attribute to search input for testing purposes 📝 (sortableListComponent): Add data-testid attribute to button for opening list selection ♻️ (utils.ts): Add testIdCase function to convert string to snake_case for test ids 📝 (composio.spec.ts): Add various test cases for interacting with composio component * ✨ (test_gmail.py): add MagicMock import to fix missing dependency for testing 🔧 (test_gmail.py): refactor execute_action method to return a structure compatible with component's logic ♻️ (test_gmail.py): refactor _build_wrapper method to return a mock for the toolset ✨ (test_gmail.py): add patching for _actions_data to ensure correct structure for GMAIL_FETCH_EMAILS 🔧 (test_gmail.py): refactor execute_action method to return mock data for testing as_dataframe method 🔧 (test_gmail.py): refactor as_dataframe method to handle mock email data and verify DataFrame content 🔧 (test_gmail.py): refactor execute_action method to return mock data for testing update_build_config method 🔧 (secretKeyModal/index.tsx): remove unused imports and clean up the file structure * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Lucas Oliveira <lucas.edu.oli@hotmail.com> Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> Co-authored-by: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Co-authored-by: cristhianzl <cristhian.lousa@gmail.com> Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
parent
f9a7c9bcef
commit
8b4cf7b1db
18 changed files with 1363 additions and 14 deletions
0
src/backend/base/langflow/base/composio/__init__.py
Normal file
0
src/backend/base/langflow/base/composio/__init__.py
Normal file
302
src/backend/base/langflow/base/composio/composio_base.py
Normal file
302
src/backend/base/langflow/base/composio/composio_base.py
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import re
|
||||||
|
from abc import abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from composio.client.collections import AppAuthScheme
|
||||||
|
from composio.client.exceptions import NoItemsFound
|
||||||
|
from composio.exceptions import ApiKeyError
|
||||||
|
from composio_langchain import ComposioToolSet
|
||||||
|
from langchain_core.tools import Tool
|
||||||
|
|
||||||
|
from langflow.custom import Component
|
||||||
|
from langflow.inputs import (
|
||||||
|
AuthInput,
|
||||||
|
MessageTextInput,
|
||||||
|
SecretStrInput,
|
||||||
|
SortableListInput,
|
||||||
|
)
|
||||||
|
from langflow.io import Output
|
||||||
|
from langflow.logging import logger
|
||||||
|
from langflow.schema.data import Data
|
||||||
|
from langflow.schema.dataframe import DataFrame
|
||||||
|
from langflow.schema.message import Message
|
||||||
|
|
||||||
|
|
||||||
|
class ComposioBaseComponent(Component):
|
||||||
|
"""Base class for Composio components with common functionality."""
|
||||||
|
|
||||||
|
# Common inputs that all Composio components will need
|
||||||
|
_base_inputs = [
|
||||||
|
MessageTextInput(
|
||||||
|
name="entity_id",
|
||||||
|
display_name="Entity ID",
|
||||||
|
value="default",
|
||||||
|
advanced=True,
|
||||||
|
tool_mode=True,
|
||||||
|
),
|
||||||
|
SecretStrInput(
|
||||||
|
name="api_key",
|
||||||
|
display_name="Composio API Key",
|
||||||
|
required=True,
|
||||||
|
info="Refer to https://docs.composio.dev/faq/api_key/api_key",
|
||||||
|
real_time_refresh=True,
|
||||||
|
value="COMPOSIO_API_KEY",
|
||||||
|
),
|
||||||
|
AuthInput(
|
||||||
|
name="auth_link",
|
||||||
|
value="",
|
||||||
|
auth_tooltip="Please insert a valid Composio API Key.",
|
||||||
|
),
|
||||||
|
SortableListInput(
|
||||||
|
name="action",
|
||||||
|
display_name="Action",
|
||||||
|
placeholder="Select action",
|
||||||
|
options=[],
|
||||||
|
value="disabled",
|
||||||
|
info="Select action to pass to the agent",
|
||||||
|
helper_text="Please connect before selecting actions.",
|
||||||
|
helper_text_metadata={"variant": "destructive"},
|
||||||
|
show=True,
|
||||||
|
real_time_refresh=True,
|
||||||
|
required=True,
|
||||||
|
limit=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
_all_fields: set[str] = set()
|
||||||
|
_bool_variables: set[str] = set()
|
||||||
|
_actions_data: dict[str, dict[str, Any]] = {}
|
||||||
|
_default_tools: set[str] = set()
|
||||||
|
_readonly_actions: frozenset[str] = frozenset()
|
||||||
|
_action_fields_cache: dict[str, set[str]] = {}
|
||||||
|
_display_to_key_map: dict[str, str] = {}
|
||||||
|
_key_to_display_map: dict[str, str] = {}
|
||||||
|
_sanitized_names: dict[str, str] = {}
|
||||||
|
_name_sanitizer = re.compile(r"[^a-zA-Z0-9_-]")
|
||||||
|
|
||||||
|
outputs = [
|
||||||
|
Output(name="dataFrame", display_name="DataFrame", method="as_dataframe"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def as_message(self) -> Message:
|
||||||
|
result = self.execute_action()
|
||||||
|
return Message(text=str(result))
|
||||||
|
|
||||||
|
def as_dataframe(self) -> DataFrame:
|
||||||
|
result = self.execute_action()
|
||||||
|
# If the result is a dict, pandas will raise ValueError: If using all scalar values, you must pass an index
|
||||||
|
# So we need to make sure the result is a list of dicts
|
||||||
|
if isinstance(result, dict):
|
||||||
|
result = [result]
|
||||||
|
return DataFrame(result)
|
||||||
|
|
||||||
|
def as_data(self) -> Data:
|
||||||
|
result = self.execute_action()
|
||||||
|
return Data(results=result)
|
||||||
|
|
||||||
|
def _build_action_maps(self):
|
||||||
|
"""Build lookup maps for action names."""
|
||||||
|
if not self._display_to_key_map:
|
||||||
|
self._display_to_key_map = {data["display_name"]: key for key, data in self._actions_data.items()}
|
||||||
|
self._key_to_display_map = {key: data["display_name"] for key, data in self._actions_data.items()}
|
||||||
|
self._sanitized_names = {
|
||||||
|
action: self._name_sanitizer.sub("-", self.sanitize_action_name(action))
|
||||||
|
for action in self._actions_data
|
||||||
|
}
|
||||||
|
|
||||||
|
def sanitize_action_name(self, action_name: str) -> str:
|
||||||
|
"""Convert action name to display name using lookup."""
|
||||||
|
self._build_action_maps()
|
||||||
|
return self._key_to_display_map.get(action_name, action_name)
|
||||||
|
|
||||||
|
def desanitize_action_name(self, action_name: str) -> str:
|
||||||
|
"""Convert display name to action key using lookup."""
|
||||||
|
self._build_action_maps()
|
||||||
|
return self._display_to_key_map.get(action_name, action_name)
|
||||||
|
|
||||||
|
def _get_action_fields(self, action_key: str | None) -> set[str]:
|
||||||
|
"""Get fields for an action."""
|
||||||
|
if action_key is None:
|
||||||
|
return set()
|
||||||
|
return set(self._actions_data[action_key]["action_fields"]) if action_key in self._actions_data else set()
|
||||||
|
|
||||||
|
def _build_wrapper(self) -> ComposioToolSet:
|
||||||
|
"""Build the Composio toolset wrapper."""
|
||||||
|
try:
|
||||||
|
if not self.api_key:
|
||||||
|
msg = "Composio API Key is required"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return ComposioToolSet(api_key=self.api_key)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Error building Composio wrapper: {e}")
|
||||||
|
msg = "Please provide a valid Composio API Key in the component settings"
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
|
def show_hide_fields(self, build_config: dict, field_value: Any):
|
||||||
|
"""Optimized field visibility updates by only modifying show values."""
|
||||||
|
if not field_value:
|
||||||
|
for field in self._all_fields:
|
||||||
|
build_config[field]["show"] = False
|
||||||
|
if field in self._bool_variables:
|
||||||
|
build_config[field]["value"] = False
|
||||||
|
else:
|
||||||
|
build_config[field]["value"] = ""
|
||||||
|
return
|
||||||
|
|
||||||
|
action_key = None
|
||||||
|
if isinstance(field_value, list) and field_value:
|
||||||
|
action_key = self.desanitize_action_name(field_value[0]["name"])
|
||||||
|
else:
|
||||||
|
action_key = field_value
|
||||||
|
|
||||||
|
fields_to_show = self._get_action_fields(action_key)
|
||||||
|
|
||||||
|
for field in self._all_fields:
|
||||||
|
should_show = field in fields_to_show
|
||||||
|
if build_config[field]["show"] != should_show:
|
||||||
|
build_config[field]["show"] = should_show
|
||||||
|
if not should_show:
|
||||||
|
if field in self._bool_variables:
|
||||||
|
build_config[field]["value"] = False
|
||||||
|
else:
|
||||||
|
build_config[field]["value"] = ""
|
||||||
|
|
||||||
|
def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict:
|
||||||
|
"""Optimized build config updates."""
|
||||||
|
if field_name == "tool_mode":
|
||||||
|
build_config["action"]["show"] = not field_value
|
||||||
|
for field in self._all_fields:
|
||||||
|
build_config[field]["show"] = False
|
||||||
|
return build_config
|
||||||
|
|
||||||
|
if field_name == "action":
|
||||||
|
self.show_hide_fields(build_config, field_value)
|
||||||
|
if build_config["auth_link"]["value"] == "validated":
|
||||||
|
return build_config
|
||||||
|
if field_name == "api_key" and len(field_value) == 0:
|
||||||
|
build_config["auth_link"]["value"] = ""
|
||||||
|
build_config["auth_link"]["auth_tooltip"] = "Please provide a valid Composio API Key."
|
||||||
|
build_config["action"]["options"] = []
|
||||||
|
build_config["action"]["helper_text"] = "Please connect before selecting actions."
|
||||||
|
build_config["action"]["helper_text_metadata"] = {"variant": "destructive"}
|
||||||
|
return build_config
|
||||||
|
if not hasattr(self, "api_key") or not self.api_key:
|
||||||
|
return build_config
|
||||||
|
|
||||||
|
# Build the action maps before using them
|
||||||
|
self._build_action_maps()
|
||||||
|
|
||||||
|
build_config["action"]["options"] = [
|
||||||
|
{"name": self.sanitize_action_name(action)} for action in self._actions_data
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
toolset = self._build_wrapper()
|
||||||
|
entity = toolset.client.get_entity(id=self.entity_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entity.get_connection(app=self.app_name)
|
||||||
|
build_config["auth_link"]["value"] = "validated"
|
||||||
|
build_config["auth_link"]["auth_tooltip"] = "Disconnect"
|
||||||
|
build_config["action"]["helper_text"] = None
|
||||||
|
build_config["action"]["helper_text_metadata"] = {}
|
||||||
|
except NoItemsFound:
|
||||||
|
auth_scheme = self._get_auth_scheme(self.app_name)
|
||||||
|
if auth_scheme and auth_scheme.auth_mode == "OAUTH2":
|
||||||
|
try:
|
||||||
|
build_config["auth_link"]["value"] = self._initiate_default_connection(entity, self.app_name)
|
||||||
|
build_config["auth_link"]["auth_tooltip"] = "Connect"
|
||||||
|
except (ValueError, ConnectionError, ApiKeyError) as e:
|
||||||
|
build_config["auth_link"]["value"] = "disabled"
|
||||||
|
build_config["auth_link"]["auth_tooltip"] = f"Error: {e!s}"
|
||||||
|
logger.error(f"Error checking auth status: {e}")
|
||||||
|
|
||||||
|
except (ValueError, ConnectionError) as e:
|
||||||
|
build_config["auth_link"]["value"] = "error"
|
||||||
|
build_config["auth_link"]["auth_tooltip"] = f"Error: {e!s}"
|
||||||
|
logger.error(f"Error checking auth status: {e}")
|
||||||
|
except ApiKeyError as e:
|
||||||
|
build_config["auth_link"]["value"] = ""
|
||||||
|
build_config["auth_link"]["auth_tooltip"] = "Please provide a valid Composio API Key."
|
||||||
|
build_config["action"]["options"] = []
|
||||||
|
build_config["action"]["value"] = ""
|
||||||
|
build_config["action"]["helper_text"] = "Please connect before selecting actions."
|
||||||
|
build_config["action"]["helper_text_metadata"] = {"variant": "destructive"}
|
||||||
|
logger.error(f"Error checking auth status: {e}")
|
||||||
|
|
||||||
|
# Handle disconnection
|
||||||
|
if field_name == "auth_link" and field_value == "disconnect":
|
||||||
|
try:
|
||||||
|
for field in self._all_fields:
|
||||||
|
build_config[field]["show"] = False
|
||||||
|
toolset = self._build_wrapper()
|
||||||
|
entity = toolset.client.get_entity(id=self.entity_id)
|
||||||
|
self.disconnect_connection(entity, self.app_name)
|
||||||
|
build_config["auth_link"]["value"] = self._initiate_default_connection(entity, self.app_name)
|
||||||
|
build_config["auth_link"]["auth_tooltip"] = "Connect"
|
||||||
|
build_config["action"]["helper_text"] = "Please connect before selecting actions."
|
||||||
|
build_config["action"]["helper_text_metadata"] = {
|
||||||
|
"variant": "destructive",
|
||||||
|
}
|
||||||
|
build_config["action"]["options"] = []
|
||||||
|
build_config["action"]["value"] = ""
|
||||||
|
except (ValueError, ConnectionError, ApiKeyError) as e:
|
||||||
|
build_config["auth_link"]["value"] = "error"
|
||||||
|
build_config["auth_link"]["auth_tooltip"] = f"Failed to disconnect from the app: {e}"
|
||||||
|
logger.error(f"Error disconnecting: {e}")
|
||||||
|
if field_name == "auth_link" and field_value == "validated":
|
||||||
|
build_config["action"]["helper_text"] = ""
|
||||||
|
build_config["action"]["helper_text_metadata"] = {"icon": "Check", "variant": "success"}
|
||||||
|
|
||||||
|
return build_config
|
||||||
|
|
||||||
|
def _get_auth_scheme(self, app_name: str) -> AppAuthScheme:
|
||||||
|
"""Get the primary auth scheme for an app."""
|
||||||
|
toolset = self._build_wrapper()
|
||||||
|
try:
|
||||||
|
return toolset.get_auth_scheme_for_app(app=app_name.lower())
|
||||||
|
except (ValueError, ConnectionError, NoItemsFound):
|
||||||
|
logger.exception(f"Error getting auth scheme for {app_name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _initiate_default_connection(self, entity: Any, app: str) -> str:
|
||||||
|
connection = entity.initiate_connection(app_name=app, use_composio_auth=True, force_new_integration=True)
|
||||||
|
return connection.redirectUrl
|
||||||
|
|
||||||
|
def disconnect_connection(self, entity: Any, app: str) -> None:
|
||||||
|
"""Disconnect a Composio connection."""
|
||||||
|
try:
|
||||||
|
# Get the connection first
|
||||||
|
connection = entity.get_connection(app=app)
|
||||||
|
# Delete the connection using the integrations collection
|
||||||
|
entity.client.integrations.remove(id=connection.integrationId)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error disconnecting from {app}: {e}")
|
||||||
|
msg = f"Failed to disconnect from {app}: {e}"
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
|
def configure_tools(self, toolset: ComposioToolSet) -> list[Tool]:
|
||||||
|
tools = toolset.get_tools(actions=self._actions_data.keys())
|
||||||
|
configured_tools = []
|
||||||
|
for tool in tools:
|
||||||
|
# Set the sanitized name
|
||||||
|
tool.name = self._sanitized_names.get(tool.name, self._name_sanitizer.sub("-", tool.name))
|
||||||
|
# Set the tags
|
||||||
|
tool.tags = [tool.name]
|
||||||
|
configured_tools.append(tool)
|
||||||
|
return configured_tools
|
||||||
|
|
||||||
|
async def _get_tools(self) -> list[Tool]:
|
||||||
|
"""Get tools with cached results and optimized name sanitization."""
|
||||||
|
toolset = self._build_wrapper()
|
||||||
|
return self.configure_tools(toolset)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled_tools(self):
|
||||||
|
if not hasattr(self, "action") or not self.action:
|
||||||
|
return list(self._default_tools)
|
||||||
|
return list(self._default_tools.union(action["name"].replace(" ", "-") for action in self.action))
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def execute_action(self) -> list[dict]:
|
||||||
|
"""Execute action and return response as Message."""
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
from .composio_api import ComposioAPIComponent
|
from .composio_api import ComposioAPIComponent
|
||||||
|
from .gmail_composio import ComposioGmailAPIComponent
|
||||||
|
|
||||||
__all__ = ["ComposioAPIComponent"]
|
__all__ = ["ComposioAPIComponent", "ComposioGmailAPIComponent"]
|
||||||
|
|
|
||||||
377
src/backend/base/langflow/components/composio/gmail_composio.py
Normal file
377
src/backend/base/langflow/components/composio/gmail_composio.py
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from composio import Action
|
||||||
|
|
||||||
|
from langflow.base.composio.composio_base import ComposioBaseComponent
|
||||||
|
from langflow.inputs import (
|
||||||
|
BoolInput,
|
||||||
|
FileInput,
|
||||||
|
IntInput,
|
||||||
|
MessageTextInput,
|
||||||
|
)
|
||||||
|
from langflow.logging import logger
|
||||||
|
|
||||||
|
|
||||||
|
class ComposioGmailAPIComponent(ComposioBaseComponent):
|
||||||
|
"""Gmail API component for interacting with Gmail services."""
|
||||||
|
|
||||||
|
display_name: str = "Gmail"
|
||||||
|
description: str = "Gmail API"
|
||||||
|
name = "GmailAPI"
|
||||||
|
icon = "Gmail"
|
||||||
|
documentation: str = "https://docs.composio.dev"
|
||||||
|
app_name = "gmail"
|
||||||
|
|
||||||
|
# Gmail-specific actions
|
||||||
|
_actions_data: dict = {
|
||||||
|
"GMAIL_SEND_EMAIL": {
|
||||||
|
"display_name": "Send Email",
|
||||||
|
"action_fields": ["recipient_email", "subject", "body", "cc", "bcc", "is_html"],
|
||||||
|
},
|
||||||
|
"GMAIL_FETCH_EMAILS": {
|
||||||
|
"display_name": "Fetch Emails",
|
||||||
|
"action_fields": ["max_results", "query"],
|
||||||
|
"get_result_field": True,
|
||||||
|
"result_field": "messages",
|
||||||
|
},
|
||||||
|
"GMAIL_GET_PROFILE": {
|
||||||
|
"display_name": "Get User Profile",
|
||||||
|
"action_fields": [],
|
||||||
|
},
|
||||||
|
"GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID": {
|
||||||
|
"display_name": "Get Email By ID",
|
||||||
|
"action_fields": ["message_id"],
|
||||||
|
"get_result_field": False,
|
||||||
|
},
|
||||||
|
"GMAIL_CREATE_EMAIL_DRAFT": {
|
||||||
|
"display_name": "Create Draft Email",
|
||||||
|
"action_fields": ["recipient_email", "subject", "body", "cc", "bcc", "is_html"],
|
||||||
|
},
|
||||||
|
"GMAIL_FETCH_MESSAGE_BY_THREAD_ID": {
|
||||||
|
"display_name": "Get Message By Thread ID",
|
||||||
|
"action_fields": ["thread_id"],
|
||||||
|
"get_result_field": False,
|
||||||
|
},
|
||||||
|
"GMAIL_LIST_THREADS": {
|
||||||
|
"display_name": "List Email Threads",
|
||||||
|
"action_fields": ["max_results", "query"],
|
||||||
|
},
|
||||||
|
"GMAIL_REPLY_TO_THREAD": {
|
||||||
|
"display_name": "Reply To Thread",
|
||||||
|
"action_fields": ["thread_id", "message_body", "recipient_email"],
|
||||||
|
},
|
||||||
|
"GMAIL_LIST_LABELS": {
|
||||||
|
"display_name": "List Email Labels",
|
||||||
|
"action_fields": [],
|
||||||
|
},
|
||||||
|
"GMAIL_CREATE_LABEL": {
|
||||||
|
"display_name": "Create Email Label",
|
||||||
|
"action_fields": ["label_name"],
|
||||||
|
},
|
||||||
|
"GMAIL_GET_PEOPLE": {
|
||||||
|
"display_name": "Get Contacts",
|
||||||
|
"action_fields": [],
|
||||||
|
},
|
||||||
|
"GMAIL_REMOVE_LABEL": {
|
||||||
|
"display_name": "Delete Email Label",
|
||||||
|
"action_fields": ["label_id"],
|
||||||
|
"get_result_field": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_all_fields = {field for action_data in _actions_data.values() for field in action_data["action_fields"]}
|
||||||
|
_bool_variables = {"is_html", "include_spam_trash"}
|
||||||
|
|
||||||
|
# Cache for action fields mapping
|
||||||
|
_action_fields_cache: dict[str, set[str]] = {}
|
||||||
|
_readonly_actions = frozenset(
|
||||||
|
[
|
||||||
|
"GMAIL_FETCH_EMAILS",
|
||||||
|
"GMAIL_GET_PROFILE",
|
||||||
|
"GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID",
|
||||||
|
"GMAIL_FETCH_MESSAGE_BY_THREAD_ID",
|
||||||
|
"GMAIL_LIST_THREADS",
|
||||||
|
"GMAIL_LIST_LABELS",
|
||||||
|
"GMAIL_GET_PEOPLE",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._all_fields = {
|
||||||
|
field for action_data in self._actions_data.values() for field in action_data["action_fields"]
|
||||||
|
}
|
||||||
|
|
||||||
|
self._bool_variables = {"is_html", "include_spam_trash"}
|
||||||
|
self._default_tools = {
|
||||||
|
self.sanitize_action_name("GMAIL_SEND_EMAIL").replace(" ", "-"),
|
||||||
|
self.sanitize_action_name("GMAIL_FETCH_EMAILS").replace(" ", "-"),
|
||||||
|
}
|
||||||
|
# Build the action maps right away
|
||||||
|
self._display_to_key_map = {data["display_name"]: key for key, data in self._actions_data.items()}
|
||||||
|
self._key_to_display_map = {key: data["display_name"] for key, data in self._actions_data.items()}
|
||||||
|
self._sanitized_names = {
|
||||||
|
action: self._name_sanitizer.sub("-", self.sanitize_action_name(action)) for action in self._actions_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Combine base inputs with Gmail-specific inputs
|
||||||
|
inputs = [
|
||||||
|
*ComposioBaseComponent._base_inputs,
|
||||||
|
# Email composition fields
|
||||||
|
MessageTextInput(
|
||||||
|
name="recipient_email",
|
||||||
|
display_name="Recipient Email",
|
||||||
|
info="Email address of the recipient",
|
||||||
|
show=False,
|
||||||
|
required=True,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="subject",
|
||||||
|
display_name="Subject",
|
||||||
|
info="Subject of the email",
|
||||||
|
show=False,
|
||||||
|
required=True,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="body",
|
||||||
|
display_name="Body",
|
||||||
|
required=True,
|
||||||
|
info="Content of the email",
|
||||||
|
show=False,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="cc",
|
||||||
|
display_name="CC",
|
||||||
|
info="Email addresses to CC (Carbon Copy) in the email, separated by commas",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="bcc",
|
||||||
|
display_name="BCC",
|
||||||
|
info="Email addresses to BCC (Blind Carbon Copy) in the email, separated by commas",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
BoolInput(
|
||||||
|
name="is_html",
|
||||||
|
display_name="Is HTML",
|
||||||
|
info="Specify whether the email body contains HTML content (true/false)",
|
||||||
|
show=False,
|
||||||
|
value=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
# Email retrieval and management fields
|
||||||
|
IntInput(
|
||||||
|
name="max_results",
|
||||||
|
display_name="Max Results",
|
||||||
|
required=True,
|
||||||
|
info="Maximum number of emails to be returned",
|
||||||
|
show=False,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="message_id",
|
||||||
|
display_name="Message ID",
|
||||||
|
info="The ID of the specific email message",
|
||||||
|
show=False,
|
||||||
|
required=True,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="thread_id",
|
||||||
|
display_name="Thread ID",
|
||||||
|
info="The ID of the email thread",
|
||||||
|
show=False,
|
||||||
|
required=True,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="query",
|
||||||
|
display_name="Query",
|
||||||
|
info="Search query to filter emails (e.g., 'from:someone@email.com' or 'subject:hello')",
|
||||||
|
show=False,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="message_body",
|
||||||
|
display_name="Message Body",
|
||||||
|
info="The body content of the message to be sent",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
# Label management fields
|
||||||
|
MessageTextInput(
|
||||||
|
name="label_name",
|
||||||
|
display_name="Label Name",
|
||||||
|
info="Name of the Gmail label to create, modify, or filter by",
|
||||||
|
show=False,
|
||||||
|
required=True,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="label_id",
|
||||||
|
display_name="Label ID",
|
||||||
|
info="The ID of the Gmail label",
|
||||||
|
show=False,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="label_ids",
|
||||||
|
display_name="Label Ids",
|
||||||
|
info="Comma-separated list of label IDs to filter messages",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="label_list_visibility",
|
||||||
|
display_name="Label List Visibility",
|
||||||
|
info="The visibility of the label in the label list in the Gmail web interface",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="message_list_visibility",
|
||||||
|
display_name="Message List Visibility",
|
||||||
|
info="The visibility of the label in the message list in the Gmail web interface",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
# Pagination and filtering
|
||||||
|
MessageTextInput(
|
||||||
|
name="page_token",
|
||||||
|
display_name="Page Token",
|
||||||
|
info="Token for retrieving the next page of results",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
BoolInput(
|
||||||
|
name="include_spam_trash",
|
||||||
|
display_name="Include messages from Spam/Trash",
|
||||||
|
info="Include messages from SPAM and TRASH in the results",
|
||||||
|
show=False,
|
||||||
|
value=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="format",
|
||||||
|
display_name="Format",
|
||||||
|
info="The format to return the message in. Possible values: minimal, full, raw, metadata",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
# Contact management fields
|
||||||
|
MessageTextInput(
|
||||||
|
name="resource_name",
|
||||||
|
display_name="Resource Name",
|
||||||
|
info="The resource name of the person to provide information about",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="person_fields",
|
||||||
|
display_name="Person fields",
|
||||||
|
info="Fields to return for the person. Multiple fields can be specified by separating them with commas",
|
||||||
|
show=False,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
# Attachment handling
|
||||||
|
MessageTextInput(
|
||||||
|
name="attachment_id",
|
||||||
|
display_name="Attachment ID",
|
||||||
|
info="Id of the attachment",
|
||||||
|
show=False,
|
||||||
|
required=True,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
MessageTextInput(
|
||||||
|
name="file_name",
|
||||||
|
display_name="File name",
|
||||||
|
info="File name of the attachment file",
|
||||||
|
show=False,
|
||||||
|
required=True,
|
||||||
|
advanced=False,
|
||||||
|
),
|
||||||
|
FileInput(
|
||||||
|
name="attachment",
|
||||||
|
display_name="Add Attachment",
|
||||||
|
file_types=[
|
||||||
|
"csv",
|
||||||
|
"txt",
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"pdf",
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"zip",
|
||||||
|
"rar",
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
],
|
||||||
|
info="Add an attachment",
|
||||||
|
show=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def execute_action(self):
|
||||||
|
"""Execute action and return response as Message."""
|
||||||
|
toolset = self._build_wrapper()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._build_action_maps()
|
||||||
|
# Get the display name from the action list
|
||||||
|
display_name = self.action[0]["name"] if isinstance(self.action, list) and self.action else self.action
|
||||||
|
# Use the display_to_key_map to get the action key
|
||||||
|
action_key = self._display_to_key_map.get(display_name)
|
||||||
|
if not action_key:
|
||||||
|
msg = f"Invalid action: {display_name}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
enum_name = getattr(Action, action_key)
|
||||||
|
params = {}
|
||||||
|
if action_key in self._actions_data:
|
||||||
|
for field in self._actions_data[action_key]["action_fields"]:
|
||||||
|
value = getattr(self, field)
|
||||||
|
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if field in ["cc", "bcc", "label_ids"] and value:
|
||||||
|
value = [item.strip() for item in value.split(",")]
|
||||||
|
|
||||||
|
if field in self._bool_variables:
|
||||||
|
value = bool(value)
|
||||||
|
|
||||||
|
params[field] = value
|
||||||
|
|
||||||
|
result = toolset.execute_action(
|
||||||
|
action=enum_name,
|
||||||
|
params=params,
|
||||||
|
).get("data", [])
|
||||||
|
if (
|
||||||
|
len(result) != 1
|
||||||
|
and not self._actions_data.get(action_key, {}).get("result_field")
|
||||||
|
and self._actions_data.get(action_key, {}).get("get_result_field")
|
||||||
|
):
|
||||||
|
msg = f"Expected a dict with a single key, got {len(result)} keys: {result.keys()}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if result:
|
||||||
|
get_result_field = self._actions_data.get(action_key, {}).get("get_result_field", True)
|
||||||
|
if get_result_field:
|
||||||
|
key = self._actions_data.get(action_key, {}).get("result_field", next(iter(result)))
|
||||||
|
return result.get(key)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error executing action: {e}")
|
||||||
|
display_name = self.action[0]["name"] if isinstance(self.action, list) and self.action else str(self.action)
|
||||||
|
msg = f"Failed to execute {display_name}: {e!s}"
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
|
def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict:
|
||||||
|
return super().update_build_config(build_config, field_value, field_name)
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from .inputs import (
|
from .inputs import (
|
||||||
|
AuthInput,
|
||||||
BoolInput,
|
BoolInput,
|
||||||
CodeInput,
|
CodeInput,
|
||||||
ConnectionInput,
|
ConnectionInput,
|
||||||
|
|
@ -29,6 +30,7 @@ from .inputs import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AuthInput",
|
||||||
"BoolInput",
|
"BoolInput",
|
||||||
"CodeInput",
|
"CodeInput",
|
||||||
"ConnectionInput",
|
"ConnectionInput",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class FieldTypes(str, Enum):
|
||||||
NESTED_DICT = "NestedDict"
|
NESTED_DICT = "NestedDict"
|
||||||
SORTABLE_LIST = "sortableList"
|
SORTABLE_LIST = "sortableList"
|
||||||
CONNECTION = "connect"
|
CONNECTION = "connect"
|
||||||
|
AUTH = "auth"
|
||||||
FILE = "file"
|
FILE = "file"
|
||||||
PROMPT = "prompt"
|
PROMPT = "prompt"
|
||||||
CODE = "code"
|
CODE = "code"
|
||||||
|
|
@ -137,6 +138,10 @@ class DatabaseLoadMixin(BaseModel):
|
||||||
load_from_db: bool = Field(default=True)
|
load_from_db: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMixin(BaseModel):
|
||||||
|
auth_tooltip: str | None = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
# Specific mixin for fields needing file interaction
|
# Specific mixin for fields needing file interaction
|
||||||
class FileMixin(BaseModel):
|
class FileMixin(BaseModel):
|
||||||
file_path: list[str] | str | None = Field(default="")
|
file_path: list[str] | str | None = Field(default="")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from langflow.services.database.models.message.model import MessageBase
|
||||||
from langflow.template.field.base import Input
|
from langflow.template.field.base import Input
|
||||||
|
|
||||||
from .input_mixin import (
|
from .input_mixin import (
|
||||||
|
AuthMixin,
|
||||||
BaseInputMixin,
|
BaseInputMixin,
|
||||||
ConnectionMixin,
|
ConnectionMixin,
|
||||||
DatabaseLoadMixin,
|
DatabaseLoadMixin,
|
||||||
|
|
@ -477,6 +478,20 @@ class ConnectionInput(BaseInputMixin, ConnectionMixin, MetadataTraceMixin, ToolM
|
||||||
field_type: SerializableFieldTypes = FieldTypes.CONNECTION
|
field_type: SerializableFieldTypes = FieldTypes.CONNECTION
|
||||||
|
|
||||||
|
|
||||||
|
class AuthInput(BaseInputMixin, AuthMixin, MetadataTraceMixin):
|
||||||
|
"""Represents an authentication input field.
|
||||||
|
|
||||||
|
This class represents an authentication input field and provides functionality for handling authentication values.
|
||||||
|
It inherits from the `BaseInputMixin` and `AuthMixin` classes.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
field_type (SerializableFieldTypes): The field type of the input. Defaults to FieldTypes.AUTH.
|
||||||
|
"""
|
||||||
|
|
||||||
|
field_type: SerializableFieldTypes = FieldTypes.AUTH
|
||||||
|
show: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SortableListInput(BaseInputMixin, SortableListMixin, MetadataTraceMixin, ToolModeMixin):
|
class SortableListInput(BaseInputMixin, SortableListMixin, MetadataTraceMixin, ToolModeMixin):
|
||||||
"""Represents a list selection input field.
|
"""Represents a list selection input field.
|
||||||
|
|
||||||
|
|
@ -484,7 +499,7 @@ class SortableListInput(BaseInputMixin, SortableListMixin, MetadataTraceMixin, T
|
||||||
It inherits from the `BaseInputMixin` and `ListableInputMixin` classes.
|
It inherits from the `BaseInputMixin` and `ListableInputMixin` classes.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
field_type (SerializableFieldTypes): The field type of the input. Defaults to FieldTypes.BUTTON.
|
field_type (SerializableFieldTypes): The field type of the input. Defaults to FieldTypes.SORTABLE_LIST.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
field_type: SerializableFieldTypes = FieldTypes.SORTABLE_LIST
|
field_type: SerializableFieldTypes = FieldTypes.SORTABLE_LIST
|
||||||
|
|
@ -590,6 +605,7 @@ class DefaultPromptField(Input):
|
||||||
|
|
||||||
InputTypes: TypeAlias = (
|
InputTypes: TypeAlias = (
|
||||||
Input
|
Input
|
||||||
|
| AuthInput
|
||||||
| DefaultPromptField
|
| DefaultPromptField
|
||||||
| BoolInput
|
| BoolInput
|
||||||
| DataInput
|
| DataInput
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ DIRECT_TYPES = [
|
||||||
"slider",
|
"slider",
|
||||||
"tab",
|
"tab",
|
||||||
"sortableList",
|
"sortableList",
|
||||||
|
"auth",
|
||||||
"connect",
|
"connect",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
132
src/backend/tests/unit/components/bundles/composio/test_base.py
Normal file
132
src/backend/tests/unit/components/bundles/composio/test_base.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langflow.base.composio.composio_base import ComposioBaseComponent
|
||||||
|
|
||||||
|
from tests.base import DID_NOT_EXIST, ComponentTestBaseWithoutClient
|
||||||
|
|
||||||
|
|
||||||
|
class MockComposioToolSet:
|
||||||
|
def __init__(self, api_key=None):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.client = MagicMock()
|
||||||
|
|
||||||
|
def get_tools(self, *_):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def execute_action(self, *_, **__):
|
||||||
|
return {"data": {"response": "mocked response"}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestComposioBase(ComponentTestBaseWithoutClient):
|
||||||
|
@pytest.fixture
|
||||||
|
def component_class(self):
|
||||||
|
class TestComponent(ComposioBaseComponent):
|
||||||
|
def execute_action(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
return TestComponent
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_composio_toolset(self):
|
||||||
|
with patch("langflow.base.composio.composio_base.ComposioToolSet", MockComposioToolSet):
|
||||||
|
yield
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_kwargs(self):
|
||||||
|
return {
|
||||||
|
"api_key": "",
|
||||||
|
"entity_id": "default",
|
||||||
|
"action": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_names_mapping(self):
|
||||||
|
# Component not yet released, mark all versions as non-existent
|
||||||
|
return [
|
||||||
|
{"version": "1.0.17", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
{"version": "1.0.18", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
{"version": "1.0.19", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
{"version": "1.1.0", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
{"version": "1.1.1", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_build_wrapper_no_api_key(self, component_class, default_kwargs):
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
with pytest.raises(ValueError, match="Please provide a valid Composio API Key in the component settings"):
|
||||||
|
component._build_wrapper()
|
||||||
|
|
||||||
|
def test_build_wrapper_with_api_key(self, component_class, default_kwargs):
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
component.api_key = "test_key"
|
||||||
|
wrapper = component._build_wrapper()
|
||||||
|
assert isinstance(wrapper, MockComposioToolSet)
|
||||||
|
assert wrapper.api_key == "test_key"
|
||||||
|
|
||||||
|
def test_build_action_maps(self, component_class, default_kwargs):
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
# Test with empty actions data
|
||||||
|
component._actions_data = {}
|
||||||
|
component._build_action_maps()
|
||||||
|
assert component._display_to_key_map == {}
|
||||||
|
assert component._key_to_display_map == {}
|
||||||
|
assert component._sanitized_names == {}
|
||||||
|
|
||||||
|
# Test with sample actions data
|
||||||
|
component._actions_data = {
|
||||||
|
"ACTION_1": {"display_name": "Action One"},
|
||||||
|
"ACTION_2": {"display_name": "Action Two"},
|
||||||
|
}
|
||||||
|
component._build_action_maps()
|
||||||
|
assert component._display_to_key_map == {
|
||||||
|
"Action One": "ACTION_1",
|
||||||
|
"Action Two": "ACTION_2",
|
||||||
|
}
|
||||||
|
assert component._key_to_display_map == {
|
||||||
|
"ACTION_1": "Action One",
|
||||||
|
"ACTION_2": "Action Two",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_get_action_fields(self, component_class, default_kwargs):
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
component._actions_data = {
|
||||||
|
"ACTION_1": {"action_fields": ["field1", "field2"]},
|
||||||
|
"ACTION_2": {"action_fields": ["field3"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test with valid action key
|
||||||
|
fields = component._get_action_fields("ACTION_1")
|
||||||
|
assert fields == {"field1", "field2"}
|
||||||
|
|
||||||
|
# Test with non-existent action key
|
||||||
|
fields = component._get_action_fields("NON_EXISTENT")
|
||||||
|
assert fields == set()
|
||||||
|
|
||||||
|
# Test with None action key
|
||||||
|
fields = component._get_action_fields(None)
|
||||||
|
assert fields == set()
|
||||||
|
|
||||||
|
def test_show_hide_fields(self, component_class, default_kwargs):
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
component._all_fields = {"field1", "field2"}
|
||||||
|
component._bool_variables = {"field2"}
|
||||||
|
component._actions_data = {
|
||||||
|
"ACTION_1": {"display_name": "Action One", "action_fields": ["field1"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
build_config = {
|
||||||
|
"field1": {"show": False, "value": "old_value"},
|
||||||
|
"field2": {"show": False, "value": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test with no field value
|
||||||
|
component.show_hide_fields(build_config, None)
|
||||||
|
assert not build_config["field1"]["show"]
|
||||||
|
assert not build_config["field2"]["show"]
|
||||||
|
assert build_config["field1"]["value"] == ""
|
||||||
|
assert build_config["field2"]["value"] is False
|
||||||
|
|
||||||
|
# Test with valid action
|
||||||
|
component.show_hide_fields(build_config, [{"name": "Action One"}])
|
||||||
|
assert build_config["field1"]["show"] # Should be shown since it's in ACTION_1's fields
|
||||||
|
assert not build_config["field2"]["show"] # Should remain hidden
|
||||||
217
src/backend/tests/unit/components/bundles/composio/test_gmail.py
Normal file
217
src/backend/tests/unit/components/bundles/composio/test_gmail.py
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from composio import Action
|
||||||
|
from langflow.components.composio.gmail_composio import ComposioGmailAPIComponent
|
||||||
|
from langflow.schema.dataframe import DataFrame
|
||||||
|
|
||||||
|
from tests.base import DID_NOT_EXIST, ComponentTestBaseWithoutClient
|
||||||
|
|
||||||
|
from .test_base import MockComposioToolSet
|
||||||
|
|
||||||
|
|
||||||
|
class MockAction:
|
||||||
|
GMAIL_SEND_EMAIL = "GMAIL_SEND_EMAIL"
|
||||||
|
GMAIL_FETCH_EMAILS = "GMAIL_FETCH_EMAILS"
|
||||||
|
GMAIL_GET_PROFILE = "GMAIL_GET_PROFILE"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGmailComponent(ComponentTestBaseWithoutClient):
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_composio_toolset(self):
|
||||||
|
with patch("langflow.base.composio.composio_base.ComposioToolSet", MockComposioToolSet):
|
||||||
|
yield
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def component_class(self):
|
||||||
|
return ComposioGmailAPIComponent
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_kwargs(self):
|
||||||
|
return {
|
||||||
|
"api_key": "",
|
||||||
|
"entity_id": "default",
|
||||||
|
"action": None,
|
||||||
|
"recipient_email": "",
|
||||||
|
"subject": "",
|
||||||
|
"body": "",
|
||||||
|
"is_html": False,
|
||||||
|
"max_results": 10,
|
||||||
|
"query": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_names_mapping(self):
|
||||||
|
# Component not yet released, mark all versions as non-existent
|
||||||
|
return [
|
||||||
|
{"version": "1.0.17", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
{"version": "1.0.18", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
{"version": "1.0.19", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
{"version": "1.1.0", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
{"version": "1.1.1", "module": "composio", "file_name": DID_NOT_EXIST},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_init(self, component_class, default_kwargs):
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
assert component.display_name == "Gmail"
|
||||||
|
assert component.name == "GmailAPI"
|
||||||
|
assert component.app_name == "gmail"
|
||||||
|
assert "GMAIL_SEND_EMAIL" in component._actions_data
|
||||||
|
assert "GMAIL_FETCH_EMAILS" in component._actions_data
|
||||||
|
|
||||||
|
def test_execute_action_send_email(self, component_class, default_kwargs, monkeypatch):
|
||||||
|
# Mock Action enum
|
||||||
|
monkeypatch.setattr(Action, "GMAIL_SEND_EMAIL", MockAction.GMAIL_SEND_EMAIL)
|
||||||
|
|
||||||
|
# Setup component
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
component.api_key = "test_key"
|
||||||
|
component.action = [{"name": "Send Email"}]
|
||||||
|
component.recipient_email = "test@example.com"
|
||||||
|
component.subject = "Test Subject"
|
||||||
|
component.body = "Test Body"
|
||||||
|
component.is_html = False
|
||||||
|
|
||||||
|
# Execute action
|
||||||
|
result = component.execute_action()
|
||||||
|
assert result == "mocked response"
|
||||||
|
|
||||||
|
def test_execute_action_fetch_emails(self, component_class, default_kwargs, monkeypatch):
|
||||||
|
# Mock Action enum
|
||||||
|
monkeypatch.setattr(Action, "GMAIL_FETCH_EMAILS", MockAction.GMAIL_FETCH_EMAILS)
|
||||||
|
|
||||||
|
# Setup component
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
component.api_key = "test_key"
|
||||||
|
component.action = [{"name": "Fetch Emails"}]
|
||||||
|
component.max_results = 10
|
||||||
|
component.query = "from:test@example.com"
|
||||||
|
|
||||||
|
# Create a mock for the toolset
|
||||||
|
mock_toolset = MagicMock()
|
||||||
|
# The execute_action method needs to return a structure that works with the component's logic
|
||||||
|
# Based on the error, we need to make sure the 'data' key contains a dictionary with at least one key
|
||||||
|
mock_toolset.execute_action.return_value = {"data": {"response": "mocked response"}}
|
||||||
|
|
||||||
|
# Patch the _build_wrapper method to return our mock
|
||||||
|
with patch.object(component, "_build_wrapper", return_value=mock_toolset):
|
||||||
|
# Also patch the _actions_data to ensure it has the correct structure for GMAIL_FETCH_EMAILS
|
||||||
|
# This ensures the result_field is set correctly
|
||||||
|
component._actions_data = {
|
||||||
|
"GMAIL_FETCH_EMAILS": {
|
||||||
|
"action_fields": ["max_results", "query"],
|
||||||
|
"result_field": "response",
|
||||||
|
"get_result_field": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute action
|
||||||
|
result = component.execute_action()
|
||||||
|
assert result == "mocked response"
|
||||||
|
|
||||||
|
def test_execute_action_get_profile(self, component_class, default_kwargs, monkeypatch):
|
||||||
|
# Mock Action enum
|
||||||
|
monkeypatch.setattr(Action, "GMAIL_GET_PROFILE", MockAction.GMAIL_GET_PROFILE)
|
||||||
|
|
||||||
|
# Setup component
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
component.api_key = "test_key"
|
||||||
|
component.action = [{"name": "Get User Profile"}]
|
||||||
|
|
||||||
|
# Execute action
|
||||||
|
result = component.execute_action()
|
||||||
|
assert result == "mocked response"
|
||||||
|
|
||||||
|
def test_execute_action_invalid_action(self, component_class, default_kwargs):
|
||||||
|
# Setup component
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
component.api_key = "test_key"
|
||||||
|
component.action = [{"name": "Invalid Action"}]
|
||||||
|
|
||||||
|
# Execute action should raise ValueError
|
||||||
|
with pytest.raises(ValueError, match="Invalid action: Invalid Action"):
|
||||||
|
component.execute_action()
|
||||||
|
|
||||||
|
def test_as_dataframe(self, component_class, default_kwargs, monkeypatch):
|
||||||
|
# Mock Action enum
|
||||||
|
monkeypatch.setattr(Action, "GMAIL_FETCH_EMAILS", MockAction.GMAIL_FETCH_EMAILS)
|
||||||
|
|
||||||
|
# Setup component
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
component.api_key = "test_key"
|
||||||
|
component.action = [{"name": "Fetch Emails"}]
|
||||||
|
component.max_results = 10
|
||||||
|
|
||||||
|
# Create mock email data that would be returned by execute_action
|
||||||
|
mock_emails = [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"threadId": "thread1",
|
||||||
|
"subject": "Test Email 1",
|
||||||
|
"from": "sender1@example.com",
|
||||||
|
"date": "2023-01-01",
|
||||||
|
"snippet": "This is a test email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"threadId": "thread2",
|
||||||
|
"subject": "Test Email 2",
|
||||||
|
"from": "sender2@example.com",
|
||||||
|
"date": "2023-01-02",
|
||||||
|
"snippet": "This is another test email",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mock the execute_action method to return our mock data
|
||||||
|
with patch.object(component, "execute_action", return_value=mock_emails):
|
||||||
|
# Test as_dataframe method
|
||||||
|
result = component.as_dataframe()
|
||||||
|
|
||||||
|
# Verify the result is a DataFrame
|
||||||
|
assert isinstance(result, DataFrame)
|
||||||
|
|
||||||
|
# Verify the DataFrame is not empty
|
||||||
|
assert not result.empty
|
||||||
|
|
||||||
|
# Verify the DataFrame contains our mock data
|
||||||
|
# This will depend on how the component processes the data
|
||||||
|
# We can check for specific column names or values
|
||||||
|
if hasattr(result, "columns"):
|
||||||
|
# If the DataFrame has columns, check for expected ones
|
||||||
|
expected_columns = ["id", "threadId", "subject", "from", "date", "snippet"]
|
||||||
|
for col in expected_columns:
|
||||||
|
if col in result.columns:
|
||||||
|
assert True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# If none of the expected columns are found, check if data is in the DataFrame
|
||||||
|
assert any(
|
||||||
|
"Test Email" in str(cell) for cell in result.values.flat if hasattr(cell, "__contains__")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If the DataFrame structure is different, just check for some expected content
|
||||||
|
assert "Test Email" in str(result)
|
||||||
|
|
||||||
|
def test_update_build_config(self, component_class, default_kwargs):
|
||||||
|
# Test that the Gmail component properly inherits and uses the base component's
|
||||||
|
# update_build_config method
|
||||||
|
component = component_class(**default_kwargs)
|
||||||
|
build_config = {
|
||||||
|
"auth_link": {"value": "", "auth_tooltip": ""},
|
||||||
|
"action": {
|
||||||
|
"options": [],
|
||||||
|
"helper_text": "",
|
||||||
|
"helper_text_metadata": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test with empty API key
|
||||||
|
result = component.update_build_config(build_config, "", "api_key")
|
||||||
|
assert result["auth_link"]["value"] == ""
|
||||||
|
assert "Please provide a valid Composio API Key" in result["auth_link"]["auth_tooltip"]
|
||||||
|
assert result["action"]["options"] == []
|
||||||
|
|
||||||
|
# Test with valid API key
|
||||||
|
component.api_key = "test_key"
|
||||||
|
result = component.update_build_config(build_config, "test_key", "api_key")
|
||||||
|
assert len(result["action"]["options"]) > 0 # Should have Gmail actions
|
||||||
|
|
@ -3,7 +3,7 @@ import ShadTooltip from "@/components/common/shadTooltipComponent";
|
||||||
import SearchBarComponent from "@/components/core/parameterRenderComponent/components/searchBarComponent";
|
import SearchBarComponent from "@/components/core/parameterRenderComponent/components/searchBarComponent";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog-with-no-close";
|
import { Dialog, DialogContent } from "@/components/ui/dialog-with-no-close";
|
||||||
import { cn } from "@/utils/utils";
|
import { cn, testIdCase } from "@/utils/utils";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
// Update interface with better types
|
// Update interface with better types
|
||||||
|
|
@ -27,6 +27,7 @@ const ListItem = ({
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
isFocused,
|
isFocused,
|
||||||
isKeyboardNavActive,
|
isKeyboardNavActive,
|
||||||
|
dataTestId,
|
||||||
}: {
|
}: {
|
||||||
item: any;
|
item: any;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|
@ -36,6 +37,7 @@ const ListItem = ({
|
||||||
onMouseLeave: () => void;
|
onMouseLeave: () => void;
|
||||||
isFocused: boolean;
|
isFocused: boolean;
|
||||||
isKeyboardNavActive: boolean;
|
isKeyboardNavActive: boolean;
|
||||||
|
dataTestId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const itemRef = useRef<HTMLButtonElement>(null);
|
const itemRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
@ -58,6 +60,7 @@ const ListItem = ({
|
||||||
<Button
|
<Button
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
data-testid={dataTestId}
|
||||||
unstyled
|
unstyled
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -308,6 +311,7 @@ const ListSelectionComponent = ({
|
||||||
}}
|
}}
|
||||||
isFocused={focusedIndex === index}
|
isFocused={focusedIndex === index}
|
||||||
isKeyboardNavActive={isKeyboardNavActive}
|
isKeyboardNavActive={isKeyboardNavActive}
|
||||||
|
dataTestId={`list_item_${testIdCase(item.name)}`}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { getSpecificClassFromBuildStatus } from "@/CustomNodes/helpers/get-class-from-build-status";
|
|
||||||
import useIconStatus from "@/CustomNodes/hooks/use-icons-status";
|
|
||||||
import useUpdateValidationStatus from "@/CustomNodes/hooks/use-update-validation-status";
|
|
||||||
import useValidationStatusString from "@/CustomNodes/hooks/use-validation-status-string";
|
|
||||||
import ShadTooltip from "@/components/common/shadTooltipComponent";
|
import ShadTooltip from "@/components/common/shadTooltipComponent";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ICON_STROKE_WIDTH } from "@/constants/constants";
|
import { ICON_STROKE_WIDTH } from "@/constants/constants";
|
||||||
import { BuildStatus, EventDeliveryType } from "@/constants/enums";
|
import { BuildStatus, EventDeliveryType } from "@/constants/enums";
|
||||||
import { useGetConfig } from "@/controllers/API/queries/config/use-get-config";
|
import { useGetConfig } from "@/controllers/API/queries/config/use-get-config";
|
||||||
|
import { usePostTemplateValue } from "@/controllers/API/queries/nodes/use-post-template-value";
|
||||||
import { track } from "@/customization/utils/analytics";
|
import { track } from "@/customization/utils/analytics";
|
||||||
|
import { getSpecificClassFromBuildStatus } from "@/CustomNodes/helpers/get-class-from-build-status";
|
||||||
|
import { mutateTemplate } from "@/CustomNodes/helpers/mutate-template";
|
||||||
|
import useIconStatus from "@/CustomNodes/hooks/use-icons-status";
|
||||||
|
import useUpdateValidationStatus from "@/CustomNodes/hooks/use-update-validation-status";
|
||||||
|
import useValidationStatusString from "@/CustomNodes/hooks/use-validation-status-string";
|
||||||
|
import useAlertStore from "@/stores/alertStore";
|
||||||
import { useDarkStore } from "@/stores/darkStore";
|
import { useDarkStore } from "@/stores/darkStore";
|
||||||
import useFlowStore from "@/stores/flowStore";
|
import useFlowStore from "@/stores/flowStore";
|
||||||
import { useShortcutsStore } from "@/stores/shortcuts";
|
import { useShortcutsStore } from "@/stores/shortcuts";
|
||||||
|
|
@ -24,6 +27,9 @@ import IconComponent from "../../../../components/common/genericIconComponent";
|
||||||
import BuildStatusDisplay from "./components/build-status-display";
|
import BuildStatusDisplay from "./components/build-status-display";
|
||||||
import { normalizeTimeString } from "./utils/format-run-time";
|
import { normalizeTimeString } from "./utils/format-run-time";
|
||||||
|
|
||||||
|
const POLLING_TIMEOUT = 21000;
|
||||||
|
const POLLING_INTERVAL = 3000;
|
||||||
|
|
||||||
export default function NodeStatus({
|
export default function NodeStatus({
|
||||||
nodeId,
|
nodeId,
|
||||||
display_name,
|
display_name,
|
||||||
|
|
@ -57,6 +63,17 @@ export default function NodeStatus({
|
||||||
const [validationString, setValidationString] = useState<string>("");
|
const [validationString, setValidationString] = useState<string>("");
|
||||||
const [validationStatus, setValidationStatus] =
|
const [validationStatus, setValidationStatus] =
|
||||||
useState<VertexBuildTypeAPI | null>(null);
|
useState<VertexBuildTypeAPI | null>(null);
|
||||||
|
const [isPolling, setIsPolling] = useState(false);
|
||||||
|
|
||||||
|
const nodeAuth = Object.values(data.node?.template ?? {}).find(
|
||||||
|
(value) => value.type === "auth",
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectionLink = nodeAuth?.value;
|
||||||
|
const isAuthenticated = nodeAuth?.value === "validated";
|
||||||
|
|
||||||
|
const pollingInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const pollingTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const conditionSuccess =
|
const conditionSuccess =
|
||||||
buildStatus === BuildStatus.BUILT ||
|
buildStatus === BuildStatus.BUILT ||
|
||||||
|
|
@ -71,11 +88,82 @@ export default function NodeStatus({
|
||||||
const setNode = useFlowStore((state) => state.setNode);
|
const setNode = useFlowStore((state) => state.setNode);
|
||||||
const version = useDarkStore((state) => state.version);
|
const version = useDarkStore((state) => state.version);
|
||||||
const config = useGetConfig();
|
const config = useGetConfig();
|
||||||
|
const setErrorData = useAlertStore((state) => state.setErrorData);
|
||||||
|
|
||||||
|
const postTemplateValue = usePostTemplateValue({
|
||||||
|
parameterId: nodeAuth?.name ?? "auth",
|
||||||
|
nodeId: nodeId,
|
||||||
|
node: data.node,
|
||||||
|
});
|
||||||
|
|
||||||
const shouldStreamEvents = () => {
|
const shouldStreamEvents = () => {
|
||||||
// Get from useGetConfig store
|
|
||||||
return config.data?.event_delivery === EventDeliveryType.STREAMING;
|
return config.data?.event_delivery === EventDeliveryType.STREAMING;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Start polling when connection is initiated
|
||||||
|
const startPolling = () => {
|
||||||
|
window.open(connectionLink, "_blank");
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
|
setIsPolling(true);
|
||||||
|
|
||||||
|
pollingInterval.current = setInterval(() => {
|
||||||
|
mutateTemplate(
|
||||||
|
{ validate: data.node?.template?.auth?.value || "" },
|
||||||
|
data.node,
|
||||||
|
(newNode) => {
|
||||||
|
setNode(nodeId, (old) => ({
|
||||||
|
...old,
|
||||||
|
data: { ...old.data, node: newNode },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
postTemplateValue,
|
||||||
|
setErrorData,
|
||||||
|
nodeAuth?.name ?? "auth_link",
|
||||||
|
() => {},
|
||||||
|
data.node.tool_mode,
|
||||||
|
);
|
||||||
|
}, POLLING_INTERVAL);
|
||||||
|
|
||||||
|
pollingTimeout.current = setTimeout(() => {
|
||||||
|
stopPolling();
|
||||||
|
}, POLLING_TIMEOUT);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
setIsPolling(true);
|
||||||
|
mutateTemplate(
|
||||||
|
"disconnect",
|
||||||
|
data.node,
|
||||||
|
(newNode) => {
|
||||||
|
setNode(nodeId, (old) => ({
|
||||||
|
...old,
|
||||||
|
data: { ...old.data, node: newNode },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
postTemplateValue,
|
||||||
|
setErrorData,
|
||||||
|
nodeAuth?.name ?? "auth_link",
|
||||||
|
() => {
|
||||||
|
setIsPolling(false);
|
||||||
|
},
|
||||||
|
data.node.tool_mode,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
setIsPolling(false);
|
||||||
|
|
||||||
|
if (pollingInterval.current) clearInterval(pollingInterval.current);
|
||||||
|
if (pollingTimeout.current) clearTimeout(pollingTimeout.current);
|
||||||
|
};
|
||||||
|
|
||||||
function handlePlayWShortcut() {
|
function handlePlayWShortcut() {
|
||||||
if (buildStatus === BuildStatus.BUILDING || isBuilding || !selected) return;
|
if (buildStatus === BuildStatus.BUILDING || isBuilding || !selected) return;
|
||||||
setValidationStatus(null);
|
setValidationStatus(null);
|
||||||
|
|
@ -107,6 +195,7 @@ export default function NodeStatus({
|
||||||
: "";
|
: "";
|
||||||
return cn(frozen ? frozenClass : className, updateClass);
|
return cn(frozen ? frozenClass : className, updateClass);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNodeBorderClassName = (
|
const getNodeBorderClassName = (
|
||||||
selected: boolean | undefined,
|
selected: boolean | undefined,
|
||||||
buildStatus: BuildStatus | undefined,
|
buildStatus: BuildStatus | undefined,
|
||||||
|
|
@ -181,7 +270,6 @@ export default function NodeStatus({
|
||||||
: "Loader2"
|
: "Loader2"
|
||||||
: "Play";
|
: "Play";
|
||||||
|
|
||||||
// Keep the existing icon classes
|
|
||||||
const iconClasses = cn(
|
const iconClasses = cn(
|
||||||
"play-button-icon",
|
"play-button-icon",
|
||||||
isHovered ? "text-foreground" : "text-placeholder-foreground",
|
isHovered ? "text-foreground" : "text-placeholder-foreground",
|
||||||
|
|
@ -196,9 +284,69 @@ export default function NodeStatus({
|
||||||
return "Run component";
|
return "Run component";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClickConnect = () => {
|
||||||
|
if (connectionLink === "error") return;
|
||||||
|
if (isAuthenticated) {
|
||||||
|
handleDisconnect();
|
||||||
|
} else {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionButtonClasses: (
|
||||||
|
connectionLink: string,
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
isPolling: boolean,
|
||||||
|
) => string = (
|
||||||
|
connectionLink: string,
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
isPolling: boolean,
|
||||||
|
): string => {
|
||||||
|
return cn(
|
||||||
|
"nodrag button-run-bg hit-area-icon group relative h-5 w-5 rounded-sm border border-accent-amber-foreground transition-colors hover:bg-accent-amber",
|
||||||
|
connectionLink === "error"
|
||||||
|
? "border-destructive text-destructive"
|
||||||
|
: isAuthenticated && !isPolling
|
||||||
|
? "border-accent-emerald-foreground hover:border-accent-amber-foreground"
|
||||||
|
: "",
|
||||||
|
connectionLink === "" && "cursor-not-allowed opacity-50",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionIconClasses: (
|
||||||
|
connectionLink: string,
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
isPolling: boolean,
|
||||||
|
) => string = (
|
||||||
|
connectionLink: string,
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
isPolling: boolean,
|
||||||
|
): string => {
|
||||||
|
return cn(
|
||||||
|
"h-3 w-3 transition-opacity",
|
||||||
|
connectionLink === "error"
|
||||||
|
? "text-destructive"
|
||||||
|
: isAuthenticated && !isPolling
|
||||||
|
? "text-accent-emerald-foreground"
|
||||||
|
: "text-accent-amber-foreground",
|
||||||
|
isPolling && "animate-spin",
|
||||||
|
isAuthenticated && !isPolling ? "group-hover:opacity-0" : "",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataTestId = () => {
|
||||||
|
if (isAuthenticated && !isPolling) {
|
||||||
|
return `button_connected_${display_name.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (connectionLink === "error") {
|
||||||
|
return `button_error_${display_name.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return `button_disconnected_${display_name.toLowerCase()}`;
|
||||||
|
};
|
||||||
|
|
||||||
return showNode ? (
|
return showNode ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-shrink-0 items-center">
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
<div className="flex items-center gap-2 self-center">
|
<div className="flex items-center gap-2 self-center">
|
||||||
<ShadTooltip
|
<ShadTooltip
|
||||||
styleClasses={cn(
|
styleClasses={cn(
|
||||||
|
|
@ -242,6 +390,55 @@ export default function NodeStatus({
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{nodeAuth && (
|
||||||
|
<ShadTooltip content={nodeAuth.auth_tooltip || "Connect"}>
|
||||||
|
<div>
|
||||||
|
{showNode && (
|
||||||
|
<Button
|
||||||
|
unstyled
|
||||||
|
disabled={connectionLink === "" || connectionLink === "error"}
|
||||||
|
className={getConnectionButtonClasses(
|
||||||
|
connectionLink,
|
||||||
|
isAuthenticated,
|
||||||
|
isPolling,
|
||||||
|
)}
|
||||||
|
onClick={handleClickConnect}
|
||||||
|
data-testid={getDataTestId()}
|
||||||
|
>
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<IconComponent
|
||||||
|
name={
|
||||||
|
isPolling
|
||||||
|
? "Loader2"
|
||||||
|
: isAuthenticated
|
||||||
|
? "Link"
|
||||||
|
: "AlertTriangle"
|
||||||
|
}
|
||||||
|
className={getConnectionIconClasses(
|
||||||
|
connectionLink,
|
||||||
|
isAuthenticated,
|
||||||
|
isPolling,
|
||||||
|
)}
|
||||||
|
strokeWidth={ICON_STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<IconComponent
|
||||||
|
name={"Unlink"}
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 text-accent-amber-foreground opacity-0 transition-opacity",
|
||||||
|
isAuthenticated && !isPolling
|
||||||
|
? "group-hover:opacity-100"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
strokeWidth={ICON_STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ShadTooltip>
|
||||||
|
)}
|
||||||
<ShadTooltip content={getTooltipContent()}>
|
<ShadTooltip content={getTooltipContent()}>
|
||||||
<div
|
<div
|
||||||
ref={divRef}
|
ref={divRef}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ const SearchBarComponent = ({
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
inputClassName="border-none focus:ring-0"
|
inputClassName="border-none focus:ring-0"
|
||||||
|
data-testid="search_bar_input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import ListSelectionComponent from "@/CustomNodes/GenericNode/components/ListSelectionComponent";
|
import ListSelectionComponent from "@/CustomNodes/GenericNode/components/ListSelectionComponent";
|
||||||
import { cn } from "@/utils/utils";
|
import { cn } from "@/utils/utils";
|
||||||
import { memo, useCallback, useMemo, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ReactSortable } from "react-sortablejs";
|
import { ReactSortable } from "react-sortablejs";
|
||||||
import { InputProps } from "../../types";
|
import { InputProps } from "../../types";
|
||||||
import HelperTextComponent from "../helperTextComponent";
|
import HelperTextComponent from "../helperTextComponent";
|
||||||
|
|
@ -118,8 +118,23 @@ const SortableListComponent = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenListSelectionDialog = useCallback(() => {
|
const handleOpenListSelectionDialog = useCallback(() => {
|
||||||
|
if (helperText) {
|
||||||
|
setShowHelperText(true);
|
||||||
|
} else {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, []);
|
}
|
||||||
|
}, [helperText]);
|
||||||
|
|
||||||
|
const [showHelperText, setShowHelperText] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!helperText) {
|
||||||
|
setShowHelperText(false);
|
||||||
|
}
|
||||||
|
if (helperText && open) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [helperText, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
|
|
@ -131,6 +146,7 @@ const SortableListComponent = ({
|
||||||
role="combobox"
|
role="combobox"
|
||||||
onClick={handleOpenListSelectionDialog}
|
onClick={handleOpenListSelectionDialog}
|
||||||
className="dropdown-component-outline input-edit-node w-full py-2"
|
className="dropdown-component-outline input-edit-node w-full py-2"
|
||||||
|
data-testid="button_open_list_selection"
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center text-sm font-semibold")}>
|
<div className={cn("flex items-center text-sm font-semibold")}>
|
||||||
{placeholder}
|
{placeholder}
|
||||||
|
|
@ -159,7 +175,7 @@ const SortableListComponent = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{helperText && (
|
{helperText && showHelperText && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<HelperTextComponent
|
<HelperTextComponent
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
|
|
|
||||||
|
|
@ -657,6 +657,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
|
||||||
"tab",
|
"tab",
|
||||||
"sortableList",
|
"sortableList",
|
||||||
"connect",
|
"connect",
|
||||||
|
"auth",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const FLEX_VIEW_TYPES = ["bool"];
|
export const FLEX_VIEW_TYPES = ["bool"];
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,7 @@ import {
|
||||||
Type,
|
Type,
|
||||||
Undo,
|
Undo,
|
||||||
Ungroup,
|
Ungroup,
|
||||||
|
UnlinkIcon,
|
||||||
Unplug,
|
Unplug,
|
||||||
Upload,
|
Upload,
|
||||||
User,
|
User,
|
||||||
|
|
@ -709,6 +710,7 @@ export const nodeIconsLucide: iconsType = {
|
||||||
EverNoteLoader: EvernoteIcon,
|
EverNoteLoader: EvernoteIcon,
|
||||||
FacebookChatLoader: FBIcon,
|
FacebookChatLoader: FBIcon,
|
||||||
FirecrawlCrawlApi: FirecrawlIcon,
|
FirecrawlCrawlApi: FirecrawlIcon,
|
||||||
|
Unlink: UnlinkIcon,
|
||||||
FirecrawlScrapeApi: FirecrawlIcon,
|
FirecrawlScrapeApi: FirecrawlIcon,
|
||||||
FirecrawlMapApi: FirecrawlIcon,
|
FirecrawlMapApi: FirecrawlIcon,
|
||||||
FirecrawlExtractApi: FirecrawlIcon,
|
FirecrawlExtractApi: FirecrawlIcon,
|
||||||
|
|
|
||||||
|
|
@ -855,3 +855,13 @@ export function setCookie(
|
||||||
|
|
||||||
document.cookie = cookieString;
|
document.cookie = cookieString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a string to snake_case
|
||||||
|
* Example: "New York" becomes "new_york"
|
||||||
|
* @param {string} str - The string to convert
|
||||||
|
* @returns {string} The snake_case string
|
||||||
|
*/
|
||||||
|
export function testIdCase(str: string): string {
|
||||||
|
return str.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
}
|
||||||
|
|
|
||||||
65
src/frontend/tests/core/features/composio.spec.ts
Normal file
65
src/frontend/tests/core/features/composio.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { adjustScreenView } from "../../utils/adjust-screen-view";
|
||||||
|
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||||
|
import { removeOldApiKeys } from "../../utils/remove-old-api-keys";
|
||||||
|
|
||||||
|
test(
|
||||||
|
"user should be able to interact with composio component",
|
||||||
|
{ tag: ["@release", "@workspace", "@api"] },
|
||||||
|
async ({ page, context }) => {
|
||||||
|
test.skip(
|
||||||
|
!process?.env?.COMPOSIO_API_KEY,
|
||||||
|
"COMPOSIO_API_KEY required to run this test",
|
||||||
|
);
|
||||||
|
|
||||||
|
await awaitBootstrapTest(page);
|
||||||
|
|
||||||
|
await page.waitForSelector('[data-testid="blank-flow"]', {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("blank-flow").click();
|
||||||
|
await page.waitForSelector('[data-testid="sidebar-search-input"]', {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-search-input").click();
|
||||||
|
await page.getByTestId("sidebar-search-input").fill("gmail");
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByTestId("composioGmail")
|
||||||
|
.hover()
|
||||||
|
.then(async (): Promise<void> => {
|
||||||
|
await page.getByTestId("add-component-button-gmail").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await removeOldApiKeys(page);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByTestId("popover-anchor-input-api_key")
|
||||||
|
.fill(process.env.COMPOSIO_API_KEY!);
|
||||||
|
|
||||||
|
await page.waitForSelector('[data-testid="button_connected_gmail"]', {
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("button_open_list_selection").click();
|
||||||
|
|
||||||
|
await page.getByTestId("search_bar_input").fill("fetch emails");
|
||||||
|
|
||||||
|
await page.getByTestId(`list_item_fetch_emails`).click();
|
||||||
|
|
||||||
|
await page.getByTestId("int_int_max_results").fill("10");
|
||||||
|
|
||||||
|
await page.getByTestId("button_run_gmail").click();
|
||||||
|
|
||||||
|
await page.waitForSelector("text=built successfully", {
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("output-inspection-dataframe-gmailapi").click();
|
||||||
|
|
||||||
|
const colNumber: number = await page.getByRole("gridcell").count();
|
||||||
|
expect(colNumber).toBeGreaterThan(9);
|
||||||
|
},
|
||||||
|
);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue