2
0

feat(integration): Add Google Analytics integration

This commit is contained in:
Baptiste Arnaud
2022-01-19 14:25:15 +01:00
parent 44b478550f
commit 3506d86d50
21 changed files with 528 additions and 152 deletions

View File

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

View File

@@ -12,7 +12,7 @@ import {
PhoneIcon, PhoneIcon,
TextIcon, TextIcon,
} from 'assets/icons' } from 'assets/icons'
import { GoogleSheetsLogo } from 'assets/logos' import { GoogleAnalyticsLogo, GoogleSheetsLogo } from 'assets/logos'
import { import {
BubbleStepType, BubbleStepType,
InputStepType, InputStepType,
@@ -48,6 +48,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <FilterIcon {...props} /> return <FilterIcon {...props} />
case IntegrationStepType.GOOGLE_SHEETS: case IntegrationStepType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} /> return <GoogleSheetsLogo {...props} />
case IntegrationStepType.GOOGLE_ANALYTICS:
return <GoogleAnalyticsLogo {...props} />
case 'start': case 'start':
return <FlagIcon {...props} /> return <FlagIcon {...props} />
default: default:

View File

@@ -1,4 +1,4 @@
import { Text } from '@chakra-ui/react' import { Text, Tooltip } from '@chakra-ui/react'
import { import {
BubbleStepType, BubbleStepType,
InputStepType, InputStepType,
@@ -41,7 +41,18 @@ export const StepTypeLabel = ({ type }: Props) => {
return <Text>Condition</Text> return <Text>Condition</Text>
} }
case IntegrationStepType.GOOGLE_SHEETS: { case IntegrationStepType.GOOGLE_SHEETS: {
return <Text>Sheets</Text> return (
<Tooltip label="Google Sheets">
<Text>Sheets</Text>
</Tooltip>
)
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
return (
<Tooltip label="Google Analytics">
<Text>Analytics</Text>
</Tooltip>
)
} }
default: { default: {
return <></> return <></>

View File

@@ -4,9 +4,7 @@ import {
PopoverBody, PopoverBody,
useEventListener, useEventListener,
Portal, Portal,
Stack,
IconButton, IconButton,
Flex,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ExpandIcon } from 'assets/icons' import { ExpandIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
@@ -28,6 +26,7 @@ import {
} from './bodies' } from './bodies'
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody' import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
import { ConditionSettingsBody } from './bodies/ConditionSettingsBody' import { ConditionSettingsBody } from './bodies/ConditionSettingsBody'
import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody' import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody' import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody' import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody'
@@ -47,7 +46,7 @@ export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
useEventListener('wheel', handleMouseWheel, ref.current) useEventListener('wheel', handleMouseWheel, ref.current)
return ( return (
<Portal> <Portal>
<PopoverContent onMouseDown={handleMouseDown}> <PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow /> <PopoverArrow />
<PopoverBody <PopoverBody
px="6" px="6"
@@ -58,18 +57,17 @@ export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
ref={ref} ref={ref}
shadow="lg" shadow="lg"
> >
<Stack> <StepSettings step={step} />
<Flex justifyContent="flex-end">
<IconButton
aria-label="expand"
icon={<ExpandIcon />}
size="xs"
onClick={onExpandClick}
/>
</Flex>
<StepSettings step={step} />
</Stack>
</PopoverBody> </PopoverBody>
<IconButton
pos="absolute"
top="5px"
right="5px"
aria-label="expand"
icon={<ExpandIcon />}
size="xs"
onClick={onExpandClick}
/>
</PopoverContent> </PopoverContent>
</Portal> </Portal>
) )
@@ -162,6 +160,14 @@ export const StepSettings = ({ step }: { step: Step }) => {
/> />
) )
} }
case IntegrationStepType.GOOGLE_ANALYTICS: {
return (
<GoogleAnalyticsSettings
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
default: { default: {
return <></> return <></>
} }

View File

@@ -0,0 +1,121 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
FormLabel,
Stack,
Tag,
} from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { InputWithVariableButton } from 'components/shared/InputWithVariableButton'
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>
<DebouncedInput
id="tracking-id"
initialValue={options?.trackingId ?? ''}
placeholder="G-123456..."
delay={100}
onChange={handleTrackingIdChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="category">
Event category:
</FormLabel>
<InputWithVariableButton
id="category"
initialValue={options?.category ?? ''}
placeholder="Example: Typebot"
delay={100}
onChange={handleCategoryChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="action">
Event action:
</FormLabel>
<InputWithVariableButton
id="action"
initialValue={options?.action ?? ''}
placeholder="Example: Submit email"
delay={100}
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>
<InputWithVariableButton
id="label"
initialValue={options?.label ?? ''}
placeholder="Example: Campaign Z"
delay={100}
onChange={handleLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="value">
Event value <Tag>Optional</Tag>:
</FormLabel>
<InputWithVariableButton
id="value"
initialValue={options?.value?.toString() ?? ''}
placeholder="Example: 0"
onChange={handleValueChange}
/>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)
}

View File

@@ -1,7 +1,7 @@
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react' import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { PlusIcon, TrashIcon } from 'assets/icons' import { PlusIcon, TrashIcon } from 'assets/icons'
import { DropdownList } from 'components/shared/DropdownList' import { DropdownList } from 'components/shared/DropdownList'
import { InputWithVariable } from 'components/shared/InputWithVariable' import { InputWithVariableButton } from 'components/shared/InputWithVariableButton'
import { Cell, Table } from 'models' import { Cell, Table } from 'models'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Sheet } from 'services/integrations' import { Sheet } from 'services/integrations'
@@ -131,9 +131,9 @@ export const CellWithValueStack = ({
items={columns} items={columns}
placeholder="Select a column" placeholder="Select a column"
/> />
<InputWithVariable <InputWithVariableButton
initialValue={cell.value ?? ''} initialValue={cell.value ?? ''}
onValueChange={handleValueChange} onChange={handleValueChange}
placeholder="Type a value..." placeholder="Type a value..."
/> />
</Stack> </Stack>

View File

@@ -41,7 +41,7 @@ export const SourceEndpoint = ({
align="center" align="center"
{...props} {...props}
> >
<Box bgColor="gray.400" rounded="full" boxSize="7px" /> <Box bgColor="gray.400" rounded="full" boxSize="6px" />
</Flex> </Flex>
) )
} }

View File

@@ -89,6 +89,11 @@ export const StepNodeContent = ({ step }: Props) => {
if (!step.options) return <Text color={'gray.500'}>Configure...</Text> if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
return <Text>{step.options?.action}</Text> return <Text>{step.options?.action}</Text>
} }
case IntegrationStepType.GOOGLE_ANALYTICS: {
if (!step.options || !step.options.action)
return <Text color={'gray.500'}>Configure...</Text>
return <Text>Track "{step.options?.action}"</Text>
}
case 'start': { case 'start': {
return <Text>{step.label}</Text> return <Text>{step.label}</Text>
} }

View File

@@ -14,23 +14,23 @@ import React, { useEffect, useRef, useState } from 'react'
import { useDebounce } from 'use-debounce' import { useDebounce } from 'use-debounce'
import { VariableSearchInput } from './VariableSearchInput' import { VariableSearchInput } from './VariableSearchInput'
export const InputWithVariable = ({ export const InputWithVariableButton = ({
initialValue, initialValue,
noAbsolute, onChange,
onValueChange, delay,
...props ...props
}: { }: {
initialValue: string initialValue: string
onValueChange: (value: string) => void onChange: (value: string) => void
noAbsolute?: boolean delay?: number
} & InputProps) => { } & Omit<InputProps, 'onChange'>) => {
const inputRef = useRef<HTMLInputElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null)
const [value, setValue] = useState(initialValue) const [value, setValue] = useState(initialValue)
const [debouncedValue] = useDebounce(value, 100) const [debouncedValue] = useDebounce(value, delay ?? 100)
const [carretPosition, setCarretPosition] = useState<number>(0) const [carretPosition, setCarretPosition] = useState<number>(0)
useEffect(() => { useEffect(() => {
onValueChange(debouncedValue) onChange(debouncedValue)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue]) }, [debouncedValue])
@@ -77,10 +77,7 @@ export const InputWithVariable = ({
{...props} {...props}
bgColor={'white'} bgColor={'white'}
/> />
<InputRightElement <InputRightElement>
pos={noAbsolute ? 'relative' : 'absolute'}
zIndex={noAbsolute ? 'unset' : '1'}
>
<Popover matchWidth isLazy> <Popover matchWidth isLazy>
<PopoverTrigger> <PopoverTrigger>
<IconButton <IconButton

View File

@@ -9,6 +9,7 @@ import {
Button, Button,
InputProps, InputProps,
IconButton, IconButton,
Portal,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { PlusIcon, TrashIcon } from 'assets/icons' import { PlusIcon, TrashIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
@@ -110,60 +111,62 @@ export const VariableSearchInput = ({
{...inputProps} {...inputProps}
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <Portal>
maxH="35vh" <PopoverContent
overflowY="scroll" maxH="35vh"
spacing="0" overflowY="scroll"
role="menu" spacing="0"
w="inherit" role="menu"
shadow="lg" w="inherit"
> shadow="lg"
{(inputValue?.length ?? 0) > 0 && >
!isDefined(variables.find((v) => v.name === inputValue)) && ( {(inputValue?.length ?? 0) > 0 &&
<Button !isDefined(variables.find((v) => v.name === inputValue)) && (
role="menuitem" <Button
minH="40px" role="menuitem"
onClick={handleCreateNewVariableClick} minH="40px"
fontSize="16px" onClick={handleCreateNewVariableClick}
fontWeight="normal" fontSize="16px"
rounded="none" fontWeight="normal"
colorScheme="gray" rounded="none"
variant="ghost" colorScheme="gray"
justifyContent="flex-start" variant="ghost"
leftIcon={<PlusIcon />} justifyContent="flex-start"
> leftIcon={<PlusIcon />}
Create "{inputValue}" >
</Button> Create "{inputValue}"
</Button>
)}
{filteredItems.length > 0 && (
<>
{filteredItems.map((item, idx) => {
return (
<Button
role="menuitem"
minH="40px"
key={idx}
onClick={handleVariableNameClick(item)}
fontSize="16px"
fontWeight="normal"
rounded="none"
colorScheme="gray"
variant="ghost"
justifyContent="space-between"
>
{item.name}
<IconButton
icon={<TrashIcon />}
aria-label="Remove variable"
size="xs"
onClick={handleDeleteVariableClick(item)}
/>
</Button>
)
})}
</>
)} )}
{filteredItems.length > 0 && ( </PopoverContent>
<> </Portal>
{filteredItems.map((item, idx) => {
return (
<Button
role="menuitem"
minH="40px"
key={idx}
onClick={handleVariableNameClick(item)}
fontSize="16px"
fontWeight="normal"
rounded="none"
colorScheme="gray"
variant="ghost"
justifyContent="space-between"
>
{item.name}
<IconButton
icon={<TrashIcon />}
aria-label="Remove variable"
size="xs"
onClick={handleDeleteVariableClick(item)}
/>
</Button>
)
})}
</>
)}
</PopoverContent>
</Popover> </Popover>
</Flex> </Flex>
) )

View File

@@ -1 +1,51 @@
import { Step, InputStepType } from 'models'
import { parseTestTypebot } from './utils'
export const userIds = ['user1', 'user2'] export const userIds = ['user1', 'user2']
export const createTypebotWithStep = (step: Omit<Step, 'id' | 'blockId'>) => {
cy.task(
'createTypebot',
parseTestTypebot({
id: 'typebot3',
name: 'Typebot #3',
ownerId: userIds[1],
steps: {
byId: {
step1: {
...step,
id: 'step1',
blockId: 'block1',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
options:
step.type === InputStepType.CHOICE
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{ itemIds: ['item1'] }
: undefined,
},
},
allIds: ['step1'],
},
blocks: {
byId: {
block1: {
id: 'block1',
graphCoordinates: { x: 400, y: 200 },
title: 'Block #1',
stepIds: ['step1'],
},
},
allIds: ['block1'],
},
choiceItems:
step.type === InputStepType.CHOICE
? {
byId: { item1: { stepId: 'step1', id: 'item1' } },
allIds: ['item1'],
}
: undefined,
})
)
}

View File

@@ -1,10 +1,7 @@
import { userIds } from 'cypress/plugins/data' import { createTypebotWithStep } from 'cypress/plugins/data'
import { import { preventUserFromRefreshing } from 'cypress/plugins/utils'
parseTestTypebot,
preventUserFromRefreshing,
} from 'cypress/plugins/utils'
import { getIframeBody } from 'cypress/support' import { getIframeBody } from 'cypress/support'
import { InputStep, InputStepType } from 'models' import { InputStepType } from 'models'
describe('Text input', () => { describe('Text input', () => {
beforeEach(() => { beforeEach(() => {
@@ -262,50 +259,3 @@ describe('Button input', () => {
getIframeBody().findByText('Cool!').should('exist') getIframeBody().findByText('Cool!').should('exist')
}) })
}) })
const createTypebotWithStep = (step: Omit<InputStep, 'id' | 'blockId'>) => {
cy.task(
'createTypebot',
parseTestTypebot({
id: 'typebot3',
name: 'Typebot #3',
ownerId: userIds[1],
steps: {
byId: {
step1: {
...step,
id: 'step1',
blockId: 'block1',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
options:
step.type === InputStepType.CHOICE
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{ itemIds: ['item1'] }
: undefined,
},
},
allIds: ['step1'],
},
blocks: {
byId: {
block1: {
id: 'block1',
graphCoordinates: { x: 400, y: 200 },
title: 'Block #1',
stepIds: ['step1'],
},
},
allIds: ['block1'],
},
choiceItems:
step.type === InputStepType.CHOICE
? {
byId: { item1: { stepId: 'step1', id: 'item1' } },
allIds: ['item1'],
}
: undefined,
})
)
}

View File

@@ -0,0 +1,36 @@
import { createTypebotWithStep } from 'cypress/plugins/data'
import { preventUserFromRefreshing } from 'cypress/plugins/utils'
import { IntegrationStepType } from 'models'
describe('Google Analytics', () => {
beforeEach(() => {
cy.task('seed')
createTypebotWithStep({ type: IntegrationStepType.GOOGLE_ANALYTICS })
cy.signOut()
})
afterEach(() => {
cy.window().then((win) => {
win.removeEventListener('beforeunload', preventUserFromRefreshing)
})
})
it.only('can be filled correctly', () => {
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot3/edit')
cy.intercept({
url: '/g/collect',
method: 'POST',
}).as('gaRequest')
cy.findByTestId('step-step1').click()
cy.findByRole('textbox', { name: 'Tracking ID:' }).type('G-VWX9WG1TNS')
cy.findByRole('textbox', { name: 'Event category:' }).type('Typebot')
cy.findByRole('textbox', { name: 'Event action:' }).type('Submit email')
cy.findByRole('button', { name: 'Advanced' }).click()
cy.findByRole('textbox', { name: 'Event label Optional :' }).type(
'Campaign Z'
)
cy.findByRole('textbox', { name: 'Event value Optional :' }).type('20')
// Not sure how to test if GA integration works correctly in the preview tab
})
})

View File

@@ -0,0 +1,37 @@
import { GoogleAnalyticsOptions } from 'models'
declare const gtag: any
const initGoogleAnalytics = (id: string): Promise<void> =>
new Promise((resolve) => {
const existingScript = document.getElementById('gtag')
if (!existingScript) {
const script = document.createElement('script')
script.src = `https://www.googletagmanager.com/gtag/js?id=${id}`
script.id = 'gtag'
const initScript = document.createElement('script')
initScript.innerHTML = `window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${id}');
`
document.body.appendChild(script)
document.body.appendChild(initScript)
script.onload = () => {
resolve()
}
}
if (existingScript) resolve()
})
export const sendGaEvent = (options: GoogleAnalyticsOptions) => {
if (!options) return
gtag('event', options.action, {
event_category: options.category,
event_label: options.label,
value: options.value,
})
}
export default initGoogleAnalytics

View File

@@ -26,7 +26,8 @@ export const HostMessageBubble = ({
const [isTyping, setIsTyping] = useState(true) const [isTyping, setIsTyping] = useState(true)
const content = useMemo( const content = useMemo(
() => parseVariables(step.content.html, typebot.variables), () =>
parseVariables({ text: step.content.html, variables: typebot.variables }),
[typebot.variables] [typebot.variables]
) )

View File

@@ -9,10 +9,12 @@ import {
GoogleSheetsUpdateRowOptions, GoogleSheetsUpdateRowOptions,
Cell, Cell,
GoogleSheetsGetOptions, GoogleSheetsGetOptions,
GoogleAnalyticsStep,
} from 'models' } from 'models'
import { stringify } from 'qs' import { stringify } from 'qs'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
import { parseVariables } from './variable' import { sendGaEvent } from '../../lib/gtag'
import { parseVariables, parseVariablesInObject } from './variable'
export const executeIntegration = ( export const executeIntegration = (
step: IntegrationStep, step: IntegrationStep,
@@ -22,9 +24,21 @@ export const executeIntegration = (
switch (step.type) { switch (step.type) {
case IntegrationStepType.GOOGLE_SHEETS: case IntegrationStepType.GOOGLE_SHEETS:
return executeGoogleSheetIntegration(step, variables, updateVariableValue) return executeGoogleSheetIntegration(step, variables, updateVariableValue)
case IntegrationStepType.GOOGLE_ANALYTICS:
return executeGoogleAnalyticsIntegration(step, variables)
} }
} }
export const executeGoogleAnalyticsIntegration = async (
step: GoogleAnalyticsStep,
variables: Table<Variable>
) => {
if (!step.options?.trackingId) return
const { default: initGoogleAnalytics } = await import('../../lib/gtag')
await initGoogleAnalytics(step.options.trackingId)
sendGaEvent(parseVariablesInObject(step.options, variables))
}
const executeGoogleSheetIntegration = async ( const executeGoogleSheetIntegration = async (
step: GoogleSheetsStep, step: GoogleSheetsStep,
variables: Table<Variable>, variables: Table<Variable>,
@@ -73,7 +87,10 @@ const updateRowInGoogleSheets = async (
values: parseCellValues(options.cellsToUpsert, variables), values: parseCellValues(options.cellsToUpsert, variables),
referenceCell: { referenceCell: {
column: options.referenceCell.column, column: options.referenceCell.column,
value: parseVariables(options.referenceCell.value ?? '', variables), value: parseVariables({
text: options.referenceCell.value ?? '',
variables,
}),
}, },
}, },
}) })
@@ -90,7 +107,10 @@ const getRowFromGoogleSheets = async (
credentialsId: options.credentialsId, credentialsId: options.credentialsId,
referenceCell: { referenceCell: {
column: options.referenceCell.column, column: options.referenceCell.column,
value: parseVariables(options.referenceCell.value ?? '', variables), value: parseVariables({
text: options.referenceCell.value ?? '',
variables,
}),
}, },
columns: options.cellsToExtract.allIds.map( columns: options.cellsToExtract.allIds.map(
(id) => options.cellsToExtract?.byId[id].column (id) => options.cellsToExtract?.byId[id].column
@@ -117,5 +137,8 @@ const parseCellValues = (
const cell = cells.byId[id] const cell = cells.byId[id]
return !cell.column || !cell.value return !cell.column || !cell.value
? row ? row
: { ...row, [cell.column]: parseVariables(cell.value, variables) } : {
...row,
[cell.column]: parseVariables({ text: cell.value, variables }),
}
}, {}) }, {})

View File

@@ -22,7 +22,7 @@ export const executeLogic = (
return return
const expression = step.options.expressionToEvaluate const expression = step.options.expressionToEvaluate
const evaluatedExpression = isMathFormula(expression) const evaluatedExpression = isMathFormula(expression)
? evaluateExpression(parseVariables(expression, variables)) ? evaluateExpression(parseVariables({ text: expression, variables }))
: expression : expression
updateVariableValue(step.options.variableId, evaluatedExpression) updateVariableValue(step.options.variableId, evaluatedExpression)
return return

View File

@@ -6,11 +6,14 @@ const safeEval = eval
export const stringContainsVariable = (str: string): boolean => export const stringContainsVariable = (str: string): boolean =>
/\{\{(.*?)\}\}/g.test(str) /\{\{(.*?)\}\}/g.test(str)
export const parseVariables = ( export const parseVariables = ({
text: string, text,
variables,
}: {
text?: string
variables: Table<Variable> variables: Table<Variable>
): string => { }): string => {
if (text === '') return text if (!text || text === '') return ''
return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => { return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => {
const matchedVarName = fullVariableString.replace(/{{|}}/g, '') const matchedVarName = fullVariableString.replace(/{{|}}/g, '')
const matchedVariableId = variables.allIds.find((variableId) => { const matchedVariableId = variables.allIds.find((variableId) => {
@@ -44,3 +47,18 @@ const countDecimals = (value: number) => {
if (value % 1 != 0) return value.toString().split('.')[1].length if (value % 1 != 0) return value.toString().split('.')[1].length
return 0 return 0
} }
export const parseVariablesInObject = (
object: { [key: string]: string | number },
variables: Table<Variable>
) =>
Object.keys(object).reduce((newObj, key) => {
const currentValue = object[key]
return {
...newObj,
[key]:
typeof currentValue === 'string'
? parseVariables({ text: currentValue, variables })
: currentValue,
}
}, {})

View File

@@ -1,12 +1,15 @@
import { StepBase } from '.' import { StepBase } from '.'
import { Table } from '../..' import { Table } from '../..'
export type IntegrationStep = GoogleSheetsStep export type IntegrationStep = GoogleSheetsStep | GoogleAnalyticsStep
export type IntegrationStepOptions = GoogleSheetsOptions export type IntegrationStepOptions =
| GoogleSheetsOptions
| GoogleAnalyticsOptions
export enum IntegrationStepType { export enum IntegrationStepType {
GOOGLE_SHEETS = 'Google Sheets', GOOGLE_SHEETS = 'Google Sheets',
GOOGLE_ANALYTICS = 'Google Analytics',
} }
export type GoogleSheetsStep = StepBase & { export type GoogleSheetsStep = StepBase & {
@@ -14,6 +17,19 @@ export type GoogleSheetsStep = StepBase & {
options?: GoogleSheetsOptions options?: GoogleSheetsOptions
} }
export type GoogleAnalyticsStep = StepBase & {
type: IntegrationStepType.GOOGLE_ANALYTICS
options?: GoogleAnalyticsOptions
}
export type GoogleAnalyticsOptions = {
trackingId?: string
category?: string
action?: string
label?: string
value?: number
}
export enum GoogleSheetsAction { export enum GoogleSheetsAction {
GET = 'Get data from sheet', GET = 'Get data from sheet',
INSERT_ROW = 'Insert a row', INSERT_ROW = 'Insert a row',

View File

@@ -73,4 +73,4 @@ export const isConditionStep = (step: Step): step is ConditionStep =>
step.type === LogicStepType.CONDITION step.type === LogicStepType.CONDITION
export const isIntegrationStep = (step: Step): step is IntegrationStep => export const isIntegrationStep = (step: Step): step is IntegrationStep =>
step.type === IntegrationStepType.GOOGLE_SHEETS (Object.values(IntegrationStepType) as string[]).includes(step.type)