2
0

Add AB test block

Closes #449
This commit is contained in:
Baptiste Arnaud
2023-04-17 16:47:17 +02:00
parent b416c6e373
commit 7e937e1c7c
28 changed files with 443 additions and 21 deletions

View File

@ -594,3 +594,13 @@ export const TableIcon = (props: IconProps) => (
<path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"></path> <path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"></path>
</Icon> </Icon>
) )
export const ShuffleIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="16 3 21 3 21 8"></polyline>
<line x1="4" y1="20" x2="21" y2="3"></line>
<polyline points="21 16 21 21 16 21"></polyline>
<line x1="15" y1="15" x2="21" y2="21"></line>
<line x1="4" y1="4" x2="9" y2="9"></line>
</Icon>
)

View File

@ -9,6 +9,7 @@ import {
HStack, HStack,
FormControl, FormControl,
FormLabel, FormLabel,
Stack,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { Variable, VariableString } from '@typebot.io/schemas' import { Variable, VariableString } from '@typebot.io/schemas'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -27,6 +28,7 @@ type Props<HasVariable extends boolean> = {
label?: string label?: string
moreInfoTooltip?: string moreInfoTooltip?: string
isRequired?: boolean isRequired?: boolean
direction?: 'row' | 'column'
onValueChange: (value?: Value<HasVariable>) => void onValueChange: (value?: Value<HasVariable>) => void
} & Omit<NumberInputProps, 'defaultValue' | 'value' | 'onChange' | 'isRequired'> } & Omit<NumberInputProps, 'defaultValue' | 'value' | 'onChange' | 'isRequired'>
@ -38,6 +40,7 @@ export const NumberInput = <HasVariable extends boolean>({
label, label,
moreInfoTooltip, moreInfoTooltip,
isRequired, isRequired,
direction,
...props ...props
}: Props<HasVariable>) => { }: Props<HasVariable>) => {
const [value, setValue] = useState(defaultValue?.toString() ?? '') const [value, setValue] = useState(defaultValue?.toString() ?? '')
@ -90,7 +93,7 @@ export const NumberInput = <HasVariable extends boolean>({
return ( return (
<FormControl <FormControl
as={HStack} as={direction === 'column' ? Stack : HStack}
isRequired={isRequired} isRequired={isRequired}
justifyContent="space-between" justifyContent="space-between"
width={label ? 'full' : 'auto'} width={label ? 'full' : 'auto'}

View File

@ -8,7 +8,7 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { PlusIcon } from '@/components/icons' import { PlusIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor/providers/TypebotProvider' import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { ButtonItem, ItemIndices, ItemType } from '@typebot.io/schemas' import { ButtonItem, Item, ItemIndices, ItemType } from '@typebot.io/schemas'
import React, { useRef, useState } from 'react' import React, { useRef, useState } from 'react'
import { isNotDefined } from '@typebot.io/lib' import { isNotDefined } from '@typebot.io/lib'
@ -26,7 +26,9 @@ export const ButtonsItemNode = ({ item, indices, isMouseOver }: Props) => {
const handleInputSubmit = () => { const handleInputSubmit = () => {
if (itemValue === '') deleteItem(indices) if (itemValue === '') deleteItem(indices)
else else
updateItem(indices, { content: itemValue === '' ? undefined : itemValue }) updateItem(indices, {
content: itemValue === '' ? undefined : itemValue,
} as Item)
} }
const handleKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => { const handleKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => {

View File

@ -0,0 +1,24 @@
import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from '@typebot.io/lib/playwright/databaseActions'
import { createId } from '@paralleldrive/cuid2'
import { getTestAsset } from '@/test/utils/playwright'
const typebotId = createId()
test.describe('AB Test block', () => {
test('its configuration should work', async ({ page }) => {
await importTypebotInDatabase(getTestAsset('typebots/logic/abTest.json'), {
id: typebotId,
})
await page.goto(`/typebots/${typebotId}/edit`)
await page.getByText('A 50%').click()
await page.getByLabel('Percent of users to follow A:').fill('100')
await expect(page.getByText('A 100%')).toBeVisible()
await expect(page.getByText('B 0%')).toBeVisible()
await page.getByRole('button', { name: 'Preview' }).click()
await expect(
page.locator('typebot-standard').getByText('How are you?')
).toBeVisible()
})
})

View File

@ -0,0 +1,7 @@
import { ShuffleIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const AbTestIcon = (props: IconProps) => (
<ShuffleIcon color="purple.500" {...props} />
)

View File

@ -0,0 +1,66 @@
import React from 'react'
import { Flex, Stack, useColorModeValue, Text, Tag } from '@chakra-ui/react'
import { AbTestBlock } from '@typebot.io/schemas'
import { SourceEndpoint } from '@/features/graph/components/endpoints/SourceEndpoint'
type Props = {
block: AbTestBlock
}
export const AbTestNodeBody = ({ block }: Props) => {
const borderColor = useColorModeValue('gray.200', 'gray.700')
const bg = useColorModeValue('white', undefined)
return (
<Stack spacing={2} w="full">
<Flex
pos="relative"
align="center"
shadow="sm"
rounded="md"
bg={bg}
borderWidth={'1px'}
borderColor={borderColor}
w="full"
>
<Text p="3">
A <Tag>{block.options.aPercent}%</Tag>
</Text>
<SourceEndpoint
source={{
groupId: block.groupId,
blockId: block.id,
itemId: block.items[0].id,
}}
pos="absolute"
right="-49px"
pointerEvents="all"
/>
</Flex>
<Flex
pos="relative"
align="center"
shadow="sm"
rounded="md"
bg={bg}
borderWidth={'1px'}
borderColor={borderColor}
w="full"
>
<Text p="3">
B <Tag>{100 - block.options.aPercent}%</Tag>
</Text>
<SourceEndpoint
source={{
groupId: block.groupId,
blockId: block.id,
itemId: block.items[1].id,
}}
pos="absolute"
right="-49px"
pointerEvents="all"
/>
</Flex>
</Stack>
)
}

View File

@ -0,0 +1,29 @@
import { Stack } from '@chakra-ui/react'
import React from 'react'
import { isDefined } from '@typebot.io/lib'
import { AbTestBlock } from '@typebot.io/schemas'
import { NumberInput } from '@/components/inputs'
type Props = {
options: AbTestBlock['options']
onOptionsChange: (options: AbTestBlock['options']) => void
}
export const AbTestSettings = ({ options, onOptionsChange }: Props) => {
const updateAPercent = (aPercent?: number) =>
isDefined(aPercent) ? onOptionsChange({ ...options, aPercent }) : null
return (
<Stack spacing={4}>
<NumberInput
defaultValue={options.aPercent}
onValueChange={updateAPercent}
withVariableButton={false}
label="Percent of users to follow A:"
direction="column"
max={100}
min={0}
/>
</Stack>
)
}

View File

@ -37,6 +37,7 @@ import { ConditionIcon } from '@/features/blocks/logic/condition/components/Cond
import { RedirectIcon } from '@/features/blocks/logic/redirect/components/RedirectIcon' import { RedirectIcon } from '@/features/blocks/logic/redirect/components/RedirectIcon'
import { SetVariableIcon } from '@/features/blocks/logic/setVariable/components/SetVariableIcon' import { SetVariableIcon } from '@/features/blocks/logic/setVariable/components/SetVariableIcon'
import { TypebotLinkIcon } from '@/features/blocks/logic/typebotLink/components/TypebotLinkIcon' import { TypebotLinkIcon } from '@/features/blocks/logic/typebotLink/components/TypebotLinkIcon'
import { AbTestIcon } from '@/features/blocks/logic/abTest/components/AbTestIcon'
type BlockIconProps = { type: BlockType } & IconProps type BlockIconProps = { type: BlockType } & IconProps
@ -91,6 +92,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => {
return <JumpIcon color={purple} {...props} /> return <JumpIcon color={purple} {...props} />
case LogicBlockType.TYPEBOT_LINK: case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkIcon color={purple} {...props} /> return <TypebotLinkIcon color={purple} {...props} />
case LogicBlockType.AB_TEST:
return <AbTestIcon color={purple} {...props} />
case IntegrationBlockType.GOOGLE_SHEETS: case IntegrationBlockType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} /> return <GoogleSheetsLogo {...props} />
case IntegrationBlockType.GOOGLE_ANALYTICS: case IntegrationBlockType.GOOGLE_ANALYTICS:

View File

@ -57,6 +57,8 @@ export const BlockLabel = ({ type }: Props): JSX.Element => {
return <Text>Wait</Text> return <Text>Wait</Text>
case LogicBlockType.JUMP: case LogicBlockType.JUMP:
return <Text>Jump</Text> return <Text>Jump</Text>
case LogicBlockType.AB_TEST:
return <Text>AB Test</Text>
case IntegrationBlockType.GOOGLE_SHEETS: case IntegrationBlockType.GOOGLE_SHEETS:
return <Text>Sheets</Text> return <Text>Sheets</Text>
case IntegrationBlockType.GOOGLE_ANALYTICS: case IntegrationBlockType.GOOGLE_ANALYTICS:

View File

@ -7,6 +7,8 @@ import {
Block, Block,
LogicBlockType, LogicBlockType,
InputBlockType, InputBlockType,
ConditionItem,
ButtonItem,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { SetTypebot } from '../TypebotProvider' import { SetTypebot } from '../TypebotProvider'
import produce from 'immer' import produce from 'immer'
@ -15,7 +17,11 @@ import { byId, blockHasItems } from '@typebot.io/lib'
import { createId } from '@paralleldrive/cuid2' import { createId } from '@paralleldrive/cuid2'
import { WritableDraft } from 'immer/dist/types/types-external' import { WritableDraft } from 'immer/dist/types/types-external'
type NewItem = Pick<Item, 'blockId' | 'outgoingEdgeId' | 'type'> & Partial<Item> type NewItem = Pick<
ConditionItem | ButtonItem,
'blockId' | 'outgoingEdgeId' | 'type'
> &
Partial<ConditionItem | ButtonItem>
export type ItemsActions = { export type ItemsActions = {
createItem: (item: NewItem, indices: ItemIndices) => void createItem: (item: NewItem, indices: ItemIndices) => void

View File

@ -39,6 +39,7 @@ import { TypebotLinkNode } from '@/features/blocks/logic/typebotLink/components/
import { ItemNodesList } from '../item/ItemNodesList' import { ItemNodesList } from '../item/ItemNodesList'
import { GoogleAnalyticsNodeBody } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsNodeBody' import { GoogleAnalyticsNodeBody } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsNodeBody'
import { ChatwootNodeBody } from '@/features/blocks/integrations/chatwoot/components/ChatwootNodeBody' import { ChatwootNodeBody } from '@/features/blocks/integrations/chatwoot/components/ChatwootNodeBody'
import { AbTestNodeBody } from '@/features/blocks/logic/abTest/components/AbTestNodeBody'
type Props = { type Props = {
block: Block | StartBlock block: Block | StartBlock
@ -142,6 +143,9 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
case LogicBlockType.JUMP: { case LogicBlockType.JUMP: {
return <JumpNodeBody options={block.options} /> return <JumpNodeBody options={block.options} />
} }
case LogicBlockType.AB_TEST: {
return <AbTestNodeBody block={block} />
}
case LogicBlockType.TYPEBOT_LINK: case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkNode block={block} /> return <TypebotLinkNode block={block} />
case LogicBlockType.CONDITION: case LogicBlockType.CONDITION:

View File

@ -45,6 +45,7 @@ import { DateInputSettings } from '@/features/blocks/inputs/date/components/Date
import { PhoneInputSettings } from '@/features/blocks/inputs/phone/components/PhoneInputSettings' import { PhoneInputSettings } from '@/features/blocks/inputs/phone/components/PhoneInputSettings'
import { GoogleSheetsSettings } from '@/features/blocks/integrations/googleSheets/components/GoogleSheetsSettings' import { GoogleSheetsSettings } from '@/features/blocks/integrations/googleSheets/components/GoogleSheetsSettings'
import { ChatwootSettings } from '@/features/blocks/integrations/chatwoot/components/ChatwootSettings' import { ChatwootSettings } from '@/features/blocks/integrations/chatwoot/components/ChatwootSettings'
import { AbTestSettings } from '@/features/blocks/logic/abTest/components/AbTestSettings'
type Props = { type Props = {
block: BlockWithOptions block: BlockWithOptions
@ -229,6 +230,14 @@ export const BlockSettings = ({
/> />
) )
} }
case LogicBlockType.AB_TEST: {
return (
<AbTestSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case IntegrationBlockType.GOOGLE_SHEETS: { case IntegrationBlockType.GOOGLE_SHEETS: {
return ( return (
<GoogleSheetsSettings <GoogleSheetsSettings

View File

@ -1,6 +1,11 @@
import { Flex, useColorModeValue } from '@chakra-ui/react' import { Flex, useColorModeValue } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider' import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { ChoiceInputBlock, Item, ItemIndices } from '@typebot.io/schemas' import {
ChoiceInputBlock,
Item,
ItemIndices,
ItemType,
} from '@typebot.io/schemas'
import React, { useRef, useState } from 'react' import React, { useRef, useState } from 'react'
import { SourceEndpoint } from '../../endpoints/SourceEndpoint' import { SourceEndpoint } from '../../endpoints/SourceEndpoint'
import { ItemNodeContent } from './ItemNodeContent' import { ItemNodeContent } from './ItemNodeContent'
@ -9,6 +14,7 @@ import { ContextMenu } from '@/components/ContextMenu'
import { isDefined } from '@typebot.io/lib' import { isDefined } from '@typebot.io/lib'
import { Coordinates } from '@/features/graph/types' import { Coordinates } from '@/features/graph/types'
import { import {
DraggabbleItem,
NodePosition, NodePosition,
useDragDistance, useDragDistance,
} from '@/features/graph/providers/GraphDndProvider' } from '@/features/graph/providers/GraphDndProvider'
@ -20,7 +26,7 @@ type Props = {
indices: ItemIndices indices: ItemIndices
onMouseDown?: ( onMouseDown?: (
blockNodePosition: { absolute: Coordinates; relative: Coordinates }, blockNodePosition: { absolute: Coordinates; relative: Coordinates },
item: Item item: DraggabbleItem
) => void ) => void
connectionDisabled?: boolean connectionDisabled?: boolean
} }
@ -33,7 +39,7 @@ export const ItemNode = ({
}: Props) => { }: Props) => {
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300') const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
const borderColor = useColorModeValue('gray.200', 'gray.700') const borderColor = useColorModeValue('gray.200', 'gray.700')
const bg = useColorModeValue('white', undefined) const bg = useColorModeValue('white', 'gray.850')
const { typebot } = useTypebot() const { typebot } = useTypebot()
const { previewingEdge } = useGraph() const { previewingEdge } = useGraph()
const [isMouseOver, setIsMouseOver] = useState(false) const [isMouseOver, setIsMouseOver] = useState(false)
@ -48,7 +54,7 @@ export const ItemNode = ({
| undefined | undefined
)?.options?.isMultipleChoice )?.options?.isMultipleChoice
const onDrag = (position: NodePosition) => { const onDrag = (position: NodePosition) => {
if (!onMouseDown) return if (!onMouseDown || item.type === ItemType.AB_TEST) return
onMouseDown(position, item) onMouseDown(position, item)
} }
useDragDistance({ useDragDistance({

View File

@ -28,5 +28,7 @@ export const ItemNodeContent = ({ item, indices, isMouseOver }: Props) => {
indices={indices} indices={indices}
/> />
) )
case ItemType.AB_TEST:
return <></>
} }
} }

View File

@ -11,7 +11,6 @@ import {
BlockIndices, BlockIndices,
BlockWithItems, BlockWithItems,
LogicBlockType, LogicBlockType,
Item,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { ItemNode } from './ItemNode' import { ItemNode } from './ItemNode'
@ -20,6 +19,7 @@ import { isDefined } from '@typebot.io/lib'
import { import {
useBlockDnd, useBlockDnd,
computeNearestPlaceholderIndex, computeNearestPlaceholderIndex,
DraggabbleItem,
} from '@/features/graph/providers/GraphDndProvider' } from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider' import { useGraph } from '@/features/graph/providers/GraphProvider'
import { Coordinates } from '@dnd-kit/utilities' import { Coordinates } from '@dnd-kit/utilities'
@ -107,7 +107,7 @@ export const ItemNodesList = ({
(itemIndex: number) => (itemIndex: number) =>
( (
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates }, { absolute, relative }: { absolute: Coordinates; relative: Coordinates },
item: Item item: DraggabbleItem
) => { ) => {
if (!typebot || block.items.length <= 1) return if (!typebot || block.items.length <= 1) return
placeholderRefs.current.splice(itemIndex + 1, 1) placeholderRefs.current.splice(itemIndex + 1, 1)

View File

@ -1,5 +1,10 @@
import { useEventListener } from '@chakra-ui/react' import { useEventListener } from '@chakra-ui/react'
import { DraggableBlock, DraggableBlockType, Item } from '@typebot.io/schemas' import {
AbTestBlock,
DraggableBlock,
DraggableBlockType,
Item,
} from '@typebot.io/schemas'
import { import {
createContext, createContext,
Dispatch, Dispatch,
@ -18,13 +23,15 @@ type NodeElement = {
element: HTMLDivElement element: HTMLDivElement
} }
export type DraggabbleItem = Exclude<Item, AbTestBlock['items'][number]>
const graphDndContext = createContext<{ const graphDndContext = createContext<{
draggedBlockType?: DraggableBlockType draggedBlockType?: DraggableBlockType
setDraggedBlockType: Dispatch<SetStateAction<DraggableBlockType | undefined>> setDraggedBlockType: Dispatch<SetStateAction<DraggableBlockType | undefined>>
draggedBlock?: DraggableBlock draggedBlock?: DraggableBlock
setDraggedBlock: Dispatch<SetStateAction<DraggableBlock | undefined>> setDraggedBlock: Dispatch<SetStateAction<DraggableBlock | undefined>>
draggedItem?: Item draggedItem?: DraggabbleItem
setDraggedItem: Dispatch<SetStateAction<Item | undefined>> setDraggedItem: Dispatch<SetStateAction<DraggabbleItem | undefined>>
mouseOverGroup?: NodeElement mouseOverGroup?: NodeElement
setMouseOverGroup: (node: NodeElement | undefined) => void setMouseOverGroup: (node: NodeElement | undefined) => void
mouseOverBlock?: NodeElement mouseOverBlock?: NodeElement
@ -40,7 +47,7 @@ export const GraphDndProvider = ({ children }: { children: ReactNode }) => {
const [draggedBlockType, setDraggedBlockType] = useState< const [draggedBlockType, setDraggedBlockType] = useState<
DraggableBlockType | undefined DraggableBlockType | undefined
>() >()
const [draggedItem, setDraggedItem] = useState<Item | undefined>() const [draggedItem, setDraggedItem] = useState<DraggabbleItem | undefined>()
const [mouseOverGroup, _setMouseOverGroup] = useState<NodeElement>() const [mouseOverGroup, _setMouseOverGroup] = useState<NodeElement>()
const [mouseOverBlock, _setMouseOverBlock] = useState<NodeElement>() const [mouseOverBlock, _setMouseOverBlock] = useState<NodeElement>()

View File

@ -1,7 +1,9 @@
import { isChoiceInput, isConditionBlock, isDefined } from '@typebot.io/lib' import { isChoiceInput, isConditionBlock, isDefined } from '@typebot.io/lib'
import { Block, InputBlockType } from '@typebot.io/schemas' import { Block, InputBlockType, LogicBlockType } from '@typebot.io/schemas'
export const hasDefaultConnector = (block: Block) => export const hasDefaultConnector = (block: Block) =>
(!isChoiceInput(block) && !isConditionBlock(block)) || (!isChoiceInput(block) &&
!isConditionBlock(block) &&
block.type !== LogicBlockType.AB_TEST) ||
(block.type === InputBlockType.CHOICE && (block.type === InputBlockType.CHOICE &&
isDefined(block.options.dynamicVariableId)) isDefined(block.options.dynamicVariableId))

View File

@ -42,10 +42,14 @@ import {
Item, Item,
ItemType, ItemType,
LogicBlockType, LogicBlockType,
defaultAbTestOptions,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
const parseDefaultItems = ( const parseDefaultItems = (
type: LogicBlockType.CONDITION | InputBlockType.CHOICE, type:
| LogicBlockType.CONDITION
| InputBlockType.CHOICE
| LogicBlockType.AB_TEST,
blockId: string blockId: string
): Item[] => { ): Item[] => {
switch (type) { switch (type) {
@ -60,6 +64,11 @@ const parseDefaultItems = (
content: defaultConditionContent, content: defaultConditionContent,
}, },
] ]
case LogicBlockType.AB_TEST:
return [
{ id: createId(), blockId, type: ItemType.AB_TEST, path: 'a' },
{ id: createId(), blockId, type: ItemType.AB_TEST, path: 'b' },
]
} }
} }
@ -112,6 +121,8 @@ const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => {
return {} return {}
case LogicBlockType.TYPEBOT_LINK: case LogicBlockType.TYPEBOT_LINK:
return {} return {}
case LogicBlockType.AB_TEST:
return defaultAbTestOptions
case IntegrationBlockType.GOOGLE_SHEETS: case IntegrationBlockType.GOOGLE_SHEETS:
return defaultGoogleSheetsOptions return defaultGoogleSheetsOptions
case IntegrationBlockType.GOOGLE_ANALYTICS: case IntegrationBlockType.GOOGLE_ANALYTICS:

View File

@ -0,0 +1,164 @@
{
"id": "clgkx6u4n00021ait8o4c1nvs",
"version": "3",
"createdAt": "2023-04-17T14:17:13.223Z",
"updatedAt": "2023-04-17T14:17:13.223Z",
"icon": null,
"name": "My typebot",
"folderId": null,
"groups": [
{
"id": "bb5ad8yx3j52fecxdnztlap5",
"title": "Start",
"blocks": [
{
"id": "q20ww00q5nnv4icmi3rhbtda",
"type": "start",
"label": "Start",
"groupId": "bb5ad8yx3j52fecxdnztlap5",
"outgoingEdgeId": "qr2ebzm62bcz21jh2wmv1odd"
}
],
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "wv63xqdlwj6ub5vb3ulbmzd3",
"graphCoordinates": { "x": 310, "y": 206 },
"title": "Group #1",
"blocks": [
{
"id": "ice9jqqw4glrx5kblisez4lp",
"groupId": "wv63xqdlwj6ub5vb3ulbmzd3",
"type": "text",
"content": {
"richText": [{ "type": "p", "children": [{ "text": "Hey" }] }]
}
},
{
"id": "rorajfbwc2evvumo837ulwmy",
"groupId": "wv63xqdlwj6ub5vb3ulbmzd3",
"type": "AB test",
"options": { "aPercent": 50 },
"items": [
{
"id": "rcw6vr8xfcb5dmfx2ts04rb0",
"blockId": "rorajfbwc2evvumo837ulwmy",
"type": 2,
"path": "a",
"outgoingEdgeId": "lmof3n7qyjo5bkw9kaksgrzh"
},
{
"id": "i9shyze7fyd5wfxfq0svg2lg",
"blockId": "rorajfbwc2evvumo837ulwmy",
"type": 2,
"path": "b",
"outgoingEdgeId": "kvaqhcb1df8cmvekwtpd1irr"
}
]
}
]
},
{
"id": "ybebe0sxg6zu307a4ruwhmb6",
"graphCoordinates": { "x": 705.96875, "y": 166.57421875 },
"title": "Group #2",
"blocks": [
{
"id": "jqqjruceaqwqvm74isgoxbez",
"groupId": "ybebe0sxg6zu307a4ruwhmb6",
"type": "text",
"content": {
"richText": [
{ "type": "p", "children": [{ "text": "How are you?" }] }
]
}
}
]
},
{
"id": "d66ppqavqy5mac7fy9jo5lib",
"graphCoordinates": { "x": 702.44921875, "y": 350.9921875 },
"title": "Group #2 copy",
"blocks": [
{
"id": "x74mxf3hby8hw7zo3k8b5vc1",
"groupId": "d66ppqavqy5mac7fy9jo5lib",
"type": "text",
"content": {
"richText": [
{ "type": "p", "children": [{ "text": "What's up?" }] }
]
}
}
]
}
],
"variables": [],
"edges": [
{
"from": {
"groupId": "wv63xqdlwj6ub5vb3ulbmzd3",
"blockId": "rorajfbwc2evvumo837ulwmy",
"itemId": "rcw6vr8xfcb5dmfx2ts04rb0"
},
"to": { "groupId": "ybebe0sxg6zu307a4ruwhmb6" },
"id": "lmof3n7qyjo5bkw9kaksgrzh"
},
{
"from": {
"groupId": "wv63xqdlwj6ub5vb3ulbmzd3",
"blockId": "rorajfbwc2evvumo837ulwmy",
"itemId": "i9shyze7fyd5wfxfq0svg2lg"
},
"to": { "groupId": "d66ppqavqy5mac7fy9jo5lib" },
"id": "kvaqhcb1df8cmvekwtpd1irr"
},
{
"from": {
"groupId": "bb5ad8yx3j52fecxdnztlap5",
"blockId": "q20ww00q5nnv4icmi3rhbtda"
},
"to": { "groupId": "wv63xqdlwj6ub5vb3ulbmzd3" },
"id": "qr2ebzm62bcz21jh2wmv1odd"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostAvatar": {
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
"isEnabled": true
},
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": {
"font": "Open Sans",
"background": { "type": "Color", "content": "#ffffff" }
}
},
"selectedThemeTemplateId": null,
"settings": {
"general": {
"isBrandingEnabled": false,
"isInputPrefillEnabled": true,
"isHideQueryParamsEnabled": true,
"isNewResultOnRefreshEnabled": true
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null,
"workspaceId": "proWorkspace",
"resultsTablePreferences": null,
"isArchived": false,
"isClosed": false
}

View File

@ -0,0 +1,16 @@
import { AbTestBlock, SessionState } from '@typebot.io/schemas'
import { ExecuteLogicResponse } from '@/features/chat/types'
export const executeAbTest = (
_: SessionState,
block: AbTestBlock
): ExecuteLogicResponse => {
const aEdgeId = block.items[0].outgoingEdgeId
const random = Math.random() * 100
if (random < block.options.aPercent && aEdgeId) {
return { outgoingEdgeId: aEdgeId }
}
const bEdgeId = block.items[1].outgoingEdgeId
if (bEdgeId) return { outgoingEdgeId: bEdgeId }
return { outgoingEdgeId: block.outgoingEdgeId }
}

View File

@ -7,6 +7,7 @@ import { executeRedirect } from '@/features/blocks/logic/redirect/executeRedirec
import { executeCondition } from '@/features/blocks/logic/condition/executeCondition' import { executeCondition } from '@/features/blocks/logic/condition/executeCondition'
import { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable' import { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable'
import { executeTypebotLink } from '@/features/blocks/logic/typebotLink/executeTypebotLink' import { executeTypebotLink } from '@/features/blocks/logic/typebotLink/executeTypebotLink'
import { executeAbTest } from '@/features/blocks/logic/abTest/executeAbTest'
export const executeLogic = export const executeLogic =
(state: SessionState, lastBubbleBlockId?: string) => (state: SessionState, lastBubbleBlockId?: string) =>
@ -26,5 +27,7 @@ export const executeLogic =
return executeWait(state, block, lastBubbleBlockId) return executeWait(state, block, lastBubbleBlockId)
case LogicBlockType.JUMP: case LogicBlockType.JUMP:
return executeJumpBlock(state, block.options) return executeJumpBlock(state, block.options)
case LogicBlockType.AB_TEST:
return executeAbTest(state, block)
} }
} }

View File

@ -132,8 +132,13 @@ export const blockTypeHasWebhook = (
export const blockTypeHasItems = ( export const blockTypeHasItems = (
type: BlockType type: BlockType
): type is LogicBlockType.CONDITION | InputBlockType.CHOICE => ): type is
type === LogicBlockType.CONDITION || type === InputBlockType.CHOICE | LogicBlockType.CONDITION
| InputBlockType.CHOICE
| LogicBlockType.AB_TEST =>
type === LogicBlockType.CONDITION ||
type === InputBlockType.CHOICE ||
type === LogicBlockType.AB_TEST
export const blockHasItems = ( export const blockHasItems = (
block: Block block: Block

View File

@ -0,0 +1,31 @@
import { z } from 'zod'
import { blockBaseSchema } from '../baseSchemas'
import { LogicBlockType } from './enums'
import { itemBaseSchema } from '../../items/baseSchemas'
import { ItemType } from '../../items/enums'
export const aItemSchema = itemBaseSchema.extend({
type: z.literal(ItemType.AB_TEST),
path: z.literal('a'),
})
export const bItemSchema = itemBaseSchema.extend({
type: z.literal(ItemType.AB_TEST),
path: z.literal('b'),
})
export const abTestBlockSchema = blockBaseSchema.merge(
z.object({
type: z.enum([LogicBlockType.AB_TEST]),
items: z.tuple([aItemSchema, bItemSchema]),
options: z.object({
aPercent: z.number().min(0).max(100),
}),
})
)
export const defaultAbTestOptions = {
aPercent: 50,
}
export type AbTestBlock = z.infer<typeof abTestBlockSchema>

View File

@ -6,4 +6,5 @@ export enum LogicBlockType {
TYPEBOT_LINK = 'Typebot link', TYPEBOT_LINK = 'Typebot link',
WAIT = 'Wait', WAIT = 'Wait',
JUMP = 'Jump', JUMP = 'Jump',
AB_TEST = 'AB test',
} }

View File

@ -5,3 +5,4 @@ export * from './redirect'
export * from './setVariable' export * from './setVariable'
export * from './typebotLink' export * from './typebotLink'
export * from './wait' export * from './wait'
export * from './abTest'

View File

@ -42,6 +42,8 @@ import {
setVariableBlockSchema, setVariableBlockSchema,
typebotLinkBlockSchema, typebotLinkBlockSchema,
waitBlockSchema, waitBlockSchema,
abTestBlockSchema,
AbTestBlock,
} from './logic' } from './logic'
import { jumpBlockSchema } from './logic/jump' import { jumpBlockSchema } from './logic/jump'
@ -79,7 +81,7 @@ export type BlockOptions =
| LogicBlockOptions | LogicBlockOptions
| IntegrationBlockOptions | IntegrationBlockOptions
export type BlockWithItems = ConditionBlock | ChoiceInputBlock export type BlockWithItems = ConditionBlock | ChoiceInputBlock | AbTestBlock
export type BlockBase = z.infer<typeof blockBaseSchema> export type BlockBase = z.infer<typeof blockBaseSchema>
@ -123,6 +125,7 @@ export const logicBlockSchema = z.discriminatedUnion('type', [
typebotLinkBlockSchema, typebotLinkBlockSchema,
waitBlockSchema, waitBlockSchema,
jumpBlockSchema, jumpBlockSchema,
abTestBlockSchema,
]) ])
export type LogicBlock = z.infer<typeof logicBlockSchema> export type LogicBlock = z.infer<typeof logicBlockSchema>

View File

@ -1,4 +1,5 @@
export enum ItemType { export enum ItemType {
BUTTON, BUTTON,
CONDITION, CONDITION,
AB_TEST,
} }

View File

@ -1,7 +1,11 @@
import { z } from 'zod' import { z } from 'zod'
import { buttonItemSchema } from '../blocks/inputs/choice' import { buttonItemSchema } from '../blocks/inputs/choice'
import { conditionItemSchema } from '../blocks/logic/condition' import { conditionItemSchema } from '../blocks/logic/condition'
import { aItemSchema, bItemSchema } from '../blocks'
const itemSchema = buttonItemSchema.or(conditionItemSchema) const itemSchema = buttonItemSchema
.or(conditionItemSchema)
.or(aItemSchema)
.or(bItemSchema)
export type Item = z.infer<typeof itemSchema> export type Item = z.infer<typeof itemSchema>