2
0

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:
Baptiste Arnaud
2022-02-04 19:00:08 +01:00
parent 8a350eee6c
commit 524ef0812c
123 changed files with 2998 additions and 3112 deletions

View File

@ -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>
)
}

View File

@ -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} />
}
}

View File

@ -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>
)
}

View File

@ -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 '!='
}
}

View File

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

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

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