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>
|
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
|
||||||
</Icon>
|
</Icon>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const UndoIcon = (props: IconProps) => (
|
||||||
|
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||||
|
<path d="M3 7v6h6"></path>
|
||||||
|
<path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path>
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const RedoIcon = (props: IconProps) => (
|
||||||
|
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||||
|
<path d="M21 7v6h-6"></path>
|
||||||
|
<path d="M3 17a9 9 0 019-9 9 9 0 016 2.3l3 2.7"></path>
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
@ -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 {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Input,
|
|
||||||
Text,
|
Text,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
useEventListener,
|
useEventListener,
|
||||||
@ -98,20 +97,17 @@ export const StepsSideBar = () => {
|
|||||||
spacing={6}
|
spacing={6}
|
||||||
userSelect="none"
|
userSelect="none"
|
||||||
>
|
>
|
||||||
<Stack>
|
<Flex justifyContent="flex-end">
|
||||||
<Flex justifyContent="flex-end">
|
<Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}>
|
||||||
<Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}>
|
<IconButton
|
||||||
<IconButton
|
icon={isLocked ? <LockedIcon /> : <UnlockedIcon />}
|
||||||
icon={isLocked ? <LockedIcon /> : <UnlockedIcon />}
|
aria-label={isLocked ? 'Unlock' : 'Lock'}
|
||||||
aria-label={isLocked ? 'Unlock' : 'Lock'}
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
variant="outline"
|
onClick={handleLockClick}
|
||||||
onClick={handleLockClick}
|
/>
|
||||||
/>
|
</Tooltip>
|
||||||
</Tooltip>
|
</Flex>
|
||||||
</Flex>
|
|
||||||
<Input placeholder="Search..." bgColor="white" />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
@ -2,7 +2,7 @@ import { Box, BoxProps } from '@chakra-ui/react'
|
|||||||
import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'
|
import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'
|
||||||
import { json } from '@codemirror/lang-json'
|
import { json } from '@codemirror/lang-json'
|
||||||
import { css } from '@codemirror/lang-css'
|
import { css } from '@codemirror/lang-css'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string
|
value: string
|
||||||
@ -19,6 +19,7 @@ export const CodeEditor = ({
|
|||||||
}: Props & Omit<BoxProps, 'onChange'>) => {
|
}: Props & Omit<BoxProps, 'onChange'>) => {
|
||||||
const editorContainer = useRef<HTMLDivElement | null>(null)
|
const editorContainer = useRef<HTMLDivElement | null>(null)
|
||||||
const editorView = useRef<EditorView | null>(null)
|
const editorView = useRef<EditorView | null>(null)
|
||||||
|
const [plainTextValue, setPlainTextValue] = useState(value)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorView.current || !isReadOnly) return
|
if (!editorView.current || !isReadOnly) return
|
||||||
@ -28,11 +29,17 @@ export const CodeEditor = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onChange || plainTextValue === value) return
|
||||||
|
onChange(plainTextValue)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [plainTextValue])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorContainer.current) return
|
if (!editorContainer.current) return
|
||||||
const updateListenerExtension = EditorView.updateListener.of((update) => {
|
const updateListenerExtension = EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged && onChange)
|
if (update.docChanged && onChange)
|
||||||
onChange(update.state.doc.toJSON().join(' '))
|
setPlainTextValue(update.state.doc.toJSON().join(' '))
|
||||||
})
|
})
|
||||||
const extensions = [
|
const extensions = [
|
||||||
updateListenerExtension,
|
updateListenerExtension,
|
||||||
|
@ -25,6 +25,7 @@ export interface ContextMenuProps<T extends HTMLElement> {
|
|||||||
menuProps?: MenuProps
|
menuProps?: MenuProps
|
||||||
portalProps?: PortalProps
|
portalProps?: PortalProps
|
||||||
menuButtonProps?: MenuButtonProps
|
menuButtonProps?: MenuButtonProps
|
||||||
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextMenu<T extends HTMLElement = HTMLElement>(
|
export function ContextMenu<T extends HTMLElement = HTMLElement>(
|
||||||
@ -56,6 +57,7 @@ export function ContextMenu<T extends HTMLElement = HTMLElement>(
|
|||||||
useEventListener(
|
useEventListener(
|
||||||
'contextmenu',
|
'contextmenu',
|
||||||
(e) => {
|
(e) => {
|
||||||
|
if (props.isDisabled) return
|
||||||
if (e.currentTarget === targetRef.current) {
|
if (e.currentTarget === targetRef.current) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
@ -18,6 +18,7 @@ export const DrawingEdge = () => {
|
|||||||
connectingIds,
|
connectingIds,
|
||||||
sourceEndpoints,
|
sourceEndpoints,
|
||||||
targetEndpoints,
|
targetEndpoints,
|
||||||
|
blocksCoordinates,
|
||||||
} = useGraph()
|
} = useGraph()
|
||||||
const { typebot, createEdge } = useTypebot()
|
const { typebot, createEdge } = useTypebot()
|
||||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
|
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
|
||||||
@ -33,7 +34,7 @@ export const DrawingEdge = () => {
|
|||||||
return getEndpointTopOffset(
|
return getEndpointTopOffset(
|
||||||
graphPosition,
|
graphPosition,
|
||||||
sourceEndpoints,
|
sourceEndpoints,
|
||||||
connectingIds.source.nodeId ??
|
connectingIds.source.buttonId ??
|
||||||
connectingIds.source.stepId + (connectingIds.source.conditionType ?? '')
|
connectingIds.source.stepId + (connectingIds.source.conditionType ?? '')
|
||||||
)
|
)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -50,22 +51,38 @@ export const DrawingEdge = () => {
|
|||||||
}, [graphPosition, targetEndpoints, connectingIds])
|
}, [graphPosition, targetEndpoints, connectingIds])
|
||||||
|
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
if (!sourceBlock || !typebot || !connectingIds) return ``
|
if (
|
||||||
|
!sourceBlock ||
|
||||||
|
!typebot ||
|
||||||
|
!connectingIds ||
|
||||||
|
!blocksCoordinates ||
|
||||||
|
!sourceTop
|
||||||
|
)
|
||||||
|
return ``
|
||||||
|
|
||||||
return connectingIds?.target
|
return connectingIds?.target
|
||||||
? computeConnectingEdgePath(
|
? computeConnectingEdgePath({
|
||||||
connectingIds as Omit<ConnectingIds, 'target'> & { target: Target },
|
connectingIds: connectingIds as Omit<ConnectingIds, 'target'> & {
|
||||||
sourceBlock,
|
target: Target
|
||||||
|
},
|
||||||
sourceTop,
|
sourceTop,
|
||||||
targetTop,
|
targetTop,
|
||||||
typebot
|
blocksCoordinates,
|
||||||
)
|
})
|
||||||
: computeEdgePathToMouse({
|
: computeEdgePathToMouse({
|
||||||
blockPosition: sourceBlock.graphCoordinates,
|
blockPosition: blocksCoordinates.byId[sourceBlock.id],
|
||||||
mousePosition,
|
mousePosition,
|
||||||
sourceTop,
|
sourceTop,
|
||||||
})
|
})
|
||||||
}, [sourceBlock, typebot, connectingIds, sourceTop, targetTop, mousePosition])
|
}, [
|
||||||
|
sourceBlock,
|
||||||
|
typebot,
|
||||||
|
connectingIds,
|
||||||
|
blocksCoordinates,
|
||||||
|
sourceTop,
|
||||||
|
targetTop,
|
||||||
|
mousePosition,
|
||||||
|
])
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
setMousePosition({
|
setMousePosition({
|
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 { Box, BoxProps, Flex } from '@chakra-ui/react'
|
||||||
import { useGraph } from 'contexts/GraphContext'
|
import { useGraph } from 'contexts/GraphContext'
|
||||||
import { Source } from 'models'
|
import { Source } from 'models'
|
||||||
import React, { MouseEvent, useEffect, useRef } from 'react'
|
import React, { MouseEvent, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
export const SourceEndpoint = ({
|
export const SourceEndpoint = ({
|
||||||
source,
|
source,
|
||||||
@ -9,7 +9,8 @@ export const SourceEndpoint = ({
|
|||||||
}: BoxProps & {
|
}: BoxProps & {
|
||||||
source: Source
|
source: Source
|
||||||
}) => {
|
}) => {
|
||||||
const { setConnectingIds, addSourceEndpoint: addEndpoint } = useGraph()
|
const [ranOnce, setRanOnce] = useState(false)
|
||||||
|
const { setConnectingIds, addSourceEndpoint, blocksCoordinates } = useGraph()
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
|
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
@ -18,15 +19,17 @@ export const SourceEndpoint = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return
|
if (ranOnce || !ref.current) return
|
||||||
const id = source.nodeId ?? source.stepId + (source.conditionType ?? '')
|
const id = source.buttonId ?? source.stepId + (source.conditionType ?? '')
|
||||||
addEndpoint({
|
addSourceEndpoint({
|
||||||
id,
|
id,
|
||||||
ref,
|
ref,
|
||||||
})
|
})
|
||||||
|
setRanOnce(true)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ref])
|
}, [ref.current])
|
||||||
|
|
||||||
|
if (!blocksCoordinates) return <></>
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
ref={ref}
|
ref={ref}
|
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 { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
|
||||||
import React, { useRef, useMemo, useEffect } from 'react'
|
import React, { useRef, useMemo, useEffect } from 'react'
|
||||||
import { blockWidth, useGraph } from 'contexts/GraphContext'
|
import { blockWidth, useGraph } from 'contexts/GraphContext'
|
||||||
import { BlockNode } from './BlockNode/BlockNode'
|
import { BlockNode } from './Nodes/BlockNode/BlockNode'
|
||||||
import { useStepDnd } from 'contexts/StepDndContext'
|
import { useStepDnd } from 'contexts/StepDndContext'
|
||||||
import { Edges } from './Edges'
|
import { Edges } from './Edges'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||||
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
|
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
|
||||||
import { DraggableStepType } from 'models'
|
import { DraggableStepType } from 'models'
|
||||||
|
import { generate } from 'short-uuid'
|
||||||
|
import { AnswersCount } from 'services/analytics'
|
||||||
|
import { DropOffNode } from './Nodes/DropOffNode'
|
||||||
|
|
||||||
const Graph = ({ ...props }: FlexProps) => {
|
export const Graph = ({
|
||||||
|
answersCounts,
|
||||||
|
...props
|
||||||
|
}: { answersCounts?: AnswersCount[] } & FlexProps) => {
|
||||||
const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } =
|
const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } =
|
||||||
useStepDnd()
|
useStepDnd()
|
||||||
const graphContainerRef = useRef<HTMLDivElement | null>(null)
|
const graphContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const editorContainerRef = useRef<HTMLDivElement | null>(null)
|
const editorContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const { createBlock, typebot } = useTypebot()
|
const { createBlock, typebot } = useTypebot()
|
||||||
const { graphPosition, setGraphPosition, setOpenedStepId } = useGraph()
|
const {
|
||||||
|
graphPosition,
|
||||||
|
setGraphPosition,
|
||||||
|
setOpenedStepId,
|
||||||
|
updateBlockCoordinates,
|
||||||
|
} = useGraph()
|
||||||
const transform = useMemo(
|
const transform = useMemo(
|
||||||
() =>
|
() =>
|
||||||
`translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`,
|
`translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`,
|
||||||
@ -48,9 +59,15 @@ const Graph = ({ ...props }: FlexProps) => {
|
|||||||
|
|
||||||
const handleMouseUp = (e: MouseEvent) => {
|
const handleMouseUp = (e: MouseEvent) => {
|
||||||
if (!draggedStep && !draggedStepType) return
|
if (!draggedStep && !draggedStepType) return
|
||||||
createBlock({
|
const coordinates = {
|
||||||
x: e.clientX - graphPosition.x - blockWidth / 3,
|
x: e.clientX - graphPosition.x - blockWidth / 3,
|
||||||
y: e.clientY - graphPosition.y - 20 - headerHeight,
|
y: e.clientY - graphPosition.y - 20 - headerHeight,
|
||||||
|
}
|
||||||
|
const id = generate()
|
||||||
|
updateBlockCoordinates(id, coordinates)
|
||||||
|
createBlock({
|
||||||
|
id,
|
||||||
|
...coordinates,
|
||||||
step: draggedStep ?? (draggedStepType as DraggableStepType),
|
step: draggedStep ?? (draggedStepType as DraggableStepType),
|
||||||
})
|
})
|
||||||
setDraggedStep(undefined)
|
setDraggedStep(undefined)
|
||||||
@ -79,13 +96,17 @@ const Graph = ({ ...props }: FlexProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edges />
|
<Edges />
|
||||||
{props.children}
|
|
||||||
{typebot.blocks.allIds.map((blockId) => (
|
{typebot.blocks.allIds.map((blockId) => (
|
||||||
<BlockNode block={typebot.blocks.byId[blockId]} key={blockId} />
|
<BlockNode block={typebot.blocks.byId[blockId]} key={blockId} />
|
||||||
))}
|
))}
|
||||||
|
{answersCounts?.map((answersCount) => (
|
||||||
|
<DropOffNode
|
||||||
|
key={answersCount.blockId}
|
||||||
|
answersCounts={answersCounts}
|
||||||
|
blockId={answersCount.blockId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Graph
|
|
@ -9,18 +9,26 @@ import React, { useEffect, useMemo, useState } from 'react'
|
|||||||
import { Block } from 'models'
|
import { Block } from 'models'
|
||||||
import { useGraph } from 'contexts/GraphContext'
|
import { useGraph } from 'contexts/GraphContext'
|
||||||
import { useStepDnd } from 'contexts/StepDndContext'
|
import { useStepDnd } from 'contexts/StepDndContext'
|
||||||
import { StepsList } from './StepsList'
|
import { StepNodesList } from '../StepNode/StepNodesList'
|
||||||
import { isDefined } from 'utils'
|
import { isNotDefined } from 'utils'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||||
import { ContextMenu } from 'components/shared/ContextMenu'
|
import { ContextMenu } from 'components/shared/ContextMenu'
|
||||||
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
|
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
block: Block
|
block: Block
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BlockNode = ({ block }: Props) => {
|
export const BlockNode = ({ block }: Props) => {
|
||||||
const { connectingIds, setConnectingIds, previewingEdgeId } = useGraph()
|
const {
|
||||||
|
connectingIds,
|
||||||
|
setConnectingIds,
|
||||||
|
previewingEdgeId,
|
||||||
|
blocksCoordinates,
|
||||||
|
updateBlockCoordinates,
|
||||||
|
isReadOnly,
|
||||||
|
} = useGraph()
|
||||||
const { typebot, updateBlock } = useTypebot()
|
const { typebot, updateBlock } = useTypebot()
|
||||||
const { setMouseOverBlockId } = useStepDnd()
|
const { setMouseOverBlockId } = useStepDnd()
|
||||||
const { draggedStep, draggedStepType } = useStepDnd()
|
const { draggedStep, draggedStepType } = useStepDnd()
|
||||||
@ -32,10 +40,26 @@ export const BlockNode = ({ block }: Props) => {
|
|||||||
return edge?.to.blockId === block.id || edge?.from.blockId === block.id
|
return edge?.to.blockId === block.id || edge?.from.blockId === block.id
|
||||||
}, [block.id, previewingEdgeId, typebot?.edges.byId])
|
}, [block.id, previewingEdgeId, typebot?.edges.byId])
|
||||||
|
|
||||||
|
const blockCoordinates = useMemo(
|
||||||
|
() => blocksCoordinates?.byId[block.id],
|
||||||
|
[block.id, blocksCoordinates?.byId]
|
||||||
|
)
|
||||||
|
const [debouncedBlockPosition] = useDebounce(blockCoordinates, 100)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debouncedBlockPosition || isReadOnly) return
|
||||||
|
if (
|
||||||
|
debouncedBlockPosition?.x === block.graphCoordinates.x &&
|
||||||
|
debouncedBlockPosition.y === block.graphCoordinates.y
|
||||||
|
)
|
||||||
|
return
|
||||||
|
updateBlock(block.id, { graphCoordinates: debouncedBlockPosition })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [debouncedBlockPosition])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsConnecting(
|
setIsConnecting(
|
||||||
connectingIds?.target?.blockId === block.id &&
|
connectingIds?.target?.blockId === block.id &&
|
||||||
!isDefined(connectingIds.target?.stepId)
|
isNotDefined(connectingIds.target?.stepId)
|
||||||
)
|
)
|
||||||
}, [block.id, connectingIds])
|
}, [block.id, connectingIds])
|
||||||
|
|
||||||
@ -53,11 +77,10 @@ export const BlockNode = ({ block }: Props) => {
|
|||||||
if (!isMouseDown) return
|
if (!isMouseDown) return
|
||||||
const { movementX, movementY } = event
|
const { movementX, movementY } = event
|
||||||
|
|
||||||
updateBlock(block.id, {
|
if (!blockCoordinates) return
|
||||||
graphCoordinates: {
|
updateBlockCoordinates(block.id, {
|
||||||
x: block.graphCoordinates.x + movementX,
|
x: blockCoordinates.x + movementX,
|
||||||
y: block.graphCoordinates.y + movementY,
|
y: blockCoordinates.y + movementY,
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +100,7 @@ export const BlockNode = ({ block }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<ContextMenu<HTMLDivElement>
|
<ContextMenu<HTMLDivElement>
|
||||||
renderMenu={() => <BlockNodeContextMenu blockId={block.id} />}
|
renderMenu={() => <BlockNodeContextMenu blockId={block.id} />}
|
||||||
|
isDisabled={isReadOnly}
|
||||||
>
|
>
|
||||||
{(ref, isOpened) => (
|
{(ref, isOpened) => (
|
||||||
<Stack
|
<Stack
|
||||||
@ -93,7 +117,9 @@ export const BlockNode = ({ block }: Props) => {
|
|||||||
transition="border 300ms, box-shadow 200ms"
|
transition="border 300ms, box-shadow 200ms"
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${block.graphCoordinates.x}px, ${block.graphCoordinates.y}px)`,
|
transform: `translate(${blockCoordinates?.x ?? 0}px, ${
|
||||||
|
blockCoordinates?.y ?? 0
|
||||||
|
}px)`,
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
@ -106,6 +132,7 @@ export const BlockNode = ({ block }: Props) => {
|
|||||||
defaultValue={block.title}
|
defaultValue={block.title}
|
||||||
onSubmit={handleTitleSubmit}
|
onSubmit={handleTitleSubmit}
|
||||||
fontWeight="semibold"
|
fontWeight="semibold"
|
||||||
|
isDisabled={isReadOnly}
|
||||||
>
|
>
|
||||||
<EditablePreview
|
<EditablePreview
|
||||||
_hover={{ bgColor: 'gray.300' }}
|
_hover={{ bgColor: 'gray.300' }}
|
||||||
@ -114,7 +141,9 @@ export const BlockNode = ({ block }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<EditableInput minW="0" px="1" />
|
<EditableInput minW="0" px="1" />
|
||||||
</Editable>
|
</Editable>
|
||||||
{typebot && <StepsList blockId={block.id} stepIds={block.stepIds} />}
|
{typebot && (
|
||||||
|
<StepNodesList blockId={block.id} stepIds={block.stepIds} />
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
@ -13,11 +13,11 @@ import { Coordinates } from 'contexts/GraphContext'
|
|||||||
import { useTypebot } from 'contexts/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
import { ChoiceInputStep, ChoiceItem } from 'models'
|
import { ChoiceInputStep, ChoiceItem } from 'models'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { isDefined, isSingleChoiceInput } from 'utils'
|
import { isNotDefined, isSingleChoiceInput } from 'utils'
|
||||||
import { SourceEndpoint } from '../SourceEndpoint'
|
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
|
||||||
import { ChoiceItemNodeContextMenu } from './ChoiceItemNodeContextMenu'
|
import { ButtonNodeContextMenu } from './ButtonNodeContextMenu'
|
||||||
|
|
||||||
type ChoiceItemNodeProps = {
|
type Props = {
|
||||||
item: ChoiceItem
|
item: ChoiceItem
|
||||||
onMouseMoveBottomOfElement?: () => void
|
onMouseMoveBottomOfElement?: () => void
|
||||||
onMouseMoveTopOfElement?: () => void
|
onMouseMoveTopOfElement?: () => void
|
||||||
@ -27,12 +27,12 @@ type ChoiceItemNodeProps = {
|
|||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChoiceItemNode = ({
|
export const ButtonNode = ({
|
||||||
item,
|
item,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
onMouseMoveBottomOfElement,
|
onMouseMoveBottomOfElement,
|
||||||
onMouseMoveTopOfElement,
|
onMouseMoveTopOfElement,
|
||||||
}: ChoiceItemNodeProps) => {
|
}: Props) => {
|
||||||
const { deleteChoiceItem, updateChoiceItem, typebot, createChoiceItem } =
|
const { deleteChoiceItem, updateChoiceItem, typebot, createChoiceItem } =
|
||||||
useTypebot()
|
useTypebot()
|
||||||
const [mouseDownEvent, setMouseDownEvent] =
|
const [mouseDownEvent, setMouseDownEvent] =
|
||||||
@ -88,9 +88,10 @@ export const ChoiceItemNode = ({
|
|||||||
|
|
||||||
const handleMouseEnter = () => setIsMouseOver(true)
|
const handleMouseEnter = () => setIsMouseOver(true)
|
||||||
const handleMouseLeave = () => setIsMouseOver(false)
|
const handleMouseLeave = () => setIsMouseOver(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu<HTMLDivElement>
|
<ContextMenu<HTMLDivElement>
|
||||||
renderMenu={() => <ChoiceItemNodeContextMenu itemId={item.id} />}
|
renderMenu={() => <ButtonNodeContextMenu itemId={item.id} />}
|
||||||
>
|
>
|
||||||
{(ref, isOpened) => (
|
{(ref, isOpened) => (
|
||||||
<Flex
|
<Flex
|
||||||
@ -112,7 +113,7 @@ export const ChoiceItemNode = ({
|
|||||||
<Editable
|
<Editable
|
||||||
defaultValue={item.content ?? 'Click to edit'}
|
defaultValue={item.content ?? 'Click to edit'}
|
||||||
flex="1"
|
flex="1"
|
||||||
startWithEditView={!isDefined(item.content)}
|
startWithEditView={isNotDefined(item.content)}
|
||||||
onSubmit={handleInputSubmit}
|
onSubmit={handleInputSubmit}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
@ -128,7 +129,7 @@ export const ChoiceItemNode = ({
|
|||||||
source={{
|
source={{
|
||||||
blockId: typebot.steps.byId[item.stepId].blockId,
|
blockId: typebot.steps.byId[item.stepId].blockId,
|
||||||
stepId: item.stepId,
|
stepId: item.stepId,
|
||||||
nodeId: item.id,
|
buttonId: item.id,
|
||||||
}}
|
}}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
right="15px"
|
right="15px"
|
@ -2,7 +2,7 @@ import { MenuList, MenuItem } from '@chakra-ui/react'
|
|||||||
import { TrashIcon } from 'assets/icons'
|
import { TrashIcon } from 'assets/icons'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||||
|
|
||||||
export const ChoiceItemNodeContextMenu = ({ itemId }: { itemId: string }) => {
|
export const ButtonNodeContextMenu = ({ itemId }: { itemId: string }) => {
|
||||||
const { deleteChoiceItem } = useTypebot()
|
const { deleteChoiceItem } = useTypebot()
|
||||||
|
|
||||||
const handleDeleteClick = () => deleteChoiceItem(itemId)
|
const handleDeleteClick = () => deleteChoiceItem(itemId)
|
@ -2,14 +2,11 @@ import { Flex, FlexProps } from '@chakra-ui/react'
|
|||||||
import { ChoiceItem } from 'models'
|
import { ChoiceItem } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type ChoiceItemNodeOverlayProps = {
|
type Props = {
|
||||||
item: ChoiceItem
|
item: ChoiceItem
|
||||||
} & FlexProps
|
} & FlexProps
|
||||||
|
|
||||||
export const ChoiceItemNodeOverlay = ({
|
export const ButtonNodeOverlay = ({ item, ...props }: Props) => {
|
||||||
item,
|
|
||||||
...props
|
|
||||||
}: ChoiceItemNodeOverlayProps) => {
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
px="4"
|
px="4"
|
@ -4,15 +4,15 @@ import { Coordinates } from 'contexts/GraphContext'
|
|||||||
import { useTypebot } from 'contexts/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
import { ChoiceInputStep, ChoiceItem } from 'models'
|
import { ChoiceInputStep, ChoiceItem } from 'models'
|
||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { SourceEndpoint } from '../SourceEndpoint'
|
import { ButtonNode } from './ButtonNode'
|
||||||
import { ChoiceItemNode } from './ChoiceItemNode'
|
import { SourceEndpoint } from '../../Endpoints'
|
||||||
import { ChoiceItemNodeOverlay } from './ChoiceItemNodeOverlay'
|
import { ButtonNodeOverlay } from './ButtonNodeOverlay'
|
||||||
|
|
||||||
type ChoiceItemsListProps = {
|
type ChoiceItemsListProps = {
|
||||||
step: ChoiceInputStep
|
step: ChoiceInputStep
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
|
export const ButtonNodesList = ({ step }: ChoiceItemsListProps) => {
|
||||||
const { typebot, createChoiceItem } = useTypebot()
|
const { typebot, createChoiceItem } = useTypebot()
|
||||||
const {
|
const {
|
||||||
draggedChoiceItem,
|
draggedChoiceItem,
|
||||||
@ -90,7 +90,7 @@ export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
|
|||||||
{step.options.itemIds.map((itemId, idx) => (
|
{step.options.itemIds.map((itemId, idx) => (
|
||||||
<Stack key={itemId} spacing={1}>
|
<Stack key={itemId} spacing={1}>
|
||||||
{typebot?.choiceItems.byId[itemId] && (
|
{typebot?.choiceItems.byId[itemId] && (
|
||||||
<ChoiceItemNode
|
<ButtonNode
|
||||||
item={typebot?.choiceItems.byId[itemId]}
|
item={typebot?.choiceItems.byId[itemId]}
|
||||||
onMouseMoveTopOfElement={() => handleMouseOnTopOfStep(idx)}
|
onMouseMoveTopOfElement={() => handleMouseOnTopOfStep(idx)}
|
||||||
onMouseMoveBottomOfElement={() => {
|
onMouseMoveBottomOfElement={() => {
|
||||||
@ -138,7 +138,7 @@ export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
|
|||||||
|
|
||||||
{draggedChoiceItem && draggedChoiceItem.stepId === step.id && (
|
{draggedChoiceItem && draggedChoiceItem.stepId === step.id && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<ChoiceItemNodeOverlay
|
<ButtonNodeOverlay
|
||||||
item={draggedChoiceItem}
|
item={draggedChoiceItem}
|
||||||
pos="fixed"
|
pos="fixed"
|
||||||
top="0"
|
top="0"
|
@ -0,0 +1 @@
|
|||||||
|
export { ButtonNodesList } from './ButtonNodesList'
|
@ -1,5 +1,5 @@
|
|||||||
import { Tag, Text, VStack } from '@chakra-ui/react'
|
import { Tag, Text, VStack } from '@chakra-ui/react'
|
||||||
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { AnswersCount } from 'services/analytics'
|
import { AnswersCount } from 'services/analytics'
|
||||||
import { computeSourceCoordinates } from 'services/graph'
|
import { computeSourceCoordinates } from 'services/graph'
|
||||||
@ -10,8 +10,8 @@ type Props = {
|
|||||||
blockId: string
|
blockId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DropOffBlock = ({ answersCounts, blockId }: Props) => {
|
export const DropOffNode = ({ answersCounts, blockId }: Props) => {
|
||||||
const { typebot } = useAnalyticsGraph()
|
const { typebot } = useTypebot()
|
||||||
|
|
||||||
const totalAnswers = useMemo(
|
const totalAnswers = useMemo(
|
||||||
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,
|
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,
|
@ -5,23 +5,21 @@ import {
|
|||||||
PopoverBody,
|
PopoverBody,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { ImageUploadContent } from 'components/shared/ImageUploadContent'
|
import { ImageUploadContent } from 'components/shared/ImageUploadContent'
|
||||||
import { useTypebot } from 'contexts/TypebotContext'
|
|
||||||
import {
|
import {
|
||||||
BubbleStep,
|
BubbleStep,
|
||||||
|
BubbleStepContent,
|
||||||
BubbleStepType,
|
BubbleStepType,
|
||||||
ImageBubbleStep,
|
|
||||||
TextBubbleStep,
|
TextBubbleStep,
|
||||||
VideoBubbleContent,
|
|
||||||
VideoBubbleStep,
|
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { VideoUploadContent } from './VideoUploadContent'
|
import { VideoUploadContent } from './VideoUploadContent'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
step: Exclude<BubbleStep, TextBubbleStep>
|
step: Exclude<BubbleStep, TextBubbleStep>
|
||||||
|
onContentChange: (content: BubbleStepContent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContentPopover = ({ step }: Props) => {
|
export const MediaBubblePopoverContent = (props: Props) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
|
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
|
||||||
|
|
||||||
@ -30,37 +28,28 @@ export const ContentPopover = ({ step }: Props) => {
|
|||||||
<PopoverContent onMouseDown={handleMouseDown} w="500px">
|
<PopoverContent onMouseDown={handleMouseDown} w="500px">
|
||||||
<PopoverArrow />
|
<PopoverArrow />
|
||||||
<PopoverBody ref={ref} shadow="lg">
|
<PopoverBody ref={ref} shadow="lg">
|
||||||
<StepContent step={step} />
|
<MediaBubbleContent {...props} />
|
||||||
</PopoverBody>
|
</PopoverBody>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Portal>
|
</Portal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StepContent = ({ step }: Props) => {
|
export const MediaBubbleContent = ({ step, onContentChange }: Props) => {
|
||||||
const { updateStep } = useTypebot()
|
const handleImageUrlChange = (url: string) => onContentChange({ url })
|
||||||
|
|
||||||
const handleContentChange = (url: string) =>
|
|
||||||
updateStep(step.id, { content: { url } } as Partial<ImageBubbleStep>)
|
|
||||||
|
|
||||||
const handleVideoContentChange = (content: VideoBubbleContent) =>
|
|
||||||
updateStep(step.id, { content } as Partial<VideoBubbleStep>)
|
|
||||||
|
|
||||||
switch (step.type) {
|
switch (step.type) {
|
||||||
case BubbleStepType.IMAGE: {
|
case BubbleStepType.IMAGE: {
|
||||||
return (
|
return (
|
||||||
<ImageUploadContent
|
<ImageUploadContent
|
||||||
url={step.content?.url}
|
url={step.content?.url}
|
||||||
onSubmit={handleContentChange}
|
onSubmit={handleImageUrlChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case BubbleStepType.VIDEO: {
|
case BubbleStepType.VIDEO: {
|
||||||
return (
|
return (
|
||||||
<VideoUploadContent
|
<VideoUploadContent content={step.content} onSubmit={onContentChange} />
|
||||||
content={step.content}
|
|
||||||
onSubmit={handleVideoContentChange}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
|
@ -7,9 +7,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { ExpandIcon } from 'assets/icons'
|
import { ExpandIcon } from 'assets/icons'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
|
||||||
import {
|
import {
|
||||||
InputStep,
|
|
||||||
InputStepType,
|
InputStepType,
|
||||||
IntegrationStepType,
|
IntegrationStepType,
|
||||||
LogicStepType,
|
LogicStepType,
|
||||||
@ -17,7 +15,6 @@ import {
|
|||||||
StepOptions,
|
StepOptions,
|
||||||
TextBubbleStep,
|
TextBubbleStep,
|
||||||
Webhook,
|
Webhook,
|
||||||
WebhookStep,
|
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
@ -33,15 +30,19 @@ import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
|
|||||||
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
|
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
|
||||||
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
|
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
|
||||||
import { RedirectSettings } from './bodies/RedirectSettings'
|
import { RedirectSettings } from './bodies/RedirectSettings'
|
||||||
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody'
|
import { SetVariableSettings } from './bodies/SetVariableSettings'
|
||||||
import { WebhookSettings } from './bodies/WebhookSettings'
|
import { WebhookSettings } from './bodies/WebhookSettings'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
step: Exclude<Step, TextBubbleStep>
|
step: Exclude<Step, TextBubbleStep>
|
||||||
|
webhook?: Webhook
|
||||||
onExpandClick: () => void
|
onExpandClick: () => void
|
||||||
|
onOptionsChange: (options: StepOptions) => void
|
||||||
|
onWebhookChange: (updates: Partial<Webhook>) => void
|
||||||
|
onTestRequestClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
|
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
|
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
|
||||||
|
|
||||||
@ -60,7 +61,7 @@ export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
shadow="lg"
|
shadow="lg"
|
||||||
>
|
>
|
||||||
<StepSettings step={step} />
|
<StepSettings {...props} />
|
||||||
</PopoverBody>
|
</PopoverBody>
|
||||||
<IconButton
|
<IconButton
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
@ -76,24 +77,25 @@ export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StepSettings = ({ step }: { step: Step }) => {
|
export const StepSettings = ({
|
||||||
const { updateStep, updateWebhook, typebot } = useTypebot()
|
step,
|
||||||
const handleOptionsChange = (options: StepOptions) => {
|
webhook,
|
||||||
updateStep(step.id, { options } as Partial<InputStep>)
|
onOptionsChange,
|
||||||
}
|
onWebhookChange,
|
||||||
|
onTestRequestClick,
|
||||||
const handleWebhookChange = (webhook: Partial<Webhook>) => {
|
}: {
|
||||||
const webhookId = (step as WebhookStep).options?.webhookId
|
step: Step
|
||||||
if (!webhookId) return
|
webhook?: Webhook
|
||||||
updateWebhook(webhookId, webhook)
|
onOptionsChange: (options: StepOptions) => void
|
||||||
}
|
onWebhookChange: (updates: Partial<Webhook>) => void
|
||||||
|
onTestRequestClick: () => void
|
||||||
|
}) => {
|
||||||
switch (step.type) {
|
switch (step.type) {
|
||||||
case InputStepType.TEXT: {
|
case InputStepType.TEXT: {
|
||||||
return (
|
return (
|
||||||
<TextInputSettingsBody
|
<TextInputSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -101,7 +103,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<NumberInputSettingsBody
|
<NumberInputSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -109,7 +111,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<EmailInputSettingsBody
|
<EmailInputSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -117,7 +119,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<UrlInputSettingsBody
|
<UrlInputSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -125,7 +127,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<DateInputSettingsBody
|
<DateInputSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -133,7 +135,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<PhoneNumberSettingsBody
|
<PhoneNumberSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -141,15 +143,15 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<ChoiceInputSettingsBody
|
<ChoiceInputSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case LogicStepType.SET_VARIABLE: {
|
case LogicStepType.SET_VARIABLE: {
|
||||||
return (
|
return (
|
||||||
<SetVariableSettingsBody
|
<SetVariableSettings
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -157,7 +159,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<ConditionSettingsBody
|
<ConditionSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -165,7 +167,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<RedirectSettings
|
<RedirectSettings
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -173,7 +175,7 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<GoogleSheetsSettingsBody
|
<GoogleSheetsSettingsBody
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
stepId={step.id}
|
stepId={step.id}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -182,18 +184,18 @@ export const StepSettings = ({ step }: { step: Step }) => {
|
|||||||
return (
|
return (
|
||||||
<GoogleAnalyticsSettings
|
<GoogleAnalyticsSettings
|
||||||
options={step.options}
|
options={step.options}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case IntegrationStepType.WEBHOOK: {
|
case IntegrationStepType.WEBHOOK: {
|
||||||
return (
|
return (
|
||||||
<WebhookSettings
|
<WebhookSettings
|
||||||
key={step.options?.webhookId}
|
|
||||||
options={step.options}
|
options={step.options}
|
||||||
webhook={typebot?.webhooks.byId[step.options?.webhookId ?? '']}
|
webhook={webhook as Webhook}
|
||||||
onOptionsChange={handleOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
onWebhookChange={handleWebhookChange}
|
onWebhookChange={onWebhookChange}
|
||||||
|
onTestRequestClick={onTestRequestClick}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -6,9 +6,13 @@ import { useTypebot } from 'contexts/TypebotContext'
|
|||||||
import { CredentialsType } from 'db'
|
import { CredentialsType } from 'db'
|
||||||
import {
|
import {
|
||||||
Cell,
|
Cell,
|
||||||
|
defaultTable,
|
||||||
ExtractingCell,
|
ExtractingCell,
|
||||||
GoogleSheetsAction,
|
GoogleSheetsAction,
|
||||||
|
GoogleSheetsGetOptions,
|
||||||
|
GoogleSheetsInsertRowOptions,
|
||||||
GoogleSheetsOptions,
|
GoogleSheetsOptions,
|
||||||
|
GoogleSheetsUpdateRowOptions,
|
||||||
Table,
|
Table,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
@ -24,7 +28,7 @@ import { CellWithValueStack } from './CellWithValueStack'
|
|||||||
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
|
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
options?: GoogleSheetsOptions
|
options: GoogleSheetsOptions
|
||||||
onOptionsChange: (options: GoogleSheetsOptions) => void
|
onOptionsChange: (options: GoogleSheetsOptions) => void
|
||||||
stepId: string
|
stepId: string
|
||||||
}
|
}
|
||||||
@ -49,8 +53,35 @@ export const GoogleSheetsSettingsBody = ({
|
|||||||
onOptionsChange({ ...options, spreadsheetId })
|
onOptionsChange({ ...options, spreadsheetId })
|
||||||
const handleSheetIdChange = (sheetId: string) =>
|
const handleSheetIdChange = (sheetId: string) =>
|
||||||
onOptionsChange({ ...options, sheetId })
|
onOptionsChange({ ...options, sheetId })
|
||||||
const handleActionChange = (action: GoogleSheetsAction) =>
|
|
||||||
onOptionsChange({ ...options, action })
|
const handleActionChange = (action: GoogleSheetsAction) => {
|
||||||
|
switch (action) {
|
||||||
|
case GoogleSheetsAction.GET: {
|
||||||
|
const newOptions: GoogleSheetsGetOptions = {
|
||||||
|
...options,
|
||||||
|
action,
|
||||||
|
cellsToExtract: defaultTable,
|
||||||
|
}
|
||||||
|
return onOptionsChange({ ...newOptions })
|
||||||
|
}
|
||||||
|
case GoogleSheetsAction.INSERT_ROW: {
|
||||||
|
const newOptions: GoogleSheetsInsertRowOptions = {
|
||||||
|
...options,
|
||||||
|
action,
|
||||||
|
cellsToInsert: defaultTable,
|
||||||
|
}
|
||||||
|
return onOptionsChange({ ...newOptions })
|
||||||
|
}
|
||||||
|
case GoogleSheetsAction.UPDATE_ROW: {
|
||||||
|
const newOptions: GoogleSheetsUpdateRowOptions = {
|
||||||
|
...options,
|
||||||
|
action,
|
||||||
|
cellsToUpsert: defaultTable,
|
||||||
|
}
|
||||||
|
return onOptionsChange({ ...newOptions })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateNewClick = async () => {
|
const handleCreateNewClick = async () => {
|
||||||
if (hasUnsavedChanges) {
|
if (hasUnsavedChanges) {
|
||||||
@ -94,14 +125,14 @@ export const GoogleSheetsSettingsBody = ({
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<DropdownList<GoogleSheetsAction>
|
<DropdownList<GoogleSheetsAction>
|
||||||
currentItem={options.action}
|
currentItem={'action' in options ? options.action : undefined}
|
||||||
onItemSelect={handleActionChange}
|
onItemSelect={handleActionChange}
|
||||||
items={Object.values(GoogleSheetsAction)}
|
items={Object.values(GoogleSheetsAction)}
|
||||||
placeholder="Select an operation"
|
placeholder="Select an operation"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{sheet && options?.action && (
|
{sheet && 'action' in options && (
|
||||||
<ActionOptions
|
<ActionOptions
|
||||||
options={options}
|
options={options}
|
||||||
sheet={sheet}
|
sheet={sheet}
|
||||||
@ -117,7 +148,10 @@ const ActionOptions = ({
|
|||||||
sheet,
|
sheet,
|
||||||
onOptionsChange,
|
onOptionsChange,
|
||||||
}: {
|
}: {
|
||||||
options: GoogleSheetsOptions
|
options:
|
||||||
|
| GoogleSheetsGetOptions
|
||||||
|
| GoogleSheetsInsertRowOptions
|
||||||
|
| GoogleSheetsUpdateRowOptions
|
||||||
sheet: Sheet
|
sheet: Sheet
|
||||||
onOptionsChange: (options: GoogleSheetsOptions) => void
|
onOptionsChange: (options: GoogleSheetsOptions) => void
|
||||||
}) => {
|
}) => {
|
||||||
@ -149,7 +183,7 @@ const ActionOptions = ({
|
|||||||
case GoogleSheetsAction.INSERT_ROW:
|
case GoogleSheetsAction.INSERT_ROW:
|
||||||
return (
|
return (
|
||||||
<TableList<Cell>
|
<TableList<Cell>
|
||||||
initialItems={options.cellsToInsert ?? { byId: {}, allIds: [] }}
|
initialItems={options.cellsToInsert}
|
||||||
onItemsChange={handleInsertColumnsChange}
|
onItemsChange={handleInsertColumnsChange}
|
||||||
Item={UpdatingCellItem}
|
Item={UpdatingCellItem}
|
||||||
addLabel="Add a value"
|
addLabel="Add a value"
|
||||||
@ -167,7 +201,7 @@ const ActionOptions = ({
|
|||||||
/>
|
/>
|
||||||
<Text>Cells to update</Text>
|
<Text>Cells to update</Text>
|
||||||
<TableList<Cell>
|
<TableList<Cell>
|
||||||
initialItems={options.cellsToUpsert ?? { byId: {}, allIds: [] }}
|
initialItems={options.cellsToUpsert}
|
||||||
onItemsChange={handleUpsertColumnsChange}
|
onItemsChange={handleUpsertColumnsChange}
|
||||||
Item={UpdatingCellItem}
|
Item={UpdatingCellItem}
|
||||||
addLabel="Add a value"
|
addLabel="Add a value"
|
||||||
@ -186,7 +220,7 @@ const ActionOptions = ({
|
|||||||
/>
|
/>
|
||||||
<Text>Cells to extract</Text>
|
<Text>Cells to extract</Text>
|
||||||
<TableList<ExtractingCell>
|
<TableList<ExtractingCell>
|
||||||
initialItems={options.cellsToExtract ?? { byId: {}, allIds: [] }}
|
initialItems={options.cellsToExtract}
|
||||||
onItemsChange={handleExtractingCellsChange}
|
onItemsChange={handleExtractingCellsChange}
|
||||||
Item={ExtractingCellItem}
|
Item={ExtractingCellItem}
|
||||||
addLabel="Add a value"
|
addLabel="Add a value"
|
@ -9,10 +9,7 @@ type Props = {
|
|||||||
onOptionsChange: (options: SetVariableOptions) => void
|
onOptionsChange: (options: SetVariableOptions) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetVariableSettingsBody = ({
|
export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
|
||||||
options,
|
|
||||||
onOptionsChange,
|
|
||||||
}: Props) => {
|
|
||||||
const handleVariableChange = (variable?: Variable) =>
|
const handleVariableChange = (variable?: Variable) =>
|
||||||
onOptionsChange({ ...options, variableId: variable?.id })
|
onOptionsChange({ ...options, variableId: variable?.id })
|
||||||
const handleExpressionChange = (expressionToEvaluate: string) =>
|
const handleExpressionChange = (expressionToEvaluate: string) =>
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionButton,
|
AccordionButton,
|
||||||
@ -22,7 +22,6 @@ import {
|
|||||||
ResponseVariableMapping,
|
ResponseVariableMapping,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { DropdownList } from 'components/shared/DropdownList'
|
import { DropdownList } from 'components/shared/DropdownList'
|
||||||
import { generate } from 'short-uuid'
|
|
||||||
import { TableList, TableListItemProps } from 'components/shared/TableList'
|
import { TableList, TableListItemProps } from 'components/shared/TableList'
|
||||||
import { CodeEditor } from 'components/shared/CodeEditor'
|
import { CodeEditor } from 'components/shared/CodeEditor'
|
||||||
import {
|
import {
|
||||||
@ -35,19 +34,22 @@ import { VariableForTestInputs } from './VariableForTestInputs'
|
|||||||
import { DataVariableInputs } from './ResponseMappingInputs'
|
import { DataVariableInputs } from './ResponseMappingInputs'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
webhook: Webhook
|
||||||
options?: WebhookOptions
|
options?: WebhookOptions
|
||||||
webhook?: Webhook
|
|
||||||
onOptionsChange: (options: WebhookOptions) => void
|
onOptionsChange: (options: WebhookOptions) => void
|
||||||
onWebhookChange: (webhook: Partial<Webhook>) => void
|
onWebhookChange: (updates: Partial<Webhook>) => void
|
||||||
|
onTestRequestClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WebhookSettings = ({
|
export const WebhookSettings = ({
|
||||||
options,
|
options,
|
||||||
webhook,
|
|
||||||
onOptionsChange,
|
onOptionsChange,
|
||||||
|
webhook,
|
||||||
onWebhookChange,
|
onWebhookChange,
|
||||||
|
onTestRequestClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { createWebhook, typebot, save } = useTypebot()
|
const { typebot, save } = useTypebot()
|
||||||
|
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
|
||||||
const [testResponse, setTestResponse] = useState<string>()
|
const [testResponse, setTestResponse] = useState<string>()
|
||||||
const [responseKeys, setResponseKeys] = useState<string[]>([])
|
const [responseKeys, setResponseKeys] = useState<string[]>([])
|
||||||
|
|
||||||
@ -56,18 +58,9 @@ export const WebhookSettings = ({
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (options?.webhookId) return
|
|
||||||
const webhookId = generate()
|
|
||||||
createWebhook({ id: webhookId })
|
|
||||||
onOptionsChange({ ...options, webhookId })
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleUrlChange = (url?: string) => onWebhookChange({ url })
|
const handleUrlChange = (url?: string) => onWebhookChange({ url })
|
||||||
|
|
||||||
const handleMethodChange = (method?: HttpMethod) =>
|
const handleMethodChange = (method: HttpMethod) => onWebhookChange({ method })
|
||||||
onWebhookChange({ method })
|
|
||||||
|
|
||||||
const handleQueryParamsChange = (queryParams: Table<KeyValue>) =>
|
const handleQueryParamsChange = (queryParams: Table<KeyValue>) =>
|
||||||
onWebhookChange({ queryParams })
|
onWebhookChange({ queryParams })
|
||||||
@ -78,14 +71,16 @@ export const WebhookSettings = ({
|
|||||||
const handleBodyChange = (body: string) => onWebhookChange({ body })
|
const handleBodyChange = (body: string) => onWebhookChange({ body })
|
||||||
|
|
||||||
const handleVariablesChange = (variablesForTest: Table<VariableForTest>) =>
|
const handleVariablesChange = (variablesForTest: Table<VariableForTest>) =>
|
||||||
onOptionsChange({ ...options, variablesForTest })
|
options && onOptionsChange({ ...options, variablesForTest })
|
||||||
|
|
||||||
const handleResponseMappingChange = (
|
const handleResponseMappingChange = (
|
||||||
responseVariableMapping: Table<ResponseVariableMapping>
|
responseVariableMapping: Table<ResponseVariableMapping>
|
||||||
) => onOptionsChange({ ...options, responseVariableMapping })
|
) => options && onOptionsChange({ ...options, responseVariableMapping })
|
||||||
|
|
||||||
const handleTestRequestClick = async () => {
|
const handleTestRequestClick = async () => {
|
||||||
if (!typebot || !webhook) return
|
if (!typebot || !webhook) return
|
||||||
|
setIsTestResponseLoading(true)
|
||||||
|
onTestRequestClick()
|
||||||
await save()
|
await save()
|
||||||
const { data, error } = await executeWebhook(
|
const { data, error } = await executeWebhook(
|
||||||
typebot.id,
|
typebot.id,
|
||||||
@ -98,6 +93,7 @@ export const WebhookSettings = ({
|
|||||||
if (error) return toast({ title: error.name, description: error.message })
|
if (error) return toast({ title: error.name, description: error.message })
|
||||||
setTestResponse(JSON.stringify(data, undefined, 2))
|
setTestResponse(JSON.stringify(data, undefined, 2))
|
||||||
setResponseKeys(getDeepKeys(data))
|
setResponseKeys(getDeepKeys(data))
|
||||||
|
setIsTestResponseLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResponseMappingInputs = useMemo(
|
const ResponseMappingInputs = useMemo(
|
||||||
@ -111,14 +107,14 @@ export const WebhookSettings = ({
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Flex>
|
<Flex>
|
||||||
<DropdownList<HttpMethod>
|
<DropdownList<HttpMethod>
|
||||||
currentItem={webhook?.method ?? HttpMethod.GET}
|
currentItem={webhook.method}
|
||||||
onItemSelect={handleMethodChange}
|
onItemSelect={handleMethodChange}
|
||||||
items={Object.values(HttpMethod)}
|
items={Object.values(HttpMethod)}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<InputWithVariableButton
|
<InputWithVariableButton
|
||||||
placeholder="Your Webhook URL..."
|
placeholder="Your Webhook URL..."
|
||||||
initialValue={webhook?.url ?? ''}
|
initialValue={webhook.url ?? ''}
|
||||||
onChange={handleUrlChange}
|
onChange={handleUrlChange}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
@ -130,7 +126,7 @@ export const WebhookSettings = ({
|
|||||||
</AccordionButton>
|
</AccordionButton>
|
||||||
<AccordionPanel pb={4} as={Stack} spacing="6">
|
<AccordionPanel pb={4} as={Stack} spacing="6">
|
||||||
<TableList<KeyValue>
|
<TableList<KeyValue>
|
||||||
initialItems={webhook?.queryParams ?? { byId: {}, allIds: [] }}
|
initialItems={webhook.queryParams}
|
||||||
onItemsChange={handleQueryParamsChange}
|
onItemsChange={handleQueryParamsChange}
|
||||||
Item={QueryParamsInputs}
|
Item={QueryParamsInputs}
|
||||||
addLabel="Add a param"
|
addLabel="Add a param"
|
||||||
@ -144,7 +140,7 @@ export const WebhookSettings = ({
|
|||||||
</AccordionButton>
|
</AccordionButton>
|
||||||
<AccordionPanel pb={4} as={Stack} spacing="6">
|
<AccordionPanel pb={4} as={Stack} spacing="6">
|
||||||
<TableList<KeyValue>
|
<TableList<KeyValue>
|
||||||
initialItems={webhook?.headers ?? { byId: {}, allIds: [] }}
|
initialItems={webhook.headers}
|
||||||
onItemsChange={handleHeadersChange}
|
onItemsChange={handleHeadersChange}
|
||||||
Item={HeadersInputs}
|
Item={HeadersInputs}
|
||||||
addLabel="Add a value"
|
addLabel="Add a value"
|
||||||
@ -158,7 +154,7 @@ export const WebhookSettings = ({
|
|||||||
</AccordionButton>
|
</AccordionButton>
|
||||||
<AccordionPanel pb={4} as={Stack} spacing="6">
|
<AccordionPanel pb={4} as={Stack} spacing="6">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={webhook?.body ?? ''}
|
value={'test'}
|
||||||
lang="json"
|
lang="json"
|
||||||
onChange={handleBodyChange}
|
onChange={handleBodyChange}
|
||||||
/>
|
/>
|
||||||
@ -181,7 +177,11 @@ export const WebhookSettings = ({
|
|||||||
</AccordionPanel>
|
</AccordionPanel>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Button onClick={handleTestRequestClick} colorScheme="blue">
|
<Button
|
||||||
|
onClick={handleTestRequestClick}
|
||||||
|
colorScheme="blue"
|
||||||
|
isLoading={isTestResponseLoading}
|
||||||
|
>
|
||||||
Test the request
|
Test the request
|
||||||
</Button>
|
</Button>
|
||||||
{testResponse && (
|
{testResponse && (
|
||||||
@ -201,6 +201,7 @@ export const WebhookSettings = ({
|
|||||||
}
|
}
|
||||||
onItemsChange={handleResponseMappingChange}
|
onItemsChange={handleResponseMappingChange}
|
||||||
Item={ResponseMappingInputs}
|
Item={ResponseMappingInputs}
|
||||||
|
addLabel="Add an entry"
|
||||||
/>
|
/>
|
||||||
</AccordionPanel>
|
</AccordionPanel>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
@ -7,23 +7,31 @@ import {
|
|||||||
useEventListener,
|
useEventListener,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { BubbleStep, DraggableStep, Step, TextBubbleStep } from 'models'
|
import {
|
||||||
|
BubbleStep,
|
||||||
|
BubbleStepContent,
|
||||||
|
DraggableStep,
|
||||||
|
Step,
|
||||||
|
StepOptions,
|
||||||
|
TextBubbleStep,
|
||||||
|
Webhook,
|
||||||
|
} from 'models'
|
||||||
import { Coordinates, useGraph } from 'contexts/GraphContext'
|
import { Coordinates, useGraph } from 'contexts/GraphContext'
|
||||||
import { StepIcon } from 'components/board/StepsSideBar/StepIcon'
|
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
|
||||||
import { isBubbleStep, isTextBubbleStep } from 'utils'
|
import { isBubbleStep, isTextBubbleStep, isWebhookStep } from 'utils'
|
||||||
import { TextEditor } from './TextEditor/TextEditor'
|
|
||||||
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
|
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
|
||||||
import { useTypebot } from 'contexts/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
import { ContextMenu } from 'components/shared/ContextMenu'
|
import { ContextMenu } from 'components/shared/ContextMenu'
|
||||||
import { SettingsPopoverContent } from './SettingsPopoverContent'
|
import { SettingsPopoverContent } from './SettingsPopoverContent'
|
||||||
import { StepNodeContextMenu } from './StepNodeContextMenu'
|
import { StepNodeContextMenu } from './StepNodeContextMenu'
|
||||||
import { SourceEndpoint } from './SourceEndpoint'
|
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
|
||||||
import { hasDefaultConnector } from 'services/typebots'
|
import { hasDefaultConnector } from 'services/typebots'
|
||||||
import { TargetEndpoint } from './TargetEndpoint'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { SettingsModal } from './SettingsPopoverContent/SettingsModal'
|
import { SettingsModal } from './SettingsPopoverContent/SettingsModal'
|
||||||
import { StepSettings } from './SettingsPopoverContent/SettingsPopoverContent'
|
import { StepSettings } from './SettingsPopoverContent/SettingsPopoverContent'
|
||||||
import { ContentPopover } from './ContentPopover'
|
import { TextBubbleEditor } from './TextBubbleEditor'
|
||||||
|
import { TargetEndpoint } from '../../Endpoints'
|
||||||
|
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
|
||||||
|
|
||||||
export const StepNode = ({
|
export const StepNode = ({
|
||||||
step,
|
step,
|
||||||
@ -42,10 +50,26 @@ export const StepNode = ({
|
|||||||
) => void
|
) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { query } = useRouter()
|
const { query } = useRouter()
|
||||||
const { setConnectingIds, connectingIds, openedStepId, setOpenedStepId } =
|
const {
|
||||||
useGraph()
|
setConnectingIds,
|
||||||
const { detachStepFromBlock } = useTypebot()
|
connectingIds,
|
||||||
|
openedStepId,
|
||||||
|
setOpenedStepId,
|
||||||
|
blocksCoordinates,
|
||||||
|
} = useGraph()
|
||||||
|
const { detachStepFromBlock, updateStep, typebot, updateWebhook } =
|
||||||
|
useTypebot()
|
||||||
|
const [localStep, setLocalStep] = useState(step)
|
||||||
|
const [localWebhook, setLocalWebhook] = useState(
|
||||||
|
isWebhookStep(step)
|
||||||
|
? typebot?.webhooks.byId[step.options.webhookId ?? '']
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
|
const [isPopoverOpened, setIsPopoverOpened] = useState(
|
||||||
|
openedStepId === step.id
|
||||||
|
)
|
||||||
|
|
||||||
const [mouseDownEvent, setMouseDownEvent] =
|
const [mouseDownEvent, setMouseDownEvent] =
|
||||||
useState<{ absolute: Coordinates; relative: Coordinates }>()
|
useState<{ absolute: Coordinates; relative: Coordinates }>()
|
||||||
const [isEditing, setIsEditing] = useState<boolean>(
|
const [isEditing, setIsEditing] = useState<boolean>(
|
||||||
@ -57,6 +81,10 @@ export const StepNode = ({
|
|||||||
onClose: onModalClose,
|
onClose: onModalClose,
|
||||||
} = useDisclosure()
|
} = useDisclosure()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalStep(step)
|
||||||
|
}, [step])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.stepId?.toString() === step.id) setOpenedStepId(step.id)
|
if (query.stepId?.toString() === step.id) setOpenedStepId(step.id)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -69,6 +97,11 @@ export const StepNode = ({
|
|||||||
)
|
)
|
||||||
}, [connectingIds, step.blockId, step.id])
|
}, [connectingIds, step.blockId, step.id])
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
updateStep(localStep.id, { ...localStep })
|
||||||
|
onModalClose()
|
||||||
|
}
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
if (connectingIds?.target)
|
if (connectingIds?.target)
|
||||||
setConnectingIds({
|
setConnectingIds({
|
||||||
@ -116,7 +149,6 @@ export const StepNode = ({
|
|||||||
onMouseDown &&
|
onMouseDown &&
|
||||||
(event.movementX > 0 || event.movementY > 0)
|
(event.movementX > 0 || event.movementY > 0)
|
||||||
if (isMovingAndIsMouseDown && step.type !== 'start') {
|
if (isMovingAndIsMouseDown && step.type !== 'start') {
|
||||||
console.log(step)
|
|
||||||
onMouseDown(mouseDownEvent, step)
|
onMouseDown(mouseDownEvent, step)
|
||||||
detachStepFromBlock(step.id)
|
detachStepFromBlock(step.id)
|
||||||
setMouseDownEvent(undefined)
|
setMouseDownEvent(undefined)
|
||||||
@ -142,10 +174,33 @@ export const StepNode = ({
|
|||||||
onModalOpen()
|
onModalOpen()
|
||||||
}
|
}
|
||||||
|
|
||||||
return isEditing && isTextBubbleStep(step) ? (
|
const updateOptions = () => {
|
||||||
<TextEditor
|
updateStep(localStep.id, { ...localStep })
|
||||||
stepId={step.id}
|
if (localWebhook) updateWebhook(localWebhook.id, { ...localWebhook })
|
||||||
initialValue={step.content.richText}
|
}
|
||||||
|
|
||||||
|
const handleOptionsChange = (options: StepOptions) => {
|
||||||
|
setLocalStep({ ...localStep, options } as Step)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentChange = (content: BubbleStepContent) =>
|
||||||
|
setLocalStep({ ...localStep, content } as Step)
|
||||||
|
|
||||||
|
const handleWebhookChange = (updates: Partial<Webhook>) => {
|
||||||
|
if (!localWebhook) return
|
||||||
|
setLocalWebhook({ ...localWebhook, ...updates })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPopoverOpened && openedStepId !== step.id) updateOptions()
|
||||||
|
setIsPopoverOpened(openedStepId === step.id)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [openedStepId])
|
||||||
|
|
||||||
|
return isEditing && isTextBubbleStep(localStep) ? (
|
||||||
|
<TextBubbleEditor
|
||||||
|
stepId={localStep.id}
|
||||||
|
initialValue={localStep.content.richText}
|
||||||
onClose={handleCloseEditor}
|
onClose={handleCloseEditor}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -153,7 +208,12 @@ export const StepNode = ({
|
|||||||
renderMenu={() => <StepNodeContextMenu stepId={step.id} />}
|
renderMenu={() => <StepNodeContextMenu stepId={step.id} />}
|
||||||
>
|
>
|
||||||
{(ref, isOpened) => (
|
{(ref, isOpened) => (
|
||||||
<Popover placement="bottom" isLazy isOpen={openedStepId === step.id}>
|
<Popover
|
||||||
|
placement="left"
|
||||||
|
isLazy
|
||||||
|
isOpen={isPopoverOpened}
|
||||||
|
closeOnBlur={false}
|
||||||
|
>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Flex
|
<Flex
|
||||||
pos="relative"
|
pos="relative"
|
||||||
@ -180,40 +240,57 @@ export const StepNode = ({
|
|||||||
w="full"
|
w="full"
|
||||||
>
|
>
|
||||||
<StepIcon
|
<StepIcon
|
||||||
type={step.type}
|
type={localStep.type}
|
||||||
mt="1"
|
mt="1"
|
||||||
data-testid={`${step.id}-icon`}
|
data-testid={`${localStep.id}-icon`}
|
||||||
/>
|
/>
|
||||||
<StepNodeContent step={step} />
|
<StepNodeContent step={localStep} />
|
||||||
<TargetEndpoint
|
<TargetEndpoint
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
left="-32px"
|
left="-32px"
|
||||||
top="19px"
|
top="19px"
|
||||||
stepId={step.id}
|
stepId={localStep.id}
|
||||||
/>
|
/>
|
||||||
{isConnectable && hasDefaultConnector(step) && (
|
{blocksCoordinates &&
|
||||||
<SourceEndpoint
|
isConnectable &&
|
||||||
source={{
|
hasDefaultConnector(localStep) && (
|
||||||
blockId: step.blockId,
|
<SourceEndpoint
|
||||||
stepId: step.id,
|
source={{
|
||||||
}}
|
blockId: localStep.blockId,
|
||||||
pos="absolute"
|
stepId: localStep.id,
|
||||||
right="15px"
|
}}
|
||||||
bottom="18px"
|
pos="absolute"
|
||||||
/>
|
right="15px"
|
||||||
)}
|
bottom="18px"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
{hasSettingsPopover(step) && (
|
{hasSettingsPopover(localStep) && (
|
||||||
<SettingsPopoverContent
|
<SettingsPopoverContent
|
||||||
step={step}
|
step={localStep}
|
||||||
|
webhook={localWebhook}
|
||||||
onExpandClick={handleExpandClick}
|
onExpandClick={handleExpandClick}
|
||||||
|
onOptionsChange={handleOptionsChange}
|
||||||
|
onWebhookChange={handleWebhookChange}
|
||||||
|
onTestRequestClick={updateOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasContentPopover(step) && <ContentPopover step={step} />}
|
{isMediaBubbleStep(localStep) && (
|
||||||
<SettingsModal isOpen={isModalOpen} onClose={onModalClose}>
|
<MediaBubblePopoverContent
|
||||||
<StepSettings step={step} />
|
step={localStep}
|
||||||
|
onContentChange={handleContentChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
|
||||||
|
<StepSettings
|
||||||
|
step={localStep}
|
||||||
|
webhook={localWebhook}
|
||||||
|
onOptionsChange={handleOptionsChange}
|
||||||
|
onWebhookChange={handleWebhookChange}
|
||||||
|
onTestRequestClick={updateOptions}
|
||||||
|
/>
|
||||||
</SettingsModal>
|
</SettingsModal>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
@ -224,7 +301,7 @@ export const StepNode = ({
|
|||||||
const hasSettingsPopover = (step: Step): step is Exclude<Step, BubbleStep> =>
|
const hasSettingsPopover = (step: Step): step is Exclude<Step, BubbleStep> =>
|
||||||
!isBubbleStep(step)
|
!isBubbleStep(step)
|
||||||
|
|
||||||
const hasContentPopover = (
|
const isMediaBubbleStep = (
|
||||||
step: Step
|
step: Step
|
||||||
): step is Exclude<BubbleStep, TextBubbleStep> =>
|
): step is Exclude<BubbleStep, TextBubbleStep> =>
|
||||||
isBubbleStep(step) && !isTextBubbleStep(step)
|
isBubbleStep(step) && !isTextBubbleStep(step)
|
@ -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 { Flex, Stack, HStack, Tag, Text } from '@chakra-ui/react'
|
||||||
import { useTypebot } from 'contexts/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
import { ConditionStep } from 'models'
|
import { ConditionStep } from 'models'
|
||||||
import { SourceEndpoint } from '../SourceEndpoint'
|
import { SourceEndpoint } from '../../../../Endpoints/SourceEndpoint'
|
||||||
|
|
||||||
export const ConditionNodeContent = ({ step }: { step: ConditionStep }) => {
|
export const ConditionContent = ({ step }: { step: ConditionStep }) => {
|
||||||
const { typebot } = useTypebot()
|
const { typebot } = useTypebot()
|
||||||
return (
|
return (
|
||||||
<Flex>
|
<Flex>
|
@ -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 { useTypebot } from 'contexts/TypebotContext'
|
||||||
import { SetVariableStep } from 'models'
|
import { SetVariableStep } from 'models'
|
||||||
|
|
||||||
export const SetVariableNodeContent = ({ step }: { step: SetVariableStep }) => {
|
export const SetVariableContent = ({ step }: { step: SetVariableStep }) => {
|
||||||
const { typebot } = useTypebot()
|
const { typebot } = useTypebot()
|
||||||
const variableName =
|
const variableName =
|
||||||
typebot?.variables.byId[step.options?.variableId ?? '']?.name ?? ''
|
typebot?.variables.byId[step.options?.variableId ?? '']?.name ?? ''
|
@ -8,7 +8,7 @@ type Props = {
|
|||||||
step: TextBubbleStep
|
step: TextBubbleStep
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextBubbleNodeContent = ({ step }: Props) => {
|
export const TextBubbleContent = ({ step }: Props) => {
|
||||||
const { typebot } = useTypebot()
|
const { typebot } = useTypebot()
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
@ -1,7 +1,7 @@
|
|||||||
import { Box, Text } from '@chakra-ui/react'
|
import { Box, Text } from '@chakra-ui/react'
|
||||||
import { VideoBubbleStep, VideoBubbleContentType } from 'models'
|
import { VideoBubbleStep, VideoBubbleContentType } from 'models'
|
||||||
|
|
||||||
export const VideoStepNodeContent = ({ step }: { step: VideoBubbleStep }) => {
|
export const VideoBubbleContent = ({ step }: { step: VideoBubbleStep }) => {
|
||||||
if (!step.content?.url || !step.content.type)
|
if (!step.content?.url || !step.content.type)
|
||||||
return <Text color="gray.500">Click to edit...</Text>
|
return <Text color="gray.500">Click to edit...</Text>
|
||||||
switch (step.content.type) {
|
switch (step.content.type) {
|
@ -7,7 +7,7 @@ type Props = {
|
|||||||
step: InputStep
|
step: InputStep
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StepNodeContentWithVariable = ({ step }: Props) => {
|
export const WithVariableContent = ({ step }: Props) => {
|
||||||
const { typebot } = useTypebot()
|
const { typebot } = useTypebot()
|
||||||
const variableName =
|
const variableName =
|
||||||
typebot?.variables.byId[step.options.variableId as string].name
|
typebot?.variables.byId[step.options.variableId as string].name
|
@ -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 { StackProps, HStack } from '@chakra-ui/react'
|
||||||
import { StartStep, Step } from 'models'
|
import { StartStep, Step } from 'models'
|
||||||
import { StepIcon } from 'components/board/StepsSideBar/StepIcon'
|
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
|
||||||
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
|
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
|
||||||
|
|
||||||
export const StepNodeOverlay = ({
|
export const StepNodeOverlay = ({
|
@ -1,12 +1,13 @@
|
|||||||
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
|
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
|
||||||
import { DraggableStep } from 'models'
|
import { DraggableStep } from 'models'
|
||||||
import { useStepDnd } from 'contexts/StepDndContext'
|
import { useStepDnd } from 'contexts/StepDndContext'
|
||||||
import { Coordinates } from 'contexts/GraphContext'
|
import { Coordinates, useGraph } from 'contexts/GraphContext'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { StepNode, StepNodeOverlay } from './StepNode'
|
|
||||||
import { useTypebot } from 'contexts/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
|
import { StepNode } from './StepNode'
|
||||||
|
import { StepNodeOverlay } from './StepNodeOverlay'
|
||||||
|
|
||||||
export const StepsList = ({
|
export const StepNodesList = ({
|
||||||
blockId,
|
blockId,
|
||||||
stepIds,
|
stepIds,
|
||||||
}: {
|
}: {
|
||||||
@ -22,6 +23,7 @@ export const StepsList = ({
|
|||||||
setMouseOverBlockId,
|
setMouseOverBlockId,
|
||||||
} = useStepDnd()
|
} = useStepDnd()
|
||||||
const { typebot, createStep } = useTypebot()
|
const { typebot, createStep } = useTypebot()
|
||||||
|
const { isReadOnly } = useGraph()
|
||||||
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
|
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
|
||||||
number | undefined
|
number | undefined
|
||||||
>()
|
>()
|
||||||
@ -73,18 +75,19 @@ export const StepsList = ({
|
|||||||
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
|
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
|
||||||
step: DraggableStep
|
step: DraggableStep
|
||||||
) => {
|
) => {
|
||||||
|
if (isReadOnly) return
|
||||||
setPosition(absolute)
|
setPosition(absolute)
|
||||||
setRelativeCoordinates(relative)
|
setRelativeCoordinates(relative)
|
||||||
setMouseOverBlockId(blockId)
|
setMouseOverBlockId(blockId)
|
||||||
setDraggedStep(step)
|
setDraggedStep(step)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseOnTopOfStep = (stepIndex: number) => {
|
const handleMouseOnTopOfStep = (stepIndex: number) => () => {
|
||||||
if (!draggedStep && !draggedStepType) return
|
if (!draggedStep && !draggedStepType) return
|
||||||
setExpandedPlaceholderIndex(stepIndex === 0 ? 0 : stepIndex)
|
setExpandedPlaceholderIndex(stepIndex === 0 ? 0 : stepIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseOnBottomOfStep = (stepIndex: number) => {
|
const handleMouseOnBottomOfStep = (stepIndex: number) => () => {
|
||||||
if (!draggedStep && !draggedStepType) return
|
if (!draggedStep && !draggedStepType) return
|
||||||
setExpandedPlaceholderIndex(stepIndex + 1)
|
setExpandedPlaceholderIndex(stepIndex + 1)
|
||||||
}
|
}
|
||||||
@ -112,12 +115,10 @@ export const StepsList = ({
|
|||||||
<Stack key={stepId} spacing={1}>
|
<Stack key={stepId} spacing={1}>
|
||||||
<StepNode
|
<StepNode
|
||||||
key={stepId}
|
key={stepId}
|
||||||
step={typebot?.steps.byId[stepId]}
|
step={typebot.steps.byId[stepId]}
|
||||||
isConnectable={stepIds.length - 1 === idx}
|
isConnectable={!isReadOnly && stepIds.length - 1 === idx}
|
||||||
onMouseMoveTopOfElement={() => handleMouseOnTopOfStep(idx)}
|
onMouseMoveTopOfElement={handleMouseOnTopOfStep(idx)}
|
||||||
onMouseMoveBottomOfElement={() => {
|
onMouseMoveBottomOfElement={handleMouseOnBottomOfStep(idx)}
|
||||||
handleMouseOnBottomOfStep(idx)
|
|
||||||
}}
|
|
||||||
onMouseDown={handleStepMouseDown}
|
onMouseDown={handleStepMouseDown}
|
||||||
/>
|
/>
|
||||||
<Flex
|
<Flex
|
@ -16,17 +16,13 @@ import { TextBubbleStep, Variable } from 'models'
|
|||||||
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
import { ReactEditor } from 'slate-react'
|
import { ReactEditor } from 'slate-react'
|
||||||
|
|
||||||
type TextEditorProps = {
|
type Props = {
|
||||||
stepId: string
|
stepId: string
|
||||||
initialValue: TDescendant[]
|
initialValue: TDescendant[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextEditor = ({
|
export const TextBubbleEditor = ({ initialValue, stepId, onClose }: Props) => {
|
||||||
initialValue,
|
|
||||||
stepId,
|
|
||||||
onClose,
|
|
||||||
}: TextEditorProps) => {
|
|
||||||
const randomEditorId = useMemo(() => Math.random().toString(), [])
|
const randomEditorId = useMemo(() => Math.random().toString(), [])
|
||||||
const editor = useMemo(
|
const editor = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -41,17 +37,14 @@ export const TextEditor = ({
|
|||||||
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
|
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
|
||||||
|
|
||||||
const textEditorRef = useRef<HTMLDivElement>(null)
|
const textEditorRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useOutsideClick({
|
useOutsideClick({
|
||||||
ref: textEditorRef,
|
ref: textEditorRef,
|
||||||
handler: onClose,
|
handler: () => {
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
save(value)
|
save(value)
|
||||||
}
|
onClose()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
},
|
||||||
}, [value])
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVariableDropdownOpen) return
|
if (!isVariableDropdownOpen) return
|
||||||
@ -104,7 +97,6 @@ export const TextEditor = ({
|
|||||||
|
|
||||||
const handleChangeEditorContent = (val: unknown[]) => {
|
const handleChangeEditorContent = (val: unknown[]) => {
|
||||||
setValue(val)
|
setValue(val)
|
||||||
save(val)
|
|
||||||
setIsVariableDropdownOpen(false)
|
setIsVariableDropdownOpen(false)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
@ -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 { Box, Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
|
||||||
import { TrashIcon, PlusIcon } from 'assets/icons'
|
import { TrashIcon, PlusIcon } from 'assets/icons'
|
||||||
|
import { deepEqual } from 'fast-equals'
|
||||||
import { Draft } from 'immer'
|
import { Draft } from 'immer'
|
||||||
import { Table } from 'models'
|
import { Table } from 'models'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
@ -31,11 +32,7 @@ export const TableList = <T,>({
|
|||||||
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
|
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (items.allIds.length === 0) createItem()
|
if (deepEqual(items, initialItems)) return
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onItemsChange(items)
|
onItemsChange(items)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [items])
|
}, [items])
|
||||||
|
@ -3,7 +3,7 @@ import { Tooltip } from '@chakra-ui/tooltip'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type EditableProps = {
|
type EditableProps = {
|
||||||
name?: string
|
name: string
|
||||||
onNewName: (newName: string) => void
|
onNewName: (newName: string) => void
|
||||||
}
|
}
|
||||||
export const EditableTypebotName = ({ name, onNewName }: EditableProps) => {
|
export const EditableTypebotName = ({ name, onNewName }: EditableProps) => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Flex, HStack, Button, IconButton } from '@chakra-ui/react'
|
import { Flex, HStack, Button, IconButton, Tooltip } from '@chakra-ui/react'
|
||||||
import { ChevronLeftIcon } from 'assets/icons'
|
import { ChevronLeftIcon, RedoIcon, UndoIcon } from 'assets/icons'
|
||||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||||
import { RightPanel, useEditor } from 'contexts/EditorContext'
|
import { RightPanel, useEditor } from 'contexts/EditorContext'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||||
@ -13,10 +13,12 @@ export const headerHeight = 56
|
|||||||
|
|
||||||
export const TypebotHeader = () => {
|
export const TypebotHeader = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { typebot, updateTypebot, save } = useTypebot()
|
const { typebot, updateTypebot, save, undo, redo, canUndo, canRedo } =
|
||||||
|
useTypebot()
|
||||||
const { setRightPanel } = useEditor()
|
const { setRightPanel } = useEditor()
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = async () => {
|
||||||
|
await save()
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/typebots`,
|
pathname: `/typebots`,
|
||||||
query: { ...router.query, typebotId: [] },
|
query: { ...router.query, typebotId: [] },
|
||||||
@ -85,18 +87,38 @@ export const TypebotHeader = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Flex pos="absolute" left="1rem" justify="center" align="center">
|
<Flex pos="absolute" left="1rem" justify="center" align="center">
|
||||||
<Flex alignItems="center">
|
<HStack alignItems="center">
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Back"
|
aria-label="Back"
|
||||||
icon={<ChevronLeftIcon fontSize={30} />}
|
icon={<ChevronLeftIcon fontSize={30} />}
|
||||||
mr={2}
|
|
||||||
onClick={handleBackClick}
|
onClick={handleBackClick}
|
||||||
/>
|
/>
|
||||||
<EditableTypebotName
|
{typebot?.name && (
|
||||||
name={typebot?.name}
|
<EditableTypebotName
|
||||||
onNewName={handleNameSubmit}
|
name={typebot?.name}
|
||||||
/>
|
onNewName={handleNameSubmit}
|
||||||
</Flex>
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Undo">
|
||||||
|
<IconButton
|
||||||
|
icon={<UndoIcon />}
|
||||||
|
size="sm"
|
||||||
|
aria-label="Undo"
|
||||||
|
onClick={undo}
|
||||||
|
isDisabled={!canUndo}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Redo">
|
||||||
|
<IconButton
|
||||||
|
icon={<RedoIcon />}
|
||||||
|
size="sm"
|
||||||
|
aria-label="Redo"
|
||||||
|
onClick={redo}
|
||||||
|
isDisabled={!canRedo}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<HStack right="40px" pos="absolute">
|
<HStack right="40px" pos="absolute">
|
||||||
|
@ -16,7 +16,7 @@ import { Variable } from 'models'
|
|||||||
import React, { useState, useRef, ChangeEvent, useMemo, useEffect } from 'react'
|
import React, { useState, useRef, ChangeEvent, useMemo, useEffect } from 'react'
|
||||||
import { generate } from 'short-uuid'
|
import { generate } from 'short-uuid'
|
||||||
import { useDebounce } from 'use-debounce'
|
import { useDebounce } from 'use-debounce'
|
||||||
import { isDefined } from 'utils'
|
import { isNotDefined } from 'utils'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialVariableId?: string
|
initialVariableId?: string
|
||||||
@ -131,7 +131,7 @@ export const VariableSearchInput = ({
|
|||||||
shadow="lg"
|
shadow="lg"
|
||||||
>
|
>
|
||||||
{(inputValue?.length ?? 0) > 0 &&
|
{(inputValue?.length ?? 0) > 0 &&
|
||||||
!isDefined(variables.find((v) => v.name === inputValue)) && (
|
isNotDefined(variables.find((v) => v.name === inputValue)) && (
|
||||||
<Button
|
<Button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
minH="40px"
|
minH="40px"
|
||||||
|
@ -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 {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
Dispatch,
|
Dispatch,
|
||||||
@ -6,8 +6,10 @@ import {
|
|||||||
ReactNode,
|
ReactNode,
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import { useImmer } from 'use-immer'
|
||||||
|
|
||||||
export const stubLength = 20
|
export const stubLength = 20
|
||||||
export const blockWidth = 300
|
export const blockWidth = 300
|
||||||
@ -48,13 +50,17 @@ export type ConnectingIds = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StepId = string
|
type StepId = string
|
||||||
type NodeId = string
|
type ButtonId = string
|
||||||
export type Endpoint = {
|
export type Endpoint = {
|
||||||
id: StepId | NodeId
|
id: StepId | ButtonId
|
||||||
ref: MutableRefObject<HTMLDivElement | null>
|
ref: MutableRefObject<HTMLDivElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BlocksCoordinates = { byId: { [key: string]: Coordinates } }
|
||||||
|
|
||||||
const graphContext = createContext<{
|
const graphContext = createContext<{
|
||||||
|
blocksCoordinates?: BlocksCoordinates
|
||||||
|
updateBlockCoordinates: (blockId: string, newCoord: Coordinates) => void
|
||||||
graphPosition: Position
|
graphPosition: Position
|
||||||
setGraphPosition: Dispatch<SetStateAction<Position>>
|
setGraphPosition: Dispatch<SetStateAction<Position>>
|
||||||
connectingIds: ConnectingIds | null
|
connectingIds: ConnectingIds | null
|
||||||
@ -67,6 +73,7 @@ const graphContext = createContext<{
|
|||||||
addTargetEndpoint: (endpoint: Endpoint) => void
|
addTargetEndpoint: (endpoint: Endpoint) => void
|
||||||
openedStepId?: string
|
openedStepId?: string
|
||||||
setOpenedStepId: Dispatch<SetStateAction<string | undefined>>
|
setOpenedStepId: Dispatch<SetStateAction<string | undefined>>
|
||||||
|
isReadOnly: boolean
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({
|
}>({
|
||||||
@ -74,7 +81,15 @@ const graphContext = createContext<{
|
|||||||
connectingIds: null,
|
connectingIds: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GraphProvider = ({ children }: { children: ReactNode }) => {
|
export const GraphProvider = ({
|
||||||
|
children,
|
||||||
|
typebot,
|
||||||
|
isReadOnly = false,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
typebot?: Typebot
|
||||||
|
isReadOnly?: boolean
|
||||||
|
}) => {
|
||||||
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
|
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
|
||||||
const [connectingIds, setConnectingIds] = useState<ConnectingIds | null>(null)
|
const [connectingIds, setConnectingIds] = useState<ConnectingIds | null>(null)
|
||||||
const [previewingEdgeId, setPreviewingEdgeId] = useState<string>()
|
const [previewingEdgeId, setPreviewingEdgeId] = useState<string>()
|
||||||
@ -87,6 +102,24 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
allIds: [],
|
allIds: [],
|
||||||
})
|
})
|
||||||
const [openedStepId, setOpenedStepId] = useState<string>()
|
const [openedStepId, setOpenedStepId] = useState<string>()
|
||||||
|
const [blocksCoordinates, setBlocksCoordinates] = useImmer<
|
||||||
|
BlocksCoordinates | undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBlocksCoordinates(
|
||||||
|
typebot?.blocks.allIds.reduce(
|
||||||
|
(coords, blockId) => ({
|
||||||
|
byId: {
|
||||||
|
...coords.byId,
|
||||||
|
[blockId]: typebot.blocks.byId[blockId].graphCoordinates,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ byId: {} }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [typebot?.blocks])
|
||||||
|
|
||||||
const addSourceEndpoint = (endpoint: Endpoint) => {
|
const addSourceEndpoint = (endpoint: Endpoint) => {
|
||||||
setSourceEndpoints((endpoints) => ({
|
setSourceEndpoints((endpoints) => ({
|
||||||
@ -102,6 +135,12 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateBlockCoordinates = (blockId: string, newCoord: Coordinates) =>
|
||||||
|
setBlocksCoordinates((blocksCoordinates) => {
|
||||||
|
if (!blocksCoordinates) return
|
||||||
|
blocksCoordinates.byId[blockId] = newCoord
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<graphContext.Provider
|
<graphContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@ -117,6 +156,9 @@ export const GraphProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
addTargetEndpoint,
|
addTargetEndpoint,
|
||||||
openedStepId,
|
openedStepId,
|
||||||
setOpenedStepId,
|
setOpenedStepId,
|
||||||
|
blocksCoordinates,
|
||||||
|
updateBlockCoordinates,
|
||||||
|
isReadOnly,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -24,14 +24,14 @@ import { fetcher, omit, preventUserFromRefreshing } from 'services/utils'
|
|||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
import { isDefined } from 'utils'
|
import { isDefined } from 'utils'
|
||||||
import { BlocksActions, blocksActions } from './actions/blocks'
|
import { BlocksActions, blocksActions } from './actions/blocks'
|
||||||
import { useImmer, Updater } from 'use-immer'
|
|
||||||
import { stepsAction, StepsActions } from './actions/steps'
|
import { stepsAction, StepsActions } from './actions/steps'
|
||||||
import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems'
|
import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems'
|
||||||
import { variablesAction, VariablesActions } from './actions/variables'
|
import { variablesAction, VariablesActions } from './actions/variables'
|
||||||
import { edgesAction, EdgesActions } from './actions/edges'
|
import { edgesAction, EdgesActions } from './actions/edges'
|
||||||
import { webhooksAction, WebhooksAction } from './actions/webhooks'
|
import { webhooksAction, WebhooksAction } from './actions/webhooks'
|
||||||
|
import { useRegisterActions } from 'kbar'
|
||||||
|
import useUndo from 'services/utils/useUndo'
|
||||||
import { useDebounce } from 'use-debounce'
|
import { useDebounce } from 'use-debounce'
|
||||||
|
|
||||||
const autoSaveTimeout = 40000
|
const autoSaveTimeout = 40000
|
||||||
|
|
||||||
type UpdateTypebotPayload = Partial<{
|
type UpdateTypebotPayload = Partial<{
|
||||||
@ -40,6 +40,8 @@ type UpdateTypebotPayload = Partial<{
|
|||||||
publicId: string
|
publicId: string
|
||||||
name: string
|
name: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
export type SetTypebot = (typebot: Typebot | undefined) => void
|
||||||
const typebotContext = createContext<
|
const typebotContext = createContext<
|
||||||
{
|
{
|
||||||
typebot?: Typebot
|
typebot?: Typebot
|
||||||
@ -50,6 +52,9 @@ const typebotContext = createContext<
|
|||||||
isSavingLoading: boolean
|
isSavingLoading: boolean
|
||||||
save: () => Promise<ToastId | undefined>
|
save: () => Promise<ToastId | undefined>
|
||||||
undo: () => void
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
canRedo: boolean
|
||||||
|
canUndo: boolean
|
||||||
updateTypebot: (updates: UpdateTypebotPayload) => void
|
updateTypebot: (updates: UpdateTypebotPayload) => void
|
||||||
publishTypebot: () => void
|
publishTypebot: () => void
|
||||||
} & BlocksActions &
|
} & BlocksActions &
|
||||||
@ -74,7 +79,6 @@ export const TypebotContext = ({
|
|||||||
position: 'top-right',
|
position: 'top-right',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
})
|
})
|
||||||
const [undoStack, setUndoStack] = useState<Typebot[]>([])
|
|
||||||
const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({
|
const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({
|
||||||
typebotId,
|
typebotId,
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
@ -84,20 +88,28 @@ export const TypebotContext = ({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const [localTypebot, setLocalTypebot] = useImmer<Typebot | undefined>(
|
const [
|
||||||
undefined
|
{ present: localTypebot },
|
||||||
)
|
{
|
||||||
|
redo,
|
||||||
|
undo,
|
||||||
|
canRedo,
|
||||||
|
canUndo,
|
||||||
|
set: setLocalTypebot,
|
||||||
|
presentRef: currentTypebotRef,
|
||||||
|
},
|
||||||
|
] = useUndo<Typebot | undefined>(undefined)
|
||||||
|
|
||||||
const [debouncedLocalTypebot] = useDebounce(localTypebot, autoSaveTimeout)
|
const saveTypebot = async () => {
|
||||||
useEffect(() => {
|
const typebotToSave = currentTypebotRef.current
|
||||||
if (hasUnsavedChanges) saveTypebot()
|
if (!typebotToSave) return
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setIsSavingLoading(true)
|
||||||
}, [debouncedLocalTypebot])
|
const { error } = await updateTypebot(typebotToSave.id, typebotToSave)
|
||||||
|
setIsSavingLoading(false)
|
||||||
const [localPublishedTypebot, setLocalPublishedTypebot] =
|
if (error) return toast({ title: error.name, description: error.message })
|
||||||
useState<PublicTypebot>()
|
mutate({ typebot: typebotToSave })
|
||||||
const [isSavingLoading, setIsSavingLoading] = useState(false)
|
window.removeEventListener('beforeunload', preventUserFromRefreshing)
|
||||||
const [isPublishing, setIsPublishing] = useState(false)
|
}
|
||||||
|
|
||||||
const hasUnsavedChanges = useMemo(
|
const hasUnsavedChanges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -107,6 +119,18 @@ export const TypebotContext = ({
|
|||||||
[typebot, localTypebot]
|
[typebot, localTypebot]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useAutoSave({
|
||||||
|
handler: saveTypebot,
|
||||||
|
item: localTypebot,
|
||||||
|
canSave: hasUnsavedChanges,
|
||||||
|
debounceTimeout: autoSaveTimeout,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [localPublishedTypebot, setLocalPublishedTypebot] =
|
||||||
|
useState<PublicTypebot>()
|
||||||
|
const [isSavingLoading, setIsSavingLoading] = useState(false)
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false)
|
||||||
|
|
||||||
const isPublished = useMemo(
|
const isPublished = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isDefined(typebot) &&
|
isDefined(typebot) &&
|
||||||
@ -117,8 +141,8 @@ export const TypebotContext = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!localTypebot || !typebot) return
|
if (!localTypebot || !typebot) return
|
||||||
|
currentTypebotRef.current = localTypebot
|
||||||
if (!checkIfTypebotsAreEqual(localTypebot, typebot)) {
|
if (!checkIfTypebotsAreEqual(localTypebot, typebot)) {
|
||||||
pushNewTypebotInUndoStack(localTypebot)
|
|
||||||
window.removeEventListener('beforeunload', preventUserFromRefreshing)
|
window.removeEventListener('beforeunload', preventUserFromRefreshing)
|
||||||
window.addEventListener('beforeunload', preventUserFromRefreshing)
|
window.addEventListener('beforeunload', preventUserFromRefreshing)
|
||||||
} else {
|
} else {
|
||||||
@ -139,43 +163,30 @@ export const TypebotContext = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isLoading])
|
}, [isLoading])
|
||||||
|
|
||||||
const pushNewTypebotInUndoStack = (typebot: Typebot) => {
|
useRegisterActions(
|
||||||
setUndoStack([...undoStack, typebot])
|
[
|
||||||
}
|
{
|
||||||
|
id: 'save',
|
||||||
|
name: 'Save typebot',
|
||||||
|
perform: () => saveTypebot(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const undo = () => {
|
useRegisterActions(
|
||||||
const lastTypebot = [...undoStack].pop()
|
[
|
||||||
setUndoStack(undoStack.slice(0, -1))
|
{
|
||||||
setLocalTypebot(lastTypebot)
|
id: 'undo',
|
||||||
}
|
name: 'Undo changes',
|
||||||
|
perform: undo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[localTypebot]
|
||||||
|
)
|
||||||
|
|
||||||
const saveTypebot = async (typebot?: Typebot) => {
|
const updateLocalTypebot = (updates: UpdateTypebotPayload) =>
|
||||||
if (!localTypebot) return
|
localTypebot && setLocalTypebot({ ...localTypebot, ...updates })
|
||||||
setIsSavingLoading(true)
|
|
||||||
const { error } = await updateTypebot(
|
|
||||||
typebot?.id ?? localTypebot.id,
|
|
||||||
typebot ?? localTypebot
|
|
||||||
)
|
|
||||||
setIsSavingLoading(false)
|
|
||||||
if (error) return toast({ title: error.name, description: error.message })
|
|
||||||
mutate({ typebot: typebot ?? localTypebot })
|
|
||||||
window.removeEventListener('beforeunload', preventUserFromRefreshing)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateLocalTypebot = ({
|
|
||||||
publicId,
|
|
||||||
settings,
|
|
||||||
theme,
|
|
||||||
name,
|
|
||||||
}: UpdateTypebotPayload) => {
|
|
||||||
setLocalTypebot((typebot) => {
|
|
||||||
if (!typebot) return
|
|
||||||
if (publicId) typebot.publicId = publicId
|
|
||||||
if (settings) typebot.settings = settings
|
|
||||||
if (theme) typebot.theme = theme
|
|
||||||
if (name) typebot.name = name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishTypebot = async () => {
|
const publishTypebot = async () => {
|
||||||
if (!localTypebot) return
|
if (!localTypebot) return
|
||||||
@ -188,8 +199,7 @@ export const TypebotContext = ({
|
|||||||
updateLocalTypebot({ publicId: newPublicId })
|
updateLocalTypebot({ publicId: newPublicId })
|
||||||
newLocalTypebot.publicId = newPublicId
|
newLocalTypebot.publicId = newPublicId
|
||||||
}
|
}
|
||||||
if (hasUnsavedChanges || !localPublishedTypebot)
|
if (hasUnsavedChanges || !localPublishedTypebot) await saveTypebot()
|
||||||
await saveTypebot(newLocalTypebot)
|
|
||||||
setIsPublishing(true)
|
setIsPublishing(true)
|
||||||
if (localPublishedTypebot) {
|
if (localPublishedTypebot) {
|
||||||
const { error } = await updatePublishedTypebot(
|
const { error } = await updatePublishedTypebot(
|
||||||
@ -218,16 +228,19 @@ export const TypebotContext = ({
|
|||||||
isSavingLoading,
|
isSavingLoading,
|
||||||
save: saveTypebot,
|
save: saveTypebot,
|
||||||
undo,
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
publishTypebot,
|
publishTypebot,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
isPublished,
|
isPublished,
|
||||||
updateTypebot: updateLocalTypebot,
|
updateTypebot: updateLocalTypebot,
|
||||||
...blocksActions(setLocalTypebot as Updater<Typebot>),
|
...blocksActions(localTypebot as Typebot, setLocalTypebot),
|
||||||
...stepsAction(setLocalTypebot as Updater<Typebot>),
|
...stepsAction(localTypebot as Typebot, setLocalTypebot),
|
||||||
...choiceItemsAction(setLocalTypebot as Updater<Typebot>),
|
...choiceItemsAction(localTypebot as Typebot, setLocalTypebot),
|
||||||
...variablesAction(setLocalTypebot as Updater<Typebot>),
|
...variablesAction(localTypebot as Typebot, setLocalTypebot),
|
||||||
...edgesAction(setLocalTypebot as Updater<Typebot>),
|
...edgesAction(localTypebot as Typebot, setLocalTypebot),
|
||||||
...webhooksAction(setLocalTypebot as Updater<Typebot>),
|
...webhooksAction(localTypebot as Typebot, setLocalTypebot),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -256,3 +269,22 @@ export const useFetchedTypebot = ({
|
|||||||
mutate,
|
mutate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useAutoSave = <T,>({
|
||||||
|
handler,
|
||||||
|
item,
|
||||||
|
canSave,
|
||||||
|
debounceTimeout,
|
||||||
|
}: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
handler: (item?: T) => Promise<any>
|
||||||
|
item?: T
|
||||||
|
canSave: boolean
|
||||||
|
debounceTimeout: number
|
||||||
|
}) => {
|
||||||
|
const [debouncedItem] = useDebounce(item, debounceTimeout)
|
||||||
|
return useEffect(() => {
|
||||||
|
if (canSave) handler(item)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [debouncedItem])
|
||||||
|
}
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { Coordinates } from 'contexts/GraphContext'
|
import { Coordinates } from 'contexts/GraphContext'
|
||||||
|
import { produce } from 'immer'
|
||||||
import { WritableDraft } from 'immer/dist/internal'
|
import { WritableDraft } from 'immer/dist/internal'
|
||||||
import { Block, DraggableStep, DraggableStepType, Typebot } from 'models'
|
import { Block, DraggableStep, DraggableStepType, Typebot } from 'models'
|
||||||
import { parseNewBlock } from 'services/typebots'
|
import { SetTypebot } from '../TypebotContext'
|
||||||
import { Updater } from 'use-immer'
|
|
||||||
import { deleteEdgeDraft } from './edges'
|
import { deleteEdgeDraft } from './edges'
|
||||||
import { createStepDraft, deleteStepDraft } from './steps'
|
import { createStepDraft, deleteStepDraft } from './steps'
|
||||||
|
|
||||||
export type BlocksActions = {
|
export type BlocksActions = {
|
||||||
createBlock: (
|
createBlock: (
|
||||||
props: Coordinates & {
|
props: Coordinates & {
|
||||||
|
id: string
|
||||||
step: DraggableStep | DraggableStepType
|
step: DraggableStep | DraggableStepType
|
||||||
}
|
}
|
||||||
) => void
|
) => void
|
||||||
@ -16,38 +17,50 @@ export type BlocksActions = {
|
|||||||
deleteBlock: (blockId: string) => void
|
deleteBlock: (blockId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({
|
export const blocksActions = (
|
||||||
|
typebot: Typebot,
|
||||||
|
setTypebot: SetTypebot
|
||||||
|
): BlocksActions => ({
|
||||||
createBlock: ({
|
createBlock: ({
|
||||||
x,
|
id,
|
||||||
y,
|
|
||||||
step,
|
step,
|
||||||
|
...graphCoordinates
|
||||||
}: Coordinates & {
|
}: Coordinates & {
|
||||||
|
id: string
|
||||||
step: DraggableStep | DraggableStepType
|
step: DraggableStep | DraggableStepType
|
||||||
}) => {
|
}) => {
|
||||||
setTypebot((typebot) => {
|
setTypebot(
|
||||||
const newBlock = parseNewBlock({
|
produce(typebot, (typebot) => {
|
||||||
totalBlocks: typebot.blocks.allIds.length,
|
const newBlock: Block = {
|
||||||
initialCoordinates: { x, y },
|
id,
|
||||||
|
graphCoordinates,
|
||||||
|
title: `Block ${typebot.blocks.allIds.length}`,
|
||||||
|
stepIds: [],
|
||||||
|
}
|
||||||
|
typebot.blocks.byId[newBlock.id] = newBlock
|
||||||
|
typebot.blocks.allIds.push(newBlock.id)
|
||||||
|
createStepDraft(typebot, step, newBlock.id)
|
||||||
|
removeEmptyBlocks(typebot)
|
||||||
})
|
})
|
||||||
typebot.blocks.byId[newBlock.id] = newBlock
|
)
|
||||||
typebot.blocks.allIds.push(newBlock.id)
|
|
||||||
createStepDraft(typebot, step, newBlock.id)
|
|
||||||
removeEmptyBlocks(typebot)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) =>
|
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) =>
|
||||||
setTypebot((typebot) => {
|
setTypebot(
|
||||||
typebot.blocks.byId[blockId] = {
|
produce(typebot, (typebot) => {
|
||||||
...typebot.blocks.byId[blockId],
|
typebot.blocks.byId[blockId] = {
|
||||||
...updates,
|
...typebot.blocks.byId[blockId],
|
||||||
}
|
...updates,
|
||||||
}),
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
deleteBlock: (blockId: string) =>
|
deleteBlock: (blockId: string) =>
|
||||||
setTypebot((typebot) => {
|
setTypebot(
|
||||||
deleteStepsInsideBlock(typebot, blockId)
|
produce(typebot, (typebot) => {
|
||||||
deleteAssociatedEdges(typebot, blockId)
|
deleteStepsInsideBlock(typebot, blockId)
|
||||||
deleteBlockDraft(typebot)(blockId)
|
deleteAssociatedEdges(typebot, blockId)
|
||||||
}),
|
deleteBlockDraft(typebot)(blockId)
|
||||||
|
})
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const removeEmptyBlocks = (typebot: WritableDraft<Typebot>) => {
|
export const removeEmptyBlocks = (typebot: WritableDraft<Typebot>) => {
|
||||||
@ -72,7 +85,7 @@ const deleteStepsInsideBlock = (
|
|||||||
blockId: string
|
blockId: string
|
||||||
) => {
|
) => {
|
||||||
const block = typebot.blocks.byId[blockId]
|
const block = typebot.blocks.byId[blockId]
|
||||||
block.stepIds.forEach((stepId) => deleteStepDraft(typebot, stepId))
|
block.stepIds.forEach((stepId) => deleteStepDraft(stepId)(typebot))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteBlockDraft =
|
export const deleteBlockDraft =
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user