2
0

Add picture choice block

Closes #476
This commit is contained in:
Baptiste Arnaud
2023-05-04 09:20:30 -04:00
parent 65c6f66a5c
commit 035dded654
54 changed files with 6282 additions and 4938 deletions

View File

@ -57,7 +57,7 @@ export const ImageUploadContent = ({
onClick={() => setCurrentTab('link')} onClick={() => setCurrentTab('link')}
size="sm" size="sm"
> >
Embed link Link
</Button> </Button>
<Button <Button
variant={currentTab === 'upload' ? 'solid' : 'ghost'} variant={currentTab === 'upload' ? 'solid' : 'ghost'}

View File

@ -2,6 +2,7 @@
import { import {
Alert, Alert,
AlertIcon, AlertIcon,
Box,
Flex, Flex,
Grid, Grid,
GridItem, GridItem,
@ -157,7 +158,7 @@ export const UnsplashPicker = ({ imageSize, onImageSelect }: Props) => {
)} )}
<Stack overflowY="scroll" maxH="400px" ref={scrollContainer}> <Stack overflowY="scroll" maxH="400px" ref={scrollContainer}>
{images.length > 0 && ( {images.length > 0 && (
<Grid templateColumns="repeat(4, 1fr)" columnGap={2} rowGap={3}> <Grid templateColumns="repeat(3, 1fr)" columnGap={2} rowGap={3}>
{images.map((image, index) => ( {images.map((image, index) => (
<GridItem <GridItem
as={Stack} as={Stack}
@ -190,11 +191,17 @@ type UnsplashImageProps = {
} }
const UnsplashImage = ({ image, onClick }: UnsplashImageProps) => { const UnsplashImage = ({ image, onClick }: UnsplashImageProps) => {
const linkColor = useColorModeValue('gray.500', 'gray.400') const [isImageHovered, setIsImageHovered] = useState(false)
const { user, urls, alt_description } = image const { user, urls, alt_description } = image
return ( return (
<> <Box
pos="relative"
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
h="full"
>
<Image <Image
objectFit="cover" objectFit="cover"
src={urls.thumb} src={urls.thumb}
@ -204,17 +211,28 @@ const UnsplashImage = ({ image, onClick }: UnsplashImageProps) => {
h="100%" h="100%"
cursor="pointer" cursor="pointer"
/> />
<TextLink <Box
fontSize="xs" pos="absolute"
isExternal bottom={0}
href={`https://unsplash.com/@${user.username}?utm_source=${env( left={0}
'UNSPLASH_APP_NAME' bgColor="rgba(0,0,0,.5)"
)}&utm_medium=referral`} px="2"
noOfLines={1} rounded="md"
color={linkColor} opacity={isImageHovered ? 1 : 0}
transition="opacity .2s ease-in-out"
> >
{user.name} <TextLink
</TextLink> fontSize="xs"
</> isExternal
href={`https://unsplash.com/@${user.username}?utm_source=${env(
'UNSPLASH_APP_NAME'
)}&utm_medium=referral`}
noOfLines={1}
color="white"
>
{user.name}
</TextLink>
</Box>
</Box>
) )
} }

View File

@ -0,0 +1,17 @@
import React from 'react'
import { SwitchWithLabel, SwitchWithLabelProps } from './inputs/SwitchWithLabel'
import { Stack } from '@chakra-ui/react'
type Props = SwitchWithLabelProps
export const SwitchWithRelatedSettings = ({ children, ...props }: Props) => (
<Stack
borderWidth={props.initialValue ? 1 : undefined}
rounded="md"
p={props.initialValue ? '4' : undefined}
spacing={4}
>
<SwitchWithLabel {...props} />
{props.initialValue && children}
</Stack>
)

View File

@ -8,7 +8,7 @@ import {
import React, { useState } from 'react' import React, { useState } from 'react'
import { MoreInfoTooltip } from '../MoreInfoTooltip' import { MoreInfoTooltip } from '../MoreInfoTooltip'
type SwitchWithLabelProps = { export type SwitchWithLabelProps = {
label: string label: string
initialValue: boolean initialValue: boolean
moreInfoContent?: string moreInfoContent?: string

View File

@ -48,7 +48,6 @@ test.describe.parallel('Image bubble block', () => {
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Click to edit...') await page.click('text=Click to edit...')
await page.click('text=Embed link')
await page.fill( await page.fill(
'input[placeholder="Paste the image link..."]', 'input[placeholder="Paste the image link..."]',
unsplashImageSrc unsplashImageSrc

View File

@ -1,10 +1,14 @@
import { TextInput } from '@/components/inputs' import { TextInput } from '@/components/inputs'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip' import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput' import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { FormControl, FormLabel, Stack } from '@chakra-ui/react' import { FormControl, FormLabel, Stack } from '@chakra-ui/react'
import { ChoiceInputOptions, Variable } from '@typebot.io/schemas' import {
ChoiceInputOptions,
Variable,
defaultChoiceInputOptions,
} from '@typebot.io/schemas'
import React from 'react' import React from 'react'
import { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
type Props = { type Props = {
options?: ChoiceInputOptions options?: ChoiceInputOptions
@ -18,6 +22,8 @@ export const ButtonsBlockSettings = ({ options, onOptionsChange }: Props) => {
options && onOptionsChange({ ...options, isSearchable }) options && onOptionsChange({ ...options, isSearchable })
const updateButtonLabel = (buttonLabel: string) => const updateButtonLabel = (buttonLabel: string) =>
options && onOptionsChange({ ...options, buttonLabel }) options && onOptionsChange({ ...options, buttonLabel })
const updateSearchInputPlaceholder = (searchInputPlaceholder: string) =>
options && onOptionsChange({ ...options, searchInputPlaceholder })
const updateSaveVariable = (variable?: Variable) => const updateSaveVariable = (variable?: Variable) =>
options && onOptionsChange({ ...options, variableId: variable?.id }) options && onOptionsChange({ ...options, variableId: variable?.id })
const updateDynamicDataVariable = (variable?: Variable) => const updateDynamicDataVariable = (variable?: Variable) =>
@ -25,23 +31,31 @@ export const ButtonsBlockSettings = ({ options, onOptionsChange }: Props) => {
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<SwitchWithLabel <SwitchWithRelatedSettings
label="Multiple choice?" label="Multiple choice?"
initialValue={options?.isMultipleChoice ?? false} initialValue={options?.isMultipleChoice ?? false}
onCheckChange={updateIsMultiple} onCheckChange={updateIsMultiple}
/> >
<SwitchWithLabel
label="Is searchable?"
initialValue={options?.isSearchable ?? false}
onCheckChange={updateIsSearchable}
/>
{options?.isMultipleChoice && (
<TextInput <TextInput
label="Button label:" label="Submit button label:"
defaultValue={options?.buttonLabel ?? 'Send'} defaultValue={options?.buttonLabel ?? 'Send'}
onChange={updateButtonLabel} onChange={updateButtonLabel}
/> />
)} </SwitchWithRelatedSettings>
<SwitchWithRelatedSettings
label="Is searchable?"
initialValue={options?.isSearchable ?? false}
onCheckChange={updateIsSearchable}
>
<TextInput
label="Input placeholder:"
defaultValue={
options?.searchInputPlaceholder ??
defaultChoiceInputOptions.searchInputPlaceholder
}
onChange={updateSearchInputPlaceholder}
/>
</SwitchWithRelatedSettings>
<FormControl> <FormControl>
<FormLabel> <FormLabel>
Dynamic data:{' '} Dynamic data:{' '}

View File

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

View File

@ -0,0 +1,148 @@
import {
Fade,
IconButton,
Flex,
Image,
Popover,
Portal,
PopoverContent,
PopoverArrow,
PopoverBody,
PopoverAnchor,
useEventListener,
useColorModeValue,
} from '@chakra-ui/react'
import { ImageIcon, PlusIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { ItemIndices, ItemType } from '@typebot.io/schemas'
import React, { useRef } from 'react'
import { PictureChoiceItem } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { PictureChoiceItemSettings } from './PictureChoiceItemSettings'
type Props = {
item: PictureChoiceItem
indices: ItemIndices
isMouseOver: boolean
}
export const PictureChoiceItemNode = ({
item,
indices,
isMouseOver,
}: Props) => {
const emptyImageBgColor = useColorModeValue('gray.100', 'gray.700')
const { openedItemId, setOpenedItemId } = useGraph()
const { updateItem, createItem, typebot } = useTypebot()
const ref = useRef<HTMLDivElement | null>(null)
const handlePlusClick = (e: React.MouseEvent) => {
e.stopPropagation()
const itemIndex = indices.itemIndex + 1
createItem(
{ blockId: item.blockId, type: ItemType.PICTURE_CHOICE },
{ ...indices, itemIndex }
)
}
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
const openPopover = () => {
setOpenedItemId(item.id)
}
const handleItemChange = (updates: Partial<PictureChoiceItem>) => {
updateItem(indices, { ...item, ...updates })
}
const handleMouseWheel = (e: WheelEvent) => {
e.stopPropagation()
}
useEventListener('wheel', handleMouseWheel, ref.current)
return (
<Popover
placement="right"
isLazy
isOpen={openedItemId === item.id}
closeOnBlur={false}
>
<PopoverAnchor>
<Flex
px={4}
py={2}
justify="center"
w="full"
pos="relative"
onClick={openPopover}
data-testid="item-node"
userSelect="none"
>
{item.pictureSrc ? (
<Image
src={item.pictureSrc}
alt="Picture choice image"
rounded="md"
maxH="128px"
w="full"
objectFit="cover"
userSelect="none"
draggable={false}
/>
) : (
<Flex
width="full"
height="100px"
bgColor={emptyImageBgColor}
rounded="md"
justify="center"
align="center"
>
<ImageIcon />
</Flex>
)}
<Fade
in={isMouseOver}
style={{
position: 'absolute',
bottom: '-15px',
zIndex: 3,
left: '90px',
}}
unmountOnExit
>
<IconButton
aria-label="Add item"
icon={<PlusIcon />}
size="xs"
shadow="md"
colorScheme="gray"
borderWidth={1}
onClick={handlePlusClick}
/>
</Fade>
</Flex>
</PopoverAnchor>
<Portal>
<PopoverContent pos="relative" onMouseDown={handleMouseDown}>
<PopoverArrow />
<PopoverBody
py="6"
overflowY="scroll"
maxH="400px"
shadow="lg"
ref={ref}
>
{typebot && (
<PictureChoiceItemSettings
typebotId={typebot.id}
item={item}
onItemChange={handleItemChange}
/>
)}
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
)
}

View File

@ -0,0 +1,69 @@
import React from 'react'
import { TextInput, Textarea } from '@/components/inputs'
import { PictureChoiceItem } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
import {
Button,
HStack,
Popover,
PopoverAnchor,
PopoverContent,
Stack,
Text,
useDisclosure,
} from '@chakra-ui/react'
import { ImageUploadContent } from '@/components/ImageUploadContent'
type Props = {
typebotId: string
item: PictureChoiceItem
onItemChange: (updates: Partial<PictureChoiceItem>) => void
}
export const PictureChoiceItemSettings = ({
typebotId,
item,
onItemChange,
}: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const updateTitle = (title: string) => onItemChange({ ...item, title })
const updateImage = (pictureSrc: string) => {
onItemChange({ ...item, pictureSrc })
onClose()
}
const updateDescription = (description: string) =>
onItemChange({ ...item, description })
return (
<Stack>
<HStack>
<Text fontWeight="medium">Image:</Text>
<Popover isLazy isOpen={isOpen}>
<PopoverAnchor>
<Button size="sm" onClick={onOpen}>
Pick an image
</Button>
</PopoverAnchor>
<PopoverContent p="4" w="500px">
<ImageUploadContent
filePath={`typebots/${typebotId}/blocks/${item.blockId}/items/${item.id}`}
defaultUrl={item.pictureSrc}
onSubmit={updateImage}
/>
</PopoverContent>
</Popover>
</HStack>
<TextInput
label="Title:"
defaultValue={item.title}
onChange={updateTitle}
/>
<Textarea
label="Description:"
defaultValue={item.description}
onChange={updateDescription}
/>
</Stack>
)
}

View File

@ -0,0 +1,42 @@
import { BlockIndices } from '@typebot.io/schemas'
import React from 'react'
import { Stack, Tag, Wrap, Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { SetVariableLabel } from '@/components/SetVariableLabel'
import { ItemNodesList } from '@/features/graph/components/nodes/item/ItemNodesList'
import { PictureChoiceBlock } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
type Props = {
block: PictureChoiceBlock
indices: BlockIndices
}
export const PictureChoiceNode = ({ block, indices }: Props) => {
const { typebot } = useTypebot()
const dynamicVariableName = typebot?.variables.find(
(variable) =>
variable.id === block.options.dynamicItems?.pictureSrcsVariableId
)?.name
return (
<Stack w="full">
{block.options.dynamicItems?.isEnabled && dynamicVariableName ? (
<Wrap spacing={1}>
<Text>Display</Text>
<Tag bg="orange.400" color="white">
{dynamicVariableName}
</Tag>
<Text>pictures</Text>
</Wrap>
) : (
<ItemNodesList block={block} indices={indices} />
)}
{block.options.variableId ? (
<SetVariableLabel
variableId={block.options.variableId}
variables={typebot?.variables}
/>
) : null}
</Stack>
)
}

View File

@ -0,0 +1,142 @@
import { TextInput } from '@/components/inputs'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { Variable } from '@typebot.io/schemas'
import React from 'react'
import {
PictureChoiceBlock,
defaultPictureChoiceOptions,
} from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
import { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
type Props = {
options?: PictureChoiceBlock['options']
onOptionsChange: (options: PictureChoiceBlock['options']) => void
}
export const PictureChoiceSettings = ({ options, onOptionsChange }: Props) => {
const updateIsMultiple = (isMultipleChoice: boolean) =>
options && onOptionsChange({ ...options, isMultipleChoice })
const updateButtonLabel = (buttonLabel: string) =>
options && onOptionsChange({ ...options, buttonLabel })
const updateSaveVariable = (variable?: Variable) =>
options && onOptionsChange({ ...options, variableId: variable?.id })
const updateSearchInputPlaceholder = (searchInputPlaceholder: string) =>
options && onOptionsChange({ ...options, searchInputPlaceholder })
const updateIsSearchable = (isSearchable: boolean) =>
options && onOptionsChange({ ...options, isSearchable })
const updateIsDynamicItemsEnabled = (isEnabled: boolean) =>
options &&
onOptionsChange({
...options,
dynamicItems: {
...options.dynamicItems,
isEnabled,
},
})
const updateDynamicItemsPictureSrcsVariable = (variable?: Variable) =>
options &&
onOptionsChange({
...options,
dynamicItems: {
...options.dynamicItems,
pictureSrcsVariableId: variable?.id,
},
})
const updateDynamicItemsTitlesVariable = (variable?: Variable) =>
options &&
onOptionsChange({
...options,
dynamicItems: {
...options.dynamicItems,
titlesVariableId: variable?.id,
},
})
const updateDynamicItemsDescriptionsVariable = (variable?: Variable) =>
options &&
onOptionsChange({
...options,
dynamicItems: {
...options.dynamicItems,
descriptionsVariableId: variable?.id,
},
})
return (
<Stack spacing={4}>
<SwitchWithRelatedSettings
label="Is searchable?"
initialValue={options?.isSearchable ?? false}
onCheckChange={updateIsSearchable}
>
<TextInput
label="Input placeholder:"
defaultValue={
options?.searchInputPlaceholder ??
defaultPictureChoiceOptions.searchInputPlaceholder
}
onChange={updateSearchInputPlaceholder}
/>
</SwitchWithRelatedSettings>
<SwitchWithRelatedSettings
label="Multiple choice?"
initialValue={options?.isMultipleChoice ?? false}
onCheckChange={updateIsMultiple}
>
<TextInput
label="Submit button label:"
defaultValue={options?.buttonLabel ?? 'Send'}
onChange={updateButtonLabel}
/>
</SwitchWithRelatedSettings>
<SwitchWithRelatedSettings
label="Dynamic items?"
initialValue={options?.dynamicItems?.isEnabled ?? false}
onCheckChange={updateIsDynamicItemsEnabled}
>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Images:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.dynamicItems?.pictureSrcsVariableId}
onSelectVariable={updateDynamicItemsPictureSrcsVariable}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Titles:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.dynamicItems?.titlesVariableId}
onSelectVariable={updateDynamicItemsTitlesVariable}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Descriptions:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.dynamicItems?.descriptionsVariableId}
onSelectVariable={updateDynamicItemsDescriptionsVariable}
/>
</Stack>
</SwitchWithRelatedSettings>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.variableId}
onSelectVariable={updateSaveVariable}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,121 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from '@typebot.io/lib/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseHelpers'
import { InputBlockType, ItemType } from '@typebot.io/schemas'
import { createId } from '@paralleldrive/cuid2'
import { defaultPictureChoiceOptions } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
const firstImageSrc =
'https://images.unsplash.com/flagged/photo-1575517111839-3a3843ee7f5d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2940&q=80'
const secondImageSrc =
'https://images.unsplash.com/photo-1582582621959-48d27397dc69?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2938&q=80'
const thirdImageSrc =
'https://images.unsplash.com/photo-1564019472231-4586c552dc27?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
test.describe.parallel('Picture choice input block', () => {
test('can edit items', async ({ page }) => {
const typebotId = createId()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.PICTURE_CHOICE,
items: [
{
id: 'choice1',
blockId: 'block1',
type: ItemType.PICTURE_CHOICE,
},
],
options: { ...defaultPictureChoiceOptions },
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.getByTestId('item-node').click()
await page.getByRole('button', { name: 'Pick an image' }).click()
await page.getByPlaceholder('Paste the image link...').fill(firstImageSrc)
await page.getByLabel('Title:').fill('First image')
await page.getByLabel('Description:').fill('First description')
await page.getByText('Default').click()
await page.getByRole('img', { name: 'Picture choice image' }).hover()
await page.getByRole('button', { name: 'Add item' }).click()
await page.getByTestId('item-node').last().click()
await page.getByRole('button', { name: 'Pick an image' }).click()
await page.getByPlaceholder('Paste the image link...').fill(secondImageSrc)
await page.getByLabel('Title:').fill('Second image')
await page.getByLabel('Description:').fill('Second description')
await page.getByRole('img', { name: 'Picture choice image' }).last().hover()
await page.getByRole('button', { name: 'Add item' }).click()
await page.getByTestId('item-node').last().click()
await expect(
page.getByRole('button', { name: 'Pick an image' })
).toHaveCount(1)
await page.getByRole('button', { name: 'Pick an image' }).click()
await page.getByPlaceholder('Paste the image link...').fill(thirdImageSrc)
await page.getByLabel('Title:').fill('Third image')
await page.getByLabel('Description:').fill('Third description')
await page.getByRole('button', { name: 'Preview' }).click()
await expect(
page.getByRole('button', {
name: 'First image First image First description',
})
).toBeVisible()
await expect(
page.getByRole('button', {
name: 'Second image Second image Second description',
})
).toBeVisible()
await page
.getByRole('button', {
name: 'Third image Third image Third description',
})
.click()
await expect(page.getByTestId('guest-bubble')).toBeVisible()
await expect(
page.locator('typebot-standard').getByText('Third image')
).toBeVisible()
await page.getByTestId('block2-icon').click()
await page.getByText('Multiple choice?').click()
await page.getByLabel('Submit button label:').fill('Go')
await page.getByRole('button', { name: 'Restart' }).click()
await page
.getByRole('checkbox', {
name: 'First image First image First description',
})
.click()
await page
.getByRole('checkbox', {
name: 'Second image Second image Second description',
})
.click()
await page.getByRole('button', { name: 'Go' }).click()
await expect(
page.locator('typebot-standard').getByText('First image, Second image')
).toBeVisible()
await page.getByTestId('block2-icon').click()
await page.getByText('Is searchable?').click()
await page.getByLabel('Input placeholder:').fill('Search...')
await page.getByRole('button', { name: 'Restart' }).click()
await page.getByPlaceholder('Search...').fill('second')
await expect(
page.getByRole('checkbox', {
name: 'First image First image First description',
})
).toBeHidden()
await page
.getByRole('checkbox', {
name: 'Second image Second image Second description',
})
.click()
await page.getByRole('button', { name: 'Go' }).click()
await expect(
page.locator('typebot-standard').getByText('Second image')
).toBeVisible()
})
})

View File

@ -6,7 +6,6 @@ import {
Wrap, Wrap,
Fade, Fade,
IconButton, IconButton,
PopoverTrigger,
Popover, Popover,
Portal, Portal,
PopoverContent, PopoverContent,
@ -14,6 +13,7 @@ import {
PopoverBody, PopoverBody,
useEventListener, useEventListener,
useColorModeValue, useColorModeValue,
PopoverAnchor,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider' import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { import {
@ -79,7 +79,7 @@ export const ConditionItemNode = ({ item, isMouseOver, indices }: Props) => {
isOpen={openedItemId === item.id} isOpen={openedItemId === item.id}
closeOnBlur={false} closeOnBlur={false}
> >
<PopoverTrigger> <PopoverAnchor>
<Flex p={3} pos="relative" w="full" onClick={openPopover}> <Flex p={3} pos="relative" w="full" onClick={openPopover}>
{item.content.comparisons.length === 0 || {item.content.comparisons.length === 0 ||
comparisonIsEmpty(item.content.comparisons[0]) ? ( comparisonIsEmpty(item.content.comparisons[0]) ? (
@ -101,7 +101,7 @@ export const ConditionItemNode = ({ item, isMouseOver, indices }: Props) => {
</Tag> </Tag>
)} )}
{comparison.comparisonOperator && ( {comparison.comparisonOperator && (
<Text> <Text fontSize="sm">
{parseComparisonOperatorSymbol( {parseComparisonOperatorSymbol(
comparison.comparisonOperator comparison.comparisonOperator
)} )}
@ -137,7 +137,7 @@ export const ConditionItemNode = ({ item, isMouseOver, indices }: Props) => {
/> />
</Fade> </Fade>
</Flex> </Flex>
</PopoverTrigger> </PopoverAnchor>
<Portal> <Portal>
<PopoverContent pos="relative" onMouseDown={handleMouseDown}> <PopoverContent pos="relative" onMouseDown={handleMouseDown}>
<PopoverArrow /> <PopoverArrow />
@ -164,7 +164,9 @@ const comparisonIsEmpty = (comparison: Comparison) =>
isNotDefined(comparison.value) && isNotDefined(comparison.value) &&
isNotDefined(comparison.variableId) isNotDefined(comparison.variableId)
const parseComparisonOperatorSymbol = (operator: ComparisonOperators) => { const parseComparisonOperatorSymbol = (
operator: ComparisonOperators
): string => {
switch (operator) { switch (operator) {
case ComparisonOperators.CONTAINS: case ComparisonOperators.CONTAINS:
return 'contains' return 'contains'
@ -178,5 +180,13 @@ const parseComparisonOperatorSymbol = (operator: ComparisonOperators) => {
return '<' return '<'
case ComparisonOperators.NOT_EQUAL: case ComparisonOperators.NOT_EQUAL:
return '!=' return '!='
case ComparisonOperators.ENDS_WITH:
return 'ends with'
case ComparisonOperators.STARTS_WITH:
return 'starts with'
case ComparisonOperators.IS_EMPTY:
return 'is empty'
case ComparisonOperators.NOT_CONTAINS:
return 'not contains'
} }
} }

View File

@ -38,6 +38,7 @@ import { RedirectIcon } from '@/features/blocks/logic/redirect/components/Redire
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' import { AbTestIcon } from '@/features/blocks/logic/abTest/components/AbTestIcon'
import { PictureChoiceIcon } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceIcon'
type BlockIconProps = { type: BlockType } & IconProps type BlockIconProps = { type: BlockType } & IconProps
@ -72,6 +73,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => {
return <PhoneInputIcon color={orange} {...props} /> return <PhoneInputIcon color={orange} {...props} />
case InputBlockType.CHOICE: case InputBlockType.CHOICE:
return <ButtonsInputIcon color={orange} {...props} /> return <ButtonsInputIcon color={orange} {...props} />
case InputBlockType.PICTURE_CHOICE:
return <PictureChoiceIcon color={orange} {...props} />
case InputBlockType.PAYMENT: case InputBlockType.PAYMENT:
return <PaymentInputIcon color={orange} {...props} /> return <PaymentInputIcon color={orange} {...props} />
case InputBlockType.RATING: case InputBlockType.RATING:

View File

@ -13,69 +13,71 @@ type Props = { type: BlockType }
export const BlockLabel = ({ type }: Props): JSX.Element => { export const BlockLabel = ({ type }: Props): JSX.Element => {
switch (type) { switch (type) {
case 'start': case 'start':
return <Text>Start</Text> return <Text fontSize="sm">Start</Text>
case BubbleBlockType.TEXT: case BubbleBlockType.TEXT:
case InputBlockType.TEXT: case InputBlockType.TEXT:
return <Text>Text</Text> return <Text fontSize="sm">Text</Text>
case BubbleBlockType.IMAGE: case BubbleBlockType.IMAGE:
return <Text>Image</Text> return <Text fontSize="sm">Image</Text>
case BubbleBlockType.VIDEO: case BubbleBlockType.VIDEO:
return <Text>Video</Text> return <Text fontSize="sm">Video</Text>
case BubbleBlockType.EMBED: case BubbleBlockType.EMBED:
return <Text>Embed</Text> return <Text fontSize="sm">Embed</Text>
case BubbleBlockType.AUDIO: case BubbleBlockType.AUDIO:
return <Text>Audio</Text> return <Text fontSize="sm">Audio</Text>
case InputBlockType.NUMBER: case InputBlockType.NUMBER:
return <Text>Number</Text> return <Text fontSize="sm">Number</Text>
case InputBlockType.EMAIL: case InputBlockType.EMAIL:
return <Text>Email</Text> return <Text fontSize="sm">Email</Text>
case InputBlockType.URL: case InputBlockType.URL:
return <Text>Website</Text> return <Text fontSize="sm">Website</Text>
case InputBlockType.DATE: case InputBlockType.DATE:
return <Text>Date</Text> return <Text fontSize="sm">Date</Text>
case InputBlockType.PHONE: case InputBlockType.PHONE:
return <Text>Phone</Text> return <Text fontSize="sm">Phone</Text>
case InputBlockType.CHOICE: case InputBlockType.CHOICE:
return <Text>Button</Text> return <Text fontSize="sm">Button</Text>
case InputBlockType.PICTURE_CHOICE:
return <Text fontSize="sm">Pic choice</Text>
case InputBlockType.PAYMENT: case InputBlockType.PAYMENT:
return <Text>Payment</Text> return <Text fontSize="sm">Payment</Text>
case InputBlockType.RATING: case InputBlockType.RATING:
return <Text>Rating</Text> return <Text fontSize="sm">Rating</Text>
case InputBlockType.FILE: case InputBlockType.FILE:
return <Text>File</Text> return <Text fontSize="sm">File</Text>
case LogicBlockType.SET_VARIABLE: case LogicBlockType.SET_VARIABLE:
return <Text>Set variable</Text> return <Text fontSize="sm">Set variable</Text>
case LogicBlockType.CONDITION: case LogicBlockType.CONDITION:
return <Text>Condition</Text> return <Text fontSize="sm">Condition</Text>
case LogicBlockType.REDIRECT: case LogicBlockType.REDIRECT:
return <Text>Redirect</Text> return <Text fontSize="sm">Redirect</Text>
case LogicBlockType.SCRIPT: case LogicBlockType.SCRIPT:
return <Text>Script</Text> return <Text fontSize="sm">Script</Text>
case LogicBlockType.TYPEBOT_LINK: case LogicBlockType.TYPEBOT_LINK:
return <Text>Typebot</Text> return <Text fontSize="sm">Typebot</Text>
case LogicBlockType.WAIT: case LogicBlockType.WAIT:
return <Text>Wait</Text> return <Text fontSize="sm">Wait</Text>
case LogicBlockType.JUMP: case LogicBlockType.JUMP:
return <Text>Jump</Text> return <Text fontSize="sm">Jump</Text>
case LogicBlockType.AB_TEST: case LogicBlockType.AB_TEST:
return <Text>AB Test</Text> return <Text fontSize="sm">AB Test</Text>
case IntegrationBlockType.GOOGLE_SHEETS: case IntegrationBlockType.GOOGLE_SHEETS:
return <Text>Sheets</Text> return <Text fontSize="sm">Sheets</Text>
case IntegrationBlockType.GOOGLE_ANALYTICS: case IntegrationBlockType.GOOGLE_ANALYTICS:
return <Text>Analytics</Text> return <Text fontSize="sm">Analytics</Text>
case IntegrationBlockType.WEBHOOK: case IntegrationBlockType.WEBHOOK:
return <Text>Webhook</Text> return <Text fontSize="sm">Webhook</Text>
case IntegrationBlockType.ZAPIER: case IntegrationBlockType.ZAPIER:
return <Text>Zapier</Text> return <Text fontSize="sm">Zapier</Text>
case IntegrationBlockType.MAKE_COM: case IntegrationBlockType.MAKE_COM:
return <Text>Make.com</Text> return <Text fontSize="sm">Make.com</Text>
case IntegrationBlockType.PABBLY_CONNECT: case IntegrationBlockType.PABBLY_CONNECT:
return <Text>Pabbly</Text> return <Text fontSize="sm">Pabbly</Text>
case IntegrationBlockType.EMAIL: case IntegrationBlockType.EMAIL:
return <Text>Email</Text> return <Text fontSize="sm">Email</Text>
case IntegrationBlockType.CHATWOOT: case IntegrationBlockType.CHATWOOT:
return <Text>Chatwoot</Text> return <Text fontSize="sm">Chatwoot</Text>
case IntegrationBlockType.OPEN_AI: case IntegrationBlockType.OPEN_AI:
return <Text>OpenAI</Text> return <Text fontSize="sm">OpenAI</Text>
} }
} }

View File

@ -3,24 +3,24 @@ import {
Item, Item,
BlockWithItems, BlockWithItems,
defaultConditionContent, defaultConditionContent,
ItemType,
Block, Block,
LogicBlockType, LogicBlockType,
InputBlockType, InputBlockType,
ConditionItem, ConditionItem,
ButtonItem, ButtonItem,
PictureChoiceItem,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { SetTypebot } from '../TypebotProvider' import { SetTypebot } from '../TypebotProvider'
import { Draft, produce } from 'immer' import { Draft, produce } from 'immer'
import { cleanUpEdgeDraft } from './edges' import { cleanUpEdgeDraft } from './edges'
import { byId, blockHasItems } from '@typebot.io/lib' import { byId, blockHasItems } from '@typebot.io/lib'
import { createId } from '@paralleldrive/cuid2' import { createId } from '@paralleldrive/cuid2'
import { DraggabbleItem } from '@/features/graph/providers/GraphDndProvider'
type NewItem = Pick< type NewItem = Pick<DraggabbleItem, 'blockId' | 'outgoingEdgeId' | 'type'> &
ConditionItem | ButtonItem, Partial<DraggabbleItem>
'blockId' | 'outgoingEdgeId' | 'type'
> & type BlockWithCreatableItems = Extract<Block, { items: DraggabbleItem[] }>
Partial<ConditionItem | ButtonItem>
export type ItemsActions = { export type ItemsActions = {
createItem: (item: NewItem, indices: ItemIndices) => void createItem: (item: NewItem, indices: ItemIndices) => void
@ -29,31 +29,40 @@ export type ItemsActions = {
deleteItem: (indices: ItemIndices) => void deleteItem: (indices: ItemIndices) => void
} }
const createItem = (block: Draft<Block>, item: NewItem, itemIndex: number) => { const createItem = (
block: Draft<BlockWithCreatableItems>,
item: NewItem,
itemIndex: number
): Item => {
switch (block.type) { switch (block.type) {
case LogicBlockType.CONDITION: { case LogicBlockType.CONDITION: {
if (item.type === ItemType.CONDITION) { const baseItem = item as ConditionItem
const newItem = { const newItem = {
...item, ...baseItem,
id: 'id' in item && item.id ? item.id : createId(), id: 'id' in item && item.id ? item.id : createId(),
content: item.content ?? defaultConditionContent, content: baseItem.content ?? defaultConditionContent,
}
block.items.splice(itemIndex, 0, newItem)
return newItem
} }
break block.items.splice(itemIndex, 0, newItem)
return newItem
} }
case InputBlockType.CHOICE: { case InputBlockType.CHOICE: {
if (item.type === ItemType.BUTTON) { const baseItem = item as ButtonItem
const newItem = { const newItem = {
...item, ...baseItem,
id: 'id' in item && item.id ? item.id : createId(), id: 'id' in item && item.id ? item.id : createId(),
content: item.content, content: baseItem.content,
}
block.items.splice(itemIndex, 0, newItem)
return newItem
} }
break block.items.splice(itemIndex, 0, newItem)
return newItem
}
case InputBlockType.PICTURE_CHOICE: {
const baseItem = item as PictureChoiceItem
const newItem = {
...baseItem,
id: 'id' in baseItem && item.id ? item.id : createId(),
}
block.items.splice(itemIndex, 0, newItem)
return newItem
} }
} }
} }
@ -65,7 +74,9 @@ const itemsAction = (setTypebot: SetTypebot): ItemsActions => ({
) => ) =>
setTypebot((typebot) => setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const block = typebot.groups[groupIndex].blocks[blockIndex] const block = typebot.groups[groupIndex].blocks[
blockIndex
] as BlockWithCreatableItems
const newItem = createItem(block, item, itemIndex) const newItem = createItem(block, item, itemIndex)

View File

@ -40,6 +40,7 @@ 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' import { AbTestNodeBody } from '@/features/blocks/logic/abTest/components/AbTestNodeBody'
import { PictureChoiceNode } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceNode'
type Props = { type Props = {
block: Block | StartBlock block: Block | StartBlock
@ -98,6 +99,9 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
case InputBlockType.CHOICE: { case InputBlockType.CHOICE: {
return <ButtonsBlockNode block={block} indices={indices} /> return <ButtonsBlockNode block={block} indices={indices} />
} }
case InputBlockType.PICTURE_CHOICE: {
return <PictureChoiceNode block={block} indices={indices} />
}
case InputBlockType.PHONE: { case InputBlockType.PHONE: {
return ( return (
<PhoneNodeContent <PhoneNodeContent

View File

@ -30,7 +30,7 @@ export const HelpDocButton = ({ blockType }: HelpDocButtonProps) => {
) )
} }
const getHelpDocUrl = (blockType: BlockWithOptions['type']): string | null => { const getHelpDocUrl = (blockType: BlockWithOptions['type']): string => {
switch (blockType) { switch (blockType) {
case LogicBlockType.TYPEBOT_LINK: case LogicBlockType.TYPEBOT_LINK:
return 'https://docs.typebot.io/editor/blocks/logic/typebot-link' return 'https://docs.typebot.io/editor/blocks/logic/typebot-link'
@ -76,7 +76,15 @@ const getHelpDocUrl = (blockType: BlockWithOptions['type']): string | null => {
return 'https://docs.typebot.io/editor/blocks/integrations/pabbly' return 'https://docs.typebot.io/editor/blocks/integrations/pabbly'
case IntegrationBlockType.WEBHOOK: case IntegrationBlockType.WEBHOOK:
return 'https://docs.typebot.io/editor/blocks/integrations/webhook' return 'https://docs.typebot.io/editor/blocks/integrations/webhook'
default: case InputBlockType.PICTURE_CHOICE:
return null return 'https://docs.typebot.io/editor/blocks/inputs/picture-choice'
case IntegrationBlockType.OPEN_AI:
return 'https://docs.typebot.io/editor/blocks/integrations/openai'
case IntegrationBlockType.MAKE_COM:
return 'https://docs.typebot.io/editor/blocks/integrations/make-com'
case LogicBlockType.AB_TEST:
return 'https://docs.typebot.io/editor/blocks/logic/abTest'
case LogicBlockType.JUMP:
return 'https://docs.typebot.io/editor/blocks/logic/jump'
} }
} }

View File

@ -46,6 +46,7 @@ import { PhoneInputSettings } from '@/features/blocks/inputs/phone/components/Ph
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' import { AbTestSettings } from '@/features/blocks/logic/abTest/components/AbTestSettings'
import { PictureChoiceSettings } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceSettings'
type Props = { type Props = {
block: BlockWithOptions block: BlockWithOptions
@ -160,6 +161,14 @@ export const BlockSettings = ({
/> />
) )
} }
case InputBlockType.PICTURE_CHOICE: {
return (
<PictureChoiceSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.PAYMENT: { case InputBlockType.PAYMENT: {
return ( return (
<PaymentSettings <PaymentSettings

View File

@ -109,6 +109,7 @@ export const ItemNode = ({
}} }}
pos="absolute" pos="absolute"
right="-49px" right="-49px"
bottom="9px"
pointerEvents="all" pointerEvents="all"
/> />
)} )}

View File

@ -1,4 +1,5 @@
import { ButtonsItemNode } from '@/features/blocks/inputs/buttons/components/ButtonsItemNode' import { ButtonsItemNode } from '@/features/blocks/inputs/buttons/components/ButtonsItemNode'
import { PictureChoiceItemNode } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceItemNode'
import { ConditionItemNode } from '@/features/blocks/logic/condition/components/ConditionItemNode' import { ConditionItemNode } from '@/features/blocks/logic/condition/components/ConditionItemNode'
import { Item, ItemIndices, ItemType } from '@typebot.io/schemas' import { Item, ItemIndices, ItemType } from '@typebot.io/schemas'
import React from 'react' import React from 'react'
@ -9,7 +10,11 @@ type Props = {
isMouseOver: boolean isMouseOver: boolean
} }
export const ItemNodeContent = ({ item, indices, isMouseOver }: Props) => { export const ItemNodeContent = ({
item,
indices,
isMouseOver,
}: Props): JSX.Element => {
switch (item.type) { switch (item.type) {
case ItemType.BUTTON: case ItemType.BUTTON:
return ( return (
@ -20,6 +25,14 @@ export const ItemNodeContent = ({ item, indices, isMouseOver }: Props) => {
indices={indices} indices={indices}
/> />
) )
case ItemType.PICTURE_CHOICE:
return (
<PictureChoiceItemNode
item={item}
isMouseOver={isMouseOver}
indices={indices}
/>
)
case ItemType.CONDITION: case ItemType.CONDITION:
return ( return (
<ConditionItemNode <ConditionItemNode

View File

@ -75,7 +75,7 @@ export const ItemNodesList = ({
}, [block.id, mouseOverBlock?.id, showPlaceholders]) }, [block.id, mouseOverBlock?.id, showPlaceholders])
const handleMouseMoveOnBlock = (event: MouseEvent) => { const handleMouseMoveOnBlock = (event: MouseEvent) => {
if (!isDraggingOnCurrentBlock) return if (!isDraggingOnCurrentBlock || !showPlaceholders) return
const index = computeNearestPlaceholderIndex(event.pageY, placeholderRefs) const index = computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
setExpandedPlaceholderIndex(index) setExpandedPlaceholderIndex(index)
} }

View File

@ -77,7 +77,7 @@ test.describe.parallel('Settings page', () => {
await expect(favIconImg).toHaveAttribute('src', '/favicon.png') await expect(favIconImg).toHaveAttribute('src', '/favicon.png')
await favIconImg.click() await favIconImg.click()
await expect(page.locator('text=Giphy')).toBeHidden() await expect(page.locator('text=Giphy')).toBeHidden()
await page.click('button:has-text("Embed link")') await page.click('button:has-text("Link")')
await page.fill( await page.fill(
'input[placeholder="Paste the image link..."]', 'input[placeholder="Paste the image link..."]',
favIconUrl favIconUrl
@ -92,7 +92,7 @@ test.describe.parallel('Settings page', () => {
await expect(websiteImg).toHaveAttribute('src', '/viewer-preview.png') await expect(websiteImg).toHaveAttribute('src', '/viewer-preview.png')
await websiteImg.click() await websiteImg.click()
await expect(page.locator('text=Giphy')).toBeHidden() await expect(page.locator('text=Giphy')).toBeHidden()
await page.click('button >> text="Embed link"') await page.click('button >> text="Link"')
await page.fill('input[placeholder="Paste the image link..."]', imageUrl) await page.fill('input[placeholder="Paste the image link..."]', imageUrl)
await expect(websiteImg).toHaveAttribute('src', imageUrl) await expect(websiteImg).toHaveAttribute('src', imageUrl)

View File

@ -76,7 +76,7 @@ test.describe.parallel('Theme page', () => {
// Host avatar // Host avatar
await expect(page.locator('[data-testid="default-avatar"]')).toBeVisible() await expect(page.locator('[data-testid="default-avatar"]')).toBeVisible()
await page.click('[data-testid="default-avatar"]') await page.click('[data-testid="default-avatar"]')
await page.click('button:has-text("Embed link")') await page.click('button:has-text("Link")')
await page.fill( await page.fill(
'input[placeholder="Paste the image link..."]', 'input[placeholder="Paste the image link..."]',
hostAvatarUrl hostAvatarUrl
@ -169,7 +169,7 @@ test.describe.parallel('Theme page', () => {
page.locator('[data-testid="default-avatar"] >> nth=-1') page.locator('[data-testid="default-avatar"] >> nth=-1')
).toBeVisible() ).toBeVisible()
await page.click('[data-testid="default-avatar"]') await page.click('[data-testid="default-avatar"]')
await page.click('button:has-text("Embed link")') await page.click('button:has-text("Link")')
await page await page
.locator('input[placeholder="Paste the image link..."]') .locator('input[placeholder="Paste the image link..."]')
.fill(guestAvatarUrl) .fill(guestAvatarUrl)

View File

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

View File

@ -43,18 +43,19 @@ import {
ItemType, ItemType,
LogicBlockType, LogicBlockType,
defaultAbTestOptions, defaultAbTestOptions,
BlockWithItems,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { defaultPictureChoiceOptions } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
const parseDefaultItems = ( const parseDefaultItems = (
type: type: BlockWithItems['type'],
| LogicBlockType.CONDITION
| InputBlockType.CHOICE
| LogicBlockType.AB_TEST,
blockId: string blockId: string
): Item[] => { ): Item[] => {
switch (type) { switch (type) {
case InputBlockType.CHOICE: case InputBlockType.CHOICE:
return [{ id: createId(), blockId, type: ItemType.BUTTON }] return [{ id: createId(), blockId, type: ItemType.BUTTON }]
case InputBlockType.PICTURE_CHOICE:
return [{ id: createId(), blockId, type: ItemType.PICTURE_CHOICE }]
case LogicBlockType.CONDITION: case LogicBlockType.CONDITION:
return [ return [
{ {
@ -103,6 +104,8 @@ const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => {
return defaultUrlInputOptions return defaultUrlInputOptions
case InputBlockType.CHOICE: case InputBlockType.CHOICE:
return defaultChoiceInputOptions return defaultChoiceInputOptions
case InputBlockType.PICTURE_CHOICE:
return defaultPictureChoiceOptions
case InputBlockType.PAYMENT: case InputBlockType.PAYMENT:
return defaultPaymentInputOptions return defaultPaymentInputOptions
case InputBlockType.RATING: case InputBlockType.RATING:

View File

@ -0,0 +1,11 @@
# Picture choice
The Picture choice input block allows you to offer your user predefined choices illustrated with a picture, either single choice options or multiple choices
<img
src="/img/blocks/inputs/picture-choice.png"
width="100%"
alt="Picture choice overview"
/>
For advanced configuration, check out the [Buttons block](./buttons) documentation. It works the same way.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -0,0 +1,55 @@
import {
SessionState,
VariableWithValue,
ItemType,
PictureChoiceBlock,
} from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib'
import { deepParseVariables } from '@/features/variables/deepParseVariable'
export const injectVariableValuesInPictureChoiceBlock =
(variables: SessionState['typebot']['variables']) =>
(block: PictureChoiceBlock): PictureChoiceBlock => {
if (
block.options.dynamicItems?.isEnabled &&
block.options.dynamicItems.pictureSrcsVariableId
) {
const pictureSrcsVariable = variables.find(
(variable) =>
variable.id === block.options.dynamicItems?.pictureSrcsVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined
if (!pictureSrcsVariable || typeof pictureSrcsVariable.value === 'string')
return block
const titlesVariable = block.options.dynamicItems.titlesVariableId
? (variables.find(
(variable) =>
variable.id === block.options.dynamicItems?.titlesVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined)
: undefined
const descriptionsVariable = block.options.dynamicItems
.descriptionsVariableId
? (variables.find(
(variable) =>
variable.id ===
block.options.dynamicItems?.descriptionsVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined)
: undefined
return {
...block,
items: pictureSrcsVariable.value
.filter(isDefined)
.map((pictureSrc, idx) => ({
id: idx.toString(),
type: ItemType.PICTURE_CHOICE,
blockId: block.id,
pictureSrc,
title: titlesVariable?.value?.[idx] ?? '',
description: descriptionsVariable?.value?.[idx] ?? '',
})),
}
}
return deepParseVariables(variables)(block)
}

View File

@ -21,6 +21,7 @@ import { executeIntegration } from './executeIntegration'
import { injectVariableValuesInButtonsInputBlock } from '@/features/blocks/inputs/buttons/injectVariableValuesInButtonsInputBlock' import { injectVariableValuesInButtonsInputBlock } from '@/features/blocks/inputs/buttons/injectVariableValuesInButtonsInputBlock'
import { deepParseVariables } from '@/features/variables/deepParseVariable' import { deepParseVariables } from '@/features/variables/deepParseVariable'
import { computePaymentInputRuntimeOptions } from '@/features/blocks/inputs/payment/computePaymentInputRuntimeOptions' import { computePaymentInputRuntimeOptions } from '@/features/blocks/inputs/payment/computePaymentInputRuntimeOptions'
import { injectVariableValuesInPictureChoiceBlock } from '@/features/blocks/inputs/pictureChoice/injectVariableValuesInPictureChoiceBlock'
export const executeGroup = export const executeGroup =
( (
@ -185,6 +186,11 @@ const injectVariablesValueInBlock =
block block
) )
} }
case InputBlockType.PICTURE_CHOICE: {
return injectVariableValuesInPictureChoiceBlock(
state.typebot.variables
)(block)
}
default: { default: {
return deepParseVariables(state.typebot.variables)({ return deepParseVariables(state.typebot.variables)({
...block, ...block,

View File

@ -170,5 +170,7 @@ const Input = ({
return ( return (
<FileUploadForm block={block} onSubmit={onSubmit} onSkip={onSkip} /> <FileUploadForm block={block} onSubmit={onSubmit} onSkip={onSkip} />
) )
default:
return null
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/js", "name": "@typebot.io/js",
"version": "0.0.45", "version": "0.0.46",
"description": "Javascript library to display typebots on your website", "description": "Javascript library to display typebots on your website",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@ -297,3 +297,40 @@ textarea {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
.typebot-picture-button {
color: var(--typebot-button-color);
background-color: var(--typebot-button-bg-color);
border-radius: var(--typebot-border-radius);
transition: all 0.3s ease;
width: 236px;
}
.typebot-picture-button > img,
.typebot-selectable-picture > img {
border-radius: var(--typebot-border-radius) var(--typebot-border-radius) 0 0;
min-width: 200px;
width: 100%;
max-height: 200px;
height: 100%;
object-fit: cover;
}
.typebot-selectable-picture {
border: 1px solid rgba(var(--typebot-button-bg-color-rgb), 0.25);
border-radius: var(--typebot-border-radius);
color: var(--typebot-container-color);
background-color: rgba(var(--typebot-button-bg-color-rgb), 0.08);
transition: all 0.3s ease;
width: 236px;
}
.typebot-selectable-picture:hover {
background-color: rgba(var(--typebot-button-bg-color-rgb), 0.12);
border-color: rgba(var(--typebot-button-bg-color-rgb), 0.3);
}
.typebot-selectable-picture.selected {
background-color: rgba(var(--typebot-button-bg-color-rgb), 0.18);
border-color: rgba(var(--typebot-button-bg-color-rgb), 0.35);
}

View File

@ -12,6 +12,7 @@ import type {
TextInputBlock, TextInputBlock,
Theme, Theme,
UrlInputBlock, UrlInputBlock,
PictureChoiceBlock,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/enums' import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/enums'
import { GuestBubble } from './bubbles/GuestBubble' import { GuestBubble } from './bubbles/GuestBubble'
@ -30,8 +31,8 @@ import { isMobile } from '@/utils/isMobileSignal'
import { PaymentForm } from '@/features/blocks/inputs/payment' import { PaymentForm } from '@/features/blocks/inputs/payment'
import { MultipleChoicesForm } from '@/features/blocks/inputs/buttons/components/MultipleChoicesForm' import { MultipleChoicesForm } from '@/features/blocks/inputs/buttons/components/MultipleChoicesForm'
import { Buttons } from '@/features/blocks/inputs/buttons/components/Buttons' import { Buttons } from '@/features/blocks/inputs/buttons/components/Buttons'
import { SearchableButtons } from '@/features/blocks/inputs/buttons/components/SearchableButtons' import { SinglePictureChoice } from '@/features/blocks/inputs/pictureChoice/SinglePictureChoice'
import { SearchableMultipleChoicesForm } from '@/features/blocks/inputs/buttons/components/SearchableMultipleChoicesForm' import { MultiplePictureChoice } from '@/features/blocks/inputs/pictureChoice/MultiplePictureChoice'
type Props = { type Props = {
block: NonNullable<ChatReply['input']> block: NonNullable<ChatReply['input']>
@ -165,42 +166,40 @@ const Input = (props: {
{(block) => ( {(block) => (
<Switch> <Switch>
<Match when={!block.options.isMultipleChoice}> <Match when={!block.options.isMultipleChoice}>
<Switch> <Buttons
<Match when={block.options.isSearchable}> inputIndex={props.inputIndex}
<SearchableButtons defaultItems={block.items}
inputIndex={props.inputIndex} options={block.options}
defaultItems={block.items} onSubmit={onSubmit}
onSubmit={onSubmit} />
/>
</Match>
<Match when={!block.options.isSearchable}>
<Buttons
inputIndex={props.inputIndex}
items={block.items}
onSubmit={onSubmit}
/>
</Match>
</Switch>
</Match> </Match>
<Match when={block.options.isMultipleChoice}> <Match when={block.options.isMultipleChoice}>
<Switch> <MultipleChoicesForm
<Match when={block.options.isSearchable}> inputIndex={props.inputIndex}
<SearchableMultipleChoicesForm defaultItems={block.items}
inputIndex={props.inputIndex} options={block.options}
defaultItems={block.items} onSubmit={onSubmit}
options={block.options} />
onSubmit={onSubmit} </Match>
/> </Switch>
</Match> )}
<Match when={!block.options.isSearchable}> </Match>
<MultipleChoicesForm <Match when={isPictureChoiceBlock(props.block)} keyed>
inputIndex={props.inputIndex} {(block) => (
items={block.items} <Switch>
options={block.options} <Match when={!block.options.isMultipleChoice}>
onSubmit={onSubmit} <SinglePictureChoice
/> defaultItems={block.items}
</Match> options={block.options}
</Switch> onSubmit={onSubmit}
/>
</Match>
<Match when={block.options.isMultipleChoice}>
<MultiplePictureChoice
defaultItems={block.items}
options={block.options}
onSubmit={onSubmit}
/>
</Match> </Match>
</Switch> </Switch>
)} )}
@ -240,3 +239,8 @@ const isButtonsBlock = (
block: ChatReply['input'] block: ChatReply['input']
): ChoiceInputBlock | undefined => ): ChoiceInputBlock | undefined =>
block?.type === InputBlockType.CHOICE ? block : undefined block?.type === InputBlockType.CHOICE ? block : undefined
const isPictureChoiceBlock = (
block: ChatReply['input']
): PictureChoiceBlock | undefined =>
block?.type === InputBlockType.PICTURE_CHOICE ? block : undefined

View File

@ -1,41 +1,82 @@
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { SearchInput } from '@/components/inputs/SearchInput'
import { InputSubmitContent } from '@/types' import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal' import { isMobile } from '@/utils/isMobileSignal'
import type { ChoiceInputBlock } from '@typebot.io/schemas' import type { ChoiceInputBlock } from '@typebot.io/schemas'
import { For } from 'solid-js' import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice'
import { For, Show, createSignal, onMount } from 'solid-js'
type Props = { type Props = {
inputIndex: number inputIndex: number
items: ChoiceInputBlock['items'] defaultItems: ChoiceInputBlock['items']
options: ChoiceInputBlock['options']
onSubmit: (value: InputSubmitContent) => void onSubmit: (value: InputSubmitContent) => void
} }
export const Buttons = (props: Props) => { export const Buttons = (props: Props) => {
let inputRef: HTMLInputElement | undefined
const [filteredItems, setFilteredItems] = createSignal(props.defaultItems)
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
const handleClick = (itemIndex: number) => () => const handleClick = (itemIndex: number) => () =>
props.onSubmit({ value: props.items[itemIndex].content ?? '' }) props.onSubmit({ value: filteredItems()[itemIndex].content ?? '' })
const filterItems = (inputValue: string) => {
setFilteredItems(
props.defaultItems.filter((item) =>
item.content?.toLowerCase().includes((inputValue ?? '').toLowerCase())
)
)
}
return ( return (
<div class="flex flex-wrap justify-end gap-2"> <div class="flex flex-col gap-2 w-full">
<For each={props.items}> <Show when={props.options.isSearchable}>
{(item, index) => ( <div class="flex items-end typebot-input w-full">
<span class={'relative' + (isMobile() ? ' w-full' : '')}> <SearchInput
<Button ref={inputRef}
on:click={handleClick(index())} onInput={filterItems}
data-itemid={item.id} placeholder={
class="w-full" props.options.searchInputPlaceholder ??
> defaultChoiceInputOptions.searchInputPlaceholder
{item.content} }
</Button> onClear={() => setFilteredItems(props.defaultItems)}
{props.inputIndex === 0 && props.items.length === 1 && ( />
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping"> </div>
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-200 opacity-75" /> </Show>
<span class="relative inline-flex rounded-full h-3 w-3 brightness-150" />
</span> <div
)} class={
</span> 'flex flex-wrap justify-end gap-2' +
)} (props.options.isSearchable
</For> ? ' overflow-y-scroll max-h-80 rounded-md hide-scrollbar'
: '')
}
>
<For each={filteredItems()}>
{(item, index) => (
<span class={'relative' + (isMobile() ? ' w-full' : '')}>
<Button
on:click={handleClick(index())}
data-itemid={item.id}
class="w-full"
>
{item.content}
</Button>
{props.inputIndex === 0 && props.defaultItems.length === 1 && (
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-200 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-150" />
</span>
)}
</span>
)}
</For>
</div>
</div> </div>
) )
} }

View File

@ -3,12 +3,17 @@ import { Show } from 'solid-js'
type Props = { type Props = {
isChecked: boolean isChecked: boolean
class?: string
} }
export const Checkbox = (props: Props) => { export const Checkbox = (props: Props) => {
return ( return (
<div <div
class={'w-4 h-4 typebot-checkbox' + (props.isChecked ? ' checked' : '')} class={
'w-4 h-4 typebot-checkbox' +
(props.isChecked ? ' checked' : '') +
(props.class ? ` ${props.class}` : '')
}
> >
<Show when={props.isChecked}> <Show when={props.isChecked}>
<CheckIcon /> <CheckIcon />

View File

@ -1,58 +1,98 @@
import { SendButton } from '@/components/SendButton' import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types' import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal' import { isMobile } from '@/utils/isMobileSignal'
import type { ChoiceInputBlock } from '@typebot.io/schemas' import { ChoiceInputBlock } from '@typebot.io/schemas'
import { createSignal, For } from 'solid-js' import { createSignal, For, onMount, Show } from 'solid-js'
import { Checkbox } from './Checkbox' import { Checkbox } from './Checkbox'
import { SearchInput } from '@/components/inputs/SearchInput'
import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice'
type Props = { type Props = {
inputIndex: number inputIndex: number
items: ChoiceInputBlock['items'] defaultItems: ChoiceInputBlock['items']
options: ChoiceInputBlock['options'] options: ChoiceInputBlock['options']
onSubmit: (value: InputSubmitContent) => void onSubmit: (value: InputSubmitContent) => void
} }
export const MultipleChoicesForm = (props: Props) => { export const MultipleChoicesForm = (props: Props) => {
const [selectedIndices, setSelectedIndices] = createSignal<number[]>([]) let inputRef: HTMLInputElement | undefined
const [filteredItems, setFilteredItems] = createSignal(props.defaultItems)
const [selectedItemIds, setSelectedItemIds] = createSignal<string[]>([])
const handleClick = (itemIndex: number) => { onMount(() => {
toggleSelectedItemIndex(itemIndex) if (!isMobile() && inputRef) inputRef.focus()
})
const handleClick = (itemId: string) => {
toggleSelectedItemId(itemId)
} }
const toggleSelectedItemIndex = (itemIndex: number) => { const toggleSelectedItemId = (itemId: string) => {
const existingIndex = selectedIndices().indexOf(itemIndex) const existingIndex = selectedItemIds().indexOf(itemId)
if (existingIndex !== -1) { if (existingIndex !== -1) {
setSelectedIndices((selectedIndices) => setSelectedItemIds((selectedItemIds) =>
selectedIndices.filter((index) => index !== itemIndex) selectedItemIds.filter((selectedItemId) => selectedItemId !== itemId)
) )
} else { } else {
setSelectedIndices((selectedIndices) => [...selectedIndices, itemIndex]) setSelectedItemIds((selectedIndices) => [...selectedIndices, itemId])
} }
} }
const handleSubmit = () => const handleSubmit = () =>
props.onSubmit({ props.onSubmit({
value: selectedIndices() value: selectedItemIds()
.map((itemIndex) => props.items[itemIndex].content) .map(
(selectedItemId) =>
props.defaultItems.find((item) => item.id === selectedItemId)
?.content
)
.join(', '), .join(', '),
}) })
const filterItems = (inputValue: string) => {
setFilteredItems(
props.defaultItems.filter((item) =>
item.content?.toLowerCase().includes((inputValue ?? '').toLowerCase())
)
)
}
return ( return (
<form class="flex flex-col items-end gap-2" onSubmit={handleSubmit}> <form class="flex flex-col items-end gap-2 w-full" onSubmit={handleSubmit}>
<div class="flex flex-wrap justify-end gap-2"> <Show when={props.options.isSearchable}>
<For each={props.items}> <div class="flex items-end typebot-input w-full">
{(item, index) => ( <SearchInput
ref={inputRef}
onInput={filterItems}
placeholder={
props.options.searchInputPlaceholder ??
defaultChoiceInputOptions.searchInputPlaceholder
}
onClear={() => setFilteredItems(props.defaultItems)}
/>
</div>
</Show>
<div
class={
'flex flex-wrap justify-end gap-2' +
(props.options.isSearchable
? ' overflow-y-scroll max-h-80 rounded-md hide-scrollbar'
: '')
}
>
<For each={filteredItems()}>
{(item) => (
<span class={'relative' + (isMobile() ? ' w-full' : '')}> <span class={'relative' + (isMobile() ? ' w-full' : '')}>
<div <div
role="checkbox" role="checkbox"
aria-checked={selectedIndices().some( aria-checked={selectedItemIds().some(
(selectedIndex) => selectedIndex === index() (selectedItemId) => selectedItemId === item.id
)} )}
on:click={() => handleClick(index())} on:click={() => handleClick(item.id)}
class={ class={
'w-full py-2 px-4 font-semibold focus:outline-none cursor-pointer select-none typebot-selectable' + 'w-full py-2 px-4 font-semibold focus:outline-none cursor-pointer select-none typebot-selectable' +
(selectedIndices().some( (selectedItemIds().some(
(selectedIndex) => selectedIndex === index() (selectedItemId) => selectedItemId === item.id
) )
? ' selected' ? ' selected'
: '') : '')
@ -61,8 +101,8 @@ export const MultipleChoicesForm = (props: Props) => {
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox <Checkbox
isChecked={selectedIndices().some( isChecked={selectedItemIds().some(
(selectedIndex) => selectedIndex === index() (selectedItemId) => selectedItemId === item.id
)} )}
/> />
<span>{item.content}</span> <span>{item.content}</span>
@ -71,8 +111,38 @@ export const MultipleChoicesForm = (props: Props) => {
</span> </span>
)} )}
</For> </For>
<For
each={selectedItemIds().filter((selectedItemId) =>
filteredItems().every((item) => item.id !== selectedItemId)
)}
>
{(selectedItemId) => (
<span class={'relative' + (isMobile() ? ' w-full' : '')}>
<div
role="checkbox"
aria-checked
on:click={() => handleClick(selectedItemId)}
class={
'w-full py-2 px-4 font-semibold focus:outline-none cursor-pointer select-none typebot-selectable selected'
}
data-itemid={selectedItemId}
>
<div class="flex items-center gap-2">
<Checkbox isChecked />
<span>
{
props.defaultItems.find(
(item) => item.id === selectedItemId
)?.content
}
</span>
</div>
</div>
</span>
)}
</For>
</div> </div>
{selectedIndices().length > 0 && ( {selectedItemIds().length > 0 && (
<SendButton disableIcon> <SendButton disableIcon>
{props.options?.buttonLabel ?? 'Send'} {props.options?.buttonLabel ?? 'Send'}
</SendButton> </SendButton>

View File

@ -1,68 +0,0 @@
import { Button } from '@/components/Button'
import { SearchInput } from '@/components/inputs/SearchInput'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { ChoiceInputBlock } from '@typebot.io/schemas'
import { For, createSignal, onMount } from 'solid-js'
type Props = {
inputIndex: number
defaultItems: ChoiceInputBlock['items']
onSubmit: (value: InputSubmitContent) => void
}
export const SearchableButtons = (props: Props) => {
let inputRef: HTMLInputElement | undefined
const [filteredItems, setFilteredItems] = createSignal(props.defaultItems)
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
// eslint-disable-next-line solid/reactivity
const handleClick = (itemIndex: number) => () =>
props.onSubmit({ value: filteredItems()[itemIndex].content ?? '' })
const filterItems = (inputValue: string) => {
setFilteredItems(
props.defaultItems.filter((item) =>
item.content?.toLowerCase().includes((inputValue ?? '').toLowerCase())
)
)
}
return (
<div class="flex flex-col gap-2 w-full">
<div class="flex items-end typebot-input w-full">
<SearchInput
ref={inputRef}
onInput={filterItems}
placeholder="Filter the options..."
onClear={() => setFilteredItems(props.defaultItems)}
/>
</div>
<div class="flex flex-wrap justify-end gap-2 overflow-y-scroll max-h-80 rounded-md hide-scrollbar">
<For each={filteredItems()}>
{(item, index) => (
<span class={'relative' + (isMobile() ? ' w-full' : '')}>
<Button
on:click={handleClick(index())}
data-itemid={item.id}
class="w-full"
>
{item.content}
</Button>
{props.inputIndex === 0 && props.defaultItems.length === 1 && (
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-200 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-150" />
</span>
)}
</span>
)}
</For>
</div>
</div>
)
}

View File

@ -1,132 +0,0 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { ChoiceInputBlock } from '@typebot.io/schemas'
import { createSignal, For } from 'solid-js'
import { Checkbox } from './Checkbox'
import { SearchInput } from '@/components/inputs/SearchInput'
type Props = {
inputIndex: number
defaultItems: ChoiceInputBlock['items']
options: ChoiceInputBlock['options']
onSubmit: (value: InputSubmitContent) => void
}
export const SearchableMultipleChoicesForm = (props: Props) => {
let inputRef: HTMLInputElement | undefined
const [filteredItems, setFilteredItems] = createSignal(props.defaultItems)
const [selectedItemIds, setSelectedItemIds] = createSignal<string[]>([])
const handleClick = (itemId: string) => {
toggleSelectedItemId(itemId)
}
const toggleSelectedItemId = (itemId: string) => {
const existingIndex = selectedItemIds().indexOf(itemId)
if (existingIndex !== -1) {
setSelectedItemIds((selectedItemIds) =>
selectedItemIds.filter((selectedItemId) => selectedItemId !== itemId)
)
} else {
setSelectedItemIds((selectedIndices) => [...selectedIndices, itemId])
}
}
const handleSubmit = () =>
props.onSubmit({
value: props.defaultItems
.filter((item) => selectedItemIds().includes(item.id))
.map((item) => item.content)
.join(', '),
})
const filterItems = (inputValue: string) => {
setFilteredItems(
props.defaultItems.filter((item) =>
item.content?.toLowerCase().includes((inputValue ?? '').toLowerCase())
)
)
}
return (
<form class="flex flex-col items-end gap-2 w-full" onSubmit={handleSubmit}>
<div class="flex items-end typebot-input w-full">
<SearchInput
ref={inputRef}
onInput={filterItems}
placeholder="Filter the options..."
onClear={() => setFilteredItems(props.defaultItems)}
/>
</div>
<div class="flex flex-wrap justify-end gap-2 overflow-y-scroll max-h-80 rounded-md hide-scrollbar">
<For each={filteredItems()}>
{(item) => (
<span class={'relative' + (isMobile() ? ' w-full' : '')}>
<div
role="checkbox"
aria-checked={selectedItemIds().some(
(selectedItemId) => selectedItemId === item.id
)}
on:click={() => handleClick(item.id)}
class={
'w-full py-2 px-4 font-semibold focus:outline-none cursor-pointer select-none typebot-selectable' +
(selectedItemIds().some(
(selectedItemId) => selectedItemId === item.id
)
? ' selected'
: '')
}
data-itemid={item.id}
>
<div class="flex items-center gap-2">
<Checkbox
isChecked={selectedItemIds().some(
(selectedItemId) => selectedItemId === item.id
)}
/>
<span>{item.content}</span>
</div>
</div>
</span>
)}
</For>
<For
each={selectedItemIds().filter((selectedItemId) =>
filteredItems().every((item) => item.id !== selectedItemId)
)}
>
{(selectedItemId) => (
<span class={'relative' + (isMobile() ? ' w-full' : '')}>
<div
role="checkbox"
aria-checked
on:click={() => handleClick(selectedItemId)}
class={
'w-full py-2 px-4 font-semibold focus:outline-none cursor-pointer select-none typebot-selectable selected'
}
data-itemid={selectedItemId}
>
<div class="flex items-center gap-2">
<Checkbox isChecked />
<span>
{
props.defaultItems.find(
(item) => item.id === selectedItemId
)?.content
}
</span>
</div>
</div>
</span>
)}
</For>
</div>
{selectedItemIds().length > 0 && (
<SendButton disableIcon>
{props.options?.buttonLabel ?? 'Send'}
</SendButton>
)}
</form>
)
}

View File

@ -0,0 +1,215 @@
import { InputSubmitContent } from '@/types'
import {
PictureChoiceBlock,
defaultPictureChoiceOptions,
} from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
import { For, Show, createSignal, onMount } from 'solid-js'
import { Checkbox } from '../buttons/components/Checkbox'
import { SendButton } from '@/components'
import { isDefined, isEmpty } from '@typebot.io/lib'
import { SearchInput } from '@/components/inputs/SearchInput'
import { isMobile } from '@/utils/isMobileSignal'
type Props = {
defaultItems: PictureChoiceBlock['items']
options: PictureChoiceBlock['options']
onSubmit: (value: InputSubmitContent) => void
}
export const MultiplePictureChoice = (props: Props) => {
let inputRef: HTMLInputElement | undefined
const [filteredItems, setFilteredItems] = createSignal(props.defaultItems)
const [selectedItemIds, setSelectedItemIds] = createSignal<string[]>([])
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
const handleClick = (itemId: string) => {
toggleSelectedItemId(itemId)
}
const toggleSelectedItemId = (itemId: string) => {
const existingIndex = selectedItemIds().indexOf(itemId)
if (existingIndex !== -1) {
setSelectedItemIds((selectedItemIds) =>
selectedItemIds.filter((selectedItemId) => selectedItemId !== itemId)
)
} else {
setSelectedItemIds((selectedIndices) => [...selectedIndices, itemId])
}
}
const handleSubmit = () =>
props.onSubmit({
value: selectedItemIds()
.map((selectedItemId) => {
const item = props.defaultItems.find(
(item) => item.id === selectedItemId
)
return item?.title ?? item?.pictureSrc
})
.join(', '),
})
const filterItems = (inputValue: string) => {
setFilteredItems(
props.defaultItems.filter(
(item) =>
item.title
?.toLowerCase()
.includes((inputValue ?? '').toLowerCase()) ||
item.description
?.toLowerCase()
.includes((inputValue ?? '').toLowerCase())
)
)
}
return (
<form class="flex flex-col gap-2 w-full items-end" onSubmit={handleSubmit}>
<Show when={props.options.isSearchable}>
<div class="flex items-end typebot-input w-full">
<SearchInput
ref={inputRef}
onInput={filterItems}
placeholder={
props.options.searchInputPlaceholder ??
defaultPictureChoiceOptions.searchInputPlaceholder
}
onClear={() => setFilteredItems(props.defaultItems)}
/>
</div>
</Show>
<div
class={
'flex flex-wrap justify-end gap-2' +
(props.options.isSearchable
? ' overflow-y-scroll max-h-[464px] rounded-md hide-scrollbar'
: '')
}
>
<For each={filteredItems()}>
{(item, index) => (
<div
role="checkbox"
aria-checked={selectedItemIds().some(
(selectedItemId) => selectedItemId === item.id
)}
on:click={() => handleClick(item.id)}
class={
'flex flex-col focus:outline-none cursor-pointer select-none typebot-selectable-picture' +
(selectedItemIds().some(
(selectedItemId) => selectedItemId === item.id
)
? ' selected'
: '')
}
data-itemid={item.id}
>
<img
src={item.pictureSrc}
alt={item.title ?? `Picture ${index() + 1}`}
elementtiming={`Picture choice ${index() + 1}`}
fetchpriority={'high'}
/>
<div
class={
'flex gap-3 py-2 flex-shrink-0' +
(isEmpty(item.title) && isEmpty(item.description)
? ' justify-center'
: ' pl-4')
}
>
<Checkbox
isChecked={selectedItemIds().some(
(selectedItemId) => selectedItemId === item.id
)}
class={item.title || item.description ? 'mt-1' : undefined}
/>
<Show when={item.title || item.description}>
<div class="flex flex-col gap-1 ">
<Show when={item.title}>
<span class="font-semibold">{item.title}</span>
</Show>
<Show when={item.description}>
<span class="text-sm">{item.description}</span>
</Show>
</div>
</Show>
</div>
</div>
)}
</For>
<For
each={selectedItemIds()
.filter((selectedItemId) =>
filteredItems().every((item) => item.id !== selectedItemId)
)
.map((selectedItemId) =>
props.defaultItems.find((item) => item.id === selectedItemId)
)
.filter(isDefined)}
>
{(selectedItem, index) => (
<div
role="checkbox"
aria-checked
on:click={() => handleClick(selectedItem.id)}
class={
'flex flex-col focus:outline-none cursor-pointer select-none typebot-selectable-picture selected'
}
data-itemid={selectedItem.id}
>
<img
src={
props.defaultItems.find((item) => item.id === selectedItem.id)
?.pictureSrc
}
alt={selectedItem.title ?? `Selected picture ${index() + 1}`}
elementtiming={`Selected picture choice ${index() + 1}`}
fetchpriority={'high'}
/>
<div
class={
'flex gap-3 py-2 flex-shrink-0' +
(isEmpty(selectedItem.title) &&
isEmpty(selectedItem.description)
? ' justify-center'
: ' pl-4')
}
>
<Checkbox
isChecked={selectedItemIds().some(
(selectedItemId) => selectedItemId === selectedItem.id
)}
class={
selectedItem.title || selectedItem.description
? 'mt-1'
: undefined
}
/>
<Show when={selectedItem.title || selectedItem.description}>
<div class="flex flex-col gap-1 ">
<Show when={selectedItem.title}>
<span class="font-semibold">{selectedItem.title}</span>
</Show>
<Show when={selectedItem.description}>
<span class="text-sm">{selectedItem.description}</span>
</Show>
</div>
</Show>
</div>
</div>
)}
</For>
</div>
{selectedItemIds().length > 0 && (
<SendButton disableIcon>
{props.options?.buttonLabel ??
defaultPictureChoiceOptions.buttonLabel}
</SendButton>
)}
</form>
)
}

View File

@ -0,0 +1,93 @@
import { SearchInput } from '@/components/inputs/SearchInput'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import { PictureChoiceBlock } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
import { For, Show, createSignal, onMount } from 'solid-js'
type Props = {
defaultItems: PictureChoiceBlock['items']
options: PictureChoiceBlock['options']
onSubmit: (value: InputSubmitContent) => void
}
export const SinglePictureChoice = (props: Props) => {
let inputRef: HTMLInputElement | undefined
const [filteredItems, setFilteredItems] = createSignal(props.defaultItems)
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
// eslint-disable-next-line solid/reactivity
const handleClick = (itemIndex: number) => () => {
const pictureSrc = filteredItems()[itemIndex].pictureSrc
if (!pictureSrc) return
return props.onSubmit({
value: filteredItems()[itemIndex].title ?? pictureSrc,
})
}
const filterItems = (inputValue: string) => {
setFilteredItems(
props.defaultItems.filter(
(item) =>
item.title
?.toLowerCase()
.includes((inputValue ?? '').toLowerCase()) ||
item.description
?.toLowerCase()
.includes((inputValue ?? '').toLowerCase())
)
)
}
return (
<div class="flex flex-col gap-2 w-full">
<Show when={props.options.isSearchable}>
<div class="flex items-end typebot-input w-full">
<SearchInput
ref={inputRef}
onInput={filterItems}
placeholder={props.options.searchInputPlaceholder ?? ''}
onClear={() => setFilteredItems(props.defaultItems)}
/>
</div>
</Show>
<div
class={
'gap-2 flex flex-wrap justify-end' +
(props.options.isSearchable
? ' overflow-y-scroll max-h-[464px] rounded-md hide-scrollbar'
: '')
}
>
<For each={filteredItems()}>
{(item, index) => (
<button
// eslint-disable-next-line solid/reactivity
on:click={handleClick(index())}
data-itemid={item.id}
class="flex flex-col typebot-picture-button focus:outline-none filter hover:brightness-90 active:brightness-75"
>
<img
src={item.pictureSrc}
alt={item.title ?? `Picture ${index() + 1}`}
elementtiming={`Picture choice ${index() + 1}`}
fetchpriority={'high'}
/>
<div
class={
'flex flex-col gap-1 py-2 flex-shrink-0 px-4 w-full' +
(item.description ? ' items-start' : '')
}
>
<span class="font-semibold">{item.title}</span>
<span class="text-sm">{item.description}</span>
</div>
</button>
)}
</For>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/react", "name": "@typebot.io/react",
"version": "0.0.45", "version": "0.0.46",
"description": "React library to display typebots on your website", "description": "React library to display typebots on your website",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -3,6 +3,7 @@ import {
BubbleBlockType, BubbleBlockType,
ComparisonOperators, ComparisonOperators,
InputBlockType, InputBlockType,
ItemType,
LogicalOperator, LogicalOperator,
LogicBlockType, LogicBlockType,
StartTypebot, StartTypebot,
@ -292,7 +293,7 @@ export const leadGenerationTypebot: StartTypebot = {
{ {
id: 'clckrlksq00113b6sz8naxdwx', id: 'clckrlksq00113b6sz8naxdwx',
blockId: 'clckrlksq00103b6s3exi90al', blockId: 'clckrlksq00103b6s3exi90al',
type: 1, type: ItemType.CONDITION,
content: { content: {
comparisons: [ comparisons: [
{ {
@ -323,7 +324,7 @@ export const leadGenerationTypebot: StartTypebot = {
{ {
id: 'clckrm1zr001a3b6s1hlfm2jh', id: 'clckrm1zr001a3b6s1hlfm2jh',
blockId: 'clckrm1zr00193b6szpz37plc', blockId: 'clckrm1zr00193b6szpz37plc',
type: 1, type: ItemType.CONDITION,
content: { content: {
comparisons: [ comparisons: [
{ {

View File

@ -18,6 +18,7 @@ import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/enums
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/enums' import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/enums'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/enums' import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/enums'
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/enums' import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/enums'
import { PictureChoiceBlock } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
export const sendRequest = async <ResponseData>( export const sendRequest = async <ResponseData>(
params: params:
@ -90,6 +91,10 @@ export const isTextInputBlock = (block: Block): block is TextInputBlock =>
export const isChoiceInput = (block: Block): block is ChoiceInputBlock => export const isChoiceInput = (block: Block): block is ChoiceInputBlock =>
block.type === InputBlockType.CHOICE block.type === InputBlockType.CHOICE
export const isPictureChoiceInput = (
block: Block
): block is PictureChoiceBlock => block.type === InputBlockType.PICTURE_CHOICE
export const isSingleChoiceInput = (block: Block): block is ChoiceInputBlock => export const isSingleChoiceInput = (block: Block): block is ChoiceInputBlock =>
block.type === InputBlockType.CHOICE && block.type === InputBlockType.CHOICE &&
'options' in block && 'options' in block &&
@ -138,7 +143,8 @@ export const blockTypeHasItems = (
| LogicBlockType.AB_TEST => | LogicBlockType.AB_TEST =>
type === LogicBlockType.CONDITION || type === LogicBlockType.CONDITION ||
type === InputBlockType.CHOICE || type === InputBlockType.CHOICE ||
type === LogicBlockType.AB_TEST type === LogicBlockType.AB_TEST ||
type === InputBlockType.PICTURE_CHOICE
export const blockHasItems = ( export const blockHasItems = (
block: Block block: Block
@ -158,7 +164,7 @@ interface Omit {
export const omit: Omit = (obj, ...keys) => { export const omit: Omit = (obj, ...keys) => {
const ret = {} as { const ret = {} as {
[K in keyof typeof obj]: typeof obj[K] [K in keyof typeof obj]: (typeof obj)[K]
} }
let key: keyof typeof obj let key: keyof typeof obj
for (key in obj) { for (key in obj) {

View File

@ -11,14 +11,16 @@ export const choiceInputOptionsSchema = optionBaseSchema.merge(
buttonLabel: z.string(), buttonLabel: z.string(),
dynamicVariableId: z.string().optional(), dynamicVariableId: z.string().optional(),
isSearchable: z.boolean().optional(), isSearchable: z.boolean().optional(),
searchInputPlaceholder: z.string().optional(),
}) })
) )
export const defaultChoiceInputOptions: ChoiceInputOptions = { export const defaultChoiceInputOptions = {
buttonLabel: defaultButtonLabel, buttonLabel: defaultButtonLabel,
searchInputPlaceholder: 'Filter the options...',
isMultipleChoice: false, isMultipleChoice: false,
isSearchable: false, isSearchable: false,
} } as const
export const buttonItemSchema = itemBaseSchema.merge( export const buttonItemSchema = itemBaseSchema.merge(
z.object({ z.object({

View File

@ -6,6 +6,7 @@ export enum InputBlockType {
DATE = 'date input', DATE = 'date input',
PHONE = 'phone number input', PHONE = 'phone number input',
CHOICE = 'choice input', CHOICE = 'choice input',
PICTURE_CHOICE = 'picture choice input',
PAYMENT = 'payment input', PAYMENT = 'payment input',
RATING = 'rating input', RATING = 'rating input',
FILE = 'file input', FILE = 'file input',

View File

@ -10,3 +10,4 @@ export * from './phone'
export * from './rating' export * from './rating'
export * from './text' export * from './text'
export * from './url' export * from './url'
export * from './pictureChoice'

View File

@ -0,0 +1,48 @@
import { z } from 'zod'
import { ItemType } from '../../items/enums'
import { itemBaseSchema } from '../../items/baseSchemas'
import { optionBaseSchema, blockBaseSchema } from '../baseSchemas'
import { defaultButtonLabel } from './constants'
import { InputBlockType } from './enums'
export const pictureChoiceOptionsSchema = optionBaseSchema.merge(
z.object({
isMultipleChoice: z.boolean().optional(),
isSearchable: z.boolean().optional(),
buttonLabel: z.string(),
searchInputPlaceholder: z.string(),
dynamicItems: z
.object({
isEnabled: z.boolean().optional(),
titlesVariableId: z.string().optional(),
descriptionsVariableId: z.string().optional(),
pictureSrcsVariableId: z.string().optional(),
})
.optional(),
})
)
export const pictureChoiceItemSchema = itemBaseSchema.merge(
z.object({
type: z.literal(ItemType.PICTURE_CHOICE),
pictureSrc: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
})
)
export const pictureChoiceBlockSchema = blockBaseSchema.merge(
z.object({
type: z.enum([InputBlockType.PICTURE_CHOICE]),
items: z.array(pictureChoiceItemSchema),
options: pictureChoiceOptionsSchema,
})
)
export type PictureChoiceItem = z.infer<typeof pictureChoiceItemSchema>
export type PictureChoiceBlock = z.infer<typeof pictureChoiceBlockSchema>
export const defaultPictureChoiceOptions: PictureChoiceBlock['options'] = {
buttonLabel: defaultButtonLabel,
searchInputPlaceholder: 'Filter the options...',
}

View File

@ -1,6 +1,6 @@
import { z } from 'zod' import { ZodDiscriminatedUnionOption, z } from 'zod'
import { BubbleBlockType } from './bubbles/enums' import { BubbleBlockType } from './bubbles/enums'
import { ChoiceInputBlock, choiceInputSchema } from './inputs/choice' import { choiceInputSchema } from './inputs/choice'
import { InputBlockType } from './inputs/enums' import { InputBlockType } from './inputs/enums'
import { IntegrationBlockType } from './integrations/enums' import { IntegrationBlockType } from './integrations/enums'
import { ConditionBlock, conditionBlockSchema } from './logic/condition' import { ConditionBlock, conditionBlockSchema } from './logic/condition'
@ -43,9 +43,10 @@ import {
typebotLinkBlockSchema, typebotLinkBlockSchema,
waitBlockSchema, waitBlockSchema,
abTestBlockSchema, abTestBlockSchema,
AbTestBlock,
} from './logic' } from './logic'
import { jumpBlockSchema } from './logic/jump' import { jumpBlockSchema } from './logic/jump'
import { pictureChoiceBlockSchema } from './inputs/pictureChoice'
import { Item } from '../items'
export type DraggableBlock = export type DraggableBlock =
| BubbleBlock | BubbleBlock
@ -66,10 +67,7 @@ export type DraggableBlockType =
| LogicBlockType | LogicBlockType
| IntegrationBlockType | IntegrationBlockType
export type BlockWithOptions = export type BlockWithOptions = Extract<Block, { options: any }>
| InputBlock
| Exclude<LogicBlock, ConditionBlock>
| IntegrationBlock
export type BlockWithOptionsType = export type BlockWithOptionsType =
| InputBlockType | InputBlockType
@ -81,8 +79,6 @@ export type BlockOptions =
| LogicBlockOptions | LogicBlockOptions
| IntegrationBlockOptions | IntegrationBlockOptions
export type BlockWithItems = ConditionBlock | ChoiceInputBlock | AbTestBlock
export type BlockBase = z.infer<typeof blockBaseSchema> export type BlockBase = z.infer<typeof blockBaseSchema>
export type BlockIndices = { export type BlockIndices = {
@ -90,18 +86,7 @@ export type BlockIndices = {
blockIndex: number blockIndex: number
} }
const bubbleBlockSchema = z.discriminatedUnion('type', [ export const inputBlockSchemas = [
textBubbleBlockSchema,
imageBubbleBlockSchema,
videoBubbleBlockSchema,
embedBubbleBlockSchema,
audioBubbleBlockSchema,
])
export type BubbleBlock = z.infer<typeof bubbleBlockSchema>
export type BubbleBlockContent = BubbleBlock['content']
export const inputBlockSchema = z.discriminatedUnion('type', [
textInputSchema, textInputSchema,
choiceInputSchema, choiceInputSchema,
emailInputSchema, emailInputSchema,
@ -112,12 +97,17 @@ export const inputBlockSchema = z.discriminatedUnion('type', [
paymentInputSchema, paymentInputSchema,
ratingInputBlockSchema, ratingInputBlockSchema,
fileInputStepSchema, fileInputStepSchema,
]) pictureChoiceBlockSchema,
] as const
export type InputBlock = z.infer<typeof inputBlockSchema> export const blockSchema = z.discriminatedUnion('type', [
export type InputBlockOptions = InputBlock['options'] startBlockSchema,
textBubbleBlockSchema,
export const logicBlockSchema = z.discriminatedUnion('type', [ imageBubbleBlockSchema,
videoBubbleBlockSchema,
embedBubbleBlockSchema,
audioBubbleBlockSchema,
...inputBlockSchemas,
scriptBlockSchema, scriptBlockSchema,
conditionBlockSchema, conditionBlockSchema,
redirectBlockSchema, redirectBlockSchema,
@ -126,19 +116,6 @@ export const logicBlockSchema = z.discriminatedUnion('type', [
waitBlockSchema, waitBlockSchema,
jumpBlockSchema, jumpBlockSchema,
abTestBlockSchema, abTestBlockSchema,
])
export type LogicBlock = z.infer<typeof logicBlockSchema>
export type LogicBlockOptions = LogicBlock extends
| {
options?: infer Options
}
| {}
? Options
: never
export const integrationBlockSchema = z.discriminatedUnion('type', [
chatwootBlockSchema, chatwootBlockSchema,
googleAnalyticsBlockSchema, googleAnalyticsBlockSchema,
googleSheetsBlockSchema, googleSheetsBlockSchema,
@ -150,15 +127,24 @@ export const integrationBlockSchema = z.discriminatedUnion('type', [
zapierBlockSchema, zapierBlockSchema,
]) ])
export type IntegrationBlock = z.infer<typeof integrationBlockSchema> export type Block = z.infer<typeof blockSchema>
export type BubbleBlock = Extract<Block, { type: BubbleBlockType }>
export type BubbleBlockContent = BubbleBlock['content']
export type InputBlock = Extract<Block, { type: InputBlockType }>
export type InputBlockOptions = InputBlock['options']
export type LogicBlock = Extract<Block, { type: LogicBlockType }>
export type LogicBlockOptions = LogicBlock extends
| {
options?: infer Options
}
| {}
? Options
: never
export type IntegrationBlock = Extract<Block, { type: IntegrationBlockType }>
export type IntegrationBlockOptions = IntegrationBlock['options'] export type IntegrationBlockOptions = IntegrationBlock['options']
export const blockSchema = z.union([ export type BlockWithItems = Extract<Block, { items: Item[] }>
startBlockSchema,
bubbleBlockSchema,
inputBlockSchema,
logicBlockSchema,
integrationBlockSchema,
])
export type Block = z.infer<typeof blockSchema>

View File

@ -1,4 +1,4 @@
import { z } from 'zod' import { ZodDiscriminatedUnion, z } from 'zod'
import { import {
googleAnalyticsOptionsSchema, googleAnalyticsOptionsSchema,
paymentInputRuntimeOptionsSchema, paymentInputRuntimeOptionsSchema,
@ -16,7 +16,7 @@ import {
} from './blocks/bubbles' } from './blocks/bubbles'
import { answerSchema } from './answer' import { answerSchema } from './answer'
import { BubbleBlockType } from './blocks/bubbles/enums' import { BubbleBlockType } from './blocks/bubbles/enums'
import { inputBlockSchema } from './blocks/schemas' import { inputBlockSchemas } from './blocks/schemas'
const typebotInSessionStateSchema = publicTypebotSchema.pick({ const typebotInSessionStateSchema = publicTypebotSchema.pick({
id: true, id: true,
@ -229,7 +229,8 @@ const clientSideActionSchema = z
export const chatReplySchema = z.object({ export const chatReplySchema = z.object({
messages: z.array(chatMessageSchema), messages: z.array(chatMessageSchema),
input: inputBlockSchema input: z
.discriminatedUnion('type', [...inputBlockSchemas])
.and( .and(
z.object({ z.object({
prefilledValue: z.string().optional(), prefilledValue: z.string().optional(),

View File

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

View File

@ -2,10 +2,14 @@ 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' import { aItemSchema, bItemSchema } from '../blocks'
import { pictureChoiceItemSchema } from '../blocks/inputs/pictureChoice'
const itemSchema = buttonItemSchema const itemSchema = z.union([
.or(conditionItemSchema) buttonItemSchema,
.or(aItemSchema) conditionItemSchema,
.or(bItemSchema) pictureChoiceItemSchema,
aItemSchema,
bItemSchema,
])
export type Item = z.infer<typeof itemSchema> export type Item = z.infer<typeof itemSchema>