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 .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 (
|
||||
AuthInput,
|
||||
BoolInput,
|
||||
CodeInput,
|
||||
ConnectionInput,
|
||||
|
|
@ -29,6 +30,7 @@ from .inputs import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthInput",
|
||||
"BoolInput",
|
||||
"CodeInput",
|
||||
"ConnectionInput",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class FieldTypes(str, Enum):
|
|||
NESTED_DICT = "NestedDict"
|
||||
SORTABLE_LIST = "sortableList"
|
||||
CONNECTION = "connect"
|
||||
AUTH = "auth"
|
||||
FILE = "file"
|
||||
PROMPT = "prompt"
|
||||
CODE = "code"
|
||||
|
|
@ -137,6 +138,10 @@ class DatabaseLoadMixin(BaseModel):
|
|||
load_from_db: bool = Field(default=True)
|
||||
|
||||
|
||||
class AuthMixin(BaseModel):
|
||||
auth_tooltip: str | None = Field(default="")
|
||||
|
||||
|
||||
# Specific mixin for fields needing file interaction
|
||||
class FileMixin(BaseModel):
|
||||
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 .input_mixin import (
|
||||
AuthMixin,
|
||||
BaseInputMixin,
|
||||
ConnectionMixin,
|
||||
DatabaseLoadMixin,
|
||||
|
|
@ -477,6 +478,20 @@ class ConnectionInput(BaseInputMixin, ConnectionMixin, MetadataTraceMixin, ToolM
|
|||
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):
|
||||
"""Represents a list selection input field.
|
||||
|
||||
|
|
@ -484,7 +499,7 @@ class SortableListInput(BaseInputMixin, SortableListMixin, MetadataTraceMixin, T
|
|||
It inherits from the `BaseInputMixin` and `ListableInputMixin` classes.
|
||||
|
||||
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
|
||||
|
|
@ -590,6 +605,7 @@ class DefaultPromptField(Input):
|
|||
|
||||
InputTypes: TypeAlias = (
|
||||
Input
|
||||
| AuthInput
|
||||
| DefaultPromptField
|
||||
| BoolInput
|
||||
| DataInput
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ DIRECT_TYPES = [
|
|||
"slider",
|
||||
"tab",
|
||||
"sortableList",
|
||||
"auth",
|
||||
"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 { Button } from "@/components/ui/button";
|
||||
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";
|
||||
|
||||
// Update interface with better types
|
||||
|
|
@ -27,6 +27,7 @@ const ListItem = ({
|
|||
onMouseLeave,
|
||||
isFocused,
|
||||
isKeyboardNavActive,
|
||||
dataTestId,
|
||||
}: {
|
||||
item: any;
|
||||
isSelected: boolean;
|
||||
|
|
@ -36,6 +37,7 @@ const ListItem = ({
|
|||
onMouseLeave: () => void;
|
||||
isFocused: boolean;
|
||||
isKeyboardNavActive: boolean;
|
||||
dataTestId: string;
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const itemRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -58,6 +60,7 @@ const ListItem = ({
|
|||
<Button
|
||||
ref={itemRef}
|
||||
key={item.id}
|
||||
data-testid={dataTestId}
|
||||
unstyled
|
||||
size="sm"
|
||||
className={cn(
|
||||
|
|
@ -308,6 +311,7 @@ const ListSelectionComponent = ({
|
|||
}}
|
||||
isFocused={focusedIndex === index}
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ICON_STROKE_WIDTH } from "@/constants/constants";
|
||||
import { BuildStatus, EventDeliveryType } from "@/constants/enums";
|
||||
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 { 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 useFlowStore from "@/stores/flowStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcuts";
|
||||
|
|
@ -24,6 +27,9 @@ import IconComponent from "../../../../components/common/genericIconComponent";
|
|||
import BuildStatusDisplay from "./components/build-status-display";
|
||||
import { normalizeTimeString } from "./utils/format-run-time";
|
||||
|
||||
const POLLING_TIMEOUT = 21000;
|
||||
const POLLING_INTERVAL = 3000;
|
||||
|
||||
export default function NodeStatus({
|
||||
nodeId,
|
||||
display_name,
|
||||
|
|
@ -57,6 +63,17 @@ export default function NodeStatus({
|
|||
const [validationString, setValidationString] = useState<string>("");
|
||||
const [validationStatus, setValidationStatus] =
|
||||
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 =
|
||||
buildStatus === BuildStatus.BUILT ||
|
||||
|
|
@ -71,11 +88,82 @@ export default function NodeStatus({
|
|||
const setNode = useFlowStore((state) => state.setNode);
|
||||
const version = useDarkStore((state) => state.version);
|
||||
const config = useGetConfig();
|
||||
const setErrorData = useAlertStore((state) => state.setErrorData);
|
||||
|
||||
const postTemplateValue = usePostTemplateValue({
|
||||
parameterId: nodeAuth?.name ?? "auth",
|
||||
nodeId: nodeId,
|
||||
node: data.node,
|
||||
});
|
||||
|
||||
const shouldStreamEvents = () => {
|
||||
// Get from useGetConfig store
|
||||
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() {
|
||||
if (buildStatus === BuildStatus.BUILDING || isBuilding || !selected) return;
|
||||
setValidationStatus(null);
|
||||
|
|
@ -107,6 +195,7 @@ export default function NodeStatus({
|
|||
: "";
|
||||
return cn(frozen ? frozenClass : className, updateClass);
|
||||
};
|
||||
|
||||
const getNodeBorderClassName = (
|
||||
selected: boolean | undefined,
|
||||
buildStatus: BuildStatus | undefined,
|
||||
|
|
@ -181,7 +270,6 @@ export default function NodeStatus({
|
|||
: "Loader2"
|
||||
: "Play";
|
||||
|
||||
// Keep the existing icon classes
|
||||
const iconClasses = cn(
|
||||
"play-button-icon",
|
||||
isHovered ? "text-foreground" : "text-placeholder-foreground",
|
||||
|
|
@ -196,9 +284,69 @@ export default function NodeStatus({
|
|||
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 ? (
|
||||
<>
|
||||
<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">
|
||||
<ShadTooltip
|
||||
styleClasses={cn(
|
||||
|
|
@ -242,6 +390,55 @@ export default function NodeStatus({
|
|||
</Badge>
|
||||
)}
|
||||
</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()}>
|
||||
<div
|
||||
ref={divRef}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const SearchBarComponent = ({
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
inputClassName="border-none focus:ring-0"
|
||||
data-testid="search_bar_input"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import ListSelectionComponent from "@/CustomNodes/GenericNode/components/ListSelectionComponent";
|
||||
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 { InputProps } from "../../types";
|
||||
import HelperTextComponent from "../helperTextComponent";
|
||||
|
|
@ -118,8 +118,23 @@ const SortableListComponent = ({
|
|||
}, []);
|
||||
|
||||
const handleOpenListSelectionDialog = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
if (helperText) {
|
||||
setShowHelperText(true);
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [helperText]);
|
||||
|
||||
const [showHelperText, setShowHelperText] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!helperText) {
|
||||
setShowHelperText(false);
|
||||
}
|
||||
if (helperText && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [helperText, open]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
|
|
@ -131,6 +146,7 @@ const SortableListComponent = ({
|
|||
role="combobox"
|
||||
onClick={handleOpenListSelectionDialog}
|
||||
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")}>
|
||||
{placeholder}
|
||||
|
|
@ -159,7 +175,7 @@ const SortableListComponent = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{helperText && (
|
||||
{helperText && showHelperText && (
|
||||
<div className="pt-2">
|
||||
<HelperTextComponent
|
||||
helperText={helperText}
|
||||
|
|
|
|||
|
|
@ -657,6 +657,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
|
|||
"tab",
|
||||
"sortableList",
|
||||
"connect",
|
||||
"auth",
|
||||
]);
|
||||
|
||||
export const FLEX_VIEW_TYPES = ["bool"];
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ import {
|
|||
Type,
|
||||
Undo,
|
||||
Ungroup,
|
||||
UnlinkIcon,
|
||||
Unplug,
|
||||
Upload,
|
||||
User,
|
||||
|
|
@ -709,6 +710,7 @@ export const nodeIconsLucide: iconsType = {
|
|||
EverNoteLoader: EvernoteIcon,
|
||||
FacebookChatLoader: FBIcon,
|
||||
FirecrawlCrawlApi: FirecrawlIcon,
|
||||
Unlink: UnlinkIcon,
|
||||
FirecrawlScrapeApi: FirecrawlIcon,
|
||||
FirecrawlMapApi: FirecrawlIcon,
|
||||
FirecrawlExtractApi: FirecrawlIcon,
|
||||
|
|
|
|||
|
|
@ -855,3 +855,13 @@ export function setCookie(
|
|||
|
||||
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