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>
</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 {
Stack,
Input,
Text,
SimpleGrid,
useEventListener,
@ -98,20 +97,17 @@ export const StepsSideBar = () => {
spacing={6}
userSelect="none"
>
<Stack>
<Flex justifyContent="flex-end">
<Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}>
<IconButton
icon={isLocked ? <LockedIcon /> : <UnlockedIcon />}
aria-label={isLocked ? 'Unlock' : 'Lock'}
size="sm"
variant="outline"
onClick={handleLockClick}
/>
</Tooltip>
</Flex>
<Input placeholder="Search..." bgColor="white" />
</Stack>
<Flex justifyContent="flex-end">
<Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}>
<IconButton
icon={isLocked ? <LockedIcon /> : <UnlockedIcon />}
aria-label={isLocked ? 'Unlock' : 'Lock'}
size="sm"
variant="outline"
onClick={handleLockClick}
/>
</Tooltip>
</Flex>
<Stack>
<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 { json } from '@codemirror/lang-json'
import { css } from '@codemirror/lang-css'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
type Props = {
value: string
@ -19,6 +19,7 @@ export const CodeEditor = ({
}: Props & Omit<BoxProps, 'onChange'>) => {
const editorContainer = useRef<HTMLDivElement | null>(null)
const editorView = useRef<EditorView | null>(null)
const [plainTextValue, setPlainTextValue] = useState(value)
useEffect(() => {
if (!editorView.current || !isReadOnly) return
@ -28,11 +29,17 @@ export const CodeEditor = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
useEffect(() => {
if (!onChange || plainTextValue === value) return
onChange(plainTextValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plainTextValue])
useEffect(() => {
if (!editorContainer.current) return
const updateListenerExtension = EditorView.updateListener.of((update) => {
if (update.docChanged && onChange)
onChange(update.state.doc.toJSON().join(' '))
setPlainTextValue(update.state.doc.toJSON().join(' '))
})
const extensions = [
updateListenerExtension,

View File

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

View File

@ -18,6 +18,7 @@ export const DrawingEdge = () => {
connectingIds,
sourceEndpoints,
targetEndpoints,
blocksCoordinates,
} = useGraph()
const { typebot, createEdge } = useTypebot()
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
@ -33,7 +34,7 @@ export const DrawingEdge = () => {
return getEndpointTopOffset(
graphPosition,
sourceEndpoints,
connectingIds.source.nodeId ??
connectingIds.source.buttonId ??
connectingIds.source.stepId + (connectingIds.source.conditionType ?? '')
)
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -50,22 +51,38 @@ export const DrawingEdge = () => {
}, [graphPosition, targetEndpoints, connectingIds])
const path = useMemo(() => {
if (!sourceBlock || !typebot || !connectingIds) return ``
if (
!sourceBlock ||
!typebot ||
!connectingIds ||
!blocksCoordinates ||
!sourceTop
)
return ``
return connectingIds?.target
? computeConnectingEdgePath(
connectingIds as Omit<ConnectingIds, 'target'> & { target: Target },
sourceBlock,
? computeConnectingEdgePath({
connectingIds: connectingIds as Omit<ConnectingIds, 'target'> & {
target: Target
},
sourceTop,
targetTop,
typebot
)
blocksCoordinates,
})
: computeEdgePathToMouse({
blockPosition: sourceBlock.graphCoordinates,
blockPosition: blocksCoordinates.byId[sourceBlock.id],
mousePosition,
sourceTop,
})
}, [sourceBlock, typebot, connectingIds, sourceTop, targetTop, mousePosition])
}, [
sourceBlock,
typebot,
connectingIds,
blocksCoordinates,
sourceTop,
targetTop,
mousePosition,
])
const handleMouseMove = (e: MouseEvent) => {
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 { useGraph } from 'contexts/GraphContext'
import { Source } from 'models'
import React, { MouseEvent, useEffect, useRef } from 'react'
import React, { MouseEvent, useEffect, useRef, useState } from 'react'
export const SourceEndpoint = ({
source,
@ -9,7 +9,8 @@ export const SourceEndpoint = ({
}: BoxProps & {
source: Source
}) => {
const { setConnectingIds, addSourceEndpoint: addEndpoint } = useGraph()
const [ranOnce, setRanOnce] = useState(false)
const { setConnectingIds, addSourceEndpoint, blocksCoordinates } = useGraph()
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
@ -18,15 +19,17 @@ export const SourceEndpoint = ({
}
useEffect(() => {
if (!ref.current) return
const id = source.nodeId ?? source.stepId + (source.conditionType ?? '')
addEndpoint({
if (ranOnce || !ref.current) return
const id = source.buttonId ?? source.stepId + (source.conditionType ?? '')
addSourceEndpoint({
id,
ref,
})
setRanOnce(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref])
}, [ref.current])
if (!blocksCoordinates) return <></>
return (
<Flex
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 React, { useRef, useMemo, useEffect } from 'react'
import { blockWidth, useGraph } from 'contexts/GraphContext'
import { BlockNode } from './BlockNode/BlockNode'
import { BlockNode } from './Nodes/BlockNode/BlockNode'
import { useStepDnd } from 'contexts/StepDndContext'
import { Edges } from './Edges'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
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 } =
useStepDnd()
const graphContainerRef = useRef<HTMLDivElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(null)
const { createBlock, typebot } = useTypebot()
const { graphPosition, setGraphPosition, setOpenedStepId } = useGraph()
const {
graphPosition,
setGraphPosition,
setOpenedStepId,
updateBlockCoordinates,
} = useGraph()
const transform = useMemo(
() =>
`translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`,
@ -48,9 +59,15 @@ const Graph = ({ ...props }: FlexProps) => {
const handleMouseUp = (e: MouseEvent) => {
if (!draggedStep && !draggedStepType) return
createBlock({
const coordinates = {
x: e.clientX - graphPosition.x - blockWidth / 3,
y: e.clientY - graphPosition.y - 20 - headerHeight,
}
const id = generate()
updateBlockCoordinates(id, coordinates)
createBlock({
id,
...coordinates,
step: draggedStep ?? (draggedStepType as DraggableStepType),
})
setDraggedStep(undefined)
@ -79,13 +96,17 @@ const Graph = ({ ...props }: FlexProps) => {
}}
>
<Edges />
{props.children}
{typebot.blocks.allIds.map((blockId) => (
<BlockNode block={typebot.blocks.byId[blockId]} key={blockId} />
))}
{answersCounts?.map((answersCount) => (
<DropOffNode
key={answersCount.blockId}
answersCounts={answersCounts}
blockId={answersCount.blockId}
/>
))}
</Flex>
</Flex>
)
}
export default Graph

View File

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

View File

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

View File

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

View File

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

View File

@ -4,15 +4,15 @@ import { Coordinates } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext'
import { ChoiceInputStep, ChoiceItem } from 'models'
import React, { useMemo, useState } from 'react'
import { SourceEndpoint } from '../SourceEndpoint'
import { ChoiceItemNode } from './ChoiceItemNode'
import { ChoiceItemNodeOverlay } from './ChoiceItemNodeOverlay'
import { ButtonNode } from './ButtonNode'
import { SourceEndpoint } from '../../Endpoints'
import { ButtonNodeOverlay } from './ButtonNodeOverlay'
type ChoiceItemsListProps = {
step: ChoiceInputStep
}
export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
export const ButtonNodesList = ({ step }: ChoiceItemsListProps) => {
const { typebot, createChoiceItem } = useTypebot()
const {
draggedChoiceItem,
@ -90,7 +90,7 @@ export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
{step.options.itemIds.map((itemId, idx) => (
<Stack key={itemId} spacing={1}>
{typebot?.choiceItems.byId[itemId] && (
<ChoiceItemNode
<ButtonNode
item={typebot?.choiceItems.byId[itemId]}
onMouseMoveTopOfElement={() => handleMouseOnTopOfStep(idx)}
onMouseMoveBottomOfElement={() => {
@ -138,7 +138,7 @@ export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
{draggedChoiceItem && draggedChoiceItem.stepId === step.id && (
<Portal>
<ChoiceItemNodeOverlay
<ButtonNodeOverlay
item={draggedChoiceItem}
pos="fixed"
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 { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import { useTypebot } from 'contexts/TypebotContext'
import React, { useMemo } from 'react'
import { AnswersCount } from 'services/analytics'
import { computeSourceCoordinates } from 'services/graph'
@ -10,8 +10,8 @@ type Props = {
blockId: string
}
export const DropOffBlock = ({ answersCounts, blockId }: Props) => {
const { typebot } = useAnalyticsGraph()
export const DropOffNode = ({ answersCounts, blockId }: Props) => {
const { typebot } = useTypebot()
const totalAnswers = useMemo(
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ type Props = {
step: InputStep
}
export const StepNodeContentWithVariable = ({ step }: Props) => {
export const WithVariableContent = ({ step }: Props) => {
const { typebot } = useTypebot()
const variableName =
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 { StartStep, Step } from 'models'
import { StepIcon } from 'components/board/StepsSideBar/StepIcon'
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
export const StepNodeOverlay = ({

View File

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

View File

@ -16,17 +16,13 @@ import { TextBubbleStep, Variable } from 'models'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ReactEditor } from 'slate-react'
type TextEditorProps = {
type Props = {
stepId: string
initialValue: TDescendant[]
onClose: () => void
}
export const TextEditor = ({
initialValue,
stepId,
onClose,
}: TextEditorProps) => {
export const TextBubbleEditor = ({ initialValue, stepId, onClose }: Props) => {
const randomEditorId = useMemo(() => Math.random().toString(), [])
const editor = useMemo(
() =>
@ -41,17 +37,14 @@ export const TextEditor = ({
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
const textEditorRef = useRef<HTMLDivElement>(null)
useOutsideClick({
ref: textEditorRef,
handler: onClose,
})
useEffect(() => {
return () => {
handler: () => {
save(value)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
onClose()
},
})
useEffect(() => {
if (!isVariableDropdownOpen) return
@ -104,7 +97,6 @@ export const TextEditor = ({
const handleChangeEditorContent = (val: unknown[]) => {
setValue(val)
save(val)
setIsVariableDropdownOpen(false)
}
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 { TrashIcon, PlusIcon } from 'assets/icons'
import { deepEqual } from 'fast-equals'
import { Draft } from 'immer'
import { Table } from 'models'
import React, { useEffect, useState } from 'react'
@ -31,11 +32,7 @@ export const TableList = <T,>({
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
useEffect(() => {
if (items.allIds.length === 0) createItem()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (deepEqual(items, initialItems)) return
onItemsChange(items)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items])

View File

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

View File

@ -1,5 +1,5 @@
import { Flex, HStack, Button, IconButton } from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
import { Flex, HStack, Button, IconButton, Tooltip } from '@chakra-ui/react'
import { ChevronLeftIcon, RedoIcon, UndoIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { RightPanel, useEditor } from 'contexts/EditorContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
@ -13,10 +13,12 @@ export const headerHeight = 56
export const TypebotHeader = () => {
const router = useRouter()
const { typebot, updateTypebot, save } = useTypebot()
const { typebot, updateTypebot, save, undo, redo, canUndo, canRedo } =
useTypebot()
const { setRightPanel } = useEditor()
const handleBackClick = () => {
const handleBackClick = async () => {
await save()
router.push({
pathname: `/typebots`,
query: { ...router.query, typebotId: [] },
@ -85,18 +87,38 @@ export const TypebotHeader = () => {
</Button>
</HStack>
<Flex pos="absolute" left="1rem" justify="center" align="center">
<Flex alignItems="center">
<HStack alignItems="center">
<IconButton
aria-label="Back"
icon={<ChevronLeftIcon fontSize={30} />}
mr={2}
onClick={handleBackClick}
/>
<EditableTypebotName
name={typebot?.name}
onNewName={handleNameSubmit}
/>
</Flex>
{typebot?.name && (
<EditableTypebotName
name={typebot?.name}
onNewName={handleNameSubmit}
/>
)}
<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>
<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 { generate } from 'short-uuid'
import { useDebounce } from 'use-debounce'
import { isDefined } from 'utils'
import { isNotDefined } from 'utils'
type Props = {
initialVariableId?: string
@ -131,7 +131,7 @@ export const VariableSearchInput = ({
shadow="lg"
>
{(inputValue?.length ?? 0) > 0 &&
!isDefined(variables.find((v) => v.name === inputValue)) && (
isNotDefined(variables.find((v) => v.name === inputValue)) && (
<Button
role="menuitem"
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 {
createContext,
Dispatch,
@ -6,8 +6,10 @@ import {
ReactNode,
SetStateAction,
useContext,
useEffect,
useState,
} from 'react'
import { useImmer } from 'use-immer'
export const stubLength = 20
export const blockWidth = 300
@ -48,13 +50,17 @@ export type ConnectingIds = {
}
type StepId = string
type NodeId = string
type ButtonId = string
export type Endpoint = {
id: StepId | NodeId
id: StepId | ButtonId
ref: MutableRefObject<HTMLDivElement | null>
}
export type BlocksCoordinates = { byId: { [key: string]: Coordinates } }
const graphContext = createContext<{
blocksCoordinates?: BlocksCoordinates
updateBlockCoordinates: (blockId: string, newCoord: Coordinates) => void
graphPosition: Position
setGraphPosition: Dispatch<SetStateAction<Position>>
connectingIds: ConnectingIds | null
@ -67,6 +73,7 @@ const graphContext = createContext<{
addTargetEndpoint: (endpoint: Endpoint) => void
openedStepId?: string
setOpenedStepId: Dispatch<SetStateAction<string | undefined>>
isReadOnly: boolean
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({
@ -74,7 +81,15 @@ const graphContext = createContext<{
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 [connectingIds, setConnectingIds] = useState<ConnectingIds | null>(null)
const [previewingEdgeId, setPreviewingEdgeId] = useState<string>()
@ -87,6 +102,24 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
allIds: [],
})
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) => {
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 (
<graphContext.Provider
value={{
@ -117,6 +156,9 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
addTargetEndpoint,
openedStepId,
setOpenedStepId,
blocksCoordinates,
updateBlockCoordinates,
isReadOnly,
}}
>
{children}

View File

@ -24,14 +24,14 @@ import { fetcher, omit, preventUserFromRefreshing } from 'services/utils'
import useSWR from 'swr'
import { isDefined } from 'utils'
import { BlocksActions, blocksActions } from './actions/blocks'
import { useImmer, Updater } from 'use-immer'
import { stepsAction, StepsActions } from './actions/steps'
import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems'
import { variablesAction, VariablesActions } from './actions/variables'
import { edgesAction, EdgesActions } from './actions/edges'
import { webhooksAction, WebhooksAction } from './actions/webhooks'
import { useRegisterActions } from 'kbar'
import useUndo from 'services/utils/useUndo'
import { useDebounce } from 'use-debounce'
const autoSaveTimeout = 40000
type UpdateTypebotPayload = Partial<{
@ -40,6 +40,8 @@ type UpdateTypebotPayload = Partial<{
publicId: string
name: string
}>
export type SetTypebot = (typebot: Typebot | undefined) => void
const typebotContext = createContext<
{
typebot?: Typebot
@ -50,6 +52,9 @@ const typebotContext = createContext<
isSavingLoading: boolean
save: () => Promise<ToastId | undefined>
undo: () => void
redo: () => void
canRedo: boolean
canUndo: boolean
updateTypebot: (updates: UpdateTypebotPayload) => void
publishTypebot: () => void
} & BlocksActions &
@ -74,7 +79,6 @@ export const TypebotContext = ({
position: 'top-right',
status: 'error',
})
const [undoStack, setUndoStack] = useState<Typebot[]>([])
const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({
typebotId,
onError: (error) =>
@ -84,20 +88,28 @@ export const TypebotContext = ({
}),
})
const [localTypebot, setLocalTypebot] = useImmer<Typebot | undefined>(
undefined
)
const [
{ present: localTypebot },
{
redo,
undo,
canRedo,
canUndo,
set: setLocalTypebot,
presentRef: currentTypebotRef,
},
] = useUndo<Typebot | undefined>(undefined)
const [debouncedLocalTypebot] = useDebounce(localTypebot, autoSaveTimeout)
useEffect(() => {
if (hasUnsavedChanges) saveTypebot()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedLocalTypebot])
const [localPublishedTypebot, setLocalPublishedTypebot] =
useState<PublicTypebot>()
const [isSavingLoading, setIsSavingLoading] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const saveTypebot = async () => {
const typebotToSave = currentTypebotRef.current
if (!typebotToSave) return
setIsSavingLoading(true)
const { error } = await updateTypebot(typebotToSave.id, typebotToSave)
setIsSavingLoading(false)
if (error) return toast({ title: error.name, description: error.message })
mutate({ typebot: typebotToSave })
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
const hasUnsavedChanges = useMemo(
() =>
@ -107,6 +119,18 @@ export const TypebotContext = ({
[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(
() =>
isDefined(typebot) &&
@ -117,8 +141,8 @@ export const TypebotContext = ({
useEffect(() => {
if (!localTypebot || !typebot) return
currentTypebotRef.current = localTypebot
if (!checkIfTypebotsAreEqual(localTypebot, typebot)) {
pushNewTypebotInUndoStack(localTypebot)
window.removeEventListener('beforeunload', preventUserFromRefreshing)
window.addEventListener('beforeunload', preventUserFromRefreshing)
} else {
@ -139,43 +163,30 @@ export const TypebotContext = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading])
const pushNewTypebotInUndoStack = (typebot: Typebot) => {
setUndoStack([...undoStack, typebot])
}
useRegisterActions(
[
{
id: 'save',
name: 'Save typebot',
perform: () => saveTypebot(),
},
],
[]
)
const undo = () => {
const lastTypebot = [...undoStack].pop()
setUndoStack(undoStack.slice(0, -1))
setLocalTypebot(lastTypebot)
}
useRegisterActions(
[
{
id: 'undo',
name: 'Undo changes',
perform: undo,
},
],
[localTypebot]
)
const saveTypebot = async (typebot?: Typebot) => {
if (!localTypebot) return
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 updateLocalTypebot = (updates: UpdateTypebotPayload) =>
localTypebot && setLocalTypebot({ ...localTypebot, ...updates })
const publishTypebot = async () => {
if (!localTypebot) return
@ -188,8 +199,7 @@ export const TypebotContext = ({
updateLocalTypebot({ publicId: newPublicId })
newLocalTypebot.publicId = newPublicId
}
if (hasUnsavedChanges || !localPublishedTypebot)
await saveTypebot(newLocalTypebot)
if (hasUnsavedChanges || !localPublishedTypebot) await saveTypebot()
setIsPublishing(true)
if (localPublishedTypebot) {
const { error } = await updatePublishedTypebot(
@ -218,16 +228,19 @@ export const TypebotContext = ({
isSavingLoading,
save: saveTypebot,
undo,
redo,
canUndo,
canRedo,
publishTypebot,
isPublishing,
isPublished,
updateTypebot: updateLocalTypebot,
...blocksActions(setLocalTypebot as Updater<Typebot>),
...stepsAction(setLocalTypebot as Updater<Typebot>),
...choiceItemsAction(setLocalTypebot as Updater<Typebot>),
...variablesAction(setLocalTypebot as Updater<Typebot>),
...edgesAction(setLocalTypebot as Updater<Typebot>),
...webhooksAction(setLocalTypebot as Updater<Typebot>),
...blocksActions(localTypebot as Typebot, setLocalTypebot),
...stepsAction(localTypebot as Typebot, setLocalTypebot),
...choiceItemsAction(localTypebot as Typebot, setLocalTypebot),
...variablesAction(localTypebot as Typebot, setLocalTypebot),
...edgesAction(localTypebot as Typebot, setLocalTypebot),
...webhooksAction(localTypebot as Typebot, setLocalTypebot),
}}
>
{children}
@ -256,3 +269,22 @@ export const useFetchedTypebot = ({
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 { produce } from 'immer'
import { WritableDraft } from 'immer/dist/internal'
import { Block, DraggableStep, DraggableStepType, Typebot } from 'models'
import { parseNewBlock } from 'services/typebots'
import { Updater } from 'use-immer'
import { SetTypebot } from '../TypebotContext'
import { deleteEdgeDraft } from './edges'
import { createStepDraft, deleteStepDraft } from './steps'
export type BlocksActions = {
createBlock: (
props: Coordinates & {
id: string
step: DraggableStep | DraggableStepType
}
) => void
@ -16,38 +17,50 @@ export type BlocksActions = {
deleteBlock: (blockId: string) => void
}
export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({
export const blocksActions = (
typebot: Typebot,
setTypebot: SetTypebot
): BlocksActions => ({
createBlock: ({
x,
y,
id,
step,
...graphCoordinates
}: Coordinates & {
id: string
step: DraggableStep | DraggableStepType
}) => {
setTypebot((typebot) => {
const newBlock = parseNewBlock({
totalBlocks: typebot.blocks.allIds.length,
initialCoordinates: { x, y },
setTypebot(
produce(typebot, (typebot) => {
const newBlock: Block = {
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'>>) =>
setTypebot((typebot) => {
typebot.blocks.byId[blockId] = {
...typebot.blocks.byId[blockId],
...updates,
}
}),
setTypebot(
produce(typebot, (typebot) => {
typebot.blocks.byId[blockId] = {
...typebot.blocks.byId[blockId],
...updates,
}
})
),
deleteBlock: (blockId: string) =>
setTypebot((typebot) => {
deleteStepsInsideBlock(typebot, blockId)
deleteAssociatedEdges(typebot, blockId)
deleteBlockDraft(typebot)(blockId)
}),
setTypebot(
produce(typebot, (typebot) => {
deleteStepsInsideBlock(typebot, blockId)
deleteAssociatedEdges(typebot, blockId)
deleteBlockDraft(typebot)(blockId)
})
),
})
export const removeEmptyBlocks = (typebot: WritableDraft<Typebot>) => {
@ -72,7 +85,7 @@ const deleteStepsInsideBlock = (
blockId: string
) => {
const block = typebot.blocks.byId[blockId]
block.stepIds.forEach((stepId) => deleteStepDraft(typebot, stepId))
block.stepIds.forEach((stepId) => deleteStepDraft(stepId)(typebot))
}
export const deleteBlockDraft =

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