♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@@ -0,0 +1,28 @@
import { Flex } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { TextBubbleBlock } from 'models'
import React from 'react'
import { parseVariableHighlight } from '@/utils/helpers'
type Props = {
block: TextBubbleBlock
}
export const TextBubbleContent = ({ block }: Props) => {
const { typebot } = useTypebot()
if (!typebot) return <></>
return (
<Flex
w="90%"
flexDir={'column'}
opacity={block.content.html === '' ? '0.5' : '1'}
className="slate-html-container"
dangerouslySetInnerHTML={{
__html:
block.content.html === ''
? `<p>Click to edit...</p>`
: parseVariableHighlight(block.content.html, typebot),
}}
/>
)
}

View File

@@ -0,0 +1,179 @@
import { Flex, Stack, useOutsideClick } from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import {
Plate,
PlateProvider,
selectEditor,
TElement,
usePlateEditorRef,
} from '@udecode/plate-core'
import { editorStyle, platePlugins } from '@/lib/plate'
import { BaseEditor, BaseSelection, Transforms } from 'slate'
import { ToolBar } from './ToolBar'
import { defaultTextBubbleContent, TextBubbleContent, Variable } from 'models'
import { ReactEditor } from 'slate-react'
import { serializeHtml } from '@udecode/plate-serializer-html'
import { parseHtmlStringToPlainText } from '../../utils'
import { VariableSearchInput } from '@/components/VariableSearchInput'
type TextBubbleEditorContentProps = {
id: string
textEditorValue: TElement[]
onClose: (newContent: TextBubbleContent) => void
}
const TextBubbleEditorContent = ({
id,
textEditorValue,
onClose,
}: TextBubbleEditorContentProps) => {
const editor = usePlateEditorRef()
const varDropdownRef = useRef<HTMLDivElement | null>(null)
const rememberedSelection = useRef<BaseSelection | null>(null)
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
const textEditorRef = useRef<HTMLDivElement>(null)
const closeEditor = () => onClose(convertValueToBlockContent(textEditorValue))
useOutsideClick({
ref: textEditorRef,
handler: closeEditor,
})
useEffect(() => {
if (!isVariableDropdownOpen) return
const el = varDropdownRef.current
if (!el) return
const { top, left } = computeTargetCoord()
el.style.top = `${top}px`
el.style.left = `${left}px`
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVariableDropdownOpen])
const computeTargetCoord = () => {
const selection = window.getSelection()
const relativeParent = textEditorRef.current
if (!selection || !relativeParent) return { top: 0, left: 0 }
const range = selection.getRangeAt(0)
const selectionBoundingRect = range.getBoundingClientRect()
const relativeRect = relativeParent.getBoundingClientRect()
return {
top: selectionBoundingRect.bottom - relativeRect.top,
left: selectionBoundingRect.left - relativeRect.left,
}
}
const convertValueToBlockContent = (value: TElement[]): TextBubbleContent => {
if (value.length === 0) defaultTextBubbleContent
const html = serializeHtml(editor, {
nodes: value,
})
return {
html,
richText: value,
plainText: parseHtmlStringToPlainText(html),
}
}
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const handleVariableSelected = (variable?: Variable) => {
setIsVariableDropdownOpen(false)
if (!rememberedSelection.current || !variable) return
Transforms.select(editor as BaseEditor, rememberedSelection.current)
Transforms.insertText(editor as BaseEditor, '{{' + variable.name + '}}')
ReactEditor.focus(editor as unknown as ReactEditor)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.shiftKey) return
if (e.key === 'Enter') closeEditor()
}
return (
<Stack
flex="1"
ref={textEditorRef}
borderWidth="2px"
borderColor="blue.400"
rounded="md"
onMouseDown={handleMouseDown}
pos="relative"
spacing={0}
cursor="text"
>
<ToolBar onVariablesButtonClick={() => setIsVariableDropdownOpen(true)} />
<Plate
id={id}
editableProps={{
style: editorStyle,
autoFocus: true,
onFocus: () => {
if (editor.children.length === 0) return
selectEditor(editor, {
edge: 'end',
})
},
'aria-label': 'Text editor',
onBlur: () => {
rememberedSelection.current = editor.selection
},
onKeyDown: handleKeyDown,
}}
/>
{isVariableDropdownOpen && (
<Flex
pos="absolute"
ref={varDropdownRef}
shadow="lg"
rounded="md"
bgColor="white"
w="250px"
zIndex={10}
>
<VariableSearchInput
onSelectVariable={handleVariableSelected}
placeholder="Search for a variable"
isDefaultOpen
/>
</Flex>
)}
</Stack>
)
}
type TextBubbleEditorProps = {
id: string
initialValue: TElement[]
onClose: (newContent: TextBubbleContent) => void
}
export const TextBubbleEditor = ({
id,
initialValue,
onClose,
}: TextBubbleEditorProps) => {
const [textEditorValue, setTextEditorValue] = useState(initialValue)
return (
<PlateProvider
id={id}
plugins={platePlugins}
initialValue={
initialValue.length === 0
? [{ type: 'p', children: [{ text: '' }] }]
: initialValue
}
onChange={setTextEditorValue}
>
<TextBubbleEditorContent
id={id}
textEditorValue={textEditorValue}
onClose={onClose}
/>
</PlateProvider>
)
}

View File

@@ -0,0 +1,67 @@
import { StackProps, HStack, IconButton } from '@chakra-ui/react'
import {
MARK_BOLD,
MARK_ITALIC,
MARK_UNDERLINE,
} from '@udecode/plate-basic-marks'
import { getPluginType, usePlateEditorRef } from '@udecode/plate-core'
import { LinkToolbarButton } from '@udecode/plate-ui-link'
import { MarkToolbarButton } from '@udecode/plate-ui-toolbar'
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
LinkIcon,
UserIcon,
} from '@/components/icons'
type Props = {
onVariablesButtonClick: () => void
} & StackProps
export const ToolBar = ({ onVariablesButtonClick, ...props }: Props) => {
const editor = usePlateEditorRef()
const handleVariablesButtonMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
onVariablesButtonClick()
}
return (
<HStack
bgColor={'white'}
borderTopRadius="md"
p={2}
w="full"
boxSizing="border-box"
borderBottomWidth={1}
{...props}
>
<IconButton
aria-label="Insert variable"
size="sm"
onMouseDown={handleVariablesButtonMouseDown}
icon={<UserIcon />}
/>
<span data-testid="bold-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_BOLD)}
icon={<BoldIcon />}
/>
</span>
<span data-testid="italic-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_ITALIC)}
icon={<ItalicIcon />}
/>
</span>
<span data-testid="underline-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_UNDERLINE)}
icon={<UnderlineIcon />}
/>
</span>
<span data-testid="link-button">
<LinkToolbarButton icon={<LinkIcon />} />
</span>
</HStack>
)
}

View File

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

View File

@@ -0,0 +1,7 @@
import { ChatIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const TextBubbleIcon = (props: IconProps) => (
<ChatIcon color="blue.500" {...props} />
)

View File

@@ -0,0 +1,3 @@
export { TextBubbleEditor } from './components/TextBubbleEditor'
export { TextBubbleContent } from './components/TextBubbleContent'
export { TextBubbleIcon } from './components/TextBubbleIcon'

View File

@@ -0,0 +1,67 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { BubbleBlockType, defaultTextBubbleContent } from 'models'
import cuid from 'cuid'
import { typebotViewer } from 'utils/playwright/testHelpers'
test.describe('Text bubble block', () => {
test('rich text features should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.TEXT,
content: defaultTextBubbleContent,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('[data-testid="bold-button"]')
await page.type('div[role="textbox"]', 'Bold text')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.click('[data-testid="bold-button"]')
await page.click('[data-testid="italic-button"]')
await page.type('div[role="textbox"]', 'Italic text')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.click('[data-testid="underline-button"]')
await page.click('[data-testid="italic-button"]')
await page.type('div[role="textbox"]', 'Underlined text')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.click('[data-testid="bold-button"]')
await page.click('[data-testid="italic-button"]')
await page.type('div[role="textbox"]', 'Everything text')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.type('div[role="textbox"]', 'My super link')
await page.waitForTimeout(300)
await page.press('div[role="textbox"]', 'Shift+Meta+ArrowLeft')
await page.click('[data-testid="link-button"]')
await page.fill('input[placeholder="Paste link"]', 'https://github.com')
await page.press('input[placeholder="Paste link"]', 'Enter')
await page.press('div[role="textbox"]', 'Shift+Enter')
await page.click('button[aria-label="Insert variable"]')
await page.fill('[data-testid="variables-input"]', 'test')
await page.click('text=Create "test"')
await page.click('text=Preview')
await expect(
typebotViewer(page).locator('span.slate-bold >> nth=0')
).toHaveText('Bold text')
await expect(
typebotViewer(page).locator('span.slate-italic >> nth=0')
).toHaveText('Italic text')
await expect(
typebotViewer(page).locator('span.slate-underline >> nth=0')
).toHaveText('Underlined text')
await expect(
typebotViewer(page).locator('a[href="https://github.com"]')
).toHaveText('My super link')
})
})

View File

@@ -0,0 +1,13 @@
import { Parser } from 'htmlparser2'
export const parseHtmlStringToPlainText = (html: string): string => {
let label = ''
const parser = new Parser({
ontext(text) {
label += `${text}`
},
})
parser.write(html)
parser.end()
return label
}