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

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

View File

@@ -1,10 +1,14 @@
import { TextInput } from '@/components/inputs'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
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 { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
type Props = {
options?: ChoiceInputOptions
@@ -18,6 +22,8 @@ export const ButtonsBlockSettings = ({ options, onOptionsChange }: Props) => {
options && onOptionsChange({ ...options, isSearchable })
const updateButtonLabel = (buttonLabel: string) =>
options && onOptionsChange({ ...options, buttonLabel })
const updateSearchInputPlaceholder = (searchInputPlaceholder: string) =>
options && onOptionsChange({ ...options, searchInputPlaceholder })
const updateSaveVariable = (variable?: Variable) =>
options && onOptionsChange({ ...options, variableId: variable?.id })
const updateDynamicDataVariable = (variable?: Variable) =>
@@ -25,23 +31,31 @@ export const ButtonsBlockSettings = ({ options, onOptionsChange }: Props) => {
return (
<Stack spacing={4}>
<SwitchWithLabel
<SwitchWithRelatedSettings
label="Multiple choice?"
initialValue={options?.isMultipleChoice ?? false}
onCheckChange={updateIsMultiple}
/>
<SwitchWithLabel
label="Is searchable?"
initialValue={options?.isSearchable ?? false}
onCheckChange={updateIsSearchable}
/>
{options?.isMultipleChoice && (
>
<TextInput
label="Button label:"
label="Submit button label:"
defaultValue={options?.buttonLabel ?? 'Send'}
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>
<FormLabel>
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,
Fade,
IconButton,
PopoverTrigger,
Popover,
Portal,
PopoverContent,
@@ -14,6 +13,7 @@ import {
PopoverBody,
useEventListener,
useColorModeValue,
PopoverAnchor,
} from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import {
@@ -79,7 +79,7 @@ export const ConditionItemNode = ({ item, isMouseOver, indices }: Props) => {
isOpen={openedItemId === item.id}
closeOnBlur={false}
>
<PopoverTrigger>
<PopoverAnchor>
<Flex p={3} pos="relative" w="full" onClick={openPopover}>
{item.content.comparisons.length === 0 ||
comparisonIsEmpty(item.content.comparisons[0]) ? (
@@ -101,7 +101,7 @@ export const ConditionItemNode = ({ item, isMouseOver, indices }: Props) => {
</Tag>
)}
{comparison.comparisonOperator && (
<Text>
<Text fontSize="sm">
{parseComparisonOperatorSymbol(
comparison.comparisonOperator
)}
@@ -137,7 +137,7 @@ export const ConditionItemNode = ({ item, isMouseOver, indices }: Props) => {
/>
</Fade>
</Flex>
</PopoverTrigger>
</PopoverAnchor>
<Portal>
<PopoverContent pos="relative" onMouseDown={handleMouseDown}>
<PopoverArrow />
@@ -164,7 +164,9 @@ const comparisonIsEmpty = (comparison: Comparison) =>
isNotDefined(comparison.value) &&
isNotDefined(comparison.variableId)
const parseComparisonOperatorSymbol = (operator: ComparisonOperators) => {
const parseComparisonOperatorSymbol = (
operator: ComparisonOperators
): string => {
switch (operator) {
case ComparisonOperators.CONTAINS:
return 'contains'
@@ -178,5 +180,13 @@ const parseComparisonOperatorSymbol = (operator: ComparisonOperators) => {
return '<'
case ComparisonOperators.NOT_EQUAL:
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'
}
}