feat: Enhance Webhook component (#6313)

* 📝 (constants.py): Add "copy_field" attribute to FIELD_FORMAT_ATTRIBUTES list
📝 (webhook.py): Add "copy_field" attribute to MultilineInput component
📝 (input_mixin.py): Add "copy_field" attribute to BaseInputMixin class
📝 (inputs.py): Add "copy_field" attribute to StrInput class
📝 (template/field/base.py): Add "copy_field" attribute to Input class
🚀 (NodeDescription/index.tsx): Remove default placeholder text for emptyPlaceholder prop
 (copyFieldAreaComponent/index.tsx): Add new component for handling copy field functionality
♻️ (strRenderComponent/index.tsx): Refactor component to include CopyFieldAreaComponent when copy_field attribute is present in template data

*  (NodeDescription/index.tsx): refactor renderedDescription useMemo to improve readability and maintainability
♻️ (GenericNode/index.tsx): refactor code to improve readability and maintainability, and optimize rendering logic

* 📝 (webhook.py): Add cURL field to WebhookComponent for better integration with external systems
📝 (graph/base.py): Add logging of vertex build information in Graph class for debugging purposes
📝 (NodeInputField/index.tsx): Add nodeInformationMetadata to NodeInputField for better tracking of node information
📝 (copyFieldAreaComponent/index.tsx): Refactor CopyFieldAreaComponent to handle different types of values, including webhooks
📝 (strRenderComponent/index.tsx): Add WebhookFieldComponent to handle webhook type in StrRenderComponent
📝 (tableNodeCellRender/index.tsx): Add nodeInformationMetadata to TableNodeCellRender for better tracking of node information
📝 (textAreaComponent/index.tsx): Add support for webhook format in TextAreaComponent for better integration with webhooks
📝 (webhookFieldComponent/index.tsx): Add WebhookFieldComponent to handle webhook type in ParameterRenderComponent
📝 (custom-parameter.tsx): Add nodeInformationMetadata to CustomParameterComponent for better tracking of node information
📝 (get-curl-code.tsx): Add support for different formats in getCurlWebhookCode for generating cURL commands
📝 (textAreaModal/index.tsx): Add onCloseModal callback to ComponentTextModal for better handling of modal closing
📝 (index.ts): Add type field to APIClassType for better typing of API classes

*  (index.tsx): Add a button to generate a token in the WebhookFieldComponent for improved user experience and functionality. Update the structure of the component to include the new button and styling adjustments.

* [autofix.ci] apply automated fixes

*  (generate-token-dialog.tsx): add GenerateTokenDialog component to handle token generation in webhookFieldComponent
📝 (index.tsx): import and use GenerateTokenDialog component in WebhookFieldComponent for token generation functionality

*  (frontend): introduce new feature to create API keys with customizable modal properties
🔧 (frontend): add modalProps object to customize modal title, description, input label, input placeholder, button text, generated key message, and show icon flag

* add pool interval variable and tests

* 📝 (NodeOutputfield): Remove unused ScanEyeIcon component
 (validate-webhook.ts): Add function to validate webhook data before processing
♻️ (use-get-builds-pooling-mutation): Refactor to set flow pool based on current flow
🔧 (content-render.tsx): Add data-testid attribute to api key input element
🔧 (webhookComponent.spec.ts): Refactor test to use waitForRequest for monitoring build requests

* [autofix.ci] apply automated fixes

* 🔧 (backend): rename webhook_pooling_interval to webhook_polling_interval for consistency
🔧 (frontend): update references to webhook_pooling_interval to webhook_polling_interval for consistency

* 📝 (frontend): Update import paths and remove unused imports for better code organization and maintainability
🔧 (frontend): Refactor background styles in components to use constants for consistency and easier theming
🚀 (frontend): Add custom SecretKeyModalButton component for better modularity and reusability

* 📝 (use-get-api-keys.ts): add a TODO comment to request API key from DSLF endpoint for future implementation.

* 📝 (input_mixin.py): Remove copy_field attribute from BaseInputMixin as it is no longer needed
♻️ (inputs.py): Remove copy_field attribute from StrInput class as it is no longer needed
♻️ (inputs.py): Set copy_field attribute to False in MultilineInput class to ensure consistency
♻️ (template/field/base.py): Remove copy_field attribute from Input class as it is no longer needed
📝 (textAreaComponent/index.tsx): Replace hardcoded value "CURL_WEBHOOK" with constant WEBHOOK_VALUE for better readability and maintainability

* 🐛 (base.py): fix issue where flow_id could be None by defaulting to an empty string if flow_id is None

* 🔧 (secret-key-modal.tsx): Remove unused SecretKeyModalButton component
🔧 (get-modal-props.tsx): Remove unused getModalPropsApiKey function and related imports and constants

* 📝 (langflow): add noqa comments to suppress linting rule A005 for specific files in the io, logging, and socket modules

* [autofix.ci] apply automated fixes

* 📝 (frontend): Remove unused endpointName property from NodeInputField component
🔧 (frontend): Add useFlowStore import and use it to get currentFlow and endpointName in CopyFieldAreaComponent, TableNodeCellRender, TextAreaComponent, and WebhookFieldComponent components
🔧 (frontend): Refactor useGetBuildsMutation to handle multiple concurrent requests and prevent duplicate requests

*  (webhookComponent.spec.ts): refactor test to improve readability and maintainability by removing redundant code and focusing on essential test steps
♻️ (userSettings.spec.ts): refactor test to improve readability and maintainability by removing redundant code and focusing on essential test steps

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Cristhian Zanforlin Lousa 2025-03-10 10:10:55 -03:00 committed by GitHub
commit 94140ccf2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 1155 additions and 182 deletions

View file

@ -156,6 +156,11 @@ def run(
help="Defines the maximum file size for the upload in MB.",
show_default=False,
),
webhook_polling_interval: int | None = typer.Option( # noqa: ARG001
None,
help="Defines the polling interval for the webhook.",
show_default=False,
),
) -> None:
"""Run Langflow."""
# Register SIGTERM handler

View file

@ -376,6 +376,7 @@ class ConfigResponse(BaseModel):
auto_saving_interval: int
health_check_max_retries: int
max_file_size_upload: int
webhook_polling_interval: int
event_delivery: Literal["polling", "streaming"]

View file

@ -38,6 +38,7 @@ FIELD_FORMAT_ATTRIBUTES = [
"refresh_button_text",
"options",
"advanced",
"copy_field",
]
ORJSON_OPTIONS = orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS | orjson.OPT_OMIT_MICROSECONDS

View file

@ -7,7 +7,6 @@ from langflow.schema import Data
class WebhookComponent(Component):
display_name = "Webhook"
description = "Defines a webhook input for the flow."
name = "Webhook"
icon = "webhook"
@ -16,7 +15,23 @@ class WebhookComponent(Component):
name="data",
display_name="Payload",
info="Receives a payload from external systems via HTTP POST.",
)
advanced=True,
),
MultilineInput(
name="curl",
display_name="cURL",
value="CURL_WEBHOOK",
advanced=True,
input_types=[],
),
MultilineInput(
name="endpoint",
display_name="Endpoint",
value="BACKEND_URL",
advanced=False,
copy_field=True,
input_types=[],
),
]
outputs = [
Output(display_name="Data", name="output_data", method="build_data"),

View file

@ -31,6 +31,7 @@ from langflow.graph.graph.utils import (
should_continue,
)
from langflow.graph.schema import InterfaceComponentTypes, RunOutputs
from langflow.graph.utils import log_vertex_build
from langflow.graph.vertex.base import Vertex, VertexStates
from langflow.graph.vertex.schema import NodeData, NodeTypeEnum
from langflow.graph.vertex.vertex_types import ComponentVertex, InterfaceVertex, StateVertex
@ -1607,6 +1608,15 @@ class Graph:
t.cancel()
raise result
if isinstance(result, VertexBuildResult):
await log_vertex_build(
flow_id=self.flow_id or "",
vertex_id=result.vertex.id,
valid=result.valid,
params=result.params,
data=result.result_dict,
artifacts=result.artifacts,
)
vertices.append(result.vertex)
else:
msg = f"Invalid result from task {task_name}: {result}"

View file

@ -240,6 +240,7 @@ class MultilineInput(MessageTextInput, MultilineMixin, InputTraceMixin, ToolMode
field_type: SerializableFieldTypes = FieldTypes.TEXT
multiline: CoalesceBool = True
copy_field: CoalesceBool = False
class MultilineSecretInput(MessageTextInput, MultilineMixin, InputTraceMixin):

View file

@ -215,6 +215,8 @@ class Settings(BaseSettings):
"""The maximum number of vertex builds to keep in the database."""
max_vertex_builds_per_vertex: int = 2
"""The maximum number of builds to keep per vertex. Older builds will be deleted."""
webhook_polling_interval: int = 5000
"""The polling interval for the webhook in ms."""
# MCP Server
mcp_server_enabled: bool = True

View file

@ -89,6 +89,7 @@ class Input(BaseModel):
refresh_button: bool | None = None
"""Specifies if the field should have a refresh button. Defaults to False."""
refresh_button_text: str | None = None
"""Specifies the text for the refresh button. Defaults to None."""
@ -97,6 +98,7 @@ class Input(BaseModel):
load_from_db: bool = False
"""Specifies if the field should be loaded from the database. Defaults to False."""
title_case: bool = False
"""Specifies if the field should be displayed in title case. Defaults to True."""

View file

@ -415,6 +415,7 @@ async def update_settings(
auto_saving_interval: int = 1000,
health_check_max_retries: int = 5,
max_file_size_upload: int = 100,
webhook_polling_interval: int = 5000,
) -> None:
"""Update the settings from a config file."""
# Check for database_url in the environment variables
@ -448,6 +449,9 @@ async def update_settings(
if max_file_size_upload is not None:
logger.debug(f"Setting max_file_size_upload to {max_file_size_upload}")
settings_service.settings.update_settings(max_file_size_upload=max_file_size_upload)
if webhook_polling_interval is not None:
logger.debug(f"Setting webhook_polling_interval to {webhook_polling_interval}")
settings_service.settings.update_settings(webhook_polling_interval=webhook_polling_interval)
def is_class_method(func, cls):

View file

@ -10,7 +10,7 @@ export default function NodeDescription({
description,
selected,
nodeId,
emptyPlaceholder = "Double Click to Edit Description",
emptyPlaceholder = "",
placeholderClassName,
charLimit,
inputClassName,
@ -69,23 +69,23 @@ export default function NodeDescription({
}, [description]);
const MemoizedMarkdown = memo(Markdown);
const renderedDescription = useMemo(
() =>
description === "" || !description ? (
emptyPlaceholder
) : (
<MemoizedMarkdown
linkTarget="_blank"
className={cn(
"markdown prose flex w-full flex-col text-[13px] leading-5 word-break-break-word [&_pre]:whitespace-break-spaces [&_pre]:!bg-code-description-background [&_pre_code]:!bg-code-description-background",
mdClassName,
)}
>
{String(description)}
</MemoizedMarkdown>
),
[description, emptyPlaceholder, mdClassName],
);
const renderedDescription = useMemo(() => {
if (description === "" || !description) {
return emptyPlaceholder;
}
return (
<MemoizedMarkdown
linkTarget="_blank"
className={cn(
"markdown prose flex w-full flex-col text-[13px] leading-5 word-break-break-word [&_pre]:whitespace-break-spaces [&_pre]:!bg-code-description-background [&_pre_code]:!bg-code-description-background",
mdClassName,
)}
>
{String(description)}
</MemoizedMarkdown>
);
}, [description, emptyPlaceholder, mdClassName]);
const handleBlurFn = () => {
setNodeDescription(nodeDescription);

View file

@ -1,12 +1,14 @@
import useHandleNodeClass from "@/CustomNodes/hooks/use-handle-node-class";
import { NodeInfoType } from "@/components/core/parameterRenderComponent/types";
import { usePostTemplateValue } from "@/controllers/API/queries/nodes/use-post-template-value";
import {
CustomParameterComponent,
CustomParameterLabel,
getCustomParameterTitle,
} from "@/customization/components/custom-parameter";
import useAuthStore from "@/stores/authStore";
import { cn } from "@/utils/utils";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { default as IconComponent } from "../../../../components/common/genericIconComponent";
import ShadTooltip from "../../../../components/common/shadTooltipComponent";
import {
@ -44,6 +46,8 @@ export default function NodeInputField({
const ref = useRef<HTMLDivElement>(null);
const nodes = useFlowStore((state) => state.nodes);
const edges = useFlowStore((state) => state.edges);
const isAuth = useAuthStore((state) => state.isAuthenticated);
const currentFlow = useFlowStore((state) => state.currentFlow);
const myData = useTypesStore((state) => state.data);
const postTemplateValue = usePostTemplateValue({
node: data.node!,
@ -64,6 +68,16 @@ export default function NodeInputField({
name,
});
const nodeInformationMetadata: NodeInfoType = useMemo(() => {
return {
flowId: currentFlow?.id ?? "",
nodeType: data?.type?.toLowerCase() ?? "",
flowName: currentFlow?.name ?? "",
isAuth,
variableName: name,
};
}, [data?.node?.id, isAuth, name]);
useFetchDataOnMount(data.node!, handleNodeClass, name, postTemplateValue);
useEffect(() => {
@ -193,6 +207,7 @@ export default function NodeInputField({
: data.node?.template[name].placeholder
}
isToolMode={isToolMode}
nodeInformationMetadata={nodeInformationMetadata}
/>
)}
</div>

View file

@ -49,14 +49,6 @@ const SnowflakeIcon = memo(() => (
<IconComponent className="h-5 w-5 text-ice" name="Snowflake" />
));
const ScanEyeIcon = memo(({ className }: { className: string }) => (
<IconComponent
className={className}
name="ScanEye"
strokeWidth={ICON_STROKE_WIDTH}
/>
));
// Memoize Button components
const HideShowButton = memo(
({

View file

@ -252,6 +252,13 @@ function GenericNode({
const [hasChangedNodeDescription, setHasChangedNodeDescription] =
useState(false);
const editedNameDescription =
editNameDescription && hasChangedNodeDescription;
const hasDescription = useMemo(() => {
return data.node?.description && data.node?.description !== "";
}, [data.node?.description]);
const memoizedNodeToolbarComponent = useMemo(() => {
return selected ? (
<>
@ -294,25 +301,21 @@ function GenericNode({
showNode
? "top-2 translate-x-[10.4rem]"
: "top-0 translate-x-[6.4rem]",
editNameDescription && hasChangedNodeDescription
editedNameDescription
? "bg-accent-emerald"
: "bg-zinc-foreground",
)}
data-testid={
editNameDescription && hasChangedNodeDescription
editedNameDescription
? "save-name-description-button"
: "edit-name-description-button"
}
>
<ForwardedIconComponent
name={
editNameDescription && hasChangedNodeDescription
? "Check"
: "PencilLine"
}
name={editedNameDescription ? "Check" : "PencilLine"}
strokeWidth={ICON_STROKE_WIDTH}
className={cn(
editNameDescription && hasChangedNodeDescription
editedNameDescription
? "text-accent-emerald-foreground"
: "text-muted-foreground",
"icon-size",
@ -490,8 +493,9 @@ function GenericNode({
<div
data-testid={`${data.id}-main-node`}
className={cn(
"grid gap-3 text-wrap p-4 leading-5",
"grid truncate text-wrap p-4 leading-5",
showNode ? "border-b" : "relative",
hasDescription && "gap-3",
)}
>
<div

View file

@ -0,0 +1,155 @@
import { GRADIENT_CLASS_DISABLED } from "@/constants/constants";
import useAlertStore from "@/stores/alertStore";
import useFlowStore from "@/stores/flowStore";
import { useMemo, useRef, useState } from "react";
import { cn } from "../../../../../utils/utils";
import IconComponent from "../../../../common/genericIconComponent";
import { Input } from "../../../../ui/input";
import { InputProps, TextAreaComponentType } from "../../types";
const BACKEND_URL = "BACKEND_URL";
const URL_WEBHOOK = `${window.location.protocol}//${window.location.host}/api/v1/webhook/`;
const inputClasses = {
base: ({ isFocused }: { isFocused: boolean }) =>
`w-full ${isFocused ? "" : "pr-3"}`,
editNode: "input-edit-node",
normal: ({ isFocused }: { isFocused: boolean }) =>
`primary-input ${isFocused ? "text-primary" : "text-muted-foreground"}`,
disabled: "disabled-state",
};
const externalLinkIconClasses = {
gradient: ({
editNode,
disabled,
}: {
editNode: boolean;
disabled: boolean;
}) =>
disabled
? "gradient-fade-input-edit-node"
: editNode
? "gradient-fade-input-edit-node"
: "gradient-fade-input",
background: ({
editNode,
disabled,
}: {
editNode: boolean;
disabled: boolean;
}) =>
disabled
? ""
: editNode
? "background-fade-input-edit-node"
: "background-fade-input",
icon: "icons-parameters-comp absolute right-3 h-4 w-4 shrink-0",
editNodeTop: "top-[-1.4rem] h-5",
normalTop: "top-[-2.1rem] h-7",
iconTop: "top-[-1.7rem]",
};
export default function CopyFieldAreaComponent({
value,
handleOnNewValue,
editNode = false,
id = "",
}: InputProps<string, TextAreaComponentType>): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const isValueToReplace = value === BACKEND_URL;
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const currentFlow = useFlowStore((state) => state.currentFlow);
const endpointName = currentFlow?.endpoint_name ?? "";
const valueToRender = useMemo(() => {
if (isValueToReplace) {
const urlWebhook = `${URL_WEBHOOK}${endpointName}`;
return isValueToReplace ? urlWebhook : value;
}
return value;
}, [value, endpointName]);
const getInputClassName = () => {
return cn(
inputClasses.base({ isFocused }),
editNode ? inputClasses.editNode : inputClasses.normal({ isFocused }),
isFocused && "pr-10",
);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleOnNewValue({ value: e.target.value });
};
const handleCopy = (event?: React.MouseEvent<HTMLDivElement>) => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
navigator.clipboard.writeText(valueToRender);
setSuccessData({
title: "Endpoint URL copied",
});
event?.stopPropagation();
};
const renderIcon = () => (
<>
{!isFocused && (
<div
className={cn(
externalLinkIconClasses.gradient({
editNode,
disabled: false,
}),
editNode
? externalLinkIconClasses.editNodeTop
: externalLinkIconClasses.normalTop,
)}
style={{
pointerEvents: "none",
background: isFocused ? undefined : GRADIENT_CLASS_DISABLED,
}}
aria-hidden="true"
/>
)}
<div onClick={handleCopy}>
<IconComponent
dataTestId={`btn_copy_${id?.toLowerCase()}${editNode ? "_advanced" : ""}`}
name={isCopied ? "Check" : "Copy"}
className={cn(
"cursor-pointer bg-muted",
externalLinkIconClasses.icon,
editNode
? externalLinkIconClasses.editNodeTop
: externalLinkIconClasses.iconTop,
"bg-muted text-foreground",
)}
/>
</div>
</>
);
return (
<div className={cn("w-full")}>
<Input
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
id={id}
data-testid={id}
value={valueToRender}
onChange={handleInputChange}
className={cn(getInputClassName())}
aria-label={valueToRender}
ref={inputRef}
type="text"
disabled
/>
<div className="relative w-full">{renderIcon()}</div>
</div>
);
}

View file

@ -1,7 +1,9 @@
import { InputProps, StrRenderComponentType } from "../../types";
import CopyFieldAreaComponent from "../copyFieldAreaComponent";
import DropdownComponent from "../dropdownComponent";
import InputGlobalComponent from "../inputGlobalComponent";
import TextAreaComponent from "../textAreaComponent";
import WebhookFieldComponent from "../webhookFieldComponent";
export function StrRenderComponent({
templateData,
@ -10,25 +12,43 @@ export function StrRenderComponent({
placeholder,
...baseInputProps
}: InputProps<string, StrRenderComponentType>) {
const { handleOnNewValue, id, isToolMode } = baseInputProps;
const { handleOnNewValue, id, isToolMode, nodeInformationMetadata } =
baseInputProps;
if (!templateData.options) {
return templateData.multiline ? (
<TextAreaComponent
{...baseInputProps}
// password={templateData.password}
updateVisibility={() => {
if (templateData.password !== undefined) {
handleOnNewValue(
{ password: !templateData.password },
{ skipSnapshot: true },
);
}
}}
id={`textarea_${id}`}
isToolMode={isToolMode}
/>
) : (
const noOptions = !templateData.options;
const isMultiline = templateData.multiline;
const copyField = templateData.copy_field;
const hasOptions = !!templateData.options;
const isWebhook = nodeInformationMetadata?.nodeType === "webhook";
if (noOptions) {
if (isMultiline) {
if (isWebhook) {
return <WebhookFieldComponent {...baseInputProps} />;
}
if (copyField) {
return <CopyFieldAreaComponent {...baseInputProps} />;
}
return (
<TextAreaComponent
{...baseInputProps}
updateVisibility={() => {
if (templateData.password !== undefined) {
handleOnNewValue(
{ password: !templateData.password },
{ skipSnapshot: true },
);
}
}}
id={`textarea_${id}`}
isToolMode={isToolMode}
/>
);
}
return (
<InputGlobalComponent
{...baseInputProps}
password={templateData.password}
@ -41,7 +61,7 @@ export function StrRenderComponent({
);
}
if (!!templateData.options) {
if (hasOptions) {
return (
<DropdownComponent
{...baseInputProps}

View file

@ -1,11 +1,14 @@
import useHandleOnNewValue from "@/CustomNodes/hooks/use-handle-new-value";
import useHandleNodeClass from "@/CustomNodes/hooks/use-handle-node-class";
import { ParameterRenderComponent } from "@/components/core/parameterRenderComponent";
import { NodeInfoType } from "@/components/core/parameterRenderComponent/types";
import useAuthStore from "@/stores/authStore";
import useFlowStore from "@/stores/flowStore";
import { useTweaksStore } from "@/stores/tweaksStore";
import { APIClassType } from "@/types/api";
import { isTargetHandleConnected } from "@/utils/reactflowUtils";
import { CustomCellRendererProps } from "ag-grid-react";
import { useMemo } from "react";
export default function TableNodeCellRender({
value: { nodeId, parameterId, isTweaks },
@ -15,6 +18,8 @@ export default function TableNodeCellRender({
? useTweaksStore((state) => state.getNode(nodeId))
: useFlowStore((state) => state.getNode(nodeId));
const parameter = node?.data?.node?.template?.[parameterId];
const currentFlow = useFlowStore((state) => state.currentFlow);
const isAuth = useAuthStore((state) => state.isAuthenticated);
const setNode = useTweaksStore((state) => state.setNode);
@ -37,6 +42,16 @@ export default function TableNodeCellRender({
isTweaks ? setNode : undefined,
);
const nodeInformationMetadata: NodeInfoType = useMemo(() => {
return {
flowId: currentFlow?.id ?? "",
nodeType: node?.data?.type?.toLowerCase() ?? "",
flowName: currentFlow?.name ?? "",
isAuth,
variableName: parameterId,
};
}, [nodeId, isAuth, parameterId]);
return (
parameter && (
<div className="group mx-auto flex h-full max-h-48 w-[300px] items-center justify-center overflow-auto px-1 py-2.5 custom-scroll">
@ -50,6 +65,7 @@ export default function TableNodeCellRender({
handleNodeClass={handleNodeClass}
nodeClass={node?.data.node}
disabled={disabled}
nodeInformationMetadata={nodeInformationMetadata}
/>
</div>
)

View file

@ -1,6 +1,7 @@
import { GRADIENT_CLASS } from "@/constants/constants";
import { getCurlWebhookCode } from "@/modals/apiModal/utils/get-curl-code";
import ComponentTextModal from "@/modals/textAreaModal";
import { useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "../../../../../utils/utils";
import IconComponent from "../../../../common/genericIconComponent";
import { Input } from "../../../../ui/input";
@ -18,6 +19,8 @@ const inputClasses = {
password: "password",
};
const WEBHOOK_VALUE = "CURL_WEBHOOK";
const externalLinkIconClasses = {
gradient: ({
disabled,
@ -61,12 +64,29 @@ export default function TextAreaComponent({
password,
placeholder,
isToolMode = false,
nodeInformationMetadata,
}: InputProps<string, TextAreaComponentType>): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [passwordVisible, setPasswordVisible] = useState(false);
const isWebhook = useMemo(
() => nodeInformationMetadata?.nodeType === "webhook",
[nodeInformationMetadata?.nodeType],
);
useEffect(() => {
if (isWebhook && value === WEBHOOK_VALUE) {
const curlWebhookCode = getCurlWebhookCode({
flowId: nodeInformationMetadata?.flowId!,
isAuth: nodeInformationMetadata?.isAuth!,
flowName: nodeInformationMetadata?.flowName!,
format: "singleline",
});
handleOnNewValue({ value: curlWebhookCode });
}
}, [isWebhook]);
const getInputClassName = () => {
return cn(
inputClasses.base({ isFocused, password: password! }),
@ -81,9 +101,21 @@ export default function TextAreaComponent({
handleOnNewValue({ value: e.target.value });
};
const changeWebhookFormat = (format: "multiline" | "singleline") => {
if (isWebhook) {
const curlWebhookCode = getCurlWebhookCode({
flowId: nodeInformationMetadata?.flowId!,
isAuth: nodeInformationMetadata?.isAuth!,
flowName: nodeInformationMetadata?.flowName!,
format,
});
handleOnNewValue({ value: curlWebhookCode });
}
};
const renderIcon = () => (
<div>
{!disabled && (
{!disabled && !isFocused && (
<div
className={cn(
externalLinkIconClasses.gradient({
@ -139,6 +171,7 @@ export default function TextAreaComponent({
aria-label={disabled ? value : undefined}
ref={inputRef}
type={password ? (passwordVisible ? "text" : "password") : "text"}
readOnly={isWebhook}
/>
<ComponentTextModal
@ -146,8 +179,14 @@ export default function TextAreaComponent({
value={value}
setValue={(newValue) => handleOnNewValue({ value: newValue })}
disabled={disabled}
onCloseModal={() => changeWebhookFormat("singleline")}
>
<div className="relative w-full">{renderIcon()}</div>
<div
onClick={() => changeWebhookFormat("multiline")}
className="relative w-full"
>
{renderIcon()}
</div>
</ComponentTextModal>
{password && !isFocused && (
<div

View file

@ -0,0 +1,77 @@
import { AuthContext } from "@/contexts/authContext";
import { useGetBuildsMutation } from "@/controllers/API/queries/_builds/use-get-builds-polling-mutation";
import SecretKeyModalButton from "@/customization/components/custom-secret-key-modal-button";
import useFlowStore from "@/stores/flowStore";
import { useContext, useEffect, useRef, useState } from "react";
import { InputProps, TextAreaComponentType } from "../../types";
import CopyFieldAreaComponent from "../copyFieldAreaComponent";
import TextAreaComponent from "../textAreaComponent";
export default function WebhookFieldComponent({
value,
handleOnNewValue,
editNode = false,
id = "",
nodeInformationMetadata,
...baseInputProps
}: InputProps<string, TextAreaComponentType>): JSX.Element {
const { userData } = useContext(AuthContext);
const [userId, setUserId] = useState("");
const { mutate: getBuildsMutation } = useGetBuildsMutation();
const hasInitialized = useRef(false);
const isBackendUrl = nodeInformationMetadata?.variableName === "endpoint";
const isCurlWebhook = nodeInformationMetadata?.variableName === "curl";
const isAuth = nodeInformationMetadata?.isAuth;
const showGenerateToken = isBackendUrl && !editNode && !isAuth;
useEffect(() => {
if (!editNode && isBackendUrl && !hasInitialized.current) {
hasInitialized.current = true;
getBuildsMutation({
flowId: nodeInformationMetadata?.flowId!,
});
}
}, []);
useEffect(() => {
if (userData) {
setUserId(userData.id);
}
}, [userData]);
return (
<div className="grid w-full gap-2">
{isBackendUrl && (
<div>
<CopyFieldAreaComponent
id={id}
value={value}
editNode={editNode}
handleOnNewValue={handleOnNewValue}
{...baseInputProps}
/>
</div>
)}
{isCurlWebhook && (
<div>
<TextAreaComponent
id={id}
value={value}
editNode={editNode}
handleOnNewValue={handleOnNewValue}
{...baseInputProps}
nodeInformationMetadata={nodeInformationMetadata}
/>
</div>
)}
{showGenerateToken && (
<div>
<SecretKeyModalButton userId={userId} />
</div>
)}
</div>
);
}

View file

@ -18,7 +18,7 @@ import PromptAreaComponent from "./components/promptComponent";
import { RefreshParameterComponent } from "./components/refreshParameterComponent";
import { StrRenderComponent } from "./components/strRenderComponent";
import ToggleShadComponent from "./components/toggleShadComponent";
import { InputProps } from "./types";
import { InputProps, NodeInfoType } from "./types";
export function ParameterRenderComponent({
handleOnNewValue,
@ -32,6 +32,7 @@ export function ParameterRenderComponent({
disabled,
placeholder,
isToolMode,
nodeInformationMetadata,
}: {
handleOnNewValue:
| handleOnNewValueType
@ -46,6 +47,7 @@ export function ParameterRenderComponent({
disabled: boolean;
placeholder?: string;
isToolMode?: boolean;
nodeInformationMetadata?: NodeInfoType;
}) {
const id = (
templateData.type +
@ -67,6 +69,7 @@ export function ParameterRenderComponent({
placeholder,
isToolMode,
nodeId,
nodeInformationMetadata,
};
if (TEXT_FIELD_TYPES.includes(templateData.type ?? "")) {

View file

@ -18,6 +18,7 @@ export type BaseInputProps<valueType = any> = {
tooltip?: string;
metadata?: any;
nodeId?: string;
nodeInformationMetadata?: NodeInfoType;
};
// Generic type for composing input props
@ -101,3 +102,11 @@ export type MultiselectComponentType = {
options: string[];
combobox?: boolean;
};
export type NodeInfoType = {
flowId: string;
nodeType: string;
flowName: string;
isAuth: boolean;
variableName: string;
};

View file

@ -998,6 +998,9 @@ export const STORE_PAGINATION_ROWS_COUNT = [12, 24, 48, 96];
export const GRADIENT_CLASS =
"linear-gradient(to right, hsl(var(--background) / 0.3), hsl(var(--background)))";
export const GRADIENT_CLASS_DISABLED =
"linear-gradient(to right, hsl(var(--muted) / 0.3), hsl(var(--muted)))";
export const RECEIVING_INPUT_VALUE = "Receiving input";
export const ICON_STROKE_WIDTH = 1.5;

View file

@ -0,0 +1,11 @@
export const validateWebhookData = (res) => {
if (!res?.data?.vertex_builds) {
return false;
}
return Object.keys(res?.data?.vertex_builds).some(
(key) =>
key.includes("Webhook") &&
Array.isArray(res?.data?.vertex_builds[key]) &&
res?.data?.vertex_builds[key]?.length > 0,
);
};

View file

@ -0,0 +1,189 @@
import useFlowStore from "@/stores/flowStore";
import { useUtilityStore } from "@/stores/utilityStore";
import { useMutationFunctionType } from "@/types/api";
import { FlowPoolType } from "@/types/zustand/flow";
import { useEffect, useRef } from "react";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface PollingItem {
interval: NodeJS.Timeout;
timestamp: number;
flowId: string;
callback: () => Promise<void>;
}
const PollingManager = {
pollingQueue: new Map<string, PollingItem[]>(),
activePolls: new Map<string, PollingItem>(),
enqueuePolling(flowId: string, pollingItem: PollingItem) {
if (!this.pollingQueue.has(flowId)) {
this.pollingQueue.set(flowId, []);
}
this.pollingQueue.set(
flowId,
(this.pollingQueue.get(flowId) || []).filter(
(item) => item.timestamp !== pollingItem.timestamp,
),
);
this.pollingQueue.get(flowId)?.push(pollingItem);
if (!this.activePolls.has(flowId)) {
this.startNextPolling(flowId);
}
},
startNextPolling(flowId: string) {
const queue = this.pollingQueue.get(flowId) || [];
if (queue.length === 0) {
this.activePolls.delete(flowId);
return;
}
const nextPoll = queue[0];
this.activePolls.set(flowId, nextPoll);
nextPoll.callback();
},
stopPoll(flowId: string) {
const activePoll = this.activePolls.get(flowId);
if (activePoll) {
clearInterval(activePoll.interval);
this.activePolls.delete(flowId);
const queue = this.pollingQueue.get(flowId) || [];
this.pollingQueue.set(
flowId,
queue.filter((item) => item.timestamp !== activePoll.timestamp),
);
this.startNextPolling(flowId);
}
},
stopAll() {
this.activePolls.forEach((poll) => clearInterval(poll.interval));
this.activePolls.clear();
this.pollingQueue.clear();
},
removeFromQueue(flowId: string, timestamp: number) {
const queue = this.pollingQueue.get(flowId) || [];
this.pollingQueue.set(
flowId,
queue.filter((item) => item.timestamp !== timestamp),
);
},
};
interface IGetBuilds {
flowId: string;
onSuccess?: (data: { vertex_builds: FlowPoolType }) => void;
stopPollingOn?: (data: { vertex_builds: FlowPoolType }) => boolean;
}
export const useGetBuildsMutation: useMutationFunctionType<
undefined,
IGetBuilds
> = (options?) => {
const { mutate } = UseRequestProcessor();
const webhookPollingInterval = useUtilityStore(
(state) => state.webhookPollingInterval,
);
const setFlowPool = useFlowStore((state) => state.setFlowPool);
const currentFlow = useFlowStore((state) => state.currentFlow);
const flowIdRef = useRef<string | null>(null);
const requestInProgressRef = useRef<Record<string, boolean>>({});
const getBuildsFn = async (
payload: IGetBuilds,
): Promise<{ vertex_builds: FlowPoolType }> => {
if (requestInProgressRef.current[payload.flowId]) {
return Promise.reject("Request already in progress");
}
try {
requestInProgressRef.current[payload.flowId] = true;
const config = {};
config["params"] = { flow_id: payload.flowId };
const res = await api.get<any>(`${getURL("BUILDS")}`, config);
if (currentFlow) {
const flowPool = res?.data?.vertex_builds;
setFlowPool(flowPool);
}
return res.data;
} finally {
requestInProgressRef.current[payload.flowId] = false;
}
};
const startPolling = (payload: IGetBuilds) => {
if (requestInProgressRef.current[payload.flowId]) {
return Promise.reject("Request already in progress");
}
if (!webhookPollingInterval || webhookPollingInterval === 0) {
return getBuildsFn(payload);
}
if (
flowIdRef.current === payload.flowId &&
PollingManager.activePolls.has(payload.flowId)
) {
return Promise.resolve({ vertex_builds: {} as FlowPoolType });
}
flowIdRef.current = payload.flowId;
const timestamp = Date.now();
const pollCallback = async () => {
const data = await getBuildsFn(payload);
payload.onSuccess?.(data);
if (payload.stopPollingOn?.(data)) {
PollingManager.stopPoll(payload.flowId);
}
};
const intervalId = setInterval(pollCallback, webhookPollingInterval);
const pollingItem: PollingItem = {
interval: intervalId,
timestamp,
flowId: payload.flowId,
callback: pollCallback,
};
PollingManager.enqueuePolling(payload.flowId, pollingItem);
return getBuildsFn(payload).then((data) => {
payload.onSuccess?.(data);
if (payload.stopPollingOn?.(data)) {
PollingManager.stopPoll(payload.flowId);
}
});
};
useEffect(() => {
return () => {
if (flowIdRef.current) {
PollingManager.stopPoll(flowIdRef.current);
}
};
}, []);
const mutation = mutate(
["useGetBuildsMutation"],
(payload: IGetBuilds) =>
startPolling(payload) ?? Promise.reject("Failed to start polling"),
options,
);
return mutation;
};
export { PollingManager };

View file

@ -26,6 +26,7 @@ export const useGetApiKeysQuery: useQueryFunctionType<
> = (options) => {
const { query } = UseRequestProcessor();
//@TODO: Request API key from DSLF endpoint
const getApiKeysFn = async () => {
return await api.get<IApiQueryResponse>(`${getURL("API_KEY")}/`);
};

View file

@ -14,6 +14,7 @@ export interface ConfigResponse {
health_check_max_retries: number;
max_file_size_upload: number;
feature_flags: Record<string, any>;
webhook_polling_interval: number;
event_delivery: EventDeliveryType;
}
@ -31,6 +32,9 @@ export const useGetConfig: useQueryFunctionType<undefined, ConfigResponse> = (
(state) => state.setMaxFileSizeUpload,
);
const setFeatureFlags = useUtilityStore((state) => state.setFeatureFlags);
const setWebhookPollingInterval = useUtilityStore(
(state) => state.setWebhookPollingInterval,
);
const { query } = UseRequestProcessor();
@ -48,6 +52,7 @@ export const useGetConfig: useQueryFunctionType<undefined, ConfigResponse> = (
setHealthCheckMaxRetries(data.health_check_max_retries);
setMaxFileSizeUpload(data.max_file_size_upload);
setFeatureFlags(data.feature_flags);
setWebhookPollingInterval(data.webhook_polling_interval);
}
return data;
};

View file

@ -1,4 +1,5 @@
import { ParameterRenderComponent } from "@/components/core/parameterRenderComponent";
import { NodeInfoType } from "@/components/core/parameterRenderComponent/types";
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APIClassType, InputFieldType } from "@/types/api";
import { cn } from "@/utils/utils";
@ -15,6 +16,7 @@ export function CustomParameterComponent({
disabled,
placeholder,
isToolMode,
nodeInformationMetadata,
}: {
handleOnNewValue: handleOnNewValueType;
name: string;
@ -27,6 +29,7 @@ export function CustomParameterComponent({
disabled: boolean;
placeholder?: string;
isToolMode?: boolean;
nodeInformationMetadata?: NodeInfoType;
}) {
return (
<ParameterRenderComponent
@ -41,6 +44,7 @@ export function CustomParameterComponent({
disabled={disabled}
placeholder={placeholder}
isToolMode={isToolMode}
nodeInformationMetadata={nodeInformationMetadata}
/>
);
}

View file

@ -0,0 +1,13 @@
import { getModalPropsApiKey } from "@/pages/SettingsPage/pages/ApiKeysPage/helpers/get-modal-props";
export const SecretKeyModalButton = ({
userId,
}: {
userId: string;
}): JSX.Element => {
const modalProps = getModalPropsApiKey();
return <></>;
};
export default SecretKeyModalButton;

View file

@ -0,0 +1 @@
export const getModalPropsApiKey = () => {};

View file

@ -51,14 +51,20 @@ export function getCurlWebhookCode({
flowId,
isAuth,
endpointName,
}: GetCodeType) {
format = "multiline",
}: GetCodeType & { format?: "multiline" | "singleline" }) {
const baseUrl = `${window.location.protocol}//${window.location.host}/api/v1/webhook/${endpointName || flowId}`;
const authHeader = !isAuth ? `-H 'x-api-key: <your api key>'` : "";
if (format === "singleline") {
return `curl -X POST "${baseUrl}" -H 'Content-Type: application/json' ${authHeader} -d '{"any": "data"}'`.trim();
}
return `curl -X POST \\
"${window.location.protocol}//${window.location.host}/api/v1/webhook/${
endpointName || flowId
}" \\
"${baseUrl}" \\
-H 'Content-Type: application/json'\\${
!isAuth ? `\n -H 'x-api-key: <your api key>'\\` : ""
}
-d '{"any": "data"}'
`;
`.trim();
}

View file

@ -74,13 +74,20 @@ const Trigger: React.FC<TriggerProps> = ({
const Header: React.FC<{
children: ReactNode;
description: string | JSX.Element | null;
}> = ({ children, description }: modalHeaderType): JSX.Element => {
clampDescription?: number;
}> = ({
children,
description,
clampDescription,
}: modalHeaderType): JSX.Element => {
return (
<DialogHeader>
<DialogTitle className="line-clamp-1 flex items-center pb-0.5 text-base">
{children}
</DialogTitle>
<DialogDescription className="line-clamp-2 text-sm">
<DialogDescription
className={`line-clamp-${clampDescription ?? 2} text-sm`}
>
{description}
</DialogDescription>
</DialogHeader>

View file

@ -0,0 +1,55 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import IconComponent from "../../../components/common/genericIconComponent";
export const ContentRenderKey = ({
inputLabel,
inputRef,
apiKeyValue,
handleCopyClick,
textCopied,
renderKey,
}: {
inputLabel: string;
inputRef: React.RefObject<HTMLInputElement>;
apiKeyValue: string;
handleCopyClick: () => void;
textCopied: boolean;
renderKey: boolean;
}) => {
return (
<>
<div className="flex items-center gap-3">
<div className="w-full">
{inputLabel && !renderKey && (
<div className="relative bottom-1">
{inputLabel as React.ReactNode}
</div>
)}
<Input
data-testid="api-key-input"
ref={inputRef}
readOnly={true}
value={apiKeyValue}
/>
</div>
<Button
onClick={(e) => {
handleCopyClick();
e.stopPropagation();
}}
data-testid="btn-copy-api-key"
unstyled
>
{textCopied ? (
<IconComponent name="Copy" className="h-4 w-4" />
) : (
<IconComponent name="Check" className="h-4 w-4" />
)}
</Button>
</div>
</>
);
};

View file

@ -0,0 +1,41 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import * as Form from "@radix-ui/react-form";
export const FormKeyRender = ({
modalProps,
apiKeyName,
inputRef,
setApiKeyName,
}: {
modalProps: any;
apiKeyName: string;
inputRef: React.RefObject<HTMLInputElement>;
setApiKeyName: (value: string) => void;
}) => {
return (
<Form.Field name="apikey">
{modalProps?.inputLabel && (
<Form.Label asChild className="mb-2">
<Label className="relative bottom-1">
{modalProps?.inputLabel as React.ReactNode}
</Label>
</Form.Label>
)}
<div className="flex items-center justify-between gap-2">
<Form.Control asChild>
<Input
id="primary-input"
value={apiKeyName}
ref={inputRef}
onChange={({ target: { value } }) => {
setApiKeyName(value);
}}
placeholder={modalProps?.inputPlaceholder}
/>
</Form.Control>
</div>
</Form.Field>
);
};

View file

@ -0,0 +1,16 @@
import IconComponent from "../../../components/common/genericIconComponent";
export const HeaderRender = ({ title, showIcon }) => {
return (
<>
<span className="pr-2">{title}</span>
{showIcon && (
<IconComponent
name="Key"
className="h-6 w-6 pl-1 text-foreground"
aria-hidden="true"
/>
)}
</>
);
};

View file

@ -1,19 +1,27 @@
import * as Form from "@radix-ui/react-form";
import { Label } from "@radix-ui/react-form";
import { useEffect, useRef, useState } from "react";
import IconComponent from "../../components/common/genericIconComponent";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { COPIED_NOTICE_ALERT } from "../../constants/alerts_constants";
import { createApiKey } from "../../controllers/API";
import useAlertStore from "../../stores/alertStore";
import { ApiKeyType } from "../../types/components";
import BaseModal from "../baseModal";
import { ContentRenderKey } from "./components/content-render";
import { FormKeyRender } from "./components/form-key-render";
import { HeaderRender } from "./components/header-render";
interface ModalProps {
generatedKeyMessage?: React.ReactNode;
description?: React.ReactNode;
}
export default function SecretKeyModal({
children,
data,
onCloseModal,
}: ApiKeyType) {
modalProps,
}: ApiKeyType & { modalProps?: ModalProps }) {
const [open, setOpen] = useState(false);
const [apiKeyName, setApiKeyName] = useState(data?.apikeyname ?? "");
const [apiKeyValue, setApiKeyValue] = useState("");
@ -27,7 +35,7 @@ export default function SecretKeyModal({
setRenderKey(false);
resetForm();
} else {
onCloseModal();
onCloseModal?.();
}
}, [open]);
@ -72,78 +80,47 @@ export default function SecretKeyModal({
return (
<BaseModal
onSubmit={handleSubmitForm}
size="small-h-full"
size={modalProps?.size ?? "small-h-full"}
open={open}
setOpen={setOpen}
>
<BaseModal.Trigger asChild>{children}</BaseModal.Trigger>
<BaseModal.Header
clampDescription={3}
description={
renderKey ? (
<>
{" "}
Please save this secret key somewhere safe and accessible. For
security reasons,{" "}
<strong>you won't be able to view it again</strong> through your
account. If you lose this secret key, you'll need to generate a
new one.
</>
<>{modalProps?.generatedKeyMessage}</>
) : (
<>Create a secret API Key to use Langflow API.</>
<>{modalProps?.description}</>
)
}
>
<span className="pr-2">Create API Key</span>
<IconComponent
name="Key"
className="h-6 w-6 pl-1 text-foreground"
aria-hidden="true"
<HeaderRender
title={modalProps?.title}
showIcon={modalProps?.showIcon}
/>
</BaseModal.Header>
<BaseModal.Content>
{renderKey ? (
<>
<div className="flex items-center gap-3">
<div className="w-full">
<Input ref={inputRef} readOnly={true} value={apiKeyValue} />
</div>
<Button
onClick={() => {
handleCopyClick();
}}
data-testid="btn-copy-api-key"
unstyled
>
{textCopied ? (
<IconComponent name="Copy" className="h-4 w-4" />
) : (
<IconComponent name="Check" className="h-4 w-4" />
)}
</Button>
</div>
</>
<ContentRenderKey
inputLabel={String(modalProps?.inputLabel ?? "")}
inputRef={inputRef}
apiKeyValue={apiKeyValue}
handleCopyClick={handleCopyClick}
textCopied={textCopied}
renderKey={renderKey}
/>
) : (
<Form.Field name="apikey">
<div className="flex items-center justify-between gap-2">
<Form.Control asChild>
<Input
//fake api key
id="primary-input"
value={apiKeyName}
ref={inputRef}
onChange={({ target: { value } }) => {
setApiKeyName(value);
}}
placeholder="Insert a name for your API Key"
/>
</Form.Control>
</div>
</Form.Field>
<FormKeyRender
modalProps={modalProps}
apiKeyName={apiKeyName}
inputRef={inputRef}
setApiKeyName={setApiKeyName}
/>
)}
</BaseModal.Content>
<BaseModal.Footer
submit={{ label: renderKey ? "Done" : "Create Secret Key" }}
submit={{ label: renderKey ? "Done" : (modalProps?.buttonText ?? "") }}
/>
</BaseModal>
);

View file

@ -19,6 +19,7 @@ export default function ComponentTextModal({
readonly = false,
password,
changeVisibility,
onCloseModal,
}: textModalPropsType): JSX.Element {
const [modalOpen, setModalOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
@ -28,6 +29,12 @@ export default function ComponentTextModal({
if (typeof value === "string") setInputValue(value);
}, [value, modalOpen]);
useEffect(() => {
if (!modalOpen) {
onCloseModal?.();
}
}, [modalOpen]);
return (
<BaseModal
onChangeOpenModal={(open) => {}}

View file

@ -6,6 +6,7 @@ import {
} from "@/components/ui/disclosure";
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
import { memo } from "react";
import { BundleItemProps } from "../../types";
import SidebarItemsList from "../sidebarItemsList";
export const BundleItem = memo(
@ -15,21 +16,11 @@ export const BundleItem = memo(
onOpenChange,
dataFilter,
nodeColors,
chatInputAdded,
uniqueInputsComponents,
onDragStart,
sensitiveSort,
handleKeyDownInput,
}: {
item: any;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
dataFilter: any;
nodeColors: any;
chatInputAdded: any;
onDragStart: any;
sensitiveSort: any;
handleKeyDownInput: any;
}) => {
}: BundleItemProps) => {
if (
!dataFilter[item.name] ||
Object.keys(dataFilter[item.name]).length === 0
@ -68,7 +59,7 @@ export const BundleItem = memo(
item={item}
dataFilter={dataFilter}
nodeColors={nodeColors}
chatInputAdded={chatInputAdded}
uniqueInputsComponents={uniqueInputsComponents}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
/>

View file

@ -15,7 +15,7 @@ export const CategoryDisclosure = memo(function CategoryDisclosure({
setOpenCategories,
dataFilter,
nodeColors,
chatInputAdded,
uniqueInputsComponents,
onDragStart,
sensitiveSort,
}: {
@ -24,7 +24,10 @@ export const CategoryDisclosure = memo(function CategoryDisclosure({
setOpenCategories;
dataFilter: any;
nodeColors: any;
chatInputAdded: boolean;
uniqueInputsComponents: {
chatInput: boolean;
webhookInput: boolean;
};
onDragStart: (
event: React.DragEvent<any>,
data: { type: string; node?: APIClassType },
@ -84,7 +87,7 @@ export const CategoryDisclosure = memo(function CategoryDisclosure({
item={item}
dataFilter={dataFilter}
nodeColors={nodeColors}
chatInputAdded={chatInputAdded}
uniqueInputsComponents={uniqueInputsComponents}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
/>

View file

@ -16,7 +16,7 @@ export const CategoryGroup = memo(function CategoryGroup({
setOpenCategories,
search,
nodeColors,
chatInputAdded,
uniqueInputsComponents,
onDragStart,
sensitiveSort,
}: CategoryGroupProps) {
@ -65,9 +65,9 @@ export const CategoryGroup = memo(function CategoryGroup({
setOpenCategories={setOpenCategories}
dataFilter={dataFilter}
nodeColors={nodeColors}
chatInputAdded={chatInputAdded}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
uniqueInputsComponents={uniqueInputsComponents}
/>
);
})}

View file

@ -5,6 +5,7 @@ import {
SidebarMenu,
} from "@/components/ui/sidebar";
import { memo, useMemo } from "react";
import { SidebarGroupProps } from "../../types";
import { BundleItem } from "../bundleItems";
export const MemoizedSidebarGroup = memo(
@ -14,26 +15,13 @@ export const MemoizedSidebarGroup = memo(
sortedCategories,
dataFilter,
nodeColors,
chatInputAdded,
onDragStart,
sensitiveSort,
openCategories,
setOpenCategories,
handleKeyDownInput,
}: {
BUNDLES: any;
search: any;
sortedCategories: any;
dataFilter: any;
nodeColors: any;
chatInputAdded: any;
onDragStart: any;
sensitiveSort: any;
openCategories: any;
setOpenCategories: any;
handleKeyDownInput: any;
}) => {
// Memoize the sorted bundles calculation
uniqueInputsComponents,
}: SidebarGroupProps) => {
const sortedBundles = useMemo(() => {
return BUNDLES.toSorted((a, b) => {
const referenceArray = search !== "" ? sortedCategories : BUNDLES;
@ -63,7 +51,7 @@ export const MemoizedSidebarGroup = memo(
}}
dataFilter={dataFilter}
nodeColors={nodeColors}
chatInputAdded={chatInputAdded}
uniqueInputsComponents={uniqueInputsComponents}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
handleKeyDownInput={handleKeyDownInput}

View file

@ -123,6 +123,7 @@ export const SidebarDraggableComponent = forwardRef(
tabIndex={0}
onKeyDown={handleKeyDown}
className="m-[1px] rounded-md outline-none ring-ring focus-visible:ring-1"
data-testid={`${sectionName.toLowerCase()}_${display_name.toLowerCase()}_draggable`}
>
<div
data-testid={sectionName + display_name}

View file

@ -1,12 +1,14 @@
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { removeCountFromString } from "@/utils/utils";
import { disableItem } from "../../helpers/disable-item";
import { getDisabledTooltip } from "../../helpers/get-disabled-tooltip";
import SidebarDraggableComponent from "../sidebarDraggableComponent";
const SidebarItemsList = ({
item,
dataFilter,
nodeColors,
chatInputAdded,
uniqueInputsComponents,
onDragStart,
sensitiveSort,
}) => {
@ -46,8 +48,11 @@ const SidebarItemsList = ({
official={currentItem.official === false ? false : true}
beta={currentItem.beta ?? false}
legacy={currentItem.legacy ?? false}
disabled={SBItemName === "ChatInput" && chatInputAdded}
disabledTooltip="Chat input already added"
disabled={disableItem(SBItemName, uniqueInputsComponents)}
disabledTooltip={getDisabledTooltip(
SBItemName,
uniqueInputsComponents,
)}
/>
</ShadTooltip>
);

View file

@ -0,0 +1,14 @@
import { UniqueInputsComponents } from "../types";
export const disableItem = (
SBItemName: string,
uniqueInputsComponents: UniqueInputsComponents,
) => {
if (SBItemName === "ChatInput" && uniqueInputsComponents.chatInput) {
return true;
}
if (SBItemName === "Webhook" && uniqueInputsComponents.webhookInput) {
return true;
}
return false;
};

View file

@ -0,0 +1,14 @@
import { UniqueInputsComponents } from "../types";
export const getDisabledTooltip = (
SBItemName: string,
uniqueInputsComponents: UniqueInputsComponents,
) => {
if (SBItemName === "ChatInput" && uniqueInputsComponents.chatInput) {
return "Chat input already added";
}
if (SBItemName === "Webhook" && uniqueInputsComponents.webhookInput) {
return "Webhook already added";
}
return "";
};

View file

@ -8,7 +8,7 @@ import SkeletonGroup from "@/components/ui/skeletonGroup";
import { useAddComponent } from "@/hooks/useAddComponent";
import { useShortcutsStore } from "@/stores/shortcuts";
import { useStoreStore } from "@/stores/storeStore";
import { checkChatInput } from "@/utils/reactflowUtils";
import { checkChatInput, checkWebhookInput } from "@/utils/reactflowUtils";
import {
nodeColors,
SIDEBAR_BUNDLES,
@ -36,16 +36,18 @@ import { combinedResultsFn } from "./helpers/combined-results";
import { filteredDataFn } from "./helpers/filtered-data";
import { normalizeString } from "./helpers/normalize-string";
import { traditionalSearchMetadata } from "./helpers/traditional-search-metadata";
import { UniqueInputsComponents } from "./types";
const CATEGORIES = SIDEBAR_CATEGORIES;
const BUNDLES = SIDEBAR_BUNDLES;
interface FlowSidebarComponentProps {
isLoading?: boolean;
showLegacy: boolean;
setShowLegacy: (value: boolean) => void;
}
export function FlowSidebarComponent({ isLoading }: { isLoading?: boolean }) {
export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
const { data, templates } = useTypesStore(
useCallback(
(state) => ({
@ -86,6 +88,13 @@ export function FlowSidebarComponent({ isLoading }: { isLoading?: boolean }) {
const searchInputRef = useRef<HTMLInputElement | null>(null);
const chatInputAdded = useMemo(() => checkChatInput(nodes), [nodes]);
const webhookInputAdded = useMemo(() => checkWebhookInput(nodes), [nodes]);
const uniqueInputsComponents: UniqueInputsComponents = useMemo(() => {
return {
chatInput: chatInputAdded,
webhookInput: webhookInputAdded,
};
}, [chatInputAdded, webhookInputAdded]);
const customComponent = useMemo(() => {
return data?.["custom_component"]?.["CustomComponent"] ?? null;
@ -347,9 +356,9 @@ export function FlowSidebarComponent({ isLoading }: { isLoading?: boolean }) {
setOpenCategories={setOpenCategories}
search={search}
nodeColors={nodeColors}
chatInputAdded={chatInputAdded}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
uniqueInputsComponents={uniqueInputsComponents}
/>
{hasBundleItems && (
@ -359,12 +368,12 @@ export function FlowSidebarComponent({ isLoading }: { isLoading?: boolean }) {
sortedCategories={sortedCategories}
dataFilter={dataFilter}
nodeColors={nodeColors}
chatInputAdded={chatInputAdded}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
openCategories={openCategories}
setOpenCategories={setOpenCategories}
handleKeyDownInput={handleKeyDownInput}
uniqueInputsComponents={uniqueInputsComponents}
/>
)}
</>

View file

@ -1,6 +1,10 @@
import { APIClassType, APIDataType } from "@/types/api";
import { Dispatch, SetStateAction } from "react";
export interface NodeColors {
[key: string]: string;
}
export interface CategoryGroupProps {
dataFilter: APIDataType;
sortedCategories: string[];
@ -12,15 +16,57 @@ export interface CategoryGroupProps {
openCategories: string[];
setOpenCategories: (categories: string[]) => void;
search: string;
nodeColors: {
[key: string]: string;
};
chatInputAdded: boolean;
nodeColors: NodeColors;
onDragStart: (
event: React.DragEvent<any>,
data: { type: string; node?: APIClassType },
) => void;
sensitiveSort: (a: string, b: string) => number;
uniqueInputsComponents: {
chatInput: boolean;
webhookInput: boolean;
};
}
export interface SidebarGroupProps {
BUNDLES: any;
search: string;
sortedCategories: string[];
dataFilter: APIDataType;
nodeColors: NodeColors;
onDragStart: (
event: React.DragEvent<HTMLDivElement>,
data: { type: string; node?: APIClassType },
) => void;
sensitiveSort: (a: string, b: string) => number;
openCategories: string[];
setOpenCategories: (
categories: string[] | ((prev: string[]) => string[]),
) => void;
handleKeyDownInput: (
event: React.KeyboardEvent<HTMLDivElement>,
name: string,
) => void;
uniqueInputsComponents: UniqueInputsComponents;
}
export interface BundleItemProps {
item: {
name: string;
display_name: string;
icon: string;
};
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
dataFilter: APIDataType;
nodeColors: NodeColors;
uniqueInputsComponents: UniqueInputsComponents;
onDragStart: (
event: React.DragEvent<any>,
data: { type: string; node?: APIClassType },
) => void;
sensitiveSort: (a: string, b: string) => number;
handleKeyDownInput: (event: React.KeyboardEvent<any>, name: string) => void;
}
export interface SidebarHeaderComponentProps {
@ -50,3 +96,8 @@ export interface SidebarHeaderComponentProps {
setFilterData: Dispatch<SetStateAction<APIDataType>>;
data: APIDataType;
}
export interface UniqueInputsComponents {
chatInput: boolean;
webhookInput: boolean;
}

View file

@ -2,6 +2,7 @@ import ForwardedIconComponent from "../../../../../../components/common/genericI
import { Button } from "../../../../../../components/ui/button";
import { API_PAGE_PARAGRAPH } from "../../../../../../constants/constants";
import SecretKeyModal from "../../../../../../modals/secretKeyModal";
import { getModalPropsApiKey } from "../../helpers/get-modal-props";
type ApiKeyHeaderComponentProps = {
selectedRows: string[];
@ -13,6 +14,7 @@ const ApiKeyHeaderComponent = ({
fetchApiKeys,
userId,
}: ApiKeyHeaderComponentProps) => {
const modalProps = getModalPropsApiKey();
return (
<>
<div className="flex w-full items-start justify-between gap-6">
@ -27,7 +29,11 @@ const ApiKeyHeaderComponent = ({
<p className="text-sm text-muted-foreground">{API_PAGE_PARAGRAPH}</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<SecretKeyModal data={userId} onCloseModal={fetchApiKeys}>
<SecretKeyModal
modalProps={modalProps}
data={userId}
onCloseModal={fetchApiKeys}
>
<Button data-testid="api-key-button-store" variant="primary">
<ForwardedIconComponent name="Plus" className="w-4" />
Add New

View file

@ -0,0 +1,26 @@
export const getModalPropsApiKey = () => {
const modalProps = {
title: "Create API Key",
description: "Create a secret API Key to use Langflow API.",
inputPlaceholder: "My API Key",
buttonText: "Generate API Key",
generatedKeyMessage: (
<>
{" "}
Please save this secret key somewhere safe and accessible. For security
reasons, <strong>you won't be able to view it again</strong> through
your account. If you lose this secret key, you'll need to generate a new
one.
</>
),
showIcon: true,
inputLabel: (
<>
<span className="text-sm">Description</span>{" "}
<span className="text-xs text-muted-foreground">(optional)</span>
</>
),
};
return modalProps;
};

View file

@ -18,7 +18,6 @@ import ApiKeyHeaderComponent from "./components/ApiKeyHeader";
import { getColumnDefs } from "./helpers/column-defs";
export default function ApiKeysPage() {
const [loadingKeys, setLoadingKeys] = useState(true);
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const setErrorData = useAlertStore((state) => state.setErrorData);

View file

@ -35,4 +35,7 @@ export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
setTags: (tags: Tag[]) => set({ tags }),
featureFlags: {},
setFeatureFlags: (featureFlags: Record<string, any>) => set({ featureFlags }),
webhookPollingInterval: 5000,
setWebhookPollingInterval: (webhookPollingInterval: number) =>
set({ webhookPollingInterval }),
}));

View file

@ -174,7 +174,7 @@
@apply flex gap-2;
}
.primary-input {
@apply form-input block w-full truncate rounded-md border border-border bg-background px-3 text-left text-sm placeholder:text-muted-foreground hover:border-muted-foreground focus:border-foreground focus:placeholder-transparent focus:ring-0 focus:ring-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted disabled:opacity-100 placeholder:disabled:text-muted-foreground;
@apply form-input block w-full truncate rounded-md border border-border bg-background px-3 text-left text-sm placeholder:text-muted-foreground hover:border-muted-foreground focus:border-foreground focus:placeholder-transparent focus:ring-0 focus:ring-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100 placeholder:disabled:text-muted-foreground;
}
.skeleton-card {

View file

@ -52,6 +52,7 @@ export type APIClassType = {
flow?: FlowType;
field_order?: string[];
tool_mode?: boolean;
type?: string;
[key: string]:
| Array<string>
| string

View file

@ -421,7 +421,29 @@ export type UserInputType = {
export type ApiKeyType = {
children: ReactElement;
data?: any;
onCloseModal: () => void;
onCloseModal?: () => void;
modalProps?: {
title?: string;
description?: string | ReactElement | HTMLElement;
inputLabel?: string | ReactElement | HTMLElement | ReactNode;
inputPlaceholder?: string;
buttonText?: string;
generatedKeyMessage?: string | ReactElement | HTMLElement;
showIcon?: boolean;
size?:
| "x-small"
| "smaller"
| "small"
| "medium"
| "medium-tall"
| "large"
| "three-cards"
| "large-thin"
| "large-h-full"
| "templates"
| "small-h-full"
| "medium-h-full";
};
};
export type StoreApiKeyType = {
@ -574,6 +596,7 @@ export type iconsType = {
export type modalHeaderType = {
children: ReactNode;
description: string | JSX.Element | null;
clampDescription?: number;
};
export type codeAreaModalPropsType = {
@ -640,6 +663,7 @@ export type textModalPropsType = {
changeVisibility?: () => void;
open?: boolean;
setOpen?: (open: boolean) => void;
onCloseModal?: () => void;
};
export type newFlowModalPropsType = {

View file

@ -11,7 +11,7 @@ export type GetCodeType = {
flowId: string;
flowName: string;
isAuth: boolean;
tweaksBuildedObject: {};
tweaksBuildedObject?: {};
endpointName?: string | null;
activeTweaks: boolean;
activeTweaks?: boolean;
};

View file

@ -15,6 +15,8 @@ export type UtilityStoreType = {
setTags: (tags: Tag[]) => void;
featureFlags: Record<string, any>;
setFeatureFlags: (featureFlags: Record<string, any>) => void;
webhookPollingInterval: number;
setWebhookPollingInterval: (webhookPollingInterval: number) => void;
chatValueStore: string;
setChatValueStore: (value: string) => void;
dismissAll: boolean;

View file

@ -54,6 +54,10 @@ export function checkChatInput(nodes: Node[]) {
return nodes.some((node) => node.data.type === "ChatInput");
}
export function checkWebhookInput(nodes: Node[]) {
return nodes.some((node) => node.data.type === "Webhook");
}
export function cleanEdges(nodes: AllNodeType[], edges: EdgeType[]) {
let newEdges: EdgeType[] = cloneDeep(
edges.map((edge) => ({ ...edge, selected: false, animated: false })),

View file

@ -0,0 +1,127 @@
import { expect, test } from "@playwright/test";
import { adjustScreenView } from "../../utils/adjust-screen-view";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
test(
"user should be able to create an api key within a webhook component",
{ tag: ["@release", "@workspace"] },
async ({ page }) => {
const randomApiKeyDescription =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("webhook");
await page.waitForSelector('[data-testid="dataWebhook"]', {
timeout: 3000,
});
await page
.getByTestId("dataWebhook")
.hover()
.then(async () => {
await page.getByTestId("add-component-button-webhook").click();
});
await adjustScreenView(page);
await page
.getByTestId("data_webhook_draggable")
.hover()
.then(async () => {
await page.waitForSelector("text=Webhook already added", {
timeout: 30000,
});
});
await page.getByTestId("btn_copy_str_endpoint").click();
await page.waitForSelector("text=Endpoint URL copied", { timeout: 30000 });
await page.getByTestId("title-Webhook").click();
await page.getByTestId("edit-button-modal").click();
await page
.getByTestId("button_open_text_area_modal_str_edit_curl_advanced")
.click();
const curl = await page.getByTestId("text-area-modal").inputValue();
const currentUrl = page.url();
const flowId = currentUrl.split("/")[2];
expect(curl).toContain(flowId);
},
);
test(
"user should be able to poll a webhook",
{ tag: ["@release", "@workspace"] },
async ({ page, request }) => {
await page.route("**/api/v1/config", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
webhook_polling_interval: 1000,
}),
headers: {
"content-type": "application/json",
...route.request().headers(),
},
});
});
const randomApiKeyDescription =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("webhook");
await page.waitForSelector('[data-testid="dataWebhook"]', {
timeout: 3000,
});
await page
.getByTestId("dataWebhook")
.hover()
.then(async () => {
await page.getByTestId("add-component-button-webhook").click();
});
await adjustScreenView(page);
await page
.getByTestId("data_webhook_draggable")
.hover()
.then(async () => {
await page.waitForSelector("text=Webhook already added", {
timeout: 30000,
});
});
await page.getByTestId("btn_copy_str_endpoint").click();
await page.waitForSelector("text=Endpoint URL copied", { timeout: 30000 });
const monitorBuildPromise = page.waitForRequest((request) =>
request.url().includes("/monitor/build"),
);
const monitorBuildRequest = await monitorBuildPromise;
expect(monitorBuildRequest).toBeTruthy();
},
);

View file

@ -165,14 +165,12 @@ test(
await page.getByText("Langflow API").first().click();
await page.getByText("Langflow API", { exact: true }).nth(1).isVisible();
await page.getByText("Add New").click();
await page.getByPlaceholder("Insert a name for your API Key").isVisible();
await page.getByPlaceholder("My API Key").isVisible();
const randomName = Math.random().toString(36).substring(2);
await page
.getByPlaceholder("Insert a name for your API Key")
.fill(randomName);
await page.getByText("Create Secret Key", { exact: true }).click();
await page.getByPlaceholder("My API Key").fill(randomName);
await page.getByText("Generate API Key", { exact: true }).click();
// Wait for api key creation to complete and render the next form element
await page.waitForTimeout(1000);