2
0

feat(integration): 🚸 Easy webhook config

This commit is contained in:
Baptiste Arnaud
2022-03-07 08:12:05 +01:00
parent 380eae545b
commit fd9c19a4c2
8 changed files with 234 additions and 110 deletions

View File

@ -6,10 +6,11 @@ import {
AccordionItem, AccordionItem,
AccordionPanel, AccordionPanel,
Button, Button,
Flex, HStack,
Spinner, Spinner,
Stack, Stack,
useToast, useToast,
Text,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton/InputWithVariableButton' import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton/InputWithVariableButton'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
@ -36,6 +37,7 @@ import { VariableForTestInputs } from './VariableForTestInputs'
import { DataVariableInputs } from './ResponseMappingInputs' import { DataVariableInputs } from './ResponseMappingInputs'
import { byId } from 'utils' import { byId } from 'utils'
import { deepEqual } from 'fast-equals' import { deepEqual } from 'fast-equals'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
type Props = { type Props = {
step: WebhookStep step: WebhookStep
@ -111,6 +113,12 @@ export const WebhookSettings = ({
responseVariableMapping: ResponseVariableMapping[] responseVariableMapping: ResponseVariableMapping[]
) => onOptionsChange({ ...options, responseVariableMapping }) ) => onOptionsChange({ ...options, responseVariableMapping })
const handleAdvancedConfigChange = (isAdvancedConfig: boolean) =>
onOptionsChange({ ...options, isAdvancedConfig })
const handleBodyFormStateChange = (isCustomBody: boolean) =>
onOptionsChange({ ...options, isCustomBody })
const handleTestRequestClick = async () => { const handleTestRequestClick = async () => {
if (!typebot || !localWebhook) return if (!typebot || !localWebhook) return
setIsTestResponseLoading(true) setIsTestResponseLoading(true)
@ -139,107 +147,126 @@ export const WebhookSettings = ({
if (!localWebhook) return <Spinner /> if (!localWebhook) return <Spinner />
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<InputWithVariableButton
placeholder="Your Webhook URL..."
initialValue={localWebhook.url ?? ''}
onChange={handleUrlChange}
/>
<SwitchWithLabel
id={'easy-config'}
label="Advanced configuration"
initialValue={options.isAdvancedConfig ?? true}
onCheckChange={handleAdvancedConfigChange}
/>
{(options.isAdvancedConfig ?? true) && (
<Stack>
<HStack justify="space-between">
<Text>Method:</Text>
<DropdownList<HttpMethod>
currentItem={localWebhook.method as HttpMethod}
onItemSelect={handleMethodChange}
items={Object.values(HttpMethod)}
/>
</HStack>
<Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Query params
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={localWebhook.queryParams}
onItemsChange={handleQueryParamsChange}
Item={QueryParamsInputs}
addLabel="Add a param"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Headers
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={localWebhook.headers}
onItemsChange={handleHeadersChange}
Item={HeadersInputs}
addLabel="Add a value"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Body
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<SwitchWithLabel
id={'custom-body'}
label="Custom body"
initialValue={options.isCustomBody ?? true}
onCheckChange={handleBodyFormStateChange}
/>
{(options.isCustomBody ?? true) && (
<CodeEditor
value={localWebhook.body ?? ''}
lang="json"
onChange={handleBodyChange}
/>
)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Variable values for test
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<VariableForTest>
initialItems={
options?.variablesForTest ?? { byId: {}, allIds: [] }
}
onItemsChange={handleVariablesChange}
Item={VariableForTestInputs}
addLabel="Add an entry"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)}
<Stack> <Stack>
<Flex> <Button
<DropdownList<HttpMethod> onClick={handleTestRequestClick}
currentItem={localWebhook.method as HttpMethod} colorScheme="blue"
onItemSelect={handleMethodChange} isLoading={isTestResponseLoading}
items={Object.values(HttpMethod)} >
/> Test the request
</Flex> </Button>
<InputWithVariableButton {testResponse && (
placeholder="Your Webhook URL..." <CodeEditor isReadOnly lang="json" value={testResponse} />
initialValue={localWebhook.url ?? ''} )}
onChange={handleUrlChange} {(testResponse || options?.responseVariableMapping) && (
/> <Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Save in variables
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<ResponseVariableMapping>
initialItems={options.responseVariableMapping}
onItemsChange={handleResponseMappingChange}
Item={ResponseMappingInputs}
addLabel="Add an entry"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
</Stack> </Stack>
<Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Query params
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={localWebhook.queryParams}
onItemsChange={handleQueryParamsChange}
Item={QueryParamsInputs}
addLabel="Add a param"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Headers
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={localWebhook.headers}
onItemsChange={handleHeadersChange}
Item={HeadersInputs}
addLabel="Add a value"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Body
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<CodeEditor
value={localWebhook.body ?? ''}
lang="json"
onChange={handleBodyChange}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Variable values for test
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<VariableForTest>
initialItems={
options?.variablesForTest ?? { byId: {}, allIds: [] }
}
onItemsChange={handleVariablesChange}
Item={VariableForTestInputs}
addLabel="Add an entry"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
<Button
onClick={handleTestRequestClick}
colorScheme="blue"
isLoading={isTestResponseLoading}
>
Test the request
</Button>
{testResponse && (
<CodeEditor isReadOnly lang="json" value={testResponse} />
)}
{(testResponse || options?.responseVariableMapping) && (
<Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Save in variables
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<ResponseVariableMapping>
initialItems={options.responseVariableMapping}
onItemsChange={handleResponseMappingChange}
Item={ResponseMappingInputs}
addLabel="Add an entry"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
</Stack> </Stack>
) )
} }

View File

@ -0,0 +1,13 @@
import { withSentry } from '@sentry/nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils'
const handler = (req: NextApiRequest, res: NextApiResponse) => {
console.log(req.method)
if (req.method === 'POST') {
return res.status(200).send(req.body)
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -62,7 +62,12 @@
"id": "soSmiE7zyb3WF77GxFxAjYX", "id": "soSmiE7zyb3WF77GxFxAjYX",
"blockId": "8XnDM1QsqPms4LQHh8q3Jo", "blockId": "8XnDM1QsqPms4LQHh8q3Jo",
"type": "Webhook", "type": "Webhook",
"options": { "responseVariableMapping": [], "variablesForTest": [] }, "options": {
"responseVariableMapping": [],
"variablesForTest": [],
"isAdvancedConfig": false,
"isCustomBody": false
},
"webhookId": "webhook1" "webhookId": "webhook1"
} }
] ]

View File

@ -5,6 +5,7 @@ import {
PublicTypebot, PublicTypebot,
Step, Step,
Typebot, Typebot,
Webhook,
} from 'models' } from 'models'
import { CollaborationType, DashboardFolder, PrismaClient, User } from 'db' import { CollaborationType, DashboardFolder, PrismaClient, User } from 'db'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
@ -19,6 +20,7 @@ export const teardownDatabase = async () => {
await prisma.user.deleteMany({ await prisma.user.deleteMany({
where: { id: { in: ['freeUser', 'proUser'] } }, where: { id: { in: ['freeUser', 'proUser'] } },
}) })
await prisma.webhook.deleteMany()
await prisma.credentials.deleteMany(ownerFilter) await prisma.credentials.deleteMany(ownerFilter)
await prisma.dashboardFolder.deleteMany(ownerFilter) await prisma.dashboardFolder.deleteMany(ownerFilter)
return prisma.typebot.deleteMany(ownerFilter) return prisma.typebot.deleteMany(ownerFilter)
@ -37,8 +39,17 @@ export const createUsers = () =>
], ],
}) })
export const createWebhook = (typebotId: string) => export const createWebhook = async (
prisma.webhook.create({ data: { method: 'GET', typebotId, id: 'webhook1' } }) typebotId: string,
webhookProps?: Partial<Webhook>
) => {
try {
await prisma.webhook.delete({ where: { id: 'webhook1' } })
} catch {}
return prisma.webhook.create({
data: { method: 'GET', typebotId, id: 'webhook1', ...webhookProps },
})
}
export const createCollaboration = ( export const createCollaboration = (
userId: string, userId: string,

View File

@ -2,11 +2,31 @@ import test, { expect, Page } from '@playwright/test'
import { createWebhook, importTypebotInDatabase } from '../../services/database' import { createWebhook, importTypebotInDatabase } from '../../services/database'
import path from 'path' import path from 'path'
import { generate } from 'short-uuid' import { generate } from 'short-uuid'
import { HttpMethod } from 'models'
const typebotId = generate()
test.describe('Webhook step', () => { test.describe('Webhook step', () => {
test('its configuration should work', async ({ page }) => { test('easy configuration should work', async ({ page }) => {
const typebotId = generate()
await importTypebotInDatabase(
path.join(__dirname, '../../fixtures/typebots/integrations/webhook.json'),
{
id: typebotId,
}
)
await createWebhook(typebotId, { method: HttpMethod.POST })
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.fill(
'input[placeholder="Your Webhook URL..."]',
`${process.env.PLAYWRIGHT_BUILDER_TEST_BASE_URL}/api/mock/webhook-easy-config`
)
await page.click('text=Test the request')
await expect(page.locator('div[role="textbox"] >> nth=-1')).toContainText(
'"statusCode": 200'
)
})
test('Generated body should work', async ({ page }) => {
const typebotId = generate()
await importTypebotInDatabase( await importTypebotInDatabase(
path.join(__dirname, '../../fixtures/typebots/integrations/webhook.json'), path.join(__dirname, '../../fixtures/typebots/integrations/webhook.json'),
{ {
@ -17,12 +37,38 @@ test.describe('Webhook step', () => {
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...') await page.click('text=Configure...')
await page.fill(
'input[placeholder="Your Webhook URL..."]',
`${process.env.PLAYWRIGHT_BUILDER_TEST_BASE_URL}/api/mock/webhook-easy-config`
)
await page.click('text=Advanced configuration')
await page.click('text=GET') await page.click('text=GET')
await page.click('text=POST') await page.click('text=POST')
await page.click('text=Test the request')
await expect(page.locator('div[role="textbox"] >> nth=-1')).toContainText(
'"message": "This is a sample result, it has been generated ⬇️"'
)
})
test('its configuration should work', async ({ page }) => {
const typebotId = generate()
await importTypebotInDatabase(
path.join(__dirname, '../../fixtures/typebots/integrations/webhook.json'),
{
id: typebotId,
}
)
await createWebhook(typebotId)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.fill( await page.fill(
'input[placeholder="Your Webhook URL..."]', 'input[placeholder="Your Webhook URL..."]',
`${process.env.PLAYWRIGHT_BUILDER_TEST_BASE_URL}/api/mock/webhook` `${process.env.PLAYWRIGHT_BUILDER_TEST_BASE_URL}/api/mock/webhook`
) )
await page.click('text=Advanced configuration')
await page.click('text=GET')
await page.click('text=POST')
await page.click('text=Query params') await page.click('text=Query params')
await page.click('text=Add a param') await page.click('text=Add a param')
@ -45,6 +91,7 @@ test.describe('Webhook step', () => {
) )
await page.click('text=Body') await page.click('text=Body')
await page.click('text=Custom body')
await page.fill('div[role="textbox"]', '{ "customField": "{{secret 4}}" }') await page.fill('div[role="textbox"]', '{ "customField": "{{secret 4}}" }')
await page.click('text=Variable values for test') await page.click('text=Variable values for test')

View File

@ -1,11 +1,13 @@
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { import {
defaultWebhookAttributes,
KeyValue, KeyValue,
PublicTypebot, PublicTypebot,
ResultValues, ResultValues,
Typebot, Typebot,
Variable, Variable,
Webhook, Webhook,
WebhookOptions,
WebhookResponse, WebhookResponse,
WebhookStep, WebhookStep,
} from 'models' } from 'models'
@ -35,14 +37,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
where: { id: typebotId }, where: { id: typebotId },
include: { webhooks: true }, include: { webhooks: true },
})) as unknown as Typebot & { webhooks: Webhook[] } })) as unknown as Typebot & { webhooks: Webhook[] }
const step = typebot.blocks.find(byId(blockId))?.steps.find(byId(stepId)) const step = typebot.blocks
const webhook = typebot.webhooks.find(byId((step as WebhookStep).webhookId)) .find(byId(blockId))
?.steps.find(byId(stepId)) as WebhookStep
const webhook = typebot.webhooks.find(byId(step.webhookId))
if (!webhook) if (!webhook)
return res return res
.status(404) .status(404)
.send({ statusCode: 404, data: { message: `Couldn't find webhook` } }) .send({ statusCode: 404, data: { message: `Couldn't find webhook` } })
const preparedWebhook = prepareWebhookAttributes(webhook, step.options)
const result = await executeWebhook(typebot)( const result = await executeWebhook(typebot)(
webhook, preparedWebhook,
variables, variables,
blockId, blockId,
resultValues resultValues
@ -52,6 +57,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return methodNotAllowed(res) return methodNotAllowed(res)
} }
const prepareWebhookAttributes = (
webhook: Webhook,
options: WebhookOptions
): Webhook => {
if (options.isAdvancedConfig === false) {
return { ...webhook, body: '{{state}}', ...defaultWebhookAttributes }
} else if (options.isCustomBody === false) {
return { ...webhook, body: '{{state}}' }
}
return webhook
}
const executeWebhook = const executeWebhook =
(typebot: Typebot) => (typebot: Typebot) =>
async ( async (

View File

@ -116,6 +116,8 @@ export type ResponseVariableMapping = {
export type WebhookOptions = { export type WebhookOptions = {
variablesForTest: VariableForTest[] variablesForTest: VariableForTest[]
responseVariableMapping: ResponseVariableMapping[] responseVariableMapping: ResponseVariableMapping[]
isAdvancedConfig?: boolean
isCustomBody?: boolean
} }
export type VariableForTest = { export type VariableForTest = {
@ -131,6 +133,8 @@ export const defaultGoogleAnalyticsOptions: GoogleAnalyticsOptions = {}
export const defaultWebhookOptions: Omit<WebhookOptions, 'webhookId'> = { export const defaultWebhookOptions: Omit<WebhookOptions, 'webhookId'> = {
responseVariableMapping: [], responseVariableMapping: [],
variablesForTest: [], variablesForTest: [],
isAdvancedConfig: false,
isCustomBody: false,
} }
export const defaultSendEmailOptions: SendEmailOptions = { export const defaultSendEmailOptions: SendEmailOptions = {

View File

@ -32,7 +32,7 @@ export const defaultWebhookAttributes: Omit<
Webhook, Webhook,
'id' | 'body' | 'url' | 'typebotId' 'id' | 'body' | 'url' | 'typebotId'
> = { > = {
method: HttpMethod.GET, method: HttpMethod.POST,
headers: [], headers: [],
queryParams: [], queryParams: [],
} }