♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
@@ -0,0 +1,7 @@
|
||||
import { Text } from '@chakra-ui/react'
|
||||
import { EmbedBubbleBlock } from 'models'
|
||||
|
||||
export const EmbedBubbleContent = ({ block }: { block: EmbedBubbleBlock }) => {
|
||||
if (!block.content?.url) return <Text color="gray.500">Click to edit...</Text>
|
||||
return <Text>Show embed</Text>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { LayoutIcon } from '@/components/icons'
|
||||
import { IconProps } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
|
||||
export const EmbedBubbleIcon = (props: IconProps) => (
|
||||
<LayoutIcon color="blue.500" {...props} />
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Input, SmartNumberInput } from '@/components/inputs'
|
||||
import { HStack, Stack, Text } from '@chakra-ui/react'
|
||||
import { EmbedBubbleContent } from 'models'
|
||||
import { sanitizeUrl } from 'utils'
|
||||
|
||||
type Props = {
|
||||
content: EmbedBubbleContent
|
||||
onSubmit: (content: EmbedBubbleContent) => void
|
||||
}
|
||||
|
||||
export const EmbedUploadContent = ({ content, onSubmit }: Props) => {
|
||||
const handleUrlChange = (url: string) => {
|
||||
const iframeUrl = sanitizeUrl(
|
||||
url.trim().startsWith('<iframe') ? extractUrlFromIframe(url) : url
|
||||
)
|
||||
onSubmit({ ...content, url: iframeUrl })
|
||||
}
|
||||
|
||||
const handleHeightChange = (height?: number) =>
|
||||
height && onSubmit({ ...content, height })
|
||||
|
||||
return (
|
||||
<Stack p="2" spacing={6}>
|
||||
<Stack>
|
||||
<Input
|
||||
placeholder="Paste the link or code..."
|
||||
defaultValue={content?.url ?? ''}
|
||||
onChange={handleUrlChange}
|
||||
/>
|
||||
<Text fontSize="sm" color="gray.400" textAlign="center">
|
||||
Works with PDFs, iframes, websites...
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text>Height: </Text>
|
||||
<SmartNumberInput
|
||||
value={content?.height}
|
||||
onValueChange={handleHeightChange}
|
||||
/>
|
||||
</HStack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const extractUrlFromIframe = (iframe: string) =>
|
||||
[...iframe.matchAll(/src="([^"]+)"/g)][0][1]
|
||||
55
apps/builder/src/features/blocks/bubbles/embed/embed.spec.ts
Normal file
55
apps/builder/src/features/blocks/bubbles/embed/embed.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { BubbleBlockType, defaultEmbedBubbleContent } from 'models'
|
||||
import cuid from 'cuid'
|
||||
import { createTypebots } from 'utils/playwright/databaseActions'
|
||||
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
|
||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||
|
||||
const pdfSrc = 'https://www.orimi.com/pdf-test.pdf'
|
||||
const siteSrc = 'https://app.cal.com/baptistearno/15min'
|
||||
|
||||
test.describe.parallel('Embed bubble block', () => {
|
||||
test.describe('Content settings', () => {
|
||||
test('should import and parse embed correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.EMBED,
|
||||
content: defaultEmbedBubbleContent,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('text=Click to edit...')
|
||||
await page.fill('input[placeholder="Paste the link or code..."]', pdfSrc)
|
||||
await expect(page.locator('text="Show embed"')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Preview', () => {
|
||||
test('should display embed correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.EMBED,
|
||||
content: {
|
||||
url: siteSrc,
|
||||
height: 700,
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('text=Preview')
|
||||
await expect(
|
||||
typebotViewer(page).locator('iframe#embed-bubble-content')
|
||||
).toHaveAttribute('src', siteSrc)
|
||||
})
|
||||
})
|
||||
})
|
||||
3
apps/builder/src/features/blocks/bubbles/embed/index.ts
Normal file
3
apps/builder/src/features/blocks/bubbles/embed/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { EmbedBubbleContent } from './components/EmbedBubbleContent'
|
||||
export { EmbedUploadContent } from './components/EmbedUploadContent'
|
||||
export { EmbedBubbleIcon } from './components/EmbedBubbleIcon'
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Box, Text, Image } from '@chakra-ui/react'
|
||||
import { ImageBubbleBlock } from 'models'
|
||||
|
||||
export const ImageBubbleContent = ({ block }: { block: ImageBubbleBlock }) => {
|
||||
const containsVariables =
|
||||
block.content?.url?.includes('{{') && block.content.url.includes('}}')
|
||||
return !block.content?.url ? (
|
||||
<Text color={'gray.500'}>Click to edit...</Text>
|
||||
) : (
|
||||
<Box w="full">
|
||||
<Image
|
||||
src={
|
||||
containsVariables ? '/images/dynamic-image.png' : block.content?.url
|
||||
}
|
||||
alt="Group image"
|
||||
rounded="md"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ImageIcon } from '@/components/icons'
|
||||
import { IconProps } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
|
||||
export const ImageBubbleIcon = (props: IconProps) => (
|
||||
<ImageIcon color="blue.500" {...props} />
|
||||
)
|
||||
130
apps/builder/src/features/blocks/bubbles/image/image.spec.ts
Normal file
130
apps/builder/src/features/blocks/bubbles/image/image.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { createTypebots } from 'utils/playwright/databaseActions'
|
||||
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
|
||||
import { BubbleBlockType, defaultImageBubbleContent } from 'models'
|
||||
import cuid from 'cuid'
|
||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||
import { getTestAsset } from '@/test/utils/playwright'
|
||||
|
||||
const unsplashImageSrc =
|
||||
'https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
|
||||
|
||||
test.describe.parallel('Image bubble block', () => {
|
||||
test.describe('Content settings', () => {
|
||||
test('should upload image file correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.IMAGE,
|
||||
content: defaultImageBubbleContent,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
|
||||
await page.click('text=Click to edit...')
|
||||
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
|
||||
await expect(page.locator('img')).toHaveAttribute(
|
||||
'src',
|
||||
`${process.env.S3_SSL === 'false' ? 'http://' : 'https://'}${
|
||||
process.env.S3_ENDPOINT
|
||||
}${process.env.S3_PORT ? `:${process.env.S3_PORT}` : ''}/${
|
||||
process.env.S3_BUCKET
|
||||
}/public/typebots/${typebotId}/blocks/block2`
|
||||
)
|
||||
})
|
||||
|
||||
test('should import image link correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.IMAGE,
|
||||
content: defaultImageBubbleContent,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
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
|
||||
)
|
||||
await expect(page.locator('img')).toHaveAttribute('src', unsplashImageSrc)
|
||||
})
|
||||
|
||||
test('should import gifs correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.IMAGE,
|
||||
content: defaultImageBubbleContent,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
|
||||
await page.click('text=Click to edit...')
|
||||
await page.click('text=Giphy')
|
||||
const firstGiphyImage = page.locator('.giphy-gif-img >> nth=0')
|
||||
await expect(firstGiphyImage).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp('giphy.com/media', 'gm')
|
||||
)
|
||||
const trendingfirstImageSrc = await firstGiphyImage.getAttribute('src')
|
||||
expect(trendingfirstImageSrc).toMatch(new RegExp('giphy.com/media', 'gm'))
|
||||
await page.type('[placeholder="Search..."]', 'fun')
|
||||
await expect(page.locator('[placeholder="Search..."]')).toHaveValue('fun')
|
||||
await page.waitForTimeout(500)
|
||||
await expect(firstGiphyImage).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp('giphy.com/media', 'gm')
|
||||
)
|
||||
const funFirstImageSrc = await firstGiphyImage.getAttribute('src')
|
||||
expect(funFirstImageSrc).toMatch(new RegExp('giphy.com/media', 'gm'))
|
||||
expect(trendingfirstImageSrc).not.toBe(funFirstImageSrc)
|
||||
await firstGiphyImage.click({
|
||||
force: true,
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
await expect(page.locator('img[alt="Group image"]')).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp('giphy.com/media', 'gm')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Preview', () => {
|
||||
test('should display correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.IMAGE,
|
||||
content: {
|
||||
url: unsplashImageSrc,
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('text=Preview')
|
||||
await expect(typebotViewer(page).locator('img')).toHaveAttribute(
|
||||
'src',
|
||||
unsplashImageSrc
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
apps/builder/src/features/blocks/bubbles/image/index.ts
Normal file
2
apps/builder/src/features/blocks/bubbles/image/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ImageBubbleContent } from './components/ImageBubbleContent'
|
||||
export { ImageBubbleIcon } from './components/ImageBubbleIcon'
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Flex } from '@chakra-ui/react'
|
||||
import { useTypebot } from '@/features/editor'
|
||||
import { TextBubbleBlock } from 'models'
|
||||
import React from 'react'
|
||||
import { parseVariableHighlight } from '@/utils/helpers'
|
||||
|
||||
type Props = {
|
||||
block: TextBubbleBlock
|
||||
}
|
||||
|
||||
export const TextBubbleContent = ({ block }: Props) => {
|
||||
const { typebot } = useTypebot()
|
||||
if (!typebot) return <></>
|
||||
return (
|
||||
<Flex
|
||||
w="90%"
|
||||
flexDir={'column'}
|
||||
opacity={block.content.html === '' ? '0.5' : '1'}
|
||||
className="slate-html-container"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
block.content.html === ''
|
||||
? `<p>Click to edit...</p>`
|
||||
: parseVariableHighlight(block.content.html, typebot),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Flex, Stack, useOutsideClick } from '@chakra-ui/react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Plate,
|
||||
PlateProvider,
|
||||
selectEditor,
|
||||
TElement,
|
||||
usePlateEditorRef,
|
||||
} from '@udecode/plate-core'
|
||||
import { editorStyle, platePlugins } from '@/lib/plate'
|
||||
import { BaseEditor, BaseSelection, Transforms } from 'slate'
|
||||
import { ToolBar } from './ToolBar'
|
||||
import { defaultTextBubbleContent, TextBubbleContent, Variable } from 'models'
|
||||
import { ReactEditor } from 'slate-react'
|
||||
import { serializeHtml } from '@udecode/plate-serializer-html'
|
||||
import { parseHtmlStringToPlainText } from '../../utils'
|
||||
import { VariableSearchInput } from '@/components/VariableSearchInput'
|
||||
|
||||
type TextBubbleEditorContentProps = {
|
||||
id: string
|
||||
textEditorValue: TElement[]
|
||||
onClose: (newContent: TextBubbleContent) => void
|
||||
}
|
||||
|
||||
const TextBubbleEditorContent = ({
|
||||
id,
|
||||
textEditorValue,
|
||||
onClose,
|
||||
}: TextBubbleEditorContentProps) => {
|
||||
const editor = usePlateEditorRef()
|
||||
const varDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||
const rememberedSelection = useRef<BaseSelection | null>(null)
|
||||
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
|
||||
|
||||
const textEditorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const closeEditor = () => onClose(convertValueToBlockContent(textEditorValue))
|
||||
|
||||
useOutsideClick({
|
||||
ref: textEditorRef,
|
||||
handler: closeEditor,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVariableDropdownOpen) return
|
||||
const el = varDropdownRef.current
|
||||
if (!el) return
|
||||
const { top, left } = computeTargetCoord()
|
||||
el.style.top = `${top}px`
|
||||
el.style.left = `${left}px`
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVariableDropdownOpen])
|
||||
|
||||
const computeTargetCoord = () => {
|
||||
const selection = window.getSelection()
|
||||
const relativeParent = textEditorRef.current
|
||||
if (!selection || !relativeParent) return { top: 0, left: 0 }
|
||||
const range = selection.getRangeAt(0)
|
||||
const selectionBoundingRect = range.getBoundingClientRect()
|
||||
const relativeRect = relativeParent.getBoundingClientRect()
|
||||
return {
|
||||
top: selectionBoundingRect.bottom - relativeRect.top,
|
||||
left: selectionBoundingRect.left - relativeRect.left,
|
||||
}
|
||||
}
|
||||
|
||||
const convertValueToBlockContent = (value: TElement[]): TextBubbleContent => {
|
||||
if (value.length === 0) defaultTextBubbleContent
|
||||
const html = serializeHtml(editor, {
|
||||
nodes: value,
|
||||
})
|
||||
return {
|
||||
html,
|
||||
richText: value,
|
||||
plainText: parseHtmlStringToPlainText(html),
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleVariableSelected = (variable?: Variable) => {
|
||||
setIsVariableDropdownOpen(false)
|
||||
if (!rememberedSelection.current || !variable) return
|
||||
Transforms.select(editor as BaseEditor, rememberedSelection.current)
|
||||
Transforms.insertText(editor as BaseEditor, '{{' + variable.name + '}}')
|
||||
ReactEditor.focus(editor as unknown as ReactEditor)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.shiftKey) return
|
||||
if (e.key === 'Enter') closeEditor()
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex="1"
|
||||
ref={textEditorRef}
|
||||
borderWidth="2px"
|
||||
borderColor="blue.400"
|
||||
rounded="md"
|
||||
onMouseDown={handleMouseDown}
|
||||
pos="relative"
|
||||
spacing={0}
|
||||
cursor="text"
|
||||
>
|
||||
<ToolBar onVariablesButtonClick={() => setIsVariableDropdownOpen(true)} />
|
||||
<Plate
|
||||
id={id}
|
||||
editableProps={{
|
||||
style: editorStyle,
|
||||
autoFocus: true,
|
||||
onFocus: () => {
|
||||
if (editor.children.length === 0) return
|
||||
selectEditor(editor, {
|
||||
edge: 'end',
|
||||
})
|
||||
},
|
||||
'aria-label': 'Text editor',
|
||||
onBlur: () => {
|
||||
rememberedSelection.current = editor.selection
|
||||
},
|
||||
onKeyDown: handleKeyDown,
|
||||
}}
|
||||
/>
|
||||
{isVariableDropdownOpen && (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
ref={varDropdownRef}
|
||||
shadow="lg"
|
||||
rounded="md"
|
||||
bgColor="white"
|
||||
w="250px"
|
||||
zIndex={10}
|
||||
>
|
||||
<VariableSearchInput
|
||||
onSelectVariable={handleVariableSelected}
|
||||
placeholder="Search for a variable"
|
||||
isDefaultOpen
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
type TextBubbleEditorProps = {
|
||||
id: string
|
||||
initialValue: TElement[]
|
||||
onClose: (newContent: TextBubbleContent) => void
|
||||
}
|
||||
|
||||
export const TextBubbleEditor = ({
|
||||
id,
|
||||
initialValue,
|
||||
onClose,
|
||||
}: TextBubbleEditorProps) => {
|
||||
const [textEditorValue, setTextEditorValue] = useState(initialValue)
|
||||
|
||||
return (
|
||||
<PlateProvider
|
||||
id={id}
|
||||
plugins={platePlugins}
|
||||
initialValue={
|
||||
initialValue.length === 0
|
||||
? [{ type: 'p', children: [{ text: '' }] }]
|
||||
: initialValue
|
||||
}
|
||||
onChange={setTextEditorValue}
|
||||
>
|
||||
<TextBubbleEditorContent
|
||||
id={id}
|
||||
textEditorValue={textEditorValue}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</PlateProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { StackProps, HStack, IconButton } from '@chakra-ui/react'
|
||||
import {
|
||||
MARK_BOLD,
|
||||
MARK_ITALIC,
|
||||
MARK_UNDERLINE,
|
||||
} from '@udecode/plate-basic-marks'
|
||||
import { getPluginType, usePlateEditorRef } from '@udecode/plate-core'
|
||||
import { LinkToolbarButton } from '@udecode/plate-ui-link'
|
||||
import { MarkToolbarButton } from '@udecode/plate-ui-toolbar'
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
UnderlineIcon,
|
||||
LinkIcon,
|
||||
UserIcon,
|
||||
} from '@/components/icons'
|
||||
|
||||
type Props = {
|
||||
onVariablesButtonClick: () => void
|
||||
} & StackProps
|
||||
|
||||
export const ToolBar = ({ onVariablesButtonClick, ...props }: Props) => {
|
||||
const editor = usePlateEditorRef()
|
||||
const handleVariablesButtonMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
onVariablesButtonClick()
|
||||
}
|
||||
return (
|
||||
<HStack
|
||||
bgColor={'white'}
|
||||
borderTopRadius="md"
|
||||
p={2}
|
||||
w="full"
|
||||
boxSizing="border-box"
|
||||
borderBottomWidth={1}
|
||||
{...props}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Insert variable"
|
||||
size="sm"
|
||||
onMouseDown={handleVariablesButtonMouseDown}
|
||||
icon={<UserIcon />}
|
||||
/>
|
||||
<span data-testid="bold-button">
|
||||
<MarkToolbarButton
|
||||
type={getPluginType(editor, MARK_BOLD)}
|
||||
icon={<BoldIcon />}
|
||||
/>
|
||||
</span>
|
||||
<span data-testid="italic-button">
|
||||
<MarkToolbarButton
|
||||
type={getPluginType(editor, MARK_ITALIC)}
|
||||
icon={<ItalicIcon />}
|
||||
/>
|
||||
</span>
|
||||
<span data-testid="underline-button">
|
||||
<MarkToolbarButton
|
||||
type={getPluginType(editor, MARK_UNDERLINE)}
|
||||
icon={<UnderlineIcon />}
|
||||
/>
|
||||
</span>
|
||||
<span data-testid="link-button">
|
||||
<LinkToolbarButton icon={<LinkIcon />} />
|
||||
</span>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TextBubbleEditor } from './TextBubbleEditor'
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ChatIcon } from '@/components/icons'
|
||||
import { IconProps } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
|
||||
export const TextBubbleIcon = (props: IconProps) => (
|
||||
<ChatIcon color="blue.500" {...props} />
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
export { TextBubbleEditor } from './components/TextBubbleEditor'
|
||||
export { TextBubbleContent } from './components/TextBubbleContent'
|
||||
export { TextBubbleIcon } from './components/TextBubbleIcon'
|
||||
@@ -0,0 +1,67 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { createTypebots } from 'utils/playwright/databaseActions'
|
||||
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
|
||||
import { BubbleBlockType, defaultTextBubbleContent } from 'models'
|
||||
import cuid from 'cuid'
|
||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||
|
||||
test.describe('Text bubble block', () => {
|
||||
test('rich text features should work', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.TEXT,
|
||||
content: defaultTextBubbleContent,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
|
||||
await page.click('[data-testid="bold-button"]')
|
||||
await page.type('div[role="textbox"]', 'Bold text')
|
||||
await page.press('div[role="textbox"]', 'Shift+Enter')
|
||||
|
||||
await page.click('[data-testid="bold-button"]')
|
||||
await page.click('[data-testid="italic-button"]')
|
||||
await page.type('div[role="textbox"]', 'Italic text')
|
||||
await page.press('div[role="textbox"]', 'Shift+Enter')
|
||||
|
||||
await page.click('[data-testid="underline-button"]')
|
||||
await page.click('[data-testid="italic-button"]')
|
||||
await page.type('div[role="textbox"]', 'Underlined text')
|
||||
await page.press('div[role="textbox"]', 'Shift+Enter')
|
||||
|
||||
await page.click('[data-testid="bold-button"]')
|
||||
await page.click('[data-testid="italic-button"]')
|
||||
await page.type('div[role="textbox"]', 'Everything text')
|
||||
await page.press('div[role="textbox"]', 'Shift+Enter')
|
||||
|
||||
await page.type('div[role="textbox"]', 'My super link')
|
||||
await page.waitForTimeout(300)
|
||||
await page.press('div[role="textbox"]', 'Shift+Meta+ArrowLeft')
|
||||
await page.click('[data-testid="link-button"]')
|
||||
await page.fill('input[placeholder="Paste link"]', 'https://github.com')
|
||||
await page.press('input[placeholder="Paste link"]', 'Enter')
|
||||
await page.press('div[role="textbox"]', 'Shift+Enter')
|
||||
await page.click('button[aria-label="Insert variable"]')
|
||||
await page.fill('[data-testid="variables-input"]', 'test')
|
||||
await page.click('text=Create "test"')
|
||||
|
||||
await page.click('text=Preview')
|
||||
await expect(
|
||||
typebotViewer(page).locator('span.slate-bold >> nth=0')
|
||||
).toHaveText('Bold text')
|
||||
await expect(
|
||||
typebotViewer(page).locator('span.slate-italic >> nth=0')
|
||||
).toHaveText('Italic text')
|
||||
await expect(
|
||||
typebotViewer(page).locator('span.slate-underline >> nth=0')
|
||||
).toHaveText('Underlined text')
|
||||
await expect(
|
||||
typebotViewer(page).locator('a[href="https://github.com"]')
|
||||
).toHaveText('My super link')
|
||||
})
|
||||
})
|
||||
13
apps/builder/src/features/blocks/bubbles/textBubble/utils.ts
Normal file
13
apps/builder/src/features/blocks/bubbles/textBubble/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Parser } from 'htmlparser2'
|
||||
|
||||
export const parseHtmlStringToPlainText = (html: string): string => {
|
||||
let label = ''
|
||||
const parser = new Parser({
|
||||
ontext(text) {
|
||||
label += `${text}`
|
||||
},
|
||||
})
|
||||
parser.write(html)
|
||||
parser.end()
|
||||
return label
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Box, Text } from '@chakra-ui/react'
|
||||
import { VideoBubbleBlock, VideoBubbleContentType } from 'models'
|
||||
|
||||
export const VideoBubbleContent = ({ block }: { block: VideoBubbleBlock }) => {
|
||||
if (!block.content?.url || !block.content.type)
|
||||
return <Text color="gray.500">Click to edit...</Text>
|
||||
switch (block.content.type) {
|
||||
case VideoBubbleContentType.URL:
|
||||
return (
|
||||
<Box w="full" h="120px" pos="relative">
|
||||
<video
|
||||
key={block.content.url}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
>
|
||||
<source src={block.content.url} />
|
||||
</video>
|
||||
</Box>
|
||||
)
|
||||
case VideoBubbleContentType.VIMEO:
|
||||
case VideoBubbleContentType.YOUTUBE: {
|
||||
const baseUrl =
|
||||
block.content.type === VideoBubbleContentType.VIMEO
|
||||
? 'https://player.vimeo.com/video'
|
||||
: 'https://www.youtube.com/embed'
|
||||
return (
|
||||
<Box w="full" h="120px" pos="relative">
|
||||
<iframe
|
||||
src={`${baseUrl}/${block.content.id}`}
|
||||
allowFullScreen
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
borderRadius: '10px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FilmIcon } from '@/components/icons'
|
||||
import { IconProps } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
|
||||
export const VideoBubbleIcon = (props: IconProps) => (
|
||||
<FilmIcon color="blue.500" {...props} />
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Stack, Text } from '@chakra-ui/react'
|
||||
import { VideoBubbleContent, VideoBubbleContentType } from 'models'
|
||||
import urlParser from 'js-video-url-parser/lib/base'
|
||||
import 'js-video-url-parser/lib/provider/vimeo'
|
||||
import 'js-video-url-parser/lib/provider/youtube'
|
||||
import { isDefined } from 'utils'
|
||||
import { Input } from '@/components/inputs'
|
||||
|
||||
type Props = {
|
||||
content?: VideoBubbleContent
|
||||
onSubmit: (content: VideoBubbleContent) => void
|
||||
}
|
||||
|
||||
export const VideoUploadContent = ({ content, onSubmit }: Props) => {
|
||||
const handleUrlChange = (url: string) => {
|
||||
const info = urlParser.parse(url)
|
||||
return isDefined(info) && info.provider && info.id
|
||||
? onSubmit({
|
||||
type: info.provider as VideoBubbleContentType,
|
||||
url,
|
||||
id: info.id,
|
||||
})
|
||||
: onSubmit({ type: VideoBubbleContentType.URL, url })
|
||||
}
|
||||
return (
|
||||
<Stack p="2">
|
||||
<Input
|
||||
placeholder="Paste the video link..."
|
||||
defaultValue={content?.url ?? ''}
|
||||
onChange={handleUrlChange}
|
||||
/>
|
||||
<Text fontSize="sm" color="gray.400" textAlign="center">
|
||||
Works with Youtube, Vimeo and others
|
||||
</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
3
apps/builder/src/features/blocks/bubbles/video/index.ts
Normal file
3
apps/builder/src/features/blocks/bubbles/video/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { VideoUploadContent } from './components/VideoUploadContent'
|
||||
export { VideoBubbleContent } from './components/VideoBubbleContent'
|
||||
export { VideoBubbleIcon } from './components/VideoBubbleIcon'
|
||||
113
apps/builder/src/features/blocks/bubbles/video/video.spec.ts
Normal file
113
apps/builder/src/features/blocks/bubbles/video/video.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { createTypebots } from 'utils/playwright/databaseActions'
|
||||
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
|
||||
import {
|
||||
BubbleBlockType,
|
||||
defaultVideoBubbleContent,
|
||||
VideoBubbleContentType,
|
||||
} from 'models'
|
||||
import cuid from 'cuid'
|
||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||
|
||||
const videoSrc =
|
||||
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
|
||||
const youtubeVideoSrc = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||
const vimeoVideoSrc = 'https://vimeo.com/649301125'
|
||||
|
||||
test.describe.parallel('Video bubble block', () => {
|
||||
test.describe('Content settings', () => {
|
||||
test('should import video url correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.VIDEO,
|
||||
content: defaultVideoBubbleContent,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
|
||||
await page.click('text=Click to edit...')
|
||||
await page.fill('input[placeholder="Paste the video link..."]', videoSrc)
|
||||
await expect(page.locator('video > source')).toHaveAttribute(
|
||||
'src',
|
||||
videoSrc
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Preview', () => {
|
||||
test('should display video correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.VIDEO,
|
||||
content: {
|
||||
type: VideoBubbleContentType.URL,
|
||||
url: videoSrc,
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('text=Preview')
|
||||
await expect(
|
||||
typebotViewer(page).locator('video > source')
|
||||
).toHaveAttribute('src', videoSrc)
|
||||
})
|
||||
|
||||
test('should display youtube video correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.VIDEO,
|
||||
content: {
|
||||
type: VideoBubbleContentType.YOUTUBE,
|
||||
url: youtubeVideoSrc,
|
||||
id: 'dQw4w9WgXcQ',
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('text=Preview')
|
||||
await expect(typebotViewer(page).locator('iframe')).toHaveAttribute(
|
||||
'src',
|
||||
'https://www.youtube.com/embed/dQw4w9WgXcQ'
|
||||
)
|
||||
})
|
||||
|
||||
test('should display vimeo video correctly', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: BubbleBlockType.VIDEO,
|
||||
content: {
|
||||
type: VideoBubbleContentType.VIMEO,
|
||||
url: vimeoVideoSrc,
|
||||
id: '649301125',
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('text=Preview')
|
||||
await expect(typebotViewer(page).locator('iframe')).toHaveAttribute(
|
||||
'src',
|
||||
'https://player.vimeo.com/video/649301125'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
3
apps/builder/src/features/blocks/inputs/buttons/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/buttons/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ButtonsOptionsForm } from './components/ButtonsOptionsForm'
|
||||
export { ButtonNodeContent } from './components/ButtonNodeContent'
|
||||
export { ButtonsInputIcon } from './components/ButtonsInputIcon'
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
61
apps/builder/src/features/blocks/inputs/date/date.spec.ts
Normal file
61
apps/builder/src/features/blocks/inputs/date/date.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
3
apps/builder/src/features/blocks/inputs/date/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/date/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DateInputSettingsBody } from './components/DateInputSettingsBody'
|
||||
export { DateNodeContent } from './components/DateNodeContent'
|
||||
export { DateInputIcon } from './components/DateInputIcon'
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export { EmailInputSettingsBody } from './components/EmailInputSettingsBody'
|
||||
export { EmailInputNodeContent } from './components/EmailInputNodeContent'
|
||||
export { EmailInputIcon } from './components/EmailInputIcon'
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export { FileInputSettings } from './components/FileInputSettings'
|
||||
export { FileInputContent } from './components/FileInputContent'
|
||||
export { FileInputIcon } from './components/FileInputIcon'
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
3
apps/builder/src/features/blocks/inputs/number/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/number/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { NumberInputSettingsBody } from './components/NumberInputSettingsBody'
|
||||
export { NumberNodeContent } from './components/NumberNodeContent'
|
||||
export { NumberInputIcon } from './components/NumberInputIcon'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
export { PaymentSettings } from './PaymentSettings'
|
||||
3
apps/builder/src/features/blocks/inputs/payment/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/payment/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PaymentSettings } from './components/PaymentSettings'
|
||||
export { PaymentInputContent } from './components/PaymentInputContent'
|
||||
export { PaymentInputIcon } from './components/PaymentInputIcon'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PhoneNumberSettingsBody } from './PhoneNumberSettingsBody'
|
||||
3
apps/builder/src/features/blocks/inputs/phone/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/phone/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PhoneNumberSettingsBody } from './components/PhoneNumberSettingsBody'
|
||||
export { PhoneNodeContent } from './components/PhoneNodeContent'
|
||||
export { PhoneInputIcon } from './components/PhoneInputIcon'
|
||||
57
apps/builder/src/features/blocks/inputs/phone/phone.spec.ts
Normal file
57
apps/builder/src/features/blocks/inputs/phone/phone.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
3
apps/builder/src/features/blocks/inputs/rating/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/rating/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { RatingInputSettings } from './components/RatingInputSettingsBody'
|
||||
export { RatingInputContent } from './components/RatingInputContent'
|
||||
export { RatingInputIcon } from './components/RatingInputIcon'
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { TextInputSettingsBody } from './components/TextInputSettingsBody'
|
||||
export { TextInputNodeContent } from './components/TextInputNodeContent'
|
||||
export { TextInputIcon } from './components/TextInputIcon'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
3
apps/builder/src/features/blocks/inputs/url/index.ts
Normal file
3
apps/builder/src/features/blocks/inputs/url/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { UrlInputSettingsBody } from './components/UrlInputSettingsBody'
|
||||
export { UrlNodeContent } from './components/UrlNodeContent'
|
||||
export { UrlInputIcon } from './components/UrlInputIcon'
|
||||
56
apps/builder/src/features/blocks/inputs/url/url.spec.ts
Normal file
56
apps/builder/src/features/blocks/inputs/url/url.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { createTypebots } from 'utils/playwright/databaseActions'
|
||||
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
|
||||
import cuid from 'cuid'
|
||||
import { defaultChatwootOptions, IntegrationBlockType } from 'models'
|
||||
|
||||
const typebotId = cuid()
|
||||
|
||||
const chatwootTestWebsiteToken = 'tueXiiqEmrWUCZ4NUyoR7nhE'
|
||||
|
||||
test.describe('Chatwoot block', () => {
|
||||
test('should be configurable', async ({ page }) => {
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: IntegrationBlockType.CHATWOOT,
|
||||
options: defaultChatwootOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.getByText('Configure...').click()
|
||||
await expect(page.getByLabel('Base URL')).toHaveAttribute(
|
||||
'value',
|
||||
defaultChatwootOptions.baseUrl
|
||||
)
|
||||
await page.getByLabel('Website token').fill(chatwootTestWebsiteToken)
|
||||
await expect(page.getByText('Open Chatwoot')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Set user details' }).click()
|
||||
await page.getByLabel('ID').fill('123')
|
||||
await page.getByLabel('Name').fill('John Doe')
|
||||
await page.getByLabel('Email').fill('john@email.com')
|
||||
await page.getByLabel('Avatar URL').fill('https://domain.com/avatar.png')
|
||||
await page.getByLabel('Phone number').fill('+33654347543')
|
||||
await page.getByRole('button', { name: 'Preview' }).click()
|
||||
await expect(
|
||||
page.getByText("Chatwoot won't open in preview mode").nth(0)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Text } from '@chakra-ui/react'
|
||||
import { ChatwootBlock } from 'models'
|
||||
|
||||
type Props = {
|
||||
block: ChatwootBlock
|
||||
}
|
||||
|
||||
export const ChatwootBlockNodeLabel = ({ block }: Props) =>
|
||||
block.options.websiteToken.length === 0 ? (
|
||||
<Text color="gray.500">Configure...</Text>
|
||||
) : (
|
||||
<Text>Open Chatwoot</Text>
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Icon, IconProps } from '@chakra-ui/react'
|
||||
|
||||
export const ChatwootLogo = (props: IconProps) => (
|
||||
<Icon
|
||||
viewBox="0 0 512 512"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g
|
||||
id="Square-logo"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
>
|
||||
<g id="chatwoot_logo" fill-rule="nonzero">
|
||||
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
|
||||
<path
|
||||
d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z"
|
||||
id="Fill-1"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="6"
|
||||
fill="#FFFFFF"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</Icon>
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Input } from '@/components/inputs'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Stack,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChatwootOptions } from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
options: ChatwootOptions
|
||||
onOptionsChange: (options: ChatwootOptions) => void
|
||||
}
|
||||
|
||||
export const ChatwootSettingsForm = ({ options, onOptionsChange }: Props) => {
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Input
|
||||
isRequired
|
||||
label="Base URL"
|
||||
defaultValue={options.baseUrl}
|
||||
onChange={(baseUrl: string) => {
|
||||
onOptionsChange({ ...options, baseUrl })
|
||||
}}
|
||||
withVariableButton={false}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="Website token"
|
||||
defaultValue={options.websiteToken}
|
||||
onChange={(websiteToken) =>
|
||||
onOptionsChange({ ...options, websiteToken })
|
||||
}
|
||||
moreInfoTooltip="Can be found in Chatwoot under Settings > Inboxes > Settings > Configuration, in the code snippet."
|
||||
/>
|
||||
<Accordion allowMultiple>
|
||||
<AccordionItem>
|
||||
<AccordionButton justifyContent="space-between">
|
||||
Set user details
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4} as={Stack} spacing="4">
|
||||
<Input
|
||||
label="ID"
|
||||
defaultValue={options.user?.id}
|
||||
onChange={(id: string) => {
|
||||
onOptionsChange({ ...options, user: { ...options.user, id } })
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Name"
|
||||
defaultValue={options.user?.name}
|
||||
onChange={(name: string) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
user: { ...options.user, name },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
defaultValue={options.user?.email}
|
||||
onChange={(email: string) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
user: { ...options.user, email },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Avatar URL"
|
||||
defaultValue={options.user?.avatarUrl}
|
||||
onChange={(avatarUrl: string) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
user: { ...options.user, avatarUrl },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Phone number"
|
||||
defaultValue={options.user?.phoneNumber}
|
||||
onChange={(phoneNumber: string) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
user: { ...options.user, phoneNumber },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ChatwootLogo } from './components/ChatwootLogo'
|
||||
export { ChatwootBlockNodeLabel } from './components/ChatwootBlockNodeLabel'
|
||||
export { ChatwootSettingsForm } from './components/ChatwootSettingsForm'
|
||||
@@ -0,0 +1,101 @@
|
||||
import { IconProps, Icon } from '@chakra-ui/react'
|
||||
|
||||
export const GoogleAnalyticsLogo = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 353 353" {...props}>
|
||||
<g clipPath="url(#clip0_1458_69)">
|
||||
<path
|
||||
d="M324.433 0H260.155C244.607 0 231.844 12.773 231.844 28.3329V111.474H138.792C123.709 111.474 111.41 123.782 111.41 139.11V232.237H27.6395C12.3241 232.237 0.0253906 244.545 0.0253906 259.873V324.899C0.0253906 340.227 12.3241 352.536 27.6395 353H324.665C340.212 353 352.975 340.227 352.975 324.667V28.3329C352.743 12.773 339.98 0 324.433 0Z"
|
||||
fill="url(#paint0_linear_1458_69)"
|
||||
/>
|
||||
<path
|
||||
d="M324.433 0H260.155C244.607 0 231.844 12.773 231.844 28.3329V111.474H138.792C123.709 111.474 111.41 123.782 111.41 139.11V232.237H27.6395C12.3241 232.237 0.0253906 244.545 0.0253906 259.873V324.899C0.0253906 340.227 12.3241 352.536 27.6395 353H324.665C340.212 353 352.975 340.227 352.975 324.667V28.3329C352.743 12.773 339.98 0 324.433 0Z"
|
||||
fill="url(#paint1_linear_1458_69)"
|
||||
/>
|
||||
<path
|
||||
d="M324.433 0H260.619C245.071 0 232.309 12.773 232.309 28.3329V353H324.433C339.98 353 352.743 340.227 352.743 324.667V28.3329C352.743 12.773 339.98 0 324.433 0Z"
|
||||
fill="#F57C00"
|
||||
/>
|
||||
<path
|
||||
d="M111.41 139.342V232.237H27.8715C12.5561 232.237 0.0253906 244.778 0.0253906 260.105V325.132C0.0253906 340.459 12.5561 353 27.8715 353H232.076V111.474H139.256C123.941 111.474 111.41 124.014 111.41 139.342Z"
|
||||
fill="#FFC107"
|
||||
/>
|
||||
<path
|
||||
d="M232.076 111.474V353H324.2C339.748 353 352.511 340.227 352.511 324.667V232.237L232.076 111.474Z"
|
||||
fill="url(#paint2_linear_1458_69)"
|
||||
/>
|
||||
<path
|
||||
opacity="0.2"
|
||||
d="M139.256 113.796H232.077V111.474H139.256C123.941 111.474 111.41 124.014 111.41 139.342V141.664C111.41 126.337 123.941 113.796 139.256 113.796Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
opacity="0.2"
|
||||
d="M27.8715 234.56H111.41V232.237H27.8715C12.5561 232.237 0.0253906 244.778 0.0253906 260.106V262.428C0.0253906 247.1 12.5561 234.56 27.8715 234.56Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
opacity="0.2"
|
||||
d="M324.433 0H260.619C245.071 0 232.309 12.773 232.309 28.3329V30.6553C232.309 15.0954 245.071 2.32237 260.619 2.32237H324.433C339.98 2.32237 352.743 15.0954 352.743 30.6553V28.3329C352.743 12.773 339.98 0 324.433 0Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
opacity="0.2"
|
||||
d="M324.433 350.678H27.8715C12.5561 350.678 0.0253906 338.137 0.0253906 322.809V325.132C0.0253906 340.459 12.5561 353 27.8715 353H324.201C339.748 353 352.511 340.227 352.511 324.667V322.345C352.743 337.905 339.98 350.678 324.433 350.678V350.678Z"
|
||||
fill="#BF360C"
|
||||
/>
|
||||
<path
|
||||
d="M324.433 0H260.619C245.071 0 232.309 12.773 232.309 28.3329V111.474H139.488C124.173 111.474 111.642 124.014 111.642 139.342V232.237H27.8715C12.5561 232.237 0.0253906 244.778 0.0253906 260.105V325.132C0.0253906 340.459 12.5561 353 27.8715 353H324.433C339.98 353 352.743 340.227 352.743 324.667V28.3329C352.743 12.773 339.98 0 324.433 0Z"
|
||||
fill="url(#paint3_linear_1458_69)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_1458_69"
|
||||
x1="0.0253906"
|
||||
y1="176.5"
|
||||
x2="352.975"
|
||||
y2="176.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="white" stopOpacity="0.1" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_1458_69"
|
||||
x1="0.0253906"
|
||||
y1="176.5"
|
||||
x2="352.975"
|
||||
y2="176.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="white" stopOpacity="0.1" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_1458_69"
|
||||
x1="172.323"
|
||||
y1="172.436"
|
||||
x2="344.434"
|
||||
y2="344.409"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#BF360C" stopOpacity="0.2" />
|
||||
<stop offset="1" stopColor="#BF360C" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_1458_69"
|
||||
x1="118.3"
|
||||
y1="118.513"
|
||||
x2="346.649"
|
||||
y2="346.679"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="white" stopOpacity="0.1" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_1458_69">
|
||||
<rect width="353" height="353" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</Icon>
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Text } from '@chakra-ui/react'
|
||||
import { GoogleAnalyticsOptions } from 'models'
|
||||
|
||||
type Props = {
|
||||
action: GoogleAnalyticsOptions['action']
|
||||
}
|
||||
|
||||
export const GoogleAnalyticsNodeContent = ({ action }: Props) => (
|
||||
<Text color={action ? 'currentcolor' : 'gray.500'} noOfLines={1}>
|
||||
{action ? `Track "${action}"` : 'Configure...'}
|
||||
</Text>
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Input } from '@/components/inputs'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
FormLabel,
|
||||
Stack,
|
||||
Tag,
|
||||
} from '@chakra-ui/react'
|
||||
import { GoogleAnalyticsOptions } from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
options?: GoogleAnalyticsOptions
|
||||
onOptionsChange: (options: GoogleAnalyticsOptions) => void
|
||||
}
|
||||
|
||||
export const GoogleAnalyticsSettings = ({
|
||||
options,
|
||||
onOptionsChange,
|
||||
}: Props) => {
|
||||
const handleTrackingIdChange = (trackingId: string) =>
|
||||
onOptionsChange({ ...options, trackingId })
|
||||
|
||||
const handleCategoryChange = (category: string) =>
|
||||
onOptionsChange({ ...options, category })
|
||||
|
||||
const handleActionChange = (action: string) =>
|
||||
onOptionsChange({ ...options, action })
|
||||
|
||||
const handleLabelChange = (label: string) =>
|
||||
onOptionsChange({ ...options, label })
|
||||
|
||||
const handleValueChange = (value?: string) =>
|
||||
onOptionsChange({
|
||||
...options,
|
||||
value: value ? parseFloat(value) : undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Stack>
|
||||
<FormLabel mb="0" htmlFor="tracking-id">
|
||||
Tracking ID:
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="tracking-id"
|
||||
defaultValue={options?.trackingId ?? ''}
|
||||
placeholder="G-123456..."
|
||||
onChange={handleTrackingIdChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<FormLabel mb="0" htmlFor="category">
|
||||
Event category:
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="category"
|
||||
defaultValue={options?.category ?? ''}
|
||||
placeholder="Example: Typebot"
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<FormLabel mb="0" htmlFor="action">
|
||||
Event action:
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="action"
|
||||
defaultValue={options?.action ?? ''}
|
||||
placeholder="Example: Submit email"
|
||||
onChange={handleActionChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Accordion allowToggle>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
Advanced
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4} as={Stack} spacing="6">
|
||||
<Stack>
|
||||
<FormLabel mb="0" htmlFor="label">
|
||||
Event label <Tag>Optional</Tag>:
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="label"
|
||||
defaultValue={options?.label ?? ''}
|
||||
placeholder="Example: Campaign Z"
|
||||
onChange={handleLabelChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<FormLabel mb="0" htmlFor="value">
|
||||
Event value <Tag>Optional</Tag>:
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="value"
|
||||
defaultValue={options?.value?.toString() ?? ''}
|
||||
placeholder="Example: 0"
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from '@playwright/test'
|
||||
import { createTypebots } from 'utils/playwright/databaseActions'
|
||||
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
|
||||
import { defaultGoogleAnalyticsOptions, IntegrationBlockType } from 'models'
|
||||
import cuid from 'cuid'
|
||||
|
||||
test.describe('Google Analytics block', () => {
|
||||
test('its configuration should work', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultGroupWithBlock({
|
||||
type: IntegrationBlockType.GOOGLE_ANALYTICS,
|
||||
options: defaultGoogleAnalyticsOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('text=Configure...')
|
||||
await page.fill('input[placeholder="G-123456..."]', 'G-VWX9WG1TNS')
|
||||
await page.fill('input[placeholder="Example: Typebot"]', 'Typebot')
|
||||
await page.fill(
|
||||
'input[placeholder="Example: Submit email"]',
|
||||
'Submit email'
|
||||
)
|
||||
await page.click('text=Advanced')
|
||||
await page.fill('input[placeholder="Example: Campaign Z"]', 'Campaign Z')
|
||||
await page.fill('input[placeholder="Example: 0"]', '0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GoogleAnalyticsSettings } from './components/GoogleAnalyticsSettings'
|
||||
export { GoogleAnalyticsLogo } from './components/GoogleAnalyticsLogo'
|
||||
@@ -0,0 +1,178 @@
|
||||
import { IconProps, Icon } from '@chakra-ui/react'
|
||||
|
||||
export const GoogleSheetsLogo = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 49 67" {...props}>
|
||||
<title>Sheets-icon</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-1"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-3"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-5"
|
||||
></path>
|
||||
<linearGradient
|
||||
x1="50.0053945%"
|
||||
y1="8.58610612%"
|
||||
x2="50.0053945%"
|
||||
y2="100.013939%"
|
||||
id="linearGradient-7"
|
||||
>
|
||||
<stop stopColor="#263238" stopOpacity="0.2" offset="0%"></stop>
|
||||
<stop stopColor="#263238" stopOpacity="0.02" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-8"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-10"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-12"
|
||||
></path>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-14"
|
||||
></path>
|
||||
<radialGradient
|
||||
cx="3.16804688%"
|
||||
cy="2.71744318%"
|
||||
fx="3.16804688%"
|
||||
fy="2.71744318%"
|
||||
r="161.248516%"
|
||||
gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)"
|
||||
id="radialGradient-16"
|
||||
>
|
||||
<stop stopColor="#FFFFFF" stopOpacity="0.1" offset="0%"></stop>
|
||||
<stop stopColor="#FFFFFF" stopOpacity="0" offset="100%"></stop>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<g
|
||||
id="Consumer-Apps-Sheets-Large-VD-R8-"
|
||||
transform="translate(-451.000000, -451.000000)"
|
||||
>
|
||||
<g id="Hero" transform="translate(0.000000, 63.000000)">
|
||||
<g id="Personal" transform="translate(277.000000, 299.000000)">
|
||||
<g id="Sheets-icon" transform="translate(174.833333, 89.958333)">
|
||||
<g id="Group">
|
||||
<g id="Clipped">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlinkHref="#path-1"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z"
|
||||
id="Path"
|
||||
fill="#0F9D58"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlinkHref="#path-3"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z"
|
||||
id="Shape"
|
||||
fill="#F1F1F1"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-6" fill="white">
|
||||
<use xlinkHref="#path-5"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<polygon
|
||||
id="Path"
|
||||
fill="url(#linearGradient-7)"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-6)"
|
||||
points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75"
|
||||
></polygon>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-9" fill="white">
|
||||
<use xlinkHref="#path-8"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<g id="Group" mask="url(#mask-9)">
|
||||
<g transform="translate(26.625000, -2.958333)">
|
||||
<path
|
||||
d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z"
|
||||
id="Path"
|
||||
fill="#87CEAC"
|
||||
fillRule="nonzero"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-11" fill="white">
|
||||
<use xlinkHref="#path-10"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.2"
|
||||
fill="#FFFFFF"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-11)"
|
||||
></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-13" fill="white">
|
||||
<use xlinkHref="#path-12"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.2"
|
||||
fill="#263238"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-13)"
|
||||
></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-15" fill="white">
|
||||
<use xlinkHref="#path-14"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path
|
||||
d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.1"
|
||||
fill="#263238"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-15)"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="Path"
|
||||
fill="url(#radialGradient-16)"
|
||||
fillRule="nonzero"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</Icon>
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Text } from '@chakra-ui/react'
|
||||
import { GoogleSheetsAction } from 'models'
|
||||
|
||||
type Props = {
|
||||
action?: GoogleSheetsAction
|
||||
}
|
||||
|
||||
export const GoogleSheetsNodeContent = ({ action }: Props) => (
|
||||
<Text color={action ? 'currentcolor' : 'gray.500'} noOfLines={1}>
|
||||
{action ?? 'Configure...'}
|
||||
</Text>
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { DropdownList } from '@/components/DropdownList'
|
||||
import { Cell } from 'models'
|
||||
import { TableListItemProps } from '@/components/TableList'
|
||||
import { Input } from '@/components/inputs'
|
||||
|
||||
export const CellWithValueStack = ({
|
||||
item,
|
||||
onItemChange,
|
||||
columns,
|
||||
}: TableListItemProps<Cell> & { columns: string[] }) => {
|
||||
const handleColumnSelect = (column: string) => {
|
||||
if (item.column === column) return
|
||||
onItemChange({ ...item, column })
|
||||
}
|
||||
const handleValueChange = (value: string) => {
|
||||
if (item.value === value) return
|
||||
onItemChange({ ...item, value })
|
||||
}
|
||||
return (
|
||||
<Stack p="4" rounded="md" flex="1" borderWidth="1px" w="full">
|
||||
<DropdownList<string>
|
||||
currentItem={item.column}
|
||||
onItemSelect={handleColumnSelect}
|
||||
items={columns}
|
||||
placeholder="Select a column"
|
||||
/>
|
||||
<Input
|
||||
defaultValue={item.value ?? ''}
|
||||
onChange={handleValueChange}
|
||||
placeholder="Type a value..."
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { DropdownList } from '@/components/DropdownList'
|
||||
import { ExtractingCell, Variable } from 'models'
|
||||
import { TableListItemProps } from '@/components/TableList'
|
||||
import { VariableSearchInput } from '@/components/VariableSearchInput'
|
||||
|
||||
export const CellWithVariableIdStack = ({
|
||||
item,
|
||||
onItemChange,
|
||||
columns,
|
||||
}: TableListItemProps<ExtractingCell> & { columns: string[] }) => {
|
||||
const handleColumnSelect = (column: string) => {
|
||||
if (item.column === column) return
|
||||
onItemChange({ ...item, column })
|
||||
}
|
||||
|
||||
const handleVariableIdChange = (variable?: Variable) => {
|
||||
if (item.variableId === variable?.id) return
|
||||
onItemChange({ ...item, variableId: variable?.id })
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
|
||||
<DropdownList<string>
|
||||
currentItem={item.column}
|
||||
onItemSelect={handleColumnSelect}
|
||||
items={columns}
|
||||
placeholder="Select a column"
|
||||
/>
|
||||
<VariableSearchInput
|
||||
initialVariableId={item.variableId}
|
||||
onSelectVariable={handleVariableIdChange}
|
||||
placeholder="Select a variable"
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Stack,
|
||||
Text,
|
||||
Image,
|
||||
Button,
|
||||
ModalFooter,
|
||||
Flex,
|
||||
} from '@chakra-ui/react'
|
||||
import { useWorkspace } from '@/features/workspace'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import { AlertInfo } from '@/components/AlertInfo'
|
||||
import { GoogleLogo } from '@/components/GoogleLogo'
|
||||
import { getGoogleSheetsConsentScreenUrlQuery } from '../../queries/getGoogleSheetsConsentScreenUrlQuery'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
blockId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const GoogleSheetConnectModal = ({
|
||||
blockId,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const { workspace } = useWorkspace()
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Connect Spreadsheets</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody as={Stack} spacing="6">
|
||||
<AlertInfo>
|
||||
Typebot needs access to Google Drive in order to list all your
|
||||
spreadsheets. It also needs access to your spreadsheets in order to
|
||||
fetch or inject data in it.
|
||||
</AlertInfo>
|
||||
<Text>
|
||||
Make sure to check all the permissions so that the integration works
|
||||
as expected:
|
||||
</Text>
|
||||
<Image
|
||||
src="/images/google-spreadsheets-scopes.jpeg"
|
||||
alt="Google Spreadsheets checkboxes"
|
||||
/>
|
||||
<Flex>
|
||||
<Button
|
||||
as={Link}
|
||||
leftIcon={<GoogleLogo />}
|
||||
data-testid="google"
|
||||
isLoading={['loading', 'authenticated'].includes(status)}
|
||||
variant="outline"
|
||||
href={getGoogleSheetsConsentScreenUrlQuery(
|
||||
window.location.href,
|
||||
blockId,
|
||||
workspace?.id
|
||||
)}
|
||||
mx="auto"
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Divider, Stack, Text, useDisclosure } from '@chakra-ui/react'
|
||||
import { DropdownList } from '@/components/DropdownList'
|
||||
import { useTypebot } from '@/features/editor'
|
||||
import {
|
||||
Cell,
|
||||
CredentialsType,
|
||||
ExtractingCell,
|
||||
GoogleSheetsAction,
|
||||
GoogleSheetsGetOptions,
|
||||
GoogleSheetsInsertRowOptions,
|
||||
GoogleSheetsOptions,
|
||||
GoogleSheetsUpdateRowOptions,
|
||||
} from 'models'
|
||||
import React, { useMemo } from 'react'
|
||||
import { isDefined, omit } from 'utils'
|
||||
import { SheetsDropdown } from './SheetsDropdown'
|
||||
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
|
||||
import { CellWithValueStack } from './CellWithValueStack'
|
||||
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
|
||||
import { GoogleSheetConnectModal } from './GoogleSheetsConnectModal'
|
||||
import { TableListItemProps, TableList } from '@/components/TableList'
|
||||
import { CredentialsDropdown } from '@/features/credentials'
|
||||
import { useSheets } from '../../hooks/useSheets'
|
||||
import { Sheet } from '../../types'
|
||||
|
||||
type Props = {
|
||||
options: GoogleSheetsOptions
|
||||
onOptionsChange: (options: GoogleSheetsOptions) => void
|
||||
blockId: string
|
||||
}
|
||||
|
||||
export const GoogleSheetsSettingsBody = ({
|
||||
options,
|
||||
onOptionsChange,
|
||||
blockId,
|
||||
}: Props) => {
|
||||
const { save } = useTypebot()
|
||||
const { sheets, isLoading } = useSheets({
|
||||
credentialsId: options?.credentialsId,
|
||||
spreadsheetId: options?.spreadsheetId,
|
||||
})
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const sheet = useMemo(
|
||||
() => sheets?.find((s) => s.id === options?.sheetId),
|
||||
[sheets, options?.sheetId]
|
||||
)
|
||||
const handleCredentialsIdChange = (credentialsId?: string) =>
|
||||
onOptionsChange({ ...omit(options, 'credentialsId'), credentialsId })
|
||||
const handleSpreadsheetIdChange = (spreadsheetId: string) =>
|
||||
onOptionsChange({ ...options, spreadsheetId })
|
||||
const handleSheetIdChange = (sheetId: string) =>
|
||||
onOptionsChange({ ...options, sheetId })
|
||||
|
||||
const handleActionChange = (action: GoogleSheetsAction) => {
|
||||
switch (action) {
|
||||
case GoogleSheetsAction.GET: {
|
||||
const newOptions: GoogleSheetsGetOptions = {
|
||||
...options,
|
||||
action,
|
||||
cellsToExtract: [],
|
||||
}
|
||||
return onOptionsChange({ ...newOptions })
|
||||
}
|
||||
case GoogleSheetsAction.INSERT_ROW: {
|
||||
const newOptions: GoogleSheetsInsertRowOptions = {
|
||||
...options,
|
||||
action,
|
||||
cellsToInsert: [],
|
||||
}
|
||||
return onOptionsChange({ ...newOptions })
|
||||
}
|
||||
case GoogleSheetsAction.UPDATE_ROW: {
|
||||
const newOptions: GoogleSheetsUpdateRowOptions = {
|
||||
...options,
|
||||
action,
|
||||
cellsToUpsert: [],
|
||||
}
|
||||
return onOptionsChange({ ...newOptions })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateNewClick = async () => {
|
||||
await save()
|
||||
onOpen()
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<CredentialsDropdown
|
||||
type={CredentialsType.GOOGLE_SHEETS}
|
||||
currentCredentialsId={options?.credentialsId}
|
||||
onCredentialsSelect={handleCredentialsIdChange}
|
||||
onCreateNewClick={handleCreateNewClick}
|
||||
/>
|
||||
<GoogleSheetConnectModal
|
||||
blockId={blockId}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{options?.credentialsId && (
|
||||
<SpreadsheetsDropdown
|
||||
credentialsId={options.credentialsId}
|
||||
spreadsheetId={options.spreadsheetId}
|
||||
onSelectSpreadsheetId={handleSpreadsheetIdChange}
|
||||
/>
|
||||
)}
|
||||
{options?.spreadsheetId && options.credentialsId && (
|
||||
<SheetsDropdown
|
||||
sheets={sheets ?? []}
|
||||
isLoading={isLoading}
|
||||
sheetId={options.sheetId}
|
||||
onSelectSheetId={handleSheetIdChange}
|
||||
/>
|
||||
)}
|
||||
{options?.spreadsheetId &&
|
||||
options.credentialsId &&
|
||||
isDefined(options.sheetId) && (
|
||||
<>
|
||||
<Divider />
|
||||
<DropdownList<GoogleSheetsAction>
|
||||
currentItem={'action' in options ? options.action : undefined}
|
||||
onItemSelect={handleActionChange}
|
||||
items={Object.values(GoogleSheetsAction)}
|
||||
placeholder="Select an operation"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{'action' in options && (
|
||||
<ActionOptions
|
||||
options={options}
|
||||
sheet={sheet}
|
||||
onOptionsChange={onOptionsChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionOptions = ({
|
||||
options,
|
||||
sheet,
|
||||
onOptionsChange,
|
||||
}: {
|
||||
options:
|
||||
| GoogleSheetsGetOptions
|
||||
| GoogleSheetsInsertRowOptions
|
||||
| GoogleSheetsUpdateRowOptions
|
||||
sheet?: Sheet
|
||||
onOptionsChange: (options: GoogleSheetsOptions) => void
|
||||
}) => {
|
||||
const handleInsertColumnsChange = (cellsToInsert: Cell[]) =>
|
||||
onOptionsChange({ ...options, cellsToInsert } as GoogleSheetsOptions)
|
||||
|
||||
const handleUpsertColumnsChange = (cellsToUpsert: Cell[]) =>
|
||||
onOptionsChange({ ...options, cellsToUpsert } as GoogleSheetsOptions)
|
||||
|
||||
const handleReferenceCellChange = (referenceCell: Cell) =>
|
||||
onOptionsChange({ ...options, referenceCell } as GoogleSheetsOptions)
|
||||
|
||||
const handleExtractingCellsChange = (cellsToExtract: ExtractingCell[]) =>
|
||||
onOptionsChange({ ...options, cellsToExtract } as GoogleSheetsOptions)
|
||||
|
||||
const UpdatingCellItem = useMemo(
|
||||
() => (props: TableListItemProps<Cell>) =>
|
||||
<CellWithValueStack {...props} columns={sheet?.columns ?? []} />,
|
||||
[sheet?.columns]
|
||||
)
|
||||
|
||||
const ExtractingCellItem = useMemo(
|
||||
() => (props: TableListItemProps<ExtractingCell>) =>
|
||||
<CellWithVariableIdStack {...props} columns={sheet?.columns ?? []} />,
|
||||
[sheet?.columns]
|
||||
)
|
||||
|
||||
switch (options.action) {
|
||||
case GoogleSheetsAction.INSERT_ROW:
|
||||
return (
|
||||
<TableList<Cell>
|
||||
initialItems={options.cellsToInsert}
|
||||
onItemsChange={handleInsertColumnsChange}
|
||||
Item={UpdatingCellItem}
|
||||
addLabel="Add a value"
|
||||
/>
|
||||
)
|
||||
case GoogleSheetsAction.UPDATE_ROW:
|
||||
return (
|
||||
<Stack>
|
||||
<Text>Row to select</Text>
|
||||
<CellWithValueStack
|
||||
columns={sheet?.columns ?? []}
|
||||
item={options.referenceCell ?? { id: 'reference' }}
|
||||
onItemChange={handleReferenceCellChange}
|
||||
/>
|
||||
<Text>Cells to update</Text>
|
||||
<TableList<Cell>
|
||||
initialItems={options.cellsToUpsert}
|
||||
onItemsChange={handleUpsertColumnsChange}
|
||||
Item={UpdatingCellItem}
|
||||
addLabel="Add a value"
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
case GoogleSheetsAction.GET:
|
||||
return (
|
||||
<Stack>
|
||||
<Text>Row to select</Text>
|
||||
<CellWithValueStack
|
||||
columns={sheet?.columns ?? []}
|
||||
item={options.referenceCell ?? { id: 'reference' }}
|
||||
onItemChange={handleReferenceCellChange}
|
||||
/>
|
||||
<Text>Cells to extract</Text>
|
||||
<TableList<ExtractingCell>
|
||||
initialItems={options.cellsToExtract}
|
||||
onItemsChange={handleExtractingCellsChange}
|
||||
Item={ExtractingCellItem}
|
||||
addLabel="Add a value"
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
|
||||
import { SearchableDropdown } from '@/components/SearchableDropdown'
|
||||
import { HStack, Input } from '@chakra-ui/react'
|
||||
import { useMemo } from 'react'
|
||||
import { isDefined } from 'utils'
|
||||
import { Sheet } from '../../types'
|
||||
|
||||
type Props = {
|
||||
sheets: Sheet[]
|
||||
isLoading: boolean
|
||||
sheetId?: string
|
||||
onSelectSheetId: (id: string) => void
|
||||
}
|
||||
|
||||
export const SheetsDropdown = ({
|
||||
sheets,
|
||||
isLoading,
|
||||
sheetId,
|
||||
onSelectSheetId,
|
||||
}: Props) => {
|
||||
const currentSheet = useMemo(
|
||||
() => sheets?.find((s) => s.id === sheetId),
|
||||
[sheetId, sheets]
|
||||
)
|
||||
|
||||
const handleSpreadsheetSelect = (name: string) => {
|
||||
const id = sheets?.find((s) => s.name === name)?.id
|
||||
if (isDefined(id)) onSelectSheetId(id)
|
||||
}
|
||||
|
||||
if (isLoading) return <Input value="Loading..." isDisabled />
|
||||
if (!sheets || sheets.length === 0)
|
||||
return (
|
||||
<HStack>
|
||||
<Input value="No sheets found" isDisabled />
|
||||
<MoreInfoTooltip>
|
||||
Make sure your spreadsheet contains at least a sheet with a header
|
||||
row.
|
||||
</MoreInfoTooltip>
|
||||
</HStack>
|
||||
)
|
||||
return (
|
||||
<SearchableDropdown
|
||||
selectedItem={currentSheet?.name}
|
||||
items={(sheets ?? []).map((s) => s.name)}
|
||||
onValueChange={handleSpreadsheetSelect}
|
||||
placeholder={'Select the sheet'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SearchableDropdown } from '@/components/SearchableDropdown'
|
||||
import { Input, Tooltip } from '@chakra-ui/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useSpreadsheets } from '../../hooks/useSpreadsheets'
|
||||
|
||||
type Props = {
|
||||
credentialsId: string
|
||||
spreadsheetId?: string
|
||||
onSelectSpreadsheetId: (id: string) => void
|
||||
}
|
||||
|
||||
export const SpreadsheetsDropdown = ({
|
||||
credentialsId,
|
||||
spreadsheetId,
|
||||
onSelectSpreadsheetId,
|
||||
}: Props) => {
|
||||
const { spreadsheets, isLoading } = useSpreadsheets({
|
||||
credentialsId,
|
||||
})
|
||||
const currentSpreadsheet = useMemo(
|
||||
() => spreadsheets?.find((s) => s.id === spreadsheetId),
|
||||
[spreadsheetId, spreadsheets]
|
||||
)
|
||||
|
||||
const handleSpreadsheetSelect = (name: string) => {
|
||||
const id = spreadsheets?.find((s) => s.name === name)?.id
|
||||
if (id) onSelectSpreadsheetId(id)
|
||||
}
|
||||
if (isLoading) return <Input value="Loading..." isDisabled />
|
||||
if (!spreadsheets || spreadsheets.length === 0)
|
||||
return (
|
||||
<Tooltip label="No spreadsheets found, make sure you have at least one spreadsheet that contains a header row">
|
||||
<span>
|
||||
<Input value="No spreadsheets found" isDisabled />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
return (
|
||||
<SearchableDropdown
|
||||
selectedItem={currentSpreadsheet?.name}
|
||||
items={(spreadsheets ?? []).map((s) => s.name)}
|
||||
onValueChange={handleSpreadsheetSelect}
|
||||
placeholder={'Search for spreadsheet'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GoogleSheetsSettingsBody } from './GoogleSheetsSettingsBody'
|
||||
@@ -0,0 +1,162 @@
|
||||
import test, { expect, Page } from '@playwright/test'
|
||||
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||
import cuid from 'cuid'
|
||||
import { getTestAsset } from '@/test/utils/playwright'
|
||||
|
||||
test.describe.parallel('Google sheets integration', () => {
|
||||
test('Insert row should work', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await importTypebotInDatabase(
|
||||
getTestAsset('typebots/integrations/googleSheets.json'),
|
||||
{
|
||||
id: typebotId,
|
||||
}
|
||||
)
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await fillInSpreadsheetInfo(page)
|
||||
await page.click('text=Select an operation')
|
||||
await page.click('text=Insert a row')
|
||||
|
||||
await page.click('text=Add a value')
|
||||
await page.click('text=Select a column')
|
||||
await page.click('button >> text="Email"')
|
||||
await page.click('[aria-label="Insert a variable"]')
|
||||
await page.click('button >> text="Email" >> nth=1')
|
||||
|
||||
await page.click('text=Add a value')
|
||||
await page.click('text=Select a column')
|
||||
await page.click('text=First name')
|
||||
await page.fill(
|
||||
'input[placeholder="Type a value..."] >> nth = 1',
|
||||
'Georges'
|
||||
)
|
||||
|
||||
await page.click('text=Preview')
|
||||
await typebotViewer(page)
|
||||
.locator('input[placeholder="Type your email..."]')
|
||||
.fill('georges@gmail.com')
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp
|
||||
.request()
|
||||
.url()
|
||||
.includes(
|
||||
'/api/integrations/google-sheets/spreadsheets/1k_pIDw3YHl9tlZusbBVSBRY0PeRPd2H6t4Nj7rwnOtM/sheets/0'
|
||||
) &&
|
||||
resp.status() === 200 &&
|
||||
resp.request().method() === 'POST'
|
||||
),
|
||||
typebotViewer(page)
|
||||
.locator('input[placeholder="Type your email..."]')
|
||||
.press('Enter'),
|
||||
])
|
||||
})
|
||||
|
||||
test('Update row should work', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await importTypebotInDatabase(
|
||||
getTestAsset('typebots/integrations/googleSheets.json'),
|
||||
{
|
||||
id: typebotId,
|
||||
}
|
||||
)
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await fillInSpreadsheetInfo(page)
|
||||
await page.click('text=Select an operation')
|
||||
await page.click('text=Update a row')
|
||||
|
||||
await page.click('text=Add a value')
|
||||
await page.click('text=Select a column')
|
||||
await page.click('button >> text="Email"')
|
||||
await page.click('[aria-label="Insert a variable"]')
|
||||
await page.click('button >> text="Email" >> nth=1')
|
||||
|
||||
await page.click('text=Add a value')
|
||||
await page.click('text=Select a column')
|
||||
await page.click('text=Last name')
|
||||
await page.fill(
|
||||
'input[placeholder="Type a value..."] >> nth = 1',
|
||||
'Last name'
|
||||
)
|
||||
|
||||
await page.click('text=Preview')
|
||||
await typebotViewer(page)
|
||||
.locator('input[placeholder="Type your email..."]')
|
||||
.fill('test@test.com')
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp
|
||||
.request()
|
||||
.url()
|
||||
.includes(
|
||||
'/api/integrations/google-sheets/spreadsheets/1k_pIDw3YHl9tlZusbBVSBRY0PeRPd2H6t4Nj7rwnOtM/sheets/0'
|
||||
) &&
|
||||
resp.status() === 200 &&
|
||||
resp.request().method() === 'PATCH'
|
||||
),
|
||||
typebotViewer(page)
|
||||
.locator('input[placeholder="Type your email..."]')
|
||||
.press('Enter'),
|
||||
])
|
||||
})
|
||||
|
||||
test('Get row should work', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await importTypebotInDatabase(
|
||||
getTestAsset('typebots/integrations/googleSheetsGet.json'),
|
||||
{
|
||||
id: typebotId,
|
||||
}
|
||||
)
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await fillInSpreadsheetInfo(page)
|
||||
await page.click('text=Select an operation')
|
||||
await page.click('text=Get data from sheet')
|
||||
|
||||
await page.click('text=Select a column')
|
||||
await page.click('button >> text="Email"')
|
||||
await page.click('[aria-label="Insert a variable"]')
|
||||
await page.click('button >> text="Email" >> nth=1')
|
||||
|
||||
await page.click('text=Add a value')
|
||||
await page.click('text=Select a column')
|
||||
await page.click('text="First name"')
|
||||
await createNewVar(page, 'First name')
|
||||
|
||||
await page.click('text=Add a value')
|
||||
await page.click('text=Select a column')
|
||||
await page.click('text="Last name"')
|
||||
await createNewVar(page, 'Last name')
|
||||
|
||||
await page.click('text=Preview')
|
||||
await typebotViewer(page)
|
||||
.locator('input[placeholder="Type your email..."]')
|
||||
.fill('test2@test.com')
|
||||
await typebotViewer(page)
|
||||
.locator('input[placeholder="Type your email..."]')
|
||||
.press('Enter')
|
||||
await expect(
|
||||
typebotViewer(page).locator('text=Your name is: John Smith')
|
||||
).toBeVisible({ timeout: 30000 })
|
||||
})
|
||||
})
|
||||
|
||||
const fillInSpreadsheetInfo = async (page: Page) => {
|
||||
await page.click('text=Configure...')
|
||||
await page.click('text=Select an account')
|
||||
await page.click('text=pro-user@email.com')
|
||||
|
||||
await page.fill('input[placeholder="Search for spreadsheet"]', 'CR')
|
||||
await page.click('text=CRM')
|
||||
|
||||
await page.fill('input[placeholder="Select the sheet"]', 'Sh')
|
||||
await page.click('text=Sheet1')
|
||||
}
|
||||
|
||||
const createNewVar = async (page: Page, name: string) => {
|
||||
await page.fill('input[placeholder="Select a variable"] >> nth=-1', name)
|
||||
await page.click(`text=Create "${name}"`)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { stringify } from 'qs'
|
||||
import { fetcher } from '@/utils/helpers'
|
||||
import useSWR from 'swr'
|
||||
import { Sheet } from '../types'
|
||||
|
||||
export const useSheets = ({
|
||||
credentialsId,
|
||||
spreadsheetId,
|
||||
onError,
|
||||
}: {
|
||||
credentialsId?: string
|
||||
spreadsheetId?: string
|
||||
onError?: (error: Error) => void
|
||||
}) => {
|
||||
const queryParams = stringify({ credentialsId })
|
||||
const { data, error, mutate } = useSWR<{ sheets: Sheet[] }, Error>(
|
||||
!credentialsId || !spreadsheetId
|
||||
? null
|
||||
: `/api/integrations/google-sheets/spreadsheets/${spreadsheetId}/sheets?${queryParams}`,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError && onError(error)
|
||||
return {
|
||||
sheets: data?.sheets,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { stringify } from 'qs'
|
||||
import { fetcher } from '@/utils/helpers'
|
||||
import useSWR from 'swr'
|
||||
import { Spreadsheet } from '../types'
|
||||
|
||||
export const useSpreadsheets = ({
|
||||
credentialsId,
|
||||
onError,
|
||||
}: {
|
||||
credentialsId: string
|
||||
onError?: (error: Error) => void
|
||||
}) => {
|
||||
const queryParams = stringify({ credentialsId })
|
||||
const { data, error, mutate } = useSWR<{ files: Spreadsheet[] }, Error>(
|
||||
`/api/integrations/google-sheets/spreadsheets?${queryParams}`,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError && onError(error)
|
||||
return {
|
||||
spreadsheets: data?.files,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { GoogleSheetsSettingsBody } from './components/GoogleSheetsSettingsBody'
|
||||
export { GoogleSheetsNodeContent } from './components/GoogleSheetsNodeContent'
|
||||
export { GoogleSheetsLogo } from './components/GoogleSheetsLogo'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user