♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ChatwootLogo } from './components/ChatwootLogo'
|
||||
export { ChatwootBlockNodeLabel } from './components/ChatwootBlockNodeLabel'
|
||||
export { ChatwootSettingsForm } from './components/ChatwootSettingsForm'
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GoogleAnalyticsSettings } from './components/GoogleAnalyticsSettings'
|
||||
export { GoogleAnalyticsLogo } from './components/GoogleAnalyticsLogo'
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 <></>
|
||||
}
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GoogleSheetsSettingsBody } from './GoogleSheetsSettingsBody'
|
||||
@@ -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}"`)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { GoogleSheetsSettingsBody } from './components/GoogleSheetsSettingsBody'
|
||||
export { GoogleSheetsNodeContent } from './components/GoogleSheetsNodeContent'
|
||||
export { GoogleSheetsLogo } from './components/GoogleSheetsLogo'
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Sheet = { id: string; name: string; columns: string[] }
|
||||
|
||||
export type Spreadsheet = { id: string; name: string }
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MakeComLogo } from './components/MakeComLogo'
|
||||
export { MakeComNodeContent } from './components/MakeComNodeContent'
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PabblyConnectLogo } from './components/PabblyConnectLogo'
|
||||
export { PabblyConnectNodeContent } from './components/PabblyConnectNodeContent'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SendEmailSettings } from './SendEmailSettings'
|
||||
@@ -0,0 +1,3 @@
|
||||
export { SendEmailSettings } from './components/SendEmailSettings'
|
||||
export { SendEmailContent } from './components/SendEmailContent'
|
||||
export { SendEmailIcon } from './components/SendEmailIcon'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { WebhookSettings } from './WebhookSettings'
|
||||
@@ -0,0 +1,4 @@
|
||||
export { duplicateWebhookQueries } from './queries/duplicateWebhookQuery'
|
||||
export { WebhookSettings } from './components/WebhookSettings'
|
||||
export { WebhookContent } from './components/WebhookContent'
|
||||
export { WebhookIcon } from './components/WebhookIcon'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 }
|
||||
}, {}),
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ZapierSettings } from './components/ZapierSettings'
|
||||
export { ZapierContent } from './components/ZapierContent'
|
||||
export { ZapierLogo } from './components/ZapierLogo'
|
||||
Reference in New Issue
Block a user