2
0

feat(engine): Can edit answers by clicking on it

This commit is contained in:
Baptiste Arnaud
2022-03-01 19:07:02 +01:00
parent d6c3e8d41a
commit f12491419d
10 changed files with 166 additions and 205 deletions

View File

@ -1,56 +1,69 @@
import React, { useEffect, useState } from 'react'
import React, {
ForwardedRef,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'
import { Avatar } from '../avatars/Avatar'
import { useFrame } from 'react-frame-component'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import { useHostAvatars } from '../../contexts/HostAvatarsContext'
import { CSSTransition } from 'react-transition-group'
export const AvatarSideContainer = ({
hostAvatarSrc,
}: {
hostAvatarSrc?: string
}) => {
const { lastBubblesTopOffset } = useHostAvatars()
const { window, document } = useFrame()
const [marginBottom, setMarginBottom] = useState(
window.innerWidth < 400 ? 38 : 48
)
type Props = { hostAvatarSrc?: string }
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
const isMobile = window.innerWidth < 400
setMarginBottom(isMobile ? 38 : 48)
})
resizeObserver.observe(document.body)
return () => {
resizeObserver.disconnect()
export const AvatarSideContainer = forwardRef(
({ hostAvatarSrc }: Props, ref: ForwardedRef<unknown>) => {
const { document } = useFrame()
const [show, setShow] = useState(false)
const [avatarTopOffset, setAvatarTopOffset] = useState(0)
const refreshTopOffset = () => {
if (!scrollingSideBlockRef.current || !avatarContainer.current) return
const { height } = scrollingSideBlockRef.current.getBoundingClientRect()
const { height: avatarHeight } =
avatarContainer.current.getBoundingClientRect()
setAvatarTopOffset(height - avatarHeight)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const scrollingSideBlockRef = useRef<HTMLDivElement>(null)
const avatarContainer = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => ({
refreshTopOffset,
}))
return (
<div className="flex w-6 xs:w-10 mr-2 flex-shrink-0 items-center">
<TransitionGroup>
{lastBubblesTopOffset
.filter((n) => n !== -1)
.map((topOffset, idx) => (
<CSSTransition
key={idx}
classNames="bubble"
timeout={500}
unmountOnExit
>
<div
className="absolute w-6 h-6 xs:w-10 xs:h-10 mb-4 xs:mb-2 flex items-center top-0"
style={{
top: `calc(${topOffset}px - ${marginBottom}px)`,
transition: 'top 350ms ease-out',
}}
>
<Avatar avatarSrc={hostAvatarSrc} />
</div>
</CSSTransition>
))}
</TransitionGroup>
</div>
)
}
useEffect(() => {
setShow(true)
const resizeObserver = new ResizeObserver(refreshTopOffset)
resizeObserver.observe(document.body)
return () => {
resizeObserver.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div
className="flex w-6 xs:w-10 mr-2 mb-2 flex-shrink-0 items-center relative "
ref={scrollingSideBlockRef}
>
<CSSTransition
classNames="bubble"
timeout={500}
in={show}
unmountOnExit
>
<div
className="absolute w-6 xs:w-10 h-6 xs:h-10 mb-4 xs:mb-2 flex items-center top-0"
ref={avatarContainer}
style={{
top: `${avatarTopOffset}px`,
transition: 'top 350ms ease-out, opacity 500ms',
}}
>
<Avatar avatarSrc={hostAvatarSrc} />
</div>
</CSSTransition>
</div>
)
}
)

View File

@ -1,8 +1,6 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { ChatStep } from './ChatStep'
import { AvatarSideContainer } from './AvatarSideContainer'
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
import { useTypebot } from '../../contexts/TypebotContext'
import {
isBubbleStep,
@ -16,7 +14,9 @@ import { executeIntegration } from 'services/integration'
import { parseRetryStep, stepCanBeRetried } from 'services/inputs'
import { parseVariables } from 'index'
import { useAnswers } from 'contexts/AnswersContext'
import { Step } from 'models'
import { BubbleStep, InputStep, Step } from 'models'
import { HostBubble } from './ChatStep/bubbles/HostBubble'
import { InputChatStep } from './ChatStep/InputChatStep'
type ChatBlockProps = {
steps: Step[]
@ -41,6 +41,13 @@ export const ChatBlock = ({
} = useTypebot()
const { resultValues } = useAnswers()
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
const bubbleSteps = displayedSteps.filter((step) =>
isBubbleStep(step)
) as BubbleStep[]
const inputSteps = displayedSteps.filter((step) =>
isInputStep(step)
) as InputStep[]
const avatarSideContainerRef = useRef<any>()
useEffect(() => {
const nextStep = steps[startStepIndex]
@ -49,6 +56,7 @@ export const ChatBlock = ({
}, [])
useEffect(() => {
avatarSideContainerRef.current?.refreshTopOffset()
onScroll()
onNewStepDisplayed()
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -118,33 +126,49 @@ export const ChatBlock = ({
}
const avatarSrc = typebot.theme.chat.hostAvatar?.url
return (
<div className="flex w-full">
<HostAvatarsContext>
{(typebot.theme.chat.hostAvatar?.isEnabled ?? true) && (
<AvatarSideContainer
hostAvatarSrc={
avatarSrc && parseVariables(typebot.variables)(avatarSrc)
}
/>
)}
<div className="flex flex-col w-full min-w-0">
<div className="flex flex-col w-full min-w-0">
<div className="flex">
{(typebot.theme.chat.hostAvatar?.isEnabled ?? true) && (
<AvatarSideContainer
ref={avatarSideContainerRef}
hostAvatarSrc={
avatarSrc && parseVariables(typebot.variables)(avatarSrc)
}
/>
)}
<TransitionGroup>
{displayedSteps
.filter((step) => isInputStep(step) || isBubbleStep(step))
.map((step) => (
<CSSTransition
key={step.id}
classNames="bubble"
timeout={500}
unmountOnExit
>
<ChatStep step={step} onTransitionEnd={displayNextStep} />
</CSSTransition>
))}
{bubbleSteps.map((step) => (
<CSSTransition
key={step.id}
classNames="bubble"
timeout={500}
unmountOnExit
>
<HostBubble step={step} onTransitionEnd={displayNextStep} />
</CSSTransition>
))}
</TransitionGroup>
</div>
</HostAvatarsContext>
<TransitionGroup>
{inputSteps.map((step) => (
<CSSTransition
key={step.id}
classNames="bubble"
timeout={500}
unmountOnExit
>
<InputChatStep
step={step}
onTransitionEnd={displayNextStep}
hasAvatar={typebot.theme.chat.hostAvatar?.isEnabled ?? true}
/>
</CSSTransition>
))}
</TransitionGroup>
</div>
</div>
)
}

View File

@ -1,31 +1,36 @@
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext'
import { useHostAvatars } from '../../../contexts/HostAvatarsContext'
import { InputStep, InputStepType, Step } from 'models'
import { InputStep, InputStepType } from 'models'
import { GuestBubble } from './bubbles/GuestBubble'
import { TextForm } from './inputs/TextForm'
import { byId, isBubbleStep, isInputStep } from 'utils'
import { byId } from 'utils'
import { DateForm } from './inputs/DateForm'
import { ChoiceForm } from './inputs/ChoiceForm'
import { HostBubble } from './bubbles/HostBubble'
import { isInputValid } from 'services/inputs'
import { useTypebot } from 'contexts/TypebotContext'
import { parseVariables } from 'index'
import { isInputValid } from 'services/inputs'
export const ChatStep = ({
export const InputChatStep = ({
step,
hasAvatar,
onTransitionEnd,
}: {
step: Step
step: InputStep
hasAvatar: boolean
onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void
}) => {
const { typebot } = useTypebot()
const { addAnswer } = useAnswers()
const [answer, setAnswer] = useState<string>()
const [isEditting, setIsEditting] = useState(false)
const handleInputSubmit = (
content: string,
isRetry: boolean,
variableId?: string
) => {
const { variableId } = step.options
const defaultValue =
variableId && typebot.variables.find(byId(variableId))?.value
const handleSubmit = (content: string) => {
setAnswer(content)
const isRetry = !isInputValid(content, step.type)
if (!isRetry)
addAnswer({
stepId: step.id,
@ -33,38 +38,13 @@ export const ChatStep = ({
content,
variableId: variableId ?? null,
})
onTransitionEnd(content, isRetry)
if (!isEditting) onTransitionEnd(content, isRetry)
setIsEditting(false)
}
if (isBubbleStep(step))
return <HostBubble step={step} onTransitionEnd={onTransitionEnd} />
if (isInputStep(step))
return <InputChatStep step={step} onSubmit={handleInputSubmit} />
return <span>No step</span>
}
const InputChatStep = ({
step,
onSubmit,
}: {
step: InputStep
onSubmit: (value: string, isRetry: boolean, variableId?: string) => void
}) => {
const { typebot } = useTypebot()
const { addNewAvatarOffset } = useHostAvatars()
const [answer, setAnswer] = useState<string>()
const { variableId } = step.options
const defaultValue =
variableId && typebot.variables.find(byId(variableId))?.value
useEffect(() => {
addNewAvatarOffset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSubmit = (value: string) => {
setAnswer(value)
onSubmit(value, !isInputValid(value, step.type), step.options.variableId)
const handleGuestBubbleClick = () => {
setAnswer(undefined)
setIsEditting(true)
}
if (answer) {
@ -74,9 +54,30 @@ const InputChatStep = ({
message={answer}
showAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
avatarSrc={avatarUrl && parseVariables(typebot.variables)(avatarUrl)}
onClick={handleGuestBubbleClick}
/>
)
}
return (
<div className="flex">
{hasAvatar && (
<div className="flex w-6 xs:w-10 h-6 xs:h-10 mr-2 mb-2 mt-1 flex-shrink-0 items-center" />
)}
<Input step={step} onSubmit={handleSubmit} defaultValue={defaultValue} />
</div>
)
}
const Input = ({
step,
onSubmit,
defaultValue,
}: {
step: InputStep
onSubmit: (value: string) => void
defaultValue?: string
}) => {
switch (step.type) {
case InputStepType.TEXT:
case InputStepType.NUMBER:
@ -84,15 +85,11 @@ const InputChatStep = ({
case InputStepType.URL:
case InputStepType.PHONE:
return (
<TextForm
step={step}
onSubmit={handleSubmit}
defaultValue={defaultValue}
/>
<TextForm step={step} onSubmit={onSubmit} defaultValue={defaultValue} />
)
case InputStepType.DATE:
return <DateForm options={step.options} onSubmit={handleSubmit} />
return <DateForm options={step.options} onSubmit={onSubmit} />
case InputStepType.CHOICE:
return <ChoiceForm step={step} onSubmit={handleSubmit} />
return <ChoiceForm step={step} onSubmit={onSubmit} />
}
}

View File

@ -6,18 +6,20 @@ interface Props {
message: string
showAvatar: boolean
avatarSrc?: string
onClick: () => void
}
export const GuestBubble = ({
message,
showAvatar,
avatarSrc,
onClick,
}: Props): JSX.Element => {
return (
<CSSTransition classNames="bubble" timeout={1000}>
<div className="flex justify-end mb-2 items-end">
<div className="flex justify-end mb-2 items-end" onClick={onClick}>
<span
className="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble"
className="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble cursor-pointer hover:brightness-90 active:brightness-75"
data-testid="guest-bubble"
>
{message}

View File

@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useHostAvatars } from 'contexts/HostAvatarsContext'
import { useTypebot } from 'contexts/TypebotContext'
import { ImageBubbleStep } from 'models'
import { TypingContent } from './TypingContent'
@ -16,7 +15,6 @@ export const mediaLoadingFallbackTimeout = 5000
export const ImageBubble = ({ step, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const { updateLastAvatarOffset } = useHostAvatars()
const messageContainer = useRef<HTMLDivElement | null>(null)
const image = useRef<HTMLImageElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
@ -27,7 +25,6 @@ export const ImageBubble = ({ step, onTransitionEnd }: Props) => {
)
useEffect(() => {
sendAvatarOffset()
showContentAfterMediaLoad()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -48,19 +45,10 @@ export const ImageBubble = ({ step, onTransitionEnd }: Props) => {
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
sendAvatarOffset()
onTransitionEnd()
}, showAnimationDuration)
}
const sendAvatarOffset = () => {
if (!messageContainer.current) return
const containerDimensions = messageContainer.current.getBoundingClientRect()
updateLastAvatarOffset(
messageContainer.current.offsetTop + containerDimensions.height
)
}
return (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full lg:w-11/12 items-center">

View File

@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useHostAvatars } from 'contexts/HostAvatarsContext'
import { useTypebot } from 'contexts/TypebotContext'
import { BubbleStepType, TextBubbleStep } from 'models'
import { computeTypingTimeout } from 'services/chat'
@ -21,7 +20,6 @@ const defaultTypingEmulation = {
export const TextBubble = ({ step, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const { updateLastAvatarOffset } = useHostAvatars()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
@ -32,7 +30,6 @@ export const TextBubble = ({ step, onTransitionEnd }: Props) => {
)
useEffect(() => {
sendAvatarOffset()
const typingTimeout = computeTypingTimeout(
step.content.plainText,
typebot.settings?.typingEmulation ?? defaultTypingEmulation
@ -46,19 +43,10 @@ export const TextBubble = ({ step, onTransitionEnd }: Props) => {
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
sendAvatarOffset()
onTransitionEnd()
}, showAnimationDuration)
}
const sendAvatarOffset = () => {
if (!messageContainer.current) return
const containerDimensions = messageContainer.current.getBoundingClientRect()
updateLastAvatarOffset(
messageContainer.current.offsetTop + containerDimensions.height
)
}
return (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full items-center">

View File

@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useHostAvatars } from 'contexts/HostAvatarsContext'
import { useTypebot } from 'contexts/TypebotContext'
import {
Variable,
@ -21,12 +20,10 @@ export const mediaLoadingFallbackTimeout = 5000
export const VideoBubble = ({ step, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const { updateLastAvatarOffset } = useHostAvatars()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
useEffect(() => {
sendAvatarOffset()
showContentAfterMediaLoad()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -41,19 +38,10 @@ export const VideoBubble = ({ step, onTransitionEnd }: Props) => {
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
sendAvatarOffset()
onTransitionEnd()
}, showAnimationDuration)
}
const sendAvatarOffset = () => {
if (!messageContainer.current) return
const containerDimensions = messageContainer.current.getBoundingClientRect()
updateLastAvatarOffset(
messageContainer.current.offsetTop + containerDimensions.height
)
}
return (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full lg:w-11/12 items-center">

View File

@ -1 +1 @@
export { ChatStep } from './ChatStep'
export { InputChatStep } from './InputChatStep'

View File

@ -40,7 +40,7 @@ export const TextForm = ({ step, onSubmit, defaultValue }: TextFormProps) => {
}
return (
<div className="flex flex-col w-full lg:w-4/6">
<div className="flex flex-col w-full lg:w-4/6 mb-2">
<div className="flex items-center">
<form
className="flex items-end justify-between rounded-lg pr-2 typebot-input"

View File

@ -1,39 +0,0 @@
import React, { createContext, ReactNode, useContext, useState } from 'react'
// This context just keeps track of the top offset of host avatar
const hostAvatarsContext = createContext<{
lastBubblesTopOffset: number[]
addNewAvatarOffset: () => void
updateLastAvatarOffset: (newOffset: number) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const HostAvatarsContext = ({ children }: { children: ReactNode }) => {
const [lastBubblesTopOffset, setLastBubblesTopOffset] = useState<number[]>([
-1,
])
const updateLastAvatarOffset = (newOffset: number) => {
const offsets = [...lastBubblesTopOffset]
offsets[offsets.length - 1] = newOffset
setLastBubblesTopOffset(offsets)
}
const addNewAvatarOffset = () =>
setLastBubblesTopOffset([...lastBubblesTopOffset, -1])
return (
<hostAvatarsContext.Provider
value={{
lastBubblesTopOffset,
updateLastAvatarOffset,
addNewAvatarOffset,
}}
>
{children}
</hostAvatarsContext.Provider>
)
}
export const useHostAvatars = () => useContext(hostAvatarsContext)