diff --git a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/WebhookSettings/CurlImportView.tsx b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/WebhookSettings/CurlImportView.tsx new file mode 100644 index 0000000000..a94cdad333 --- /dev/null +++ b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/WebhookSettings/CurlImportView.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react' +import { + Box, + Button, + HStack, + Stack, + Textarea as ChakraTextarea, + Tooltip, + Text, +} from '@chakra-ui/react' +import { OutlineInformationIcon } from 'assets/icons' +import { parseCurlCommand, ParsedCurl } from 'services/curlParser' + +type Props = { + onImport: (parsed: ParsedCurl) => void +} + +export const CurlImportView = ({ onImport }: Props) => { + const [curlInput, setCurlInput] = useState('') + const [curlError, setCurlError] = useState('') + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + setCurlInput(value) + + const trimmed = value.trim() + if (!trimmed) { + setCurlError('') + return + } + + const parsed = parseCurlCommand(trimmed) + if (!parsed) { + setCurlError( + 'Esse não parece ser um comando cURL válido. Confira e tente novamente.' + ) + } else { + setCurlError('') + } + } + + const handleImport = () => { + const parsed = parseCurlCommand(curlInput) + if (!parsed) return + onImport(parsed) + } + + const isDisabled = !curlInput.trim() || !!curlError + + return ( + + + + Importar cURL + + + Cole aqui o comando cURL completo para preencher automaticamente os + campos de conexão (URL, método, headers, parâmetros). + + + + + + Comando cURL + + + + + + + + + {curlError && ( + + {curlError} + + )} + + + + ) +} diff --git a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/WebhookSettings/WebhookSettings.tsx b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/WebhookSettings/WebhookSettings.tsx index 74fc42638b..b89060c1bd 100644 --- a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/WebhookSettings/WebhookSettings.tsx +++ b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/WebhookSettings/WebhookSettings.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react' +import React, { useContext, useEffect, useMemo, useState } from 'react' import { Accordion, AccordionButton, @@ -37,6 +37,9 @@ import { Input, Textarea } from 'components/shared/Textbox' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import * as z from 'zod' +import { CurlImportView } from './CurlImportView' +import type { ParsedCurl } from 'services/curlParser' +import { StepNodeContext } from '../../../StepNode/StepNode' type Props = { step: WebhookStep @@ -75,6 +78,22 @@ export const WebhookSettings = React.memo(function WebhookSettings({ const [variablesKeyDown, setVariablesKeyDown] = useState() const [accordionIndex, setAccordionIndex] = useState([0, 1, 2, 3, 4, 5]) + const { registerBeforeClose } = useContext(StepNodeContext) + const [showCurlImport, setShowCurlImport] = useState(false) + + useEffect(() => { + if (!registerBeforeClose) return + if (showCurlImport) { + registerBeforeClose(() => { + setShowCurlImport(false) + return true + }) + } else { + registerBeforeClose(null) + } + return () => registerBeforeClose(null) + }, [showCurlImport, registerBeforeClose]) + const schema = z.object({ url: z.string().url({ message: 'url inválida' }), pathPortion: z.string().min(1, { message: 'Campo obrigatório' }), @@ -364,6 +383,32 @@ export const WebhookSettings = React.memo(function WebhookSettings({ const handleBodyFormStateChange = (isCustomBody: boolean) => onOptionsChange({ ...step.options, isCustomBody }) + const handleCurlImport = (parsed: ParsedCurl) => { + setValue('url', parsed.url) + setValue('pathPortion', parsed.path || '/') + trigger('url') + trigger('pathPortion') + + onOptionsChange({ + ...step.options, + method: parsed.method, + url: parsed.url, + path: parsed.path || '/', + headers: parsed.headers, + parameters: parsed.parameters, + body: parsed.body, + isCustomBody: parsed.body !== '{}', + variablesForTest: [], + responseVariableMapping: [], + }) + + clearOptions() + setShowCurlImport(false) + successToast({ + title: 'cURL importado com sucesso!', + }) + } + const resolveSession = ( variablesForTest: VariableForTest[], variables: Variable[] @@ -407,48 +452,55 @@ export const WebhookSettings = React.memo(function WebhookSettings({ if (!typebot || !step.options || !!Object.keys(errors).length) return setIsTestResponseLoading(true) - const options = step.options as WebhookOptions - const parameters = step.options.parameters.concat(options.headers) + try { + const options = step.options as WebhookOptions + const parameters = step.options.parameters.concat(options.headers) - const localWebhook = { - method: options.method, - body: options.body, - path: options.path, - parameters: parameters, - url: options.url, - } + const localWebhook = { + method: options.method, + body: options.body, + path: options.path, + parameters: parameters, + url: options.url, + } - const session = resolveSession(options.variablesForTest, typebot.variables) + const session = resolveSession(options.variablesForTest, typebot.variables) - const { data } = await sendOctaRequest({ - url: `validate/webhook`, - method: 'POST', - body: { - session, - webhook: localWebhook, - }, - }) + const { data } = await sendOctaRequest({ + url: `validate/webhook`, + method: 'POST', + body: { + session, + webhook: localWebhook, + }, + }) - const { response, success } = data + const { response, success } = data + + setSuccessTest(success) + setResponseData(data) + if (!success) { + errorToast({ + title: 'Não foi possível executar sua integração.', + }) + } else { + successToast({ + title: 'Sua integração está funcionando!', + }) + } - setIsTestResponseLoading(false) - setSuccessTest(success) - setResponseData(data) - if (!success) { + if (typeof response === 'object') { + setTestResponse(JSON.stringify(response, undefined, 2)) + setResponseKeys(getDeepKeys(response)) + } else { + setTestResponse(response) + } + } catch (err) { errorToast({ - title: 'Não foi possível executar sua integração.', + title: 'Erro ao executar a requisição. Verifique os dados e tente novamente.', }) - } else { - successToast({ - title: 'Sua integração está funcionando!', - }) - } - - if (typeof response === 'object') { - setTestResponse(JSON.stringify(response, undefined, 2)) - setResponseKeys(getDeepKeys(response)) - } else { - setTestResponse(response) + } finally { + setIsTestResponseLoading(false) } } @@ -458,17 +510,31 @@ export const WebhookSettings = React.memo(function WebhookSettings({ [responseKeys] ) + if (showCurlImport) { + return + } + return ( ( O que você quer fazer ? - - currentItem={step.options.method} - onItemSelect={handleMethodChange} - items={Object.values(HttpMethodsWebhook)} - /> + + + + currentItem={step.options.method} + onItemSelect={handleMethodChange} + items={Object.values(HttpMethodsWebhook)} + /> + @@ -684,7 +750,6 @@ export const WebhookSettings = React.memo(function WebhookSettings({ )} - as ) }) diff --git a/apps/builder/components/shared/Graph/Nodes/StepNode/StepNode/StepNode.tsx b/apps/builder/components/shared/Graph/Nodes/StepNode/StepNode/StepNode.tsx index d1985b1881..993907065e 100644 --- a/apps/builder/components/shared/Graph/Nodes/StepNode/StepNode/StepNode.tsx +++ b/apps/builder/components/shared/Graph/Nodes/StepNode/StepNode/StepNode.tsx @@ -65,6 +65,7 @@ import { BlockStack } from './StepNode.style' type StepNodeContextProps = { setIsPopoverOpened?: (isPopoverOpened: boolean) => void setIsModalOpen?: (isModalOpen: boolean) => void + registerBeforeClose?: (handler: (() => boolean) | null) => void } export const StepNodeContext = createContext({}) @@ -122,6 +123,11 @@ export const StepNode = ({ }) const [isModalOpen, setIsModalOpen] = useState(false) + const beforeCloseRef = useRef<(() => boolean) | null>(null) + + const registerBeforeClose = (handler: (() => boolean) | null) => { + beforeCloseRef.current = handler + } const [validationMessages, setValidationMessages] = useState>() @@ -169,6 +175,8 @@ export const StepNode = ({ }, [cleanup]) const handleModalClose = () => { + if (beforeCloseRef.current?.()) return + updateStep(indices, { ...step }) refreshConnections(step) @@ -245,7 +253,7 @@ export const StepNode = ({ menuPosition="absolute" /> ) : ( - + renderMenu={() => } > diff --git a/apps/builder/services/curlParser.ts b/apps/builder/services/curlParser.ts new file mode 100644 index 0000000000..df9954c8d0 --- /dev/null +++ b/apps/builder/services/curlParser.ts @@ -0,0 +1,97 @@ +import { HttpMethodsWebhook, QueryParameters } from 'models' + +export type ParsedCurl = { + method: HttpMethodsWebhook + url: string + path: string + headers: QueryParameters[] + parameters: QueryParameters[] + body: string +} + +export const parseCurlCommand = (curl: string): ParsedCurl | null => { + try { + const normalized = curl + .replace(/\\\n/g, ' ') + .replace(/\\\r\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + if (!normalized.toLowerCase().startsWith('curl')) return null + + const methodValues = Object.values(HttpMethodsWebhook) as string[] + let method: HttpMethodsWebhook = HttpMethodsWebhook.GET + let hasExplicitMethod = false + + const methodMatch = normalized.match(/-X\s+([A-Z]+)/i) + if (methodMatch) { + const upper = methodMatch[1].toUpperCase() + if (methodValues.includes(upper)) { + method = upper as HttpMethodsWebhook + hasExplicitMethod = true + } + } + + const urlMatch = normalized.match(/https?:\/\/[^\s'"]+/) + if (!urlMatch) return null + let fullUrl = urlMatch[0] + + const headers: QueryParameters[] = [] + const headerRegex = /(?:-H|--header)\s+["']([^"']+)["']/g + let headerMatch + while ((headerMatch = headerRegex.exec(normalized)) !== null) { + const [key, value] = headerMatch[1].split(/:(.+)/) + if (key && value) { + headers.push({ + key: key.trim(), + value: value.trim(), + displayValue: value.trim(), + type: 'header', + isNew: true, + } as QueryParameters) + } + } + + let body = '' + const bodyMatch = normalized.match( + /--data(?:-raw|-binary|-urlencode)?\s+(['"])([\s\S]*?)\1/ + ) + if (bodyMatch) { + body = bodyMatch[2] + if (!hasExplicitMethod) method = HttpMethodsWebhook.POST + } else { + const bodyMatchUnquoted = normalized.match(/-d\s+(['"])([\s\S]*?)\1/) + if (bodyMatchUnquoted) { + body = bodyMatchUnquoted[2] + if (!hasExplicitMethod) method = HttpMethodsWebhook.POST + } + } + + if (!fullUrl.startsWith('http://') && !fullUrl.startsWith('https://')) { + fullUrl = 'https://' + fullUrl + } + + const urlObj = new URL(fullUrl) + const parameters: QueryParameters[] = [] + urlObj.searchParams.forEach((value, key) => { + parameters.push({ + key, + value, + displayValue: value, + type: 'query', + isNew: true, + } as QueryParameters) + }) + + return { + method, + url: urlObj.origin, + path: urlObj.pathname !== '/' ? urlObj.pathname : '', + headers, + parameters, + body: body || '{}', + } + } catch { + return null + } +}