♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
8
apps/builder/src/components/AlertInfo.tsx
Normal file
8
apps/builder/src/components/AlertInfo.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { AlertProps, Alert, AlertIcon } from '@chakra-ui/react'
|
||||
|
||||
export const AlertInfo = (props: AlertProps) => (
|
||||
<Alert status="info" bgColor={'blue.50'} rounded="md" {...props}>
|
||||
<AlertIcon />
|
||||
{props.children}
|
||||
</Alert>
|
||||
)
|
||||
146
apps/builder/src/components/CodeEditor.tsx
Normal file
146
apps/builder/src/components/CodeEditor.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Box, BoxProps, HStack } from '@chakra-ui/react'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { linter, LintSource } from '@codemirror/lint'
|
||||
import { VariablesButton } from '@/features/variables'
|
||||
import { Variable } from 'models'
|
||||
import { env } from 'utils'
|
||||
|
||||
const linterExtension = linter(jsonParseLinter() as unknown as LintSource)
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
lang?: 'css' | 'json' | 'js' | 'html'
|
||||
isReadOnly?: boolean
|
||||
debounceTimeout?: number
|
||||
withVariableButton?: boolean
|
||||
height?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
export const CodeEditor = ({
|
||||
value,
|
||||
lang,
|
||||
onChange,
|
||||
height = '250px',
|
||||
withVariableButton = true,
|
||||
isReadOnly = false,
|
||||
debounceTimeout = 1000,
|
||||
...props
|
||||
}: Props & Omit<BoxProps, 'onChange'>) => {
|
||||
const editorContainer = useRef<HTMLDivElement | null>(null)
|
||||
const editorView = useRef<EditorView | null>(null)
|
||||
const [, setPlainTextValue] = useState(value)
|
||||
const [carretPosition, setCarretPosition] = useState<number>(0)
|
||||
const isVariableButtonDisplayed = withVariableButton && !isReadOnly
|
||||
|
||||
const debounced = useDebouncedCallback(
|
||||
(value) => {
|
||||
setPlainTextValue(value)
|
||||
onChange && onChange(value)
|
||||
},
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debounced.flush()
|
||||
},
|
||||
[debounced]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorView.current || !isReadOnly) return
|
||||
editorView.current.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.current.state.doc.length,
|
||||
insert: value,
|
||||
},
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
const editor = initEditor(value)
|
||||
return () => {
|
||||
editor?.destroy()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const initEditor = (value: string) => {
|
||||
if (!editorContainer.current) return
|
||||
const updateListenerExtension = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && onChange)
|
||||
debounced(update.state.doc.toJSON().join('\n'))
|
||||
})
|
||||
const extensions = [
|
||||
updateListenerExtension,
|
||||
basicSetup,
|
||||
EditorState.readOnly.of(isReadOnly),
|
||||
]
|
||||
if (lang === 'json') {
|
||||
extensions.push(json())
|
||||
extensions.push(linterExtension)
|
||||
}
|
||||
if (lang === 'css') extensions.push(css())
|
||||
if (lang === 'js') extensions.push(javascript())
|
||||
if (lang === 'html') extensions.push(html())
|
||||
extensions.push(
|
||||
EditorView.theme({
|
||||
'&': { maxHeight: '500px' },
|
||||
'.cm-gutter,.cm-content': { minHeight: isReadOnly ? '0' : height },
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
})
|
||||
)
|
||||
const editor = new EditorView({
|
||||
state: EditorState.create({
|
||||
extensions,
|
||||
}),
|
||||
parent: editorContainer.current,
|
||||
})
|
||||
editor.dispatch({
|
||||
changes: { from: 0, insert: value },
|
||||
})
|
||||
editorView.current = editor
|
||||
return editor
|
||||
}
|
||||
|
||||
const handleVariableSelected = (variable?: Pick<Variable, 'id' | 'name'>) => {
|
||||
editorView.current?.focus()
|
||||
const insert = `{{${variable?.name}}}`
|
||||
editorView.current?.dispatch({
|
||||
changes: {
|
||||
from: carretPosition,
|
||||
insert,
|
||||
},
|
||||
selection: { anchor: carretPosition + insert.length },
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
if (!editorContainer.current) return
|
||||
setCarretPosition(editorView.current?.state.selection.main.from ?? 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack align="flex-end" spacing={0}>
|
||||
<Box
|
||||
w={isVariableButtonDisplayed ? 'calc(100% - 32px)' : '100%'}
|
||||
ref={editorContainer}
|
||||
data-testid="code-editor"
|
||||
{...props}
|
||||
onKeyUp={handleKeyUp}
|
||||
/>
|
||||
{isVariableButtonDisplayed && (
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} size="sm" />
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
137
apps/builder/src/components/ColorPicker.tsx
Normal file
137
apps/builder/src/components/ColorPicker.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverArrow,
|
||||
PopoverCloseButton,
|
||||
PopoverHeader,
|
||||
Center,
|
||||
PopoverBody,
|
||||
SimpleGrid,
|
||||
Input,
|
||||
Button,
|
||||
Stack,
|
||||
ButtonProps,
|
||||
} from '@chakra-ui/react'
|
||||
import React, { ChangeEvent, useEffect, useState } from 'react'
|
||||
import tinyColor from 'tinycolor2'
|
||||
|
||||
const colorsSelection: `#${string}`[] = [
|
||||
'#264653',
|
||||
'#e9c46a',
|
||||
'#2a9d8f',
|
||||
'#7209b7',
|
||||
'#023e8a',
|
||||
'#ffe8d6',
|
||||
'#d8f3dc',
|
||||
'#4ea8de',
|
||||
'#ffb4a2',
|
||||
]
|
||||
|
||||
type Props = {
|
||||
initialColor: string
|
||||
onColorChange: (color: string) => void
|
||||
}
|
||||
|
||||
export const ColorPicker = ({ initialColor, onColorChange }: Props) => {
|
||||
const [color, setColor] = useState(initialColor)
|
||||
|
||||
useEffect(() => {
|
||||
onColorChange(color)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [color])
|
||||
|
||||
const handleColorChange = (e: ChangeEvent<HTMLInputElement>) =>
|
||||
setColor(e.target.value)
|
||||
|
||||
const handleClick = (color: string) => () => setColor(color)
|
||||
|
||||
return (
|
||||
<Popover variant="picker" placement="right" isLazy>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
aria-label={'Pick a color'}
|
||||
bgColor={color}
|
||||
_hover={{ bgColor: `#${tinyColor(color).darken(10).toHex()}` }}
|
||||
_active={{ bgColor: `#${tinyColor(color).darken(30).toHex()}` }}
|
||||
height="22px"
|
||||
width="22px"
|
||||
padding={0}
|
||||
borderRadius={3}
|
||||
borderWidth={1}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent width="170px">
|
||||
<PopoverArrow bg={color} />
|
||||
<PopoverCloseButton color="white" />
|
||||
<PopoverHeader
|
||||
height="100px"
|
||||
backgroundColor={color}
|
||||
borderTopLeftRadius={5}
|
||||
borderTopRightRadius={5}
|
||||
color={tinyColor(color).isLight() ? 'gray.800' : 'white'}
|
||||
>
|
||||
<Center height="100%">{color}</Center>
|
||||
</PopoverHeader>
|
||||
<PopoverBody as={Stack}>
|
||||
<SimpleGrid columns={5} spacing={2}>
|
||||
{colorsSelection.map((c) => (
|
||||
<Button
|
||||
key={c}
|
||||
aria-label={c}
|
||||
background={c}
|
||||
height="22px"
|
||||
width="22px"
|
||||
padding={0}
|
||||
minWidth="unset"
|
||||
borderRadius={3}
|
||||
_hover={{ background: c }}
|
||||
onClick={handleClick(c)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Input
|
||||
borderRadius={3}
|
||||
marginTop={3}
|
||||
placeholder="#2a9d8f"
|
||||
aria-label="Color value"
|
||||
size="sm"
|
||||
value={color}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
<NativeColorPicker
|
||||
size="sm"
|
||||
color={color}
|
||||
onColorChange={handleColorChange}
|
||||
>
|
||||
Advanced picker
|
||||
</NativeColorPicker>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const NativeColorPicker = ({
|
||||
color,
|
||||
onColorChange,
|
||||
...props
|
||||
}: {
|
||||
color: string
|
||||
onColorChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
} & ButtonProps) => {
|
||||
return (
|
||||
<>
|
||||
<Button as="label" htmlFor="native-picker" {...props}>
|
||||
{props.children}
|
||||
</Button>
|
||||
<Input
|
||||
type="color"
|
||||
display="none"
|
||||
id="native-picker"
|
||||
value={color}
|
||||
onChange={onColorChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
77
apps/builder/src/components/ConfirmModal.tsx
Normal file
77
apps/builder/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
type ConfirmDeleteModalProps = {
|
||||
isOpen: boolean
|
||||
onConfirm: () => Promise<unknown>
|
||||
onClose: () => void
|
||||
message: JSX.Element
|
||||
title?: string
|
||||
confirmButtonLabel: string
|
||||
confirmButtonColor?: 'blue' | 'red'
|
||||
}
|
||||
|
||||
export const ConfirmModal = ({
|
||||
title,
|
||||
message,
|
||||
isOpen,
|
||||
onClose,
|
||||
confirmButtonLabel,
|
||||
onConfirm,
|
||||
confirmButtonColor = 'red',
|
||||
}: ConfirmDeleteModalProps) => {
|
||||
const [confirmLoading, setConfirmLoading] = useState(false)
|
||||
const cancelRef = useRef(null)
|
||||
|
||||
const onConfirmClick = async () => {
|
||||
setConfirmLoading(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
} catch (e) {
|
||||
setConfirmLoading(false)
|
||||
return setConfirmLoading(false)
|
||||
}
|
||||
setConfirmLoading(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
{title ?? 'Are you sure?'}
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>{message}</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={confirmButtonColor}
|
||||
onClick={onConfirmClick}
|
||||
ml={3}
|
||||
isLoading={confirmLoading}
|
||||
>
|
||||
{confirmButtonLabel}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
108
apps/builder/src/components/ContextMenu.tsx
Normal file
108
apps/builder/src/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useEventListener,
|
||||
Portal,
|
||||
Menu,
|
||||
MenuButton,
|
||||
PortalProps,
|
||||
MenuButtonProps,
|
||||
MenuProps,
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
export interface ContextMenuProps<T extends HTMLElement> {
|
||||
renderMenu: () => JSX.Element | null
|
||||
children: (
|
||||
ref: MutableRefObject<T | null>,
|
||||
isOpened: boolean
|
||||
) => JSX.Element | null
|
||||
menuProps?: MenuProps
|
||||
portalProps?: PortalProps
|
||||
menuButtonProps?: MenuButtonProps
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export function ContextMenu<T extends HTMLElement = HTMLElement>(
|
||||
props: ContextMenuProps<T>
|
||||
) {
|
||||
const [isOpened, setIsOpened] = useState(false)
|
||||
const [isRendered, setIsRendered] = useState(false)
|
||||
const [isDeferredOpen, setIsDeferredOpen] = useState(false)
|
||||
const [position, setPosition] = useState<[number, number]>([0, 0])
|
||||
const targetRef = useRef<T>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
setTimeout(() => {
|
||||
setIsRendered(true)
|
||||
setTimeout(() => {
|
||||
setIsDeferredOpen(true)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
setIsDeferredOpen(false)
|
||||
const timeout = setTimeout(() => {
|
||||
setIsRendered(isOpened)
|
||||
}, 1000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isOpened])
|
||||
|
||||
useEventListener(
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
if (props.isDisabled) return
|
||||
if (e.currentTarget === targetRef.current) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsOpened(true)
|
||||
setPosition([e.pageX, e.pageY])
|
||||
} else {
|
||||
setIsOpened(false)
|
||||
}
|
||||
},
|
||||
targetRef.current
|
||||
)
|
||||
|
||||
const onCloseHandler = useCallback(() => {
|
||||
props.menuProps?.onClose?.()
|
||||
setIsOpened(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.menuProps?.onClose, setIsOpened])
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.children(targetRef, isOpened)}
|
||||
{isRendered && (
|
||||
<Portal {...props.portalProps}>
|
||||
<Menu
|
||||
isOpen={isDeferredOpen}
|
||||
gutter={0}
|
||||
{...props.menuProps}
|
||||
onClose={onCloseHandler}
|
||||
>
|
||||
<MenuButton
|
||||
aria-hidden={true}
|
||||
w={1}
|
||||
h={1}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: position[0],
|
||||
top: position[1],
|
||||
cursor: 'default',
|
||||
}}
|
||||
{...props.menuButtonProps}
|
||||
/>
|
||||
{props.renderMenu()}
|
||||
</Menu>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
apps/builder/src/components/CopyButton.tsx
Normal file
25
apps/builder/src/components/CopyButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { ButtonProps, Button, useClipboard } from '@chakra-ui/react'
|
||||
|
||||
interface CopyButtonProps extends ButtonProps {
|
||||
textToCopy: string
|
||||
onCopied?: () => void
|
||||
}
|
||||
|
||||
export const CopyButton = (props: CopyButtonProps) => {
|
||||
const { textToCopy, onCopied, ...buttonProps } = props
|
||||
const { hasCopied, onCopy } = useClipboard(textToCopy)
|
||||
|
||||
return (
|
||||
<Button
|
||||
isDisabled={hasCopied}
|
||||
onClick={() => {
|
||||
onCopy()
|
||||
if (onCopied) onCopied()
|
||||
}}
|
||||
{...buttonProps}
|
||||
>
|
||||
{!hasCopied ? 'Copy' : 'Copied'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
66
apps/builder/src/components/DropdownList.tsx
Normal file
66
apps/builder/src/components/DropdownList.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Button,
|
||||
chakra,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuButtonProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Portal,
|
||||
Stack,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from '@/components/icons'
|
||||
import React, { ReactNode } from 'react'
|
||||
|
||||
type Props<T> = {
|
||||
currentItem?: T
|
||||
onItemSelect: (item: T) => void
|
||||
items: T[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export const DropdownList = <T,>({
|
||||
currentItem,
|
||||
onItemSelect,
|
||||
items,
|
||||
placeholder = '',
|
||||
...props
|
||||
}: Props<T> & MenuButtonProps) => {
|
||||
const handleMenuItemClick = (operator: T) => () => {
|
||||
onItemSelect(operator)
|
||||
}
|
||||
return (
|
||||
<Menu isLazy placement="bottom-end" matchWidth>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
|
||||
colorScheme="gray"
|
||||
justifyContent="space-between"
|
||||
textAlign="left"
|
||||
{...props}
|
||||
>
|
||||
<chakra.span noOfLines={1} display="block">
|
||||
{(currentItem ?? placeholder) as unknown as ReactNode}
|
||||
</chakra.span>
|
||||
</MenuButton>
|
||||
<Portal>
|
||||
<MenuList maxW="500px" zIndex={1500}>
|
||||
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||
{items.map((item) => (
|
||||
<MenuItem
|
||||
key={item as unknown as string}
|
||||
maxW="500px"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
onClick={handleMenuItemClick(item)}
|
||||
>
|
||||
{item as unknown as ReactNode}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Stack>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
64
apps/builder/src/components/EditableEmojiOrImageIcon.tsx
Normal file
64
apps/builder/src/components/EditableEmojiOrImageIcon.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Popover,
|
||||
Tooltip,
|
||||
chakra,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
Flex,
|
||||
} from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
import { EmojiOrImageIcon } from './EmojiOrImageIcon'
|
||||
import { ImageUploadContent } from './ImageUploadContent'
|
||||
|
||||
type Props = {
|
||||
uploadFilePath: string
|
||||
icon?: string | null
|
||||
onChangeIcon: (icon: string) => void
|
||||
boxSize?: string
|
||||
}
|
||||
|
||||
export const EditableEmojiOrImageIcon = ({
|
||||
uploadFilePath,
|
||||
icon,
|
||||
onChangeIcon,
|
||||
boxSize,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Popover isLazy>
|
||||
{({ onClose }) => (
|
||||
<>
|
||||
<Tooltip label="Change icon">
|
||||
<Flex
|
||||
cursor="pointer"
|
||||
p="2"
|
||||
rounded="md"
|
||||
_hover={{ bgColor: 'gray.100' }}
|
||||
transition="background-color 0.2s"
|
||||
data-testid="editable-icon"
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<chakra.span>
|
||||
<EmojiOrImageIcon
|
||||
icon={icon}
|
||||
emojiFontSize="2xl"
|
||||
boxSize={boxSize}
|
||||
/>
|
||||
</chakra.span>
|
||||
</PopoverTrigger>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<PopoverContent p="2">
|
||||
<ImageUploadContent
|
||||
filePath={uploadFilePath}
|
||||
defaultUrl={icon ?? ''}
|
||||
onSubmit={onChangeIcon}
|
||||
isGiphyEnabled={false}
|
||||
isEmojiEnabled={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
39
apps/builder/src/components/EmojiOrImageIcon.tsx
Normal file
39
apps/builder/src/components/EmojiOrImageIcon.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ToolIcon } from '@/components/icons'
|
||||
import React from 'react'
|
||||
import { chakra, IconProps, Image } from '@chakra-ui/react'
|
||||
|
||||
type Props = {
|
||||
icon?: string | null
|
||||
emojiFontSize?: string
|
||||
boxSize?: string
|
||||
defaultIcon?: (props: IconProps) => JSX.Element
|
||||
}
|
||||
|
||||
export const EmojiOrImageIcon = ({
|
||||
icon,
|
||||
boxSize = '25px',
|
||||
emojiFontSize,
|
||||
defaultIcon = ToolIcon,
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
{icon ? (
|
||||
icon.startsWith('http') ? (
|
||||
<Image
|
||||
src={icon}
|
||||
boxSize={boxSize}
|
||||
objectFit={icon.endsWith('.svg') ? undefined : 'cover'}
|
||||
alt="typebot icon"
|
||||
rounded="10%"
|
||||
/>
|
||||
) : (
|
||||
<chakra.span role="img" fontSize={emojiFontSize}>
|
||||
{icon}
|
||||
</chakra.span>
|
||||
)
|
||||
) : (
|
||||
defaultIcon({ boxSize })
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
apps/builder/src/components/GoogleLogo.tsx
Normal file
24
apps/builder/src/components/GoogleLogo.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IconProps, Icon } from '@chakra-ui/react'
|
||||
|
||||
export const GoogleLogo = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...props}>
|
||||
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
|
||||
/>
|
||||
</g>
|
||||
</Icon>
|
||||
)
|
||||
23
apps/builder/src/components/ImageUploadContent/GiphyLogo.tsx
Normal file
23
apps/builder/src/components/ImageUploadContent/GiphyLogo.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IconProps, Icon } from '@chakra-ui/react'
|
||||
|
||||
export const GiphyLogo = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 163.79999999999998 35" {...props}>
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<path d="M4 4h20v27H4z" fill="#000" />
|
||||
<g fillRule="nonzero">
|
||||
<path d="M0 3h4v29H0z" fill="#04ff8e" />
|
||||
<path d="M24 11h4v21h-4z" fill="#8e2eff" />
|
||||
<path d="M0 31h28v4H0z" fill="#00c5ff" />
|
||||
<path d="M0 0h16v4H0z" fill="#fff152" />
|
||||
<path d="M24 8V4h-4V0h-4v12h12V8" fill="#ff5b5b" />
|
||||
<path d="M24 16v-4h4" fill="#551c99" />
|
||||
</g>
|
||||
<path d="M16 0v4h-4" fill="#999131" />
|
||||
<path
|
||||
d="M59.1 12c-2-1.9-4.4-2.4-6.2-2.4-4.4 0-7.3 2.6-7.3 8 0 3.5 1.8 7.8 7.3 7.8 1.4 0 3.7-.3 5.2-1.4v-3.5h-6.9v-6h13.3v12.1c-1.7 3.5-6.4 5.3-11.7 5.3-10.7 0-14.8-7.2-14.8-14.3S42.7 3.2 52.9 3.2c3.8 0 7.1.8 10.7 4.4zm9.1 19.2V4h7.6v27.2zm20.1-7.4v7.3h-7.7V4h13.2c7.3 0 10.9 4.6 10.9 9.9 0 5.6-3.6 9.9-10.9 9.9zm0-6.5h5.5c2.1 0 3.2-1.6 3.2-3.3 0-1.8-1.1-3.4-3.2-3.4h-5.5zM125 31.2V20.9h-9.8v10.3h-7.7V4h7.7v10.3h9.8V4h7.6v27.2zm24.2-17.9l5.9-9.3h8.7v.3l-10.8 16v10.8h-7.7V20.3L135 4.3V4h8.7z"
|
||||
fill="#000"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</Icon>
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Flex, Stack, Text } from '@chakra-ui/react'
|
||||
import { GiphyFetch } from '@giphy/js-fetch-api'
|
||||
import { Grid } from '@giphy/react-components'
|
||||
import { GiphyLogo } from './GiphyLogo'
|
||||
import React, { useState } from 'react'
|
||||
import { env, isEmpty } from 'utils'
|
||||
import { Input } from '../inputs'
|
||||
|
||||
type GiphySearchFormProps = {
|
||||
onSubmit: (url: string) => void
|
||||
}
|
||||
|
||||
const giphyFetch = new GiphyFetch(env('GIPHY_API_KEY') as string)
|
||||
|
||||
export const GiphySearchForm = ({ onSubmit }: GiphySearchFormProps) => {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const fetchGifs = (offset: number) =>
|
||||
giphyFetch.search(inputValue, { offset, limit: 10 })
|
||||
|
||||
const fetchGifsTrending = (offset: number) =>
|
||||
giphyFetch.trending({ offset, limit: 10 })
|
||||
|
||||
return isEmpty(env('GIPHY_API_KEY')) ? (
|
||||
<Text>NEXT_PUBLIC_GIPHY_API_KEY is missing in environment</Text>
|
||||
) : (
|
||||
<Stack>
|
||||
<Flex align="center">
|
||||
<Input
|
||||
flex="1"
|
||||
autoFocus
|
||||
placeholder="Search..."
|
||||
onChange={setInputValue}
|
||||
withVariableButton={false}
|
||||
/>
|
||||
<GiphyLogo w="100px" />
|
||||
</Flex>
|
||||
<Flex overflowY="scroll" maxH="400px">
|
||||
<Grid
|
||||
key={inputValue}
|
||||
onGifClick={(gif, e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(gif.images.downsized.url)
|
||||
}}
|
||||
fetchGifs={inputValue === '' ? fetchGifsTrending : fetchGifs}
|
||||
width={475}
|
||||
columns={3}
|
||||
className="my-4"
|
||||
/>
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Flex, HStack, Stack } from '@chakra-ui/react'
|
||||
import { UploadButton } from './UploadButton'
|
||||
import { GiphySearchForm } from './GiphySearchForm'
|
||||
import { Input } from '../inputs/Input'
|
||||
import { EmojiSearchableList } from './emoji/EmojiSearchableList'
|
||||
|
||||
type Props = {
|
||||
filePath: string
|
||||
includeFileName?: boolean
|
||||
defaultUrl?: string
|
||||
isEmojiEnabled?: boolean
|
||||
isGiphyEnabled?: boolean
|
||||
onSubmit: (url: string) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export const ImageUploadContent = ({
|
||||
filePath,
|
||||
includeFileName,
|
||||
defaultUrl,
|
||||
onSubmit,
|
||||
isEmojiEnabled = false,
|
||||
isGiphyEnabled = true,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const [currentTab, setCurrentTab] = useState<
|
||||
'link' | 'upload' | 'giphy' | 'emoji'
|
||||
>(isEmojiEnabled ? 'emoji' : 'upload')
|
||||
|
||||
const handleSubmit = (url: string) => {
|
||||
onSubmit(url)
|
||||
onClose && onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<HStack>
|
||||
{isEmojiEnabled && (
|
||||
<Button
|
||||
variant={currentTab === 'emoji' ? 'solid' : 'ghost'}
|
||||
onClick={() => setCurrentTab('emoji')}
|
||||
size="sm"
|
||||
>
|
||||
Emoji
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={currentTab === 'upload' ? 'solid' : 'ghost'}
|
||||
onClick={() => setCurrentTab('upload')}
|
||||
size="sm"
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentTab === 'link' ? 'solid' : 'ghost'}
|
||||
onClick={() => setCurrentTab('link')}
|
||||
size="sm"
|
||||
>
|
||||
Embed link
|
||||
</Button>
|
||||
{isGiphyEnabled && (
|
||||
<Button
|
||||
variant={currentTab === 'giphy' ? 'solid' : 'ghost'}
|
||||
onClick={() => setCurrentTab('giphy')}
|
||||
size="sm"
|
||||
>
|
||||
Giphy
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<BodyContent
|
||||
filePath={filePath}
|
||||
includeFileName={includeFileName}
|
||||
tab={currentTab}
|
||||
onSubmit={handleSubmit}
|
||||
defaultUrl={defaultUrl}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const BodyContent = ({
|
||||
includeFileName,
|
||||
filePath,
|
||||
tab,
|
||||
defaultUrl,
|
||||
onSubmit,
|
||||
}: {
|
||||
includeFileName?: boolean
|
||||
filePath: string
|
||||
tab: 'upload' | 'link' | 'giphy' | 'emoji'
|
||||
defaultUrl?: string
|
||||
onSubmit: (url: string) => void
|
||||
}) => {
|
||||
switch (tab) {
|
||||
case 'upload':
|
||||
return (
|
||||
<UploadFileContent
|
||||
filePath={filePath}
|
||||
includeFileName={includeFileName}
|
||||
onNewUrl={onSubmit}
|
||||
/>
|
||||
)
|
||||
case 'link':
|
||||
return <EmbedLinkContent defaultUrl={defaultUrl} onNewUrl={onSubmit} />
|
||||
case 'giphy':
|
||||
return <GiphyContent onNewUrl={onSubmit} />
|
||||
case 'emoji':
|
||||
return <EmojiSearchableList onEmojiSelected={onSubmit} />
|
||||
}
|
||||
}
|
||||
|
||||
type ContentProps = { onNewUrl: (url: string) => void }
|
||||
|
||||
const UploadFileContent = ({
|
||||
filePath,
|
||||
includeFileName,
|
||||
onNewUrl,
|
||||
}: ContentProps & { filePath: string; includeFileName?: boolean }) => (
|
||||
<Flex justify="center" py="2">
|
||||
<UploadButton
|
||||
filePath={filePath}
|
||||
onFileUploaded={onNewUrl}
|
||||
includeFileName={includeFileName}
|
||||
colorScheme="blue"
|
||||
>
|
||||
Choose an image
|
||||
</UploadButton>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
const EmbedLinkContent = ({
|
||||
defaultUrl,
|
||||
onNewUrl,
|
||||
}: ContentProps & { defaultUrl?: string }) => (
|
||||
<Stack py="2">
|
||||
<Input
|
||||
placeholder={'Paste the image link...'}
|
||||
onChange={onNewUrl}
|
||||
defaultValue={defaultUrl ?? ''}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
const GiphyContent = ({ onNewUrl }: ContentProps) => (
|
||||
<GiphySearchForm onSubmit={onNewUrl} />
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import { compressFile } from '@/utils/helpers'
|
||||
import { Button, ButtonProps, chakra } from '@chakra-ui/react'
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { uploadFiles } from 'utils'
|
||||
|
||||
type UploadButtonProps = {
|
||||
filePath: string
|
||||
includeFileName?: boolean
|
||||
onFileUploaded: (url: string) => void
|
||||
} & ButtonProps
|
||||
|
||||
export const UploadButton = ({
|
||||
filePath,
|
||||
includeFileName,
|
||||
onFileUploaded,
|
||||
...props
|
||||
}: UploadButtonProps) => {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target?.files) return
|
||||
setIsUploading(true)
|
||||
const file = e.target.files[0]
|
||||
const urls = await uploadFiles({
|
||||
files: [
|
||||
{
|
||||
file: await compressFile(file),
|
||||
path: `public/${filePath}${includeFileName ? `/${file.name}` : ''}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (urls.length && urls[0]) onFileUploaded(urls[0])
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<chakra.input
|
||||
data-testid="file-upload-input"
|
||||
type="file"
|
||||
id="file-input"
|
||||
display="none"
|
||||
onChange={handleInputChange}
|
||||
accept=".jpg, .jpeg, .png, .svg, .gif"
|
||||
/>
|
||||
<Button
|
||||
as="label"
|
||||
size="sm"
|
||||
htmlFor="file-input"
|
||||
cursor="pointer"
|
||||
isLoading={isUploading}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import emojis from './emojiList.json'
|
||||
import emojiTagsData from 'emojilib'
|
||||
import {
|
||||
Stack,
|
||||
SimpleGrid,
|
||||
GridItem,
|
||||
Button,
|
||||
Input as ClassicInput,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { useState, ChangeEvent, useEffect, useRef } from 'react'
|
||||
|
||||
const emojiTags = emojiTagsData as Record<string, string[]>
|
||||
|
||||
const people = emojis['Smileys & Emotion'].concat(emojis['People & Body'])
|
||||
const nature = emojis['Animals & Nature']
|
||||
const food = emojis['Food & Drink']
|
||||
const activities = emojis['Activities']
|
||||
const travel = emojis['Travel & Places']
|
||||
const objects = emojis['Objects']
|
||||
const symbols = emojis['Symbols']
|
||||
const flags = emojis['Flags']
|
||||
|
||||
export const EmojiSearchableList = ({
|
||||
onEmojiSelected,
|
||||
}: {
|
||||
onEmojiSelected: (emoji: string) => void
|
||||
}) => {
|
||||
const scrollContainer = useRef<HTMLDivElement>(null)
|
||||
const bottomElement = useRef<HTMLDivElement>(null)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [filteredPeople, setFilteredPeople] = useState(people)
|
||||
const [filteredAnimals, setFilteredAnimals] = useState(nature)
|
||||
const [filteredFood, setFilteredFood] = useState(food)
|
||||
const [filteredTravel, setFilteredTravel] = useState(travel)
|
||||
const [filteredActivities, setFilteredActivities] = useState(activities)
|
||||
const [filteredObjects, setFilteredObjects] = useState(objects)
|
||||
const [filteredSymbols, setFilteredSymbols] = useState(symbols)
|
||||
const [filteredFlags, setFilteredFlags] = useState(flags)
|
||||
const [totalDisplayedCategories, setTotalDisplayedCategories] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bottomElement.current) return
|
||||
const observer = new IntersectionObserver(handleObserver, {
|
||||
root: scrollContainer.current,
|
||||
})
|
||||
if (bottomElement.current) observer.observe(bottomElement.current)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bottomElement.current])
|
||||
|
||||
const handleObserver = (entities: IntersectionObserverEntry[]) => {
|
||||
const target = entities[0]
|
||||
if (target.isIntersecting) setTotalDisplayedCategories((c) => c + 1)
|
||||
}
|
||||
|
||||
const handleSearchChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const searchValue = e.target.value
|
||||
if (searchValue.length <= 2 && isSearching) return resetEmojiList()
|
||||
setIsSearching(true)
|
||||
setTotalDisplayedCategories(8)
|
||||
const byTag = (emoji: string) =>
|
||||
emojiTags[emoji].find((tag) => tag.includes(searchValue))
|
||||
setFilteredPeople(people.filter(byTag))
|
||||
setFilteredAnimals(nature.filter(byTag))
|
||||
setFilteredFood(food.filter(byTag))
|
||||
setFilteredTravel(travel.filter(byTag))
|
||||
setFilteredActivities(activities.filter(byTag))
|
||||
setFilteredObjects(objects.filter(byTag))
|
||||
setFilteredSymbols(symbols.filter(byTag))
|
||||
setFilteredFlags(flags.filter(byTag))
|
||||
}
|
||||
|
||||
const resetEmojiList = () => {
|
||||
setTotalDisplayedCategories(1)
|
||||
setIsSearching(false)
|
||||
setFilteredPeople(people)
|
||||
setFilteredAnimals(nature)
|
||||
setFilteredFood(food)
|
||||
setFilteredTravel(travel)
|
||||
setFilteredActivities(activities)
|
||||
setFilteredObjects(objects)
|
||||
setFilteredSymbols(symbols)
|
||||
setFilteredFlags(flags)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<ClassicInput placeholder="Search..." onChange={handleSearchChange} />
|
||||
<Stack ref={scrollContainer} overflow="scroll" maxH="350px" spacing={4}>
|
||||
{filteredPeople.length > 0 && (
|
||||
<Stack>
|
||||
<Text fontSize="sm" pl="2">
|
||||
People
|
||||
</Text>
|
||||
<EmojiGrid emojis={filteredPeople} onEmojiClick={onEmojiSelected} />
|
||||
</Stack>
|
||||
)}
|
||||
{filteredAnimals.length > 0 && totalDisplayedCategories >= 2 && (
|
||||
<Stack>
|
||||
<Text fontSize="sm" pl="2">
|
||||
Animals & Nature
|
||||
</Text>
|
||||
<EmojiGrid
|
||||
emojis={filteredAnimals}
|
||||
onEmojiClick={onEmojiSelected}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{filteredFood.length > 0 && totalDisplayedCategories >= 3 && (
|
||||
<Stack>
|
||||
<Text fontSize="sm" pl="2">
|
||||
Food & Drink
|
||||
</Text>
|
||||
<EmojiGrid emojis={filteredFood} onEmojiClick={onEmojiSelected} />
|
||||
</Stack>
|
||||
)}
|
||||
{filteredTravel.length > 0 && totalDisplayedCategories >= 4 && (
|
||||
<Stack>
|
||||
<Text fontSize="sm" pl="2">
|
||||
Travel & Places
|
||||
</Text>
|
||||
<EmojiGrid emojis={filteredTravel} onEmojiClick={onEmojiSelected} />
|
||||
</Stack>
|
||||
)}
|
||||
{filteredActivities.length > 0 && totalDisplayedCategories >= 5 && (
|
||||
<Stack>
|
||||
<Text fontSize="sm" pl="2">
|
||||
Activities
|
||||
</Text>
|
||||
<EmojiGrid
|
||||
emojis={filteredActivities}
|
||||
onEmojiClick={onEmojiSelected}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{filteredObjects.length > 0 && totalDisplayedCategories >= 6 && (
|
||||
<Stack>
|
||||
<Text fontSize="sm" pl="2">
|
||||
Objects
|
||||
</Text>
|
||||
<EmojiGrid
|
||||
emojis={filteredObjects}
|
||||
onEmojiClick={onEmojiSelected}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{filteredSymbols.length > 0 && totalDisplayedCategories >= 7 && (
|
||||
<Stack>
|
||||
<Text fontSize="sm" pl="2">
|
||||
Symbols
|
||||
</Text>
|
||||
<EmojiGrid
|
||||
emojis={filteredSymbols}
|
||||
onEmojiClick={onEmojiSelected}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{filteredFlags.length > 0 && totalDisplayedCategories >= 8 && (
|
||||
<Stack>
|
||||
<Text fontSize="sm" pl="2">
|
||||
Flags
|
||||
</Text>
|
||||
<EmojiGrid emojis={filteredFlags} onEmojiClick={onEmojiSelected} />
|
||||
</Stack>
|
||||
)}
|
||||
<div ref={bottomElement} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const EmojiGrid = ({
|
||||
emojis,
|
||||
onEmojiClick,
|
||||
}: {
|
||||
emojis: string[]
|
||||
onEmojiClick: (emoji: string) => void
|
||||
}) => {
|
||||
const handleClick = (emoji: string) => () => onEmojiClick(emoji)
|
||||
return (
|
||||
<SimpleGrid spacing={0} columns={7}>
|
||||
{emojis.map((emoji) => (
|
||||
<GridItem
|
||||
as={Button}
|
||||
onClick={handleClick(emoji)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fontSize="xl"
|
||||
key={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</GridItem>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
1
apps/builder/src/components/ImageUploadContent/index.tsx
Normal file
1
apps/builder/src/components/ImageUploadContent/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ImageUploadContent } from './ImageUploadContent'
|
||||
16
apps/builder/src/components/MoreInfoTooltip.tsx
Normal file
16
apps/builder/src/components/MoreInfoTooltip.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Tooltip, chakra } from '@chakra-ui/react'
|
||||
import { HelpCircleIcon } from './icons'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const MoreInfoTooltip = ({ children }: Props) => {
|
||||
return (
|
||||
<Tooltip label={children} hasArrow rounded="md" p="3" placement="top">
|
||||
<chakra.span cursor="pointer">
|
||||
<HelpCircleIcon />
|
||||
</chakra.span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
225
apps/builder/src/components/SearchableDropdown.tsx
Normal file
225
apps/builder/src/components/SearchableDropdown.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
useDisclosure,
|
||||
useOutsideClick,
|
||||
Flex,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
Input,
|
||||
PopoverContent,
|
||||
Button,
|
||||
InputProps,
|
||||
HStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { Variable } from 'models'
|
||||
import { useState, useRef, useEffect, ChangeEvent } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { env, isDefined } from 'utils'
|
||||
import { VariablesButton } from '@/features/variables'
|
||||
|
||||
type Props = {
|
||||
selectedItem?: string
|
||||
items: string[]
|
||||
debounceTimeout?: number
|
||||
withVariableButton?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
} & InputProps
|
||||
|
||||
export const SearchableDropdown = ({
|
||||
selectedItem,
|
||||
items,
|
||||
withVariableButton = false,
|
||||
debounceTimeout = 1000,
|
||||
onValueChange,
|
||||
...inputProps
|
||||
}: Props) => {
|
||||
const [carretPosition, setCarretPosition] = useState<number>(0)
|
||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||
const [inputValue, setInputValue] = useState(selectedItem ?? '')
|
||||
const debounced = useDebouncedCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onValueChange ? onValueChange : () => {},
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
const [filteredItems, setFilteredItems] = useState([
|
||||
...items
|
||||
.filter((item) =>
|
||||
item.toLowerCase().includes((selectedItem ?? '').toLowerCase())
|
||||
)
|
||||
.slice(0, 50),
|
||||
])
|
||||
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
|
||||
number | undefined
|
||||
>()
|
||||
const dropdownRef = useRef(null)
|
||||
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debounced.flush()
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredItems.length > 0) return
|
||||
setFilteredItems([
|
||||
...items
|
||||
.filter((item) =>
|
||||
item.toLowerCase().includes((selectedItem ?? '').toLowerCase())
|
||||
)
|
||||
.slice(0, 50),
|
||||
])
|
||||
if (inputRef.current === document.activeElement) onOpen()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items])
|
||||
|
||||
useOutsideClick({
|
||||
ref: dropdownRef,
|
||||
handler: onClose,
|
||||
})
|
||||
|
||||
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) onOpen()
|
||||
setInputValue(e.target.value)
|
||||
debounced(e.target.value)
|
||||
if (e.target.value === '') {
|
||||
setFilteredItems([...items.slice(0, 50)])
|
||||
return
|
||||
}
|
||||
setFilteredItems([
|
||||
...items
|
||||
.filter((item) =>
|
||||
item.toLowerCase().includes((inputValue ?? '').toLowerCase())
|
||||
)
|
||||
.slice(0, 50),
|
||||
])
|
||||
}
|
||||
|
||||
const handleItemClick = (item: string) => () => {
|
||||
setInputValue(item)
|
||||
debounced(item)
|
||||
setKeyboardFocusIndex(undefined)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleVariableSelected = (variable?: Variable) => {
|
||||
if (!inputRef.current || !variable) return
|
||||
const cursorPosition = carretPosition
|
||||
const textBeforeCursorPosition = inputRef.current.value.substring(
|
||||
0,
|
||||
cursorPosition
|
||||
)
|
||||
const textAfterCursorPosition = inputRef.current.value.substring(
|
||||
cursorPosition,
|
||||
inputRef.current.value.length
|
||||
)
|
||||
const newValue =
|
||||
textBeforeCursorPosition +
|
||||
`{{${variable.name}}}` +
|
||||
textAfterCursorPosition
|
||||
setInputValue(newValue)
|
||||
debounced(newValue)
|
||||
inputRef.current.focus()
|
||||
setTimeout(() => {
|
||||
if (!inputRef.current) return
|
||||
inputRef.current.selectionStart = inputRef.current.selectionEnd =
|
||||
carretPosition + `{{${variable.name}}}`.length
|
||||
setCarretPosition(inputRef.current.selectionStart)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (inputRef.current?.selectionStart)
|
||||
setCarretPosition(inputRef.current.selectionStart)
|
||||
if (e.key === 'Enter' && isDefined(keyboardFocusIndex)) {
|
||||
handleItemClick(filteredItems[keyboardFocusIndex])()
|
||||
return setKeyboardFocusIndex(undefined)
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (keyboardFocusIndex === undefined) return setKeyboardFocusIndex(0)
|
||||
if (keyboardFocusIndex === filteredItems.length - 1) return
|
||||
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (keyboardFocusIndex === undefined) return
|
||||
if (keyboardFocusIndex === 0) return setKeyboardFocusIndex(undefined)
|
||||
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
setKeyboardFocusIndex(keyboardFocusIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex ref={dropdownRef} w="full">
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
initialFocusRef={inputRef}
|
||||
matchWidth
|
||||
offset={[0, 0]}
|
||||
isLazy
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<HStack spacing={0} align={'flex-end'} w="full">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onClick={onOpen}
|
||||
type="text"
|
||||
onKeyUp={handleKeyUp}
|
||||
{...inputProps}
|
||||
/>
|
||||
{withVariableButton && (
|
||||
<VariablesButton
|
||||
onSelectVariable={handleVariableSelected}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
maxH="35vh"
|
||||
overflowY="scroll"
|
||||
role="menu"
|
||||
w="inherit"
|
||||
shadow="lg"
|
||||
>
|
||||
{filteredItems.length > 0 && (
|
||||
<>
|
||||
{filteredItems.map((item, idx) => {
|
||||
return (
|
||||
<Button
|
||||
ref={(el) => (itemsRef.current[idx] = el)}
|
||||
minH="40px"
|
||||
key={idx}
|
||||
onClick={handleItemClick(item)}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
role="menuitem"
|
||||
variant="ghost"
|
||||
bgColor={
|
||||
keyboardFocusIndex === idx ? 'gray.200' : 'transparent'
|
||||
}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
37
apps/builder/src/components/Seo.tsx
Normal file
37
apps/builder/src/components/Seo.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Head from 'next/head'
|
||||
|
||||
export const Seo = ({
|
||||
title,
|
||||
currentUrl = 'https://app.typebot.io',
|
||||
description = 'Create and publish conversational forms that collect 4 times more answers and feel native to your product',
|
||||
imagePreviewUrl = 'https://app.typebot.io/site-preview.png',
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
currentUrl?: string
|
||||
imagePreviewUrl?: string
|
||||
}) => {
|
||||
const formattedTitle = `${title} | Typebot`
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{formattedTitle}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
|
||||
<meta property="twitter:url" content={currentUrl} />
|
||||
<meta property="og:url" content={currentUrl} />
|
||||
|
||||
<meta name="description" content={description} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
|
||||
<meta property="og:image" content={imagePreviewUrl} />
|
||||
<meta property="twitter:image" content={imagePreviewUrl} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
43
apps/builder/src/components/SupportBubble.tsx
Normal file
43
apps/builder/src/components/SupportBubble.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useTypebot } from '@/features/editor'
|
||||
import { useUser } from '@/features/account'
|
||||
import { useWorkspace } from '@/features/workspace'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { initBubble } from 'typebot-js'
|
||||
import { isCloudProdInstance } from '@/utils/helpers'
|
||||
import { planToReadable } from '@/features/billing'
|
||||
|
||||
export const SupportBubble = () => {
|
||||
const { typebot } = useTypebot()
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const [localTypebotId, setLocalTypebotId] = useState(typebot?.id)
|
||||
const [localUserId, setLocalUserId] = useState(user?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudProdInstance() &&
|
||||
(localTypebotId !== typebot?.id || localUserId !== user?.id)
|
||||
) {
|
||||
setLocalTypebotId(typebot?.id)
|
||||
setLocalUserId(user?.id)
|
||||
initBubble({
|
||||
url: `https://viewer.typebot.io/typebot-support`,
|
||||
backgroundColor: '#ffffff',
|
||||
button: {
|
||||
color: '#0042DA',
|
||||
},
|
||||
hiddenVariables: {
|
||||
'User ID': user?.id,
|
||||
'First name': user?.name?.split(' ')[0] ?? undefined,
|
||||
Email: user?.email ?? undefined,
|
||||
'Typebot ID': typebot?.id,
|
||||
'Avatar URL': user?.image ?? undefined,
|
||||
Plan: planToReadable(workspace?.plan),
|
||||
},
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, typebot])
|
||||
|
||||
return <></>
|
||||
}
|
||||
44
apps/builder/src/components/SwitchWithLabel.tsx
Normal file
44
apps/builder/src/components/SwitchWithLabel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Switch,
|
||||
SwitchProps,
|
||||
} from '@chakra-ui/react'
|
||||
import React, { useState } from 'react'
|
||||
import { MoreInfoTooltip } from './MoreInfoTooltip'
|
||||
|
||||
type SwitchWithLabelProps = {
|
||||
label: string
|
||||
initialValue: boolean
|
||||
moreInfoContent?: string
|
||||
onCheckChange: (isChecked: boolean) => void
|
||||
} & SwitchProps
|
||||
|
||||
export const SwitchWithLabel = ({
|
||||
label,
|
||||
initialValue,
|
||||
moreInfoContent,
|
||||
onCheckChange,
|
||||
...switchProps
|
||||
}: SwitchWithLabelProps) => {
|
||||
const [isChecked, setIsChecked] = useState(initialValue)
|
||||
|
||||
const handleChange = () => {
|
||||
setIsChecked(!isChecked)
|
||||
onCheckChange(!isChecked)
|
||||
}
|
||||
return (
|
||||
<FormControl as={HStack} justifyContent="space-between">
|
||||
<FormLabel mb="0">
|
||||
{label}
|
||||
{moreInfoContent && (
|
||||
<>
|
||||
<MoreInfoTooltip>{moreInfoContent}</MoreInfoTooltip>
|
||||
</>
|
||||
)}
|
||||
</FormLabel>
|
||||
<Switch isChecked={isChecked} onChange={handleChange} {...switchProps} />
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
107
apps/builder/src/components/TableList.tsx
Normal file
107
apps/builder/src/components/TableList.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Box, Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
|
||||
import { TrashIcon, PlusIcon } from '@/components/icons'
|
||||
import cuid from 'cuid'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
type ItemWithId<T> = T & { id: string }
|
||||
|
||||
export type TableListItemProps<T> = {
|
||||
item: T
|
||||
debounceTimeout?: number
|
||||
onItemChange: (item: T) => void
|
||||
}
|
||||
|
||||
type Props<T> = {
|
||||
initialItems: ItemWithId<T>[]
|
||||
addLabel?: string
|
||||
debounceTimeout?: number
|
||||
onItemsChange: (items: ItemWithId<T>[]) => void
|
||||
Item: (props: TableListItemProps<T>) => JSX.Element
|
||||
ComponentBetweenItems?: (props: unknown) => JSX.Element
|
||||
}
|
||||
|
||||
export const TableList = <T,>({
|
||||
initialItems,
|
||||
onItemsChange,
|
||||
addLabel = 'Add',
|
||||
debounceTimeout,
|
||||
Item,
|
||||
ComponentBetweenItems,
|
||||
}: Props<T>) => {
|
||||
const [items, setItems] = useState(initialItems)
|
||||
const [showDeleteIndex, setShowDeleteIndex] = useState<number | null>(null)
|
||||
|
||||
const createItem = () => {
|
||||
const id = cuid()
|
||||
const newItem = { id } as ItemWithId<T>
|
||||
setItems([...items, newItem])
|
||||
onItemsChange([...items, newItem])
|
||||
}
|
||||
|
||||
const updateItem = (itemIndex: number, updates: Partial<T>) => {
|
||||
const newItems = items.map((item, idx) =>
|
||||
idx === itemIndex ? { ...item, ...updates } : item
|
||||
)
|
||||
setItems(newItems)
|
||||
onItemsChange(newItems)
|
||||
}
|
||||
|
||||
const deleteItem = (itemIndex: number) => () => {
|
||||
const newItems = [...items]
|
||||
newItems.splice(itemIndex, 1)
|
||||
setItems([...newItems])
|
||||
onItemsChange([...newItems])
|
||||
}
|
||||
|
||||
const handleMouseEnter = (itemIndex: number) => () =>
|
||||
setShowDeleteIndex(itemIndex)
|
||||
|
||||
const handleCellChange = (itemIndex: number) => (item: T) =>
|
||||
updateItem(itemIndex, item)
|
||||
|
||||
const handleMouseLeave = () => setShowDeleteIndex(null)
|
||||
|
||||
return (
|
||||
<Stack spacing="4">
|
||||
{items.map((item, itemIndex) => (
|
||||
<Box key={item.id}>
|
||||
{itemIndex !== 0 && ComponentBetweenItems && (
|
||||
<ComponentBetweenItems />
|
||||
)}
|
||||
<Flex
|
||||
pos="relative"
|
||||
onMouseEnter={handleMouseEnter(itemIndex)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
mt={itemIndex !== 0 && ComponentBetweenItems ? 4 : 0}
|
||||
>
|
||||
<Item
|
||||
item={item}
|
||||
onItemChange={handleCellChange(itemIndex)}
|
||||
debounceTimeout={debounceTimeout}
|
||||
/>
|
||||
<Fade in={showDeleteIndex === itemIndex}>
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove cell"
|
||||
onClick={deleteItem(itemIndex)}
|
||||
pos="absolute"
|
||||
left="-15px"
|
||||
top="-15px"
|
||||
size="sm"
|
||||
shadow="md"
|
||||
/>
|
||||
</Fade>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
<Button
|
||||
leftIcon={<PlusIcon />}
|
||||
onClick={createItem}
|
||||
flexShrink={0}
|
||||
colorScheme="blue"
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
37
apps/builder/src/components/TextLink.tsx
Normal file
37
apps/builder/src/components/TextLink.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Link, { LinkProps } from 'next/link'
|
||||
import React from 'react'
|
||||
import { chakra, HStack, TextProps } from '@chakra-ui/react'
|
||||
import { ExternalLinkIcon } from '@/components/icons'
|
||||
|
||||
type TextLinkProps = LinkProps & TextProps & { isExternal?: boolean }
|
||||
|
||||
export const TextLink = ({
|
||||
children,
|
||||
href,
|
||||
shallow,
|
||||
replace,
|
||||
scroll,
|
||||
prefetch,
|
||||
isExternal,
|
||||
...textProps
|
||||
}: TextLinkProps) => (
|
||||
<Link
|
||||
href={href}
|
||||
shallow={shallow}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
prefetch={prefetch}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
>
|
||||
<chakra.span textDecor="underline" display="inline-block" {...textProps}>
|
||||
{isExternal ? (
|
||||
<HStack spacing={1}>
|
||||
<chakra.span>{children}</chakra.span>
|
||||
<ExternalLinkIcon />
|
||||
</HStack>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</chakra.span>
|
||||
</Link>
|
||||
)
|
||||
45
apps/builder/src/components/TypebotLogo.tsx
Normal file
45
apps/builder/src/components/TypebotLogo.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IconProps, Icon } from '@chakra-ui/react'
|
||||
|
||||
export const TypebotLogo = ({
|
||||
isDark,
|
||||
...props
|
||||
}: { isDark?: boolean } & IconProps) => (
|
||||
<Icon w="50px" h="50px" viewBox="0 0 800 800" {...props}>
|
||||
<rect
|
||||
width="800"
|
||||
height="800"
|
||||
rx="80"
|
||||
fill={isDark ? 'white' : '#0042DA'}
|
||||
/>
|
||||
<rect
|
||||
x="650"
|
||||
y="293"
|
||||
width="85.4704"
|
||||
height="384.617"
|
||||
rx="20"
|
||||
transform="rotate(90 650 293)"
|
||||
fill="#FF8E20"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M192.735 378.47C216.337 378.47 235.47 359.337 235.47 335.735C235.47 312.133 216.337 293 192.735 293C169.133 293 150 312.133 150 335.735C150 359.337 169.133 378.47 192.735 378.47Z"
|
||||
fill="#FF8E20"
|
||||
/>
|
||||
<rect
|
||||
x="150"
|
||||
y="506.677"
|
||||
width="85.4704"
|
||||
height="384.617"
|
||||
rx="20"
|
||||
transform="rotate(-90 150 506.677)"
|
||||
fill={isDark ? '#0042DA' : 'white'}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M607.265 421.206C583.663 421.206 564.53 440.34 564.53 463.942C564.53 487.544 583.663 506.677 607.265 506.677C630.867 506.677 650 487.544 650 463.942C650 440.34 630.867 421.206 607.265 421.206Z"
|
||||
fill={isDark ? '#0042DA' : 'white'}
|
||||
/>
|
||||
</Icon>
|
||||
)
|
||||
47
apps/builder/src/components/UnlockPlanAlertInfo.tsx
Normal file
47
apps/builder/src/components/UnlockPlanAlertInfo.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertProps,
|
||||
Button,
|
||||
HStack,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
import { ChangePlanModal, LimitReached } from '@/features/billing'
|
||||
|
||||
export const UnlockPlanAlertInfo = ({
|
||||
contentLabel,
|
||||
buttonLabel = 'More info',
|
||||
type,
|
||||
...props
|
||||
}: {
|
||||
contentLabel: React.ReactNode
|
||||
buttonLabel?: string
|
||||
type?: LimitReached
|
||||
} & AlertProps) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
return (
|
||||
<Alert
|
||||
status="info"
|
||||
rounded="md"
|
||||
justifyContent="space-between"
|
||||
flexShrink={0}
|
||||
{...props}
|
||||
>
|
||||
<HStack>
|
||||
<AlertIcon />
|
||||
<Text>{contentLabel}</Text>
|
||||
</HStack>
|
||||
<Button
|
||||
colorScheme={props.status === 'warning' ? 'orange' : 'blue'}
|
||||
onClick={onOpen}
|
||||
flexShrink={0}
|
||||
ml="2"
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
<ChangePlanModal isOpen={isOpen} onClose={onClose} type={type} />
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
264
apps/builder/src/components/VariableSearchInput.tsx
Normal file
264
apps/builder/src/components/VariableSearchInput.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
useDisclosure,
|
||||
useOutsideClick,
|
||||
Flex,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
Input,
|
||||
PopoverContent,
|
||||
Button,
|
||||
InputProps,
|
||||
IconButton,
|
||||
HStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { EditIcon, PlusIcon, TrashIcon } from '@/components/icons'
|
||||
import { useTypebot } from '@/features/editor'
|
||||
import cuid from 'cuid'
|
||||
import { Variable } from 'models'
|
||||
import React, { useState, useRef, ChangeEvent, useEffect } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { byId, env, isDefined, isNotDefined } from 'utils'
|
||||
|
||||
type Props = {
|
||||
initialVariableId?: string
|
||||
debounceTimeout?: number
|
||||
isDefaultOpen?: boolean
|
||||
onSelectVariable: (
|
||||
variable: Pick<Variable, 'id' | 'name'> | undefined
|
||||
) => void
|
||||
} & InputProps
|
||||
|
||||
export const VariableSearchInput = ({
|
||||
initialVariableId,
|
||||
onSelectVariable,
|
||||
isDefaultOpen,
|
||||
debounceTimeout = 1000,
|
||||
...inputProps
|
||||
}: Props) => {
|
||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||
const { typebot, createVariable, deleteVariable, updateVariable } =
|
||||
useTypebot()
|
||||
const variables = typebot?.variables ?? []
|
||||
const [inputValue, setInputValue] = useState(
|
||||
variables.find(byId(initialVariableId))?.name ?? ''
|
||||
)
|
||||
const debounced = useDebouncedCallback(
|
||||
(value) => {
|
||||
const variable = variables.find((v) => v.name === value)
|
||||
if (variable) onSelectVariable(variable)
|
||||
},
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
const [filteredItems, setFilteredItems] = useState<Variable[]>(
|
||||
variables ?? []
|
||||
)
|
||||
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
|
||||
number | undefined
|
||||
>()
|
||||
const dropdownRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
const createVariableItemRef = useRef<HTMLButtonElement | null>(null)
|
||||
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
useOutsideClick({
|
||||
ref: dropdownRef,
|
||||
handler: onClose,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefaultOpen) onOpen()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debounced.flush()
|
||||
},
|
||||
[debounced]
|
||||
)
|
||||
|
||||
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
debounced(e.target.value)
|
||||
onOpen()
|
||||
if (e.target.value === '') {
|
||||
onSelectVariable(undefined)
|
||||
setFilteredItems([...variables.slice(0, 50)])
|
||||
return
|
||||
}
|
||||
setFilteredItems([
|
||||
...variables
|
||||
.filter((item) =>
|
||||
item.name.toLowerCase().includes((e.target.value ?? '').toLowerCase())
|
||||
)
|
||||
.slice(0, 50),
|
||||
])
|
||||
}
|
||||
|
||||
const handleVariableNameClick = (variable: Variable) => () => {
|
||||
setInputValue(variable.name)
|
||||
onSelectVariable(variable)
|
||||
setKeyboardFocusIndex(undefined)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleCreateNewVariableClick = () => {
|
||||
if (!inputValue || inputValue === '') return
|
||||
const id = 'v' + cuid()
|
||||
onSelectVariable({ id, name: inputValue })
|
||||
createVariable({ id, name: inputValue })
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDeleteVariableClick =
|
||||
(variable: Variable) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
deleteVariable(variable.id)
|
||||
setFilteredItems(filteredItems.filter((item) => item.id !== variable.id))
|
||||
if (variable.name === inputValue) {
|
||||
setInputValue('')
|
||||
debounced('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameVariableClick =
|
||||
(variable: Variable) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const name = prompt('Rename variable', variable.name)
|
||||
if (!name) return
|
||||
updateVariable(variable.id, { name })
|
||||
setFilteredItems(
|
||||
filteredItems.map((item) =>
|
||||
item.id === variable.id ? { ...item, name } : item
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const isCreateVariableButtonDisplayed =
|
||||
(inputValue?.length ?? 0) > 0 &&
|
||||
isNotDefined(variables.find((v) => v.name === inputValue))
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && isDefined(keyboardFocusIndex)) {
|
||||
if (keyboardFocusIndex === 0 && isCreateVariableButtonDisplayed)
|
||||
handleCreateNewVariableClick()
|
||||
else handleVariableNameClick(filteredItems[keyboardFocusIndex])()
|
||||
return setKeyboardFocusIndex(undefined)
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (keyboardFocusIndex === undefined) return setKeyboardFocusIndex(0)
|
||||
if (keyboardFocusIndex >= filteredItems.length) return
|
||||
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (keyboardFocusIndex === undefined) return
|
||||
if (keyboardFocusIndex <= 0) return setKeyboardFocusIndex(undefined)
|
||||
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
return setKeyboardFocusIndex(keyboardFocusIndex - 1)
|
||||
}
|
||||
return setKeyboardFocusIndex(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex ref={dropdownRef} w="full">
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
initialFocusRef={inputRef}
|
||||
matchWidth
|
||||
isLazy
|
||||
offset={[0, 2]}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Input
|
||||
data-testid="variables-input"
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onClick={onOpen}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder={inputProps.placeholder ?? 'Select a variable'}
|
||||
{...inputProps}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
maxH="35vh"
|
||||
overflowY="scroll"
|
||||
role="menu"
|
||||
w="inherit"
|
||||
shadow="lg"
|
||||
>
|
||||
{isCreateVariableButtonDisplayed && (
|
||||
<Button
|
||||
ref={createVariableItemRef}
|
||||
role="menuitem"
|
||||
minH="40px"
|
||||
onClick={handleCreateNewVariableClick}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
leftIcon={<PlusIcon />}
|
||||
bgColor={keyboardFocusIndex === 0 ? 'gray.200' : 'transparent'}
|
||||
>
|
||||
Create "{inputValue}"
|
||||
</Button>
|
||||
)}
|
||||
{filteredItems.length > 0 && (
|
||||
<>
|
||||
{filteredItems.map((item, idx) => {
|
||||
const indexInList = isCreateVariableButtonDisplayed
|
||||
? idx + 1
|
||||
: idx
|
||||
return (
|
||||
<Button
|
||||
ref={(el) => (itemsRef.current[idx] = el)}
|
||||
role="menuitem"
|
||||
minH="40px"
|
||||
key={idx}
|
||||
onClick={handleVariableNameClick(item)}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
variant="ghost"
|
||||
justifyContent="space-between"
|
||||
bgColor={
|
||||
keyboardFocusIndex === indexInList
|
||||
? 'gray.200'
|
||||
: 'transparent'
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
aria-label="Rename variable"
|
||||
size="xs"
|
||||
onClick={handleRenameVariableClick(item)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove variable"
|
||||
size="xs"
|
||||
onClick={handleDeleteVariableClick(item)}
|
||||
/>
|
||||
</HStack>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
498
apps/builder/src/components/icons.tsx
Normal file
498
apps/builder/src/components/icons.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import { IconProps, Icon } from '@chakra-ui/react'
|
||||
|
||||
const featherIconsBaseProps: IconProps = {
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: '2px',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
}
|
||||
|
||||
// 99% of these icons are from Feather icons (https://feathericons.com/)
|
||||
|
||||
export const SettingsIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const LogOutIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ChevronLeftIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const PlusIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const FolderIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const MoreVerticalIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const GlobeIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ToolIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const FolderPlusIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
<line x1="12" y1="11" x2="12" y2="17"></line>
|
||||
<line x1="9" y1="14" x2="15" y2="14"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const TextIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="4 7 4 4 20 4 20 7"></polyline>
|
||||
<line x1="9" y1="20" x2="15" y2="20"></line>
|
||||
<line x1="12" y1="4" x2="12" y2="20"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ImageIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CalendarIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const FlagIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path>
|
||||
<line x1="4" y1="22" x2="4" y2="15"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const BoldIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
||||
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ItalicIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="19" y1="4" x2="10" y2="4"></line>
|
||||
<line x1="14" y1="20" x2="5" y2="20"></line>
|
||||
<line x1="15" y1="4" x2="9" y2="20"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UnderlineIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"></path>
|
||||
<line x1="4" y1="21" x2="20" y2="21"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const LinkIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const SaveIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||
<polyline points="7 3 7 8 15 8"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CheckIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ChatIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const TrashIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const LayoutIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="9" y1="21" x2="9" y2="9"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CodeIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="16 18 22 12 16 6"></polyline>
|
||||
<polyline points="8 6 2 12 8 18"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const PencilIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M12 19l7-7 3 3-7 7-3-3z"></path>
|
||||
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path>
|
||||
<path d="M2 2l7.586 7.586"></path>
|
||||
<circle cx="11" cy="11" r="2"></circle>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const EditIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UploadIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="16 16 12 12 8 16"></polyline>
|
||||
<line x1="12" y1="12" x2="12" y2="21"></line>
|
||||
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path>
|
||||
<polyline points="16 16 12 12 8 16"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const DownloadIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const NumberIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="4" y1="9" x2="20" y2="9"></line>
|
||||
<line x1="4" y1="15" x2="20" y2="15"></line>
|
||||
<line x1="10" y1="3" x2="8" y2="21"></line>
|
||||
<line x1="16" y1="3" x2="14" y2="21"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const EmailIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const PhoneIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CheckSquareIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="9 11 12 14 22 4"></polyline>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const FilterIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UserIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ExpandIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<polyline points="9 21 3 21 3 15"></polyline>
|
||||
<line x1="21" y1="3" x2="14" y2="10"></line>
|
||||
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ExternalLinkIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const FilmIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
|
||||
<line x1="7" y1="2" x2="7" y2="22"></line>
|
||||
<line x1="17" y1="2" x2="17" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="2" y1="7" x2="7" y2="7"></line>
|
||||
<line x1="2" y1="17" x2="7" y2="17"></line>
|
||||
<line x1="17" y1="17" x2="22" y2="17"></line>
|
||||
<line x1="17" y1="7" x2="22" y2="7"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const WebhookIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const GripIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="9" r="1"></circle>
|
||||
<circle cx="19" cy="9" r="1"></circle>
|
||||
<circle cx="5" cy="9" r="1"></circle>
|
||||
<circle cx="12" cy="15" r="1"></circle>
|
||||
<circle cx="19" cy="15" r="1"></circle>
|
||||
<circle cx="5" cy="15" r="1"></circle>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const LockedIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UnlockedIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UndoIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M3 7v6h6"></path>
|
||||
<path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const RedoIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M21 7v6h-6"></path>
|
||||
<path d="M3 17a9 9 0 019-9 9 9 0 016 2.3l3 2.7"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const FileIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const EyeIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const SendEmailIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const GithubIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 512 512" {...props}>
|
||||
<title>{'Logo Github'}</title>
|
||||
<path d="M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UsersIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const AlignLeftTextIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="17" y1="10" x2="3" y2="10"></line>
|
||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||
<line x1="17" y1="18" x2="3" y2="18"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const BoxIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const HelpCircleIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CopyIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const TemplateIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const MinusIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const LaptopIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path
|
||||
d="M3.2 14.2222V4C3.2 2.89543 4.09543 2 5.2 2H18.8C19.9046 2 20.8 2.89543 20.8 4V14.2222M3.2 14.2222H20.8M3.2 14.2222L1.71969 19.4556C1.35863 20.7321 2.31762 22 3.64418 22H20.3558C21.6824 22 22.6414 20.7321 22.2803 19.4556L20.8 14.2222"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M11 19L13 19"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const MouseIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path
|
||||
d="M12 2V2C16.4183 2 20 5.58172 20 10V14C20 18.4183 16.4183 22 12 22V22C7.58172 22 4 18.4183 4 14V10C4 5.58172 7.58172 2 12 2V2ZM12 2V9"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const HardDriveIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<line x1="22" y1="12" x2="2" y2="12"></line>
|
||||
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
|
||||
<line x1="6" y1="16" x2="6.01" y2="16"></line>
|
||||
<line x1="10" y1="16" x2="10.01" y2="16"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CreditCardIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="1" y1="10" x2="23" y2="10"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const PlayIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const StarIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</Icon>
|
||||
)
|
||||
export const BuoyIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<line x1="4.93" y1="4.93" x2="9.17" y2="9.17"></line>
|
||||
<line x1="14.83" y1="14.83" x2="19.07" y2="19.07"></line>
|
||||
<line x1="14.83" y1="9.17" x2="19.07" y2="4.93"></line>
|
||||
<line x1="14.83" y1="9.17" x2="18.36" y2="5.64"></line>
|
||||
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const EyeOffIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const AlertIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CloudOffIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M22.61 16.95A5 5 0 0 0 18 10h-1.26a8 8 0 0 0-7.05-6M5 5a8 8 0 0 0 4 15h9a5 5 0 0 0 1.7-.3"></path>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
</Icon>
|
||||
)
|
||||
7
apps/builder/src/components/inputs/Input.tsx
Normal file
7
apps/builder/src/components/inputs/Input.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Input as ChakraInput } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
import { TextBox, TextBoxProps } from './TextBox'
|
||||
|
||||
export const Input = (props: Omit<TextBoxProps, 'TextBox'>) => (
|
||||
<TextBox TextBox={ChakraInput} {...props} />
|
||||
)
|
||||
55
apps/builder/src/components/inputs/SmartNumberInput.tsx
Normal file
55
apps/builder/src/components/inputs/SmartNumberInput.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
NumberInputProps,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
} from '@chakra-ui/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { env } from 'utils'
|
||||
|
||||
export const SmartNumberInput = ({
|
||||
value,
|
||||
onValueChange,
|
||||
debounceTimeout = 1000,
|
||||
...props
|
||||
}: {
|
||||
value?: number
|
||||
debounceTimeout?: number
|
||||
onValueChange: (value?: number) => void
|
||||
} & NumberInputProps) => {
|
||||
const [currentValue, setCurrentValue] = useState(value?.toString() ?? '')
|
||||
const debounced = useDebouncedCallback(
|
||||
onValueChange,
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debounced.flush()
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
setCurrentValue(value)
|
||||
if (value.endsWith('.') || value.endsWith(',')) return
|
||||
if (value === '') return debounced(undefined)
|
||||
const newValue = parseFloat(value)
|
||||
if (isNaN(newValue)) return
|
||||
debounced(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberInput onChange={handleValueChange} value={currentValue} {...props}>
|
||||
<NumberInputField placeholder={props.placeholder} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
)
|
||||
}
|
||||
134
apps/builder/src/components/inputs/TextBox.tsx
Normal file
134
apps/builder/src/components/inputs/TextBox.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
ComponentWithAs,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
InputProps,
|
||||
TextareaProps,
|
||||
} from '@chakra-ui/react'
|
||||
import { Variable } from 'models'
|
||||
import React, { ChangeEvent, useEffect, useRef, useState } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { env } from 'utils'
|
||||
import { VariablesButton } from '../../features/variables/components/VariablesButton'
|
||||
import { MoreInfoTooltip } from '../MoreInfoTooltip'
|
||||
|
||||
export type TextBoxProps = {
|
||||
onChange: (value: string) => void
|
||||
TextBox:
|
||||
| ComponentWithAs<'textarea', TextareaProps>
|
||||
| ComponentWithAs<'input', InputProps>
|
||||
withVariableButton?: boolean
|
||||
debounceTimeout?: number
|
||||
label?: string
|
||||
moreInfoTooltip?: string
|
||||
} & Omit<InputProps & TextareaProps, 'onChange'>
|
||||
|
||||
export const TextBox = ({
|
||||
onChange,
|
||||
TextBox,
|
||||
withVariableButton = true,
|
||||
debounceTimeout = 1000,
|
||||
label,
|
||||
moreInfoTooltip,
|
||||
...props
|
||||
}: TextBoxProps) => {
|
||||
const textBoxRef = useRef<(HTMLInputElement & HTMLTextAreaElement) | null>(
|
||||
null
|
||||
)
|
||||
const [carretPosition, setCarretPosition] = useState<number>(0)
|
||||
const [value, setValue] = useState(props.defaultValue ?? '')
|
||||
const [isTouched, setIsTouched] = useState(false)
|
||||
const debounced = useDebouncedCallback(
|
||||
(value) => {
|
||||
onChange(value)
|
||||
},
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.defaultValue !== value && value === '' && !isTouched)
|
||||
setValue(props.defaultValue ?? '')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.defaultValue])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debounced.flush()
|
||||
},
|
||||
[debounced]
|
||||
)
|
||||
|
||||
const handleChange = (
|
||||
e: ChangeEvent<HTMLInputElement & HTMLTextAreaElement>
|
||||
) => {
|
||||
setIsTouched(true)
|
||||
setValue(e.target.value)
|
||||
debounced(e.target.value)
|
||||
}
|
||||
|
||||
const handleVariableSelected = (variable?: Variable) => {
|
||||
if (!textBoxRef.current || !variable) return
|
||||
setIsTouched(true)
|
||||
const cursorPosition = carretPosition
|
||||
const textBeforeCursorPosition = textBoxRef.current.value.substring(
|
||||
0,
|
||||
cursorPosition
|
||||
)
|
||||
const textAfterCursorPosition = textBoxRef.current.value.substring(
|
||||
cursorPosition,
|
||||
textBoxRef.current.value.length
|
||||
)
|
||||
const newValue =
|
||||
textBeforeCursorPosition +
|
||||
`{{${variable.name}}}` +
|
||||
textAfterCursorPosition
|
||||
setValue(newValue)
|
||||
debounced(newValue)
|
||||
textBoxRef.current.focus()
|
||||
setTimeout(() => {
|
||||
if (!textBoxRef.current) return
|
||||
textBoxRef.current.selectionStart = textBoxRef.current.selectionEnd =
|
||||
carretPosition + `{{${variable.name}}}`.length
|
||||
setCarretPosition(textBoxRef.current.selectionStart)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
if (!textBoxRef.current?.selectionStart) return
|
||||
setCarretPosition(textBoxRef.current.selectionStart)
|
||||
}
|
||||
|
||||
const Input = (
|
||||
<TextBox
|
||||
ref={textBoxRef}
|
||||
value={value}
|
||||
onKeyUp={handleKeyUp}
|
||||
onClick={handleKeyUp}
|
||||
onChange={handleChange}
|
||||
bgColor={'white'}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<FormControl isRequired={props.isRequired}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}{' '}
|
||||
{moreInfoTooltip && (
|
||||
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
|
||||
)}
|
||||
</FormLabel>
|
||||
)}
|
||||
{withVariableButton ? (
|
||||
<HStack spacing={0} align={'flex-end'}>
|
||||
{Input}
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} />
|
||||
</HStack>
|
||||
) : (
|
||||
Input
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
7
apps/builder/src/components/inputs/Textarea.tsx
Normal file
7
apps/builder/src/components/inputs/Textarea.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Textarea as ChakraTextarea } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
import { TextBox, TextBoxProps } from './TextBox'
|
||||
|
||||
export const Textarea = (props: Omit<TextBoxProps, 'TextBox'>) => (
|
||||
<TextBox TextBox={ChakraTextarea} {...props} />
|
||||
)
|
||||
3
apps/builder/src/components/inputs/index.tsx
Normal file
3
apps/builder/src/components/inputs/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Input } from './Input'
|
||||
export { Textarea } from './Textarea'
|
||||
export { SmartNumberInput } from './SmartNumberInput'
|
||||
Reference in New Issue
Block a user