chore(editor): ♻️ Revert tables to arrays
Yet another refacto. I improved many many mechanisms on this one including dnd. It is now end 2 end tested 🎉
This commit is contained in:
@ -0,0 +1,90 @@
|
||||
import { Flex } from '@chakra-ui/react'
|
||||
import { ContextMenu } from 'components/shared/ContextMenu'
|
||||
import { Coordinates } from 'contexts/GraphContext'
|
||||
import { NodePosition, useDragDistance } from 'contexts/GraphDndContext'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { ButtonItem, Item, ItemIndices, ItemType } from 'models'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { setMultipleRefs } from 'services/utils'
|
||||
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
|
||||
import { ItemNodeContent } from './ItemNodeContent'
|
||||
import { ItemNodeContextMenu } from './ItemNodeContextMenu'
|
||||
|
||||
type Props = {
|
||||
item: Item
|
||||
indices: ItemIndices
|
||||
isReadOnly: boolean
|
||||
isLastItem: boolean
|
||||
onMouseDown?: (
|
||||
stepNodePosition: { absolute: Coordinates; relative: Coordinates },
|
||||
item: ButtonItem
|
||||
) => void
|
||||
}
|
||||
|
||||
export const ItemNode = ({
|
||||
item,
|
||||
indices,
|
||||
isReadOnly,
|
||||
isLastItem,
|
||||
onMouseDown,
|
||||
}: Props) => {
|
||||
const { typebot } = useTypebot()
|
||||
const [isMouseOver, setIsMouseOver] = useState(false)
|
||||
const itemRef = useRef<HTMLDivElement | null>(null)
|
||||
const onDrag = (position: NodePosition) => {
|
||||
if (!onMouseDown || item.type !== ItemType.BUTTON) return
|
||||
onMouseDown(position, item)
|
||||
}
|
||||
useDragDistance({
|
||||
ref: itemRef,
|
||||
onDrag,
|
||||
isDisabled: !onMouseDown || item.type !== ItemType.BUTTON,
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => setIsMouseOver(true)
|
||||
const handleMouseLeave = () => setIsMouseOver(false)
|
||||
|
||||
return (
|
||||
<ContextMenu<HTMLDivElement>
|
||||
renderMenu={() => <ItemNodeContextMenu indices={indices} />}
|
||||
>
|
||||
{(ref, isOpened) => (
|
||||
<Flex
|
||||
data-testid="item"
|
||||
ref={setMultipleRefs([ref, itemRef])}
|
||||
align="center"
|
||||
pos="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
shadow="sm"
|
||||
_hover={isReadOnly ? {} : { shadow: 'md' }}
|
||||
transition="box-shadow 200ms"
|
||||
borderWidth="1px"
|
||||
rounded="md"
|
||||
borderColor={isOpened ? 'blue.400' : 'gray.300'}
|
||||
pointerEvents={isReadOnly ? 'none' : 'all'}
|
||||
w="full"
|
||||
>
|
||||
<ItemNodeContent
|
||||
item={item}
|
||||
isMouseOver={isMouseOver}
|
||||
indices={indices}
|
||||
isLastItem={isLastItem}
|
||||
/>
|
||||
{typebot && (
|
||||
<SourceEndpoint
|
||||
source={{
|
||||
blockId: typebot.blocks[indices.blockIndex].id,
|
||||
stepId: item.stepId,
|
||||
itemId: item.id,
|
||||
}}
|
||||
pos="absolute"
|
||||
right="15px"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import { Item, ItemIndices, ItemType } from 'models'
|
||||
import React from 'react'
|
||||
import { ButtonNodeContent } from './contents/ButtonNodeContent'
|
||||
import { ConditionNodeContent } from './contents/ConditionNodeContent'
|
||||
|
||||
type Props = {
|
||||
item: Item
|
||||
indices: ItemIndices
|
||||
isMouseOver: boolean
|
||||
isLastItem: boolean
|
||||
}
|
||||
|
||||
export const ItemNodeContent = ({
|
||||
item,
|
||||
indices,
|
||||
isMouseOver,
|
||||
isLastItem,
|
||||
}: Props) => {
|
||||
switch (item.type) {
|
||||
case ItemType.BUTTON:
|
||||
return (
|
||||
<ButtonNodeContent
|
||||
item={item}
|
||||
isMouseOver={isMouseOver}
|
||||
indices={indices}
|
||||
isLastItem={isLastItem}
|
||||
/>
|
||||
)
|
||||
case ItemType.CONDITION:
|
||||
return <ConditionNodeContent item={item} />
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
import {
|
||||
EditablePreview,
|
||||
EditableInput,
|
||||
Editable,
|
||||
Fade,
|
||||
IconButton,
|
||||
Flex,
|
||||
} from '@chakra-ui/react'
|
||||
import { PlusIcon } from 'assets/icons'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { ButtonItem, ItemIndices, ItemType } from 'models'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { isNotDefined } from 'utils'
|
||||
|
||||
type Props = {
|
||||
item: ButtonItem
|
||||
indices: ItemIndices
|
||||
isLastItem: boolean
|
||||
isMouseOver: boolean
|
||||
}
|
||||
|
||||
export const ButtonNodeContent = ({
|
||||
item,
|
||||
indices,
|
||||
isMouseOver,
|
||||
isLastItem,
|
||||
}: Props) => {
|
||||
const { deleteItem, updateItem, createItem } = useTypebot()
|
||||
const [initialContent] = useState(item.content ?? '')
|
||||
const [itemValue, setItemValue] = useState(item.content ?? 'Click to edit')
|
||||
const editableRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleInputSubmit = () => {
|
||||
if (itemValue === '') deleteItem(indices)
|
||||
else
|
||||
updateItem(indices, { content: itemValue === '' ? undefined : itemValue })
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Escape' && itemValue === 'Click to edit') deleteItem(indices)
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
itemValue !== '' &&
|
||||
isLastItem &&
|
||||
initialContent === ''
|
||||
)
|
||||
handlePlusClick()
|
||||
}
|
||||
|
||||
const handlePlusClick = () => {
|
||||
const itemIndex = indices.itemIndex + 1
|
||||
createItem(
|
||||
{ stepId: item.stepId, type: ItemType.BUTTON },
|
||||
{ ...indices, itemIndex }
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex px={4} py={2} justify="center" w="full">
|
||||
<Editable
|
||||
ref={editableRef}
|
||||
flex="1"
|
||||
startWithEditView={isNotDefined(item.content)}
|
||||
value={itemValue}
|
||||
onChange={setItemValue}
|
||||
onSubmit={handleInputSubmit}
|
||||
onKeyDownCapture={handleKeyPress}
|
||||
>
|
||||
<EditablePreview
|
||||
w="full"
|
||||
color={item.content !== 'Click to edit' ? 'inherit' : 'gray.500'}
|
||||
cursor="pointer"
|
||||
/>
|
||||
<EditableInput />
|
||||
</Editable>
|
||||
<Fade
|
||||
in={isMouseOver}
|
||||
style={{ position: 'absolute', bottom: '-15px', zIndex: 3 }}
|
||||
unmountOnExit
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Add item"
|
||||
icon={<PlusIcon />}
|
||||
size="xs"
|
||||
shadow="md"
|
||||
colorScheme="blue"
|
||||
onClick={handlePlusClick}
|
||||
/>
|
||||
</Fade>
|
||||
</Flex>
|
||||
)
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
import { Stack, Tag, Text, Flex, Wrap } from '@chakra-ui/react'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { Comparison, ConditionItem, ComparisonOperators } from 'models'
|
||||
import React from 'react'
|
||||
import { byId, isNotDefined } from 'utils'
|
||||
|
||||
type Props = {
|
||||
item: ConditionItem
|
||||
}
|
||||
|
||||
export const ConditionNodeContent = ({ item }: Props) => {
|
||||
const { typebot } = useTypebot()
|
||||
return (
|
||||
<Flex px={2} py={2}>
|
||||
{item.content.comparisons.length === 0 ||
|
||||
comparisonIsEmpty(item.content.comparisons[0]) ? (
|
||||
<Text color={'gray.500'}>Configure...</Text>
|
||||
) : (
|
||||
<Stack maxW="170px">
|
||||
{item.content.comparisons.map((comparison, idx) => {
|
||||
const variable = typebot?.variables.find(
|
||||
byId(comparison.variableId)
|
||||
)
|
||||
return (
|
||||
<Wrap key={comparison.id} spacing={1} isTruncated>
|
||||
{idx > 0 && <Text>{item.content.logicalOperator ?? ''}</Text>}
|
||||
{variable?.name && (
|
||||
<Tag bgColor="orange.400" color="white">
|
||||
{variable.name}
|
||||
</Tag>
|
||||
)}
|
||||
{comparison.comparisonOperator && (
|
||||
<Text>
|
||||
{parseComparisonOperatorSymbol(
|
||||
comparison.comparisonOperator
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{comparison?.value && (
|
||||
<Tag bgColor={'gray.200'}>
|
||||
<Text isTruncated>{comparison.value}</Text>
|
||||
</Tag>
|
||||
)}
|
||||
</Wrap>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const comparisonIsEmpty = (comparison: Comparison) =>
|
||||
isNotDefined(comparison.comparisonOperator) &&
|
||||
isNotDefined(comparison.value) &&
|
||||
isNotDefined(comparison.variableId)
|
||||
|
||||
const parseComparisonOperatorSymbol = (operator: ComparisonOperators) => {
|
||||
switch (operator) {
|
||||
case ComparisonOperators.CONTAINS:
|
||||
return 'contains'
|
||||
case ComparisonOperators.EQUAL:
|
||||
return '='
|
||||
case ComparisonOperators.GREATER:
|
||||
return '>'
|
||||
case ComparisonOperators.IS_SET:
|
||||
return 'is set'
|
||||
case ComparisonOperators.LESS:
|
||||
return '<'
|
||||
case ComparisonOperators.NOT_EQUAL:
|
||||
return '!='
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ItemNodeContent } from './ItemNodeContent'
|
@ -0,0 +1,21 @@
|
||||
import { MenuList, MenuItem } from '@chakra-ui/react'
|
||||
import { TrashIcon } from 'assets/icons'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { ItemIndices } from 'models'
|
||||
|
||||
type Props = {
|
||||
indices: ItemIndices
|
||||
}
|
||||
export const ItemNodeContextMenu = ({ indices }: Props) => {
|
||||
const { deleteItem } = useTypebot()
|
||||
|
||||
const handleDeleteClick = () => deleteItem(indices)
|
||||
|
||||
return (
|
||||
<MenuList>
|
||||
<MenuItem icon={<TrashIcon />} onClick={handleDeleteClick}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
)
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import { Flex, FlexProps } from '@chakra-ui/react'
|
||||
import { Item } from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
item: Item
|
||||
} & FlexProps
|
||||
|
||||
export const ItemNodeOverlay = ({ item, ...props }: Props) => {
|
||||
return (
|
||||
<Flex
|
||||
px="4"
|
||||
py="2"
|
||||
rounded="md"
|
||||
bgColor="white"
|
||||
borderWidth="1px"
|
||||
borderColor={'gray.300'}
|
||||
w="212px"
|
||||
pointerEvents="none"
|
||||
shadow="lg"
|
||||
{...props}
|
||||
>
|
||||
{item.content ?? 'Click to edit'}
|
||||
</Flex>
|
||||
)
|
||||
}
|
@ -0,0 +1,189 @@
|
||||
import { Flex, Portal, Stack, Text, useEventListener } from '@chakra-ui/react'
|
||||
import {
|
||||
computeNearestPlaceholderIndex,
|
||||
useStepDnd,
|
||||
} from 'contexts/GraphDndContext'
|
||||
import { Coordinates } from 'contexts/GraphContext'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { ButtonItem, StepIndices, StepWithItems } from 'models'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ItemNode } from './ItemNode'
|
||||
import { SourceEndpoint } from '../../Endpoints'
|
||||
import { ItemNodeOverlay } from './ItemNodeOverlay'
|
||||
|
||||
type Props = {
|
||||
step: StepWithItems
|
||||
indices: StepIndices
|
||||
isReadOnly?: boolean
|
||||
}
|
||||
|
||||
export const ItemNodesList = ({
|
||||
step,
|
||||
indices: { blockIndex, stepIndex },
|
||||
isReadOnly = false,
|
||||
}: Props) => {
|
||||
const { typebot, createItem, deleteItem } = useTypebot()
|
||||
const { draggedItem, setDraggedItem, mouseOverBlock } = useStepDnd()
|
||||
const placeholderRefs = useRef<HTMLDivElement[]>([])
|
||||
const blockId = typebot?.blocks[blockIndex].id
|
||||
const isDraggingOnCurrentBlock =
|
||||
(draggedItem && mouseOverBlock?.id === blockId) ?? false
|
||||
const showPlaceholders = draggedItem && !isReadOnly
|
||||
|
||||
const [position, setPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
const [relativeCoordinates, setRelativeCoordinates] = useState({ x: 0, y: 0 })
|
||||
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
|
||||
number | undefined
|
||||
>()
|
||||
|
||||
const handleGlobalMouseMove = (event: MouseEvent) => {
|
||||
if (!draggedItem || draggedItem.stepId !== step.id) return
|
||||
const { clientX, clientY } = event
|
||||
setPosition({
|
||||
...position,
|
||||
x: clientX - relativeCoordinates.x,
|
||||
y: clientY - relativeCoordinates.y,
|
||||
})
|
||||
}
|
||||
useEventListener('mousemove', handleGlobalMouseMove)
|
||||
|
||||
useEffect(() => {
|
||||
if (mouseOverBlock?.id !== step.blockId)
|
||||
setExpandedPlaceholderIndex(undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mouseOverBlock?.id])
|
||||
|
||||
const handleMouseMoveOnBlock = (event: MouseEvent) => {
|
||||
if (!isDraggingOnCurrentBlock || isReadOnly) return
|
||||
const index = computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
|
||||
setExpandedPlaceholderIndex(index)
|
||||
}
|
||||
useEventListener(
|
||||
'mousemove',
|
||||
handleMouseMoveOnBlock,
|
||||
mouseOverBlock?.ref.current
|
||||
)
|
||||
|
||||
const handleMouseUpOnBlock = (e: MouseEvent) => {
|
||||
setExpandedPlaceholderIndex(undefined)
|
||||
if (!isDraggingOnCurrentBlock) return
|
||||
const itemIndex = computeNearestPlaceholderIndex(e.pageY, placeholderRefs)
|
||||
e.stopPropagation()
|
||||
createItem(draggedItem as ButtonItem, {
|
||||
blockIndex,
|
||||
stepIndex,
|
||||
itemIndex,
|
||||
})
|
||||
setDraggedItem(undefined)
|
||||
}
|
||||
useEventListener(
|
||||
'mouseup',
|
||||
handleMouseUpOnBlock,
|
||||
mouseOverBlock?.ref.current,
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
)
|
||||
|
||||
const handleStepMouseDown =
|
||||
(itemIndex: number) =>
|
||||
(
|
||||
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
|
||||
item: ButtonItem
|
||||
) => {
|
||||
if (!typebot || isReadOnly) return
|
||||
deleteItem({ blockIndex, stepIndex, itemIndex })
|
||||
setPosition(absolute)
|
||||
setRelativeCoordinates(relative)
|
||||
setDraggedItem(item)
|
||||
}
|
||||
|
||||
const stopPropagating = (e: React.MouseEvent) => e.stopPropagation()
|
||||
|
||||
const handlePushElementRef =
|
||||
(idx: number) => (elem: HTMLDivElement | null) => {
|
||||
elem && (placeholderRefs.current[idx] = elem)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex={1}
|
||||
spacing={1}
|
||||
maxW="full"
|
||||
onClick={stopPropagating}
|
||||
pointerEvents={isReadOnly ? 'none' : 'all'}
|
||||
>
|
||||
<Flex
|
||||
ref={handlePushElementRef(0)}
|
||||
h={showPlaceholders && expandedPlaceholderIndex === 0 ? '50px' : '2px'}
|
||||
bgColor={'gray.300'}
|
||||
visibility={showPlaceholders ? 'visible' : 'hidden'}
|
||||
rounded="lg"
|
||||
transition={showPlaceholders ? 'height 200ms' : 'none'}
|
||||
/>
|
||||
{step.items.map((item, idx) => (
|
||||
<Stack key={item.id} spacing={1}>
|
||||
<ItemNode
|
||||
item={item}
|
||||
indices={{ blockIndex, stepIndex, itemIndex: idx }}
|
||||
onMouseDown={handleStepMouseDown(idx)}
|
||||
isReadOnly={isReadOnly}
|
||||
isLastItem={idx === step.items.length - 1}
|
||||
/>
|
||||
<Flex
|
||||
ref={handlePushElementRef(idx + 1)}
|
||||
h={
|
||||
showPlaceholders && expandedPlaceholderIndex === idx + 1
|
||||
? '50px'
|
||||
: '2px'
|
||||
}
|
||||
bgColor={'gray.300'}
|
||||
visibility={showPlaceholders ? 'visible' : 'hidden'}
|
||||
rounded="lg"
|
||||
transition={showPlaceholders ? 'height 200ms' : 'none'}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
<Stack>
|
||||
<Flex
|
||||
px="4"
|
||||
py="2"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.300"
|
||||
bgColor={isReadOnly ? '' : 'gray.50'}
|
||||
rounded="md"
|
||||
pos="relative"
|
||||
align="center"
|
||||
cursor={isReadOnly ? 'pointer' : 'not-allowed'}
|
||||
>
|
||||
<Text color={isReadOnly ? 'inherit' : 'gray.500'}>Default</Text>
|
||||
<SourceEndpoint
|
||||
source={{
|
||||
blockId: step.blockId,
|
||||
stepId: step.id,
|
||||
}}
|
||||
pos="absolute"
|
||||
right="15px"
|
||||
/>
|
||||
</Flex>
|
||||
</Stack>
|
||||
|
||||
{draggedItem && draggedItem.stepId === step.id && (
|
||||
<Portal>
|
||||
<ItemNodeOverlay
|
||||
item={draggedItem}
|
||||
pos="fixed"
|
||||
top="0"
|
||||
left="0"
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`,
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ItemNodesList } from './ItemNodesList'
|
Reference in New Issue
Block a user