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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
33
packages/models/src/typebot/blocks/input/file.ts
Normal file
33
packages/models/src/typebot/blocks/input/file.ts
Normal 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>
|
||||
@@ -8,3 +8,4 @@ export * from './choice'
|
||||
export * from './payment'
|
||||
export * from './phone'
|
||||
export * from './rating'
|
||||
export * from './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>
|
||||
|
||||
@@ -40,6 +40,7 @@ export enum InputBlockType {
|
||||
CHOICE = 'choice input',
|
||||
PAYMENT = 'payment input',
|
||||
RATING = 'rating input',
|
||||
FILE = 'file input',
|
||||
}
|
||||
|
||||
export enum LogicBlockType {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './utils'
|
||||
export * from './storage'
|
||||
|
||||
51
packages/utils/src/api/storage.ts
Normal file
51
packages/utils/src/api/storage.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user