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 },

View File

@@ -20,14 +20,6 @@ export const defaultChoiceInputOptions: ChoiceInputOptions = {
isMultipleChoice: false,
}
export const choiceInputSchema = blockBaseSchema.and(
z.object({
type: z.enum([InputBlockType.CHOICE]),
items: z.array(z.any()),
options: choiceInputOptionsSchema,
})
)
export const buttonItemSchema = itemBaseSchema.and(
z.object({
type: z.literal(ItemType.BUTTON),
@@ -35,6 +27,14 @@ export const buttonItemSchema = itemBaseSchema.and(
})
)
export const choiceInputSchema = blockBaseSchema.and(
z.object({
type: z.enum([InputBlockType.CHOICE]),
items: z.array(buttonItemSchema),
options: choiceInputOptionsSchema,
})
)
export type ButtonItem = z.infer<typeof buttonItemSchema>
export type ChoiceInputBlock = z.infer<typeof choiceInputSchema>
export type ChoiceInputOptions = z.infer<typeof choiceInputOptionsSchema>

View File

@@ -0,0 +1,33 @@
import { z } from 'zod'
import { InputBlockType, optionBaseSchema, blockBaseSchema } from '../shared'
export const fileInputOptionsSchema = optionBaseSchema.and(
z.object({
isMultipleAllowed: z.boolean(),
labels: z.object({
placeholder: z.string(),
button: z.string(),
}),
})
)
export const fileInputStepSchema = blockBaseSchema.and(
z.object({
type: z.literal(InputBlockType.FILE),
options: fileInputOptionsSchema,
})
)
export const defaultFileInputOptions: FileInputOptions = {
isMultipleAllowed: false,
labels: {
placeholder: `<strong>
Click to upload
</strong> or drag and drop<br>
(size limit: 10MB)`,
button: 'Upload',
},
}
export type FileInputBlock = z.infer<typeof fileInputStepSchema>
export type FileInputOptions = z.infer<typeof fileInputOptionsSchema>

View File

@@ -8,3 +8,4 @@ export * from './choice'
export * from './payment'
export * from './phone'
export * from './rating'
export * from './file'

View File

@@ -11,6 +11,7 @@ import {
} from './phone'
import { ratingInputOptionsSchema, ratingInputBlockSchema } from './rating'
import { textInputOptionsSchema, textInputSchema } from './text'
import { fileInputOptionsSchema, fileInputStepSchema } from './file'
import { urlInputOptionsSchema, urlInputSchema } from './url'
export type OptionBase = z.infer<typeof optionBaseSchema>
@@ -24,6 +25,7 @@ export const inputBlockOptionsSchema = textInputOptionsSchema
.or(dateInputOptionsSchema)
.or(paymentInputOptionsSchema)
.or(ratingInputOptionsSchema)
.or(fileInputOptionsSchema)
export const inputBlockSchema = textInputSchema
.or(numberInputSchema)
@@ -34,6 +36,7 @@ export const inputBlockSchema = textInputSchema
.or(choiceInputSchema)
.or(paymentInputSchema)
.or(ratingInputBlockSchema)
.or(fileInputStepSchema)
export type InputBlock = z.infer<typeof inputBlockSchema>
export type InputBlockOptions = z.infer<typeof inputBlockOptionsSchema>

View File

@@ -40,6 +40,7 @@ export enum InputBlockType {
CHOICE = 'choice input',
PAYMENT = 'payment input',
RATING = 'rating input',
FILE = 'file input',
}
export enum LogicBlockType {

View File

@@ -10,6 +10,7 @@
"@rollup/plugin-commonjs": "^22.0.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-typescript": "^8.3.2",
"@types/aws-sdk": "^2.7.0",
"rollup": "^2.72.1",
"rollup-plugin-dts": "^4.2.1",
"rollup-plugin-peer-deps-external": "^2.2.4",
@@ -17,12 +18,15 @@
"typescript": "^4.6.4"
},
"dependencies": {
"aws-sdk": "^2.1152.0",
"models": "*",
"next": "^12.1.6"
},
"peerDependencies": {
"next": "^12.1.6",
"models": "*"
"aws-sdk": "^2.1152.0",
"@types/aws-sdk": "^2.7.0",
"models": "*",
"next": "^12.1.6"
},
"scripts": {
"build": "yarn rollup -c",

View File

@@ -1 +1,2 @@
export * from './utils'
export * from './storage'

View File

@@ -0,0 +1,51 @@
import { config, Endpoint, S3 } from 'aws-sdk'
type GeneratePresignedUrlProps = {
filePath: string
fileType: string
}
const tenMB = 10485760
const oneHundredAndTwentySeconds = 120
export const generatePresignedUrl = ({
filePath,
fileType,
}: GeneratePresignedUrlProps): S3.PresignedPost => {
if (
!process.env.S3_ENDPOINT ||
!process.env.S3_ACCESS_KEY ||
!process.env.S3_SECRET_KEY
)
throw new Error(
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY'
)
const sslEnabled =
process.env.S3_SSL && process.env.S3_SSL === 'false' ? false : true
config.update({
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
region: process.env.S3_REGION,
sslEnabled,
})
const protocol = sslEnabled ? 'https' : 'http'
const s3 = new S3({
endpoint: new Endpoint(
`${protocol}://${process.env.S3_ENDPOINT}${
process.env.S3_PORT ? `:${process.env.S3_PORT}` : ''
}`
),
})
const presignedUrl = s3.createPresignedPost({
Bucket: process.env.S3_BUCKET ?? 'typebot',
Fields: {
key: filePath,
'Content-Type': fileType,
},
Expires: oneHundredAndTwentySeconds,
Conditions: [['content-length-range', 0, tenMB]],
})
return presignedUrl
}

View File

@@ -24,7 +24,7 @@ export const sendRequest = async <ResponseData>(
| {
url: string
method: string
body?: Record<string, unknown>
body?: Record<string, unknown> | FormData
}
| string
): Promise<{ data?: ResponseData; error?: Error }> => {
@@ -191,3 +191,53 @@ export const generateId = (idDesiredLength: number): string => {
})
.join('')
}
type UploadFileProps = {
files: {
file: File
path: string
}[]
onUploadProgress?: (percent: number) => void
}
type UrlList = string[]
export const uploadFiles = async ({
files,
onUploadProgress,
}: UploadFileProps): Promise<UrlList> => {
const requests = files.map(async ({ file, path }) => {
const { data } = await sendRequest<{
presignedUrl: { url: string; fields: any }
}>(
`/api/storage/upload-url?filePath=${encodeURIComponent(path)}&fileType=${
file.type
}`
)
if (!data?.presignedUrl) return null
const { url, fields } = data.presignedUrl
const formData = new FormData()
Object.entries({ ...fields, file }).forEach(([key, value]) => {
formData.append(key, value as string | Blob)
})
const upload = await fetch(url, {
method: 'POST',
body: formData,
})
if (!upload.ok) return
return `${url.split('?')[0]}/${path}`
})
const urls = []
let i = 0
for (const request of requests) {
i += 1
const url = await request
onUploadProgress && onUploadProgress((i / requests.length) * 100)
if (!url) continue
urls.push(url)
}
return urls
}