2
0

feat(editor): Add file upload input

This commit is contained in:
Baptiste Arnaud
2022-06-12 17:34:33 +02:00
parent d4c52d47b3
commit 75365a0d82
48 changed files with 1022 additions and 587 deletions

View File

@ -218,3 +218,26 @@ textarea {
.rating-icon-container:active svg {
filter: brightness(0.75);
}
.upload-progress-bar {
background-color: var(--typebot-button-bg-color);
}
.total-files-indicator {
background-color: var(--typebot-button-bg-color);
color: var(--typebot-button-color);
font-size: 10px;
}
.typebot-upload-input {
transition: border-color 100ms ease-out;
}
.typebot-upload-input.dragging-over {
border-color: var(--typebot-button-bg-color);
}
.secondary-button {
background-color: var(--typebot-host-bubble-bg-color);
color: var(--typebot-host-bubble-color);
}

View File

@ -11,6 +11,9 @@ import { parseVariables } from '../../../services/variable'
import { isInputValid } from 'services/inputs'
import { PaymentForm } from './inputs/PaymentForm'
import { RatingForm } from './inputs/RatingForm'
import { FileUploadForm } from './inputs/FileUploadForm'
export type InputSubmitContent = { label?: string; value: string }
export const InputChatBlock = ({
block,
@ -21,7 +24,10 @@ export const InputChatBlock = ({
block: InputBlock
hasGuestAvatar: boolean
hasAvatar: boolean
onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void
onTransitionEnd: (
answerContent?: InputSubmitContent,
isRetry?: boolean
) => void
}) => {
const { typebot } = useTypebot()
const { addAnswer } = useAnswers()
@ -34,17 +40,17 @@ export const InputChatBlock = ({
? variableId && typebot.variables.find(byId(variableId))?.value
: undefined
const handleSubmit = async (content: string) => {
setAnswer(content)
const isRetry = !isInputValid(content, block.type)
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
setAnswer(label ?? value)
const isRetry = !isInputValid(value, block.type)
if (!isRetry && addAnswer)
await addAnswer({
blockId: block.id,
groupId: block.groupId,
content,
content: value,
variableId: variableId ?? null,
})
if (!isEditting) onTransitionEnd(content, isRetry)
if (!isEditting) onTransitionEnd({ label, value }, isRetry)
setIsEditting(false)
}
@ -87,7 +93,7 @@ const Input = ({
hasGuestAvatar,
}: {
block: InputBlock
onSubmit: (value: string) => void
onSubmit: (value: InputSubmitContent) => void
defaultValue?: string
hasGuestAvatar: boolean
}) => {
@ -113,10 +119,14 @@ const Input = ({
return (
<PaymentForm
options={block.options}
onSuccess={() => onSubmit(block.options.labels.success ?? 'Success')}
onSuccess={() =>
onSubmit({ value: block.options.labels.success ?? 'Success' })
}
/>
)
case InputBlockType.RATING:
return <RatingForm block={block} onSubmit={onSubmit} />
case InputBlockType.FILE:
return <FileUploadForm block={block} onSubmit={onSubmit} />
}
}

View File

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

View File

@ -1,11 +1,12 @@
import { useAnswers } from 'contexts/AnswersContext'
import { ChoiceInputBlock } from 'models'
import React, { useState } from 'react'
import { InputSubmitContent } from '../InputChatBlock'
import { SendButton } from './SendButton'
type ChoiceFormProps = {
block: ChoiceInputBlock
onSubmit: (value: string) => void
onSubmit: (value: InputSubmitContent) => void
}
export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => {
@ -15,7 +16,7 @@ export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => {
const handleClick = (itemIndex: number) => (e: React.MouseEvent) => {
e.preventDefault()
if (block.options?.isMultipleChoice) toggleSelectedItemIndex(itemIndex)
else onSubmit(block.items[itemIndex].content ?? '')
else onSubmit({ value: block.items[itemIndex].content ?? '' })
}
const toggleSelectedItemIndex = (itemIndex: number) => {
@ -29,11 +30,11 @@ export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => {
}
const handleSubmit = () =>
onSubmit(
selectedIndices
onSubmit({
value: selectedIndices
.map((itemIndex) => block.items[itemIndex].content)
.join(', ')
)
.join(', '),
})
const isUniqueFirstButton =
resultValues &&

View File

@ -1,9 +1,10 @@
import { DateInputOptions } from 'models'
import React, { useState } from 'react'
import { InputSubmitContent } from '../InputChatBlock'
import { SendButton } from './SendButton'
type DateInputProps = {
onSubmit: (inputValue: `${string} to ${string}` | string) => void
onSubmit: (inputValue: InputSubmitContent) => void
options?: DateInputOptions
}
@ -23,9 +24,11 @@ export const DateForm = ({
onSubmit={(e) => {
if (inputValues.from === '' && inputValues.to === '') return
e.preventDefault()
onSubmit(
`${inputValues.from}${isRange ? ` to ${inputValues.to}` : ''}`
)
onSubmit({
value: `${inputValues.from}${
isRange ? ` to ${inputValues.to}` : ''
}`,
})
}}
>
<div className="flex flex-col">

View File

@ -0,0 +1,236 @@
import { useAnswers } from 'contexts/AnswersContext'
import { useTypebot } from 'contexts/TypebotContext'
import { FileInputBlock } from 'models'
import React, { ChangeEvent, FormEvent, useState, DragEvent } from 'react'
import { uploadFiles } from 'utils'
import { InputSubmitContent } from '../InputChatBlock'
import { SendButton, Spinner } from './SendButton'
type Props = {
block: FileInputBlock
onSubmit: (url: InputSubmitContent) => void
}
const tenMB = 10 * 1024 * 1024
export const FileUploadForm = ({
block: {
id,
options: { isMultipleAllowed, labels },
},
onSubmit,
}: Props) => {
const { isPreview } = useTypebot()
const { resultId } = useAnswers()
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [isUploading, setIsUploading] = useState(false)
const [uploadProgressPercent, setUploadProgressPercent] = useState(20)
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [errorMessage, setErrorMessage] = useState<string>()
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return
onNewFiles(e.target.files)
}
const onNewFiles = (files: FileList) => {
setErrorMessage(undefined)
const newFiles = Array.from(files)
if (newFiles.some((file) => file.size > tenMB))
return setErrorMessage('A file is larger than 10MB')
if (!isMultipleAllowed && files) return startSingleFileUpload(newFiles[0])
setSelectedFiles([...selectedFiles, ...newFiles])
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (selectedFiles.length === 0) return
startFilesUpload(selectedFiles)
}
const startSingleFileUpload = async (file: File) => {
if (isPreview)
return onSubmit({
label: `File uploaded`,
value: 'http://fake-upload-url.com',
})
setIsUploading(true)
const urls = await uploadFiles({
files: [
{
file,
path: `public/results/${resultId}/${id}`,
},
],
})
setIsUploading(false)
if (urls.length) return onSubmit({ label: `File uploaded`, value: urls[0] })
setErrorMessage('An error occured while uploading the file')
}
const startFilesUpload = async (files: File[]) => {
if (isPreview)
return onSubmit({
label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`,
value: files
.map((_, idx) => `http://fake-upload-url.com/${idx}`)
.join(', '),
})
setIsUploading(true)
const urls = await uploadFiles({
files: files.map((file) => ({
file: file,
path: `public/results/${resultId}/${id}/${file.name}`,
})),
onUploadProgress: setUploadProgressPercent,
})
if (urls.length !== files.length)
return setErrorMessage('An error occured while uploading the files')
onSubmit({
label: `${urls.length} file${urls.length > 1 ? 's' : ''} uploaded`,
value: urls.join(', '),
})
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
setIsDraggingOver(true)
}
const handleDragLeave = () => setIsDraggingOver(false)
const handleDropFile = (e: DragEvent<HTMLLabelElement>) => {
e.preventDefault()
e.stopPropagation()
if (!e.dataTransfer.files) return
onNewFiles(e.dataTransfer.files)
}
const clearFiles = () => setSelectedFiles([])
return (
<form className="flex flex-col w-full" onSubmit={handleSubmit}>
<label
htmlFor="dropzone-file"
className={
'typebot-upload-input py-6 flex flex-col justify-center items-center w-full bg-gray-50 rounded-lg border-2 border-gray-300 border-dashed cursor-pointer dark:hover:bg-bray-800 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600 px-8 mb-2 ' +
(isDraggingOver ? 'dragging-over' : '')
}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDropFile}
>
{isUploading && uploadProgressPercent ? (
<>
{selectedFiles.length === 1 ? (
<Spinner />
) : (
<div className="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div
className="upload-progress-bar h-2.5 rounded-full"
style={{
width: `${uploadProgressPercent}%`,
transition: 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</div>
)}
</>
) : (
<>
<div className="flex flex-col justify-center items-center">
{selectedFiles.length ? (
<span className="relative">
<FileIcon />
<div
className="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 h-4"
style={{ bottom: '5px' }}
>
{selectedFiles.length}
</div>
</span>
) : (
<UploadIcon />
)}
<p
className="text-sm text-gray-500 dark:text-gray-400 text-center"
dangerouslySetInnerHTML={{ __html: labels.placeholder }}
/>
</div>
<input
id="dropzone-file"
type="file"
className="hidden"
multiple={isMultipleAllowed}
onChange={handleFileChange}
/>
</>
)}
</label>
{isMultipleAllowed && selectedFiles.length > 0 && !isUploading && (
<div className="flex justify-end">
<div className="flex">
{selectedFiles.length && (
<button
className={
'secondary-button py-2 px-4 justify-center 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 mr-2'
}
onClick={clearFiles}
>
Clear
</button>
)}
<SendButton
type="submit"
label={
labels.button
? `${labels.button} ${selectedFiles.length} file${
selectedFiles.length > 1 ? 's' : ''
}`
: 'Upload'
}
disableIcon
/>
</div>
</div>
)}
{errorMessage && <p className="text-red-500 text-sm">{errorMessage}</p>}
</form>
)
}
const UploadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mb-3"
>
<polyline points="16 16 12 12 8 16"></polyline>
<line x1="12" y1="12" x2="12" y2="21"></line>
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path>
<polyline points="16 16 12 12 8 16"></polyline>
</svg>
)
const FileIcon = () => (
<svg
className="mb-3"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
)

View File

@ -1,11 +1,12 @@
import { RatingInputOptions, RatingInputBlock } from 'models'
import React, { FormEvent, useRef, useState } from 'react'
import { isDefined, isEmpty, isNotDefined } from 'utils'
import { InputSubmitContent } from '../InputChatBlock'
import { SendButton } from './SendButton'
type Props = {
block: RatingInputBlock
onSubmit: (value: string) => void
onSubmit: (value: InputSubmitContent) => void
}
export const RatingForm = ({ block, onSubmit }: Props) => {
@ -15,7 +16,7 @@ export const RatingForm = ({ block, onSubmit }: Props) => {
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (isNotDefined(rating)) return
onSubmit(rating.toString())
onSubmit({ value: rating.toString() })
}
const handleClick = (rating: number) => setRating(rating)

View File

@ -43,6 +43,7 @@ export const Spinner = (props: SVGProps<SVGSVGElement>) => (
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
data-testid="loading-spinner"
>
<circle
className="opacity-25"

View File

@ -7,6 +7,7 @@ import {
UrlInputBlock,
} from 'models'
import React, { FormEvent, useState } from 'react'
import { InputSubmitContent } from '../../InputChatBlock'
import { SendButton } from '../SendButton'
import { TextInput } from './TextInputContent'
@ -17,7 +18,7 @@ type TextFormProps = {
| NumberInputBlock
| UrlInputBlock
| PhoneNumberInputBlock
onSubmit: (value: string) => void
onSubmit: (value: InputSubmitContent) => void
defaultValue?: string
hasGuestAvatar: boolean
}
@ -44,7 +45,7 @@ export const TextForm = ({
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (inputValue === '') return
onSubmit(inputValue)
onSubmit({ value: inputValue })
}
return (

View File

@ -23,10 +23,10 @@ import {
PublicTypebot,
Block,
} from 'models'
import { HostBubble } from './ChatBlock/bubbles/HostBubble'
import { getLastChatBlockType } from '../../services/chat'
import { useChat } from 'contexts/ChatContext'
import { InputChatBlock } from './ChatBlock'
import { getLastChatBlockType } from 'services/chat'
import { HostBubble } from './ChatBlock/bubbles/HostBubble'
import { InputChatBlock, InputSubmitContent } from './ChatBlock/InputChatBlock'
type ChatGroupProps = {
blocks: Block[]
@ -162,7 +162,10 @@ export const ChatGroup = ({
onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
}
const displayNextBlock = (answerContent?: string, isRetry?: boolean) => {
const displayNextBlock = (
answerContent?: InputSubmitContent,
isRetry?: boolean
) => {
scroll()
const currentBlock = [...processedBlocks].pop()
if (currentBlock) {
@ -175,13 +178,16 @@ export const ChatGroup = ({
currentBlock.options?.variableId &&
answerContent
) {
updateVariableValue(currentBlock.options.variableId, answerContent)
updateVariableValue(
currentBlock.options.variableId,
answerContent.value
)
}
const isSingleChoiceBlock =
isChoiceInput(currentBlock) && !currentBlock.options.isMultipleChoice
if (isSingleChoiceBlock) {
const nextEdgeId = currentBlock.items.find(
(i) => i.content === answerContent
(i) => i.content === answerContent?.value
)?.outgoingEdgeId
if (nextEdgeId) return onGroupEnd({ edgeId: nextEdgeId })
}
@ -224,7 +230,10 @@ type Props = {
hostAvatar: { isEnabled: boolean; src?: string }
hasGuestAvatar: boolean
keepShowingHostAvatar: boolean
onDisplayNextBlock: (answerContent?: string, isRetry?: boolean) => void
onDisplayNextBlock: (
answerContent?: InputSubmitContent,
isRetry?: boolean
) => void
}
const ChatChunks = ({
displayChunk: { bubbles, input },