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 { Avatar } from '../avatars/Avatar'
import { useFrame } from 'react-frame-component' import { useFrame } from 'react-frame-component'
import { CSSTransition, TransitionGroup } from 'react-transition-group' import { CSSTransition } from 'react-transition-group'
import { useHostAvatars } from '../../contexts/HostAvatarsContext'
export const AvatarSideContainer = ({ type Props = { hostAvatarSrc?: string }
hostAvatarSrc,
}: {
hostAvatarSrc?: string
}) => {
const { lastBubblesTopOffset } = useHostAvatars()
const { window, document } = useFrame()
const [marginBottom, setMarginBottom] = useState(
window.innerWidth < 400 ? 38 : 48
)
useEffect(() => { export const AvatarSideContainer = forwardRef(
const resizeObserver = new ResizeObserver(() => { ({ hostAvatarSrc }: Props, ref: ForwardedRef<unknown>) => {
const isMobile = window.innerWidth < 400 const { document } = useFrame()
setMarginBottom(isMobile ? 38 : 48) const [show, setShow] = useState(false)
}) const [avatarTopOffset, setAvatarTopOffset] = useState(0)
resizeObserver.observe(document.body)
return () => { const refreshTopOffset = () => {
resizeObserver.disconnect() 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 ( useEffect(() => {
<div className="flex w-6 xs:w-10 mr-2 flex-shrink-0 items-center"> setShow(true)
<TransitionGroup> const resizeObserver = new ResizeObserver(refreshTopOffset)
{lastBubblesTopOffset resizeObserver.observe(document.body)
.filter((n) => n !== -1) return () => {
.map((topOffset, idx) => ( resizeObserver.disconnect()
<CSSTransition }
key={idx} // eslint-disable-next-line react-hooks/exhaustive-deps
classNames="bubble" }, [])
timeout={500}
unmountOnExit return (
> <div
<div className="flex w-6 xs:w-10 mr-2 mb-2 flex-shrink-0 items-center relative "
className="absolute w-6 h-6 xs:w-10 xs:h-10 mb-4 xs:mb-2 flex items-center top-0" ref={scrollingSideBlockRef}
style={{ >
top: `calc(${topOffset}px - ${marginBottom}px)`, <CSSTransition
transition: 'top 350ms ease-out', classNames="bubble"
}} timeout={500}
> in={show}
<Avatar avatarSrc={hostAvatarSrc} /> unmountOnExit
</div> >
</CSSTransition> <div
))} className="absolute w-6 xs:w-10 h-6 xs:h-10 mb-4 xs:mb-2 flex items-center top-0"
</TransitionGroup> ref={avatarContainer}
</div> 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 { TransitionGroup, CSSTransition } from 'react-transition-group'
import { ChatStep } from './ChatStep'
import { AvatarSideContainer } from './AvatarSideContainer' import { AvatarSideContainer } from './AvatarSideContainer'
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
import { useTypebot } from '../../contexts/TypebotContext' import { useTypebot } from '../../contexts/TypebotContext'
import { import {
isBubbleStep, isBubbleStep,
@@ -16,7 +14,9 @@ import { executeIntegration } from 'services/integration'
import { parseRetryStep, stepCanBeRetried } from 'services/inputs' import { parseRetryStep, stepCanBeRetried } from 'services/inputs'
import { parseVariables } from 'index' import { parseVariables } from 'index'
import { useAnswers } from 'contexts/AnswersContext' 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 = { type ChatBlockProps = {
steps: Step[] steps: Step[]
@@ -41,6 +41,13 @@ export const ChatBlock = ({
} = useTypebot() } = useTypebot()
const { resultValues } = useAnswers() const { resultValues } = useAnswers()
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([]) 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(() => { useEffect(() => {
const nextStep = steps[startStepIndex] const nextStep = steps[startStepIndex]
@@ -49,6 +56,7 @@ export const ChatBlock = ({
}, []) }, [])
useEffect(() => { useEffect(() => {
avatarSideContainerRef.current?.refreshTopOffset()
onScroll() onScroll()
onNewStepDisplayed() onNewStepDisplayed()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -118,33 +126,49 @@ export const ChatBlock = ({
} }
const avatarSrc = typebot.theme.chat.hostAvatar?.url const avatarSrc = typebot.theme.chat.hostAvatar?.url
return ( return (
<div className="flex w-full"> <div className="flex w-full">
<HostAvatarsContext> <div className="flex flex-col w-full min-w-0">
{(typebot.theme.chat.hostAvatar?.isEnabled ?? true) && ( <div className="flex">
<AvatarSideContainer {(typebot.theme.chat.hostAvatar?.isEnabled ?? true) && (
hostAvatarSrc={ <AvatarSideContainer
avatarSrc && parseVariables(typebot.variables)(avatarSrc) ref={avatarSideContainerRef}
} hostAvatarSrc={
/> avatarSrc && parseVariables(typebot.variables)(avatarSrc)
)} }
<div className="flex flex-col w-full min-w-0"> />
)}
<TransitionGroup> <TransitionGroup>
{displayedSteps {bubbleSteps.map((step) => (
.filter((step) => isInputStep(step) || isBubbleStep(step)) <CSSTransition
.map((step) => ( key={step.id}
<CSSTransition classNames="bubble"
key={step.id} timeout={500}
classNames="bubble" unmountOnExit
timeout={500} >
unmountOnExit <HostBubble step={step} onTransitionEnd={displayNextStep} />
> </CSSTransition>
<ChatStep step={step} onTransitionEnd={displayNextStep} /> ))}
</CSSTransition>
))}
</TransitionGroup> </TransitionGroup>
</div> </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> </div>
) )
} }

View File

@@ -1,31 +1,36 @@
import React, { useEffect, useState } from 'react' import React, { useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext' import { useAnswers } from '../../../contexts/AnswersContext'
import { useHostAvatars } from '../../../contexts/HostAvatarsContext' import { InputStep, InputStepType } from 'models'
import { InputStep, InputStepType, Step } from 'models'
import { GuestBubble } from './bubbles/GuestBubble' import { GuestBubble } from './bubbles/GuestBubble'
import { TextForm } from './inputs/TextForm' import { TextForm } from './inputs/TextForm'
import { byId, isBubbleStep, isInputStep } from 'utils' import { byId } from 'utils'
import { DateForm } from './inputs/DateForm' import { DateForm } from './inputs/DateForm'
import { ChoiceForm } from './inputs/ChoiceForm' import { ChoiceForm } from './inputs/ChoiceForm'
import { HostBubble } from './bubbles/HostBubble'
import { isInputValid } from 'services/inputs'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { parseVariables } from 'index' import { parseVariables } from 'index'
import { isInputValid } from 'services/inputs'
export const ChatStep = ({ export const InputChatStep = ({
step, step,
hasAvatar,
onTransitionEnd, onTransitionEnd,
}: { }: {
step: Step step: InputStep
hasAvatar: boolean
onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void
}) => { }) => {
const { typebot } = useTypebot()
const { addAnswer } = useAnswers() const { addAnswer } = useAnswers()
const [answer, setAnswer] = useState<string>()
const [isEditting, setIsEditting] = useState(false)
const handleInputSubmit = ( const { variableId } = step.options
content: string, const defaultValue =
isRetry: boolean, variableId && typebot.variables.find(byId(variableId))?.value
variableId?: string
) => { const handleSubmit = (content: string) => {
setAnswer(content)
const isRetry = !isInputValid(content, step.type)
if (!isRetry) if (!isRetry)
addAnswer({ addAnswer({
stepId: step.id, stepId: step.id,
@@ -33,38 +38,13 @@ export const ChatStep = ({
content, content,
variableId: variableId ?? null, variableId: variableId ?? null,
}) })
onTransitionEnd(content, isRetry) if (!isEditting) onTransitionEnd(content, isRetry)
setIsEditting(false)
} }
if (isBubbleStep(step)) const handleGuestBubbleClick = () => {
return <HostBubble step={step} onTransitionEnd={onTransitionEnd} /> setAnswer(undefined)
if (isInputStep(step)) setIsEditting(true)
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)
} }
if (answer) { if (answer) {
@@ -74,9 +54,30 @@ const InputChatStep = ({
message={answer} message={answer}
showAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false} showAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
avatarSrc={avatarUrl && parseVariables(typebot.variables)(avatarUrl)} 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) { switch (step.type) {
case InputStepType.TEXT: case InputStepType.TEXT:
case InputStepType.NUMBER: case InputStepType.NUMBER:
@@ -84,15 +85,11 @@ const InputChatStep = ({
case InputStepType.URL: case InputStepType.URL:
case InputStepType.PHONE: case InputStepType.PHONE:
return ( return (
<TextForm <TextForm step={step} onSubmit={onSubmit} defaultValue={defaultValue} />
step={step}
onSubmit={handleSubmit}
defaultValue={defaultValue}
/>
) )
case InputStepType.DATE: case InputStepType.DATE:
return <DateForm options={step.options} onSubmit={handleSubmit} /> return <DateForm options={step.options} onSubmit={onSubmit} />
case InputStepType.CHOICE: 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 message: string
showAvatar: boolean showAvatar: boolean
avatarSrc?: string avatarSrc?: string
onClick: () => void
} }
export const GuestBubble = ({ export const GuestBubble = ({
message, message,
showAvatar, showAvatar,
avatarSrc, avatarSrc,
onClick,
}: Props): JSX.Element => { }: Props): JSX.Element => {
return ( return (
<CSSTransition classNames="bubble" timeout={1000}> <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 <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" data-testid="guest-bubble"
> >
{message} {message}

View File

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

View File

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