Skip to content

Commit

Permalink
feat(engine): ✨ Can edit answers by clicking on it
Browse files Browse the repository at this point in the history
  • Loading branch information
baptisteArno committed Mar 1, 2022
1 parent d6c3e8d commit f124914
Show file tree
Hide file tree
Showing 10 changed files with 166 additions and 205 deletions.
113 changes: 63 additions & 50 deletions packages/bot-engine/src/components/ChatBlock/AvatarSideContainer.tsx
Original file line number Diff line number Diff line change
@@ -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,
}))

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 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>
)
}
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>
)
}
)
76 changes: 50 additions & 26 deletions packages/bot-engine/src/components/ChatBlock/ChatBlock.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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[]
Expand All @@ -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]
Expand All @@ -49,6 +56,7 @@ export const ChatBlock = ({
}, [])

useEffect(() => {
avatarSideContainerRef.current?.refreshTopOffset()
onScroll()
onNewStepDisplayed()
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -1,70 +1,50 @@
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 { variableId } = step.options
const defaultValue =
variableId && typebot.variables.find(byId(variableId))?.value

const handleInputSubmit = (
content: string,
isRetry: boolean,
variableId?: string
) => {
const handleSubmit = (content: string) => {
setAnswer(content)
const isRetry = !isInputValid(content, step.type)
if (!isRetry)
addAnswer({
stepId: step.id,
blockId: step.blockId,
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) {
Expand All @@ -74,25 +54,42 @@ 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:
case InputStepType.EMAIL:
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} />
}
}
Loading

0 comments on commit f124914

Please sign in to comment.