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

View File

@ -8,7 +8,7 @@ import {
} from '@chakra-ui/react'
import { PlusIcon } from '@/components/icons'
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 { isNotDefined } from '@typebot.io/lib'
@ -26,7 +26,9 @@ export const ButtonsItemNode = ({ item, indices, isMouseOver }: Props) => {
const handleInputSubmit = () => {
if (itemValue === '') deleteItem(indices)
else
updateItem(indices, { content: itemValue === '' ? undefined : itemValue })
updateItem(indices, {
content: itemValue === '' ? undefined : itemValue,
} as Item)
}
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 { SetVariableIcon } from '@/features/blocks/logic/setVariable/components/SetVariableIcon'
import { TypebotLinkIcon } from '@/features/blocks/logic/typebotLink/components/TypebotLinkIcon'
import { AbTestIcon } from '@/features/blocks/logic/abTest/components/AbTestIcon'
type BlockIconProps = { type: BlockType } & IconProps
@ -91,6 +92,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => {
return <JumpIcon color={purple} {...props} />
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkIcon color={purple} {...props} />
case LogicBlockType.AB_TEST:
return <AbTestIcon color={purple} {...props} />
case IntegrationBlockType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} />
case IntegrationBlockType.GOOGLE_ANALYTICS:

View File

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

View File

@ -7,6 +7,8 @@ import {
Block,
LogicBlockType,
InputBlockType,
ConditionItem,
ButtonItem,
} from '@typebot.io/schemas'
import { SetTypebot } from '../TypebotProvider'
import produce from 'immer'
@ -15,7 +17,11 @@ import { byId, blockHasItems } from '@typebot.io/lib'
import { createId } from '@paralleldrive/cuid2'
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 = {
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 { GoogleAnalyticsNodeBody } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsNodeBody'
import { ChatwootNodeBody } from '@/features/blocks/integrations/chatwoot/components/ChatwootNodeBody'
import { AbTestNodeBody } from '@/features/blocks/logic/abTest/components/AbTestNodeBody'
type Props = {
block: Block | StartBlock
@ -142,6 +143,9 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
case LogicBlockType.JUMP: {
return <JumpNodeBody options={block.options} />
}
case LogicBlockType.AB_TEST: {
return <AbTestNodeBody block={block} />
}
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkNode block={block} />
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 { GoogleSheetsSettings } from '@/features/blocks/integrations/googleSheets/components/GoogleSheetsSettings'
import { ChatwootSettings } from '@/features/blocks/integrations/chatwoot/components/ChatwootSettings'
import { AbTestSettings } from '@/features/blocks/logic/abTest/components/AbTestSettings'
type Props = {
block: BlockWithOptions
@ -229,6 +230,14 @@ export const BlockSettings = ({
/>
)
}
case LogicBlockType.AB_TEST: {
return (
<AbTestSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<GoogleSheetsSettings

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,9 @@
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) =>
(!isChoiceInput(block) && !isConditionBlock(block)) ||
(!isChoiceInput(block) &&
!isConditionBlock(block) &&
block.type !== LogicBlockType.AB_TEST) ||
(block.type === InputBlockType.CHOICE &&
isDefined(block.options.dynamicVariableId))

View File

@ -42,10 +42,14 @@ import {
Item,
ItemType,
LogicBlockType,
defaultAbTestOptions,
} from '@typebot.io/schemas'
const parseDefaultItems = (
type: LogicBlockType.CONDITION | InputBlockType.CHOICE,
type:
| LogicBlockType.CONDITION
| InputBlockType.CHOICE
| LogicBlockType.AB_TEST,
blockId: string
): Item[] => {
switch (type) {
@ -60,6 +64,11 @@ const parseDefaultItems = (
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 {}
case LogicBlockType.TYPEBOT_LINK:
return {}
case LogicBlockType.AB_TEST:
return defaultAbTestOptions
case IntegrationBlockType.GOOGLE_SHEETS:
return defaultGoogleSheetsOptions
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 { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable'
import { executeTypebotLink } from '@/features/blocks/logic/typebotLink/executeTypebotLink'
import { executeAbTest } from '@/features/blocks/logic/abTest/executeAbTest'
export const executeLogic =
(state: SessionState, lastBubbleBlockId?: string) =>
@ -26,5 +27,7 @@ export const executeLogic =
return executeWait(state, block, lastBubbleBlockId)
case LogicBlockType.JUMP:
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 = (
type: BlockType
): type is LogicBlockType.CONDITION | InputBlockType.CHOICE =>
type === LogicBlockType.CONDITION || type === InputBlockType.CHOICE
): type is
| LogicBlockType.CONDITION
| InputBlockType.CHOICE
| LogicBlockType.AB_TEST =>
type === LogicBlockType.CONDITION ||
type === InputBlockType.CHOICE ||
type === LogicBlockType.AB_TEST
export const blockHasItems = (
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',
WAIT = 'Wait',
JUMP = 'Jump',
AB_TEST = 'AB test',
}

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
import { z } from 'zod'
import { buttonItemSchema } from '../blocks/inputs/choice'
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>