diff --git a/apps/builder/assets/icons.tsx b/apps/builder/assets/icons.tsx index 13ea30c81c..a15f0df5ea 100644 --- a/apps/builder/assets/icons.tsx +++ b/apps/builder/assets/icons.tsx @@ -70,3 +70,35 @@ export const FolderPlusIcon = (props: IconProps) => ( ) + +export const TextIcon = (props: IconProps) => ( + + + + + +) + +export const ImageIcon = (props: IconProps) => ( + + + + + +) + +export const CalendarIcon = (props: IconProps) => ( + + + + + + +) + +export const FlagIcon = (props: IconProps) => ( + + + + +) diff --git a/apps/builder/components/board/Board.tsx b/apps/builder/components/board/Board.tsx new file mode 100644 index 0000000000..a059904ca5 --- /dev/null +++ b/apps/builder/components/board/Board.tsx @@ -0,0 +1,14 @@ +import { Flex } from '@chakra-ui/react' +import React from 'react' +import StepsList from './StepTypesList' +import Graph from './graph/Graph' +import { DndContext } from 'contexts/DndContext' + +export const Board = () => ( + + + + + + +) diff --git a/apps/builder/components/board/StepTypesList/StepCard.tsx b/apps/builder/components/board/StepTypesList/StepCard.tsx new file mode 100644 index 0000000000..ba5a0fc255 --- /dev/null +++ b/apps/builder/components/board/StepTypesList/StepCard.tsx @@ -0,0 +1,70 @@ +import { Button, ButtonProps, Flex, HStack } from '@chakra-ui/react' +import { StepType } from 'bot-engine' +import { useDnd } from 'contexts/DndContext' +import React, { useEffect, useState } from 'react' +import { StepIcon } from './StepIcon' +import { StepLabel } from './StepLabel' + +export const StepCard = ({ + type, + onMouseDown, +}: { + type: StepType + onMouseDown: (e: React.MouseEvent, type: StepType) => void +}) => { + const { draggedStepType } = useDnd() + const [isMouseDown, setIsMouseDown] = useState(false) + + useEffect(() => { + setIsMouseDown(draggedStepType === type) + }, [draggedStepType, type]) + + const handleMouseDown = (e: React.MouseEvent) => onMouseDown(e, type) + + return ( + + + + ) +} + +export const StepCardOverlay = ({ + type, + ...props +}: Omit & { type: StepType }) => { + return ( + + ) +} diff --git a/apps/builder/components/board/StepTypesList/StepIcon.tsx b/apps/builder/components/board/StepTypesList/StepIcon.tsx new file mode 100644 index 0000000000..fa425c3eac --- /dev/null +++ b/apps/builder/components/board/StepTypesList/StepIcon.tsx @@ -0,0 +1,25 @@ +import { CalendarIcon, FlagIcon, ImageIcon, TextIcon } from 'assets/icons' +import { StepType } from 'bot-engine' +import React from 'react' + +type StepIconProps = { type: StepType } + +export const StepIcon = ({ type }: StepIconProps) => { + switch (type) { + case StepType.TEXT: { + return + } + case StepType.IMAGE: { + return + } + case StepType.DATE_PICKER: { + return + } + case StepType.START: { + return + } + default: { + return + } + } +} diff --git a/apps/builder/components/board/StepTypesList/StepLabel.tsx b/apps/builder/components/board/StepTypesList/StepLabel.tsx new file mode 100644 index 0000000000..4d1b9a75b7 --- /dev/null +++ b/apps/builder/components/board/StepTypesList/StepLabel.tsx @@ -0,0 +1,22 @@ +import { Text } from '@chakra-ui/react' +import { StepType } from 'bot-engine' +import React from 'react' + +type Props = { type: StepType } + +export const StepLabel = ({ type }: Props) => { + switch (type) { + case StepType.TEXT: { + return Text + } + case StepType.IMAGE: { + return Image + } + case StepType.DATE_PICKER: { + return Date + } + default: { + return <> + } + } +} diff --git a/apps/builder/components/board/StepTypesList/StepTypesList.tsx b/apps/builder/components/board/StepTypesList/StepTypesList.tsx new file mode 100644 index 0000000000..84cca87e52 --- /dev/null +++ b/apps/builder/components/board/StepTypesList/StepTypesList.tsx @@ -0,0 +1,104 @@ +import { + Stack, + Input, + Text, + SimpleGrid, + useEventListener, +} from '@chakra-ui/react' +import { StepType } from 'bot-engine' +import { useDnd } from 'contexts/DndContext' +import React, { useState } from 'react' +import { StepCard, StepCardOverlay } from './StepCard' + +export const stepListItems: { + bubbles: { type: StepType }[] + inputs: { type: StepType }[] +} = { + bubbles: [{ type: StepType.TEXT }, { type: StepType.IMAGE }], + inputs: [{ type: StepType.DATE_PICKER }], +} + +export const StepTypesList = () => { + const { setDraggedStepType, draggedStepType } = useDnd() + const [position, setPosition] = useState({ + x: 0, + y: 0, + }) + const [relativeCoordinates, setRelativeCoordinates] = useState({ x: 0, y: 0 }) + + const handleMouseMove = (event: MouseEvent) => { + if (!draggedStepType) return + const { clientX, clientY } = event + setPosition({ + ...position, + x: clientX - relativeCoordinates.x, + y: clientY - relativeCoordinates.y, + }) + } + useEventListener('mousemove', handleMouseMove) + + const handleMouseDown = (e: React.MouseEvent, type: StepType) => { + const element = e.currentTarget as HTMLDivElement + const rect = element.getBoundingClientRect() + const relativeX = e.clientX - rect.left + const relativeY = e.clientY - rect.top + setPosition({ x: e.clientX - relativeX, y: e.clientY - relativeY }) + setRelativeCoordinates({ x: relativeX, y: relativeY }) + setDraggedStepType(type) + } + + const handleMouseUp = () => { + if (!draggedStepType) return + setDraggedStepType(undefined) + setPosition({ + x: 0, + y: 0, + }) + } + useEventListener('mouseup', handleMouseUp) + + return ( + + + + Bubbles + + + {stepListItems.bubbles.map((props) => ( + + ))} + + + + Inputs + + + {stepListItems.inputs.map((props) => ( + + ))} + + {draggedStepType && ( + + )} + + ) +} diff --git a/apps/builder/components/board/StepTypesList/index.tsx b/apps/builder/components/board/StepTypesList/index.tsx new file mode 100644 index 0000000000..c62cbe0216 --- /dev/null +++ b/apps/builder/components/board/StepTypesList/index.tsx @@ -0,0 +1 @@ +export { StepTypesList as default } from './StepTypesList' diff --git a/apps/builder/components/board/graph/BlockNode/BlockNode.tsx b/apps/builder/components/board/graph/BlockNode/BlockNode.tsx new file mode 100644 index 0000000000..a8cb79ee69 --- /dev/null +++ b/apps/builder/components/board/graph/BlockNode/BlockNode.tsx @@ -0,0 +1,112 @@ +import { + Editable, + EditableInput, + EditablePreview, + Stack, + useEventListener, +} from '@chakra-ui/react' +import React, { useEffect, useRef, useState } from 'react' +import { Block, StartBlock } from 'bot-engine' +import { useGraph } from 'contexts/GraphContext' +import { useDnd } from 'contexts/DndContext' +import { StepsList } from './StepsList' +import { isNotDefined } from 'services/utils' + +export const BlockNode = ({ block }: { block: Block | StartBlock }) => { + const { + updateBlockPosition, + addNewStepToBlock, + connectingIds, + setConnectingIds, + } = useGraph() + const { draggedStep, draggedStepType, setDraggedStepType, setDraggedStep } = + useDnd() + const blockRef = useRef(null) + const [isMouseDown, setIsMouseDown] = useState(false) + const [titleValue, setTitleValue] = useState(block.title) + const [showSortPlaceholders, setShowSortPlaceholders] = useState(false) + const [isConnecting, setIsConnecting] = useState(false) + + useEffect(() => { + setIsConnecting( + connectingIds?.target?.blockId === block.id && + isNotDefined(connectingIds.target?.stepId) + ) + }, [block.id, connectingIds]) + + const handleTitleChange = (title: string) => setTitleValue(title) + + 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) + + const handleMouseEnter = () => { + if (draggedStepType || draggedStep) setShowSortPlaceholders(true) + if (connectingIds) + setConnectingIds({ ...connectingIds, target: { blockId: block.id } }) + } + + const handleMouseLeave = () => { + setShowSortPlaceholders(false) + if (connectingIds) setConnectingIds({ ...connectingIds, target: undefined }) + } + + const handleStepDrop = (index: number) => { + setShowSortPlaceholders(false) + if (draggedStepType) { + addNewStepToBlock(block.id, draggedStepType, index) + setDraggedStepType(undefined) + } + if (draggedStep) { + addNewStepToBlock(block.id, draggedStep, index) + setDraggedStep(undefined) + } + } + + return ( + + + + + + + + ) +} diff --git a/apps/builder/components/board/graph/BlockNode/StartBlockNode.tsx b/apps/builder/components/board/graph/BlockNode/StartBlockNode.tsx new file mode 100644 index 0000000000..d214b555e9 --- /dev/null +++ b/apps/builder/components/board/graph/BlockNode/StartBlockNode.tsx @@ -0,0 +1,66 @@ +import { + Editable, + EditableInput, + EditablePreview, + Stack, + useEventListener, +} from '@chakra-ui/react' +import React, { useState } from 'react' +import { StartBlock } from 'bot-engine' +import { useGraph } from 'contexts/GraphContext' +import { StepNode } from './StepNode' + +export const StartBlockNode = ({ block }: { block: StartBlock }) => { + const { setStartBlock } = useGraph() + const [isMouseDown, setIsMouseDown] = useState(false) + const [titleValue, setTitleValue] = useState(block.title) + + const handleTitleChange = (title: string) => setTitleValue(title) + + const handleMouseDown = () => { + setIsMouseDown(true) + } + const handleMouseUp = () => { + setIsMouseDown(false) + } + + const handleMouseMove = (event: MouseEvent) => { + if (!isMouseDown) return + const { movementX, movementY } = event + + setStartBlock({ + ...block, + graphCoordinates: { + x: block.graphCoordinates.x + movementX, + y: block.graphCoordinates.y + movementY, + }, + }) + } + + useEventListener('mousemove', handleMouseMove) + + return ( + + + + + + + + ) +} diff --git a/apps/builder/components/board/graph/BlockNode/StepNode/SourceEndpoint.tsx b/apps/builder/components/board/graph/BlockNode/StepNode/SourceEndpoint.tsx new file mode 100644 index 0000000000..14ac0520f2 --- /dev/null +++ b/apps/builder/components/board/graph/BlockNode/StepNode/SourceEndpoint.tsx @@ -0,0 +1,26 @@ +import { Box, BoxProps } from '@chakra-ui/react' +import React, { MouseEvent } from 'react' + +export const SourceEndpoint = ({ + onConnectionDragStart, + ...props +}: BoxProps & { + onConnectionDragStart?: () => void +}) => { + const handleMouseDown = (e: MouseEvent) => { + if (!onConnectionDragStart) return + e.stopPropagation() + onConnectionDragStart() + } + + return ( + + ) +} diff --git a/apps/builder/components/board/graph/BlockNode/StepNode/StepNode.tsx b/apps/builder/components/board/graph/BlockNode/StepNode/StepNode.tsx new file mode 100644 index 0000000000..0b9ca24a84 --- /dev/null +++ b/apps/builder/components/board/graph/BlockNode/StepNode/StepNode.tsx @@ -0,0 +1,205 @@ +import { Box, Flex, HStack, StackProps, Text } from '@chakra-ui/react' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { Block, StartStep, Step, StepType } from 'bot-engine' +import { SourceEndpoint } from './SourceEndpoint' +import { useGraph } from 'contexts/GraphContext' +import { StepIcon } from 'components/board/StepTypesList/StepIcon' +import { isDefined } from 'services/utils' + +export const StepNode = ({ + step, + isConnectable, + onMouseMoveBottomOfElement, + onMouseMoveTopOfElement, + onMouseDown, +}: { + step: Step | StartStep + isConnectable: boolean + onMouseMoveBottomOfElement?: () => void + onMouseMoveTopOfElement?: () => void + onMouseDown?: (e: React.MouseEvent, step: Step) => void +}) => { + const stepRef = useRef(null) + const { + setConnectingIds, + removeStepFromBlock, + blocks, + connectingIds, + startBlock, + } = useGraph() + const [isConnecting, setIsConnecting] = useState(false) + + useEffect(() => { + setIsConnecting( + connectingIds?.target?.blockId === step.blockId && + connectingIds?.target?.stepId === step.id + ) + }, [connectingIds, step.blockId, step.id]) + + const handleMouseEnter = () => { + if (connectingIds?.target) + setConnectingIds({ + ...connectingIds, + target: { ...connectingIds.target, stepId: step.id }, + }) + } + + const handleMouseLeave = () => { + if (connectingIds?.target) + setConnectingIds({ + ...connectingIds, + target: { ...connectingIds.target, stepId: undefined }, + }) + } + + const handleConnectionDragStart = () => { + setConnectingIds({ blockId: step.blockId, stepId: step.id }) + } + + const handleMouseDown = (e: React.MouseEvent) => { + if (!onMouseDown) return + e.stopPropagation() + onMouseDown(e, step as Step) + removeStepFromBlock(step.blockId, step.id) + } + + const handleMouseMove = (event: React.MouseEvent) => { + if (!onMouseMoveBottomOfElement || !onMouseMoveTopOfElement) return + const element = event.currentTarget as HTMLDivElement + const rect = element.getBoundingClientRect() + const y = event.clientY - rect.top + if (y > rect.height / 2) onMouseMoveBottomOfElement() + else onMouseMoveTopOfElement() + } + + const connectedStubPosition: 'right' | 'left' | undefined = useMemo(() => { + const currentBlock = [startBlock, ...blocks].find( + (b) => b?.id === step.blockId + ) + const isDragginConnectorFromCurrentBlock = + connectingIds?.blockId === currentBlock?.id && + connectingIds?.target?.blockId + const targetBlockId = isDragginConnectorFromCurrentBlock + ? connectingIds.target?.blockId + : step.target?.blockId + const targetedBlock = targetBlockId + ? blocks.find((b) => b.id === targetBlockId) + : undefined + return targetedBlock + ? targetedBlock.graphCoordinates.x < + (currentBlock as Block).graphCoordinates.x + ? 'left' + : 'right' + : undefined + }, [ + blocks, + connectingIds?.blockId, + connectingIds?.target?.blockId, + step.blockId, + step.target?.blockId, + startBlock, + ]) + + return ( + + {connectedStubPosition === 'left' && ( + + )} + + + + {isConnectable && ( + + )} + + + {isDefined(connectedStubPosition) && ( + + )} + + ) +} + +export const StepContent = (props: Step | StartStep) => { + switch (props.type) { + case StepType.TEXT: { + return ( + + {props.content === '' ? 'Type text...' : props.content} + + ) + } + case StepType.DATE_PICKER: { + return ( + + {props.content === '' ? 'Pick a date...' : props.content} + + ) + } + case StepType.START: { + return {props.label} + } + default: { + return No input + } + } +} + +export const StepNodeOverlay = ({ + step, + ...props +}: { step: Step } & StackProps) => { + return ( + + + + + ) +} diff --git a/apps/builder/components/board/graph/BlockNode/StepNode/index.tsx b/apps/builder/components/board/graph/BlockNode/StepNode/index.tsx new file mode 100644 index 0000000000..b367912970 --- /dev/null +++ b/apps/builder/components/board/graph/BlockNode/StepNode/index.tsx @@ -0,0 +1 @@ +export { StepNode, StepNodeOverlay } from './StepNode' diff --git a/apps/builder/components/board/graph/BlockNode/StepsList.tsx b/apps/builder/components/board/graph/BlockNode/StepsList.tsx new file mode 100644 index 0000000000..d731d1d848 --- /dev/null +++ b/apps/builder/components/board/graph/BlockNode/StepsList.tsx @@ -0,0 +1,129 @@ +import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react' +import { StartStep, Step } from 'bot-engine' +import { useDnd } from 'contexts/DndContext' +import { useState } from 'react' +import { StepNode, StepNodeOverlay } from './StepNode' + +export const StepsList = ({ + blockId, + steps, + showSortPlaceholders, + onMouseUp, +}: { + blockId: string + steps: Step[] | [StartStep] + showSortPlaceholders: boolean + onMouseUp: (index: number) => void +}) => { + const { draggedStep, setDraggedStep, draggedStepType } = useDnd() + const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState< + number | undefined + >() + const [position, setPosition] = useState({ + x: 0, + y: 0, + }) + const [relativeCoordinates, setRelativeCoordinates] = useState({ x: 0, y: 0 }) + + const handleStepMove = (event: MouseEvent) => { + if (!draggedStep) return + const { clientX, clientY } = event + setPosition({ + ...position, + x: clientX - relativeCoordinates.x, + y: clientY - relativeCoordinates.y, + }) + } + useEventListener('mousemove', handleStepMove) + + const handleMouseMove = (event: React.MouseEvent) => { + if (!draggedStep) return + const element = event.currentTarget as HTMLDivElement + const rect = element.getBoundingClientRect() + const y = event.clientY - rect.top + if (y < 20) setExpandedPlaceholderIndex(0) + } + + const handleMouseUp = (e: React.MouseEvent) => { + if (expandedPlaceholderIndex === undefined) return + e.stopPropagation() + setExpandedPlaceholderIndex(undefined) + onMouseUp(expandedPlaceholderIndex) + } + + const handleStepMouseDown = (e: React.MouseEvent, step: Step) => { + const element = e.currentTarget as HTMLDivElement + const rect = element.getBoundingClientRect() + const relativeX = e.clientX - rect.left + const relativeY = e.clientY - rect.top + setPosition({ x: e.clientX - relativeX, y: e.clientY - relativeY }) + setRelativeCoordinates({ x: relativeX, y: relativeY }) + setDraggedStep(step) + } + + const handleMouseOnTopOfStep = (stepIndex: number) => { + if (!draggedStep && !draggedStepType) return + setExpandedPlaceholderIndex(stepIndex === 0 ? 0 : stepIndex) + } + + const handleMouseOnBottomOfStep = (stepIndex: number) => { + if (!draggedStep && !draggedStepType) return + setExpandedPlaceholderIndex(stepIndex + 1) + } + return ( + + + {steps.map((step, idx) => ( + + handleMouseOnTopOfStep(idx)} + onMouseMoveBottomOfElement={() => { + handleMouseOnBottomOfStep(idx) + }} + onMouseDown={handleStepMouseDown} + /> + + + ))} + {draggedStep && draggedStep.blockId === blockId && ( + + + + )} + + ) +} diff --git a/apps/builder/components/board/graph/BlockNode/index.tsx b/apps/builder/components/board/graph/BlockNode/index.tsx new file mode 100644 index 0000000000..50bc6b62a5 --- /dev/null +++ b/apps/builder/components/board/graph/BlockNode/index.tsx @@ -0,0 +1 @@ +export { BlockNode } from './BlockNode' diff --git a/apps/builder/components/board/graph/Edges/DrawingEdge.tsx b/apps/builder/components/board/graph/Edges/DrawingEdge.tsx new file mode 100644 index 0000000000..ece353b0a4 --- /dev/null +++ b/apps/builder/components/board/graph/Edges/DrawingEdge.tsx @@ -0,0 +1,126 @@ +import { useEventListener } from '@chakra-ui/hooks' +import { Coordinates } from '@dnd-kit/core/dist/types' +import { Block } from 'bot-engine' +import { + blockWidth, + firstStepOffsetY, + spaceBetweenSteps, + stubLength, + useGraph, +} from 'contexts/GraphContext' +import React, { useMemo, useState } from 'react' +import { + computeFlowChartConnectorPath, + getAnchorsPosition, +} from 'services/graph' +import { roundCorners } from 'svg-round-corners' + +export const DrawingEdge = () => { + const { + graphPosition, + setConnectingIds, + blocks, + connectingIds, + addTarget, + startBlock, + } = useGraph() + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }) + + const sourceBlock = useMemo( + () => [startBlock, ...blocks].find((b) => b?.id === connectingIds?.blockId), + // eslint-disable-next-line react-hooks/exhaustive-deps + [connectingIds] + ) + + const path = useMemo(() => { + if (!sourceBlock) return `` + if (connectingIds?.target) { + const targetedBlock = blocks.find( + (b) => b.id === connectingIds.target?.blockId + ) as Block + const targetedStepIndex = connectingIds.target.stepId + ? targetedBlock.steps.findIndex( + (s) => s.id === connectingIds.target?.stepId + ) + : undefined + const anchorsPosition = getAnchorsPosition( + sourceBlock, + targetedBlock, + sourceBlock?.steps.findIndex((s) => s.id === connectingIds?.stepId), + targetedStepIndex + ) + return computeFlowChartConnectorPath(anchorsPosition) + } + return computeConnectingEdgePath( + sourceBlock?.graphCoordinates, + mousePosition, + sourceBlock.steps.findIndex((s) => s.id === connectingIds?.stepId) + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sourceBlock, mousePosition]) + + const handleMouseMove = (e: MouseEvent) => { + setMousePosition({ + x: e.clientX - graphPosition.x, + y: e.clientY - graphPosition.y, + }) + } + useEventListener('mousemove', handleMouseMove) + useEventListener('mouseup', () => { + if (connectingIds?.target) addTarget(connectingIds) + setConnectingIds(null) + }) + + if ((mousePosition.x === 0 && mousePosition.y === 0) || !connectingIds) + return <> + return ( + + ) +} + +const computeConnectingEdgePath = ( + blockPosition: Coordinates, + mousePosition: Coordinates, + stepIndex: number +): string => { + const sourcePosition = { + x: + mousePosition.x - blockPosition.x > blockWidth / 2 + ? blockPosition.x + blockWidth - 40 + : blockPosition.x + 40, + y: blockPosition.y + firstStepOffsetY + stepIndex * spaceBetweenSteps, + } + const sourceType = + mousePosition.x - blockPosition.x > blockWidth / 2 ? 'right' : 'left' + const segments = computeThreeSegments( + sourcePosition, + mousePosition, + sourceType + ) + return roundCorners( + `M${sourcePosition.x},${sourcePosition.y} ${segments}`, + 10 + ).path +} + +const computeThreeSegments = ( + sourcePosition: Coordinates, + targetPosition: Coordinates, + sourceType: 'right' | 'left' +) => { + const segments = [] + const firstSegmentX = + sourceType === 'right' + ? sourcePosition.x + stubLength + : sourcePosition.x - stubLength + segments.push(`L${firstSegmentX},${sourcePosition.y}`) + segments.push(`L${firstSegmentX},${targetPosition.y}`) + segments.push(`L${targetPosition.x},${targetPosition.y}`) + return segments.join(' ') +} diff --git a/apps/builder/components/board/graph/Edges/Edge.tsx b/apps/builder/components/board/graph/Edges/Edge.tsx new file mode 100644 index 0000000000..784c4b11ea --- /dev/null +++ b/apps/builder/components/board/graph/Edges/Edge.tsx @@ -0,0 +1,63 @@ +import { Block, StartStep, Step, Target } from 'bot-engine' +import { Coordinates, useGraph } from 'contexts/GraphContext' +import React, { useMemo } from 'react' +import { + getAnchorsPosition, + computeFlowChartConnectorPath, +} from 'services/graph' + +export type AnchorsPositionProps = { + sourcePosition: Coordinates + targetPosition: Coordinates + sourceType: 'right' | 'left' + totalSegments: number +} + +export type StepWithTarget = Omit & { + target: Target +} + +export const Edge = ({ step }: { step: StepWithTarget }) => { + const { blocks, startBlock } = useGraph() + + const { sourceBlock, targetBlock, targetStepIndex } = useMemo(() => { + const targetBlock = blocks.find( + (b) => b?.id === step.target.blockId + ) as Block + const targetStepIndex = step.target.stepId + ? targetBlock.steps.findIndex((s) => s.id === step.target.stepId) + : undefined + return { + sourceBlock: [startBlock, ...blocks].find((b) => b?.id === step.blockId), + targetBlock, + targetStepIndex, + } + }, [ + blocks, + startBlock, + step.blockId, + step.target.blockId, + step.target.stepId, + ]) + + const path = useMemo(() => { + if (!sourceBlock || !targetBlock) return `` + const anchorsPosition = getAnchorsPosition( + sourceBlock, + targetBlock, + sourceBlock.steps.findIndex((s) => s.id === step.id), + targetStepIndex + ) + return computeFlowChartConnectorPath(anchorsPosition) + }, [sourceBlock, step.id, targetBlock, targetStepIndex]) + + return ( + + ) +} diff --git a/apps/builder/components/board/graph/Edges/Edges.tsx b/apps/builder/components/board/graph/Edges/Edges.tsx new file mode 100644 index 0000000000..3f194b8969 --- /dev/null +++ b/apps/builder/components/board/graph/Edges/Edges.tsx @@ -0,0 +1,49 @@ +import { chakra } from '@chakra-ui/system' +import { useGraph } from 'contexts/GraphContext' +import React, { useMemo } from 'react' +import { DrawingEdge } from './DrawingEdge' +import { Edge, StepWithTarget } from './Edge' + +export const Edges = () => { + const { blocks, startBlock } = useGraph() + const stepsWithTarget: StepWithTarget[] = useMemo(() => { + if (!startBlock) return [] + return [ + ...(startBlock.steps.filter((s) => s.target) as StepWithTarget[]), + ...(blocks + .flatMap((b) => b.steps) + .filter((s) => s.target) as StepWithTarget[]), + ] + }, [blocks, startBlock]) + + return ( + + + {stepsWithTarget.map((step) => ( + + ))} + + + + + ) +} diff --git a/apps/builder/components/board/graph/Edges/index.tsx b/apps/builder/components/board/graph/Edges/index.tsx new file mode 100644 index 0000000000..1f83b5a43e --- /dev/null +++ b/apps/builder/components/board/graph/Edges/index.tsx @@ -0,0 +1 @@ +export { Edges } from './Edges' diff --git a/apps/builder/components/board/graph/Graph.tsx b/apps/builder/components/board/graph/Graph.tsx new file mode 100644 index 0000000000..6ba6862905 --- /dev/null +++ b/apps/builder/components/board/graph/Graph.tsx @@ -0,0 +1,91 @@ +import { Flex, useEventListener } from '@chakra-ui/react' +import React, { useRef, useMemo, useEffect } from 'react' +import { blockWidth, useGraph } from 'contexts/GraphContext' +import { BlockNode } from './BlockNode/BlockNode' +import { useDnd } from 'contexts/DndContext' +import { Edges } from './Edges' +import { useTypebot } from 'contexts/TypebotContext' +import { StartBlockNode } from './BlockNode/StartBlockNode' + +const Graph = () => { + const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } = + useDnd() + const graphRef = useRef(null) + const graphContainerRef = useRef(null) + const { typebot } = useTypebot() + const { + blocks, + setBlocks, + graphPosition, + setGraphPosition, + addNewBlock, + setStartBlock, + startBlock, + } = useGraph() + const transform = useMemo( + () => + `translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`, + [graphPosition] + ) + + useEffect(() => { + if (!typebot) return + setBlocks(typebot.blocks) + setStartBlock(typebot.startBlock) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [typebot?.blocks]) + + 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) + + const handleMouseUp = (e: MouseEvent) => { + if (!draggedStep && !draggedStepType) return + addNewBlock({ + step: draggedStep, + type: draggedStepType, + x: e.x - graphPosition.x - blockWidth / 3, + y: e.y - graphPosition.y - 20, + }) + setDraggedStep(undefined) + setDraggedStepType(undefined) + } + useEventListener('mouseup', handleMouseUp, graphContainerRef.current) + + if (!typebot) return <> + return ( + + + + {startBlock && } + {blocks.map((block) => ( + + ))} + + + ) +} + +export default Graph diff --git a/apps/builder/contexts/BoardContext.tsx b/apps/builder/contexts/BoardContext.tsx deleted file mode 100644 index 4ca889bbd3..0000000000 --- a/apps/builder/contexts/BoardContext.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { BrowserJsPlumbInstance } from '@jsplumb/browser-ui' -import { - createContext, - Dispatch, - ReactNode, - SetStateAction, - useContext, - useState, -} from 'react' -import { Step } from 'bot-engine' - -type Position = { x: number; y: number; scale: number } - -const graphPositionDefaultValue = { x: 400, y: 100, scale: 1 } - -const graphContext = createContext<{ - position: Position - setGraphPosition: Dispatch> - plumbInstance?: BrowserJsPlumbInstance - setPlumbInstance: Dispatch> - draggedStep?: Step - setDraggedStep: Dispatch> -}>({ - position: graphPositionDefaultValue, - setGraphPosition: () => { - console.log("I'm not instantiated") - }, - setPlumbInstance: () => { - console.log("I'm not instantiated") - }, - setDraggedStep: () => { - console.log("I'm not instantiated") - }, -}) - -export const GraphProvider = ({ children }: { children: ReactNode }) => { - const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue) - const [plumbInstance, setPlumbInstance] = useState< - BrowserJsPlumbInstance | undefined - >() - const [draggedStep, setDraggedStep] = useState() - - return ( - - {children} - - ) -} - -export const useGraph = () => useContext(graphContext) diff --git a/apps/builder/contexts/DndContext.tsx b/apps/builder/contexts/DndContext.tsx new file mode 100644 index 0000000000..8def57824b --- /dev/null +++ b/apps/builder/contexts/DndContext.tsx @@ -0,0 +1,39 @@ +import { Step, StepType } from 'bot-engine' +import { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useContext, + useState, +} from 'react' + +const dndContext = createContext<{ + draggedStepType?: StepType + setDraggedStepType: Dispatch> + draggedStep?: Step + setDraggedStep: Dispatch> +}>({ + setDraggedStep: () => console.log("I'm not implemented"), + setDraggedStepType: () => console.log("I'm not implemented"), +}) + +export const DndContext = ({ children }: { children: ReactNode }) => { + const [draggedStep, setDraggedStep] = useState() + const [draggedStepType, setDraggedStepType] = useState() + + return ( + + {children} + + ) +} + +export const useDnd = () => useContext(dndContext) diff --git a/apps/builder/contexts/GraphContext.tsx b/apps/builder/contexts/GraphContext.tsx new file mode 100644 index 0000000000..e308d98de7 --- /dev/null +++ b/apps/builder/contexts/GraphContext.tsx @@ -0,0 +1,247 @@ +import { Block, StartBlock, Step, StepType, Target } from 'bot-engine' +import { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useContext, + useState, +} from 'react' +import { parseNewBlock, parseNewStep } from 'services/graph' +import { insertItemInList } from 'services/utils' + +export const stubLength = 20 +export const blockWidth = 300 +export const blockAnchorsOffset = { + left: { + x: 0, + y: 20, + }, + top: { + x: blockWidth / 2, + y: 0, + }, + right: { + x: blockWidth, + y: 20, + }, +} +export const firstStepOffsetY = 88 +export const spaceBetweenSteps = 66 + +export type Coordinates = { x: number; y: number } + +type Position = Coordinates & { scale: number } + +export type Anchor = { + coordinates: Coordinates +} + +export type Node = Omit & { + steps: (Step & { + sourceAnchorsPosition: { left: Coordinates; right: Coordinates } + })[] +} + +export type NewBlockPayload = { + x: number + y: number + type?: StepType + step?: Step +} + +const graphPositionDefaultValue = { x: 400, y: 100, scale: 1 } + +const graphContext = createContext<{ + graphPosition: Position + setGraphPosition: Dispatch> + connectingIds: { blockId: string; stepId: string; target?: Target } | null + setConnectingIds: Dispatch< + SetStateAction<{ blockId: string; stepId: string; target?: Target } | null> + > + startBlock?: StartBlock + setStartBlock: Dispatch> + blocks: Block[] + setBlocks: Dispatch> + addNewBlock: (props: NewBlockPayload) => void + updateBlockPosition: (blockId: string, newPositon: Coordinates) => void + addNewStepToBlock: ( + blockId: string, + step: StepType | Step, + index: number + ) => void + removeStepFromBlock: (blockId: string, stepId: string) => void + addTarget: (connectingIds: { + blockId: string + stepId: string + target?: Target + }) => void + removeTarget: (connectingIds: { blockId: string; stepId: string }) => void +}>({ + graphPosition: graphPositionDefaultValue, + setGraphPosition: () => console.log("I'm not instantiated"), + connectingIds: null, + setConnectingIds: () => console.log("I'm not instantiated"), + blocks: [], + setBlocks: () => console.log("I'm not instantiated"), + updateBlockPosition: () => console.log("I'm not instantiated"), + addNewStepToBlock: () => console.log("I'm not instantiated"), + addNewBlock: () => console.log("I'm not instantiated"), + removeStepFromBlock: () => console.log("I'm not instantiated"), + addTarget: () => console.log("I'm not instantiated"), + removeTarget: () => console.log("I'm not instantiated"), + setStartBlock: () => console.log("I'm not instantiated"), +}) + +export const GraphProvider = ({ children }: { children: ReactNode }) => { + const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue) + const [connectingIds, setConnectingIds] = useState<{ + blockId: string + stepId: string + target?: Target + } | null>(null) + const [blocks, setBlocks] = useState([]) + const [startBlock, setStartBlock] = useState() + + const addNewBlock = ({ x, y, type, step }: NewBlockPayload) => { + const boardCoordinates = { + x, + y, + } + setBlocks((blocks) => [ + ...blocks.filter((block) => block.steps.length > 0), + parseNewBlock({ + step, + type, + totalBlocks: blocks.length, + initialCoordinates: boardCoordinates, + }), + ]) + } + + const updateBlockPosition = (blockId: string, newPosition: Coordinates) => { + setBlocks((blocks) => + blocks.map((block) => + block.id === blockId + ? { ...block, graphCoordinates: newPosition } + : block + ) + ) + } + + const addNewStepToBlock = ( + blockId: string, + step: StepType | Step, + index: number + ) => { + setBlocks((blocks) => + blocks + .map((block) => + block.id === blockId + ? { + ...block, + steps: insertItemInList( + block.steps, + index, + typeof step === 'string' + ? parseNewStep(step as StepType, block.id) + : { ...step, blockId: block.id } + ), + } + : block + ) + .filter((block) => block.steps.length > 0) + ) + } + + const removeStepFromBlock = (blockId: string, stepId: string) => { + setBlocks((blocks) => + blocks.map((block) => + block.id === blockId + ? { + ...block, + steps: [...block.steps.filter((step) => step.id !== stepId)], + } + : block + ) + ) + } + + const addTarget = ({ + blockId, + stepId, + target, + }: { + blockId: string + stepId: string + target?: Target + }) => { + startBlock && blockId === 'start-block' + ? setStartBlock({ + ...startBlock, + steps: [{ ...startBlock.steps[0], target }], + }) + : setBlocks((blocks) => + blocks.map((block) => + block.id === blockId + ? { + ...block, + steps: [ + ...block.steps.map((step) => + step.id === stepId ? { ...step, target } : step + ), + ], + } + : block + ) + ) + } + + const removeTarget = ({ + blockId, + stepId, + }: { + blockId: string + stepId: string + }) => { + setBlocks((blocks) => + blocks.map((block) => + block.id === blockId + ? { + ...block, + steps: [ + ...block.steps.map((step) => + step.id === stepId ? { ...step, target: undefined } : step + ), + ], + } + : block + ) + ) + } + + return ( + + {children} + + ) +} + +export const useGraph = () => useContext(graphContext) diff --git a/apps/builder/contexts/TypebotContext.tsx b/apps/builder/contexts/TypebotContext.tsx new file mode 100644 index 0000000000..ab4eb6d207 --- /dev/null +++ b/apps/builder/contexts/TypebotContext.tsx @@ -0,0 +1,72 @@ +import { useToast } from '@chakra-ui/react' +import { Typebot } from 'bot-engine' +import { useRouter } from 'next/router' +import { createContext, ReactNode, useContext, useEffect } from 'react' +import { fetcher } from 'services/utils' +import useSWR from 'swr' + +const typebotContext = createContext<{ + typebot?: Typebot +}>({}) + +export const TypebotContext = ({ + children, + typebotId, +}: { + children: ReactNode + typebotId?: string +}) => { + const router = useRouter() + const toast = useToast({ + position: 'top-right', + status: 'error', + }) + const { typebot, isLoading } = useFetchedTypebot({ + typebotId, + onError: (error) => + toast({ + title: 'Error while fetching typebot', + description: error.message, + }), + }) + + useEffect(() => { + if (isLoading) return + if (!typebot) { + toast({ status: 'info', description: "Couldn't find typebot" }) + router.replace('/typebots') + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading]) + + return ( + + {children} + + ) +} + +export const useTypebot = () => useContext(typebotContext) + +export const useFetchedTypebot = ({ + typebotId, + onError, +}: { + typebotId?: string + onError: (error: Error) => void +}) => { + const { data, error, mutate } = useSWR<{ typebot: Typebot }, Error>( + typebotId ? `/api/typebots/${typebotId}` : null, + fetcher + ) + if (error) onError(error) + return { + typebot: data?.typebot, + isLoading: !error && !data, + mutate, + } +} diff --git a/apps/builder/cypress/plugins/database.ts b/apps/builder/cypress/plugins/database.ts index 09769e9197..7343bb9d38 100644 --- a/apps/builder/cypress/plugins/database.ts +++ b/apps/builder/cypress/plugins/database.ts @@ -1,4 +1,5 @@ import { PrismaClient } from '.prisma/client' +import { StartBlock, StepType } from 'bot-engine' const prisma = new PrismaClient() @@ -24,7 +25,23 @@ const createFolders = () => data: [{ ownerId: 'test2', name: 'Folder #1', id: 'folder1' }], }) -const createTypebots = () => +const createTypebots = () => { + const startBlock: StartBlock = { + graphCoordinates: { x: 0, y: 0 }, + id: 'start-block', + steps: [ + { + id: 'start-step', + blockId: 'start-block', + type: StepType.START, + label: 'Start', + }, + ], + title: 'Start', + } prisma.typebot.createMany({ - data: [{ name: 'Typebot #1', ownerId: 'test2' }], + data: [ + { id: 'typebot1', name: 'Typebot #1', ownerId: 'test2', startBlock }, + ], }) +} diff --git a/apps/builder/cypress/tests/board.ts b/apps/builder/cypress/tests/board.ts new file mode 100644 index 0000000000..b876d1816c --- /dev/null +++ b/apps/builder/cypress/tests/board.ts @@ -0,0 +1,10 @@ +describe('BoardPage', () => { + beforeEach(() => { + cy.task('seed') + cy.signOut() + }) + it('steps should be droppable', () => { + cy.signIn('test2@gmail.com') + cy.visit('/typebots/typebot1') + }) +}) diff --git a/apps/builder/package.json b/apps/builder/package.json index 4fe7e0687a..372aa469dc 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -13,9 +13,9 @@ "@chakra-ui/css-reset": "^1.1.1", "@chakra-ui/react": "^1.7.2", "@dnd-kit/core": "^4.0.3", + "@dnd-kit/sortable": "^5.1.0", "@emotion/react": "^11", "@emotion/styled": "^11", - "@jsplumb/browser-ui": "^5.2.3", "@next-auth/prisma-adapter": "next", "focus-visible": "^5.2.0", "framer-motion": "^4", @@ -25,6 +25,8 @@ "nprogress": "^0.2.0", "react": "^17.0.2", "react-dom": "^17.0.2", + "short-uuid": "^4.2.0", + "svg-round-corners": "^0.2.0", "swr": "^1.0.1", "use-debounce": "^7.0.1" }, diff --git a/apps/builder/pages/api/typebots.ts b/apps/builder/pages/api/typebots.ts index c2e919c173..5a3409766f 100644 --- a/apps/builder/pages/api/typebots.ts +++ b/apps/builder/pages/api/typebots.ts @@ -1,3 +1,4 @@ +import { StartBlock, StepType } from 'bot-engine' import { Typebot, User } from 'db' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' @@ -23,8 +24,21 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } if (req.method === 'POST') { const data = JSON.parse(req.body) as Typebot + const startBlock: StartBlock = { + id: 'start-block', + title: 'Start', + graphCoordinates: { x: 0, y: 0 }, + steps: [ + { + id: 'start-step', + blockId: 'start-block', + label: 'Form starts here', + type: StepType.START, + }, + ], + } const typebot = await prisma.typebot.create({ - data: { ...data, ownerId: user.id }, + data: { ...data, ownerId: user.id, startBlock }, }) return res.send(typebot) } diff --git a/apps/builder/pages/api/typebots/[id].ts b/apps/builder/pages/api/typebots/[id].ts index ea364c86f3..6fa5c861a0 100644 --- a/apps/builder/pages/api/typebots/[id].ts +++ b/apps/builder/pages/api/typebots/[id].ts @@ -1,4 +1,3 @@ -import { Typebot } from '.prisma/client' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' import { getSession } from 'next-auth/react' @@ -11,6 +10,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { return res.status(401).json({ message: 'Not authenticated' }) const id = req.query.id.toString() + if (req.method === 'GET') { + const typebot = await prisma.typebot.findUnique({ + where: { id }, + }) + return res.send({ typebot }) + } if (req.method === 'DELETE') { const typebots = await prisma.typebot.delete({ where: { id }, @@ -18,7 +23,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { return res.send({ typebots }) } if (req.method === 'PATCH') { - const data = JSON.parse(req.body) as Partial + const data = JSON.parse(req.body) const typebots = await prisma.typebot.update({ where: { id }, data, diff --git a/apps/builder/pages/typebots/[id].tsx b/apps/builder/pages/typebots/[id].tsx index 9f31464615..7cfc1e778a 100644 --- a/apps/builder/pages/typebots/[id].tsx +++ b/apps/builder/pages/typebots/[id].tsx @@ -1,19 +1,24 @@ import { Flex } from '@chakra-ui/layout' +import { Board } from 'components/board/Board' +import withAuth from 'components/HOC/withUser' import { Seo } from 'components/Seo' -import { GraphProvider } from 'contexts/BoardContext' +import { GraphProvider } from 'contexts/GraphContext' +import { TypebotContext } from 'contexts/TypebotContext' +import { useRouter } from 'next/router' import React from 'react' const TypebotEditPage = () => { + const { query } = useRouter() return ( - <> + - <> + - + ) } -export default TypebotEditPage +export default withAuth(TypebotEditPage) diff --git a/apps/builder/services/graph.ts b/apps/builder/services/graph.ts new file mode 100644 index 0000000000..ee344311cf --- /dev/null +++ b/apps/builder/services/graph.ts @@ -0,0 +1,266 @@ +import { Coordinates } from '@dnd-kit/core/dist/types' +import { + StepType, + Block, + Step, + TextStep, + ImageStep, + DatePickerStep, + StartBlock, +} from 'bot-engine' +import { AnchorsPositionProps } from 'components/board/graph/Edges/Edge' +import { + stubLength, + blockWidth, + blockAnchorsOffset, + spaceBetweenSteps, + firstStepOffsetY, +} from 'contexts/GraphContext' +import shortId from 'short-uuid' +import { roundCorners } from 'svg-round-corners' +import { isDefined } from './utils' + +export const parseNewBlock = ({ + type, + totalBlocks, + initialCoordinates, + step, +}: { + step?: Step + type?: StepType + totalBlocks: number + initialCoordinates: { x: number; y: number } +}): Block => { + const id = `b${shortId.generate()}` + return { + id, + title: `Block #${totalBlocks + 1}`, + graphCoordinates: initialCoordinates, + steps: [ + step ? { ...step, blockId: id } : parseNewStep(type as StepType, id), + ], + } +} + +export const parseNewStep = (type: StepType, blockId: string): Step => { + const id = `s${shortId.generate()}` + switch (type) { + case StepType.TEXT: { + const textStep: TextStep = { type, content: '' } + return { blockId, id, ...textStep } + } + case StepType.IMAGE: { + const imageStep: ImageStep = { type, content: { url: '' } } + return { blockId, id, ...imageStep } + } + case StepType.DATE_PICKER: { + const imageStep: DatePickerStep = { type, content: '' } + return { blockId, id, ...imageStep } + } + default: { + const textStep: TextStep = { type: StepType.TEXT, content: '' } + return { blockId, id, ...textStep } + } + } +} + +export const computeFlowChartConnectorPath = ({ + sourcePosition, + targetPosition, + sourceType, + totalSegments, +}: AnchorsPositionProps) => { + const segments = getSegments({ + sourcePosition, + targetPosition, + sourceType, + totalSegments, + }) + return roundCorners( + `M${sourcePosition.x},${sourcePosition.y} ${segments}`, + 10 + ).path +} + +const getSegments = ({ + sourcePosition, + targetPosition, + sourceType, + totalSegments, +}: AnchorsPositionProps) => { + switch (totalSegments) { + case 2: + return computeTwoSegments(sourcePosition, targetPosition) + case 3: + return computeThreeSegments(sourcePosition, targetPosition, sourceType) + case 4: + return computeFourSegments(sourcePosition, targetPosition, sourceType) + default: + return computeFiveSegments(sourcePosition, targetPosition, sourceType) + } +} + +const computeTwoSegments = ( + sourcePosition: Coordinates, + targetPosition: Coordinates +) => { + const segments = [] + segments.push(`L${targetPosition.x},${sourcePosition.y}`) + segments.push(`L${targetPosition.x},${targetPosition.y}`) + return segments.join(' ') +} + +const computeThreeSegments = ( + sourcePosition: Coordinates, + targetPosition: Coordinates, + sourceType: 'right' | 'left' +) => { + const segments = [] + const firstSegmentX = + sourceType === 'right' + ? sourcePosition.x + (targetPosition.x - sourcePosition.x) / 2 + : sourcePosition.x - (sourcePosition.x - targetPosition.x) / 2 + segments.push(`L${firstSegmentX},${sourcePosition.y}`) + segments.push(`L${firstSegmentX},${targetPosition.y}`) + segments.push(`L${targetPosition.x},${targetPosition.y}`) + return segments.join(' ') +} + +const computeFourSegments = ( + sourcePosition: Coordinates, + targetPosition: Coordinates, + sourceType: 'right' | 'left' +) => { + const segments = [] + const firstSegmentX = + sourcePosition.x + (sourceType === 'right' ? stubLength : -stubLength) + segments.push(`L${firstSegmentX},${sourcePosition.y}`) + const secondSegmentY = + sourcePosition.y + (targetPosition.y - sourcePosition.y) / 2 + segments.push(`L${firstSegmentX},${secondSegmentY}`) + + segments.push(`L${targetPosition.x},${secondSegmentY}`) + + segments.push(`L${targetPosition.x},${targetPosition.y}`) + return segments.join(' ') +} + +const computeFiveSegments = ( + sourcePosition: Coordinates, + targetPosition: Coordinates, + sourceType: 'right' | 'left' +) => { + const segments = [] + const firstSegmentX = + sourcePosition.x + (sourceType === 'right' ? stubLength : -stubLength) + segments.push(`L${firstSegmentX},${sourcePosition.y}`) + const firstSegmentY = + sourcePosition.y + (targetPosition.y - sourcePosition.y) / 2 + segments.push( + `L${ + sourcePosition.x + (sourceType === 'right' ? stubLength : -stubLength) + },${firstSegmentY}` + ) + + const secondSegmentX = + targetPosition.x - (sourceType === 'right' ? stubLength : -stubLength) + segments.push(`L${secondSegmentX},${firstSegmentY}`) + + segments.push(`L${secondSegmentX},${targetPosition.y}`) + + segments.push(`L${targetPosition.x},${targetPosition.y}`) + return segments.join(' ') +} + +export const getAnchorsPosition = ( + sourceBlock: Block | StartBlock, + targetBlock: Block, + sourceStepIndex: number, + targetStepIndex?: number +): AnchorsPositionProps => { + const sourceOffsetY = + (sourceBlock.graphCoordinates.y ?? 0) + + firstStepOffsetY + + spaceBetweenSteps * sourceStepIndex + const targetOffsetY = isDefined(targetStepIndex) + ? (targetBlock.graphCoordinates.y ?? 0) + + firstStepOffsetY + + spaceBetweenSteps * targetStepIndex + : undefined + + const sourcePosition = { + x: (sourceBlock.graphCoordinates.x ?? 0) + blockWidth, + y: sourceOffsetY, + } + let sourceType: 'right' | 'left' = 'right' + if (sourceBlock.graphCoordinates.x > targetBlock.graphCoordinates.x) { + sourcePosition.x = sourceBlock.graphCoordinates.x + sourceType = 'left' + } + + const { targetPosition, totalSegments } = computeBlockTargetPosition( + sourceBlock.graphCoordinates, + targetBlock.graphCoordinates, + sourceOffsetY, + targetOffsetY + ) + return { sourcePosition, targetPosition, sourceType, totalSegments } +} + +const computeBlockTargetPosition = ( + sourceBlockPosition: Coordinates, + targetBlockPosition: Coordinates, + offsetY: number, + targetOffsetY?: number +): { targetPosition: Coordinates; totalSegments: number } => { + const isTargetBlockBelow = + targetBlockPosition.y > offsetY && + targetBlockPosition.x < sourceBlockPosition.x + blockWidth + stubLength && + targetBlockPosition.x > sourceBlockPosition.x - blockWidth - stubLength + const isTargetBlockToTheRight = targetBlockPosition.x < sourceBlockPosition.x + const isTargettingBlock = !targetOffsetY + + if (isTargetBlockBelow && isTargettingBlock) { + const isExterior = + targetBlockPosition.x < + sourceBlockPosition.x - blockWidth / 2 - stubLength || + targetBlockPosition.x > + sourceBlockPosition.x + blockWidth / 2 + stubLength + const targetPosition = parseBlockAnchorPosition(targetBlockPosition, 'top') + return { totalSegments: isExterior ? 2 : 4, targetPosition } + } else { + const isExterior = + targetBlockPosition.x < sourceBlockPosition.x - blockWidth || + targetBlockPosition.x > sourceBlockPosition.x + blockWidth + const targetPosition = parseBlockAnchorPosition( + targetBlockPosition, + isTargetBlockToTheRight ? 'right' : 'left', + targetOffsetY + ) + return { totalSegments: isExterior ? 3 : 5, targetPosition } + } +} + +const parseBlockAnchorPosition = ( + blockPosition: Coordinates, + anchor: 'left' | 'top' | 'right', + targetOffsetY?: number +): Coordinates => { + switch (anchor) { + case 'left': + return { + x: blockPosition.x + blockAnchorsOffset.left.x, + y: targetOffsetY ?? blockPosition.y + blockAnchorsOffset.left.y, + } + case 'top': + return { + x: blockPosition.x + blockAnchorsOffset.top.x, + y: blockPosition.y + blockAnchorsOffset.top.y, + } + case 'right': + return { + x: blockPosition.x + blockAnchorsOffset.right.x, + y: targetOffsetY ?? blockPosition.y + blockAnchorsOffset.right.y, + } + } +} diff --git a/apps/builder/services/utils.ts b/apps/builder/services/utils.ts index 02626880e3..7555923b36 100644 --- a/apps/builder/services/utils.ts +++ b/apps/builder/services/utils.ts @@ -30,3 +30,17 @@ export const sendRequest = async ({ return { error: e as Error } } } + +export const insertItemInList = ( + arr: T[], + index: number, + newItem: T +): T[] => [...arr.slice(0, index), newItem, ...arr.slice(index)] + +export const isDefined = (value: T | undefined | null): value is T => { + return value !== undefined && value !== null +} + +export const isNotDefined = (value: T | undefined | null): value is T => { + return value === undefined || value === null +} diff --git a/packages/bot-engine/src/models/typebot.ts b/packages/bot-engine/src/models/typebot.ts index cbe2897126..8de349ce9c 100644 --- a/packages/bot-engine/src/models/typebot.ts +++ b/packages/bot-engine/src/models/typebot.ts @@ -1,25 +1,47 @@ import { Typebot as TypebotFromPrisma } from 'db' -export type Typebot = TypebotFromPrisma & { blocks: Block[] } +export type Typebot = TypebotFromPrisma & { + blocks: Block[] + startBlock: StartBlock +} + +export type StartBlock = { + id: `start-block` + graphCoordinates: { + x: number + y: number + } + title: string + steps: [StartStep] +} + +export type StartStep = { + id: 'start-step' + blockId: 'start-block' + target?: Target + type: StepType.START + label: string +} export type Block = { id: string title: string steps: Step[] - boardCoordinates: { + graphCoordinates: { x: number y: number } } export enum StepType { + START = 'start', TEXT = 'text', IMAGE = 'image', BUTTONS = 'buttons', DATE_PICKER = 'date picker', } -type Target = { blockId: string; stepId?: string } +export type Target = { blockId: string; stepId?: string } export type Step = { id: string; blockId: string; target?: Target } & ( | TextStep diff --git a/packages/db/prisma/migrations/20211216093901_init_graph_properites/migration.sql b/packages/db/prisma/migrations/20211216093901_init_graph_properites/migration.sql new file mode 100644 index 0000000000..9433e270ee --- /dev/null +++ b/packages/db/prisma/migrations/20211216093901_init_graph_properites/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Added the required column `startBlock` to the `PublicTypebot` table without a default value. This is not possible if the table is not empty. + - Added the required column `startBlock` to the `Typebot` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "PublicTypebot" ADD COLUMN "blocks" JSONB[], +ADD COLUMN "startBlock" JSONB NOT NULL; + +-- AlterTable +ALTER TABLE "Typebot" ADD COLUMN "blocks" JSONB[], +ADD COLUMN "startBlock" JSONB NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3e240dba5a..c98f889fae 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -87,16 +87,18 @@ model Typebot { results Result[] folderId String? folder DashboardFolder? @relation(fields: [folderId], references: [id]) - blocks Json[] + blocks Json[] + startBlock Json } model PublicTypebot { - id String @id @default(cuid()) - typebotId String @unique - typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) - steps Json[] - name String - blocks Json[] + id String @id @default(cuid()) + typebotId String @unique + typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) + steps Json[] + name String + blocks Json[] + startBlock Json } model Result { diff --git a/yarn.lock b/yarn.lock index 01a11c475d..8db273b49f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1035,7 +1035,20 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/utilities@npm:^3.0.1": +"@dnd-kit/sortable@npm:^5.1.0": + version: 5.1.0 + resolution: "@dnd-kit/sortable@npm:5.1.0" + dependencies: + "@dnd-kit/utilities": ^3.0.0 + tslib: ^2.0.0 + peerDependencies: + "@dnd-kit/core": ^4.0.2 + react: ">=16.8.0" + checksum: f2f687a70dc52894e569d6377d7cb2e5e1761f0164cf08a1d0c8c3df43d6e88fd2858e0f5dfc8a5670fac8f6ce9a2ed91b0aeb8837f44d067acf21d1baf6a55e + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.0.0, @dnd-kit/utilities@npm:^3.0.1": version: 3.0.1 resolution: "@dnd-kit/utilities@npm:3.0.1" dependencies: @@ -1300,40 +1313,6 @@ __metadata: languageName: node linkType: hard -"@jsplumb/browser-ui@npm:^5.2.3": - version: 5.2.3 - resolution: "@jsplumb/browser-ui@npm:5.2.3" - dependencies: - "@jsplumb/core": 5.2.3 - checksum: e6076dea1dff18e9121b12544132be6b71222370578973514f6f3ac863736b7262b15169ba57a65078d3dd041b0eea757f989799a680594431415ffbf9e4dca2 - languageName: node - linkType: hard - -"@jsplumb/common@npm:5.2.3": - version: 5.2.3 - resolution: "@jsplumb/common@npm:5.2.3" - dependencies: - "@jsplumb/util": 5.2.3 - checksum: 7c7241ca5b673788eb9bd88b15acf59f1497ccbfde8198b6b649659d70cefff7b60f34062e8735da4c886d8fc5d75d3467c53369525f4e7a62c8b59710613354 - languageName: node - linkType: hard - -"@jsplumb/core@npm:5.2.3": - version: 5.2.3 - resolution: "@jsplumb/core@npm:5.2.3" - dependencies: - "@jsplumb/common": 5.2.3 - checksum: d5008eeb5e8bcf11af156dc0896fcc01a8ccee437462a4d9ff05473ad6523c9f1c446a4333ff99ed67e141e7cf9abc4c514ef9f1044540920be08818d36935db - languageName: node - linkType: hard - -"@jsplumb/util@npm:5.2.3": - version: 5.2.3 - resolution: "@jsplumb/util@npm:5.2.3" - checksum: 5bef8e4845943aed038dc120bce88da4f7d7b3bef9326034b6e08e3a3019bae9376ffc32f63872f6c426aaac866567a53ba183847259dbd3c249abe59b8c14e3 - languageName: node - linkType: hard - "@napi-rs/triples@npm:1.0.3": version: 1.0.3 resolution: "@napi-rs/triples@npm:1.0.3" @@ -2386,6 +2365,13 @@ __metadata: languageName: node linkType: hard +"any-base@npm:^1.1.0": + version: 1.1.0 + resolution: "any-base@npm:1.1.0" + checksum: c1fd040de52e710e2de7d9ae4df52bac589f35622adb24686c98ce21c7b824859a8db9614bc119ed8614f42fd08918b2612e6a6c385480462b3100a1af59289d + languageName: node + linkType: hard + "anymatch@npm:~3.1.1, anymatch@npm:~3.1.2": version: 3.1.2 resolution: "anymatch@npm:3.1.2" @@ -2899,9 +2885,9 @@ __metadata: "@chakra-ui/css-reset": ^1.1.1 "@chakra-ui/react": ^1.7.2 "@dnd-kit/core": ^4.0.3 + "@dnd-kit/sortable": ^5.1.0 "@emotion/react": ^11 "@emotion/styled": ^11 - "@jsplumb/browser-ui": ^5.2.3 "@next-auth/prisma-adapter": next "@testing-library/cypress": ^8.0.2 "@types/node": ^16.11.9 @@ -2926,6 +2912,8 @@ __metadata: prettier: ^2.4.1 react: ^17.0.2 react-dom: ^17.0.2 + short-uuid: ^4.2.0 + svg-round-corners: ^0.2.0 swr: ^1.0.1 typescript: ^4.5.2 use-debounce: ^7.0.1 @@ -6295,6 +6283,13 @@ __metadata: languageName: node linkType: hard +"lodash.clonedeep@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.clonedeep@npm:4.5.0" + checksum: 92c46f094b064e876a23c97f57f81fbffd5d760bf2d8a1c61d85db6d1e488c66b0384c943abee4f6af7debf5ad4e4282e74ff83177c9e63d8ff081a4837c3489 + languageName: node + linkType: hard + "lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -8940,6 +8935,16 @@ __metadata: languageName: node linkType: hard +"short-uuid@npm:^4.2.0": + version: 4.2.0 + resolution: "short-uuid@npm:4.2.0" + dependencies: + any-base: ^1.1.0 + uuid: ^8.3.2 + checksum: 09013559393bc26d1462ae27c84b4eb7e6e4052e9fea1704f21370e226864f9dfcd1b9465eefa980da84b2537b70cabd98718193934dcc2a0fc06e7b0d1f4b9e + languageName: node + linkType: hard + "side-channel@npm:^1.0.4": version: 1.0.4 resolution: "side-channel@npm:1.0.4" @@ -9443,6 +9448,15 @@ __metadata: languageName: node linkType: hard +"svg-round-corners@npm:^0.2.0": + version: 0.2.0 + resolution: "svg-round-corners@npm:0.2.0" + dependencies: + lodash.clonedeep: ^4.5.0 + checksum: edaeaea3aa8bbef8509d747477381333bc93b49c3daf82503fe0236152d9e33124b17c3acde5cca424abd0602cf9794fda97f0446a547a0a8a4d84e13e99cbf0 + languageName: node + linkType: hard + "svgo@npm:^2.7.0": version: 2.8.0 resolution: "svgo@npm:2.8.0"