🚸 Add a better select input

Also improves other inputs behavior
This commit is contained in:
Baptiste Arnaud
2023-03-03 09:01:11 +01:00
parent a66bfca1ec
commit cc7d7285e5
94 changed files with 1251 additions and 1109 deletions

View File

@@ -0,0 +1,230 @@
import {
useDisclosure,
Popover,
PopoverContent,
Button,
useColorModeValue,
PopoverAnchor,
Portal,
Input,
HStack,
FormControl,
FormLabel,
} from '@chakra-ui/react'
import { useState, useRef, useEffect, ReactNode } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env, isDefined } from 'utils'
import { useOutsideClick } from '@/hooks/useOutsideClick'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
import { VariablesButton } from '@/features/variables'
import { Variable } from 'models'
import { injectVariableInText } from '@/features/variables/utils/injectVariableInTextInput'
import { focusInput } from '@/utils/focusInput'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
type Props = {
items: string[]
defaultValue?: string
debounceTimeout?: number
placeholder?: string
withVariableButton?: boolean
label?: ReactNode
moreInfoTooltip?: string
isRequired?: boolean
onChange: (value: string) => void
}
export const AutocompleteInput = ({
items,
onChange: _onChange,
debounceTimeout,
placeholder,
withVariableButton = true,
defaultValue,
label,
moreInfoTooltip,
isRequired,
}: Props) => {
const bg = useColorModeValue('gray.200', 'gray.700')
const { onOpen, onClose, isOpen } = useDisclosure()
const [isTouched, setIsTouched] = useState(false)
const [inputValue, setInputValue] = useState(defaultValue ?? '')
const [carretPosition, setCarretPosition] = useState<number>(
inputValue.length ?? 0
)
const onChange = useDebouncedCallback(
_onChange,
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
useEffect(() => {
if (isTouched || inputValue !== '' || !defaultValue || defaultValue === '')
return
setInputValue(defaultValue ?? '')
}, [defaultValue, inputValue, isTouched])
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
number | undefined
>()
const dropdownRef = useRef(null)
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
const inputRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null)
const { ref: parentModalRef } = useParentModal()
const filteredItems = (
inputValue === ''
? items
: [
...items.filter(
(item) =>
item.toLowerCase().startsWith((inputValue ?? '').toLowerCase()) &&
item.toLowerCase() !== inputValue.toLowerCase()
),
]
).slice(0, 50)
useOutsideClick({
ref: dropdownRef,
handler: onClose,
})
useEffect(
() => () => {
onChange.flush()
},
[onChange]
)
const changeValue = (value: string) => {
if (!isTouched) setIsTouched(true)
if (!isOpen) onOpen()
setInputValue(value)
onChange(value)
}
const handleItemClick = (value: string) => () => {
setInputValue(value)
onChange(value)
setKeyboardFocusIndex(undefined)
inputRef.current?.focus()
}
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 setKeyboardFocusIndex(0)
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
}
if (e.key === 'ArrowUp') {
if (keyboardFocusIndex === 0 || keyboardFocusIndex === undefined)
return setKeyboardFocusIndex(filteredItems.length - 1)
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
setKeyboardFocusIndex(keyboardFocusIndex - 1)
}
}
const handleVariableSelected = (variable?: Variable) => {
if (!variable) return
const { text, carretPosition: newCarretPosition } = injectVariableInText({
variable,
text: inputValue,
at: carretPosition,
})
changeValue(text)
focusInput({ at: newCarretPosition, input: inputRef.current })
}
const updateCarretPosition = (e: React.FocusEvent<HTMLInputElement>) => {
const carretPosition = e.target.selectionStart
if (!carretPosition) return
setCarretPosition(carretPosition)
}
return (
<FormControl isRequired={isRequired}>
{label && (
<FormLabel>
{label}{' '}
{moreInfoTooltip && (
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
)}
</FormLabel>
)}
<HStack ref={dropdownRef} spacing={0} w="full">
<Popover
isOpen={isOpen}
initialFocusRef={inputRef}
matchWidth
offset={[0, 1]}
isLazy
>
<PopoverAnchor>
<Input
autoComplete="off"
ref={inputRef}
value={inputValue}
onChange={(e) => changeValue(e.target.value)}
onFocus={onOpen}
onBlur={updateCarretPosition}
onKeyDown={handleKeyUp}
placeholder={placeholder}
/>
</PopoverAnchor>
{filteredItems.length > 0 && (
<Portal containerRef={parentModalRef}>
<PopoverContent
maxH="35vh"
overflowY="scroll"
role="menu"
w="inherit"
shadow="lg"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<>
{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"
bg={keyboardFocusIndex === idx ? bg : 'transparent'}
justifyContent="flex-start"
transition="none"
>
{item}
</Button>
)
})}
</>
</PopoverContent>
</Portal>
)}
</Popover>
{withVariableButton && (
<VariablesButton onSelectVariable={handleVariableSelected} />
)}
</HStack>
</FormControl>
)
}

View File

@@ -0,0 +1,142 @@
import {
BoxProps,
Fade,
HStack,
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react'
import { useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { VariablesButton } from '@/features/variables'
import { Variable } from 'models'
import { env } from 'utils'
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'
import { tokyoNight } from '@uiw/codemirror-theme-tokyo-night'
import { githubLight } from '@uiw/codemirror-theme-github'
import { LanguageName, loadLanguage } from '@uiw/codemirror-extensions-langs'
import { isDefined } from '@udecode/plate-common'
import { CopyButton } from '../CopyButton'
type Props = {
value?: string
defaultValue?: string
lang: LanguageName
isReadOnly?: boolean
debounceTimeout?: number
withVariableButton?: boolean
height?: string
onChange?: (value: string) => void
}
export const CodeEditor = ({
defaultValue,
lang,
onChange,
height = '250px',
withVariableButton = true,
isReadOnly = false,
debounceTimeout = 1000,
...props
}: Props & Omit<BoxProps, 'onChange'>) => {
const theme = useColorModeValue(githubLight, tokyoNight)
const codeEditor = useRef<ReactCodeMirrorRef | null>(null)
const [carretPosition, setCarretPosition] = useState<number>(0)
const isVariableButtonDisplayed = withVariableButton && !isReadOnly
const [value, _setValue] = useState(defaultValue ?? '')
const { onOpen, onClose, isOpen } = useDisclosure()
const setValue = useDebouncedCallback(
(value) => {
_setValue(value)
onChange && onChange(value)
},
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
const handleVariableSelected = (variable?: Pick<Variable, 'id' | 'name'>) => {
codeEditor.current?.view?.focus()
const insert = `{{${variable?.name}}}`
codeEditor.current?.view?.dispatch({
changes: {
from: carretPosition,
insert,
},
selection: { anchor: carretPosition + insert.length },
})
}
const handleChange = (newValue: string) => {
if (isDefined(props.value)) return
setValue(newValue)
setCarretPosition(codeEditor.current?.state?.selection.main.head ?? 0)
}
useEffect(
() => () => {
setValue.flush()
},
[setValue]
)
return (
<HStack
align="flex-end"
spacing={0}
borderWidth={'1px'}
rounded="md"
bg={useColorModeValue('white', '#1A1B26')}
width="full"
h="full"
pos="relative"
onMouseEnter={onOpen}
onMouseLeave={onClose}
sx={{
'& .cm-editor': {
maxH: '70vh',
outline: '0px solid transparent !important',
rounded: 'md',
},
'& .cm-scroller': {
rounded: 'md',
overflow: 'auto',
},
'& .cm-gutter,.cm-content': {
minH: isReadOnly ? '0' : height,
},
'& .ͼ1 .cm-scroller': {
fontSize: '14px',
fontFamily:
'JetBrainsMono, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',
},
}}
>
<CodeMirror
data-testid="code-editor"
ref={codeEditor}
value={props.value ?? value}
onChange={handleChange}
theme={theme}
extensions={[loadLanguage(lang)].filter(isDefined)}
editable={!isReadOnly}
style={{
width: isVariableButtonDisplayed ? 'calc(100% - 32px)' : '100%',
}}
spellCheck={false}
/>
{isVariableButtonDisplayed && (
<VariablesButton onSelectVariable={handleVariableSelected} size="sm" />
)}
{isReadOnly && (
<Fade in={isOpen}>
<CopyButton
textToCopy={props.value ?? value}
pos="absolute"
right={0.5}
top={0.5}
size="xs"
colorScheme="blue"
/>
</Fade>
)}
</HStack>
)
}

View File

@@ -1,7 +0,0 @@
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} />
)

View File

@@ -1,7 +1,7 @@
import { VariablesButton } from '@/features/variables'
import {
NumberInputProps,
NumberInput,
NumberInput as ChakraNumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
@@ -30,7 +30,7 @@ type Props<HasVariable extends boolean> = {
onValueChange: (value?: Value<HasVariable>) => void
} & Omit<NumberInputProps, 'defaultValue' | 'value' | 'onChange' | 'isRequired'>
export const SmartNumberInput = <HasVariable extends boolean>({
export const NumberInput = <HasVariable extends boolean>({
defaultValue,
onValueChange,
withVariableButton,
@@ -79,13 +79,13 @@ export const SmartNumberInput = <HasVariable extends boolean>({
}
const Input = (
<NumberInput onChange={handleValueChange} value={value} {...props}>
<ChakraNumberInput onChange={handleValueChange} value={value} {...props}>
<NumberInputField placeholder={props.placeholder} />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</ChakraNumberInput>
)
return (

View File

@@ -0,0 +1,227 @@
import {
useDisclosure,
Flex,
Popover,
Input,
PopoverContent,
Button,
useColorModeValue,
PopoverAnchor,
Portal,
InputGroup,
InputRightElement,
Text,
Box,
} from '@chakra-ui/react'
import { useState, useRef, ChangeEvent } from 'react'
import { isDefined } from 'utils'
import { useOutsideClick } from '@/hooks/useOutsideClick'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
import { ChevronDownIcon } from '../icons'
const dropdownCloseAnimationDuration = 200
type Item = string | { icon?: JSX.Element; label: string; value: string }
type Props = {
selectedItem?: string
items: Item[]
placeholder?: string
debounceTimeout?: number
onSelect?: (value: string) => void
}
export const Select = ({
selectedItem,
placeholder,
items,
onSelect,
}: Props) => {
const focusedItemBgColor = useColorModeValue('gray.200', 'gray.700')
const selectedItemBgColor = useColorModeValue('blue.50', 'blue.400')
const [isTouched, setIsTouched] = useState(false)
const { onOpen, onClose, isOpen } = useDisclosure()
const [inputValue, setInputValue] = useState(
getItemLabel(
items.find((item) =>
typeof item === 'string'
? selectedItem === item
: selectedItem === item.value
)
)
)
const closeDropwdown = () => {
onClose()
}
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
number | undefined
>()
const dropdownRef = useRef(null)
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const { ref: parentModalRef } = useParentModal()
const filteredItems = (
isTouched
? [
...items.filter((item) =>
getItemLabel(item)
.toLowerCase()
.includes((inputValue ?? '').toLowerCase())
),
]
: items
).slice(0, 50)
useOutsideClick({
ref: dropdownRef,
handler: closeDropwdown,
})
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!isOpen) onOpen()
if (!isTouched) setIsTouched(true)
setInputValue(e.target.value)
}
const handleItemClick = (item: Item) => () => {
if (!isTouched) setIsTouched(true)
setInputValue(getItemLabel(item))
onSelect?.(getItemValue(item))
setKeyboardFocusIndex(undefined)
closeDropwdown()
}
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 setKeyboardFocusIndex(0)
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
}
if (e.key === 'ArrowUp') {
if (keyboardFocusIndex === 0 || keyboardFocusIndex === undefined)
return setKeyboardFocusIndex(filteredItems.length - 1)
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
setKeyboardFocusIndex(keyboardFocusIndex - 1)
}
}
const resetIsTouched = () => {
setTimeout(() => {
setIsTouched(false)
}, dropdownCloseAnimationDuration)
}
return (
<Flex ref={dropdownRef} w="full">
<Popover
isOpen={isOpen}
initialFocusRef={inputRef}
matchWidth
offset={[0, 1]}
isLazy
>
<PopoverAnchor>
<InputGroup>
<Box pos="absolute" py={2} pl={4} pr={6}>
{!isTouched && (
<Text noOfLines={1} data-testid="selected-item-label">
{inputValue}
</Text>
)}
</Box>
<Input
type="text"
autoComplete="off"
ref={inputRef}
className="select-input"
value={isTouched ? inputValue : ''}
placeholder={
!isTouched && inputValue !== '' ? undefined : placeholder
}
onBlur={resetIsTouched}
onChange={handleInputChange}
onFocus={onOpen}
onKeyDown={handleKeyUp}
/>
<InputRightElement pointerEvents="none" cursor="pointer">
<ChevronDownIcon />
</InputRightElement>
</InputGroup>
</PopoverAnchor>
<Portal containerRef={parentModalRef}>
<PopoverContent
maxH="35vh"
overflowY="scroll"
role="menu"
w="inherit"
shadow="lg"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{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"
bg={
keyboardFocusIndex === idx
? focusedItemBgColor
: selectedItem === getItemValue(item)
? selectedItemBgColor
: 'transparent'
}
justifyContent="flex-start"
transition="none"
leftIcon={
typeof item === 'object' ? item.icon : undefined
}
>
{getItemLabel(item)}
</Button>
)
})}
</>
)}
</PopoverContent>
</Portal>
</Popover>
</Flex>
)
}
const getItemLabel = (item?: Item) => {
if (!item) return ''
if (typeof item === 'object') return item.label
return item
}
const getItemValue = (item: Item) => {
if (typeof item === 'object') return item.value
return item
}

View 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 && (
<>
&nbsp;<MoreInfoTooltip>{moreInfoContent}</MoreInfoTooltip>
</>
)}
</FormLabel>
<Switch isChecked={isChecked} onChange={handleChange} {...switchProps} />
</FormControl>
)
}

View File

@@ -1,134 +0,0 @@
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'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
export type TextBoxProps = {
defaultValue?: string
onChange: (value: string) => void
TextBox:
| ComponentWithAs<'textarea', TextareaProps>
| ComponentWithAs<'input', InputProps>
withVariableButton?: boolean
debounceTimeout?: number
label?: string
moreInfoTooltip?: string
} & Omit<InputProps & TextareaProps, 'onChange' | 'defaultValue'>
export const TextBox = ({
onChange,
TextBox,
withVariableButton = true,
debounceTimeout = 1000,
label,
moreInfoTooltip,
defaultValue,
isRequired,
...props
}: TextBoxProps) => {
const textBoxRef = useRef<(HTMLInputElement & HTMLTextAreaElement) | null>(
null
)
const [value, setValue] = useState<string>(defaultValue ?? '')
const [carretPosition, setCarretPosition] = useState<number>(
defaultValue?.length ?? 0
)
const [isTouched, setIsTouched] = useState(false)
const debounced = useDebouncedCallback(
(value) => {
onChange(value)
},
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
useEffect(() => {
if (isTouched || defaultValue === value) return
setValue(defaultValue ?? '')
}, [defaultValue, isTouched, value])
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 (!variable) return
setIsTouched(true)
const textBeforeCursorPosition = value.substring(0, carretPosition)
const textAfterCursorPosition = value.substring(
carretPosition,
value.length
)
const newValue =
textBeforeCursorPosition +
`{{${variable.name}}}` +
textAfterCursorPosition
setValue(newValue)
debounced(newValue)
const newCarretPosition = carretPosition + `{{${variable.name}}}`.length
setCarretPosition(newCarretPosition)
textBoxRef.current?.focus()
setTimeout(() => {
if (!textBoxRef.current) return
textBoxRef.current.selectionStart = textBoxRef.current.selectionEnd =
newCarretPosition
}, 100)
}
const updateCarretPosition = () => {
if (textBoxRef.current?.selectionStart === undefined) return
setCarretPosition(textBoxRef.current.selectionStart)
}
const Input = (
<TextBox
ref={textBoxRef}
value={value}
onKeyUp={updateCarretPosition}
onClick={updateCarretPosition}
onChange={handleChange}
{...props}
/>
)
return (
<FormControl isRequired={isRequired}>
{label && (
<FormLabel>
{label}{' '}
{moreInfoTooltip && (
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
)}
</FormLabel>
)}
{withVariableButton ? (
<HStack spacing={0} align={'flex-end'}>
{Input}
<VariablesButton onSelectVariable={handleVariableSelected} />
</HStack>
) : (
Input
)}
</FormControl>
)
}

View File

@@ -0,0 +1,131 @@
import { VariablesButton } from '@/features/variables'
import { injectVariableInText } from '@/features/variables/utils/injectVariableInTextInput'
import { focusInput } from '@/utils/focusInput'
import {
FormControl,
FormLabel,
HStack,
Input as ChakraInput,
InputProps,
} from '@chakra-ui/react'
import { Variable } from 'models'
import React, { ReactNode, useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env } from 'utils'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
export type TextInputProps = {
defaultValue?: string
onChange: (value: string) => void
debounceTimeout?: number
label?: ReactNode
moreInfoTooltip?: string
withVariableButton?: boolean
isRequired?: boolean
placeholder?: string
isDisabled?: boolean
} & Pick<
InputProps,
'autoComplete' | 'onFocus' | 'onKeyUp' | 'type' | 'autoFocus'
>
export const TextInput = ({
type,
defaultValue,
debounceTimeout = 1000,
label,
moreInfoTooltip,
withVariableButton = true,
isRequired,
placeholder,
autoComplete,
isDisabled,
autoFocus,
onChange: _onChange,
onFocus,
onKeyUp,
}: TextInputProps) => {
const inputRef = useRef<HTMLInputElement | null>(null)
const [isTouched, setIsTouched] = useState(false)
const [localValue, setLocalValue] = useState<string>(defaultValue ?? '')
const [carretPosition, setCarretPosition] = useState<number>(
localValue.length ?? 0
)
const onChange = useDebouncedCallback(
_onChange,
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
useEffect(() => {
if (isTouched || localValue !== '' || !defaultValue || defaultValue === '')
return
setLocalValue(defaultValue ?? '')
}, [defaultValue, isTouched, localValue])
useEffect(
() => () => {
onChange.flush()
},
[onChange]
)
const changeValue = (value: string) => {
if (!isTouched) setIsTouched(true)
setLocalValue(value)
onChange(value)
}
const handleVariableSelected = (variable?: Variable) => {
if (!variable) return
const { text, carretPosition: newCarretPosition } = injectVariableInText({
variable,
text: localValue,
at: carretPosition,
})
changeValue(text)
focusInput({ at: newCarretPosition, input: inputRef.current })
}
const updateCarretPosition = (e: React.FocusEvent<HTMLInputElement>) => {
const carretPosition = e.target.selectionStart
if (!carretPosition) return
setCarretPosition(carretPosition)
}
const Input = (
<ChakraInput
type={type}
ref={inputRef}
value={localValue}
autoComplete={autoComplete}
placeholder={placeholder}
isDisabled={isDisabled}
autoFocus={autoFocus}
onFocus={onFocus}
onKeyUp={onKeyUp}
onBlur={updateCarretPosition}
onChange={(e) => changeValue(e.target.value)}
/>
)
return (
<FormControl isRequired={isRequired}>
{label && (
<FormLabel>
{label}{' '}
{moreInfoTooltip && (
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
)}
</FormLabel>
)}
{withVariableButton ? (
<HStack spacing={0} align={'flex-end'}>
{Input}
<VariablesButton onSelectVariable={handleVariableSelected} />
</HStack>
) : (
Input
)}
</FormControl>
)
}

View File

@@ -1,7 +1,115 @@
import { Textarea as ChakraTextarea } from '@chakra-ui/react'
import React from 'react'
import { TextBox, TextBoxProps } from './TextBox'
import { VariablesButton } from '@/features/variables'
import { injectVariableInText } from '@/features/variables/utils/injectVariableInTextInput'
import { focusInput } from '@/utils/focusInput'
import {
FormControl,
FormLabel,
HStack,
Textarea as ChakraTextarea,
TextareaProps,
} from '@chakra-ui/react'
import { Variable } from 'models'
import React, { useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env } from 'utils'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
export const Textarea = (props: Omit<TextBoxProps, 'TextBox'>) => (
<TextBox TextBox={ChakraTextarea} {...props} />
)
type Props = {
id?: string
defaultValue?: string
debounceTimeout?: number
label?: string
moreInfoTooltip?: string
withVariableButton?: boolean
isRequired?: boolean
onChange: (value: string) => void
} & Pick<TextareaProps, 'minH'>
export const Textarea = ({
id,
defaultValue,
onChange: _onChange,
debounceTimeout = 1000,
label,
moreInfoTooltip,
withVariableButton = true,
isRequired,
}: Props) => {
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const [isTouched, setIsTouched] = useState(false)
const [localValue, setLocalValue] = useState<string>(defaultValue ?? '')
const [carretPosition, setCarretPosition] = useState<number>(
localValue.length ?? 0
)
const onChange = useDebouncedCallback(
_onChange,
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
useEffect(() => {
if (isTouched || localValue !== '' || !defaultValue || defaultValue === '')
return
setLocalValue(defaultValue ?? '')
}, [defaultValue, isTouched, localValue])
useEffect(
() => () => {
onChange.flush()
},
[onChange]
)
const changeValue = (value: string) => {
if (!isTouched) setIsTouched(true)
setLocalValue(value)
onChange(value)
}
const handleVariableSelected = (variable?: Variable) => {
if (!variable) return
const { text, carretPosition: newCarretPosition } = injectVariableInText({
variable,
text: localValue,
at: carretPosition,
})
changeValue(text)
focusInput({ at: newCarretPosition, input: inputRef.current })
}
const updateCarretPosition = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const carretPosition = e.target.selectionStart
if (!carretPosition) return
setCarretPosition(carretPosition)
}
const Textarea = (
<ChakraTextarea
ref={inputRef}
id={id}
value={localValue}
onBlur={updateCarretPosition}
onChange={(e) => changeValue(e.target.value)}
/>
)
return (
<FormControl isRequired={isRequired}>
{label && (
<FormLabel>
{label}{' '}
{moreInfoTooltip && (
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
)}
</FormLabel>
)}
{withVariableButton ? (
<HStack spacing={0} align={'flex-end'}>
{Textarea}
<VariablesButton onSelectVariable={handleVariableSelected} />
</HStack>
) : (
Textarea
)}
</FormControl>
)
}

View File

@@ -0,0 +1,268 @@
import {
useDisclosure,
Flex,
Popover,
Input,
PopoverContent,
Button,
InputProps,
IconButton,
HStack,
useColorModeValue,
PopoverAnchor,
Portal,
Tag,
} from '@chakra-ui/react'
import { EditIcon, PlusIcon, TrashIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor/providers/TypebotProvider/TypebotProvider'
import { createId } from '@paralleldrive/cuid2'
import { Variable } from 'models'
import React, { useState, useRef, ChangeEvent, useEffect } from 'react'
import { byId, isDefined, isNotDefined } from 'utils'
import { useOutsideClick } from '@/hooks/useOutsideClick'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
type Props = {
initialVariableId?: string
autoFocus?: boolean
onSelectVariable: (
variable: Pick<Variable, 'id' | 'name'> | undefined
) => void
} & InputProps
export const VariableSearchInput = ({
initialVariableId,
onSelectVariable,
autoFocus,
...inputProps
}: Props) => {
const focusedItemBgColor = useColorModeValue('gray.200', 'gray.700')
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 [filteredItems, setFilteredItems] = useState<Variable[]>(
variables ?? []
)
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
number | undefined
>()
const dropdownRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const createVariableItemRef = useRef<HTMLButtonElement | null>(null)
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
const { ref: parentModalRef } = useParentModal()
useOutsideClick({
ref: dropdownRef,
handler: onClose,
})
useEffect(() => {
if (autoFocus) onOpen()
}, [autoFocus, onOpen])
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
if (e.target.value === '') {
if (inputValue.length > 0) {
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)
inputRef.current?.blur()
onClose()
}
const handleCreateNewVariableClick = () => {
if (!inputValue || inputValue === '') return
const id = 'v' + createId()
onSelectVariable({ id, name: inputValue })
createVariable({ id, name: inputValue })
inputRef.current?.blur()
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('')
}
}
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 - (isCreateVariableButtonDisplayed ? 1 : 0)
]
)()
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]}
>
<PopoverAnchor>
<Input
data-testid="variables-input"
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onFocus={onOpen}
onKeyDown={handleKeyUp}
placeholder={inputProps.placeholder ?? 'Select a variable'}
{...inputProps}
/>
</PopoverAnchor>
<Portal containerRef={parentModalRef}>
<PopoverContent
maxH="35vh"
overflowY="scroll"
role="menu"
w="inherit"
shadow="lg"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{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 ? focusedItemBgColor : 'transparent'
}
>
Create
<Tag colorScheme="orange" ml="1">
{inputValue}
</Tag>
</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
? focusedItemBgColor
: 'transparent'
}
transition="none"
>
{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>
</Portal>
</Popover>
</Flex>
)
}

View File

@@ -1,3 +1,3 @@
export { Input } from './Input'
export { TextInput } from './TextInput'
export { Textarea } from './Textarea'
export { SmartNumberInput } from './SmartNumberInput'
export { NumberInput } from './NumberInput'