2
0

refactor: ♻️ Rename step to block

This commit is contained in:
Baptiste Arnaud
2022-06-11 07:27:38 +02:00
parent 8751766d0e
commit 2df8338505
297 changed files with 4292 additions and 3989 deletions

View File

@@ -1,193 +1,248 @@
import {
Editable,
EditableInput,
EditablePreview,
IconButton,
Stack,
Flex,
HStack,
Popover,
PopoverTrigger,
useDisclosure,
} from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import { Block } from 'models'
import {
BubbleBlock,
BubbleBlockContent,
ConditionBlock,
DraggableBlock,
Block,
BlockWithOptions,
TextBubbleContent,
TextBubbleBlock,
} from 'models'
import { useGraph } from 'contexts/GraphContext'
import { useStepDnd } from 'contexts/GraphDndContext'
import { StepNodesList } from '../StepNode/StepNodesList'
import { isDefined, isNotDefined } from 'utils'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { BlockIcon } from 'components/editor/BlocksSideBar/BlockIcon'
import { isBubbleBlock, isTextBubbleBlock } from 'utils'
import { BlockNodeContent } from './BlockNodeContent/BlockNodeContent'
import { useTypebot } from 'contexts/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent'
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
import { useDebounce } from 'use-debounce'
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
import { hasDefaultConnector } from 'services/typebots'
import { useRouter } from 'next/router'
import { SettingsModal } from './SettingsPopoverContent/SettingsModal'
import { BlockSettings } from './SettingsPopoverContent/SettingsPopoverContent'
import { TextBubbleEditor } from './TextBubbleEditor'
import { TargetEndpoint } from '../../Endpoints'
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
import { NodePosition, useDragDistance } from 'contexts/GraphDndContext'
import { setMultipleRefs } from 'services/utils'
import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'
import { PlayIcon } from 'assets/icons'
import { RightPanel, useEditor } from 'contexts/EditorContext'
type Props = {
export const BlockNode = ({
block,
isConnectable,
indices,
onMouseDown,
}: {
block: Block
blockIndex: number
}
export const BlockNode = ({ block, blockIndex }: Props) => {
isConnectable: boolean
indices: { blockIndex: number; groupIndex: number }
onMouseDown?: (blockNodePosition: NodePosition, block: DraggableBlock) => void
}) => {
const { query } = useRouter()
const {
connectingIds,
setConnectingIds,
connectingIds,
openedBlockId,
setOpenedBlockId,
setFocusedGroupId,
previewingEdge,
blocksCoordinates,
updateBlockCoordinates,
isReadOnly,
focusedBlockId,
setFocusedBlockId,
graphPosition,
} = useGraph()
const { typebot, updateBlock } = useTypebot()
const { setMouseOverBlock, mouseOverBlock } = useStepDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
const { updateBlock } = useTypebot()
const [isConnecting, setIsConnecting] = useState(false)
const { setRightPanel, setStartPreviewAtBlock } = useEditor()
const isPreviewing =
previewingEdge?.from.blockId === block.id ||
(previewingEdge?.to.blockId === block.id &&
isNotDefined(previewingEdge.to.stepId))
const isStartBlock =
isDefined(block.steps[0]) && block.steps[0].type === 'start'
const blockCoordinates = blocksCoordinates[block.id]
const [isPopoverOpened, setIsPopoverOpened] = useState(
openedBlockId === block.id
)
const [isEditing, setIsEditing] = useState<boolean>(
isTextBubbleBlock(block) && block.content.plainText === ''
)
const blockRef = useRef<HTMLDivElement | null>(null)
const [debouncedBlockPosition] = useDebounce(blockCoordinates, 100)
const isPreviewing = isConnecting || previewingEdge?.to.blockId === block.id
const onDrag = (position: NodePosition) => {
if (block.type === 'start' || !onMouseDown) return
onMouseDown(position, block)
}
useDragDistance({
ref: blockRef,
onDrag,
isDisabled: !onMouseDown || block.type === 'start',
})
const {
isOpen: isModalOpen,
onOpen: onModalOpen,
onClose: onModalClose,
} = useDisclosure()
useEffect(() => {
if (!debouncedBlockPosition || isReadOnly) return
if (
debouncedBlockPosition?.x === block.graphCoordinates.x &&
debouncedBlockPosition.y === block.graphCoordinates.y
)
return
updateBlock(blockIndex, { graphCoordinates: debouncedBlockPosition })
if (query.blockId?.toString() === block.id) setOpenedBlockId(block.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedBlockPosition])
}, [query])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.blockId === block.id &&
isNotDefined(connectingIds.target?.stepId)
connectingIds?.target?.groupId === block.groupId &&
connectingIds?.target?.blockId === block.id
)
}, [block.id, connectingIds])
}, [connectingIds, block.groupId, block.id])
const handleTitleSubmit = (title: string) =>
updateBlock(blockIndex, { title })
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
const handleModalClose = () => {
updateBlock(indices, { ...block })
onModalClose()
}
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverBlock?.id !== block.id && !isStartBlock)
setMouseOverBlock({ id: block.id, ref: blockRef })
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { blockId: block.id } })
setConnectingIds({
...connectingIds,
target: { groupId: block.groupId, blockId: block.id },
})
}
const handleMouseLeave = () => {
if (isReadOnly) return
setMouseOverBlock(undefined)
if (connectingIds) setConnectingIds({ ...connectingIds, target: undefined })
if (connectingIds?.target)
setConnectingIds({
...connectingIds,
target: { ...connectingIds.target, blockId: undefined },
})
}
const onDrag = (_: DraggableEvent, draggableData: DraggableData) => {
const { deltaX, deltaY } = draggableData
updateBlockCoordinates(block.id, {
x: blockCoordinates.x + deltaX / graphPosition.scale,
y: blockCoordinates.y + deltaY / graphPosition.scale,
})
const handleCloseEditor = (content: TextBubbleContent) => {
const updatedBlock = { ...block, content } as Block
updateBlock(indices, updatedBlock)
setIsEditing(false)
}
const onDragStart = () => {
setFocusedBlockId(block.id)
setIsMouseDown(true)
const handleClick = (e: React.MouseEvent) => {
setFocusedGroupId(block.groupId)
e.stopPropagation()
if (isTextBubbleBlock(block)) setIsEditing(true)
setOpenedBlockId(block.id)
}
const startPreviewAtThisBlock = () => {
setStartPreviewAtBlock(block.id)
setRightPanel(RightPanel.PREVIEW)
const handleExpandClick = () => {
setOpenedBlockId(undefined)
onModalOpen()
}
const onDragStop = () => setIsMouseDown(false)
return (
const handleBlockUpdate = (updates: Partial<Block>) =>
updateBlock(indices, { ...block, ...updates })
const handleContentChange = (content: BubbleBlockContent) =>
updateBlock(indices, { ...block, content } as Block)
useEffect(() => {
setIsPopoverOpened(openedBlockId === block.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openedBlockId])
return isEditing && isTextBubbleBlock(block) ? (
<TextBubbleEditor
initialValue={block.content.richText}
onClose={handleCloseEditor}
/>
) : (
<ContextMenu<HTMLDivElement>
renderMenu={() => <BlockNodeContextMenu blockIndex={blockIndex} />}
isDisabled={isReadOnly || isStartBlock}
renderMenu={() => <BlockNodeContextMenu indices={indices} />}
>
{(ref, isOpened) => (
<DraggableCore
enableUserSelectHack={false}
onDrag={onDrag}
onStart={onDragStart}
onStop={onDragStop}
onMouseDown={(e) => e.stopPropagation()}
<Popover
placement="left"
isLazy
isOpen={isPopoverOpened}
closeOnBlur={false}
>
<Stack
ref={setMultipleRefs([ref, blockRef])}
data-testid="block"
p="4"
rounded="xl"
bgColor="#ffffff"
borderWidth="2px"
borderColor={
isConnecting || isOpened || isPreviewing ? 'blue.400' : '#ffffff'
}
w="300px"
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{
transform: `translate(${blockCoordinates?.x ?? 0}px, ${
blockCoordinates?.y ?? 0
}px)`,
}}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
cursor={isMouseDown ? 'grabbing' : 'pointer'}
shadow="md"
_hover={{ shadow: 'lg' }}
zIndex={focusedBlockId === block.id ? 10 : 1}
>
<Editable
defaultValue={block.title}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly || isStartBlock ? 'none' : 'auto'}
<PopoverTrigger>
<Flex
pos="relative"
ref={setMultipleRefs([ref, blockRef])}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
data-testid={`block`}
w="full"
>
<EditablePreview
_hover={{ bgColor: 'gray.200' }}
px="1"
userSelect={'none'}
<HStack
flex="1"
userSelect="none"
p="3"
borderWidth={isOpened || isPreviewing ? '2px' : '1px'}
borderColor={isOpened || isPreviewing ? 'blue.400' : 'gray.200'}
margin={isOpened || isPreviewing ? '-1px' : 0}
rounded="lg"
cursor={'pointer'}
bgColor="gray.50"
align="flex-start"
w="full"
transition="border-color 0.2s"
>
<BlockIcon
type={block.type}
mt="1"
data-testid={`${block.id}-icon`}
/>
<BlockNodeContent block={block} indices={indices} />
<TargetEndpoint
pos="absolute"
left="-32px"
top="19px"
blockId={block.id}
/>
{isConnectable && hasDefaultConnector(block) && (
<SourceEndpoint
source={{
groupId: block.groupId,
blockId: block.id,
}}
pos="absolute"
right="-34px"
bottom="10px"
/>
)}
</HStack>
</Flex>
</PopoverTrigger>
{hasSettingsPopover(block) && (
<>
<SettingsPopoverContent
block={block}
onExpandClick={handleExpandClick}
onBlockChange={handleBlockUpdate}
/>
<EditableInput
minW="0"
px="1"
onMouseDown={(e) => e.stopPropagation()}
/>
</Editable>
{typebot && (
<StepNodesList
blockId={block.id}
steps={block.steps}
blockIndex={blockIndex}
blockRef={ref}
isStartBlock={isStartBlock}
/>
)}
<IconButton
icon={<PlayIcon />}
aria-label={'Preview bot from this group'}
pos="absolute"
right={2}
top={0}
size="sm"
variant="outline"
onClick={startPreviewAtThisBlock}
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<BlockSettings
block={block}
onBlockChange={handleBlockUpdate}
/>
</SettingsModal>
</>
)}
{isMediaBubbleBlock(block) && (
<MediaBubblePopoverContent
block={block}
onContentChange={handleContentChange}
/>
</Stack>
</DraggableCore>
)}
</Popover>
)}
</ContextMenu>
)
}
const hasSettingsPopover = (
block: Block
): block is BlockWithOptions | ConditionBlock => !isBubbleBlock(block)
const isMediaBubbleBlock = (
block: Block
): block is Exclude<BubbleBlock, TextBubbleBlock> =>
isBubbleBlock(block) && !isTextBubbleBlock(block)

View File

@@ -0,0 +1,156 @@
import { Text } from '@chakra-ui/react'
import {
Block,
StartBlock,
BubbleBlockType,
InputBlockType,
LogicBlockType,
IntegrationBlockType,
BlockIndices,
} from 'models'
import { isChoiceInput, isInputBlock } from 'utils'
import { ItemNodesList } from '../../ItemNode'
import {
EmbedBubbleContent,
SetVariableContent,
TextBubbleContent,
VideoBubbleContent,
WebhookContent,
WithVariableContent,
} from './contents'
import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PaymentInputContent } from './contents/PaymentInputContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
import { RatingInputContent } from './contents/RatingInputContent'
import { SendEmailContent } from './contents/SendEmailContent'
import { TypebotLinkContent } from './contents/TypebotLinkContent'
import { ProviderWebhookContent } from './contents/ZapierContent'
type Props = {
block: Block | StartBlock
indices: BlockIndices
}
export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
if (
isInputBlock(block) &&
!isChoiceInput(block) &&
block.options.variableId
) {
return <WithVariableContent block={block} />
}
switch (block.type) {
case BubbleBlockType.TEXT: {
return <TextBubbleContent block={block} />
}
case BubbleBlockType.IMAGE: {
return <ImageBubbleContent block={block} />
}
case BubbleBlockType.VIDEO: {
return <VideoBubbleContent block={block} />
}
case BubbleBlockType.EMBED: {
return <EmbedBubbleContent block={block} />
}
case InputBlockType.TEXT: {
return (
<PlaceholderContent
placeholder={block.options.labels.placeholder}
isLong={block.options.isLong}
/>
)
}
case InputBlockType.NUMBER:
case InputBlockType.EMAIL:
case InputBlockType.URL:
case InputBlockType.PHONE: {
return (
<PlaceholderContent placeholder={block.options.labels.placeholder} />
)
}
case InputBlockType.DATE: {
return <Text color={'gray.500'}>Pick a date...</Text>
}
case InputBlockType.CHOICE: {
return <ItemNodesList block={block} indices={indices} />
}
case InputBlockType.PAYMENT: {
return <PaymentInputContent block={block} />
}
case InputBlockType.RATING: {
return <RatingInputContent block={block} />
}
case LogicBlockType.SET_VARIABLE: {
return <SetVariableContent block={block} />
}
case LogicBlockType.CONDITION: {
return <ItemNodesList block={block} indices={indices} isReadOnly />
}
case LogicBlockType.REDIRECT: {
return (
<ConfigureContent
label={
block.options?.url ? `Redirect to ${block.options?.url}` : undefined
}
/>
)
}
case LogicBlockType.CODE: {
return (
<ConfigureContent
label={
block.options?.content ? `Run ${block.options?.name}` : undefined
}
/>
)
}
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkContent block={block} />
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<ConfigureContent
label={
block.options && 'action' in block.options
? block.options.action
: undefined
}
/>
)
}
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<ConfigureContent
label={
block.options?.action
? `Track "${block.options?.action}" `
: undefined
}
/>
)
}
case IntegrationBlockType.WEBHOOK: {
return <WebhookContent block={block} />
}
case IntegrationBlockType.ZAPIER: {
return (
<ProviderWebhookContent block={block} configuredLabel="Trigger zap" />
)
}
case IntegrationBlockType.PABBLY_CONNECT:
case IntegrationBlockType.MAKE_COM: {
return (
<ProviderWebhookContent
block={block}
configuredLabel="Trigger scenario"
/>
)
}
case IntegrationBlockType.EMAIL: {
return <SendEmailContent block={block} />
}
case 'start': {
return <Text>Start</Text>
}
}
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
type Props = { label?: string }
export const ConfigureContent = ({ label }: Props) => (
<Text color={label ? 'currentcolor' : 'gray.500'} noOfLines={0}>
{label ?? 'Configure...'}
</Text>
)

View File

@@ -0,0 +1,20 @@
import { Box, 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 (
<Box w="full" h="120px" pos="relative">
<iframe
id="embed-bubble-content"
src={block.content.url}
style={{
width: '100%',
height: '100%',
pointerEvents: 'none',
borderRadius: '5px',
}}
/>
</Box>
)
}

View File

@@ -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>
)
}

View File

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

View File

@@ -0,0 +1,10 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
type Props = { placeholder: string; isLong?: boolean }
export const PlaceholderContent = ({ placeholder, isLong }: Props) => (
<Text color={'gray.500'} h={isLong ? '100px' : 'auto'}>
{placeholder}
</Text>
)

View File

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

View File

@@ -0,0 +1,23 @@
import { Tag, Text, Wrap, WrapItem } from '@chakra-ui/react'
import { SendEmailBlock } from 'models'
type Props = {
block: SendEmailBlock
}
export const SendEmailContent = ({ block }: Props) => {
if (block.options.recipients.length === 0)
return <Text color="gray.500">Configure...</Text>
return (
<Wrap noOfLines={2} pr="6">
<WrapItem>
<Text>Send email to</Text>
</WrapItem>
{block.options.recipients.map((to) => (
<WrapItem key={to}>
<Tag>{to}</Tag>
</WrapItem>
))}
</Wrap>
)
}

View File

@@ -0,0 +1,18 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { SetVariableBlock } from 'models'
import { byId } from 'utils'
export const SetVariableContent = ({ block }: { block: SetVariableBlock }) => {
const { typebot } = useTypebot()
const variableName =
typebot?.variables.find(byId(block.options.variableId))?.name ?? ''
const expression = block.options.expressionToEvaluate ?? ''
return (
<Text color={'gray.500'} noOfLines={2}>
{variableName === '' && expression === ''
? 'Click to edit...'
: `${variableName} ${expression ? `= ${expression}` : ``}`}
</Text>
)
}

View File

@@ -0,0 +1,28 @@
import { Flex } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { TextBubbleBlock } from 'models'
import React from 'react'
import { parseVariableHighlight } from 'services/utils'
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),
}}
/>
)
}

View File

@@ -0,0 +1,44 @@
import { TypebotLinkBlock } from 'models'
import React from 'react'
import { Tag, Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { byId } from 'utils'
type Props = {
block: TypebotLinkBlock
}
export const TypebotLinkContent = ({ block }: Props) => {
const { linkedTypebots, typebot } = useTypebot()
const isCurrentTypebot =
typebot &&
(block.options.typebotId === typebot.id ||
block.options.typebotId === 'current')
const linkedTypebot = isCurrentTypebot
? typebot
: linkedTypebots?.find(byId(block.options.typebotId))
const blockTitle = linkedTypebot?.groups.find(
byId(block.options.groupId)
)?.title
if (!block.options.typebotId)
return <Text color="gray.500">Configure...</Text>
return (
<Text>
Jump{' '}
{blockTitle ? (
<>
to <Tag>{blockTitle}</Tag>
</>
) : (
<></>
)}{' '}
{!isCurrentTypebot ? (
<>
in <Tag colorScheme="blue">{linkedTypebot?.name}</Tag>
</>
) : (
<></>
)}
</Text>
)
}

View File

@@ -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>
)
}
}
}

View File

@@ -0,0 +1,20 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { WebhookBlock } from 'models'
import { byId } from 'utils'
type Props = {
block: WebhookBlock
}
export const WebhookContent = ({ block: { webhookId } }: Props) => {
const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(webhookId))
if (!webhook?.url) return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={2} pr="6">
{webhook.method} {webhook.url}
</Text>
)
}

View File

@@ -0,0 +1,31 @@
import { InputBlock } from 'models'
import { chakra, Text } from '@chakra-ui/react'
import React from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { byId } from 'utils'
type Props = {
block: InputBlock
}
export const WithVariableContent = ({ block }: Props) => {
const { typebot } = useTypebot()
const variableName = typebot?.variables.find(
byId(block.options.variableId)
)?.name
return (
<Text>
Collect{' '}
<chakra.span
bgColor="orange.400"
color="white"
rounded="md"
py="0.5"
px="1"
>
{variableName}
</chakra.span>
</Text>
)
}

View File

@@ -0,0 +1,43 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import {
defaultWebhookAttributes,
MakeComBlock,
PabblyConnectBlock,
Webhook,
ZapierBlock,
} from 'models'
import { useEffect } from 'react'
import { byId, isNotDefined } from 'utils'
type Props = {
block: ZapierBlock | MakeComBlock | PabblyConnectBlock
configuredLabel: string
}
export const ProviderWebhookContent = ({ block, configuredLabel }: Props) => {
const { webhooks, typebot, updateWebhook } = useTypebot()
const webhook = webhooks.find(byId(block.webhookId))
useEffect(() => {
if (!typebot) return
if (!webhook) {
const { webhookId } = block
const newWebhook = {
id: webhookId,
...defaultWebhookAttributes,
typebotId: typebot.id,
} as Webhook
updateWebhook(webhookId, newWebhook)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (isNotDefined(webhook?.body))
return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={0} pr="6">
{webhook?.url ? configuredLabel : 'Disabled'}
</Text>
)
}

View File

@@ -0,0 +1,6 @@
export * from './SetVariableContent'
export * from './WithVariableContent'
export * from './VideoBubbleContent'
export * from './WebhookContent'
export * from './TextBubbleContent'
export * from './EmbedBubbleContent'

View File

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

View File

@@ -1,17 +1,15 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { CopyIcon, TrashIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { BlockIndices } from 'models'
export const BlockNodeContextMenu = ({
blockIndex,
}: {
blockIndex: number
}) => {
type Props = { indices: BlockIndices }
export const BlockNodeContextMenu = ({ indices }: Props) => {
const { deleteBlock, duplicateBlock } = useTypebot()
const handleDeleteClick = () => deleteBlock(blockIndex)
const handleDuplicateClick = () => duplicateBlock(indices)
const handleDuplicateClick = () => duplicateBlock(blockIndex)
const handleDeleteClick = () => deleteBlock(indices)
return (
<MenuList>

View File

@@ -0,0 +1,27 @@
import { StackProps, HStack } from '@chakra-ui/react'
import { StartBlock, Block, BlockIndices } from 'models'
import { BlockIcon } from 'components/editor/BlocksSideBar/BlockIcon'
import { BlockNodeContent } from './BlockNodeContent/BlockNodeContent'
export const BlockNodeOverlay = ({
block,
indices,
...props
}: { block: Block | StartBlock; indices: BlockIndices } & StackProps) => {
return (
<HStack
p="3"
borderWidth="1px"
rounded="lg"
bgColor="white"
cursor={'grab'}
w="264px"
pointerEvents="none"
shadow="lg"
{...props}
>
<BlockIcon type={block.type} />
<BlockNodeContent block={block} indices={indices} />
</HStack>
)
}

View File

@@ -0,0 +1,181 @@
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { DraggableBlock, DraggableBlockType, Block } from 'models'
import {
computeNearestPlaceholderIndex,
useBlockDnd,
} from 'contexts/GraphDndContext'
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { useEffect, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { BlockNode } from './BlockNode'
import { BlockNodeOverlay } from './BlockNodeOverlay'
type Props = {
groupId: string
blocks: Block[]
groupIndex: number
groupRef: React.MutableRefObject<HTMLDivElement | null>
isStartGroup: boolean
}
export const BlockNodesList = ({
groupId,
blocks,
groupIndex,
groupRef,
isStartGroup,
}: Props) => {
const {
draggedBlock,
setDraggedBlock,
draggedBlockType,
mouseOverGroup,
setDraggedBlockType,
} = useBlockDnd()
const { typebot, createBlock, detachBlockFromGroup } = useTypebot()
const { isReadOnly, graphPosition } = useGraph()
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined
>()
const placeholderRefs = useRef<HTMLDivElement[]>([])
const [position, setPosition] = useState({
x: 0,
y: 0,
})
const [mousePositionInElement, setMousePositionInElement] = useState({
x: 0,
y: 0,
})
const isDraggingOnCurrentGroup =
(draggedBlock || draggedBlockType) && mouseOverGroup?.id === groupId
const showSortPlaceholders =
!isStartGroup && (draggedBlock || draggedBlockType)
useEffect(() => {
if (mouseOverGroup?.id !== groupId) setExpandedPlaceholderIndex(undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mouseOverGroup?.id])
const handleMouseMoveGlobal = (event: MouseEvent) => {
if (!draggedBlock || draggedBlock.groupId !== groupId) return
const { clientX, clientY } = event
setPosition({
x: clientX - mousePositionInElement.x,
y: clientY - mousePositionInElement.y,
})
}
const handleMouseMoveOnGroup = (event: MouseEvent) => {
if (!isDraggingOnCurrentGroup) return
setExpandedPlaceholderIndex(
computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
)
}
const handleMouseUpOnGroup = (e: MouseEvent) => {
setExpandedPlaceholderIndex(undefined)
if (!isDraggingOnCurrentGroup) return
const blockIndex = computeNearestPlaceholderIndex(
e.clientY,
placeholderRefs
)
createBlock(
groupId,
(draggedBlock || draggedBlockType) as DraggableBlock | DraggableBlockType,
{
groupIndex,
blockIndex,
}
)
setDraggedBlock(undefined)
setDraggedBlockType(undefined)
}
const handleBlockMouseDown =
(blockIndex: number) =>
(
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
block: DraggableBlock
) => {
if (isReadOnly) return
placeholderRefs.current.splice(blockIndex + 1, 1)
detachBlockFromGroup({ groupIndex, blockIndex })
setPosition(absolute)
setMousePositionInElement(relative)
setDraggedBlock(block)
}
const handlePushElementRef =
(idx: number) => (elem: HTMLDivElement | null) => {
elem && (placeholderRefs.current[idx] = elem)
}
useEventListener('mousemove', handleMouseMoveGlobal)
useEventListener('mousemove', handleMouseMoveOnGroup, groupRef.current)
useEventListener(
'mouseup',
handleMouseUpOnGroup,
mouseOverGroup?.ref.current,
{
capture: true,
}
)
return (
<Stack
spacing={1}
transition="none"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
>
<Flex
ref={handlePushElementRef(0)}
h={
showSortPlaceholders && expandedPlaceholderIndex === 0
? '50px'
: '2px'
}
bgColor={'gray.300'}
visibility={showSortPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
{typebot &&
blocks.map((block, idx) => (
<Stack key={block.id} spacing={1}>
<BlockNode
key={block.id}
block={block}
indices={{ groupIndex, blockIndex: idx }}
isConnectable={blocks.length - 1 === idx}
onMouseDown={handleBlockMouseDown(idx)}
/>
<Flex
ref={handlePushElementRef(idx + 1)}
h={
showSortPlaceholders && expandedPlaceholderIndex === idx + 1
? '50px'
: '2px'
}
bgColor={'gray.300'}
visibility={showSortPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
</Stack>
))}
{draggedBlock && draggedBlock.groupId === groupId && (
<Portal>
<BlockNodeOverlay
block={draggedBlock}
indices={{ groupIndex, blockIndex: 0 }}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg) scale(${graphPosition.scale})`,
}}
transformOrigin="0 0 0"
/>
</Portal>
)}
</Stack>
)
}

View File

@@ -0,0 +1,48 @@
import { HStack, Stack, Text } from '@chakra-ui/react'
import { SmartNumberInput } from 'components/shared/SmartNumberInput'
import { Input } from 'components/shared/Textbox/Input'
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]

View File

@@ -0,0 +1,71 @@
import {
Portal,
PopoverContent,
PopoverArrow,
PopoverBody,
} from '@chakra-ui/react'
import { ImageUploadContent } from 'components/shared/ImageUploadContent'
import {
BubbleBlock,
BubbleBlockContent,
BubbleBlockType,
TextBubbleBlock,
} from 'models'
import { useRef } from 'react'
import { EmbedUploadContent } from './EmbedUploadContent'
import { VideoUploadContent } from './VideoUploadContent'
type Props = {
block: Exclude<BubbleBlock, TextBubbleBlock>
onContentChange: (content: BubbleBlockContent) => void
}
export const MediaBubblePopoverContent = (props: Props) => {
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
return (
<Portal>
<PopoverContent
onMouseDown={handleMouseDown}
w={props.block.type === BubbleBlockType.IMAGE ? '500px' : '400px'}
>
<PopoverArrow />
<PopoverBody ref={ref} shadow="lg">
<MediaBubbleContent {...props} />
</PopoverBody>
</PopoverContent>
</Portal>
)
}
export const MediaBubbleContent = ({ block, onContentChange }: Props) => {
const handleImageUrlChange = (url: string) => onContentChange({ url })
switch (block.type) {
case BubbleBlockType.IMAGE: {
return (
<ImageUploadContent
url={block.content?.url}
onSubmit={handleImageUrlChange}
/>
)
}
case BubbleBlockType.VIDEO: {
return (
<VideoUploadContent
content={block.content}
onSubmit={onContentChange}
/>
)
}
case BubbleBlockType.EMBED: {
return (
<EmbedUploadContent
content={block.content}
onSubmit={onContentChange}
/>
)
}
}
}

View File

@@ -0,0 +1,37 @@
import { Stack, Text } from '@chakra-ui/react'
import { Input } from 'components/shared/Textbox/Input'
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'
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>
)
}

View File

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

View File

@@ -0,0 +1,38 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
ModalBodyProps,
} from '@chakra-ui/react'
import React from 'react'
type Props = {
isOpen: boolean
onClose: () => void
}
export const SettingsModal = ({
isOpen,
onClose,
...props
}: Props & ModalBodyProps) => {
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent onMouseDown={handleMouseDown}>
<ModalHeader mb="2">
<ModalCloseButton />
</ModalHeader>
<ModalBody {...props}>{props.children}</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,271 @@
import {
PopoverContent,
PopoverArrow,
PopoverBody,
useEventListener,
Portal,
IconButton,
} from '@chakra-ui/react'
import { ExpandIcon } from 'assets/icons'
import {
ConditionItem,
ConditionBlock,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
Block,
BlockOptions,
BlockWithOptions,
Webhook,
} from 'models'
import { useRef } from 'react'
import {
TextInputSettingsBody,
NumberInputSettingsBody,
EmailInputSettingsBody,
UrlInputSettingsBody,
DateInputSettingsBody,
} from './bodies'
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
import { CodeSettings } from './bodies/CodeSettings'
import { ConditionSettingsBody } from './bodies/ConditionSettingsBody'
import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PaymentSettings } from './bodies/PaymentSettings'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RatingInputSettings } from './bodies/RatingInputSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings'
import { SendEmailSettings } from './bodies/SendEmailSettings'
import { SetVariableSettings } from './bodies/SetVariableSettings'
import { TypebotLinkSettingsForm } from './bodies/TypebotLinkSettingsForm'
import { WebhookSettings } from './bodies/WebhookSettings'
import { ZapierSettings } from './bodies/ZapierSettings'
type Props = {
block: BlockWithOptions | ConditionBlock
webhook?: Webhook
onExpandClick: () => void
onBlockChange: (updates: Partial<Block>) => void
}
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
const handleMouseWheel = (e: WheelEvent) => {
e.stopPropagation()
}
useEventListener('wheel', handleMouseWheel, ref.current)
return (
<Portal>
<PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow />
<PopoverBody
pt="10"
pb="6"
overflowY="scroll"
maxH="400px"
ref={ref}
shadow="lg"
>
<BlockSettings {...props} />
</PopoverBody>
<IconButton
pos="absolute"
top="5px"
right="5px"
aria-label="expand"
icon={<ExpandIcon />}
size="xs"
onClick={onExpandClick}
/>
</PopoverContent>
</Portal>
)
}
export const BlockSettings = ({
block,
onBlockChange,
}: {
block: BlockWithOptions | ConditionBlock
webhook?: Webhook
onBlockChange: (block: Partial<Block>) => void
}): JSX.Element => {
const handleOptionsChange = (options: BlockOptions) => {
onBlockChange({ options } as Partial<Block>)
}
const handleItemChange = (updates: Partial<ConditionItem>) => {
onBlockChange({
items: [{ ...(block as ConditionBlock).items[0], ...updates }],
} as Partial<Block>)
}
switch (block.type) {
case InputBlockType.TEXT: {
return (
<TextInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.NUMBER: {
return (
<NumberInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.EMAIL: {
return (
<EmailInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.URL: {
return (
<UrlInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.DATE: {
return (
<DateInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.PHONE: {
return (
<PhoneNumberSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.CHOICE: {
return (
<ChoiceInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.PAYMENT: {
return (
<PaymentSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.RATING: {
return (
<RatingInputSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicBlockType.SET_VARIABLE: {
return (
<SetVariableSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicBlockType.CONDITION: {
return (
<ConditionSettingsBody block={block} onItemChange={handleItemChange} />
)
}
case LogicBlockType.REDIRECT: {
return (
<RedirectSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicBlockType.CODE: {
return (
<CodeSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicBlockType.TYPEBOT_LINK: {
return (
<TypebotLinkSettingsForm
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<GoogleSheetsSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
blockId={block.id}
/>
)
}
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<GoogleAnalyticsSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationBlockType.ZAPIER: {
return <ZapierSettings block={block} />
}
case IntegrationBlockType.MAKE_COM: {
return (
<WebhookSettings
block={block}
onOptionsChange={handleOptionsChange}
provider={{
name: 'Make.com',
url: 'https://eu1.make.com/app/invite/43fa76a621f67ea27f96cffc3a2477e1',
}}
/>
)
}
case IntegrationBlockType.PABBLY_CONNECT: {
return (
<WebhookSettings
block={block}
onOptionsChange={handleOptionsChange}
provider={{
name: 'Pabbly Connect',
url: 'https://www.pabbly.com/connect/integrations/typebot/',
}}
/>
)
}
case IntegrationBlockType.WEBHOOK: {
return (
<WebhookSettings block={block} onOptionsChange={handleOptionsChange} />
)
}
case IntegrationBlockType.EMAIL: {
return (
<SendEmailSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
}
}

View File

@@ -0,0 +1,55 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ChoiceInputOptions, Variable } from 'models'
import React from 'react'
type ChoiceInputSettingsBodyProps = {
options?: ChoiceInputOptions
onOptionsChange: (options: ChoiceInputOptions) => void
}
export const ChoiceInputSettingsBody = ({
options,
onOptionsChange,
}: ChoiceInputSettingsBodyProps) => {
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
id={'is-multiple'}
label={'Multiple choice?'}
initialValue={options?.isMultipleChoice ?? false}
onCheckChange={handleIsMultipleChange}
/>
{options?.isMultipleChoice && (
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options?.buttonLabel ?? 'Send'}
onChange={handleButtonLabelChange}
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,40 @@
import { FormLabel, Stack, Text } from '@chakra-ui/react'
import { CodeEditor } from 'components/shared/CodeEditor'
import { Input } from 'components/shared/Textbox'
import { CodeOptions } from 'models'
import React from 'react'
type Props = {
options: CodeOptions
onOptionsChange: (options: CodeOptions) => void
}
export const CodeSettings = ({ options, onOptionsChange }: Props) => {
const handleNameChange = (name: string) =>
onOptionsChange({ ...options, name })
const handleCodeChange = (content: string) =>
onOptionsChange({ ...options, content })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="name">
Name:
</FormLabel>
<Input
id="name"
defaultValue={options.name}
onChange={handleNameChange}
withVariableButton={false}
/>
</Stack>
<Stack>
<Text>Code:</Text>
<CodeEditor
value={options.content ?? ''}
lang="js"
onChange={handleCodeChange}
/>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,50 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { Input } from 'components/shared/Textbox/Input'
import { TableListItemProps } from 'components/shared/TableList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { Comparison, Variable, ComparisonOperators } from 'models'
export const ComparisonItem = ({
item,
onItemChange,
}: TableListItemProps<Comparison>) => {
const handleSelectVariable = (variable?: Variable) => {
if (variable?.id === item.variableId) return
onItemChange({ ...item, variableId: variable?.id })
}
const handleSelectComparisonOperator = (
comparisonOperator: ComparisonOperators
) => {
if (comparisonOperator === item.comparisonOperator) return
onItemChange({ ...item, comparisonOperator })
}
const handleChangeValue = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<VariableSearchInput
initialVariableId={item.variableId}
onSelectVariable={handleSelectVariable}
placeholder="Search for a variable"
/>
<DropdownList<ComparisonOperators>
currentItem={item.comparisonOperator}
onItemSelect={handleSelectComparisonOperator}
items={Object.values(ComparisonOperators)}
placeholder="Select an operator"
/>
{item.comparisonOperator !== ComparisonOperators.IS_SET && (
<Input
defaultValue={item.value ?? ''}
onChange={handleChangeValue}
placeholder="Type a value..."
/>
)}
</Stack>
)
}

View File

@@ -0,0 +1,46 @@
import { Flex } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { TableList } from 'components/shared/TableList'
import {
Comparison,
ConditionItem,
ConditionBlock,
LogicalOperator,
} from 'models'
import React from 'react'
import { ComparisonItem } from './ComparisonsItem'
type ConditionSettingsBodyProps = {
block: ConditionBlock
onItemChange: (updates: Partial<ConditionItem>) => void
}
export const ConditionSettingsBody = ({
block,
onItemChange,
}: ConditionSettingsBodyProps) => {
const itemContent = block.items[0].content
const handleComparisonsChange = (comparisons: Comparison[]) =>
onItemChange({ content: { ...itemContent, comparisons } })
const handleLogicalOperatorChange = (logicalOperator: LogicalOperator) =>
onItemChange({ content: { ...itemContent, logicalOperator } })
return (
<TableList<Comparison>
initialItems={itemContent.comparisons}
onItemsChange={handleComparisonsChange}
Item={ComparisonItem}
ComponentBetweenItems={() => (
<Flex justify="center">
<DropdownList<LogicalOperator>
currentItem={itemContent.logicalOperator}
onItemSelect={handleLogicalOperatorChange}
items={Object.values(LogicalOperator)}
/>
</Flex>
)}
addLabel="Add a comparison"
/>
)
}

View File

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

View File

@@ -0,0 +1,89 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
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
id="is-range"
label={'Is range?'}
initialValue={options.isRange}
onCheckChange={handleIsRangeChange}
/>
<SwitchWithLabel
id="with-time"
label={'With time?'}
initialValue={options.isRange}
onCheckChange={handleHasTimeChange}
/>
{options.isRange && (
<Stack>
<FormLabel mb="0" htmlFor="from">
From label:
</FormLabel>
<Input
id="from"
defaultValue={options.labels.from}
onChange={handleFromChange}
/>
</Stack>
)}
{options?.isRange && (
<Stack>
<FormLabel mb="0" htmlFor="to">
To label:
</FormLabel>
<Input
id="to"
defaultValue={options.labels.to}
onChange={handleToChange}
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

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

View File

@@ -0,0 +1,116 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
FormLabel,
Stack,
Tag,
} from '@chakra-ui/react'
import { Input } from 'components/shared/Textbox'
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>
)
}

View File

@@ -0,0 +1,35 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { Input } from 'components/shared/Textbox/Input'
import { TableListItemProps } from 'components/shared/TableList'
import { Cell } from 'models'
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>
)
}

View File

@@ -0,0 +1,37 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { TableListItemProps } from 'components/shared/TableList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ExtractingCell, Variable } from 'models'
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>
)
}

View File

@@ -0,0 +1,77 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
Text,
Image,
Button,
ModalFooter,
Flex,
} from '@chakra-ui/react'
import { GoogleLogo } from 'assets/logos'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { Info } from 'components/shared/Info'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
import { getGoogleSheetsConsentScreenUrl } from 'services/integrations'
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">
<Info>
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.
</Info>
<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={NextChakraLink}
leftIcon={<GoogleLogo />}
data-testid="google"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
href={getGoogleSheetsConsentScreenUrl(
window.location.href,
blockId,
workspace?.id
)}
mx="auto"
>
Continue with Google
</Button>
</Flex>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,224 @@
import { Divider, Stack, Text, useDisclosure } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { TableList, TableListItemProps } from 'components/shared/TableList'
import { useTypebot } from 'contexts/TypebotContext'
import {
Cell,
CredentialsType,
ExtractingCell,
GoogleSheetsAction,
GoogleSheetsGetOptions,
GoogleSheetsInsertRowOptions,
GoogleSheetsOptions,
GoogleSheetsUpdateRowOptions,
} from 'models'
import React, { useMemo } from 'react'
import { Sheet, useSheets } from 'services/integrations'
import { isDefined, omit } from 'utils'
import { SheetsDropdown } from './SheetsDropdown'
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
import { CellWithValueStack } from './CellWithValueStack'
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import { GoogleSheetConnectModal } from './GoogleSheetsConnectModal'
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 <></>
}
}

View File

@@ -0,0 +1,40 @@
import { Input } from '@chakra-ui/react'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { useMemo } from 'react'
import { Sheet } from 'services/integrations'
import { isDefined } from 'utils'
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) return <Input value="No sheets found" isDisabled />
return (
<SearchableDropdown
selectedItem={currentSheet?.name}
items={(sheets ?? []).map((s) => s.name)}
onValueChange={handleSpreadsheetSelect}
placeholder={'Select the sheet'}
/>
)
}

View File

@@ -0,0 +1,46 @@
import { Input, Tooltip } from '@chakra-ui/react'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { useMemo } from 'react'
import { useSpreadsheets } from 'services/integrations'
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'}
/>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
FormControl,
FormLabel,
Stack,
Text,
HStack,
} from '@chakra-ui/react'
import { useUser } from 'contexts/UserContext'
import { CredentialsType, StripeCredentialsData } from 'models'
import React, { useState } from 'react'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Input } from 'components/shared/Textbox'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { MoreInfoTooltip } from 'components/shared/MoreInfoTooltip'
import { ExternalLinkIcon } from 'assets/icons'
import { createCredentials } from 'services/credentials'
import { omit } from 'utils'
import { useToast } from 'components/shared/hooks/useToast'
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 createCredentials({
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{' '}
<NextChakraLink
href="https://dashboard.stripe.com/apikeys"
isExternal
textDecor="underline"
>
here <ExternalLinkIcon />
</NextChakraLink>
)
</Text>
</Stack>
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
onClick={handleCreateClick}
isDisabled={
stripeConfig.live.publicKey === '' ||
stripeConfig.name === '' ||
stripeConfig.live.secretKey === ''
}
isLoading={isCreating}
>
Connect
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,149 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { RatingInputOptions, Variable } from 'models'
import React from 'react'
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 handleMiddleLabelChange = (middle: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, middle } })
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
id="switch"
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">
1 label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.left}
onChange={handleLeftLabelChange}
placeholder="Not likely at all"
/>
</Stack>
{options.length >= 4 && (
<Stack>
<FormLabel mb="0" htmlFor="button">
{Math.floor(options.length / 2)} label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.middle}
onChange={handleMiddleLabelChange}
placeholder="Neutral"
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="button">
{options.length} label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.right}
onChange={handleRightLabelChange}
placeholder="Extremely likely"
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,39 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input } from 'components/shared/Textbox'
import { RedirectOptions } from 'models'
import React from 'react'
type Props = {
options: RedirectOptions
onOptionsChange: (options: RedirectOptions) => void
}
export const RedirectSettings = ({ options, onOptionsChange }: Props) => {
const handleUrlChange = (url?: string) => onOptionsChange({ ...options, url })
const handleIsNewTabChange = (isNewTab: boolean) =>
onOptionsChange({ ...options, isNewTab })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="tracking-id">
Url:
</FormLabel>
<Input
id="tracking-id"
defaultValue={options.url ?? ''}
placeholder="Type a URL..."
onChange={handleUrlChange}
/>
</Stack>
<SwitchWithLabel
id="new-tab"
label="Open in new tab?"
initialValue={options.isNewTab}
onCheckChange={handleIsNewTabChange}
/>
</Stack>
)
}

View File

@@ -0,0 +1,140 @@
import { Stack, useDisclosure, Text } from '@chakra-ui/react'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import { Input, Textarea } from 'components/shared/Textbox'
import { CredentialsType, SendEmailOptions } from 'models'
import React, { useState } from 'react'
import { SmtpConfigModal } from './SmtpConfigModal'
type Props = {
options: SendEmailOptions
onOptionsChange: (options: SendEmailOptions) => void
}
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({
...options,
credentialsId: credentialsId === undefined ? 'default' : credentialsId,
})
}
const handleToChange = (recipientsStr: string) => {
const recipients: string[] = recipientsStr
.split(',')
.map((str) => str.trim())
onOptionsChange({
...options,
recipients,
})
}
const handleCcChange = (ccStr: string) => {
const cc: string[] = ccStr.split(',').map((str) => str.trim())
onOptionsChange({
...options,
cc,
})
}
const handleBccChange = (bccStr: string) => {
const bcc: string[] = bccStr.split(',').map((str) => str.trim())
onOptionsChange({
...options,
bcc,
})
}
const handleSubjectChange = (subject: string) =>
onOptionsChange({
...options,
subject,
})
const handleBodyChange = (body: string) =>
onOptionsChange({
...options,
body,
})
const handleReplyToChange = (replyTo: string) =>
onOptionsChange({
...options,
replyTo,
})
return (
<Stack spacing={4}>
<Stack>
<Text>From: </Text>
<CredentialsDropdown
type={CredentialsType.SMTP}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
defaultCredentialLabel={process.env.NEXT_PUBLIC_SMTP_FROM?.match(
/\<(.*)\>/
)?.pop()}
refreshDropdownKey={refreshCredentialsKey}
/>
</Stack>
<Stack>
<Text>Reply to: </Text>
<Input
onChange={handleReplyToChange}
defaultValue={options.replyTo}
placeholder={'email@gmail.com'}
/>
</Stack>
<Stack>
<Text>To: </Text>
<Input
onChange={handleToChange}
defaultValue={options.recipients.join(', ')}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack>
<Text>Cc: </Text>
<Input
onChange={handleCcChange}
defaultValue={options.cc?.join(', ') ?? ''}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack>
<Text>Bcc: </Text>
<Input
onChange={handleBccChange}
defaultValue={options.bcc?.join(', ') ?? ''}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack>
<Text>Subject: </Text>
<Input
data-testid="subject-input"
onChange={handleSubjectChange}
defaultValue={options.subject ?? ''}
/>
</Stack>
<Stack>
<Text>Body: </Text>
<Textarea
data-testid="body-input"
minH="300px"
onChange={handleBodyChange}
defaultValue={options.body ?? ''}
/>
</Stack>
<SmtpConfigModal
isOpen={isOpen}
onClose={onClose}
onNewCredentials={handleCredentialsSelect}
/>
</Stack>
)
}

View File

@@ -0,0 +1,93 @@
import { FormControl, FormLabel, HStack, Stack } from '@chakra-ui/react'
import { isDefined } from '@udecode/plate-common'
import { SmartNumberInput } from 'components/shared/SmartNumberInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input } from 'components/shared/Textbox'
import { SmtpCredentialsData } from 'models'
import React from 'react'
type Props = {
config: SmtpCredentialsData
onConfigChange: (config: SmtpCredentialsData) => void
}
export const SmtpConfigForm = ({ config, onConfigChange }: Props) => {
const handleFromEmailChange = (email: string) =>
onConfigChange({ ...config, from: { ...config.from, email } })
const handleFromNameChange = (name: string) =>
onConfigChange({ ...config, from: { ...config.from, name } })
const handleHostChange = (host: string) => onConfigChange({ ...config, host })
const handleUsernameChange = (username: string) =>
onConfigChange({ ...config, username })
const handlePasswordChange = (password: string) =>
onConfigChange({ ...config, password })
const handleTlsCheck = (isTlsEnabled: boolean) =>
onConfigChange({ ...config, isTlsEnabled })
const handlePortNumberChange = (port?: number) =>
isDefined(port) && onConfigChange({ ...config, port })
return (
<Stack as="form" spacing={4}>
<FormControl isRequired>
<FormLabel>From email:</FormLabel>
<Input
defaultValue={config.from.email ?? ''}
onChange={handleFromEmailChange}
placeholder="notifications@provider.com"
withVariableButton={false}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>From name:</FormLabel>
<Input
defaultValue={config.from.name ?? ''}
onChange={handleFromNameChange}
placeholder="John Smith"
withVariableButton={false}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Host:</FormLabel>
<Input
defaultValue={config.host ?? ''}
onChange={handleHostChange}
placeholder="mail.provider.com"
withVariableButton={false}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Username / Email:</FormLabel>
<Input
type="email"
defaultValue={config.username ?? ''}
onChange={handleUsernameChange}
placeholder="user@provider.com"
withVariableButton={false}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Password:</FormLabel>
<Input
type="password"
defaultValue={config.password ?? ''}
onChange={handlePasswordChange}
withVariableButton={false}
/>
</FormControl>
<SwitchWithLabel
id="Tls"
label={'Use TLS?'}
initialValue={config.isTlsEnabled ?? false}
onCheckChange={handleTlsCheck}
/>
<FormControl as={HStack} justifyContent="space-between">
<FormLabel mb="0">Port number:</FormLabel>
<SmartNumberInput
placeholder="25"
value={config.port}
onValueChange={handlePortNumberChange}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,98 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
} from '@chakra-ui/react'
import { useUser } from 'contexts/UserContext'
import { CredentialsType, SmtpCredentialsData } from 'models'
import React, { useState } from 'react'
import { createCredentials } from 'services/user'
import { testSmtpConfig } from 'services/integrations'
import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { useToast } from 'components/shared/hooks/useToast'
type Props = {
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const SmtpConfigModal = ({
isOpen,
onNewCredentials,
onClose,
}: Props) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const [isCreating, setIsCreating] = useState(false)
const { showToast } = useToast()
const [smtpConfig, setSmtpConfig] = useState<SmtpCredentialsData>({
from: {},
port: 25,
})
const handleCreateClick = async () => {
if (!user?.email || !workspace?.id) return
setIsCreating(true)
const { error: testSmtpError } = await testSmtpConfig(
smtpConfig,
user.email
)
if (testSmtpError) {
setIsCreating(false)
return showToast({
title: 'Invalid configuration',
description: "We couldn't send the test email with your configuration",
})
}
const { data, error } = await createCredentials({
data: smtpConfig,
name: smtpConfig.from.email as string,
type: CredentialsType.SMTP,
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>Create SMTP config</ModalHeader>
<ModalCloseButton />
<ModalBody>
<SmtpConfigForm config={smtpConfig} onConfigChange={setSmtpConfig} />
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
onClick={handleCreateClick}
isDisabled={
isNotDefined(smtpConfig.from.email) ||
isNotDefined(smtpConfig.from.name) ||
isNotDefined(smtpConfig.host) ||
isNotDefined(smtpConfig.username) ||
isNotDefined(smtpConfig.password)
}
isLoading={isCreating}
>
Create
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

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

View File

@@ -0,0 +1,68 @@
import { FormLabel, HStack, Stack, Switch, Text } from '@chakra-ui/react'
import { CodeEditor } from 'components/shared/CodeEditor'
import { Textarea } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { SetVariableOptions, Variable } from 'models'
import React from 'react'
type Props = {
options: SetVariableOptions
onOptionsChange: (options: SetVariableOptions) => void
}
export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleExpressionChange = (expressionToEvaluate: string) =>
onOptionsChange({ ...options, expressionToEvaluate })
const handleValueTypeChange = () =>
onOptionsChange({
...options,
isCode: options.isCode ? !options.isCode : true,
})
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="variable-search">
Search or create variable:
</FormLabel>
<VariableSearchInput
onSelectVariable={handleVariableChange}
initialVariableId={options.variableId}
id="variable-search"
/>
</Stack>
<Stack>
<HStack justify="space-between">
<FormLabel mb="0" htmlFor="expression">
Value:
</FormLabel>
<HStack>
<Text fontSize="sm">Text</Text>
<Switch
size="sm"
isChecked={options.isCode ?? false}
onChange={handleValueTypeChange}
/>
<Text fontSize="sm">Code</Text>
</HStack>
</HStack>
{options.isCode ?? false ? (
<CodeEditor
value={options.expressionToEvaluate ?? ''}
onChange={handleExpressionChange}
lang="js"
/>
) : (
<Textarea
id="expression"
defaultValue={options.expressionToEvaluate ?? ''}
onChange={handleExpressionChange}
/>
)}
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,65 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
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
id="switch"
label="Long text?"
initialValue={options?.isLong ?? false}
onCheckChange={handleLongChange}
/>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<Input
id="placeholder"
defaultValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,41 @@
import { Input } from '@chakra-ui/react'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { Group } from 'models'
import { useMemo } from 'react'
import { byId } from 'utils'
type Props = {
groups: Group[]
groupId?: string
onGroupIdSelected: (groupId: string) => void
isLoading?: boolean
}
export const GroupsDropdown = ({
groups,
groupId,
onGroupIdSelected,
isLoading,
}: Props) => {
const currentGroup = useMemo(
() => groups?.find(byId(groupId)),
[groupId, groups]
)
const handleGroupSelect = (title: string) => {
const id = groups?.find((b) => b.title === title)?.id
if (id) onGroupIdSelected(id)
}
if (isLoading) return <Input value="Loading..." isDisabled />
if (!groups || groups.length === 0)
return <Input value="No groups found" isDisabled />
return (
<SearchableDropdown
selectedItem={currentGroup?.title}
items={(groups ?? []).map((b) => b.title)}
onValueChange={handleGroupSelect}
placeholder={'Select a block'}
/>
)
}

View File

@@ -0,0 +1,50 @@
import { Stack } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { TypebotLinkOptions } from 'models'
import { byId } from 'utils'
import { GroupsDropdown } from './GroupsDropdown'
import { TypebotsDropdown } from './TypebotsDropdown'
type Props = {
options: TypebotLinkOptions
onOptionsChange: (options: TypebotLinkOptions) => void
}
export const TypebotLinkSettingsForm = ({
options,
onOptionsChange,
}: Props) => {
const { linkedTypebots, typebot } = useTypebot()
const handleTypebotIdChange = (typebotId: string | 'current') =>
onOptionsChange({ ...options, typebotId })
const handleGroupIdChange = (groupId: string) =>
onOptionsChange({ ...options, groupId })
return (
<Stack>
{typebot && (
<TypebotsDropdown
typebotId={options.typebotId}
onSelectTypebotId={handleTypebotIdChange}
currentWorkspaceId={typebot.workspaceId as string}
/>
)}
<GroupsDropdown
groups={
typebot &&
(options.typebotId === typebot.id || options.typebotId === 'current')
? typebot.groups
: linkedTypebots?.find(byId(options.typebotId))?.groups ?? []
}
groupId={options.groupId}
onGroupIdSelected={handleGroupIdChange}
isLoading={
linkedTypebots === undefined &&
typebot &&
typebot.id !== options.typebotId
}
/>
</Stack>
)
}

View File

@@ -0,0 +1,61 @@
import { HStack, IconButton, Input } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useToast } from 'components/shared/hooks/useToast'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { useTypebots } from 'services/typebots'
import { byId } from 'utils'
type Props = {
typebotId?: string
currentWorkspaceId: string
onSelectTypebotId: (typebotId: string | 'current') => void
}
export const TypebotsDropdown = ({
typebotId,
onSelectTypebotId,
currentWorkspaceId,
}: Props) => {
const { query } = useRouter()
const { showToast } = useToast()
const { typebots, isLoading } = useTypebots({
workspaceId: currentWorkspaceId,
allFolders: true,
onError: (e) => showToast({ title: e.name, description: e.message }),
})
const currentTypebot = useMemo(
() => typebots?.find(byId(typebotId)),
[typebotId, typebots]
)
const handleTypebotSelect = (name: string) => {
if (name === 'Current typebot') return onSelectTypebotId('current')
const id = typebots?.find((s) => s.name === name)?.id
if (id) onSelectTypebotId(id)
}
if (isLoading) return <Input value="Loading..." isDisabled />
if (!typebots || typebots.length === 0)
return <Input value="No typebots found" isDisabled />
return (
<HStack>
<SearchableDropdown
selectedItem={currentTypebot?.name}
items={['Current typebot', ...(typebots ?? []).map((t) => t.name)]}
onValueChange={handleTypebotSelect}
placeholder={'Select a typebot'}
/>
{currentTypebot?.id && (
<IconButton
aria-label="Navigate to typebot"
icon={<ExternalLinkIcon />}
as={NextChakraLink}
href={`/typebots/${currentTypebot?.id}/edit?parentId=${query.typebotId}`}
/>
)}
</HStack>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { Input } from 'components/shared/Textbox'
import { TableListItemProps } from 'components/shared/TableList'
import { KeyValue } from 'models'
export const QueryParamsInputs = (props: TableListItemProps<KeyValue>) => (
<KeyValueInputs
{...props}
keyPlaceholder="e.g. email"
valuePlaceholder="e.g. {{Email}}"
/>
)
export const HeadersInputs = (props: TableListItemProps<KeyValue>) => (
<KeyValueInputs
{...props}
keyPlaceholder="e.g. Content-Type"
valuePlaceholder="e.g. application/json"
/>
)
export const KeyValueInputs = ({
item,
onItemChange,
keyPlaceholder,
valuePlaceholder,
debounceTimeout,
}: TableListItemProps<KeyValue> & {
keyPlaceholder?: string
valuePlaceholder?: string
}) => {
const handleKeyChange = (key: string) => {
if (key === item.key) return
onItemChange({ ...item, key })
}
const handleValueChange = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor={'key' + item.id}>Key:</FormLabel>
<Input
id={'key' + item.id}
defaultValue={item.key ?? ''}
onChange={handleKeyChange}
placeholder={keyPlaceholder}
debounceTimeout={debounceTimeout}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor={'value' + item.id}>Value:</FormLabel>
<Input
id={'value' + item.id}
defaultValue={item.value ?? ''}
onChange={handleValueChange}
placeholder={valuePlaceholder}
debounceTimeout={debounceTimeout}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,42 @@
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { TableListItemProps } from 'components/shared/TableList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { Variable, ResponseVariableMapping } from 'models'
export const DataVariableInputs = ({
item,
onItemChange,
dataItems,
debounceTimeout,
}: TableListItemProps<ResponseVariableMapping> & { dataItems: string[] }) => {
const handleBodyPathChange = (bodyPath: string) =>
onItemChange({ ...item, bodyPath })
const handleVariableChange = (variable?: Variable) =>
onItemChange({ ...item, variableId: variable?.id })
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor="name">Data:</FormLabel>
<SearchableDropdown
items={dataItems}
value={item.bodyPath}
onValueChange={handleBodyPathChange}
placeholder="Select the data"
debounceTimeout={debounceTimeout}
withVariableButton
/>
</FormControl>
<FormControl>
<FormLabel htmlFor="value">Set variable:</FormLabel>
<VariableSearchInput
onSelectVariable={handleVariableChange}
placeholder="Search for a variable"
initialVariableId={item.variableId}
debounceTimeout={debounceTimeout}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,40 @@
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { TableListItemProps } from 'components/shared/TableList'
import { Input } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { VariableForTest, Variable } from 'models'
export const VariableForTestInputs = ({
item,
onItemChange,
debounceTimeout,
}: TableListItemProps<VariableForTest>) => {
const handleVariableSelect = (variable?: Variable) =>
onItemChange({ ...item, variableId: variable?.id })
const handleValueChange = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor={'name' + item.id}>Variable name:</FormLabel>
<VariableSearchInput
id={'name' + item.id}
initialVariableId={item.variableId}
onSelectVariable={handleVariableSelect}
debounceTimeout={debounceTimeout}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor={'value' + item.id}>Test value:</FormLabel>
<Input
id={'value' + item.id}
defaultValue={item.value ?? ''}
onChange={handleValueChange}
debounceTimeout={debounceTimeout}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,298 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Button,
HStack,
Spinner,
Stack,
Text,
Alert,
AlertIcon,
Link,
} from '@chakra-ui/react'
import { Input } from 'components/shared/Textbox'
import { useTypebot } from 'contexts/TypebotContext'
import {
HttpMethod,
KeyValue,
WebhookOptions,
VariableForTest,
ResponseVariableMapping,
WebhookBlock,
defaultWebhookAttributes,
Webhook,
MakeComBlock,
PabblyConnectBlock,
} from 'models'
import { DropdownList } from 'components/shared/DropdownList'
import { TableList, TableListItemProps } from 'components/shared/TableList'
import { CodeEditor } from 'components/shared/CodeEditor'
import {
convertVariableForTestToVariables,
executeWebhook,
getDeepKeys,
} from 'services/integrations'
import { HeadersInputs, QueryParamsInputs } from './KeyValueInputs'
import { VariableForTestInputs } from './VariableForTestInputs'
import { DataVariableInputs } from './ResponseMappingInputs'
import { byId } from 'utils'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { ExternalLinkIcon } from 'assets/icons'
import { useToast } from 'components/shared/hooks/useToast'
type Provider = {
name: 'Make.com' | 'Pabbly Connect'
url: string
}
type Props = {
block: WebhookBlock | MakeComBlock | PabblyConnectBlock
onOptionsChange: (options: WebhookOptions) => void
provider?: Provider
}
export const WebhookSettings = ({
block: { options, id: blockId, webhookId },
onOptionsChange,
provider,
}: Props) => {
const { typebot, save, webhooks, updateWebhook } = useTypebot()
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
const [testResponse, setTestResponse] = useState<string>()
const [responseKeys, setResponseKeys] = useState<string[]>([])
const { showToast } = useToast()
const [localWebhook, setLocalWebhook] = useState(
webhooks.find(byId(webhookId))
)
useEffect(() => {
if (localWebhook) return
const incomingWebhook = webhooks.find(byId(webhookId))
setLocalWebhook(incomingWebhook)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [webhooks])
useEffect(() => {
if (!typebot) return
if (!localWebhook) {
const newWebhook = {
id: webhookId,
...defaultWebhookAttributes,
typebotId: typebot.id,
} as Webhook
updateWebhook(webhookId, newWebhook)
}
return () => {
setLocalWebhook((localWebhook) => {
if (!localWebhook) return
updateWebhook(webhookId, localWebhook).then()
return localWebhook
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleUrlChange = (url?: string) =>
localWebhook && setLocalWebhook({ ...localWebhook, url: url ?? null })
const handleMethodChange = (method: HttpMethod) =>
localWebhook && setLocalWebhook({ ...localWebhook, method })
const handleQueryParamsChange = (queryParams: KeyValue[]) =>
localWebhook && setLocalWebhook({ ...localWebhook, queryParams })
const handleHeadersChange = (headers: KeyValue[]) =>
localWebhook && setLocalWebhook({ ...localWebhook, headers })
const handleBodyChange = (body: string) =>
localWebhook && setLocalWebhook({ ...localWebhook, body })
const handleVariablesChange = (variablesForTest: VariableForTest[]) =>
onOptionsChange({ ...options, variablesForTest })
const handleResponseMappingChange = (
responseVariableMapping: ResponseVariableMapping[]
) => onOptionsChange({ ...options, responseVariableMapping })
const handleAdvancedConfigChange = (isAdvancedConfig: boolean) =>
onOptionsChange({ ...options, isAdvancedConfig })
const handleBodyFormStateChange = (isCustomBody: boolean) =>
onOptionsChange({ ...options, isCustomBody })
const handleTestRequestClick = async () => {
if (!typebot || !localWebhook) return
setIsTestResponseLoading(true)
await Promise.all([updateWebhook(localWebhook.id, localWebhook), save()])
const { data, error } = await executeWebhook(
typebot.id,
convertVariableForTestToVariables(
options.variablesForTest,
typebot.variables
),
{ blockId }
)
if (error)
return showToast({ title: error.name, description: error.message })
setTestResponse(JSON.stringify(data, undefined, 2))
setResponseKeys(getDeepKeys(data))
setIsTestResponseLoading(false)
}
const ResponseMappingInputs = useMemo(
() => (props: TableListItemProps<ResponseVariableMapping>) =>
<DataVariableInputs {...props} dataItems={responseKeys} />,
[responseKeys]
)
if (!localWebhook) return <Spinner />
return (
<Stack spacing={4}>
{provider && (
<Alert status={'info'} bgColor={'blue.50'} rounded="md">
<AlertIcon />
<Stack>
<Text>Head up to {provider.name} to configure this block:</Text>
<Button as={Link} href={provider.url} isExternal colorScheme="blue">
<Text mr="2">{provider.name}</Text> <ExternalLinkIcon />
</Button>
</Stack>
</Alert>
)}
<Input
placeholder="Paste webhook URL..."
defaultValue={localWebhook.url ?? ''}
onChange={handleUrlChange}
debounceTimeout={0}
withVariableButton={!provider}
/>
<SwitchWithLabel
id={'easy-config'}
label="Advanced configuration"
initialValue={options.isAdvancedConfig ?? true}
onCheckChange={handleAdvancedConfigChange}
/>
{(options.isAdvancedConfig ?? true) && (
<Stack>
<HStack justify="space-between">
<Text>Method:</Text>
<DropdownList<HttpMethod>
currentItem={localWebhook.method as HttpMethod}
onItemSelect={handleMethodChange}
items={Object.values(HttpMethod)}
/>
</HStack>
<Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Query params
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={localWebhook.queryParams}
onItemsChange={handleQueryParamsChange}
Item={QueryParamsInputs}
addLabel="Add a param"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Headers
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={localWebhook.headers}
onItemsChange={handleHeadersChange}
Item={HeadersInputs}
addLabel="Add a value"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Body
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<SwitchWithLabel
id={'custom-body'}
label="Custom body"
initialValue={options.isCustomBody ?? true}
onCheckChange={handleBodyFormStateChange}
/>
{(options.isCustomBody ?? true) && (
<CodeEditor
value={localWebhook.body ?? ''}
lang="json"
onChange={handleBodyChange}
debounceTimeout={0}
/>
)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Variable values for test
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<VariableForTest>
initialItems={
options?.variablesForTest ?? { byId: {}, allIds: [] }
}
onItemsChange={handleVariablesChange}
Item={VariableForTestInputs}
addLabel="Add an entry"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)}
<Stack>
{localWebhook.url && (
<Button
onClick={handleTestRequestClick}
colorScheme="blue"
isLoading={isTestResponseLoading}
>
Test the request
</Button>
)}
{testResponse && (
<CodeEditor isReadOnly lang="json" value={testResponse} />
)}
{(testResponse || options?.responseVariableMapping.length > 0) && (
<Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Save in variables
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<ResponseVariableMapping>
initialItems={options.responseVariableMapping}
onItemsChange={handleResponseMappingChange}
Item={ResponseMappingInputs}
addLabel="Add an entry"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
</Stack>
</Stack>
)
}

View File

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

View File

@@ -0,0 +1,51 @@
import {
Alert,
AlertIcon,
Button,
Input,
Link,
Stack,
Text,
} from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext'
import { ZapierBlock } from 'models'
import React from 'react'
import { byId } from 'utils'
type Props = {
block: ZapierBlock
}
export const ZapierSettings = ({ block }: Props) => {
const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(block.webhookId))
return (
<Stack spacing={4}>
<Alert
status={webhook?.url ? 'success' : 'info'}
bgColor={webhook?.url ? undefined : 'blue.50'}
rounded="md"
>
<AlertIcon />
{webhook?.url ? (
<>Your zap is correctly configured 🚀</>
) : (
<Stack>
<Text>Head up to Zapier to configure this block:</Text>
<Button
as={Link}
href="https://zapier.com/apps/typebot/integrations"
isExternal
colorScheme="blue"
>
<Text mr="2">Zapier</Text> <ExternalLinkIcon />
</Button>
</Stack>
)}
</Alert>
{webhook?.url && <Input value={webhook?.url} isDisabled />}
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
export * from './DateInputSettingsBody'
export * from './EmailInputSettingsBody'
export * from './NumberInputSettingsBody'
export * from './TextInputSettingsBody'
export * from './UrlInputSettingsBody'

View File

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

View File

@@ -0,0 +1,166 @@
import { Flex, Stack, useOutsideClick } from '@chakra-ui/react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
Plate,
selectEditor,
serializeHtml,
TEditor,
TElement,
Value,
withPlate,
} from '@udecode/plate-core'
import { editorStyle, platePlugins } from 'libs/plate'
import { BaseEditor, BaseSelection, createEditor, Transforms } from 'slate'
import { ToolBar } from './ToolBar'
import { parseHtmlStringToPlainText } from 'services/utils'
import { defaultTextBubbleContent, TextBubbleContent, Variable } from 'models'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ReactEditor } from 'slate-react'
type Props = {
initialValue: TElement[]
onClose: (newContent: TextBubbleContent) => void
}
export const TextBubbleEditor = ({ initialValue, onClose }: Props) => {
const randomEditorId = useMemo(() => Math.random().toString(), [])
const editor = useMemo(
() =>
withPlate(createEditor() as TEditor<Value>, {
id: randomEditorId,
plugins: platePlugins,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const [value, setValue] = useState(initialValue)
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(value))
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 handleChangeEditorContent = (val: TElement[]) => {
setValue(val)
setIsVariableDropdownOpen(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
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
editor={editor}
onVariablesButtonClick={() => setIsVariableDropdownOpen(true)}
/>
<Plate
id={randomEditorId}
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,
}}
initialValue={
initialValue.length === 0
? [{ type: 'p', children: [{ text: '' }] }]
: initialValue
}
onChange={handleChangeEditorContent}
editor={editor}
/>
{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>
)
}

View File

@@ -0,0 +1,62 @@
import { StackProps, HStack, Button } from '@chakra-ui/react'
import {
MARK_BOLD,
MARK_ITALIC,
MARK_UNDERLINE,
} from '@udecode/plate-basic-marks'
import { getPluginType, PlateEditor, Value } from '@udecode/plate-core'
import { LinkToolbarButton } from '@udecode/plate-ui-link'
import { MarkToolbarButton } from '@udecode/plate-ui-toolbar'
import { BoldIcon, ItalicIcon, UnderlineIcon, LinkIcon } from 'assets/icons'
type Props = {
editor: PlateEditor<Value>
onVariablesButtonClick: () => void
} & StackProps
export const ToolBar = ({
editor,
onVariablesButtonClick,
...props
}: Props) => {
const handleVariablesButtonMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
onVariablesButtonClick()
}
return (
<HStack
bgColor={'white'}
borderTopRadius="md"
p={2}
w="full"
boxSizing="border-box"
borderBottomWidth={1}
{...props}
>
<Button size="sm" onMouseDown={handleVariablesButtonMouseDown}>
Variables
</Button>
<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>
)
}

View File

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

View File

@@ -1 +1 @@
export { BlockNode } from './BlockNode'
export { BlockNodesList } from './BlockNodesList'