feat(editor): ✨ Add file upload input
This commit is contained in:
@ -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);
|
||||
}
|
||||
|
@ -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} />
|
||||
}
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
export { InputChatBlock } from './InputChatBlock'
|
@ -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 &&
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
)
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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 (
|
||||
|
@ -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 },
|
||||
|
Reference in New Issue
Block a user