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:
@ -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>
|
||||
)
|
||||
|
@ -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
|
@ -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" />
|
||||
}
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { Edges } from './Edges'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { ChoiceItemsList as ChoiceInputStepNodeContent } from './ChoiceItemsList'
|
@ -1 +0,0 @@
|
||||
export { ContentPopover } from './ContentPopover'
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { TextEditor } from './TextEditor'
|
@ -1,2 +0,0 @@
|
||||
export { StepNode } from './StepNode'
|
||||
export { StepNodeOverlay } from './StepNodeOverlay'
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import {
|
||||
Stack,
|
||||
Input,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
useEventListener,
|
||||
@ -98,7 +97,6 @@ export const StepsSideBar = () => {
|
||||
spacing={6}
|
||||
userSelect="none"
|
||||
>
|
||||
<Stack>
|
||||
<Flex justifyContent="flex-end">
|
||||
<Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}>
|
||||
<IconButton
|
||||
@ -110,8 +108,6 @@ export const StepsSideBar = () => {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Input placeholder="Search..." bgColor="white" />
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
@ -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,
|
||||
|
@ -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()
|
||||
|
@ -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({
|
87
apps/builder/components/shared/Graph/Edges/Edge.tsx
Normal file
87
apps/builder/components/shared/Graph/Edges/Edge.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
@ -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}
|
2
apps/builder/components/shared/Graph/Endpoints/index.tsx
Normal file
2
apps/builder/components/shared/Graph/Endpoints/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export { SourceEndpoint } from './SourceEndpoint'
|
||||
export { TargetEndpoint } from './TargetEndpoint'
|
@ -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
|
@ -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>
|
@ -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"
|
@ -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)
|
@ -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"
|
@ -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"
|
@ -0,0 +1 @@
|
||||
export { ButtonNodesList } from './ButtonNodesList'
|
@ -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,
|
@ -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} />
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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"
|
@ -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) =>
|
@ -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>
|
@ -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,22 +240,24 @@ 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) && (
|
||||
{blocksCoordinates &&
|
||||
isConnectable &&
|
||||
hasDefaultConnector(localStep) && (
|
||||
<SourceEndpoint
|
||||
source={{
|
||||
blockId: step.blockId,
|
||||
stepId: step.id,
|
||||
blockId: localStep.blockId,
|
||||
stepId: localStep.id,
|
||||
}}
|
||||
pos="absolute"
|
||||
right="15px"
|
||||
@ -205,15 +267,30 @@ export const StepNode = ({
|
||||
</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)
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
@ -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 ?? ''
|
@ -8,7 +8,7 @@ type Props = {
|
||||
step: TextBubbleStep
|
||||
}
|
||||
|
||||
export const TextBubbleNodeContent = ({ step }: Props) => {
|
||||
export const TextBubbleContent = ({ step }: Props) => {
|
||||
const { typebot } = useTypebot()
|
||||
return (
|
||||
<Flex
|
@ -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) {
|
@ -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
|
@ -0,0 +1,6 @@
|
||||
export * from './ConditionContent'
|
||||
export * from './SetVariableContent'
|
||||
export * from './WithVariableContent'
|
||||
export * from './VideoBubbleContent'
|
||||
export * from './WebhookContent'
|
||||
export * from './TextBubbleContent'
|
@ -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 = ({
|
@ -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
|
@ -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 (
|
@ -0,0 +1 @@
|
||||
export { TextBubbleEditor } from './TextBubbleEditor'
|
@ -0,0 +1 @@
|
||||
export { StepNodesList } from './StepNodesList'
|
1
apps/builder/components/shared/Graph/index.tsx
Normal file
1
apps/builder/components/shared/Graph/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { Graph } from './Graph'
|
@ -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])
|
||||
|
@ -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) => {
|
||||
|
@ -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}
|
||||
/>
|
||||
{typebot?.name && (
|
||||
<EditableTypebotName
|
||||
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>
|
||||
|
||||
<HStack right="40px" pos="absolute">
|
||||
|
@ -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"
|
||||
|
@ -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)
|
@ -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}
|
||||
|
@ -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])
|
||||
}
|
||||
|
||||
const undo = () => {
|
||||
const lastTypebot = [...undoStack].pop()
|
||||
setUndoStack(undoStack.slice(0, -1))
|
||||
setLocalTypebot(lastTypebot)
|
||||
}
|
||||
|
||||
const saveTypebot = async (typebot?: Typebot) => {
|
||||
if (!localTypebot) return
|
||||
setIsSavingLoading(true)
|
||||
const { error } = await updateTypebot(
|
||||
typebot?.id ?? localTypebot.id,
|
||||
typebot ?? localTypebot
|
||||
useRegisterActions(
|
||||
[
|
||||
{
|
||||
id: 'save',
|
||||
name: 'Save typebot',
|
||||
perform: () => saveTypebot(),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
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
|
||||
})
|
||||
}
|
||||
useRegisterActions(
|
||||
[
|
||||
{
|
||||
id: 'undo',
|
||||
name: 'Undo changes',
|
||||
perform: undo,
|
||||
},
|
||||
],
|
||||
[localTypebot]
|
||||
)
|
||||
|
||||
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])
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
)
|
||||
},
|
||||
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) =>
|
||||
setTypebot((typebot) => {
|
||||
setTypebot(
|
||||
produce(typebot, (typebot) => {
|
||||
typebot.blocks.byId[blockId] = {
|
||||
...typebot.blocks.byId[blockId],
|
||||
...updates,
|
||||
}
|
||||
}),
|
||||
})
|
||||
),
|
||||
deleteBlock: (blockId: string) =>
|
||||
setTypebot((typebot) => {
|
||||
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
Reference in New Issue
Block a user