2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@ -0,0 +1,99 @@
import test, { expect } from '@playwright/test'
import {
createTypebots,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultChoiceInputOptions, InputBlockType, ItemType } from 'models'
import cuid from 'cuid'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
test.describe.parallel('Buttons input block', () => {
test('can edit button items', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.CHOICE,
items: [
{
id: 'choice1',
blockId: 'block1',
type: ItemType.BUTTON,
},
],
options: { ...defaultChoiceInputOptions },
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.fill('input[value="Click to edit"]', 'Item 1')
await page.press('input[value="Item 1"]', 'Enter')
await page.fill('input[value="Click to edit"]', 'Item 2')
await page.press('input[value="Item 2"]', 'Enter')
await page.fill('input[value="Click to edit"]', 'Item 3')
await page.press('input[value="Item 3"]', 'Enter')
await page.press('input[value="Click to edit"]', 'Escape')
await page.click('text=Item 2', { button: 'right' })
await page.click('text=Delete')
await expect(page.locator('text=Item 2')).toBeHidden()
await page.click('text=Preview')
const item3Button = typebotViewer(page).locator('button >> text=Item 3')
await item3Button.click()
await expect(item3Button).toBeHidden()
await expect(typebotViewer(page).locator('text=Item 3')).toBeVisible()
await page.click('button[aria-label="Close"]')
await page.click('[data-testid="block2-icon"]')
await page.click('text=Multiple choice?')
await page.fill('#button', 'Go')
await page.click('[data-testid="block2-icon"]')
await page.locator('text=Item 1').hover()
await page.waitForTimeout(1000)
await page.click('[aria-label="Add item"]')
await page.fill('input[value="Click to edit"]', 'Item 2')
await page.press('input[value="Item 2"]', 'Enter')
await page.click('text=Preview')
await typebotViewer(page).locator('button >> text="Item 3"').click()
await typebotViewer(page).locator('button >> text="Item 1"').click()
await typebotViewer(page).locator('text=Go').click()
await expect(
typebotViewer(page).locator('text="Item 3, Item 1"')
).toBeVisible()
})
})
test('Variable buttons should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/inputs/variableButton.json'),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await typebotViewer(page).locator('text=Variable item').click()
await expect(typebotViewer(page).locator('text=Variable item')).toBeVisible()
await expect(typebotViewer(page).locator('text=Ok great!')).toBeVisible()
await page.click('text="Item 1"')
await page.fill('input[value="Item 1"]', '{{Item 2}}')
await page.click('[data-testid="block1-icon"]')
await page.click('text=Multiple choice?')
await page.click('text="Restart"')
await typebotViewer(page).locator('text="Variable item" >> nth=0').click()
await typebotViewer(page).locator('text="Variable item" >> nth=1').click()
await typebotViewer(page).locator('text="Send"').click()
await expect(
typebotViewer(page).locator('text="Variable item, Variable item"')
).toBeVisible()
})

View File

@ -0,0 +1,93 @@
import {
EditablePreview,
EditableInput,
Editable,
Fade,
IconButton,
Flex,
} from '@chakra-ui/react'
import { PlusIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import { ButtonItem, ItemIndices, ItemType } from 'models'
import React, { useEffect, useRef, useState } from 'react'
import { isNotDefined } from 'utils'
type Props = {
item: ButtonItem
indices: ItemIndices
isMouseOver: boolean
}
export const ButtonNodeContent = ({ item, indices, isMouseOver }: Props) => {
const { deleteItem, updateItem, createItem } = useTypebot()
const [initialContent] = useState(item.content ?? '')
const [itemValue, setItemValue] = useState(item.content ?? 'Click to edit')
const editableRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (itemValue !== item.content)
setItemValue(item.content ?? 'Click to edit')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item])
const handleInputSubmit = () => {
if (itemValue === '') deleteItem(indices)
else
updateItem(indices, { content: itemValue === '' ? undefined : itemValue })
}
const handleKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape' && itemValue === 'Click to edit') deleteItem(indices)
if (e.key === 'Enter' && itemValue !== '' && initialContent === '')
handlePlusClick()
}
const handlePlusClick = () => {
const itemIndex = indices.itemIndex + 1
createItem(
{ blockId: item.blockId, type: ItemType.BUTTON },
{ ...indices, itemIndex }
)
}
return (
<Flex px={4} py={2} justify="center" w="90%" pos="relative">
<Editable
ref={editableRef}
flex="1"
startWithEditView={isNotDefined(item.content)}
value={itemValue}
onChange={setItemValue}
onSubmit={handleInputSubmit}
onKeyDownCapture={handleKeyPress}
maxW="180px"
>
<EditablePreview
w="full"
color={item.content !== 'Click to edit' ? 'inherit' : 'gray.500'}
cursor="pointer"
/>
<EditableInput />
</Editable>
<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"
onClick={handlePlusClick}
/>
</Fade>
</Flex>
)
}

View File

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

View File

@ -0,0 +1,54 @@
import { Input } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { ChoiceInputOptions, Variable } from 'models'
import React from 'react'
type ButtonsOptionsFormProps = {
options?: ChoiceInputOptions
onOptionsChange: (options: ChoiceInputOptions) => void
}
export const ButtonsOptionsForm = ({
options,
onOptionsChange,
}: ButtonsOptionsFormProps) => {
const handleIsMultipleChange = (isMultipleChoice: boolean) =>
options && onOptionsChange({ ...options, isMultipleChoice })
const handleButtonLabelChange = (buttonLabel: string) =>
options && onOptionsChange({ ...options, buttonLabel })
const handleVariableChange = (variable?: Variable) =>
options && onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<SwitchWithLabel
label="Multiple choice?"
initialValue={options?.isMultipleChoice ?? false}
onCheckChange={handleIsMultipleChange}
/>
{options?.isMultipleChoice && (
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options?.buttonLabel ?? 'Send'}
onChange={handleButtonLabelChange}
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,3 @@
export { ButtonsOptionsForm } from './components/ButtonsOptionsForm'
export { ButtonNodeContent } from './components/ButtonNodeContent'
export { ButtonsInputIcon } from './components/ButtonsInputIcon'

View File

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

View File

@ -0,0 +1,87 @@
import { Input } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { DateInputOptions, Variable } from 'models'
import React from 'react'
type DateInputSettingsBodyProps = {
options: DateInputOptions
onOptionsChange: (options: DateInputOptions) => void
}
export const DateInputSettingsBody = ({
options,
onOptionsChange,
}: DateInputSettingsBodyProps) => {
const handleFromChange = (from: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, from } })
const handleToChange = (to: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, to } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleIsRangeChange = (isRange: boolean) =>
onOptionsChange({ ...options, isRange })
const handleHasTimeChange = (hasTime: boolean) =>
onOptionsChange({ ...options, hasTime })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<SwitchWithLabel
label="Is range?"
initialValue={options.isRange}
onCheckChange={handleIsRangeChange}
/>
<SwitchWithLabel
label="With time?"
initialValue={options.isRange}
onCheckChange={handleHasTimeChange}
/>
{options.isRange && (
<Stack>
<FormLabel mb="0" htmlFor="from">
From label:
</FormLabel>
<Input
id="from"
defaultValue={options.labels.from}
onChange={handleFromChange}
/>
</Stack>
)}
{options?.isRange && (
<Stack>
<FormLabel mb="0" htmlFor="to">
To label:
</FormLabel>
<Input
id="to"
defaultValue={options.labels.to}
onChange={handleToChange}
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,6 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
export const DateNodeContent = () => (
<Text color={'gray.500'}>Pick a date...</Text>
)

View File

@ -0,0 +1,61 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultDateInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
test.describe('Date input block', () => {
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.DATE,
options: defaultDateInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator('[data-testid="from-date"]')
).toHaveAttribute('type', 'date')
await expect(typebotViewer(page).locator(`button`)).toBeDisabled()
await typebotViewer(page)
.locator('[data-testid="from-date"]')
.fill('2021-01-01')
await typebotViewer(page).locator(`button`).click()
await expect(typebotViewer(page).locator('text="01/01/2021"')).toBeVisible()
await page.click(`text=Pick a date...`)
await page.click('text=Is range?')
await page.click('text=With time?')
await page.fill('#from', 'Previous:')
await page.fill('#to', 'After:')
await page.fill('#button', 'Go')
await page.click('text=Restart')
await expect(
typebotViewer(page).locator(`[data-testid="from-date"]`)
).toHaveAttribute('type', 'datetime-local')
await expect(
typebotViewer(page).locator(`[data-testid="to-date"]`)
).toHaveAttribute('type', 'datetime-local')
await typebotViewer(page)
.locator('[data-testid="from-date"]')
.fill('2021-01-01T11:00')
await typebotViewer(page)
.locator('[data-testid="to-date"]')
.fill('2022-01-01T09:00')
await typebotViewer(page).locator(`button`).click()
await expect(
typebotViewer(page).locator(
'text="01/01/2021, 11:00 AM to 01/01/2022, 09:00 AM"'
)
).toBeVisible()
})
})

View File

@ -0,0 +1,3 @@
export { DateInputSettingsBody } from './components/DateInputSettingsBody'
export { DateNodeContent } from './components/DateNodeContent'
export { DateInputIcon } from './components/DateInputIcon'

View File

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

View File

@ -0,0 +1,11 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { EmailInputBlock } from 'models'
type Props = {
placeholder: EmailInputBlock['options']['labels']['placeholder']
}
export const EmailInputNodeContent = ({ placeholder }: Props) => (
<Text color={'gray.500'}>{placeholder}</Text>
)

View File

@ -0,0 +1,68 @@
import { Input } from '@/components/inputs'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { EmailInputOptions, Variable } from 'models'
import React from 'react'
type EmailInputSettingsBodyProps = {
options: EmailInputOptions
onOptionsChange: (options: EmailInputOptions) => void
}
export const EmailInputSettingsBody = ({
options,
onOptionsChange,
}: EmailInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleRetryMessageChange = (retryMessageContent: string) =>
onOptionsChange({ ...options, retryMessageContent })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<Input
id="placeholder"
defaultValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="retry">
Retry message:
</FormLabel>
<Input
id="retry"
defaultValue={options.retryMessageContent}
onChange={handleRetryMessageChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,59 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultEmailInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
test.describe('Email input block', () => {
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.EMAIL,
options: defaultEmailInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultEmailInputOptions.labels.placeholder}"]`
)
).toHaveAttribute('type', 'email')
await expect(typebotViewer(page).locator(`button`)).toBeDisabled()
await page.click(`text=${defaultEmailInputOptions.labels.placeholder}`)
await page.fill(
`input[value="${defaultEmailInputOptions.labels.placeholder}"]`,
'Your email...'
)
await expect(page.locator('text=Your email...')).toBeVisible()
await page.fill('#button', 'Go')
await page.fill(
`input[value="${defaultEmailInputOptions.retryMessageContent}"]`,
'Try again bro'
)
await page.click('text=Restart')
await typebotViewer(page)
.locator(`input[placeholder="Your email..."]`)
.fill('test@test')
await typebotViewer(page).locator('text=Go').click()
await expect(
typebotViewer(page).locator('text=Try again bro')
).toBeVisible()
await typebotViewer(page)
.locator(`input[placeholder="Your email..."]`)
.fill('test@test.com')
await typebotViewer(page).locator('text=Go').click()
await expect(
typebotViewer(page).locator('text=test@test.com')
).toBeVisible()
})
})

View File

@ -0,0 +1,3 @@
export { EmailInputSettingsBody } from './components/EmailInputSettingsBody'
export { EmailInputNodeContent } from './components/EmailInputNodeContent'
export { EmailInputIcon } from './components/EmailInputIcon'

View File

@ -0,0 +1,12 @@
import { Text } from '@chakra-ui/react'
import { FileInputOptions } from 'models'
type Props = {
options: FileInputOptions
}
export const FileInputContent = ({ options: { isMultipleAllowed } }: Props) => (
<Text noOfLines={1} pr="6">
Collect {isMultipleAllowed ? 'files' : 'file'}
</Text>
)

View File

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

View File

@ -0,0 +1,81 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { CodeEditor } from '@/components/CodeEditor'
import { FileInputOptions, Variable } from 'models'
import React from 'react'
import { Input, SmartNumberInput } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput'
type Props = {
options: FileInputOptions
onOptionsChange: (options: FileInputOptions) => void
}
export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handlePlaceholderLabelChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleMultipleFilesChange = (isMultipleAllowed: boolean) =>
onOptionsChange({ ...options, isMultipleAllowed })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleSizeLimitChange = (sizeLimit?: number) =>
onOptionsChange({ ...options, sizeLimit })
const handleRequiredChange = (isRequired: boolean) =>
onOptionsChange({ ...options, isRequired })
return (
<Stack spacing={4}>
<SwitchWithLabel
label="Required?"
initialValue={options.isRequired ?? true}
onCheckChange={handleRequiredChange}
/>
<SwitchWithLabel
label="Allow multiple files?"
initialValue={options.isMultipleAllowed}
onCheckChange={handleMultipleFilesChange}
/>
<Stack>
<FormLabel mb="0" htmlFor="limit">
Size limit (MB):
</FormLabel>
<SmartNumberInput
id="limit"
value={options.sizeLimit ?? 10}
onValueChange={handleSizeLimitChange}
/>
</Stack>
<Stack>
<FormLabel mb="0">Placeholder:</FormLabel>
<CodeEditor
lang="html"
onChange={handlePlaceholderLabelChange}
value={options.labels.placeholder}
height={'100px'}
withVariableButton={false}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
withVariableButton={false}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save upload URL{options.isMultipleAllowed ? 's' : ''} in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,81 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultFileInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
import { freeWorkspaceId } from 'utils/playwright/databaseSetup'
import { getTestAsset } from '@/test/utils/playwright'
test.describe.configure({ mode: 'parallel' })
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.FILE,
options: defaultFileInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator(`text=Click to upload`)
).toBeVisible()
await expect(typebotViewer(page).locator(`text="Skip"`)).toBeHidden()
await typebotViewer(page)
.locator(`input[type="file"]`)
.setInputFiles([getTestAsset('avatar.jpg')])
await expect(typebotViewer(page).locator(`text=File uploaded`)).toBeVisible()
await page.click('text="Collect file"')
await page.click('text="Required?"')
await page.click('text="Allow multiple files?"')
await page.fill('div[contenteditable=true]', '<strong>Upload now!!</strong>')
await page.fill('[value="Upload"]', 'Go')
await page.fill('input[value="10"]', '20')
await page.click('text="Restart"')
await expect(typebotViewer(page).locator(`text="Skip"`)).toBeVisible()
await expect(typebotViewer(page).locator(`text="Upload now!!"`)).toBeVisible()
await typebotViewer(page)
.locator(`input[type="file"]`)
.setInputFiles([
getTestAsset('avatar.jpg'),
getTestAsset('avatar.jpg'),
getTestAsset('avatar.jpg'),
])
await expect(typebotViewer(page).locator(`text="3"`)).toBeVisible()
await typebotViewer(page).locator('text="Go 3 files"').click()
await expect(
typebotViewer(page).locator(`text="3 files uploaded"`)
).toBeVisible()
})
test.describe('Free workspace', () => {
test("shouldn't be able to publish typebot", async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.FILE,
options: defaultFileInputOptions,
}),
workspaceId: freeWorkspaceId,
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text="Collect file"')
await page.click('text="Allow multiple files?"')
await page.click('text="Publish"')
await expect(
page.locator(
'text="You need to upgrade your plan in order to use file input blocks"'
)
).toBeVisible()
})
})

View File

@ -0,0 +1,3 @@
export { FileInputSettings } from './components/FileInputSettings'
export { FileInputContent } from './components/FileInputContent'
export { FileInputIcon } from './components/FileInputIcon'

View File

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

View File

@ -0,0 +1,94 @@
import { Input, SmartNumberInput } from '@/components/inputs'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { removeUndefinedFields } from '@/utils/helpers'
import { FormLabel, HStack, Stack } from '@chakra-ui/react'
import { NumberInputOptions, Variable } from 'models'
import React from 'react'
type NumberInputSettingsBodyProps = {
options: NumberInputOptions
onOptionsChange: (options: NumberInputOptions) => void
}
export const NumberInputSettingsBody = ({
options,
onOptionsChange,
}: NumberInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleMinChange = (min?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, min }))
const handleMaxChange = (max?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, max }))
const handleBlockChange = (block?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, block }))
const handleVariableChange = (variable?: Variable) => {
onOptionsChange({ ...options, variableId: variable?.id })
}
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<Input
id="placeholder"
defaultValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options?.labels?.button ?? 'Send'}
onChange={handleButtonLabelChange}
/>
</Stack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="min">
Min:
</FormLabel>
<SmartNumberInput
id="min"
value={options.min}
onValueChange={handleMinChange}
/>
</HStack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="max">
Max:
</FormLabel>
<SmartNumberInput
id="max"
value={options.max}
onValueChange={handleMaxChange}
/>
</HStack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="step">
Step:
</FormLabel>
<SmartNumberInput
id="step"
value={options.step}
onValueChange={handleBlockChange}
/>
</HStack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,11 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { NumberInputBlock } from 'models'
type Props = {
placeholder: NumberInputBlock['options']['labels']['placeholder']
}
export const NumberNodeContent = ({ placeholder }: Props) => (
<Text color={'gray.500'}>{placeholder}</Text>
)

View File

@ -0,0 +1,3 @@
export { NumberInputSettingsBody } from './components/NumberInputSettingsBody'
export { NumberNodeContent } from './components/NumberNodeContent'
export { NumberInputIcon } from './components/NumberInputIcon'

View File

@ -0,0 +1,51 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultNumberInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
test.describe('Number input block', () => {
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.NUMBER,
options: defaultNumberInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultNumberInputOptions.labels.placeholder}"]`
)
).toHaveAttribute('type', 'number')
await expect(typebotViewer(page).locator(`button`)).toBeDisabled()
await page.click(`text=${defaultNumberInputOptions.labels.placeholder}`)
await page.fill('#placeholder', 'Your number...')
await expect(page.locator('text=Your number...')).toBeVisible()
await page.fill('#button', 'Go')
await page.fill('[role="spinbutton"] >> nth=0', '0')
await page.fill('[role="spinbutton"] >> nth=1', '100')
await page.fill('[role="spinbutton"] >> nth=2', '10')
await page.click('text=Restart')
const input = typebotViewer(page).locator(
`input[placeholder="Your number..."]`
)
await input.fill('-1')
await input.press('Enter')
await input.fill('150')
await input.press('Enter')
await input.fill('50')
await input.press('Enter')
await expect(typebotViewer(page).locator('text=50')).toBeVisible()
})
})

View File

@ -0,0 +1,20 @@
import { Text } from '@chakra-ui/react'
import { PaymentInputBlock } from 'models'
type Props = {
block: PaymentInputBlock
}
export const PaymentInputContent = ({ block }: Props) => {
if (
!block.options.amount ||
!block.options.credentialsId ||
!block.options.currency
)
return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={1} pr="6">
Collect {block.options.amount} {block.options.currency}
</Text>
)
}

View File

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

View File

@ -0,0 +1,189 @@
import {
Stack,
useDisclosure,
Text,
Select,
HStack,
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
} from '@chakra-ui/react'
import { DropdownList } from '@/components/DropdownList'
import { CredentialsType, PaymentInputOptions, PaymentProvider } from 'models'
import React, { ChangeEvent, useState } from 'react'
import { currencies } from './currencies'
import { StripeConfigModal } from './StripeConfigModal'
import { CredentialsDropdown } from '@/features/credentials'
import { Input } from '@/components/inputs'
type Props = {
options: PaymentInputOptions
onOptionsChange: (options: PaymentInputOptions) => void
}
export const PaymentSettings = ({ options, onOptionsChange }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
const handleProviderChange = (provider: PaymentProvider) => {
onOptionsChange({
...options,
provider,
})
}
const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({
...options,
credentialsId,
})
}
const handleAmountChange = (amount?: string) =>
onOptionsChange({
...options,
amount,
})
const handleCurrencyChange = (e: ChangeEvent<HTMLSelectElement>) =>
onOptionsChange({
...options,
currency: e.target.value,
})
const handleNameChange = (name: string) =>
onOptionsChange({
...options,
additionalInformation: { ...options.additionalInformation, name },
})
const handleEmailChange = (email: string) =>
onOptionsChange({
...options,
additionalInformation: { ...options.additionalInformation, email },
})
const handlePhoneNumberChange = (phoneNumber: string) =>
onOptionsChange({
...options,
additionalInformation: { ...options.additionalInformation, phoneNumber },
})
const handleButtonLabelChange = (button: string) =>
onOptionsChange({
...options,
labels: { ...options.labels, button },
})
const handleSuccessLabelChange = (success: string) =>
onOptionsChange({
...options,
labels: { ...options.labels, success },
})
return (
<Stack spacing={4}>
<Stack>
<Text>Provider:</Text>
<DropdownList
onItemSelect={handleProviderChange}
items={Object.values(PaymentProvider)}
currentItem={options.provider}
/>
</Stack>
<Stack>
<Text>Account:</Text>
<CredentialsDropdown
type={CredentialsType.STRIPE}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
refreshDropdownKey={refreshCredentialsKey}
/>
</Stack>
<HStack>
<Stack>
<Text>Price amount:</Text>
<Input
onChange={handleAmountChange}
defaultValue={options.amount}
placeholder="30.00"
/>
</Stack>
<Stack>
<Text>Currency:</Text>
<Select
placeholder="Select option"
value={options.currency}
onChange={handleCurrencyChange}
>
{currencies.map((currency) => (
<option value={currency.code} key={currency.code}>
{currency.code}
</option>
))}
</Select>
</Stack>
</HStack>
<Stack>
<Text>Button label:</Text>
<Input
onChange={handleButtonLabelChange}
defaultValue={options.labels.button}
placeholder="Pay"
/>
</Stack>
<Stack>
<Text>Success message:</Text>
<Input
onChange={handleSuccessLabelChange}
defaultValue={options.labels.success ?? 'Success'}
placeholder="Success"
/>
</Stack>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Additional information
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<Stack>
<Text>Name:</Text>
<Input
defaultValue={options.additionalInformation?.name ?? ''}
onChange={handleNameChange}
placeholder="John Smith"
/>
</Stack>
<Stack>
<Text>Email:</Text>
<Input
defaultValue={options.additionalInformation?.email ?? ''}
onChange={handleEmailChange}
placeholder="john@gmail.com"
/>
</Stack>
<Stack>
<Text>Phone number:</Text>
<Input
defaultValue={options.additionalInformation?.phoneNumber ?? ''}
onChange={handlePhoneNumberChange}
placeholder="+33XXXXXXXXX"
/>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
<StripeConfigModal
isOpen={isOpen}
onClose={onClose}
onNewCredentials={handleCredentialsSelect}
/>
</Stack>
)
}

View File

@ -0,0 +1,184 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
FormControl,
FormLabel,
Stack,
Text,
HStack,
} from '@chakra-ui/react'
import { useUser } from '@/features/account'
import { CredentialsType, StripeCredentialsData } from 'models'
import React, { useState } from 'react'
import { useWorkspace } from '@/features/workspace'
import { omit } from 'utils'
import { useToast } from '@/hooks/useToast'
import { Input } from '@/components/inputs'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { TextLink } from '@/components/TextLink'
import { createCredentialsQuery } from '@/features/credentials'
type Props = {
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const StripeConfigModal = ({
isOpen,
onNewCredentials,
onClose,
}: Props) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const [isCreating, setIsCreating] = useState(false)
const { showToast } = useToast()
const [stripeConfig, setStripeConfig] = useState<
StripeCredentialsData & { name: string }
>({
name: '',
live: { publicKey: '', secretKey: '' },
test: { publicKey: '', secretKey: '' },
})
const handleNameChange = (name: string) =>
setStripeConfig({
...stripeConfig,
name,
})
const handlePublicKeyChange = (publicKey: string) =>
setStripeConfig({
...stripeConfig,
live: { ...stripeConfig.live, publicKey },
})
const handleSecretKeyChange = (secretKey: string) =>
setStripeConfig({
...stripeConfig,
live: { ...stripeConfig.live, secretKey },
})
const handleTestPublicKeyChange = (publicKey: string) =>
setStripeConfig({
...stripeConfig,
test: { ...stripeConfig.test, publicKey },
})
const handleTestSecretKeyChange = (secretKey: string) =>
setStripeConfig({
...stripeConfig,
test: { ...stripeConfig.test, secretKey },
})
const handleCreateClick = async () => {
if (!user?.email || !workspace?.id) return
setIsCreating(true)
const { data, error } = await createCredentialsQuery({
data: omit(stripeConfig, 'name'),
name: stripeConfig.name,
type: CredentialsType.STRIPE,
workspaceId: workspace.id,
})
setIsCreating(false)
if (error)
return showToast({ title: error.name, description: error.message })
if (!data?.credentials)
return showToast({ description: "Credentials wasn't created" })
onNewCredentials(data.credentials.id)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Connect Stripe account</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Stack as="form" spacing={4}>
<FormControl isRequired>
<FormLabel>Account name:</FormLabel>
<Input
onChange={handleNameChange}
placeholder="Typebot"
withVariableButton={false}
/>
</FormControl>
<Stack>
<FormLabel>
Test keys:{' '}
<MoreInfoTooltip>
Will be used when previewing the bot.
</MoreInfoTooltip>
</FormLabel>
<HStack>
<FormControl>
<Input
onChange={handleTestPublicKeyChange}
placeholder="pk_test_..."
withVariableButton={false}
/>
</FormControl>
<FormControl>
<Input
onChange={handleTestSecretKeyChange}
placeholder="sk_test_..."
withVariableButton={false}
/>
</FormControl>
</HStack>
</Stack>
<Stack>
<FormLabel>Live keys:</FormLabel>
<HStack>
<FormControl>
<Input
onChange={handlePublicKeyChange}
placeholder="pk_live_..."
withVariableButton={false}
/>
</FormControl>
<FormControl>
<Input
onChange={handleSecretKeyChange}
placeholder="sk_live_..."
withVariableButton={false}
/>
</FormControl>
</HStack>
</Stack>
<Text>
(You can find your keys{' '}
<TextLink href="https://dashboard.stripe.com/apikeys" isExternal>
here
</TextLink>
)
</Text>
</Stack>
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
onClick={handleCreateClick}
isDisabled={
stripeConfig.live.publicKey === '' ||
stripeConfig.name === '' ||
stripeConfig.live.secretKey === ''
}
isLoading={isCreating}
>
Connect
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,545 @@
// The STRIPE-supported currencies, sorted by code
// https://gist.github.com/chrisdavies/9e3f00889fb764013339632bd3f2a71b
export const currencies = [
{
code: 'AED',
description: 'United Arab Emirates Dirham',
},
{
code: 'AFN',
description: 'Afghan Afghani**',
},
{
code: 'ALL',
description: 'Albanian Lek',
},
{
code: 'AMD',
description: 'Armenian Dram',
},
{
code: 'ANG',
description: 'Netherlands Antillean Gulden',
},
{
code: 'AOA',
description: 'Angolan Kwanza**',
},
{
code: 'ARS',
description: 'Argentine Peso**',
},
{
code: 'AUD',
description: 'Australian Dollar',
},
{
code: 'AWG',
description: 'Aruban Florin',
},
{
code: 'AZN',
description: 'Azerbaijani Manat',
},
{
code: 'BAM',
description: 'Bosnia & Herzegovina Convertible Mark',
},
{
code: 'BBD',
description: 'Barbadian Dollar',
},
{
code: 'BDT',
description: 'Bangladeshi Taka',
},
{
code: 'BGN',
description: 'Bulgarian Lev',
},
{
code: 'BIF',
description: 'Burundian Franc',
},
{
code: 'BMD',
description: 'Bermudian Dollar',
},
{
code: 'BND',
description: 'Brunei Dollar',
},
{
code: 'BOB',
description: 'Bolivian Boliviano**',
},
{
code: 'BRL',
description: 'Brazilian Real**',
},
{
code: 'BSD',
description: 'Bahamian Dollar',
},
{
code: 'BWP',
description: 'Botswana Pula',
},
{
code: 'BZD',
description: 'Belize Dollar',
},
{
code: 'CAD',
description: 'Canadian Dollar',
},
{
code: 'CDF',
description: 'Congolese Franc',
},
{
code: 'CHF',
description: 'Swiss Franc',
},
{
code: 'CLP',
description: 'Chilean Peso**',
},
{
code: 'CNY',
description: 'Chinese Renminbi Yuan',
},
{
code: 'COP',
description: 'Colombian Peso**',
},
{
code: 'CRC',
description: 'Costa Rican Colón**',
},
{
code: 'CVE',
description: 'Cape Verdean Escudo**',
},
{
code: 'CZK',
description: 'Czech Koruna**',
},
{
code: 'DJF',
description: 'Djiboutian Franc**',
},
{
code: 'DKK',
description: 'Danish Krone',
},
{
code: 'DOP',
description: 'Dominican Peso',
},
{
code: 'DZD',
description: 'Algerian Dinar',
},
{
code: 'EGP',
description: 'Egyptian Pound',
},
{
code: 'ETB',
description: 'Ethiopian Birr',
},
{
code: 'EUR',
description: 'Euro',
},
{
code: 'FJD',
description: 'Fijian Dollar',
},
{
code: 'FKP',
description: 'Falkland Islands Pound**',
},
{
code: 'GBP',
description: 'British Pound',
},
{
code: 'GEL',
description: 'Georgian Lari',
},
{
code: 'GIP',
description: 'Gibraltar Pound',
},
{
code: 'GMD',
description: 'Gambian Dalasi',
},
{
code: 'GNF',
description: 'Guinean Franc**',
},
{
code: 'GTQ',
description: 'Guatemalan Quetzal**',
},
{
code: 'GYD',
description: 'Guyanese Dollar',
},
{
code: 'HKD',
description: 'Hong Kong Dollar',
},
{
code: 'HNL',
description: 'Honduran Lempira**',
},
{
code: 'HRK',
description: 'Croatian Kuna',
},
{
code: 'HTG',
description: 'Haitian Gourde',
},
{
code: 'HUF',
description: 'Hungarian Forint**',
},
{
code: 'IDR',
description: 'Indonesian Rupiah',
},
{
code: 'ILS',
description: 'Israeli New Sheqel',
},
{
code: 'INR',
description: 'Indian Rupee**',
},
{
code: 'ISK',
description: 'Icelandic Króna',
},
{
code: 'JMD',
description: 'Jamaican Dollar',
},
{
code: 'JPY',
description: 'Japanese Yen',
},
{
code: 'KES',
description: 'Kenyan Shilling',
},
{
code: 'KGS',
description: 'Kyrgyzstani Som',
},
{
code: 'KHR',
description: 'Cambodian Riel',
},
{
code: 'KMF',
description: 'Comorian Franc',
},
{
code: 'KRW',
description: 'South Korean Won',
},
{
code: 'KYD',
description: 'Cayman Islands Dollar',
},
{
code: 'KZT',
description: 'Kazakhstani Tenge',
},
{
code: 'LAK',
description: 'Lao Kip**',
},
{
code: 'LBP',
description: 'Lebanese Pound',
},
{
code: 'LKR',
description: 'Sri Lankan Rupee',
},
{
code: 'LRD',
description: 'Liberian Dollar',
},
{
code: 'LSL',
description: 'Lesotho Loti',
},
{
code: 'MAD',
description: 'Moroccan Dirham',
},
{
code: 'MDL',
description: 'Moldovan Leu',
},
{
code: 'MGA',
description: 'Malagasy Ariary',
},
{
code: 'MKD',
description: 'Macedonian Denar',
},
{
code: 'MNT',
description: 'Mongolian Tögrög',
},
{
code: 'MOP',
description: 'Macanese Pataca',
},
{
code: 'MRO',
description: 'Mauritanian Ouguiya',
},
{
code: 'MUR',
description: 'Mauritian Rupee**',
},
{
code: 'MVR',
description: 'Maldivian Rufiyaa',
},
{
code: 'MWK',
description: 'Malawian Kwacha',
},
{
code: 'MXN',
description: 'Mexican Peso**',
},
{
code: 'MYR',
description: 'Malaysian Ringgit',
},
{
code: 'MZN',
description: 'Mozambican Metical',
},
{
code: 'NAD',
description: 'Namibian Dollar',
},
{
code: 'NGN',
description: 'Nigerian Naira',
},
{
code: 'NIO',
description: 'Nicaraguan Córdoba**',
},
{
code: 'NOK',
description: 'Norwegian Krone',
},
{
code: 'NPR',
description: 'Nepalese Rupee',
},
{
code: 'NZD',
description: 'New Zealand Dollar',
},
{
code: 'PAB',
description: 'Panamanian Balboa**',
},
{
code: 'PEN',
description: 'Peruvian Nuevo Sol**',
},
{
code: 'PGK',
description: 'Papua New Guinean Kina',
},
{
code: 'PHP',
description: 'Philippine Peso',
},
{
code: 'PKR',
description: 'Pakistani Rupee',
},
{
code: 'PLN',
description: 'Polish Złoty',
},
{
code: 'PYG',
description: 'Paraguayan Guaraní**',
},
{
code: 'QAR',
description: 'Qatari Riyal',
},
{
code: 'RON',
description: 'Romanian Leu',
},
{
code: 'RSD',
description: 'Serbian Dinar',
},
{
code: 'RUB',
description: 'Russian Ruble',
},
{
code: 'RWF',
description: 'Rwandan Franc',
},
{
code: 'SAR',
description: 'Saudi Riyal',
},
{
code: 'SBD',
description: 'Solomon Islands Dollar',
},
{
code: 'SCR',
description: 'Seychellois Rupee',
},
{
code: 'SEK',
description: 'Swedish Krona',
},
{
code: 'SGD',
description: 'Singapore Dollar',
},
{
code: 'SHP',
description: 'Saint Helenian Pound**',
},
{
code: 'SLL',
description: 'Sierra Leonean Leone',
},
{
code: 'SOS',
description: 'Somali Shilling',
},
{
code: 'SRD',
description: 'Surinamese Dollar**',
},
{
code: 'STD',
description: 'São Tomé and Príncipe Dobra',
},
{
code: 'SVC',
description: 'Salvadoran Colón**',
},
{
code: 'SZL',
description: 'Swazi Lilangeni',
},
{
code: 'THB',
description: 'Thai Baht',
},
{
code: 'TJS',
description: 'Tajikistani Somoni',
},
{
code: 'TOP',
description: 'Tongan Paʻanga',
},
{
code: 'TRY',
description: 'Turkish Lira',
},
{
code: 'TTD',
description: 'Trinidad and Tobago Dollar',
},
{
code: 'TWD',
description: 'New Taiwan Dollar',
},
{
code: 'TZS',
description: 'Tanzanian Shilling',
},
{
code: 'UAH',
description: 'Ukrainian Hryvnia',
},
{
code: 'UGX',
description: 'Ugandan Shilling',
},
{
code: 'USD',
description: 'United States Dollar',
},
{
code: 'UYU',
description: 'Uruguayan Peso**',
},
{
code: 'UZS',
description: 'Uzbekistani Som',
},
{
code: 'VND',
description: 'Vietnamese Đồng',
},
{
code: 'VUV',
description: 'Vanuatu Vatu',
},
{
code: 'WST',
description: 'Samoan Tala',
},
{
code: 'XAF',
description: 'Central African Cfa Franc',
},
{
code: 'XCD',
description: 'East Caribbean Dollar',
},
{
code: 'XOF',
description: 'West African Cfa Franc**',
},
{
code: 'XPF',
description: 'Cfp Franc**',
},
{
code: 'YER',
description: 'Yemeni Rial',
},
{
code: 'ZAR',
description: 'South African Rand',
},
{
code: 'ZMW',
description: 'Zambian Kwacha',
},
]

View File

@ -0,0 +1 @@
export { PaymentSettings } from './PaymentSettings'

View File

@ -0,0 +1,3 @@
export { PaymentSettings } from './components/PaymentSettings'
export { PaymentInputContent } from './components/PaymentInputContent'
export { PaymentInputIcon } from './components/PaymentInputIcon'

View File

@ -0,0 +1,75 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultPaymentInputOptions, InputBlockType } from 'models'
import cuid from 'cuid'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { stripePaymentForm } from '@/test/utils/selectorUtils'
test.describe('Payment input block', () => {
test('Can configure Stripe account', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.PAYMENT,
options: defaultPaymentInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.click('text=Select an account')
await page.click('text=Connect new')
await page.fill('[placeholder="Typebot"]', 'My Stripe Account')
await page.fill(
'[placeholder="sk_test_..."]',
process.env.STRIPE_SECRET_KEY ?? ''
)
await page.fill(
'[placeholder="sk_live_..."]',
process.env.STRIPE_SECRET_KEY ?? ''
)
await page.fill(
'[placeholder="pk_test_..."]',
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY ?? ''
)
await page.fill(
'[placeholder="pk_live_..."]',
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY ?? ''
)
await expect(page.locator('button >> text="Connect"')).toBeEnabled()
await page.click('button >> text="Connect"')
await expect(page.locator('text="Secret test key:"')).toBeHidden()
await expect(page.locator('text="My Stripe Account"')).toBeVisible()
await page.fill('[placeholder="30.00"] >> nth=-1', '30.00')
await page.selectOption('select', 'EUR')
await page.click('text=Additional information')
await page.fill('[placeholder="John Smith"]', 'Baptiste')
await page.fill('[placeholder="john@gmail.com"]', 'baptiste@typebot.io')
await expect(page.locator('text="Phone number:"')).toBeVisible()
await page.click('text=Preview')
await stripePaymentForm(page)
.locator(`[placeholder="1234 1234 1234 1234"]`)
.fill('4000000000000002')
await stripePaymentForm(page)
.locator(`[placeholder="MM / YY"]`)
.fill('12 / 25')
await stripePaymentForm(page).locator(`[placeholder="CVC"]`).fill('240')
await typebotViewer(page).locator(`text="Pay 30€"`).click()
await expect(
typebotViewer(page).locator(`text="Your card has been declined."`)
).toBeVisible()
await stripePaymentForm(page)
.locator(`[placeholder="1234 1234 1234 1234"]`)
.fill('4242424242424242')
const zipInput = stripePaymentForm(page).getByPlaceholder('90210')
const isZipInputVisible = await zipInput.isVisible()
if (isZipInputVisible) await zipInput.fill('12345')
await typebotViewer(page).locator(`text="Pay 30€"`).click()
await expect(typebotViewer(page).locator(`text="Success"`)).toBeVisible()
})
})

View File

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

View File

@ -0,0 +1,11 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { PhoneNumberInputOptions } from 'models'
type Props = {
placeholder: PhoneNumberInputOptions['labels']['placeholder']
}
export const PhoneNodeContent = ({ placeholder }: Props) => (
<Text color={'gray.500'}>{placeholder}</Text>
)

View File

@ -0,0 +1,235 @@
import { Select } from '@chakra-ui/react'
import React, { ChangeEvent } from 'react'
type Props = {
countryCode?: string
onSelect: (countryCode: string) => void
}
export const CountryCodeSelect = ({ countryCode, onSelect }: Props) => {
const handleOnChange = (e: ChangeEvent<HTMLSelectElement>) => {
onSelect(e.target.value)
}
return (
<Select
placeholder="International"
value={countryCode}
onChange={handleOnChange}
>
<option value="DZ">Algeria (+213)</option>
<option value="AD">Andorra (+376)</option>
<option value="AO">Angola (+244)</option>
<option value="AI">Anguilla (+1264)</option>
<option value="AG">Antigua &amp; Barbuda (+1268)</option>
<option value="AR">Argentina (+54)</option>
<option value="AM">Armenia (+374)</option>
<option value="AW">Aruba (+297)</option>
<option value="AU">Australia (+61)</option>
<option value="AT">Austria (+43)</option>
<option value="AZ">Azerbaijan (+994)</option>
<option value="BS">Bahamas (+1242)</option>
<option value="BH">Bahrain (+973)</option>
<option value="BD">Bangladesh (+880)</option>
<option value="BB">Barbados (+1246)</option>
<option value="BY">Belarus (+375)</option>
<option value="BE">Belgium (+32)</option>
<option value="BZ">Belize (+501)</option>
<option value="BJ">Benin (+229)</option>
<option value="BM">Bermuda (+1441)</option>
<option value="BT">Bhutan (+975)</option>
<option value="BO">Bolivia (+591)</option>
<option value="BA">Bosnia Herzegovina (+387)</option>
<option value="BW">Botswana (+267)</option>
<option value="BR">Brazil (+55)</option>
<option value="BN">Brunei (+673)</option>
<option value="BG">Bulgaria (+359)</option>
<option value="BF">Burkina Faso (+226)</option>
<option value="BI">Burundi (+257)</option>
<option value="KH">Cambodia (+855)</option>
<option value="CM">Cameroon (+237)</option>
<option value="CA">Canada (+1)</option>
<option value="CV">Cape Verde Islands (+238)</option>
<option value="KY">Cayman Islands (+1345)</option>
<option value="CF">Central African Republic (+236)</option>
<option value="CL">Chile (+56)</option>
<option value="CN">China (+86)</option>
<option value="CO">Colombia (+57)</option>
<option value="KM">Comoros (+269)</option>
<option value="CG">Congo (+242)</option>
<option value="CK">Cook Islands (+682)</option>
<option value="CR">Costa Rica (+506)</option>
<option value="HR">Croatia (+385)</option>
<option value="CU">Cuba (+53)</option>
<option value="CY">Cyprus North (+90392)</option>
<option value="CY">Cyprus South (+357)</option>
<option value="CZ">Czech Republic (+42)</option>
<option value="DK">Denmark (+45)</option>
<option value="DJ">Djibouti (+253)</option>
<option value="DM">Dominica (+1809)</option>
<option value="DO">Dominican Republic (+1809)</option>
<option value="EC">Ecuador (+593)</option>
<option value="EG">Egypt (+20)</option>
<option value="SV">El Salvador (+503)</option>
<option value="GQ">Equatorial Guinea (+240)</option>
<option value="ER">Eritrea (+291)</option>
<option value="EE">Estonia (+372)</option>
<option value="ET">Ethiopia (+251)</option>
<option value="FK">Falkland Islands (+500)</option>
<option value="FO">Faroe Islands (+298)</option>
<option value="FJ">Fiji (+679)</option>
<option value="FI">Finland (+358)</option>
<option value="FR">France (+33)</option>
<option value="GF">French Guiana (+594)</option>
<option value="PF">French Polynesia (+689)</option>
<option value="GA">Gabon (+241)</option>
<option value="GM">Gambia (+220)</option>
<option value="GE">Georgia (+7880)</option>
<option value="DE">Germany (+49)</option>
<option value="GH">Ghana (+233)</option>
<option value="GI">Gibraltar (+350)</option>
<option value="GR">Greece (+30)</option>
<option value="GL">Greenland (+299)</option>
<option value="GD">Grenada (+1473)</option>
<option value="GP">Guadeloupe (+590)</option>
<option value="GU">Guam (+671)</option>
<option value="GT">Guatemala (+502)</option>
<option value="GN">Guinea (+224)</option>
<option value="GW">Guinea - Bissau (+245)</option>
<option value="GY">Guyana (+592)</option>
<option value="HT">Haiti (+509)</option>
<option value="HN">Honduras (+504)</option>
<option value="HK">Hong Kong (+852)</option>
<option value="HU">Hungary (+36)</option>
<option value="IS">Iceland (+354)</option>
<option value="IN">India (+91)</option>
<option value="ID">Indonesia (+62)</option>
<option value="IR">Iran (+98)</option>
<option value="IQ">Iraq (+964)</option>
<option value="IE">Ireland (+353)</option>
<option value="IL">Israel (+972)</option>
<option value="IT">Italy (+39)</option>
<option value="JM">Jamaica (+1876)</option>
<option value="JP">Japan (+81)</option>
<option value="JO">Jordan (+962)</option>
<option value="KZ">Kazakhstan (+7)</option>
<option value="KE">Kenya (+254)</option>
<option value="KI">Kiribati (+686)</option>
<option value="KP">Korea North (+850)</option>
<option value="KR">Korea South (+82)</option>
<option value="KW">Kuwait (+965)</option>
<option value="KG">Kyrgyzstan (+996)</option>
<option value="LA">Laos (+856)</option>
<option value="LV">Latvia (+371)</option>
<option value="LB">Lebanon (+961)</option>
<option value="LS">Lesotho (+266)</option>
<option value="LR">Liberia (+231)</option>
<option value="LY">Libya (+218)</option>
<option value="LI">Liechtenstein (+417)</option>
<option value="LT">Lithuania (+370)</option>
<option value="LU">Luxembourg (+352)</option>
<option value="MO">Macao (+853)</option>
<option value="MK">Macedonia (+389)</option>
<option value="MG">Madagascar (+261)</option>
<option value="MW">Malawi (+265)</option>
<option value="MY">Malaysia (+60)</option>
<option value="MV">Maldives (+960)</option>
<option value="ML">Mali (+223)</option>
<option value="MT">Malta (+356)</option>
<option value="MH">Marshall Islands (+692)</option>
<option value="MQ">Martinique (+596)</option>
<option value="MR">Mauritania (+222)</option>
<option value="YT">Mayotte (+269)</option>
<option value="MX">Mexico (+52)</option>
<option value="FM">Micronesia (+691)</option>
<option value="MD">Moldova (+373)</option>
<option value="MC">Monaco (+377)</option>
<option value="MN">Mongolia (+976)</option>
<option value="MS">Montserrat (+1664)</option>
<option value="MA">Morocco (+212)</option>
<option value="MZ">Mozambique (+258)</option>
<option value="MN">Myanmar (+95)</option>
<option value="NA">Namibia (+264)</option>
<option value="NR">Nauru (+674)</option>
<option value="NP">Nepal (+977)</option>
<option value="NL">Netherlands (+31)</option>
<option value="NC">New Caledonia (+687)</option>
<option value="NZ">New Zealand (+64)</option>
<option value="NI">Nicaragua (+505)</option>
<option value="NE">Niger (+227)</option>
<option value="NG">Nigeria (+234)</option>
<option value="NU">Niue (+683)</option>
<option value="NF">Norfolk Islands (+672)</option>
<option value="NP">Northern Marianas (+670)</option>
<option value="NO">Norway (+47)</option>
<option value="OM">Oman (+968)</option>
<option value="PW">Palau (+680)</option>
<option value="PA">Panama (+507)</option>
<option value="PG">Papua New Guinea (+675)</option>
<option value="PY">Paraguay (+595)</option>
<option value="PE">Peru (+51)</option>
<option value="PH">Philippines (+63)</option>
<option value="PL">Poland (+48)</option>
<option value="PT">Portugal (+351)</option>
<option value="PR">Puerto Rico (+1787)</option>
<option value="QA">Qatar (+974)</option>
<option value="RE">Reunion (+262)</option>
<option value="RO">Romania (+40)</option>
<option value="RU">Russia (+7)</option>
<option value="RW">Rwanda (+250)</option>
<option value="SM">San Marino (+378)</option>
<option value="ST">Sao Tome &amp; Principe (+239)</option>
<option value="SA">Saudi Arabia (+966)</option>
<option value="SN">Senegal (+221)</option>
<option value="CS">Serbia (+381)</option>
<option value="SC">Seychelles (+248)</option>
<option value="SL">Sierra Leone (+232)</option>
<option value="SG">Singapore (+65)</option>
<option value="SK">Slovak Republic (+421)</option>
<option value="SI">Slovenia (+386)</option>
<option value="SB">Solomon Islands (+677)</option>
<option value="SO">Somalia (+252)</option>
<option value="ZA">South Africa (+27)</option>
<option value="ES">Spain (+34)</option>
<option value="LK">Sri Lanka (+94)</option>
<option value="SH">St. Helena (+290)</option>
<option value="KN">St. Kitts (+1869)</option>
<option value="SC">St. Lucia (+1758)</option>
<option value="SD">Sudan (+249)</option>
<option value="SR">Suriname (+597)</option>
<option value="SZ">Swaziland (+268)</option>
<option value="SE">Sweden (+46)</option>
<option value="CH">Switzerland (+41)</option>
<option value="SI">Syria (+963)</option>
<option value="TW">Taiwan (+886)</option>
<option value="TJ">Tajikstan (+7)</option>
<option value="TH">Thailand (+66)</option>
<option value="TG">Togo (+228)</option>
<option value="TO">Tonga (+676)</option>
<option value="TT">Trinidad &amp; Tobago (+1868)</option>
<option value="TN">Tunisia (+216)</option>
<option value="TR">Turkey (+90)</option>
<option value="TM">Turkmenistan (+7)</option>
<option value="TM">Turkmenistan (+993)</option>
<option value="TC">Turks &amp; Caicos Islands (+1649)</option>
<option value="TV">Tuvalu (+688)</option>
<option value="UG">Uganda (+256)</option>
<option value="GB">UK (+44)</option>
<option value="UA">Ukraine (+380)</option>
<option value="AE">United Arab Emirates (+971)</option>
<option value="UY">Uruguay (+598)</option>
<option value="US">USA (+1)</option>
<option value="UZ">Uzbekistan (+7)</option>
<option value="VU">Vanuatu (+678)</option>
<option value="VA">Vatican City (+379)</option>
<option value="VE">Venezuela (+58)</option>
<option value="VN">Vietnam (+84)</option>
<option value="VG">Virgin Islands - British (+1284)</option>
<option value="VI">Virgin Islands - US (+1340)</option>
<option value="WF">Wallis &amp; Futuna (+681)</option>
<option value="YE">Yemen (North)(+969)</option>
<option value="YE">Yemen (South)(+967)</option>
<option value="ZM">Zambia (+260)</option>
<option value="ZW">Zimbabwe (+263)</option>
</Select>
)
}

View File

@ -0,0 +1,80 @@
import { Input } from '@/components/inputs'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { PhoneNumberInputOptions, Variable } from 'models'
import React from 'react'
import { CountryCodeSelect } from './CountryCodeSelect'
type PhoneNumberSettingsBodyProps = {
options: PhoneNumberInputOptions
onOptionsChange: (options: PhoneNumberInputOptions) => void
}
export const PhoneNumberSettingsBody = ({
options,
onOptionsChange,
}: PhoneNumberSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleRetryMessageChange = (retryMessageContent: string) =>
onOptionsChange({ ...options, retryMessageContent })
const handleDefaultCountryChange = (defaultCountryCode: string) =>
onOptionsChange({ ...options, defaultCountryCode })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<Input
id="placeholder"
defaultValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Default country:
</FormLabel>
<CountryCodeSelect
onSelect={handleDefaultCountryChange}
countryCode={options.defaultCountryCode}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="retry">
Retry message:
</FormLabel>
<Input
id="retry"
defaultValue={options.retryMessageContent}
onChange={handleRetryMessageChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1 @@
export { PhoneNumberSettingsBody } from './PhoneNumberSettingsBody'

View File

@ -0,0 +1,3 @@
export { PhoneNumberSettingsBody } from './components/PhoneNumberSettingsBody'
export { PhoneNodeContent } from './components/PhoneNodeContent'
export { PhoneInputIcon } from './components/PhoneInputIcon'

View File

@ -0,0 +1,57 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultPhoneInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
test.describe('Phone input block', () => {
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.PHONE,
options: defaultPhoneInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultPhoneInputOptions.labels.placeholder}"]`
)
).toHaveAttribute('type', 'tel')
await expect(typebotViewer(page).locator(`button`)).toBeDisabled()
await page.click(`text=${defaultPhoneInputOptions.labels.placeholder}`)
await page.fill('#placeholder', '+33 XX XX XX XX')
await page.fill('#button', 'Go')
await page.fill(
`input[value="${defaultPhoneInputOptions.retryMessageContent}"]`,
'Try again bro'
)
await page.click('text=Restart')
await typebotViewer(page)
.locator(`input[placeholder="+33 XX XX XX XX"]`)
.fill('+33 6 73')
await expect(typebotViewer(page).locator(`img`)).toHaveAttribute(
'alt',
'France'
)
await typebotViewer(page).locator('button >> text="Go"').click()
await expect(
typebotViewer(page).locator('text=Try again bro')
).toBeVisible()
await typebotViewer(page)
.locator(`input[placeholder="+33 XX XX XX XX"]`)
.fill('+33 6 73 54 45 67')
await typebotViewer(page).locator('button >> text="Go"').click()
await expect(typebotViewer(page).locator('text=+33673544567')).toBeVisible()
})
})

View File

@ -0,0 +1,13 @@
import { Text } from '@chakra-ui/react'
import { RatingInputBlock } from 'models'
type Props = {
block: RatingInputBlock
}
export const RatingInputContent = ({ block }: Props) => (
<Text noOfLines={1} pr="6">
Rate from {block.options.buttonType === 'Icons' ? 1 : 0} to{' '}
{block.options.length}
</Text>
)

View File

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

View File

@ -0,0 +1,132 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DropdownList } from '@/components/DropdownList'
import { RatingInputOptions, Variable } from 'models'
import React from 'react'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { Input } from '@/components/inputs'
import { VariableSearchInput } from '@/components/VariableSearchInput'
type RatingInputSettingsProps = {
options: RatingInputOptions
onOptionsChange: (options: RatingInputOptions) => void
}
export const RatingInputSettings = ({
options,
onOptionsChange,
}: RatingInputSettingsProps) => {
const handleLengthChange = (length: number) =>
onOptionsChange({ ...options, length })
const handleTypeChange = (buttonType: 'Icons' | 'Numbers') =>
onOptionsChange({ ...options, buttonType })
const handleCustomIconCheck = (isEnabled: boolean) =>
onOptionsChange({
...options,
customIcon: { ...options.customIcon, isEnabled },
})
const handleIconSvgChange = (svg: string) =>
onOptionsChange({ ...options, customIcon: { ...options.customIcon, svg } })
const handleLeftLabelChange = (left: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, left } })
const handleRightLabelChange = (right: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, right } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="button">
Maximum:
</FormLabel>
<DropdownList
onItemSelect={handleLengthChange}
items={[3, 4, 5, 6, 7, 8, 9, 10]}
currentItem={options.length}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Type:
</FormLabel>
<DropdownList
onItemSelect={handleTypeChange}
items={['Icons', 'Numbers']}
currentItem={options.buttonType}
/>
</Stack>
{options.buttonType === 'Icons' && (
<SwitchWithLabel
label="Custom icon?"
initialValue={options.customIcon.isEnabled}
onCheckChange={handleCustomIconCheck}
/>
)}
{options.buttonType === 'Icons' && options.customIcon.isEnabled && (
<Stack>
<FormLabel mb="0" htmlFor="svg">
Icon SVG:
</FormLabel>
<Input
id="svg"
defaultValue={options.customIcon.svg}
onChange={handleIconSvgChange}
placeholder="<svg>...</svg>"
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="button">
{options.buttonType === 'Icons' ? '1' : '0'} label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.left}
onChange={handleLeftLabelChange}
placeholder="Not likely at all"
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
{options.length} label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.right}
onChange={handleRightLabelChange}
placeholder="Extremely likely"
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,3 @@
export { RatingInputSettings } from './components/RatingInputSettingsBody'
export { RatingInputContent } from './components/RatingInputContent'
export { RatingInputIcon } from './components/RatingInputIcon'

View File

@ -0,0 +1,61 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultRatingInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
const boxSvg = `<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>`
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.RATING,
options: defaultRatingInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(typebotViewer(page).locator(`text=Send`)).toBeHidden()
await typebotViewer(page).locator(`text=8`).click()
await typebotViewer(page).locator(`text=Send`).click()
await expect(typebotViewer(page).locator(`text=8`)).toBeVisible()
await page.click('text=Rate from 0 to 10')
await page.click('text="10"')
await page.click('text="5"')
await page.click('text=Numbers')
await page.click('text=Icons')
await page.click('text="Custom icon?"')
await page.fill('[placeholder="<svg>...</svg>"]', boxSvg)
await page.fill('[placeholder="Not likely at all"]', 'Not likely at all')
await page.fill('[placeholder="Extremely likely"]', 'Extremely likely')
await page.click('text="Restart"')
await expect(typebotViewer(page).locator(`text=8`)).toBeHidden()
await expect(typebotViewer(page).locator(`text=4`)).toBeHidden()
await expect(
typebotViewer(page).locator(`text=Not likely at all`)
).toBeVisible()
await expect(
typebotViewer(page).locator(`text=Extremely likely`)
).toBeVisible()
await typebotViewer(page).locator(`svg >> nth=4`).click()
await typebotViewer(page).locator(`text=Send`).click()
await expect(typebotViewer(page).locator(`text=5`)).toBeVisible()
})

View File

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

View File

@ -0,0 +1,14 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { TextInputOptions } from 'models'
type Props = {
placeholder: TextInputOptions['labels']['placeholder']
isLong: TextInputOptions['isLong']
}
export const TextInputNodeContent = ({ placeholder, isLong }: Props) => (
<Text color={'gray.500'} h={isLong ? '100px' : 'auto'}>
{placeholder}
</Text>
)

View File

@ -0,0 +1,64 @@
import { Input } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { TextInputOptions, Variable } from 'models'
import React from 'react'
type TextInputSettingsBodyProps = {
options: TextInputOptions
onOptionsChange: (options: TextInputOptions) => void
}
export const TextInputSettingsBody = ({
options,
onOptionsChange,
}: TextInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleLongChange = (isLong: boolean) =>
onOptionsChange({ ...options, isLong })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<SwitchWithLabel
label="Long text?"
initialValue={options?.isLong ?? false}
onCheckChange={handleLongChange}
/>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<Input
id="placeholder"
defaultValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,3 @@
export { TextInputSettingsBody } from './components/TextInputSettingsBody'
export { TextInputNodeContent } from './components/TextInputNodeContent'
export { TextInputIcon } from './components/TextInputIcon'

View File

@ -0,0 +1,42 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultTextInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
test.describe.parallel('Text input block', () => {
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
)
).toHaveAttribute('type', 'text')
await expect(typebotViewer(page).locator(`button`)).toBeDisabled()
await page.click(`text=${defaultTextInputOptions.labels.placeholder}`)
await page.fill('#placeholder', 'Your name...')
await page.fill('#button', 'Go')
await page.click('text=Long text?')
await page.click('text=Restart')
await expect(
typebotViewer(page).locator(`textarea[placeholder="Your name..."]`)
).toBeVisible()
await expect(typebotViewer(page).locator(`text=Go`)).toBeVisible()
})
})

View File

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

View File

@ -0,0 +1,68 @@
import { Input } from '@/components/inputs'
import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { UrlInputOptions, Variable } from 'models'
import React from 'react'
type UrlInputSettingsBodyProps = {
options: UrlInputOptions
onOptionsChange: (options: UrlInputOptions) => void
}
export const UrlInputSettingsBody = ({
options,
onOptionsChange,
}: UrlInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleRetryMessageChange = (retryMessageContent: string) =>
onOptionsChange({ ...options, retryMessageContent })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<Input
id="placeholder"
defaultValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="retry">
Retry message:
</FormLabel>
<Input
id="retry"
defaultValue={options.retryMessageContent}
onChange={handleRetryMessageChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,11 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { UrlInputOptions } from 'models'
type Props = {
placeholder: UrlInputOptions['labels']['placeholder']
}
export const UrlNodeContent = ({ placeholder }: Props) => (
<Text color={'gray.500'}>{placeholder}</Text>
)

View File

@ -0,0 +1,3 @@
export { UrlInputSettingsBody } from './components/UrlInputSettingsBody'
export { UrlNodeContent } from './components/UrlNodeContent'
export { UrlInputIcon } from './components/UrlInputIcon'

View File

@ -0,0 +1,56 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultUrlInputOptions, InputBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
import cuid from 'cuid'
test.describe('Url input block', () => {
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.URL,
options: defaultUrlInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultUrlInputOptions.labels.placeholder}"]`
)
).toHaveAttribute('type', 'url')
await expect(typebotViewer(page).locator(`button`)).toBeDisabled()
await page.click(`text=${defaultUrlInputOptions.labels.placeholder}`)
await page.fill('#placeholder', 'Your URL...')
await expect(page.locator('text=Your URL...')).toBeVisible()
await page.fill('#button', 'Go')
await page.fill(
`input[value="${defaultUrlInputOptions.retryMessageContent}"]`,
'Try again bro'
)
await page.click('text=Restart')
await typebotViewer(page)
.locator(`input[placeholder="Your URL..."]`)
.fill('https://https://test')
await typebotViewer(page).locator('button >> text="Go"').click()
await expect(
typebotViewer(page).locator('text=Try again bro')
).toBeVisible()
await typebotViewer(page)
.locator(`input[placeholder="Your URL..."]`)
.fill('https://website.com')
await typebotViewer(page).locator('button >> text="Go"').click()
await expect(
typebotViewer(page).locator('text=https://website.com')
).toBeVisible()
})
})