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:
Edwin Jose 2025-04-03 15:38:04 -04:00 committed by GitHub
commit 8b4cf7b1db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1363 additions and 14 deletions

View file

@ -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)}`}
/>
))
) : (

View file

@ -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}

View file

@ -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>
);

View file

@ -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}

View file

@ -657,6 +657,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
"tab",
"sortableList",
"connect",
"auth",
]);
export const FLEX_VIEW_TYPES = ["bool"];

View file

@ -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,

View file

@ -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, "_");
}

View 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);
},
);