2
0

feat(inputs): Add text options

This commit is contained in:
Baptiste Arnaud
2022-01-06 16:54:23 +01:00
parent eea522f5bd
commit f712c7ad98
16 changed files with 356 additions and 75 deletions

View File

@ -15,8 +15,8 @@ export const Board = () => {
<StepTypesList />
<GraphProvider>
<Graph flex="1" />
</GraphProvider>
{rightPanel === RightPanel.PREVIEW && <PreviewDrawer />}
</GraphProvider>
</DndContext>
</Flex>
)

View File

@ -0,0 +1,40 @@
import { PopoverContent, PopoverArrow, PopoverBody } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { Step, StepType, TextInputOptions } from 'models'
import { TextInputSettingsBody } from './TextInputSettingsBody'
type Props = {
step: Step
}
export const SettingsPopoverContent = ({ step }: Props) => {
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
return (
<PopoverContent onMouseDown={handleMouseDown}>
<PopoverArrow />
<PopoverBody p="6">
<SettingsPopoverBodyContent step={step} />
</PopoverBody>
</PopoverContent>
)
}
const SettingsPopoverBodyContent = ({ step }: Props) => {
const { updateStep } = useTypebot()
const handleOptionsChange = (options: TextInputOptions) =>
updateStep(step.id, { options } as Partial<Step>)
switch (step.type) {
case StepType.TEXT_INPUT: {
return (
<TextInputSettingsBody
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
default: {
return <></>
}
}
}

View File

@ -0,0 +1,55 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { TextInputOptions } from 'models'
import React from 'react'
type TextInputSettingsBodyProps = {
options?: TextInputOptions
onOptionsChange: (options: TextInputOptions) => void
}
export const TextInputSettingsBody = ({
options,
onOptionsChange,
}: TextInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleLongChange = (isLong: boolean) =>
onOptionsChange({ ...options, isLong })
return (
<Stack spacing={4}>
<SwitchWithLabel
id="switch"
label="Long text?"
initialValue={options?.isLong ?? false}
onCheckChange={handleLongChange}
/>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<DebouncedInput
id="placeholder"
initialValue={options?.labels?.placeholder ?? 'Type your answer...'}
delay={100}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options?.labels?.button ?? 'Send'}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>
</Stack>
)
}

View File

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

View File

@ -1,4 +1,11 @@
import { Box, Flex, HStack, useEventListener } from '@chakra-ui/react'
import {
Box,
Flex,
HStack,
Popover,
PopoverTrigger,
useEventListener,
} from '@chakra-ui/react'
import React, { useEffect, useMemo, useState } from 'react'
import { Block, Step, StepType } from 'models'
import { SourceEndpoint } from './SourceEndpoint'
@ -11,6 +18,7 @@ import { StepContent } from './StepContent'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { StepNodeContextMenu } from './RightClickMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent'
export const StepNode = ({
step,
@ -144,6 +152,8 @@ export const StepNode = ({
renderMenu={() => <StepNodeContextMenu stepId={step.id} />}
>
{(ref, isOpened) => (
<Popover placement="left" isLazy>
<PopoverTrigger>
<Flex
pos="relative"
ref={ref}
@ -152,6 +162,7 @@ export const StepNode = ({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
data-testid={`step-${step.id}`}
>
{connectedStubPosition === 'left' && (
<Box
@ -196,6 +207,9 @@ export const StepNode = ({
/>
)}
</Flex>
</PopoverTrigger>
<SettingsPopoverContent step={step} />
</Popover>
)}
</ContextMenu>
)

View File

@ -21,8 +21,9 @@ export const PreviewDrawer = () => {
const { setRightPanel } = useEditor()
const { previewingIds, setPreviewingIds } = useGraph()
const [isResizing, setIsResizing] = useState(false)
const [width, setWidth] = useState(400)
const [width, setWidth] = useState(500)
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false)
const [restartKey, setRestartKey] = useState(0)
const publicTypebot = useMemo(
() => (typebot ? parseTypebotToPublicTypebot(typebot) : undefined),
@ -47,11 +48,13 @@ export const PreviewDrawer = () => {
const handleNewBlockVisible = (targetId: string) =>
setPreviewingIds({
sourceId: !previewingIds.sourceId
? 'start-block'
? typebot?.blocks.allIds[0]
: previewingIds.targetId,
targetId: targetId,
})
const handleRestartClick = () => setRestartKey((key) => key + 1)
return (
<Flex
pos="absolute"
@ -77,7 +80,7 @@ export const PreviewDrawer = () => {
<VStack w="full" spacing={4}>
<Flex justifyContent={'space-between'} w="full">
<Button>Restart</Button>
<Button onClick={handleRestartClick}>Restart</Button>
<CloseButton onClick={() => setRightPanel(undefined)} />
</Flex>
@ -87,6 +90,7 @@ export const PreviewDrawer = () => {
borderRadius={'lg'}
h="full"
w="full"
key={restartKey}
pointerEvents={isResizing ? 'none' : 'auto'}
>
<TypebotViewer

View File

@ -0,0 +1,31 @@
import { Input, InputProps } from '@chakra-ui/react'
import { ChangeEvent, useEffect, useState } from 'react'
import { useDebounce } from 'use-debounce'
type Props = Omit<InputProps, 'onChange' | 'value'> & {
delay: number
initialValue: string
onChange: (debouncedValue: string) => void
}
export const DebouncedInput = ({
delay,
onChange,
initialValue,
...props
}: Props) => {
const [currentValue, setCurrentValue] = useState(initialValue)
const [currentValueDebounced] = useDebounce(currentValue, delay)
useEffect(() => {
if (currentValueDebounced === initialValue) return
onChange(currentValueDebounced)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentValueDebounced])
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setCurrentValue(e.target.value)
}
return <Input {...props} value={currentValue} onChange={handleChange} />
}

View File

@ -0,0 +1,30 @@
import { FormLabel, HStack, Switch, SwitchProps } from '@chakra-ui/react'
import React, { useState } from 'react'
type SwitchWithLabelProps = {
label: string
initialValue: boolean
onCheckChange: (isChecked: boolean) => void
} & SwitchProps
export const SwitchWithLabel = ({
label,
initialValue,
onCheckChange,
...props
}: SwitchWithLabelProps) => {
const [isChecked, setIsChecked] = useState(initialValue)
const handleChange = () => {
setIsChecked(!isChecked)
onCheckChange(!isChecked)
}
return (
<HStack justifyContent="space-between">
<FormLabel htmlFor={props.id} mb="0">
{label}
</FormLabel>
<Switch isChecked={isChecked} onChange={handleChange} {...props} />
</HStack>
)
}

View File

@ -15,6 +15,9 @@ export const seedDb = async () => {
return createAnswers()
}
export const createTypebot = (typebot: Typebot) =>
prisma.typebot.create({ data: typebot as any })
const createUsers = () =>
prisma.user.createMany({
data: [

View File

@ -3,7 +3,7 @@ import {
FacebookSocialLogin,
GoogleSocialLogin,
} from 'cypress-social-logins/src/Plugins'
import { seedDb } from './database'
import { createTypebot, seedDb } from './database'
/// <reference types="cypress" />
/**
@ -16,6 +16,7 @@ const handler = (on: any) => {
FacebookSocialLogin: FacebookSocialLogin,
GitHubSocialLogin: GitHubSocialLogin,
seed: seedDb,
createTypebot,
})
}

View File

@ -0,0 +1,66 @@
import { parseTestTypebot } from 'cypress/plugins/utils'
import { StepType } from 'models'
describe('Text input', () => {
beforeEach(() => {
cy.task('seed')
cy.task(
'createTypebot',
parseTestTypebot({
id: 'typebot3',
name: 'Typebot #3',
ownerId: 'test2',
steps: {
byId: {
step1: {
id: 'step1',
blockId: 'block1',
type: StepType.TEXT_INPUT,
},
},
allIds: ['step1'],
},
blocks: {
byId: {
block1: {
id: 'block1',
graphCoordinates: { x: 400, y: 200 },
title: 'Block #1',
stepIds: ['step1'],
},
},
allIds: ['block1'],
},
})
)
cy.signOut()
})
it('text input options should work', () => {
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByPlaceholderText('Type your answer...').should('exist')
getIframeBody().findByRole('button', { name: 'Send' })
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('textbox', { name: 'Placeholder:' })
.clear()
.type('Your name...')
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody().findByPlaceholderText('Your name...').should('exist')
getIframeBody().findByRole('button', { name: 'Go' })
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('checkbox', { name: 'Long text?' }).check({ force: true })
cy.findByRole('button', { name: 'Restart' }).click()
getIframeBody().findByTestId('textarea').should('exist')
})
})
const getIframeBody = () => {
return cy
.get('#typebot-iframe')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
}

View File

@ -35,6 +35,7 @@
"kbar": "^0.1.0-beta.24",
"micro": "^9.3.4",
"micro-cors": "^0.1.1",
"models": "*",
"next": "^12.0.7",
"next-auth": "beta",
"nodemailer": "^6.7.2",
@ -56,8 +57,7 @@
"swr": "^1.1.2",
"use-debounce": "^7.0.1",
"use-immer": "^0.6.0",
"utils": "*",
"models": "*"
"utils": "*"
},
"devDependencies": {
"@testing-library/cypress": "^8.0.2",

View File

@ -19,6 +19,9 @@ export const AvatarSideContainer = () => {
setMarginBottom(isMobile ? 38 : 48)
})
resizeObserver.observe(document.body)
return () => {
resizeObserver.disconnect()
}
}, [])
return (

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext'
import { useHostAvatars } from '../../../contexts/HostAvatarsContext'
import { Step } from 'models'
import { InputStep, Step } from 'models'
import { isTextInputStep, isTextStep } from '../../../services/utils'
import { GuestBubble } from './bubbles/GuestBubble'
import { HostMessageBubble } from './bubbles/HostMessageBubble'
@ -24,11 +24,17 @@ export const ChatStep = ({
if (isTextStep(step))
return <HostMessageBubble step={step} onTransitionEnd={onTransitionEnd} />
if (isTextInputStep(step))
return <InputChatStep onSubmit={handleInputSubmit} />
return <InputChatStep step={step} onSubmit={handleInputSubmit} />
return <span>No step</span>
}
const InputChatStep = ({ onSubmit }: { onSubmit: (value: string) => void }) => {
const InputChatStep = ({
step,
onSubmit,
}: {
step: InputStep
onSubmit: (value: string) => void
}) => {
const { addNewAvatarOffset } = useHostAvatars()
const [answer, setAnswer] = useState<string>()
@ -44,5 +50,5 @@ const InputChatStep = ({ onSubmit }: { onSubmit: (value: string) => void }) => {
if (answer) {
return <GuestBubble message={answer} />
}
return <TextInput onSubmit={handleSubmit} />
return <TextInput step={step} onSubmit={handleSubmit} />
}

View File

@ -1,11 +1,13 @@
import { TextInputStep } from 'models'
import React, { FormEvent, useRef, useState } from 'react'
import { SendIcon } from '../../../../assets/icons'
type TextInputProps = {
step: TextInputStep
onSubmit: (value: string) => void
}
export const TextInput = ({ onSubmit }: TextInputProps) => {
export const TextInput = ({ step, onSubmit }: TextInputProps) => {
const inputRef = useRef(null)
const [inputValue, setInputValue] = useState('')
@ -19,24 +21,43 @@ export const TextInput = ({ onSubmit }: TextInputProps) => {
<div className="flex flex-col w-full lg:w-4/6">
<div className="flex items-center">
<form
className="flex items-center justify-between rounded-lg pr-2 typebot-input"
className="flex items-end justify-between rounded-lg pr-2 typebot-input"
onSubmit={handleSubmit}
>
{step.options?.isLong ? (
<textarea
ref={inputRef}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full comp-input"
placeholder={
step.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={(e) => setInputValue(e.target.value)}
rows={4}
data-testid="textarea"
required
/>
) : (
<input
ref={inputRef}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full comp-input"
type="text"
placeholder={'Type your answer...'}
placeholder={
step.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={(e) => setInputValue(e.target.value)}
required
/>
)}
<button
type="submit"
className={
'py-2 px-4 font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button active'
'my-2 ml-2 py-2 px-4 font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button active'
}
disabled={inputValue === ''}
>
<span className="hidden xs:flex">Submit</span>
<span className="hidden xs:flex">
{step.options?.labels?.button ?? 'Send'}
</span>
<SendIcon className="send-icon flex xs:hidden" />
</button>
</form>

View File

@ -24,6 +24,12 @@ export type TextStep = StepBase & {
export type TextInputStep = StepBase & {
type: StepType.TEXT_INPUT
options?: TextInputOptions
}
export type TextInputOptions = {
labels?: { placeholder?: string; button?: string }
isLong?: boolean
}
export type Target = { blockId: string; stepId?: string }