2
0

refactor(editor): ♻️ Undo / Redo buttons + structure refacto

Yet another huge refacto... While implementing undo and redo features I understood that I updated the stored typebot too many times (i.e. on each key input) so I had to rethink it entirely. I also moved around some files.
This commit is contained in:
Baptiste Arnaud
2022-02-02 08:05:02 +01:00
parent fc1d654772
commit 8a350eee6c
153 changed files with 1512 additions and 1352 deletions

View File

@ -311,3 +311,17 @@ export const UnlockedIcon = (props: IconProps) => (
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path> <path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
</Icon> </Icon>
) )
export const UndoIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M3 7v6h6"></path>
<path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path>
</Icon>
)
export const RedoIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M21 7v6h-6"></path>
<path d="M3 17a9 9 0 019-9 9 9 0 016 2.3l3 2.7"></path>
</Icon>
)

View File

@ -1,59 +0,0 @@
import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import React, { useMemo, useRef } from 'react'
import { AnswersCount } from 'services/analytics'
import { BlockNode } from './blocks/BlockNode'
import { Edges } from './Edges'
const AnalyticsGraph = ({
answersCounts,
...props
}: { answersCounts?: AnswersCount[] } & FlexProps) => {
const { typebot, graphPosition, setGraphPosition } = useAnalyticsGraph()
const graphContainerRef = useRef<HTMLDivElement | null>(null)
const transform = useMemo(
() =>
`translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`,
[graphPosition]
)
const handleMouseWheel = (e: WheelEvent) => {
e.preventDefault()
const isPinchingTrackpad = e.ctrlKey
if (isPinchingTrackpad) {
const scale = graphPosition.scale - e.deltaY * 0.01
if (scale <= 0.2 || scale >= 1) return
setGraphPosition({
...graphPosition,
scale,
})
} else
setGraphPosition({
...graphPosition,
x: graphPosition.x - e.deltaX,
y: graphPosition.y - e.deltaY,
})
}
useEventListener('wheel', handleMouseWheel, graphContainerRef.current)
if (!typebot) return <></>
return (
<Flex ref={graphContainerRef} {...props}>
<Flex
flex="1"
boxSize={'200px'}
maxW={'200px'}
style={{
transform,
}}
>
<Edges answersCounts={answersCounts} />
{typebot.blocks.allIds.map((blockId) => (
<BlockNode block={typebot.blocks.byId[blockId]} key={blockId} />
))}
</Flex>
</Flex>
)
}
export default AnalyticsGraph

View File

@ -1,19 +0,0 @@
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import React, { useMemo } from 'react'
import { computeDropOffPath } from 'services/graph'
type Props = {
blockId: string
}
export const DropOffEdge = ({ blockId }: Props) => {
const { typebot } = useAnalyticsGraph()
const path = useMemo(() => {
if (!typebot) return
const block = typebot.blocks.byId[blockId]
if (!block) return ''
return computeDropOffPath(block.graphCoordinates, block.stepIds.length - 1)
}, [blockId, typebot])
return <path d={path} stroke={'#E53E3E'} strokeWidth="2px" fill="none" />
}

View File

@ -1,88 +0,0 @@
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import { useGraph } from 'contexts/GraphContext'
import React, { useEffect, useMemo, useState } from 'react'
import {
getAnchorsPosition,
computeEdgePath,
getEndpointTopOffset,
getSourceEndpointId,
} from 'services/graph'
type Props = { edgeId: string }
export const Edge = ({ edgeId }: Props) => {
const { typebot } = useAnalyticsGraph()
const edge = typebot?.edges.byId[edgeId]
const { sourceEndpoints, targetEndpoints, graphPosition } = useGraph()
const [sourceTop, setSourceTop] = useState(
getEndpointTopOffset(
graphPosition,
sourceEndpoints,
getSourceEndpointId(edge)
)
)
const [targetTop, setTargetTop] = useState(
getEndpointTopOffset(graphPosition, sourceEndpoints, edge?.to.stepId)
)
useEffect(() => {
const newSourceTop = getEndpointTopOffset(
graphPosition,
sourceEndpoints,
getSourceEndpointId(edge)
)
const sensibilityThreshold = 10
const newSourceTopIsTooClose =
sourceTop < newSourceTop + sensibilityThreshold &&
sourceTop > newSourceTop - sensibilityThreshold
if (newSourceTopIsTooClose) return
setSourceTop(newSourceTop)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [graphPosition])
useEffect(() => {
const newTargetTop = getEndpointTopOffset(
graphPosition,
targetEndpoints,
edge?.to.stepId
)
const sensibilityThreshold = 10
const newSourceTopIsTooClose =
targetTop < newTargetTop + sensibilityThreshold &&
targetTop > newTargetTop - sensibilityThreshold
if (newSourceTopIsTooClose) return
setTargetTop(newTargetTop)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [graphPosition])
const { sourceBlock, targetBlock } = useMemo(() => {
if (!typebot || !edge) return {}
const targetBlock = typebot.blocks.byId[edge.to.blockId]
const sourceBlock = typebot.blocks.byId[edge.from.blockId]
return {
sourceBlock,
targetBlock,
}
}, [edge, typebot])
const path = useMemo(() => {
if (!sourceBlock || !targetBlock) return ``
const anchorsPosition = getAnchorsPosition({
sourceBlock,
targetBlock,
sourceTop,
targetTop,
})
return computeEdgePath(anchorsPosition)
}, [sourceBlock, sourceTop, targetBlock, targetTop])
return (
<path
d={path}
stroke={'#718096'}
strokeWidth="2px"
markerEnd="url(#arrow)"
fill="none"
/>
)
}

View File

@ -1,58 +0,0 @@
import { chakra } from '@chakra-ui/system'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import React from 'react'
import { AnswersCount } from 'services/analytics'
import { DropOffBlock } from '../blocks/DropOffBlock'
import { DropOffEdge } from './DropOffEdge'
import { Edge } from './Edge'
type Props = { answersCounts?: AnswersCount[] }
export const Edges = ({ answersCounts }: Props) => {
const { typebot } = useAnalyticsGraph()
return (
<>
<chakra.svg
width="full"
height="full"
overflow="visible"
pos="absolute"
left="0"
top="0"
>
{typebot?.edges.allIds.map((edgeId) => (
<Edge key={edgeId} edgeId={edgeId} />
))}
<marker
id={'arrow'}
refX="8"
refY="4"
orient="auto"
viewBox="0 0 20 20"
markerUnits="userSpaceOnUse"
markerWidth="20"
markerHeight="20"
>
<path
d="M7.07138888,5.50174526 L2.43017246,7.82235347 C1.60067988,8.23709976 0.592024983,7.90088146 0.177278692,7.07138888 C0.0606951226,6.83822174 0,6.58111307 0,6.32042429 L0,1.67920787 C0,0.751806973 0.751806973,0 1.67920787,0 C1.93989666,0 2.19700532,0.0606951226 2.43017246,0.177278692 L7,3 C7.82949258,3.41474629 8.23709976,3.92128809 7.82235347,4.75078067 C7.6598671,5.07575341 7.39636161,5.33925889 7.07138888,5.50174526 Z"
fill="#718096"
/>
</marker>
{answersCounts?.map((answersCount) => (
<DropOffEdge
key={answersCount.blockId}
blockId={answersCount.blockId}
/>
))}
</chakra.svg>
{answersCounts?.map((answersCount) => (
<DropOffBlock
key={answersCount.blockId}
answersCounts={answersCounts}
blockId={answersCount.blockId}
/>
))}
</>
)
}

View File

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

View File

@ -1,62 +0,0 @@
import {
Editable,
EditableInput,
EditablePreview,
Stack,
useEventListener,
} from '@chakra-ui/react'
import React, { useState } from 'react'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import { StepsList } from './StepsList'
import { Block } from 'models'
type Props = {
block: Block
}
export const BlockNode = ({ block }: Props) => {
const { updateBlockPosition } = useAnalyticsGraph()
const [isMouseDown, setIsMouseDown] = useState(false)
const handleMouseDown = () => {
setIsMouseDown(true)
}
const handleMouseUp = () => {
setIsMouseDown(false)
}
const handleMouseMove = (event: MouseEvent) => {
if (!isMouseDown) return
const { movementX, movementY } = event
updateBlockPosition(block.id, {
x: block.graphCoordinates.x + movementX,
y: block.graphCoordinates.y + movementY,
})
}
useEventListener('mousemove', handleMouseMove)
return (
<Stack
p="4"
rounded="lg"
bgColor="blue.50"
borderWidth="2px"
minW="300px"
transition="border 300ms"
pos="absolute"
style={{
transform: `translate(${block.graphCoordinates.x}px, ${block.graphCoordinates.y}px)`,
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<Editable defaultValue={block.title}>
<EditablePreview px="1" userSelect={'none'} />
<EditableInput minW="0" px="1" />
</Editable>
<StepsList stepIds={block.stepIds} />
</Stack>
)
}

View File

@ -1,24 +0,0 @@
import { Flex, Stack } from '@chakra-ui/react'
import { StepNodeOverlay } from 'components/board/graph/BlockNode/StepNode'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
export const StepsList = ({ stepIds }: { stepIds: string[] }) => {
const { typebot } = useTypebot()
return (
<Stack spacing={1} transition="none">
<Flex h={'2px'} bgColor={'gray.400'} visibility={'hidden'} rounded="lg" />
{typebot &&
stepIds.map((stepId) => (
<Stack key={stepId} spacing={1}>
<StepNodeOverlay step={typebot?.steps.byId[stepId]} />
<Flex
h={'2px'}
bgColor={'gray.400'}
visibility={'hidden'}
rounded="lg"
/>
</Stack>
))}
</Stack>
)
}

View File

@ -1 +0,0 @@
export { ChoiceItemsList as ChoiceInputStepNodeContent } from './ChoiceItemsList'

View File

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

View File

@ -1,118 +0,0 @@
import { Box, Image, Text } from '@chakra-ui/react'
import {
Step,
StartStep,
BubbleStepType,
InputStepType,
LogicStepType,
IntegrationStepType,
} from 'models'
import { isInputStep } from 'utils'
import { ChoiceItemsList } from '../ChoiceInputStepNode/ChoiceItemsList'
import { ConditionNodeContent } from './ConditionNodeContent'
import { SetVariableNodeContent } from './SetVariableNodeContent'
import { StepNodeContentWithVariable } from './StepNodeContentWithVariable'
import { TextBubbleNodeContent } from './TextBubbleNodeContent'
import { VideoStepNodeContent } from './VideoStepNodeContent'
import { WebhookContent } from './WebhookContent'
type Props = {
step: Step | StartStep
isConnectable?: boolean
}
export const StepNodeContent = ({ step }: Props) => {
if (isInputStep(step) && step.options.variableId) {
return <StepNodeContentWithVariable step={step} />
}
switch (step.type) {
case BubbleStepType.TEXT: {
return <TextBubbleNodeContent step={step} />
}
case BubbleStepType.IMAGE: {
return !step.content?.url ? (
<Text color={'gray.500'}>Click to edit...</Text>
) : (
<Box w="full">
<Image
src={step.content?.url}
alt="Step image"
rounded="md"
objectFit="cover"
/>
</Box>
)
}
case BubbleStepType.VIDEO: {
return <VideoStepNodeContent step={step} />
}
case InputStepType.TEXT: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.NUMBER: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.EMAIL: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your email...'}
</Text>
)
}
case InputStepType.URL: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your URL...'}
</Text>
)
}
case InputStepType.DATE: {
return <Text color={'gray.500'}>Pick a date...</Text>
}
case InputStepType.PHONE: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Your phone number...'}
</Text>
)
}
case InputStepType.CHOICE: {
return <ChoiceItemsList step={step} />
}
case LogicStepType.SET_VARIABLE: {
return <SetVariableNodeContent step={step} />
}
case LogicStepType.CONDITION: {
return <ConditionNodeContent step={step} />
}
case LogicStepType.REDIRECT: {
if (!step.options.url) return <Text color={'gray.500'}>Configure...</Text>
return <Text isTruncated>Redirect to {step.options?.url}</Text>
}
case IntegrationStepType.GOOGLE_SHEETS: {
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
return <Text>{step.options?.action}</Text>
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
if (!step.options || !step.options.action)
return <Text color={'gray.500'}>Configure...</Text>
return <Text>Track "{step.options?.action}"</Text>
}
case IntegrationStepType.WEBHOOK: {
return <WebhookContent step={step} />
}
case 'start': {
return <Text>{step.label}</Text>
}
default: {
return <Text>No input</Text>
}
}
}

View File

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

View File

@ -1,2 +0,0 @@
export { StepNode } from './StepNode'
export { StepNodeOverlay } from './StepNodeOverlay'

View File

@ -1,100 +0,0 @@
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import React, { useEffect, useMemo, useState } from 'react'
import {
getAnchorsPosition,
computeEdgePath,
getEndpointTopOffset,
getSourceEndpointId,
} from 'services/graph'
export type AnchorsPositionProps = {
sourcePosition: Coordinates
targetPosition: Coordinates
sourceType: 'right' | 'left'
totalSegments: number
}
export const Edge = ({ edgeId }: { edgeId: string }) => {
const { typebot } = useTypebot()
const { previewingEdgeId, sourceEndpoints, targetEndpoints, graphPosition } =
useGraph()
const edge = useMemo(
() => typebot?.edges.byId[edgeId],
[edgeId, typebot?.edges.byId]
)
const isPreviewing = previewingEdgeId === edgeId
const [sourceTop, setSourceTop] = useState(
getEndpointTopOffset(
graphPosition,
sourceEndpoints,
getSourceEndpointId(edge)
)
)
const [targetTop, setTargetTop] = useState(
getEndpointTopOffset(graphPosition, targetEndpoints, edge?.to.stepId)
)
useEffect(() => {
const newSourceTop = getEndpointTopOffset(
graphPosition,
sourceEndpoints,
getSourceEndpointId(edge)
)
const sensibilityThreshold = 10
const newSourceTopIsTooClose =
sourceTop < newSourceTop + sensibilityThreshold &&
sourceTop > newSourceTop - sensibilityThreshold
if (newSourceTopIsTooClose) return
setSourceTop(newSourceTop)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot?.blocks, typebot?.steps, graphPosition, sourceEndpoints])
useEffect(() => {
if (!edge) return
const newTargetTop = getEndpointTopOffset(
graphPosition,
targetEndpoints,
edge?.to.stepId
)
const sensibilityThreshold = 10
const newSourceTopIsTooClose =
targetTop < newTargetTop + sensibilityThreshold &&
targetTop > newTargetTop - sensibilityThreshold
if (newSourceTopIsTooClose) return
setTargetTop(newTargetTop)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot?.blocks, typebot?.steps, graphPosition, targetEndpoints])
const { sourceBlock, targetBlock } = useMemo(() => {
if (!typebot || !edge?.from.stepId) return {}
const sourceBlock = typebot.blocks.byId[edge.from.blockId]
const targetBlock = typebot.blocks.byId[edge.to.blockId]
return {
sourceBlock,
targetBlock,
}
}, [edge?.from.blockId, edge?.from.stepId, edge?.to.blockId, typebot])
const path = useMemo(() => {
if (!sourceBlock || !targetBlock) return ``
const anchorsPosition = getAnchorsPosition({
sourceBlock,
targetBlock,
sourceTop,
targetTop,
})
return computeEdgePath(anchorsPosition)
}, [sourceBlock, sourceTop, targetBlock, targetTop])
if (sourceTop === 0) return <></>
return (
<path
d={path}
stroke={isPreviewing ? '#1a5fff' : '#718096'}
strokeWidth="2px"
markerEnd={isPreviewing ? 'url(#blue-arrow)' : 'url(#arrow)'}
fill="none"
/>
)
}

View File

@ -1,6 +1,5 @@
import { import {
Stack, Stack,
Input,
Text, Text,
SimpleGrid, SimpleGrid,
useEventListener, useEventListener,
@ -98,20 +97,17 @@ export const StepsSideBar = () => {
spacing={6} spacing={6}
userSelect="none" userSelect="none"
> >
<Stack> <Flex justifyContent="flex-end">
<Flex justifyContent="flex-end"> <Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}>
<Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}> <IconButton
<IconButton icon={isLocked ? <LockedIcon /> : <UnlockedIcon />}
icon={isLocked ? <LockedIcon /> : <UnlockedIcon />} aria-label={isLocked ? 'Unlock' : 'Lock'}
aria-label={isLocked ? 'Unlock' : 'Lock'} size="sm"
size="sm" variant="outline"
variant="outline" onClick={handleLockClick}
onClick={handleLockClick} />
/> </Tooltip>
</Tooltip> </Flex>
</Flex>
<Input placeholder="Search..." bgColor="white" />
</Stack>
<Stack> <Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600"> <Text fontSize="sm" fontWeight="semibold" color="gray.600">

View File

@ -2,7 +2,7 @@ import { Box, BoxProps } from '@chakra-ui/react'
import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup' import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'
import { json } from '@codemirror/lang-json' import { json } from '@codemirror/lang-json'
import { css } from '@codemirror/lang-css' import { css } from '@codemirror/lang-css'
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
type Props = { type Props = {
value: string value: string
@ -19,6 +19,7 @@ export const CodeEditor = ({
}: Props & Omit<BoxProps, 'onChange'>) => { }: Props & Omit<BoxProps, 'onChange'>) => {
const editorContainer = useRef<HTMLDivElement | null>(null) const editorContainer = useRef<HTMLDivElement | null>(null)
const editorView = useRef<EditorView | null>(null) const editorView = useRef<EditorView | null>(null)
const [plainTextValue, setPlainTextValue] = useState(value)
useEffect(() => { useEffect(() => {
if (!editorView.current || !isReadOnly) return if (!editorView.current || !isReadOnly) return
@ -28,11 +29,17 @@ export const CodeEditor = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]) }, [value])
useEffect(() => {
if (!onChange || plainTextValue === value) return
onChange(plainTextValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plainTextValue])
useEffect(() => { useEffect(() => {
if (!editorContainer.current) return if (!editorContainer.current) return
const updateListenerExtension = EditorView.updateListener.of((update) => { const updateListenerExtension = EditorView.updateListener.of((update) => {
if (update.docChanged && onChange) if (update.docChanged && onChange)
onChange(update.state.doc.toJSON().join(' ')) setPlainTextValue(update.state.doc.toJSON().join(' '))
}) })
const extensions = [ const extensions = [
updateListenerExtension, updateListenerExtension,

View File

@ -25,6 +25,7 @@ export interface ContextMenuProps<T extends HTMLElement> {
menuProps?: MenuProps menuProps?: MenuProps
portalProps?: PortalProps portalProps?: PortalProps
menuButtonProps?: MenuButtonProps menuButtonProps?: MenuButtonProps
isDisabled?: boolean
} }
export function ContextMenu<T extends HTMLElement = HTMLElement>( export function ContextMenu<T extends HTMLElement = HTMLElement>(
@ -56,6 +57,7 @@ export function ContextMenu<T extends HTMLElement = HTMLElement>(
useEventListener( useEventListener(
'contextmenu', 'contextmenu',
(e) => { (e) => {
if (props.isDisabled) return
if (e.currentTarget === targetRef.current) { if (e.currentTarget === targetRef.current) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()

View File

@ -18,6 +18,7 @@ export const DrawingEdge = () => {
connectingIds, connectingIds,
sourceEndpoints, sourceEndpoints,
targetEndpoints, targetEndpoints,
blocksCoordinates,
} = useGraph() } = useGraph()
const { typebot, createEdge } = useTypebot() const { typebot, createEdge } = useTypebot()
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }) const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
@ -33,7 +34,7 @@ export const DrawingEdge = () => {
return getEndpointTopOffset( return getEndpointTopOffset(
graphPosition, graphPosition,
sourceEndpoints, sourceEndpoints,
connectingIds.source.nodeId ?? connectingIds.source.buttonId ??
connectingIds.source.stepId + (connectingIds.source.conditionType ?? '') connectingIds.source.stepId + (connectingIds.source.conditionType ?? '')
) )
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -50,22 +51,38 @@ export const DrawingEdge = () => {
}, [graphPosition, targetEndpoints, connectingIds]) }, [graphPosition, targetEndpoints, connectingIds])
const path = useMemo(() => { const path = useMemo(() => {
if (!sourceBlock || !typebot || !connectingIds) return `` if (
!sourceBlock ||
!typebot ||
!connectingIds ||
!blocksCoordinates ||
!sourceTop
)
return ``
return connectingIds?.target return connectingIds?.target
? computeConnectingEdgePath( ? computeConnectingEdgePath({
connectingIds as Omit<ConnectingIds, 'target'> & { target: Target }, connectingIds: connectingIds as Omit<ConnectingIds, 'target'> & {
sourceBlock, target: Target
},
sourceTop, sourceTop,
targetTop, targetTop,
typebot blocksCoordinates,
) })
: computeEdgePathToMouse({ : computeEdgePathToMouse({
blockPosition: sourceBlock.graphCoordinates, blockPosition: blocksCoordinates.byId[sourceBlock.id],
mousePosition, mousePosition,
sourceTop, sourceTop,
}) })
}, [sourceBlock, typebot, connectingIds, sourceTop, targetTop, mousePosition]) }, [
sourceBlock,
typebot,
connectingIds,
blocksCoordinates,
sourceTop,
targetTop,
mousePosition,
])
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ setMousePosition({

View File

@ -0,0 +1,87 @@
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import React, { useMemo } from 'react'
import {
getAnchorsPosition,
computeEdgePath,
getEndpointTopOffset,
getSourceEndpointId,
} from 'services/graph'
export type AnchorsPositionProps = {
sourcePosition: Coordinates
targetPosition: Coordinates
sourceType: 'right' | 'left'
totalSegments: number
}
export const Edge = ({ edgeId }: { edgeId: string }) => {
const { typebot } = useTypebot()
const {
previewingEdgeId,
sourceEndpoints,
targetEndpoints,
graphPosition,
blocksCoordinates,
} = useGraph()
const edge = useMemo(
() => typebot?.edges.byId[edgeId],
[edgeId, typebot?.edges.byId]
)
const isPreviewing = previewingEdgeId === edgeId
const sourceBlock = edge && typebot?.blocks.byId[edge.from.blockId]
const targetBlock = edge && typebot?.blocks.byId[edge.to.blockId]
const sourceBlockCoordinates =
sourceBlock && blocksCoordinates?.byId[sourceBlock.id]
const targetBlockCoordinates =
targetBlock && blocksCoordinates?.byId[targetBlock.id]
const sourceTop = useMemo(
() =>
getEndpointTopOffset(
graphPosition,
sourceEndpoints,
getSourceEndpointId(edge)
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[edge, graphPosition, sourceEndpoints, sourceBlockCoordinates?.y]
)
const targetTop = useMemo(
() => getEndpointTopOffset(graphPosition, targetEndpoints, edge?.to.stepId),
// eslint-disable-next-line react-hooks/exhaustive-deps
[graphPosition, targetEndpoints, edge?.to.stepId, targetBlockCoordinates?.y]
)
const path = useMemo(() => {
if (!sourceBlockCoordinates || !targetBlockCoordinates || !sourceTop)
return ``
const anchorsPosition = getAnchorsPosition({
sourceBlockCoordinates,
targetBlockCoordinates,
sourceTop,
targetTop,
})
return computeEdgePath(anchorsPosition)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sourceBlockCoordinates?.x,
sourceBlockCoordinates?.y,
targetBlockCoordinates?.x,
targetBlockCoordinates?.y,
sourceTop,
])
if (sourceTop === 0) return <></>
return (
<path
d={path}
stroke={isPreviewing ? '#1a5fff' : '#718096'}
strokeWidth="2px"
markerEnd={isPreviewing ? 'url(#blue-arrow)' : 'url(#arrow)'}
fill="none"
/>
)
}

View File

@ -1,7 +1,7 @@
import { Box, BoxProps, Flex } from '@chakra-ui/react' import { Box, BoxProps, Flex } from '@chakra-ui/react'
import { useGraph } from 'contexts/GraphContext' import { useGraph } from 'contexts/GraphContext'
import { Source } from 'models' import { Source } from 'models'
import React, { MouseEvent, useEffect, useRef } from 'react' import React, { MouseEvent, useEffect, useRef, useState } from 'react'
export const SourceEndpoint = ({ export const SourceEndpoint = ({
source, source,
@ -9,7 +9,8 @@ export const SourceEndpoint = ({
}: BoxProps & { }: BoxProps & {
source: Source source: Source
}) => { }) => {
const { setConnectingIds, addSourceEndpoint: addEndpoint } = useGraph() const [ranOnce, setRanOnce] = useState(false)
const { setConnectingIds, addSourceEndpoint, blocksCoordinates } = useGraph()
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => { const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
@ -18,15 +19,17 @@ export const SourceEndpoint = ({
} }
useEffect(() => { useEffect(() => {
if (!ref.current) return if (ranOnce || !ref.current) return
const id = source.nodeId ?? source.stepId + (source.conditionType ?? '') const id = source.buttonId ?? source.stepId + (source.conditionType ?? '')
addEndpoint({ addSourceEndpoint({
id, id,
ref, ref,
}) })
setRanOnce(true)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref]) }, [ref.current])
if (!blocksCoordinates) return <></>
return ( return (
<Flex <Flex
ref={ref} ref={ref}

View File

@ -0,0 +1,2 @@
export { SourceEndpoint } from './SourceEndpoint'
export { TargetEndpoint } from './TargetEndpoint'

View File

@ -1,20 +1,31 @@
import { Flex, FlexProps, useEventListener } from '@chakra-ui/react' import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import React, { useRef, useMemo, useEffect } from 'react' import React, { useRef, useMemo, useEffect } from 'react'
import { blockWidth, useGraph } from 'contexts/GraphContext' import { blockWidth, useGraph } from 'contexts/GraphContext'
import { BlockNode } from './BlockNode/BlockNode' import { BlockNode } from './Nodes/BlockNode/BlockNode'
import { useStepDnd } from 'contexts/StepDndContext' import { useStepDnd } from 'contexts/StepDndContext'
import { Edges } from './Edges' import { Edges } from './Edges'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader' import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
import { DraggableStepType } from 'models' import { DraggableStepType } from 'models'
import { generate } from 'short-uuid'
import { AnswersCount } from 'services/analytics'
import { DropOffNode } from './Nodes/DropOffNode'
const Graph = ({ ...props }: FlexProps) => { export const Graph = ({
answersCounts,
...props
}: { answersCounts?: AnswersCount[] } & FlexProps) => {
const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } = const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } =
useStepDnd() useStepDnd()
const graphContainerRef = useRef<HTMLDivElement | null>(null) const graphContainerRef = useRef<HTMLDivElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(null) const editorContainerRef = useRef<HTMLDivElement | null>(null)
const { createBlock, typebot } = useTypebot() const { createBlock, typebot } = useTypebot()
const { graphPosition, setGraphPosition, setOpenedStepId } = useGraph() const {
graphPosition,
setGraphPosition,
setOpenedStepId,
updateBlockCoordinates,
} = useGraph()
const transform = useMemo( const transform = useMemo(
() => () =>
`translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`, `translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`,
@ -48,9 +59,15 @@ const Graph = ({ ...props }: FlexProps) => {
const handleMouseUp = (e: MouseEvent) => { const handleMouseUp = (e: MouseEvent) => {
if (!draggedStep && !draggedStepType) return if (!draggedStep && !draggedStepType) return
createBlock({ const coordinates = {
x: e.clientX - graphPosition.x - blockWidth / 3, x: e.clientX - graphPosition.x - blockWidth / 3,
y: e.clientY - graphPosition.y - 20 - headerHeight, y: e.clientY - graphPosition.y - 20 - headerHeight,
}
const id = generate()
updateBlockCoordinates(id, coordinates)
createBlock({
id,
...coordinates,
step: draggedStep ?? (draggedStepType as DraggableStepType), step: draggedStep ?? (draggedStepType as DraggableStepType),
}) })
setDraggedStep(undefined) setDraggedStep(undefined)
@ -79,13 +96,17 @@ const Graph = ({ ...props }: FlexProps) => {
}} }}
> >
<Edges /> <Edges />
{props.children}
{typebot.blocks.allIds.map((blockId) => ( {typebot.blocks.allIds.map((blockId) => (
<BlockNode block={typebot.blocks.byId[blockId]} key={blockId} /> <BlockNode block={typebot.blocks.byId[blockId]} key={blockId} />
))} ))}
{answersCounts?.map((answersCount) => (
<DropOffNode
key={answersCount.blockId}
answersCounts={answersCounts}
blockId={answersCount.blockId}
/>
))}
</Flex> </Flex>
</Flex> </Flex>
) )
} }
export default Graph

View File

@ -9,18 +9,26 @@ import React, { useEffect, useMemo, useState } from 'react'
import { Block } from 'models' import { Block } from 'models'
import { useGraph } from 'contexts/GraphContext' import { useGraph } from 'contexts/GraphContext'
import { useStepDnd } from 'contexts/StepDndContext' import { useStepDnd } from 'contexts/StepDndContext'
import { StepsList } from './StepsList' import { StepNodesList } from '../StepNode/StepNodesList'
import { isDefined } from 'utils' import { isNotDefined } from 'utils'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu' import { ContextMenu } from 'components/shared/ContextMenu'
import { BlockNodeContextMenu } from './BlockNodeContextMenu' import { BlockNodeContextMenu } from './BlockNodeContextMenu'
import { useDebounce } from 'use-debounce'
type Props = { type Props = {
block: Block block: Block
} }
export const BlockNode = ({ block }: Props) => { export const BlockNode = ({ block }: Props) => {
const { connectingIds, setConnectingIds, previewingEdgeId } = useGraph() const {
connectingIds,
setConnectingIds,
previewingEdgeId,
blocksCoordinates,
updateBlockCoordinates,
isReadOnly,
} = useGraph()
const { typebot, updateBlock } = useTypebot() const { typebot, updateBlock } = useTypebot()
const { setMouseOverBlockId } = useStepDnd() const { setMouseOverBlockId } = useStepDnd()
const { draggedStep, draggedStepType } = useStepDnd() const { draggedStep, draggedStepType } = useStepDnd()
@ -32,10 +40,26 @@ export const BlockNode = ({ block }: Props) => {
return edge?.to.blockId === block.id || edge?.from.blockId === block.id return edge?.to.blockId === block.id || edge?.from.blockId === block.id
}, [block.id, previewingEdgeId, typebot?.edges.byId]) }, [block.id, previewingEdgeId, typebot?.edges.byId])
const blockCoordinates = useMemo(
() => blocksCoordinates?.byId[block.id],
[block.id, blocksCoordinates?.byId]
)
const [debouncedBlockPosition] = useDebounce(blockCoordinates, 100)
useEffect(() => {
if (!debouncedBlockPosition || isReadOnly) return
if (
debouncedBlockPosition?.x === block.graphCoordinates.x &&
debouncedBlockPosition.y === block.graphCoordinates.y
)
return
updateBlock(block.id, { graphCoordinates: debouncedBlockPosition })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedBlockPosition])
useEffect(() => { useEffect(() => {
setIsConnecting( setIsConnecting(
connectingIds?.target?.blockId === block.id && connectingIds?.target?.blockId === block.id &&
!isDefined(connectingIds.target?.stepId) isNotDefined(connectingIds.target?.stepId)
) )
}, [block.id, connectingIds]) }, [block.id, connectingIds])
@ -53,11 +77,10 @@ export const BlockNode = ({ block }: Props) => {
if (!isMouseDown) return if (!isMouseDown) return
const { movementX, movementY } = event const { movementX, movementY } = event
updateBlock(block.id, { if (!blockCoordinates) return
graphCoordinates: { updateBlockCoordinates(block.id, {
x: block.graphCoordinates.x + movementX, x: blockCoordinates.x + movementX,
y: block.graphCoordinates.y + movementY, y: blockCoordinates.y + movementY,
},
}) })
} }
@ -77,6 +100,7 @@ export const BlockNode = ({ block }: Props) => {
return ( return (
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
renderMenu={() => <BlockNodeContextMenu blockId={block.id} />} renderMenu={() => <BlockNodeContextMenu blockId={block.id} />}
isDisabled={isReadOnly}
> >
{(ref, isOpened) => ( {(ref, isOpened) => (
<Stack <Stack
@ -93,7 +117,9 @@ export const BlockNode = ({ block }: Props) => {
transition="border 300ms, box-shadow 200ms" transition="border 300ms, box-shadow 200ms"
pos="absolute" pos="absolute"
style={{ style={{
transform: `translate(${block.graphCoordinates.x}px, ${block.graphCoordinates.y}px)`, transform: `translate(${blockCoordinates?.x ?? 0}px, ${
blockCoordinates?.y ?? 0
}px)`,
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
@ -106,6 +132,7 @@ export const BlockNode = ({ block }: Props) => {
defaultValue={block.title} defaultValue={block.title}
onSubmit={handleTitleSubmit} onSubmit={handleTitleSubmit}
fontWeight="semibold" fontWeight="semibold"
isDisabled={isReadOnly}
> >
<EditablePreview <EditablePreview
_hover={{ bgColor: 'gray.300' }} _hover={{ bgColor: 'gray.300' }}
@ -114,7 +141,9 @@ export const BlockNode = ({ block }: Props) => {
/> />
<EditableInput minW="0" px="1" /> <EditableInput minW="0" px="1" />
</Editable> </Editable>
{typebot && <StepsList blockId={block.id} stepIds={block.stepIds} />} {typebot && (
<StepNodesList blockId={block.id} stepIds={block.stepIds} />
)}
</Stack> </Stack>
)} )}
</ContextMenu> </ContextMenu>

View File

@ -13,11 +13,11 @@ import { Coordinates } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { ChoiceInputStep, ChoiceItem } from 'models' import { ChoiceInputStep, ChoiceItem } from 'models'
import React, { useState } from 'react' import React, { useState } from 'react'
import { isDefined, isSingleChoiceInput } from 'utils' import { isNotDefined, isSingleChoiceInput } from 'utils'
import { SourceEndpoint } from '../SourceEndpoint' import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
import { ChoiceItemNodeContextMenu } from './ChoiceItemNodeContextMenu' import { ButtonNodeContextMenu } from './ButtonNodeContextMenu'
type ChoiceItemNodeProps = { type Props = {
item: ChoiceItem item: ChoiceItem
onMouseMoveBottomOfElement?: () => void onMouseMoveBottomOfElement?: () => void
onMouseMoveTopOfElement?: () => void onMouseMoveTopOfElement?: () => void
@ -27,12 +27,12 @@ type ChoiceItemNodeProps = {
) => void ) => void
} }
export const ChoiceItemNode = ({ export const ButtonNode = ({
item, item,
onMouseDown, onMouseDown,
onMouseMoveBottomOfElement, onMouseMoveBottomOfElement,
onMouseMoveTopOfElement, onMouseMoveTopOfElement,
}: ChoiceItemNodeProps) => { }: Props) => {
const { deleteChoiceItem, updateChoiceItem, typebot, createChoiceItem } = const { deleteChoiceItem, updateChoiceItem, typebot, createChoiceItem } =
useTypebot() useTypebot()
const [mouseDownEvent, setMouseDownEvent] = const [mouseDownEvent, setMouseDownEvent] =
@ -88,9 +88,10 @@ export const ChoiceItemNode = ({
const handleMouseEnter = () => setIsMouseOver(true) const handleMouseEnter = () => setIsMouseOver(true)
const handleMouseLeave = () => setIsMouseOver(false) const handleMouseLeave = () => setIsMouseOver(false)
return ( return (
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
renderMenu={() => <ChoiceItemNodeContextMenu itemId={item.id} />} renderMenu={() => <ButtonNodeContextMenu itemId={item.id} />}
> >
{(ref, isOpened) => ( {(ref, isOpened) => (
<Flex <Flex
@ -112,7 +113,7 @@ export const ChoiceItemNode = ({
<Editable <Editable
defaultValue={item.content ?? 'Click to edit'} defaultValue={item.content ?? 'Click to edit'}
flex="1" flex="1"
startWithEditView={!isDefined(item.content)} startWithEditView={isNotDefined(item.content)}
onSubmit={handleInputSubmit} onSubmit={handleInputSubmit}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
@ -128,7 +129,7 @@ export const ChoiceItemNode = ({
source={{ source={{
blockId: typebot.steps.byId[item.stepId].blockId, blockId: typebot.steps.byId[item.stepId].blockId,
stepId: item.stepId, stepId: item.stepId,
nodeId: item.id, buttonId: item.id,
}} }}
pos="absolute" pos="absolute"
right="15px" right="15px"

View File

@ -2,7 +2,7 @@ import { MenuList, MenuItem } from '@chakra-ui/react'
import { TrashIcon } from 'assets/icons' import { TrashIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
export const ChoiceItemNodeContextMenu = ({ itemId }: { itemId: string }) => { export const ButtonNodeContextMenu = ({ itemId }: { itemId: string }) => {
const { deleteChoiceItem } = useTypebot() const { deleteChoiceItem } = useTypebot()
const handleDeleteClick = () => deleteChoiceItem(itemId) const handleDeleteClick = () => deleteChoiceItem(itemId)

View File

@ -2,14 +2,11 @@ import { Flex, FlexProps } from '@chakra-ui/react'
import { ChoiceItem } from 'models' import { ChoiceItem } from 'models'
import React from 'react' import React from 'react'
type ChoiceItemNodeOverlayProps = { type Props = {
item: ChoiceItem item: ChoiceItem
} & FlexProps } & FlexProps
export const ChoiceItemNodeOverlay = ({ export const ButtonNodeOverlay = ({ item, ...props }: Props) => {
item,
...props
}: ChoiceItemNodeOverlayProps) => {
return ( return (
<Flex <Flex
px="4" px="4"

View File

@ -4,15 +4,15 @@ import { Coordinates } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { ChoiceInputStep, ChoiceItem } from 'models' import { ChoiceInputStep, ChoiceItem } from 'models'
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { SourceEndpoint } from '../SourceEndpoint' import { ButtonNode } from './ButtonNode'
import { ChoiceItemNode } from './ChoiceItemNode' import { SourceEndpoint } from '../../Endpoints'
import { ChoiceItemNodeOverlay } from './ChoiceItemNodeOverlay' import { ButtonNodeOverlay } from './ButtonNodeOverlay'
type ChoiceItemsListProps = { type ChoiceItemsListProps = {
step: ChoiceInputStep step: ChoiceInputStep
} }
export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => { export const ButtonNodesList = ({ step }: ChoiceItemsListProps) => {
const { typebot, createChoiceItem } = useTypebot() const { typebot, createChoiceItem } = useTypebot()
const { const {
draggedChoiceItem, draggedChoiceItem,
@ -90,7 +90,7 @@ export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
{step.options.itemIds.map((itemId, idx) => ( {step.options.itemIds.map((itemId, idx) => (
<Stack key={itemId} spacing={1}> <Stack key={itemId} spacing={1}>
{typebot?.choiceItems.byId[itemId] && ( {typebot?.choiceItems.byId[itemId] && (
<ChoiceItemNode <ButtonNode
item={typebot?.choiceItems.byId[itemId]} item={typebot?.choiceItems.byId[itemId]}
onMouseMoveTopOfElement={() => handleMouseOnTopOfStep(idx)} onMouseMoveTopOfElement={() => handleMouseOnTopOfStep(idx)}
onMouseMoveBottomOfElement={() => { onMouseMoveBottomOfElement={() => {
@ -138,7 +138,7 @@ export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
{draggedChoiceItem && draggedChoiceItem.stepId === step.id && ( {draggedChoiceItem && draggedChoiceItem.stepId === step.id && (
<Portal> <Portal>
<ChoiceItemNodeOverlay <ButtonNodeOverlay
item={draggedChoiceItem} item={draggedChoiceItem}
pos="fixed" pos="fixed"
top="0" top="0"

View File

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

View File

@ -1,5 +1,5 @@
import { Tag, Text, VStack } from '@chakra-ui/react' import { Tag, Text, VStack } from '@chakra-ui/react'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider' import { useTypebot } from 'contexts/TypebotContext'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { AnswersCount } from 'services/analytics' import { AnswersCount } from 'services/analytics'
import { computeSourceCoordinates } from 'services/graph' import { computeSourceCoordinates } from 'services/graph'
@ -10,8 +10,8 @@ type Props = {
blockId: string blockId: string
} }
export const DropOffBlock = ({ answersCounts, blockId }: Props) => { export const DropOffNode = ({ answersCounts, blockId }: Props) => {
const { typebot } = useAnalyticsGraph() const { typebot } = useTypebot()
const totalAnswers = useMemo( const totalAnswers = useMemo(
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers, () => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,

View File

@ -5,23 +5,21 @@ import {
PopoverBody, PopoverBody,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ImageUploadContent } from 'components/shared/ImageUploadContent' import { ImageUploadContent } from 'components/shared/ImageUploadContent'
import { useTypebot } from 'contexts/TypebotContext'
import { import {
BubbleStep, BubbleStep,
BubbleStepContent,
BubbleStepType, BubbleStepType,
ImageBubbleStep,
TextBubbleStep, TextBubbleStep,
VideoBubbleContent,
VideoBubbleStep,
} from 'models' } from 'models'
import { useRef } from 'react' import { useRef } from 'react'
import { VideoUploadContent } from './VideoUploadContent' import { VideoUploadContent } from './VideoUploadContent'
type Props = { type Props = {
step: Exclude<BubbleStep, TextBubbleStep> step: Exclude<BubbleStep, TextBubbleStep>
onContentChange: (content: BubbleStepContent) => void
} }
export const ContentPopover = ({ step }: Props) => { export const MediaBubblePopoverContent = (props: Props) => {
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation() const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
@ -30,37 +28,28 @@ export const ContentPopover = ({ step }: Props) => {
<PopoverContent onMouseDown={handleMouseDown} w="500px"> <PopoverContent onMouseDown={handleMouseDown} w="500px">
<PopoverArrow /> <PopoverArrow />
<PopoverBody ref={ref} shadow="lg"> <PopoverBody ref={ref} shadow="lg">
<StepContent step={step} /> <MediaBubbleContent {...props} />
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
</Portal> </Portal>
) )
} }
export const StepContent = ({ step }: Props) => { export const MediaBubbleContent = ({ step, onContentChange }: Props) => {
const { updateStep } = useTypebot() const handleImageUrlChange = (url: string) => onContentChange({ url })
const handleContentChange = (url: string) =>
updateStep(step.id, { content: { url } } as Partial<ImageBubbleStep>)
const handleVideoContentChange = (content: VideoBubbleContent) =>
updateStep(step.id, { content } as Partial<VideoBubbleStep>)
switch (step.type) { switch (step.type) {
case BubbleStepType.IMAGE: { case BubbleStepType.IMAGE: {
return ( return (
<ImageUploadContent <ImageUploadContent
url={step.content?.url} url={step.content?.url}
onSubmit={handleContentChange} onSubmit={handleImageUrlChange}
/> />
) )
} }
case BubbleStepType.VIDEO: { case BubbleStepType.VIDEO: {
return ( return (
<VideoUploadContent <VideoUploadContent content={step.content} onSubmit={onContentChange} />
content={step.content}
onSubmit={handleVideoContentChange}
/>
) )
} }
} }

View File

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

View File

@ -7,9 +7,7 @@ import {
IconButton, IconButton,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ExpandIcon } from 'assets/icons' import { ExpandIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { import {
InputStep,
InputStepType, InputStepType,
IntegrationStepType, IntegrationStepType,
LogicStepType, LogicStepType,
@ -17,7 +15,6 @@ import {
StepOptions, StepOptions,
TextBubbleStep, TextBubbleStep,
Webhook, Webhook,
WebhookStep,
} from 'models' } from 'models'
import { useRef } from 'react' import { useRef } from 'react'
import { import {
@ -33,15 +30,19 @@ import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody' import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody' import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings' import { RedirectSettings } from './bodies/RedirectSettings'
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody' import { SetVariableSettings } from './bodies/SetVariableSettings'
import { WebhookSettings } from './bodies/WebhookSettings' import { WebhookSettings } from './bodies/WebhookSettings'
type Props = { type Props = {
step: Exclude<Step, TextBubbleStep> step: Exclude<Step, TextBubbleStep>
webhook?: Webhook
onExpandClick: () => void onExpandClick: () => void
onOptionsChange: (options: StepOptions) => void
onWebhookChange: (updates: Partial<Webhook>) => void
onTestRequestClick: () => void
} }
export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => { export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation() const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
@ -60,7 +61,7 @@ export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
ref={ref} ref={ref}
shadow="lg" shadow="lg"
> >
<StepSettings step={step} /> <StepSettings {...props} />
</PopoverBody> </PopoverBody>
<IconButton <IconButton
pos="absolute" pos="absolute"
@ -76,24 +77,25 @@ export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
) )
} }
export const StepSettings = ({ step }: { step: Step }) => { export const StepSettings = ({
const { updateStep, updateWebhook, typebot } = useTypebot() step,
const handleOptionsChange = (options: StepOptions) => { webhook,
updateStep(step.id, { options } as Partial<InputStep>) onOptionsChange,
} onWebhookChange,
onTestRequestClick,
const handleWebhookChange = (webhook: Partial<Webhook>) => { }: {
const webhookId = (step as WebhookStep).options?.webhookId step: Step
if (!webhookId) return webhook?: Webhook
updateWebhook(webhookId, webhook) onOptionsChange: (options: StepOptions) => void
} onWebhookChange: (updates: Partial<Webhook>) => void
onTestRequestClick: () => void
}) => {
switch (step.type) { switch (step.type) {
case InputStepType.TEXT: { case InputStepType.TEXT: {
return ( return (
<TextInputSettingsBody <TextInputSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -101,7 +103,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<NumberInputSettingsBody <NumberInputSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -109,7 +111,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<EmailInputSettingsBody <EmailInputSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -117,7 +119,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<UrlInputSettingsBody <UrlInputSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -125,7 +127,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<DateInputSettingsBody <DateInputSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -133,7 +135,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<PhoneNumberSettingsBody <PhoneNumberSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -141,15 +143,15 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<ChoiceInputSettingsBody <ChoiceInputSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
case LogicStepType.SET_VARIABLE: { case LogicStepType.SET_VARIABLE: {
return ( return (
<SetVariableSettingsBody <SetVariableSettings
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -157,7 +159,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<ConditionSettingsBody <ConditionSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -165,7 +167,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<RedirectSettings <RedirectSettings
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
@ -173,7 +175,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<GoogleSheetsSettingsBody <GoogleSheetsSettingsBody
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
stepId={step.id} stepId={step.id}
/> />
) )
@ -182,18 +184,18 @@ export const StepSettings = ({ step }: { step: Step }) => {
return ( return (
<GoogleAnalyticsSettings <GoogleAnalyticsSettings
options={step.options} options={step.options}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
/> />
) )
} }
case IntegrationStepType.WEBHOOK: { case IntegrationStepType.WEBHOOK: {
return ( return (
<WebhookSettings <WebhookSettings
key={step.options?.webhookId}
options={step.options} options={step.options}
webhook={typebot?.webhooks.byId[step.options?.webhookId ?? '']} webhook={webhook as Webhook}
onOptionsChange={handleOptionsChange} onOptionsChange={onOptionsChange}
onWebhookChange={handleWebhookChange} onWebhookChange={onWebhookChange}
onTestRequestClick={onTestRequestClick}
/> />
) )
} }

View File

@ -6,9 +6,13 @@ import { useTypebot } from 'contexts/TypebotContext'
import { CredentialsType } from 'db' import { CredentialsType } from 'db'
import { import {
Cell, Cell,
defaultTable,
ExtractingCell, ExtractingCell,
GoogleSheetsAction, GoogleSheetsAction,
GoogleSheetsGetOptions,
GoogleSheetsInsertRowOptions,
GoogleSheetsOptions, GoogleSheetsOptions,
GoogleSheetsUpdateRowOptions,
Table, Table,
} from 'models' } from 'models'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
@ -24,7 +28,7 @@ import { CellWithValueStack } from './CellWithValueStack'
import { CellWithVariableIdStack } from './CellWithVariableIdStack' import { CellWithVariableIdStack } from './CellWithVariableIdStack'
type Props = { type Props = {
options?: GoogleSheetsOptions options: GoogleSheetsOptions
onOptionsChange: (options: GoogleSheetsOptions) => void onOptionsChange: (options: GoogleSheetsOptions) => void
stepId: string stepId: string
} }
@ -49,8 +53,35 @@ export const GoogleSheetsSettingsBody = ({
onOptionsChange({ ...options, spreadsheetId }) onOptionsChange({ ...options, spreadsheetId })
const handleSheetIdChange = (sheetId: string) => const handleSheetIdChange = (sheetId: string) =>
onOptionsChange({ ...options, sheetId }) onOptionsChange({ ...options, sheetId })
const handleActionChange = (action: GoogleSheetsAction) =>
onOptionsChange({ ...options, action }) const handleActionChange = (action: GoogleSheetsAction) => {
switch (action) {
case GoogleSheetsAction.GET: {
const newOptions: GoogleSheetsGetOptions = {
...options,
action,
cellsToExtract: defaultTable,
}
return onOptionsChange({ ...newOptions })
}
case GoogleSheetsAction.INSERT_ROW: {
const newOptions: GoogleSheetsInsertRowOptions = {
...options,
action,
cellsToInsert: defaultTable,
}
return onOptionsChange({ ...newOptions })
}
case GoogleSheetsAction.UPDATE_ROW: {
const newOptions: GoogleSheetsUpdateRowOptions = {
...options,
action,
cellsToUpsert: defaultTable,
}
return onOptionsChange({ ...newOptions })
}
}
}
const handleCreateNewClick = async () => { const handleCreateNewClick = async () => {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
@ -94,14 +125,14 @@ export const GoogleSheetsSettingsBody = ({
<> <>
<Divider /> <Divider />
<DropdownList<GoogleSheetsAction> <DropdownList<GoogleSheetsAction>
currentItem={options.action} currentItem={'action' in options ? options.action : undefined}
onItemSelect={handleActionChange} onItemSelect={handleActionChange}
items={Object.values(GoogleSheetsAction)} items={Object.values(GoogleSheetsAction)}
placeholder="Select an operation" placeholder="Select an operation"
/> />
</> </>
)} )}
{sheet && options?.action && ( {sheet && 'action' in options && (
<ActionOptions <ActionOptions
options={options} options={options}
sheet={sheet} sheet={sheet}
@ -117,7 +148,10 @@ const ActionOptions = ({
sheet, sheet,
onOptionsChange, onOptionsChange,
}: { }: {
options: GoogleSheetsOptions options:
| GoogleSheetsGetOptions
| GoogleSheetsInsertRowOptions
| GoogleSheetsUpdateRowOptions
sheet: Sheet sheet: Sheet
onOptionsChange: (options: GoogleSheetsOptions) => void onOptionsChange: (options: GoogleSheetsOptions) => void
}) => { }) => {
@ -149,7 +183,7 @@ const ActionOptions = ({
case GoogleSheetsAction.INSERT_ROW: case GoogleSheetsAction.INSERT_ROW:
return ( return (
<TableList<Cell> <TableList<Cell>
initialItems={options.cellsToInsert ?? { byId: {}, allIds: [] }} initialItems={options.cellsToInsert}
onItemsChange={handleInsertColumnsChange} onItemsChange={handleInsertColumnsChange}
Item={UpdatingCellItem} Item={UpdatingCellItem}
addLabel="Add a value" addLabel="Add a value"
@ -167,7 +201,7 @@ const ActionOptions = ({
/> />
<Text>Cells to update</Text> <Text>Cells to update</Text>
<TableList<Cell> <TableList<Cell>
initialItems={options.cellsToUpsert ?? { byId: {}, allIds: [] }} initialItems={options.cellsToUpsert}
onItemsChange={handleUpsertColumnsChange} onItemsChange={handleUpsertColumnsChange}
Item={UpdatingCellItem} Item={UpdatingCellItem}
addLabel="Add a value" addLabel="Add a value"
@ -186,7 +220,7 @@ const ActionOptions = ({
/> />
<Text>Cells to extract</Text> <Text>Cells to extract</Text>
<TableList<ExtractingCell> <TableList<ExtractingCell>
initialItems={options.cellsToExtract ?? { byId: {}, allIds: [] }} initialItems={options.cellsToExtract}
onItemsChange={handleExtractingCellsChange} onItemsChange={handleExtractingCellsChange}
Item={ExtractingCellItem} Item={ExtractingCellItem}
addLabel="Add a value" addLabel="Add a value"

View File

@ -9,10 +9,7 @@ type Props = {
onOptionsChange: (options: SetVariableOptions) => void onOptionsChange: (options: SetVariableOptions) => void
} }
export const SetVariableSettingsBody = ({ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
options,
onOptionsChange,
}: Props) => {
const handleVariableChange = (variable?: Variable) => const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id }) onOptionsChange({ ...options, variableId: variable?.id })
const handleExpressionChange = (expressionToEvaluate: string) => const handleExpressionChange = (expressionToEvaluate: string) =>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { import {
Accordion, Accordion,
AccordionButton, AccordionButton,
@ -22,7 +22,6 @@ import {
ResponseVariableMapping, ResponseVariableMapping,
} from 'models' } from 'models'
import { DropdownList } from 'components/shared/DropdownList' import { DropdownList } from 'components/shared/DropdownList'
import { generate } from 'short-uuid'
import { TableList, TableListItemProps } from 'components/shared/TableList' import { TableList, TableListItemProps } from 'components/shared/TableList'
import { CodeEditor } from 'components/shared/CodeEditor' import { CodeEditor } from 'components/shared/CodeEditor'
import { import {
@ -35,19 +34,22 @@ import { VariableForTestInputs } from './VariableForTestInputs'
import { DataVariableInputs } from './ResponseMappingInputs' import { DataVariableInputs } from './ResponseMappingInputs'
type Props = { type Props = {
webhook: Webhook
options?: WebhookOptions options?: WebhookOptions
webhook?: Webhook
onOptionsChange: (options: WebhookOptions) => void onOptionsChange: (options: WebhookOptions) => void
onWebhookChange: (webhook: Partial<Webhook>) => void onWebhookChange: (updates: Partial<Webhook>) => void
onTestRequestClick: () => void
} }
export const WebhookSettings = ({ export const WebhookSettings = ({
options, options,
webhook,
onOptionsChange, onOptionsChange,
webhook,
onWebhookChange, onWebhookChange,
onTestRequestClick,
}: Props) => { }: Props) => {
const { createWebhook, typebot, save } = useTypebot() const { typebot, save } = useTypebot()
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
const [testResponse, setTestResponse] = useState<string>() const [testResponse, setTestResponse] = useState<string>()
const [responseKeys, setResponseKeys] = useState<string[]>([]) const [responseKeys, setResponseKeys] = useState<string[]>([])
@ -56,18 +58,9 @@ export const WebhookSettings = ({
status: 'error', status: 'error',
}) })
useEffect(() => {
if (options?.webhookId) return
const webhookId = generate()
createWebhook({ id: webhookId })
onOptionsChange({ ...options, webhookId })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleUrlChange = (url?: string) => onWebhookChange({ url }) const handleUrlChange = (url?: string) => onWebhookChange({ url })
const handleMethodChange = (method?: HttpMethod) => const handleMethodChange = (method: HttpMethod) => onWebhookChange({ method })
onWebhookChange({ method })
const handleQueryParamsChange = (queryParams: Table<KeyValue>) => const handleQueryParamsChange = (queryParams: Table<KeyValue>) =>
onWebhookChange({ queryParams }) onWebhookChange({ queryParams })
@ -78,14 +71,16 @@ export const WebhookSettings = ({
const handleBodyChange = (body: string) => onWebhookChange({ body }) const handleBodyChange = (body: string) => onWebhookChange({ body })
const handleVariablesChange = (variablesForTest: Table<VariableForTest>) => const handleVariablesChange = (variablesForTest: Table<VariableForTest>) =>
onOptionsChange({ ...options, variablesForTest }) options && onOptionsChange({ ...options, variablesForTest })
const handleResponseMappingChange = ( const handleResponseMappingChange = (
responseVariableMapping: Table<ResponseVariableMapping> responseVariableMapping: Table<ResponseVariableMapping>
) => onOptionsChange({ ...options, responseVariableMapping }) ) => options && onOptionsChange({ ...options, responseVariableMapping })
const handleTestRequestClick = async () => { const handleTestRequestClick = async () => {
if (!typebot || !webhook) return if (!typebot || !webhook) return
setIsTestResponseLoading(true)
onTestRequestClick()
await save() await save()
const { data, error } = await executeWebhook( const { data, error } = await executeWebhook(
typebot.id, typebot.id,
@ -98,6 +93,7 @@ export const WebhookSettings = ({
if (error) return toast({ title: error.name, description: error.message }) if (error) return toast({ title: error.name, description: error.message })
setTestResponse(JSON.stringify(data, undefined, 2)) setTestResponse(JSON.stringify(data, undefined, 2))
setResponseKeys(getDeepKeys(data)) setResponseKeys(getDeepKeys(data))
setIsTestResponseLoading(false)
} }
const ResponseMappingInputs = useMemo( const ResponseMappingInputs = useMemo(
@ -111,14 +107,14 @@ export const WebhookSettings = ({
<Stack> <Stack>
<Flex> <Flex>
<DropdownList<HttpMethod> <DropdownList<HttpMethod>
currentItem={webhook?.method ?? HttpMethod.GET} currentItem={webhook.method}
onItemSelect={handleMethodChange} onItemSelect={handleMethodChange}
items={Object.values(HttpMethod)} items={Object.values(HttpMethod)}
/> />
</Flex> </Flex>
<InputWithVariableButton <InputWithVariableButton
placeholder="Your Webhook URL..." placeholder="Your Webhook URL..."
initialValue={webhook?.url ?? ''} initialValue={webhook.url ?? ''}
onChange={handleUrlChange} onChange={handleUrlChange}
/> />
</Stack> </Stack>
@ -130,7 +126,7 @@ export const WebhookSettings = ({
</AccordionButton> </AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6"> <AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue> <TableList<KeyValue>
initialItems={webhook?.queryParams ?? { byId: {}, allIds: [] }} initialItems={webhook.queryParams}
onItemsChange={handleQueryParamsChange} onItemsChange={handleQueryParamsChange}
Item={QueryParamsInputs} Item={QueryParamsInputs}
addLabel="Add a param" addLabel="Add a param"
@ -144,7 +140,7 @@ export const WebhookSettings = ({
</AccordionButton> </AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6"> <AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue> <TableList<KeyValue>
initialItems={webhook?.headers ?? { byId: {}, allIds: [] }} initialItems={webhook.headers}
onItemsChange={handleHeadersChange} onItemsChange={handleHeadersChange}
Item={HeadersInputs} Item={HeadersInputs}
addLabel="Add a value" addLabel="Add a value"
@ -158,7 +154,7 @@ export const WebhookSettings = ({
</AccordionButton> </AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6"> <AccordionPanel pb={4} as={Stack} spacing="6">
<CodeEditor <CodeEditor
value={webhook?.body ?? ''} value={'test'}
lang="json" lang="json"
onChange={handleBodyChange} onChange={handleBodyChange}
/> />
@ -181,7 +177,11 @@ export const WebhookSettings = ({
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
<Button onClick={handleTestRequestClick} colorScheme="blue"> <Button
onClick={handleTestRequestClick}
colorScheme="blue"
isLoading={isTestResponseLoading}
>
Test the request Test the request
</Button> </Button>
{testResponse && ( {testResponse && (
@ -201,6 +201,7 @@ export const WebhookSettings = ({
} }
onItemsChange={handleResponseMappingChange} onItemsChange={handleResponseMappingChange}
Item={ResponseMappingInputs} Item={ResponseMappingInputs}
addLabel="Add an entry"
/> />
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>

View File

@ -7,23 +7,31 @@ import {
useEventListener, useEventListener,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { BubbleStep, DraggableStep, Step, TextBubbleStep } from 'models' import {
BubbleStep,
BubbleStepContent,
DraggableStep,
Step,
StepOptions,
TextBubbleStep,
Webhook,
} from 'models'
import { Coordinates, useGraph } from 'contexts/GraphContext' import { Coordinates, useGraph } from 'contexts/GraphContext'
import { StepIcon } from 'components/board/StepsSideBar/StepIcon' import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { isBubbleStep, isTextBubbleStep } from 'utils' import { isBubbleStep, isTextBubbleStep, isWebhookStep } from 'utils'
import { TextEditor } from './TextEditor/TextEditor'
import { StepNodeContent } from './StepNodeContent/StepNodeContent' import { StepNodeContent } from './StepNodeContent/StepNodeContent'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu' import { ContextMenu } from 'components/shared/ContextMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent' import { SettingsPopoverContent } from './SettingsPopoverContent'
import { StepNodeContextMenu } from './StepNodeContextMenu' import { StepNodeContextMenu } from './StepNodeContextMenu'
import { SourceEndpoint } from './SourceEndpoint' import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
import { hasDefaultConnector } from 'services/typebots' import { hasDefaultConnector } from 'services/typebots'
import { TargetEndpoint } from './TargetEndpoint'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { SettingsModal } from './SettingsPopoverContent/SettingsModal' import { SettingsModal } from './SettingsPopoverContent/SettingsModal'
import { StepSettings } from './SettingsPopoverContent/SettingsPopoverContent' import { StepSettings } from './SettingsPopoverContent/SettingsPopoverContent'
import { ContentPopover } from './ContentPopover' import { TextBubbleEditor } from './TextBubbleEditor'
import { TargetEndpoint } from '../../Endpoints'
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
export const StepNode = ({ export const StepNode = ({
step, step,
@ -42,10 +50,26 @@ export const StepNode = ({
) => void ) => void
}) => { }) => {
const { query } = useRouter() const { query } = useRouter()
const { setConnectingIds, connectingIds, openedStepId, setOpenedStepId } = const {
useGraph() setConnectingIds,
const { detachStepFromBlock } = useTypebot() connectingIds,
openedStepId,
setOpenedStepId,
blocksCoordinates,
} = useGraph()
const { detachStepFromBlock, updateStep, typebot, updateWebhook } =
useTypebot()
const [localStep, setLocalStep] = useState(step)
const [localWebhook, setLocalWebhook] = useState(
isWebhookStep(step)
? typebot?.webhooks.byId[step.options.webhookId ?? '']
: undefined
)
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [isPopoverOpened, setIsPopoverOpened] = useState(
openedStepId === step.id
)
const [mouseDownEvent, setMouseDownEvent] = const [mouseDownEvent, setMouseDownEvent] =
useState<{ absolute: Coordinates; relative: Coordinates }>() useState<{ absolute: Coordinates; relative: Coordinates }>()
const [isEditing, setIsEditing] = useState<boolean>( const [isEditing, setIsEditing] = useState<boolean>(
@ -57,6 +81,10 @@ export const StepNode = ({
onClose: onModalClose, onClose: onModalClose,
} = useDisclosure() } = useDisclosure()
useEffect(() => {
setLocalStep(step)
}, [step])
useEffect(() => { useEffect(() => {
if (query.stepId?.toString() === step.id) setOpenedStepId(step.id) if (query.stepId?.toString() === step.id) setOpenedStepId(step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -69,6 +97,11 @@ export const StepNode = ({
) )
}, [connectingIds, step.blockId, step.id]) }, [connectingIds, step.blockId, step.id])
const handleModalClose = () => {
updateStep(localStep.id, { ...localStep })
onModalClose()
}
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (connectingIds?.target) if (connectingIds?.target)
setConnectingIds({ setConnectingIds({
@ -116,7 +149,6 @@ export const StepNode = ({
onMouseDown && onMouseDown &&
(event.movementX > 0 || event.movementY > 0) (event.movementX > 0 || event.movementY > 0)
if (isMovingAndIsMouseDown && step.type !== 'start') { if (isMovingAndIsMouseDown && step.type !== 'start') {
console.log(step)
onMouseDown(mouseDownEvent, step) onMouseDown(mouseDownEvent, step)
detachStepFromBlock(step.id) detachStepFromBlock(step.id)
setMouseDownEvent(undefined) setMouseDownEvent(undefined)
@ -142,10 +174,33 @@ export const StepNode = ({
onModalOpen() onModalOpen()
} }
return isEditing && isTextBubbleStep(step) ? ( const updateOptions = () => {
<TextEditor updateStep(localStep.id, { ...localStep })
stepId={step.id} if (localWebhook) updateWebhook(localWebhook.id, { ...localWebhook })
initialValue={step.content.richText} }
const handleOptionsChange = (options: StepOptions) => {
setLocalStep({ ...localStep, options } as Step)
}
const handleContentChange = (content: BubbleStepContent) =>
setLocalStep({ ...localStep, content } as Step)
const handleWebhookChange = (updates: Partial<Webhook>) => {
if (!localWebhook) return
setLocalWebhook({ ...localWebhook, ...updates })
}
useEffect(() => {
if (isPopoverOpened && openedStepId !== step.id) updateOptions()
setIsPopoverOpened(openedStepId === step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openedStepId])
return isEditing && isTextBubbleStep(localStep) ? (
<TextBubbleEditor
stepId={localStep.id}
initialValue={localStep.content.richText}
onClose={handleCloseEditor} onClose={handleCloseEditor}
/> />
) : ( ) : (
@ -153,7 +208,12 @@ export const StepNode = ({
renderMenu={() => <StepNodeContextMenu stepId={step.id} />} renderMenu={() => <StepNodeContextMenu stepId={step.id} />}
> >
{(ref, isOpened) => ( {(ref, isOpened) => (
<Popover placement="bottom" isLazy isOpen={openedStepId === step.id}> <Popover
placement="left"
isLazy
isOpen={isPopoverOpened}
closeOnBlur={false}
>
<PopoverTrigger> <PopoverTrigger>
<Flex <Flex
pos="relative" pos="relative"
@ -180,40 +240,57 @@ export const StepNode = ({
w="full" w="full"
> >
<StepIcon <StepIcon
type={step.type} type={localStep.type}
mt="1" mt="1"
data-testid={`${step.id}-icon`} data-testid={`${localStep.id}-icon`}
/> />
<StepNodeContent step={step} /> <StepNodeContent step={localStep} />
<TargetEndpoint <TargetEndpoint
pos="absolute" pos="absolute"
left="-32px" left="-32px"
top="19px" top="19px"
stepId={step.id} stepId={localStep.id}
/> />
{isConnectable && hasDefaultConnector(step) && ( {blocksCoordinates &&
<SourceEndpoint isConnectable &&
source={{ hasDefaultConnector(localStep) && (
blockId: step.blockId, <SourceEndpoint
stepId: step.id, source={{
}} blockId: localStep.blockId,
pos="absolute" stepId: localStep.id,
right="15px" }}
bottom="18px" pos="absolute"
/> right="15px"
)} bottom="18px"
/>
)}
</HStack> </HStack>
</Flex> </Flex>
</PopoverTrigger> </PopoverTrigger>
{hasSettingsPopover(step) && ( {hasSettingsPopover(localStep) && (
<SettingsPopoverContent <SettingsPopoverContent
step={step} step={localStep}
webhook={localWebhook}
onExpandClick={handleExpandClick} onExpandClick={handleExpandClick}
onOptionsChange={handleOptionsChange}
onWebhookChange={handleWebhookChange}
onTestRequestClick={updateOptions}
/> />
)} )}
{hasContentPopover(step) && <ContentPopover step={step} />} {isMediaBubbleStep(localStep) && (
<SettingsModal isOpen={isModalOpen} onClose={onModalClose}> <MediaBubblePopoverContent
<StepSettings step={step} /> step={localStep}
onContentChange={handleContentChange}
/>
)}
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<StepSettings
step={localStep}
webhook={localWebhook}
onOptionsChange={handleOptionsChange}
onWebhookChange={handleWebhookChange}
onTestRequestClick={updateOptions}
/>
</SettingsModal> </SettingsModal>
</Popover> </Popover>
)} )}
@ -224,7 +301,7 @@ export const StepNode = ({
const hasSettingsPopover = (step: Step): step is Exclude<Step, BubbleStep> => const hasSettingsPopover = (step: Step): step is Exclude<Step, BubbleStep> =>
!isBubbleStep(step) !isBubbleStep(step)
const hasContentPopover = ( const isMediaBubbleStep = (
step: Step step: Step
): step is Exclude<BubbleStep, TextBubbleStep> => ): step is Exclude<BubbleStep, TextBubbleStep> =>
isBubbleStep(step) && !isTextBubbleStep(step) isBubbleStep(step) && !isTextBubbleStep(step)

View File

@ -0,0 +1,104 @@
import { Text } from '@chakra-ui/react'
import {
Step,
StartStep,
BubbleStepType,
InputStepType,
LogicStepType,
IntegrationStepType,
} from 'models'
import { isInputStep } from 'utils'
import { ButtonNodesList } from '../../ButtonNode'
import {
ConditionContent,
SetVariableContent,
TextBubbleContent,
VideoBubbleContent,
WebhookContent,
WithVariableContent,
} from './contents'
import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
type Props = {
step: Step | StartStep
isConnectable?: boolean
}
export const StepNodeContent = ({ step }: Props) => {
if (isInputStep(step) && step.options.variableId) {
return <WithVariableContent step={step} />
}
switch (step.type) {
case BubbleStepType.TEXT: {
return <TextBubbleContent step={step} />
}
case BubbleStepType.IMAGE: {
return <ImageBubbleContent step={step} />
}
case BubbleStepType.VIDEO: {
return <VideoBubbleContent step={step} />
}
case InputStepType.TEXT:
case InputStepType.NUMBER:
case InputStepType.EMAIL:
case InputStepType.URL:
case InputStepType.PHONE: {
return (
<PlaceholderContent placeholder={step.options.labels.placeholder} />
)
}
case InputStepType.DATE: {
return <Text color={'gray.500'}>Pick a date...</Text>
}
case InputStepType.CHOICE: {
return <ButtonNodesList step={step} />
}
case LogicStepType.SET_VARIABLE: {
return <SetVariableContent step={step} />
}
case LogicStepType.CONDITION: {
return <ConditionContent step={step} />
}
case LogicStepType.REDIRECT: {
return (
<ConfigureContent
label={
step.options?.url ? `Redirect to ${step.options?.url}` : undefined
}
/>
)
}
case IntegrationStepType.GOOGLE_SHEETS: {
return (
<ConfigureContent
label={
step.options && 'action' in step.options
? step.options.action
: undefined
}
/>
)
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
return (
<ConfigureContent
label={
step.options?.action
? `Track "${step.options?.action}" `
: undefined
}
/>
)
}
case IntegrationStepType.WEBHOOK: {
return <WebhookContent step={step} />
}
case 'start': {
return <Text>Start</Text>
}
default: {
return <Text>No input</Text>
}
}
}

View File

@ -1,9 +1,9 @@
import { Flex, Stack, HStack, Tag, Text } from '@chakra-ui/react' import { Flex, Stack, HStack, Tag, Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { ConditionStep } from 'models' import { ConditionStep } from 'models'
import { SourceEndpoint } from '../SourceEndpoint' import { SourceEndpoint } from '../../../../Endpoints/SourceEndpoint'
export const ConditionNodeContent = ({ step }: { step: ConditionStep }) => { export const ConditionContent = ({ step }: { step: ConditionStep }) => {
const { typebot } = useTypebot() const { typebot } = useTypebot()
return ( return (
<Flex> <Flex>

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'} isTruncated>
{label ?? 'Configure...'}
</Text>
)

View File

@ -0,0 +1,16 @@
import { Box, Text, Image } from '@chakra-ui/react'
import { ImageBubbleStep } from 'models'
export const ImageBubbleContent = ({ step }: { step: ImageBubbleStep }) =>
!step.content?.url ? (
<Text color={'gray.500'}>Click to edit...</Text>
) : (
<Box w="full">
<Image
src={step.content?.url}
alt="Step image"
rounded="md"
objectFit="cover"
/>
</Box>
)

View File

@ -0,0 +1,8 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
type Props = { placeholder: string }
export const PlaceholderContent = ({ placeholder }: Props) => (
<Text color={'gray.500'}>{placeholder}</Text>
)

View File

@ -2,7 +2,7 @@ import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { SetVariableStep } from 'models' import { SetVariableStep } from 'models'
export const SetVariableNodeContent = ({ step }: { step: SetVariableStep }) => { export const SetVariableContent = ({ step }: { step: SetVariableStep }) => {
const { typebot } = useTypebot() const { typebot } = useTypebot()
const variableName = const variableName =
typebot?.variables.byId[step.options?.variableId ?? '']?.name ?? '' typebot?.variables.byId[step.options?.variableId ?? '']?.name ?? ''

View File

@ -8,7 +8,7 @@ type Props = {
step: TextBubbleStep step: TextBubbleStep
} }
export const TextBubbleNodeContent = ({ step }: Props) => { export const TextBubbleContent = ({ step }: Props) => {
const { typebot } = useTypebot() const { typebot } = useTypebot()
return ( return (
<Flex <Flex

View File

@ -1,7 +1,7 @@
import { Box, Text } from '@chakra-ui/react' import { Box, Text } from '@chakra-ui/react'
import { VideoBubbleStep, VideoBubbleContentType } from 'models' import { VideoBubbleStep, VideoBubbleContentType } from 'models'
export const VideoStepNodeContent = ({ step }: { step: VideoBubbleStep }) => { export const VideoBubbleContent = ({ step }: { step: VideoBubbleStep }) => {
if (!step.content?.url || !step.content.type) if (!step.content?.url || !step.content.type)
return <Text color="gray.500">Click to edit...</Text> return <Text color="gray.500">Click to edit...</Text>
switch (step.content.type) { switch (step.content.type) {

View File

@ -7,7 +7,7 @@ type Props = {
step: InputStep step: InputStep
} }
export const StepNodeContentWithVariable = ({ step }: Props) => { export const WithVariableContent = ({ step }: Props) => {
const { typebot } = useTypebot() const { typebot } = useTypebot()
const variableName = const variableName =
typebot?.variables.byId[step.options.variableId as string].name typebot?.variables.byId[step.options.variableId as string].name

View File

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

View File

@ -1,6 +1,6 @@
import { StackProps, HStack } from '@chakra-ui/react' import { StackProps, HStack } from '@chakra-ui/react'
import { StartStep, Step } from 'models' import { StartStep, Step } from 'models'
import { StepIcon } from 'components/board/StepsSideBar/StepIcon' import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { StepNodeContent } from './StepNodeContent/StepNodeContent' import { StepNodeContent } from './StepNodeContent/StepNodeContent'
export const StepNodeOverlay = ({ export const StepNodeOverlay = ({

View File

@ -1,12 +1,13 @@
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react' import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { DraggableStep } from 'models' import { DraggableStep } from 'models'
import { useStepDnd } from 'contexts/StepDndContext' import { useStepDnd } from 'contexts/StepDndContext'
import { Coordinates } from 'contexts/GraphContext' import { Coordinates, useGraph } from 'contexts/GraphContext'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { StepNode, StepNodeOverlay } from './StepNode'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { StepNode } from './StepNode'
import { StepNodeOverlay } from './StepNodeOverlay'
export const StepsList = ({ export const StepNodesList = ({
blockId, blockId,
stepIds, stepIds,
}: { }: {
@ -22,6 +23,7 @@ export const StepsList = ({
setMouseOverBlockId, setMouseOverBlockId,
} = useStepDnd() } = useStepDnd()
const { typebot, createStep } = useTypebot() const { typebot, createStep } = useTypebot()
const { isReadOnly } = useGraph()
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState< const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined number | undefined
>() >()
@ -73,18 +75,19 @@ export const StepsList = ({
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates }, { absolute, relative }: { absolute: Coordinates; relative: Coordinates },
step: DraggableStep step: DraggableStep
) => { ) => {
if (isReadOnly) return
setPosition(absolute) setPosition(absolute)
setRelativeCoordinates(relative) setRelativeCoordinates(relative)
setMouseOverBlockId(blockId) setMouseOverBlockId(blockId)
setDraggedStep(step) setDraggedStep(step)
} }
const handleMouseOnTopOfStep = (stepIndex: number) => { const handleMouseOnTopOfStep = (stepIndex: number) => () => {
if (!draggedStep && !draggedStepType) return if (!draggedStep && !draggedStepType) return
setExpandedPlaceholderIndex(stepIndex === 0 ? 0 : stepIndex) setExpandedPlaceholderIndex(stepIndex === 0 ? 0 : stepIndex)
} }
const handleMouseOnBottomOfStep = (stepIndex: number) => { const handleMouseOnBottomOfStep = (stepIndex: number) => () => {
if (!draggedStep && !draggedStepType) return if (!draggedStep && !draggedStepType) return
setExpandedPlaceholderIndex(stepIndex + 1) setExpandedPlaceholderIndex(stepIndex + 1)
} }
@ -112,12 +115,10 @@ export const StepsList = ({
<Stack key={stepId} spacing={1}> <Stack key={stepId} spacing={1}>
<StepNode <StepNode
key={stepId} key={stepId}
step={typebot?.steps.byId[stepId]} step={typebot.steps.byId[stepId]}
isConnectable={stepIds.length - 1 === idx} isConnectable={!isReadOnly && stepIds.length - 1 === idx}
onMouseMoveTopOfElement={() => handleMouseOnTopOfStep(idx)} onMouseMoveTopOfElement={handleMouseOnTopOfStep(idx)}
onMouseMoveBottomOfElement={() => { onMouseMoveBottomOfElement={handleMouseOnBottomOfStep(idx)}
handleMouseOnBottomOfStep(idx)
}}
onMouseDown={handleStepMouseDown} onMouseDown={handleStepMouseDown}
/> />
<Flex <Flex

View File

@ -16,17 +16,13 @@ import { TextBubbleStep, Variable } from 'models'
import { VariableSearchInput } from 'components/shared/VariableSearchInput' import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ReactEditor } from 'slate-react' import { ReactEditor } from 'slate-react'
type TextEditorProps = { type Props = {
stepId: string stepId: string
initialValue: TDescendant[] initialValue: TDescendant[]
onClose: () => void onClose: () => void
} }
export const TextEditor = ({ export const TextBubbleEditor = ({ initialValue, stepId, onClose }: Props) => {
initialValue,
stepId,
onClose,
}: TextEditorProps) => {
const randomEditorId = useMemo(() => Math.random().toString(), []) const randomEditorId = useMemo(() => Math.random().toString(), [])
const editor = useMemo( const editor = useMemo(
() => () =>
@ -41,17 +37,14 @@ export const TextEditor = ({
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false) const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
const textEditorRef = useRef<HTMLDivElement>(null) const textEditorRef = useRef<HTMLDivElement>(null)
useOutsideClick({ useOutsideClick({
ref: textEditorRef, ref: textEditorRef,
handler: onClose, handler: () => {
})
useEffect(() => {
return () => {
save(value) save(value)
} onClose()
// eslint-disable-next-line react-hooks/exhaustive-deps },
}, [value]) })
useEffect(() => { useEffect(() => {
if (!isVariableDropdownOpen) return if (!isVariableDropdownOpen) return
@ -104,7 +97,6 @@ export const TextEditor = ({
const handleChangeEditorContent = (val: unknown[]) => { const handleChangeEditorContent = (val: unknown[]) => {
setValue(val) setValue(val)
save(val)
setIsVariableDropdownOpen(false) setIsVariableDropdownOpen(false)
} }
return ( return (

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { Box, Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react' import { Box, Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { TrashIcon, PlusIcon } from 'assets/icons' import { TrashIcon, PlusIcon } from 'assets/icons'
import { deepEqual } from 'fast-equals'
import { Draft } from 'immer' import { Draft } from 'immer'
import { Table } from 'models' import { Table } from 'models'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
@ -31,11 +32,7 @@ export const TableList = <T,>({
const [showDeleteId, setShowDeleteId] = useState<string | undefined>() const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
useEffect(() => { useEffect(() => {
if (items.allIds.length === 0) createItem() if (deepEqual(items, initialItems)) return
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
onItemsChange(items) onItemsChange(items)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [items]) }, [items])

View File

@ -3,7 +3,7 @@ import { Tooltip } from '@chakra-ui/tooltip'
import React from 'react' import React from 'react'
type EditableProps = { type EditableProps = {
name?: string name: string
onNewName: (newName: string) => void onNewName: (newName: string) => void
} }
export const EditableTypebotName = ({ name, onNewName }: EditableProps) => { export const EditableTypebotName = ({ name, onNewName }: EditableProps) => {

View File

@ -1,5 +1,5 @@
import { Flex, HStack, Button, IconButton } from '@chakra-ui/react' import { Flex, HStack, Button, IconButton, Tooltip } from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons' import { ChevronLeftIcon, RedoIcon, UndoIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink' import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { RightPanel, useEditor } from 'contexts/EditorContext' import { RightPanel, useEditor } from 'contexts/EditorContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
@ -13,10 +13,12 @@ export const headerHeight = 56
export const TypebotHeader = () => { export const TypebotHeader = () => {
const router = useRouter() const router = useRouter()
const { typebot, updateTypebot, save } = useTypebot() const { typebot, updateTypebot, save, undo, redo, canUndo, canRedo } =
useTypebot()
const { setRightPanel } = useEditor() const { setRightPanel } = useEditor()
const handleBackClick = () => { const handleBackClick = async () => {
await save()
router.push({ router.push({
pathname: `/typebots`, pathname: `/typebots`,
query: { ...router.query, typebotId: [] }, query: { ...router.query, typebotId: [] },
@ -85,18 +87,38 @@ export const TypebotHeader = () => {
</Button> </Button>
</HStack> </HStack>
<Flex pos="absolute" left="1rem" justify="center" align="center"> <Flex pos="absolute" left="1rem" justify="center" align="center">
<Flex alignItems="center"> <HStack alignItems="center">
<IconButton <IconButton
aria-label="Back" aria-label="Back"
icon={<ChevronLeftIcon fontSize={30} />} icon={<ChevronLeftIcon fontSize={30} />}
mr={2}
onClick={handleBackClick} onClick={handleBackClick}
/> />
<EditableTypebotName {typebot?.name && (
name={typebot?.name} <EditableTypebotName
onNewName={handleNameSubmit} name={typebot?.name}
/> onNewName={handleNameSubmit}
</Flex> />
)}
<Tooltip label="Undo">
<IconButton
icon={<UndoIcon />}
size="sm"
aria-label="Undo"
onClick={undo}
isDisabled={!canUndo}
/>
</Tooltip>
<Tooltip label="Redo">
<IconButton
icon={<RedoIcon />}
size="sm"
aria-label="Redo"
onClick={redo}
isDisabled={!canRedo}
/>
</Tooltip>
</HStack>
</Flex> </Flex>
<HStack right="40px" pos="absolute"> <HStack right="40px" pos="absolute">

View File

@ -16,7 +16,7 @@ import { Variable } from 'models'
import React, { useState, useRef, ChangeEvent, useMemo, useEffect } from 'react' import React, { useState, useRef, ChangeEvent, useMemo, useEffect } from 'react'
import { generate } from 'short-uuid' import { generate } from 'short-uuid'
import { useDebounce } from 'use-debounce' import { useDebounce } from 'use-debounce'
import { isDefined } from 'utils' import { isNotDefined } from 'utils'
type Props = { type Props = {
initialVariableId?: string initialVariableId?: string
@ -131,7 +131,7 @@ export const VariableSearchInput = ({
shadow="lg" shadow="lg"
> >
{(inputValue?.length ?? 0) > 0 && {(inputValue?.length ?? 0) > 0 &&
!isDefined(variables.find((v) => v.name === inputValue)) && ( isNotDefined(variables.find((v) => v.name === inputValue)) && (
<Button <Button
role="menuitem" role="menuitem"
minH="40px" minH="40px"

View File

@ -1,62 +0,0 @@
import { PublicTypebot } from 'models'
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useState,
} from 'react'
import { Coordinates } from './GraphContext'
import produce from 'immer'
type Position = Coordinates & { scale: number }
const graphPositionDefaultValue = { x: 400, y: 100, scale: 1 }
const graphContext = createContext<{
typebot?: PublicTypebot
updateBlockPosition: (blockId: string, newPositon: Coordinates) => void
graphPosition: Position
setGraphPosition: Dispatch<SetStateAction<Position>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({
graphPosition: graphPositionDefaultValue,
})
export const AnalyticsGraphProvider = ({
children,
initialTypebot,
}: {
children: ReactNode
initialTypebot: PublicTypebot
}) => {
const [typebot, setTypebot] = useState(initialTypebot)
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const updateBlockPosition = (blockId: string, newPosition: Coordinates) => {
if (!typebot) return
setTypebot(
produce(typebot, (nextTypebot) => {
nextTypebot.blocks.byId[blockId].graphCoordinates = newPosition
})
)
}
return (
<graphContext.Provider
value={{
typebot,
graphPosition,
setGraphPosition,
updateBlockPosition,
}}
>
{children}
</graphContext.Provider>
)
}
export const useAnalyticsGraph = () => useContext(graphContext)

View File

@ -1,4 +1,4 @@
import { Block, Source, Step, Table, Target } from 'models' import { Block, Source, Step, Table, Target, Typebot } from 'models'
import { import {
createContext, createContext,
Dispatch, Dispatch,
@ -6,8 +6,10 @@ import {
ReactNode, ReactNode,
SetStateAction, SetStateAction,
useContext, useContext,
useEffect,
useState, useState,
} from 'react' } from 'react'
import { useImmer } from 'use-immer'
export const stubLength = 20 export const stubLength = 20
export const blockWidth = 300 export const blockWidth = 300
@ -48,13 +50,17 @@ export type ConnectingIds = {
} }
type StepId = string type StepId = string
type NodeId = string type ButtonId = string
export type Endpoint = { export type Endpoint = {
id: StepId | NodeId id: StepId | ButtonId
ref: MutableRefObject<HTMLDivElement | null> ref: MutableRefObject<HTMLDivElement | null>
} }
export type BlocksCoordinates = { byId: { [key: string]: Coordinates } }
const graphContext = createContext<{ const graphContext = createContext<{
blocksCoordinates?: BlocksCoordinates
updateBlockCoordinates: (blockId: string, newCoord: Coordinates) => void
graphPosition: Position graphPosition: Position
setGraphPosition: Dispatch<SetStateAction<Position>> setGraphPosition: Dispatch<SetStateAction<Position>>
connectingIds: ConnectingIds | null connectingIds: ConnectingIds | null
@ -67,6 +73,7 @@ const graphContext = createContext<{
addTargetEndpoint: (endpoint: Endpoint) => void addTargetEndpoint: (endpoint: Endpoint) => void
openedStepId?: string openedStepId?: string
setOpenedStepId: Dispatch<SetStateAction<string | undefined>> setOpenedStepId: Dispatch<SetStateAction<string | undefined>>
isReadOnly: boolean
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
}>({ }>({
@ -74,7 +81,15 @@ const graphContext = createContext<{
connectingIds: null, connectingIds: null,
}) })
export const GraphProvider = ({ children }: { children: ReactNode }) => { export const GraphProvider = ({
children,
typebot,
isReadOnly = false,
}: {
children: ReactNode
typebot?: Typebot
isReadOnly?: boolean
}) => {
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue) const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const [connectingIds, setConnectingIds] = useState<ConnectingIds | null>(null) const [connectingIds, setConnectingIds] = useState<ConnectingIds | null>(null)
const [previewingEdgeId, setPreviewingEdgeId] = useState<string>() const [previewingEdgeId, setPreviewingEdgeId] = useState<string>()
@ -87,6 +102,24 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
allIds: [], allIds: [],
}) })
const [openedStepId, setOpenedStepId] = useState<string>() const [openedStepId, setOpenedStepId] = useState<string>()
const [blocksCoordinates, setBlocksCoordinates] = useImmer<
BlocksCoordinates | undefined
>(undefined)
useEffect(() => {
setBlocksCoordinates(
typebot?.blocks.allIds.reduce(
(coords, blockId) => ({
byId: {
...coords.byId,
[blockId]: typebot.blocks.byId[blockId].graphCoordinates,
},
}),
{ byId: {} }
)
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot?.blocks])
const addSourceEndpoint = (endpoint: Endpoint) => { const addSourceEndpoint = (endpoint: Endpoint) => {
setSourceEndpoints((endpoints) => ({ setSourceEndpoints((endpoints) => ({
@ -102,6 +135,12 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
})) }))
} }
const updateBlockCoordinates = (blockId: string, newCoord: Coordinates) =>
setBlocksCoordinates((blocksCoordinates) => {
if (!blocksCoordinates) return
blocksCoordinates.byId[blockId] = newCoord
})
return ( return (
<graphContext.Provider <graphContext.Provider
value={{ value={{
@ -117,6 +156,9 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
addTargetEndpoint, addTargetEndpoint,
openedStepId, openedStepId,
setOpenedStepId, setOpenedStepId,
blocksCoordinates,
updateBlockCoordinates,
isReadOnly,
}} }}
> >
{children} {children}

View File

@ -24,14 +24,14 @@ import { fetcher, omit, preventUserFromRefreshing } from 'services/utils'
import useSWR from 'swr' import useSWR from 'swr'
import { isDefined } from 'utils' import { isDefined } from 'utils'
import { BlocksActions, blocksActions } from './actions/blocks' import { BlocksActions, blocksActions } from './actions/blocks'
import { useImmer, Updater } from 'use-immer'
import { stepsAction, StepsActions } from './actions/steps' import { stepsAction, StepsActions } from './actions/steps'
import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems' import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems'
import { variablesAction, VariablesActions } from './actions/variables' import { variablesAction, VariablesActions } from './actions/variables'
import { edgesAction, EdgesActions } from './actions/edges' import { edgesAction, EdgesActions } from './actions/edges'
import { webhooksAction, WebhooksAction } from './actions/webhooks' import { webhooksAction, WebhooksAction } from './actions/webhooks'
import { useRegisterActions } from 'kbar'
import useUndo from 'services/utils/useUndo'
import { useDebounce } from 'use-debounce' import { useDebounce } from 'use-debounce'
const autoSaveTimeout = 40000 const autoSaveTimeout = 40000
type UpdateTypebotPayload = Partial<{ type UpdateTypebotPayload = Partial<{
@ -40,6 +40,8 @@ type UpdateTypebotPayload = Partial<{
publicId: string publicId: string
name: string name: string
}> }>
export type SetTypebot = (typebot: Typebot | undefined) => void
const typebotContext = createContext< const typebotContext = createContext<
{ {
typebot?: Typebot typebot?: Typebot
@ -50,6 +52,9 @@ const typebotContext = createContext<
isSavingLoading: boolean isSavingLoading: boolean
save: () => Promise<ToastId | undefined> save: () => Promise<ToastId | undefined>
undo: () => void undo: () => void
redo: () => void
canRedo: boolean
canUndo: boolean
updateTypebot: (updates: UpdateTypebotPayload) => void updateTypebot: (updates: UpdateTypebotPayload) => void
publishTypebot: () => void publishTypebot: () => void
} & BlocksActions & } & BlocksActions &
@ -74,7 +79,6 @@ export const TypebotContext = ({
position: 'top-right', position: 'top-right',
status: 'error', status: 'error',
}) })
const [undoStack, setUndoStack] = useState<Typebot[]>([])
const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({ const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({
typebotId, typebotId,
onError: (error) => onError: (error) =>
@ -84,20 +88,28 @@ export const TypebotContext = ({
}), }),
}) })
const [localTypebot, setLocalTypebot] = useImmer<Typebot | undefined>( const [
undefined { present: localTypebot },
) {
redo,
undo,
canRedo,
canUndo,
set: setLocalTypebot,
presentRef: currentTypebotRef,
},
] = useUndo<Typebot | undefined>(undefined)
const [debouncedLocalTypebot] = useDebounce(localTypebot, autoSaveTimeout) const saveTypebot = async () => {
useEffect(() => { const typebotToSave = currentTypebotRef.current
if (hasUnsavedChanges) saveTypebot() if (!typebotToSave) return
// eslint-disable-next-line react-hooks/exhaustive-deps setIsSavingLoading(true)
}, [debouncedLocalTypebot]) const { error } = await updateTypebot(typebotToSave.id, typebotToSave)
setIsSavingLoading(false)
const [localPublishedTypebot, setLocalPublishedTypebot] = if (error) return toast({ title: error.name, description: error.message })
useState<PublicTypebot>() mutate({ typebot: typebotToSave })
const [isSavingLoading, setIsSavingLoading] = useState(false) window.removeEventListener('beforeunload', preventUserFromRefreshing)
const [isPublishing, setIsPublishing] = useState(false) }
const hasUnsavedChanges = useMemo( const hasUnsavedChanges = useMemo(
() => () =>
@ -107,6 +119,18 @@ export const TypebotContext = ({
[typebot, localTypebot] [typebot, localTypebot]
) )
useAutoSave({
handler: saveTypebot,
item: localTypebot,
canSave: hasUnsavedChanges,
debounceTimeout: autoSaveTimeout,
})
const [localPublishedTypebot, setLocalPublishedTypebot] =
useState<PublicTypebot>()
const [isSavingLoading, setIsSavingLoading] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const isPublished = useMemo( const isPublished = useMemo(
() => () =>
isDefined(typebot) && isDefined(typebot) &&
@ -117,8 +141,8 @@ export const TypebotContext = ({
useEffect(() => { useEffect(() => {
if (!localTypebot || !typebot) return if (!localTypebot || !typebot) return
currentTypebotRef.current = localTypebot
if (!checkIfTypebotsAreEqual(localTypebot, typebot)) { if (!checkIfTypebotsAreEqual(localTypebot, typebot)) {
pushNewTypebotInUndoStack(localTypebot)
window.removeEventListener('beforeunload', preventUserFromRefreshing) window.removeEventListener('beforeunload', preventUserFromRefreshing)
window.addEventListener('beforeunload', preventUserFromRefreshing) window.addEventListener('beforeunload', preventUserFromRefreshing)
} else { } else {
@ -139,43 +163,30 @@ export const TypebotContext = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]) }, [isLoading])
const pushNewTypebotInUndoStack = (typebot: Typebot) => { useRegisterActions(
setUndoStack([...undoStack, typebot]) [
} {
id: 'save',
name: 'Save typebot',
perform: () => saveTypebot(),
},
],
[]
)
const undo = () => { useRegisterActions(
const lastTypebot = [...undoStack].pop() [
setUndoStack(undoStack.slice(0, -1)) {
setLocalTypebot(lastTypebot) id: 'undo',
} name: 'Undo changes',
perform: undo,
},
],
[localTypebot]
)
const saveTypebot = async (typebot?: Typebot) => { const updateLocalTypebot = (updates: UpdateTypebotPayload) =>
if (!localTypebot) return localTypebot && setLocalTypebot({ ...localTypebot, ...updates })
setIsSavingLoading(true)
const { error } = await updateTypebot(
typebot?.id ?? localTypebot.id,
typebot ?? localTypebot
)
setIsSavingLoading(false)
if (error) return toast({ title: error.name, description: error.message })
mutate({ typebot: typebot ?? localTypebot })
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
const updateLocalTypebot = ({
publicId,
settings,
theme,
name,
}: UpdateTypebotPayload) => {
setLocalTypebot((typebot) => {
if (!typebot) return
if (publicId) typebot.publicId = publicId
if (settings) typebot.settings = settings
if (theme) typebot.theme = theme
if (name) typebot.name = name
})
}
const publishTypebot = async () => { const publishTypebot = async () => {
if (!localTypebot) return if (!localTypebot) return
@ -188,8 +199,7 @@ export const TypebotContext = ({
updateLocalTypebot({ publicId: newPublicId }) updateLocalTypebot({ publicId: newPublicId })
newLocalTypebot.publicId = newPublicId newLocalTypebot.publicId = newPublicId
} }
if (hasUnsavedChanges || !localPublishedTypebot) if (hasUnsavedChanges || !localPublishedTypebot) await saveTypebot()
await saveTypebot(newLocalTypebot)
setIsPublishing(true) setIsPublishing(true)
if (localPublishedTypebot) { if (localPublishedTypebot) {
const { error } = await updatePublishedTypebot( const { error } = await updatePublishedTypebot(
@ -218,16 +228,19 @@ export const TypebotContext = ({
isSavingLoading, isSavingLoading,
save: saveTypebot, save: saveTypebot,
undo, undo,
redo,
canUndo,
canRedo,
publishTypebot, publishTypebot,
isPublishing, isPublishing,
isPublished, isPublished,
updateTypebot: updateLocalTypebot, updateTypebot: updateLocalTypebot,
...blocksActions(setLocalTypebot as Updater<Typebot>), ...blocksActions(localTypebot as Typebot, setLocalTypebot),
...stepsAction(setLocalTypebot as Updater<Typebot>), ...stepsAction(localTypebot as Typebot, setLocalTypebot),
...choiceItemsAction(setLocalTypebot as Updater<Typebot>), ...choiceItemsAction(localTypebot as Typebot, setLocalTypebot),
...variablesAction(setLocalTypebot as Updater<Typebot>), ...variablesAction(localTypebot as Typebot, setLocalTypebot),
...edgesAction(setLocalTypebot as Updater<Typebot>), ...edgesAction(localTypebot as Typebot, setLocalTypebot),
...webhooksAction(setLocalTypebot as Updater<Typebot>), ...webhooksAction(localTypebot as Typebot, setLocalTypebot),
}} }}
> >
{children} {children}
@ -256,3 +269,22 @@ export const useFetchedTypebot = ({
mutate, mutate,
} }
} }
const useAutoSave = <T,>({
handler,
item,
canSave,
debounceTimeout,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handler: (item?: T) => Promise<any>
item?: T
canSave: boolean
debounceTimeout: number
}) => {
const [debouncedItem] = useDebounce(item, debounceTimeout)
return useEffect(() => {
if (canSave) handler(item)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedItem])
}

View File

@ -1,14 +1,15 @@
import { Coordinates } from 'contexts/GraphContext' import { Coordinates } from 'contexts/GraphContext'
import { produce } from 'immer'
import { WritableDraft } from 'immer/dist/internal' import { WritableDraft } from 'immer/dist/internal'
import { Block, DraggableStep, DraggableStepType, Typebot } from 'models' import { Block, DraggableStep, DraggableStepType, Typebot } from 'models'
import { parseNewBlock } from 'services/typebots' import { SetTypebot } from '../TypebotContext'
import { Updater } from 'use-immer'
import { deleteEdgeDraft } from './edges' import { deleteEdgeDraft } from './edges'
import { createStepDraft, deleteStepDraft } from './steps' import { createStepDraft, deleteStepDraft } from './steps'
export type BlocksActions = { export type BlocksActions = {
createBlock: ( createBlock: (
props: Coordinates & { props: Coordinates & {
id: string
step: DraggableStep | DraggableStepType step: DraggableStep | DraggableStepType
} }
) => void ) => void
@ -16,38 +17,50 @@ export type BlocksActions = {
deleteBlock: (blockId: string) => void deleteBlock: (blockId: string) => void
} }
export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({ export const blocksActions = (
typebot: Typebot,
setTypebot: SetTypebot
): BlocksActions => ({
createBlock: ({ createBlock: ({
x, id,
y,
step, step,
...graphCoordinates
}: Coordinates & { }: Coordinates & {
id: string
step: DraggableStep | DraggableStepType step: DraggableStep | DraggableStepType
}) => { }) => {
setTypebot((typebot) => { setTypebot(
const newBlock = parseNewBlock({ produce(typebot, (typebot) => {
totalBlocks: typebot.blocks.allIds.length, const newBlock: Block = {
initialCoordinates: { x, y }, id,
graphCoordinates,
title: `Block ${typebot.blocks.allIds.length}`,
stepIds: [],
}
typebot.blocks.byId[newBlock.id] = newBlock
typebot.blocks.allIds.push(newBlock.id)
createStepDraft(typebot, step, newBlock.id)
removeEmptyBlocks(typebot)
}) })
typebot.blocks.byId[newBlock.id] = newBlock )
typebot.blocks.allIds.push(newBlock.id)
createStepDraft(typebot, step, newBlock.id)
removeEmptyBlocks(typebot)
})
}, },
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) => updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) =>
setTypebot((typebot) => { setTypebot(
typebot.blocks.byId[blockId] = { produce(typebot, (typebot) => {
...typebot.blocks.byId[blockId], typebot.blocks.byId[blockId] = {
...updates, ...typebot.blocks.byId[blockId],
} ...updates,
}), }
})
),
deleteBlock: (blockId: string) => deleteBlock: (blockId: string) =>
setTypebot((typebot) => { setTypebot(
deleteStepsInsideBlock(typebot, blockId) produce(typebot, (typebot) => {
deleteAssociatedEdges(typebot, blockId) deleteStepsInsideBlock(typebot, blockId)
deleteBlockDraft(typebot)(blockId) deleteAssociatedEdges(typebot, blockId)
}), deleteBlockDraft(typebot)(blockId)
})
),
}) })
export const removeEmptyBlocks = (typebot: WritableDraft<Typebot>) => { export const removeEmptyBlocks = (typebot: WritableDraft<Typebot>) => {
@ -72,7 +85,7 @@ const deleteStepsInsideBlock = (
blockId: string blockId: string
) => { ) => {
const block = typebot.blocks.byId[blockId] const block = typebot.blocks.byId[blockId]
block.stepIds.forEach((stepId) => deleteStepDraft(typebot, stepId)) block.stepIds.forEach((stepId) => deleteStepDraft(stepId)(typebot))
} }
export const deleteBlockDraft = export const deleteBlockDraft =

Some files were not shown because too many files have changed in this diff Show More