♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@@ -0,0 +1,42 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import cuid from 'cuid'
import { defaultChatwootOptions, IntegrationBlockType } from 'models'
const typebotId = cuid()
const chatwootTestWebsiteToken = 'tueXiiqEmrWUCZ4NUyoR7nhE'
test.describe('Chatwoot block', () => {
test('should be configurable', async ({ page }) => {
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: IntegrationBlockType.CHATWOOT,
options: defaultChatwootOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.getByText('Configure...').click()
await expect(page.getByLabel('Base URL')).toHaveAttribute(
'value',
defaultChatwootOptions.baseUrl
)
await page.getByLabel('Website token').fill(chatwootTestWebsiteToken)
await expect(page.getByText('Open Chatwoot')).toBeVisible()
await page.getByRole('button', { name: 'Set user details' }).click()
await page.getByLabel('ID').fill('123')
await page.getByLabel('Name').fill('John Doe')
await page.getByLabel('Email').fill('john@email.com')
await page.getByLabel('Avatar URL').fill('https://domain.com/avatar.png')
await page.getByLabel('Phone number').fill('+33654347543')
await page.getByRole('button', { name: 'Preview' }).click()
await expect(
page.getByText("Chatwoot won't open in preview mode").nth(0)
).toBeVisible()
})
})

View File

@@ -0,0 +1,13 @@
import { Text } from '@chakra-ui/react'
import { ChatwootBlock } from 'models'
type Props = {
block: ChatwootBlock
}
export const ChatwootBlockNodeLabel = ({ block }: Props) =>
block.options.websiteToken.length === 0 ? (
<Text color="gray.500">Configure...</Text>
) : (
<Text>Open Chatwoot</Text>
)

View File

@@ -0,0 +1,29 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const ChatwootLogo = (props: IconProps) => (
<Icon
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g
id="Square-logo"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g id="chatwoot_logo" fill-rule="nonzero">
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
<path
d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z"
id="Fill-1"
stroke="#FFFFFF"
stroke-width="6"
fill="#FFFFFF"
></path>
</g>
</g>
</Icon>
)

View File

@@ -0,0 +1,98 @@
import { Input } from '@/components/inputs'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Stack,
} from '@chakra-ui/react'
import { ChatwootOptions } from 'models'
import React from 'react'
type Props = {
options: ChatwootOptions
onOptionsChange: (options: ChatwootOptions) => void
}
export const ChatwootSettingsForm = ({ options, onOptionsChange }: Props) => {
return (
<Stack spacing={4}>
<Input
isRequired
label="Base URL"
defaultValue={options.baseUrl}
onChange={(baseUrl: string) => {
onOptionsChange({ ...options, baseUrl })
}}
withVariableButton={false}
/>
<Input
isRequired
label="Website token"
defaultValue={options.websiteToken}
onChange={(websiteToken) =>
onOptionsChange({ ...options, websiteToken })
}
moreInfoTooltip="Can be found in Chatwoot under Settings > Inboxes > Settings > Configuration, in the code snippet."
/>
<Accordion allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Set user details
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="4">
<Input
label="ID"
defaultValue={options.user?.id}
onChange={(id: string) => {
onOptionsChange({ ...options, user: { ...options.user, id } })
}}
/>
<Input
label="Name"
defaultValue={options.user?.name}
onChange={(name: string) => {
onOptionsChange({
...options,
user: { ...options.user, name },
})
}}
/>
<Input
label="Email"
defaultValue={options.user?.email}
onChange={(email: string) => {
onOptionsChange({
...options,
user: { ...options.user, email },
})
}}
/>
<Input
label="Avatar URL"
defaultValue={options.user?.avatarUrl}
onChange={(avatarUrl: string) => {
onOptionsChange({
...options,
user: { ...options.user, avatarUrl },
})
}}
/>
<Input
label="Phone number"
defaultValue={options.user?.phoneNumber}
onChange={(phoneNumber: string) => {
onOptionsChange({
...options,
user: { ...options.user, phoneNumber },
})
}}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)
}

View File

@@ -0,0 +1,3 @@
export { ChatwootLogo } from './components/ChatwootLogo'
export { ChatwootBlockNodeLabel } from './components/ChatwootBlockNodeLabel'
export { ChatwootSettingsForm } from './components/ChatwootSettingsForm'

View File

@@ -0,0 +1,101 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const GoogleAnalyticsLogo = (props: IconProps) => (
<Icon viewBox="0 0 353 353" {...props}>
<g clipPath="url(#clip0_1458_69)">
<path
d="M324.433 0H260.155C244.607 0 231.844 12.773 231.844 28.3329V111.474H138.792C123.709 111.474 111.41 123.782 111.41 139.11V232.237H27.6395C12.3241 232.237 0.0253906 244.545 0.0253906 259.873V324.899C0.0253906 340.227 12.3241 352.536 27.6395 353H324.665C340.212 353 352.975 340.227 352.975 324.667V28.3329C352.743 12.773 339.98 0 324.433 0Z"
fill="url(#paint0_linear_1458_69)"
/>
<path
d="M324.433 0H260.155C244.607 0 231.844 12.773 231.844 28.3329V111.474H138.792C123.709 111.474 111.41 123.782 111.41 139.11V232.237H27.6395C12.3241 232.237 0.0253906 244.545 0.0253906 259.873V324.899C0.0253906 340.227 12.3241 352.536 27.6395 353H324.665C340.212 353 352.975 340.227 352.975 324.667V28.3329C352.743 12.773 339.98 0 324.433 0Z"
fill="url(#paint1_linear_1458_69)"
/>
<path
d="M324.433 0H260.619C245.071 0 232.309 12.773 232.309 28.3329V353H324.433C339.98 353 352.743 340.227 352.743 324.667V28.3329C352.743 12.773 339.98 0 324.433 0Z"
fill="#F57C00"
/>
<path
d="M111.41 139.342V232.237H27.8715C12.5561 232.237 0.0253906 244.778 0.0253906 260.105V325.132C0.0253906 340.459 12.5561 353 27.8715 353H232.076V111.474H139.256C123.941 111.474 111.41 124.014 111.41 139.342Z"
fill="#FFC107"
/>
<path
d="M232.076 111.474V353H324.2C339.748 353 352.511 340.227 352.511 324.667V232.237L232.076 111.474Z"
fill="url(#paint2_linear_1458_69)"
/>
<path
opacity="0.2"
d="M139.256 113.796H232.077V111.474H139.256C123.941 111.474 111.41 124.014 111.41 139.342V141.664C111.41 126.337 123.941 113.796 139.256 113.796Z"
fill="white"
/>
<path
opacity="0.2"
d="M27.8715 234.56H111.41V232.237H27.8715C12.5561 232.237 0.0253906 244.778 0.0253906 260.106V262.428C0.0253906 247.1 12.5561 234.56 27.8715 234.56Z"
fill="white"
/>
<path
opacity="0.2"
d="M324.433 0H260.619C245.071 0 232.309 12.773 232.309 28.3329V30.6553C232.309 15.0954 245.071 2.32237 260.619 2.32237H324.433C339.98 2.32237 352.743 15.0954 352.743 30.6553V28.3329C352.743 12.773 339.98 0 324.433 0Z"
fill="white"
/>
<path
opacity="0.2"
d="M324.433 350.678H27.8715C12.5561 350.678 0.0253906 338.137 0.0253906 322.809V325.132C0.0253906 340.459 12.5561 353 27.8715 353H324.201C339.748 353 352.511 340.227 352.511 324.667V322.345C352.743 337.905 339.98 350.678 324.433 350.678V350.678Z"
fill="#BF360C"
/>
<path
d="M324.433 0H260.619C245.071 0 232.309 12.773 232.309 28.3329V111.474H139.488C124.173 111.474 111.642 124.014 111.642 139.342V232.237H27.8715C12.5561 232.237 0.0253906 244.778 0.0253906 260.105V325.132C0.0253906 340.459 12.5561 353 27.8715 353H324.433C339.98 353 352.743 340.227 352.743 324.667V28.3329C352.743 12.773 339.98 0 324.433 0Z"
fill="url(#paint3_linear_1458_69)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_1458_69"
x1="0.0253906"
y1="176.5"
x2="352.975"
y2="176.5"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" stopOpacity="0.1" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint1_linear_1458_69"
x1="0.0253906"
y1="176.5"
x2="352.975"
y2="176.5"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" stopOpacity="0.1" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint2_linear_1458_69"
x1="172.323"
y1="172.436"
x2="344.434"
y2="344.409"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#BF360C" stopOpacity="0.2" />
<stop offset="1" stopColor="#BF360C" stopOpacity="0.02" />
</linearGradient>
<linearGradient
id="paint3_linear_1458_69"
x1="118.3"
y1="118.513"
x2="346.649"
y2="346.679"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" stopOpacity="0.1" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<clipPath id="clip0_1458_69">
<rect width="353" height="353" fill="white" />
</clipPath>
</defs>
</Icon>
)

View File

@@ -0,0 +1,13 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { GoogleAnalyticsOptions } from 'models'
type Props = {
action: GoogleAnalyticsOptions['action']
}
export const GoogleAnalyticsNodeContent = ({ action }: Props) => (
<Text color={action ? 'currentcolor' : 'gray.500'} noOfLines={1}>
{action ? `Track "${action}"` : 'Configure...'}
</Text>
)

View File

@@ -0,0 +1,116 @@
import { Input } from '@/components/inputs'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
FormLabel,
Stack,
Tag,
} from '@chakra-ui/react'
import { GoogleAnalyticsOptions } from 'models'
import React from 'react'
type Props = {
options?: GoogleAnalyticsOptions
onOptionsChange: (options: GoogleAnalyticsOptions) => void
}
export const GoogleAnalyticsSettings = ({
options,
onOptionsChange,
}: Props) => {
const handleTrackingIdChange = (trackingId: string) =>
onOptionsChange({ ...options, trackingId })
const handleCategoryChange = (category: string) =>
onOptionsChange({ ...options, category })
const handleActionChange = (action: string) =>
onOptionsChange({ ...options, action })
const handleLabelChange = (label: string) =>
onOptionsChange({ ...options, label })
const handleValueChange = (value?: string) =>
onOptionsChange({
...options,
value: value ? parseFloat(value) : undefined,
})
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="tracking-id">
Tracking ID:
</FormLabel>
<Input
id="tracking-id"
defaultValue={options?.trackingId ?? ''}
placeholder="G-123456..."
onChange={handleTrackingIdChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="category">
Event category:
</FormLabel>
<Input
id="category"
defaultValue={options?.category ?? ''}
placeholder="Example: Typebot"
onChange={handleCategoryChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="action">
Event action:
</FormLabel>
<Input
id="action"
defaultValue={options?.action ?? ''}
placeholder="Example: Submit email"
onChange={handleActionChange}
/>
</Stack>
<Accordion allowToggle>
<AccordionItem>
<h2>
<AccordionButton>
<Box flex="1" textAlign="left">
Advanced
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4} as={Stack} spacing="6">
<Stack>
<FormLabel mb="0" htmlFor="label">
Event label <Tag>Optional</Tag>:
</FormLabel>
<Input
id="label"
defaultValue={options?.label ?? ''}
placeholder="Example: Campaign Z"
onChange={handleLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="value">
Event value <Tag>Optional</Tag>:
</FormLabel>
<Input
id="value"
defaultValue={options?.value?.toString() ?? ''}
placeholder="Example: 0"
onChange={handleValueChange}
/>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)
}

View File

@@ -0,0 +1,32 @@
import test from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultGoogleAnalyticsOptions, IntegrationBlockType } from 'models'
import cuid from 'cuid'
test.describe('Google Analytics block', () => {
test('its configuration should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: IntegrationBlockType.GOOGLE_ANALYTICS,
options: defaultGoogleAnalyticsOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.fill('input[placeholder="G-123456..."]', 'G-VWX9WG1TNS')
await page.fill('input[placeholder="Example: Typebot"]', 'Typebot')
await page.fill(
'input[placeholder="Example: Submit email"]',
'Submit email'
)
await page.click('text=Advanced')
await page.fill('input[placeholder="Example: Campaign Z"]', 'Campaign Z')
await page.fill('input[placeholder="Example: 0"]', '0')
})
})

View File

@@ -0,0 +1,2 @@
export { GoogleAnalyticsSettings } from './components/GoogleAnalyticsSettings'
export { GoogleAnalyticsLogo } from './components/GoogleAnalyticsLogo'

View File

@@ -0,0 +1,178 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const GoogleSheetsLogo = (props: IconProps) => (
<Icon viewBox="0 0 49 67" {...props}>
<title>Sheets-icon</title>
<desc>Created with Sketch.</desc>
<defs>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-1"
></path>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-3"
></path>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-5"
></path>
<linearGradient
x1="50.0053945%"
y1="8.58610612%"
x2="50.0053945%"
y2="100.013939%"
id="linearGradient-7"
>
<stop stopColor="#263238" stopOpacity="0.2" offset="0%"></stop>
<stop stopColor="#263238" stopOpacity="0.02" offset="100%"></stop>
</linearGradient>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-8"
></path>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-10"
></path>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-12"
></path>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-14"
></path>
<radialGradient
cx="3.16804688%"
cy="2.71744318%"
fx="3.16804688%"
fy="2.71744318%"
r="161.248516%"
gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)"
id="radialGradient-16"
>
<stop stopColor="#FFFFFF" stopOpacity="0.1" offset="0%"></stop>
<stop stopColor="#FFFFFF" stopOpacity="0" offset="100%"></stop>
</radialGradient>
</defs>
<g id="Page-1" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g
id="Consumer-Apps-Sheets-Large-VD-R8-"
transform="translate(-451.000000, -451.000000)"
>
<g id="Hero" transform="translate(0.000000, 63.000000)">
<g id="Personal" transform="translate(277.000000, 299.000000)">
<g id="Sheets-icon" transform="translate(174.833333, 89.958333)">
<g id="Group">
<g id="Clipped">
<mask id="mask-2" fill="white">
<use xlinkHref="#path-1"></use>
</mask>
<g id="SVGID_1_"></g>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z"
id="Path"
fill="#0F9D58"
fillRule="nonzero"
mask="url(#mask-2)"
></path>
</g>
<g id="Clipped">
<mask id="mask-4" fill="white">
<use xlinkHref="#path-3"></use>
</mask>
<g id="SVGID_1_"></g>
<path
d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z"
id="Shape"
fill="#F1F1F1"
fillRule="nonzero"
mask="url(#mask-4)"
></path>
</g>
<g id="Clipped">
<mask id="mask-6" fill="white">
<use xlinkHref="#path-5"></use>
</mask>
<g id="SVGID_1_"></g>
<polygon
id="Path"
fill="url(#linearGradient-7)"
fillRule="nonzero"
mask="url(#mask-6)"
points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75"
></polygon>
</g>
<g id="Clipped">
<mask id="mask-9" fill="white">
<use xlinkHref="#path-8"></use>
</mask>
<g id="SVGID_1_"></g>
<g id="Group" mask="url(#mask-9)">
<g transform="translate(26.625000, -2.958333)">
<path
d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z"
id="Path"
fill="#87CEAC"
fillRule="nonzero"
></path>
</g>
</g>
</g>
<g id="Clipped">
<mask id="mask-11" fill="white">
<use xlinkHref="#path-10"></use>
</mask>
<g id="SVGID_1_"></g>
<path
d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z"
id="Path"
fillOpacity="0.2"
fill="#FFFFFF"
fillRule="nonzero"
mask="url(#mask-11)"
></path>
</g>
<g id="Clipped">
<mask id="mask-13" fill="white">
<use xlinkHref="#path-12"></use>
</mask>
<g id="SVGID_1_"></g>
<path
d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z"
id="Path"
fillOpacity="0.2"
fill="#263238"
fillRule="nonzero"
mask="url(#mask-13)"
></path>
</g>
<g id="Clipped">
<mask id="mask-15" fill="white">
<use xlinkHref="#path-14"></use>
</mask>
<g id="SVGID_1_"></g>
<path
d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z"
id="Path"
fillOpacity="0.1"
fill="#263238"
fillRule="nonzero"
mask="url(#mask-15)"
></path>
</g>
</g>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="Path"
fill="url(#radialGradient-16)"
fillRule="nonzero"
></path>
</g>
</g>
</g>
</g>
</g>
</Icon>
)

View File

@@ -0,0 +1,13 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { GoogleSheetsAction } from 'models'
type Props = {
action?: GoogleSheetsAction
}
export const GoogleSheetsNodeContent = ({ action }: Props) => (
<Text color={action ? 'currentcolor' : 'gray.500'} noOfLines={1}>
{action ?? 'Configure...'}
</Text>
)

View File

@@ -0,0 +1,35 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from '@/components/DropdownList'
import { Cell } from 'models'
import { TableListItemProps } from '@/components/TableList'
import { Input } from '@/components/inputs'
export const CellWithValueStack = ({
item,
onItemChange,
columns,
}: TableListItemProps<Cell> & { columns: string[] }) => {
const handleColumnSelect = (column: string) => {
if (item.column === column) return
onItemChange({ ...item, column })
}
const handleValueChange = (value: string) => {
if (item.value === value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px" w="full">
<DropdownList<string>
currentItem={item.column}
onItemSelect={handleColumnSelect}
items={columns}
placeholder="Select a column"
/>
<Input
defaultValue={item.value ?? ''}
onChange={handleValueChange}
placeholder="Type a value..."
/>
</Stack>
)
}

View File

@@ -0,0 +1,37 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from '@/components/DropdownList'
import { ExtractingCell, Variable } from 'models'
import { TableListItemProps } from '@/components/TableList'
import { VariableSearchInput } from '@/components/VariableSearchInput'
export const CellWithVariableIdStack = ({
item,
onItemChange,
columns,
}: TableListItemProps<ExtractingCell> & { columns: string[] }) => {
const handleColumnSelect = (column: string) => {
if (item.column === column) return
onItemChange({ ...item, column })
}
const handleVariableIdChange = (variable?: Variable) => {
if (item.variableId === variable?.id) return
onItemChange({ ...item, variableId: variable?.id })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList<string>
currentItem={item.column}
onItemSelect={handleColumnSelect}
items={columns}
placeholder="Select a column"
/>
<VariableSearchInput
initialVariableId={item.variableId}
onSelectVariable={handleVariableIdChange}
placeholder="Select a variable"
/>
</Stack>
)
}

View File

@@ -0,0 +1,77 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
Text,
Image,
Button,
ModalFooter,
Flex,
} from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace'
import Link from 'next/link'
import React from 'react'
import { AlertInfo } from '@/components/AlertInfo'
import { GoogleLogo } from '@/components/GoogleLogo'
import { getGoogleSheetsConsentScreenUrlQuery } from '../../queries/getGoogleSheetsConsentScreenUrlQuery'
type Props = {
isOpen: boolean
blockId: string
onClose: () => void
}
export const GoogleSheetConnectModal = ({
blockId,
isOpen,
onClose,
}: Props) => {
const { workspace } = useWorkspace()
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Connect Spreadsheets</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing="6">
<AlertInfo>
Typebot needs access to Google Drive in order to list all your
spreadsheets. It also needs access to your spreadsheets in order to
fetch or inject data in it.
</AlertInfo>
<Text>
Make sure to check all the permissions so that the integration works
as expected:
</Text>
<Image
src="/images/google-spreadsheets-scopes.jpeg"
alt="Google Spreadsheets checkboxes"
/>
<Flex>
<Button
as={Link}
leftIcon={<GoogleLogo />}
data-testid="google"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
href={getGoogleSheetsConsentScreenUrlQuery(
window.location.href,
blockId,
workspace?.id
)}
mx="auto"
>
Continue with Google
</Button>
</Flex>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,225 @@
import { Divider, Stack, Text, useDisclosure } from '@chakra-ui/react'
import { DropdownList } from '@/components/DropdownList'
import { useTypebot } from '@/features/editor'
import {
Cell,
CredentialsType,
ExtractingCell,
GoogleSheetsAction,
GoogleSheetsGetOptions,
GoogleSheetsInsertRowOptions,
GoogleSheetsOptions,
GoogleSheetsUpdateRowOptions,
} from 'models'
import React, { useMemo } from 'react'
import { isDefined, omit } from 'utils'
import { SheetsDropdown } from './SheetsDropdown'
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
import { CellWithValueStack } from './CellWithValueStack'
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
import { GoogleSheetConnectModal } from './GoogleSheetsConnectModal'
import { TableListItemProps, TableList } from '@/components/TableList'
import { CredentialsDropdown } from '@/features/credentials'
import { useSheets } from '../../hooks/useSheets'
import { Sheet } from '../../types'
type Props = {
options: GoogleSheetsOptions
onOptionsChange: (options: GoogleSheetsOptions) => void
blockId: string
}
export const GoogleSheetsSettingsBody = ({
options,
onOptionsChange,
blockId,
}: Props) => {
const { save } = useTypebot()
const { sheets, isLoading } = useSheets({
credentialsId: options?.credentialsId,
spreadsheetId: options?.spreadsheetId,
})
const { isOpen, onOpen, onClose } = useDisclosure()
const sheet = useMemo(
() => sheets?.find((s) => s.id === options?.sheetId),
[sheets, options?.sheetId]
)
const handleCredentialsIdChange = (credentialsId?: string) =>
onOptionsChange({ ...omit(options, 'credentialsId'), credentialsId })
const handleSpreadsheetIdChange = (spreadsheetId: string) =>
onOptionsChange({ ...options, spreadsheetId })
const handleSheetIdChange = (sheetId: string) =>
onOptionsChange({ ...options, sheetId })
const handleActionChange = (action: GoogleSheetsAction) => {
switch (action) {
case GoogleSheetsAction.GET: {
const newOptions: GoogleSheetsGetOptions = {
...options,
action,
cellsToExtract: [],
}
return onOptionsChange({ ...newOptions })
}
case GoogleSheetsAction.INSERT_ROW: {
const newOptions: GoogleSheetsInsertRowOptions = {
...options,
action,
cellsToInsert: [],
}
return onOptionsChange({ ...newOptions })
}
case GoogleSheetsAction.UPDATE_ROW: {
const newOptions: GoogleSheetsUpdateRowOptions = {
...options,
action,
cellsToUpsert: [],
}
return onOptionsChange({ ...newOptions })
}
}
}
const handleCreateNewClick = async () => {
await save()
onOpen()
}
return (
<Stack>
<CredentialsDropdown
type={CredentialsType.GOOGLE_SHEETS}
currentCredentialsId={options?.credentialsId}
onCredentialsSelect={handleCredentialsIdChange}
onCreateNewClick={handleCreateNewClick}
/>
<GoogleSheetConnectModal
blockId={blockId}
isOpen={isOpen}
onClose={onClose}
/>
{options?.credentialsId && (
<SpreadsheetsDropdown
credentialsId={options.credentialsId}
spreadsheetId={options.spreadsheetId}
onSelectSpreadsheetId={handleSpreadsheetIdChange}
/>
)}
{options?.spreadsheetId && options.credentialsId && (
<SheetsDropdown
sheets={sheets ?? []}
isLoading={isLoading}
sheetId={options.sheetId}
onSelectSheetId={handleSheetIdChange}
/>
)}
{options?.spreadsheetId &&
options.credentialsId &&
isDefined(options.sheetId) && (
<>
<Divider />
<DropdownList<GoogleSheetsAction>
currentItem={'action' in options ? options.action : undefined}
onItemSelect={handleActionChange}
items={Object.values(GoogleSheetsAction)}
placeholder="Select an operation"
/>
</>
)}
{'action' in options && (
<ActionOptions
options={options}
sheet={sheet}
onOptionsChange={onOptionsChange}
/>
)}
</Stack>
)
}
const ActionOptions = ({
options,
sheet,
onOptionsChange,
}: {
options:
| GoogleSheetsGetOptions
| GoogleSheetsInsertRowOptions
| GoogleSheetsUpdateRowOptions
sheet?: Sheet
onOptionsChange: (options: GoogleSheetsOptions) => void
}) => {
const handleInsertColumnsChange = (cellsToInsert: Cell[]) =>
onOptionsChange({ ...options, cellsToInsert } as GoogleSheetsOptions)
const handleUpsertColumnsChange = (cellsToUpsert: Cell[]) =>
onOptionsChange({ ...options, cellsToUpsert } as GoogleSheetsOptions)
const handleReferenceCellChange = (referenceCell: Cell) =>
onOptionsChange({ ...options, referenceCell } as GoogleSheetsOptions)
const handleExtractingCellsChange = (cellsToExtract: ExtractingCell[]) =>
onOptionsChange({ ...options, cellsToExtract } as GoogleSheetsOptions)
const UpdatingCellItem = useMemo(
() => (props: TableListItemProps<Cell>) =>
<CellWithValueStack {...props} columns={sheet?.columns ?? []} />,
[sheet?.columns]
)
const ExtractingCellItem = useMemo(
() => (props: TableListItemProps<ExtractingCell>) =>
<CellWithVariableIdStack {...props} columns={sheet?.columns ?? []} />,
[sheet?.columns]
)
switch (options.action) {
case GoogleSheetsAction.INSERT_ROW:
return (
<TableList<Cell>
initialItems={options.cellsToInsert}
onItemsChange={handleInsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
/>
)
case GoogleSheetsAction.UPDATE_ROW:
return (
<Stack>
<Text>Row to select</Text>
<CellWithValueStack
columns={sheet?.columns ?? []}
item={options.referenceCell ?? { id: 'reference' }}
onItemChange={handleReferenceCellChange}
/>
<Text>Cells to update</Text>
<TableList<Cell>
initialItems={options.cellsToUpsert}
onItemsChange={handleUpsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
/>
</Stack>
)
case GoogleSheetsAction.GET:
return (
<Stack>
<Text>Row to select</Text>
<CellWithValueStack
columns={sheet?.columns ?? []}
item={options.referenceCell ?? { id: 'reference' }}
onItemChange={handleReferenceCellChange}
/>
<Text>Cells to extract</Text>
<TableList<ExtractingCell>
initialItems={options.cellsToExtract}
onItemsChange={handleExtractingCellsChange}
Item={ExtractingCellItem}
addLabel="Add a value"
/>
</Stack>
)
default:
return <></>
}
}

View File

@@ -0,0 +1,50 @@
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { SearchableDropdown } from '@/components/SearchableDropdown'
import { HStack, Input } from '@chakra-ui/react'
import { useMemo } from 'react'
import { isDefined } from 'utils'
import { Sheet } from '../../types'
type Props = {
sheets: Sheet[]
isLoading: boolean
sheetId?: string
onSelectSheetId: (id: string) => void
}
export const SheetsDropdown = ({
sheets,
isLoading,
sheetId,
onSelectSheetId,
}: Props) => {
const currentSheet = useMemo(
() => sheets?.find((s) => s.id === sheetId),
[sheetId, sheets]
)
const handleSpreadsheetSelect = (name: string) => {
const id = sheets?.find((s) => s.name === name)?.id
if (isDefined(id)) onSelectSheetId(id)
}
if (isLoading) return <Input value="Loading..." isDisabled />
if (!sheets || sheets.length === 0)
return (
<HStack>
<Input value="No sheets found" isDisabled />
<MoreInfoTooltip>
Make sure your spreadsheet contains at least a sheet with a header
row.
</MoreInfoTooltip>
</HStack>
)
return (
<SearchableDropdown
selectedItem={currentSheet?.name}
items={(sheets ?? []).map((s) => s.name)}
onValueChange={handleSpreadsheetSelect}
placeholder={'Select the sheet'}
/>
)
}

View File

@@ -0,0 +1,46 @@
import { SearchableDropdown } from '@/components/SearchableDropdown'
import { Input, Tooltip } from '@chakra-ui/react'
import { useMemo } from 'react'
import { useSpreadsheets } from '../../hooks/useSpreadsheets'
type Props = {
credentialsId: string
spreadsheetId?: string
onSelectSpreadsheetId: (id: string) => void
}
export const SpreadsheetsDropdown = ({
credentialsId,
spreadsheetId,
onSelectSpreadsheetId,
}: Props) => {
const { spreadsheets, isLoading } = useSpreadsheets({
credentialsId,
})
const currentSpreadsheet = useMemo(
() => spreadsheets?.find((s) => s.id === spreadsheetId),
[spreadsheetId, spreadsheets]
)
const handleSpreadsheetSelect = (name: string) => {
const id = spreadsheets?.find((s) => s.name === name)?.id
if (id) onSelectSpreadsheetId(id)
}
if (isLoading) return <Input value="Loading..." isDisabled />
if (!spreadsheets || spreadsheets.length === 0)
return (
<Tooltip label="No spreadsheets found, make sure you have at least one spreadsheet that contains a header row">
<span>
<Input value="No spreadsheets found" isDisabled />
</span>
</Tooltip>
)
return (
<SearchableDropdown
selectedItem={currentSpreadsheet?.name}
items={(spreadsheets ?? []).map((s) => s.name)}
onValueChange={handleSpreadsheetSelect}
placeholder={'Search for spreadsheet'}
/>
)
}

View File

@@ -0,0 +1 @@
export { GoogleSheetsSettingsBody } from './GoogleSheetsSettingsBody'

View File

@@ -0,0 +1,162 @@
import test, { expect, Page } from '@playwright/test'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
import { getTestAsset } from '@/test/utils/playwright'
test.describe.parallel('Google sheets integration', () => {
test('Insert row should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/integrations/googleSheets.json'),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/edit`)
await fillInSpreadsheetInfo(page)
await page.click('text=Select an operation')
await page.click('text=Insert a row')
await page.click('text=Add a value')
await page.click('text=Select a column')
await page.click('button >> text="Email"')
await page.click('[aria-label="Insert a variable"]')
await page.click('button >> text="Email" >> nth=1')
await page.click('text=Add a value')
await page.click('text=Select a column')
await page.click('text=First name')
await page.fill(
'input[placeholder="Type a value..."] >> nth = 1',
'Georges'
)
await page.click('text=Preview')
await typebotViewer(page)
.locator('input[placeholder="Type your email..."]')
.fill('georges@gmail.com')
await Promise.all([
page.waitForResponse(
(resp) =>
resp
.request()
.url()
.includes(
'/api/integrations/google-sheets/spreadsheets/1k_pIDw3YHl9tlZusbBVSBRY0PeRPd2H6t4Nj7rwnOtM/sheets/0'
) &&
resp.status() === 200 &&
resp.request().method() === 'POST'
),
typebotViewer(page)
.locator('input[placeholder="Type your email..."]')
.press('Enter'),
])
})
test('Update row should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/integrations/googleSheets.json'),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/edit`)
await fillInSpreadsheetInfo(page)
await page.click('text=Select an operation')
await page.click('text=Update a row')
await page.click('text=Add a value')
await page.click('text=Select a column')
await page.click('button >> text="Email"')
await page.click('[aria-label="Insert a variable"]')
await page.click('button >> text="Email" >> nth=1')
await page.click('text=Add a value')
await page.click('text=Select a column')
await page.click('text=Last name')
await page.fill(
'input[placeholder="Type a value..."] >> nth = 1',
'Last name'
)
await page.click('text=Preview')
await typebotViewer(page)
.locator('input[placeholder="Type your email..."]')
.fill('test@test.com')
await Promise.all([
page.waitForResponse(
(resp) =>
resp
.request()
.url()
.includes(
'/api/integrations/google-sheets/spreadsheets/1k_pIDw3YHl9tlZusbBVSBRY0PeRPd2H6t4Nj7rwnOtM/sheets/0'
) &&
resp.status() === 200 &&
resp.request().method() === 'PATCH'
),
typebotViewer(page)
.locator('input[placeholder="Type your email..."]')
.press('Enter'),
])
})
test('Get row should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/integrations/googleSheetsGet.json'),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/edit`)
await fillInSpreadsheetInfo(page)
await page.click('text=Select an operation')
await page.click('text=Get data from sheet')
await page.click('text=Select a column')
await page.click('button >> text="Email"')
await page.click('[aria-label="Insert a variable"]')
await page.click('button >> text="Email" >> nth=1')
await page.click('text=Add a value')
await page.click('text=Select a column')
await page.click('text="First name"')
await createNewVar(page, 'First name')
await page.click('text=Add a value')
await page.click('text=Select a column')
await page.click('text="Last name"')
await createNewVar(page, 'Last name')
await page.click('text=Preview')
await typebotViewer(page)
.locator('input[placeholder="Type your email..."]')
.fill('test2@test.com')
await typebotViewer(page)
.locator('input[placeholder="Type your email..."]')
.press('Enter')
await expect(
typebotViewer(page).locator('text=Your name is: John Smith')
).toBeVisible({ timeout: 30000 })
})
})
const fillInSpreadsheetInfo = async (page: Page) => {
await page.click('text=Configure...')
await page.click('text=Select an account')
await page.click('text=pro-user@email.com')
await page.fill('input[placeholder="Search for spreadsheet"]', 'CR')
await page.click('text=CRM')
await page.fill('input[placeholder="Select the sheet"]', 'Sh')
await page.click('text=Sheet1')
}
const createNewVar = async (page: Page, name: string) => {
await page.fill('input[placeholder="Select a variable"] >> nth=-1', name)
await page.click(`text=Create "${name}"`)
}

View File

@@ -0,0 +1,28 @@
import { stringify } from 'qs'
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { Sheet } from '../types'
export const useSheets = ({
credentialsId,
spreadsheetId,
onError,
}: {
credentialsId?: string
spreadsheetId?: string
onError?: (error: Error) => void
}) => {
const queryParams = stringify({ credentialsId })
const { data, error, mutate } = useSWR<{ sheets: Sheet[] }, Error>(
!credentialsId || !spreadsheetId
? null
: `/api/integrations/google-sheets/spreadsheets/${spreadsheetId}/sheets?${queryParams}`,
fetcher
)
if (error) onError && onError(error)
return {
sheets: data?.sheets,
isLoading: !error && !data,
mutate,
}
}

View File

@@ -0,0 +1,24 @@
import { stringify } from 'qs'
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { Spreadsheet } from '../types'
export const useSpreadsheets = ({
credentialsId,
onError,
}: {
credentialsId: string
onError?: (error: Error) => void
}) => {
const queryParams = stringify({ credentialsId })
const { data, error, mutate } = useSWR<{ files: Spreadsheet[] }, Error>(
`/api/integrations/google-sheets/spreadsheets?${queryParams}`,
fetcher
)
if (error) onError && onError(error)
return {
spreadsheets: data?.files,
isLoading: !error && !data,
mutate,
}
}

View File

@@ -0,0 +1,3 @@
export { GoogleSheetsSettingsBody } from './components/GoogleSheetsSettingsBody'
export { GoogleSheetsNodeContent } from './components/GoogleSheetsNodeContent'
export { GoogleSheetsLogo } from './components/GoogleSheetsLogo'

View File

@@ -0,0 +1,10 @@
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const createSheetsCredentialQuery = async (code: string) => {
const queryParams = stringify({ code })
return sendRequest({
url: `/api/credentials/google-sheets/callback?${queryParams}`,
method: 'GET',
})
}

View File

@@ -0,0 +1,10 @@
import { stringify } from 'qs'
export const getGoogleSheetsConsentScreenUrlQuery = (
redirectUrl: string,
blockId: string,
workspaceId?: string
) => {
const queryParams = stringify({ redirectUrl, blockId, workspaceId })
return `/api/credentials/google-sheets/consent-url?${queryParams}`
}

View File

@@ -0,0 +1,3 @@
export type Sheet = { id: string; name: string; columns: string[] }
export type Spreadsheet = { id: string; name: string }

View File

@@ -0,0 +1,65 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const MakeComLogo = (props: IconProps) => (
<Icon
viewBox="0 0 195 139"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M189.89 136.77H156C154.759 136.77 153.569 136.277 152.691 135.399C151.813 134.522 151.32 133.331 151.32 132.09V7.0001C151.328 5.76317 151.824 4.57945 152.701 3.70666C153.578 2.83388 154.763 2.34271 156 2.3401H189.88C191.12 2.33744 192.31 2.82659 193.189 3.70023C194.068 4.57386 194.565 5.76062 194.57 7.0001V132.09C194.572 132.705 194.451 133.314 194.217 133.883C193.982 134.451 193.637 134.967 193.202 135.402C192.768 135.837 192.251 136.182 191.683 136.417C191.114 136.651 190.505 136.771 189.89 136.77V136.77Z"
fill="url(#paint0_linear_132_18)"
/>
<path
d="M32.8198 137.24L2.54979 122C1.4416 121.443 0.600022 120.468 0.210001 119.29C-0.18002 118.113 -0.0865786 116.829 0.469792 115.72L56.6298 3.99999C57.1872 2.89179 58.1617 2.05022 59.3393 1.6602C60.5169 1.27018 61.8011 1.36362 62.9098 1.91999L93.1898 17.13C94.298 17.6874 95.1396 18.6619 95.5296 19.8395C95.9196 21.017 95.8262 22.3013 95.2698 23.41L39.0998 135.16C38.5424 136.268 37.5679 137.11 36.3903 137.5C35.2127 137.89 33.9285 137.796 32.8198 137.24V137.24Z"
fill="url(#paint1_linear_132_18)"
/>
<path
d="M122.23 134.72L146.23 12.9099C146.468 11.697 146.217 10.4388 145.532 9.41022C144.846 8.38162 143.781 7.66611 142.57 7.41994L109.36 0.709944C108.756 0.588979 108.134 0.588219 107.529 0.707709C106.925 0.827198 106.35 1.06459 105.837 1.40634C105.325 1.74808 104.884 2.18747 104.542 2.69941C104.199 3.21135 103.96 3.7858 103.84 4.38994L79.8397 126.21C79.6012 127.424 79.8535 128.684 80.5413 129.713C81.229 130.741 82.2964 131.456 83.5097 131.7L116.71 138.4C117.314 138.524 117.937 138.526 118.542 138.408C119.148 138.29 119.723 138.053 120.237 137.71C120.75 137.368 121.19 136.928 121.532 136.415C121.874 135.901 122.111 135.325 122.23 134.72V134.72Z"
fill="url(#paint2_linear_132_18)"
/>
<defs>
<linearGradient
id="paint0_linear_132_18"
x1="110.22"
y1="92.6901"
x2="201.65"
y2="58.9801"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.02" stopColor="#B02DE9" />
<stop offset="0.8" stopColor="#6D00CC" />
</linearGradient>
<linearGradient
id="paint1_linear_132_18"
x1="29.9698"
y1="77.31"
x2="128.26"
y2="34.81"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF00FF" />
<stop offset="0.18" stopColor="#E80DF9" />
<stop offset="0.54" stopColor="#C024ED" />
<stop offset="0.73" stopColor="#B02DE9" />
</linearGradient>
<linearGradient
id="paint2_linear_132_18"
x1="7.03968"
y1="108.59"
x2="208.52"
y2="34.4099"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.02" stopColor="#FF00FF" />
<stop offset="0.09" stopColor="#E80DF9" />
<stop offset="0.23" stopColor="#C024ED" />
<stop offset="0.3" stopColor="#B02DE9" />
<stop offset="0.42" stopColor="#A42BE3" />
<stop offset="0.63" stopColor="#8527D5" />
<stop offset="0.85" stopColor="#6021C3" />
</linearGradient>
</defs>
</Icon>
)

View File

@@ -0,0 +1,36 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { defaultWebhookAttributes, MakeComBlock, Webhook } from 'models'
import { useEffect } from 'react'
import { byId, isNotDefined } from 'utils'
type Props = {
block: MakeComBlock
}
export const MakeComNodeContent = ({ block }: Props) => {
const { webhooks, typebot, updateWebhook } = useTypebot()
const webhook = webhooks.find(byId(block.webhookId))
useEffect(() => {
if (!typebot) return
if (!webhook) {
const { webhookId } = block
const newWebhook = {
id: webhookId,
...defaultWebhookAttributes,
typebotId: typebot.id,
} as Webhook
updateWebhook(webhookId, newWebhook)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (isNotDefined(webhook?.body))
return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={1} pr="6">
{webhook?.url ? 'Trigger scenario' : 'Disabled'}
</Text>
)
}

View File

@@ -0,0 +1,2 @@
export { MakeComLogo } from './components/MakeComLogo'
export { MakeComNodeContent } from './components/MakeComNodeContent'

View File

@@ -0,0 +1,27 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const PabblyConnectLogo = (props: IconProps) => (
<Icon
viewBox="0 0 258.753 258.753"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<switch>
<g>
<path
fill="#20B276"
d="M258.753 129.375c0 71.454-57.925 129.376-129.377 129.376-22.403 0-43.476-5.692-61.85-15.713C27.296 221.099 0 178.426 0 129.375 0 57.924 57.924 0 129.376 0c71.453 0 129.377 57.924 129.377 129.375z"
></path>
<path
fill="#147F52"
d="M181.014 166.173c-13.69 14.108-30.304 21.167-49.838 21.167-11.923 0-22.999-2.018-33.383-7.88l-.125 75.124-.984-.266-.609-.156-.719-.391-.339-.613-.312-.693.346-.434-34.257-40.846.01-96.555c0-20.119 6.844-37.206 20.535-51.264 13.688-14.059 30.301-21.087 49.837-21.087 19.534 0 36.147 7.053 49.838 21.164 13.688 14.109 20.534 31.232 20.534 51.366 0 20.136-6.845 37.256-20.534 51.364zm-26.131-75.819c-6.5-6.705-14.402-10.056-23.707-10.056-9.308 0-17.21 3.351-23.707 10.056-6.5 6.705-9.75 14.855-9.75 24.457 0 9.599 3.249 17.749 9.75 24.454 6.497 6.706 14.399 10.059 23.707 10.059 9.305 0 17.207-3.353 23.707-10.059 6.499-6.705 9.749-14.855 9.749-24.454 0-9.602-3.25-17.753-9.749-24.457z"
></path>
<path
fill="#FFF"
d="M178.321 163.506c-13.69 14.11-30.302 21.167-49.837 21.167-11.922 0-23.073-2.931-33.456-8.793l-.011 78.261s-2.527-.696-5.816-1.758a97.162 97.162 0 01-2.488-.846c-.408-.145-.607-.23-1.027-.351-1.076-.31-2.44-.908-3.491-1.317a132.106 132.106 0 01-6.463-2.718c-.069-.033-10.179-4.856-11.319-5.568-1.021-.638-1.93-1.138-2.709-1.697-.249-.178-.437-.305-.673-.467-1.852-1.273-2.887-2.004-2.887-2.004l-.031-125.45c0-20.12 6.844-37.207 20.535-51.264 13.689-14.06 30.301-21.087 49.837-21.087 19.535 0 36.146 7.053 49.837 21.164 13.688 14.109 20.536 31.232 20.536 51.365-.001 20.134-6.849 37.254-20.537 51.363zm-26.13-75.819c-6.5-6.705-14.402-10.056-23.707-10.056-9.307 0-17.209 3.351-23.707 10.056-6.5 6.705-9.749 14.855-9.749 24.456 0 9.6 3.249 17.75 9.749 24.454 6.498 6.708 14.4 10.06 23.707 10.06 9.305 0 17.207-3.352 23.707-10.06 6.498-6.704 9.749-14.854 9.749-24.454 0-9.601-3.251-17.751-9.749-24.456z"
></path>
</g>
</switch>
</Icon>
)

View File

@@ -0,0 +1,36 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { defaultWebhookAttributes, PabblyConnectBlock, Webhook } from 'models'
import { useEffect } from 'react'
import { byId, isNotDefined } from 'utils'
type Props = {
block: PabblyConnectBlock
}
export const PabblyConnectNodeContent = ({ block }: Props) => {
const { webhooks, typebot, updateWebhook } = useTypebot()
const webhook = webhooks.find(byId(block.webhookId))
useEffect(() => {
if (!typebot) return
if (!webhook) {
const { webhookId } = block
const newWebhook = {
id: webhookId,
...defaultWebhookAttributes,
typebotId: typebot.id,
} as Webhook
updateWebhook(webhookId, newWebhook)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (isNotDefined(webhook?.body))
return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={1} pr="6">
{webhook?.url ? 'Trigger scenario' : 'Disabled'}
</Text>
)
}

View File

@@ -0,0 +1,2 @@
export { PabblyConnectLogo } from './components/PabblyConnectLogo'
export { PabblyConnectNodeContent } from './components/PabblyConnectNodeContent'

View File

@@ -0,0 +1,23 @@
import { Tag, Text, Wrap, WrapItem } from '@chakra-ui/react'
import { SendEmailBlock } from 'models'
type Props = {
block: SendEmailBlock
}
export const SendEmailContent = ({ block }: Props) => {
if (block.options.recipients.length === 0)
return <Text color="gray.500">Configure...</Text>
return (
<Wrap noOfLines={2} pr="6">
<WrapItem>
<Text>Send email to</Text>
</WrapItem>
{block.options.recipients.map((to) => (
<WrapItem key={to}>
<Tag>{to}</Tag>
</WrapItem>
))}
</Wrap>
)
}

View File

@@ -0,0 +1,5 @@
import { SendEmailIcon as SendEmailIco } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const SendEmailIcon = (props: IconProps) => <SendEmailIco {...props} />

View File

@@ -0,0 +1,207 @@
import {
Stack,
useDisclosure,
Text,
Flex,
HStack,
Switch,
FormLabel,
} from '@chakra-ui/react'
import { CodeEditor } from '@/components/CodeEditor'
import { CredentialsType, SendEmailOptions, Variable } from 'models'
import React, { useState } from 'react'
import { env } from 'utils'
import { SmtpConfigModal } from './SmtpConfigModal'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { CredentialsDropdown } from '@/features/credentials'
import { Input, Textarea } from '@/components/inputs'
type Props = {
options: SendEmailOptions
onOptionsChange: (options: SendEmailOptions) => void
}
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({
...options,
credentialsId: credentialsId === undefined ? 'default' : credentialsId,
})
}
const handleToChange = (recipientsStr: string) => {
const recipients: string[] = recipientsStr
.split(',')
.map((str) => str.trim())
onOptionsChange({
...options,
recipients,
})
}
const handleCcChange = (ccStr: string) => {
const cc: string[] = ccStr.split(',').map((str) => str.trim())
onOptionsChange({
...options,
cc,
})
}
const handleBccChange = (bccStr: string) => {
const bcc: string[] = bccStr.split(',').map((str) => str.trim())
onOptionsChange({
...options,
bcc,
})
}
const handleSubjectChange = (subject: string) =>
onOptionsChange({
...options,
subject,
})
const handleBodyChange = (body: string) =>
onOptionsChange({
...options,
body,
})
const handleReplyToChange = (replyTo: string) =>
onOptionsChange({
...options,
replyTo,
})
const handleIsCustomBodyChange = (isCustomBody: boolean) =>
onOptionsChange({
...options,
isCustomBody,
})
const handleIsBodyCodeChange = () =>
onOptionsChange({
...options,
isBodyCode: options.isBodyCode ? !options.isBodyCode : true,
})
const handleChangeAttachmentVariable = (
variable: Pick<Variable, 'id' | 'name'> | undefined
) =>
onOptionsChange({
...options,
attachmentsVariableId: variable?.id,
})
return (
<Stack spacing={4}>
<Stack>
<Text>From: </Text>
<CredentialsDropdown
type={CredentialsType.SMTP}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
defaultCredentialLabel={env('SMTP_FROM')
?.match(/\<(.*)\>/)
?.pop()}
refreshDropdownKey={refreshCredentialsKey}
/>
</Stack>
<Stack>
<Text>Reply to: </Text>
<Input
onChange={handleReplyToChange}
defaultValue={options.replyTo}
placeholder={'email@gmail.com'}
/>
</Stack>
<Stack>
<Text>To: </Text>
<Input
onChange={handleToChange}
defaultValue={options.recipients.join(', ')}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack>
<Text>Cc: </Text>
<Input
onChange={handleCcChange}
defaultValue={options.cc?.join(', ') ?? ''}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack>
<Text>Bcc: </Text>
<Input
onChange={handleBccChange}
defaultValue={options.bcc?.join(', ') ?? ''}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack>
<Text>Subject: </Text>
<Input
data-testid="subject-input"
onChange={handleSubjectChange}
defaultValue={options.subject ?? ''}
/>
</Stack>
<SwitchWithLabel
label={'Custom content?'}
initialValue={options.isCustomBody ?? false}
onCheckChange={handleIsCustomBodyChange}
/>
{options.isCustomBody && (
<Stack>
<Flex justifyContent="space-between">
<Text>Content: </Text>
<HStack>
<Text fontSize="sm">Text</Text>
<Switch
size="sm"
isChecked={options.isBodyCode ?? false}
onChange={handleIsBodyCodeChange}
/>
<Text fontSize="sm">Code</Text>
</HStack>
</Flex>
{options.isBodyCode ? (
<CodeEditor
value={options.body ?? ''}
onChange={handleBodyChange}
lang="html"
/>
) : (
<Textarea
data-testid="body-input"
minH="300px"
onChange={handleBodyChange}
defaultValue={options.body ?? ''}
/>
)}
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="variable">
Attachments:
</FormLabel>
<VariableSearchInput
initialVariableId={options.attachmentsVariableId}
onSelectVariable={handleChangeAttachmentVariable}
/>
</Stack>
<SmtpConfigModal
isOpen={isOpen}
onClose={onClose}
onNewCredentials={handleCredentialsSelect}
/>
</Stack>
)
}

View File

@@ -0,0 +1,86 @@
import { Input, SmartNumberInput } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { FormControl, FormLabel, HStack, Stack } from '@chakra-ui/react'
import { isDefined } from '@udecode/plate-common'
import { SmtpCredentialsData } from 'models'
import React from 'react'
type Props = {
config: SmtpCredentialsData
onConfigChange: (config: SmtpCredentialsData) => void
}
export const SmtpConfigForm = ({ config, onConfigChange }: Props) => {
const handleFromEmailChange = (email: string) =>
onConfigChange({ ...config, from: { ...config.from, email } })
const handleFromNameChange = (name: string) =>
onConfigChange({ ...config, from: { ...config.from, name } })
const handleHostChange = (host: string) => onConfigChange({ ...config, host })
const handleUsernameChange = (username: string) =>
onConfigChange({ ...config, username })
const handlePasswordChange = (password: string) =>
onConfigChange({ ...config, password })
const handleTlsCheck = (isTlsEnabled: boolean) =>
onConfigChange({ ...config, isTlsEnabled })
const handlePortNumberChange = (port?: number) =>
isDefined(port) && onConfigChange({ ...config, port })
return (
<Stack as="form" spacing={4}>
<Input
isRequired
label="From email"
defaultValue={config.from.email ?? ''}
onChange={handleFromEmailChange}
placeholder="notifications@provider.com"
withVariableButton={false}
/>
<Input
label="From name"
defaultValue={config.from.name ?? ''}
onChange={handleFromNameChange}
placeholder="John Smith"
withVariableButton={false}
/>
<Input
isRequired
label="Host"
defaultValue={config.host ?? ''}
onChange={handleHostChange}
placeholder="mail.provider.com"
withVariableButton={false}
/>
<Input
isRequired
label="Username / Email"
type="email"
defaultValue={config.username ?? ''}
onChange={handleUsernameChange}
placeholder="user@provider.com"
withVariableButton={false}
/>
<Input
isRequired
label="Password"
type="password"
defaultValue={config.password ?? ''}
onChange={handlePasswordChange}
withVariableButton={false}
/>
<SwitchWithLabel
label="Secure?"
initialValue={config.isTlsEnabled ?? false}
onCheckChange={handleTlsCheck}
moreInfoContent="If enabled, the connection will use TLS when connecting to server. If disabled then TLS is used if server supports the STARTTLS extension. In most cases enable it if you are connecting to port 465. For port 587 or 25 keep it disabled."
/>
<FormControl as={HStack} justifyContent="space-between" isRequired>
<FormLabel mb="0">Port number:</FormLabel>
<SmartNumberInput
placeholder="25"
value={config.port}
onValueChange={handlePortNumberChange}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,99 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
} from '@chakra-ui/react'
import { useUser } from '@/features/account'
import { CredentialsType, SmtpCredentialsData } from 'models'
import React, { useState } from 'react'
import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '@/hooks/useToast'
import { testSmtpConfig } from '../../queries/testSmtpConfigQuery'
import { createCredentialsQuery } from '@/features/credentials'
type Props = {
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const SmtpConfigModal = ({
isOpen,
onNewCredentials,
onClose,
}: Props) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const [isCreating, setIsCreating] = useState(false)
const { showToast } = useToast()
const [smtpConfig, setSmtpConfig] = useState<SmtpCredentialsData>({
from: {},
port: 25,
})
const handleCreateClick = async () => {
if (!user?.email || !workspace?.id) return
setIsCreating(true)
const { error: testSmtpError } = await testSmtpConfig(
smtpConfig,
user.email
)
if (testSmtpError) {
console.error(testSmtpError)
setIsCreating(false)
return showToast({
title: 'Invalid configuration',
description: "We couldn't send the test email with your configuration",
})
}
const { data, error } = await createCredentialsQuery({
data: smtpConfig,
name: smtpConfig.from.email as string,
type: CredentialsType.SMTP,
workspaceId: workspace.id,
})
setIsCreating(false)
if (error)
return showToast({ title: error.name, description: error.message })
if (!data?.credentials)
return showToast({ description: "Credentials wasn't created" })
onNewCredentials(data.credentials.id)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Create SMTP config</ModalHeader>
<ModalCloseButton />
<ModalBody>
<SmtpConfigForm config={smtpConfig} onConfigChange={setSmtpConfig} />
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
onClick={handleCreateClick}
isDisabled={
isNotDefined(smtpConfig.from.email) ||
isNotDefined(smtpConfig.host) ||
isNotDefined(smtpConfig.username) ||
isNotDefined(smtpConfig.password) ||
isNotDefined(smtpConfig.port)
}
isLoading={isCreating}
>
Create
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { SendEmailSettings } from './SendEmailSettings'

View File

@@ -0,0 +1,3 @@
export { SendEmailSettings } from './components/SendEmailSettings'
export { SendEmailContent } from './components/SendEmailContent'
export { SendEmailIcon } from './components/SendEmailIcon'

View File

@@ -0,0 +1,72 @@
import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
import { getTestAsset } from '@/test/utils/playwright'
const typebotId = cuid()
test.describe('Send email block', () => {
test('its configuration should work', async ({ page }) => {
if (
!process.env.SMTP_USERNAME ||
!process.env.SMTP_PORT ||
!process.env.SMTP_HOST ||
!process.env.SMTP_PASSWORD ||
!process.env.NEXT_PUBLIC_SMTP_FROM
)
throw new Error('SMTP_ env vars are missing')
await importTypebotInDatabase(
getTestAsset('typebots/integrations/sendEmail.json'),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.click(`text=notifications@typebot.io`)
await page.click('text=Connect new')
const createButton = page.locator('button >> text=Create')
await expect(createButton).toBeDisabled()
await page.fill(
'[placeholder="notifications@provider.com"]',
process.env.SMTP_USERNAME
)
await page.fill('[placeholder="John Smith"]', 'John Smith')
await page.fill('[placeholder="mail.provider.com"]', process.env.SMTP_HOST)
await page.fill(
'[placeholder="user@provider.com"]',
process.env.SMTP_USERNAME
)
await page.fill('[type="password"]', process.env.SMTP_PASSWORD)
await page.fill('input[role="spinbutton"]', process.env.SMTP_PORT)
await expect(createButton).toBeEnabled()
await createButton.click()
await expect(
page.locator(`button >> text=${process.env.SMTP_USERNAME}`)
).toBeVisible()
await page.fill(
'[placeholder="email1@gmail.com, email2@gmail.com"]',
'email1@gmail.com, email2@gmail.com'
)
await expect(page.locator('span >> text=email1@gmail.com')).toBeVisible()
await expect(page.locator('span >> text=email2@gmail.com')).toBeVisible()
await page.fill(
'[placeholder="email1@gmail.com, email2@gmail.com"]',
'email1@gmail.com, email2@gmail.com'
)
await page.fill('[data-testid="subject-input"]', 'Email subject')
await page.click('text="Custom content?"')
await page.fill('[data-testid="body-input"]', 'Here is my email')
await page.click('text=Preview')
await typebotViewer(page).locator('text=Go').click()
await expect(
page.locator('text=Emails are not sent in preview mode >> nth=0')
).toBeVisible()
})
})

View File

@@ -0,0 +1,12 @@
import { SmtpCredentialsData } from 'models'
import { sendRequest } from 'utils'
export const testSmtpConfig = (smtpData: SmtpCredentialsData, to: string) =>
sendRequest({
method: 'POST',
url: '/api/integrations/email/test-config',
body: {
...smtpData,
to,
},
})

View File

@@ -0,0 +1,20 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { WebhookBlock } from 'models'
import { byId } from 'utils'
type Props = {
block: WebhookBlock
}
export const WebhookContent = ({ block: { webhookId } }: Props) => {
const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(webhookId))
if (!webhook?.url) return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={2} pr="6">
{webhook.method} {webhook.url}
</Text>
)
}

View File

@@ -0,0 +1,5 @@
import { WebhookIcon as WebhookIco } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const WebhookIcon = (props: IconProps) => <WebhookIco {...props} />

View File

@@ -0,0 +1,64 @@
import { Input } from '@/components/inputs'
import { TableListItemProps } from '@/components/TableList'
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { KeyValue } from 'models'
export const QueryParamsInputs = (props: TableListItemProps<KeyValue>) => (
<KeyValueInputs
{...props}
keyPlaceholder="e.g. email"
valuePlaceholder="e.g. {{Email}}"
/>
)
export const HeadersInputs = (props: TableListItemProps<KeyValue>) => (
<KeyValueInputs
{...props}
keyPlaceholder="e.g. Content-Type"
valuePlaceholder="e.g. application/json"
/>
)
export const KeyValueInputs = ({
item,
onItemChange,
keyPlaceholder,
valuePlaceholder,
debounceTimeout,
}: TableListItemProps<KeyValue> & {
keyPlaceholder?: string
valuePlaceholder?: string
}) => {
const handleKeyChange = (key: string) => {
if (key === item.key) return
onItemChange({ ...item, key })
}
const handleValueChange = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor={'key' + item.id}>Key:</FormLabel>
<Input
id={'key' + item.id}
defaultValue={item.key ?? ''}
onChange={handleKeyChange}
placeholder={keyPlaceholder}
debounceTimeout={debounceTimeout}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor={'value' + item.id}>Value:</FormLabel>
<Input
id={'value' + item.id}
defaultValue={item.value ?? ''}
onChange={handleValueChange}
placeholder={valuePlaceholder}
debounceTimeout={debounceTimeout}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,42 @@
import { SearchableDropdown } from '@/components/SearchableDropdown'
import { TableListItemProps } from '@/components/TableList'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { Variable, ResponseVariableMapping } from 'models'
export const DataVariableInputs = ({
item,
onItemChange,
dataItems,
debounceTimeout,
}: TableListItemProps<ResponseVariableMapping> & { dataItems: string[] }) => {
const handleBodyPathChange = (bodyPath: string) =>
onItemChange({ ...item, bodyPath })
const handleVariableChange = (variable?: Variable) =>
onItemChange({ ...item, variableId: variable?.id })
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor="name">Data:</FormLabel>
<SearchableDropdown
items={dataItems}
value={item.bodyPath}
onValueChange={handleBodyPathChange}
placeholder="Select the data"
debounceTimeout={debounceTimeout}
withVariableButton
/>
</FormControl>
<FormControl>
<FormLabel htmlFor="value">Set variable:</FormLabel>
<VariableSearchInput
onSelectVariable={handleVariableChange}
placeholder="Search for a variable"
initialVariableId={item.variableId}
debounceTimeout={debounceTimeout}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,40 @@
import { Input } from '@/components/inputs'
import { TableListItemProps } from '@/components/TableList'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { VariableForTest, Variable } from 'models'
export const VariableForTestInputs = ({
item,
onItemChange,
debounceTimeout,
}: TableListItemProps<VariableForTest>) => {
const handleVariableSelect = (variable?: Variable) =>
onItemChange({ ...item, variableId: variable?.id })
const handleValueChange = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor={'name' + item.id}>Variable name:</FormLabel>
<VariableSearchInput
id={'name' + item.id}
initialVariableId={item.variableId}
onSelectVariable={handleVariableSelect}
debounceTimeout={debounceTimeout}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor={'value' + item.id}>Test value:</FormLabel>
<Input
id={'value' + item.id}
defaultValue={item.value ?? ''}
onChange={handleValueChange}
debounceTimeout={debounceTimeout}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,294 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Button,
HStack,
Spinner,
Stack,
Text,
Alert,
AlertIcon,
Link,
} from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import {
HttpMethod,
KeyValue,
WebhookOptions,
VariableForTest,
ResponseVariableMapping,
WebhookBlock,
defaultWebhookAttributes,
Webhook,
MakeComBlock,
PabblyConnectBlock,
} from 'models'
import { DropdownList } from '@/components/DropdownList'
import { CodeEditor } from '@/components/CodeEditor'
import { HeadersInputs, QueryParamsInputs } from './KeyValueInputs'
import { VariableForTestInputs } from './VariableForTestInputs'
import { DataVariableInputs } from './ResponseMappingInputs'
import { byId } from 'utils'
import { ExternalLinkIcon } from '@/components/icons'
import { useToast } from '@/hooks/useToast'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { TableListItemProps, TableList } from '@/components/TableList'
import { executeWebhook } from '../../queries/executeWebhookQuery'
import { getDeepKeys } from '../../utils/getDeepKeys'
import { Input } from '@/components/inputs'
import { convertVariablesForTestToVariables } from '../../utils/convertVariablesForTestToVariables'
type Provider = {
name: 'Make.com' | 'Pabbly Connect'
url: string
}
type Props = {
block: WebhookBlock | MakeComBlock | PabblyConnectBlock
onOptionsChange: (options: WebhookOptions) => void
provider?: Provider
}
export const WebhookSettings = ({
block: { options, id: blockId, webhookId },
onOptionsChange,
provider,
}: Props) => {
const { typebot, save, webhooks, updateWebhook } = useTypebot()
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
const [testResponse, setTestResponse] = useState<string>()
const [responseKeys, setResponseKeys] = useState<string[]>([])
const { showToast } = useToast()
const [localWebhook, setLocalWebhook] = useState(
webhooks.find(byId(webhookId))
)
useEffect(() => {
if (localWebhook) return
const incomingWebhook = webhooks.find(byId(webhookId))
setLocalWebhook(incomingWebhook)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [webhooks])
useEffect(() => {
if (!typebot) return
if (!localWebhook) {
const newWebhook = {
id: webhookId,
...defaultWebhookAttributes,
typebotId: typebot.id,
} as Webhook
updateWebhook(webhookId, newWebhook)
}
return () => {
setLocalWebhook((localWebhook) => {
if (!localWebhook) return
updateWebhook(webhookId, localWebhook).then()
return localWebhook
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleUrlChange = (url?: string) =>
localWebhook && setLocalWebhook({ ...localWebhook, url: url ?? null })
const handleMethodChange = (method: HttpMethod) =>
localWebhook && setLocalWebhook({ ...localWebhook, method })
const handleQueryParamsChange = (queryParams: KeyValue[]) =>
localWebhook && setLocalWebhook({ ...localWebhook, queryParams })
const handleHeadersChange = (headers: KeyValue[]) =>
localWebhook && setLocalWebhook({ ...localWebhook, headers })
const handleBodyChange = (body: string) =>
localWebhook && setLocalWebhook({ ...localWebhook, body })
const handleVariablesChange = (variablesForTest: VariableForTest[]) =>
onOptionsChange({ ...options, variablesForTest })
const handleResponseMappingChange = (
responseVariableMapping: ResponseVariableMapping[]
) => onOptionsChange({ ...options, responseVariableMapping })
const handleAdvancedConfigChange = (isAdvancedConfig: boolean) =>
onOptionsChange({ ...options, isAdvancedConfig })
const handleBodyFormStateChange = (isCustomBody: boolean) =>
onOptionsChange({ ...options, isCustomBody })
const handleTestRequestClick = async () => {
if (!typebot || !localWebhook) return
setIsTestResponseLoading(true)
await Promise.all([updateWebhook(localWebhook.id, localWebhook), save()])
const { data, error } = await executeWebhook(
typebot.id,
convertVariablesForTestToVariables(
options.variablesForTest,
typebot.variables
),
{ blockId }
)
if (error)
return showToast({ title: error.name, description: error.message })
setTestResponse(JSON.stringify(data, undefined, 2))
setResponseKeys(getDeepKeys(data))
setIsTestResponseLoading(false)
}
const ResponseMappingInputs = useMemo(
() => (props: TableListItemProps<ResponseVariableMapping>) =>
<DataVariableInputs {...props} dataItems={responseKeys} />,
[responseKeys]
)
if (!localWebhook) return <Spinner />
return (
<Stack spacing={4}>
{provider && (
<Alert status={'info'} bgColor={'blue.50'} rounded="md">
<AlertIcon />
<Stack>
<Text>Head up to {provider.name} to configure this block:</Text>
<Button as={Link} href={provider.url} isExternal colorScheme="blue">
<Text mr="2">{provider.name}</Text> <ExternalLinkIcon />
</Button>
</Stack>
</Alert>
)}
<Input
placeholder="Paste webhook URL..."
defaultValue={localWebhook.url ?? ''}
onChange={handleUrlChange}
debounceTimeout={0}
withVariableButton={!provider}
/>
<SwitchWithLabel
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 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"
debounceTimeout={0}
/>
</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"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Body
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<SwitchWithLabel
label="Custom body"
initialValue={options.isCustomBody ?? true}
onCheckChange={handleBodyFormStateChange}
/>
{(options.isCustomBody ?? true) && (
<CodeEditor
value={localWebhook.body ?? ''}
lang="json"
onChange={handleBodyChange}
debounceTimeout={0}
/>
)}
</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"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)}
<Stack>
{localWebhook.url && (
<Button
onClick={handleTestRequestClick}
colorScheme="blue"
isLoading={isTestResponseLoading}
>
Test the request
</Button>
)}
{testResponse && (
<CodeEditor isReadOnly lang="json" value={testResponse} />
)}
{(testResponse || options?.responseVariableMapping.length > 0) && (
<Accordion 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"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1 @@
export { WebhookSettings } from './WebhookSettings'

View File

@@ -0,0 +1,4 @@
export { duplicateWebhookQueries } from './queries/duplicateWebhookQuery'
export { WebhookSettings } from './components/WebhookSettings'
export { WebhookContent } from './components/WebhookContent'
export { WebhookIcon } from './components/WebhookIcon'

View File

@@ -0,0 +1,17 @@
import { Webhook } from 'models'
import { sendRequest } from 'utils'
import { saveWebhookQuery } from './saveWebhookQuery'
export const duplicateWebhookQueries = async (
typebotId: string,
existingWebhookId: string,
newWebhookId: string
): Promise<Webhook | undefined> => {
const { data } = await sendRequest<{ webhook: Webhook }>(
`/api/webhooks/${existingWebhookId}`
)
if (!data) return
const newWebhook = { ...data.webhook, id: newWebhookId, typebotId }
await saveWebhookQuery(newWebhook.id, newWebhook)
return newWebhook
}

View File

@@ -0,0 +1,17 @@
import { Variable, WebhookResponse } from 'models'
import { getViewerUrl, sendRequest } from 'utils'
export const executeWebhook = (
typebotId: string,
variables: Variable[],
{ blockId }: { blockId: string }
) =>
sendRequest<WebhookResponse>({
url: `${getViewerUrl({
isBuilder: true,
})}/api/typebots/${typebotId}/blocks/${blockId}/executeWebhook`,
method: 'POST',
body: {
variables,
},
})

View File

@@ -0,0 +1,12 @@
import { Webhook } from 'models'
import { sendRequest } from 'utils'
export const saveWebhookQuery = (
webhookId: string,
webhook: Partial<Webhook>
) =>
sendRequest<{ webhook: Webhook }>({
method: 'PUT',
url: `/api/webhooks/${webhookId}`,
body: webhook,
})

View File

@@ -0,0 +1,19 @@
import { Variable, VariableForTest } from 'models'
export const convertVariablesForTestToVariables = (
variablesForTest: VariableForTest[],
variables: Variable[]
): Variable[] => {
if (!variablesForTest) return []
return [
...variables,
...variablesForTest
.filter((v) => v.variableId)
.map((variableForTest) => {
const variable = variables.find(
(v) => v.id === variableForTest.variableId
) as Variable
return { ...variable, value: variableForTest.value }
}, {}),
]
}

View File

@@ -0,0 +1,26 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getDeepKeys = (obj: any): string[] => {
let keys: string[] = []
for (const key in obj) {
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
const subkeys = getDeepKeys(obj[key])
keys = keys.concat(
subkeys.map(function (subkey) {
return key + '.' + subkey
})
)
} else if (Array.isArray(obj[key])) {
for (let i = 0; i < obj[key].length; i++) {
const subkeys = getDeepKeys(obj[key][i])
keys = keys.concat(
subkeys.map(function (subkey) {
return key + '[' + i + ']' + '.' + subkey
})
)
}
} else {
keys.push(key)
}
}
return keys
}

View File

@@ -0,0 +1,100 @@
import test, { expect, Page } from '@playwright/test'
import {
createWebhook,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { HttpMethod } from 'models'
import cuid from 'cuid'
import { getTestAsset } from '@/test/utils/playwright'
test.describe('Webhook block', () => {
test('easy configuration should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/integrations/easyConfigWebhook.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="Paste webhook URL..."]',
`${process.env.NEXTAUTH_URL}/api/mock/webhook-easy-config`
)
await page.click('text=Test the request')
await expect(page.locator('div[role="textbox"] >> nth=-1')).toContainText(
`"Group #1": "answer value", "Group #2": "20", "Group #2 (1)": "Yes"`
)
})
test('its configuration should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/integrations/webhook.json'),
{
id: typebotId,
}
)
await createWebhook(typebotId)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.fill(
'input[placeholder="Paste webhook URL..."]',
`${process.env.NEXTAUTH_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=Add a param')
await page.fill('input[placeholder="e.g. email"]', 'firstParam')
await page.fill('input[placeholder="e.g. {{Email}}"]', '{{secret 1}}')
await page.click('text=Add a param')
await page.fill('input[placeholder="e.g. email"] >> nth=1', 'secondParam')
await page.fill(
'input[placeholder="e.g. {{Email}}"] >> nth=1',
'{{secret 2}}'
)
await page.click('text=Headers')
await page.waitForTimeout(200)
await page.getByRole('button', { name: 'Add a value' }).click()
await page.fill('input[placeholder="e.g. Content-Type"]', 'Custom-Typebot')
await page.fill(
'input[placeholder="e.g. application/json"]',
'{{secret 3}}'
)
await page.click('text=Body')
await page.click('text=Custom body')
await page.fill('div[role="textbox"]', '{ "customField": "{{secret 4}}" }')
await page.click('text=Variable values for test')
await addTestVariable(page, 'secret 1', 'secret1')
await addTestVariable(page, 'secret 2', 'secret2')
await addTestVariable(page, 'secret 3', 'secret3')
await addTestVariable(page, 'secret 4', 'secret4')
await page.click('text=Test the request')
await expect(page.locator('div[role="textbox"] >> nth=-1')).toContainText(
'"statusCode": 200'
)
await page.click('text=Save in variables')
await page.click('text=Add an entry >> nth=-1')
await page.click('input[placeholder="Select the data"]')
await page.click('text=data[0].name')
})
})
const addTestVariable = async (page: Page, name: string, value: string) => {
await page.click('text=Add an entry')
await page.click('[data-testid="variables-input"] >> nth=-1')
await page.click(`text="${name}"`)
await page.fill('input >> nth=-1', value)
}

View File

@@ -0,0 +1,36 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { defaultWebhookAttributes, Webhook, ZapierBlock } from 'models'
import { useEffect } from 'react'
import { byId, isNotDefined } from 'utils'
type Props = {
block: ZapierBlock
}
export const ZapierContent = ({ block }: Props) => {
const { webhooks, typebot, updateWebhook } = useTypebot()
const webhook = webhooks.find(byId(block.webhookId))
useEffect(() => {
if (!typebot) return
if (!webhook) {
const { webhookId } = block
const newWebhook = {
id: webhookId,
...defaultWebhookAttributes,
typebotId: typebot.id,
} as Webhook
updateWebhook(webhookId, newWebhook)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (isNotDefined(webhook?.body))
return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={1} pr="6">
{webhook?.url ? 'Trigger zap' : 'Disabled'}
</Text>
)
}

View File

@@ -0,0 +1,15 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const ZapierLogo = (props: IconProps) => (
<Icon
viewBox="0 0 256 256"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M159.999 128.056a76.55 76.55 0 0 1-4.915 27.024 76.745 76.745 0 0 1-27.032 4.923h-.108c-9.508-.012-18.618-1.75-27.024-4.919A76.557 76.557 0 0 1 96 128.056v-.112a76.598 76.598 0 0 1 4.91-27.02A76.492 76.492 0 0 1 127.945 96h.108a76.475 76.475 0 0 1 27.032 4.923 76.51 76.51 0 0 1 4.915 27.02v.112zm94.223-21.389h-74.716l52.829-52.833a128.518 128.518 0 0 0-13.828-16.349v-.004a129 129 0 0 0-16.345-13.816l-52.833 52.833V1.782A128.606 128.606 0 0 0 128.064 0h-.132c-7.248.004-14.347.62-21.265 1.782v74.716L53.834 23.665A127.82 127.82 0 0 0 37.497 37.49l-.028.02A128.803 128.803 0 0 0 23.66 53.834l52.837 52.833H1.782S0 120.7 0 127.956v.088c0 7.256.615 14.367 1.782 21.289h74.716l-52.837 52.833a128.91 128.91 0 0 0 30.173 30.173l52.833-52.837v74.72a129.3 129.3 0 0 0 21.24 1.778h.181a129.15 129.15 0 0 0 21.24-1.778v-74.72l52.838 52.837a128.994 128.994 0 0 0 16.341-13.82l.012-.012a129.245 129.245 0 0 0 13.816-16.341l-52.837-52.833h74.724c1.163-6.91 1.77-14 1.778-21.24v-.186c-.008-7.24-.615-14.33-1.778-21.24z"
fill="#FF4A00"
/>
</Icon>
)

View File

@@ -0,0 +1,51 @@
import {
Alert,
AlertIcon,
Button,
Input,
Link,
Stack,
Text,
} from '@chakra-ui/react'
import { ExternalLinkIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import { ZapierBlock } from 'models'
import React from 'react'
import { byId } from 'utils'
type Props = {
block: ZapierBlock
}
export const ZapierSettings = ({ block }: Props) => {
const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(block.webhookId))
return (
<Stack spacing={4}>
<Alert
status={webhook?.url ? 'success' : 'info'}
bgColor={webhook?.url ? undefined : 'blue.50'}
rounded="md"
>
<AlertIcon />
{webhook?.url ? (
<>Your zap is correctly configured 🚀</>
) : (
<Stack>
<Text>Head up to Zapier to configure this block:</Text>
<Button
as={Link}
href="https://zapier.com/apps/typebot/integrations"
isExternal
colorScheme="blue"
>
<Text mr="2">Zapier</Text> <ExternalLinkIcon />
</Button>
</Stack>
)}
</Alert>
{webhook?.url && <Input value={webhook?.url} isDisabled />}
</Stack>
)
}

View File

@@ -0,0 +1,3 @@
export { ZapierSettings } from './components/ZapierSettings'
export { ZapierContent } from './components/ZapierContent'
export { ZapierLogo } from './components/ZapierLogo'